Skip to content

注意力掩码里的 True:同一个矩阵,两个 API 含义相反 ​

一个小型语言模型的训练损失下降得出奇地快,验证集上也很好,可是逐词生成时输出的全是乱码。原因是训练时每个位置都看到了后面的词:模型学会的是「抄答案」,而生成时答案还不存在。

因果语言模型的前提是:位置 t 的输出只能依赖位置 0..t 的输入。保证这一点的就是注意力掩码。掩码写错时程序不会报错,损失还会很好看,所以这类错误往往要到生成阶段才暴露。更麻烦的是,PyTorch 里两个常用的注意力接口对布尔掩码的约定恰好相反。

本文在 PyTorch 2.14.1(CPU)上用一个简单的方法检验掩码:扰动位置 t 之后的输入,看位置 0..t 的输出是否变化。变了,就说明有未来信息泄漏。实验见文末。

布尔矩阵(下三角 True)TrueFalsescaled_dot_product_attentionTrue = 参与:因果,正确nn.MultiheadAttentionTrue = 遮住:只看未来能看:过去与自己能看:只有未来检验方法扰动位置 t 之后的输入 → 位置 0..t 的输出变了 = 泄漏;与掩码怎么构造、用哪个 API 无关
图 1 · 实测(PyTorch 2.14.1 CPU):行是查询位置,列是被注意的位置。下三角 True 传给 scaled_dot_product_attention 得到因果注意力;原样传给 nn.MultiheadAttention,每个位置只看得到未来。扰动位置 2 之后的输入,位置 0..2 的输出随之变化,就是泄漏。

一、两个 API,两种约定 ​

接口布尔掩码中 True 的含义因果掩码应该传
F.scaled_dot_product_attention(attn_mask=...)参与注意力下三角(含对角线)为 True
nn.MultiheadAttention(attn_mask=...)不允许注意(遮住)严格上三角为 True
nn.MultiheadAttention(key_padding_mask=...)这个位置是填充,忽略填充位置为 True
text
scaled_dot_product_attention 传下三角 True(True=参与):与 is_causal=True 一致:True
nn.MultiheadAttention 传严格上三角 True(True=遮住):位置 0 对位置 1..4 的权重和 0.0;位置 2 之后的输入会影响位置 0..2:不会
nn.MultiheadAttention 误传下三角 True:位置 0 对自己的权重 0.0,只看未来;位置 2 之后的输入会影响位置 0..2:会

把为 scaled_dot_product_attention 写的下三角矩阵原样传给 nn.MultiheadAttention,掩码就完全反了:每个位置看不到自己和过去,只能看到未来。这正是开头那种「训练很好、生成乱码」的模式。

浮点掩码没有这个歧义,它按加法作用在注意力分数上:遮住的位置是 -inf,其余是 0。nn.Transformer.generate_square_subsequent_mask 生成的就是这种。

text
nn.MultiheadAttention 传浮点掩码(遮住处 -inf,其余 0,按加法作用):与布尔上三角结果一致:True
generate_square_subsequent_mask 生成浮点掩码,位置 0 行:['0', '-inf', '-inf', '-inf', '-inf']

还有一个容易误会的参数:

text
nn.MultiheadAttention 只传 is_causal=True、不传 attn_mask:RuntimeError(is_causal 只是提示,掩码仍要自己给)

在 scaled_dot_product_attention 里,is_causal=True 会直接应用因果掩码;在 nn.MultiheadAttention 和 nn.Transformer* 模块里,is_causal 只是告诉实现「你传的掩码是因果的,可以走优化路径」,掩码本身仍然要传。

二、全被遮住的行:结果取决于计算路径 ​

如果某一行的所有位置都被遮住,softmax 的分母是零。

text
scaled_dot_product_attention 传严格上三角 True:与 is_causal=True 一致:False;最后一行全被遮住,这一行输出 [0.0, 0.0, 0.0, 0.0]
最后一行全被遮住:need_weights=True 时这一行输出全是 NaN:True;need_weights=False 时含 NaN:False(走了另一条计算路径,全遮住的行得到 0)

同一个 nn.MultiheadAttention 模块、同一个掩码,只是 need_weights 不同,全遮住的行就一个是 NaN、一个是 0。NaN 会在后续层里传播,让整个批次的损失变成 NaN;0 则悄无声息。两种结果都不该依赖:出现全遮住的行,说明掩码的构造或者数据(例如一条完全是填充的样本)有问题,应该在数据阶段就排除。

三、填充掩码 ​

批量训练时,短序列要补齐到同样的长度。填充位置不是真实的输入,不应该被注意到。

text
不传 key_padding_mask:真实词的输出与不填充时一致:False;随填充向量变化:True
传 key_padding_mask(True=填充):真实词的输出与不填充时一致:True;随填充向量变化:False

不传填充掩码时,同一句话补齐的内容不同,真实词的输出就不同:模型的结果取决于这句话和谁分在了同一批。传了之后,输出与不填充时完全一致。

因果掩码和填充掩码管的是两件事,通常要同时传:前者限制「能看哪些位置」,后者排除「哪些位置不是真的」。填充位置在损失里也要排除,那是标签的事,见微调样本的标签对齐。

四、编码器—解码器结构里,memory 也要遮 ​

nn.TransformerDecoder 有两次注意力:对目标序列的自注意力(用 tgt_mask),和对编码器输出 memory 的交叉注意力(用 memory_mask)。在翻译这类任务里,memory 是源语言句子,解码器看到它的全部是合理的。但如果把同一段输入既当 tgt 又当 memory,事情就变了:

text
TransformerDecoder(tgt=x, memory=x),只给 tgt_mask:位置 2 之后的输入会影响位置 0..2:会(交叉注意力看得到整段 memory)
同时给 memory_mask:不会
只用自注意力的「仅解码器」结构(TransformerEncoder + 因果掩码):不会
TransformerEncoder 不给因果掩码:会

自注意力遮住了未来,交叉注意力又把整段未来送了进来。GPT 这类「仅解码器」模型实际上只有带因果掩码的自注意力,没有交叉注意力;在 PyTorch 里搭这种结构,用 nn.TransformerEncoder 加因果掩码更直接(模块名字里的 Encoder 与 Decoder 指的是结构,不决定是否因果)。

五、把扰动检验写成测试 ​

掩码错误不会报错,所以值得为它写一个单元测试:

python
def leaks(fn, x, t):
    """扰动 t 之后的位置,看位置 0..t 的输出是否变化。"""
    y1 = fn(x)
    x2 = x.clone()
    x2[:, t + 1:] += torch.randn_like(x2[:, t + 1:])
    y2 = fn(x2)
    return bool((y1[:, : t + 1] - y2[:, : t + 1]).abs().max() > 1e-6)

assert not leaks(model_forward, x, t=2)

它不关心掩码是怎么构造的、用的哪个 API,只检查「未来是否影响过去」这个性质本身。模型在 eval() 模式下运行(关闭 dropout),结果是确定的。换注意力实现、升级框架或者重构模型之后,这个测试都能直接复用。

六、检查清单 ​

  • 用布尔掩码时先查清楚这个 API 的 True 是「参与」还是「遮住」;不确定就用浮点掩码。
  • nn.MultiheadAttention 的 is_causal 只是提示,掩码仍要传。
  • 因果掩码和填充掩码通常同时需要;不要让任何一行全被遮住。
  • 自己的输入当作 memory 时,交叉注意力也要遮住未来;仅解码器结构只用带因果掩码的自注意力。
  • 为模型写一个扰动检验,作为单元测试。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权