迁移学习与微调


文档摘要

迁移学习与微调 本节摘要:别人花了一百万 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)。

学习目标

阅读完本节,你应当能够:

  1. 区分特征提取微调,并按数据集大小、领域距离和算力预算选对方案。
  2. 加载预训练骨干、替换分类头,在 20 行内只训头就得到一个可用基线
  3. 判别式学习率渐进解冻,让早期通用特征比后期任务特有特征更新得更慢。
  4. 诊断三种常见失败:解冻块学习率过高导致特征漂移、小数据集上 BN 统计崩塌灾难性遗忘

一、问题与直觉

在 ImageNet 上训一个 ResNet-50 大约要 2000 GPU 小时。很少有团队能为每个交付任务都拿出这个预算。几乎所有团队真正交付的,都是「预训练骨干 + 新头 + 在几百到几千张任务特有图像上训练」。

这不是抄近路。任何在 ImageNet 上训练的 CNN,第一个卷积块学的是边缘和 Gabor 式滤波器,接下来几块学纹理和简单母题,中间块学物体部件,最后块学开始像那一千个 ImageNet 类别的组合。这条层级的前 90%,几乎原样迁移到医学影像、工业检测、卫星数据,以及任何你能想到的视觉任务——因为自然的边缘与纹理词汇表是有限的。你要真正训练的,是最后那 10%。

迁移有三只拦路虎等着你:用过高的学习率毁掉预训练特征、冻结太多饿死模型、让 BatchNorm 的运行统计漂向一个网络其余部分从没学过的小数据集。本节会刻意逐个演示。

特征提取 vs 微调

两种机制,按你多信任预训练特征、手头多少数据来选。

经验法则:

数据集大小 领域距离 配方
< 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 里这就是传给优化器的一组参数组。一个模型、五个学习率、零额外代码。

BatchNorm 问题

BN 层持有在 ImageNet 上算出的 running_meanrunning_var 缓冲。如果你的任务像素分布不同——光线不同、传感器不同、色彩空间不同——这些缓冲就是错的。三种选择,按偏好排序:

  1. BN 留在 train 模式微调。让 BN 跟所有其他东西一起更新运行统计。任务数据集中等规模(≥ 5k 样本)时的默认选择。
  2. BN 冻结在 eval 模式。保留 ImageNet 统计,只训权重。数据集小到 BN 移动平均会噪声很大时选这个。
  3. 用 GroupNorm 替换 BN。彻底消除移动平均问题。检测和分割骨干里用,因为单 GPU 批次太小。

选错会悄悄掉 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)有用。

逐层 LR 衰减

现代微调(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。永远两个都打印。

二、从零实现

步骤 1:加载预训练骨干并检视

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 分类骨干都有类似结构。

步骤 2:特征提取——全冻结,换头

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 可训练。骨干是一个冻结的特征提取器。

步骤 3:判别式微调

一个工具函数,构造按 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% 的速率训。fcbase_lr,layer40.3 * base_lr,conv10.3^5 * base_lr ≈ 0.00243 * base_lr。听着极端,实际好用。

步骤 4:BatchNorm 处理

一个帮手:冻结 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 层把它翻回去。

步骤 5:一个最小端到端微调循环

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% 停住。

步骤 6:渐进式解冻

一个调度:每个 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 动物园的微调,它都是标准。
  • 对 transformer,transformers.AutoModelForImageClassification.from_pretrained(name, num_labels=N) 给你 ViT / BEiT / DeiT,加载语义和文本模型一致。

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-fine-tune-planner.md:一个提示词——按数据集大小、领域距离和算力预算,在「特征提取 / 渐进 / 端到端微调」之间挑方案。
  • skill-freeze-inspector.md:一个技能——给定一个 PyTorch 模型,报告哪些参数可训练、哪些 BN 层在 eval 模式、优化器是否真的拿到了可训练参数。

五、练习

  1. (简单) 在同一合成 CIFAR 数据集上,分别把 ResNet18 当线性探测(骨干冻结)和全量微调训练。并排报告两个准确率。解释哪个差距说明特征迁移得好、哪个说明迁移得差。
  2. (中等) 故意植入一个 bug:把骨干 stage 的 base_lr = 1e-1(而不是头)。展示训练损失爆炸,再用 discriminative_param_groups 帮手恢复。记录每个 stage 开始发散的 LR。
  3. (困难) 取一个医学影像数据集(如 CheXpert-small、PatchCamelyon 或 HAM10000),比较三种机制:(a) ImageNet 预训练冻结骨干 + 线性头;(b) ImageNet 预训练端到端微调;(c) 从头训。报告每种的准确率和算力成本。在多大样本规模下,从头训才能匹敌?

本节要点回顾

  1. 前 90% 特征通用——边缘、纹理、形状原语跨领域稳定;你要训的只是最后 10% 任务特有部分。
  2. 两种机制——特征提取(冻结骨干只训头,小数据/近领域)、微调(端到端小 LR,大数据/远领域)。
  3. 冻结为何有效——特征专属于自然图像统计而非 1000 类;零样本线性探测就能 80%+。
  4. 判别式学习率——早期层 lr 是后期层的零头(stage 衰减如 0.3);一个模型、多组参数组、零额外代码。
  5. 逐层 LR 衰减——lr_k = base_lr * decay^(L-k),transformer 微调更常用。
  6. BN 三选——中数据 train 模式、小数据 eval 冻结、检测/分割换 GroupNorm;选错掉 5-15%。
  7. 头设计——单线性通常够,任务远时加一层隐藏层。
  8. 同时报告下限与上限——仅预训练准确率(下限)与微调准确率(上限),微调更低说明 LR/BN 有 bug。
  9. 渐进式解冻——每 epoch 解冻一个 stage,缓解特征漂移,改了可训练集合就要重建优化器。
  10. 生产级选择——torchvision 三行起步;timm 给 800 个骨干;transformers 给 ViT/BEiT/DeiT。

下一节,我们离开「给整张图打一个标签」,进入目标检测——用 YOLO 从零讲清如何在一张图里同时定位和识别多个物体。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U