【深度学习Day9】不讲武德!一行代码继承 ImageNet 亿级参数,迁移学习 (Transfer Learning) 暴力通关

28 阅读11分钟

ImageNet数据集为ResNet提供了“视觉通识课”,通过迁移学习,我们可以轻松利用这些通用特征进行特定任务的微调。

摘要:手写ResNet-18时,咱还在为调通56层网络沾沾自喜,但从零训练(Training from Scratch)这事儿,除非你有ImageNet级别的数据和算力,否则纯纯是“用MATLAB手算矩阵逆”式的自虐。今天,作为踩过HOG特征提取、SVM调参坑的MATLAB老鸟,我带你解锁深度学习界的“Ctrl+C/Ctrl+V”——迁移学习:一行代码薅走PyTorch官方预训练ResNet-18的亿级参数,解决CIFAR-10尺寸不匹配的致命坑,通过微调(Fine-tuning)半小时内把准确率干到94%+,比手写ResNet从零训快3倍,准确率高5%!

关键词:PyTorch, 迁移学习, Fine-tuning, Resize, 冻结参数, 预训练权重, CIFAR-10尺寸适配

1. 从零训练=自虐?MATLAB老鸟的血泪对比

当年用MATLAB做图像分类,我能为了区分“猫和狗”折腾一周:手动写HOG特征提取函数,调SVM的gamma参数调到眼瞎,最后准确率还超不过80%——核心问题是“自己造轮子,啥都得从头学”。

深度学习里也一样:

  • 从零训ResNet-18 :5万张CIFAR-10数据,2080Ti跑20个Epoch,准确率89%,还得天天盯着梯度有没有消失;
  • 迁移学习ResNet-18(预训练版) :同样5万张数据,跑5个Epoch准确率91%,20个Epoch直冲94%,训练时间砍半,显存占用还少!

1.1 ResNet在ImageNet上学到的“通用知识”

ImageNet数据集(1000类、120万张图)相当于给ResNet上了“视觉通识课”:

  • 浅层卷积:学会识别线条、圆圈、颜色、纹理(比如“直线=边缘”“圆形=孔洞”)——这些特征对猫、狗、飞机、卡车都通用;
  • 中层卷积:学会识别局部特征(比如“三角形=屋顶”“弧形=耳朵”);
  • 深层卷积:学会识别语义特征(比如“两只尖耳朵+胡须=猫”“四条粗腿+尾巴=狗”)。

迁移学习核心逻辑

你用MATLAB做多项式拟合时,不会每次都从“1+1=2”教起;同理,ResNet已经学会了“如何看世界”,咱做CIFAR-10分类时,没必要让它重新学“什么是线条”——只需要借它的“大脑(卷积层Backbone)”,换掉它的“嘴巴(全连接层Classifier)”,教它说“飞机、汽车、鸟、猫”这10个新单词就行!

2. 两种“继承巨人遗产”的策略(面试必考,附实操代码)

调用预训练模型后,不是直接训就行——选对策略,准确率能差10%,训练速度能差10倍!

2.1 策略A:特征提取(冻结大法)—— 显卡乞丐版

核心思路

把ResNet的卷积层全部“锁死”(requires_grad=False),只训练最后新加的全连接层——相当于让学霸帮你划好所有考点,你只需要背最后10道大题答案。

适用场景

  • 你的数据极少(比如只有几百张蜜蜂/蚂蚁图片);
  • 你的数据和ImageNet相似度高(都是日常物体分类)。

优点

训练速度快到飞起(只更新最后一层几千个参数),显存占用少(2080Ti只占4GB),新手也能一次跑通。

关键代码(避坑版)

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
from torchvision.models import resnet18, ResNet18_Weights

# 加载预训练模型(PyTorch新版本推荐用weights参数,别用pretrained=True!)
model = resnet18(weights=ResNet18_Weights.DEFAULT)

# 第一步:锁死所有卷积层参数(不让梯度更新)
for param in model.parameters():
    param.requires_grad = False

# 第二步:替换最后一层全连接层(新层默认可训练)
# 原fc层是1000类(ImageNet),改成10类(CIFAR-10)
num_ftrs = model.fc.in_features  # 获取fc层输入维度(固定512)
model.fc = nn.Linear(num_ftrs, 10)

# 第三步:优化器只传fc层参数(省内存,速度翻倍)
optimizer = optim.SGD(model.fc.parameters(), lr=0.001, momentum=0.9)

2.2 策略B:微调(解冻热身)—— 追求极致准确率

核心思路

不锁死任何层,用极小的学习率(比如1e-4)全网训练——相当于学霸帮你划好考点后,你还跟他一起复习,把考点适配成自己的考试风格。

适用场景

  • 你的数据量还行(CIFAR-10有5万张);
  • 你的数据和ImageNet差异较大(比如医疗CT、工业缺陷检测)。

