1. SimCLR基础框架实现

任务目标

实现SimCLR基础框架,包含随机裁剪/色彩扰动等数据增强、两层投影头(Projection Head)以及NT-Xent对比损失,并在CIFAR-10上训练一个简化版ResNet-18编码器。训练完成后,通过线性评估(冻结编码器,训练线性分类头)验证表征质量,以分类准确率作为核心指标。

环境依赖

需要 torch、torchvision、numpy。若使用GPU,需安装对应CUDA版本PyTorch。以下代码默认在单卡GPU或CPU上运行。

核心实现

以下代码完整实现了SimCLR训练流程:数据增强管道、编码器+投影头、NT-Xent损失函数、训练循环以及线性评估。将代码保存为 simclr_cifar10.py 后直接运行即可。

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torchvision
import torchvision.transforms as T
from torch.utils.data import DataLoader, Subset
import numpy as np

# ---------- 1. 数据增强(SimCLR核心:随机裁剪+色彩扰动) ----------
class SimCLRAugment:
    def __init__(self, size=32):
        self.transform = T.Compose([
            T.RandomResizedCrop(size=size, scale=(0.2, 1.0)),
            T.RandomHorizontalFlip(),
            T.ColorJitter(0.4, 0.4, 0.4, 0.1),
            T.RandomGrayscale(p=0.2),
            T.ToTensor(),
            T.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
        ])
    def __call__(self, x):
        return self.transform(x), self.transform(x)  # 返回两个增强视图

# ---------- 2. 编码器 + 投影头 ----------
class Encoder(nn.Module):
    def __init__(self, out_dim=128):
        super().__init__()
        # 使用CIFAR-10适配的简化ResNet-18(去掉首层大卷积)
        self.conv = nn.Sequential(
            nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
            nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
            nn.Conv2d(256, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
            nn.AdaptiveAvgPool2d(1),
        )
        self.fc = nn.Linear(256, 512)
        self.projection = nn.Sequential(
            nn.Linear(512, 512), nn.ReLU(), nn.Linear(512, out_dim)
        )
    def forward(self, x):
        h = self.conv(x).flatten(1)
        h = F.relu(self.fc(h))
        return self.projection(h), h  # 返回投影特征和表征

# ---------- 3. NT-Xent损失 ----------
def nt_xent_loss(z1, z2, temperature=0.5):
    batch_size = z1.size(0)
    z = torch.cat([z1, z2], dim=0)  # 2N x D
    z = F.normalize(z, dim=1)
    sim = torch.mm(z, z.t()) / temperature  # 2N x 2N
    # 屏蔽对角线(自身与自身)
    mask = torch.eye(2 * batch_size, device=z.device).bool()
    sim.masked_fill_(mask, -1e9)
    # 正样本对:i 与 i+batch_size(互为增强视图)
    labels = torch.cat([torch.arange(batch_size, 2*batch_size), torch.arange(0, batch_size)], dim=0).to(z.device)
    loss = F.cross_entropy(sim, labels)
    return loss

# ---------- 4. 训练配置 ----------
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
batch_size = 128
epochs = 50  # 小型模型训练50轮,约10分钟(GPU)
lr = 3e-4

# 数据加载(CIFAR-10,仅用训练集做无监督预训练)
base_transform = T.Compose([T.ToTensor(), T.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))])
train_set = torchvision.datasets.CIFAR10(root="./data", train=True, download=True, transform=base_transform)
# 为节省时间,使用前10000张子集(可自行调整)
train_subset = Subset(train_set, range(10000))
augment = SimCLRAugment()
# 包装数据集以返回两个增强视图
class PairDataset(torch.utils.data.Dataset):
    def __init__(self, subset):
        self.subset = subset
    def __len__(self):
        return len(self.subset)
    def __getitem__(self, idx):
        img, _ = self.subset[idx]
        x1, x2 = augment(img)
        return x1, x2

pair_set = PairDataset(train_subset)
loader = DataLoader(pair_set, batch_size=batch_size, shuffle=True, num_workers=2, drop_last=True)

model = Encoder().to(device)
optimizer = optim.Adam(model.parameters(), lr=lr)

# ---------- 5. 预训练循环 ----------
model.train()
for epoch in range(epochs):
    total_loss = 0.0
    for x1, x2 in loader:
        x1, x2 = x1.to(device), x2.to(device)
        z1, _ = model(x1)
        z2, _ = model(x2)
        loss = nt_xent_loss(z1, z2)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    if (epoch+1) % 10 == 0:
        print(f"Epoch [{epoch+1}/{epochs}] Loss: {total_loss/len(loader):.4f}")

# ---------- 6. 线性评估(冻结编码器) ----------
print("开始线性评估...")
model.eval()
# 冻结卷积层和fc层(不更新)
for param in model.parameters():
    param.requires_grad = False

# 提取特征的数据集
class FeatureSet(torch.utils.data.Dataset):
    def __init__(self, subset):
        self.subset = subset
    def __len__(self):
        return len(self.subset)
    def __getitem__(self, idx):
        img, label = self.subset[idx]
        with torch.no_grad():
            _, h = model(img.unsqueeze(0).to(device))
        return h.cpu().squeeze(0), label

# 使用完整训练集(50000张)评估,但为速度可只用10000张
eval_train = Subset(train_set, range(10000))
eval_test = torchvision.datasets.CIFAR10(root="./data", train=False, download=True, transform=base_transform)
eval_test = Subset(eval_test, range(2000))

train_feat = FeatureSet(eval_train)
test_feat = FeatureSet(eval_test)
train_loader = DataLoader(train_feat, batch_size=256, shuffle=True)
test_loader = DataLoader(test_feat, batch_size=256, shuffle=False)

# 线性分类头
linear = nn.Linear(512, 10).to(device)
opt_lin = optim.Adam(linear.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

for epoch in range(20):
    linear.train()
    for h, y in train_loader:
        h, y = h.to(device), y.to(device)
        out = linear(h)
        loss = loss_fn(out, y)
        opt_lin.zero_grad()
        loss.backward()
        opt_lin.step()
    # 测试
    if (epoch+1) % 5 == 0:
        linear.eval()
        correct = 0
        total = 0
        with torch.no_grad():
            for h, y in test_loader:
                h, y = h.to(device), y.to(device)
                out = linear(h)
                pred = out.argmax(dim=1)
                correct += (pred == y).sum().item()
                total += y.size(0)
        acc = 100.0 * correct / total
        print(f"Linear Epoch [{epoch+1}/20] Test Acc: {acc:.2f}%")

运行与验证

在终端执行:

python simclr_cifar10.py

程序会自动下载CIFAR-10数据集到 ./data 目录。训练过程中每10轮打印一次NT-Xent损失值,损失应呈下降趋势(例如从约4.0降至1.5以下)。线性评估阶段每5轮打印一次测试准确率。

通过判定标准

  • 预训练损失持续下降,最终低于2.0(使用10000张子集、50轮训练时)。
  • 线性评估准确率在20轮内达到40%以上(使用10000张训练子集、2000张测试子集)。若使用完整50000张训练集,准确率可提升至60%以上。
  • 若准确率远低于上述值,可检查增强强度是否过大(如ColorJitter参数)、学习率是否合适,或增加训练轮数。

常见问题

  • 显存不足:降低 batch_size 至64或32。
  • 损失不下降:确认数据增强已生效(可打印增强后的图像检查),并确认NT-Xent实现中正样本对索引正确。
  • 线性评估准确率低:增加预训练轮数至100轮,或使用完整训练集;也可尝试增大投影头输出维度(如256)。