@浙大疏锦行
预训练、迁移学习与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 属于均衡选择,兼顾训练速度、精度、工程易用性;
- 预训练微调的核心逻辑不变:冻结骨干先学分类头,再全参数微调,降低过拟合风险。