一、引言:站在巨人的肩膀上

在之前的博客中,我们从零开始搭建CNN,用数据增强、学习率调度等技巧训练了一个食物分类模型。但你可能已经发现:训练一个从零开始的CNN需要大量数据、大量时间,而且效果不一定理想

假如现在要做一个新的图像分类任务——比如识别100种不同的鸟类,但手头只有几千张图片,从头训练一个CNN几乎不可能达到高准确率。这时候,迁移学习(Transfer Learning) 就是你的救星。

迁移学习的核心思想:将在一个大规模数据集(如ImageNet,包含1000类、120万张图片)上训练好的模型,迁移到自己的小数据集任务上。就像一位已经学过“如何看图”的专家,只需要稍微学习一下新的类别,就能快速上手。

本篇博客将基于PyTorch,使用经典的ResNet18预训练模型,完成食物分类任务。我们将学习如何加载预训练权重、冻结参数、替换分类层,以及为什么这样做能大幅提升效率。

二、迁移学习核心概念

2.1 什么是迁移学习

迁移学习是指将一个任务(源任务)上学到的知识,应用到另一个相关任务(目标任务)上。在深度学习中,通常指:

  • 源任务:在大规模数据集(如ImageNet)上训练一个模型

  • 目标任务:将自己的数据集(如食物分类)交给这个模型微调

为什么有效?

  • 浅层卷积层学到的特征(边缘、纹理、颜色)是通用的,适用于几乎所有图像任务

  • 深层卷积层学到的特征(语义、物体部件)也大部分通用

  • 只有最后的分类层需要根据新任务的类别数重新学习

2.2 迁移学习的三种策略

策略做法适用场景
特征提取冻结所有卷积层,只训练新的分类层数据量很小,目标任务与源任务相似
微调冻结部分卷积层,训练剩余层+分类层数据量中等,目标任务与源任务有一定差异
从头训练不使用预训练权重,全部重新训练数据量很大,目标任务与源任务差异极大

本代码采用的是特征提取策略:冻结ResNet18的全部卷积层,只训练最后替换的全连接层。

2.3 ResNet18简介

ResNet(残差网络) 由何恺明等人于2015年提出,通过残差连接解决了深层网络难以训练的问题。ResNet18是该系列中最轻量的版本,包含18层卷积层(含残差块),在ImageNet上达到了约70%的top-1准确率。

ResNet18结构

  • 1个7×7卷积 + 最大池化

  • 4个残差块组(每个组包含2个BasicBlock)

  • 全局平均池化

  • 全连接层(输出1000类)

PyTorch的torchvision.models中提供了预训练的ResNet18,可直接加载。

三、代码逐段解析

3.1 加载预训练模型

import torchvision.models as models

resnet_model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)

weights=models.ResNet18_Weights.DEFAULT:表示加载在ImageNet上预训练好的权重。这是PyTorch 1.13+的新写法,等价于旧版的pretrained=True

为什么要加载预训练权重?

  • 模型已经学会了如何提取通用图像特征

  • 相比随机初始化,微调后准确率更高、收敛更快

3.2 冻结所有参数

for param in resnet_model.parameters():
    param.requires_grad = False

requires_grad = False:将该参数的梯度计算关闭。在反向传播时,这些参数不会更新。

效果:所有卷积层的权重都被“冻结”,在训练中保持不变。这样做的好处是:

  • 大幅减少计算量(不需要计算卷积层的梯度)

  • 防止小数据集上过拟合

  • 加快训练速度

3.3 替换分类层

in_features = resnet_model.fc.in_features
resnet_model.fc = nn.Linear(in_features, out_features=20)

resnet_model.fc:ResNet18最后的全连接层,原输出为1000类(ImageNet类别数)。

in_features:获取原全连接层的输入特征数(ResNet18为512)。

替换为nn.Linear(512, 20):新的全连接层输出20类(食物分类的类别数)。

关键点:新替换的fc层默认requires_grad=True,所以只有这一层会被训练。

3.4 收集需要训练的参数

params_to_update = []
for param in resnet_model.parameters():
    if param.requires_grad == True:
        params_to_update.append(param)

作用:将所有需要梯度的参数(即新的全连接层参数)收集到一个列表中。

为什么需要这个列表?

  • 优化器只需要更新这些参数

  • 如果直接把resnet_model.parameters()传给优化器,虽然冻结的参数不会更新,但会浪费内存和计算

  • 只传需要更新的参数更高效

