☰
Python打卡第45天
2026/10/23 9:13:44 网站建设 项目流程

@浙大疏锦行

预训练、迁移学习与ResNet微调实战

前言

在我们之前从零训练CNN的过程里,发现模型参数初始化非常关键:初始化不好,收敛慢,还容易卡在局部最优。预训练+微调(迁移学习)就是用来解决这个痛点的核心方案。今天系统梳理预训练、迁移学习概念,同时附带可直接运行的PyTorch代码,基于ResNet18在CIFAR10上做阶段性冻结微调。

配套环境:PyTorch + torchvision,数据集CIFAR10,骨干网络ResNet18,上游预训练权重来自ImageNet。

一、预训练是什么?

模型参数初始值对最终训练结果影响巨大:

  • 初始参数好:收敛更快,需要的epoch更少
  • 初始参数差:容易陷入局部最优,最终效果差

预训练思想:在和目标任务相似的大规模公开数据集上提前训练好模型,把这套训练好的参数拿来初始化我们自己的模型。

自问自答理解核心

✅ 为什么要选相似任务的预训练权重? 任务相近,网络学到的特征提取能力才可以复用;任务差距过大,学到的特征不匹配,迁移效果很差。预训练本质:复用模型已经学会的通用视觉特征,再在我们自己数据集上继续适配下游任务。

✅ 为什么预训练要用大规模数据集,小规模数据集不行? 预训练目标是学习通用特征。小数据集很难让复杂网络学到普适的特征。类比:物理博士做小学数学题上手很快,小学数学高手很难搞定物理前沿课题。

术语区分

  • 上游任务:大规模数据集上的预训练过程(如ImageNet图像分类)
  • 下游任务:在自己数据集上,基于预训练参数继续调参的过程,这个过程叫做微调(Finetune)
  • 整套思想:迁移学习

一个反例:CIFAR10不适合用来做预训练数据集

  1. 规模小:仅10万张图,图像尺寸只有32×32
  2. 类别少:只有10个类别,学到的特征泛化能力弱

经典预训练数据集:ImageNet,1000类别,上亿图像,常用输入尺寸224×224,是CV分类预训练最经典的数据集。

二、经典预训练模型汇总

2.1 CNN类预训练骨干网络

模型预训练数据集核心特点CIFAR10适配要点
AlexNetImageNet首次ReLU、LRN归一化,参数量6000w+修改首层卷积核,适配32×32小图
VGG16ImageNet纯3×3卷积堆叠,结构简单规整冻结大部分卷积层,只微调头部
ResNet18ImageNet残差连接解决梯度消失,1100w参数直接适配小图,仅调整池化步长
MobileNetV2ImageNet深度可分离卷积,轻量化适合算力弱、移动端场景

2.2 Transformer类预训练模型

ViT、Swin Transformer、DeiT原生面向224×224大图;放到CIFAR10这种32×32小图,要么上采样放大图片,要么调整patch大小。

2.3 自监督预训练模型

不需要人工标签,依靠辅助任务(对比学习、掩码重建)学习特征,标签稀缺场景很香。

  • MoCo v3:对比学习预训练
  • BEiT:掩码图像建模

三、CNN预训练模型发展史梳理

模型年份团队核心创新
LeNet-51998Yann LeCun最早CNN,卷积+池化+全连接
AlexNet2012AlexReLU、Dropout、GPU训练,开启深度学习时代
VGGNet2014Oxford VGG统一3×3卷积,结构简洁
GoogLeNet2014GoogleInception多分支、1×1卷积降维
ResNet2015何恺明残差连接,解决深层网络退化,CV里程碑
DenseNet2017黄高密集连接,特征复用,小数据集友好
MobileNet2017Google深度可分离卷积,轻量化
EfficientNet2019GoogleNAS复合缩放(深度/宽度/分辨率一起优化)

补充指标说明:ImageNet Top-5 Error:1000分类任务,预测概率前5个里面包含真实标签即算正确,是当年经典评估指标。

模型演进4条主线

  1. 深度突破:残差连接让上百层网络可以稳定训练
  2. 计算效率:Inception、MobileNet在精度和算力之间做平衡
  3. 特征复用:DenseNet密集连接,适合小数据集,抑制过拟合
  4. 自动架构:EfficientNet用NAS自动搜索网络配置

模型选型建议

需求推荐模型理由
快速跑原型ResNet18/50生态成熟、预训练权重稳定、上手简单
移动端部署MobileNetV3参数少、推理速度快
追求高精度、算力充足EfficientNet-B7ImageNet精度高
数据集很小DenseNet特征复用,抗过拟合

四、微调的训练策略

加载预训练模型做微调,不是直接全部参数一起训,有几个关键点:

  1. 加载官方预训练权重;
  2. 输入图片尺寸、归一化参数要和预训练时匹配;
  3. 替换最后的分类头(FC层),适配我们自己数据集类别数(CIFAR10是10类);
  4. 阶段训练策略:先冻结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卡车

六、训练中观察到的现象

  1. 收敛速度极快:解冻骨干之后,短短几个epoch效果就能超过从零训练CNN 20轮,这就是预训练迁移学习最大优势;
  2. 前期训练acc可能低于测试acc:因为训练集加了大量数据增强(随机裁剪、翻转、色彩抖动、旋转),训练图片难度更高;测试图片无增强。前期很容易出现这个现象,训练后期会慢慢缓解;
  3. 精度上限更高:微调收敛后的准确率显著高于从零开始训练的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深度可分离卷积 + 倒残差,轻量化设计推理最快、参数量最小,精度略低,适合移动端场景

小结

  1. DenseNet 依靠密集特征复用,在小数据集 CIFAR10 上微调上限更高;
  2. MobileNetV2 主打轻量化,精度换取速度和显存;
  3. ResNet 属于均衡选择,兼顾训练速度、精度、工程易用性;
  4. 预训练微调的核心逻辑不变:冻结骨干先学分类头,再全参数微调,降低过拟合风险。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询