简介:基于SimCLR自监督学习与监督学习对比的图像分类研究项目,面向深度学习图像分类与自监督表征学习方向的开发者,可帮助理解在标注数据稀缺时如何借助对比学习训练出有效模型。项目选用TinyImageNet数据集,用SimCLR预训练ResNet18,并与传统监督学习训练结果比较,代码覆盖数据重组模型定义数据加载器训练与测试全流程,同时包含可视化配置与数据增强扩展,便于复现和二次开发。压缩包共35个文件,以Python脚本、SVG图像、txt与md文档为主,脚本对应训练流程,SVG展示图表,md与txt为说明;整体约580KB,目录结构清晰,适合自监督学习入门。目前已有57人学习下载,资料包含完整工程目录、实验说明与图表结果,可直接用于课程设计、毕业设计或科研工作,并为超参数调整与模型改进提供参考。
1. 用 SimCLR 重新审视图像分类:自监督学习真的能追上监督学习吗
图像分类任务里,标注数据始终是稀缺资源。TinyImageNet 虽然有 200 类、每类 500 张训练图,规模比完整 ImageNet 小一个量级,但标注成本一样不低。这个项目把 SimCLR 自监督对比学习算法搬上来,先在无标注的 TinyImageNet 上预训练 ResNet18,再做线性评估,和传统监督学习做一次正面交锋。它解决的核心问题不是“自监督能不能用”,而是“在数据量有限、标签昂贵的条件下,自监督预训练到底能不能逼近甚至超过端到端监督训练”。对新入门的研究生、算法工程师来说,这是一条低成本的验证路径:一套代码、一块消费级 GPU,就能亲手测出对比学习和监督学习的差距到底在哪层。
2. SimCLR 对比学习原理:把数据增强变成监督信号
2.1 核心机制:用正负样本对构造自监督损失
SimCLR 的思想并不复杂:对同一张图做两次不同的随机增强,得到两个视图,把这两个视图看作正样本对,把同批次里其他样本看作负样本。编码器共享权重提取特征,投影头把特征映射到低维空间,最后用对比损失拉近正样本对、推远负样本对。整套流程里没有标签参与,但通过“哪些图来自同一个源”这一自带信息,模型被迫学会忽略颜色抖动、裁剪、翻转这些增强扰动,保留真正决定物体身份的语义特征。
这里有个新手容易忽略的点:投影头不是多余的。SimCLR 论文明确指出,对比损失作用在投影头输出的 z 上,而不是编码器输出的 h 上。投影头用一个两层的 MLP,把高维表示压到 128 维,损失函数在这个低维空间里计算。训练完做下游任务时丢弃投影头,只用编码器输出。原因是 h 里保留了很多增强相关的细节,比如颜色直方图,这些信息在对比空间里反而干扰正样本对的聚合,投影头相当于一个“过滤器”,先把特征里跟增强相关的成分洗掉,逼迫编码器保留更本质的结构信息。
损失函数用的是 NT-Xent(归一化温度标度交叉熵损失)。对一个 batch 里的每个正样本对 (i, j),损失会这样计算:分子是 z_i 和 z_j 的点积除以温度参数 τ 再取指数,分母是所有负样本对的和再取指数。温度 τ 是控制判别难度的超参数,值越小,softmax 分布越尖锐,模型被迫把注意力集中到最难的负样本上;值越大,分布越平缓,训练信号越柔和。SimCLR 原论文在 ImageNet 上验证了 τ=0.1 附近效果最好,但在 TinyImageNet 这种小数据、小分辨率场景下,τ=0.1 有时候会让训练不稳定,我一般建议从 0.07 到 0.3 之间先扫一遍,再决定固定值。
SimCLR 对 batch size 极其敏感。因为负样本就是同批次里的所有其他样本,batch 越大,负样本越丰富,对比任务越有挑战性,学到特征越泛化。原论文用 4096 的 batch,这在实际项目里不现实。好在 TinyImageNet 图像只有 64×64,在显存允许的前提下,一个 RTX 3090 跑 512 或者 1024 的 batch 是可行的。如果显存不够,可以用梯度累积模拟更大的 batch,但要注意 SimCLR 论文里有一个被证实的现象:batch 小于 256 时性能会显著下降,所以这块不能省。
对比学习还存在一个“坍塌”风险。如果编码器把所有输入映射到同一个输出点,损失会完美优化,但学出来的特征毫无意义。常见的诱因是投影头初始化不合理或学习率过大,表现为训练曲线正常下降但准确率纹丝不动。检测手段很简单:保存一批编码器输出,算一下每个维度的标准差,如果绝大多数维度标准差趋近于 0,说明已经坍塌了。
2.2 为什么选 ResNet18:在 64×64 分辨率下的平衡点
TinyImageNet 的图像分辨率是 64×64,这是很多人第一次接触这个数据集时踩的坑——误以为它和 ImageNet 一样是 224×224。分辨率小,意味着模型不需要很大的感受野就能覆盖整个物体。ResNet18 用 5 层结构(一个 7×7 卷积 stem 加四个 stage),理论上最低层输出分辨率是 7×7,对于 64×64 输入,最后一层 feature map 是 2×2,已经逼近极限。再深下去,比如 ResNet50,在 64×64 输入上并不是不能跑,但前几层就会把特征压成很小分辨率,梯度传播效率反而下降,收益有限。
ResNet18 在自监督预训练里还有另一个身份:它是被验证得最多的骨干网络之一。SimCLR 原论文做了 ResNet50 的完整实验,但社区里有大量基于 ResNet18 跑 TinyImageNet、CIFAR-10 的复现工作。选它意味着遇到问题能搜到大量参考实现,这对于做对比研究非常重要。如果你后续想扩大实验,把 ResNet18 换成 ResNet34 或 ResNet50,只需改一行模型定义,数据增强、优化器、损失函数都不用动。这也是做实验设计的通用思路:骨架用成熟网络,变量留给训练策略。
不过要留个心眼:ResNet18 在 ImageNet 上的预训练权重是 224×224 输入下学习到的,直接拿来在 64×64 上做迁移通常有效,但如果做“从零自监督预训练”,第一个卷积层的 7×7、stride 2 对 64×64 输入有点太激进。我见过不少人会把 stem 换成 3×3、stride 1,再用 MaxPool 做下采样,保证初期特征图分辨率不会掉太快。这个改动在 ImageNet 那种大分辨率下无所谓,在 TinyImageNet 上影响明显。
3. 在 TinyImageNet 上预训练 ResNet18:最小可复现的 PyTorch 代码与参数
3.1 数据集处理与增强策略:两个增强视图的正确打开方式
TinyImageNet 下载解压后是 ImageFolder 结构:train 目录下 200 个子目录,名字是 wnid,比如 n01443537。用 PyTorch 的 torchvision.datasets.ImageFolder 能直接读进来,但类名是 wnid 字符串,评估时要注意映射回真实标签。验证集也需要特殊处理——它默认提供的是 val 目录加 val_annotations.txt,必须按注释文件重排成子目录结构,否则 ImageFolder 会把整个 val 当成一个类。
数据增强是 SimCLR 的灵魂,直接决定特征质量。每一张训练图都要生成两个独立的增强视图,增强强度要足够大,否则正样本对太简单,模型学到的是 trivial 的匹配而非语义特征。我常规的增强组合是这样:
import torchvision.transforms as T # TinyImageNet 图像是 64x64,SimCLR 原文用 RandomResizedCrop 裁剪到相同尺寸 simclr_aug = T.Compose([ T.RandomResizedCrop(size=(64, 64), scale=(0.08, 1.0), ratio=(0.75, 1.333)), T.RandomHorizontalFlip(p=0.5), T.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4, hue=0.1), T.RandomGrayscale(p=0.2), T.ToTensor(), T.Normalize(mean=[0.4802, 0.4481, 0.3975], std=[0.2297, 0.2274, 0.2250]), ]) # 对每张图应用两次生成两个视图,x_i 和 x_j 构成正样本对 train_transform = T.Compose([ T.RandomApply([simclr_aug], p=1.0), ])这段代码里 RandomResizedCrop 的 scale 下限设置到 0.08,是 SimCLR 原论文推荐值。scale 太小会让裁剪区域过小,模型很难学到全局结构;太大会让两个视图过于相似,正样本对失去挑战性。Grayscale 的概率 0.2 对应论文里的 color distortion 强度,这个值偏低,如果你的训练 loss 下降缓慢,可以尝试加到 0.5 试试。
数据加载器要注意的第二个细节:两个视图必须独立生成。常见错误是用同一个 transform 实例对同一张图调用两次,但因为随机种子和内部状态的问题,两次结果可能在序列化时被缓存。正确姿势是每个视图单独执行一次 transform,或者自定义 Dataset 在getitem里返回两个增强后的张量:
class SimCLRDataset(Dataset): def __init__(self, imagefolder, transform): self.data = imagefolder self.transform = transform def __getitem__(self, idx): img, label = self.data[idx] # label 在预训练阶段可忽略 x_i = self.transform(img) x_j = self.transform(img) return x_i, x_j这样写还有额外好处:未来做半监督或带标签微调时,这个 Dataset 可以直接复用,改动成本几乎为零。
3.2 SimCLR 预训练循环:NT-Xent 损失、batch 大小与优化器设置
预训练循环的核心是损失函数实现和批次构造。NT-Xent 损失在 PyTorch 中实现时,推荐的做法是以整个 batch 为单元构造相似度矩阵,然后用交叉熵一次性算出所有正样本对的贡献:
import torch import torch.nn.functional as F def nt_xent_loss(z_i, z_j, temperature=0.1): # 将两个视图的嵌入拼接,构成 2N x D 的矩阵 batch_size = z_i.shape[0] z = torch.cat([z_i, z_j], dim=0) # 2N x D # L2 归一化,确保余弦相似度 = 点积 z = F.normalize(z, dim=1) # 计算 2N x 2N 相似度矩阵 sim_matrix = z @ z.T / temperature # 屏蔽对角线(自己与自己)和对称位置的重复项 mask = torch.eye(2 * batch_size, dtype=torch.bool, device=z.device) sim_matrix = sim_matrix.masked_fill(mask, -1e9) # 每个样本的正样本对是它在前半/后半个 batch 中对应的那个 labels = torch.cat([ torch.arange(batch_size, 2 * batch_size, device=z.device), torch.arange(0, batch_size, device=z.device) ]) loss = F.cross_entropy(sim_matrix, labels) return loss这个实现里正样本对的索引方式值得解释。第 i 个视图和它对应的 j 视图在拼接矩阵里的位置分别落在前半和后半,所以把 labels 设定为偏移 batch_size 的索引,交叉熵会自动把相似度矩阵的第 i 行当成分布,去拟合正确正样本的位置。masked_fill 把对角线置为 -1e9 是为了防止模型学会“自己和自己的相似度最高”这种作弊答案。
优化器在 SimCLR 里推荐用 Adam 或带 Nesterov 的 SGD。我个人的经验是:在 TinyImageNet 这种小数据集上,Adam 的收敛速度明显更快,但最终特征质量略低;SGD 收敛慢但结果更稳。做对比研究时,如果论文基线用的是 SGD,那预训练建议跟进同样的优化器类型,避免把优化器的差异误判成自监督策略的差异。学习率需要和 batch size 做线性缩放,这是 SimCLR 原论文里明确写到的技巧:batch 加大一倍,学习率跟着加倍。一个从零开始的参考配置:
optimizer = torch.optim.SGD( model.parameters(), lr=0.3 * (batch_size / 256), momentum=0.9, weight_decay=5e-4, nesterov=True )学习率调度的选择也很讲究。SimCLR 原论文用 cosine annealing 衰减,整个训练过程从高学习率平滑降到接近 0。在 TinyImageNet 上训练 100 个 epoch 是常见配置,cosine 周期设置成 100。如果提前终止训练,特征质量会打折扣,因为 cosine 调度在训练后期才真正“沉淀”出好特征。
整个预训练模型输出的维度是 512(ResNet18 最后一个卷积层的输出通道数),投影头把它映射成 128 维空间。参考文献设定,投影头用两层 MLP,中间带 BN 和 ReLU。训练时检查 loss 数值:如果从 4 到 6 附近起步,并缓慢下降,是正常现象;如果一开始就低于 0.5,说明 batch 太小或增强太弱,模型在走捷径。
4. 监督学习基线:用同一份数据把公平对比做扎实
4.1 构建监督基线:常规训练路线与超参数配置
监督学习的基线不是随便跑一个 ResNet18 就完事,它要和 SimCLR 预训练严格对齐。需要控制的变量至少有三个:数据增强策略、训练 epoch 数和优化器类型。如果监督基线用了更强的增强,而自监督只用简单增强,最后对比出来的差距,你分不清是算法带来的还是数据增强带来的。
监督学习的标准做法是在训练集上用相对温和的增强,测试集上只做 Resize 和 Normalize。TinyImageNet 因为输入是 64×64,增强一般就用 RandomCrop(带 padding)、RandomHorizontalFlip,也可以加入轻度的 CutMix 或 MixUp,但那是额外技巧,基线最好保持朴素。关键控制项是 epoch 数:如果 SimCLR 预训练跑了 100 个 epoch,监督基线也应该跑够同样的迭代次数。注意二者收敛特性完全不同,监督学习在 30 到 50 个 epoch 就到平台期,后期多跑的收益极小,但为了让“训练总成本”更具可比性,我通常会统计两者的总计算量(FLOPs 或 GPU 时长),在图里标注出来,而不是强行把监督训练拉到 100 epoch。
opt_sup = torch.optim.SGD( model_sup.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4 ) sched_sup = torch.optim.lr_scheduler.CosineAnnealingLR(opt_sup, T_max=epoch) criterion = torch.nn.CrossEntropyLoss()这个是经典 ResNet 训练配置的 TinyImageNet 适配版。学习率 0.1 配合 momentum 0.9 是直接的参考起点,batch size 建议和预训练保持一致。如果 batch 从 256 调到 512,学习率翻倍到 0.2 是合理做法。T_max 设成总 epoch 数会让学习率从 0.1 平滑降到接近零,后期微调效果更好,比固定学习率训练最后一两个点。
监督基线的结果受类别数量的影响很大。TinyImageNet 有 200 类,比 CIFAR-10 的 10 类困难一个数量级。训练一个从头开始的 ResNet18,top-1 准确率在 50% 到 60% 区间是正常的。如果你的基线跑到 60% 以上,大概率是加了外部数据的预训练或做了额外增强,说明你的实验设置和论文预期不完全一致。
不过更常见的坑是:预训练权重没有正确加载。PyTorch 里 model.load_state_dict 在 strict=True 模式下,如果键名不匹配直接报错,但很多人会用 strict=False 硬加载,导致模型实际上从随机初始化开始跑。打印一遍加载前后第一个卷积层的权重均值,对比一下变化就能确认。
4.2 评估协议:线性探针与微调的差异
公平对比不能只看微调后的准确率。SimCLR 是预训练策略,监督学习是端到端训练策略,两者在微调阶段的表现差异很大。业界标准的评估方式有两种:线性探针(linear probe)和全模型微调(fine-tune)。
线性探针是在冻结编码器权重的前提下,在特征输出后接一个线性分类层,只训练这个分类层。它的核心假设是:如果预训练学到的好特征,只需要一个线性分类器就能取得不错的准确率。这种方式最能体现自监督预训练的特征质量,因为它不允许模型在使用预训练权重的同时再修改底层特征来适配任务。
class LinearProbe(torch.nn.Module): def __init__(self, backbone, num_classes=200, feature_dim=512): super().__init__() self.backbone = backbone # 冻结骨干网络 for p in self.backbone.parameters(): p.requires_grad = False self.fc = torch.nn.Linear(feature_dim, num_classes) def forward(self, x): feat = self.backbone(x) # ResNet18 默认输出 512 维 return self.fc(feat)这里要注意 ResNet18 的输出维度:如果用 torchvision 官方实现,model.fc 在 forward 的时候会直接输出类别 logits,所以线性探针要先把 model.fc 替换成恒等映射,再从倒数第二层取特征。更省事的做法是把 model.fc 摘掉,在 forward 里只过卷积部分。
另一种评估是微调,把预训练权重当作初始化,解冻所有层继续训练。这种方式在数据量较少时通常能获得最高准确率,但它的缺点是无法区分“预训练提供了好起点”和“微调期间重新学到了好特征”。做对比研究报告时建议两个指标都上:线性探针反映表征质量,微调反映最终可用性。
微调阶段的另一个常见做法是冻结前几层(layer1、layer2),只更新后几层和分类头。这在目标数据量特别小的时候有效,但 TinyImageNet 有 10 万张训练图,完全解冻也不至于过拟合,我一般直接全量微调,把学习率调到预训练阶段的十分之一。
评估时还要思考一下:最终对比的指标除了 top-1 准确率之外,最好加上 top-5 准确率和每个类别的平均召回。TinyImageNet 类间相似度高(比如不同的犬种),有些模型宏观表现一样,但在难分类别上差异明显。必要时可以给出按相似类聚组的混淆矩阵图,能直观地看到 SimCLR 和监督学习各自擅长什么。
5. SimCLR 与监督学习对比研究的高频坑:现象、原因与解决
5.1 崩溃:loss 降到 0.2,准确率却接近随机
现象:自监督预训练过程中对比损失快速下降,让人信心满满。结果线性探针做下来 top-1 只有 1% 到 2%,和随机猜差不多。
原因:模型发生维度坍塌,编码器把不同输入映射成了近线性相关的输出。这种情况在 batch size 小于 128、温度系数偏低(比如 0.05)时尤其容易出现。训练框架不会报警,loss 一样在下降,但嵌入的方差已经塌掉了。
解决:先查嵌入的方差。取 64 张验证图过编码器,输出特征矩阵对每个维度算标准差,如果一大半维度的标准差小于 0.01,就可以坐实坍塌。临时补救的方法是把温度从 0.05 调回 0.1,学习率砍半重新跑;长期来说,确保 batch 大于等于 256 或者启用梯度累积,并且不要省掉投影头的 BatchNorm。
5.2 数据增强顺序不当:Normalize 在随机裁剪之前
现象:代码能跑通,loss 曲线平滑,但最终特征质量偏低,且对不同的随机种子结果起伏很大,怎么看都像是训练不稳定。
原因:把 ToTensor 和 Normalize 放在了 RandomResizedCrop 前面。Normalize 之后像素范围被压缩到大约 -1 到 1,再执行 crop 虽然语法不报错,但裁剪的边界取样和颜色抖动计算都基于标准化后的分布,数值范围和颜色统计已经被改过,增强效果被削弱。
解决:固定增强顺序:几何变换(RandomResizedCrop、HorizontalFlip)→ 颜色变换(ColorJitter、RandomGrayscale)→ ToTensor → Normalize。这个顺序最好写成配置注释,防止其他人改动时无意识调整。此外,Normalize 用的 mean/std 要和数据集匹配,不要直接抄 ImageNet 的数值,TinyImageNet 的 RGB 均值和标准差有专门统计值。
5.3 公平性被打破:预训练和微调用了同一批数据
现象:对比实验最后差 2 到 3 个点,你反复检查代码却找不到问题,直到某天发现验证集上不可思议的高准确率。
原因:TinyImageNet 的验证集实际上是从训练集中的类别里分出来的,如果预训练阶段的数据加载器没有严格只用 train 目录,某些自监督增强视图可能来自 val 目录,特征里已经“偷看”过下游任务的数据。自监督没有标签,不像有监督那样能立刻察觉到过拟合,等微调阶段它会把偷看过的信息释放出来。
解决:在数据集加载阶段做一次目录白名单校验,预训练和线性探针分别用不同的 Dataset 实例,且线性探针在编码器冻结后严禁再次访问训练集做特征统计(比如 BatchNorm 的 running stats 更新要关掉)。把数据划分写进实验配置,git 记录,方便审计。
5.4 torchvision 模型的 fc 层维度不匹配
现象:加载 ResNet18 做自监督预训练时,没有报错,但最后一次前向传播输出维度是 200 而不是 128;或者微调时维度对不上,报 size mismatch。
原因:torchvision 的 resnet18 默认把最后的全连接层设置为 1000 类(ImageNet)。如果直接在自己的数据集上跑,不替换 fc 层,模型输出 1000 维,与投影头输入 1000 维、线性探针输入 512 维就完全对不上了。
解决:在加载模型后立即把 fc 层替换成恒等映射或随机初始化的线性层。规范的写法是先用一个 dummy forward 检查输出维度,再进训练循环。不要在训练中途才发现维度问题,浪费的时间和算力很难找回来。
5.5 用微调结果当唯一指标,埋没自监督真实价值
现象:对比报告里,监督学习微调 92%,自监督微调 90%,结论是“自监督略逊于监督学习”。但实际上自监督只用了 10% 的标注数据参与微调,监督学习用了 100% 的标注。
原因:评估协议的设定和问题定义不匹配。这个项目标题是“自监督 vs 监督”,但真实讨论的其实是“无标注预训练 + 少量标注微调”的分支,如果不在报告里说明标注数据比例,对比就没有意义。
解决:建议把实验设计成多组对比:A 组监督学习用 100% 标签,B 组自监督预训练后用 10% 标签微调,C 组自监督预训练后用 100% 标签微调。如果 B 组无限接近 C 组,那就证明了自监督预训练在标签稀缺场景下的价值,这比单纯比“最高准确率谁夺冠”更有说服力。
6. 验证预训练特征质量:KNN 分类与投影可视化的实用手法
训练完的特征质量除了用线性探针衡量,还有一个成本更低的即时检验手段:K 近邻分类器。用预训练编码器把训练集全部过一遍,得到特征向量库,然后对每张验证图查它的 K 个近邻,如果最近邻的类别高度一致,说明特征空间已经形成了清晰的聚类结构。这个方法不需要任何额外训练,一张 2080Ti 上跑完 TinyImageNet 全套特征只需十几分钟,是可以当作每次实验结束后的例行检查。
实践中我会用这个流程:预训练完先保存所有训练特征到磁盘(方便后续复用),对特征做 L2 归一化后存成 .npy。验证时逐个过编码器,用余弦相似度算近邻。代码实现从简,但要注意 T-SNE 之类的可视化做全局结构展示时,采样类别要均衡,否则大类会把小类压缩成一团。可视化只适合定性观察,定量结论还是以 KNN 准确率和线性探针为准。
我的一个习惯是:每次跑完预训练,不急着做线性探针,先输出一张 KNN 准确率随 K(比如 K=1, 5, 20, 50)变化的曲线。如果 K=1 的准确率已经接近监督基线的 70%,说明特征很锐利;如果 K=20 反而比 K=1 高,说明存在聚类但边界模糊。这种观察能快速定位预训练存在的问题,避免把时间浪费在后续微调上。
跑这项对比研究最值回票价的地方在于:它把“自监督学习是否值得投入”从口号变成了可复现的数字。我在实验中还吃过温度系数没有随 batch 联动调整的哑巴亏,后来养成一个习惯:改 batch 大小就同时改学习率和温度,顺序是学习率先按比例缩,温度再按经验微调,每次只改一个变量。如果你也是初次接触自监督领域,建议严格遵守这一条,能省下大量定位问题的精力。验证完这套流程,你可以再往上搭 MAE、BYOL 或 SwAV 做横向扩展,框架不变,只是把损失函数替换掉,希望这个对比项目的拆解路径能帮到你。
本文还有配套的精品资源,点击获取