ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

概率编程与不确定性推理:从贝叶斯推断到业务决策

概率编程与不确定性推理:从贝叶斯推断到业务决策

1. 项目概述:当模型开始承认自己“不知道”

概率编程这个词,乍一听像是一个学术味极重的黑话。但如果我用一句话说清楚它到底在做什么,其实是——让模型在给出预测的同时,也诚实地告诉你它对自己的预测有多没把握。

我最初接触这个方向,是在做一个产品转化率分析的项目。当时用传统的统计假设检验,跑了一轮A/B测试,结论是“B版本显著优于A版本”。但业务方问了两个问题,直接把我问住了:第一,“显著”到底有多显著?第二,如果样本再收集一周,结论会不会翻盘?如果用点估计的思路,这两个问题你根本没法回答,因为点估计天然丢掉了不确定性信息。后来我转向了概率编程和贝叶斯方法,才发现“不确定性的显式建模”这件事,才是实际业务推断里最缺的一环。

该项目标题“基于概率编程的不确定性推理模型设计”,拆开看其实就是三件事:

  • 用概率编程框架(比如PyMC、Stan)来定义模型;
  • 用贝叶斯推断去求解后验分布;
  • 把“不确定性”作为一等公民纳入模型的输出和决策流程。

这套东西适合谁来参考?我认为至少有三类人。第一类是做数据分析和机器学习落地的人,你们手里有不少预测任务,但客户或业务方追问“置信区间”的时候,你们发现传统模型给不出来;第二类是刚学过统计学但被频率学派框架局限住的同学,你们想了解如何把主观先验和数据证据自然地融合;第三类是正在做系统设计的技术负责人,你们关心一个模型在真实环境里如何优雅地处理缺失、噪声、小样本和无数据区域。

我在这篇文章里不会只堆概念。我会从模型设计思路、框架选型、具体实现、踩坑实录四个层面,把一个实际可复现的案例完整走一遍。你会看到我从“数据长什么样”到“后验采样”再到“决策建议”的全过程。更重要的是,我会解释每一步背后的“为什么”。为什么用这个分布做先验?为什么采样器要跑那么多步?为什么后验均值不能直接当预测值用?

2. 整体思路拆解:为什么“不确定性”值得被显式建模

2.1 点估计的天然缺陷,以及贝叶斯学派给出的答案

绝大多数人在学校学的统计学,核心思路是“找一个最好的估计值”。比如线性回归的系数估计、逻辑回归的概率输出,最终我们拿到的是一个具体的数:系数是0.35,预测概率是72%。这种点估计的做法,在样本充足、模型正确时没有大问题。但一旦遇到小样本、特征共线性、模型误设这些真实世界里的常见情况,点估计就会给你一种虚假的自信心。

举个最简单的例子。假设你在测试两种营销文案A和B,A收到了100次曝光点击了5次,B收到了100次曝光点击了10次。频率学派的p-value会告诉你说,B比A好,“具有统计显著性”。但如果你用概率编程的思路,你会得到两个后验分布——A的真实点击率在2%到10%之间有一个高概率区间,B的点击率在5%到17%之间。两个分布有大量重叠,也就是说,A其实并不一定比B差。这个信息是点估计完全无法提供的。

我在动手设计模型时,习惯先把问题分三步想清楚。

  • 第一步是明确“我要推断什么”。是推断一个参数(比如转化率),还是多个参数(比如每个用户群体的转化率),还是更复杂的结构(比如时间序列的趋势项)?
  • 第二步是明确“数据生成过程长什么样”。也就是这块业务的数据,我认为它是怎么产生的。这个环节决定了似然函数的形式。
  • 第三步是明确“我的先验知识是什么”。这个环节决定了后验分布的形状,也是贝叶斯方法区别于频率学派的关键。

这三步走完,模型的大致框架就出来了。接下来需要解决的,就是如何高效计算后验分布。

2.2 概率编程框架怎么选:三种主流方案的对比

概率编程这个词的落地产物,就是一套帮你定义概率模型、并自动跑推断算法的框架。我用过三种,分别是PyMC、Stan和NumPyro。这里直接给出我的选型建议。

