如何通过 ceffyl.Sampler 的 PTMCMC 结果计算 Bayes evidence(logZ)

 

本文档面向把 ceffyl.Sampler 输出的(并行退火)MCMC 链转换成 Bayesian evidence(边际似然,$\log Z$)的场景。
适用于:likelihood / prior 与(例如 UltraNest)nested sampling 使用 同一套 pta.get_lnlikelihoodpta.get_lnprior(或 prior transform)时,对两种方法得到的 $\log Z$ 进行一致比较。


1. 核心概念与常见误区

1.1 Evidence 的定义

Bayesian evidence(边际似然)定义为:

\[Z \equiv p(y) = \int p(\theta)\, p(y\mid\theta)\, d\theta = \int p(\theta)\, L(\theta)\, d\theta\]

常用对数形式:

\[\log Z = \log \int p(\theta)\, L(\theta)\, d\theta\]

其中:

  • $p(\theta)$ 是 归一化的 prior 密度(否则 $\log Z$ 会差一个常数)
  • $L(\theta)$ 是 likelihood

1.2 为什么“只有一条冷链”算不出 TI evidence

热力学积分(Thermodynamic Integration,TI)需要一族 power posterior

\[p_\beta(\theta\mid y) \propto p(\theta)\,L(\theta)^\beta,\quad \beta\in[0,1]\]

其中 $\beta$ 是逆温度($\beta = 1/T$)。TI 的公式是:

\[\log Z = \int_0^1 \left\langle \log L(\theta)\right\rangle_\beta\, d\beta\]

这里 $\langle \cdot \rangle_\beta$ 表示在 $p_\beta(\theta\mid y)$ 下取期望。

重要: 若输出里只有一条 $\beta=1$(冷链,$T=1$)的链,那么只有一个点 $\left\langle \log L\right\rangle_{\beta=1}$,无法完成 $[0,1]$ 的积分,因此 TI evidence 无法从单链得到

常见误区:人为补一个 $\beta=0$ 点并令 $\langle\log L\rangle_{\beta=0}=\langle\log L\rangle_{\beta=1}$,会让积分退化成
\(\int_0^1 \text{常数}\, d\beta = \langle\log L\rangle_{\beta=1}\) 得到的只是 后验下的平均 log-likelihood,不是 evidence。


1.3 用公式解释 “mean(logL) 比 logZ 大”的原因

当 prior 归一化时,有恒等式:

\[\log Z = \mathbb{E}_{p(\theta\mid y)}[\log L(\theta)] - D_{\mathrm{KL}}\!\left(p(\theta\mid y)\,\|\,p(\theta)\right)\]

由于 $D_{\mathrm{KL}}\ge 0$,因此:

\[\log Z \le \mathbb{E}_{post}[\log L]\]

这解释了:当 TI 被误算成 $\mathbb{E}_{post}[\log L]$ 时,数值往往会比 nested sampling 的 $\log Z$ 更不负(更大),并且二者差值正好对应 KL 信息增益(Occam 因子)。


2. 让 ceffyl.Sampler 产出多温度链(Parallel Tempering)

ceffyl.Sampler 底层使用 PTMCMCSampler。多温度链通常依赖 MPI 并行:启动多少个 MPI 进程,就对应多少条温度链。

2.1 编写采样脚本 run_pt.py

下面脚本展示了最小可用配置:指定 Tmax,并启用 writeHotChains=True 写出热链文件。

# run_pt.py
from ceffyl.Sampler import Sampler

# 需要保证 pta_NG15 已在脚本里正确构造
# 例如:pta_NG15 = ...

outdir = "./mcmc2"

sampler = Sampler.setup_sampler(
    pta_NG15,
    outdir=outdir,
    logL=pta_NG15.ln_likelihood,   # 或者 pta_NG15.get_lnlikelihood 的封装
    logp=pta_NG15.ln_prior,        # MCMC 场景使用 ln_prior(不是 prior transform)
    resume=False,
    jump=True,
)

x0 = pta_NG15.initial_samples()

sampler.sample(
    x0,
    int(20_000_000),
    Tmin=1,
    Tmax=1e3,               # 可调:见 2.3 节
    Tskip=100,
    isave=1000,
    thin=10,
    writeHotChains=True,    # 关键:写出热链
)