优点

准确率天花板更高(CIFAR-10能到94%+),网络能适配你的数据特征。

关键调参心法(踩坑总结)

  • 学习率必须小:从零训练用0.1,微调只能用0.01/0.001(避免冲掉预训练的“通用知识”);
  • 权重衰减必加:weight_decay=5e-4,防止全连接层过拟合;
  • 用余弦退火调度器:让学习率慢慢降,比固定学习率多涨2%准确率。

3. 致命坑:CIFAR-10和ImageNet的尺寸不匹配

绝大多数 新手直接调用resnet18(weights=ResNet18_Weights.DEFAULT)跑CIFAR-10,要么报错要么准确率60%都不到——核心是输入尺寸和模型结构不兼容

3.1 先算笔账:32x32图片过原版ResNet会怎样?

ImageNet标准输入:224x224 → ResNet-18第一层是7x7卷积(stride=2)+ 3x3池化(stride=2),刚好把图缩到56x56,后续层层下采样最后剩7x7,特征足够。

但CIFAR-10是32x32:

  1. 过7x7卷积(stride=2, padding=3):输出尺寸=(32-7+6)/2 +1 = 16x16;
  2. 过3x3池化(stride=2):输出尺寸=8x8;
  3. 再过ResNet的4个残差层(还要下采样3次):最后特征图变成1x1!

👉 结果:特征图缩成像素点,全连接层只能学到“随机噪声”,准确率能高才怪。

3.2 两种解决方案(土豪vs平民)

方案做法优点缺点
土豪法transforms.Resize(224) 把32x32放大到224x224不用改模型,原汁原味显存占用翻倍(2080Ti占10GB),训练慢
魔改法改第一层卷积为3x3(stride=1),去掉第一层池化省显存(占6GB),速度快考验模型结构理解能力

今天咱用魔改法(改代码比加显存容易),还能顺便搞懂ResNet的结构!

4. 代码实战:魔改预训练ResNet-18(可直接跑)

4.1 完整代码(含数据加载+魔改+训练+评估)

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
from torchvision.models import resnet18, ResNet18_Weights
import time

# ========== 1. 配置环境和超参数 ==========
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
batch_size = 128
epochs = 20
lr = 0.01  # 微调学习率(比从零训小10倍)

# ========== 2. 准备CIFAR-10数据(不用Resize到224!) ==========
# 注意:归一化均值/方差用CIFAR-10官方值,不是ImageNet的!
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),  # 随机裁剪,增加数据量
    transforms.RandomHorizontalFlip(),     # 随机水平翻转,防过拟合
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])

# 下载/加载数据集
trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size, shuffle=True)

testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test)
testloader = torch.utils.data.DataLoader(testset, batch_size=100, shuffle=False)

classes = ('plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck')

# ========== 3. 加载预训练模型 + 魔改适配32x32 ==========
print("\n正在加载ImageNet预训练权重...")
model = resnet18(weights=ResNet18_Weights.DEFAULT)  # 一行继承亿级参数!

# 手术A:修改第一层卷积(7x7→3x3,stride=2→1)
# 原卷积:Conv2d(3, 64, 7, stride=2, padding=3) → 现在:Conv2d(3, 64, 3, stride=1, padding=1)
model.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)

# 手术B:去掉第一层池化(用恒等映射替代)
model.maxpool = nn.Identity()

# 手术C:替换最后一层全连接层(1000类→10类)
num_ftrs = model.fc.in_features
model.fc = nn.Linear(num_ftrs, 10)

# 搬到GPU
model = model.to(device)

# ========== 4. 定义损失函数和优化器 ==========
criterion = nn.CrossEntropyLoss()
# 微调用SGD+动量+权重衰减,学习率0.01(从零训是0.1)
optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9, weight_decay=5e-4)
# 余弦退火调度器:让学习率慢慢降,比固定lr多涨2%准确率
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)

# ========== 5. 训练+评估函数 ==========
def train(model, trainloader, criterion, optimizer, epoch):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0
    start_time = time.time()
    for i, (inputs, labels) in enumerate(trainloader):
        inputs, labels = inputs.to(device), labels.to(device)
        
        # 前向+反向+优化
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        
        # 统计损失和准确率
        running_loss += loss.item()
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()
        
        # 每100批次打印一次
        if i % 100 == 99:
            print(f'Epoch [{epoch+1}/{epochs}], Batch [{i+1}], Loss: {running_loss/100:.3f}, Acc: {100.*correct/total:.2f}%')
            running_loss = 0.0
    print(f'Epoch [{epoch+1}] 训练耗时:{time.time()-start_time:.2f}s,训练集准确率:{100.*correct/total:.2f}%')

