做贝叶斯模型选择的人,迟早会撞上同一个问题:你的MCMC跑得好好的,采样结果也很稳定,但一旦模型维度本身变成未知参数,标准Metropolis–Hastings算法就会集体失效。我最早接触可逆跳跃马尔可夫链蒙特卡罗采样(RJMCMC)是在一个变点检测任务里——我根本不知道数据里到底有几个变点,只知道“可能有”。当时用固定维度的MCMC反复枚举k=1、2、3……跑完整套流程后,发现不仅计算量大得离谱,而且每次都要重新设计更新机制,遇到稍微复杂一点的模型就完全撑不住。后来花了两周时间把RJMCMC从原理啃到实现,才真正把这类问题跑通。
这篇文章我把RJMCMC从“为什么需要它”到“代码怎么写”讲清楚,重点放在变点检测这个经典场景上,包含完整推导、Python代码片段和调参经验。适合正在做贝叶斯计算、模型选择、变点检测或者有限混合模型的读者,不管你是刚接触MCMC的新人,还是已经写过普通MH但被变维度问题卡住的老手,都应该能从中收获一些可以直接上手的思路。
1. 为什么标准MCMC在变维度问题上集体失效
1.1 细致平衡:维度不同的两个状态根本没有可比性
先回顾一下标准Metropolis–Hastings的接受率长什么样:
[
\alpha = \min\left{1, \frac{\pi(\theta') q(\theta' \to \theta)}{\pi(\theta) q(\theta \to \theta')}\right}
]
这个公式成立有个前提:(\theta)和(\theta')必须在同一个参数空间上。为什么?因为(\pi(\theta))是一个密度函数,它的定义域是当前模型下的参数空间。如果(\theta)在(\mathbb{R}^d)上,(\theta')在(\mathbb{R}^{d'})上,而且(d \neq d'),你根本没有办法直接比较两个密度值——它们生活在不同的测度空间里,就像你不能把“3米长的绳子”和“5公斤重的石头”放在一起比大小。
举个更生活化的例子。想象你在两个房间之间搬家,一个房间只能放5件家具,另一个能放7件。你想用MH决定“要不要搬过去”,但每次移动只允许增减一件家具。问题是:多出来的2件家具从哪里来?如果你直接硬塞,那“搬过去”和“搬回来”的路径根本不对称,细致平衡条件就被破坏了。
在实际应用中,这类问题比比皆是:
- 回归分析里要不要加入某个自变量,模型维度按变量个数变化;
- 有限混合模型不知道有多少个组分,每个组分带来一组参数;
- 变点检测不知道有多少个变点,每个变点带来位置和段参数;
- 隐马尔可夫模型隐状态个数未知;
- 非参数贝叶斯里Dirichlet过程混合模型的近似实现。
它们的共同特征是:模型指示变量管理着“哪些参数存在”,维度本身变成了未知量。标准MCMC在这个框架下没有合法的更新方式,这就是必须引入新算法的原因。
1.2 试过的替代方案为什么都半吊子
很多人第一次遇到变维度问题时,会先尝试一些“看起来能行”的变通方法。我当年也试过,下面这些路子各踩过一遍:
第一种,最朴素的做法:固定k跑MCMC,把每个k的边际似然算出来,再用贝叶斯因子选模型。这个方法思路没错,但实现起来很痛苦。因为边际似然本身就需要用harmonic mean、Laplace近似、bridge sampling之类的技巧去估算,误差一大,模型比较的结果就是错的。而且当k的可能取值很多时,组合数量爆炸,每个k都要单独调试一遍MCMC参数,工程量大得离谱。
第二种,用指示变量乘以参数。比如设一个(z \in {0,1}),让参数变成(z\theta),这样维度表面上保持不变,MCMC好像还可以跑。但这个方案有一个致命问题:当(z=0)时,(\theta)进入后验的“退化区域”,后验分布变得不规则,采样器在0附近来回试探,混合效率极差。更麻烦的是,(z)的改变往往对应一个很大的参数空间跳跃,接受率低得可怜。
第三种,Carlin和Chib提出的伪先验法。这个方法需要为每个模型下的参数人为构造伪先验分布,并要求这些伪先验和后验有足够的重叠。问题在于,构造一个质量好的伪先验本身就是一门玄学,尤其是在模型数量多、参数结构差异大的时候,伪先验设不好,结果就崩了。
说到底,以上方法的核心问题都是:把“维度变化”这个本质困难绕过去了,而没有正面解决。RJMCMC的思路恰恰相反——它专门设计了一套机制来处理维度不匹配的情况,这也正是它能成为变维度采样主流方案的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 可逆跳跃的底层逻辑:用辅助变量搭一座维度之桥
2.1 维度匹配映射:从θ到θ'的桥怎么搭
RJMCMC是Peter Green在1995年提出的,核心思想一句话概括:当参数维度不匹配时,人为引入一个辅助变量,让两边维度对齐,再定义一条可逆的路径连接它们。
假设当前状态是((\theta, k)),维度为(d_k),提议跳到((\theta', k')),维度为(d_{k'})。如果(d_{k'} > d_k),那就生成一个辅助变量(u),它的维度恰好是(d_{k'} - d_k),然后构造一个可逆光滑映射:
[
(\theta, u) \leftrightarrow \theta'
]
这样左边括号里的总维度等于右边参数维度,转移就发生在同一个维度空间内。跳跃完成后,多出来的辅助变量只是搭建桥梁用的,不会出现在最终状态里。
这里需要注意:这个映射不是唯一的,设计自由度非常大。你可以是线性映射、非线性映射,甚至在一些离散维度上直接指定配对规则。这既是RJMCMC灵活的地方,也是实现时最容易出错的地方。经验是用尽可能简单的线性映射,后面会详细讲。
2.2 接受率公式逐项拆解:后验比、提议比、Jacobian
在维度匹配之后,RJMCMC的接受率可以写成:
[
\alpha = \min\left{1, \frac{\pi(\theta', k')}{\pi(\theta, k)} \times \frac{q(\theta' \to \theta)}{q(\theta \to \theta')} \times |J|\right}
]
它比标准MH多了一项(|J|),也就是Jacobian行列式的绝对值。这一项从哪里来?回想一下换元积分:当把对(\theta')的积分变量替换成((\theta, u))时,积分元之间会差一个变换矩阵的行列式。这个Jacobian修正的就是“体积变化”,相当于尺寸转换因子。
逐项拆解一下:
- 后验比:新模型参数空间上的后验密度,相对旧模型参数空间上的后验密度,谁更“重”。
- 提议比:从新状态跳回旧状态的提议机制,与从旧状态跳到新状态的提议机制,两者概率之比。它包含跳跃方式的概率、辅助变量的抽取密度等。
- Jacobian:映射引起的体积伸缩因子。如果映射设计得特殊,也可以恰好等于1,但必须严格推导,不能想当然。
这三个部分缺一不可。实际写代码时,很多人把后验比写得很大,结果接受率还是很低,查了半天才发现是Jacobian算错或者漏了提议比里的某个密度因子。
2.3 birth与death:一对必须严格互逆的孪生操作
RJMCMC里最常用的跨维度操作是birth和death,即增加维度和减少维度。在变点检测中,birth是“加一个变点”,把一段拆成两段;death是“删一个变点”,把两段合并成一段。在有限混合模型中,birth是“加一个组分”,death是“减一个组分”。
这两个操作必须严格配对,互逆性体现在下面几层:
- birth选择的机制(比如随机选一段)必须和death选择的机制(比如随机删一个变点)在概率上形成对应;
- birth生成新参数时抽的辅助变量,在death的反向路径中必须能由新参数确定地反解出来;
- birth接受率里的提议比,和death接受率里的提议比,在公式上应该是对称的。
如果你只写了birth没写death,或者两者配对不齐,细致平衡条件就会被破坏,最终采出来的模型维度分布是错的。这一点怎么强调都不过分。
3. 从公式到代码:变点检测案例的完整实现
3.1 模型设定与模拟数据
下面用变点检测作为完整示例。假设观测数据由若干段组成,每段内部服从正态分布,均值不同,方差固定为(\sigma^2)。变点个数(k)、变点位置(\tau=(\tau_1,\dots,\tau_k))、每段均值(\mu=(\mu_1,\dots,\mu_{k+1}))都是未知的。
模型如下:
- (k \sim \text{Poisson}(\lambda)),截断在(0)到(K_{\max})之间;
- 变点位置(\tau_j)在有效样本点内均匀取值;
- 每段均值(\mu_j \sim N(\mu_0, \tau_0^2));
- 观测(y_i \sim N(\mu_j, \sigma^2)),其中(j)是样本点所属的段。
先构造一组模拟数据。真实情况是400个样本点、3个变点、4段均值分别为2、5、3、1,噪声标准差为0.8。
python复制import numpy as np
np.random.seed(42)
n = 400
true_tau = [50, 150, 300]
true_mu = [2.0, 5.0, 3.0, 1.0]
sigma = 0.8
seg = np.zeros(n, dtype=int)
for j in range(4):
left = 0 if j == 0 else true_tau[j - 1]
right = n if j == 3 else true_tau[j]
seg[left:right] = j
y = np.random.normal(true_mu[seg], sigma)
有了数据以后,就可以写状态和对数后验函数了。
3.2 状态表示与对数后验函数
状态我用一个字典表示,包含变点个数、变点位置数组和每段均值数组:
python复制state = {
"k": 0,
"tau": [],
"mu": [np.mean(y)]
}
对数后验函数是三个部分的加和:先验部分、段均值的先验密度、观测数据的似然密度。为了方便MCMC计算,我常用对数形式,并且省略与参数无关的常数项,因为MH中这些常数会抵消。
python复制from scipy.special import gammaln
def log_gaussian(x, mu=0.0, sd=1.0):
return -0.5 * ((x - mu) / sd) ** 2 - np.log(sd) - 0.5 * np.log(2 * np.pi)
def log_prior_k(k, lam=2.0, K_max=10):
if k < 0 or k > K_max:
return -np.inf
# 截断常数在MCMC中每一步都一样,可以省略
return k * np.log(lam) - lam - gammaln(k + 1)
def log_posterior(state, data, sigma, mu0=0.0, tau0=10.0, lam=2.0):
k = state["k"]
tau = state["tau"]
mu = state["mu"]
lp = log_prior_k(k, lam)
lp += np.sum(log_gaussian(np.array(mu), mu0, tau0))
for j in range(k + 1):
left = 0 if j == 0 else tau[j - 1]
right = len(data) if j == k else tau[j]
seg_data = data[left:right]
lp += np.sum(log_gaussian(seg_data, mu[j], sigma))
return lp
注意,变点位置的先验我在这里省略了常数项(\log(1 / \binom{n-1}{k})),因为它在比较同一(k)的状态时会被抵消,但在birth和death的跨维度比较中,这个常数不会完全抵消,后面接受率公式我会单独处理。严谨的做法是把这个常数写在先验里,但为了代码简洁,我把它归入先验比中人工计算。
3.3 三种move的实现:within、birth、death
完整的RJMCMC采样器循环里,每一步分两种操作:模型内更新(within-model move)和模型间跳跃(between-model jump)。模型内更新就是普通MH,维度不变;模型间跳跃才是重头戏。
模型内更新有两类:更新段均值,以及微调变点位置。维度不变,接受率直接套标准MH公式,这里不展开。
接下来是birth move。当前状态有(k)个变点,算法要尝试增加一个。具体步骤:
