1. 从“只给最优参数,不给误差”的拟合痛点说起
做科学计算和数据分析的人,大概率都经历过这样一个阶段:手里有一组观测数据,心里有一个参数化模型,想根据数据反推模型参数。早期大家用scipy.optimize.curve_fit解决,输入数据和函数形式,吐出几个最优参数和协方差矩阵,完事。但这套流程在真实数据面前有几个很麻烦的边界,一旦踩到就让人头大。
第一个麻烦是没法加先验知识。比如你明明知道某个参数的物理意义决定了它不可能为负,但在curve_fit里处理这种约束就得靠参数变换或者硬编码边界,写起来非常绕。第二个麻烦是它对似然面的假定太强。curve_fit给出的误差基于一个隐含假说:似然函数在最优值附近长得像一个高斯山包,协方差矩阵就是这座山的曲率。一旦你的似然面出现强相关性、长尾、甚至双峰,这个误差估计就和实际对不上号了。第三个麻烦是它只能给你一个点估计,无法告诉你参数分布的长相,更没法做模型比较。
所以当这类需求出现时,我一般会转向emcee。emcee是Python生态里最经典的MCMC采样库,核心功能非常纯粹:你给它一个能计算“某组参数下模型与数据合理程度”的函数,它返回一大堆按照这个合理程度分布采样的参数组合。有了这堆样本,参数最优值、置信区间、参数间相关性、边缘分布形态全都能估计出来。这篇东西我不打算写官方文档式的API罗列,而是尽量把一个真实项目里emcee要用的功能、参数、坑和判断方法讲透,适合刚接触MCMC、想尽快上手处理参数估计和不确定性分析的人参考。
1.1 最大似然拟合的边界在哪里
先用自己的话复述一下为什么curve_fit不够用。curve_fit本质上是在最大化一个高斯似然,输出结果是最大似然估计,以及由似然函数曲率近似出来的参数协方差。在二维参数空间里,这等于在你的最大似然点附近套了一个椭圆,用它去描述参数的不确定性。如果真实问题的参数分布差不多就是个椭圆,那这套近似没问题;但现实数据往往不配合:参数之间高度相关时椭圆会拉成一条细窄的带状,而当似然面有不止一个峰时,你在本地峰附近套的椭圆完全忽略了另一个峰,置信区间就严重失真。
举一个实际例子。拟合一条直线y = mx + b,如果x的数据范围很窄,斜率m和截距b在参数平面里就是一条几乎沿着对角线方向拉长的细条分布。此时curve_fit给出的两个参数误差单独看可能都不大,但如果你用这对参数随机撒点画一堆直线,会发现它们在高x区域发散得很厉害,因为m和b的联合分布不是独立椭圆。MCMC这类采样方法则是直接在完整参数空间里随机游走,把真实分布的形状一点一点“画”出来,而不是用一个椭圆去强行近似,这正是它在这种场景下的优势。
当然,MCMC不是一个优化器,不要指望它像L-BFGS那样快速找到某个局部最优。它对每个参数组合算的是一个概率值,然后据此决定接受还是拒绝,最终产出的是一大堆样本点,这些样本点的密度反映了后验概率密度。这类方法的定位是“给参数分布画像”,而不是“找一个最好答案”。
1.2 MCMC要回答的问题和emcee的定位
MCMC要回答的核心问题,用最直白的话说就是:在给定数据和模型的前提下,哪些参数组合更可信?它不直接告诉你“最优解是哪一组”,而是给你一组从后验分布里抽取的样本。后验概率密度高的区域,样本点自然就密;概率密度低的区域,样本点就稀。所以当你拿到样本后做直方图、做百分位数、画等值线,都是在描述这个分布本身。
在众多MCMC实现里,emcee是入手门槛相对低的一个。它的设计思路和传统Metropolis-Hastings不同,采用多个walker协作采样,几乎不需要用户手动调提议分布宽度,而且对参数相关性的容忍度高。这种特性让它在天文学、物理学、生物学、金融建模等领域被广泛使用,因为这些问题里的参数往往天然强相关。emcee的入口只有一个核心类EnsembleSampler,功能边界很清晰:做采样、存链、算自相关时间、支持断点续跑。你不需要了解太多马尔可夫链理论的细节就能跑起来,但如果你完全不理解它在干什么,后面排查问题时就会一头雾水。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. emcee不是又一个Metropolis采样器:仿射不变集合采样原理
很多人第一次接触MCMC都是从Metropolis-Hastings开始的:从一个初始点出发,用一个高斯分布生成候选点,计算接受率,然后决定是否移动。这个方案看着简单,但一旦遇到参数空间是那种狭长的“山谷”形状,就知道有多痛苦了。高斯提议分布的宽度需要人工反复调试:太窄了,步子小,采样器在局部区域磨蹭,样本高度自相关;太宽了,候选点大部分落在低概率区域,被拒绝的概率高,走不动路。更麻烦的是,如果参数维数升高,这种随机游走的低效问题会被放大。
2.1 传统Metropolis-Hastings的先天不足
用一个生活类比来解释这个缺陷。想象你在一个很长的山脊上散步,目标是尽可能多地走到山脊的各个地方。如果每次迈步的幅度是固定的,那步伐太小时你半天走不了多远,步伐太大时你又总是跨到山脊外面去。更糟糕的是,如果山脊的宽度在不同地方不一样,你没法找到一个统一的步长同时适配所有区域。传统Metropolis-Hastings的提议分布就是这个“步长设置”,它在高维、强相关、多尺度的问题里几乎必然遇到调参地狱。
emcee的思路完全不同。它运行的不是一个采样器,而是一组采样器同时工作。这一组采样器里的每个个体称为一个walker,它们共享目标分布,但在参数空间里各自独立地游走。更新某个walker时,不是基于固定的高斯提议分布,而是从这一组walker里随机挑一个其他个体作为参照,沿这两个点的连线方向生成候选位置。这样就天然实现了“步长自适应”:在狭窄区域,walker之间的距离小,跨步也自动变小;在宽阔区域,walker散布开,跨步自动变大。你不需要为每个问题重新调提议宽度,这是emcee上手体验好的根本原因。
2.2 stretch move:让一组walker互相拉拽
emcee默认使用的移动方式叫stretch move。具体来说,假设当前要更新的walker在位置X,随机从集合里挑另一个walker在位置Y。新的候选点X'按如下规则生成:
X' = Y + z * (X - Y)
其中z是一个随机变量,取值满足一定的分布,默认控制参数a=2决定这个分布的宽度。从几何上看,X'就是落在X和Y连线延长线上的一个随机点。如果z取值接近0,候选点靠近参照walker的位置;如果z取值大于1,候选点越过当前walker继续向前;z也可以取负值,让候选点翻到参照点的另一侧。
这个移动方式好在哪? 它完全不需要你提供一个提议协方差矩阵。整个提议过程是纯几何的,依赖的是当前这一组walker的空间分布。当这组walker已经大致铺满目标分布区域时,新候选点天然落在目标区域附近,接受率保持在一个合理的水平。当某个walker暂时陷入一个低概率区域时,拉伸移动可以基于其他walker的位置一次跳出困境。实际跑下来,这种协作机制和传统单链式MCMC相比,在强相关问题上的效率提升是数量级的。
2.3 仿射不变性意味着什么
“仿射不变”这个词听起来抽象,但理解它只需要一个关键概念:仿射变换,即旋转、缩放、平移这类线性变换的组合。如果一个采样算法是仿射不变的,意味着无论目标分布在这个变换下被拉成什么形状,算法的采样效率都不会变差。
为什么这对MCMC很重要?因为在很多实际问题里,参数之间存在强相关,联合分布就是一条被拉伸得很厉害的椭圆带。如果用传统Metropolis采样,这个拉伸方向就是最大的障碍。但emcee的stretch move因为只依赖两个walker之间的相对位置和连线方向,所以当整个参数空间被线性拉伸时,整个walker集合和他们的相对位置也跟着一起拉伸,拉伸前后的更新过程在数学上是等价的。这意味着你不必为每个具体问题重新设计提议分布,不必研究参数间的相关结构,直接跑就行。
当然,这不是说emcee在所有情况下都表现完美。如果参数空间是那种极度扭曲的流形(比如脊线是弯的),或者后验分布有多个彼此分离很远的峰,emcee仍然可能遇到困难,因为它在峰之间的跳跃能力有限。后面讲调优时我会提到应对思路,但在这之前,先把一个端到端的例子跑通是最重要的。
3. 用emcee拟合线性模型:从对数似然到后验分布
理论讲太多没有意义,直接上一个最小可用示例。这里以线性拟合为例子,因为它足够简单,又完整覆盖了emcee的所有核心操作步骤:定义对数概率、初始化walker、运行采样、提取样本。
3.1 定义对数概率函数的三段式写法
使用emcee时,你要提供给EnsembleSampler的入口是一个函数,输入参数向量theta,输出该参数组合下的对数概率值。一般习惯上把函数拆成三部分:对数似然、对数先验、总对数概率。这么做是为了代码结构清晰,也方便调试各部分。
对数似然的写法取决于你的误差模型。假设观测噪声是独立高斯分布,且标准差已知,那么对数似然就是:
python复制import numpy as np
import emcee
np.random.seed(42)
true_m = 2.0
true_b = 0.5
true_sigma = 0.3
x = np.linspace(0, 10, 30)
y = true_m * x + true_b + np.random.normal(0, true_sigma, size=x.size)
yerr = np.full_like(x, true_sigma)
def log_likelihood(theta, x, y, yerr):
m, b = theta
model = m * x + b
return -0.5 * np.sum(((y - model) / yerr) ** 2)
这个函数里没有任何归一化常数,因为MCMC只需要知道概率密度的相对大小,常数项不影响采样结果。然后写先验。如果对参数范围有合理判断,可以用均匀先验,在边界内返回0,边界外返回负无穷:
python复制def log_prior(theta):
m, b = theta
if -10.0 < m < 10.0 and -10.0 < b < 10.0:
return 0.0
return -np.inf
最后组合成总对数概率。注意必须先检查先验是否有限,再计算似然,否则可能出现极大值导致undefined:
python复制def log_probability(theta, x, y, yerr):
lp = log_prior(theta)
if not np.isfinite(lp):
return -np.inf
return lp + log_likelihood(theta, x, y, yerr)
这块有一个值得强调的细节:浮点数的返回类型必须严格是float,不能返回数组。EnsembleSampler会并行对每个walker调用这个函数,如果函数内部不小心返回了一个向量,运行时会出现难以理解的报错。
3.2 初始化walkers的两种方式和踩坑提醒
需要有一个初始位置集合,形状必须是(nwalkers, ndim)。两种常见做法:
第一种,先在最大似然或最大后验估计附近加微小扰动。这是最推荐的方式,因为起点已经落在高概率区域,burn-in阶段就短。比如先用scipy.optimize.minimize快速得到一个粗略最优解,然后在此最优值上叠加小的高斯噪声:
python复制from scipy.optimize import minimize
nll = lambda *args: -log_likelihood(*args)
initial_guess = np.array([1.0, 1.0])
soln = minimize(nll, initial_guess, args=(x, y, yerr))
nwalkers = 32
ndim = 2
initial = soln.x + 1e-4 * np.random.randn(nwalkers, ndim)
第二种,从先验分布里直接采样。这个方法适合你完全不知道最优解在哪、先验范围又足够宽松的情况。注意起点如果落在先验边界上,可能导致部分walker一开始就在无效区域,需要更长的burn-in。无论用哪种方式,初始状态的标准差不要太大,否则一些walker会从概率极低的区域开始,拖慢整体收敛。
一个常见新手坑是直接把初始状态写成一行数组,比如initial = [1.0, 1.0],结果EnsembleSampler报维度错误。必须确保形状是二维的,即(nwalkers, ndim)。可以用initial = np.array([1.0, 1.0]) + 0.01 * np.random.randn(nwalkers, ndim)避免这个问题。
3.3 运行采样与提取后验:get_chain的理解
初始化采样器并运行链:
python复制sampler = emcee.EnsembleSampler(
nwalkers, ndim, log_probability,
args=(x, y, yerr)
)
sampler.run_mcmc(initial, 5000, progress=True)
run_mcmc第二个参数是采样步数。每走一步,每个walker会产生一个新的参数组合,所以最终链的形状是(nsteps, nwalkers, ndim),也就是(5000, 32, 2)。这个三维数组的三个轴分别代表:第几步、哪个walker、哪个维度。需要注意的是,run_mcmc默认会从当前采样器的状态继续跑,而不是重新开始。 如果你重复执行run_mcmc,它会接着上次的链继续延伸,而不是从头再来。如果希望重新初始化,需要重新创建采样器或手动重置状态。
提取链用get_chain:
python复制flat_samples = sampler.get_chain(discard=500, flat=True)
discard=500表示丢弃前500步作为burn-in,flat=True把三维数组压平成二维数组,此时行数为(5000-500) * 32,列数是2,每一行是一组参数样本。有了这些样本,就可以用corner库画后验分布图了:
python复制import corner
fig = corner.corner(
flat_samples,
labels=["m", "b"],
truths=[true_m, true_b],
quantiles=[0.16, 0.5, 0.84],
show_titles=True,
)
corner图是MCMC后处理的标准可视化方式:对角线上是每个参数的边缘后验分布直方图,非对角线上是两个参数的联合分布等值线。看到样本椭圆把真实参数值包在中间,基本就能确认采样效果不错。
4. 采样结果好不好,不靠“跑得多”来判断
很多人第一次跑emcee,跑了5000步就开始拿着链做统计。这不一定错,但有几个更可靠的判断方法,比单纯看步数有用得多。
4.1 自相关时间tau:衡量独立样本的效率指标
MCMC链里相邻样本之间存在相关性,这是采样机制本身决定的。比如你这次接受了一个新位置,这个位置大概率离上一次的位置不远,所以前后两个样本不是独立的。自相关时间(autocorrelation time,用tau表示)描述的是:平均需要间隔多少步,才能得到一个与当前样本近似独立的新样本。
emcee提供了现成方法:
python复制tau = sampler.get_autocorr_time()
print(tau)
如果tau大,说明提议分布虽然被接受了,但链整体移动慢,有效独立样本数远小于链的总长度。有效样本数大致为N_eff = nsteps / tau。比如链长5000,tau是100,那有效样本数只有50个,这意味着你的后验分布是用50个独立样本画出来的,统计噪声会很大。
一个经验规则是:总链长至少应该是tau的50倍以上,否则后验估计的误差不可忽略。如果get_autocorr_time()报错,提示链太短无法估计tau,这本身就是一个警告,说明你跑得还不够。
4.2 burn-in判断:链的均值趋于平稳
burn-in是指chain从初始位置逐步到达高概率区域的那一段。这一段样本严重受初始点影响,不能用于后验统计。判断burn-in是否结束有一个很实用的办法:把链分成几段,分别计算每个参数在每一段上的均值、方差,看是否趋于稳定。
可以写一个简单检查:
python复制chain = sampler.get_chain()
for i in range(ndim):
# 把链按walker平均
param_series = chain[:, :, i].mean(axis=1)
seg_len = len(param_series) // 5
means = []
for s in range(5):
seg = param_series[s * seg_len:(s + 1) * seg_len]
means.append(seg.mean())
print(f"dim {i} segment means:", np.round(means, 4))
如果前几个区段的均值明显漂移,后几个区段趋于水平,就可以把漂移段全部记为burn-in。如果全程都在漂移,说明链远未收敛,需要继续跑。
4.3 链尾分布对比:最朴素的收敛验证
还有一个更直观的方法:把链的后半段再分成两半,分别画这两半的边缘后验分布,叠在一起看是否基本重合。如果两段分布形态差异很大,说明还没有收敛到稳定状态;如果差异很小,说明后验估计已经稳定。
这个方法虽然简陋,但很可靠,而且特别适合向没有统计背景的合作者展示。我自己在项目里基本都会做这个检查,也会把对比图存下来作为提交报告时的佐证。注意R-hat这类诊断指标在emcee这种ensemble采样器里也能算,但需要把32个walker当作32条独立链,再比较组间方差和组内方差,操作起来比上面这个分段对比麻烦,实际排查时先看链尾分布对比就够了。
5. emcee调优实战:nwalkers怎么定、先验怎么写、高维怎么起步
跑通示例后,下一步就是把emcee用到自己的真实数据上。这里整理几个高频决策点和调优方向。
5.1 nwalkers、nsteps、thin的合理配置
先给一张速查表,是我实际使用中比较稳的起点配置:
| 参数 | 建议范围 | 说明 |
|---|---|---|
| nwalkers | max(32, 2 * ndim) 到 100 | walker太少,集合间协作信息不够,收敛慢;太多则总计算量变大,但每步的并行开销也可能变大 |
| nsteps | 先跑短链估算tau,再按tau的50倍起步 | 不要第一次就盲目跑10万步,先用2000步试跑 |
| discard | 根据trace plot和分段均值判断 | 一般丢弃总链长的10%到30%,但如果初始化很差,可能需要更多 |
| thin | 默认不thin | 现代观点认为thinning会丢信息,除非你有存储压力 |
emcee实际上不推荐thinning,因为MCMC样本本身就是按概率密度分布的,自相关性只是让相邻样本之间存在冗余,但整体来说,全部样本仍比只取其中一部分更接近目标分布。只有在链特别长、内存吃紧时才考虑取子集。
关于moves参数,如果默认的StretchMove表现不佳,可以考虑混合多种移动方式。例如:
python复制from emcee import moves
sampler = emcee.EnsembleSampler(
nwalkers, ndim, log_probability,
moves=[
(moves.StretchMove(a=2.0), 0.8),
(moves.DEMove(), 0.2),
],
args=(x, y, yerr),
)
这里每个move后面的权重表示使用该移动方式的概率。DEMove是差分演化移动,基于两个其他walker的差值作为更新方向,在某些分布形态下比拉伸移动更有效。如果某个参数经常卡在边界,DESnookerMove也能作为补充。但不要一上来就堆各种移动,先用默认配跑通,再按需增加。
5.2 先验编码的几种形态与隐患
先验设计是贝叶斯分析的核心,也是emcee使用中最容易出问题的地方。均匀先验写法前面已经给了,适合参数取值范围明确、边界外物理上不成立的场景。但要注意:均匀先验范围内如果存在某一区域概率密度突变,采样器可能在边界附近表现异常。比如先验在边界处从0突然跳到常数,这会形成一个“悬崖”,部分walker可能长时间卡在边界外不敢进入,或者反复在边界附近试探。
另一种常用的是高斯先验,对应参数有一定先验期望和不确定度:
python复制def log_prior(theta):
m, b = theta
mu_m, sigma_m = 2.0, 0.5
mu_b, sigma_b = 0.0, 2.0
lp_m = -0.5 * ((m - mu_m) / sigma_m) ** 2
lp_b = -0.5 * ((b - mu_b) / sigma_b) ** 2
return lp_m + lp_b
高斯先验的好处是平滑,不会在边界处制造跳变。写高斯先验时建议把方差设得比直觉大一些,因为过强的先验会主导后验,让数据几乎没有发言权。判断方法很简单:比较一下有先验和无先验(或宽先验)时的后验差异,如果差异非常显著,说明先验可能太强了。
还有一类细节:如果某个参数是“尺度参数”(比如噪声标准差sigma),通常应该在对数空间里采样,因为sigma只能为正且跨数量级变化。这时不要直接在theta里放sigma,而是放log_sigma,然后在概率函数里用exp(log_sigma)取回真实值。这能显著改善采样效率。
5.3 高维参数空间初始化:从最优值附近或从先验中采样
参数维度从2维升到10维以上,MCMC的收敛速度会明显变慢,初始化策略变成了成败关键。最稳妥的做法是先用优化算法寻找最大后验估计,再在这个点附近做微小扰动来初始化walker。这样所有walker都从高概率区域出发,burn-in段非常短。
具体步骤是:比较复杂的模型,可以先跑几轮优化,用不同起点得到多个局部最优解。在这些局部最优解附近撒walker,甚至可以从多个局部最优中平均选择。如果某个局部最优对应的概率明显低于其他,可以少放几个walker在那个区域。注意初始化扰动的幅度要小,避免walker直接掉到低概率区。
如果连优化器都找不到合理解,那就需要检查模型设定是否出了问题,比如参数化方式是否合理、似然函数是否有bug。这时候再继续优化采样设计没有意义,根因通常在模型本身。
6. 运行中会遇到的典型问题和排查路径
最后一节写排查经验。emcee跑起来后报错或结果异常的情况不少,这里按“现象 -> 排查 -> 解决”的路径写几条高频问题。
6.1 链死活不混合:检查参数相关性和刻度
现象:运行几千步后,每个walker基本在初始位置附近小幅振动,某些参数的trace plot看起来像一条细毛线,而不是上下抖动的“毛毛虫”。
排查路径:
第一步,先画出trace plot。emcee保存了完整的链,用sampler.get_chain()取出来,逐个维度画线。如果某些维度的线在不同walker之间彼此分离,各自形成一束,可能说明后验分布存在多个分离的峰,或者初始位置分布过宽,walker没能跨过低概率区。
第二步,检查参数之间的标度差异。比如一个参数量级在1e-6,另一个在1e6,stretch move虽然对线性变换具仿射不变性,但仍然会受这种极端标度差异影响。解决办法是对参数做重参数化,比如把1e-6的那个参数记为log10(x)。
第三步,检查log_probability函数在参数空间各处是否真的能返回合理值。一个常见bug是函数内部某个数值计算在特定参数组合下产生NaN,此时np.isfinite检查能拦住,但如果你返回了NaN而不是-np.inf,采样器的行为会变得不确定,链自然无法正常混合。建议在正式运行前,手动随机采样几百组参数,逐一调用log_probability,确认输出都是有限值或合理拒绝。
6.2 自相关时间报错或无穷大:链长不够
现象:调用sampler.get_autocorr_time()时抛出autocorr.AutocorrError,提示链太短或自相关时间近似无法可靠估计。
这个报错本身不是bug,而是诊断工具在告诉你:当前链长不足以支撑可靠的统计推断。解决方法是把nsteps增加到至少现在估计值的50倍以上。如果已经跑了很多步仍然报错,那说明采样效率很低,需要回到上一节看参数刻度和先验设置。
一个小技巧是先用一个临时的小链(比如1000步)跑一次,打印出sampler.acceptance_fraction,这个值是接受率。emcee在接受率0.2到0.5之间表现较好。如果接受率很高(高于0.8),说明步子太小,链移动慢;如果很低(低于0.05),说明步子太大,大部分提议被拒绝。可以通过调整StretchMove的a参数来改变步长,a越大步长越大,a越小步长越小,一般范围取1.5到3。
6.3 用HDFBackend断点续跑:长任务的务实方案
真实项目里采样一个复杂的后验分布经常要跑几小时甚至一晚上,如果中途服务器断掉、内存溢出,链就白跑了。emcee提供了backends.HDFBackend,可以让采样过程实时写入h5文件,支持断点续跑。
python复制import os
import emcee
from emcee.backends import HDFBackend
backend = HDFBackend("mcmc_result.h5", name="chain")
if os.path.exists("mcmc_result.h5"):
backend.reset(nwalkers, ndim)
sampler = emcee.EnsembleSampler(
nwalkers, ndim, log_probability,
args=(x, y, yerr),
backend=backend,
)
# 如果已经跑了部分链,从当前位置继续
old_nsteps = backend.iteration
sampler.run_mcmc(initial, nsteps, progress=True) # 内部会自动从上次状态继续
注意run_mcmc在传入initial时会判断当前backend是否已有迭代记录。如果backend里已经有链,但你在run_mcmc里重新传了一个initial_state,它会用新的初始状态接上,这可能导致链产生不连续。所以断点续跑时传initial_state=None,让采样器自动从backend保存的最后状态继续。这个细节很容易被忽略,我踩过之后就固定这么写了。
HDFBackend还有一个附带好处:h5文件在采样过程中同步更新,所以即使采样还没结束,也可以另开一个Python进程读取当前已完成的链,实时监控收敛情况。对大项目来说,这个功能几乎必不可少。
最后分享一个实际体会:emcee的很多功能设计得很顺手,但真正跑一个真实项目时,采样前的模型定义和参数化工作占七八成时间,真正运行采样反而简单。如果你发现怎么调emcee都不收敛,先别怀疑采样器,回头审视一下似然和先验的编码,大概率问题出在那里面。