3.5 数据准备

数据增强和自定义数据集与之前一致:

data_transforms = {
    'trainda': transforms.Compose([
        transforms.RandomRotation(45),
        transforms.CenterCrop(256),
        transforms.RandomHorizontalFlip(p=0.5),
        transforms.RandomVerticalFlip(p=0.5),
        transforms.ColorJitter(brightness=0.2, contrast=0.1, saturation=0.1, hue=0.1),
        transforms.RandomGrayscale(p=0.1),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
    ]),
    'valid': transforms.Compose([
        transforms.Resize([256, 256]),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
    ]),
}

注意:验证集不做数据增强,只做必要的尺寸调整和标准化。

3.6 模型移到设备

model = resnet_model.to(device)

为什么不需要加括号?

  • resnet_model已经是models.resnet18()返回的实例对象

  • 而之前的CNN()是先定义类,再实例化。这里直接使用已实例化的模型

  • .to(device)是实例方法,返回模型本身

3.7 优化器与调度器

optimizer = torch.optim.Adam(params_to_update, lr=0.001)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)

注意:优化器只传入params_to_update(新的全连接层参数),而不是所有模型参数。

StepLR:每5个epoch学习率乘以0.5,逐步衰减。

3.8 训练与测试函数

训练函数与之前类似,但不需要 scheduler.step(loss),因为StepLR的step不需要参数:

def train(dataloader, model, loss_fn, optimizer):
    model.train()
    for X, y in dataloader:
        X, y = X.to(device), y.to(device)
        pred = model.forward(X)
        loss = loss_fn(pred, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

测试函数记录准确率和损失,并保存最优准确率。

3.9 训练循环

epochs = 100
acc_s = []
loss_s = []
for t in range(epochs):
    print(f"Epoch {t+1}")
    train(train_dataloader, model, loss_fn, optimizer)
    scheduler.step()    # 每个epoch后更新学习率
    test(test_dataloader, model, loss_fn)
print('最优训练结果为:', best_acc)

关键scheduler.step()放在train()之后、test()之前或之后都可以,但通常放在epoch末尾。

四、迁移学习的优势与注意事项

4.1 优势

优势说明
训练速度快只训练最后一层,参数量从1100万降到1万左右
准确率高预训练特征强大,小数据集也能达到高精度
防止过拟合冻结大量参数,模型复杂度降低
节省数据不需要海量数据即可取得好效果

4.2 注意事项

  • 输入尺寸:ResNet18原输入为224×224,但代码中用了256×256。由于是全卷积结构(最后是全局池化),ResNet可以接受任意尺寸输入,但最好与预训练时接近。

  • 标准化参数:必须使用ImageNet的均值和标准差,因为预训练模型是在这种标准化下训练的。

  • 学习率:新全连接层的学习率可以设大一些(如0.001),如果微调卷积层,学习率要更小(如0.0001)。

  • 冻结策略:数据量越小,冻结的层应越多;数据量越大,可以解冻更多层进行微调。

五、完整代码总结

import torch
import torchvision.models as models
from torch import nn
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from PIL import Image
import numpy as np

# 1. 加载预训练ResNet18
resnet_model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)

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

# 3. 替换全连接层
in_features = resnet_model.fc.in_features
resnet_model.fc = nn.Linear(in_features, 20)

# 4. 收集需要训练的参数
params_to_update = [p for p in resnet_model.parameters() if p.requires_grad]

# 5. 数据准备(与之前一致)
# ...

# 6. 设备、优化器、调度器
device = "cuda" if torch.cuda.is_available() else "cpu"
model = resnet_model.to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(params_to_update, lr=0.001)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)

# 7. 训练与测试函数
# ...

# 8. 训练循环
epochs = 100
for t in range(epochs):
    train(train_dataloader, model, loss_fn, optimizer)
    scheduler.step()
    test(test_dataloader, model, loss_fn)

六、总结

本篇博客通过一个完整的食物分类项目,系统讲解了迁移学习的核心技术:

知识点核心内容
迁移学习将预训练模型迁移到新任务
ResNet18经典残差网络,ImageNet预训练
冻结参数requires_grad = False
替换分类层model.fc = nn.Linear(512, 20)
仅训练新层优化器只传params_to_update
学习率调度StepLR逐步衰减

更多推荐