ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

对比学习实战:从表示学习到向量召回的关键逻辑与工程踩坑

对比学习实战:从表示学习到向量召回的关键逻辑与工程踩坑

我习惯把啃深度学习资料的过程拆成一份份编号笔记,这次单独把deeplearningbook_016-1拎出来聊。按我自己的归档规则,016是第十六讲,-1代表这一讲的上半部分;内容一句话概括:当标签不够用,深度学习怎么靠“对比学习”把特征表示学出来。我在读完这份笔记之后,把其中思路原封不动搬到手头一个短文本召回项目里跑了一遍,中间踩了些很典型的坑,也搞明白了很多以前只停留在口头上的概念。这篇博文就把笔记里的关键逻辑、可复现代码、以及真实训练中遇到的问题一起整理出来,适合正在做向量召回、图像检索,或者打算给冷启动场景配一套预训练方案的人参考。

1. 先拆文件名:016-1 这份笔记到底在解决什么问题

1.1 从两段式到端到端:特征表示是怎么“长”出来的

很多人做机器学习项目时有过这种惯性:先整理一批手工特征,比如文本里的词频、TF-IDF、图片里的颜色直方图、边缘密度,再把特征丢给分类器或者回归模型。这种方式本身没错,但它把“特征工程”和“最终任务”硬生生切成了两段。特征一旦定下来,模型能学到的上限也就定下来了,后面调参再努力,也只是在既定框架里做优化。

016-1这份笔记讲的表示学习,思路刚好是反着来的:不预先定义特征,而是让网络在靠近输入端的位置自己整理出一份中间表示,这份中间表示再往后面的任务层传。深度学习里天天挂在嘴边的 embedding、编码向量、语义向量,本质就是这份中间表示。为什么说这件事重要?因为很多场景下你根本说不清什么特征真正有用。比如判断两条商品评论是不是在表达同一个意思,靠关键词重合度往往不准,“价格贵但质量好”和“性价比高”在字面上差异很大,但语义上可以很接近。手工业规则永远追不上这种表达变化,让网络自己从海量样本里学到“什么样的特征组合更合理”,才是端到端的价值所在。

我一直觉得,“表示”这个词容易把人唬住。把它换成“编码后的向量”就好懂了。模型读一条数据,吐出一个固定维度的向量;这个向量落在空间里的位置越能反映真实语义关系,表示就越好。比如“苹果”和“香蕉”的向量距离近,“苹果”和“汽车”的向量距离远,这就是好的表示。016-1的核心问题,就是怎么在没有大量人工标注的情况下,让网络把这种距离关系训练出来。

1.2 为什么是非得“对比”,而不能继续“分类”

分类任务简单直接:给样本一个标签,模型学会预测标签。但标签是这个时代最贵的东西。一个项目里几百万条数据,标得动的最多几万条,剩下大量无标注数据躺在那里,单独丢掉可惜,硬标又没钱没时间。对比学习回答的正是这个问题:没有标签,模型还能从数据本身挖出监督信号吗?能。

我可以打个比方。教小朋友认识动物,如果每条都要告诉他“这是猫、这是狗”,成本很高;但如果只说“这两张图里是同一个东西,那张图里不是”,小朋友一样能慢慢建立起对“同一类物体长什么样”的感知。对比学习就是让模型做这个事:同一份数据做两次不同的增强变换,得到两个“视图”,模型要能从一堆样本里把这些本来属于同一条数据的视图找出来。它没学过类别名称,但学会了数据之间的相似与差异,这种相似性结构本身就是极有价值的表示。

这也解释了为什么这一讲不叫“无监督学习”,而叫“自监督学习”——它用数据自己构造标签,把无监督问题重新包装成了有监督问题。后面整篇笔记所有公式、模型结构、训练细节,都是在给这件事打补丁。

2. 第十六讲第一部分的算法骨架:把无监督视图变成有监督信号

2.1 代理任务:没有标签,就自己给自己出题

对比学习里最先要理解的概念是“代理任务”。真实任务是你最终想做的分类、检索或排序,但这个任务暂时没有标签,所以模型先做一件看似不同、实际相关的任务:识别“同一个数据的不同视角”。代理任务做好了,学到的表示就能迁移到真实任务上。

具体到实现,你需要对每个训练样本做两次数据增强。增强的方式依模态而定:图像可以是随机裁剪、翻转、颜色抖动;文本可以是随机屏蔽部分词、调整语序。增强后的两个样本构成一个“正样本对”。与此同时,同一个 batch 里其他样本都作为“负样本”。模型要做的事情是:在向量空间里把正样本对拉近,把负样本推开。这个目标函数由对比损失来承担,接在编码器后面。

