迁移学习的"最小调参模块"

用最少的代码、最少的训练时间,撬动预训练模型的强大能力。

1. 什么是迁移学习?

迁移学习的核心思想:把一个在大数据集上已经学好的模型,拿来解决你自己的小任务

类比:一位学了十年绘画的画家,转行做 UI 设计------他不需要从"怎么握笔"重新学起,只要学"设计规范"就行。预训练模型就是那位有十年功底的画家。

2. 为什么"只训最后一层"就够了?

2.1 CNN 各层学到了什么

深度卷积神经网络(如 ResNet50)各层学到的特征呈层级递进

markdown 复制代码
浅层(conv1, layer1)     中层(layer2, layer3)     深层(layer4)
      │                        │                        │
   边缘、纹理               局部部件                  高级语义
   ┌────────┐             ┌────────┐              ┌────────┐
   │ ╱  ╲   │             │  眼睛   │              │ 狗脸    │
   │ ━  ┃   │             │  鼻子   │              │ 猫脸    │
   │ 圆 角   │             │  腿    │              │ 车体    │
   └────────┘             └────────┘              └────────┘
   
   通用性:极高              通用性:高                通用性:中
   任何图像都有边缘           大多数动物有眼鼻           与任务相关

关键发现:浅层和中层的特征是跨任务通用的。

不管是识别狗、车、花还是 X 光片,图像里都有边缘、纹理、形状这些基础视觉元素。

2.2 fc 层的角色

bash 复制代码
  图片 ──► [通用特征提取器(冻结)] ──► 2048 维语义向量 ──► fc ──► 类别得分
                                            ↑
                                     已经是高度抽象的表示

到达 fc 层之前,图像已经被压缩成一个 2048 维的语义特征向量。fc 层只做一件事:把这个向量映射到你的类别空间。

2.3 线性可分直觉

预训练网络的强大之处在于:它把原始像素空间中非线性纠缠 的类别,映射到了特征空间中近似线性可分的分布:

objectivec 复制代码
   像素空间(混在一起)             特征空间(分得开)
   
   🐕🐈🐕🐈🐕                    🐕🐕🐕🐕🐕
   🐈🐕🐈🐕🐈      ── CNN ──►    ─────────  ← 一条直线就能分
   🐕🐈🐕🐈🐕                    🐈🐈🐈🐈🐈

fc 层本质上就是画这条"分界线"(超平面),用线性分类器就足以划分类别边界。

3. 最小调参模块:5 步范式

整体思路图

bash 复制代码
  预训练 ResNet50(在 ImageNet 上学会识别 1000 类)
        │
        │  ① 加载权重
        ▼
  ┌───────────────────────────────────┐
  │ Conv → Conv → ... → fc(2048→1000) │
  └───────────────────────────────────┘
        │
        │  ② 冻结所有层(不让它们的权重再变)
        ▼
  ┌──────────────────────────────┐
  │ 🔒Conv → 🔒Conv → ... → 🔒fc │
  └──────────────────────────────┘
        │
        │  ③ 把最后一层换成自己任务的形状(120 类犬种)
        ▼
  ┌────────────────────────────────────────┐
  │ 🔒Conv → 🔒Conv → ... → 🆕fc(2048→120) │   ← 新 fc 默认可训练
  └────────────────────────────────────────┘
        │
        │  ④ 只让 optimizer 看到新 fc 的参数
        ▼
  训练时只更新最后一层,主干当"特征提取器"

代码实现

python 复制代码
import torch.nn as nn
from torchvision import models

# ① 加载预训练模型
model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)

# ② 冻结所有参数
for p in model.parameters():
    p.requires_grad = False

# ③ 替换最后的全连接层
model.fc = nn.Linear(model.fc.in_features, num_classes)

# ④ 构造优化器(只传 fc 层参数)
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)

# ⑤ 定义损失函数
criterion = nn.CrossEntropyLoss()

逐步解析

Step 1:加载预训练权重
python 复制代码
model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)
  • 自动下载 ImageNet 上训练好的权重(约 98 MB)
  • 此时所有层的 requires_grad 默认为 True
  • 最后一层形状:Linear(2048 → 1000)
Step 2:冻结主干
python 复制代码
for p in model.parameters():
    p.requires_grad = False
效果 说明
反向传播不计算这些参数的梯度 省显存 + 省算力
权重不会被更新 保护预训练学到的通用特征
训练速度大幅提升 只算一层的梯度
Step 3:替换分类头
python 复制代码
model.fc = nn.Linear(model.fc.in_features, num_classes)
# model.fc.in_features = 2048
# num_classes = 你的任务类别数(如 120 种犬种)