2.2 用 MPI 启动(16 核 → 16 条链)

mpirun -np 16 python run_pt.py

运行结束后,outdir 中应出现多条链文件(具体命名取决于 ceffyl/PTMCMCSampler 版本),例如:

  • chains_1.txt(冷链)
  • chains_2.txtchains_3.txt …(热链) 或按温度/β 命名的变体。

2.3 Tmax 不是越大越好:如何选择

对固定链数 $N_\mathrm{temp}$,若使用几何温度梯子,相邻温度比大致为:

\[r \approx \left(\frac{T_{\max}}{T_{\min}}\right)^{1/(N_\mathrm{temp}-1)}\]
  • $T_{\max}$ 太小:最热链不够“热”,$\beta_{\min}=1/T_{\max}$ 不够接近 0,TI/SS 在低 $\beta$ 端误差变大
  • $T_{\max}$ 太大:相邻温度差变大,swap 接受率降低,链间交换变差,均值 $\langle\log L\rangle_\beta$ 偏差与方差都可能变大

实践上更好的目标是同时满足:

1) 最热端已经让 $\langle\log L\rangle_\beta$ 对 $\beta$ 的变化 趋于平缓
2) 相邻温度的 swap 接受率 不至于过低(经验上常希望在 $0.1\sim0.3$ 量级)


3. 用 la_forge 读取多温度链并计算 evidence

3.1 读取链(cold + hot)

import la_forge.core as co

outdir = "./mcmc2"
c = co.Core(outdir, burn=0.85, pt_chains=True)

# 参数列名列表
print("params:", c.params)
print("has hot_chains:", bool(c.hot_chains))
if c.hot_chains:
    print("hot chain keys:", list(c.hot_chains.keys())[:10])

hot_chains 为空,通常是:

  • 实际没有写出热链(writeHotChains=False
  • 文件命名不被 la_forge 识别(必要时可用软链接/重命名,让热链文件符合 la_forge 的扫描规则)

4. Evidence 的两种常用估计:TI 与 Stepping-Stone(SS)

4.1 准备:提取 $\beta$ 与 $\langle\log L\rangle_\beta$

import numpy as np
import la_forge.core as co

outdir = "./mcmc2"
c = co.Core(outdir, burn=0.85, pt_chains=True)
lnlike_idx = c.params.index("lnlike")

pairs = []

# 冷链:T=1 -> beta=1
pairs.append((1.0, c.chain[c.burn:, lnlike_idx]))

# 热链:key 常是温度或 beta 的字符串(视版本而定)
for key, ch in (c.hot_chains or {}).items():
    # 常见情况:key 是温度 T
    T = float(key)
    beta = 1.0 / T
    pairs.append((beta, ch[c.burn:, lnlike_idx]))

# beta 升序
pairs.sort(key=lambda t: t[0])
betas = np.array([b for b, _ in pairs])
lnL_series = [x for _, x in pairs]

print("beta_min =", betas[0], "T_max ~", 1.0/betas[0])
print("N_temps =", len(betas))

若 key 实际是 $\beta$ 而非温度 $T$,应改为 beta=float(key)(并去掉 1.0/T)。
一个简单识别方式:若 key 里出现 1.00.3 这类小于 1 的数,通常更像 $\beta$;若普遍大于 1,则更像温度 $T$。


4.2 方法 A:热力学积分(TI)

TI 估计:

\[\log Z \approx \int_0^1 \langle \log L\rangle_\beta\, d\beta\]

用 Simpson 积分(或 trapezoid):

import numpy as np
from scipy.integrate import simpson

# 计算每个 beta 的均值
ElogL = np.array([x.mean() for x in lnL_series])

# (推荐)只对已覆盖的 beta 做积分;不要在缺少高温端时“硬补 beta=0”
logZ_ti = simpson(ElogL, x=betas)
print("logZ (TI, covered beta-range) =", logZ_ti)

4.2.1 低 $\beta$ 端点的处理($\beta\to 0$)

严格的 TI 需要覆盖 $\beta\in[0,1]$。若最小 $\beta_{\min}$ 仍不够小,有三种可选策略:

1) 增大 $T_{\max}$ 或增加链数:让 $\beta_{\min}$ 更接近 0
2) 加入 prior sampling 估计 $\langle\log L\rangle_{\beta=0}$
$\beta=0$ 时,$p_\beta(\theta)\equiv p(\theta)$,因此可从 prior 抽样计算 $\log L$ 并取均值 3) 在小 $\beta$ 区间外推:用最小几条链拟合 $\langle\log L\rangle_\beta$ 在 $\beta\to 0$ 的趋势(通常不如 1/2 稳)

