深度学习入门:迁移学习与 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 | 输入图像 |
| conv1 | 64×112×112 | 7×7 卷积,stride=2 |
| maxpool | 64×56×56 | 3×3 最大池化,stride=2 |
| conv2_x | 64×56×56 | 2 个残差块 |
| conv3_x | 128×28×28 | 2 个残差块 |
| conv4_x | 256×14×14 | 2 个残差块 |
| conv5_x | 512×7×7 | 2 个残差块 |
| 全局平均池化 | 512×1×1 | 将每个特征图的平均值作为最终向量 |
| 全连接层 | 1000 | 输出 1000 类(ImageNet 类别数) |
不同版本的 ResNet:
| ResNet-18 | 18 层 |
| ResNet-34 | 34 层 |
| ResNet-50 | 50 层 |
| ResNet-101 | 101 层 |
| ResNet-152 | 152 层 |
四、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_size | ResNet 参数量大,显存不足时可调小 |
| 学习率 | 微调时用更小的学习率(如 0.0001) |
| 冻结策略 | 数据少时先冻结,数据多时可解冻部分层 |
| 归一化参数 | 使用 ImageNet 的均值和标准差 |
系列直达
- 上篇:深度学习入门:模型保存与加载
- 本篇:深度学习入门:迁移学习与 ResNet(本文)
- 下篇:敬请期待