新建的 nn.Linear 默认 requires_grad=True,不受上面冻结的影响。

此时模型的梯度状态:

scss 复制代码
🔒 conv1 → 🔒 layer1 → 🔒 layer2 → 🔒 layer3 → 🔒 layer4 → 🆕 fc(可训练)
Step 4:优化器只接收新 fc
python 复制代码
optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)

这是"双重保险":

闸门 作用
requires_grad=False 不产生梯度(省资源)
optimizer 只收 fc 即使有梯度也不会被更新
Step 5:损失函数
python 复制代码
criterion = nn.CrossEntropyLoss()

多分类任务的标准损失函数,内部包含 Softmax + 负对数似然。

4. 完整实战:用 ResNet50 识别 120 种犬种

下面是一个完整的实战案例:使用预训练 ResNet50 微调识别 Kaggle Dog Breed Identification 比赛中的 120 种犬种。

4.1 数据准备

python 复制代码
import torch, os, torchvision
import torch.nn as nn
import pandas as pd
from torch.utils.data import DataLoader, Dataset
from torchvision import datasets, models, transforms
from PIL import Image
from sklearn.model_selection import StratifiedShuffleSplit

# 读取标签
DATA_ROOT = 'data/dog-breed-identification'
all_labels_df = pd.read_csv(os.path.join(DATA_ROOT, 'labels.csv'))

# 犬种名 → 数字标签
breeds = all_labels_df.breed.unique() # 120 种
breed2idx = dict((breed, idx) for idx, breed in enumerate(breeds))
all_labels_df['label_idx'] = [breed2idx[b] for b in all_labels_df.breed]

4.2 自定义 Dataset

python 复制代码
class DogDataset(Dataset):
    def __init__(self, labels_df, img_path, transform=None):
        self.labels_df = labels_df
        self.img_path = img_path
        self.transform = transform

    def __len__(self):
        return self.labels_df.shape[0]       # 样本总数

    def __getitem__(self, idx):
        image_name = os.path.join(self.img_path, self.labels_df.id[idx]) + '.jpg'
        img = Image.open(image_name)
        label = self.labels_df.label_idx[idx]
        if self.transform:
            img = self.transform(img)
        return img, label

4.3 数据增强与加载

python 复制代码
IMG_SIZE = 224          # ResNet50 输入尺寸
BATCH_SIZE = 256
IMG_MEAN = [0.485, 0.456, 0.406]   # ImageNet 均值
IMG_STD  = [0.229, 0.224, 0.225]   # ImageNet 标准差
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "mps")

# 训练集:随机裁剪 + 翻转 + 旋转(数据增强)
train_transforms = transforms.Compose([
    transforms.Resize(IMG_SIZE),
    transforms.RandomResizedCrop(IMG_SIZE),
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(30),
    transforms.ToTensor(),
    transforms.Normalize(IMG_MEAN, IMG_STD)
])

# 验证集:确定性变换(不做增强)
test_transforms = transforms.Compose([
    transforms.Resize(IMG_SIZE),
    transforms.CenterCrop(IMG_SIZE),
    transforms.ToTensor(),
    transforms.Normalize(IMG_MEAN, IMG_STD)
])

# 分层抽样:保证每个犬种在 train/val 中占比一致
stratified_split = StratifiedShuffleSplit(n_splits=1, test_size=0.1, random_state=0)
train_split_idx, test_split_idx = next(iter(
    stratified_split.split(all_labels_df.id, all_labels_df.breed)
))
train_df = all_labels_df.iloc[train_split_idx].reset_index()
test_df   = all_labels_df.iloc[test_split_idx].reset_index()

# 构建 Dataset 和 DataLoader
train_dataset = DogDataset(train_df, os.path.join(DATA_ROOT, 'train'), transform=train_transforms)
test_dataset   = DogDataset(test_df,   os.path.join(DATA_ROOT, 'train'), transform=test_transforms)

train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
test_loader   = DataLoader(test_dataset,   batch_size=BATCH_SIZE, shuffle=False)

4.4 模型搭建(最小调参模块)

python 复制代码
# 加载预训练 ResNet50
model_ft = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)

# 冻结所有参数
for param in model_ft.parameters():
    param.requires_grad = False

# 替换最后的 fc 层:2048 → 120 类
model_ft.fc = nn.Linear(model_ft.fc.in_features, len(breeds))
model_ft = model_ft.to(DEVICE)

