微调 LayoutLMv3 和 ERNIE-Layout 时三个不报错的 bug
在给我的 LayoutLMv3 预训练仓库加长文档支持时,我也通过社区的 PyTorch 移植版接入了 ERNIE-Layout。过程中发现了三个 bug:它们都不会报错或警告,却会悄悄让模型变差。三个都已经连同修复一起报告给了上游。
1. ERNIE-Layout 的图像被归一化了两次
ERNIE-Layout 的视觉骨干是一个 ResNet,它会自己对输入做归一化:
images_input = (images - self.pixel_mean) / self.pixel_std
# pixel_mean = [103.53, 116.28, 123.675], pixel_std = [57.375, 57.12, 58.395]
这组常数期望的是 0-255 的像素。模型原本来自 PaddleNLP,它送进去的正是这样的输入:页面解码成 RGB,缩放到 224 x 224,保持 0-255(_str2im 之后 _permute(image, to_bgr=False))。但 PyTorch 移植版的 README 和示例用的是 LayoutLMv3ImageProcessor(apply_ocr=False),它默认先缩放到 [0, 1],再用均值 = 标准差 = 0.5 归一化,像素在进入骨干网络之前就已经是 [-1, 1],然后又被归一化一次。
两次归一化之后,所有像素都集中在 -1.97 附近,标准差只有 0.13,模型看到的几乎是一张黑页。我在 8 张 FUNSD 测试页上测了它对视觉特征的影响:
| 图像处理器 | 骨干网络输入的均值 / 标准差 | 不同页面视觉特征的余弦相似度 |
|---|---|---|
移植版 README:LayoutLMv3ImageProcessor(apply_ocr=False) | -1.97 / 0.133 | 0.9993(均值) |
与 PaddleNLP 一致:do_rescale=False, do_normalize=False | 2.16 / 0.625 | 0.9398(均值) |
按 README 的写法,所有页面在模型看来都一样。修复只需要两个参数。有一个坑要注意:那组均值看起来像 Detectron 惯用的 BGR 顺序常数,但 PaddleNLP 送进去的是 RGB,模型也是这样预训练的,所以不要转成 BGR。已报告为 NormXU/ERNIE-Layout-Pytorch#26,修复见 #27。
如果你已经用 README 的预处理微调过模型,这些模型请继续用原来的预处理,或者重新微调。我在 FUNSD 上分别用两种预处理微调 ERNIE-Layout(3 个种子),再用两种预处理分别评估每个模型。用 README 预处理训练的模型,用同样的预处理时 F1 为 84.19,换成修复后的预处理会降到 82.78(−1.4,配对 t 检验 p = 0.03)。用修复后的预处理重新微调得到 84.55,比旧设置略高;但 FUNSD 很少依赖图像,3 个种子下这个差异不显著。训练和推理的预处理必须一起改(W&B:README 预处理、修复后的预处理)。
LayoutLMv3 本身没有这个问题:它的模型内部不做归一化,处理器的 0.5 / 0.5 也和微软原版微调代码一致。只有 ERNIE-Layout 借用 LayoutLMv3 的处理器时才会出问题。
2. 冻结编码器时 BatchNorm 统计量仍在变化
继续预训练有一个常见做法叫 head 预热:先冻结编码器,只训练新加的 head。我会检查预热后编码器是否逐位不变,ERNIE-Layout 却没有通过这个检查。
它的 ResNet 有 11 个 BatchNorm 层。在 model.train() 模式下,BatchNorm 每次前向都会更新它的 running mean 和 variance,不管参数是否需要梯度。冻结编码器后只做一次前向,11 个层的统计量就全变了。用每批 2 页做微调时,预训练的统计量会被这些小批次的内容带偏。
修复方法和检测类骨干网络的常规做法一样:训练时让 BatchNorm 保持 eval 模式。这样做之后,head 预热改变的编码器参数和 buffer 都是 0 个。更一般的教训是:requires_grad_(False) 冻结的是参数,不是 buffer。
3. transformers 从 v4.42 起不再训练 LayoutLMv2/v3 的相对位置偏置
LayoutLMv3 会在注意力分数上加上可学习的 1D 和 2D 相对位置偏置。transformers 里查表的写法是:
with torch.no_grad():
rel_pos = self.rel_pos_bias.weight.t()[rel_pos].permute(0, 3, 1, 2)
上面的注释说这次查表"和输入无关,不需要追踪梯度"。索引确实和输入无关,但结果依赖于权重,所以三张偏置表(rel_pos_bias、rel_pos_x_bias、rel_pos_y_bias)永远拿不到梯度:反向传播后它们的 .grad 是 None,其他权重都有梯度。在任何微调或预训练中,它们都停留在预训练时的值。
它是怎么来的:#26139 为了省显存,把 one-hot 加 nn.Linear 的计算换成了索引。索引的反向传播非常慢,训练慢了大约 5 倍(#30974)。随后 #30988 没有回退,而是把查表包进了 no_grad,速度恢复了,偏置表也悄悄地不再训练了。从 v4.42.0 开始的每个版本都是这样,LayoutLMv2 和 LayoutLMv3 都受影响。
修复要在保留梯度的同时避免变慢。我测了 LayoutLMv3ForTokenClassification 完整训练一步的时间(base,512 个文本 token 加 197 个图块,batch 2,A100):
| 查表方式 | 每步毫秒 | 偏置表是否训练 |
|---|---|---|
main:no_grad 里的索引 | 92.8 | 否 |
| 带梯度的索引(导致 #30974 的版本) | 477.0 | 是 |
F.embedding | 107.9 | 是 |
index_select | 99.3 | 是 |
index_select 的反向传播用 index_add_ 累加梯度,偏置表重新开始训练,每步只多大约 7% 的时间,前向结果不变。已报告为 huggingface/transformers#50636,修复和回归测试见 #50641,等 issue 被分类后会重新打开。
怎样发现这类 bug
- 对比输入,而不只是输出。ERNIE 的转换是通过给 PaddleNLP 和移植版喂同一个张量来验证的,这种比较永远发现不了预处理的差异。要检查第一层实际收到的是什么。
- 冻结之后,除了参数也要检查 buffer。在冻结阶段前后各记录一次
named_buffers()。 - 反向传播后,检查每个可训练参数都有梯度。
requires_grad=True但grad is None几乎一定是 bug。
这三项检查都很便宜,完全可以写成单元测试,仓库里现在已经有了。
排查这些 bug、做测量和撰写本文时,我使用了 Claude(Anthropic)辅助。所有结果都经过我本人审阅,检查也由我亲自运行。
包启明,现居新西兰奥克兰的 AI 研究者,研究方向为大模型推理与模型部署。博士毕业于奥克兰大学 Strong AI Lab,参与 PyTorch ONNX 导出器、ONNX Runtime、ONNX Runtime GenAI 和 Microsoft PyRIT 等开源项目的贡献。