资讯动态

PyTorch masked_fill 详解:一行代码解决 padding 带来的模型污染

发布时间:2026/10/14 23:34:37 来源:尧图企业网站定制
我第一次注意到这行代码是在处理一批不等长用户行为序列的时候。那批序列最长几百步最短只有几条记录按惯例 pad 到统一长度后丢进一个简单的 attention pooling 层结果训练 loss 反复震荡embedding 分布里还多出一坨“灰色地带”。排查了很久才意识到pad 出来的位置一直在参与计算模型等于每天都在看一堆无效输入。后来在参考实现里看到value value.masked_fill(input_padding_mask[..., None], float(0))加上之后整个模型收敛速度明显改善。这行代码看起来像一行“备注”实际上横跨了 PyTorch 的广播机制、mask 语义和数值稳定性三个问题很适合拿来单独拆解。本文就围绕这行代码把每个零件和实际使用场景展开讲读者主要是需要处理序列数据、做特征聚合或实现 Transformer 结构的开发者。1. 从一次收敛异常说起为什么序列模型总要对 padding 动手1.1 变长样本进了 Batchpadding 就成了绕不开的“副作用”深度学习训练离不开 batch。但文本、语音、用户行为序列天然是变长的GPU 并行要求一个 batch 内所有样本形状一致所以最常见的做法就是 pad短样本补零到与最长样本一致。from torch.nn.utils.rnn import pad_sequence # 每条样本是 (seq_len_i, hidden_dim)取 batch 内最长那个为基准 padded pad_sequence(seq_list, batch_firstTrue)这一步之后一个 batch 的形状变成了[B, T, D]其中 T 是 batch 内最长序列长度D 是特征维度。代价也随之而来长度不足 T 的样本后面多出来的全是无效位置。这些无效位置在进入 embedding 层之后同样会拿到一个“向量”而这个向量会在后续计算中被当成真实数据一样处理。很多入门项目在数据预处理阶段就把 padding 隐形了因为平时用单条样本调试看不出问题。一旦切换成 batch 训练模型就像“喝多了酒”效果忽好忽坏却很难一眼定位到 padding 上。你需要的其实不是删掉这些位置因为 batch 的张量形状是固定死的你只能“捂住”它们。1.2 让 padding 参与计算的三条污染链路padding 位置至少会在三个地方污染模型这也是masked_fill必须存在的根本原因链路一池化。最常见的操作是把序列 embedding 沿时间维做 sum 或 average pooling。padding 位置的向量会原封不动地加进去。真实长度只有 3 的样本pad 到 10avg pooling 一分母是 10真实语义被稀释了 70%。链路二注意力。如果用了 attentionpadding 位置会和真实 token 计算相似度然后在 softmax 里分走一部分概率权重。哪怕概率很小也是噪声更麻烦的是 padding 位置本身还会和 padding 位置互相关注产生没有意义的 attention 模式。链路三损失函数。在序列生成、序列标注这类任务里如果不处理 padding 位置的预测结果loss 会在这些无意义位置上回传梯度。模型被迫去“学习”预测一堆没有内容的位置训练目标被带偏。所以一个健壮的序列建模流程里mask 不是可选项而是必经步骤。value value.masked_fill(input_padding_mask[..., None], float(0))就是这条链路上的第一道闸门在特征进入后续计算之前先把 padding 位置的向量清零。1.3 mask 语义先对齐True 到底表示屏蔽还是保留这里必须先说清楚一个最容易混淆的点。不同代码库里的 mask 语义不一样比如input_padding_mask通常True表示该位置是 padding需要被屏蔽attention_mask很多开源实现里1表示“保留”0表示 padding尤其出现在预训练模型的输入里。标题这行代码用的是input_padding_mask可以确定它遵循“True 要填充”的约定。这一点必须时刻记在心里否则后续从别的项目中复制一个 mask 过来直接塞进masked_fill很可能把所有真实 token 都填成 0。后面我会专门讲这个坑。2. 把这行代码拆成四个零件mask、维度、填充值、赋值风格2.1 masked_fill一个“按图索骥”的填充操作torch.Tensor.masked_fill是 PyTorch 张量自带的方法。它的行为很直接给定一个布尔类型的 mask凡是 mask 中为True的位置就用指定的值替换False的位置保持原样。import torch value torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) mask torch.tensor([[False, False, True], [False, True, False]]) result value.masked_fill(mask, float(0)) # result: # tensor([[1., 2., 0.], # [4., 0., 6.]])注意几个细节第一mask 要求是torch.bool类型不是 0/1 的 long 或者 float。如果你手里是(input_ids ! pad_id).long()直接传进去会报错需要先.bool()。第二填充位置不参与梯度。masked_fill在反向传播时被填充位置的梯度是 0因为填充值是一个常数。这个特性非常关键它意味着你可以放心地把 padding 向量清零而不用担心这个操作本身引入额外的梯度噪声。第三mask 和 value 的形状不要求严格一致但必须满足广播规则。这也是标题代码里[..., None]存在的直接原因。2.2 [..., None]把二维 mask 扩到目标张量的最后一维假设value的形状是[B, T, D]batch 维、序列维、特征维。input_padding_mask的形状通常是[B, T]标记每个 token 是不是 padding但它只有两个维度没法直接和三维权值对齐。input_padding_mask[..., None]做的事情本质上是input_padding_mask.unsqueeze(-1)也就是在末尾新加一个维度让 mask 变成[B, T, 1]。然后masked_fill在广播机制的帮助下把这个[B, T, 1]的 mask 沿着 D 方向自动复制扩展。广播机制有一个简单理解的类比你有一枚[B, T, 1]的“图章”系统会在最后一维是 1 的位置自动把图章盖满整个特征维度。这个扩展是逻辑上的不会真的复制出一块大内存所以不会带来额外显存开销。但这里要注意[..., None]是“加到最后一维”。它能生效的前提是你的 mask 本来就是沿着 value 的倒数第二维也就是序列维 T做屏蔽。如果 value 是[B, T, D]这个写法正合适。如果 value 是四维的注意力分数[B, H, T, T]那么[..., None]就不够用了需要手动用[:, None, None, :]或者[:, :, None, None]精确指定要扩到哪一维。value 形状要屏蔽的轴mask 推荐写法[B, T]序列维 Tmask 直接使用[B, T][B, T, D]序列维 Tmask[..., None]或mask.unsqueeze(-1)[B, H, T, T]key 侧 padding最后一个 Tmask[:, None, None, :][B, H, T, T]query 侧 padding倒数第二个 Tmask[:, :, None, None]我自己平时写代码更习惯用unsqueeze(-1)而不是[..., None]因为显式方法在代码审查时一眼能看出加了几维。但这两种写法等价标题代码里用[..., None]也很常见尤其是在torch的参考实现风格里。2.3 float(0)填充值的 dtype 转换和写法习惯有人会问为什么不直接写0或者0.0非要写float(0)从功能上说三者最终效果一样PyTorch 会自动把标量值转换到value的 dtype。这里的float(0)更多是“显式表达意图”强调这里要填充的是一个浮点零而不是整数零。这个习惯在源码里确实很常见尤其是当代码同时可能处理 float32、float16、bfloat16 中间张量时float(0)能减少歧义。还有一个实际细节如果value是 fp16 或 bf16填充值也会被自动 cast 成对应精度的 0。你不需要担心类型不匹配的问题。反过来真正值得关心的是填充值在某个精度下的数值行为比如 fp16 下-1e9会被直接溢出成-inf这个问题在后面填-inf时我会再讲到。2.4 value 与 value.masked_fill_原地操作的两个陷阱标题代码小心翼翼地写了value value.masked_fill(...)而不是value.masked_fill_(...)。这个区别看着小实际影响很大。masked_fill返回一个新张量原来的value不变。masked_fill_带下划线会原地修改value本身。两者的坑主要体现在两个地方坑一原地操作和自动求导冲突。如果value是一个需要梯度的叶子张量或中间结果原地修改可能导致 PyTorch 在反向传播时报错或者静默地改变计算图得到错误的梯度。非原地版本在大多数情况下更安全。坑二视图连带修改。如果value是大张量的一个切片或 view原地修改会把原始张量也一起改掉。你可能只打算屏蔽某一层中间特征结果上游另一个共享这个内存的变量也“被屏蔽”了这种问题非常隐蔽。所以在训练路径里我基本只用非原地版本。只有在推理阶段、明确不需要梯度的场景下才会用masked_fill_省一点临时内存。3. 为什么这里填 0而 attention 里总填 -inf3.1 先分清你处理的是“特征”还是“logits”这是解读这行代码最关键的一点。同样是masked_fill填充float(0)和填充float(-inf)完全是两种用途。value value.masked_fill(input_padding_mask[..., None], float(0))的典型作用是把 padding 位置的特征向量“关闭”。在特征空间里零向量表示“这里没有有效内容”这是很自然的语义。后续做 sum pooling、mean pooling、拼接padding 位置都不会产生实际贡献。但如果value是 softmax 之前的注意力分数logits填充 0 就完蛋了。因为exp(0) 1即使在 softmax 里指数再小也仍然是一个非零概率。padding 位置照样会从模型那里分走注意力权重。3.2 一个数字例子score 位置填 0 会漏多少概率我们看一个具体的极端例子。假设注意力分数向量是[3.0, 1.0, 0.0]其中第三个位置是 padding前面两个是真实 token。如果第三个位置被填充成 0softmax 计算结果是exp(3.0) 20.09 exp(1.0) 2.72 exp(0) 1.00 分母 23.81 padding 位置的注意力权重 1.00 / 23.81 ≈ 4.2%这个 4.2% 看着不大但注意它意味着模型把 4.2% 的“注意力预算”花在了一个完全无意义的位置上。如果 batch 里长尾样本很多padding 比例很高累计损失会非常可观。更糟的是如果所有真实 token 的分数都很小padding 位置的权重还会进一步上升。所以凡是后面要接 softmax 的分数标准做法是填float(-inf)。这样exp(-inf) 0softmax 后 padding 位置严格等于 0不占任何概率。3.3 attention score 屏蔽的标准写法与 -1e9 的选择在多头注意力里通常是这样屏蔽 key 侧 padding 的# scores shape: [B, H, T, T] # key_padding_mask shape: [B, T] scores scores.masked_fill( key_padding_mask[:, None, None, :], float(-inf) ) attn_weights torch.softmax(scores, dim-1)这里[:, None, None, :]把[B, T]扩成[B, 1, 1, T]广播后正好对应注意力分数矩阵的最后一维也就是被 attend 的 key 位置。有人会问填-1e9行不行大部分情况可以exp(-1e9)在 float32 下就是 0。但如果你在 fp16 下训练-1e9超出 fp16 的表示范围会被自动转成-inf。这种情况下效果和float(-inf)反而一致。不过我仍然建议直接用float(-inf)语义更明确也不会因为 dtype 的边界行为产生意外。还需要注意一个极端情况如果某一行全部被 mask 覆盖比如整个样本都是 paddingsoftmax 会出现nan。工程上遇到全 padding 样本时一般会在数据阶段就过滤掉或者在 softmax 之后额外加一个很小的 epsilon 做保护。3.4 阅读代码的小技巧从 fill value 反推这段代码在干嘛这个技巧是我读开源代码时总结出来的非常实用看到float(0)说明这段代码大概率在操作特征向量看到float(-inf)或float(-1e9)说明这段代码大概率在操作 softmax 之前的分数。有时候一段代码长到几百行你不需要从头读。先扫一遍所有masked_fill调用看它们填的是什么值就能快速定位到哪几段是在做特征屏蔽、哪几段是在做注意力屏蔽。标题这行代码填的是 0所以它更可能出现在 embedding 输出层、特征聚合层这类位置而不是注意力内层。4. 这行代码最常见的三种藏身之处4.1 Encoder 特征层先把 padding 向量清空再交给池化第一种常见位置是序列编码器的前几行。输入 token 经过 embedding 变成[B, T, D]后紧接着就把 padding 位置清零hidden embedding(input_ids) # [B, T, D] hidden hidden.masked_fill( input_padding_mask[..., None], float(0) ) hidden transformer_encoder(hidden) # 后续计算不再受 padding 干扰这个位置加 mask 的好处是防御性最强从源头切断 padding 向量进入后续所有模块。Transformer 层内部的 attention 确实自带 key mask但池化、拼接、残差连接等路径未必有 mask提前清零能让整体更干净。我第一次看到这行代码时还担心一个问题把 embedding 向量清零后再经过多层 Transformerpadding 位置会不会又因为注意力交互重新“获得”非零向量答案是会的。注意力在计算时不会把全零向量特殊对待它一样能把非 padding 位置的向量“搬运”到 padding 位置。所以高级一点的实现会在每层 attention 得分处都做 mask而不只是入口处。对于只用池化、不用深层 Transformer 的模型入口清零就够用了。4.2 行为序列的 mask 池化mean 池化千万别除以 pad 后的长度第二种常见位置是序列特征聚合层。无论是推荐系统里的用户行为序列还是 NLP 里的句子 embedding最后都要把[B, T, D]压缩成[B, D]。这段代码通常长这样seq_emb embedding(seq_ids) # [B, T, D] seq_emb seq_emb.masked_fill( input_padding_mask[..., None], float(0) ) sum_pool seq_emb.sum(dim1) # [B, D] valid_len (~input_padding_mask).sum(dim1, keepdimTrue) # [B, 1] avg_pool sum_pool / valid_len.clamp(min1)这里有个非常经典的错误有人masked_fill做对了池化时却依然除以Tpadding 后的长度导致均值被稀释。正确做法是除以每个样本的真实有效长度也就是~input_padding_mask的和。举个简单的例子一个样本真实长度 2pad 到 10。两个真实位置的向量之和是[10.0]。如果你除以 10得到平均特征 1.0除以 2得到 5.0。显然 5.0 才是这个样本真正想要的语义表示。padding 位置虽然已经是 0 了但分母会硬生生把均值拉低所以分母必须排除 padding。4.3 注意力分数的 key 侧 mask多维张量下 None 放哪一层第三种位置是注意力分数计算内部。虽然标题代码写的是[..., None]但在多头注意力场景里[..., None]往往要替换成更精确的维度扩展写法。标准 self-attention 的分数矩阵是[B, H, T, T]H 是头数。你需要在T方向屏蔽 key 的 padding在另一个T方向屏蔽 query 的 padding。写法分别是# scores: [B, H, T, T] # key_padding_mask: [B, T] scores scores.masked_fill( input_padding_mask[:, None, None, :], float(-inf) ) # query_padding_mask: [B, T]如果有必要 scores scores.masked_fill( input_padding_mask[:, :, None, None], float(-inf) )很多新手在这里抄代码容易把[:, None, None, :]写成[:, :, None, None]结果本来想屏蔽 key却把 query 侧屏蔽了。调试半天看不出问题因为 attention 结果仍然能算出来只是语义错了。还有一种常见需求训练结束做注意力权重可视化时希望热力图上 padding 区域干干净净。这时不需要动分数直接对已经过 softmax 的attn_weights做一次masked_fill清零即可attn_weights torch.softmax(scores, dim-1) attn_weights attn_weights.masked_fill( input_padding_mask[:, None, None, :], float(0) )注意这里填 0 是合理的因为权重本身已经是概率值清零后不影响其他位置的归一化结果只影响可视化。这次填 0 不是特征而是后处理效果等效于“在图上把不需要的区域涂白”。5. 动手验证一行 mask 的正确性以及我踩过的四个坑5.1 验证脚本形状、填充结果、梯度三个维度一起查每次写完带 mask 的代码我都会写一段极简验证脚本避免把隐患带进大模型训练。核心检查三件事padding 位置是否被正确填充非 padding 位置是否原样保留填充位置的反向梯度是否为 0。import torch B, T, D 2, 4, 8 value torch.randn(B, T, D, requires_gradTrue) input_padding_mask torch.tensor([ [False, False, True, True], [False, True, True, True], ]) out value.masked_fill(input_padding_mask[..., None], float(0)) # 1. padding 位置全零 assert (out[input_padding_mask] 0).all() # 2. 非 padding 位置保持一致 assert torch.equal(out[~input_padding_mask], value[~input_padding_mask]) # 3. 梯度只落在非 padding 位置 out.sum().backward() grad_padding value.grad[input_padding_mask] grad_valid value.grad[~input_padding_mask] assert (grad_padding 0).all() assert (grad_valid 1).all() print(mask check pass)这个脚本 5 分钟能跑完能在早期拦住大部分问题。我强烈建议在每一个新增 mask 逻辑时都跑一遍。5.2 坑一mask 不是 bool报错和一根筋的 .long()PyTorch 的masked_fill只接受 bool 类型 mask。如果用的是 0/1 的 long 张量会看到类似这样的报错RuntimeError: expected a Bool tensor for mask常见的错误源头是习惯性写法mask (input_ids ! pad_token_id).long() # 错误 mask (input_ids ! pad_token_id) # 正确本身就是 bool如果 mask 是从某些数据处理流程里继承来的 long 类型统一在前面加一步mask mask.bool()。不要省这行代码不然同一个 bug 会在不同项目里反复出现。5.3 坑二忘了 [..., None]广播报错怎么读假设 value 是[B, T, D]mask 是[B, T]你直接写value value.masked_fill(input_padding_mask, float(0))大概率会得到类似这样的报错RuntimeError: The size of tensor a (T) must match the size of tensor b (D) at non-singleton dimension 2这个报错翻译成人话就是mask 的 T 维和 value 的 D 维对不上。PyTorch 在广播时按维度从右往左对齐[B, T]和[B, T, D]对齐到最后一个维度时T 和 D 不匹配而且 D 不是 1无法自动扩展。解决办法就是标题里的[..., None]或unsqueeze(-1)。一旦看到这种报错优先检查是不是维度扩展少了。5.4 坑三attention_mask 的 1 表示保留拿来直接 fill 全填错这是我在真实项目中踩过最值钱的一个坑。某个预训练模型的输入接口返回的attention_mask长这样# 1 表示保留非 padding0 表示 padding attention_mask torch.tensor([[1, 1, 1, 0]])它和标题里的input_padding_mask语义正好相反。如果你照着“True 填充”的习惯直接写value value.masked_fill(attention_mask[..., None], float(0))那么所有真实 token 反而被填成 0padding 位置毫发无损。正确写法先取反is_padding attention_mask.bool() 0 value value.masked_fill(is_padding[..., None], float(0))或者直接等价为keep_mask attention_mask.bool()[..., None] value value * keep_mask建议把这个语义差异刻在脑子里用masked_fill之前先确认你的 mask 是“True 要填”还是“True 要保留”。这一步错了结果基本全反。5.5 坑四masked_fill_ 连坐 view 背后的原张量有一次我在推理脚本里为了省显存把value value.masked_fill(...)改成了value.masked_fill_(...)。当时 value 是前一层输出的一个切片 view结果下游的残差连接也莫名出现了一堆 0。查了很久才发现是原地修改“穿透”了 view把底层共享内存的原张量一起改了。如果你也遇到这种诡异问题优先检查两件事代码里是否用了任何带下划线的原地操作当前变量是否为某个大张量的.view()、[:, 0, :]或expand产物。训练路径里非原地masked_fill是我的默认选择原因就在这。推理阶段如果确实想省内存也要先确认 value 不是一个共享视图。6. masked_fill 之外的“捂法”以及它们各自的脾气6.1 torch.where当条件和其他分支都不是恒值时torch.where(condition, a, b)可以实现类似效果value torch.where( input_padding_mask[..., None], torch.zeros_like(value), value )它的可读性比masked_fill更强尤其是当你希望“True 保留、False 填充”这种语义时把两个分支反过来写就行value torch.where( (~input_padding_mask)[..., None], value, torch.zeros_like(value), )torch.where的一个优势是它不要求 condition 严格为 bool0/1 张量也能工作。但如果 value 特别大torch.zeros_like(value)会额外创建一块全零张量内存占用比masked_fill稍高。在显存紧张的长序列任务里我倾向用masked_fill。6.2 乘法 mask能省一次布尔索引但要先转 dtype还有一个常见写法是直接用乘法keep_mask (~input_padding_mask).to(value.dtype).unsqueeze(-1) value value * keep_mask这个写法在 keep mask 是 0/1 浮点张量时非常高效而且不会改变梯度路径。但它有两个注意点必须先把 bool 转成 float直接用 bool 张量和浮点张量相乘在一些版本里会报类型错误乘法只适合目标值是 0 的场景。如果你想填的不是 0而是某个常数 c那就得value * mask c * (1 - mask)写起来反而更啰嗦。所以乘法方式我一般只在“清 0”场景用和masked_fill等价。6.3 pack_padded_sequence 与 mask 方案怎么选有人会提到torch.nn.utils.rnn.pack_padded_sequence它能在 RNN 内部跳过 padding 计算看起来比 mask 更“优雅”。但它有几个现实问题它要求输入按长度降序排列数据预处理会多一步它返回PackedSequence对象很多模块和 Transformer 层不支持直接输入在 GPU 并行度上固定形状的矩阵运算仍然比动态 pack/unpack 更容易优化。所以目前的主流做法是pad mask masked_fill。你宁可多算一点 padding 位置的矩阵乘法也要保住规则的 batch 张量形状。mask 的成本几乎可以忽略代码也更统一。6.4 一张表总结四种遮罩写法方法核心语义适用场景注意点masked_fillmask 为 True 的位置填固定值特征清零、logits 填 -infmask 要求 bool注意语义方向masked_fill_原地版本推理阶段省内存避免作用于共享 view可能破坏梯度torch.where按条件从两个张量中选值需要动态分支、保留分支非恒值会产生零张量临时内存乘法value * keep_mask保留位置乘以 1padding 乘以 0只想清 0 的简单场景需要先转 dtype只适合目标值 0这四种写法我都很常用。日常写特征清洗时默认masked_fill需要语义可读性更强的后处理时用torch.where推理阶段确定没有视图共享问题时才考虑原地版本。最后分享一个我养成的习惯每次写完类似value value.masked_fill(input_padding_mask[..., None], float(0))的代码我都在旁边加一行注释标明 mask 的最终形状是[B, T, 1]广播发生在 D 维。这个注释一开始看着多余但项目大了之后再回来看代码能省下大量回忆时间。也希望这篇文章能帮你在下一次遇到 mask 相关的 bug 时少走几步弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