框架语言接口推断引擎学习曲线适用场景
PyMCPythonMCMC(NUTS)、ADVI平缓数据分析、教学、快速迭代
StanR / Python(CmdStanPy)HMC、NUTS较陡复杂模型、学术研究、大规模采样
NumPyroPython(JAX)HMC、NUTS、SVI较陡大规模数据、GPU加速

如果项目要求快速出结果,且团队以Python为主,我通常推荐PyMC。它可以用几行代码定义一个回归模型,内置了采样器、诊断工具和可视化,最重要的是它有很好的文档和社区支持。如果模型非常复杂、对采样效率要求很高,我会考虑NumPyro——它基于JAX,可以充分利用GPU并行加速,但在代码复杂度上明显高一个档次。

选框架的核心逻辑很简单:没有最好的框架,只有在你当前场景下最顺手的框架。我自己做过一个对比实验,同一个分层贝叶斯模型,在PyMC和NumPyro上跑相同的采样步数,NumPyro在GPU上是PyMC的8倍速。但如果模型本身只有几百个参数,PyMC在CPU上也只需要几十秒,那点性能差距完全不必纠结。

2.3 不确定性建模的三大效用

我为什么要花这么大的篇幅强调“不确定性”这件事?因为在实际业务中,不确定性建模至少能带来三个层面的价值。

第一个层面是风险量化。你在做业务预测时,如果模型说“下季度销售额是100万”,那是点估计;如果模型说“销售额有80%的概率落在85万到120万之间”,那才是决策可直接利用的信息。后者可以让业务方提前准备多种预案。

第二个层面是数据效率。贝叶斯方法天然适合小样本场景。你可以把历史数据的信息编码为先验,然后结合少量新数据得到后验。比如一个新上线的产品只有几天的数据,如果硬跑频数统计,方差会大到不可用;但如果你把上一代产品的参数分布作为先验,就能得到合理得多的推断。

第三个层面是模型鲁棒性。显式建模不确定性之后,模型对于异常点和缺失值的反应会更加平滑。因为模型知道“这个位置我没把握”,而不是“强行给你一个看似确定的答案”。

3. 核心技术与实操要点:从分布选择到后验采样

3.1 先验分布该怎么选:信息性先验和弱信息先验的权衡

在概率编程的实际建模中,先验分布是新手最容易犯迷糊、也最容易翻车的环节。很多人要么直接用均匀分布这种无信息先验,要么拍脑袋放一个看起来很合理的正态分布。但是我要说,先验不是用来“表决心”的,它实质上在告诉模型:在没有数据的时候,你默认应该相信什么。

有两类常用做法值得掌握。

弱信息先验是目前比较主流的选择。它的特点是不太可能给模型错误的强制约束,但又能提供一个合理的量级范围。例如定义一个回归系数,我不会直接写beta[0] ~ Normal(0, 100),因为方差设得太大其实相当于告诉模型“任何量级的系数都行”,这会导致采样器在探索初期浪费大量步数。我惯用的做法是给一个略微收紧但依然包容的分布,比如Normal(0, 5),或是对标准差用一个HalfNormal(2)。

信息性先验则用于有明确业务经验的场景。比如你要推断一个用户对一个新功能的日活跃率,你之前做过类似功能、数据整体在5%到20%之间波动,那你大可以使用Beta(10, 80)这种分布去居中在11%附近。

不过这里有个关键禁忌:先验不能定得过于自信。我有一个真实的翻车经历:有一次做转化率推断,我把先验设成了Beta(200, 800),均值为20%,但实际数据的转化率只有8%。因为先验的形状非常“尖”,它像一根强力弹簧一样把后验拉向了错误的中心,导致最终模型严重偏离数据证据。后来我吸取的教训是:如果你对先验的把握不是100%,请给先验的方差留足余量。

3.2 似然函数的设计:分布假设和实际业务数据的匹配

似然函数是模型里“数据是如何产生的”这一部分。你在设计似然时,本质上是在描述数据的随机性来源。

最常见的选择是伯努利分布和二项分布。比如分析用户是否点击、是否注册、是否付费,这些“是否”型事件天然适合用伯努利分布建模。如果你收集了一定时间段内的订单数量,那泊松分布是更合适的选择。而连续型指标,例如响应时间、消费金额,则通常用正态分布或学生t分布。

