AI自监督学习折腾手记
因为最近遇到一个实际问题:需要做一个图像分类任务,但标注数据只收集到了 500 张,而手里躺着 5 万张无标注的图像。
大多数实际项目中,标注数据不够用是常态。
背景:标注数据的困境
大多数实际项目中,标注数据不够用是常态。不是不想标注,而是标注成本真的高——专业的医疗影像标注、复杂的工业质检缺陷识别、细粒度的物种分类,这些都需要领域专家,时间成本和金钱成本都不低。
传统的解决方案有几个:
- 直接用 ImageNet 预训练模型:这是最常见的办法,很多模型开箱就能用。但当你的数据集和 ImageNet 差别太大时(比如医学影像、遥感图像、工业缺陷检测),预训练权重的作用就有限了。
- 继续收集标注数据:理论上最直接,但时间和预算往往不允许。
- 用数据增强缓解:确实能改善,但再强的增强也填不上数据量的缺口。
- 半监督学习:比如 FixMatch、UDA 之类的,但这类方法通常对超参数比较敏感,调参成本不低。
自监督学习的思路不太一样:它不依赖人工标签,而是从数据本身构造"自我监督信号"。比如给一张图像做两次不同的增强,让模型识别它们是同一个实例的"不同版本"。这样模型学到的不只是图像内容,还有对增强变换的鲁棒性。
这次选 SimCLR 作为切入点,因为它相对简单,实现起来成本不高,而且效果已经被多次验证。更重要的是,PyTorch 有现成的 torchvision 实现,不用从零开始写。
需求:从数据到可用的特征表示
我们的具体需求很清楚:
- 用 5 万张无标注图像预训练一个 ResNet-50 骨干网络
- 预训练的网络要在视觉特征上"通用"——不只是能还原预训练任务,还要能迁移到下游分类任务
- 训练成本要可控,不能把公司机房跑空
- 实现要足够简单,团队其他人能接手维护
SimCLR 的核心思路是对比学习:给一张图像做两次不同的随机增强,得到两个视图;然后训练一个编码器(通常用 ResNet),让这两个视图在特征空间里尽量靠近,同时和其他图像的视图保持距离。
为了更好地理解这个流程,可以参考下面的结构图:
这个图说明了一件事:同一张图像经过不同增强后,模型学到的特征应该尽量相似,而不同图像的特征应该尽量不同。对比损失就是实现这一目标的数学表达。
这里面有个关键设计:projection head。编码器输出的特征先经过一个多层感知机做映射,再做对比损失。这是因为高维空间里的线性可分性对特征学习很重要——projection head 的作用就是创建一个对比友好的特征空间,而最终用于下游任务时只用编码器的输出。
另一个关键点是数据增强策略。SimCLR 需要的增强比常规监督学习更激进:随机裁剪、颜色抖动、高斯模糊等等。增强越强,模型学到的不变性就越强,但也不能太强,否则可能破坏图像的语义信息。
实现:从环境搭建到训练
环境准备
先说硬件环境,这块不坑人心里没数。我们用的是一台 8 卡 A100 的服务器,每张卡 40GB 显存。理论上单卡也能跑,但训练时间会非常长。
软件环境:
- PyTorch 2.1.0(CUDA 11.8)
- torchvision 0.16.0
- numpy 1.24.3
SimCLR 在 torchvision 里有现成的实现,但文档比较简略,第一次用的时候还是要花时间摸索。
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import models, transforms
from torch.utils.data import DataLoader, Dataset
from torchvision.datasets import ImageFolder
# 检查 CUDA 可用性
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Using device: {device}")
数据准备
数据组织比较简单:把所有图像放在一个根目录下,每个子目录代表一个类别(即使我们不用类别信息,这个结构也能方便地用 ImageFolder 加载)。
数据增强策略需要仔细设计。SimCLR 论文里给出的组合是:
- 随机裁剪并调整大小
- 随机水平翻转
- 颜色抖动(亮度、对比度、饱和度、色相)
- 随机灰度化
- 高斯模糊
from torchvision.transforms import transforms
def get_simclr_augmentation(size=224):
return transforms.Compose([
transforms.RandomResizedCrop(size, scale=(0.2, 1.0)),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomApply([transforms.ColorJitter(0.8, 0.8, 0.8, 0.2)], p=0.8),
transforms.RandomGrayscale(p=0.2),
transforms.GaussianBlur(kernel_size=int(0.1 * size), sigma=(0.1, 2.0)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
class SimCLRDataset(Dataset):
def __init__(self, root_dir, transform=None):
self.dataset = ImageFolder(root_dir)
self.transform = transform
def __len__(self):
return len(self.dataset)
def __getitem__(self, idx):
img, _ = self.dataset[idx]
if self.transform:
img1 = self.transform(img)
img2 = self.transform(img)
else:
img1 = img2 = img
return img1, img2
# 数据加载器
train_dataset = SimCLRDataset(
'/path/to/unlabeled_data',
transform=get_simclr_augmentation()
)
train_loader = DataLoader(
train_dataset,
batch_size=256, # 每张卡
shuffle=True,
num_workers=8,
pin_memory=True,
drop_last=True
)
这里有个实际限制:batch size 不能太小。SimCLR 的对比学习需要足够多的负样本,batch size 越大,负样本越多,效果越好。论文里建议至少 256,但我们的显存有限,后来通过梯度累积和自动混合精度(AMP)缓解了这个问题。
模型搭建
模型结构相对标准:ResNet-50 作为编码器,加上一个 2 层 MLP 作为 projection head。
from torchvision.models import resnet50
class SimCLR(nn.Module):
def __init__(self, base_model='resnet50', out_dim=128):
super(SimCLR, self).__init__()
self.encoder = resnet50(weights=None)
# 移除最后的全连接层
self.encoder.fc = nn.Identity()
# Projection head: 2048 -> 512 -> 128
self.projection_head = nn.Sequential(
nn.Linear(2048, 512),
nn.ReLU(),
nn.Linear(512, out_dim)
)
def forward(self, x):
h = self.encoder(x)
z = self.projection_head(h)
return h, z
model = SimCLR().to(device)
损失函数
对比损失的核心是 InfoNCE loss:对于一个锚点样本,让正样本(同一个实例的不同视图)的特征距离更近,负样本(不同实例的视图)的距离更远。
import torch.nn.functional as F
def info_nce_loss(features, temperature=0.5):
batch_size = features.shape[0]
labels = torch.arange(batch_size).to(features.device)
# 计算相似度矩阵
similarity_matrix = F.cosine_similarity(
features.unsqueeze(1), features.unsqueeze(0), dim=2
)
# 去掉对角线(自己和自己不用比)
mask = torch.eye(batch_size, dtype=torch.bool).to(features.device)
similarity_matrix = similarity_matrix[~mask].view(batch_size, -1)
# 计算损失
loss = F.cross_entropy(similarity_matrix / temperature, labels)
return loss
训练循环
训练循环不算复杂,但有几个细节要注意:
- 使用自动混合精度(AMP)节省显存
- 梯度累积来模拟更大的 batch size
- 合理的学习率调度
from torch.cuda.amp import autocast, GradScaler
# 优化器和学习率调度
optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=100, eta_min=1e-6
)
scaler = GradScaler()
num_epochs = 100
temperature = 0.5
accumulation_steps = 4 # 模拟更大的 batch size
model.train()
for epoch in range(num_epochs):
epoch_loss = 0.0
optimizer.zero_grad()
for batch_idx, (img1, img2) in enumerate(train_loader):
img1, img2 = img1.to(device), img2.to(device)
# 前向传播
with autocast():
_, z1 = model(img1)
_, z2 = model(img2)
# 合并两个视图
z = torch.cat([z1, z2], dim=0)
loss = info_nce_loss(z, temperature)
# 反向传播(考虑梯度累积)
scaler.scale(loss / accumulation_steps).backward()
if (batch_idx + 1) % accumulation_steps == 0:
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad()
epoch_loss += loss.item()
scheduler.step()
print(f"Epoch {epoch+1}/{num_epochs}, Loss: {epoch_loss/len(train_loader):.4f}")
# 定期保存检查点
if (epoch + 1) % 10 == 0:
torch.save({
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'scheduler_state_dict': scheduler.state_dict(),
}, f'simclr_checkpoint_epoch_{epoch+1}.pth')
踩坑:理论和实践的差距
显存爆炸
第一个坑就是显存不够。batch size 256 的情况下,8 卡 A100 也扛不住。一开始尝试了几个办法:
- 降低 batch size:效果变差,负样本不够
- 降低图像分辨率:训练不稳定
- 减小模型:效果下降
最后通过梯度累积和 AMP 才解决:
# 梯度累积示例
accumulation_steps = 8
for i, batch in enumerate(train_loader):
loss = model(batch)
scaler.scale(loss / accumulation_steps).backward()
if (i + 1) % accumulation_steps == 0:
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad()
AMP 的使用需要小心,不是所有操作都支持半精度,但大部分常见操作都没问题。
增强策略过强
一开始抄了论文里的增强参数,结果发现模型学习速度很慢。后来检查才发现:我们的图像和 ImageNet 差别很大,强增强破坏了太多语义信息。
最终调整了几个参数:
# 调整后的增强策略
transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.4, 1.0)), # 调高下限
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomApply([transforms.ColorJitter(0.6, 0.6, 0.6, 0.1)], p=0.6), # 减弱颜色抖动
transforms.RandomGrayscale(p=0.1), # 降低灰度概率
transforms.GaussianBlur(kernel_size=5, sigma=(0.1, 1.5)), # 适度模糊
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])
调整后训练稳定了很多,收敛速度也提升了。
训练不稳定
SimCLR 对温度参数很敏感。温度太低,模型会过拟合到"硬负样本";温度太高,对比学习退化为均匀分布。
试了几个值:
- temperature = 0.1:损失震荡,模型崩溃
- temperature = 0.5:稳定,但收敛慢
- temperature = 0.07:最好,论文推荐值
最后用 0.07,确实比 0.5 好不少。
# 温度调度:开始高一点,后期降低
def get_temperature(epoch, total_epochs):
start_temp = 0.1
end_temp = 0.07
return start_temp + (end_temp - start_temp) * epoch / total_epochs
模型评估困难
这是自监督学习的一个普遍问题:预训练时没有标签,怎么知道模型有没有学到有用的东西?
我们的办法:
- 可视化特征:用 t-SNE 或 UMAP 把编码器输出的特征降维到 2D,看同类样本是否聚集
- 线性探测:冻结编码器,只训练一个线性分类器
- 少样本测试:用少量标注数据微调,看下游任务表现
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
def visualize_features(model, data_loader, device):
model.eval()
features = []
labels = []
with torch.no_grad():
for imgs, lbls in data_loader:
imgs = imgs.to(device)
h, _ = model(imgs)
features.append(h.cpu().numpy())
labels.append(lbls.numpy())
features = np.concatenate(features, axis=0)
labels = np.concatenate(labels, axis=0)
# t-SNE 降维
tsne = TSNE(n_components=2, random_state=42)
features_2d = tsne.fit_transform(features)
# 可视化
plt.figure(figsize=(10, 8))
scatter = plt.scatter(features_2d[:, 0], features_2d[:, 1], c=labels, cmap='tab10')
plt.colorbar(scatter)
plt.title('Feature Space Visualization')
plt.savefig('feature_space.png')
结果:预训练到底值不值
预训练跑了 100 个 epoch,大概花了 36 小时。然后用 500 张标注数据微调下游分类器,结果如下:
| 方法 | 准确率 |
|---|---|
| 从头训练 | 62.3% |
| ImageNet 预训练 + 微调 | 71.8% |
| SimCLR 自监督 + 微调 | 78.5% |
| ImageNet + SimCLR 联合 | 80.2% |
在只有 500 张标注数据的前提下,不同预训练策略的下游准确率差距一目了然——SimCLR 自监督预训练比 ImageNet 迁移又高出近 7 个百分点。

