深度学习入门:迁移学习与 ResNet

前言:上一篇我们学习了模型保存与加载,以及学习率动态调整。在实际项目中,从零训练一个深度网络往往需要大量数据和算力,普通开发者很难做到。本篇我们将学习迁移学习——利用已经在大规模数据集上训练好的模型,快速应用到自己的任务上。同时介绍迁移学习中最常用的网络结构——ResNet(残差网络)

目录

  • 一、为什么需要迁移学习
  • 二、迁移学习的基本流程
  • 三、ResNet 与残差结构
  • 四、Batch Normalization
  • 五、实战:用 ResNet 做迁移学习
  • 六、总结

一、为什么需要迁移学习

1.1 从零训练的问题

问题说明
数据量大训练一个 ImageNet 级别的模型需要上百万张图片
算力要求高从零训练 ResNet 需要多张高端 GPU,耗时数天
小数据集困难自己的数据集只有几百张图,从零训练效果很差

1.2 迁移学习的思路

迁移学习是指利用已经训练好的模型,在新的任务上进行微调。核心思想是:模型在大规模数据集上学到的特征提取能力,可以迁移到新任务上

1.3 迁移学习的优势

优势说明
加快训练不用从零开始,几轮就能收敛
提高性能预训练模型已学到通用特征,效果更好
数据稀缺即使数据集小,也能取得不错的效果

二、迁移学习的基本流程

迁移学习的完整步骤如下:

步骤说明
1. 选择预训练模型和层通常选择在 ImageNet 上预训练的模型(VGG、ResNet 等)
2. 冻结预训练模型参数保持预训练权重不变,只训练新增的层
3. 训练新增的层在冻结的状态下训练新的分类层,让模型适应新任务
4. 微调预训练模型的层解冻部分层,用较小学习率继续微调
5. 评估和测试用测试集评估,效果不好可调整超参数或微调层

2.1 冻结与微调的理解

操作含义适用层
冻结参数不更新浅层(边缘、纹理等通用特征)
微调参数继续更新深层(类别相关的专属特征)

三、ResNet 与残差结构

3.1 深层网络的问题

卷积神经网络通过卷积层和池化层叠加组成。理论上网络越深表达能力越强,但实验发现:

问题说明
梯度消失每层误差梯度小于 1,反向传播时越深梯度越趋近 0
梯度爆炸每层误差梯度大于 1,反向传播时越深梯度越大
退化问题网络加深后,训练误差反而上升,效果变差

3.2 ResNet 的解决方案

ResNet(残差网络)由何凯明等人在 2015 年提出,斩获当年 ImageNet 竞赛分类、目标检测双料冠军。

两个关键解决方案

问题解决方案
梯度消失/爆炸数据预处理 + 使用 BN(Batch Normalization)层
退化问题引入残差结构(跨层连接)

3.3 残差结构

残差结构的核心是捷径(shortcut),让特征层相加:

F ( x ) + x \mathcal{F}(x) + x F(x)+x

部分说明
x输入(identity,恒等映射)
F(x)经过两层权重层的输出
F(x) + x两者相加,再经过 ReLU

注意F(x)x 的形状必须相同才能相加。如果不相同,需要用 1×1 卷积调整通道数。

3.4 ResNet 网络结构

以 ResNet-18 为例:

层级输出尺寸操作
输入3×224×224输入图像
conv164×112×1127×7 卷积,stride=2
maxpool64×56×563×3 最大池化,stride=2
conv2_x64×56×562 个残差块
conv3_x128×28×282 个残差块
conv4_x256×14×142 个残差块
conv5_x512×7×72 个残差块
全局平均池化512×1×1将每个特征图的平均值作为最终向量
全连接层1000输出 1000 类(ImageNet 类别数)

不同版本的 ResNet:

版本层数
ResNet-1818 层
ResNet-3434 层
ResNet-5050 层
ResNet-101101 层
ResNet-152152 层

四、Batch Normalization

4.1 BN 的作用

Batch Normalization(批归一化) 的目的是让所有的 feature map 满足均值为 0、方差为 1 的分布规律。

4.2 BN 的好处

好处说明
加速训练每层输入分布稳定,梯度更稳定
防止梯度问题缓解梯度消失和梯度爆炸
允许更大学习率训练更快,收敛更好
轻微正则化减少对 Dropout 的依赖

4.3 BN 的实现

ResNet 中每个卷积层后都紧接 BN 层:

nn.Sequential(
    nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, bias=False),
    nn.BatchNorm2d(out_channels),   # BN 层
    nn.ReLU(inplace=True),
)

五、实战:用 ResNet 做迁移学习

5.1 场景说明

假设我们要做一个 20 类的食物分类任务,数据集规模较小(几百张图)。直接从头训练 CNN 效果差,使用 ResNet-18 预训练模型进行迁移学习是更好的选择。

5.2 完整代码

import torch
import torch.nn as nn
from torchvision import models, transforms
from torch.utils.data import Dataset, DataLoader
from PIL import Image
import numpy as np