值得强调的是,正样本对虽然来自同一条原始数据,但经过增强之后,两者在输入空间里已经不太一样了。模型只有学会“忽略那些不影响语义的变化,保留真正稳定的语义信息”,才能把两个视图识别成同一个东西。所以对比学习不是在记住样本,而是在学一种不变性。这种不变性,正是下游任务最需要的泛化能力。图像分类需要应对不同拍摄角度,检索需要应对同一商品不同角度的照片,本质都是要模型对这些变化“视而不见”。

2.2 InfoNCE 与温度系数:拉开还是聚拢,靠一个参数调节

016-1里最核心的公式是 InfoNCE 风格的对比损失。思想很好懂:给定一个正样本对,模型要知道这个正样本对在所有可能配对里的相对地位。写成公式就是:

[ L = -\log \frac{\exp(\mathrm{sim}(z_i, z_j) / \tau)} {\sum_{k=1}^{2N} \exp(\mathrm{sim}(z_i, z_k) / \tau)} ]

这里 ( z_i ) 和 ( z_j ) 是同一个样本两个增强视图的向量表示,(\mathrm{sim}) 通常是余弦相似度,(\tau) 是温度系数,(N) 是 batch size。分母把整个 batch 内所有样本对都算进去,也就是说模型要把正样本对从一群负样本中分辨出来。

温度系数 (\tau) 是个容易被忽略但极其关键的超参数。它控制着相似度分布的“尖锐程度”。(\tau) 越小,相似度分数被放大得越厉害,损失函数就越“较真”,只关注那些最像正样本的难负样本,训练容易变得不稳定;(\tau) 越大,所有样本的区分度被摊平,模型不容易区分正负,学到的表示会比较松散。一般实践中 (\tau) 取 0.05 到 0.2 之间,我自己的经验是先固定在 0.1,把 batch size 和数据增强确定下来之后再调它。

这里也顺便回答一个新手常问的问题:为什么编码器输出的向量还要再过一层“投影头”再算损失?因为最终用于下游任务的表示应该尽量保留完整信息,而对比损失会把向量里的信息压缩成“足够区分正负样本”的程度。投影头就像一个缓冲池,让损失函数在这个小空间里折腾,真正的表示在主干网络里保留更多细节。训练完成后,下游任务用的是投影头之前的主干输出,而不是投影头输出。

2.3 数据增强的“度”决定了表示质量的上限

我在读这份笔记之前一直有一个误解:以为对比学习的难点在网络结构或者损失函数上。真正跑起来才发现,数据增强的选择才是决定表示质量的头号因素。增强太弱,两个视图几乎没有差异,模型不需要理解语义就能轻松区分正负样本,学出来的表示在真实场景里一碰就碎;增强太强,两个视图可能已经不再是“同一个语义”,模型想学也学不动,损失曲线直接躺平。

图像场景里,随机裁剪很重要,它强迫模型不依赖某一个局部区域;颜色抖动能降低模型对颜色的过度敏感。文本场景里,常用的增强是 random masking,按一定比例把词遮掉,让模型学会根据上下文补全语义;也可以做随机词级替换,但替换词必须保证语义方向一致,否则会把正样本对变成事实上的负样本。总之,增强的目标是“破坏掉那些不重要的表面信息,保留核心语义”,而不是把数据变成一段随机噪声。

3. 用 PyTorch 复现一个 016-1 风格的对比学习基线

3.1 编码器加投影头:一套可以换主干的骨架

为了快速验证思路,我用一个简单的 MLP 编码器做了原型。实际换成 CNN 就是图像对比学习,换成序列模型就是文本对比学习,骨架部分完全不用动。我把这个结构写在下面,注意主干网络和投影头是分开定义的,这样下游任务可以直接拿到主干输出h。

import torch import torch.nn as nn import torch.nn.functional as F class Encoder(nn.Module): def __init__(self, input_dim=64, feat_dim=128): super().__init__() self.backbone = nn.Sequential( nn.Linear(input_dim, 128), nn.ReLU(inplace=True), nn.Linear(128, 128), nn.ReLU(inplace=True), ) self.proj = nn.Sequential( nn.Linear(128, 64), nn.ReLU(inplace=True), nn.Linear(64, feat_dim), ) def forward(self, x): h = self.backbone(x) z = self.proj(h) return h, z

