6.3 无偏性与偏差

截断与轮盘赌都会让图像偏离参考解,但只有一个是错的:maxDepth 硬截断丢掉 (ab)^K 的尾部能量,是偏差;俄罗斯轮盘赌的权重补偿保持期望不变,只放大方差。本节用双板腔把两条曲线分开量,截断偏差从 0.44442655 降到 0.00010173 后被定点地板挡住,轮盘赌标准差从 0.459160 涨到 5.258522。

6.3 无偏性与偏差

6.2 留下两个数字:maxDepth = 1 时图像比参考解暗 22.28%,打开俄罗斯轮盘赌后亮度又偏了 0.40%。两个都是「不准」,但只有一个是错的。实时混合光追那篇用屏幕空间近似加时间累积把噪声抹平(见 实时光线追踪的混合方案 ),代价是它没法告诉你剩下的误差里哪一部分再多跑几帧也消不掉;本节要把这两种误差分开:偏差是系统性的,加样本不会消;方差是随机的,加样本就会降。判据不能只看「和参考解差多少」,必须看「差值随样本数怎么变」。

两种误差的定义

设真值 $I$,估计量 $\langle I \rangle$(随机变量,取决于采样的随机数),$N$ 是样本数。

  • 偏差 $b = E[\langle I \rangle] - I$。与 $N$ 无关。$b \ne 0$ 就是有偏,$N \to \infty$ 时估计量收敛到 $I + b$——一个固定的错值。
  • 方差 $\mathrm{Var}[\langle I \rangle]$。无偏估计量的误差只来自这里,而它按 $1/N$ 下降,所以标准差按 $1/\sqrt{N}$ 下降。

「有偏」和「方差大」是两个正交的维度。一个估计量可以既无偏又方差大(本节的轮盘赌),也可以方差很小却有偏(本节的硬截断)。图像上看不出区别——两者都是「和参考解不一样」。

双板腔:一个能把两条曲线解析化的构型

6.1 的双板腔(两块无限大平行板,反照率 $a$、$b$,板间均匀发射 $E$)在这里派上用场:它的真值 $C = E/(1-ab)$ 可以算出来,而且两种误差都能写成闭式。

硬截断只统计前 $K$ 次到达,估计量是

$$ \langle C \rangle_K = \sum_{k=0}^{K-1}(ab)^k E, \qquad b_K = \frac{C - \langle C \rangle_K}{C} = (ab)^K $$

偏差与样本数完全无关,只由反照率乘积和截断阶数决定。

俄罗斯轮盘赌在每次到达之后(本节的 rrStart = 2)以概率 $p$ 存活;存活时把权重除以 $p$。单次到达的期望贡献因此不变:

$$ E[\text{到达}] = p\cdot\frac{w}{p} + (1-p)\cdot 0 = w $$

但权重的平方期望涨了:$E[\text{到达}^2] = p\cdot(w/p)^2 = w^2/p$。多弹一次就多乘一个 $1/p$ 因子,于是方差随 $p$ 减小而膨胀。当 $p < (ab)^2$ 时,权重平方的期望发散——期望仍然存在(无偏),方差却不存在。这个阈值是本节最可断言的一条:

$$ p_{crit} = (ab)^2 = 0.444427^2 = 0.197516 $$

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_06_3_rr_walk.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_06_3_rr_walk.py
"""6.3 最小复现:把「截断」与「轮盘赌」两件事分开量。

双板腔 a = b = 170/255、E = 1,真值 C = E/(1 − ab) = 1.799942。
  · 截断到 K 项:估计量少了尾部 Σ_{k≥K}(ab)^k·E,相对偏差 = (ab)^K,**不随样本数下降**。
  · 轮盘赌存活 p:每次到达后以概率 p 存活并把权重除以 p。期望不变(无偏),
    但方差随 p 减小而膨胀;p < (ab)² = 0.197516 时期望仍存在而方差**发散**。
两组数字都从同一条 SplitMix64 整数流里跑出来。
"""

import os
import sys

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

ONE = cglib.ONE
WALKS = 200000
PS = [32768, 16384, 8192, 4096]


def truncation(ab, e, exact, kmax=16):
    """截断到 K 项(k = 0..K−1)的估计值与相对偏差。"""
    rows = []
    acc = 0
    thr = ONE
    for k in range(kmax):
        acc += cglib.fmul(thr, e)
        thr = cglib.fmul(thr, ab)
        rows.append((k + 1, acc, (exact - acc) / exact))
    return rows