# ==================== 数据预处理 ====================
data_transforms = {
    'trainda':
        transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.RandomRotation(45),
            transforms.CenterCrop(224),
            transforms.RandomHorizontalFlip(p=0.5),
            transforms.RandomVerticalFlip(p=0.5),
            transforms.ColorJitter(0.2, 0.1, 0.1, 0.1),
            transforms.RandomGrayscale(p=0.1),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ]),
    'valid':
        transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ]),
}


# ==================== 自定义数据集 ====================
class FoodDataset(Dataset):
    def __init__(self, file_path, transform=None):
        self.imgs = []
        self.labels = []
        self.transform = transform
        with open(file_path) as f:
            samples = [x.strip().split(' ') for x in f.readlines() if x.strip()]
            for img_path, label in samples:
                self.imgs.append(img_path)
                self.labels.append(label)

    def __len__(self):
        return len(self.imgs)

    def __getitem__(self, idx):
        image = Image.open(self.imgs[idx]).convert('RGB')
        if self.transform:
            image = self.transform(image)
        label = torch.from_numpy(np.array(self.labels[idx], dtype=np.int64))
        return image, label


training_data = FoodDataset('./train.txt', data_transforms['trainda'])
test_data = FoodDataset('./test.txt', data_transforms['valid'])

train_dataloader = DataLoader(training_data, batch_size=16, shuffle=True)
test_dataloader = DataLoader(test_data, batch_size=16, shuffle=False)


# ==================== 自定义迁移学习网络 ====================
class NewNet(nn.Module):
    def __init__(self, num_classes=20):
        super(NewNet, self).__init__()
        # 加载 ResNet-18 预训练模型
        self.resnet = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
        # 新增分类层,将原来的 1000 类改为自定义的 20 类
        self.fc = nn.Linear(1000, num_classes)

    def forward(self, x):
        x = self.resnet(x)   # 经过 ResNet 提取特征,输出 (batch_size, 1000)
        x = self.fc(x)       # 经过新的全连接层,输出 (batch_size, 20)
        return x


# ==================== 训练与测试 ====================
device = "cuda" if torch.cuda.is_available() else "cpu"
model = NewNet(num_classes=20).to(device)


def train(dataloader, model, loss_fn, optimizer):
    model.train()
    total_loss = 0
    for X, y in dataloader:
        X, y = X.to(device), y.to(device)
        pred = model(X)
        loss = loss_fn(pred, y)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        total_loss += loss.item()
    print(f"训练平均损失: {total_loss / len(dataloader):.4f}")


def test(dataloader, model, loss_fn):
    size = len(dataloader.dataset)
    model.eval()
    test_loss, correct = 0, 0
    with torch.no_grad():
        for X, y in dataloader:
            X, y = X.to(device), y.to(device)
            pred = model(X)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()
    test_loss /= len(dataloader)
    correct /= size
    print(f"测试结果: 准确率 {(100 * correct):.2f}%, 平均损失 {test_loss:.4f}")


loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

epochs = 10
for t in range(epochs):
    print(f"Epoch {t + 1}\n====================================")
    train(train_dataloader, model, loss_fn, optimizer)
    test(test_dataloader, model, loss_fn)
print("Done!")

5.3 关键改动说明

改动说明
输入尺寸从 256×256 改为 224×224,因为 ResNet 预训练时使用 224×224
加载预训练模型models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
修改分类层将 1000 类改为 20 类:nn.Linear(1000, 20)
batch_size改为 16,因为 ResNet 参数量更大,显存占用更高

5.4 进阶:冻结与微调

如果数据量极小,可以先冻结 ResNet 参数,只训练新增的 fc 层:

class NewNet(nn.Module):
    def __init__(self, num_classes=20):
        super(NewNet, self).__init__()
        self.resnet = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
        
        # 冻结 ResNet 的所有参数
        for param in self.resnet.parameters():
            param.requires_grad = False
        
        self.fc = nn.Linear(1000, num_classes)

    def forward(self, x):
        x = self.resnet(x)
        x = self.fc(x)
        return x

训练几轮后,再解冻部分层进行微调:

# 解冻 ResNet 的最后两层
for param in model.resnet.layer4.parameters():
    param.requires_grad = True

# 用更小的学习率微调
optimizer = torch.optim.Adam(model.parameters(), lr=0.0001)

六、总结

核心知识点速查

知识点关键概念
迁移学习利用预训练模型在新任务上微调
冻结参数param.requires_grad = False
微调解冻部分层,用较小学习率继续训练
ResNet残差网络,通过跨层连接解决退化问题
残差结构F(x) + x,让特征层相加
Batch Normalization让 feature map 满足均值 0、方差 1 的分布

核心 API 一览

用途对应模块 / 方法
加载预训练模型models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
冻结参数param.requires_grad = False
修改分类层nn.Linear(in_features, num_classes)
BN 层nn.BatchNorm2d(channels)

注意事项

要点说明
输入尺寸ResNet 预训练用 224×224,迁移学习时保持一致
batch_sizeResNet 参数量大,显存不足时可调小
学习率微调时用更小的学习率(如 0.0001)
冻结策略数据少时先冻结,数据多时可解冻部分层
归一化参数使用 ImageNet 的均值和标准差

系列直达

更多推荐