5.1 积分与估计

蒙特卡洛积分把「算一个积分」换成「估一个积分」:无偏估计量的均值收敛到真值,偏差则永远停在那里。本节讲清无偏性成立的条件,逐位核对 SplitMix64 的掩码语义——漏掉 `& RNG_MASK` 会让输出涨到 191 位、Q16.16 采样当场失效;再用 3 分箱卡方量出取模的量化偏置:值域 2^4 时 χ² = 15677,而 64 位拒绝采样只有 1.301。

5.1 积分与估计

前四节的输出都是一个确定的像素值:求交给出 $t$,着色给出 $(r,g,b)$,深度限制把递归截断。从本节起这件事变了——要算的量变成一个积分,而积分的输出是一个带方差的估计值,判据也从"相等"换成"收敛"。实时 GI 那篇把这件事压在屏幕空间里做近似(见 实时全局光照 );本卷要把估计量本身拆到能逐位复现的粒度:估计量怎么写、无偏性靠什么成立、随机数的哪一位决定了它还能不能信。

从积分到估计量

着色方程里到处都是这种量,$f$ 是被积函数(沿某方向的入射辐亮度乘 BRDF 与余弦项),$\Omega$ 是定义域:

$$ I = \int_{\Omega} f(x), dx $$

除了极少数构型,它没有闭式解。蒙特卡洛的做法是把它换成一个估计量,$x_i$ 按概率密度 $p$ 采样:

$$ \langle I \rangle = \frac{1}{N}\sum_{i=1}^{N} \frac{f(x_i)}{p(x_i)} $$

这个式子的全部内容就是:在 $p$ 大的地方少放样本、在 $p$ 小的地方多放大权重。当 $p$ 恰好正比于 $f$ 时每一项都相等,方差为零;当 $p$ 是均匀分布时它就是朴素的平均值。估计量要无偏,只需一个条件:

$$ p(x) > 0 \quad \text{只要} \quad f(x) \neq 0 $$

一旦漏掉一块区域,那个区域对积分的贡献就永久丢失——不是"少算一点",是"再也算不回来"。这就是偏差:$N$ 加到无穷,均值收敛到的是 $\int f,dx$ 的一个固定错误值。方差是随机项,多采样就能压小;偏差是系统项,多采样只会让它更精确地停在错的地方。本节的实跑就围绕这条分界展开:估 $\int_0^1 x^2,dx = 1/3$,正确写法用 $p(x)=1$,把 pdf 故意写错成常数 $1.1$ 就得到一个有偏版本。

SplitMix64:掩码必须显式写出来

采样之前先要有随机数。本卷全程用 SplitMix64——一个纯整数、全 64 位掩码的确定性函数,两侧(Python 的 int 与 TypeScript 的 BigInt)逐位一致。它的核心是四步:state = (state + GOLDEN) & MASK;z = state;两次 z = ((z ^ (z >> k)) * MIX) & MASK;最后 z = z ^ (z >> 31)。

这里有一个语言层面的陷阱:Python 的 int 是任意精度,BigInt 也是,它们都不会自动截断到 64 位。& MASK 必须手写,漏掉一个,乘法就把位宽撑到 191 位。脚本里专门留了一个漏掉掩码的对照版本 splitmix_unmasked,并用它和正确版本跑同一批状态:

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_05_1_estimator.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_05_1_estimator.py
"""5.1 积分与估计:无偏估计量、SplitMix64 的掩码、拒绝采样与取模的偏置。

I = ∫ f(x) dx 没有闭式解时,用 N 个样本的均值去估它:<I> = (1/N) Σ f(x_i)/p(x_i)。
三件事做成可断言的实跑:(1) 掩码——每步混合都要显式 `& RNG_MASK`,Python 的 int 与 JS
的 BigInt 都不自动截断,漏掉掩码后输出 z 涨到 191 位,`z >> 48` 返回 143 位整数,Q16.16
采样当场失效;(2) 无偏性——估 ∫_0^1 x² dx = 1/3,pdf 写错成常数 1.1 后均值收敛到
(1/3)/1.1 并停在那里;(3) 取模的偏置——`% n` 在 n 不整除值域宽度 R 时相对超配
(n − R mod n)/R,R = 2^64 时不可测,把随机数截窄到 8 位、4 位则升到 0.78%、12.5%。

产物:config/cg_trace_05_1_rng.json(采样器定义 + 逐位样本头 + 全量计数,P 层读它复现)
      out/cg_trace_05_1_estimator.json(估计量表、掩码位宽表、直方图与卡方)
"""