def main():
    a = b = ub.C8(170)
    e = ONE
    exact, ab, rr_rows = ub.plate_experiment(a, b, e, PS, WALKS, 20261016)
    abf = ab / ONE
    print("== 6.3 无偏性与偏差 ==")
    print(f"双板腔 a = b = 170/255;ab = {abf:.6f};真值 C = E/(1−ab) = {exact / ONE:.6f}")
    print(f"{'截断 K 项':>9} {'估计值':>12} {'相对偏差':>14} {'(ab)^K':>14}")
    for k, est, bias in truncation(ab, e, exact, 16):
        if k in (1, 2, 4, 8, 16):
            print(f"{k:>9} {est / ONE:>12.8f} {bias:>14.8f} {abf ** k:>14.8f}")
    print(f"轮盘赌(每个 p 跑 {WALKS} 次独立游走,同一批种子):")
    print(f"{'存活概率 p':>10} {'经验均值':>12} {'经验标准差':>14} {'最大值':>14}")
    for p, mean, std, mx in rr_rows:
        print(f"{p / ONE:>10.4f} {mean:>12.6f} {std:>14.6f} {mx:>14.4f}")
    print(f"方差发散阈值 (ab)² = {abf * abf:.6f}"
          f"(p = 0.125 / 0.0625 已在阈值之下,方差不存在)")


if __name__ == "__main__":
    main()

实跑输出:

== 6.3 无偏性与偏差 ==
双板腔 a = b = 170/255;ab = 0.444427;真值 C = E/(1−ab) = 1.799942
   截断 K 项          估计值           相对偏差         (ab)^K
        1   1.00000000     0.44442655     0.44442749
        2   1.44442749     0.19751443     0.19751579
        4   1.72970581     0.03902137     0.03901249
        8   1.79711914     0.00156831     0.00152197
       16   1.79975891     0.00010173     0.00000232
轮盘赌(每个 p 跑 200000 次独立游走,同一批种子):
    存活概率 p         经验均值          经验标准差            最大值
    0.5000     1.800495       0.459160         4.6189
    0.2500     1.798277       1.166232       180.5572
    0.1250     1.791037       2.636715       352.1297
    0.0625     1.774285       5.258522      1323.1475
方差发散阈值 (ab)² = 0.197516(p = 0.125 / 0.0625 已在阈值之下,方差不存在)

曲线一:截断偏差有地板

截断 $K$ 项估计值相对偏差理论 $(ab)^K$
11.000000000.444426550.44442749
21.444427490.197514430.19751579
41.729705810.039021370.03901249
81.797119140.001568310.00152197
161.799758910.000101730.00000232

$K = 1, 2, 4$ 三行的实测与理论差在 0.1% 以内,$(ab)^K$ 这条等式成立。$K = 8$ 分开(实测 0.00156831 对理论 0.00152197),$K = 16$ 分得更开(0.00010173 对 0.00000232,差 44 倍)。

分开的原因和 6.1 里是同一个:Q16.16 的 $1 - ab$ 偏大,真值 117961/65536 本身就带 3.2e-5 的相对误差,而 $(ab)^{16}$ 只有 2.3e-6——理论曲线已经钻到定点算术的分辨率以下了。这条偏差曲线因此不是「无限下降」,而是降到一个地板就停住。

真正要记住的是它不随样本数变化:把每个 $p$ 的游走次数从 20 万加到 200 万,这些数字一位都不会动。这就是「有偏」的含义。

曲线二:轮盘赌标准差无上限

存活概率 $p$经验均值经验标准差最大值
0.50001.8004950.4591604.6189
0.25001.7982771.166232180.5572
0.12501.7910372.636715352.1297
0.06251.7742855.2585221323.1475

四个 $p$ 的经验均值都在真值 1.799942 附近(最大偏离 1.43%),无偏性成立。但标准差从 0.459160 涨到 5.258522(11.5 倍),最大值从 4.6189 涨到 1323.1475(286 倍)。

最大值这一列的膨胀比标准差更值得看:$p = 0.0625$ 时,200000 次游走里出现了一条权重累积到 1323 的路径。它的贡献是「一次幸运的连续存活」,权重被 $1/0.0625 = 16$ 连乘放大了。这条路径在物理上完全合法——它就是「光子连续弹射了七八次才被吸收」——但它的权重远大于平均值,于是方差被少数几条这样的路径主导。

