1. 从一次模型“失语”说起:表达退化到底是什么
如果你最近在折腾大语言模型或者深度文本表示,可能会遇到一个让人抓狂的现象:模型训练损失明明在降,下游任务指标却纹丝不动,甚至悄悄往下掉。更诡异的是,你把模型中间层输出的向量拿出来一看,发现几乎所有句子的表示都挤在向量空间的一个极小角落里,彼此之间的余弦相似度高得离谱——不管输入是“今天天气不错”还是“这家餐厅难吃死了”,模型给出的句向量几乎一模一样。这个现象,就是表达退化(Representation Degeneration)。
我第一次撞上这个问题,是在做一个语义相似度匹配的小项目。当时用了一个标准的Transformer编码器,训练集上对比学习损失降得很漂亮,但一到验证集,模型就像“失语”了一样,对任何输入都给出高度相似的表示。排查了半天,最后把句向量做PCA降维可视化,才发现所有点几乎重叠在一起,方差小得可怜。这就是典型的表达退化:模型失去了区分不同输入的能力,表示空间发生了坍缩。
表达退化不是某一个具体bug,而是一类现象的统称。它可能表现为维度坍缩(少数几个维度承载了几乎全部方差,其余维度接近常数)、各向异性(所有向量集中在某个狭窄锥体内,方向高度一致)、或者秩崩塌(表示矩阵的有效秩远低于理论维度)。在NLP领域,这个问题在句子表示学习、对比学习、自监督预训练中尤其常见,因为这类任务往往依赖大量负样本对来“撑开”表示空间,一旦负样本采样或温度系数设置不当,模型就会找到一条“捷径”——把所有东西映射到同一个点,损失照样能降,但表示已经废了。
这篇文章适合谁看?如果你正在做文本嵌入、语义检索、句子相似度、对比学习微调,或者只是好奇为什么自己的模型“学了个寂寞”,那这篇内容应该能帮到你。我会从底层机制讲起,拆解表达退化的几种典型形态,然后给出可复现的检测方法和实操层面的缓解策略。不堆公式,尽量说人话,把我在实际项目里踩过的坑和验证过的方案都摊开讲。
2. 表示空间为什么会“塌”:三种退化形态的底层机制
2.1 维度坍缩:当方差被少数维度垄断
维度坍缩是表达退化里最直观的一种。假设你有一个768维的句向量,理论上每个维度都应该承载一定的信息量。但实际训练中,你可能会发现前10个维度的方差占了总方差的95%以上,剩下700多个维度几乎是一条直线——方差接近于零,不同样本在这些维度上的取值几乎没区别。
这种情况的根源,通常和对比学习的目标函数有关。以InfoNCE损失为例,它鼓励正样本对靠近、负样本对远离。但如果负样本的数量不够多、或者负样本的分布太单一,模型只需要在少数几个维度上把正负样本拉开,就能让损失降下去。剩下的维度因为没有梯度信号去“激活”它们,就慢慢退化成常数。这就像一间大教室,本来每个座位都该坐人,结果所有人都挤在前排几个位置上,后排全空着。
更麻烦的是,这种坍缩往往是渐进式的。训练初期看不出来,随着训练轮次增加,方差越来越集中,等你发现的时候,模型已经“偏科”严重了。我在一个文本聚类任务里就遇到过,训练到第5轮的时候聚类指标还在涨,第10轮开始持平,第15轮直接崩掉——回头一看,表示的有效秩从最初的200多降到了不到20。
2.2 各向异性:所有向量挤在一个锥子里
各向异性是另一个高频问题,尤其在预训练语言模型的原始输出中很常见。简单说,就是所有句向量都指向大致相同的方向,彼此之间的夹角很小。你算一下任意两个句向量的余弦相似度,发现平均值高达0.8甚至0.9,而理想情况下这个值应该在0附近。
这种现象的成因,和语言模型本身的训练目标有关。自回归语言模型或者掩码语言模型在预训练时,主要任务是预测下一个词或还原被掩码的词,它们并不直接优化句向量的区分度。结果就是,高频词、常见句式对表示的“拉扯”方向高度一致,导致所有句子都被推到了表示空间的同一个区域。这就像一群人拍照,本来应该散开站,结果全挤到了镜头正中央。
各向异性对下游任务的伤害是隐性的。做语义相似度时,你会发现模型对“相似”的判断还算准,但对“不相似”的判断一塌糊涂——因为所有句子的基线相似度就很高,真正相似的句子反而凸显不出来。做检索时更明显,召回的结果往往集中在某个主题附近,多样性极差。
2.3 秩崩塌:表示矩阵的“虚假繁荣”
秩崩塌是三种形态里最隐蔽的。表面上看,你的表示矩阵是N×D的,N个样本、D个维度,秩理论上可以到min(N, D)。但实际算一下奇异值分解,你会发现前几个奇异值特别大,后面的迅速衰减到接近零,有效秩可能只有几十甚至十几。
这种情况在小批量训练或者负样本复用的场景下特别容易发生。因为每次梯度更新只看到一小批数据,模型很容易在这批数据上找到一个低秩的“捷径”解,让损失降下去。但这个小批量上的低秩解,放到全局来看就是严重的表达退化。更坑的是,这种退化在训练损失上往往看不出来——损失曲线很平滑,甚至还在降,但表示的秩已经悄悄崩了。
我印象最深的一次,是在一个跨领域文本匹配项目里。训练集和验证集的损失都在降,但验证集的F1就是上不去。后来把每一层的表示矩阵拿出来算有效秩,发现从第6层开始,秩就断崖式下跌。原因是我们用了太大的学习率,加上批量太小,模型直接“抄近路”了。把学习率降下来、批量提上去之后,秩才慢慢恢复。
3. 怎么判断模型“病”了:可落地的检测指标与排查链路
3.1 三个核心指标:方差占比、平均余弦相似度、有效秩
检测表达退化,不需要多复杂的工具,三个指标基本就能覆盖大部分情况。
方差占比:对表示矩阵做PCA,看前k个主成分的方差占总方差的比例。如果前10个主成分占比超过90%,基本可以判定维度坍缩。实操上,用sklearn的PCA或者直接对表示矩阵做SVD都行,几行代码的事。
平均余弦相似度:随机采样一批句向量,两两算余弦相似度,取平均值。如果这个值超过0.7,说明各向异性比较严重;超过0.9,基本就是所有向量挤在一起了。注意要随机采样,不要只取同一类的样本,否则会高估相似度。
有效秩:对表示矩阵做奇异值分解,把奇异值归一化后,计算熵或者直接数大于某个阈值的奇异值个数。有效秩远低于理论维度(比如768维的表示,有效秩只有30),就是秩崩塌的信号。
这三个指标我一般会做成一个监控脚本,在每个epoch结束后自动跑一遍,输出成表格。这样一旦某个指标异常,能立刻发现,不用等到下游任务崩了才回头查。
| 指标 | 健康范围 | 预警阈值 | 检测频率 |
|---|---|---|---|
| 前10主成分方差占比 | < 70% | > 85% | 每epoch |
| 平均余弦相似度 | < 0.3 | > 0.7 | 每epoch |
| 有效秩(768维) | > 200 | < 50 | 每epoch |
3.2 可视化排查:PCA散点图与相似度热力图
指标是量化的,但可视化能帮你更快定位问题。我习惯做两张图:一张是PCA降维后的二维散点图,按类别着色;另一张是随机采样100个句向量的相似度热力图。
PCA散点图如果显示所有点挤成一团,颜色混杂,那就是表达退化的典型画面。健康的情况下,不同类别的点应该有一定的分离度,同类点相对聚集但不过度重叠。相似度热力图如果整体偏红(高相似度),说明各向异性严重;如果对角线附近有块状结构,说明模型还是学到了一些区分度。
这两张图我一般用matplotlib和seaborn画,代码不复杂,但直观程度远超数字指标。尤其是给团队做汇报的时候,一张图比一堆数字管用得多。
3.3 排查链路:从损失曲线到表示矩阵的完整复现
发现指标异常后,怎么定位根因?我总结了一条排查链路,按顺序走基本能覆盖大部分情况。
第一步,看损失曲线。如果训练损失降得特别快、特别低,但验证损失不降反升,说明模型可能在“抄近路”。这时候要警惕表达退化。
第二步,看表示矩阵的统计量。算一下每个维度的方差,如果大部分维度的方差接近零,就是维度坍缩。算一下平均余弦相似度,如果偏高,就是各向异性。
第三步,看有效秩的变化趋势。如果有效秩随着训练轮次持续下降,说明退化在加剧。这时候要回头检查超参数:学习率是不是太大了?批量是不是太小了?温度系数是不是设得太极端了?
第四步,做消融实验。把学习率降一半、批量翻倍、温度系数调回常规范围,分别跑一遍,看哪个改动对有效秩的恢复最明显。这一步能帮你锁定主要矛盾。
注意:排查的时候一定要用验证集或测试集的表示来算指标,不要用训练集。训练集上的表示往往因为过拟合而看起来“正常”,但验证集上的退化才是真实问题。
4. 对症下药:从数据、损失到架构的缓解策略
4.1 负样本策略:数量、质量与去偏
负样本是对比学习的命脉,也是表达退化最常见的诱因。负样本数量不够,模型没有足够的“推力”去撑开表示空间;负样本质量差(比如太容易区分),模型学不到细粒度的区分能力;负样本有偏(比如都来自同一领域),模型会把表示空间压缩到那个领域的子空间里。
实操上,我一般会保证每个正样本对至少配64个负样本,条件允许的话上到256甚至更多。如果显存不够,可以用队列机制——维护一个负样本队列,每次只从队列里采样一部分,但队列整体在持续更新。这样既能用到大数量级的负样本,又不会爆显存。
负样本的质量也很关键。太容易的负样本(比如随机采的完全不相关句子)对模型帮助有限,太难的可能引入噪声。我习惯用半难负样本:从当前模型认为相似度中等偏上的样本里采样。具体做法是,先用模型算一遍相似度,取相似度在0.3到0.7之间的作为候选负样本。这个区间既有区分度,又不会太难导致训练不稳定。
去偏方面,如果负样本来自不同领域,要注意领域间的比例平衡。我遇到过一次,负样本里80%都是新闻类文本,结果模型把表示空间压缩到了新闻领域,做其他领域的匹配时效果极差。后来按领域分层采样,每个领域至少占10%,问题才解决。
4.2 温度系数与损失函数:别让模型找到“捷径”
温度系数τ在对比学习里是个关键超参数。τ太小,模型对相似度差异过于敏感,容易过拟合到噪声上;τ太大,正负样本的区分度被抹平,模型学不到东西。更隐蔽的是,τ设置不当会直接诱发表达退化。
我的经验是,τ的初始值设在0.05到0.1之间比较稳妥。如果发现有效秩下降快,可以适当调大τ,比如从0.05调到0.1,给模型更多“呼吸空间”。反过来,如果模型区分度不够,可以稍微调小τ,但不要低于0.02。
损失函数方面,InfoNCE是标配,但可以加一些正则项来抑制退化。比如均匀性损失,直接惩罚表示空间中向量的平均相似度,鼓励它们分散开。或者加白化损失,对表示做白化变换后计算损失,强制各维度方差均衡。这些正则项的权重不用太大,0.01到0.1之间就够了,太大反而会干扰主任务的学习。
还有一个技巧是动态温度:训练初期用较大的τ,让模型先学粗粒度的区分;训练后期逐渐调小τ,让模型学细粒度的区分。这样既能避免早期坍缩,又能保证后期的区分度。
4.3 架构层面的“防塌”设计:白化、归一化与残差
如果数据和损失都调过了,退化还是严重,那就得从架构上想办法。
白化操作是最直接的。在句向量输出后加一个白化层,把表示矩阵的均值和协方差矩阵标准化,强制各维度方差为1、维度间不相关。这样能有效缓解维度坍缩和各向异性。白化矩阵可以用训练集统计出来,固定住,也可以做成可学习的。我一般先用固定的白化矩阵试,效果不够再考虑可学习的。
归一化方面,LayerNorm和BatchNorm都有帮助,但要注意位置。在Transformer的每一层后面加LayerNorm是标配,但在句向量输出前再加一个BatchNorm,对抑制各向异性效果更明显。不过BatchNorm对批量大小敏感,批量太小的时候统计量不准,反而可能引入噪声。
残差连接也能帮上忙。如果发现深层表示退化严重,可以把浅层表示和深层表示做加权融合,让浅层的信息“兜底”。具体做法是,取最后几层的表示做加权平均,权重可以固定(比如等权),也可以让模型自己学。这样即使深层退化了,浅层的区分度还能保留一部分。
| 策略 | 适用场景 | 预期效果 | 注意事项 |
|---|---|---|---|
| 白化层 | 各向异性严重 | 显著降低平均余弦相似度 | 统计量需在训练集上估计 |
| 均匀性损失 | 维度坍缩 | 提升有效秩 | 权重不宜过大 |
| 动态温度 | 训练不稳定 | 平衡粗/细粒度区分 | 调度策略需调参 |
| 浅层融合 | 深层退化 | 保留浅层区分度 | 融合权重需实验 |
5. 实战复盘:一个语义检索项目的完整调优过程
5.1 初始方案与第一次“翻车”
去年我参与了一个语义检索的小项目,目标是把用户查询和文档库做匹配。初始方案很标准:用预训练语言模型做编码器,对比学习微调,InfoNCE损失,温度系数0.05,批量大小64,负样本从当前批次里采。
训练过程看起来很顺利,损失从2.3降到0.4,验证集上的Recall@10从0.3涨到0.6。但到了第8个epoch,Recall突然掉到0.4,之后一路下滑。把表示拿出来一算,平均余弦相似度从0.4飙到0.85,有效秩从180降到30。典型的表达退化。
5.2 逐步排查:从超参数到数据分布
排查从超参数开始。学习率是3e-5,不算大;批量64,偏小但不算极端;温度0.05,在常规范围内。看起来都不是明显的问题。
然后看数据。负样本是从当前批次里随机采的,批次大小64,意味着每个正样本对只有63个负样本。而且因为批次是随机打乱的,负样本的领域分布很不均匀——有时候一批里全是新闻,有时候全是论坛帖子。这导致模型在不同批次上学到的“推开方向”不一致,表示空间被反复拉扯,最后干脆坍缩到一个保守的区域。
再看损失曲线。训练损失降得很快,但验证损失在第6个epoch就开始抬头。这说明模型在训练集上找到了“捷径”——把所有表示映射到相近的区域,损失照样能降,但泛化能力已经没了。
5.3 调整方案与效果验证
针对排查结果,我做了三处调整。
第一,把批量从64提到256,同时引入负样本队列,队列大小设为4096。这样每个正样本对能用到4096个负样本,而且队列里的样本跨批次累积,领域分布更均匀。
第二,温度系数从0.05调到0.08,并加了动态调度:前5个epoch用0.1,之后线性降到0.05。给模型更多“呼吸空间”,避免早期坍缩。
第三,在句向量输出后加了一个白化层,白化矩阵用训练集统计得到,固定不更新。同时加了一个均匀性损失,权重0.05。
调整后重新训练,有效秩稳定在200以上,平均余弦相似度降到0.35左右。Recall@10从0.6涨到0.78,而且训练过程稳定,没有再出现中途崩掉的情况。
5.4 踩过的坑与留下的经验
这个项目里我踩的最大的坑,是过早下结论。第一次发现退化的时候,我以为是学习率太大,直接把学习率降到1e-5,结果训练变得极慢,退化是缓解了,但模型也没学到东西。后来才意识到,学习率不是主因,负样本的数量和分布才是。
另一个坑是白化层的统计量。我一开始用当前批次的数据算白化矩阵,结果因为批次间分布差异大,白化矩阵不稳定,反而引入了噪声。后来改成用整个训练集统计一个固定的白化矩阵,问题才解决。
还有一个经验是:监控要趁早。如果我在第一个epoch就加上有效秩和平均余弦相似度的监控,可能第3个epoch就能发现问题,不用等到第8个epoch崩了才回头查。现在我做任何表示学习项目,都会在训练脚本里内置这三个指标的监控,每个epoch打印一次,异常了自动告警。
6. 几个容易被忽略的细节与个人体会
表达退化这个问题,说大不大,说小不小。它不会让模型直接报错,但会让模型“学了个寂寞”。我见过太多项目,损失曲线漂亮得很,下游指标就是上不去,最后发现是表示退化了。
有几个细节,我觉得值得单独拎出来说。
第一,不要迷信损失曲线。损失降不代表表示学好了。对比学习的损失函数有很多“捷径”可走,模型完全可以在表示退化的情况下把损失降得很低。所以一定要看表示本身的统计量,不要只看损失。
第二,批量大小和负样本数量要匹配。批量64配63个负样本,在简单任务上可能够用,但在细粒度区分任务上远远不够。如果显存允许,批量至少上到128,负样本数量至少256。显存不够就用队列,别硬扛。
第三,温度系数不是越小越好。很多人觉得温度小一点,模型对相似度差异更敏感,学得更好。但温度太小会让模型对噪声过度敏感,反而容易坍缩。0.05到0.1之间是比较稳妥的范围,具体看任务。
第四,白化和归一化要慎用。白化能有效缓解退化,但会损失一部分信息。如果任务本身对表示的绝对尺度敏感,白化可能会带来副作用。我一般先不加白化,等确认退化严重了再加,加了之后也要重新调下游任务的阈值。
第五,监控要自动化。手动算指标太累,也容易忘。我现在的做法是,在训练脚本里写一个回调函数,每个epoch结束后自动算有效秩、平均余弦相似度和方差占比,输出到日志里。如果某个指标超过阈值,就发个提醒。这样即使训练跑一晚上,第二天早上也能第一时间知道有没有问题。
最后分享一个小技巧:如果你怀疑表示退化,但不确定严重程度,可以做一个简单的“随机替换”测试。把一批句子的表示随机打乱,然后算下游任务的指标。如果打乱后的指标和打乱前差不多,说明模型根本没学到区分度,表示退化已经很严重了。这个测试几行代码就能做,但能给你一个很直观的判断。
表达退化不是绝症,大部分情况下通过调整负样本策略、温度系数和加一点正则就能缓解。关键是要早发现、早干预,别等到下游任务崩了才回头查。希望这些经验能帮你少走点弯路。