注意:策略 2 要求能从 prior 采样。若已有 nested sampling 的 prior transform(把 $u\sim U[0,1]^d$ 映射到 $\theta$),那是最方便的方式。

下面给出“有 prior transform 时”的 prior 采样估计(示例接口名需按工程实际替换):

import numpy as np

# 假设存在 prior_transform(u) -> theta
# u: shape (d,) in [0,1]
# theta: parameter vector compatible with pta.get_lnlikelihood

def estimate_ElogL_beta0(pta, prior_transform, nsamp=20000, seed=0):
    rng = np.random.default_rng(seed)
    d = len(pta.param_names)
    vals = []
    for _ in range(nsamp):
        u = rng.random(d)
        theta = prior_transform(u)
        vals.append(pta.get_lnlikelihood(theta))
    return float(np.mean(vals)), float(np.std(vals, ddof=1)/np.sqrt(nsamp))

E0, E0_se = estimate_ElogL_beta0(pta_NG15, prior_transform=pta_NG15.prior_transform)
print("E[lnL] at beta=0 ~", E0, "+/-", E0_se)

将 $\beta=0$ 点加入积分(例如把它作为一个额外点插入到 betasElogL 的开头):

from scipy.integrate import simpson
import numpy as np

ElogL = np.array([x.mean() for x in lnL_series])
if betas[0] > 0:
    betas2 = np.r_[0.0, betas]
    ElogL2 = np.r_[E0, ElogL]   # 用 prior 采样给出的 E0
else:
    betas2, ElogL2 = betas, ElogL

logZ_ti_full = simpson(ElogL2, x=betas2)
print("logZ (TI, with beta=0 prior estimate) =", logZ_ti_full)

4.3 方法 B:Stepping-Stone(SS)(常比 TI 更稳)

定义 $\beta_0=0<\beta_1<\cdots<\beta_K=1$。令

\[Z_\beta=\int p(\theta)\,L(\theta)^\beta\, d\theta\]

在分布 $p_{\beta_k}$ 下有:

\[\mathbb{E}_{\beta_k}\!\left[L(\theta)^{\beta_{k-1}-\beta_k}\right] = \frac{Z_{\beta_{k-1}}}{Z_{\beta_k}}\]

因此(若 prior 归一化,$Z_{\beta_0}=1$):

\[\log Z = -\sum_{k=1}^{K} \log \mathbb{E}_{\beta_k}\!\left[L(\theta)^{\beta_{k-1}-\beta_k}\right]\]

用 log-sum-exp 稳定计算:

import numpy as np
from scipy.special import logsumexp

def logZ_stepping_stone(betas, lnL_series, logZ_beta0=0.0):
    # betas: ascending array, should include 1.0; can optionally include 0.0
    # lnL_series[k]: samples of lnL at beta=betas[k]
    betas = np.asarray(betas)
    logZ = float(logZ_beta0)

    for k in range(1, len(betas)):
        db = betas[k-1] - betas[k]  # negative number if betas ascending
        lnL = np.asarray(lnL_series[k])
        # E_{beta_k}[exp(db * lnL)]  (db <= 0)
        log_mean = logsumexp(db * lnL) - np.log(len(lnL))
        logZ += -log_mean

    return float(logZ)

# 如果 betas 不含 0,可把 beta=0 的 prior 采样加入(推荐)
# 例如:betas2 = [0] + betas, lnL_series2 = [prior_lnL_samples] + lnL_series

logZ_ss = logZ_stepping_stone(betas, lnL_series, logZ_beta0=0.0)
print("logZ (SS, covered beta-range) =", logZ_ss)

若能提供 $\beta=0$ 的 prior 采样链(或至少 prior 的 lnL 样本),把它作为第 0 级 stepping stone 会更稳。


5. 结果一致性检查:确认 MCMC 链的 lnlike 与 PTA likelihood 完全一致

