
1. 先把“平均客单价”从算术均值里捞出来为什么CLV要单独建模金额1.1 你手上已有次数模型但它对金额无能为力前两篇我聊CLV的时候一直在强调一个拆解框架CLV 不等于“历史消费总额”而是三种因子相乘的结果——用户预期还能存续多久、预期还能产生多少次交易、以及每次交易平均贡献多少金额。很多项目在第一层就卡住了因为大家习惯用一段时间的总流水除以用户数得到一个粗糙的ARPU再乘个留存率就交差。那套做法在用户规模大、消费行为稳定的时候还能糊弄一旦群体出现分层比如有大量高频低客单和少量低频高客单的用户混在一起平均数会被拉成四不像。Gamma-Gamma 模型处理的正是第三个因子预期每笔交易金额。它不是为了替代你现有的交易次数模型而是和交易次数模型拼装成完整的CLV预测管线。次数模型告诉你“某人未来还会买几次”Gamma-Gamma 告诉你“这几次里每一次大概值多少钱”。两者通过独立建模再乘上毛利率才是能拿去指导投放预算和会员运营的CLV。听完你可能觉得那不就是算一下历史客单价的期望值吗远没这么简单。历史客单价是所有用户的混合统计量它混入了用户分层、消费频次、生命周期阶段等噪声而Gamma-Gamma 要做的是在剥离频次影响后单独估计“每个用户真实的单次价值水平”和“群体层面该水平的分布参数”再对每个用户做贝叶斯式调整。这是它和“除以总单数”之间最大的区别。1.2 用历史均值替代的三种坏场景我见过很多团队在MVP阶段直接输一个“人均客单价70元”到CLV公式里结果模型上线后一塌糊涂。拆开看主要有三种坏场景新用户没几单均值严重不稳定。一个用户刚注册只买过一单9.9元直接拿这个数去推他未来每年贡献500元会低估到没法看。高频用户和低频用户混合计算均值被交易频次加权。其实你算的是“单均金额”不是“人均客单价”。低频高客单用户的价值被高频低客单用户稀释运营重点就会跑偏。消费金额随生命周期变化。用户在前3个月可能大量试用低价品半年后转向高价复购。历史均值没有时间轴预测出来的是一个模糊的终身平均而不是未来时段的预期值。我在真实项目里遇到过的典型案例是把一个美容院的用户分群后SPA客户和基础护理客户的单次金额差异能到3倍以上但全店平均客单价看起来稳定在400多元。Gamma-Gamma 建模后部分低频高价值用户的CLV被修正到普通用户的5倍以上运营团队才敢把高价值用户单独拉出来做专项策略。这就是单独建模金额部分的直接价值。2. Gamma-Gamma模型的基本假设与那个著名的条件期望公式2.1 三个核心假设Gamma-Gamma 模型之所以叫“Gamma-Gamma”是因为它假设了两个层次的Gamma分布一层描述“每个用户真实的单次消费水平在群体中的分布”另一层描述“用户单次成交金额围绕这个真实水平波动的分布”。用人话翻译一下每个用户身上有一个“隐藏的真实客单价水平”我们观测不到只能通过他历史每笔订单去推测。这个“真实水平”在不同用户之间不是常数而是服从一个Gamma分布。有的用户天生客单高有的用户客单低但整体分布是右偏的少数高客单用户拉出一条长尾。用户每一次具体成交金额是在他自己的“真实水平”附近随机波动的波动规律也近似Gamma分布。除此之外模型还有一个关键前提交易金额和交易次数相互独立。也就是说一个用户买得频繁并不改变他单次金额的分布他单次金额高也不意味着他下次购买概率更大。这个假设在实际数据里并不永远成立比如大促期间低客单用户可能高频购买客单价反而被拉低。因此做数据清洗时最好先把大促订单、退款订单、异常大单单独拆出来看不要一股脑丢进模型。2.2 条件期望公式模型的核心输出Gamma-Gamma 最核心的输出不是点估计而是一个条件期望。公式长这样E(M | p, q, v, x, m) ((q - 1) / (p * x q - 1)) * (p * v / (q - 1)) (p * x / (p * x q - 1)) * m这里x 是该用户历史交易次数m 是该用户历史每笔平均交易金额p 是第一层Gamma分布的形状参数q 是第二层Gamma分布的形状参数v 是第二层Gamma分布的尺度参数第一眼看上去有点吓人但拆开就很简单它本质上是“群体平均单次金额”和“用户自己的历史平均金额”的加权平均。前一项 p * v / (q - 1) 是群体层面的长期平均单次金额相当于先验后一项 m 是用户自己的观测均值权重由 p * x 和 q - 1 之比决定。p * x 会随着交易次数 x 增大而增大所以历史交易次数越多的用户模型越信任他自己的均值交易次数越少的用户模型越倾向于把他往群体均值方向拉。这个“往群体均值收缩”的行为正是Gamma-Gamma 对比朴素均值最大优势。一个只买过一单9.9元的用户不会被直接当作客单价9.9元处理而是会被拉到接近群体的平均水平随着他交易次数增加再逐渐偏移到个体真实水平。2.3 什么时候不适合用Gamma-Gamma 不是万金油它有三个明显的使用边界金额出现强双峰或多峰分布时一个Gamma分布描述不了“一半用户买50元另一半用户买5000元”这种结构。遇到这种情况先做用户分层分层后各层再单独建模。金额和频次显著相关时独立假设会被破坏。你可以在建模前跑一个相关性检验如果频次和客单价的相关系数绝对值超过0.3就要谨慎。用户生命周期极短绝大多数用户只有一两笔订单时模型能学到的用户级信息太少参数估计会不稳。此时更需要先对整体群体做聚合分析。3. 制造数据带“标准答案”的Gamma-Gamma模拟3.1 模拟思路写代码之前先明确一个信念模型验证最怕的是“拿着结果对着空气鼓掌”。所以我会人造一份数据集先指定参数生成数据再让模型去还原这些参数。如果模型能还原说明实现是对的如果还原不了说明代码、假设或边界条件有问题。生成步骤大致是设置两个Gamma分布的参数一个控制用户真实客单水平的分布另一个控制单笔订单的波动。为每个用户生成他个人的“尺度参数”这个参数代表了他的消费层级。根据这个参数为每个用户生成多笔交易金额。汇总成一张客户级表用户ID、交易次数、平均交易金额。这里要说明一点模拟数据不可能完美覆盖真实业务的所有噪声但用来验证代码逻辑是足够的。我会用2000个用户生成后保留至少完成过1笔交易的用户因为Gamma-Gamma 模型针对的是“有历史交易”的用户群体零交易用户本来就该由成交概率模型去处理。3.2 模拟代码import numpy as np import pandas as pd np.random.seed(42) n_customers 2000 # 用户真实客单水平的分布参数 shape_customer 3.0 # 相当于模型中的q scale_customer 1.0 # 相当于模型中的v # 单笔交易金额波动的形状参数 shape_transaction 6.0 # 相当于模型中的p # 每个用户个人尺度参数定的层级越高平均客单越高 customer_scale np.random.gamma(shapeshape_customer, scalescale_customer, sizen_customers) # 模拟每位用户的交易次数简单用泊松分布 freq np.random.poisson(lam5, sizen_customers) freq np.maximum(freq, 1) # Gamma-Gamma 至少需要1次交易 # 展开成交易明细 customer_ids np.repeat(np.arange(n_customers), freq) transaction_scale np.repeat(customer_scale, freq) # 每笔订单金额围绕个人消费水平波动的Gamma分布 spends np.random.gamma(shapeshape_transaction, scaletransaction_scale) df_trans pd.DataFrame({ customer_id: customer_ids, spend: spends }) # 汇总成客户级数据 summary df_trans.groupby(customer_id)[spend].agg([count, sum]) summary.columns [frequency, monetary_sum] summary[monetary_value] summary[monetary_sum] / summary[frequency] print(summary.head()) print(summary.describe())这里需要注意一个细节np.random.gamma 的第二个参数是 scale尺度不是 rate速率。很多新手在这里栽过跟头以为传进去的是速率参数导致生成结果大好几倍。用 scale 参数时期望值等于 shape * scale生成出来的平均消费层级大约就是 shape_transaction * shape_customer * scale_customer也就是 6 * 3 * 1 18 左右。3.3 数据体检数据生成后不要急着跑模型花一分钟先看看分布特征。import matplotlib.pyplot as plt fig, axes plt.subplots(1, 2, figsize(12, 4)) axes[0].hist(summary[monetary_value], bins50, edgecolorwhite) axes[0].set_title(Distribution of Mean Spend per Customer) axes[1].hist(summary[frequency], binsrange(0, 16), edgecolorwhite) axes[1].set_title(Distribution of Transaction Frequency) plt.tight_layout() plt.show() # 相关性和独立性初判 print(summary[[frequency, monetary_value]].corr())正常情况下你会看到平均交易金额分布是右偏的一个明显的长尾拖到右边交易次数则大致符合泊松形态。两者的相关系数应该很低说明“买得多的人不一定买得贵”这样Gamma-Gamma 的独立性假设才勉强说得过去。如果模拟时人为设计了正相关结构比如高频用户客单价也高那Gamma-Gamma 拟合出来参数仍然能跑但你必须意识到现实中这个结果会被频次模型重复计算。4. 不用库自己写最大似然估计Gamma-Gamma4.1 对数似然函数的设计很多人以为Gamma-Gamma 的拟合像线性回归一样有解析解直接套个公式就能出参数。事实是没有需要靠最大似然估计迭代求解。最大似然估计的核心是找一组参数让当前观测数据出现的概率最大。Gamma-Gamma 的对数似然函数比较复杂完整的数学推导需要组合两个Gamma分布并做积分。我这里直接给出和 lifetimes 库等价的负对数似然函数形式方便对照。它的结构是第一部分是Gamma函数的组合把两个层的形状参数关联起来第二部分是用户级观测数据的贡献结合交易次数和平均金额最后一部分是归一项保证概率密度积分为1。写成Python代码from scipy.special import gammaln from scipy.optimize import minimize def negative_log_likelihood_gamma_gamma(params, freq, mon): p, q, v params # 参数必须为正 if p 0 or q 0 or v 0: return 1e10 # 注意这里freq和mon都是numpy数组长度等于用户数 ll np.sum( gammaln(p * freq q) - gammaln(p * freq) - gammaln(q) q * np.log(v) p * freq * np.log(mon) - (p * freq q) * np.log(v freq * mon) ) return -ll # 因为我们要最小化负对数似然这段代码里最关键的是最后一行v freq * mon。freq * mon其实等于该用户历史总交易金额。为什么要用这个组合因为当把用户个人尺度的不确定性积分掉以后观测到“总金额和总次数”这两个统计量就足够描述这个用户在模型中的信息了。这也是Gamma-Gamma 被归为“充分统计量模型”的原因你不需要保留每一张订单的明细只需要客户级的总次数和总金额参数估计结果完全一致。4.2 优化求解对数似然函数写好后接下来就是常规的数值优化。我习惯用 scipy.optimize.minimize配 L-BFGS-B 方法并给参数加上非负约束。初始值可以给得随机一点但不能太离谱否则可能陷入局部最优。# 提取数组 freq_arr summary[frequency].values.astype(float) mon_arr summary[monetary_value].values.astype(float) # 多组初始值尝试避免局部最优 start_points [ np.array([1.0, 1.0, 1.0]), np.array([2.0, 0.5, 0.5]), np.array([0.5, 2.0, 2.0]), ] best_result None best_nll np.inf for start in start_points: res minimize( negative_log_likelihood_gamma_gamma, start, args(freq_arr, mon_arr), methodL-BFGS-B, bounds[(1e-6, None), (1e-6, None), (1e-6, None)], options{maxiter: 10000} ) if res.fun best_nll: best_nll res.fun best_result res print(最优点参数:, best_result.x) print(收敛状态:, best_result.success) print(负对数似然:, best_result.fun)这里多跑几组初始值的习惯很重要。Gamma-Gamma 的似然曲面虽然大多时候光滑但偶尔会出现参数相关的沟壑尤其数据稀疏时从单一初始点出发容易停留在某个非最优参数组合上。多组初始值取最小负对数似然算是一种廉价而有效的稳健性检查。4.3 拟合结果解读跑完之后你会得到一组 p、q、v 的估计值。拿这组值和模拟时的真实参数对照参数模拟真值手写MLE估计值p6.0通常接近5~7q3.0通常接近2.5~3.5v1.0通常接近0.8~1.2如果模拟数据的随机种子固定估计值和真值不会完全一致这是正常的。只要在合理波动范围内说明代码实现没有问题。真正该关注的是用这组参数计算每个用户的期望单次金额是否和用户真实的平均金额高度相关。p_est, q_est, v_est best_result.x def expected_spend(p, q, v, freq, mon): global_weight p * v / (q - 1) individual_weight mon mix p * freq / (p * freq q - 1) return (1 - mix) * global_weight mix * individual_weight summary[expected_spend] expected_spend( p_est, q_est, v_est, summary[frequency].values, summary[monetary_value].values ) # 和用户历史平均金额对比 print(np.corrcoef(summary[monetary_value], summary[expected_spend])[0, 1])这个相关系数通常会在0.9以上说明模型输出没有偏离个体观测太多同时又不是完全等于观测值因为它包含了群体先验的收缩效应。交易次数少的用户他的 expected_spend 会比自身历史均值更靠近群体均值这符合我们前面讲的设计逻辑。5. lifetimes库快速拟合不重复造轮子的正确姿势5.1 为什么最终还是推荐你用它手写最大似然估计能帮你透彻理解模型但在实际项目里我更推荐直接使用 lifetimes 库。原因有三一是它经过大量项目和单元测试验证数值稳定性比我临时写的优化循环好二是它内置了惩罚项可以避免过度拟合三是它和其他频次模型比如BG/NBD接口一致方便串成一条完整的CLV建模链路。lifetimes 的安装很简单pip install lifetimes如果你在Jupyter里遇到安装问题多半是Python环境变量没配好或者在终端直接输入python -m pip install lifetimes来避免当前环境串台。5.2 GammaGammaFitter 用法lifetimes 里对应的类是 GammaGammaFitter。使用时需要传入两个数组frequency 和 monetary_value。注意这里的 frequency 指“有交易的时期数”通常直接用交易次数即可monetary_value 是用户历史平均每笔交易金额。from lifetimes import GammaGammaFitter ggf GammaGammaFitter(penalizer_coef0.0) ggf.fit( frequencysummary[frequency].values, monetary_valuesummary[monetary_value].values ) print(ggf.params_) # 输出类似 {p: 5.83, q: 2.96, v: 0.98}对比上一节手写MLE的结果两者的 p、q、v 差异应该在零点几以内。如果差异过大优先检查手写代码里的似然函数是否漏了项或初始值是否合理。拿到参数后预测每个用户的期望单次金额summary[expected_spend_lifetimes] ggf.conditional_expected_average_profit( frequencysummary[frequency].values, monetary_valuesummary[monetary_value].values )这个方法名里虽然有 profit但它计算的就是期望平均交易金额不是利润。你可以在后续CLV计算时再乘以毛利率不要太教条地区分名字。5.3 手写与库估计的对照把两组结果放一起compare pd.DataFrame({ monetary_value: summary[monetary_value], handwrite: summary[expected_spend], lifetimes: summary[expected_spend_lifetimes] }) print(compare.head(10)) print((compare[handwrite] - compare[lifetimes]).abs().max())通常两条预测曲线的差异会在0.01元以下差太多就说明手写实现有bug。这种“手写实现 成熟库对照”的组合拳是我做模型验证时最常用的套路。它可以帮你同时获得深度理解和工程稳定性。6. 和频次模型合成终版CLV6.1 从交易明细到频次模型输入Gamma-Gamma 只解决了“每次花多少”完整CLV还需要“未来买几次”。这一部分我简单带一笔因为前两篇已经详细聊过BG/NBD的使用。关键步骤是把交易明细转换成 lifetimes 的 summary_by_transaction_data 格式。假设我们模拟的交易明细里有日期字段就可以这样处理from lifetimes.utils import summary_data_from_transaction_data from lifetimes import BetaGeoFitter # 假设 df_trans 中已有交易日期 # 实际使用时日期列是datetime类型 summary_bg summary_data_from_transaction_data( transactionsdf_trans, customer_id_colcustomer_id, datetime_coltransaction_date, observation_period_end2025-01-01 ) bgf BetaGeoFitter(penalizer_coef0.0) bgf.fit( frequencysummary_bg[frequency].values, recencysummary_bg[recency].values, Tsummary_bg[T].values )注意一个关键点这里预测的是“未来一段时间内的购买次数”时间窗口由你指定。BG/NBD 默认假设预测期长度等于观察期长度。如果要预测未来一年观察期也要有一年的跨度。6.2 组合计算组合计算时最常在口径上出问题。建议按这个顺序走先预测未来N期的购买次数。再预测每单期望金额。两者相乘得未来流水。乘毛利率和折现率得到净现值口径的CLV。代码示例# 预测未来12周购买次数 future_periods 12 expected_purchases bgf.conditional_expected_number_of_purchases_up_to_time( tfuture_periods, frequencysummary_bg[frequency].values, recencysummary_bg[recency].values, Tsummary_bg[T].values ) # Gamma-Gamma 预测期望每单金额 expected_monetary ggf.conditional_expected_average_profit( frequencysummary_bg[frequency].values, monetary_valuesummary_bg[monetary_value].values ) gross_margin 0.4 # 假设毛利率40% # 简单版本CLV 未来购买次数 * 每单金额 * 毛利率 clv expected_purchases * expected_monetary * gross_margin print(Top 10 高价值用户) print(np.sort(clv)[-10:])这里没有做折现属于静态口径。如果业务要求考虑资金时间价值通常会在下一个版本的模型里加入折现因子比如月度折现率 0.01 就对未来每个月的现金流逐期折现。不要一上来就上复杂财务模型先用常规口径跑通全链路后续再迭代。6.3 警惕双重计算一个最常见的业务错误是把频次模型预测的购买次数直接乘以“全店历史平均客单价”然后又用Gamma-Gamma修复客单价。这样其实没有修复任何东西因为Gamma-Gamma的价值在于为每个用户单独估计金额如果最后又退回全店均值那等于白做。正确的组合方式是让两个模型各自输出用户级预测值再逐用户相乘。这样高频低客单用户会被突出频次优势低频高客单用户会被突出金额优势整体CLV分布才是真正反映用户差异化的。上线后你大概率会发现用单一规则算出来的高CLV用户和组合模型算出来的高CLV用户重合度不到六成。这就是独立建模的价值。7. 复盘我在真实项目里踩过的几个坑7.1 零交易用户要不要进模型Gamma-Gamma 建模的对象是“已经有至少1笔交易的用户”。零交易用户的金额期望就是0不需要模型预测。常见错误是把零交易用户也塞进来导致 monetary_value 为0Gamma分布的密度计算直接报错。正确做法是先用成交概率模型预测零交易用户的购买概率再用Gamma-Gamma预测成交后的每单金额。两个模型各自管好一段最后乘起来。第一步没有成交后面全部归零这在逻辑上也是自洽的。7.2 退款和异常大单我对这个坑最有感触。电商行业退款率动辄20%以上订单金额里有大量负数或零。Gamma-Gamma 的Gamma分布定义域是正数遇到负数会直接崩掉。处理方式分两步第一步把退款相关的负金额单独拆出来不要和正常成交金额混在一起算均值。第二步如果用户平均交易金额里混入了明显离谱的异常值比如某人有一笔10万元订单而其他订单都低于200元先用分位数截断或IQR规则剔除再做建模。这笔异常大单如果确实来自真实大客户也应该单独分层处理而不是和普通零售用户混用一个模型。7.3 参数不收敛怎么办我自己跑模拟数据时几乎不会遇到不收敛因为数据是从模型里生成出来的结构干净。但真实数据里经常出现 p、q、v 的估计值飘到很大的情况或者优化器报“DESIRED ERROR NOT ACHIEVED”之类提示。应对办法增加 penalizer_coef比如从 0.0 调到 0.1给似然函数加一个小的惩罚防止参数过大。检查输入数据是否包含极端值。检查用户覆盖是否太少少于100个用户时参数估计极不稳定。换约束优化方法比如用 Nelder-Mead 或 SLSQP 交叉验证。7.4 上线前一定要做的交叉验证模型在模拟数据上表现再好也只是验证了代码正确性。真正上线前必须做时间切分验证用前6个月的数据训练模型预测后6个月的实际成交金额再对比预测值和真实值。具体指标上我习惯先看整体平均误差再看用户分层后的误差。如果高价值用户群体的预测偏差超过30%这个模型的落地价值就要打问号。我实际项目里的经验是Gamma-Gamma 在数据质量足够干净的场景下预测每个用户的未来平均单笔金额误差可以控制在15%以内一旦数据里有大量退款、优惠券抵扣、满减分摊问题误差会迅速扩大。所以这个模型的成败有时候不取决于算法而取决于特征工程层面的金额口径定义。最后再分享一个我自己的习惯每次拟合完Gamma-Gamma我都会顺手把“用户的预测单次金额”和“用户的频次”画在一张散点图上看一眼。如果两者明显正相关说明模型假设里的独立性被破坏了这时候强行用Gamma-Gamma只会得到一个看似漂亮但不稳定的输出。宁可先做用户分群也别让模型背负它不该背的复杂度。