AI自监督学习折腾手记

因为最近遇到一个实际问题:需要做一个图像分类任务,但标注数据只收集到了 500 张,而手里躺着 5 万张无标注的图像。

大多数实际项目中,标注数据不够用是常态。

背景:标注数据的困境

大多数实际项目中,标注数据不够用是常态。不是不想标注,而是标注成本真的高——专业的医疗影像标注、复杂的工业质检缺陷识别、细粒度的物种分类,这些都需要领域专家,时间成本和金钱成本都不低。

传统的解决方案有几个:

  • 直接用 ImageNet 预训练模型:这是最常见的办法,很多模型开箱就能用。但当你的数据集和 ImageNet 差别太大时(比如医学影像、遥感图像、工业缺陷检测),预训练权重的作用就有限了。
  • 继续收集标注数据:理论上最直接,但时间和预算往往不允许。
  • 用数据增强缓解:确实能改善,但再强的增强也填不上数据量的缺口。
  • 半监督学习:比如 FixMatch、UDA 之类的,但这类方法通常对超参数比较敏感,调参成本不低。

自监督学习的思路不太一样:它不依赖人工标签,而是从数据本身构造"自我监督信号"。比如给一张图像做两次不同的增强,让模型识别它们是同一个实例的"不同版本"。这样模型学到的不只是图像内容,还有对增强变换的鲁棒性。

这次选 SimCLR 作为切入点,因为它相对简单,实现起来成本不高,而且效果已经被多次验证。更重要的是,PyTorch 有现成的 torchvision 实现,不用从零开始写。

需求:从数据到可用的特征表示

我们的具体需求很清楚:

  1. 用 5 万张无标注图像预训练一个 ResNet-50 骨干网络
  2. 预训练的网络要在视觉特征上"通用"——不只是能还原预训练任务,还要能迁移到下游分类任务
  3. 训练成本要可控,不能把公司机房跑空
  4. 实现要足够简单,团队其他人能接手维护

SimCLR 的核心思路是对比学习:给一张图像做两次不同的随机增强,得到两个视图;然后训练一个编码器(通常用 ResNet),让这两个视图在特征空间里尽量靠近,同时和其他图像的视图保持距离。

为了更好地理解这个流程,可以参考下面的结构图:

graph LR A[原始图像] --> B[增强1] A --> C[增强2] B --> D[编码器] C --> E[编码器] D --> F[Projection Head] E --> G[Projection Head] F --> H[特征z1] G --> I[特征z2] H --> J[对比损失<br/>拉近正样本<br/>推远负样本] I --> J J --> K[反向传播更新] style A fill:#e1f5ff style D fill:#fff4e1 style E fill:#fff4e1 style F fill:#ffe1e1 style G fill:#ffe1e1 style J fill:#e1ffe1

这个图说明了一件事:同一张图像经过不同增强后,模型学到的特征应该尽量相似,而不同图像的特征应该尽量不同。对比损失就是实现这一目标的数学表达。

这里面有个关键设计: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

模型评估困难

这是自监督学习的一个普遍问题:预训练时没有标签,怎么知道模型有没有学到有用的东西?

我们的办法:

  1. 可视化特征:用 t-SNE 或 UMAP 把编码器输出的特征降维到 2D,看同类样本是否聚集
  2. 线性探测:冻结编码器,只训练一个线性分类器
  3. 少样本测试:用少量标注数据微调,看下游任务表现
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 个百分点。

500 张标注数据微调后:从头训练、ImageNet 预训练、SimCLR 与联合方案的分类准确率对比

自监督预训练的价值在于:完全不依赖人工标签,就能从无标注数据里榨出比通用 ImageNet 权重更贴合本域的特征。

如果先在 ImageNet 上预训练,再在我们的数据上做自监督,效果还能再好一点。但考虑到训练成本,我们最终没有采用这个方案。

graph LR A[无标注数据 5万张] --> B[SimCLR预训练] B --> C[特征表示学习] C --> D[标注数据 500张] D --> E[下游分类微调] E --> F[最终准确率 78.5%] style A fill:#e1f5ff style B fill:#fff4e1 style C fill:#e1ffe1 style D fill:#ffe1e1 style E fill:#f0e1ff style F fill:#e1f5ff

这个流程图说明了自监督学习的工作方式:先用无标注数据学习特征表示,再用少量标注数据完成具体任务。

评估:谁需要自监督学习

从这次实践来看,自监督学习不是"银弹",但在特定场景下确实有用:

适合的场景

  • 有大量无标注数据,但标注成本高
  • 数据域和 ImageNet 差别较大
  • 团队有一定深度学习基础,愿意折腾
  • 计算资源相对充足(至少要有 2-3 块 GPU)

不适合的场景

  • 数据量本来就不大,自监督提升有限
  • 计算资源紧张,预训练成本无法接受
  • 团队深度学习基础薄弱,维护成本高
  • 下游任务简单,传统方法已经够用

还有一个边界:自监督学习学到的是"通用特征",不是"任务特定特征"。如果你的任务需要非常细粒度的特征(比如细微的纹理差异),自监督的帮助可能有限。

结语

折腾自监督学习的这段时间,让我对"数据"有了新的认识。以前总觉得"数据质量最重要",现在觉得"数据数量"和"数据质量"同样重要——只要数量够多,即使没有标签,也能从中学到一些有用的东西。

SimCLR 不是最先进的自监督学习方法,但它足够简单,效果也不错。如果你的场景和我们类似,值得试一试。

最后提醒一句:自监督学习的训练成本不低,评估成本也不低。在做决定之前,先问自己几个问题:

  1. 你真的有那么多无标注数据吗?
  2. 下游任务的提升值得这么折腾吗?
  3. 团队有足够的经验和资源支持吗?

如果答案都是肯定的,那就动手吧。从数据到标签的距离,可能没有你想象的那么远。

版权声明: 本文首发于 指尖魔法屋-AI自监督学习折腾手记https://blog.thinkmoon.cn/post/312-ai-self-supervised-learning-data-label-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!