如果说BG/NBD和Pareto/NBD解决的是“用户接下来还会买几次”的问题,那Gamma-Gamma模型就是在回答同一个CLV拼图里的另一半——“用户每一笔平均会花多少钱”。之前我们在系列前两篇里拆解过RFM的结构,也聊过如何用BG/NBD把交易频率单独建模,很多朋友当时就在评论区追问:频率算完了,金额那部分是不是也有一套类似的做法?对,就是本文要讲的Gamma-Gamma模型。
这一篇我会直接带你把Gamma-Gamma模型的数学假设、适用边界、Python模拟数据、极大似然拟合、条件期望预测全部走一遍,代码全部自实现,不依赖lifetimes这种现成库,保证你能看清每个公式是怎么落地的。标题里写了“Python模拟”,所以我不止会给你一个能跑的模型,还会伪造一组符合Gamma-Gamma假设的数据,再用模型反推回去,验证参数能不能恢复出来。适合的人群:正在做用户价值分层、会员体系、订阅续费分析的数据分析师,或者单纯想弄懂CLV金额维度建模的算法工程师。
1. 先把CLV这台机器拆开看:Gamma-Gamma模型到底在预测什么
1.1 单算交易频率为什么不够完整
很多团队第一次做CLV的时候,都会直接从RFM里拿一个“用户平均消费金额”出来,乘以预测的购买次数,完事。这么做在当前业务节奏里当然能跑,但你只要拆开看就会发现问题:同一个用户的历史平均消费金额,根本没法代表他“未来”的平均消费金额。原因很简单,样本量越小,样本均值越不稳定。一个用户过去只买了两次,一次是30块一次是90块,你直接拿60块去乘未来的购买次数,这数字就是碰运气。
BG/NBD或者Pareto/NBD解决的是“未来交易次数”的条件期望问题,但它的输入只有交易频率和最近一次购买时间,完全没用到金额信息。而CLV的计算公式本来就该拆成两部分:
[CLV = E[未来交易次数] \times E[未来每笔平均交易金额]]
前半段交给BG/NBD,后半段就是Gamma-Gamma的地盘。Gamma-Gamma这个名字听起来很玄乎,本质上是面对“平均交易金额”做分层:每个用户的真实平均购买力不同,我们不能直接拿观测到的样本均值当估计值,而是要给每个用户假设一个“潜在平均购买金额”,再从它的分布出发做贝叶斯收缩。
1.2 Gamma-Gamma在整套CLV流程里的位置
我先把整个流程串一遍,方便你回头对照。经典BTYD(Buy Till You Die)分析里,一个完整CLV项目通常分三部分:
- 第一部分:用RFM结构清洗数据,搞清楚观察期、预测期、frequency、recency、T这些基础字段。
- 第二部分:用BG/NBD模型拟合交易次数过程,输出每个用户未来一定时间内的期望购买次数。
- 第三部分:用Gamma-Gamma模型拟合每笔交易金额,输出每个用户的期望平均交易金额。
- 最后把两部分乘起来,再乘上贡献毛利率,得到以用户为粒度的CLV预测值。
Gamma-Gamma的核心输出是一个条件期望值:在已知某个用户历史一共购买x次、平均每笔消费m_x的情况下,他未来单笔消费的期望是多少。写成概率语言就是:
[E[M | x, m_x]]
注意,这里的(M)是随机变量,代表未来单笔交易金额,不是观测到的样本均值。Gamma-Gamma做的事情,就是用Gamma分布族构造出这个条件期望的闭合解。这也是我喜欢它的原因:不需要蒙特卡洛采样,不需要数值积分,一个公式就能给出预测,对工程落地非常友好。
2. Gamma-Gamma模型的数学假设:为什么它偏偏选了Gamma分布
2.1 三条Gamma假设构成整个故事
Gamma-Gamma这个名字,是因为它对三个层次全部用了Gamma分布。我直接给出参数化定义,这里采用rate参数化形式,后面写代码也按这套来:
第一层,每个客户的单笔交易金额服从Gamma分布,形状参数为(p),率参数为(v_i):
[X_i \sim Gamma(p, v_i), \quad E[X_i] = p / v_i]
第二层,不同客户之间的(v_i)存在异质性,假设它也服从Gamma分布:
[v_i \sim Gamma(q, \xi), \quad E[v_i] = q / \xi]
第三层,Gamma分布本身的形状参数(p)和(q)、率参数(\xi)在全量客户中是共同的,相当于全局参数。
这个设定巧妙在哪里?你看(v_i),它在第一层决定客户的真实平均消费,又在第二层被一个Gamma先验约束。因为Gamma分布是共轭先验,所以当我们观测到某个客户的具体交易金额后,(v_i)的后验分布仍然是一个Gamma分布,只是参数变了。这就是后面所有公式能推出解析解的根本原因。
2.2 条件独立是个大门槛,验证不了就要谨慎
任何模型都有前提条件,Gamma-Gamma有两个很重要的前提,我强调一下,因为实操中踩坑的人太多了:
第一个前提,交易过程与交易金额相互独立。意思是,客户买几次跟每次花多少钱没有关系。这在逻辑上不是天然成立的:你去观察电商数据,往往高复购用户的平均客单价也不低,两者明显正相关;这时候硬套Gamma-Gamma,预测就会偏。
第二个前提,客户未来的平均交易金额围绕一个“潜在均值”波动,不受时间推移影响。也就是说不存在明显的金额衰减或上升趋势,像那种首单大额、复购小额的业务,Gamma-Gamma就不是首选。
实操中,我会用一句话落地:把log(交易次数)和log(平均订单金额)做一下相关分析,如果相关系数绝对值超过0.3,就要谨慎使用Gamma-Gamma,或者考虑在模型外对金额做一个趋势修正。相关系数不大,才能放心把金额维度交给Gamma-Gamma。
2.3 什么时候别用Gamma-Gamma
除了独立性问题,还有两类情况不适合直接用Gamma-Gamma:
一类是订单金额出现大量0元订单。比如用优惠券全额抵扣、积分兑换单,这些订单金额为0甚至为负,Gamma分布定义域是正实数,没法处理。我的处理方式是:先把金额<=0的订单单独拎出来看业务含义,如果量不大就直接删除,如果占比高,说明你的核心行为不是“花钱”而是“薅羊毛”,Gamma-Gamma根本不匹配。
另一类是客单价分布特别诡异的业务,比如机票、酒店、大额B2B采购,金额尾部极长。Gamma分布确实能拟合右偏,但它的右尾不够重,一旦出现极少数超高频巨额订单,参数估计会被严重拖走。我会先用四分位数或IQR识别异常值,再决定是否剔除。需要注意的是,剔除阈值不要凭感觉,要看剔除后业务口径是否仍然成立。
3. 用Python从0到1做数据模拟与参数拟合
3.1 伪造一份符合Gamma-Gamma假设的数据集
模型写代码调试最痛苦的事情就是没有“标准答案”可以对照。所以我现在先设定一组已知真实参数,用这组参数生成一批模拟数据,再用极大似然估计反推参数。能反推出真实参数,说明模型实现正确;如果反推结果离真实值八丈远,说明代码或公式有bug,这时候去套真实业务数据就是灾难。
先导入依赖,如果你是新装的Python环境,记得先装好numpy、pandas、scipy和matplotlib,这些都是做数据分析绕不开的基础库。
import numpy as np import pandas as pd from scipy.optimize import minimize from scipy.special import gammaln # 设置随机种子,保证结果可复现 np.random.seed(42) # 定义真实参数 true_p = 4.0 # 形状参数,控制单笔金额的离散程度 true_q = 6.0 # 形状参数,控制客户间购买力异质性 true_xi = 120.0 # 率参数,控制整体金额水平接下来模拟10万个客户,对每个客户先抽取一个潜在率参数(v_i),再根据这个(v_i)生成该客户的全部历史交易金额。为了方便演示,这里固定每个客户的交易次数(x_i),你也可以用一个负二项分布去模拟,但核心逻辑不变。
# 模拟10万客户 n_customers = 100000 # 第一层:客户异质性,v ~ Gamma(q, xi) v_i = np.random.gamma(shape=true_q, scale=1.0 / true_xi, size=n_customers) # 每个客户交易次数固定为5次 x = 5 amounts_list = [] for i in range(n_customers): amounts = np.random.gamma(shape=true_p, scale=1.0 / v_i[i], size=x) amounts_list.append(amounts) amounts_array = np.array(amounts_list) # shape = (n_customers, x) # 计算每个客户的总金额、平均金额、交易次数向量 sum_amount = amounts_array.sum(axis=1) mean_amount = sum_amount / x freq = np.full(n_customers, x, dtype=int)这个生成过程的目标就是让数据完全符合Gamma-Gamma的生成式假设。你可以想象成业务方的生成过程和你的模型完全一致,这样后面验证的时候,模型恢复出的参数就和真实参数一致。真实业务数据做不到这一点,但因为我们是“模拟”,所以这是一个非常强的自检手段。
3.2 核心:负对数似然函数的推导与实现
现在要写极大似然估计了。每个客户有(x_i)次交易,总金额为(s_i = x_i m_{x_i}),那么该客户的似然函数需要对不可观测的(v_i)积分。公式推导我在前面讲过,这里直接给出积分结果对应的负对数似然:
已知每个客户的边际似然为:
[L_i = \frac{\prod_{j=1}^{x_i} amount_{ij}^{p-1}}{\Gamma(p)^{x_i}} \cdot \frac{\xi^{q}}{\Gamma(q)} \cdot \frac{\Gamma(q + x_i p)}{(\xi + s_i)^{q + x_i p}}]
取负对数后变成:
[NLL_i = -(p-1) \sum_j \log(amount_{ij}) + x_i \log\Gamma(p) - q \log \xi + \log\Gamma(q) - \log\Gamma(q + x_i p) + (q + x_i p)\log(\xi + s_i)]
这里用到了gammaln函数而不是log(gamma),因为Gamma函数的值可能非常大,直接取对数会溢出。
下面是完整实现:
def negative_log_likelihood(params, sum_amount, freq, log_amount_sum): """ params: [p, q, xi] sum_amount: 每个客户的总消费金额 freq: 每个客户的交易次数 log_amount_sum: 每个客户 log(每笔金额) 的和 """ p, q, xi = params if p <= 0 or q <= 0 or xi <= 0: return 1e10 # 非法参数返回极大值 # 每个客户的负对数似然 term1 = -(p - 1) * log_amount_sum term2 = freq * gammaln(p) term3 = -q * np.log(xi) term4 = gammaln(q) term5 = -gammaln(q + freq * p) term6 = (q + freq * p) * np.log(xi + sum_amount) nll = term1 + term2 + term3 + term4 + term5 + term6 return np.sum(nll)为了数值稳定,(p)非常大时(\Gamma(p))也大,但这里用gammaln处理就没问题。初始参数我习惯取p=1, q=1, xi=1,然后用L-BFGS-B做带边界优化,把p、q、xi约束在正数区间:
log_amount_sum = np.log(amounts_array + 1e-12).sum(axis=1) init_params = [1.0, 1.0, 1.0] bounds = [(1e-6, None), (1e-6, None), (1e-6, None)] result = minimize( negative_log_likelihood, init_params, args=(sum_amount, freq, log_amount_sum), method="L-BFGS-B", bounds=bounds, ) p_hat, q_hat, xi_hat = result.x print(f"真实参数: p={true_p}, q={true_q}, xi={true_xi}") print(f"拟合参数: p={p_hat:.3f}, q={q_hat:.3f}, xi={xi_hat:.3f}") print(f"收敛成功: {result.success}, 迭代信息: {result.message}")跑出来的结果,p应该在4附近,q在6附近,xi在120附近。如果出现偏差,首先检查你生成的样本量是否足够。10万客户对这三个参数来说通常绰绰有余,但如果把客户数降到1000,估计结果就会开始抖动。这也是一个可复现实验,你可以在自己电脑上调整样本量试试:样本量越大,参数越接近真实值。
3.3 参数恢复验证:怎么判断模型拟合得好不好
上一节代码跑完后,需要有个衡量标准。我一般看三个东西:
第一个是优化器返回的success标志和message信息,如果显示收敛失败,多半是目标函数写错了或者边界有问题。
第二个是拟合参数和真实参数的相对误差。比如:
rel_err_p = abs(p_hat - true_p) / true_p rel_err_q = abs(q_hat - true_q) / true_q rel_err_xi = abs(xi_hat - true_xi) / true_xi print(f"p相对误差: {rel_err_p:.3%}") print(f"q相对误差: {rel_err_q:.3%}") print(f"xi相对误差: {rel_err_xi:.3%}")正常情况下相对误差应该控制在1%以内。如果超过5%,基本可以断定NLL函数、数据生成逻辑或边界条件三者有一个地方对不上。这时候我会先打印几行log_amount_sum、sum_amount检查有无NaN或Inf,再用最小化函数的callback或梯度检查来定位。
第三个是复现业务验证的套路:不直接看参数,而是把全体预测均值与真实样本均值比较。因为Gamma-Gamma是生成式模型,理论总体均值(E[M])等于(\frac{p \cdot q}{\xi \cdot (p-1)})左右,你可以和模拟数据的总体平均金额对比,偏差小于2%说明模型在整体口径上是校准的。这个验证在真实业务数据中也适用,是不依赖外部预测期标签的内部一致性检查。
4. 条件期望公式:怎么用模型预测单个用户的未来平均消费
4.1 后验推导与最终计算公式
拟合出来的(p, q, \xi)是全局参数,现在要对每个客户算条件期望。前面推导过,给定客户历史交易次数(x)和总金额(s = x \cdot m_x)后,后验分布为:
[v_i | x, s \sim Gamma(q + xp, \xi + s)]
而每笔交易金额期望是(E[X] = p / v_i),所以:
[E[M|x, m_x] = \frac{p \cdot (\xi + x m_x)}{q + xp - 1}]
注意分母要求(q + xp > 1)。对于低频客户,(x=0)时条件期望会退化为(\frac{p \xi}{q-1}),这其实就是全体的先验均值,本质上就是在没有任何个人历史时用全局均值去兜底。
用一个生活化类比:一个你只见过一两次的用户,他的历史平均消费不太可信,模型就会把他的预测值往全局平均方向“拉”回来;而一个买了50次的用户,模型会更相信他自己的历史均值。这就是贝叶斯收缩,Gamma-Gamma最有价值的地方就在这。
4.2 应用到模拟数据上的预测代码
直接实现公式:
def conditional_expected_avg_profit(freq, mean_amount, p, q, xi): """ freq: 交易次数 mean_amount: 历史平均每笔金额 返回预测的未来平均每笔金额 """ numerator = p * (xi + freq * mean_amount) denominator = q + freq * p - 1 return numerator / denominator pred_mean_amount = conditional_expected_avg_profit( freq, mean_amount, p_hat, q_hat, xi_hat ) # 对比预测值、样本均值、真实平均金额 true_avg = p_hat / v_i # 注意这里用的是模拟生成的v_i result_df = pd.DataFrame({ "true_avg": true_avg, "sample_mean": mean_amount, "pred_mean": pred_mean_amount, }) print(result_df.head(10))你会看到这样一个现象:对每个客户,样本均值与真实均值总有一些偏差,但预测均值通常落在二者之间,而且客户交易次数越多,预测均值越靠近样本均值。这就是贝叶斯收缩的直观体现。为了量化模型效果,我习惯算一下预测值与真实值之间的平均绝对百分比误差MAPE,以及和“直接用样本均值”的MAPE做对比:
def mape(y_true, y_pred): return np.mean(np.abs((y_true - y_pred) / y_true)) * 100 mape_sample = mape(true_avg, mean_amount) mape_pred = mape(true_avg, pred_mean_amount) print(f"样本均值MAPE: {mape_sample:.2f}%") print(f"Gamma-Gamma预测MAPE: {mape_pred:.2f}%")这个对比非常直观:Gamma-Gamma的预测误差一定小于直接使用样本均值。原因也简单,样本均值方差大,尤其是在低频客户那部分,贝叶斯收缩反而把那些极端值拉回到了一个更合理的位置。这个结果在真实业务里很常见,尤其是当你面对大量低频用户时,Gamma-Gamma的价值比面对高频用户更大。
4.3 和样本均值对比:哪个场景下优势最明显
我做过很多次实验,结论是:低频客户占比越高,Gamma-Gamma相对样本均值的提升越大。你可以把上面的模拟改成x分别等于1、2、3、5、10来试,会看到x=1时样本均值MAPE惨不忍睹,而Gamma-Gamma因为有全局先验兜底,MAPE稳定得多;到x=10时两者差距缩小,因为样本均值本身已经收敛到真实值附近。
这里有个实用建议:如果你的业务里有大量只买过一两次的用户,别急着用Gamma-Gamma输出结果,可以先按交易次数分层,分别计算不同层级的收缩强度。最后在汇报时,你可以非常自信地在PPT里写:模型平均将单客户平均交易金额的预测误差从XX%降到XX%,其中低频客户的提升最明显。数值对比是最有说服力的。
5. 实战中的坑:数据清洗、收敛失败与组合CLV
5.1 金额数据里的0、负数和极端离群点
真实数据永远不会像模拟数据这么干净,第一个坎就是金额的分布形态。我的处理顺序是这样的:
先把金额小于等于0的订单单独筛出来,看业务原因。积分兑换、全额抵扣、退款订单,这些都会让金额变为0或负数。如果占比小于5%,我倾向于直接剔除,因为这些订单不反映真实的“花钱能力”。如果占比超过10%,就要停下来想想业务模式是不是出了问题。
然后是极端值。我会先用中位数和MAD(绝对中位差)来识别离群点,而不是简单用3倍标准差。Gamma分布本身允许右偏,但极端离群点会严重拉偏似然函数。具体操作时可以对金额做log变换后的IQR过滤,再转回原尺度,保留业务可解释区间内的数据。删除之前一定要记录删了多少、删的是谁,方便后面复盘。
5.2 优化器收敛失败与多起点策略
scipy的L-BFGS-B对初值比较敏感,尤其当数据量小或者参数间相关性较高时。我碰到过几次明明确信NLL公式没错,但拟合结果却和真实参数差很多的情况,后来发现是优化器掉进了局部最优。解决办法很简单:多取几组初始点分别拟合,从结果中选NLL最小的那组参数。
init_candidates = [ [1.0, 1.0, 1.0], [0.5, 0.5, 50.0], [5.0, 5.0, 200.0], [2.0, 8.0, 80.0], ] best_nll = np.inf best_params = None for init in init_candidates: res = minimize( negative_log_likelihood, init, args=(sum_amount, freq, log_amount_sum), method="L-BFGS-B", bounds=bounds, ) if res.fun < best_nll and res.success: best_nll = res.fun best_params = res.x print(f"最优参数: {best_params}")这个多起点策略几乎零成本,强烈建议写进你的标准流程里。另外还可以加一个梯度检查,确保NLL函数对p、q、xi的梯度方向正确。不过我自己日常够用的话就不折腾梯度了,多起点已经能挡掉大部分问题。
5.3 和BG/NBD组合时最容易犯的统一量纲错误
Gamma-Gamma算出来的期望平均金额,是“每笔订单”维度的,而BG/NBD预测出来的期望购买次数,往往是“单位时间”(比如未来三个月)维度的。两者相乘之前,一定要确认时间口径对齐。
我举个真实例子:有次帮一个团队看CLV模型,他们把Gamma-Gamma算出的单笔均值和BG/NBD算出的未来52周购买次数直接相乘,结果CLV高得离谱。仔细一查才发现,Gamma-Gamma用的训练期金额是老客户一整年的平均单笔金额,而BG/NBD输出的是未来12周的购买次数,时间窗口没对齐。正确做法是先统一预测窗口,再用:
[CLV_{h} = E[购买次数_{h}] \times E[平均每笔金额]]
另外,模型输出的单笔金额通常要乘以毛利率,才能用来做营销预算分配。毛利率如果不是全局常量,可以再按商品品类做加权平均,但这就属于CLV进一步细分的范畴了,不在Gamma-Gamma本身。
5.4 模型验证的一个实操建议:时间窗划分
真实业务数据没有模拟数据的“真实值”标签,怎么验证Gamma-Gamma是否有效?我用的是时间窗方法。把数据按购买日期切成两段:前80%时间作为训练期,后20%时间作为预测期。用训练期数据拟合参数,并对每个用户计算期望平均金额;在预测期里,每个用户只要发生过购买,就统计他的实际平均订单金额。然后比较两者,计算MAPE或相关系数。
这里要注意:预测期没有购买行为的用户,无法计算实际平均金额,所以要单独说明样本口径。我一般会在报告里标注“基于预测期仍有交易记录的客户进行验证”,避免被挑战。这个验证方法不一定能证明模型绝对正确,但至少能说明模型在时间维度上没有过拟合,这是业务落地前必须过的最后一关。
我在自己的项目里还发现一个细节:预测窗口的长度不能太短,至少要让大部分用户有机会产生1到2笔新交易,否则验证样本量不足,对比结果随机性太强。遇到季节性强的业务,比如年货、开学季,最好选取能覆盖一个完整业务周期的预测窗口,不然验证结果会误导你对模型真实效果的判断。