Loss 与 SSIM
损失函数的两项
3DGS 原论文的损失(本课程沿用):
L = 0.8 ⋅ L 1 + 0.2 ⋅ ( 1 − S S I M ) L = 0.8 \cdot L_1 + 0.2 \cdot (1 - \mathrm{SSIM}) L = 0.8 ⋅ L 1 + 0.2 ⋅ ( 1 − SSIM )
像素值归一化到 [ 0 , 1 ] [0,1] [ 0 , 1 ] 后,两项都落在 [ 0 , 1 ] [0,1] [ 0 , 1 ] 区间,量纲一致,0.8 / 0.2 0.8/0.2 0.8/0.2 才有可比性。
L 1 L_1 L 1 是逐像素绝对误差的平均:
L 1 = 1 N ∑ p ∣ I p render − I p gt ∣ L_1 = \frac{1}{N}\sum_{p} \left| I_p^{\text{render}} - I_p^{\text{gt}} \right| L 1 = N 1 ∑ p I p render − I p gt
为什么用 L 1 L_1 L 1 而不是 L 2 L_2 L 2 (均方误差)?L 2 L_2 L 2 对大误差开平方放大惩罚,训练会倾向于”抹平”误差大的区域——边缘和高频纹理正是误差集中处,结果就是画面发糊。L 1 L_1 L 1 一视同仁,不惧怕少数大误差像素,边缘更锐。
SSIM 的三路打分
SSIM(结构相似性,Structural Similarity Index)在局部窗口内比较两张图的三种统计量,对窗口 x x x 、y y y :
S S I M ( x , y ) = 2 μ x μ y + C 1 μ x 2 + μ y 2 + C 1 ⏟ 亮度 l ⋅ 2 σ x σ y + C 2 σ x 2 + σ y 2 + C 2 ⏟ 对比度 c ⋅ σ x y + C 3 σ x σ y + C 3 ⏟ 结构 s \mathrm{SSIM}(x,y) = \underbrace{\frac{2\mu_x\mu_y + C_1}{\mu_x^2+\mu_y^2+C_1}}_{\text{亮度 } l} \cdot \underbrace{\frac{2\sigma_x\sigma_y + C_2}{\sigma_x^2+\sigma_y^2+C_2}}_{\text{对比度 } c} \cdot \underbrace{\frac{\sigma_{xy} + C_3}{\sigma_x\sigma_y + C_3}}_{\text{结构 } s} SSIM ( x , y ) = 亮度 l μ x 2 + μ y 2 + C 1 2 μ x μ y + C 1 ⋅ 对比度 c σ x 2 + σ y 2 + C 2 2 σ x σ y + C 2 ⋅ 结构 s σ x σ y + C 3 σ x y + C 3
μ \mu μ :窗口内像素均值;σ 2 \sigma^2 σ 2 :方差;σ x y \sigma_{xy} σ x y :两图的协方差。
C 1 = ( K 1 L range ) 2 C_1 = (K_1 L_{\text{range}})^2 C 1 = ( K 1 L range ) 2 ,C 2 = ( K 2 L range ) 2 C_2 = (K_2 L_{\text{range}})^2 C 2 = ( K 2 L range ) 2 ,C 3 = C 2 / 2 C_3 = C_2/2 C 3 = C 2 /2 是防零稳定项,标准取 K 1 = 0.01 K_1 = 0.01 K 1 = 0.01 、K 2 = 0.03 K_2 = 0.03 K 2 = 0.03 ,L range = 1 L_{\text{range}}=1 L range = 1 。
结构项 s s s 只看相关性(去掉了均值和方差),这是 SSIM “看得见边缘糊没糊”的来源:亮度对比度都对但纹理错位时,σ x y \sigma_{xy} σ x y 掉得很快。
SSIM 取值 [ − 1 , 1 ] [-1,1] [ − 1 , 1 ] ,越接近 1 越相似,所以损失里用 1 − S S I M 1-\mathrm{SSIM} 1 − SSIM 。原始论文实现用 11 × 11 11\times11 11 × 11 高斯加权窗口;本课程自研训练器卡面标注的是 8 × 8 8\times8 8 × 8 窗口。
容易看错的点
SSIM 可微 。均值、方差、协方差都是像素的平滑函数,整条链可以反向传播——所以它能进损失,不只是评估指标。
μ \mu μ 撞名 。SSIM 公式里的 μ \mu μ 是窗口像素均值,和高斯中心 μ \mu μ 完全是两回事。
0.8/0.2 是经验配比 。出自 3DGS 原论文的设定,不是推导结论;像素项主导收敛方向,结构项纠正”数值对了但糊了”的解。
参考
Adam 的三个状态
更新方程
Adam(Adaptive Moment Estimation)给每个参数槽位单独维护三个状态 m m m 、v v v 、t t t ,每步更新:
m ← β 1 m + ( 1 − β 1 ) g 一阶动量: g 的滑动平均 v ← β 2 v + ( 1 − β 2 ) g 2 二阶动量: g 2 的滑动平均 t ← t + 1 该槽位自己的步数(per-slot) m ^ = m / ( 1 − β 1 t ) , v ^ = v / ( 1 − β 2 t ) 偏差修正 θ ← θ − l r ⋅ m ^ v ^ + e p s \begin{aligned}
m &\leftarrow \beta_1 m + (1-\beta_1)\, g && \text{一阶动量:} g \text{ 的滑动平均}\\
v &\leftarrow \beta_2 v + (1-\beta_2)\, g^2 && \text{二阶动量:} g^2 \text{ 的滑动平均}\\
t &\leftarrow t + 1 && \text{该槽位自己的步数(per-slot)}\\
\hat{m} &= m/(1-\beta_1^t),\quad \hat{v} = v/(1-\beta_2^t) && \text{偏差修正}\\
\theta &\leftarrow \theta - \mathrm{lr}\cdot \frac{\hat{m}}{\sqrt{\hat{v}} + \mathrm{eps}}
\end{aligned} m v t m ^ θ ← β 1 m + ( 1 − β 1 ) g ← β 2 v + ( 1 − β 2 ) g 2 ← t + 1 = m / ( 1 − β 1 t ) , v ^ = v / ( 1 − β 2 t ) ← θ − lr ⋅ v ^ + eps m ^ 一阶动量: g 的滑动平均 二阶动量: g 2 的滑动平均 该槽位自己的步数( per-slot ) 偏差修正
本项目配置 β 1 = 0.9 \beta_1 = 0.9 β 1 = 0.9 ,β 2 = 0.999 \beta_2 = 0.999 β 2 = 0.999 ,e p s = 10 − 15 \mathrm{eps} = 10^{-15} eps = 1 0 − 15 。
偏差修正为什么必须除
m m m 、v v v 从零初始化,是衰减因子 ( 1 − β ) (1-\beta) ( 1 − β ) 很小的滑动平均,开局必然系统性偏小。对梯度近似平稳的情形,滑动平均的期望可以递推出来:
E [ m t ] = ( 1 − β 1 t ) E [ g ] \mathbb{E}[m_t] = (1-\beta_1^t)\,\mathbb{E}[g] E [ m t ] = ( 1 − β 1 t ) E [ g ]
偏掉的因子恰是 1 − β 1 t 1-\beta_1^t 1 − β 1 t ——这就是修正分母的由来,它不是凑出来的,是把初始化偏差精确除回去。t t t 大时 β t → 0 \beta^t \to 0 β t → 0 ,修正自动退场。
为什么”方向除以尺度”就是自适应步长
v v v 估计的是 g 2 g^2 g 2 的水平:梯度长期又大又抖的参数,v ^ \sqrt{\hat{v}} v ^ 大,有效步长被压小;梯度小而稳的参数,步长相对放大。
量纲上 m ^ / v ^ \hat{m}/\sqrt{\hat{v}} m ^ / v ^ 近似无量纲(梯度量级被约掉),所以各参数的步长都在 l r \mathrm{lr} lr 附近——这是 Adam 对 3DGS 这种”位置、旋转、不透明度、SH 系数量级差几个数量级”的参数集合特别合身的原因。
容易看错的点
t 是 per-slot 的 。每个参数槽位各数各的步数,不是全局迭代数。这个区分在致密化动态新增参数时会变成生死问题(见 t=0 的除以零)。
eps 的位置在根号外 :分母是 v ^ + e p s \sqrt{\hat{v}} + \mathrm{eps} v ^ + eps ,不是 v ^ + e p s \sqrt{\hat{v} + \mathrm{eps}} v ^ + eps ;两种写法数值行为不同。
eps 的取值因框架而异 。PyTorch 默认 e p s = 10 − 8 \mathrm{eps}=10^{-8} eps = 1 0 − 8 ,本项目取 10 − 15 10^{-15} 1 0 − 15 ——它只是防除零的兜底,不参与”调节步长”。同名不同值,别和 gradcheck 的差分步长 ε = 10 − 6 \varepsilon=10^{-6} ε = 1 0 − 6 混淆。
参考
手算 Adam 一步
标准递推的逐步核算
取 β 1 = 0.9 \beta_1 = 0.9 β 1 = 0.9 、β 2 = 0.999 \beta_2 = 0.999 β 2 = 0.999 、恒定梯度 g = 0.1 g = 0.1 g = 0.1 、初值 m 0 = v 0 = 0 m_0 = v_0 = 0 m 0 = v 0 = 0 、e p s → 0 \mathrm{eps} \to 0 eps → 0 ,按 m ← β 1 m + ( 1 − β 1 ) g m \leftarrow \beta_1 m + (1-\beta_1)g m ← β 1 m + ( 1 − β 1 ) g 递推:
步 m m m v v v m ^ = m / ( 1 − 0.9 t ) \hat{m}=m/(1-0.9^t) m ^ = m / ( 1 − 0. 9 t ) v ^ = v / ( 1 − 0.999 t ) \hat{v}=v/(1-0.999^t) v ^ = v / ( 1 − 0.99 9 t ) 更新 = l r ⋅ m ^ / v ^ =\mathrm{lr}\cdot\hat{m}/\sqrt{\hat{v}} = lr ⋅ m ^ / v ^ t = 1 t=1 t = 1 0.01 0.01 0.01 1 × 10 − 5 1\times10^{-5} 1 × 1 0 − 5 0.01 / 0.1 = 0.1 0.01/0.1 = 0.1 0.01/0.1 = 0.1 10 − 5 / 0.001 = 0.01 10^{-5}/0.001 = 0.01 1 0 − 5 /0.001 = 0.01 0.1 / 0.1 = 1.0 ⋅ l r 0.1/0.1 = 1.0\cdot\mathrm{lr} 0.1/0.1 = 1.0 ⋅ lr t = 2 t=2 t = 2 0.019 0.019 0.019 1.999 × 10 − 5 1.999\times10^{-5} 1.999 × 1 0 − 5 0.019 / 0.19 = 0.1 0.019/0.19 = 0.1 0.019/0.19 = 0.1 1.999 × 10 − 5 / 0.001999 = 0.01 1.999\times10^{-5}/0.001999 = 0.01 1.999 × 1 0 − 5 /0.001999 = 0.01 1.0 ⋅ l r 1.0\cdot\mathrm{lr} 1.0 ⋅ lr
注意两个状态本身每步都在变(m m m :0.01 → 0.019 → 0.0271 ⋯ 0.01 \to 0.019 \to 0.0271 \cdots 0.01 → 0.019 → 0.0271 ⋯ ),并不存在”梯度没变、m m m 、v v v 原地踏步”的阶段——原地踏步的是修正后 的 m ^ \hat{m} m ^ 、v ^ \hat{v} v ^ 。
恒定梯度下的恒等式
对恒定 g g g ,滑动平均递推可以求出闭式解:
m t = ( 1 − β 1 t ) g , v t = ( 1 − β 2 t ) g 2 m_t = (1-\beta_1^t)\,g, \qquad v_t = (1-\beta_2^t)\,g^2 m t = ( 1 − β 1 t ) g , v t = ( 1 − β 2 t ) g 2
偏差修正恰好把因子除回去:
m ^ t = g , v ^ t = g 2 ⟹ 更新 = l r ⋅ g g 2 = l r ⋅ s i g n ( g ) \hat{m}_t = g, \qquad \hat{v}_t = g^2 \quad\Longrightarrow\quad \text{更新} = \mathrm{lr}\cdot\frac{g}{\sqrt{g^2}} = \mathrm{lr}\cdot\mathrm{sign}(g) m ^ t = g , v ^ t = g 2 ⟹ 更新 = lr ⋅ g 2 g = lr ⋅ sign ( g )
结论:恒定梯度下 Adam 从第一步起每步都恰好走一个 lr ,方向由梯度符号决定,与 ∣ g ∣ |g| ∣ g ∣ 无关。这正体现了”方向除以尺度”的自适应——步长被梯度自身的量级归一化了。
与视频卡面数字的对账
视频卡面把 t = 1 t=1 t = 1 时的 m m m 、v v v 记为 0.100 0.100 0.100 、0.010 0.010 0.010 (即直接取了 g g g 、g 2 g^2 g 2 ),由此得到 m ^ 1 = 1 \hat{m}_1=1 m ^ 1 = 1 、v ^ 1 = 10 \hat{v}_1=10 v ^ 1 = 10 、更新 0.316 ⋅ l r 0.316\cdot\mathrm{lr} 0.316 ⋅ lr ,以及 t = 2 t=2 t = 2 的 0.235 ⋅ l r 0.235\cdot\mathrm{lr} 0.235 ⋅ lr 和”步长先大后稳”的观感。但这组状态值与 m ← β 1 m + ( 1 − β 1 ) g m \leftarrow \beta_1 m + (1-\beta_1)g m ← β 1 m + ( 1 − β 1 ) g 的递推不符:标准递推下 m 1 = ( 1 − β 1 ) g = 0.01 m_1 = (1-\beta_1)g = 0.01 m 1 = ( 1 − β 1 ) g = 0.01 、v 1 = ( 1 − β 2 ) g 2 = 10 − 5 v_1 = (1-\beta_2)g^2 = 10^{-5} v 1 = ( 1 − β 2 ) g 2 = 1 0 − 5 。卡面的 0.1 0.1 0.1 /0.01 0.01 0.01 是 m m m 、v v v 的不动点 (极限值),不是第一步的值;恒定梯度场景里修正后每步都恰为 l r \mathrm{lr} lr ,并不存在收缩过程。偏差修正真正的作用场景是梯度在变的真实训练:开局几步 m m m 、v v v 系统性偏小,不修正会白白浪费 warmup 期的步幅。
验算口径
1 − 0.9 1 = 0.1 1-0.9^1 = 0.1 1 − 0. 9 1 = 0.1 ,1 − 0.999 1 = 0.001 1-0.999^1 = 0.001 1 − 0.99 9 1 = 0.001 ,1 − 0.9 2 = 0.19 1-0.9^2 = 0.19 1 − 0. 9 2 = 0.19 ,1 − 0.999 2 = 0.001999 1-0.999^2 = 0.001999 1 − 0.99 9 2 = 0.001999 ——修正分母收敛很慢(β 2 t \beta_2^t β 2 t 要近千步才明显衰减),这就是 β 2 \beta_2 β 2 取 0.999 0.999 0.999 的含义:二阶统计用约 1 / ( 1 − β 2 ) = 1000 1/(1-\beta_2) = 1000 1/ ( 1 − β 2 ) = 1000 步的窗口估计。
参考
t=0 的除以零
失效链条
偏差修正分母 1 − β t 1-\beta^t 1 − β t 在 t = 0 t=0 t = 0 时精确为零:任何 β 0 = 1 \beta^0 = 1 β 0 = 1 ,于是
1 − β 0 = 0 ⟹ m ^ = m 0 1-\beta^0 = 0 \quad\Longrightarrow\quad \hat{m} = \frac{m}{0} 1 − β 0 = 0 ⟹ m ^ = 0 m
而此刻 m = v = 0 m = v = 0 m = v = 0 (从未更新过),得到 0 / 0 0/0 0/0 。IEEE 754 语义下 0 / 0 = N a N 0/0 = \mathrm{NaN} 0/0 = NaN (不是无穷大,也不是零),且 NaN 具有传染性:它参与的任何算术都得 NaN,下一步 θ ← θ − l r ⋅ N a N = N a N \theta \leftarrow \theta - \mathrm{lr}\cdot\mathrm{NaN} = \mathrm{NaN} θ ← θ − lr ⋅ NaN = NaN ,参数被永久污染,loss 随之变 NaN。
注意分母处的 e p s \mathrm{eps} eps 救不了场:e p s \mathrm{eps} eps 加在更新分母 v ^ + e p s \sqrt{\hat{v}} + \mathrm{eps} v ^ + eps 里,而 v ^ = v / ( 1 − β 2 t ) \hat{v} = v/(1-\beta_2^t) v ^ = v / ( 1 − β 2 t ) 在除到 eps 之前就已经是 NaN 了。
谁会有 t=0
老参数不会 。训练正常起步后,所有槽位第一次 Adam 更新时 t : 0 → 1 t: 0 \to 1 t : 0 → 1 ,此后 t ≥ 1 t \ge 1 t ≥ 1 ,分母 1 − β t > 0 1-\beta^t > 0 1 − β t > 0 。
只有训练中途”刚出生”的参数槽位 。致密化(densification)在迭代中途 clone/split 出新高斯,新槽位的 m m m 、v v v 、t t t 从零开始,如果当步就对它做 Adam 更新而 t t t 还是 0,0 / 0 0/0 0/0 当场上演。静止不变的参数集永远踩不到这颗雷——这正是它隐蔽的原因:不写致密化,测试全绿;一写致密化,第一轮就炸。
为什么”用全局 t”也不是解
一个天真的修法是给所有参数共用一个全局迭代计数。但这样新生槽位的修正分母是 1 − β T 1-\beta^T 1 − β T (T T T 是全局步数,很大),≈ 1 \approx 1 ≈ 1 ,而它的 m m m 只有 ( 1 − β 1 ) g (1-\beta_1)g ( 1 − β 1 ) g 大小——m ^ \hat{m} m ^ 被低估约 1 − β 1 = 10 1-\beta_1 = 10 1 − β 1 = 10 倍,新参数的步长被不合理地放大。t 必须 per-slot:每个槽位按自己走过的步数修正。
实际修法(三件套)
本系列事实底稿记录的修法,三条缺一不可:
born 记法 :新槽位出生迭代记为当前迭代,保证首步更新时 t ≥ 1 t \ge 1 t ≥ 1 (致密化安排在本迭代 Adam 步之前);
per-slot t :每个槽位自己的步数计数;
t≥1 守卫 :更新前强制 t = max ( iter − born + 1 , 1 ) t = \max(\text{iter}-\text{born}+1,\ 1) t = max ( iter − born + 1 , 1 ) ,把任何漏网的 t = 0 t=0 t = 0 兜住。
// adam.comp 中的守卫(语义还原):per-slot 步数,至少为 1
int t = max (iter - bornIter + 1 , 1 );
容易看错的点
“eps 防除零”防的是 v ^ = 0 \sqrt{\hat{v}}=0 v ^ = 0 那一层 ,不是偏差修正分母这层。两个”除零风险”在公式里是两个位置。
NaN ≠ Inf 。β 0 \beta^0 β 0 是精确的 1,1 − β 0 1-\beta^0 1 − β 0 是精确的 0,0 / 0 0/0 0/0 得 NaN;若分子非零才是 Inf。排查 NaN 时沿计算图反向找第一个 NaN 源头,比盯着 loss 曲线有效得多。
gradcheck:数值微分验证解析梯度
中心差分公式
对参数 x x x 把损失各扰动一次,用斜率近似导数:
f ′ ( x ) ≈ f ( x + ε ) − f ( x − ε ) 2 ε f'(x) \approx \frac{f(x+\varepsilon) - f(x-\varepsilon)}{2\varepsilon} f ′ ( x ) ≈ 2 ε f ( x + ε ) − f ( x − ε )
用中心 差分而不用前向差分 ( f ( x + ε ) − f ( x ) ) / ε (f(x+\varepsilon)-f(x))/\varepsilon ( f ( x + ε ) − f ( x )) / ε ,差别在误差阶数。泰勒展开:
f ( x ± ε ) = f ± ε f ′ + ε 2 2 f ′ ′ ± ε 3 6 f ′ ′ ′ + O ( ε 4 ) f(x\pm\varepsilon) = f \pm \varepsilon f' + \frac{\varepsilon^2}{2}f'' \pm \frac{\varepsilon^3}{6}f''' + O(\varepsilon^4) f ( x ± ε ) = f ± ε f ′ + 2 ε 2 f ′′ ± 6 ε 3 f ′′′ + O ( ε 4 )
两式相减,偶次项对消:
f ( x + ε ) − f ( x − ε ) 2 ε = f ′ ( x ) + ε 2 6 f ′ ′ ′ ( x ) + O ( ε 4 ) \frac{f(x+\varepsilon)-f(x-\varepsilon)}{2\varepsilon} = f'(x) + \frac{\varepsilon^2}{6}f'''(x) + O(\varepsilon^4) 2 ε f ( x + ε ) − f ( x − ε ) = f ′ ( x ) + 6 ε 2 f ′′′ ( x ) + O ( ε 4 )
截断误差 O ( ε 2 ) O(\varepsilon^2) O ( ε 2 ) ;前向差分只有 O ( ε ) O(\varepsilon) O ( ε ) 。ε = 10 − 6 \varepsilon = 10^{-6} ε = 1 0 − 6 时中心差分的截断误差约 10 − 12 10^{-12} 1 0 − 12 量级,而前向差分还有 10 − 6 10^{-6} 1 0 − 6 ——这就是多用一次前向换来的两个数量级。
ε 的两难与定案
太大 :O ( ε 2 ) O(\varepsilon^2) O ( ε 2 ) 截断误差,弯的损失面被当直线;
太小 :浮点舍入误差约 δ ⋅ ∣ f ∣ / ε \delta \cdot |f|/\varepsilon δ ⋅ ∣ f ∣/ ε (δ \delta δ 是计算 f f f 的相对噪声),ε \varepsilon ε 越小噪声被放得越大。
最优 ε \varepsilon ε 在两者交叉处。本项目定案(selftest.cpp 源码):一般参数 ε = 10 − 6 \varepsilon = 10^{-6} ε = 1 0 − 6 ,位置参数 ε = 2 × 10 − 6 \varepsilon = 2\times10^{-6} ε = 2 × 1 0 − 6 。
门禁判据
逐参数比较数值梯度与解析梯度:
rel-err = ∣ g num − g ana ∣ max ( ∣ g num ∣ , ∣ g ana ∣ ) < 10 − 3 \text{rel-err} = \frac{|\,g_{\text{num}} - g_{\text{ana}}\,|}{\max(|g_{\text{num}}|,\ |g_{\text{ana}}|)} < 10^{-3} rel-err = m a x ( ∣ g num ∣ , ∣ g ana ∣ ) ∣ g num − g ana ∣ < 1 0 − 3
两个数字各司其职:ε = 10 − 6 \varepsilon = 10^{-6} ε = 1 0 − 6 是差分步长 ,10 − 3 10^{-3} 1 0 − 3 是判过/判挂的红线 。
为什么零梯度参数要换绝对差 :相对误差的分母接近零时,比值被微小噪声顶爆,会出现”两个都基本为零却判挂”的假阳性。对理论梯度应为零的参数(如早退之后的高斯),改用绝对差 ∣ g num − g ana ∣ |g_{\text{num}} - g_{\text{ana}}| ∣ g num − g ana ∣ 直接判。
容易看错的点
gradcheck 验证的是实现一致性,不是”梯度对不对”的物理意义 。它证明”反向传播算出的确实是这个前向函数的导数”,前向本身错了它也照样通过——所以基准前向本身也要被审(见”裁判也要被审”)。
每次差分是两次完整前向 。59 个参数就是 118 次渲染,gradcheck 只能跑小场景,不能拿来对全量训练。
参考
gradcheck 六用例与三原则
六个真实用例
自研训练器 gsplat_train 的 selftest.cpp(约 742–812 行)里的六个场景:
用例 构造 抓什么 guard 断言 isolated单高斯 基线:全参数链 — overlap8 个高斯随机重叠 多高斯 T 链恢复、贡献分配 — clamp构造使 α \alpha α 被截断到 0.99 截断分支的梯度 截断次数 > 0 earlyexit3 个遮挡体把 T 压到约 10 − 6 10^{-6} 1 0 − 6 早退之后梯度严格为零 存在 T < 10 − 4 10^{-4} 1 0 − 4 的像素 ssim6 个高斯,损失带 SSIM 项 SSIM 项梯度 — boundary高斯中心恰落 u = 16 / 32 / 48 u=16/32/48 u = 16/32/48 tile 边界遍历逻辑 —
每个用例针对一条容易写错的代码路径:T 链除法逆推、α \alpha α 截断、T < 10 − 4 T<10^{-4} T < 1 0 − 4 早退、SSIM 反向、tile 边界——这些地方正是手写 CUDA/compute 反向最容易错一个因子、错一个下标的位置。
三原则
1. 每个参数一条链。 每个高斯 59 个参数(14 个基础:位置 3、四元数 4、对数尺度 3、不透明度 1、SH DC 3;加 SH 高阶 45 个系数)全部逐个过差分,不能只测”代表参数”。因为不同参数的梯度链长得不一样:位置走双路径(一阶 W ⊤ J ⊤ W^\top J^\top W ⊤ J ⊤ 项 + 二阶协方差回传项)、四元数要做切空间投影、不透明度外带 sigmoid——测一个位置通过,不能推出四元数的链也对。
2. 每个分支一个用例 + guard 断言。 guard 断言回答的是”这个分支真的被执行了吗”。clamp 用例若一次截断都没发生,差分再准也只测了普通路径——没测到不等于通过 。断言的形式是计数器:前向时统计截断次数/早退像素数,测试结尾断言它大于零。
3. 每个用例可解释。 先讲清”这个构造应该暴露什么”,再看数字。构造不针对具体机制的测试,挂了不知道查哪,过了也不知道证明了什么。
容易看错的点
用例小不代表覆盖弱 。单高斯、8 高斯这类微场景的每一声前向都能手工复算,反而比大场景更容易定位错位的一个因子。
边界用例测的是遍历,不是数学 。boundary 抓的是 tile 分块时”中心落在边界上的高斯被处理几次”这类下标/区间错误——数学公式全对也会挂。
裁判也要被审:差分基准的精度问题
f32 前向不能当裁判
gradcheck 的”数值梯度”需要一个可信的 f ( x ) f(x) f ( x ) 。最顺手的选择是直接调 GPU 前向——但它存像素用 f32(单精度浮点)。f32 的机器精度是 2 − 24 ≈ 6 × 10 − 8 2^{-24} \approx 6\times10^{-8} 2 − 24 ≈ 6 × 1 0 − 8 ,一条渲染链多次累乘累加后,输出像素的相对舍入噪声约 10 − 7 10^{-7} 1 0 − 7 。
问题在量级对比:差分信号是 f ( x + ε ) − f ( x − ε ) ≈ 2 ε f ′ f(x+\varepsilon)-f(x-\varepsilon) \approx 2\varepsilon f' f ( x + ε ) − f ( x − ε ) ≈ 2 ε f ′ ,取 ε = 10 − 6 \varepsilon = 10^{-6} ε = 1 0 − 6 后,SSIM 项这类弱梯度参数的差分信号只有约 2 × 10 − 7 2\times10^{-7} 2 × 1 0 − 7 ——信号和基准自身的舍入噪声同量级 。除出来的”数值梯度”一半是噪声,用它审解析梯度,审出来的全是裁判自己的抖动。
项目源码注释原文(gsplat_train/src/selftest.cpp,约 534–538 行):
// the GPU forward stores pixels as f32, whose
// rounding (~1e-7) exceeds the SSIM-term FD signal
// (~2e-7) ... The CPU replica is validated
// against the GPU by fwdcheck (conic/blend to ~1e-5).
对策:双精度 CPU 复刻 + 先审裁判
差分基准换成一个全 double 精度的 CPU 版前向 (语义与 GPU 版逐行对应,只换精度)。f64 机器精度 2 − 53 ≈ 10 − 16 2^{-53} \approx 10^{-16} 2 − 53 ≈ 1 0 − 16 ,舍入噪声比差分信号低约十个数量级,基准稳如磐石。
但 CPU 复刻自己也是手写的代码,也可能写错——所以复刻上岗前要先过 fwdcheck :用同一批输入,比对 CPU double 版与 GPU f32 版的 conic/blend 中间量,对拍到约 10 − 5 10^{-5} 1 0 − 5 一致(这个量级正是 f32 精度允许的上限),证明两份实现语义相同、差的只是精度。
链条因此是:fwdcheck 证明 CPU 复刻 ≈ GPU 前向(语义对拍)→ CPU 复刻才有资格当 gradcheck 的裁判(精度够格)→ gradcheck 审反向梯度。验证工具自身也要被验证。
容易看错的点
“GPU 和 CPU 对拍 1e-5”不是目标精度,是 f32 的天花板 。对拍残差若远好于 10 − 7 10^{-7} 1 0 − 7 反而可疑——说明比对的可能不是同一条计算路径。
不是”double 更准所以直接信 double” 。double 只解决精度,不解决语义;两份独立实现才能互相抓对方的笔误,同一份代码换精度重编没有验证价值。
参考
无 SfM 初始化
SfM 在原版管线里的角色
原版 3DGS 的训练输入来自 COLMAP 的 SfM(运动恢复结构,Structure from Motion):它同时产出两样东西——每张图的相机位姿,和一个稀疏点云。点云用作初始高斯中心,让训练从”大致对的骨架”起步。
本项目的场景是合成采集(Unity 端输出 Nerfstudio 格式 transforms.json),位姿是精确已知的 ,SfM 的第一项产出直接作废;第二项产出(初始点云)用程序生成替代:10 万个高斯中心均匀撒在相机射线附近。代价是初始高斯的位置只有粗粒度先验,尺度、旋转、颜色、不透明度全部交给优化从零长出来——这之所以能成立,是因为 3DGS 的梯度对位置和形状都有明确信号,只是收敛会慢一些。
半分辨率与 tile 计数
训练用 960 × 540 960\times540 960 × 540 (1080p 每边减半)。像素数是 1 / 4 1/4 1/4 而不是”一半”:( 1920 × 1080 ) / ( 960 × 540 ) = 4 (1920\times1080)/(960\times540) = 4 ( 1920 × 1080 ) / ( 960 × 540 ) = 4 ,每步前向+反向的开销近似同比缩小。
光栅化按 16 × 16 16\times16 16 × 16 的 tile 分块,tile 数是向上取整的除法:
⌈ 960 16 ⌉ × ⌈ 540 16 ⌉ = 60 × 34 = 2040 \left\lceil\frac{960}{16}\right\rceil \times \left\lceil\frac{540}{16}\right\rceil = 60 \times 34 = 2040 ⌈ 16 960 ⌉ × ⌈ 16 540 ⌉ = 60 × 34 = 2040
注意竖直方向 540 / 16 = 33.75 540/16 = 33.75 540/16 = 33.75 ,最后一行 tile 只有 12 像素高,是”残缺 tile”——tile 覆盖检查、边界裁剪的代码必须容忍这种不满块,这正是 gradcheck 里 boundary 用例存在的现实背景。
单步数据流
每步取一张训练图,数据流固定五步:
按该帧的 transform_matrix 摆相机(世界系→相机系);
前向:投影、分 tile、α 合成,渲染出图;
损失:0.8 ⋅ L 1 + 0.2 ⋅ ( 1 − S S I M ) 0.8\cdot L_1 + 0.2\cdot(1-\mathrm{SSIM}) 0.8 ⋅ L 1 + 0.2 ⋅ ( 1 − SSIM ) ;
反向:逐 tile 逆序重放,得全部 59×N 个参数的梯度;
Adam 更新,回到 1。
每步只算一帧而不是全训练集,等价于 batch size = 1 的随机梯度下降——单帧梯度噪声大,但每秒能跑数百步,三万步下来等效遍历每帧约几十遍。
容易看错的点
“无 SfM”省掉的是 COLMAP,不是位姿 。位姿必须来自采集端;位姿本身有误差时,随机初始化救不回来。
初始点撒在”射线附近”而非全空间 。相机视锥外的点在训练初期对任何训练帧都没贡献(梯度为零),撒了也是死参数。
参考
读曲线:四列各查各的病
PSNR 的定义
曲线里的 trainPsnr / holdoutMean 单位是分贝(dB)。PSNR(峰值信噪比,Peak Signal-to-Noise Ratio)定义:
P S N R = 10 log 10 M A X 2 M S E = − 10 log 10 ( M S E ) ( M A X = 1 ) \mathrm{PSNR} = 10\log_{10}\frac{\mathrm{MAX}^2}{\mathrm{MSE}} = -10\log_{10}(\mathrm{MSE}) \quad (\mathrm{MAX}=1) PSNR = 10 log 10 MSE MAX 2 = − 10 log 10 ( MSE ) ( MAX = 1 )
像素归一化到 [ 0 , 1 ] [0,1] [ 0 , 1 ] 后就是 MSE 的负对数。分贝换算的直觉:
PSNR 变化 MSE 变化 +3.01 dB 减半 +10 dB 降到 1/10 +20 dB 降到 1/100
所以”平台期再涨 1 dB”比前期难得多——误差每减一半才值 3 dB。
注意 PSNR 基于 MSE(L 2 L_2 L 2 ),而训练损失是 0.8 L 1 + 0.2 ( 1 − S S I M ) 0.8 L_1 + 0.2(1-\mathrm{SSIM}) 0.8 L 1 + 0.2 ( 1 − SSIM ) :两者正相关但不是同一个量,loss 曲线和 PSNR 曲线形状相似却不同构,判断收敛以趋势为准,不要拿数值直接互算。
四列分工
loss :优化器视角的总损失,一路向下是健康的必要条件(不是充分条件——它在训练集上算的)。
trainPsnr :训练集重建质量。爬升后进入平台是正常的:模型容量(当前高斯数量)见顶。
holdoutMean :留出集(不参与训练的视角)的 PSNR 均值。这是泛化指标——trainPsnr 涨而 holdoutMean 不涨,就是过拟合 :模型在背训练视角的颜色,没有学到三维结构。两者之间的缝隙宽度就是过拟合程度。
alive :存活高斯数。静止参数集下它恒定;它开始自己涨,说明致密化在起作用。
数量级参照
本系列的实测锚点:splatfacto 在全分辨率 30k 迭代后约 31.1 dB;自研训练器 overfit 单场景 3000 迭代可到 48.5 dB(单场景过拟合天然偏高,不能拿来比泛化),全量训练约 23 dB。读曲线时先想清楚”这条曲线是哪个设置下跑的”,再下结论。
容易看错的点
PSNR 对系统性几何错误不敏感 。事实底稿记录过:23 dB 量级的模型,画面整体竖直翻转也只让 PSNR 从 20.1 掉到 19.8——逐像素指标分不出”结构上全错”。诊断几何问题要用几何判据(如地平线位置),不能只看曲线。
loss 平滑下降 ≠ 梯度正确 。错误的反向传播往往也”能降 loss”(比如错一个常数因子,方向大致还对),曲线看不出来——这正是 gradcheck 存在的理由。
参考