2. MoCo动量对比实现

任务目标

实现MoCo v1(Momentum Contrast)的核心机制:动量编码器、负样本队列以及InfoNCE损失函数,并在ImageNet子集(10类,每类约130张训练图)上完成训练与评估,验证对比学习表征质量。

核心机制说明

MoCo通过维护一个大型负样本队列(而非依赖大batch size)来提供充足的负样本。查询编码器(query encoder)与动量编码器(momentum encoder)共享相同网络结构,但动量编码器的参数通过指数移动平均(EMA)更新,保证队列中负样本特征的一致性。InfoNCE损失将每个样本的增强视图(query)与对应正样本(key)拉近,同时推远队列中的负样本。

环境依赖

torch==2.0.1
torchvision==0.15.2
Pillow==10.0.0
numpy==1.24.3

完整实现代码

import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision
import torchvision.transforms as T
from torch.utils.data import DataLoader, Subset
import numpy as np
from PIL import Image
import os, random

# ---------- 1. 数据准备:ImageNet子集(10类) ----------
def prepare_subset(data_dir='/data/imagenet', num_classes=10, samples_per_class=130):
    """从ImageNet训练集中抽取前num_classes类,每类samples_per_class张"""
    full_set = torchvision.datasets.ImageFolder(
        root=os.path.join(data_dir, 'train'),
        transform=T.Compose([
            T.RandomResizedCrop(224, scale=(0.2, 1.0)),
            T.RandomHorizontalFlip(),
            T.ColorJitter(0.4, 0.4, 0.4, 0.1),
            T.ToTensor(),
            T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ]))
    # 按类别筛选
    class_indices = {c: [] for c in range(num_classes)}
    for idx, (_, label) in enumerate(full_set.samples):
        if label < num_classes and len(class_indices[label]) < samples_per_class:
            class_indices[label].append(idx)
    selected = [i for c in class_indices.values() for i in c]
    return Subset(full_set, selected)

# ---------- 2. 编码器(ResNet-18,输出128维) ----------
class Encoder(nn.Module):
    def __init__(self, feat_dim=128):
        super().__init__()
        resnet = torchvision.models.resnet18(weights=None)
        self.backbone = nn.Sequential(*list(resnet.children())[:-1])
        self.fc = nn.Linear(512, feat_dim)
    def forward(self, x):
        feat = self.backbone(x).flatten(1)
        return F.normalize(self.fc(feat), dim=1)

# ---------- 3. MoCo核心实现 ----------
class MoCo(nn.Module):
    def __init__(self, dim=128, K=4096, m=0.999, T=0.07):
        super().__init__()
        self.K = K          # 队列大小
        self.m = m          # 动量系数
        self.T = T          # 温度
        self.encoder_q = Encoder(dim)
        self.encoder_k = Encoder(dim)
        # 初始化动量编码器参数
        for p_q, p_k in zip(self.encoder_q.parameters(), self.encoder_k.parameters()):
            p_k.data.copy_(p_q.data)
            p_k.requires_grad = False
        # 负样本队列
        self.register_buffer('queue', F.normalize(torch.randn(K, dim), dim=1))
        self.register_buffer('queue_ptr', torch.zeros(1, dtype=torch.long))

    @torch.no_grad()
    def _momentum_update(self):
        for p_q, p_k in zip(self.encoder_q.parameters(), self.encoder_k.parameters()):
            p_k.data = p_k.data * self.m + p_q.data * (1. - self.m)

    @torch.no_grad()
    def _dequeue_enqueue(self, keys):
        batch_size = keys.shape[0]
        ptr = int(self.queue_ptr)
        assert self.K % batch_size == 0
        self.queue[ptr:ptr+batch_size] = keys
        ptr = (ptr + batch_size) % self.K
        self.queue_ptr[0] = ptr

    def forward(self, im_q, im_k):
        # 查询特征
        q = self.encoder_q(im_q)
        # 动量编码器计算key(无梯度)
        with torch.no_grad():
            self._momentum_update()
            k = self.encoder_k(im_k)
        # InfoNCE损失
        l_pos = torch.einsum('nc,nc->n', q, k).unsqueeze(-1)  # [N,1]
        l_neg = torch.einsum('nc,ck->nk', q, self.queue.clone().detach())  # [N,K]
        logits = torch.cat([l_pos, l_neg], dim=1) / self.T
        labels = torch.zeros(logits.shape[0], dtype=torch.long).cuda()
        loss = F.cross_entropy(logits, labels)
        # 更新队列
        self._dequeue_enqueue(k)
        return loss

# ---------- 4. 训练与评估 ----------
def train_moco(model, train_loader, epochs=50, lr=0.03):
    optimizer = torch.optim.SGD(model.encoder_q.parameters(), lr=lr,
                                momentum=0.9, weight_decay=5e-4)
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
    model.train()
    for epoch in range(epochs):
        total_loss = 0.0
        for i, (im_q, im_k, _) in enumerate(train_loader):
            im_q, im_k = im_q.cuda(), im_k.cuda()
            loss = model(im_q, im_k)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            total_loss += loss.item()
        scheduler.step()
        if (epoch+1) % 10 == 0:
            print(f'Epoch [{epoch+1}/{epochs}] Loss: {total_loss/len(train_loader):.4f}')