def evaluate(model, testloader):
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():  # 评估时关闭梯度,省显存
        for inputs, labels in testloader:
            inputs, labels = inputs.to(device), labels.to(device)
            outputs = model(inputs)
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()
    acc = 100. * correct / total
    print(f'测试集准确率:{acc:.2f}%\n')
    return acc

# ========== 6. 开始训练 ==========
best_acc = 0.0
print("\n开始微调预训练ResNet-18...")
for epoch in range(epochs):
    train(model, trainloader, criterion, optimizer, epoch)
    acc = evaluate(model, testloader)
    scheduler.step()  # 更新学习率
    
    # 保存最优模型
    if acc > best_acc:
        best_acc = acc
        torch.save(model.state_dict(), 'resnet18_cifar10_best.pth')

print(f"训练完成!最优测试集准确率:{best_acc:.2f}%")
Epoch [20/20], Batch [100], Loss: 0.010, Acc: 99.80%
Epoch [20/20], Batch [200], Loss: 0.011, Acc: 99.73%
Epoch [20/20], Batch [300], Loss: 0.013, Acc: 99.71%
Epoch [20] 训练耗时:42.04s,训练集准确率:99.71%
测试集准确率:94.91%

4.2 魔改关键处注释(新手必看)

  1. model.maxpool = nn.Identity()Identity是PyTorch的“空操作”层,相当于把池化层换成“啥也不干”,避免32x32图片被过早缩小;
  2. 归一化参数:(0.4914, 0.4822, 0.4465)是CIFAR-10的官方均值,不是ImageNet的(0.485, 0.456, 0.406)——用错会直接掉10%准确率;

6. 实验结果对比(2080Ti实测)

训练方式Epoch 5准确率Epoch 20准确率训练总耗时
手写ResNet18(从零训)65%89%50分钟
预训练ResNet18(土豪Resize)80%81%57分钟
预训练ResNet18(魔改微调)91%94%15分钟

魔改微调赢在 “用预训练特征,且让特征适配小图”;土豪 Resize 输在 “用了预训练,但强行放大图片让特征失效”;手写从零训输在 “结构适配但没预训练,全靠自己学”。

👉 看到差距了吗?预训练模型在第5个Epoch就达到了手写模型20个Epoch的效果——这不是“作弊”,是站在巨人的肩膀上干活!

7. 面试避坑指南(高频问题+标准答案)

Q1:为什么Input Size不匹配还能加载预训练权重?

答:卷积层的权重是“卷积核”(比如3x3x3x64),只和输入/输出通道有关,和图片尺寸无关——3x3卷积核既能在224x224上滑动,也能在32x32上滑动。但全连接层的输入维度是固定的(比如512x1x1),如果卷积层把图片缩得太小(比如0x0),全连接层就会维度不匹配报错。

Q2:什么时候冻结,什么时候微调?(必答)

答:看两个维度:

  • 数据量:<1万张→冻结,>1万张→微调;
  • 数据相似度:和ImageNet像(日常物体)→冻结,不像(医疗/工业)→微调(甚至只解冻后几层)。

Q3:PyTorch新版本为什么不用pretrained=True

答:pretrained=True在PyTorch 1.13后被弃用了,新版本推荐用weights=ResNet18_Weights.DEFAULT——前者容易下载失败,后者会自动校验权重完整性,还能选择不同版本的预训练权重(比如ResNet18_Weights.IMAGENET1K_V2)。

Q4:为什么归一化要用指定的均值/方差?

答:预训练模型是在“归一化后的ImageNet”上学的,如果你用CIFAR-10的均值/方差,网络能更快适配;用错均值/方差会导致特征分布偏移,准确率直接掉档。

📌 下期预告

通过今天的实战,我们体会到了站在巨人肩膀上的快乐——用迁移学习半小时搞定高准确率模型,确实比从零手搓香太多!

但你有没有想过,训练过程中的“核心引擎”——优化器(Optimizer) 和“关键油门”——学习率(Learning Rate) ,你真的会玩明白吗?为什么有时候Loss像坐过山车一样震荡不收敛?为什么有时候Loss卡在半山腰,收敛慢到让人崩溃?训练分类任务到底是SGD稳还是Adam快?学习率是一直固定不变,还是要跟着训练进度动态调整?

下一篇,我们将正式进入优化器与学习率调度器专题。作为踩过无数调参坑的MATLAB老鸟,我不会只讲枯燥的公式原理——而是带你用代码可视化对比 SGD、Adam、AdamW 的收敛轨迹,一眼看清不同优化器的“脾气”;还会手把手教你用 CosineAnnealing、StepLR 等调度器实现“自动换挡”,不用手动调参,就能让模型训练又快又稳!

欢迎关注我的专栏,见证MATLAB老鸟到算法工程师的进阶之路!