这里有一个人容易忽略的经验:当你的连续数据存在明显的厚尾或者异常值时,优先考虑用学生t分布替代正态分布。学生t分布的尾部更重,对极端值更“宽容”。我处理过一份用户消费金额的数据,极差非常大,有的用户一个月消费几十块,有的消费几万块。如果硬用正态分布建模,那两个大额用户的杠杆作用会毁掉整个均值的估计;而学生t分布自由度比较低时,模型会自动“看淡”这些异常点。

实践中有个比较重要的诊断方式:把后验预测分布画出来,和你真实的观测值做对比。如果观测值大量落在后验预测分布的边缘甚至外面,说明你的似然假设很可能不符合数据生成过程。这个时候不是硬着头皮调采样参数,而是要回头审视你的分布选择。

3.3 后验推断的原理理解:为什么MCMC值得跑那么多步

贝叶斯公式写出来只有一行:后验 = 似然 × 先验 / 归一化常数。但真实世界的模型里,归一化常数通常不可计算,因为它是多维积分。这就是概率编程框架发挥作用的时刻——它们不用算出精确的归一化常数,而是用采样方法从后验分布中抽取样本。

MCMC的原理,可以用一个生活化类比来理解。想象你站在一座山脉的某些位置,山脉的高度就是后验密度。你想知道哪些位置海拔高(后验概率大),但你没有整个山脉的地图。MCMC方法相当于一个探索者,它根据当前位置的海拔高度,不断提出新的移动方向。如果新位置的密度更高,它大概率会接受移动;如果新位置密度更低,它有一定概率也会接受,从而避免困在局部高点无法脱身。跑了很多很多步之后,探索者访问过的位置分布,就近似等于山脉的密度分布。

在实际操作中,我会关注两个参数定义上的细节。第一个是采样步数,一般建议至少跑两到四条链,每条链几千步采样,前面还要留一部分作为预热期(warm-up)丢弃。第二个是采样器的自适应参数,比如NUTS的target_accept_prob。这个参数控制的是每一步被接受的概率。默认值0.8在多数情况下够用,但如果遇到比较曲折的后验分布、采样发散警告很多时,可以把它提高到0.9甚至0.95。代价是采样速度变慢,但换来更可靠的采样结果。

3.4 模型诊断的核心指标:R-hat、ESS和发散警告

模型跑完之后,第一件事不是看结果,而是做诊断。我把诊断当成贝叶斯建模的“安全检查”。

第一个指标是R-hat。这个指标用于评估多链条采样是否收敛。它的理想值是1.0,通常认为低于1.01比较可靠。如果R-hat明显大于1.05,说明链在跑动过程中没有充分混合,后验近似不可信。此时要优先增加预热步数或重新参数化模型。

第二个指标是有效样本量ESS。它描述的是采样样本中包含了多少有效信息。你可能跑了8000步采样,但相邻样本之间高度相关、都在原地打转,实际独立样本量可能只有几百。ESS过低会导致你计算的分位数有比较大的误差。通常会希望核心参数的有效样本量不低于400。

第三个需要盯紧的是发散警告。PyMC在采样结束后会报告有多少次发散转移。如果发散数量过大,一个直接的解决方案是调高target_accept_prob,另一个思路是检查模型参数化方式。曾经有一次我做分层模型,身高和截距的联合后验呈现明显的相关弯月形,导致采样器频繁发散。后来把参数做了中心化处理(改为非中心化参数化),发散立刻消失了。这个经验我非常建议大家遇到过类似问题时优先尝试。

4. 实操过程:从数据清洗到后验输出的完整案例

这一部分,我会用一个模拟业务场景来走一遍全流程。场景是:一家电商平台做了一个新的推荐策略,想知道它到底有没有提升用户点击率,以及提升幅度的置信区间。数据只有两组——对照组和实验组,每组各有一批用户的点击/未点击记录。

4.1 数据准备和探索性分析

我生成了一批模拟的数据,结构非常简单:两列,一列是分组标记,另一列是是否点击。

