4. BYOL自举潜在表示实现

任务目标

实现BYOL(Bootstrap Your Own Latent)自监督表示学习模型,包含在线网络与目标网络,通过动量更新机制在Tiny ImageNet子集上完成预训练,并使用线性探测协议评估所学表示的质量。

核心原理与必要说明

BYOL的核心在于两个结构相同但参数不同步更新的网络:在线网络(online network)与目标网络(target network)。在线网络通过梯度下降更新,目标网络参数则通过在线网络参数的指数移动平均(EMA)获得,即动量更新:θ_target = τ·θ_target + (1−τ)·θ_online。训练时,对同一输入图像做两次不同的随机增强,分别送入两个网络,在线网络输出经预测器(predictor)后与目标网络输出的投影(projection)计算均方误差损失。由于目标网络参数滞后于在线网络,BYOL无需负样本即可避免表示坍塌。

环境依赖

需要PyTorch 1.10+、torchvision、tqdm。Tiny ImageNet数据集需预先下载并整理为ImageFolder格式(200类,每类500张训练图)。若使用完整Tiny ImageNet训练时间过长,可先使用其子集(如随机选取20类)验证代码正确性,本实现支持通过--num_classes参数控制类别数。

完整实现代码

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
from tqdm import tqdm
import copy
import argparse
import os

# ---------- 基础模块 ----------
class MLP(nn.Module):
    def __init__(self, in_dim, hidden_dim, out_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),
            nn.ReLU(inplace=True),
            nn.Linear(hidden_dim, out_dim)
        )
    def forward(self, x):
        return self.net(x)

class Encoder(nn.Module):
    """ResNet-18编码器,输出2048维特征"""
    def __init__(self):
        super().__init__()
        resnet = torchvision.models.resnet18(weights=None)
        self.features = nn.Sequential(*list(resnet.children())[:-1])
        self.fc = nn.Linear(512, 2048)
    def forward(self, x):
        x = self.features(x)
        x = torch.flatten(x, 1)
        return self.fc(x)

class BYOL(nn.Module):
    def __init__(self, base_encoder=Encoder, proj_dim=256, pred_hidden=4096, tau=0.996):
        super().__init__()
        self.tau = tau
        # 在线网络
        self.online_encoder = base_encoder()
        self.online_projector = MLP(2048, pred_hidden, proj_dim)
        self.online_predictor = MLP(proj_dim, pred_hidden, proj_dim)
        # 目标网络(结构相同,参数不梯度更新)
        self.target_encoder = copy.deepcopy(self.online_encoder)
        self.target_projector = copy.deepcopy(self.online_projector)
        for p in self.target_encoder.parameters():
            p.requires_grad = False
        for p in self.target_projector.parameters():
            p.requires_grad = False

    @torch.no_grad()
    def _momentum_update(self):
        """动量更新目标网络参数"""
        for online_p, target_p in zip(self.online_encoder.parameters(), self.target_encoder.parameters()):
            target_p.data = self.tau * target_p.data + (1 - self.tau) * online_p.data
        for online_p, target_p in zip(self.online_projector.parameters(), self.target_projector.parameters()):
            target_p.data = self.tau * target_p.data + (1 - self.tau) * online_p.data

    def forward(self, x1, x2):
        # 在线分支
        proj1 = self.online_predictor(self.online_projector(self.online_encoder(x1)))
        # 目标分支(无梯度)
        with torch.no_grad():
            proj2 = self.target_projector(self.target_encoder(x2))
        return proj1, proj2

def byol_loss(pred, target):
    pred = F.normalize(pred, dim=1)
    target = F.normalize(target, dim=1)
    return 2 - 2 * (pred * target).sum(dim=1).mean()