$p = 0.125$ 与 $p = 0.0625$ 都在阈值 0.197516 之下,它们的理论方差是无穷大。经验标准差仍是有限数,是因为 20 万次游走采不到那条无穷长的尾部;换一批种子,这两个数字会明显跳动,而 $p = 0.5$ 那一行几乎不动。

灯箱:两条误差曲线的形状

双板腔是解析的,但它是确定性的——只有到达次数在变,没有逐像素的噪声。把同一组对照搬到 6.2 的灯箱上(32×24,参考解取 maxDepth=64、RR p=0.5、768 spp,平均辐射亮度 33970.39、每条路径 2.7689 次求交):

配置每像素样本数平均亮度相对参考解求交/路径
截断 maxDepth=4(RR 关)3232554.27−4.168713%4.4482
截断 maxDepth=4(RR 关)12832606.97−4.013558%4.4489
截断 maxDepth=4(RR 关)51232527.82−4.246552%4.4503
轮盘赌 maxDepth=64(RR p=0.5)3234297.80+0.963795%2.7784
轮盘赌 maxDepth=64(RR p=0.5)12834049.96+0.234222%2.7703
轮盘赌 maxDepth=64(RR p=0.5)51233927.56−0.126100%2.7695

两条曲线的形状完全不同:

  • 截断组是一条水平线。 样本数从 32 加到 512(16 倍),误差在 −4.17%、−4.01%、−4.25% 之间来回,没有下降趋势。这 4% 就是 maxDepth=4 丢掉的那部分间接光。加样本只会让这条水平线更精确地停在 −4% 上。
  • 轮盘赌组按 $1/\sqrt{N}$ 下降。 +0.963795% → +0.234222% → −0.126100%:样本数翻两番,误差大致减半(0.96 → 0.23 是 4 倍,0.23 → 0.13 是 1.9 倍),且符号会变——这正是随机波动的特征。样本数够多时它会继续往 0 收。

两者还顺带给出了代价对比:截断组每条路径 4.45 次求交,轮盘赌组 2.77 次。轮盘赌组不仅更准,还更快——因为 maxDepth=64 的硬截断在封闭盒子里要追完所有长路径,而轮盘赌用概率提前砍掉了它们,且不引入偏差。

判据:怎么区分「有偏」和「方差大」

从这两组曲线可以提炼出可操作的判据:

观察结论
误差随样本数下降且符号变化无偏,纯方差,加样本即可
误差是水平线、符号不变有偏差,加样本无用,必须改算法
误差随参数(如 maxDepth)单调收缩到 0参数截断型偏差,放宽参数可修
标准差随参数(如 $p$)爆炸、最大值远超均值方差发散前兆,参数已越过阈值

6.2 里那个 −0.397915% 属于第一行:它随样本数下降、符号可正可负,是方差不是偏差。而 maxDepth=4 的 −4% 属于第二行:它不会因为多跑几小时而变小。

P 层:游走序列逐位复现

TypeScript 侧复用同一套 fmul / fdiv / rngQ16,把前 16 次游走、截断表与轮盘赌统计量全部重算一遍:

// file: lantern/core/test/cg_trace_06_3_rr_walk.test.ts
// tier: P
// verify: node --test 'core/test/cg_trace_06_3_rr_walk.test.ts'
// 6.3 的最小 P 复现:同一批整数游走序列,TypeScript 侧必须给出与 Python 侧相同的
// 截断表与轮盘赌统计量。「截断有偏」与「轮盘赌无偏但方差大」是两条独立曲线。

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

import { ONE } from "../src/lantern/fixed.ts";
import {
  c8, plateExact, plateExperiment, plateWalkSeries, truncatePlate, varianceThreshold,
} from "../src/lantern/cg_trace_06_3_unbiasedness_bias.ts";

const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const doc = JSON.parse(
  readFileSync(join(root, "out", "cg_trace_06_3_walks.json"), "utf8"),
) as {
  aQ16: number; bQ16: number; eQ16: number; rrStart: number; maxArrivals: number;
  seed: number; count: number;
  walks: Record<string, { values: number[]; arrivals: number[] }>;
};
const py = JSON.parse(
  readFileSync(join(root, "out", "cg_trace_06_3_bias.json"), "utf8"),
) as {
  twoPlate: {
    varianceThresholdP: number; walksPerP: number;
    truncation: { K: number; relBias: number }[];
    roulette: { surviveQ16: number; meanQ16: number; stdQ16: number; maxQ16: number }[];
  };
};

