把 Adam 写进端侧训练器:学习率与新生高斯状态

0 阅读6分钟

磁刻技术实录 · 第 15 篇 · 工程实践 · 技术基线 2026-10-31

我们正在做的「磁刻(Cike)」,是一款鸿蒙原生的生活记录与个性化创作应用。用户可以把照片做成数字冰箱贴,在洞洞板上自由摆放、组合自己的生活片段,也可以把喜欢的内容做成桌面卡片,用来回看、互动和分享。

在空间记录上,我们引入了 3D Gaussian Splatting(3DGS,三维高斯泼溅),把拍摄的视频重建为可以转动、靠近观察的三维场景。为此,我们重构了视频重建链路,实现自己的 C++ 训练核心,并结合鸿蒙原生的媒体处理、模型查看和分享能力,希望让用户保存一段经历时,也能留下当时的空间关系。

高斯训练过程中,参数持续更新,模型还会新增或删除元素。我们分享 Adam 的实现细节,是想说明优化器的历史状态怎样跟随这些变化,以及为什么只把更新公式翻译成 C++,还不足以支撑磁刻的动态训练过程。

Adam 的公式广为人知,但把它放进 3DGS 训练器后,难点不再只是计算两个滑动平均。高斯数量会动态变化,位置与颜色具有不同单位,透明度可能被重置,旋转需要保持合法。磁刻在 C++ 核心中维护每个高斯自己的优化器状态,使这些变化能够被明确处理。

以下代码按当前实现整理,保留关键运算与控制顺序;省略导入、日志和完整类型,部分辅助函数采用说明性命名,片段需结合上下文阅读。

当前训练参数包括位置、log scale、四元数、opacity logit 和 SH0 颜色。它们对画面的影响方式不同,因此使用不同学习率。位置更新还会随训练推进衰减,减少外观开始拟合后几何仍持续大幅漂移的风险。

一次参数更新的组成

图 1:动态高斯集合需要动态优化器状态

Adam 状态依附于参数身份

一阶矩记录梯度方向历史,二阶矩记录逐分量平方梯度历史。偏置校正使用已经更新的步数。这里的步数应该属于该参数,而不是简单使用整个训练任务的全局迭代号。

例如,第 1,000 次迭代创建的新高斯只有一次梯度观察。如果按 1,000 步进行偏置校正,就会把它当作已有充分历史的参数,改变有效更新幅度。当前实现为高斯保存自身计步,新点可以从与自身历史匹配的状态开始。

克隆和分裂不仅增加数组元素

致密化会改变高斯集合。位置、尺度、颜色、梯度累计和 Adam 槽位必须同步重排。若点数组删除了某个元素,而优化器状态仍按旧索引保留,后续会把一个点的历史用于另一个点,错误通常不会立即崩溃,却会破坏训练。

因此,创建、保留和淘汰高斯应作为一个整体操作组织。新生点的状态如何初始化,哪些历史可以保留,哪些必须清空,都应由明确策略决定,并由回归检查数组长度、对应关系和有效步数。

分组学习率对应参数敏感度

颜色改变直接影响像素值,位置则会通过投影改变覆盖与遮挡。对所有参数使用同一学习率,往往难以同时保证收敛速度与几何稳定。当前配置分别控制位置、尺度、旋转、不透明度和颜色,位置学习率还设有最终衰减比例。

这些数值属于经验配置,不能离开场景尺度、图像分辨率与损失归一化讨论。把世界坐标整体缩放后,位置学习率的实际含义也会变化。调参实验应固定其他条件,并记录参数范围,而不是只观察最终损失。

Adam 的时间步属于参数状态

if (std::isfinite(gradient)) {
    ++slot.step;
    slot.m = beta1 * slot.m + (1 - beta1) * gradient;
    slot.v = beta2 * slot.v + (1 - beta2) * gradient * gradient;
    float m_hat = slot.m / (1 - std::pow(beta1, slot.step));
    float v_hat = slot.v / (1 - std::pow(beta2, slot.step));
    parameter -= learning_rate * m_hat / (std::sqrt(v_hat) + 1e-8f);
}

这段以单个标量解释更新公式,实际按高斯参数组维护状态。m 平滑梯度方向,v 平滑梯度平方,两个偏差修正项则补偿初始化为零的影响。新生高斯并没有经历前面几千次更新,所以不应直接把全局迭代次数当成它的历史长度。

位置、尺度、旋转、颜色和不透明度的单位与敏感性不同,因此使用不同学习率。某个参数更新很慢,并不一定应该整体提高学习率;先确认梯度链完整、尺度定义正确,再调整对应参数组,能避免让其他已经稳定的变量一起震荡。

动态模型的三种年龄

图 2:参数更新计步不能只依赖全局迭代号

主动重置参数时,也要检查历史状态

训练核心具备不透明度重置能力。重置参数后,如果仍沿用此前强烈的优化动量,下一步可能迅速把参数推回原状态。因此,透明度维护独立计步与相应矩状态,便于与这种主动调整协调。

需要区分核心能力和 App 默认配置。当前 App 视频训练关闭了周期性不透明度重置;核心支持这一机制,并不意味着每次用户任务都会执行。介绍训练器时应分别列出可配置能力和实际入口参数。

看懂曲线之外的诊断

损失下降可以作为整体信号,但还应观察尺度分布、透明度分布、梯度大小、新生点比例以及异常数值。某些配置会快速降低训练视角误差,却通过过大的高斯覆盖产生模糊结果。留出视角和局部结构检查能够补充曲线看不到的信息。

克隆后,不透明度改变了,相关历史也要处理

AdamSlot retained = parent_slot;
retained.m_opacity = 0;
retained.v_opacity = 0;
retained.opacity_step = 0;
output.push_back(adjusted_parent);
output_slots.push_back(retained);
output.push_back(new_child);
output_slots.push_back(AdamSlot{});

当前克隆会重新分配合成不透明度,所以保留父项时也重置不透明度的动量与计数。其他未被这次操作重新定义的参数历史可以保留;新孩子则从新的优化器状态开始。把所有状态一律复制,会让孩子继承与自身不匹配的历史。

模型数组与状态数组必须同步重排,不能只保证长度相同。若第十个高斯对应第十一个高斯的动量,程序可能不会崩溃,却会发生难以解释的训练波动。验证应检查新增点的首次更新、保留点的状态连续性,以及剪枝后参数与状态的身份对应。

回归测试可以固定梯度验证更新公式,再在某一步插入新点,检查它是否与从头初始化的同等状态一致。对于删除和重排,应确认剩余点仍使用自己的历史。把优化器状态当成模型的一部分管理,动态 3DGS 才能保持可解释的训练行为。