L2归一化能修复错误的平均池化吗
L2归一化有时会掩盖错误的平均池化,却不能普遍修复它。若错误仅把一个非零句向量乘上正数,单位方向不变;若padding隐藏状态进入求和、改变了方向,归一化仍会留下错误。只比较最终余弦相似度,可能漏掉前一种问题。
文章目录
这里固定token隐藏状态,分别构造零pad与非零pad,用相同代码验证两种结果。读完可以给句向量管线增加一个不依赖模型分数的池化回归测试。Read in English。
先分清注意力与池化的参与集合
注意力阶段的mask限制信息从哪些位置读取;平均池化阶段需要决定哪些输出行参加句向量的求和与计数。前一阶段传入过mask,不等于后一阶段可以对整个序列直接mean。尤其不能据此断言padding对应的输出隐藏状态一定全零。
Sentence Transformers v5.1.0的Pooling实现在普通mean分支中,把attention_mask扩展到隐藏维度,按mask求和,再除以有效位置计数,并为分母设下界。该版本还支持其他池化方式;这里讨论的公式限于没有额外token权重的普通平均。
设隐藏状态H的形状为B×L×D,mask为B×L。对一个样本,hᵢ是第i个位置的D维向量,mᵢ∈{0,1},n=Σmᵢ。n>0时,正确平均为u=Σ(mᵢhᵢ)/n。mask在最后一维广播,意味着同一个位置的所有特征一起参加或一起排除,不是给每个特征另算一种有效长度。
限制读取哪些位置
选择求和行与有效计数
改变长度,不清除错误方向
此前的padding_idx与注意力mask解释了零嵌入为何仍会影响注意力分母。本篇继续检查注意力之后的池化与归一化,重点是找出一个测试盲区,而不是再把“零向量”一概视为安全或危险。
分母错了,方向却可能恰好没错
把两个有效隐藏状态设为(2,0)和(0,2)。正确平均u=(1,1)。追加一行全零pad后,直接对三行求平均得到v=(2/3,2/3),显然v不等于u,但v=(2/3)u。
定义N(x)=x/‖x‖₂。对非零x和正数c,在精确数学下N(cx)=N(x),因为‖cx‖₂=c‖x‖₂。于是这两个向量归一化后都为(1/√2,1/√2)。如果单元测试只断言归一化输出接近,它会通过,即使平均分母已经用错。这里的归一化发生在池化之后;先把每个token分别单位化再平均,是另一种运算,不属于本例的抵消结论。
更一般地,若只保留有效位置的和S,却错误地除以补齐总长度L,而不是有效数量n,那么S/L=(n/L)u。只要u非零,这种错误具有相同的正比例关系。零pad只是最容易看见的特例,关键在于分子是否保持不变。
这不代表错误无害。管线若还使用未归一化向量做点积、范数阈值或其他计算,比例本身就有意义;若数学上接近零,具体归一化函数的eps下界也会影响结果。上面的抵消结论不能不加条件地推广到零向量或任意浮点实现。相关边界可接着看余弦相似度中的eps。
分子被污染后,单位长度也救不回来
仍保留原来的两个有效行,只把pad行改成(6,0)。正确mask排除它后,平均仍是(1,1)。未遮蔽平均则为((2+0+6)/3,(0+2+0)/3)=(8/3,2/3),方向与(4,1)相同,已经不是原向量的正倍数。
用查询q=(1,0)比较余弦分数:正确方向的分数为1/√2,约0.7071;污染后的分数为4/√17,约0.9701。保持查询与另一篇文档的表示不变,假设后者的单位向量为(0.8,0.6),其查询分数为0.8,那么两篇文档的顺序便会反转。这里的向量和分数都是教学构造,不是模型检索成绩,也不证明某个模型会出现同样幅度的误差。
正确平均:(1,1)
查询余弦≈0.7071
错误平均:(2/3,2/3)
单位方向相同
错误平均:(8/3,2/3)
查询余弦≈0.9701
若pad向量恰好与有效和共线,方向也可能仍然不变。因此,“有非零pad”本身不是方向必然变化的充分条件。能推出的结论是:一旦未参与位置进入求和,就不能再靠统一的比例抵消保证结果正确;需要实际检查分子及其方向。
二十四项检查,把被掩盖的错误也测出来
下面是可独立运行的Python标准库程序。池化用Fraction保留例子里的精确有理数,归一化转为浮点数并用容差比较。它只验证固定隐藏状态的归约,没有加载Transformer、运行PyTorch或测试GPU内核。
from fractions import Fraction as F
from math import sqrt, isfinite, isclose
def mean_pool(rows, mask):
if not rows or len(rows) != len(mask):
raise ValueError('empty input or mask length mismatch')
d = len(rows[0])
if not d or any(len(row) != d for row in rows):
raise ValueError('invalid hidden dimension')
if any(m not in (0, 1) for m in mask):
raise ValueError('mask must contain 0 or 1')
if any(not isfinite(x) for row in rows for x in row):
raise ValueError('hidden states must be finite')
count = sum(mask)
if not count:
raise ValueError('no participating token')
return tuple(sum((F(row[j]) for row, m in zip(rows, mask) if m), F(0)) / count
for j in range(d))
def unit(v):
length = sqrt(sum(float(x) ** 2 for x in v))
if length == 0:
raise ValueError('zero vector has no direction')
return tuple(float(x) / length for x in v)
def close(a, b):
return all(isclose(x, y, rel_tol=1e-12, abs_tol=1e-12) for x, y in zip(a, b))
checks = 0
def check(condition):
global checks
assert condition
checks += 1
def reject(fn):
try:
fn()
except ValueError:
check(True)
else:
raise AssertionError('expected ValueError')
valid = [(2, 0), (0, 2)]
correct = mean_pool(valid, [1, 1])
check(correct == (1, 1))
zero_pad = valid + [(0, 0)]
wrong_zero = mean_pool(zero_pad, [1, 1, 1])
check(wrong_zero == (F(2, 3), F(2, 3)))
check(wrong_zero != correct)
check(close(unit(wrong_zero), unit(correct)))
nonzero_pad = valid + [(6, 0)]
wrong_nonzero = mean_pool(nonzero_pad, [1, 1, 1])
check(wrong_nonzero == (F(8, 3), F(2, 3)))
check(not close(unit(wrong_nonzero), unit(correct)))
check(mean_pool(nonzero_pad, [1, 1, 0]) == correct)
# A correct numerator with the wrong length is still only a rescaling.
wrong_denominator = tuple(x * F(2, 3) for x in correct)
check(wrong_denominator != correct)
check(close(unit(wrong_denominator), unit(correct)))
# These are synthetic cosine scores against query (1, 0).
check(unit(correct)[0] < 0.8 < unit(wrong_nonzero)[0])
for n in (1, 3, 7):
rows = valid + [(6, 0)] * n
check(mean_pool(rows, [1, 1] + [0] * n) == correct)
check(mean_pool(rows, [1] * len(rows)) != correct)
check(mean_pool(list(reversed(nonzero_pad)), [0, 1, 1]) == correct)
for rows, mask in [([], []), (valid, [1]), (valid, [0, 0]),
([(2, 0), (1,)], [1, 1]), (valid, [1, 2]),
([(float('nan'), 0)], [0])]:
reject(lambda rows=rows, mask=mask: mean_pool(rows, mask))
reject(lambda: unit((0, 0)))
print(f'{checks} pooling checks passed')
print('correct:', tuple(map(float, correct)))
print('unmasked nonzero pad:', tuple(map(float, wrong_nonzero)))
print('synthetic query cosine:', unit(correct)[0], unit(wrong_nonzero)[0])
本地输出为“24 pooling checks passed”。程序分别检查直接平均全部行,以及只保留有效分子却除以总长度这两种错误。先检查未归一化平均,再检查方向与构造的分数顺序;随后追加1、3、7个被排除的非零行,验证正确mask下结果不变。还把行与mask一起反转,确认参与关系跟随位置数据移动。
空输入、长度不匹配、全零mask、隐藏维度不一致、非二值mask及非有限隐藏状态均被拒绝;零句向量也不做单位化。这是本示例选择的输入契约。函数通过筛选有效行求和,不能拿它对NaN的处理去替代张量乘法语义;程序主动拒绝全部非有限输入,因此没有宣称乘以零就能消除NaN。
如何把这个测试接到真实模型
先保存一次真实前向的隐藏状态与mask,只测试池化函数。保持有效行不变,追加几行非零合成pad,并把新增mask设为0。正确的池化输出应在合理浮点误差内一致。非零pad比全零pad更有诊断价值,因为它能揭露漏掉分子mask的问题,而全零pad可能只暴露一个随后被归一化抵消的缩放。
比较应分成两个断言:原始池化值符合参考公式,最终归一化值符合参考方向。只测后者不足以证明前者正确;只测向量长度接近1更弱,因为错误方向也可以被变成单位长度。排查时同时记录有效数量、分子与归一化前范数,通常比只保存最终相似度更容易定位偏差。
随后再做端到端的补齐测试。此时要固定分词、截断、有效token的位置语义和随机因素,只改变padding数量或批内配对。若有效隐藏状态本身已经变化,就应先检查上游计算,不能把差异全归咎于mean pooling。池化层的不变性证明只针对输入有效行固定的条件,不是对整个模型的无条件保证。
最后核对模型的池化契约。CLS、末token、普通mean和带权mean不是可以随意互换的实现细节;也不要因为某些token看起来是“特殊符号”,就自行把它们从mask中去掉。是否包含它们、是否包含提示前缀,应遵循所用模型与配置,而不是用本例的二值mask替代完整模型设计。
全padding输入需要业务决定
当n=0时,平均没有数学定义。示例选择抛异常,让无参与token的问题直接暴露。库实现给分母设下界可以避免某种除零运算,但不能使一个没有有效内容的样本自动获得有意义的语义表示。业务可以拒绝、跳过或显式标记这类输入;选哪种都应在进入相似度和索引之前定义清楚。
这里的“全padding”是mask全零,不等于用户输入空字符串。分词器仍可能产生特殊token,是否参加池化需要另外核实。不要根据原始字符串长度猜测n,也不要用“没有抛错”代替检查句向量是否可用。
修复时优先保证参与集合正确,再看归一化是否符合所用相似度。能够把比例错误和方向错误分开,就能解释为什么有些测试一直通过,而换一个批次或一条pad隐藏状态后,检索顺序突然发生变化。
来源与补发说明
实现依据为Sentence Transformers v5.1.0 Pooling.py的普通mean分支,该版本于2025年8月6日发布。本文没有把它称为当前最新版本。两幅图、向量反例、排名反转与二十四项检查为原创教学内容;没有运行真实句向量模型,也没有测量检索质量。
文章日期保留2026年10月23日14:00(北京时间),实际于2026年10月25日补发。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