为排除 “$\log L$ 定义不一致(差常数/模型不同)” 导致的 evidence 偏差,可抽样对比:

import numpy as np
import la_forge.core as co

outdir = "./mcmc2"
c = co.Core(outdir, burn=0.85, pt_chains=True)

lnlike_idx = c.params.index("lnlike")

# 建议使用 pta.param_names 的顺序与链对齐
pta_names = getattr(pta_NG15, "param_names", None)
if pta_names is None:
    pta_names = [par.name for par in pta_NG15.params]

col_idx = [c.params.index(n) for n in pta_names]

rng = np.random.default_rng(0)
rows = rng.integers(low=c.burn, high=c.chain.shape[0], size=20)

diffs = []
for r in rows:
    theta = c.chain[r, col_idx]
    ll_chain = c.chain[r, lnlike_idx]
    ll_eval  = pta_NG15.get_lnlikelihood(theta)
    diffs.append(ll_chain - ll_eval)

diffs = np.array(diffs)
print("lnlike(chain)-lnlike(pta) mean =", diffs.mean(), "std =", diffs.std())

若均值与标准差都在数值误差量级(例如 $10^{-6}$ 以内),说明两边 likelihood 一致;evidence 差异更可能来自温度覆盖/积分误差/链混合等因素。


6. 不确定度评估:Block Bootstrap 给 TI/SS 一个误差条

对于 TI/SS,常见差异 $0.1\sim0.5$ 很可能来自有限样本与积分离散化。下面对 TI 做一个简单 block bootstrap(对 SS 也可类似处理):

import numpy as np
from scipy.integrate import simpson

def block_resample(x, B, rng):
    x = np.asarray(x)
    n = len(x)
    m = n // B
    x = x[:m*B].reshape(B, m)
    idx = rng.integers(0, B, size=B)
    return x[idx, :].reshape(-1)

def bootstrap_ti(betas, lnL_series, B=50, R=300, seed=0):
    rng = np.random.default_rng(seed)
    betas = np.asarray(betas)
    vals = []
    for _ in range(R):
        E = []
        for x in lnL_series:
            xr = block_resample(x, B, rng)
            E.append(xr.mean())
        vals.append(simpson(np.array(E), x=betas))
    vals = np.array(vals)
    return float(vals.mean()), float(vals.std(ddof=1))

m, s = bootstrap_ti(betas, lnL_series, B=50, R=300)
print("TI logZ ~", m, "+/-", s)

若 bootstrap 标准差接近当前与 nested 的差距(例如 $\sim 0.3$),该差距可能处于 TI 估计本身的不确定度范围内。


7. Troubleshooting 清单

7.1 只看到 chains_1.txt(或仅冷链)

  • 检查是否用 MPI 启动:mpirun -np N python run_pt.py
  • 确认 writeHotChains=True
  • 确认 outdir 中确实出现多条链文件

7.2 la_forge 读不到热链

  • 检查文件命名是否被 la_forge 扫描规则识别
  • 必要时创建软链接/重命名,使其符合 la_forge 的期望(例如 chain_1.0.txt 等变体)

7.3 TI/SS 与 nested 差 $\sim 0.1\sim0.5$

  • 检查低 $\beta$ 端覆盖:$\beta_{\min}$ 是否足够小;最热端 $\langle\log L\rangle_\beta$ 是否趋于平缓
  • 尝试 SS(通常更稳)
  • 计算 bootstrap 误差条,判断差异是否在不确定度范围
  • 检查热链 ESS(高温链混合不足会直接影响 $\langle\log L\rangle_\beta$)

8. 最小工作流程总结

1) MPI 多链采样mpirun -np 16 python run_pt.py,并开启 writeHotChains=True
2) la_forge 读取热链co.Core(outdir, burn=..., pt_chains=True)
3) 提取 betas 与 lnL 序列
4) 先算 SS,再算 TI;必要时加入 prior 采样估计 $\beta=0$ 端点
5) 用 block bootstrap 给误差条,再与 nested 的 $\log Z$ 对比
6) 用样本点回代 likelihood 验证 lnlikepta.get_lnlikelihood 一致


如果这个短文对您有用,请关注赵志超的相关工作,并在有帮助时适当引用: https://inspirehep.net/authors/1608126?ui-citation-summary=true