1. 从一次模型“翻车”说起:为什么要研究 Padding Mask
事情得从某次训练序列模型说起。我正满怀信心地把一批长度不等的文本丢进模型,词表、嵌入、位置编码全部就位,训练了没几个 epoch,Loss 从 1.8 一路跌到 1.5,看起来一切正常。结果一评估,发现模型预测出来的结果里全是填充符,几乎把真正的文本给“无视”了。
我当时第一反应是数据清洗不够干净,又去检查分词和截断逻辑,折腾了两天,问题纹丝不动。后来把注意力权重矩阵打印出来,这才发现端倪:模型把大量的注意力分数投给了那些用来补齐长度的填充符,而真正的文本 token 反而被冷落了。换句话说,模型压根没学会“该忽略什么”。
这就是 Padding Mask 存在的意义。它不是一个花哨的模块,也不是某篇论文里突然冒出来的创新,而是所有序列模型在训练时几乎绕不开的一个基础操作。你只要用 batch 训练,只要序列长度不齐,就必然面对填充符带来的问题。而 Padding Mask 的任务,就是告诉模型:这些位置是占位的,别去关注它们,也别让它们参与计算。
这篇文章我会从“为什么要填充”讲起,把 Padding Mask 的原理、实现、常见错误和排查方法完整拆开。不讲空泛的概念,全部是对着代码和实际训练过程来的。无论你是在做文本分类、机器翻译还是序列生成,只要用 Transformer 或者 RNN,这篇文章都值得你花十分钟看完。
2. 为什么会有填充符:Batch 训练的刚需
2.1 张量形状的硬性约束
深度学习框架里,张量的形状必须规则。你用 PyTorch 或 TensorFlow 训练时,一个 batch 的数据是以矩阵形式进入模型的,形状通常是[batch_size, seq_len]。问题在于,真实世界的文本长度千差万别,有的句子 5 个词,有的句子 50 个词,它们没法直接拼成一个规则矩阵。
解决办法有两种。一种是不用 batch,一条一条地送进模型,这样就没有填充需求了,但训练效率低得让人崩溃,GPU 的并行能力基本被浪费。另一种就是填充(Padding)——把短句子的后面补上特殊符号,让一个 batch 里所有句子都对齐到同一个长度。这个特殊符号通常是<pad>,它在词表里单独占一个 id,比如 0。
填充之后,张量形状统一了,模型可以批量计算,速度飞快。但问题也随之而来:模型无法天然区分“真实 token”和“填充 token”。它会把<pad>当成普通符号,参与注意力计算、参与损失计算,甚至可能从填充符里学到一些根本不存在的“语义”。
2.2 简单 RNN 里的填充问题
很多人第一次接触 Padding Mask 是在 Transformer 里,但实际上,RNN 系列模型同样有这个问题。以 LSTM 为例,nn.LSTM的pack_padded_sequence函数就是专门处理填充序列的。原因很简单:LSTM 是逐步处理的,如果一句话只有 5 个 token,但被填充到 50 个,那模型就会多看 45 个无意义的<pad>,隐藏状态被这些填充符“带偏”,最后一步的输出也会受到影响。
这一点在情感分析任务里特别明显。你输入“I love this movie”并在后面补了 45 个<pad>,如果模型不屏蔽填充符,最后一步的隐藏状态可能就是<pad>位置的输出,那个状态几乎不包含任何真实情感信息。
2.3 Transformer 场景下的新维度
Transformer 和 RNN 不同,它是并行计算整个序列的。这意味着所有 token 位置会在同一时刻互相“看到”。如果不做 Mask,第一个位置的 token 会去 attend 那些<pad>,最后一个真实 token 也会 attend 后面的填充符。这种无意义的注意力不仅浪费算力,更会引入噪声。
所以在 Transformer 中,Padding Mask 的作用就是:在计算 Attention 分数时,把填充符位置对应的分数设为负无穷,这样 Softmax 之后它们的注意力权重就会变成 0。这一步干净利落,直接从根源上解决问题的关键。“掩码”这个词很形象——你就是在用一个二进制矩阵,把某些位置遮住,不让它们参与计算。
3. Padding Mask 的核心原理解读
3.1 掩码矩阵的形态
Padding Mask 的本质是一个布尔矩阵或 0/1 矩阵,形状与序列数据对齐。对于输入张量[batch_size, seq_len],它的掩码矩阵也是[batch_size, seq_len]。
按照最常见的约定,掩码矩阵中的True或1表示这个位置是有效 token,False或0表示这个位置需要被遮住。但在不同框架的 API 里,这个约定可能反过来。比如 PyTorch 的某些函数用True表示需要被掩码,而 Hugging Face 的attention_mask参数则用1表示保留,0表示掩码。这个细节极容易踩坑,后面我会专门讲。
掩码矩阵的生成逻辑非常简单,就是根据 token id 判断是否等于<pad>:
# 以 PyTorch 为例 def create_padding_mask(seq_ids, pad_token_id=0): # seq_ids: [batch_size, seq_len] # True 表示填充位置,False 表示有效位置 mask = (seq_ids == pad_token_id) # [batch_size, seq_len] return mask如果你的填充符 id 不是 0,那改成对应的 pad token id 就行。最核心的逻辑就这一行,没有任何神秘感。
3.2 注意力分数掩蔽的具体操作
Transformer 的 Attention 计算过程可以拆成三步。第一步是算查询和键的相似度,得到分数矩阵;第二步是对分数做 Softmax 归一化;第三步是用归一化后的权重对值做加权求和。
Padding Mask 要在 Softmax 之前介入,具体操作是把填充符位置对应的分数替换成负无穷:
import torch def masked_attention_scores(query, key, mask): # query: [batch_size, num_heads, seq_len_q, head_dim] # key: [batch_size, num_heads, seq_len_k, head_dim] # mask: [batch_size, seq_len_k] 或 [batch_size, 1, 1, seq_len_k] scores = torch.matmul(query, key.transpose(-2, -1)) / (key.size(-1) ** 0.5) # 把需要掩码的位置设为负无穷 scores = scores.masked_fill(mask == 0, float('-inf')) # Softmax 后,掩码位置的权重自动变为 0 probs = torch.softmax(scores, dim=-1) return probs为什么是负无穷,不是 0?因为如果直接把分数设成 0,Softmax 计算时 0 位置仍然会得到一个非零的概率值,等于没遮干净。负无穷经过指数运算后趋近于 0,再加上浮点数的精度限制,最终结果就是 0。
3.3 SDPA 与 Flash Attention 的掩码优化
如果你用的是 PyTorch 2.0 以上的F.scaled_dot_product_attention,掩码会被当作一个布尔张量传入,内部会做性能优化。这个 API 快速发展后,手动写注意力实现的场景减少了很多,但理解原理依然很重要,否则你根本不知道 mask 究竟是在哪一步生效的,出了问题也无从下手。
在 Flash Attention 算法中,掩码的应用也有优化。它并不是先把整个 score 矩阵算出来再 mask,而是在分块计算的过程中,对每个 block 单独处理掩码。这样做的好处是省显存、加速计算,但外人看代码可能觉得不太直观。不过对于我们日常使用来说,不需要过度关注内部实现,只需要知道:你写的 mask 在语义上等同于“Softmax 前把填充位置分数设为负无穷”。
4. 实操过程:完整实现一个带 Padding Mask 的 Transformer 编码器层
4.1 从零搭建一个编码器层
很多框架已经内置了 Mask 机制,比如nn.TransformerEncoderLayer在传入src_key_padding_mask参数时就会自动处理。但如果你自己搭 Transformer,或者为了理解原理手写实现,下面这段代码很值得参考。
import torch import torch.nn as nn import torch.nn.functional as F class EncoderLayer(nn.Module): def __init__(self, d_model=512, n_heads=8, d_ff=2048, dropout=0.1): super().__init__() self.self_attn = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model), ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, padding_mask): # x: [batch_size, seq_len, d_model] # padding_mask: [batch_size, seq_len],True 表示填充位 attn_out, _ = self.self_attn(x, x, x, key_padding_mask=padding_mask) x = self.norm1(x + self.dropout(attn_out)) ffn_out = self.ffn(x) x = self.norm2(x + self.dropout(ffn_out)) return x这里用到的是nn.MultiheadAttention的key_padding_mask参数。特别要注意,PyTorch 的key_padding_mask约定是True表示该位置需要被掩码,而不是大多数习惯的“True 表示保留”。这个反直觉的设定坑了我好几次,后面会详细说。
4.2 Padding Mask 在自回归解码器中的配合
如果你是做生成任务,比如机器翻译或文本摘要,解码器里同时存在两种 Mask:Padding Mask 和因果 Mask(Causal Mask)。
因果 Mask 的作用是防止模型在预测第 t 个词时看到第 t 个词之后的词。它的形状是一个上三角矩阵,对角线及以下部分是可见的,右上部分被遮住。在自回归生成过程中,这个 Mask 是必不可少的。
如果你把 Padding Mask 和因果 Mask 混为一谈,训练出来的生成模型就会有泄露问题或者输出异常。两者要做“按位与”操作合并成一个最终的 attention mask:
def combined_mask(query_len, key_len, padding_mask): # 因果 mask causal_mask = torch.triu(torch.ones(key_len, key_len), diagonal=1).bool() # 这里形状需要对齐,实际使用时应转换到 [batch_size, 1, seq_len, seq_len] # padding mask 作为 key 侧的掩码 key_padding_mask = padding_mask.clone() # 两种掩码相乘/相与 final_mask = causal_mask | key_padding_mask.unsqueeze(1).unsqueeze(1) return final_mask这个操作在 Hugging Face 的实现中会自动完成,但你手写实现时很容易漏。常见错误是只加了因果 Mask,忘了 Padding Mask,导致解码器输出的第一个 token 就带着填充符的噪声。
4.3 损失函数中的掩码
很多人以为 Padding Mask 只作用于注意力,但损失函数计算时同样需要处理。如果不屏蔽 Loss 中的填充符,模型会花大量算力在“预测<pad>”上,导致真实 token 的梯度被稀释。
以交叉熵为例,标准的F.cross_entropy允许通过ignore_index参数直接排除填充符:
loss = F.cross_entropy(logits.view(-1, vocab_size), labels.view(-1), ignore_index=pad_token_id)这一行代码的作用是:所有标签等于pad_token_id的位置不参与 Loss 计算,反向传播时梯度为 0。如果你用的是nn.CrossEntropyLoss,同样设置ignore_index=pad_token_id即可。
如果不设置ignore_index,模型会为了“猜对填充符”而优化,最终可能导致模型输出大量<pad>,这正好对应了本文开头我说的那次训练事故。
4.4 实际训练中的一个完整记忆点
我建议你把 Mask 相关操作总结成一张“检查表”,每次搭建模型时逐一核对:
- 注意力计算前是否传入了 Key 侧的 Padding Mask
- 自回归解码时是否同时使用了因果 Mask
- 损失函数是否设置了
ignore_index - 如果使用的是
nn.Transformer这类高层 API,确认参数名称是src_key_padding_mask还是src_mask,两者语义完全不同
做完这四步,序列模型的训练质量基本就有保障了。我后来就把这个检查表贴在屏幕边上,每次新开项目都扫一眼,再也没有犯过低级错误。
5. 常见问题与排查技巧实录
5.1 到底该用 1 还是 0?掩码约定大乱斗
这是新手最容易懵的地方。不同的框架、不同的 API,掩码约定完全相反。我专门整理了一张对照表:
| API | 掩码语义 | 备注 |
|---|---|---|
PyTorchnn.MultiheadAttentionkey_padding_mask | True 表示需要掩码 | 与直觉相反 |
PyTorchF.scaled_dot_product_attention的attn_mask | True 表示保留(float mask 中 -inf 表示掩码) | 与上面相反 |
Hugging Faceattention_mask | 1 表示保留,0 表示掩码 | 常规直觉 |
TensorFlowkeras.layers.MultiHeadAttention | 1 表示保留 | 常规直觉 |
为什么 PyTorch 高层 API 这么别扭?因为nn.MultiheadAttention的key_padding_mask是从早期实现里继承下来的逻辑,直接对接了torch.Tensor.masked_fill的布尔语义。它说“True 的地方要填入 -inf”,所以如果你按照直觉传一个“有效位置为 True”的 mask,结果就是把所有有效 token 给屏蔽了,模型学了等于白学。
我给出一个可行方案:写代码时统一用“有效位置为 1”的语义来设计 mask,在传入框架时再做一次取反或转换:
# 使用统一语义:1 表示有效,0 表示填充 valid_mask = (input_ids != pad_token_id).long() # [batch, seq_len] # 传入 nn.MultiheadAttention 时取反 key_padding_mask = (valid_mask == 0) # True 表示需要掩码的填充位这样做至少让你的业务逻辑代码里保持一致性,只在框架边界处做转换。
5.2 为什么我的模型总是输出<pad>?
如果你训练一个生成模型,发现结果里全是<pad>或模型停在填充符上不动,大概率是下面这几个原因之一:
- 损失函数没有设置
ignore_index,模型被填充符带偏。 - 解码时内部因果 Mask 与 Padding Mask 没有正确合并。
- 生成策略里有“强制重复禁止”之类逻辑,干扰了正常结束符的判断。
我排查这类问题的顺序:先看 Loss 曲线,如果 Loss 正常下降,但生成质量差,那基本就是 Mask 没生效。打印一次注意力权重矩阵,看看哪些列的权重始终很高——如果都是最后一列填充符附近,那几十有八九是 Mask 传错了方向。
5.3 掩码形状不匹配:一个看似离谱但频繁出现的问题
你可能会遇到这样一个报错:The shape of the mask X at index Y does not match the shape of the indexed tensor Z。原因往往是 mask 的形状没有对齐到 batch 或 seq_len。
对于nn.MultiheadAttention,当batch_first=True时,key_padding_mask形状应该是[batch_size, key_len],而不是[key_len]。很多人在单条数据推理时没问题,一上 batch 就报错,就是因为少加了一个 batch 维度。
而F.scaled_dot_product_attention对 mask 的形状要求更严格,甚至在key_len != query_len时需要手动扩展维度。
更隐蔽的问题是:虽然你设置了 mask,但 mask 的形状是[seq_len]而不是[batch, seq_len],PyTorch 在某些版本里会自动广播,结果就是每个 batch 都用了同一条 mask,数据没配对,出错时还不好排查。我建议在用 mask 前统一:
# 确保 mask 是 [batch_size, 1, 1, seq_len] 或 [batch_size, seq_len] def ensure_mask_shape(mask, batch_size): if mask.dim() == 1: mask = mask.unsqueeze(0).expand(batch_size, -1) return mask5.4 为什么加了 Mask 反而更慢了?
有些朋友发现加了 Padding Mask 后,训练速度居然变慢了。这不一定是 Mask 本身的问题,而是因为负无穷填充破坏了 Flash Attention 的分块计算优化。Flash Attention 在计算 Softmax 时需要维护行最大值和行求和,如果你把大量位置设为负无穷,算法的数值稳定性会降低,某些优化路径没法走通。
这种情况我一般建议:能不用负无穷就不要用负无穷;如果框架支持布尔掩码,就把布尔 mask 直接传给scaled_dot_product_attention,让它自己决定内部表演法。如果实在要手动实现注意力,可以尝试把分数先重置为 0,再配合一个加性的掩码偏移,避免负无穷的参与。
不过这属于优化细节,日常小数据量训练影响不大,真正部署大规模模型时值得留意。
5.5 关于“没有填充可能”的场景
也有一种情况,你完全不需要 Padding Mask——输入都是固定长度的序列。比如你把所有文本截到相同长度,或者用滑动窗口切分,那么 batch 内每个样本长度一致,填充符根本不存在,Mask 也就不用写了。
但我还是建议你保留一个通用的 Mask 函数。因为实验过程中,你可能会改变数据处理逻辑,某一天数据又变成变长的了,虽然理论上能追查,但考虑到训练时间成本,直接“无脑加 Mask”往往更省事。
6. Padding Mask 之外:你还需要知道的相邻概念
6.1 Causal Mask 与 Padding Mask 的对比
虽然前面提过,但这里我想专门再摆一张对比表,把两者的逻辑彻底分清楚:
| 类型 | 作用对象 | 核心思想 | 典型应用 |
|---|---|---|---|
| Padding Mask | Key/Value 侧 | 屏蔽填充符 | 编码器、解码器 |
| Causal Mask | Query 和 Key 同时 | 屏蔽未来信息 | 解码器自回归 |
| Combined Mask | 两者合并 | 同时屏蔽填充和未来信息 | 解码器训练 |
理解这个分类之后,你去看任何 Transformer 源码,都会很轻松地辨认出每一处 mask 到底是干嘛的。
6.2 为什么 BERT 里也有 Padding Mask?
BERT 是编码器架构,处理输入时同样有attention_mask。很多人在微调 BERT 时只关注input_ids和labels,经常忽略attention_mask,结果模型效果总差一点,调了半天才发现是 mask 没传。
在 Hugging Face 的 tokenizer 返回结果里,attention_mask就是自动生成好的 Padding Mask。它的值只有 0 和 1,1 代表有效,0 代表填充。如果你在微调时手动拼 batch,需要自行生成这个 mask,否则填入的<pad>会干扰注意力权重。
6.3 位置编码和 Padding Mask 的关系
有人可能会问:填充符也有位置编码,位置编码会不会给<pad>注入位置信息?确实,如果直接用绝对位置编码,填充符也会得到它的位置向量。
但 Mask 的位置是在注意力计算之前把分数设为负无穷,位置编码本身并不会被“擦除”——只是那些位置的 token 不会有效地参与后续计算。如果你用的是 ALiBi 或 RoPE 这类位置编码方法,它们的实现方式略有差异,但核心原则还是一样的:被 Mask 遮住的位置不产生影响力。
7. 从原理到调参的完整经验总结
7.1 入手路线建议
如果你是刚要学 Transformer,我建议按这个顺序理解 Mask:
- 先搞清什么是
[batch_size, seq_len]张量,什么是 padding; - 先手动实现一次
masked_fill+softmax,打印出掩码前后的权重变化; - 再使用高层 API 的 Mask 参数,对比结果是否一致。
- 最后再去看 Flash Attention 和 SDPA 的内部优化。
这套路线可以让你不走弯路。我见过太多人从 Flash Attention 的源码入手,结果被掩码形状搞得头昏脑涨,基础概念反而没吃透。
7.2 实际调参中的一个小技巧
如果你用 Hugging Face 的 Trainer 进行微调,需要传attention_mask,那么你的 Data Collator 建议用框架自带的DataCollatorWithPadding,它会自动生成 mask,不需要自己手写。这一点很多人不知道,结果自己写了半天 padding 逻辑,还不如官方一行调用干净利落。
手写 batch 拼接时,注意设置padding=True, truncation=True, return_tensors="pt",tokenizer 就会返回已经包含attention_mask的字段。这比手动生 mask 靠谱得多。
7.3 关于负无穷的一个边界案例
我曾在某次实验中观察到,当 batch 特别小、填充比例特别高的时候,即使掩码正确,模型训练初期的 Loss 也可能出现异常波动。原因是有效 token 太少,分母太小,数值稳定性较差。
解决办法非常简单:调整 batch size 或数据排序策略,让填充比例尽量低。你可以按序列长度排序采样,让同一批次的样本长度接近,从而减少填充数量。这个技巧对自回归训练效果提升尤其明显。
8. 最后给你一个可以直接复制的完整示例
这里我把上述要点合成一个可直接运行的完整示例,包含 mask 创建、注意力掩码、损失屏蔽三部分。你拿去稍作修改就能用在项目里。
import torch import torch.nn.functional as F # 模拟一批输入,padding_token_id=0 input_ids = torch.tensor([ [1, 2, 3, 4, 0, 0], [1, 2, 3, 0, 0, 0], [1, 2, 5, 6, 7, 0], ]) pad_token_id = 0 # 1. 生成 Padding Mask(有效位置为 1) attention_mask = (input_ids != pad_token_id).long() # [batch, seq_len] # 2. 手动实现单头注意力并应用掩码 def attention_with_mask(q, k, v, mask): # q, k, v: [batch, seq_len, dim] dim = q.size(-1) scores = torch.matmul(q, k.transpose(-2, -1)) / (dim ** 0.5) # 关键:有效位置为 1 的 mask 转换为布尔掩码,填充位置 True bool_padding = mask == 0 # 调整形状以便广播到注意力分数矩阵 [batch, 1, 1, seq_len] bool_padding = bool_padding.unsqueeze(1).unsqueeze(1) scores = scores.masked_fill(bool_padding, float('-inf')) attn_weights = F.softmax(scores, dim=-1) output = torch.matmul(attn_weights, v) return output, attn_weights # 3. 计算 Loss 时忽略填充符 labels = torch.tensor([ [2, 3, 4, 5, -100, -100], [2, 3, 4, -100, -100, -100], [2, 3, 5, 6, 7, -100], ]) logits = torch.randn(3, 6, 10) # 模拟 logits,10 是 vocab_size loss = F.cross_entropy(logits.reshape(-1, 10), labels.reshape(-1), ignore_index=-100) print(loss.item())上述代码里我故意在labels填充位置使用了-100作为 ignore 值,这是另一种常见且安全的写法,避免和pad_token_id=0混淆。这种设置在很多训练框架中也是默认惯例。
从我个人的经验来看,Padding Mask 最大的难点从来不是概念本身,而是框架之间的掩码约定差异,以及它和因果 Mask 的协作问题。只要你能亲手打印出一次 mask 前后的注意力权重,这个知识点就算彻底掌握了。之后再遇到模型输出<pad>、Loss 虚低、生成重复词等问题,你的排查路径就会清晰很多。