引言
随着深度学习的快速发展,卷积神经网络(CNN)已成为计算机视觉领域的核心技术之一。从图像分类、目标检测到语义分割,CNN及其变体在各种任务中都展现出了卓越的性能。对于初学者而言,从零开始实现一个CNN模型并完成训练,是理解深度学习原理的重要途径;而对于实际应用,我们往往采用迁移学习(Transfer Learning)来利用在大规模数据集上预训练的模型,从而在小数据集上快速获得高性能的分类器。
本文将以食物分类任务为例,通过两段完整的PyTorch代码,详细阐述从数据加载、预处理、自定义CNN模型搭建、训练、测试,到使用ResNet-18进行迁移学习的整个过程。文章将深入解析每一行关键代码的原理与设计思路,并通过实验结果对比展示迁移学习的巨大优势。全文力求万字,希望为读者提供一份详实、可复现的实战教程。
一、项目背景与目标
食物分类是图像识别中一个典型的细粒度分类问题。假设我们有一个包含20类食物的数据集,每张图片对应一个类别标签,训练集和测试集已经按行记录在txt文件中,格式如下:
path/to/img1.jpg 3
path/to/img2.jpg 15
...
我们的任务分为两个阶段:
- 使用自定义的简单CNN网络,从零开始训练一个食物分类模型。
- 使用在ImageNet上预训练的ResNet-18模型,采用迁移学习的方式微调,对比效果。
最终模型应能对食物图片进行准确的20分类。
二、数据准备与预处理
良好的数据处理是模型成功的基础。代码中首先使用PyTorch的torchvision.transforms模块定义了一系列数据增强和归一化方法,同时通过自定义Dataset类读取txt文件中的图片路径和标签。
2.1 数据增强策略
对于训练集,我们设计了一组较丰富的数据增强操作:
python
data_transforms = {
'trainda':
transforms.Compose([
transforms.Resize([300,300]), # 缩放到指定大小
transforms.RandomRotation(45), # 随机旋转,-45到45度
transforms.CenterCrop(256), # 中心裁剪到256x256
transforms.RandomHorizontalFlip(p=0.5), # 随机水平翻转,概率0.5
transforms.RandomVerticalFlip(p=0.5), # 随机垂直翻转
transforms.RandomGrayscale(p=0.1), # 随机转灰度图,概率0.1
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) # 归一化
]),
'valid':
transforms.Compose([
transforms.Resize([256,256]),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
]),
}
注意:这里使用了ImageNet的均值和标准差进行归一化,这是为之后迁移学习做准备,使得输入分布与预训练模型的训练分布一致。自定义CNN也同样使用该归一化。
Resize:将图像统一缩放到某个尺寸,便于批量处理。RandomRotation:随机旋转角度在±45°之间,增加模型对旋转的鲁棒性。CenterCrop:从中心裁剪原图,对于训练集是先缩放到300×300再裁剪到256×256,相当于保留了中间区域并略微缩放,可视为数据增强的一部分。RandomHorizontalFlip和RandomVerticalFlip:通过随机翻转增加样本多样性。RandomGrayscale:将图像以一定概率变为灰度图,旨在减少对颜色的依赖。ToTensor:将PIL图像或numpy数组转换为PyTorch张量,并自动将像素值从0,255缩放到0,1。Normalize:对RGB三个通道分别进行标准化,减均值除以标准差,加速模型收敛。
对于验证集,我们只做缩放、转张量和归一化,不进行其他增强操作。
2.2 自定义Dataset类
food_dataset类继承自torch.utils.data.Dataset,需要实现三个核心方法:__init__,__len__和__getitem__。
python
class food_dataset(Dataset):
def __init__(self, file_path, transform=None):
self.file_path = file_path
self.imgs = []
self.labels = []
self.transform = transform
with open(self.file_path) as f:
samples = [x.strip().split(' ') for x in f.readlines()]
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])
if self.transform:
image = self.transform(image)
label = self.labels[idx]
label = torch.from_numpy(np.array(label, dtype=np.int64))
return image, label
__init__读取文件,将图片路径和标签分别存储在self.imgs和self.labels列表中。__getitem__根据索引加载图片,应用变换,并将标签转为长整型张量。
注意 :标签在文件中是字符串(或数字字符串),我们使用np.array(label, dtype=np.int64)转换为numpy数组再转为torch张量,确保标签为整数类型,符合交叉熵损失函数的要求。
2.3 数据加载器(DataLoader)
实例化Dataset后,通过PyTorch的DataLoader实现批量化数据加载和打乱:
python
train_dataloader = DataLoader(training_data, batch_size=64, shuffle=True)
test_dataloader = DataLoader(test_data, batch_size=64, shuffle=True)
batch_size=64表示每次喂给模型64张图像。shuffle=True在训练时打乱顺序,防止模型学习到数据的顺序;测试时打乱也无妨,但通常不必要。
此外,代码中定义了设备选择:
python
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
这样可以在有GPU的机器上利用CUDA加速,在苹果M芯片上使用MPS加速,或者回退到CPU。
三、从零搭建卷积神经网络(CNN)
在掌握了数据流之后,我们首先尝试自己设计一个CNN模型进行训练。
3.1 模型架构设计
代码中定义了一个名为CNN的类,继承自nn.Module,其结构如下:
python
class CNN(nn.Module):
def __init__(self): # 输入大小 (3, 256, 256)
super(CNN, self).__init__()
self.conv1 = nn.Sequential(
nn.Conv2d(3, 16, 5, 1, 2), # 输出 (16, 256, 256)
nn.ReLU(),
nn.MaxPool2d(2), # (16, 128, 128)
)
self.conv2 = nn.Sequential(
nn.Conv2d(16, 32, 5, 1, 2), # (32, 128, 128)
nn.ReLU(),
nn.Conv2d(32, 32, 5, 1, 2), # (32, 128, 128)
nn.ReLU(),
nn.MaxPool2d(2), # (32, 64, 64)
)
self.conv3 = nn.Sequential(
nn.Conv2d(32, 64, 5, 1, 2), # (64, 64, 64)
nn.ReLU(),
)
self.out = nn.Linear(64 * 64 * 64, 20) # 全连接层
def forward(self, x):
x = self.conv1(x)
x = self.conv2(x)
x = self.conv3(x)
x = x.view(x.size(0), -1) # 展平为 (batch_size, 64*64*64)
output = self.out(x)
return output
让我们逐层讲解设计思想:
第一卷积块(conv1)
nn.Conv2d(3, 16, 5, 1, padding=2):输入3通道,输出16个特征图,卷积核大小5×5,步长1,填充2。由于填充=2,输入尺寸256×256经过卷积后保持为256×256。输出形状变为(16, 256, 256)。ReLU:非线性激活函数,增加模型表达能力。MaxPool2d(2):2×2最大池化,步长默认等于卷积核大小,输出尺寸减半,变为(16, 128, 128)。
第二卷积块(conv2)
- 包含两个卷积层,均采用3×3或5×5卷积核?此处第一层
nn.Conv2d(16, 32, 5,1,2)将16通道增至32通道,尺寸保持128×128;第二层nn.Conv2d(32, 32, 5,1,2)维持通道数和尺寸;最后通过最大池化将尺寸降到(32, 64, 64)。
第三卷积块(conv3)
nn.Conv2d(32, 64, 5, 1, 2)将通道数扩到64,尺寸保持64×64。- ReLU激活。
全连接层
- 经过卷积后,特征图尺寸为64×64×64,将其展平为一维向量
64*64*64 = 262144,然后输入线性层得到20个类别的logits。
forward方法:定义数据流向,依次经过三个卷积块、展平、全连接层。
该模型总计参数约在千万级(可计算,但这里不深究),对于一个小型数据集可能存在过拟合风险,论文中通常会通过增加正则化或数据增强来缓解。
3.2 训练与测试函数
训练函数:
python
def train(dataloader, model, loss_fn, optimizer):
model.train()
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model.forward(X)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
model.train():将模型设置为训练模式,某些层如Dropout、BatchNorm在该模式下表现不同。- 批量数据送入设备,前向传播计算预测
pred。 - 通过损失函数(交叉熵损失)计算
loss。 optimizer.zero_grad()清空之前梯度。loss.backward()反向传播计算梯度。optimizer.step()更新参数。
测试函数:
python
best_acc = 0
def test(dataloader, model, loss_fn):
global best_acc
size = len(dataloader.dataset)
num_batches = len(dataloader)
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.forward(X)
test_loss += loss_fn(pred, y).item()
correct += (pred.argmax(1) == y).type(torch.float).sum().item()
test_loss /= num_batches
correct /= size
print(f"Test result: \n Accuracy: {(100*correct)}%, Avg loss: {test_loss}")
if correct > best_acc:
best_acc = correct
# 保存最优模型
torch.save(model.state_dict(), "best2026-615.pth")
script_model = torch.jit.script(model)
torch.jit.save(script_model, "best615.pth")
model.eval():将模型设为评估模式,关闭Dropout等。with torch.no_grad():禁用梯度计算,节省内存和计算。- 累计测试损失和预测正确数。
- 计算平均损失和准确率。
- 若当前准确率超过历史最佳,则保存模型参数(
state_dict)和脚本化模型(可部署)。
模型保存部分提供了两种方式:
torch.save(model.state_dict())只存参数,需要事先定义模型结构;torch.jit.script和torch.jit.save则将模型结构及参数打包保存,方便后续跨平台使用。
3.3 训练与结果分析
设置训练5个epoch,学习率0.001,优化器Adam。
python
epochs = 5
for t in range(epochs):
print(f"Epoch {t+1}\n-------------------------------")
train(train_dataloader, model, loss_fn, optimizer)
test(test_dataloader, model, loss_fn)
输出的训练结果为:
Epoch 1
Test result: Accuracy: 7.69%, Avg loss: 3.0257
Epoch 2
Test result: Accuracy: 7.69%, Avg loss: 3.1042
Epoch 3
Test result: Accuracy: 7.69%, Avg loss: 3.0594
Epoch 4
Test result: Accuracy: 15.38%, Avg loss: 2.9116
Epoch 5
Test result: Accuracy: 15.38%, Avg loss: 3.0768
自定义CNN在5个epoch后只达到了约15%的准确率,对于20分类任务,随机猜测准确率应为5%,说明模型几乎没有有效学习。可能的原因包括:
- 模型过于简单,参数不足以捕获20类食物的细微差别。
- 训练数据可能较少或质量不高。
- 学习率、优化器配置可能不是最佳。
- 训练轮次太少,模型仍未收敛。
为了验证我们的猜想,接着引入迁移学习的方法,对比效果。
四、迁移学习:使用ResNet-18
迁移学习是解决小数据集训练的有效手段。我们利用在ImageNet上预训练的ResNet-18,固定其特征提取部分,只重新训练最后的全连接层以适应我们的20分类任务。
4.1 加载预训练模型
python
resnet_model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
weights=models.ResNet18_Weights.DEFAULT表示使用在ImageNet上预训练的权重加载模型。该设置会从网络下载预训练参数,若无法访问外网可能需要手动下载。
4.2 冻结特征提取层
为了只训练最后的全连接层,我们需要冻结卷积层的参数:
python
for param in resnet_model.parameters():
param.requires_grad = False
这样所有参数的梯度计算关闭,只有之后新添加的层的梯度是开启的。
4.3 修改全连接层
ResNet-18原始的全连接层输出为1000类,我们需要将其替换为20类的全连接层:
python
in_features = resnet_model.fc.in_features # 获取原全连接层的输入特征数(512)
resnet_model.fc = nn.Linear(in_features, 20) # 替换为新的全连接层
4.4 设置优化器
我们只将需要训练的参数(即新替换的全连接层)传递给优化器:
python
params_to_update = []
for param in resnet_model.parameters():
if param.requires_grad == True:
params_to_update.append(param)
optimizer = torch.optim.Adam(params_to_update, lr=0.001)
注释中也指出:如果想训练所有层(即微调整个模型),只需将全部参数
resnet_model.parameters()传入优化器,并设置所有参数的requires_grad = True。
此外,代码中添加了学习率调度器,每5个epoch学习率衰减一半:
python
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)
4.5 数据预处理调整
由于ResNet-18的输入尺寸通常为224×224,我们在训练集增强时也相应调整:
python
'valid':
transforms.Compose([
transforms.Resize([224,224]),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
]),
并且训练集也使用了CenterCrop(224)和Resize([300,300])的组合。注意在自定义CNN的训练代码中,数据变换使用了Resize([300,300])+CenterCrop(256),而迁移学习代码改为CenterCrop(224),以匹配预训练模型的输入尺寸。
4.6 训练与评估
训练100个epoch,并在每个epoch调用scheduler.step()更新学习率。
python
epochs = 100
for t in range(epochs):
print(f"Epoch {t+1}\n-------------------------------")
train(train_dataloader, model, loss_fn, optimizer)
scheduler.step()
test(test_dataloader, model, loss_fn)
print('最优训练结果为:', best_acc)
4.7 实验结果
从控制台输出可以看到,迁移学习的效果显著提升:
Epoch 1
Test result: Accuracy: 2.56%, Avg loss: 3.1158
Epoch 2
Test result: Accuracy: 10.26%, Avg loss: 2.9900
Epoch 3
Test result: Accuracy: 23.08%, Avg loss: 2.7177
Epoch 4
Test result: Accuracy: 35.90%, Avg loss: 2.4873
Epoch 5
Test result: Accuracy: 41.03%, Avg loss: 2.2943
...
Epoch 100
Test result: Accuracy: 56.41%, Avg loss: 1.7328
最优训练结果为: 0.5897435897435898
经过100个epoch的训练,最优准确率达到了58.97%(在第98个epoch左右),且损失降至约1.73。这相比自定义CNN的15%提升巨大,证明了迁移学习的有效性。
值得注意的是,在初始epoch模型准确率低于5%看似反常,但这是因为全连接层参数随机初始化,而特征提取层被冻结且未经过任务相关训练,经过几个epoch微调后准确率迅速上升。
五、迁移学习如何改进了性能?
迁移学习之所以有效,是因为:
- 预训练特征具有通用性:ImageNet包含数百万张图片,1000个类别,训练出的卷积核能提取低层边缘、纹理,中高层形状、语义等通用视觉特征。
- 小数据集避免过拟合:冻结大部分参数,仅训练最后全连接层调整分类,模型容量受到限制,从而降低过拟合风险。
- 更好的初始化:相比随机初始化,预训练权重为模型提供了较好的起点,能使损失函数更快收敛,更容易找到较好的局部最优。
在本次实验中,即使只训练最后一层,模型准确率也能达到约59%,相比自定义CNN的15%提升近4倍。
(全文完)