# ---------- 数据增强 ----------
def get_augmentation():
    return T.Compose([
        T.RandomResizedCrop(64, 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(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
    ])

class TwoCropsTransform:
    def __init__(self, base_transform):
        self.base = base_transform
    def __call__(self, x):
        return self.base(x), self.base(x)

# ---------- 训练 ----------
def train_byol(model, train_loader, epochs, lr, device):
    optimizer = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9, weight_decay=1e-4)
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
    model.train()
    for epoch in range(epochs):
        total_loss = 0.0
        pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{epochs}")
        for (x1, x2), _ in pbar:
            x1, x2 = x1.to(device), x2.to(device)
            pred, target = model(x1, x2)
            loss = byol_loss(pred, target)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            model._momentum_update()
            total_loss += loss.item()
            pbar.set_postfix({"loss": f"{loss.item():.4f}"})
        scheduler.step()
        print(f"Epoch {epoch+1} avg loss: {total_loss/len(train_loader):.4f}")

# ---------- 线性评估 ----------
def linear_eval(encoder, train_loader, test_loader, device, num_classes):
    encoder.eval()
    # 提取特征
    train_feats, train_labels = [], []
    with torch.no_grad():
        for x, y in train_loader:
            x = x.to(device)
            feat = encoder(x).cpu()
            train_feats.append(feat)
            train_labels.append(y)
    train_feats = torch.cat(train_feats)
    train_labels = torch.cat(train_labels)
    # 训练线性分类器
    classifier = nn.Linear(2048, num_classes).to(device)
    opt = torch.optim.Adam(classifier.parameters(), lr=1e-3)
    ce = nn.CrossEntropyLoss()
    dataset = torch.utils.data.TensorDataset(train_feats, train_labels)
    loader = DataLoader(dataset, batch_size=256, shuffle=True)
    classifier.train()
    for _ in range(30):
        for feat, label in loader:
            feat, label = feat.to(device), label.to(device)
            out = classifier(feat)
            loss = ce(out, label)
            opt.zero_grad()
            loss.backward()
            opt.step()
    # 测试
    classifier.eval()
    correct = total = 0
    with torch.no_grad():
        for x, y in test_loader:
            x = x.to(device)
            feat = encoder(x)
            out = classifier(feat)
            pred = out.argmax(dim=1)
            correct += (pred.cpu() == y).sum().item()
            total += y.size(0)
    return correct / total

# ---------- 主程序 ----------
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--data_dir", type=str, required=True, help="Tiny ImageNet根目录,含train/和val/子目录")
    parser.add_argument("--epochs", type=int, default=50)
    parser.add_argument("--batch_size", type=int, default=128)
    parser.add_argument("--lr", type=float, default=0.03)
    parser.add_argument("--num_classes", type=int, default=200, help="使用类别数,可设小值快速验证")
    parser.add_argument("--device", type=str, default="cuda")
    args = parser.parse_args()

    device = torch.device(args.device if torch.cuda.is_available() else "cpu")
    print(f"Using device: {device}")

    # 数据集(需预先按类别子目录整理)
    train_transform = TwoCropsTransform(get_augmentation())
    eval_transform = T.Compose([
        T.Resize(64),
        T.CenterCrop(64),
        T.ToTensor(),
        T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
    ])

    # 若使用子集,需先筛选类别目录
    train_dir = os.path.join(args.data_dir, "train")
    val_dir = os.path.join(args.data_dir, "val")
    if args.num_classes < 200:
        classes = sorted(os.listdir(train_dir))[:args.num_classes]
        train_dir = os.path.join(args.data_dir, "train_subset")
        val_dir = os.path.join(args.data_dir, "val_subset")
        os.makedirs(train_dir, exist_ok=True)
        os.makedirs(val_dir, exist_ok=True)
        for c in classes:
            os.makedirs(os.path.join(train_dir, c), exist_ok=True)
            os.makedirs(os.path.join(val_dir, c), exist_ok=True)

    train_dataset = torchvision.datasets.ImageFolder(train_dir, transform=train_transform)
    train_loader = DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, num_workers=4, drop_last=True)

    # 预训练
    model = BYOL().to(device)
    train_byol(model, train_loader, args.epochs, args.lr, device)

    # 保存模型
    torch.save(model.state_dict(), "byol_tinyimagenet.pth")

    # 线性评估(使用单视图增强)
    eval_train_dataset = torchvision.datasets.ImageFolder(train_dir, transform=eval_transform)
    eval_test_dataset = torchvision.datasets.ImageFolder(val_dir, transform=eval_transform)
    eval_train_loader = DataLoader(eval_train_dataset, batch_size=256, shuffle=False, num_workers=4)
    eval_test_loader = DataLoader(eval_test_dataset, batch_size=256, shuffle=False, num_workers=4)

    acc = linear_eval(model.online_encoder, eval_train_loader, eval_test_loader, device, args.num_classes)
    print(f"Linear evaluation accuracy: {acc:.4f}")

if __name__ == "__main__":
    main()

运行方式

# 使用全部200类
python byol.py --data_dir /path/to/tiny-imagenet --epochs 50 --batch_size 128

# 快速验证(20类,约10分钟)
python byol.py --data_dir /path/to/tiny-imagenet --epochs 10 --batch_size 64 --num_classes 20

验证方法与通过标准

训练过程中观察loss应持续下降并趋于稳定(例如从初始约1.5降至0.5以下)。训练完成后,程序自动执行线性评估并输出准确率。判定通过的参考标准:在20类子集上训练10个epoch后线性评估准确率应高于50%;在完整200类上训练50个epoch后准确率应高于25%。若准确率接近随机猜测(1/类别数),说明模型发生坍塌,需检查动量更新是否生效、学习率是否过大。

常见问题

  • loss不下降:检查是否调用了model._momentum_update(),以及目标网络参数是否处于requires_grad=False状态。
  • 显存不足:减小batch_size或使用更小的编码器(如ResNet-18的浅层替代)。
  • 数据集路径错误:Tiny ImageNet的train目录下应为200个类别子文件夹,val目录下为200个类别子文件夹(需从val_annotations.txt重新整理)。