import pandas as pd import numpy as np np.random.seed(42) n_control = 3000 n_treatment = 2700 control_rate = 0.08 treatment_rate = 0.10 control_clicks = np.random.binomial(1, control_rate, n_control) treatment_clicks = np.random.binomial(1, treatment_rate, n_treatment) df_control = pd.DataFrame({"group": "control", "click": control_clicks}) df_treatment = pd.DataFrame({"group": "treatment", "click": treatment_clicks}) df = pd.concat([df_control, df_treatment], ignore_index=True)

先看一眼各组的基础数据:

df.groupby("group")["click"].agg(["mean", "sum", "count"])

输出大致是这样的:对照组点击率约8.0%,实验组约10.0%。表面上看实验组高了两个百分点。但如果直接宣布胜利,就是犯了用点估计做决策的毛病。我需要知道这两个百分点的差异,到底有多少不确定性。

4.2 在PyMC中构建贝叶斯模型

这个场景的建模思路非常经典:每个组的真实点击率是一个未知参数,我们用Beta分布描述先验,用伯努利分布作为似然。模型代码如下。

import pymc as pm import arviz as az control_data = df[df["group"] == "control"]["click"].values treatment_data = df[df["group"] == "treatment"]["click"].values with pm.Model() as click_model: # 弱信息先验:Beta(2, 20),均值约9%,但方差足够大 p_control = pm.Beta("p_control", alpha=2, beta=20) p_treatment = pm.Beta("p_treatment", alpha=2, beta=20) # 似然 obs_control = pm.Bernoulli("obs_control", p=p_control, observed=control_data) obs_treatment = pm.Bernoulli("obs_treatment", p=p_treatment, observed=treatment_data) # 差异变量:这是营销决策时最关心的量 diff = pm.Deterministic("diff", p_treatment - p_control)

这里有几个地方值得说明。

  • 先验为什么选Beta(2, 20)?因为历史数据告诉我这个位置的点击率在5%到15%之间波动。Beta分布的期望是2/(2+20)≈9.1%,但分布的尾部能覆盖到1%到30%的范围,足够包容。
  • 我定义了一个Deterministic变量diff。它不是一个有先验的随机变量,而是两个参数的一个确定性变换。这个东西在后验采样的每一步都会被计算出来,最终你会得到一个完整的“差异”后验分布。
  • 为什么不直接给diff一个先验?因为差异是根据两个组参数推导出来的,给diff本身一个先验会引入不必要的复杂度,而且容易破坏模型的参数识别。

4.3 后验采样与诊断

模型定义好之后,直接调用采样接口。我通常会设置4条链,每条链1000步预热、2000步采样,然后诊断。

with click_model: trace = pm.sample(draws=2000, tune=1000, chains=4, target_accept_prob=0.9, random_seed=42)

采样结束后,用az.summary检查核心参数:

az.summary(trace, var_names=["p_control", "p_treatment", "diff"])

输出的表格会包含每个参数的均值、标准差、HDI区间和R-hat。我特别关注diff这一行的R-hat,如果小于1.01,说明采样是可靠的。目标实际运行中R-hat通常在1.00到1.001之间,ESS也足够,说明这个简单模型采样很顺利。

这里顺便提一句,target_accept_prob=0.9是我刻意提高的值。对于这种只有两三个参数的简单模型,提上去几乎没有成本,但能有效规避边缘发散。

4.4 结果解读:后验分布的经验价值远大于一个数值

后验采样完成后,我会画三样东西:后验分布直方图、后验预测检查和HDI区间标注。这里我重点讲后验分布直方图能看出什么。

az.plot_posterior(trace, var_names=["p_control", "p_treatment", "diff"], ref_val=0)

你会看到p_control的后验中心大约在8.5%,p_treatment大约在10.5%,diff的后验分布则中心大约在2个百分点。更重要的是HDI区间——假设结果是diff的94% HDI从0.2%到3.8%——这表示我相当确信新策略有一点正面效果,但幅度没那么大,可能只有0.2%的提升。

这个信息比“B组显著优于A组”这种表述有用得多。因为如果业务方告诉我,低于0.5%的差异对营收没有实际意义,那我就可以直接说:模型显示有大约4.5%的概率真实增量低于0.5%,但也有大约95.5%的概率提升超过0.5%。这个概率就是决策者可以直接拿来用的话语。

