Qiming Bao · Blog · 2026 年 11 月 13 日 · English / 中文

微调 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.1330.9993(均值)
与 PaddleNLP 一致:do_rescale=False, do_normalize=False2.16 / 0.6250.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.embedding107.9是
index_select99.3是

index_select 的反向传播用 index_add_ 累加梯度,偏置表重新开始训练,每步只多大约 7% 的时间,前向结果不变。已报告为 huggingface/transformers#50636,修复和回归测试见 #50641,等 issue 被分类后会重新打开。

怎样发现这类 bug

这三项检查都很便宜,完全可以写成单元测试,仓库里现在已经有了。

排查这些 bug、做测量和撰写本文时,我使用了 Claude(Anthropic)辅助。所有结果都经过我本人审阅,检查也由我亲自运行。


包启明,现居新西兰奥克兰的 AI 研究者,研究方向为大模型推理与模型部署。博士毕业于奥克兰大学 Strong AI Lab,参与 PyTorch ONNX 导出器、ONNX Runtime、ONNX Runtime GenAI 和 Microsoft PyRIT 等开源项目的贡献。