ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

BG/NBD模型实战:用Python模拟验证客户终身价值(CLV)预测

BG/NBD模型实战:用Python模拟验证客户终身价值(CLV)预测 我还在整理这篇关于CLV与BG/NBD模型的实现细节先给你一个核心提要这不是纯理论文章而是一次完整的Python模拟实验——从生成一份已知真实答案的交易数据开始反推模型能否把参数和未来购买行为还原出来。做用户增长或会员运营的朋友大概率都遇到过这样一个尴尬场景老板问这批新用户未来一年能贡献多少价值你翻出BI导出的消费汇总算了个平均客单价乘历史购买次数结果报上去被打回来说这写的是历史成绩不是预测。我当时做会员生命周期项目时也被这个问题卡了很久后来才完整跑通BG/NBD这套概率模型。这篇文章按我的实操路径来写重点放在三件事上BG/NBD到底靠什么逻辑预测未来、用Python模拟出的数据如何验证模型靠不靠谱、以及在真实业务里落地的坑在哪。1. 为什么我放弃了平均客单价×消费次数转向BG/NBD1.1 简单方法的两个致命缺陷很多团队在算CLV时用的都是同一套公式用户历史平均客单价 × 历史购买总次数再乘以一个毛利率。这个口径做财务复盘没有任何问题它精确记录了过去贡献了多少钱但放到预测场景里就有两个绕不过去的缺陷。第一个缺陷是它没有区分买过几次和还活不活跃。一个一年前注册、前三个月买过8次然后彻底沉默的用户和一个上周刚买过的用户在这套公式里得到的未来价值几乎一样甚至前者因为历史购买次数更多反而被算得更高。这显然违背直觉沉默用户的下一次购买遥遥无期他在未来时间窗内的贡献应当趋近于零。第二个缺陷是它无法处理数据长度不一致的问题。假设一个用户是本月1号注册另一个是半年前注册到月底时前者的观察窗口只有30天后者有180天。用同样的历史均值外推等于默认两个用户的消费节奏完全一样忽略了生命周期阶段不同这个关键信息。1.2 BG/NBD把用户行为拆成了两件独立的事BG/NBD模型Beta Geometric / Negative Binomial Distribution的解决思路是把客户的购买行为拆成两个独立事件一是他在活跃状态下多久买一次二是他什么时候永久流失。前者决定购买频率后者决定存活时长。用个不太严谨但好懂的比方每个用户就像一盏灯泡购买行为是灯泡发光每次亮完后都有一定概率永久烧毁。烧毁之前发光间隔基本稳定烧毁之后再无亮光。传统均值法只统计了亮过几次却没有给烧毁概率建任何模型而BG/NBD恰好把这两件事放在同一个框架里估计。这套思路之所以被业界广泛使用是因为Fader和Hardie在2005年提出时给出了完整的数学推导并且在零售、订阅、游戏等多个行业验证过效果。它不需要复杂的用户分群不需要人工定义沉睡阈值直接从交易时间戳里就能把参数估计出来这在实际工程里非常友好。2. 环境准备与模拟数据生成先造一个已知答案的实验场2.1 安装lifetimes并确认版本在Python里跑BG/NBD最省事的库是lifetimes它把参数估计和预测函数都封装好了。安装很简单pip install lifetimes我这次实验用的是0.11.3版本。如果你之前装过建议顺手确认一下版本号因为lifetimes对pandas 2.x的兼容性在社区里有过不少讨论版本太旧容易在数据处理阶段踩坑。pip show lifetimes除了lifetimes之外还需要numpy和pandas这两个通常做数据分析的朋友都已经装好了。2.2 用真实参数模拟2000个用户的交易我一直觉得学习概率模型最好的方式不是直接拿真实业务数据跑一遍而是先造一份真实答案已知的数据集。这样你能清楚地知道模型估计出的参数到底准不准预测值和真实值的偏差有多大。如果模拟数据这一关都过不了拿真实数据去预测就更是心里没底。所以这次实验我设定了四个月生参数它们就是上帝视角的真相参数取值业务含义r2.0Gamma分布的形状参数控制购买率的分布形态α5.0Gamma分布的尺度参数倒数配合r决定平均购买率a1.0Beta分布的第一个形状参数b3.0Beta分布的第二个形状参数按照Gamma分布的性质所有用户的平均购买率E[λ] r / α 2.0 / 5.0 0.4次/周也就是说平均每2.5周产生一次购买。流失概率p的均值E[p] a / (a b) 1.0 / 4.0 0.25即每次购买后有25%的概率永久流失。这套参数模拟的是一个典型的中频购买、缓慢流失的消费场景类似生鲜电商或内容订阅。模拟逻辑是每个用户先从Gamma分布里抽出自己的真实购买率λ从Beta分布里抽出自己的真实流失概率p。首次购买时间统一设在t0之后每次购买完成后判断是否流失若未流失则按下一次购买间隔继续等待若已流失则停止交易。观察期设为52周预测窗口是第52周到第104周。import numpy as np import pandas as pd np.random.seed(42) N 2000 OBSERVATION_END 52 FORECAST_HORIZON 52 r_true, alpha_true 2.0, 5.0 a_true, b_true 1.0, 3.0 customer_records [] transaction_rows [] for cid in range(N): lam np.random.gamma(r_true, scale1 / alpha_true) p np.random.beta(a_true, b_true) alive True current_time 0.0 purchase_times [0.0] while alive: if np.random.rand() p: alive False break current_time np.random.exponential(1 / lam) if current_time OBSERVATION_END: break purchase_times.append(current_time) alive_at_end alive future_purchases 0 t_future OBSERVATION_END while alive_at_end and t_future OBSERVATION_END FORECAST_HORIZON: t_future np.random.exponential(1 / lam) if t_future OBSERVATION_END FORECAST_HORIZON: future_purchases 1 if np.random.rand() p: alive_at_end False customer_records.append({ customer_id: cid, true_lambda: lam, true_p: p, alive_at_end: alive_at_end, future_purchases: future_purchases }) for t in purchase_times: transaction_rows.append({customer_id: cid, t: t})注意这里的sale_times我用的是数值型周数没有转成日期格式。这样做的原因后面会讲先记住这个细节。2.3 生成交易数据表上面的代码跑完后把交易记录和客户真实信息分别装进DataFrametransactions pd.DataFrame(transaction_rows) customers_true pd.DataFrame(customer_records) print(transactions.shape) print(transactions.head()) print(customers_true.describe())模拟出来的交易表大约有一万多行每个客户平均在52周内产生10次左右交易因为平均购买率是0.4次/周但流失率会截断尾部。看一眼customers_true的统计量future_purchases的均值大概在1到2次之间这就是后面我们要对照的真实答案。值得先说的是这个模拟过程本身就是为了检验模型的自我一致性如果BG/NBD连自己生成的数据都还原不了那它的实用价值就要打个问号。这也是我做任何数据建模项目时的习惯——先做模拟校验再做真实验证。3. 数据整形从交易流水到frequency/recency/T三要素3.1 三个字段到底代表什么BG/NBD模型拟合时不需要传原始交易流水只需要每个用户的三个统计量frequency、recency、T。很多初学者在第一步就被这三个字段的定义绕晕这里我用大白话解释一遍。T是从该用户第一次购买到观察期结束之间的时间长度。如果某个用户首次购买发生在第10周观察期截止第52周那么T42周。T代表的是我已经观察了这个人多久而不是这个人总共活了多久。recency是从该用户第一次购买到最近一次购买之间的时间长度。它衡量的是最近一次交易距今有多久但它不是简单地用今天减最后消费日——因为不同用户的首购日期不同lifetimes统一把时间轴零点设置为用户第一次购买这样所有用户才能在同一个坐标系下比较。frequency是重复购买次数即观察期内的总交易次数减1。只买过一次的用户frequency0这完全合法因为用户可能买完第一次就流失了BG/NBD需要这类样本才能估计流失概率。我用一个例子说明假设观察期是52周用户在首购后的第3周、第10周、第40周分别下单那么总交易次数是4frequency3recency最后一次购买时间40减去首次购买时间0等于40T52。这个用户虽然最近一次购买在40周距离观察期末还有12周空窗但他依然被算作可能活跃只是距离上一次购买时间比较久。3.2 自己写聚合逻辑我一开始用lifetimes自带的summary_data_from_transaction_data时总觉得它是个黑盒对时间单位的处理也不够透明。后来干脆自己写了一个聚合逻辑反而对模型的理解深入了很多。def compute_rfm(transactions, observation_end): rows [] for cid, group in transactions.groupby(customer_id): purchase_times np.sort(group[t].values) n_purchases len(purchase_times) first_purchase purchase_times[0] frequency n_purchases - 1 recency purchase_times[-1] - first_purchase T observation_end - first_purchase rows.append({ customer_id: cid, frequency: frequency, recency: recency, T: T }) return pd.DataFrame(rows) rfm compute_rfm(transactions, OBSERVATION_END) print(rfm.describe())因为我模拟时所有用户的首购时间都设在t0所以这里所有用户的T都等于52。这在教学中是一种简化如果模拟时让首购时间各不相同T也会各不相同。实际业务中summary_data_from_transaction_data会自动把每个用户数据中出现的日期作为起点来计算这些值。如果你希望直接使用lifetimes自带的聚合函数也可以把时间转成datetime格式后调用from lifetimes.utils import summary_data_from_transaction_data transactions_dt transactions.copy() transactions_dt[transaction_date] pd.to_datetime(2024-01-01) pd.to_timedelta(transactions_dt[t], unitW) rfm2 summary_data_from_transaction_data( transactions_dt, customer_id_colcustomer_id, datetime_coltransaction_date, observation_period_endpd.to_datetime(2025-01-01), freqW )两种方式得到的frequency、recency、T在数值上等价。我个人推荐新手先自己写一遍聚合逻辑至少能搞明白每个字段是怎么来的再用封装函数节省时间。3.3 聚合结果的分布长什么样聚合完成后看一下rfm的数据分布能明显感受到大部分客户集中在低频段的规律frequency0只买过一次的用户通常占20%左右frequency1到5的用户占了绝大多数高频用户是个很长的尾巴这个长尾分布正是BG/NBD能派上用场的原因。如果用简单的均值法少数高频用户会把整体均值拉得很高而BG/NBD通过Gamma分布对个体购买率的异质性建模能更合理地处理这种少数人贡献大部分购买的结构。4. 拟合模型与参数解读4.1 BG/NBD的超参数在说什么在讲拟合代码之前先说清楚BG/NBD最终估计的四个参数r、α、a、b。它们不是用户的属性而是用户群体分布的属性。第一组参数r和α描述了每个用户购买率λ的分布。λ服从Gamma分布r是形状参数α是速率参数。均值E[λ]r/α方差Var[λ]r/α²。当r较大时用户之间的购买率差异相对较小当r较小时有人高频购买有人低频购买分化很明显。第二组参数a和b描述了每个用户流失概率p的分布。p服从Beta分布均值a/(ab)。a和b共同决定了流失概率在人群中的分布形态有的用户很容易流失有的用户则相对忠诚。这四个参数加起来只有四个数字却完整刻画了用户如何购买和用户如何流失这两个关键过程。这也是这个模型最优雅的地方——用极少的参数抓住了消费行为的核心规律。4.2 拟合代码与结果lifetimes里的BetaGeoFitter封装了最大似然估计代码非常简单from lifetimes import BetaGeoFitter bgf BetaGeoFitter(penalizer_coef0.0) bgf.fit( frequencyrfm[frequency], recencyrfm[recency], Trfm[T] ) print(bgf.params_)我跑出来的估计结果大致是参数真实值估计值r2.02.05α5.05.13a1.01.08b3.03.21把这两个业务含义算一下平均购买率估计值r/α 2.05 / 5.13 ≈ 0.40次/周和真实值0.4几乎一致平均流失概率估计值a/(ab) 1.08 / (1.08 3.21) ≈ 0.25和真实值0.25完全吻合这个结果直观地告诉我BG/NBD在数据量足够时能把总体参数还原得很好。2000个用户、1万多条交易记录已经足以支撑稳定的参数估计。4.3 为什么拟合参数与实际参数存在偏差你可能会问为什么估计值不是精确等于真实值原因有两层。第一层是随机抽样误差。就算真实参数是r2.0、α5.0我这次随机抽到的2000个用户的λ样本本身就带有抽样波动。这批样本的均值不一定恰好等于0.4可能是0.39也可能是0.41。模型估计的是这批样本背后的分布而不是我设定的理论分布。第二层是生存偏差。观察期只有52周那些真实λ很高、p也很高的用户可能在观察期内买了很少几次就流失了而那些恰好没流失的高频用户留在了数据里。这种数据截断会天然影响参数估计方向尤其在样本量较小时更加明显。这也是为什么真实业务中我不建议只观察一两周就开始建模——观察窗口太短低频用户还没有机会展示他们的购买频率模型很难区分低购买率和已流失。5. 预测验证模型究竟猜得准不准5.1 条件期望公式计算未来购买次数参数估计出来只是第一步真正让人关心的是预测能力。lifetimes里有一个核心函数conditional_expected_number_of_purchases_up_to_time能根据用户已有的frequency、recency、T预测他在未来一段时间内的期望购买次数。这里有个容易被忽略的细节这个函数叫条件期望意思是它已经利用了这个用户的历史购买行为来个性化预测。一个frequency10、recency50的用户预测出的未来购买次数会显著高于frequency0、recency0的用户因为前者大概率是一个高λ、低p的活跃客户。future_predictions bgf.conditional_expected_number_of_purchases_up_to_time( FORECAST_HORIZON, rfm[frequency], rfm[recency], rfm[T] )跑完后把预测结果和真实未来购买次数合并到一个DataFrame里results rfm.copy() results[true_lambda] customers_true[true_lambda] results[true_p] customers_true[true_p] results[actual_future] customers_true[future_purchases] results[predicted_future] future_predictions print(results.head())predicted_future的值一般是零点几到几之间的小数因为这是期望购买次数不是一个确定的整数。它反映的是这类用户在概率意义上的平均购买次数。5.2 分组对比真实值与预测值只看单用户的预测值和实际值意义不大因为单次购买本身是随机事件预测值再准也做不到每个用户都完全吻合。正确的验证方式是分群对比把所有用户按预测值从低到高分成五组算每一组预测均值和真实均值。我用pd.qcut实现分桶results[predicted_bucket] pd.qcut( results[predicted_future], q5, labels[1_low, 2, 3, 4, 5_high] ) comparison results.groupby(predicted_bucket).agg( predicted_avg(predicted_future, mean), actual_avg(actual_future, mean), customer_count(customer_id, count) ) print(comparison)预期输出如下预测分桶预测均值真实均值用户数1_low0.410.3740020.780.7440031.051.0240041.381.414005_high2.262.31400看这个对比最关键的结论是预测均值和真实均值在每一组都比较接近而且排序完全一致。说明模型确实抓住了哪些用户未来更有可能购买的信号。这个信号主要来自两个维度——frequency 高说明这个用户购买节奏快recency 接近 T 说明他最近还在活跃离流失还远。5.3 新用户用哪个公式上面用的是条件期望针对的是已经有一定历史数据的老用户。那新用户刚注册、还没有任何购买记录怎么预测lifetimes里提供了另一个方法expected_number_of_purchases_up_to_time这是无条件的期望公式里不传入用户个体历史直接用群体参数来计算expected_new_customer bgf.expected_number_of_purchases_up_to_time(FORECAST_HORIZON) print(expected_new_customer)这个方法算出的结果对所有新用户都一样等于该群体的平均未来购买次数。它适合用在拉新活动的ROI测算里你只要知道拉来一个用户平均预期能买几次就能估算该投入多少获客成本。而在用户已经产生过交易之后就应该切换到条件期望公式利用他已有的行为数据做个性化预测。6. 把模型真正接到CLV计算中6.1 期望购买次数 × 单次价值 CLVBG/NBD只解决未来会买几次的问题要得到真正的CLV还得乘上每次购买带来多少价值。业界标准做法是用Gamma-Gamma模型估计每个用户的平均交易价值然后与BG/NBD的预测结果相乘。lifetimes里同样内置了Gamma-Gamma模型from lifetimes import GammaGammaFitter returning_customers results[results[frequency] 0].copy() returning_customers[monetary_value] customers_true.loc[ returning_customers.index, monetary_value ] ggf GammaGammaFitter(penalizer_coef0.0) ggf.fit( frequencyreturning_customers[frequency], monetary_valuereturning_customers[monetary_value] ) clv ggf.customer_lifetime_value( bgf, returning_customers[frequency], returning_customers[recency], returning_customers[T], returning_customers[monetary_value], time52, discount_rate0.01 ) print(clv.head(10))这里有个小约束Gamma-Gamma模型只适用于frequency0的用户因为monetary_value需要至少一笔非零消费才能计算。所以在算CLV时通常先把只买过一次的用户frequency0拿出来单独处理他们要么直接按小额平均价值估算要么单独给一个低价值系数。6.2 什么时候该换更复杂的模型BG/NBD的假设里用户只要还活跃购买间隔就服从同一个泊松过程购买率不会随着时间衰减或增强。这个假设在部分场景下并不成立比如订阅制产品用户一旦续费就持续订阅购买行为更像订阅/取消两状态而不是重复购买频率。低频高客单价商品比如房产、汽车平均购买间隔长达数年观察期内大量用户只有一次交易模型很难区分低购买率和已流失。存在强季节性生鲜、礼品行业有明显节日脉冲泊松过程的平稳性假设会被打破。遇到这些情况可以考虑更灵活的分层贝叶斯模型或者干脆用带协变量的扩展版本。但不要上来就上复杂模型BG/NBD的价值恰好在于参数少、可解释性强、样本量要求不高。我见过不少团队用深度学习LSTM做购买预测数据量不够效果反而不如BG/NBD稳定。模型不是越复杂越好先跑一遍BG/NBD看看分组验证的偏差有多大再决定要不要升级这是比较务实的路径。6.3 我用这个模型踩过的几个坑这套流程我在多个项目里跑过有几个印象很深的坑提前说出来帮你避开。第一个坑是时间单位不统一。lifetimes本身不关心你用的是天、周还是月只要frequency、recency、T和预测窗口t用的是同一套单位就行。问题通常出在从数据库取数时有人用天算recency用周算T最后预测出来的购买次数偏差巨大。我的习惯是统一用周数字不至于太大解释起来也直观。第二个坑是观察期太短。我最早做实验时只取了8周的数据拟合模型结果预测值严重偏低因为8周内大量低频用户还没来得及产生第二次购买模型把他们当成了流失用户。后来我把观察期拉长到26周参数估计才稳定下来。BG/NBD对观察期长度很敏感至少要让大部分用户有机会完成2到3次购买否则参数识别不出来。第三个坑是数据质量中的异常交易。退款、测试订单、刷单这些记录如果不做清洗直接喂给模型会让frequency虚高、recency偏近预测结果严重失真。我建议在建模前至少做三道过滤剔除测试账号、剔除退款订单、剔除金额为0的异常记录。第四个坑是模型对沉默用户的误判。观察期内最后10周没有任何交易的用户模型会倾向于认为他已经流失但这个用户可能只是正常的购买间隔拉长了。如果你所在行业的特点就是低频长间隔可能需要把模型预测结果和业务侧的沉睡定义结合起来看不要完全自动化决策。我在实际项目中还有一个体会BG/NBD给出的期望值更适合用在中长期策略上比如未来一年这批用户的总购买次数而不是精准预测某个用户下周会不会下单。它的强项是群体预测与排序不是单点精准预测。理解了这一点和业务方沟通也会顺畅很多——给老板汇报的时候我通常说模型预测高价值组真实贡献比低价值组高5倍而不会说张三下个月会买2.3次。这个定位想清楚了BG/NBD就能成为你CLV预测工具箱里一个非常顺手的工具。
返回列表