另一个常见的操作是把后验预测分布和真实数据对比。PyMC提供了pm.sample_posterior_predictive接口:

with click_model: posterior_predictive = pm.sample_posterior_predictive(trace, random_seed=42)

然后可以画观测值的直方图和后验预测的分布对比图。如果两者严重不符,说明模型的似然假设有问题。在这个简单案例中,匹配度通常很好,因为伯努利分布就是点击事件的真实生成机制。

5. 常见问题与排查实录:十几次踩坑换来的经验清单

5.1 采样不收敛:先别急着加步数,检查模型参数化

我见过很多新手在遇到R-hat偏高时,第一反应是把draws从2000加到20000。这种做法有时候有效,但更多时候是在事倍功半。

采样不收敛的根源,往往是后验分布的形状很“恶劣”——比如参数之间存在强相关性,导致采样器在空间中只能慢慢爬行。这种情况下,加步数确实能得到更好的统计量,但时间成本高昂。

我的排查顺序是这样的:先画一下参数的轨迹图(trace plot),如果两三条链的轨迹在跑动中表现出很强的“粘滞感”,我会优先尝试重新参数化。最常见的是把中心化参数改成非中心化。举个直观例子,在一个包含截距和斜率的回归里,如果截距的取值范围是0到100,斜率的取值范围是0到1,它们在后验中很容易形成狭长相关区。这时候把截距拆成“全局均值+偏移量”,可以让采样器在解耦后的参数空间里移动得更顺畅。

5.2 发散警告:提高接受率目标,和检查分布尾端的局部行为

NUTS采样器在模型中后验密度出现极端曲率变化时,容易在局部区域出现数值发散。PyMC会给出警告,打印出发散转移的数量占比。

低数量的发散(比如一万步里有几十个)可以尝试调高target_accept_prob到0.95或者更高。我在实际操作中,发现把接受率提高到0.95后,发散数通常能降一个数量级。如果还要更多的发散,就要考虑是不是模型本身设定有问题了,比如某个参数的先验方差太大,导致探索初期模型进入了一个数值不稳定的区域。

除此之外,还有一个特别容易被忽略但很值得检查的点:变量的尺度。如果某个参数的取值范围是百万级别、而另一个是零点几,那你最好做一下标准化,否则采样器在不同的轴向上需要不同的步长来适应,效率极低。

5.3 先验敏感性问题:做一个简单敏感性分析

贝叶斯模型里的先验不是摆设。业务方或者审稿人经常会问:你的结论对先验有多敏感?这个问题不能只回一句“我用了弱信息先验”就完了。

我在项目里惯用的做法是跑三组对比:第一组用弱信息先验,第二组用更宽的先验(比如把Beta参数缩小十倍),第三组用无信息先验(Beta(1,1))。对比看核心参数的后验均值和HDI区间是否发生了显著偏移。如果三组结果差别不大,说明数据的信息量足够强,先验只是起到了一个“支架”的作用。如果差别明显,你需要警惕:当前的结论很可能是先验和数据的混合产物,而不是纯粹由数据驱动的。

具体到电商案例,我用Beta(1,1)、Beta(2,20)、Beta(20,80)三组先验分别跑了一遍。结果显示,diff的后验HDI没有发生跨越零点的翻转,只是区间宽度略有变化。这说明在这个数据量下,样本已经足够强势,先验的影响被有效稀释。

5.4 后验预测检查异常:数据中存在不可解释的离群点

有时候后验预测检查会告诉你模型整体拟合得不错,但总有那么几个点预测不到——它们观测到的值落在后验预测分布的尾部之外。

这种离群点的出现,通常不是模型的bug,而是真实数据里存在某些结构性因素无法被简单的似然函数捕捉。比如点击率分析中,某些渠道会突然涌入大量机器人流量,导致点击表现异常。处理方案要么是引入协变量(比如渠道类型)扩展模型,要么是在似然函数中加入离群点检测的结构(比如混合分布)。

我在实际建模中,通常会先把离群点单独标注出来做一次报告,而不是直接删掉。因为有时候离群点恰恰是最有价值的业务信号。比如在一次活动分析中我们发现,某天的后端响应时间有一个明显的尖峰,模型整体拟合得很好,就是那个尖峰没预测到。后来排查发现是当天有临时的大规模数据补偿任务,并非系统常态。如果直接删掉这个点,我们就会错过了解这个临时任务的机会。

