
我习惯把啃深度学习资料的过程拆成一份份编号笔记这次单独把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_{k1}^{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_dim64, feat_dim128): super().__init__() self.backbone nn.Sequential( nn.Linear(input_dim, 128), nn.ReLU(inplaceTrue), nn.Linear(128, 128), nn.ReLU(inplaceTrue), ) self.proj nn.Sequential( nn.Linear(128, 64), nn.ReLU(inplaceTrue), 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, temperature0.1): batch_size z1.shape[0] z torch.cat([z1, z2], dim0) z F.normalize(z, dim1) sim (z z.T) / temperature # 自己与自己的相似度不是学习目标直接屏蔽 sim.fill_diagonal_(-1e9) # 正样本对是 [i, i batch_size] 和 [i batch_size, i] labels torch.cat([ torch.arange(batch_size, devicez.device) batch_size, torch.arange(batch_size, devicez.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, temperature0.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这份笔记只是系列里的一部分后续如果要继续深挖还可以往负样本队列、文本增强策略、模型蒸馏这些方向展开但先把这里的对比学习基线跑稳后面的路会顺很多。