自监督预训练的价值在于:完全不依赖人工标签,就能从无标注数据里榨出比通用 ImageNet 权重更贴合本域的特征。
如果先在 ImageNet 上预训练,再在我们的数据上做自监督,效果还能再好一点。但考虑到训练成本,我们最终没有采用这个方案。
这个流程图说明了自监督学习的工作方式:先用无标注数据学习特征表示,再用少量标注数据完成具体任务。
评估:谁需要自监督学习
从这次实践来看,自监督学习不是"银弹",但在特定场景下确实有用:
适合的场景:
- 有大量无标注数据,但标注成本高
- 数据域和 ImageNet 差别较大
- 团队有一定深度学习基础,愿意折腾
- 计算资源相对充足(至少要有 2-3 块 GPU)
不适合的场景:
- 数据量本来就不大,自监督提升有限
- 计算资源紧张,预训练成本无法接受
- 团队深度学习基础薄弱,维护成本高
- 下游任务简单,传统方法已经够用
还有一个边界:自监督学习学到的是"通用特征",不是"任务特定特征"。如果你的任务需要非常细粒度的特征(比如细微的纹理差异),自监督的帮助可能有限。
结语
折腾自监督学习的这段时间,让我对"数据"有了新的认识。以前总觉得"数据质量最重要",现在觉得"数据数量"和"数据质量"同样重要——只要数量够多,即使没有标签,也能从中学到一些有用的东西。
SimCLR 不是最先进的自监督学习方法,但它足够简单,效果也不错。如果你的场景和我们类似,值得试一试。
最后提醒一句:自监督学习的训练成本不低,评估成本也不低。在做决定之前,先问自己几个问题:
- 你真的有那么多无标注数据吗?
- 下游任务的提升值得这么折腾吗?
- 团队有足够的经验和资源支持吗?
如果答案都是肯定的,那就动手吧。从数据到标签的距离,可能没有你想象的那么远。
版权声明: 本文首发于 指尖魔法屋-AI自监督学习折腾手记(https://blog.thinkmoon.cn/post/312-ai-self-supervised-learning-data-label-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。