5.5 常见问题速查表

症状优先排查方向操作建议
R-hat > 1.05采样器混合不足或参数化不良加预热步数、检查轨迹图、尝试非中心化参数化
发散警告频繁NUTS步长适配不佳提高target_accept_prob至0.95、检查先验方差是否过大
ESS过低相邻样本高度相关增加draws、检查是否需要对参数做变换(如中心化)
后验预测与实际偏离明显似然函数分布选择错误改用t分布代替正态、引入协变量、考虑混合分布
先验敏感性过高数据量不足以推翻先验追加样本收集、减弱先验强度、报告敏感性分析结果
模型运行极慢参数空间维度高或采样步数浪费使用更高效的推断方法(如ADVI初始化后接MCMC)、利用GPU加速

6. 延伸与思考:从单模型到多层模型和决策系统

前面讲的案例是单组参数推断,实际项目里很少这么简单。当你把不确定性推理放到更复杂的业务系统中,有几个方向值得深刻理解。

第一个方向是多层/分层模型。假设你有二十个不同的商品品类,每个品类只有几十条点击记录。如果不做任何处理,直接分品类独立建模,每个品类都会得到方差很大的后验。而分层模型让各个品类共享一个“全局分布”,每个品类自己的参数从全局分布中抽取。好处是,数据量少的品类会向全局均值收缩(shrinkage),得到一个更合理的估计;数据量多的品类仍然可以有自己独特的偏移量。这在业务分析中是一个巨大的进步,因为小样本分组在非贝叶斯框架下几乎毫无办法。

第二个方向是将后验分布接入决策系统。概率编程的最终输出不是一个数,而是一个分布,因此天然适合与蒙特卡洛模拟接在一起。例如你可以从后验分布中抽样一万次,每次抽样都跑一遍业务规则(比如模拟库存分配、模拟收益计算),最终得到收益预测的完整分布。这种决策方式远优于“拿点估计算一个数然后基于这个数做决定”。

第三个方向是在线更新。贝叶斯方法的增量学习特性让我特别着迷。前一天的模型后验可以直接作为第二天的先验,实现天然的时间衰减自适应效果。这在新品策略反复迭代的电商场景非常有用——昨天的数据成为今天的先验,今天的后验继续为明天提供基础,模型不需要重训就能持续跟踪真实点击率的变化。

7. 我的一点心得与建议

做了这么多个不确定性推理相关的项目,我最大的体会是:概率编程的价值不在于让模型变得更“聪明”,而在于让模型变得更“诚实”。一个诚实的模型会告诉你,哪些位置它有把握、哪些位置它在瞎猜、哪些地方数据还不够。这种诚实,在实际业务沟通中是极有价值的资产。

有几个建议送给准备上手的朋友。

第一,不要一上来就追求复杂的模型。先用一个最简单的贝叶斯模型把你的数据和业务流程跑通,再逐步加入协变量、层级结构。每一层复杂度都要有足够的业务理由支撑,不要为了炫技去建一个三层嵌套模型。

第二,多花一点时间在后验可视化上。把后验分布的HDI区间、后验预测分布的对比图画好,很多时候比一堆统计指标更能说明问题。业务方可能看不懂R-hat,但一定能看懂“有95%的概率时流失率在8%到13%之间”这句话。

第三,采样器警告不是小事。我看到过很多人看到发散警告后选择忽略它,然后直接分析结果。这样做非常危险,因为发散已经是模型在告诉你:你对后验的近似可能是错的。宁可多花十分钟去调参数化、调接受率,也不要带着一个不可靠的后验去做决策支撑。

这个领域当然还有不少值得继续挖的内容,比如变分推断在大规模场景下的应用、概率编程与强化学习的结合、以及如何用结构化先验表达领域知识。但无论向哪个方向深入,把“不确定性”作为模型的一等公民这个观念,是先要建立起来的。希望这篇文章能让你对概率编程和不确定性推理有一个既具体又实用的认知,可以少走一些我用试错换来的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进