# 损失函数 + 优化器(只传 fc 参数)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam([{'params': model_ft.fc.parameters()}], lr=0.001)

4.5 训练与验证

python 复制代码
def train(model, device, train_loader, epoch):
    model.train()
    for _, data in enumerate(train_loader):
        x, y = data
        x, y = x.to(device), y.to(device)
        optimizer.zero_grad()           # 清空旧梯度
        y_hat = model(x)                # 前向:经过所有层
        loss = criterion(y_hat, y)      # 计算损失
        loss.backward()                 # 反向:只算 fc 的梯度
        optimizer.step()                # 只更新 fc 的参数
    print(f'Train Epoch: {epoch}\t Loss: {loss.item():.6f}')


def test(model, device, test_loader):
    model.eval()
    test_loss = 0
    correct = 0
    with torch.no_grad():
        for _, data in enumerate(test_loader):
            x, y = data
            x, y = x.to(device), y.to(device)
            y_hat = model(x)
            test_loss += criterion(y_hat, y).item()
            pred = y_hat.max(1, keepdim=True)[1]         # 取最大得分的类别
            correct += pred.eq(y.view_as(pred)).sum().item()
    test_loss /= len(test_dataset)
    print(f'Test set: Average loss: {test_loss:.4f}, '
          f'Accuracy: {correct}/{len(test_dataset)} ({100.*correct/len(test_dataset):.0f}%)')


# 训练 9 个 epoch
for epoch in range(1, 10):
    train(model=model_ft, device=DEVICE, train_loader=train_loader, epoch=epoch)
    test(model=model_ft, device=DEVICE, test_loader=test_loader)

4.6 训练结果

在 Apple M 系列芯片(MPS 加速)上运行 9 个 epoch,输出如下:

yaml 复制代码
Train Epoch: 1	 Loss: 2.759902
Test set: Average loss: 0.0080, Accuracy: 647/1023 (63%)
Train Epoch: 2	 Loss: 2.027701
Test set: Average loss: 0.0046, Accuracy: 784/1023 (77%)
Train Epoch: 3	 Loss: 1.593534
Test set: Average loss: 0.0037, Accuracy: 784/1023 (77%)
Train Epoch: 4	 Loss: 1.603166
Test set: Average loss: 0.0032, Accuracy: 800/1023 (78%)
Train Epoch: 5	 Loss: 1.612357
Test set: Average loss: 0.0029, Accuracy: 829/1023 (81%)
Train Epoch: 6	 Loss: 1.554072
Test set: Average loss: 0.0027, Accuracy: 816/1023 (80%)
Train Epoch: 7	 Loss: 1.354374
Test set: Average loss: 0.0026, Accuracy: 835/1023 (82%)
Train Epoch: 8	 Loss: 1.098651
Test set: Average loss: 0.0026, Accuracy: 832/1023 (81%)
Train Epoch: 9	 Loss: 1.223167
Test set: Average loss: 0.0025, Accuracy: 837/1023 (82%)

结果分析:

指标 说明
第 1 轮准确率 63% --- 仅 1 轮训练,新 fc 层已初步学会分类
第 9 轮准确率 82% --- 只训了一个线性层就达到不错的效果
Loss 趋势 从 2.76 降至 1.22,持续下降
收敛速度 前 2 轮提升最快(63%→77%),后续逐步饱和

仅训练 fc 层(约 24.6 万参数,占 ResNet50 总参数的不到 1%),9 个 epoch 就达到 82% 的准确率。这就是迁移学习"最小调参"的威力。

4.7 训练过程中数据的流动

bash 复制代码
前向(所有层参与计算):
  x ──► [🔒conv层们] ──► [🆕fc] ──► y_hat

反向(只有 fc 产生梯度):
  loss ──► [🆕fc 梯度更新] ──► STOP(冻结层不传梯度)

这个实例的核心流程:

步骤 内容
数据 Kaggle Dog Breed 数据集,10222 张图片,120 个犬种
分割 StratifiedShuffleSplit,90% 训练 / 10% 验证
模型 ResNet50 主干冻结 + 新 fc(2048→120)
优化 Adam,lr=0.001,只优化 fc 层
训练 9 个 epoch,每轮后验证准确率

5. 数据预处理:不能忽略的细节

预训练模型在 ImageNet 上使用了固定的归一化参数,你的数据必须用相同的预处理

python 复制代码
IMG_MEAN = [0.485, 0.456, 0.406]  # ImageNet 均值
IMG_STD  = [0.229, 0.224, 0.225]  # ImageNet 标准差