import json
import math
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cgtrace  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
SEED0 = 0x00C0FFEE_12345678
STREAM_STRIDE = 1 << 40               # 子流起点间隔,远大于任何一条子流的样本数
P_BIAS = 72090                        # 错的 pdf:1.1 * 65536 = 72090.6 -> 72090
N_LIST = (1, 16, 64, 256, 1024, 4096)
TRIALS = 1024
M_HIST = 2_000_000
N_BINS = 3
RANGES = (4, 8, 12, 16, 64)           # 64 表示「不截窄,直接用 64 位拒绝采样」
SIGMA_X2 = math.sqrt(1 / 5 - 1 / 9)   # x 均匀时 x² 的标准差 = 0.298142…


def stream(index):
    """第 index 条独立子流的起点(index 从 1 开始)。"""
    return (SEED0 + index * STREAM_STRIDE) & cgtrace.RNG_MASK


def splitmix_unmasked(state):
    """**故意漏掉掩码**的 SplitMix64:只保留 `state += GOLDEN`,三次乘法不截断。"""
    state = state + cgtrace.RNG_GOLDEN
    z = state
    z = (z ^ (z >> 30)) * cgtrace.RNG_MIX1
    z = (z ^ (z >> 27)) * cgtrace.RNG_MIX2
    return state, z ^ (z >> 31)


def mask_table(seed, steps=6):
    """逐步对比「有掩码 / 无掩码」:位宽,以及 `z >> 48` 是否还在 Q16.16 的值域内。"""
    rows, sm, su = [], seed, seed
    for k in range(1, steps + 1):
        sm, zm = cgtrace.rng_next(sm)
        su, zu = splitmix_unmasked(su)
        rows.append({"step": k, "maskedBits": zm.bit_length(),
                     "unmaskedBits": zu.bit_length(), "maskedQ16": zm >> 48,
                     "unmaskedQ16Bits": (zu >> 48).bit_length(),
                     "unmaskedInRange": (zu >> 48) < ONE})
    return rows


def estimator_table():
    """无偏 vs 有偏:**同一批样本**喂给两个估计量,故比值不受统计噪声影响。"""
    exact, rows = ONE / 3.0, []
    for N in N_LIST:
        su = sb = 0
        for t in range(TRIALS):
            state = stream(t + 1)
            acc_u = acc_b = 0
            for _ in range(N):
                state, q = cgtrace.rng_q16(state)
                f = cglib.fmul(q, q)                       # f(x) = x²,Q16.16
                acc_u += f
                acc_b += cglib.fdiv(f, P_BIAS)             # 用错的 pdf
            su += acc_u // N
            sb += acc_b // N
        mu_u, mu_b = su / TRIALS, sb / TRIALS
        # 无偏均值的标准误(相对值):σ(x²)/√(N·T) / 真值
        rel_se = SIGMA_X2 / math.sqrt(N * TRIALS) / (1 / 3)
        rows.append({"N": N, "unbiasedMeanQ16": int(mu_u + 0.5),
                     "unbiasedRelBias": round((mu_u - exact) / exact, 6),
                     "unbiasedStdErr": round(rel_se, 6),
                     "unbiasedSigma": round(((mu_u - exact) / exact) / rel_se, 3),
                     "biasedMeanQ16": int(mu_b + 0.5), "ratio": round(mu_b / mu_u, 6),
                     "expectedRatio": round(ONE / P_BIAS, 6)})
    return rows