def evaluate(model, val_loader):
    """线性评估:冻结编码器,训练线性分类头"""
    model.eval()
    feat_dim = 128
    classifier = nn.Linear(feat_dim, 10).cuda()
    opt = torch.optim.Adam(classifier.parameters(), lr=1e-3)
    ce = nn.CrossEntropyLoss()
    # 提取特征
    feats, labels = [], []
    with torch.no_grad():
        for x, y in val_loader:
            x = x.cuda()
            feat = model.encoder_q(x)
            feats.append(feat.cpu())
            labels.append(y)
    feats = torch.cat(feats)
    labels = torch.cat(labels)
    # 训练线性层
    dataset = torch.utils.data.TensorDataset(feats, labels)
    loader = DataLoader(dataset, batch_size=256, shuffle=True)
    for epoch in range(30):
        for xb, yb in loader:
            xb, yb = xb.cuda(), yb.cuda()
            out = classifier(xb)
            loss = ce(out, yb)
            opt.zero_grad()
            loss.backward()
            opt.step()
    # 评估
    classifier.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for xb, yb in loader:
            xb, yb = xb.cuda(), yb.cuda()
            pred = classifier(xb).argmax(dim=1)
            correct += (pred == yb).sum().item()
            total += yb.size(0)
    return correct / total

if __name__ == '__main__':
    # 数据加载(需先运行prepare_subset生成索引)
    train_set = prepare_subset()
    # 为对比学习构造两个增强视图
    class TwoCropsTransform:
        def __init__(self, base_transform):
            self.base = base_transform
        def __call__(self, x):
            return self.base(x), self.base(x)
    train_loader = DataLoader(train_set, batch_size=256, shuffle=True,
                              num_workers=4, drop_last=True)
    # 注意:此处train_set的transform已包含增强,需替换为TwoCrops
    train_set.dataset.transform = TwoCropsTransform(train_set.dataset.transform)
    # 验证集(ImageNet val,10类)
    val_transform = T.Compose([
        T.Resize(256), T.CenterCrop(224),
        T.ToTensor(), T.Normalize([0.485,0.456,0.406],[0.229,0.224,0.225])
    ])
    val_set = torchvision.datasets.ImageFolder(
        root='/data/imagenet/val',
        transform=val_transform)
    val_indices = [i for i, (_, l) in enumerate(val_set.samples) if l < 10]
    val_loader = DataLoader(Subset(val_set, val_indices), batch_size=256, shuffle=False)

    model = MoCo(dim=128, K=4096, m=0.999, T=0.07).cuda()
    train_moco(model, train_loader, epochs=50)
    acc = evaluate(model, val_loader)
    print(f'Linear Evaluation Accuracy: {acc:.4f}')

运行方式

# 1. 准备数据(ImageNet格式目录结构)
#    /data/imagenet/train/ 包含类别子文件夹
#    /data/imagenet/val/   包含类别子文件夹
# 2. 运行训练
python moco_implementation.py

验证方法与通过标准

  • 训练收敛验证:训练过程中InfoNCE损失应持续下降,50个epoch后损失通常低于0.5(具体值取决于数据子集难度)。
  • 表征质量评估:通过线性评估协议(冻结编码器,训练线性分类头)在10类ImageNet子集上测试,若top-1准确率高于60%则证明MoCo动量对比机制有效工作。
  • 消融检查:若将动量系数m设为0(即无动量更新),或队列大小K设为0,准确率应显著下降,以此验证各组件必要性。

常见问题

  • GPU显存不足:减小batch size至128或64,同时确保K能被batch size整除(如K=2048, batch=256)。
  • 队列更新错误:K必须能被batch size整除,否则_dequeue_enqueue中的切片会越界。
  • 动量编码器梯度问题:动量编码器参数必须设置requires_grad=False,否则反向传播会错误更新key编码器。
MoCo v1 动量对比学习架构 图像 x 增强1 增强2 编码器 q 梯度更新 动量编码器 k 无梯度,EMA更新 m=0.999 q 特征 k 特征 负样本队列 (K=4096) 存储历史key特征,先进先出 入队 InfoNCE Loss 正样本对:q·k⁺ 负样本对:q·k⁻ (队列) 温度 τ = 0.07 反向传播(仅更新编码器q)

架构说明:图中展示了MoCo的核心数据流。输入图像经两种随机增强得到两个视图,分别送入查询编码器(可训练)和动量编码器(EMA更新)。查询特征与动量特征计算正样本相似度,同时与队列中4096个历史负样本特征计算相似度,共同构成InfoNCE损失。每次迭代后,当前batch的动量特征入队,最旧的batch出队,实现动态负样本库维护。