# 训练集:加数据增强
train_transforms = transforms.Compose([
    transforms.Resize(256),
    transforms.RandomResizedCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(30),
    transforms.ToTensor(),
    transforms.Normalize(IMG_MEAN, IMG_STD)
])

# 验证集:不做增强,只做确定性变换
test_transforms = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(IMG_MEAN, IMG_STD)
])

不做归一化或均值/标准差填错,预训练权重发挥不出效果。

6. 进阶:分层学习率

当"只训 fc"效果不够时,可以解冻部分深层 + 分组设置不同学习率

python 复制代码
# 解冻 layer4
for p in model.layer4.parameters():
    p.requires_grad = True

# 分组优化:主干小 lr,分类头大 lr
optimizer = torch.optim.Adam([
    {'params': model.layer4.parameters(), 'lr': 1e-5},   # 精细微调
    {'params': model.fc.parameters(),     'lr': 1e-3},   # 快速学习
])

逻辑:

  • 预训练层已经很好了,只需小幅调整 → 小学习率
  • 新建的 fc 层是随机初始化的,需要大步前进 → 大学习率

7. 策略选择指南

数据量 与 ImageNet 相似度 推荐策略
< 1 万 高(自然图像) 只训 fc
< 1 万 低(医学/卫星) 只训 fc + 更多数据增强
1-10 万 解冻 layer4 + fc
1-10 万 解冻 layer3 + layer4 + fc
> 10 万 任意 全网微调(小 lr)

8. 核心参数速查(ResNet50)

参数
输入尺寸 224×224×3
总参数量 ~25.6 M
fc.in_features 2048
ImageNet Top-1 精度 76.1%
预训练权重大小 ~98 MB

9. 常见问题

Q1:为什么用 Adam 而不是 SGD?

Adam 自适应学习率,收敛更快,对单层微调这种简单任务很合适。如果全网微调,SGD + momentum 通常效果更好。

Q2:学习率设多少?

  • 只训 fc:1e-3 是安全起点
  • 解冻深层:建议 1e-5 ~ 1e-4
  • 可用 torch.optim.lr_scheduler 做学习率衰减

Q3:需要训练多少个 epoch?

只训 fc 时通常 5-15 个 epoch 就能收敛。如果 loss 不再下降或验证准确率不再提升,就可以停了。

Q4:model.train()model.eval() 有什么区别?

模式 BatchNorm 行为 Dropout 行为
model.train() 用当前 batch 的均值/方差 随机丢弃
model.eval() 用训练时积累的全局均值/方差 不丢弃

即使冻结了主干,eval() 时 BatchNorm 的行为也会改变,测试时必须调 model.eval()

10. 一句话总结

迁移学习最小调参模块 = 加载预训练 → 冻结主干 → 换分类头 → 只优化新头。它利用了预训练网络已经学会"看懂图像"的能力,你只需要教它"看完之后怎么分类"------这是一个简单的线性映射,几百个样本训几轮就能学会。

相关推荐
狒狒热知识7 小时前
软文发稿行业深度洗牌专业平台成企业品牌营销核心依托
大数据·人工智能
AI医影跨模态组学7 小时前
NPJ Precis Oncol 青岛大学附属医院放射科王鹤翔:基于CT的可解释深度学习模型预测膀胱癌患者总生存期的多中心研究
人工智能·深度学习·论文·医学影像·影像组学
普密斯科技7 小时前
在线图像测量仪实战案例:医疗西林瓶尺寸检测的精准解决方案
大数据·人工智能·计算机视觉·健康医疗·测量
Biocloudy7 小时前
信号分子:从 CD8⁺ T 细胞到癌症免疫疗法
大数据·人工智能·经验分享·其他
AI人工智能+7 小时前
不动产权证书识别技术:融合了计算机视觉、自然语言处理(NLP)和人工智能的深度技术栈
人工智能·计算机视觉·语言模型·ocr·不动产权证书识别
绝知此事7 小时前
【产品更名】通义灵码升级为 Qoder CN:AI 编码助手新时代,附大模型收费与 Spring Boot 支持全对比
人工智能·spring boot·后端·idea·ai编程
无忧智库7 小时前
某制造企业售后服务智能体(Agent)工单自动分派与处置闭环系统详细设计方案(WORD)
大数据·人工智能·制造
Agent产品评测局7 小时前
国企制造企业vs民营工厂,AI Agent方案选型对比 —— 2026制造业智能体落地全景拆解
人工智能·ai·chatgpt·制造
穗余7 小时前
2026 AI x Web3 School共学营笔记-Day2
人工智能·区块链