const a = c8(170);
const b = c8(170);
const e = ONE;
const { ab, exact } = plateExact(a, b, e);
const PS = [32768n, 16384n, 8192n, 4096n];

test("【核心】四个 p 下的前 16 次游走值与到达次数逐字节相同", () => {
  assert.equal(Number(a), doc.aQ16);
  assert.equal(Number(exact), 117961);
  assert.equal(exact, 117961n);
  for (const p of PS) {
    const got = plateWalkSeries(a, b, e, p, doc.rrStart, doc.maxArrivals, doc.seed, doc.count);
    assert.deepEqual(got.values.map(Number), doc.walks[String(p)].values);
    assert.deepEqual(got.arrivals, doc.walks[String(p)].arrivals);
  }
});

test("【核心】截断偏差逐位一致:K = 1/2/4/8/16 与 (ab)^K 分离", () => {
  for (const row of py.twoPlate.truncation) {
    const got = truncatePlate(ab, e, exact, row.K);
    assert.equal(got.relBias.toFixed(8), row.relBias.toFixed(8));
  }
  const k1 = truncatePlate(ab, e, exact, 1).relBias;
  const k16 = truncatePlate(ab, e, exact, 16).relBias;
  assert.equal(k1.toFixed(8), "0.44442655");
  // K = 16 时理论 (ab)^16 = 2.32e-6,实测停在 1.02e-4:定点噪声设了地板
  assert.equal(k16.toFixed(8), "0.00010173");
  assert.ok(k16 > 40 * Math.pow(Number(ab) / 65536, 16));
});

test("【核心】轮盘赌无偏但方差膨胀:均值贴 1.8,标准差涨 11.5 倍", () => {
  const got = plateExperiment(a, b, e, PS, py.twoPlate.walksPerP, doc.seed);
  for (let i = 0; i < PS.length; i += 1) {
    const want = py.twoPlate.roulette[i];
    assert.ok(Math.abs(got[i].mean - want.meanQ16) < 1e-3);
    assert.ok(Math.abs(got[i].std - want.stdQ16) < 1e-3);
    assert.ok(Math.abs(got[i].max - want.maxQ16) < 1e-4);
    assert.ok(Math.abs(got[i].mean - 1.799942) < 0.036, "均值始终无偏");
  }
  assert.ok(got[3].std / got[0].std > 11);
  assert.ok(got[3].max / got[0].max > 280);
});

test("方差发散阈值 (ab)² = 0.197516 落在 p = 0.25 与 p = 0.125 之间", () => {
  const th = varianceThreshold(ab);
  assert.equal(th.toFixed(6), py.twoPlate.varianceThresholdP.toFixed(6));
  assert.ok(16384 / 65536 > th);
  assert.ok(8192 / 65536 < th);
});
$ node --test 'core/test/cg_trace_06_3_rr_walk.test.ts'
✔ 【核心】四个 p 下的前 16 次游走值与到达次数逐字节相同
✔ 【核心】截断偏差逐位一致:K = 1/2/4/8/16 与 (ab)^K 分离
✔ 【核心】轮盘赌无偏但方差膨胀:均值贴 1.8,标准差涨 11.5 倍
✔ 方差发散阈值 (ab)² = 0.197516 落在 p = 0.25 与 p = 0.125 之间

最硬的一条是游走序列:四个 $p$ 各 16 次游走的值与到达次数都逐项相同(assert.deepEqual 比整数数组)。到达次数也要对,是因为它记录了拒绝采样与轮盘赌各消耗了几次 RNG——序列对而次数不对,说明某处的 rngQ16 调用次序被重排过,后续会全面错位。

本节的位置

到这里,6.2 的两个「不准」被彻底分开了:硬截断有偏、加样本不消;轮盘赌无偏、方差会涨且当 $p < (ab)^2$ 时发散。剩下的问题是怎么把方差压下来——同样的无偏估计量,换一个采样分布就能让方差差出几个数量级。下一章从 pdf 与权重的关系讲起,再给出余弦加权、光源采样与多重重要性采样三种降方差手段。

阅读导航:上一节:6.2 路径采样 · 下一节:7.1 概率密度与权重 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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