Python打卡第45天

@浙大疏锦行

预训练、迁移学习与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适配要点
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 Google Inception多分支、1×1卷积降维
ResNet 2015 何恺明 残差连接,解决深层网络退化,CV里程碑
DenseNet 2017 黄高 密集连接,特征复用,小数据集友好
MobileNet 2017 Google 深度可分离卷积,轻量化
EfficientNet 2019 Google NAS复合缩放(深度/宽度/分辨率一起优化)

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

模型演进4条主线

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

模型选型建议

需求 推荐模型 理由
快速跑原型 ResNet18/50 生态成熟、预训练权重稳定、上手简单
移动端部署 MobileNetV3 参数少、推理速度快
追求高精度、算力充足 EfficientNet-B7 ImageNet精度高
数据集很小 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. 预训练微调的核心逻辑不变:冻结骨干先学分类头,再全参数微调,降低过拟合风险。
相关推荐
无语的小歪歪1 小时前
ATF 智测 1.0
python·测试工具·集成测试
宇卿.1 小时前
C#委托详解
开发语言·c#
java_prinln1 小时前
9. Agent 的长短期记忆系统怎么做的?记忆是怎么存的?粒度是多少?怎么用的?
数据库·python·agent
ZoeLandia1 小时前
React04:事件处理与受控表单
开发语言·前端·react.js·typescript
FOAF-lambda1 小时前
Python实战:基于OpenCV与单调栈,精准定位PDF中最大的空白区域
python·opencv·pdf
熊猫钓鱼>_>1 小时前
端侧高斯泼溅(3DGS)渲染与性能调优初探:把“实景重建“跑在手机上【鸿蒙心迹】
开发语言·3d·华为·智能手机·harmonyos
terry6001 小时前
企业数据查询接口防爬实战:图形验证码服务端校验接入与限流设计(附Python)
python·信息与通信·图形验证码·反爬虫·人机验证·行为验证码·滑动拼图验证
量化炼金 (CodeAlchemy)1 小时前
【Python】pandas vs polars 简单性能对比测试
开发语言·python·pandas
苦瓜汤补钙1 小时前
Matlab 2022b(中文)的使用教程——Win11
开发语言·matlab