预训练、迁移学习与ResNet微调实战
前言
在我们之前从零训练CNN的过程里,发现模型参数初始化非常关键:初始化不好,收敛慢,还容易卡在局部最优。预训练+微调(迁移学习)就是用来解决这个痛点的核心方案。今天系统梳理预训练、迁移学习概念,同时附带可直接运行的PyTorch代码,基于ResNet18在CIFAR10上做阶段性冻结微调。
配套环境:PyTorch + torchvision,数据集CIFAR10,骨干网络ResNet18,上游预训练权重来自ImageNet。
一、预训练是什么?
模型参数初始值对最终训练结果影响巨大:
- 初始参数好:收敛更快,需要的epoch更少
- 初始参数差:容易陷入局部最优,最终效果差
预训练思想:在和目标任务相似的大规模公开数据集上提前训练好模型,把这套训练好的参数拿来初始化我们自己的模型。
自问自答理解核心
✅ 为什么要选相似任务的预训练权重? 任务相近,网络学到的特征提取能力才可以复用;任务差距过大,学到的特征不匹配,迁移效果很差。预训练本质:复用模型已经学会的通用视觉特征,再在我们自己数据集上继续适配下游任务。
✅ 为什么预训练要用大规模数据集 ,小规模数据集不行? 预训练目标是学习通用特征。小数据集很难让复杂网络学到普适的特征。类比:物理博士做小学数学题上手很快,小学数学高手很难搞定物理前沿课题。
术语区分
- 上游任务:大规模数据集上的预训练过程(如ImageNet图像分类)
- 下游任务:在自己数据集上,基于预训练参数继续调参的过程,这个过程叫做微调(Finetune)
- 整套思想:迁移学习
一个反例:CIFAR10不适合用来做预训练数据集
- 规模小:仅10万张图,图像尺寸只有32×32
- 类别少:只有10个类别,学到的特征泛化能力弱
经典预训练数据集:ImageNet,1000类别,上亿图像,常用输入尺寸224×224,是CV分类预训练最经典的数据集。
二、经典预训练模型汇总
2.1 CNN类预训练骨干网络
| 模型 | 预训练数据集 | 核心特点 | CIFAR10适配要点 |
|---|---|---|---|
| AlexNet | ImageNet | 首次ReLU、LRN归一化,参数量6000w+ | 修改首层卷积核,适配32×32小图 |
| VGG16 | ImageNet | 纯3×3卷积堆叠,结构简单规整 | 冻结大部分卷积层,只微调头部 |
| ResNet18 | ImageNet | 残差连接解决梯度消失,1100w参数 | 直接适配小图,仅调整池化步长 |
| MobileNetV2 | ImageNet | 深度可分离卷积,轻量化 | 适合算力弱、移动端场景 |
2.2 Transformer类预训练模型
ViT、Swin Transformer、DeiT原生面向224×224大图;放到CIFAR10这种32×32小图,要么上采样放大图片,要么调整patch大小。
2.3 自监督预训练模型
不需要人工标签,依靠辅助任务(对比学习、掩码重建)学习特征,标签稀缺场景很香。
- MoCo v3:对比学习预训练
- BEiT:掩码图像建模
三、CNN预训练模型发展史梳理
| 模型 | 年份 | 团队 | 核心创新 |
|---|---|---|---|
| LeNet-5 | 1998 | Yann LeCun | 最早CNN,卷积+池化+全连接 |
| AlexNet | 2012 | Alex | ReLU、Dropout、GPU训练,开启深度学习时代 |
| VGGNet | 2014 | Oxford VGG | 统一3×3卷积,结构简洁 |
| GoogLeNet | 2014 | Inception多分支、1×1卷积降维 | |
| ResNet | 2015 | 何恺明 | 残差连接,解决深层网络退化,CV里程碑 |
| DenseNet | 2017 | 黄高 | 密集连接,特征复用,小数据集友好 |
| MobileNet | 2017 | 深度可分离卷积,轻量化 | |
| EfficientNet | 2019 | NAS复合缩放(深度/宽度/分辨率一起优化) |
补充指标说明:ImageNet Top-5 Error:1000分类任务,预测概率前5个里面包含真实标签即算正确,是当年经典评估指标。
模型演进4条主线
- 深度突破:残差连接让上百层网络可以稳定训练
- 计算效率:Inception、MobileNet在精度和算力之间做平衡
- 特征复用:DenseNet密集连接,适合小数据集,抑制过拟合
- 自动架构:EfficientNet用NAS自动搜索网络配置
模型选型建议
| 需求 | 推荐模型 | 理由 |
|---|---|---|
| 快速跑原型 | ResNet18/50 | 生态成熟、预训练权重稳定、上手简单 |
| 移动端部署 | MobileNetV3 | 参数少、推理速度快 |
| 追求高精度、算力充足 | EfficientNet-B7 | ImageNet精度高 |
| 数据集很小 | DenseNet | 特征复用,抗过拟合 |
四、微调的训练策略
加载预训练模型做微调,不是直接全部参数一起训,有几个关键点:
- 加载官方预训练权重;
- 输入图片尺寸、归一化参数要和预训练时匹配;
- 替换最后的分类头(FC层),适配我们自己数据集类别数(CIFAR10是10类);
- 阶段训练策略:先冻结backbone骨干,只训练分类头,稳定后解冻全部参数微调。
术语:
- Backbone(骨干):负责提取图像特征(ResNet卷积层部分)
- Head(头):最后的全连接分类层,输出类别logits
- FPN:特征金字塔网络,常用于检测、分割任务做多尺度特征融合
常规方案:前若干epoch冻结骨干,只训head;之后解冻全部参数,使用更小学习率微调,避免破坏学到的通用特征。
五、完整可运行代码(ResNet18 + 分阶段冻结微调 CIFAR10)
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms, models
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt
import os
# 设置中文字体支持
plt.rcParams["font.family"] = ["SimHei"]
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
# 检查GPU是否可用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")
# 1. 数据预处理(训练集增强,测试集标准化)
train_transform = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1),
transforms.RandomRotation(15),
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
test_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010))
])
# 2. 加载CIFAR-10数据集
train_dataset = datasets.CIFAR10(
root='./data',
train=True,
download=True,
transform=train_transform
)
test_dataset = datasets.CIFAR10(
root='./data',
train=False,
transform=test_transform
)
# 3. 创建数据加载器
batch_size = 64
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
# 4. 定义ResNet18模型
def create_resnet18(pretrained=True, num_classes=10):
model = models.resnet18(pretrained=pretrained)
# 修改最后一层全连接层适配10分类
in_features = model.fc.in_features
model.fc = nn.Linear(in_features, num_classes)
return model.to(device)
# 5. 冻结/解冻骨干卷积层
def freeze_model(model, freeze=True):
"""冻结或解冻除fc以外的所有参数"""
for name, param in model.named_parameters():
if 'fc' not in name:
param.requires_grad = not freeze
frozen_params = sum(p.numel() for p in model.parameters() if not p.requires_grad)
total_params = sum(p.numel() for p in model.parameters())
if freeze:
print(f"已冻结骨干卷积层参数,可训练参数:{total_params - frozen_params}/{total_params}")
else:
print(f"全部参数解冻,所有{total_params}参数均可训练")
return model
# 6. 训练函数,支持分阶段冻结
def train_with_freeze_schedule(model, train_loader, test_loader, criterion, optimizer, scheduler, device, epochs, freeze_epochs=5):
train_loss_history = []
test_loss_history = []
train_acc_history = []
test_acc_history = []
all_iter_losses = []
iter_indices = []
if freeze_epochs > 0:
model = freeze_model(model, freeze=True)
for epoch in range(epochs):
# 到达解冻轮次,放开全部参数
if epoch == freeze_epochs:
model = freeze_model(model, freeze=False)
optimizer.param_groups[0]['lr'] = 1e-4
model.train()
running_loss = 0.0
correct_train = 0
total_train = 0
for batch_idx, (data, target) in enumerate(train_loader):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
iter_loss = loss.item()
all_iter_losses.append(iter_loss)
iter_indices.append(epoch * len(train_loader) + batch_idx + 1)
running_loss += iter_loss
_, predicted = output.max(1)
total_train += target.size(0)
correct_train += predicted.eq(target).sum().item()
if (batch_idx + 1) % 100 == 0:
print(f"Epoch {epoch+1}/{epochs} | Batch {batch_idx+1}/{len(train_loader)} | 单Batch损失: {iter_loss:.4f}")
epoch_train_loss = running_loss / len(train_loader)
epoch_train_acc = 100. * correct_train / total_train
# 测试
model.eval()
correct_test = 0
total_test = 0
test_loss = 0.0
with torch.no_grad():
for data, target in test_loader:
data, target = data.to(device), target.to(device)
output = model(data)
test_loss += criterion(output, target).item()
_, predicted = output.max(1)
total_test += target.size(0)
correct_test += predicted.eq(target).sum().item()
epoch_test_loss = test_loss / len(test_loader)
epoch_test_acc = 100. * correct_test / total_test
train_loss_history.append(epoch_train_loss)
test_loss_history.append(epoch_test_loss)
train_acc_history.append(epoch_train_acc)
test_acc_history.append(epoch_test_acc)
if scheduler is not None:
scheduler.step(epoch_test_loss)
print(f"Epoch {epoch+1} 完成 | 训练损失: {epoch_train_loss:.4f} | 训练准确率: {epoch_train_acc:.2f}% | 测试准确率: {epoch_test_acc:.2f}%")
plot_iter_losses(all_iter_losses, iter_indices)
plot_epoch_metrics(train_acc_history, test_acc_history, train_loss_history, test_loss_history)
return epoch_test_acc
# 7. 绘图函数
def plot_iter_losses(losses, indices):
plt.figure(figsize=(10, 4))
plt.plot(indices, losses, 'b-', alpha=0.7)
plt.xlabel('Iteration(Batch序号)')
plt.ylabel('损失值')
plt.title('训练过程Iter损失变化')
plt.grid(True)
plt.show()
def plot_epoch_metrics(train_acc, test_acc, train_loss, test_loss):
epochs = range(1, len(train_acc) + 1)
plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plt.plot(epochs, train_acc, 'b-', label='训练准确率')
plt.plot(epochs, test_acc, 'r-', label='测试准确率')
plt.xlabel('Epoch')
plt.ylabel('准确率 (%)')
plt.title('准确率变化')
plt.legend()
plt.grid(True)
plt.subplot(1, 2, 2)
plt.plot(epochs, train_loss, 'b-', label='训练损失')
plt.plot(epochs, test_loss, 'r-', label='测试损失')
plt.xlabel('Epoch')
plt.ylabel('损失值')
plt.title('损失变化')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
# 主训练入口
def main():
epochs = 40
freeze_epochs = 5
learning_rate = 1e-3
weight_decay = 1e-4
model = create_resnet18(pretrained=True, num_classes=10)
optimizer = optim.Adam(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
criterion = nn.CrossEntropyLoss()
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=2, verbose=True)
final_accuracy = train_with_freeze_schedule(model, train_loader, test_loader, criterion, optimizer, scheduler, device, epochs, freeze_epochs)
print(f"训练完成!最终测试准确率: {final_accuracy:.2f}%")
# torch.save(model.state_dict(), 'resnet18_cifar10_finetuned.pth')
if __name__ == "__main__":
main()
CIFAR10标签映射:
0:airplane飞机,1:automobile汽车,2:bird鸟,3:cat猫,4:deer鹿
5:dog狗,6:frog青蛙,7:horse马,8:ship船,9:truck卡车
六、训练中观察到的现象
- 收敛速度极快:解冻骨干之后,短短几个epoch效果就能超过从零训练CNN 20轮,这就是预训练迁移学习最大优势;
- 前期训练acc可能低于测试acc:因为训练集加了大量数据增强(随机裁剪、翻转、色彩抖动、旋转),训练图片难度更高;测试图片无增强。前期很容易出现这个现象,训练后期会慢慢缓解;
- 精度上限更高:微调收敛后的准确率显著高于从零开始训练的CNN,轻松突破80%。
七、总结
预训练+微调是CV工程里最常用的范式,核心就是:用大规模数据集学到通用视觉先验,再迁移到小数据集下游任务。 实操上记住两个关键点:
- 分类头一定要改成自己任务的类别数;
- 优先冻结骨干训头,再小学习率全参数微调,保护预训练学到的通用特征。
后续可以拓展:尝试冻结不同层、对比freeze和不freeze的差异、尝试ViT/MobileNet预训练权重迁移。
class BasicBlock(nn.Module):
def forward(self, x):
identity = x # 捷径分支shortcut,保存原始输入
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
# 残差核心:卷积学习到的特征 + 原始输入
out += identity
out = self.relu(out)
return out
残差思想:模型不去直接学习目标映射 \(H(x)\),改为学习残差 \(F(x)=H(x)-x\),最终输出 \(H(x)=F(x)+x\)。
- x:恒等捷径分支,不需要额外学习;
- \(F(x)\):卷积层需要拟合的残差部分;
- 若输入、输出通道或者尺寸不一致,会启用
downsample对identity做卷积变换,保证张量形状匹配,才能相加。 残差连接解决了深层 CNN 的网络退化问题,让梯度可以沿着 shortcut 支路直接回传,深层网络更容易训练收敛。
实验结果与差异分析
表格
| 模型 | 参数量 | 核心结构特点 | CIFAR10 微调表现 |
|---|---|---|---|
| ResNet18 | 约 11.2M | 残差 shortcut 连接,结构简洁,上手简单 | 收敛稳定,精度中等,训练速度快 |
| DenseNet121 | 约 8M | 密集连接:每一层接收前面所有层的特征,特征复用强 | 精度最高,参数量更少,显存占用偏高,训练慢一点 |
| MobileNetV2 | 约 2.2M | 深度可分离卷积 + 倒残差,轻量化设计 | 推理最快、参数量最小,精度略低,适合移动端场景 |
小结
- DenseNet 依靠密集特征复用,在小数据集 CIFAR10 上微调上限更高;
- MobileNetV2 主打轻量化,精度换取速度和显存;
- ResNet 属于均衡选择,兼顾训练速度、精度、工程易用性;
- 预训练微调的核心逻辑不变:冻结骨干先学分类头,再全参数微调,降低过拟合风险。