def histogram(state, n, m, bits=64):
    """bits = 64 时走 `rng_below` 拒绝采样;否则取低 bits 位再取模。"""
    mask, counts, head = (1 << bits) - 1, [0] * n, []
    for i in range(m):
        if bits == 64:
            state, v = cgtrace.rng_below(state, n)
        else:
            state, z = cgtrace.rng_next(state)
            v = (z & mask) % n
        counts[v] += 1
        if i < 256:
            head.append(v)
    return counts, head, state


def chi2(counts, m):
    """卡方统计量:Σ (obs - exp)² / exp,exp = M/n。"""
    exp = m / len(counts)
    return sum((c - exp) ** 2 / exp for c in counts)


def chi2_theory(n, r, m):
    """理论卡方(非中心度):值域 R = n*q + r 时 r 个大箱各占 (q+1)/R。"""
    q, rem, u = *divmod(r, n), 1.0 / n
    return m * (rem * ((q + 1) / r - u) ** 2 / u + (n - rem) * (q / r - u) ** 2 / u)


def chi2_crit(df, z=3.0902):
    """Wilson–Hilferty 近似:卡方分布的 99.9% 分位点。"""
    return df * (1 - 2 / (9 * df) + z * math.sqrt(2 / (9 * df))) ** 3


def main():
    os.makedirs(os.path.join(ROOT, "out"), exist_ok=True)
    mrows, est_rows = mask_table(SEED0), estimator_table()
    head64, s = [], SEED0                      # 前 8 个 64 位原始输出,P 层靠它比对
    for _ in range(8):
        s, z = cgtrace.rng_next(s)
        head64.append(z)

    # 五个值域宽度、同一分箱数、各一条独立子流
    hist = {}
    for i, bits in enumerate(RANGES):
        counts, head, _ = histogram(stream(100 + i), N_BINS, M_HIST, bits)
        hist[bits] = {"counts": counts, "head": head, "chi2": chi2(counts, M_HIST),
                      "theory": chi2_theory(N_BINS, 1 << bits, M_HIST),
                      "over": (N_BINS - ((1 << bits) % N_BINS)) / (1 << bits)}

    crit = chi2_crit(N_BINS - 1)
    # 可检出样本数:M* = 临界值 / (理论卡方每样本)。偏置小于噪声时要多少样本才够。
    for b in RANGES:
        h = hist[b]
        h["detectN"] = None if h["theory"] <= 0 else int(crit * M_HIST / h["theory"])

    # 一份 report 同时落盘到 config/ 与 out/:P 层读前者复现,读者看后者核对。
    # 64 位整数超过 JS 的 2^53,一律以字符串落盘,TS 侧用 BigInt 还原。
    report = {"seed": str(SEED0), "streamStride": STREAM_STRIDE, "M": M_HIST,
              "n": N_BINS, "ranges": list(RANGES), "exactQ16": ONE / 3.0,
              "maskTable": mrows, "head64": [str(v) for v in head64],
              "counts": {str(b): hist[b]["counts"] for b in RANGES},
              "heads3": {str(b): hist[b]["head"] for b in RANGES},
              "estimator": {"nList": list(N_LIST), "trials": TRIALS, "pBias": P_BIAS},
              "estimatorRows": est_rows,
              "estimatorMeans": {"unbiased": [r["unbiasedMeanQ16"] for r in est_rows],
                                 "biased": [r["biasedMeanQ16"] for r in est_rows]},
              "hist": {"M": M_HIST, "n": N_BINS, "crit": round(crit, 4),
                       "rows": [{"bits": b, "counts": hist[b]["counts"],
                                 "chi2": hist[b]["chi2"], "chi2Theory": hist[b]["theory"],
                                 "over": hist[b]["over"], "detectN": hist[b]["detectN"]}
                                for b in RANGES]}}
    for name in ("out/cg_trace_05_1_estimator.json", "config/cg_trace_05_1_rng.json"):
        with open(os.path.join(ROOT, name), "w", encoding="utf-8") as fh:
            json.dump(report, fh, ensure_ascii=False, indent=2)

    print("== 1. 掩码:SplitMix64 的输出位宽与 `z >> 48`(同一条 64 位状态流)")
    for r in mrows:
        print(f"  步 {r['step']}:掩码 {r['maskedBits']} 位 / 无掩码 {r['unmaskedBits']} 位"
              f"|掩码 q16 = {r['maskedQ16']}|无掩码 >>48 = {r['unmaskedQ16Bits']} 位"
              f"|在 [0,65536) 内 = {r['unmaskedInRange']}")
    print(f"  掩码后前 3 个 64 位输出:{[str(v) for v in head64[:3]]}")
    print("== 2. 估计量:∫_0^1 x² dx = 1/3 = 0.333333(每条试验 1024 次)")
    for r in est_rows:
        print(f"  N = {r['N']:>4}:无偏 {r['unbiasedMeanQ16'] / ONE:.6f}"
              f"(相对偏差 {r['unbiasedRelBias']:+.6f},标准误 {r['unbiasedStdErr']:.6f},"
              f"{r['unbiasedSigma']:+.3f}σ)|有偏 {r['biasedMeanQ16'] / ONE:.6f}"
              f"|有偏/无偏 = {r['ratio']:.6f}")
    print(f"  期望比值 1/1.1 = {est_rows[0]['expectedRatio']:.6f}")
    print("== 3. 直方图:n = 3,M = {:,},期望每箱 {:.1f}".format(M_HIST, M_HIST / N_BINS))
    print(f"  χ² 的 99.9% 临界值(df = 2)= {crit:.3f}")
    for b in RANGES:
        h = hist[b]
        tag = "拒绝采样(R = 2^64)" if b == 64 else f"取低 {b:>2} 位"
        dn = "不可检出" if h["detectN"] is None else f"{h['detectN']:,}"
        print(f"  {tag}: 计数 {h['counts']}|实测 χ² = {h['chi2']:9.3f}"
              f"|理论 χ² = {h['theory']:11.4g}|相对超配 = {h['over'] * 100:.6f}%"
              f"|M* = {dn}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

跑一遍(9.98 秒,单线程),输出节选:

== 1. 掩码:SplitMix64 的输出位宽与 `z >> 48`(同一条 64 位状态流)
  步 1:掩码 64 位 / 无掩码 191 位|掩码 q16 = 54087|无掩码 >>48 = 143 位|在 [0,65536) 内 = False
  步 2:掩码 64 位 / 无掩码 192 位|掩码 q16 = 58932|无掩码 >>48 = 144 位|在 [0,65536) 内 = False
  步 3:掩码 64 位 / 无掩码 192 位|掩码 q16 = 59937|无掩码 >>48 = 144 位|在 [0,65536) 内 = False
  步 4:掩码 64 位 / 无掩码 193 位|掩码 q16 = 45597|无掩码 >>48 = 145 位|在 [0,65536) 内 = False
  步 5:掩码 63 位 / 无掩码 193 位|掩码 q16 = 26635|无掩码 >>48 = 145 位|在 [0,65536) 内 = False
  步 6:掩码 64 位 / 无掩码 193 位|掩码 q16 = 57670|无掩码 >>48 = 145 位|在 [0,65536) 内 = False
  掩码后前 3 个 64 位输出:['15224344366058555842', '16587940323163529587', '16870881601195667877']
== 3. 直方图:n = 3,M = 2,000,000,期望每箱 666666.7
  χ² 的 99.9% 临界值(df = 2)= 14.133
  取低  4 位: 计数 [750139, 624774, 625087]|实测 χ² = 15677.242|理论 χ² =   1.562e+04|相对超配 = 12.500000%|M* = 1,808
  取低  8 位: 计数 [672568, 662987, 664445]|实测 χ² =    79.952|理论 χ² =       61.04|相对超配 = 0.781250%|M* = 463,099
  取低 12 位: 计数 [667342, 667220, 665438]|实测 χ² =     3.408|理论 χ² =      0.2384|相对超配 = 0.048828%|M* = 118,553,572
  取低 16 位: 计数 [666572, 665535, 667893]|实测 χ² =     4.190|理论 χ² =   0.0009313|相对超配 = 0.003052%|M* = 30,349,714,502
  拒绝采样(R = 2^64): 计数 [666653, 667332, 666015]|实测 χ² =     1.301|理论 χ² =           0|相对超配 = 0.000000%|M* = 不可检出

读这张表要注意两列。掩码位数恒在 63–64:状态加一个常数,位数本来就可能掉到 63(第 5 步就是),这不是 bug。无掩码位数从 191 涨到 193:三次 64 位乘法每次贡献约 64 位,越乘越长。真正致命的是最后一列——无掩码时 z >> 48 返回的是 143–145 位的整数,而 rng_q16 约定它落在 $[0, 65536)$。于是 fmul(q, q) 的中间量直接炸到 $2^{290}$ 量级,Q16.16 的定点约定当场作废。掩码不是优化,是语义的一部分。

估计量:无偏与有偏的实跑

每条试验(共 1024 条)取一条独立子流——子流起点间隔 1 << 40,远大于任何一条子流的样本数;否则相邻试验的样本会重叠,“1024 条试验的均值"就退化成一次估计。Q16.16 定点下真值是 $65536/3 = 21845.33$:

$N$无偏均值相对偏差标准误偏离/标准误有偏均值有偏/无偏
10.325241−0.0242590.027951−0.868σ0.2956700.909062
160.332352−0.0029460.006988−0.422σ0.3021240.909061
640.331161−0.0065360.003494−1.871σ0.3010410.909060
2560.332504−0.0024820.001747−1.421σ0.3022610.909060
10240.333054−0.0008260.000873−0.946σ0.3027650.909061
40960.333328−0.0000060.000437−0.013σ0.3030240.909060

两列行为完全不同。无偏那列:相对偏差从 −0.024259 掉到 −0.000006,而偏离/标准误始终在 ±2σ 内游走——它没有系统性错误,剩下的全是噪声;标准误严格按 $1/\sqrt{N}$ 缩(0.027951 → 0.000437,$N$ 涨 4096 倍,标准误降 64 倍)。有偏那列:均值从 0.295670 爬到 0.303024,停在那里。加 4096 倍样本只让它更靠近 $0.303030 = (1/3)/1.1$,永远到不了 0.333333。同一批样本给出的比值是 0.909060–0.909062,与理论值 $65536/72090 = 0.909086$ 只差 2.6e-5——差值来自 Q16.16 的截断与逐样本取整,不是统计噪声。这就是"多采样救不了偏差"的量化版本。

拒绝采样为什么不能换成取模

rng_below(state, n) 要返回 $[0,n)$ 的整数。最省事的写法是 z % n,但这是错的。设随机数的值域宽度为 $R$(取低 $k$ 位就是 $R = 2^k$),把 $R$ 个等概率残数分给 $n$ 个箱:前 $R \bmod n$ 个箱各多分到一个残数,于是

$$ \text{相对超配} = \frac{n - (R \bmod n)}{R} $$

$R = 2^{64}$、$n = 3$ 时这个量是 $2^{-63}$ 量级——理论上存在,实际上测不出来。问题在于人们常常先把随机数截窄再用:z & 0xFF 之后取模,$R$ 就从 $2^{64}$ 掉到 $2^8$,相对超配立刻变成 0.78%。脚本用 3 分箱直方图 + 卡方把这条曲线量了出来,$M = 2{,}000{,}000$ 个样本、每条曲线一条独立子流:

值域相对超配(理论)实测 $\chi^2$理论 $\chi^2$判定(临界 14.133)
$2^4$12.500%15677.2421.562e+04拒绝
$2^8$0.781%79.95261.04拒绝
$2^{12}$0.0488%3.4080.2384接受
$2^{16}$0.00305%4.1900.0009313接受
$2^{64}$(拒绝采样)01.3010接受

理论 $\chi^2$ 是"非中心度”:值域 $R = n q + r$ 时,$r$ 个大箱各占 $(q+1)/R$、其余占 $q/R$,把偏离代入 $\sum (p_k - 1/n)^2/(1/n)$ 再乘 $M$。实测与理论严格对应:$2^4$ 那一行 15677 对 15620,$2^8$ 是 79.952 对 61.04。$2^{64}$ 那一行理论值恰好为 0——$2^{64} \bmod 3 = 1$,但那"多分一个残数"摊到 $2^{64}$ 个残数上是 $2^{-63}$,落在浮点精度之外。最后一列 M* 是可检出样本数:$M^* = \text{临界值} \cdot M / \chi^2_{\text{理论}}$,$2^4$ 只要 1808 个样本就能测出偏置,$2^{16}$ 要 3.03e10 个,$2^{64}$ 则永远测不出来。结论不是"取模有害",而是:取模的偏置与值域宽度成反比,所以危险不在取模,在你把值域截窄了。 拒绝采样(lim = MASK - (MASK % n),超出 lim 就重抽)在 64 位值域上把偏置压到不可测,代价是期望重抽次数小于 2 次。第 7 章讲重要性采样时会再遇到这一取舍。

P 层:两侧逐位一致

TypeScript 侧读同一份 config(seed 与 64 位输出都以字符串落盘——它们超过 Number 的 2^53 精度),用同一套整数运算复现全部五组直方图与估计量均值:

// snippet: 取自 lantern/core/src/lantern/cg_trace_05_1_estimator.ts 的掩码对照与取模直方图(逐字摘录,完整文件另含 chi2 与 estimateMeanSquare)
/** **故意漏掉掩码**的 SplitMix64:只保留 `state += GOLDEN`,三次乘法不截断。 */
export function rngNextUnmasked(state: bigint): [bigint, bigint] {
  const s = state + RNG_GOLDEN;
  let z = s;
  z = (z ^ (z >> 30n)) * RNG_MIX1;
  z = (z ^ (z >> 27n)) * RNG_MIX2;
  z = z ^ (z >> 31n);
  return [s, z];
}

/** 第 index 条独立子流的起点(index 从 1 开始)。 */
export function streamStart(seed0: bigint, index: number, stride: bigint): bigint {
  return (seed0 + BigInt(index) * stride) & RNG_MASK;
}

/** 取低 `bits` 位再取模:值域 2^bits。返回计数、前 256 个样本、末状态。 */
export function histTruncated(
  state: bigint, n: number, bits: number, m: number,
): { counts: number[]; head: number[]; state: bigint } {
  const mask = (1n << BigInt(bits)) - 1n;
  const nb = BigInt(n);
  const counts = new Array<number>(n).fill(0);
  const head: number[] = [];
  let s = state;
  for (let i = 0; i < m; i += 1) {
    const r = rngNext(s);
    s = r[0];
    const v = Number((r[1] & mask) % nb);
    counts[v] += 1;
    if (i < 256) head.push(v);
  }
  return { counts, head, state: s };
}

八条断言全绿,其中三条是硬口径:前 8 个 64 位输出逐位相同('15224344366058555842' 起)、五组直方图计数数组逐项相同([750139, 624774, 625087] 等)、六个估计量均值逐位相同([21315, 21781, 21703, 21791, 21827, 21845])。一条负例把掩码摘掉,断言 z >> 48 > 65535 立刻成立,证明这个判据确实有区分力。整套 node --test 跑完 2.8 秒。到这里,“算一个积分"已被拆成三件独立可验证的事:估计量怎么写、随机数怎么保证语义、偏差从哪来——三件事都没涉及"估得准不准”,那是下一节的问题。

阅读导航:上一节:4.3 递归的光线树 · 下一节:5.2 方差与收敛 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配