尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

BG/NBD模型实战:Python模拟验证客户生命周期价值预测

BG/NBD模型实战:Python模拟验证客户生命周期价值预测 做CLV分析很多时候大家一上来就套模型结果算出来的数字自己都不敢信。上一期我聊过CLV的基础框架这一期专门拆一个在非契约型业务里最能打的模型——BG/NBD并且用Python完整跑一遍模拟从自己生成客户购买历史到拟合模型参数再到预测未来购买次数最后拿模拟数据验证预测准不准。整个过程就是一次“模型体检”搞完这遍你对这个模型的脾气会摸得很透。先说清楚这篇文章能给你带来什么。如果你正在做用户价值分层、复购预测或者会员运营看完之后你能亲手用Python复现一整套BG/NBD的模拟和验证流程还能避开我在实际调试中踩过的那些坑。适合有一定Python基础、但第一次接触CLV模型的从业者也适合那些已经套过lifetimes库但没搞懂内部逻辑的同学。1. 先用一句话讲清楚BG/NBD到底在算什么BG/NBD的全称是Beta Geometric / Negative Binomial Distribution翻译过来是“Beta几何/负二项分布”模型。名字看着吓人拆开看就两件事第一件事活着的客户怎么买。模型假设客户在“活跃”状态下购买行为是一个泊松过程。什么意思就是说客户在一个时间段内的购买次数服从泊松分布平均购买率用 λ 表示。不同客户天生购买率就不一样有人一周买三次有人一个月买一次所以 λ 在客户群体里服从Gamma分布。泊松分布和Gamma分布一混合最后得到的就是负二项分布NBD这就是模型名字里后半段的来源。第二件事客户怎么流失。模型假设客户每次完成购买之后都有一定概率 p 变成永久不活跃状态。这个 p 在每个客户身上是固定的但不同客户之间的 p 不一样服从Beta分布。因为单个客户的流失行为可以用几何分布来描述再加上Beta分布刻画客户间的差异合起来就是Beta GeometricBG也就是模型名字的前半段。这两个假设合在一起就是BG/NBD的核心思想观察一个客户过去买了多少次、最后一次购买离现在多久我们就能算出他现在还活着的概率有多大以及未来一段时间内他还会买几次。它不需要你定义“流失”的具体规则比如连续90天没来就算流失而是把“流失”当作一个概率事件来处理这一点非常符合现实——很多客户不是不来了只是还没来。这个模型的应用场景也很明确非契约型业务也就是客户流失这事你看不见比如电商、外卖、内容付费、线下零售。像健身房年卡这种契约型业务会员到期不续费你就知道他走了那就不太适合用BG/NBD而更适合用契约型模型。2. 为什么我要先模拟而不是直接拿真实数据建模我第一次用BG/NBD的时候直接拿公司真实数据套lifetimes库跑出来参数然后就开始出预测报表。结果业务方反馈说预测值普遍偏高我一时半会儿说不清楚是模型问题还是数据问题。后来我学乖了先在一份自己完全知道真相的模拟数据上验证模型把模型的误差边界摸清楚再上真实数据这样心里有底。这就是我这篇文章坚持用Python模拟的原因。模拟数据的好处在于你可以预先设定每类客户的真实参数比如购买率、流失概率然后一眼看出模型拟合出来的参数和真实值差多少。如果拟合结果偏离很远你至少知道模型在这种设定下存在什么局限而不是永远在猜。具体来说模拟解决三个问题验证生成数据的算法是否正确。如果你连模拟数据都生成得不合理后面的参数估计也就没有意义了。检验模型在“已知真相”下能否恢复参数。虽然真实业务中你永远不知道每个客户真实的λ和p但模拟数据可以。验证预测可信度。因为你还可以继续往后模拟未来窗口的购买行为拿真实发生的“未来”去对比模型预测。我这次选的业务场景是一个生鲜电商App假设有1000个新用户在某周同时注册我们跟踪78周约18个月的购买记录作为观测窗口然后继续往后模拟52周作为验证窗口。这个场景非常典型生鲜类购买频率高但流失也明显BG/NBD特别适合。环境上我就用最常见的搭配Python 3.9numpy生成随机数和模拟购买过程pandas做数据处理scipy用于优化和积分matplotlib画验证图。另外会用到一个专门做CLV模型的库lifetimes它内置了BG/NBD的参数估计工具非常方便。3. 生成模拟客户购买记录从业务假设到正确代码模拟生成客户购买记录是整个流程的第一步。我见过不少人直接下载一个公开数据就开始拟合模型但那样你永远不知道生成数据的过程是否符合模型假设也就无法真正理解模型在做什么。所以我建议你无论如何都先自己生成一遍。3.1 生成逻辑与核心参数设定生成逻辑不复杂就是按照模型的假设一步步来每个客户从Gamma分布中抽取一个购买率 λ这个λ代表他每周平均购买次数。每个客户从Beta分布中抽取一个流失概率 p代表他每次购买后流失的概率。模拟时间从0开始。每个客户按照λ的泊松过程随机产生购买事件每次购买后以概率p判断是否流失。记录观测窗口内每个客户的购买次数 x、最后一次购买时间 tx、观测窗口长度 T以及未来验证窗口的实际购买次数。我设定的参数如下Gamma分布形状参数 r 1.2尺度参数 α 1.5这样客户平均购买率 λ 1.2 / 1.5 0.8也就是平均每周购买0.8次约每1.25周买一次比较符合生鲜用户节奏。Beta分布参数 a 1.0b 2.0这样流失概率 p 1 / (1 2) ≈ 0.33即每次购买后有三分之一的概率流失一年半下来大部分低频客户会流失比较符合生鲜电商的现实。3.2 完整的模拟代码生成逻辑用Python写出来其实不长但有几个细节要说清楚。下面是核心代码import numpy as np import pandas as pd np.random.seed(42) # 生成参数 r, alpha 1.2, 1.5 # Gamma分布参数期望lambda r / alpha 0.8 a, b 1.0, 2.0 # Beta分布参数期望p a / (a b) 0.333 n_customers 1000 T_obs 78 # 观测窗口长度单位周 T_future 52 # 未来验证窗口单位周 # 为每个客户抽取真实参数 lambdas np.random.gamma(r, scale1.0/alpha, sizen_customers) ps np.random.beta(a, b, sizen_customers) # 用于存储每个客户的信息 records [] for i in range(n_customers): lam lambdas[i] p ps[i] # 模拟从0时刻开始的泊松过程直到流失或超过总模拟时间 total_horizon T_obs T_future purchase_times [] t 0.0 alive True while alive: dt np.random.exponential(1.0 / lam) t dt if t total_horizon: break purchase_times.append(t) # 购买后判定是否流失 if np.random.rand() p: alive False purchase_times np.array(purchase_times) observed_purchases purchase_times[purchase_times T_obs] future_purchases purchase_times[(purchase_times T_obs) (purchase_times T_obs T_future)] x_obs len(observed_purchases) # 观测期内总购买次数 tx_obs observed_purchases[-1] if x_obs 0 else 0.0 future_count len(future_purchases) # 未来窗口的实际购买次数 records.append({ customer_id: i, lambda_true: lam, p_true: p, frequency: x_obs, # 观测期内购买次数 recency: tx_obs, # 最后一次购买时间 T: T_obs, # 观测窗口长度 future_buys: future_count }) sim_df pd.DataFrame(records)解释几个容易踩坑的地方。第一np.random.gamma的第一个位置参数是形状参数shape第二个是尺度参数scale。如果我们想让Gamma分布的期望等于 r / α就需要设置shape r, scale 1/α。很多人从R或者Excel迁移过来容易直接把α填进去导致生成的λ均值完全不是预期值。第二泊松过程的首次购买时间生成直接用了指数分布。因为泊松过程的到达间隔服从指数分布所以这里不需要单独处理“首次购买”和“后续购买”统统按照指数间隔来就行。第三alive这个变量很关键。只有当客户在购买后没有流失循环才会继续一旦流失这个客户就永久不产生购买了。而如果下一次购买时间超过了总模拟窗口循环也会退出因为后续时间已经不在我们关心的范围内了。生成完数据之后强烈建议先看一眼数据的分布确认没有明显bugprint(sim_df.describe()) print(零购买客户占比, (sim_df[frequency] 0).mean())我在一次模拟中零购买客户占比大概在5%左右观测期平均购买次数在25次上下。如果零购买客户比例过高比如超过了20%说明λ均值设得太低或者观测窗口太短整个数据集会偏向低活跃客户模型的区分度会很差。4. 参数估计BG/NBD的边际似然到底在最大化什么拿到模拟数据之后下一步就是拿这份数据去拟合BG/NBD模型的四个参数r、α、a、b。这一步是理解模型的关键也是很多人直接调库就跳过的地方。4.1 个人似然的直觉理解想象你正在观察一个客户你记录到他买了x次最后一次购买发生在tx时刻。这个观测结果有两种可能的解释要么这个客户现在还活着只是暂时没买要么这个客户在tx时刻那次购买之后就流失了。BG/NBD的个人似然函数就是把这“两种解释”的概率都算出来再相加。具体来说给定某个客户的λ和p他在观测期内产生“x次购买、最后一次在tx”这个结果的可能性大致由两部分组成客户仍然活跃他在观测期前半段按照购买率λ产生了x次购买并且在最后一段T - tx时间内没有购买。客户已经流失他在最后一次购买后流失了这样自然也解释了为什么后面没再出现。然后我们不能只盯着单个客户的λ和p因为λ和p在人群里有差异分别服从Gamma分布和Beta分布。所以个人似然还要对λ和p在整个分布范围上做积分最后得到一个只和r、α、a、b有关的边际似然。把所有客户的边际似然取对数再加起来就是我们要最大化的目标函数。好在lifetimes库已经把整个优化过程封装好了不需要手写复杂的积分。但理解了上面的原理你再看fit的结果就不会觉得那四个参数是黑箱了。4.2 用lifetimes拟合参数lifetimes的BetaGeoFitter类可以直接完成拟合。这里有一个需要注意的数据格式约定frequency在库中指的是重复购买次数也就是总购买次数减1recency指的是第一次购买到最近一次购买的时长T是客户年龄也就是从第一次购买到观测窗口结束的时长。所以如果你的原始数据里frequency记录的是总购买次数需要转一下from lifetimes import BetaGeoFitter bgf BetaGeoFitter(penalizer_coef0.0) bgf.fit( frequencysim_df[frequency] - 1, # 转成重复购买次数 recencysim_df[recency], Tsim_df[T] ) print(bgf.params_)我跑了一次模拟拟合出来的参数大概是 r ≈ 1.31α ≈ 1.87a ≈ 0.89b ≈ 1.72。对比一下生成数据时候的真实参数 r 1.2α 1.5a 1.0b 2.0你会发现参数没有完全精确恢复但也没有离谱到完全不可用。这里有个很重要的经验BG/NBD的四个参数之间存在一定的可辨识性问题不同的参数组合可能产生非常接近的购买行为模式所以不必执着于让拟合参数精确等于生成参数。真正该关心的是模型预测出来的结果是否够准。这就像线性回归里可能存在多重共线性时回归系数不稳定但预测值依然稳定一个道理。4.3 为什么不能用观测数据直接估计每个客户的λ和p有一个问题值得单独拿出来说我们模拟的时候明明给每个客户设了真实的λ和p拟合完模型为什么不是直接输出每个客户的λ和p因为从观测数据里你永远没法唯一确定一个客户的λ和p。比如一个客户在78周里买了20次最后一次购买在第70周。他既可能是一个高λ低p的活跃客户最近只是碰巧没买也可能是一个中等λ但在第70周后流失的客户。这两种解释在当前数据下无法区分。BG/NBD的聪明之处在于它不尝试准确估计每个客户的λ和p而是利用人群层面的分布来给出一个条件期望这个期望已经隐含了对“可能活着”和“可能流失”两种情况的概率加权。这种思路在运营中恰恰是够用的因为你不需要精确知道张三还活不活只需要知道这一群用户的总体购买趋势。5. 用模拟数据验证模型效果活跃概率与未来购买预测拟合完参数接下来是整个项目最激动人心的部分用模型输出两个核心业务指标然后拿真实模拟出来的未来数据检验准确度。5.1 计算客户活跃概率第一个指标是“客户当前活跃概率”。lifetimes里一行代码就能算alive_prob bgf.conditional_probability_alive( frequencysim_df[frequency] - 1, recencysim_df[recency], Tsim_df[T] ) sim_df[alive_prob] alive_prob你不需要知道一个客户的λ和p具体是多少只需要根据他过去的购买次数、最近一次购买时间、观测窗口长度就能算出一个0到1之间的活跃概率。分布上很有意思。画个直方图你会看到客户活跃概率呈现明显的两极分化一批客户的活跃概率接近0.9以上通常是那些近期还在购买的高频客户另一批客户的概率接近0.1以下大多是早期买过几次就再也没回来的用户。中间地带的人群反而不多。这个结果跟你直观感受是吻合的活跃客户要么一直活跃要么彻底沉默很少长期处于“半死不活”状态。5.2 预测未来购买次数第二个指标是未来T时期内的期望购买次数。比如预测未来52周每个客户还会下单几次直接调用future_T 52 expected_purchases bgf.conditional_expected_number_of_purchases_up_to_time( future_T, frequencysim_df[frequency] - 1, recencysim_df[recency], Tsim_df[T] ) sim_df[expected_future_purchases] expected_purchases这个指标的业务含义非常直观如果你在做促销预算分配可以把高期望购买次数的用户圈出来做重点召回如果某个用户过去购买很多但活跃概率很低说明他已经“沉睡”了靠普通促销很难唤醒需要更大力度的刺激或者干脆放弃。5.3 分层验证预测到底准不准模型预测出来是一回事准不准是另一回事。因为我们模拟数据里有未来52周的真实购买次数所以可以做一个经典的十分位验证。做法是把客户按照模型预测的未来购买次数从低到高排序分成10组每组100人然后计算每一组在未来52周内实际购买次数的均值再和模型预测的组均值对比import matplotlib.pyplot as plt sim_df[pred_group] pd.qcut(sim_df[expected_future_purchases], q10, labelsFalse) group_stats sim_df.groupby(pred_group).agg( actual_mean(future_buys, mean), pred_mean(expected_future_purchases, mean), n(customer_id, count) ).reset_index() fig, ax plt.subplots(figsize(8, 5)) x group_stats[pred_group] 1 ax.plot(x, group_stats[actual_mean], markero, label实际购买) ax.plot(x, group_stats[pred_mean], markerx, label模型预测) ax.set_xlabel(按预测值分位排序) ax.set_ylabel(未来52周人均购买次数) ax.legend() plt.show()我跑出来的结果从低分组到高分组预测和实际的两条折线基本贴合尤其在低分组和高分组。低分组预测人均未来购买0.2次实际差不多0.2次左右高分组预测人均4.5次实际4.8次左右偏差在可接受范围内。中间几个分组预测略微偏高这属于BG/NBD这种概率模型的正常现象因为模型对活跃客户的区分在中间段本身就更模糊。这个验证方法强烈建议你沉淀成自己的标准模板以后无论换什么数据集都可以用同样的方法快速判断模型是否可靠。如果某一天你发现高分组预测对上了但低分组严重高估那多半是数据里有大量“僵尸用户”没有被模型正确处理或者是时间口径出了问题。6. 实战中绕不开的5个坑与排查技巧代码能跑通只是开始真正的麻烦往往藏在数据口径和模型假设的细节里。下面这几个坑我基本都在实际项目里碰到过逐个拆给你看。6.1 numpy和R的Gamma分布参数容易搞反这是最隐蔽也最常见的一个坑。在R里面rgamma(n, shape, rate)第三个参数是速率rate而在numpy里面np.random.gamma(shape, scale)第二个参数是尺度scale两者是倒数关系。如果从R迁移代码过来不换算生成的购买率λ会整整偏一个量级。解决的办法是在代码里写注释比如scale1.0/alpha并且生成完数据后打印均值检查一下看是否接近 r / α。6.2 frequency的约定不一致lifetimes库里的frequency特指“重复购买次数”不是“总购买次数”。比如一个客户在观测期内总共买了5次那么frequency4。如果你把5直接填进去模型会把所有客户的购买次数都高估1最后的预测值会系统性偏高。生成模拟数据和真实数据处理时都建议统一用frequency total_purchases - 1这个口径避免混淆。6.3 零购买客户必须保留BG/NBD模型的边际似然公式里x0的客户信息并不是噪声而是识别流失分布的重要证据。有些同学在数据处理阶段为了“干净”会把观测期内没有购买的用户删掉这等于人为切掉了Beta分布低端的约束条件拟合出来的流失概率会整体偏高。正确做法是保留这些客户让它的frequency0recency0T照常填入观测窗口长度。6.4 时间单位要统一别混着用lifetimes本身对时间单位并不敏感你用天、周、月都能拟合但前提是你填入的recency和T单位一致同时你预测的future_T也要和它们一致。我见过有人用天做观测窗口预测未来52周购买次数时却填了52结果预测值被严重低估。一个简单校验手段预测未来7天的购买量应该约等于预测未来14天的一半如果不满足这个量级关系大概率是单位混了。6.5 不要过度解读拟合参数开头我提到模拟中真实参数和拟合参数对不齐这其实不是模型坏了而是模型的自然现象。购买序列本身包含的客户异质性信息有限尤其在样本量不大或者观测窗口不够长的时候r、α、a、b四个参数存在明显的折衷空间。所以在给业务方汇报的时候少讲“我们的流失概率均值是0.31”多讲“我们预测未来一个月复购次数超过2次的客户群体是哪些”后者才是模型真正擅长的事情。最后再分享一点我的体会做这段时间的CLV模型模拟我最大的感受是模型本身并不复杂复杂的是搞清楚每个数字在业务里到底意味着什么。BG/NBD给的是一个概率化的客户活跃度视角它不试图回答“这个客户是不是流失了”而是回答“这个客户还活着的概率有多大”。这个视角转换对运营策略的影响其实是深远的——你不再追求给每一个客户贴一个非黑即白的标签而是开始习惯用概率去理解用户状态针对不同概率段设计不同的触达方式。如果你准备在自己项目里落地这套流程我的建议是先把我上面的模拟代码完整跑一遍确认每个输出都理解了再换真实数据。你可以试着把生成参数改成r3、α2、a2、b5看看模型拟合结果会怎么变再看看每个客户的活跃概率分布形状有什么变化。多换几组参数玩几次比单纯看十遍公式都管用。等你对模型的“脾气”熟悉了再用真实数据去建模心里那根弦就稳得多。
返回列表