Gamma-Gamma模型详解:Python模拟与极大似然拟合实现CLV金额预测

发布时间:2026/9/15 13:09:49
Gamma-Gamma模型详解:Python模拟与极大似然拟合实现CLV金额预测 如果说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流程里的位置我先把整个流程串一遍方便你回头对照。经典BTYDBuy 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(shapetrue_q, scale1.0 / true_xi, sizen_customers) # 每个客户交易次数固定为5次 x 5 amounts_list [] for i in range(n_customers): amounts np.random.gamma(shapetrue_p, scale1.0 / v_i[i], sizex) amounts_list.append(amounts) amounts_array np.array(amounts_list) # shape (n_customers, x) # 计算每个客户的总金额、平均金额、交易次数向量 sum_amount amounts_array.sum(axis1) mean_amount sum_amount / x freq np.full(n_customers, x, dtypeint)这个生成过程的目标就是让数据完全符合Gamma-Gamma的生成式假设。你可以想象成业务方的生成过程和你的模型完全一致这样后面验证的时候模型恢复出的参数就和真实参数一致。真实业务数据做不到这一点但因为我们是“模拟”所以这是一个非常强的自检手段。3.2 核心负对数似然函数的推导与实现现在要写极大似然估计了。每个客户有(x_i)次交易总金额为(s_i x_i m_{x_i})那么该客户的似然函数需要对不可观测的(v_i)积分。公式推导我在前面讲过这里直接给出积分结果对应的负对数似然已知每个客户的边际似然为[L_i \frac{\prod_{j1}^{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处理就没问题。初始参数我习惯取p1, q1, xi1然后用L-BFGS-B做带边界优化把p、q、xi约束在正数区间log_amount_sum np.log(amounts_array 1e-12).sum(axis1) 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), methodL-BFGS-B, boundsbounds, ) 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(fp相对误差: {rel_err_p:.3%}) print(fq相对误差: {rel_err_q:.3%}) print(fxi相对误差: {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)。对于低频客户(x0)时条件期望会退化为(\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(fGamma-Gamma预测MAPE: {mape_pred:.2f}%)这个对比非常直观Gamma-Gamma的预测误差一定小于直接使用样本均值。原因也简单样本均值方差大尤其是在低频客户那部分贝叶斯收缩反而把那些极端值拉回到了一个更合理的位置。这个结果在真实业务里很常见尤其是当你面对大量低频用户时Gamma-Gamma的价值比面对高频用户更大。4.3 和样本均值对比哪个场景下优势最明显我做过很多次实验结论是低频客户占比越高Gamma-Gamma相对样本均值的提升越大。你可以把上面的模拟改成x分别等于1、2、3、5、10来试会看到x1时样本均值MAPE惨不忍睹而Gamma-Gamma因为有全局先验兜底MAPE稳定得多到x10时两者差距缩小因为样本均值本身已经收敛到真实值附近。这里有个实用建议如果你的业务里有大量只买过一两次的用户别急着用Gamma-Gamma输出结果可以先按交易次数分层分别计算不同层级的收缩强度。最后在汇报时你可以非常自信地在PPT里写模型平均将单客户平均交易金额的预测误差从XX%降到XX%其中低频客户的提升最明显。数值对比是最有说服力的。5. 实战中的坑数据清洗、收敛失败与组合CLV5.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), methodL-BFGS-B, boundsbounds, ) 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笔新交易否则验证样本量不足对比结果随机性太强。遇到季节性强的业务比如年货、开学季最好选取能覆盖一个完整业务周期的预测窗口不然验证结果会误导你对模型真实效果的判断。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询