这里的input_dim是原始输入特征维度,feat_dim是投影后做损失计算的维度。投影头不需要很大,我见过不少人把投影头堆得很深,结果训练慢不说,表示质量也没有提升。记住一点:投影头是辅助训练用的,不是模型容量所在。

3.2 对比损失的实现:自己和自己别算正样本

对比损失的实现细节决定了训练是否收敛。最容易出错的地方是正样本对的索引构造。我把一个 batch 的两个视图拼在一起,得到2N个向量,然后计算它们之间的相似度矩阵。对于第i个样本,它的正样本在矩阵里的位置应该是i + batch_size;反过来,第i + batch_size个样本的正样本在i。自己和自己那种对角线位置不是学习目标,必须屏蔽掉,否则模型会走捷径,靠“认出自己”来降低损失。

def contrastive_loss(z1, z2, temperature=0.1): batch_size = z1.shape[0] z = torch.cat([z1, z2], dim=0) z = F.normalize(z, dim=1) sim = (z @ z.T) / temperature # 自己与自己的相似度不是学习目标,直接屏蔽 sim.fill_diagonal_(-1e9) # 正样本对是 [i, i + batch_size] 和 [i + batch_size, i] labels = torch.cat([ torch.arange(batch_size, device=z.device) + batch_size, torch.arange(batch_size, device=z.device), ]) return F.cross_entropy(sim, labels)

这样每个样本的行向量里只有一个位置对应正样本,cross_entropy会逼着模型把最大相似度分数堆到那个位置上去。分母里自然包含了 batch 内所有负样本,相当于同时承担了 InfoNCE 的归一化职责。用fill_diagonal_时我习惯填一个很大的负数而不是 0,因为 0 经过温度缩放后仍然可能是最大相似度,模型会误以为自己是自己的正样本。

3.3 训练主循环和一组不会乱跑的初始参数

训练循环本身不复杂,就是每次取一批数据,做两次增强,分别编码,算损失,反传更新。关键点是两个视图必须用同一个编码器、同一套参数,不能一个走主分支一个走影子分支。

for epoch in range(epochs): for batch_x in loader: x1 = augment(batch_x) x2 = augment(batch_x) _, z1 = model(x1) _, z2 = model(x2) loss = contrastive_loss(z1, z2, temperature=0.1) optimizer.zero_grad() loss.backward() optimizer.step()

我把这组参数当作默认起点,很多项目里不用大改就能跑通:

参数取值说明
batch size256负样本越多越好,但受显存限制
温度系数0.1训练不稳定时往大调
优化器Adam 或带动量 SGD文本场景 Adam 更省心
学习率1e-3可分阶段衰减
投影维度128不需要太大
训练轮数50 到 100看验证集检索指标决定

特别说明一下 batch size。对比学习对负样本数量非常敏感,batch 越小,每个样本看到的负样本越少,训练越容易陷入局部最优。但不代表可以无脑加大 batch,后面专门有一节讲我踩过的显存坑。

4. 实测翻车记录:损失不降、表征坍塌、显存爆炸

4.1 损失曲线纹丝不动:增强强度和学习率在“打架”

我在模拟项目X上第一次跑对比学习时,损失曲线在前几十个 iteration 里几乎是一条水平线。当时第一反应是模型写错了,反复检查代码也没发现问题。后来把增强强度一步步调低,损失才开始往下降。

原因其实不复杂:增强强度太高,两个视图的语义一致性被破坏,模型根本找不到任何可学习的规律;学习率再一上去,梯度方向完全随机,不降反升。排查思路应该是先固定增强强度,观察损失能不能降到合理区间;如果降了,再逐步加大增强强度,找到一个“模型刚好能学动”的临界点。这时候的损失曲线不是越低越好,要在收敛速度和最终表示质量之间折中。损失降得太快太狠,往往意味着代理任务太简单,学到的表示可能不够泛化。

4.2 表征坍塌:模型把所有样本映射到了同一个点

比损失不降更隐蔽的问题是表征坍塌。现象是:训练还在继续,损失也稳定在一个数值附近,但你把任意两个样本的编码向量拿出来算相似度,会发现它们几乎完全一样。模型找到了一个偷懒的解——把所有东西都映射到同一个位置,这样正负样本间的相对距离都一样,损失函数也拿它没办法。

我那次坍塌的诱因是温度系数设得太小,模型被逼着去学极度困难负样本,梯度方向反复横跳,最后干脆把所有表示压到一起。解决方式分几步:检查向量相似度的均值,如果正负样本的相似度都接近 1,基本可断定坍塌;再把温度系数调高,比如从 0.05 调到 0.15;给投影头加一层 BatchNorm 也能有效打破这种对称性。另外,如果 backbone 里有 BatchNorm,确保它在训练模式下工作,否则统计量不更新也会导致表示退化。

