用Python实现BG/NBD模型预测CLV:从概率原理到业务实战
做用户增长和会员运营的人大多会遇到同一个尴尬老板让你预测“下个月这批用户能贡献多少营收”你把RFM表格甩上去结果只能回答“哪些用户最近活跃”却说不清“这批用户未来还会买几次、什么时候会沉默”。CLVCustomer Lifetime Value用户存续期价值这几年被反复讨论就是因为它不看用户“过去有多好”而是把用户当成一条有流动性的生命线去估值。上一篇我们聊过CLV的基础口径怎么搭包括观察窗口怎么定义、毛利率怎么扣这篇我聚焦一个在电商、内容订阅和SaaS场景里被反复验证过的概率模型——BG/NBD Model用Python完整跑一遍模拟和预测。这篇内容适合三类人。一是数据分析师想给业务一个“能解释、可落地”的用户价值口径二是刚接触CLV的运营和产品同学不需要啃透数学但要理解模型为什么能预测、数据和代码怎么落地三是对用户增长感兴趣、想加深概率建模理解的Python学习者。我会把BG/NBD里最容易劝退的部分尽量翻译成人话代码全部给出可以直接复制跑。1. BG/NBD模型到底在解决什么问题1.1 为什么普通RFM分析不够用RFM是公司里最常见的用户分层工具Recency、Frequency、Monetary三个维度一拉用户就被分成不同价值梯队。但它本质上是“状态快照”——它告诉你用户上一次购买距今多久、累计买了多少次、累计花了多少钱却没法回答“这个用户下个月还会不会买、大概买几次”。前者是描述后者是预测业务决策恰恰更需要后者。举一个真实场景。运营手里有20万老用户想规划下个月大促的备货和客服排班。用RFM你能统计出“高价值用户有3万人”但不知道这3万人里哪些下个月会买、平均买多少备货只能靠经验拍。BG/NBD模型要补的正是这个缺口给定某个用户的历史行为比如买了x次、最后一次购买在第t_x天、观察窗口总共T天模型能算出他在未来任意时长t内还会买几次。这也是它能衔接CLV的核心原因——CLV不是“过去贡献了多少”的统计量而是“未来价值的期望”要估计未来就得先建立关于购买和流失的概率模型。1.2 两块积木购买过程NBD和流失过程BGBG/NBD的全称是Beta Geometric / Negative Binomial Distribution名字很长拆开只有两块。第一块是购买过程对应Negative Binomial Distribution。模型假设用户在“存活”期间的购买行为近似一个泊松过程。每个用户有自己的购买率λλ越大买得越勤。但用户与用户之间差异很大有人一个月买四次有人一年买一次。为了刻画这种异质性模型进一步假设这群人的λ服从Gamma分布。这个假设非常贴合现实电商领域“少数头部用户贡献大部分交易”的长尾结构正是Gamma分布能模拟的形状。第二块是流失过程对应Beta Geometric。模型假设用户每完成一次购买就有概率p“永久死亡”也就是不再回来消费。这里的“死亡”不是账号注销而是行为意义上的沉默。每个用户的p也不一样人群中的p服从Beta分布。Beta分布很灵活能拟合出“多数用户一次买完就走少数用户非常忠诚”的两极分化情况。把两块拼起来就是用Gamma分布描述购买率在人群中的分布用Beta分布描述流失倾向在人群中的分布再通过贝叶斯框架把每个用户的观测行为买了多少次、最后一次什么时候买传回去得到这个用户的参数后验。听起来复杂但lifetimes库已经把推导过程封装好了我们要做的是理解含义、正确调用。1.3 核心假设与使用边界任何概率模型都有假设BG/NBD也有四条用户只在活跃期内产生购买活跃期内购买间隔服从指数分布用户的购买率λ在生命期内保持不变每次购买后用户以概率p流失流失后不再购买购买率λ与流失概率p在用户群中是独立的如果业务场景明显违背这些假设模型结果就要谨慎。比如订阅服务有固定到期时间、用户购买率有明显季节性波动都会偏离假设。遇到这种情况要么分场景建模要么先做敏感性分析。这也是我坚持先用模拟数据跑通全流程的原因模拟的时候我们知道真实参数能判断模型在什么情况下依然稳健、什么情况下会失效积累了对套路的感觉再上真实数据心里才有底。2. 动手之前先造一份可验证的数据2.1 为什么用Python模拟而不是直接上真实数据直接拿线上交易流水来拟合当然可以但有一个致命问题你不知道“真实答案”。BG/NBD最终输出一组参数估计和未来购买预测真实数据里没人知道用户的真实购买率和流失率你只能大概判断预测值是否符合直觉。一旦结果不合理很难分清是模型错了、参数没调好、还是数据本身脏。模拟数据的优势是“答案在手”。我先人为设定真实的r、alpha、a、b再按这个规则生成交易流水最后让模型反推。反推结果如果和真实值接近说明整套流程没问题再切到真实数据时就有把握。这是数据建模里很标准的“先验验证”做法做金融风控和因果推断的人经常这么干做CLV同样适用。2.2 环境准备与依赖库用Python跑BG/NBD最省事的库是lifetimes它把模型拟合、预测、绘图、CLV计算都封装好了。数据处理用pandas可视化用matplotlib。一条命令装齐pip install lifetimes pandas numpy matplotlib我这边跑通的版本是Python 3.10、lifetimes 0.11.x。如果你用的版本更老函数签名可能有细微差异遇到报错优先查版本。2.3 按真实生成机制构造交易流水我现在模拟一个电商业务5000个用户、365天观察窗口目标是生成每个用户的订单记录。真实参数参考Fader、Hardie和Lee在2005年论文里的经典取值购买率分布r0.25alpha15.0平均购买间隔不算短属于低频品类流失概率分布a0.8b10.0单次购买后的平均死亡概率约7%留存压力不小先初始化参数和用户级异质性import numpy as np import pandas as pd np.random.seed(42) r_true, alpha_true 0.25, 15.0 a_true, b_true 0.8, 10.0 n_users 5000 T_window 365 lam np.random.gamma(r_true, scale1 / alpha_true, sizen_users) p_death np.random.beta(a_true, b_true, sizen_users)这里有个容易踩的细节np.random.gamma的第二个参数是scale尺度等于1/rate。所以当你想用Gamma分布的形状r和速率alpha构造λ时要写成scale1/alpha_true。写成scalealpha_true会让购买率大十几倍后面的预测全乱。接着逐个用户模拟购买事件records [] for uid in range(n_users): t 0.0 while t T_window: dt np.random.exponential(1 / lam[uid]) t dt if t T_window: break amount np.random.gamma(6.0, scale8.0) records.append([uid, t, amount]) if np.random.random() p_death[uid]: break这段逻辑顺序别改错先判断时间是否超出观察窗口超出就break没有超出才记录交易记录之后才检查流失。如果先检查流失再记录交易用户“死”的那次购买就会消失与BG/NBD的设定不符——模型里的p是“完成购买后流失”当次购买本身是真实存在的。交易金额我用了Gamma分布生成换成对数正态分布也行它不影响BG/NBD本身的拟合只是为后续的Gamma-Gamma模型提供“用户平均客单价有差异”的数据形态。把records转成交易表补一个时间列tran_df pd.DataFrame(records, columns[customer_id, transaction_dt, amount]) tran_df[transaction_dt] pd.to_datetime(tran_df[transaction_dt], unitD, origin2023-01-01)然后交给lifetimes自带的函数转成summary表from lifetimes.utils import summary_data_from_transaction_data summary summary_data_from_transaction_data( tran_df, customer_id_colcustomer_id, datetime_coltransaction_dt, monetary_value_colamount, observation_period_end2023-12-31, freqD ) summary.head()看一眼summary四个字段的含义要默背frequency重复购买次数。比如用户一共买了3次frequency2recency最后一次购买距首次购买的时间单位与freq一致这里是天T首次购买距观察窗口结束的时间monetary_value观察期内订单金额的平均值仅在有购买的样本上计算一切顺利的话summary里会有大量frequency0或1的用户这符合业务直觉大部分用户买过一两次后就不再复购。2.4 数据检查的三个要点拿到summary后别急着拟合先做三个快速检查。第一看frequency的分布。如果全是0或1说明样本里几乎没有复购用户BG/NBD能拟合但预测区间会非常宽业务上大概率需要拉长观察窗口。第二看recency是否都小于等于T。出现recency大于T的记录通常说明时间格式转换错误或者observation_period_end设置有问题。第三看monetary_value是否有负值或0。Gamma-Gamma建模要求monetary_value为正出现负数要立刻排查是否混入了退款订单或异常金额。这三个检查在真实数据项目中能拦下一大半“模型为什么跑出来这么怪”的问题。3. 拟合BG/NBD模型并解读参数3.1 一行代码拟合模型代码短到让人怀疑是不是漏了步骤from lifetimes import BetaGeoFitter bgf BetaGeoFitter(penalizer_coef0.0) bgf.fit( summary[frequency], summary[recency], summary[T] ) bgf.summary输出里四行分别是r、alpha、a、b的估计值和标准误。用我们模拟的数据估计值应该落在真实值附近r在0.25上下、alpha在15上下、a在0.8上下、b在10上下。如果你的结果和这个范围差得远先别怀疑模型去查查lam和p_death生成时是不是用错了scale。这里提一下penalizer_coef。它是L2正则化系数数值越大参数被压制得越厉害能防止极端拟合但太大也会让参数失去业务含义。常规数据用0.0就好只有当你发现参数发散发散时才调大比如0.1到0.5。3.2 四个参数怎么翻译成业务语言理解参数比死记公式重要得多。r和alpha合在一起描述的是“用户的购买节奏”。规范化用户组的平均购买间隔可以粗略用alpha/r估算。r相对alpha越小说明大部分用户购买频率偏低少数高频用户会把平均值拉高。如果你的业务是生鲜日百这类参数往往会让r显得偏大如果是家具建材r通常很小。a和b合在一起描述的是“用户群的留存韧性”。单次购买后的平均死亡概率约等于a/(ab)。在我们设定的例子里a0.8、b10.0死亡概率约7.4%看着不算高但考虑到低频品类一年下来累计流失依然很可观。这个数字可以直接拿去做留存漏斗的对照基准。3.3 活跃概率矩阵哪个用户还值得救模型最有张力的可视化是概率存活矩阵from lifetimes.plotting import plot_probability_alive_matrix plot_probability_alive_matrix(bgf, max_frequency20, max_recency365)矩阵横轴是recency纵轴是frequency颜色表示用户当前仍然活跃的概率。读图有个经典规律同样recency下frequency越高存活概率越高。买过20次、最近一次在200天前的用户存活概率可能还有六七成而只买过1次、最近一次在200天前的用户基本可以判定已经离开。这对运营的“唤醒”动作很有参考价值——宁可把短信和优惠券发给“高存活概率但短期沉默”的用户也别浪费在“几乎死透”的尾巴上。如果想把概率落成一个字段代码也很简单summary[prob_alive] bgf.conditional_probability_alive( summary[frequency], summary[recency], summary[T] )3.4 预测未来购买次数预测函数本质是条件期望购买次数t 30 summary[pred_purchases_30d] bgf.conditional_expected_number_of_purchases_up_to_time( t, summary[frequency], summary[recency], summary[T] )这里传入的t必须和summary的时间单位一致。summary是用freqD汇总的那t30就是未来30天如果当初用freqW汇总同样的t30指的是未来30周。这个单位换算问题几乎每个初学者都会栽一次。把全体用户的pred_purchases_30d加起来就得到一个有概率依据的未来购买量预测。这个数值可以直接乘上平均客单价推算GMV也可以用来规划库存和人力。相比“同比增长百分之二十”的拍脑袋这种预测至少能在业务复盘时追溯误差来源。4. 从购买次数到真金白银完整CLV计算4.1 为什么需要第二个模型Gamma-GammaBG/NBD只能预测购买次数CLV要落到金额上。解决办法是叠加一个Gamma-Gamma模型用历史平均客单价估计每个用户的“期望单次购买金额”。Gamma-Gamma的核心思想一句话用户的单次消费金额服从以“个人均值”为中心的Gamma分布而这个个人均值在人群里又服从另一个Gamma分布。模型会把每个用户的个人均值向整体均值方向收缩。买得多的用户金额估计偏向自己的历史均值买得少的用户金额估计向全量用户均值回归避免被一两次大额订单带偏。这和“贝叶斯收缩”是同一个思路用来压低小样本估计的方差。4.2 Gamma-Gamma建模的注意点先筛选出至少完成过一次购买的样本from lifetimes import GammaGammaFitter valid_mask summary[frequency] 0 ggf GammaGammaFitter(penalizer_coef0.0) ggf.fit( summary.loc[valid_mask, frequency], summary.loc[valid_mask, recency], summary.loc[valid_mask, monetary_value] ) ggf.summary这里有个容易混淆的地方虽然Gamma-Gamma拟合时只用frequency0的用户但后续计算CLV时你仍会把全部用户传进去。因为frequency0的用户未来也可能产生购买金额模型对这类用户会给出“人群平均金额”的估计这恰恰是期望意义上该有的行为。4.3 串联两个模型计算CLV把两个模型串起来clv ggf.customer_lifetime_value( bgf, summary[frequency], summary[recency], summary[T], summary[monetary_value], time365, discount_rate0.01, freqD ) summary[clv_12m] clv参数time365配合freqD意思是未来365天。想预测未来12个月就把time设成365想预测未来52周就得重跑summary用freqW汇总然后time52。时刻记住时间单位和time要一一对应。discount_rate是资金的时间价值这里设成0.01意思是每个周期折现1%。如果业务用周或月做周期这个值也要同步调整别拿日频数据和月折现率混用。算完后按CLV排序看分布CLV通常会严重右偏少数用户贡献大头。实用建议是把用户分成四层人数前5%到10%的超级用户值得一对一维护腰部用户适合用券和会员权益提频低频沉默用户做低成本召回最后一层基本无价值别投入重资源。4.4 用模拟数据的真值校准模型这是模拟数据的杀手锏——因为真实参数已知我可以在模型预测结束后把每个用户未来30天的实际模拟购买次数统计出来和pred_purchases_30d做对比。把所有用户按预测值从低到高分成10组每组用户的实际平均购买次数也应该从低到高单调递增且总体误差在一个可接受范围内。如果对比结果明显偏离通常要排查三个阶段一是数据汇总步骤确认frequency和recency算对了二是参数拟合是否收敛看标准误差是否过大三是未来时长t是否写错单位。这一步做完模型从“黑盒”变成了“可审计”这也是我坚持用模拟数据开篇讲CLV建模的根本原因。5. 实战中容易踩的坑5.1 summary里大量frequency0的用户真实项目里数据埋点上线晚会导致很多用户只有一次购买记录甚至没有购买。frequency0并不是错误BG/NBD本身就能处理重复购买率为0的用户。但Gamma-Gamma建模时一定要先剔除frequency0否则它内部算monetary_value时会出问题。5.2 预测结果是负值或NaN主要原因十有八九是时间单位混用。比如recency和T用天但给t传了周或者observation_period_end晚于实际数据导致T为负。先按2.4的检查清单看一遍summary基本能解决。5.3 参数估计不收敛如果summary里的标准误特别大或者整体数值飞掉先做三件事检查有没有极端离群值比如一年买几千次的机器人行为做缩尾处理给penalizer_coef赋一个非零值拉长观察期增加有效样本。5.4 用户购买率季节性波动BG/NBD假设购买率恒定对季节性品类比如节令食品、冬季运动装备会有明显偏差。一种做法是把数据切成“同季节”的窗口分别建模另一种是用带协变量的扩展版本但那是进阶内容先用分群绕开最省事。5.5 模型上线后要定期重训CLV模型不是一锤子买卖。用户行为、品类结构、营销策略都在变模型参数会漂移。我在实际项目中默认每周重训一次把观察窗口滚动更新到最新日期同时保留历史CLV作为监控基线。这样能看到每个用户群体的CLV趋势是上涨还是下降也能提前发现策略有效性变化。以上这套流程跑完你就获得了从模拟到真实、从交易流水到CLV分布的完整链路。最后说一个我自己的使用习惯拿到CLV之后别急着只盯一个总和数字。我更建议把用户按渠道、进店时间、首单品类各拆一层用条件CLV做比较。很多看起来“用户整体价值在涨”的结论拆分之后会发现只是某条新渠道拉高了均值老渠道的存量用户其实一直在萎缩。CLV的最终价值是让你在汇报和决策时手里有了一把能反复切角度的尺子而不是一个只能在季度总结里躺着的指标。