从零搭建CNN到迁移学习:以食物分类为例深入理解PyTorch图像分类实战

引言

随着深度学习的快速发展,卷积神经网络(CNN)已成为计算机视觉领域的核心技术之一。从图像分类、目标检测到语义分割,CNN及其变体在各种任务中都展现出了卓越的性能。对于初学者而言,从零开始实现一个CNN模型并完成训练,是理解深度学习原理的重要途径;而对于实际应用,我们往往采用迁移学习(Transfer Learning)来利用在大规模数据集上预训练的模型,从而在小数据集上快速获得高性能的分类器。

本文将以食物分类任务为例,通过两段完整的PyTorch代码,详细阐述从数据加载、预处理、自定义CNN模型搭建、训练、测试,到使用ResNet-18进行迁移学习的整个过程。文章将深入解析每一行关键代码的原理与设计思路,并通过实验结果对比展示迁移学习的巨大优势。全文力求万字,希望为读者提供一份详实、可复现的实战教程。

一、项目背景与目标

食物分类是图像识别中一个典型的细粒度分类问题。假设我们有一个包含20类食物的数据集,每张图片对应一个类别标签,训练集和测试集已经按行记录在txt文件中,格式如下:

复制代码
path/to/img1.jpg 3
path/to/img2.jpg 15
...

我们的任务分为两个阶段:

  1. 使用自定义的简单CNN网络,从零开始训练一个食物分类模型。
  2. 使用在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,相当于保留了中间区域并略微缩放,可视为数据增强的一部分。
  • RandomHorizontalFlipRandomVerticalFlip:通过随机翻转增加样本多样性。
  • 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.imgsself.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.scripttorch.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%,说明模型几乎没有有效学习。可能的原因包括:

  1. 模型过于简单,参数不足以捕获20类食物的细微差别。
  2. 训练数据可能较少或质量不高。
  3. 学习率、优化器配置可能不是最佳。
  4. 训练轮次太少,模型仍未收敛。

为了验证我们的猜想,接着引入迁移学习的方法,对比效果。

四、迁移学习:使用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微调后准确率迅速上升。

五、迁移学习如何改进了性能?

迁移学习之所以有效,是因为:

  1. 预训练特征具有通用性:ImageNet包含数百万张图片,1000个类别,训练出的卷积核能提取低层边缘、纹理,中高层形状、语义等通用视觉特征。
  2. 小数据集避免过拟合:冻结大部分参数,仅训练最后全连接层调整分类,模型容量受到限制,从而降低过拟合风险。
  3. 更好的初始化:相比随机初始化,预训练权重为模型提供了较好的起点,能使损失函数更快收敛,更容易找到较好的局部最优。

在本次实验中,即使只训练最后一层,模型准确率也能达到约59%,相比自定义CNN的15%提升近4倍。

(全文完)

相关推荐
wen_zhufeng1 小时前
AudioX\-Turbo:面向通用音频生成的高效多模态统一框架
人工智能·算法·音视频
IT新视界1 小时前
星环科技发布XClaw:全能桌面智能体,开启轻量安全的AI助手新时代
人工智能·科技·安全
knight_9___1 小时前
AI Agent 是什么?
人工智能·python·agent·rag·mcp
百胜软件@百胜软件1 小时前
货品“精”营:ABC-XYZ分类如何驱动鞋服全渠道库存效率革命?
人工智能·分类·数据挖掘·零售数字化·数智中台·珠宝行业
招标采购导航网1 小时前
标讯类目体系的自动演化:招标采购导航网如何根据新出现的行业自动扩展分类
大数据·运维·人工智能
by————组态1 小时前
Ricon组态实时监控 - 毫秒级数据可视化
大数据·人工智能·物联网·信息可视化·架构·组态
尽兴-1 小时前
6.1 模型优化:量化 INT4/INT8、GPTQ、AWQ、GGUF
人工智能·gptq·awq·gguf·int4/int8
Cloud_Shy6181 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第七章 Item 51)
开发语言·人工智能·笔记·python·学习方法