4.3 显存被相似度矩阵吃光:batch size 加大之后的反噬

对比学习鼓励大 batch,但大 batch 的代价不只是前向计算。相似度矩阵是2N × 2N的,batch 从 256 涨到 1024,矩阵规模直接扩大 16 倍,显存压力瞬间上来。我的机器在 batch 512 时还能撑住,到 1024 就频繁 OOM。

实操下来有两个折中方案。第一个是梯度累积,把一个大 batch 拆成几个小 batch 分别算损失,梯度累积后再更新,效果接近大 batch,但显存占用大幅下降。第二个方案更粗暴:不动 batch,而是把投影头维度从 128 降到 64,或者去掉投影头里最后一层激活函数。相似度矩阵的占用和 batch 强相关,和投影维度弱相关,所以优先控制 batch 更有效。如果这两个方案都不够,可以换用带滑动平均的负样本队列式结构,把负样本放到队列里而不是全部放进当前 batch,这是对比学习后续演进里常见工程做法,等这批实验跑稳定之后可以再试。

5. 从笔记到项目:我给模拟项目X的特征管线做了三个改动

5.1 把固定路径负样本换成 batch 内负样本

模拟项目X是短文本召回场景,几十万条商品描述,没有人工标注。最初的做法是给每条文本随机抽若干条其它文本当负样本,训练慢,召回效果也平庸。读016-1之后我意识到问题出在负样本的“难度”上:完全随机抽的文本很容易区分,模型根本不需要理解语义就能把正负样本分开。

改法是在一个 batch 内,除了当前样本对应的正样本对之外,其余所有样本都当作负样本。这样每个文本看到的负样本覆盖了当批次的真实数据分布,难度更自然,训练效率也高很多。为了再增加一点挑战性,我把同一个商品的不同描述也留在了 batch 内,让模型必须学会区分“同商品不同表达”和“不同商品相似表达”,这一步对最终召回效果提升最明显。

5.2 用项目真实数据做任务内预训练,而不是拿通用向量凑合

很多人习惯直接下载一个预训练好的通用文本向量模型来用。通用向量在通用领域确实不错,但项目X里的文本是商品描述,里面大量出现规格、型号、促销语言,这些词的语义关系和通用语料里的用法不一定一致。016-1给我的启发是:预训练不一定非要做在亿级数据上,用几万条项目真实数据做对比学习,得到的表示可能更贴合业务。

我把项目里几十万条无标注商品描述全部拿来做对比学习预训练,batch 设为 256,温度系数 0.1,增强方式采用随机屏蔽 10% 的词。训练 80 轮之后,用最后一层主干输出做 K 近邻检索,Top 10 召回率比直接用通用向量大约提升了 7 个百分点。这个数字不算夸张,但考虑到项目数据量不大、完全没有人工标注,这个收益已经非常值得。

5.3 下游任务只微调分类头,不轻易动预训练表示

还有一个值得记录的经验:预训练表示跑通之后,下游任务要克制一点。项目X里有一个小样本分类任务,只有几千条标注数据。第一次做的时候我把预训练模型连同分类头一起微调,结果分类准确率反而下降了。原因很典型:下游数据少,模型在微调时把表示空间带偏了,之前学到的语义结构被破坏。

后来改成只训练分类头、冻结主干网络,效果稳定很多。之后又试了折中方案:低学习率微调最后两层,效果有小幅提升,但风险也随之变大。我的经验是,预训练表示的稳定性很珍贵,下游标注数据少于一万条时,优先冻结主干;等数据量上来或任务和预训练任务差异很大时,再做全量微调。这算是我读完这份笔记之后在工程取舍上最直观的收获。

最后再分享一个训练过程中的小技巧。对比学习模型训练完之后,不要只盯着 loss 或者分类准确率,我建议直接抽几百条数据做一次 KNN 可视化,看看每条样本的最近邻到底是不是语义上相似的内容。这一步比任何指标都更早暴露问题,比如召回结果里混入了完全不相关的文本,那说明表示空间在某个方向上发生了偏移,返回去调整增强策略或者温度系数,往往比改网络结构更有效。016-1这份笔记只是系列里的一部分,后续如果要继续深挖,还可以往负样本队列、文本增强策略、模型蒸馏这些方向展开,但先把这里的对比学习基线跑稳,后面的路会顺很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进