迁移学习与微调 本节摘要:别人花了一百万 GPU 小时教会一个网络什么是边缘、纹理、物体部件。在训练你自己的之前,你应该先借用这些特征。本节讲清两种迁移机制——特征提取(冻结骨干,只训头)与微调(端到端小学习率),并按数据集大小与领域距离给出选择配方。我们会手写判别式学习率(早期层用更小 LR)、处理 BatchNorm 在小数据集上的统计漂移、做渐进式解冻,并诊断三种常见失败:学习率过高把预训练特征打飞、BN 统计崩塌、灾难性遗忘。读完本节,你能用不到 20 行把一个 ImageNet 预训练骨干接到新任务上,并知道何时该全量微调、何时只训头。 对应原课程:Phase 4 · Lesson 05 · (原英文 )。
本节摘要:别人花了一百万 GPU 小时教会一个网络什么是边缘、纹理、物体部件。在训练你自己的之前,你应该先借用这些特征。本节讲清两种迁移机制——特征提取(冻结骨干,只训头)与微调(端到端小学习率),并按数据集大小与领域距离给出选择配方。我们会手写判别式学习率(早期层用更小 LR)、处理 BatchNorm 在小数据集上的统计漂移、做渐进式解冻,并诊断三种常见失败:学习率过高把预训练特征打飞、BN 统计崩塌、灾难性遗忘。读完本节,你能用不到 20 行把一个 ImageNet 预训练骨干接到新任务上,并知道何时该全量微调、何时只训头。
对应原课程:Phase 4 · Lesson 05 ·
transfer-learning(原英文phases/04-computer-vision/05-transfer-learning/docs/en.md)。
阅读完本节,你应当能够:
在 ImageNet 上训一个 ResNet-50 大约要 2000 GPU 小时。很少有团队能为每个交付任务都拿出这个预算。几乎所有团队真正交付的,都是「预训练骨干 + 新头 + 在几百到几千张任务特有图像上训练」。
这不是抄近路。任何在 ImageNet 上训练的 CNN,第一个卷积块学的是边缘和 Gabor 式滤波器,接下来几块学纹理和简单母题,中间块学物体部件,最后块学开始像那一千个 ImageNet 类别的组合。这条层级的前 90%,几乎原样迁移到医学影像、工业检测、卫星数据,以及任何你能想到的视觉任务——因为自然的边缘与纹理词汇表是有限的。你要真正训练的,是最后那 10%。
迁移有三只拦路虎等着你:用过高的学习率毁掉预训练特征、冻结太多饿死模型、让 BatchNorm 的运行统计漂向一个网络其余部分从没学过的小数据集。本节会刻意逐个演示。
两种机制,按你多信任预训练特征、手头多少数据来选。
经验法则:
| 数据集大小 | 领域距离 | 配方 |
|---|---|---|
| < 1k 张 | 接近 ImageNet | 冻结骨干,只训头 |
| 1k-10k | 接近 | 冻结前 2-3 个 stage,微调其余 |
| 10k-100k | 任意 | 端到端微调 + 判别式 LR |
| 100k+ | 远 | 全量微调;若领域够远,考虑从头训 |
「接近 ImageNet」大致指自然 RGB 照片、内容像物体。医学 CT、俯视卫星、显微图像是远领域——特征仍然有用,但你得放更多层去适应。
CNN 学到的 ImageNet 特征并不专属于那一千类,而是专属于自然图像的统计:特定朝向的边缘、纹理、对比模式、形状原语。这些统计在人类能命名的几乎每一个视觉领域里都稳定。这正是为什么一个在 ImageNet 上训练的模型,在 CIFAR-10 上做零样本评估(只加一个新的线性头、不微调骨干)能到 80%+ 准确率——头学的是「为这个任务,该把哪些已经学到的特征加权」。
当你确实解冻时,早期层应训得比后期层慢。早期层编码你想保留的通用特征,后期层编码你要大幅移动的任务特有结构。
典型配方: stage 0 (stem + 第一组): lr = base_lr / 100 (基本不动) stage 1: lr = base_lr / 10 stage 2: lr = base_lr / 3 stage 3 (骨干最后一组): lr = base_lr head: lr = base_lr (或略高)
在 PyTorch 里这就是传给优化器的一组参数组。一个模型、五个学习率、零额外代码。
BN 层持有在 ImageNet 上算出的 running_mean 和 running_var 缓冲。如果你的任务像素分布不同——光线不同、传感器不同、色彩空间不同——这些缓冲就是错的。三种选择,按偏好排序:
选错会悄悄掉 5-15% 准确率。
分类头是 1-3 层线性加一个可选 dropout。每个 torchvision 骨干都带一个你要替换的默认头:
backbone.fc = nn.Linear(backbone.fc.in_features, num_classes) # ResNet backbone.classifier[1] = nn.Linear(..., num_classes) # EfficientNet, MobileNet backbone.heads.head = nn.Linear(..., num_classes) # torchvision ViT
小数据集上,单层线性通常够。任务分布离骨干训练分布较远时,加一层隐藏层(Linear → ReLU → Dropout → Linear)有用。
现代微调(BEiT、DINOv2、ViT-B 微调)里判别式 LR 的更平滑版本:不再把层分成 stage,而是给每层一个比上一层略小的 LR:
lr_layer_k = base_lr * decay^(L - k)
decay = 0.75、L = 12 个 transformer 块时,第一个块以 0.75^11 ≈ 0.04x 头的 LR 在训。对 transformer 微调比对 CNN 更重要——CNN 里按 stage 分组通常够了。
迁移学习的运行,需要追踪两个你从头训时不会看的数:
如果微调后还不如仅预训练,你有学习率或 BN 的 bug。永远两个都打印。
import torch import torch.nn as nn from torchvision.models import resnet18, ResNet18_Weights backbone = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1) print(backbone) print() print("classifier head:", backbone.fc) print("feature dim:", backbone.fc.in_features)
ResNet18 有四个 stage(layer1..layer4),加一个 stem 和一个 fc 头。每个 torchvision 分类骨干都有类似结构。
def make_feature_extractor(num_classes=10): model = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1) for p in model.parameters(): p.requires_grad = False model.fc = nn.Linear(model.fc.in_features, num_classes) return model model = make_feature_extractor(num_classes=10) trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) frozen = sum(p.numel() for p in model.parameters() if not p.requires_grad) print(f"trainable: {trainable:>10,}") print(f"frozen: {frozen:>10,}")
只有 model.fc 可训练。骨干是一个冻结的特征提取器。
一个工具函数,构造按 stage 给学习率的参数组。
def discriminative_param_groups(model, base_lr=1e-3, decay=0.3): stages = [ ["conv1", "bn1"], ["layer1"], ["layer2"], ["layer3"], ["layer4"], ["fc"], ] groups = [] for i, names in enumerate(stages): lr = base_lr * (decay ** (len(stages) - 1 - i)) params = [p for n, p in model.named_parameters() if any(n.startswith(k) for k in names)] if params: groups.append({"params": params, "lr": lr, "name": "_".join(names)}) return groups model = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, 10) for p in model.parameters(): p.requires_grad = True groups = discriminative_param_groups(model) for g in groups: print(f"{g['name']:>10s} lr={g['lr']:.2e} params={sum(p.numel() for p in g['params']):>8,}")
decay=0.3 意味着每个 stage 以它下一个 stage 30% 的速率训。fc 拿 base_lr,layer4 拿 0.3 * base_lr,conv1 拿 0.3^5 * base_lr ≈ 0.00243 * base_lr。听着极端,实际好用。
一个帮手:冻结 BN 运行统计,但不冻结它的权重。
def freeze_bn_stats(model): for m in model.modules(): if isinstance(m, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)): m.eval() for p in m.parameters(): p.requires_grad = False return model
在每个 epoch 开始的 model.train() 之后调用它。model.train() 把一切切到训练模式;这个函数只对 BN 层把它翻回去。
from torch.optim import SGD from torch.utils.data import DataLoader from torch.optim.lr_scheduler import CosineAnnealingLR import torch.nn.functional as F def fine_tune(model, train_loader, val_loader, device, epochs=5, base_lr=1e-3, freeze_bn=False): model = model.to(device) groups = discriminative_param_groups(model, base_lr=base_lr) optimizer = SGD(groups, momentum=0.9, weight_decay=1e-4, nesterov=True) scheduler = CosineAnnealingLR(optimizer, T_max=epochs) for epoch in range(epochs): model.train() if freeze_bn: freeze_bn_stats(model) tr_loss, tr_correct, tr_total = 0.0, 0, 0 for x, y in train_loader: x, y = x.to(device), y.to(device) logits = model(x) loss = F.cross_entropy(logits, y, label_smoothing=0.1) optimizer.zero_grad() loss.backward() optimizer.step() tr_loss += loss.item() * x.size(0) tr_total += x.size(0) tr_correct += (logits.argmax(-1) == y).sum().item() scheduler.step() model.eval() va_total, va_correct = 0, 0 with torch.no_grad(): for x, y in val_loader: x, y = x.to(device), y.to(device) pred = model(x).argmax(-1) va_total += x.size(0) va_correct += (pred == y).sum().item() print(f"epoch {epoch} train {tr_loss/tr_total:.3f}/{tr_correct/tr_total:.3f} " f"val {va_correct/va_total:.3f}") return model
上述配方在 CIFAR-10 上跑五个 epoch,把 ResNet18-IMAGENET1K_V1 从约 70% 零样本线性探测准确率带到约 93% 微调准确率。只训头、不动骨干,会在约 86% 停住。
一个调度:每个 epoch 从尾到头解冻一个 stage。以多花几个 epoch 为代价,缓解特征漂移。
def progressive_unfreeze_schedule(model): stages = ["layer4", "layer3", "layer2", "layer1"] yielded = set() def start(): for p in model.parameters(): p.requires_grad = False for p in model.fc.parameters(): p.requires_grad = True def unfreeze(epoch): if epoch < len(stages): name = stages[epoch] yielded.add(name) for n, p in model.named_parameters(): if n.startswith(name): p.requires_grad = True return name return None return start, unfreeze
第一个 epoch 前调一次 start()。每个 epoch 开头调 unfreeze(epoch)。每当可训练参数集合变了就重建优化器,否则冻结参数仍持有缓存的动量,会把优化器搞乱。
对多数真实任务,torchvision.models 加三行就够。上面那套更重的机制,是你撞上库默认值救不了的问题时才用。
from torchvision.models import resnet50, ResNet50_Weights model = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2) model.fc = nn.Linear(model.fc.in_features, num_classes) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4)
另外两个生产级默认:
timm 提供约 800 个预训练视觉骨干,API 一致(timm.create_model("resnet50", pretrained=True, num_classes=10))。任何超出 torchvision 动物园的微调,它都是标准。transformers.AutoModelForImageClassification.from_pretrained(name, num_labels=N) 给你 ViT / BEiT / DeiT,加载语义和文本模型一致。本节产出两个可复用文件(位于原课程 outputs/):
prompt-fine-tune-planner.md:一个提示词——按数据集大小、领域距离和算力预算,在「特征提取 / 渐进 / 端到端微调」之间挑方案。skill-freeze-inspector.md:一个技能——给定一个 PyTorch 模型,报告哪些参数可训练、哪些 BN 层在 eval 模式、优化器是否真的拿到了可训练参数。ResNet18 当线性探测(骨干冻结)和全量微调训练。并排报告两个准确率。解释哪个差距说明特征迁移得好、哪个说明迁移得差。base_lr = 1e-1(而不是头)。展示训练损失爆炸,再用 discriminative_param_groups 帮手恢复。记录每个 stage 开始发散的 LR。lr_k = base_lr * decay^(L-k),transformer 微调更常用。timm 给 800 个骨干;transformers 给 ViT/BEiT/DeiT。下一节,我们离开「给整张图打一个标签」,进入目标检测——用 YOLO 从零讲清如何在一张图里同时定位和识别多个物体。