• 👉 声明:本文为学习记录性文章,参考「365天深度学习训练营」相关内容整理。
  • 🍨 本文为🔗365天深度学习训练营 中的学习记录博客
  • 🍖 原作者:K同学啊


前言

本篇是我训练营的第七次学习,主要目标是使用 PyTorch 完成马铃薯病害识别,并且重点学习 手动搭建 VGG-16 网络框架。上一周学习了调用官方 VGG-16 预训练模型,而本周需要自己手动写出 VGG-16 的五个卷积块和三个全连接层,VGG-16 中16 代表网络中「带权重的可训练层」总数量,只算卷积层 + 全连接层,池化层、ReLU 激活函数一般不计入这个 16 里面,因为Conv2d 有权重参数,需要学习;Linear 有权重参数,需要学习;MaxPool2d 只是做下采样,通常没有可训练参数;ReLU 只是激活函数,也没有可训练参数。
VGG16 标准结构
卷积层 Conv:13 层,分为 5 组卷积块,每组卷积后接最大池化:Block1:Conv×2、Block2:Conv×2、Block3:Conv×3、Block4:Conv×3、Block5:Conv×3,合计:2+2+3+3+3 = 13 个卷积层;全连接层 FC:3 层,卷积提取特征后拉平,接三层全连接做分类:FC1、FC2、FC3(最后一层输出分类概率),合计:3 个全连接层。总数:13 + 3 = 16 层

本周使用的是 PotatoPlants 马铃薯叶片病害数据集,任务是根据叶片图片判断马铃薯植株属于哪一种状态。数据集包含 3 个类别:Early_blight 早期疫病Late_blight 晚期疫病healthy 健康叶片

感谢 K同学啊 老师的教学,以及 ChatGPT和Kimi。


一、准备工作

1. 设置运行设备:GPU 或 CPU

import torch
import torch.nn as nn
import torchvision.transforms as transforms
import torchvision
from torchvision import transforms, datasets
import os, PIL, pathlib, warnings

warnings.filterwarnings("ignore")             # 忽略警告信息

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
device
device(type='cpu')

这一步和 P1-P6 周完全相同,程序会自动判断当前电脑能不能使用 CUDA。如果可以使用,就把模型和数据放到 GPU 上运行;如果不可以,就放到 CPU 上运行。我自己安装的是 CPU 版本 PyTorch,所以只有CPU。


2. 关于马铃薯病害识别数据集

本周使用的是马铃薯病害数据集(PotatoPlants),包含表现出各种疾病的马铃薯植物的高分辨率图像,包括早期疫病晚期疫病健康叶子。需要自己下载并放在本地 PotatoPlants/ 文件夹下。

文件夹结构如下:

PotatoPlants/
├── Early_blight/          
├── Late_blight/        
└── healthy/         

数据集共有 2152 张图片,包含 3 个类别,这个任务本质上是一个 三分类图像识别任务。模型输入一张马铃薯叶片图片,输出 3 个类别分数,最后取分数最高的类别作为预测结果。


3. 导入本地数据集

P3-P7 周的数据加载方式基本一致,都是使用 pathlib.Path + datasets.ImageFolder 来读取本地图片。P6 周是人脸识别 17 分类,本周是马铃薯病害 3 分类,首先使用 pathlib.Path 读取本地数据文件夹,并提取类别名称。

import os, PIL, random, pathlib

data_dir = './PotatoPlants/'
data_dir = pathlib.Path(data_dir)

data_paths  = list(data_dir.glob('*'))
classeNames = [str(path).split("\\")[1] for path in data_paths]
classeNames
['Early_blight', 'healthy', 'Late_blight']

这一段的意思是:

  1. pathlib.Path(data_dir):把字符串路径 ./PotatoPlants/ 转换成 Path 对象;
  2. 使用 glob('*') 获取 data_dir 路径下的所有子文件夹路径;
  3. 通过 split("\\") 对每条路径进行分割,提取出文件夹名称(即类别名称),存入 classeNames 列表中;
  4. 每一个子文件夹名称就是一个类别;
  5. 最终得到 3 个类别名称。

classeNames 的显示顺序可能和 ImageFolder 最终生成标签的顺序不完全一致,所以真正训练时应该以 total_data.class_to_idx 为准。


4. 数据预处理:transforms.Compose()

本周的图片来自本地文件夹,原始图片尺寸可能并不统一。VGG-16 的标准输入尺寸是 224 × 224,所以在送入模型之前,需要先做统一处理。

# 关于transforms.Compose的更多介绍可以参考:https://blog.csdn.net/qq_38251616/article/details/124878863
train_transforms = transforms.Compose([
    transforms.Resize([224, 224]),  # 将输入图片resize成统一尺寸
    # transforms.RandomHorizontalFlip(), # 随机水平翻转
    transforms.ToTensor(),          # 将PIL Image或numpy.ndarray转换为tensor,并归一化到[0,1]之间
    transforms.Normalize(           # 标准化处理-->转换为标准正太分布(高斯分布),使模型更容易收敛
        mean=[0.485, 0.456, 0.406], 
        std=[0.229, 0.224, 0.225])  # 其中 mean=[0.485,0.456,0.406]与std=[0.229,0.224,0.225] 从数据集中随机抽样计算得到的。
])

test_transform = transforms.Compose([
    transforms.Resize([224, 224]),  # 将输入图片resize成统一尺寸
    transforms.ToTensor(),          # 将PIL Image或numpy.ndarray转换为tensor,并归一化到[0,1]之间
    transforms.Normalize(           # 标准化处理-->转换为标准正太分布(高斯分布),使模型更容易收敛
        mean=[0.485, 0.456, 0.406], 
        std=[0.229, 0.224, 0.225])  # 其中 mean=[0.485,0.456,0.406]与std=[0.229,0.224,0.225] 从数据集中随机抽样计算得到的。
])

total_data = datasets.ImageFolder("./PotatoPlants/", transform=train_transforms)
total_data
Dataset ImageFolder
    Number of datapoints: 2152
    Root location: ./PotatoPlants/
    StandardTransform
Transform: Compose(
               Resize(size=[224, 224], interpolation=bilinear, max_size=None, antialias=True)
               ToTensor()
               Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
           )

这一部分和 P3-P6 周非常相似,都是使用 transforms.Compose() 把多个预处理步骤组合起来。
transforms.Resize([224, 224]) 将不同大小的原始图片统一 resize 成 224 × 224 像素。这是 VGG-16 模型要求的标准输入尺寸。经过 5 次 2×2 最大池化后224 → 112 → 56 → 28 → 14 → 7,最后得到的特征图大小正好是 512 × 7 × 7,展平后就是 512 × 7 × 7 = 25088

transforms.ToTensor() 将 PIL Image 或 numpy.ndarray 格式的图片转换为 PyTorch 的 Tensor 格式,同时把像素值从 0-255 缩放到 0-1 之间。转换后,单张 RGB 图片的 shape 是[3, 224, 224],其中:

3   → RGB 三个颜色通道
224 → 图片高度
224 → 图片宽度

transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) 对 RGB 三个通道进行标准化处理。

🌟 mean 与 std 数值是怎么来的?

这些均值和标准差是通过计算 ImageNet 数据集 中所有训练图像的 RGB 通道均值和标准差得出的。VGG-16 模型就是在 ImageNet 数据集上预训练的,所以使用这组 mean 和 std 进行标准化,可以让我们的数据分布与预训练模型的训练数据分布保持一致,这是迁移学习中非常重要的一步。

具体数值:

  1. 计算均值(Mean)
    • Red 通道均值 ≈ 0.485
    • Green 通道均值 ≈ 0.456
    • Blue 通道均值 ≈ 0.406
  2. 计算标准差(Standard Deviation)
    • Red 通道标准差 ≈ 0.229
    • Green 通道标准差 ≈ 0.224
    • Blue 通道标准差 ≈ 0.225

这组均值和标准差通常来自 ImageNet 数据集。虽然本周是手动搭建 VGG-16,不是直接加载预训练权重,但使用这组标准化参数仍然是图像分类任务中的常见做法,可以让输入数据的数值分布更稳定。


5. 使用 ImageFolder 自动生成标签

total_data.class_to_idx
{'Early_blight': 0, 'Late_blight': 1, 'healthy': 2}

ImageFolder 会根据文件夹名称自动分配标签,total_data.class_to_idx 是一个存储了数据集类别和对应索引的字典。Early_blight 对应索引 0Late_blight 对应索引 1healthy 对应索引 2。训练时使用 CrossEntropyLoss(),它会根据真实标签计算分类损失。


6. 划分训练集和测试集

和 P3、P4、P6 周一样,本周数据集没有提前分好训练集和测试集,需要使用 random_split 按 8:2手动划分。

train_size = int(0.8 * len(total_data))
test_size  = len(total_data) - train_size
train_dataset, test_dataset = torch.utils.data.random_split(total_data, [train_size, test_size])
train_dataset, test_dataset
(<torch.utils.data.dataset.Subset at 0x1be5c0ef530>,
 <torch.utils.data.dataset.Subset at 0x1be5c0efe30>)

这一段的意思是

  • train_size = int(0.8 * len(total_data)):训练集大小为总数据量的 80%。总数据量是 2152,所以训练集大小为 int(0.8 × 2152) = 1721
  • test_size = len(total_data) - train_size:测试集大小为剩余的 20%,即 2152 - 1721 = 431
  • torch.utils.data.random_split(total_data, [train_size, test_size]):将数据集随机打乱后,按照 [1721, 431] 的比例划分为训练集和测试集。
    如果希望每次划分结果一致,可以在 random_split() 中加入随机种子,例如使用 torch.Generator().manual_seed(42)

7. 创建 DataLoader 数据加载器

划分好数据集后,用 DataLoader 包装成可以批量加载的数据迭代器。

batch_size = 32

train_dl = torch.utils.data.DataLoader(train_dataset,
                                           batch_size=batch_size,
                                           shuffle=True,
                                           num_workers=1)
test_dl = torch.utils.data.DataLoader(test_dataset,
                                          batch_size=batch_size,
                                          shuffle=True,
                                          num_workers=1)

这一段和前几周非常相似。DataLoader 的作用就是把数据按 batch 送入模型。这里设置 batch_size = 32,表示每次送入 32 张图片。shuffle=True 表示每个 epoch 开始前打乱数据顺序。num_workers=1 用 1 个子进程辅助加载数据。


8. 查看一个 batch 的数据格式

for X, y in test_dl:
    print("Shape of X [N, C, H, W]: ", X.shape)
    print("Shape of y: ", y.shape, y.dtype)
    break
Shape of X [N, C, H, W]:  torch.Size([32, 3, 224, 224])
Shape of y:  torch.Size([32]) torch.int64

这个 shape 可以拆开理解:

torch.Size([32, 3, 224, 224])
             ↑   ↑    ↑    ↑
             N   C    H    W
             │   │    │    └── 宽度:224 像素
             │   │    └─────── 高度:224 像素
             │   └──────────── 通道数:3,RGB 彩色图
             └──────────────── batch_size,一批 32 张图片

七周数据集 shape 对比:

MNIST:       [32, 1, 28, 28]
CIFAR10:     [32, 3, 32, 32]
天气图像:    [32, 3, 224, 224]
猴痘图像:    [32, 3, 224, 224]
运动鞋图像:  [32, 3, 224, 224]
人脸图像:    [32, 3, 224, 224]
马铃薯图像:  [32, 3, 224, 224]   ← 本周

从 P3 周开始,图像基本都统一到了 224 × 224。这是因为很多经典 CNN 网络,例如 VGG、ResNet 等,都常以 224 × 224 作为输入尺寸。本周的类别数为 3 类(早疫病、晚疫病、健康叶子)。


二、手动搭建 VGG-16 模型

1. 什么是 VGG-16?

VGG-16(Visual Geometry Group-16)是由牛津大学视觉几何组提出的一种深度卷积神经网络架构。VGG-16 在 2014 年被提出,是 VGG 系列中最经典的版本之一,在 ImageNet 图像识别竞赛中取得了很好的成绩,最早用于大规模图像分类任务。。

VGG-16 的主要特点:

  1. 深度:VGG-16 由 16 个隐藏层组成(13 个卷积层和 3 个全连接层),因此具有较深的网络结构;
  2. 小卷积核:全部采用 3×3 的卷积核和步长为 1 的卷积操作,通过堆叠多个小卷积核来提高非线性建模能力;
  3. 池化层:使用 2×2 最大池化层来减少特征图的空间尺寸;
  4. 全连接层:最后接 3 个全连接层,输出与类别数对应的向量。

VGG-16 结构说明:

模块 层数 说明
block1 2 个卷积层 + 1 个池化层 Conv2d(3→64) ×2 + MaxPool2d
block2 2 个卷积层 + 1 个池化层 Conv2d(64→128) ×2 + MaxPool2d
block3 3 个卷积层 + 1 个池化层 Conv2d(128→256) ×3 + MaxPool2d
block4 3 个卷积层 + 1 个池化层 Conv2d(256→512) ×3 + MaxPool2d
block5 3 个卷积层 + 1 个池化层 Conv2d(512→512) ×3 + MaxPool2d
classifier 3 个全连接层 Linear(25088→4096→4096→num_classes)

VGG-16 包含了 16 个隐藏层(13 个卷积层和 3 全连接层),故称为 VGG-16。
与 P1-P5 周手动搭建的小 CNN 相比,VGG-16 深很多、参数多很多,因此更接近真实深度学习任务中常用的经典网络。


2. 手动搭建 VGG-16 网络

import torch.nn.functional as F

class vgg16(nn.Module):
    def __init__(self):
        super(vgg16, self).__init__()
        # 卷积块1: 输入3通道, 输出64通道, 2层卷积+1层池化
        self.block1 = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=(2, 2), stride=(2, 2))
        )
        # 卷积块2: 输入64通道, 输出128通道, 2层卷积+1层池化
        self.block2 = nn.Sequential(
            nn.Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=(2, 2), stride=(2, 2))
        )
        # 卷积块3: 输入128通道, 输出256通道, 3层卷积+1层池化
        self.block3 = nn.Sequential(
            nn.Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=(2, 2), stride=(2, 2))
        )
        # 卷积块4: 输入256通道, 输出512通道, 3层卷积+1层池化
        self.block4 = nn.Sequential(
            nn.Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=(2, 2), stride=(2, 2))
        )
        # 卷积块5: 输入512通道, 输出512通道, 3层卷积+1层池化
        self.block5 = nn.Sequential(
            nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=(2, 2), stride=(2, 2))
        )

        # 全连接网络层,用于分类
        self.classifier = nn.Sequential(
            nn.Linear(in_features=512*7*7, out_features=4096),
            nn.ReLU(),
            nn.Linear(in_features=4096, out_features=4096),
            nn.ReLU(),
            nn.Linear(in_features=4096, out_features=3)  # 3个类别
        )

    def forward(self, x):
        x = self.block1(x)
        x = self.block2(x)
        x = self.block3(x)
        x = self.block4(x)
        x = self.block5(x)
        x = torch.flatten(x, start_dim=1)
        x = self.classifier(x)
        return x

device = "cuda" if torch.cuda.is_available() else "cpu"
print("Using {} device".format(device))

model = vgg16().to(device)
model
Using cpu device
vgg16(
  (block1): Sequential(
    (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): ReLU()
    (2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): ReLU()
    (4): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), padding=0, dilation=1, ceil_mode=False)
  )
  (block2): Sequential(
    (0): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): ReLU()
    (2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): ReLU()
    (4): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), padding=0, dilation=1, ceil_mode=False)
  )
  (block3): Sequential(
    (0): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): ReLU()
    (2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): ReLU()
    (4): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (5): ReLU()
    (6): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), padding=0, dilation=1, ceil_mode=False)
  )
  (block4): Sequential(
    (0): Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): ReLU()
    (2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): ReLU()
    (4): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (5): ReLU()
    (6): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), padding=0, dilation=1, ceil_mode=False)
  )
  (block5): Sequential(
    (0): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): ReLU()
    (2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): ReLU()
    (4): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (5): ReLU()
    (6): MaxPool2d(kernel_size=(2, 2), stride=(2, 2), padding=0, dilation=1, ceil_mode=False)
  )
  (classifier): Sequential(
    (0): Linear(in_features=25088, out_features=4096, bias=True)
    (1): ReLU()
    (2): Linear(in_features=4096, out_features=4096, bias=True)
    (3): ReLU()
    (4): Linear(in_features=4096, out_features=3, bias=True)
  )
)

手动搭建 VGG-16 的完整结构解析:

输入图片:[3, 224, 224]
↓
block1(卷积块1)
  ├─ Conv2d(3→64, 3×3, padding=1) + ReLU    输出: [64, 224, 224]
  ├─ Conv2d(64→64, 3×3, padding=1) + ReLU   输出: [64, 224, 224]
  └─ MaxPool2d(2×2, stride=2)               输出: [64, 112, 112]
↓
block2(卷积块2)
  ├─ Conv2d(64→128, 3×3, padding=1) + ReLU   输出: [128, 112, 112]
  ├─ Conv2d(128→128, 3×3, padding=1) + ReLU  输出: [128, 112, 112]
  └─ MaxPool2d(2×2, stride=2)               输出: [128, 56, 56]
↓
block3(卷积块3)
  ├─ Conv2d(128→256, 3×3, padding=1) + ReLU  输出: [256, 56, 56]
  ├─ Conv2d(256→256, 3×3, padding=1) + ReLU  输出: [256, 56, 56]
  ├─ Conv2d(256→256, 3×3, padding=1) + ReLU  输出: [256, 56, 56]
  └─ MaxPool2d(2×2, stride=2)               输出: [256, 28, 28]
↓
block4(卷积块4)
  ├─ Conv2d(256→512, 3×3, padding=1) + ReLU  输出: [512, 28, 28]
  ├─ Conv2d(512→512, 3×3, padding=1) + ReLU  输出: [512, 28, 28]
  ├─ Conv2d(512→512, 3×3, padding=1) + ReLU  输出: [512, 28, 28]
  └─ MaxPool2d(2×2, stride=2)               输出: [512, 14, 14]
↓
block5(卷积块5)
  ├─ Conv2d(512→512, 3×3, padding=1) + ReLU  输出: [512, 14, 14]
  ├─ Conv2d(512→512, 3×3, padding=1) + ReLU  输出: [512, 14, 14]
  ├─ Conv2d(512→512, 3×3, padding=1) + ReLU  输出: [512, 14, 14]
  └─ MaxPool2d(2×2, stride=2)               输出: [512, 7, 7]
↓
Flatten: 512 × 7 × 7 = 25088
↓
classifier(分类器)
  ├─ Linear(25088, 4096) + ReLU
  ├─ Linear(4096, 4096) + ReLU
  └─ Linear(4096, 3)                        输出: 3 个类别分数

这段代码就是本周最核心的内容。与前几周的小 CNN 相比,它最大的区别是:

  1. nn.Sequential() 把每个卷积块打包起来;
  2. 每个卷积块中包含多个 Conv2d + ReLU
  3. 每个卷积块最后接一个 MaxPool2d
  4. 五个卷积块之后使用 torch.flatten() 展平;
  5. 分类器部分使用三个全连接层;
  6. 最后一层输出维度是 3,对应 3 类马铃薯叶片。

3. 五个卷积块的作用

卷积块 1:从 RGB 图像提取低级特征

self.block1 = nn.Sequential(
    nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1),
    nn.ReLU(),
    nn.Conv2d(64, 64, kernel_size=3, stride=1, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2, stride=2)
)

输入是 RGB 图片,所以第一层卷积的输入通道数是 3。输出通道数变成 64,表示模型开始提取 64 组不同的低级图像特征,例如边缘、颜色变化、叶片纹理等。
因为卷积层使用了 padding=1,所以每次 3×3 卷积后,图像的高和宽保持不变。最后经过 MaxPool2d(2,2) 后,图像尺寸减半。

[3, 224, 224] → [64, 224, 224] → [64, 112, 112]

卷积块 2:提取更复杂的纹理特征

self.block2 = nn.Sequential(
    nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),
    nn.ReLU(),
    nn.Conv2d(128, 128, kernel_size=3, stride=1, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2, stride=2)
)

第二个卷积块把通道数从 64 增加到 128,说明模型开始学习更丰富的图像特征。对于马铃薯病害识别来说,这些特征可能包括叶片病斑的颜色、形状、纹理分布等。

[64, 112, 112] → [128, 112, 112] → [128, 56, 56]

卷积块 3:进一步增加通道数

第三个卷积块有 3 个卷积层,通道数从 128 增加到 256

[128, 56, 56] → [256, 56, 56] → [256, 28, 28]

这一层之后,特征图的空间尺寸已经从原来的 224 × 224 缩小到了 28 × 28,但是通道数变多了,说明模型用更多特征图来表示更抽象的图像信息。


卷积块 4:提取高级语义特征

第四个卷积块通道数从 256 增加到 512

[256, 28, 28] → [512, 28, 28] → [512, 14, 14]

到这一层时,模型已经不只是识别简单边缘和纹理,而是在组合更高级的病害特征,例如病斑区域、叶片整体状态等。


卷积块 5:输出最终特征图

第五个卷积块保持 512 个通道,经过最后一次池化后,输出特征图大小变成:

[512, 14, 14] → [512, 7, 7]

这就是为什么后面的全连接层输入写成:

nn.Linear(512*7*7, 4096)

因为:

512 × 7 × 7 = 25088

4. VGG-16 网络 shape 变化推导

本周输入图片被统一 resize 成:[3, 224, 224]。VGG-16 使用 3×3 卷积核,padding=1,这种设计使得每经过一层卷积,图片的高和宽保持不变,只有池化层会让高和宽减半。

卷积层输出尺寸公式

输出尺寸 = floor((输入尺寸 + 2 × padding - kernel_size) / stride + 1)

VGG-16 的卷积层参数:

kernel_size = 3
stride = 1
padding = 1

所以公式变为:

输出尺寸 = floor((输入尺寸 + 2 × 1 - 3) / 1 + 1) = 输入尺寸

也就是说,每经过一个 3×3padding=1 的卷积层,高和宽不变

池化层输出尺寸公式

VGG-16 使用 MaxPool2d(2, 2),每经过一次池化,高和宽大约变成原来的一半。

完整 shape 推导

操作 输出 shape
输入 - [3, 224, 224]
block1 卷积1 Conv2d(3→64, 3×3, padding=1) [64, 224, 224]
block1 卷积2 Conv2d(64→64, 3×3, padding=1) [64, 224, 224]
block1 池化 MaxPool2d(2, 2) [64, 112, 112]
block2 卷积1 Conv2d(64→128, 3×3, padding=1) [128, 112, 112]
block2 卷积2 Conv2d(128→128, 3×3, padding=1) [128, 112, 112]
block2 池化 MaxPool2d(2, 2) [128, 56, 56]
block3 卷积1 Conv2d(128→256, 3×3, padding=1) [256, 56, 56]
block3 卷积2 Conv2d(256→256, 3×3, padding=1) [256, 56, 56]
block3 卷积3 Conv2d(256→256, 3×3, padding=1) [256, 56, 56]
block3 池化 MaxPool2d(2, 2) [256, 28, 28]
block4 卷积1 Conv2d(256→512, 3×3, padding=1) [512, 28, 28]
block4 卷积2 Conv2d(512→512, 3×3, padding=1) [512, 28, 28]
block4 卷积3 Conv2d(512→512, 3×3, padding=1) [512, 28, 28]
block4 池化 MaxPool2d(2, 2) [512, 14, 14]
block5 卷积1 Conv2d(512→512, 3×3, padding=1) [512, 14, 14]
block5 卷积2 Conv2d(512→512, 3×3, padding=1) [512, 14, 14]
block5 卷积3 Conv2d(512→512, 3×3, padding=1) [512, 14, 14]
block5 池化 MaxPool2d(2, 2) [512, 7, 7]
Flatten - 512 × 7 × 7 = 25088
全连接1 Linear(25088, 4096) [4096]
全连接2 Linear(4096, 4096) [4096]
全连接3 Linear(4096, 3) [3]

VGG-16 使用 padding=13×3 卷积核,每次卷积后尺寸不变,只有池化层会将尺寸减半。经过 5 次池化后,224 → 112 → 56 → 28 → 14 → 7,最终特征图尺寸为 512 × 7 × 7,展平后得到 25088 维向量。


4. 查看模型详情

# 统计模型参数量以及其他指标
import torchsummary as summary
summary.summary(model, (3, 224, 224))
----------------------------------------------------------------
        Layer (type)               Output Shape         Param #
================================================================
            Conv2d-1         [-1, 64, 224, 224]           1,792
              ReLU-2         [-1, 64, 224, 224]               0
            Conv2d-3         [-1, 64, 224, 224]          36,928
              ReLU-4         [-1, 64, 224, 224]               0
         MaxPool2d-5         [-1, 64, 112, 112]               0
            Conv2d-6        [-1, 128, 112, 112]          73,856
              ReLU-7        [-1, 128, 112, 112]               0
            Conv2d-8        [-1, 128, 112, 112]         147,584
              ReLU-9        [-1, 128, 112, 112]               0
        MaxPool2d-10          [-1, 128, 56, 56]               0
           Conv2d-11          [-1, 256, 56, 56]         295,168
             ReLU-12          [-1, 256, 56, 56]               0
           Conv2d-13          [-1, 256, 56, 56]         590,080
             ReLU-14          [-1, 256, 56, 56]               0
           Conv2d-15          [-1, 256, 56, 56]         590,080
             ReLU-16          [-1, 256, 56, 56]               0
        MaxPool2d-17          [-1, 256, 28, 28]               0
           Conv2d-18          [-1, 512, 28, 28]       1,180,160
             ReLU-19          [-1, 512, 28, 28]               0
           Conv2d-20          [-1, 512, 28, 28]       2,359,808
             ReLU-21          [-1, 512, 28, 28]               0
           Conv2d-22          [-1, 512, 28, 28]       2,359,808
             ReLU-23          [-1, 512, 28, 28]               0
        MaxPool2d-24          [-1, 512, 14, 14]               0
           Conv2d-25          [-1, 512, 14, 14]       2,359,808
             ReLU-26          [-1, 512, 14, 14]               0
           Conv2d-27          [-1, 512, 14, 14]       2,359,808
             ReLU-28          [-1, 512, 14, 14]               0
           Conv2d-29          [-1, 512, 14, 14]       2,359,808
             ReLU-30          [-1, 512, 14, 14]               0
        MaxPool2d-31            [-1, 512, 7, 7]               0
           Linear-32                 [-1, 4096]     102,764,544
             ReLU-33                 [-1, 4096]               0
           Linear-34                 [-1, 4096]      16,781,312
             ReLU-35                 [-1, 4096]               0
           Linear-36                    [-1, 3]          12,291
================================================================
Total params: 134,272,835
Trainable params: 134,272,835
Non-trainable params: 0
----------------------------------------------------------------
Input size (MB): 0.57
Forward/backward pass size (MB): 218.52
Params size (MB): 512.21
Estimated Total Size (MB): 731.30
----------------------------------------------------------------

手动搭建的 VGG-16 模型参数量分析

手动搭建的 VGG-16 总参数量为 134,272,835,全部为可训练参数。

模块 参数量 说明
block1(2 个卷积层) 38,720 Conv2d(3→64) + Conv2d(64→64)
block2(2 个卷积层) 221,440 Conv2d(64→128) + Conv2d(128→128)
block3(3 个卷积层) 1,475,328 3 × Conv2d(256→256)
block4(3 个卷积层) 7,079,424 3 × Conv2d(512→512)
block5(3 个卷积层) 7,079,424 3 × Conv2d(512→512)
classifier(3 个全连接层) 102,764,544 + 16,781,312 + 12,291 Linear(25088→4096→4096→3)
合计 134,272,835

VGG-16 参数量大,主要不是因为卷积层,而是因为全连接层非常大,VGG-16 的绝大部分参数(约 90%)都集中在全连接层,尤其是第一个全连接层 Linear(25088, 4096) 就有约 1.03 亿参数。


三、调用官方的 VGG-16 模型

除了手动搭建 VGG-16,本周还学习了如何调用 PyTorch 官方的 VGG-16 预训练模型。这部分内容与 P6 周的人脸识别任务基本一致。

1. 加载并微调官方 VGG-16

from torchvision.models import vgg16

device = "cuda" if torch.cuda.is_available() else "cpu"
print("Using {} device".format(device))

# 加载预训练模型
model_official = vgg16(pretrained=True).to(device)

# 冻结模型的参数
for param in model_official.parameters():
    param.requires_grad = False

# 修改最后一层全连接层,输出目标类别个数(3类)
model_official.classifier._modules['6'] = nn.Linear(4096, len(classeNames))
model_official.to(device)

这段代码的核心步骤解析:

步骤 1:加载预训练模型

vgg16(pretrained=True):加载在 ImageNet 数据集上预训练好的 VGG-16 模型。

步骤 2:冻结模型参数

param.requires_grad = False冻结所有参数,在训练时不计算这些参数的梯度,也不更新这些参数。这样做的好处是保持预训练模型学到的特征提取能力不变,只训练新替换的分类层。

步骤 3:修改最后一层全连接层

VGG-16 原来的最后一层是 Linear(4096, 1000),输出 1000 个类别(ImageNet 有 1000 类)。我们的任务只有 3 个类别,所以需要把最后一层替换为 Linear(4096, 3)


2. 手动搭建 vs 官方 VGG-16 对比

对比项 手动搭建 VGG-16 官方预训练 VGG-16
模型来源 torchvision.models 自己写 class vgg16(nn.Module)
是否有预训练权重 有,可以加载 ImageNet 权重 没有,从零开始训练
参数冻结 可以冻结特征提取层 默认全部参数参与训练
权重来源 随机初始化 ImageNet 预训练权重
参数量 134,272,835 134,272,835(结构相同)
可训练参数 全部(134,272,835) 仅最后一层(12,291)
训练速度 慢(需要训练全部参数) 快(只训练最后一层)
数据需求量
适用场景 数据充足、任务差异大 数据较少、任务与ImageNet相似
特征提取能力 从头学习 直接继承预训练特征

手动搭建和官方 VGG-16 的网络结构完全相同,区别在于权重是否经过预训练。


四、训练模型

1. 编写训练函数

# 训练循环
def train(dataloader, model, loss_fn, optimizer):
    size = len(dataloader.dataset)  # 训练集的大小
    num_batches = len(dataloader)   # 批次数目

    train_loss, train_acc = 0, 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()  # grad属性归零
        loss.backward()        # 反向传播
        optimizer.step()       # 每一步自动更新

        # 记录acc与loss
        train_acc  += (pred.argmax(1) == y).type(torch.float).sum().item()
        train_loss += loss.item()

    train_acc  /= size
    train_loss /= num_batches

    return train_acc, train_loss

训练函数的核心仍然是三步:

第一步:前向传播

pred = model(X)
loss = loss_fn(pred, y)

模型根据输入图片 X 得到预测结果 pred,再用 loss_fn 计算预测结果和真实标签 y 之间的差距。

第二步:反向传播

optimizer.zero_grad()
loss.backward()

optimizer.zero_grad() 用来清空上一轮梯度,因为 PyTorch 中梯度默认会累加。loss.backward() 根据当前损失值计算梯度。

第三步:更新参数

optimizer.step()

优化器根据梯度更新模型参数。本周由于是手动搭建 VGG-16,所以所有参数都会被更新。


2. 编写测试函数

def test(dataloader, model, loss_fn):
    size        = len(dataloader.dataset)  # 测试集的大小
    num_batches = len(dataloader)          # 批次数目
    test_loss, test_acc = 0, 0

    # 当不进行训练时,停止梯度更新,节省计算内存消耗
    with torch.no_grad():
        for imgs, target in dataloader:
            imgs, target = imgs.to(device), target.to(device)

            # 计算loss
            target_pred = model(imgs)
            loss        = loss_fn(target_pred, target)

            test_loss += loss.item()
            test_acc  += (target_pred.argmax(1) == target).type(torch.float).sum().item()

    test_acc  /= size
    test_loss /= num_batches

    return test_acc, test_loss

测试函数和训练函数很像,但是有两个关键区别:

  1. 测试时不调用 optimizer.step(),所以不会更新模型参数;
  2. 测试时使用 torch.no_grad(),关闭梯度计算,节省内存和计算量。

另外,在正式训练循环中要注意:

model.train()

表示进入训练模式;

model.eval()

表示进入评估模式。

即使本周模型没有 Dropout 和 BatchNorm,养成这个习惯仍然非常重要,因为很多模型在训练模式和测试模式下行为不同。


3. 设置损失函数和优化器(本周重点:Adam 优化器)

optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
loss_fn   = nn.CrossEntropyLoss()

CrossEntropyLoss() 是多分类任务中最常用的损失函数。本周任务是 3 分类,所以非常适合使用它。
需要注意的是,CrossEntropyLoss() 的输入是模型输出的原始分数,不需要手动先做 softmax。PyTorch 会在损失函数内部完成相关计算。

Adam 优化器介绍:

Adam(Adaptive Moment Estimation)是一种自适应学习率的优化算法,结合了动量梯度下降和 RMSprop 的优点。与前几周使用的 SGD 相比,Adam 有以下特点:

对比项 SGD Adam
学习率调整 固定或手动调整 自适应调整每个参数的学习率
收敛速度 较慢 通常更快
超参数 需要调学习率、动量 默认参数通常效果就好
适用场景 大规模数据、简单模型 复杂模型、深度网络
内存需求 稍高(需要保存一阶和二阶动量)

为什么 VGG-16 使用 Adam 而不是 SGD?

VGG-16 是一个参数量非常大的深度网络(约 1.34 亿参数),使用 Adam 优化器可以:

  1. 自适应学习率:不同参数使用不同的学习率,收敛更稳定;
  2. 加速收敛:在训练初期就能快速下降损失;
  3. 减少调参:默认参数通常就能取得不错的效果。

教程中提到:如果将优化器换成 SGD 会发生一些"诡异事件",这是因为 SGD 对学习率非常敏感,在 VGG-16 这种深层网络中,固定学习率的 SGD 很难收敛。


4. 正式训练并保存最佳模型

import copy

epochs     = 40

train_loss = []
train_acc  = []
test_loss  = []
test_acc   = []

best_acc = 0    # 设置一个最佳准确率,作为最佳模型的判别指标

for epoch in range(epochs):

    model.train()
    epoch_train_acc, epoch_train_loss = train(train_dl, model, loss_fn, optimizer)

    model.eval()
    epoch_test_acc, epoch_test_loss = test(test_dl, model, loss_fn)

    # 保存最佳模型到 best_model
    if epoch_test_acc > best_acc:
        best_acc   = epoch_test_acc
        best_model = copy.deepcopy(model)

    train_acc.append(epoch_train_acc)
    train_loss.append(epoch_train_loss)
    test_acc.append(epoch_test_acc)
    test_loss.append(epoch_test_loss)

    # 获取当前的学习率
    lr = optimizer.state_dict()['param_groups'][0]['lr']

    template = ('Epoch:{:2d}, Train_acc:{:.1f}%, Train_loss:{:.3f}, Test_acc:{:.1f}%, Test_loss:{:.3f}, Lr:{:.2E}')
    print(template.format(epoch+1, epoch_train_acc*100, epoch_train_loss,
                          epoch_test_acc*100, epoch_test_loss, lr))

# 保存最佳模型到文件中
PATH = './best_model.pth'
torch.save(model.state_dict(), PATH)

print('Done')
Epoch: 1, Train_acc:45.1%, Train_loss:0.925, Test_acc:45.9%, Test_loss:0.900, Lr:1.00E-04
Epoch: 2, Train_acc:46.4%, Train_loss:0.907, Test_acc:47.3%, Test_loss:0.891, Lr:1.00E-04
Epoch: 3, Train_acc:46.5%, Train_loss:0.907, Test_acc:49.7%, Test_loss:0.910, Lr:1.00E-04
Epoch: 4, Train_acc:75.2%, Train_loss:0.597, Test_acc:83.1%, Test_loss:0.475, Lr:1.00E-04
Epoch: 5, Train_acc:83.4%, Train_loss:0.474, Test_acc:87.0%, Test_loss:0.407, Lr:1.00E-04
Epoch: 6, Train_acc:85.0%, Train_loss:0.426, Test_acc:87.5%, Test_loss:0.375, Lr:1.00E-04
Epoch: 7, Train_acc:86.1%, Train_loss:0.378, Test_acc:90.0%, Test_loss:0.285, Lr:1.00E-04
Epoch: 8, Train_acc:88.2%, Train_loss:0.304, Test_acc:86.8%, Test_loss:0.334, Lr:1.00E-04
Epoch: 9, Train_acc:90.4%, Train_loss:0.239, Test_acc:88.4%, Test_loss:0.320, Lr:1.00E-04
Epoch:10, Train_acc:91.3%, Train_loss:0.224, Test_acc:90.3%, Test_loss:0.238, Lr:1.00E-04
Epoch:11, Train_acc:92.0%, Train_loss:0.208, Test_acc:87.7%, Test_loss:0.285, Lr:1.00E-04
Epoch:12, Train_acc:94.9%, Train_loss:0.145, Test_acc:96.8%, Test_loss:0.095, Lr:1.00E-04
Epoch:13, Train_acc:96.0%, Train_loss:0.100, Test_acc:95.6%, Test_loss:0.122, Lr:1.00E-04
Epoch:14, Train_acc:96.4%, Train_loss:0.100, Test_acc:96.8%, Test_loss:0.092, Lr:1.00E-04
Epoch:15, Train_acc:97.2%, Train_loss:0.082, Test_acc:97.9%, Test_loss:0.053, Lr:1.00E-04
Epoch:16, Train_acc:97.4%, Train_loss:0.063, Test_acc:96.8%, Test_loss:0.089, Lr:1.00E-04
Epoch:17, Train_acc:98.0%, Train_loss:0.059, Test_acc:96.8%, Test_loss:0.112, Lr:1.00E-04
Epoch:18, Train_acc:98.0%, Train_loss:0.048, Test_acc:97.2%, Test_loss:0.060, Lr:1.00E-04
Epoch:19, Train_acc:98.0%, Train_loss:0.048, Test_acc:96.8%, Test_loss:0.103, Lr:1.00E-04
Epoch:20, Train_acc:95.0%, Train_loss:0.167, Test_acc:96.3%, Test_loss:0.091, Lr:1.00E-04
Epoch:21, Train_acc:98.1%, Train_loss:0.048, Test_acc:97.7%, Test_loss:0.057, Lr:1.00E-04
Epoch:22, Train_acc:99.2%, Train_loss:0.025, Test_acc:98.6%, Test_loss:0.046, Lr:1.00E-04
Epoch:23, Train_acc:98.9%, Train_loss:0.039, Test_acc:97.2%, Test_loss:0.092, Lr:1.00E-04
Epoch:24, Train_acc:98.5%, Train_loss:0.035, Test_acc:98.6%, Test_loss:0.044, Lr:1.00E-04
Epoch:25, Train_acc:99.4%, Train_loss:0.017, Test_acc:97.4%, Test_loss:0.127, Lr:1.00E-04
Epoch:26, Train_acc:96.8%, Train_loss:0.092, Test_acc:97.9%, Test_loss:0.048, Lr:1.00E-04
Epoch:27, Train_acc:99.4%, Train_loss:0.015, Test_acc:98.4%, Test_loss:0.043, Lr:1.00E-04
Epoch:28, Train_acc:99.4%, Train_loss:0.014, Test_acc:97.7%, Test_loss:0.054, Lr:1.00E-04
Epoch:29, Train_acc:99.4%, Train_loss:0.013, Test_acc:97.9%, Test_loss:0.079, Lr:1.00E-04
Epoch:30, Train_acc:100.0%, Train_loss:0.002, Test_acc:98.6%, Test_loss:0.054, Lr:1.00E-04
Epoch:31, Train_acc:99.3%, Train_loss:0.024, Test_acc:90.5%, Test_loss:0.639, Lr:1.00E-04
Epoch:32, Train_acc:97.2%, Train_loss:0.094, Test_acc:98.8%, Test_loss:0.029, Lr:1.00E-04
Epoch:33, Train_acc:99.7%, Train_loss:0.009, Test_acc:95.6%, Test_loss:0.162, Lr:1.00E-04
Epoch:34, Train_acc:98.0%, Train_loss:0.067, Test_acc:97.9%, Test_loss:0.065, Lr:1.00E-04
Epoch:35, Train_acc:99.2%, Train_loss:0.025, Test_acc:97.7%, Test_loss:0.069, Lr:1.00E-04
Epoch:36, Train_acc:99.7%, Train_loss:0.010, Test_acc:99.1%, Test_loss:0.042, Lr:1.00E-04
Epoch:37, Train_acc:99.9%, Train_loss:0.003, Test_acc:97.7%, Test_loss:0.127, Lr:1.00E-04
Epoch:38, Train_acc:99.9%, Train_loss:0.004, Test_acc:97.2%, Test_loss:0.085, Lr:1.00E-04
Epoch:39, Train_acc:99.4%, Train_loss:0.021, Test_acc:97.2%, Test_loss:0.094, Lr:1.00E-04
Epoch:40, Train_acc:98.5%, Train_loss:0.052, Test_acc:99.1%, Test_loss:0.041, Lr:1.00E-04
Done

训练结果分析

  1. 初始阶段(Epoch 1-3):训练准确率约 45-46%,接近随机猜测水平(3 类问题的随机概率为 33.3%),说明模型还在初步探索;
  2. 关键突破(Epoch 4):训练准确率从 46.5% 突然跃升到 75.2%,测试准确率更是跳到 83.1%,这是模型学习的关键转折点,说明 VGG-16 的深层结构在第 4 个 epoch 开始有效提取到叶片病害的特征;
  3. 快速收敛(Epoch 5-15):训练准确率从 83.4% 稳步提升到 97.2%,测试准确率也同步上升到 97.9%(Epoch 15),损失值持续下降;
  4. 高平台期(Epoch 16-30):训练准确率达到 98-100%,测试准确率在 96-98.6% 之间波动,Epoch 30 训练准确率达到 100%
  5. 异常波动与过拟合现象
    • Epoch 31:训练准确率 99.3%,但测试准确率突然暴跌到 90.5%,测试损失飙升到 0.639,这是一个典型的过拟合信号——模型在训练集上表现很好,但泛化能力下降;
    • Epoch 33:训练准确率 99.7%,测试准确率再次跌到 95.6%,测试损失 0.162;
    • 这些波动说明模型在训练后期开始"记住"训练数据,而不是学习通用特征;
  6. 恢复与稳定(Epoch 32-40):经过波动后,模型逐步恢复,最终 Epoch 40 测试准确率达到 99.1%,这是整个训练过程中的最佳测试准确率之一(与 Epoch 36 持平);
  7. 最佳模型:由于使用了 best_acc 机制保存最佳模型,所以即使最后几个 epoch 有波动,保存的仍然是测试准确率最高的模型(99.1%);
  8. 学习率:使用 Adam 优化器,学习率保持固定 1e-4,整体收敛效果良好,但后期的波动提示可能需要引入学习率衰减或早停(Early Stopping)机制来抑制过拟合。

五、结果可视化

1. Loss 与 Accuracy 图

import matplotlib.pyplot as plt
#隐藏警告
import warnings
warnings.filterwarnings("ignore")               #忽略警告信息
plt.rcParams['font.sans-serif']    = ['SimHei'] #用来正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False      #用来正常显示负号
plt.rcParams['figure.dpi']         = 100        #分辨率

from datetime import datetime
current_time = datetime.now() #获取当前时间

epochs_range = range(epochs)

plt.figure(figsize=(12, 3))
plt.subplot(1, 2, 1)

plt.plot(epochs_range, train_acc, label='Training Accuracy')
plt.plot(epochs_range, test_acc, label='Test Accuracy')
plt.legend(loc='lower right')
plt.title('Training and Validation Accuracy')
plt.xlabel(current_time) #打卡请带上时间戳,否则代码截图无效

plt.subplot(1, 2, 2)
plt.plot(epochs_range, train_loss, label='Training Loss')
plt.plot(epochs_range, test_loss, label='Test Loss')
plt.legend(loc='upper right')
plt.title('Training and Validation Loss')
plt.show()

这是我的结果
在这里插入图片描述

可视化结果分析

  • 训练准确率:前 3 个 epoch 几乎平缓(45-46%),Epoch 4 出现断崖式跃升到 75%,之后快速上升,约 15 个 epoch 后达到 97% 以上,Epoch 30 达到 100%;
  • 测试准确率:整体趋势与训练准确率一致,但在 Epoch 31 出现明显暴跌到 90.5%(过拟合现象),Epoch 33 也下降到 95.6%,最终恢复到 99.1%
  • 训练损失:前 3 个 epoch 缓慢下降,Epoch 4 后快速下降,Epoch 30 后接近于 0;
  • 测试损失:整体呈下降趋势,但在 Epoch 31 飙升到 0.639(对应准确率暴跌),之后回落,最终稳定在 0.041;
  • 过拟合分析:Epoch 31 和 Epoch 33 的测试准确率/损失出现明显波动,说明模型在训练后期存在一定程度的过拟合。这是因为 VGG-16 参数量巨大(约 1.34 亿),在只有 2152 张图片的数据集上容易记住训练样本。使用 best_acc 保存最佳模型的机制有效避免了保存过拟合的模型。
    从训练结果图可以看出,模型在前几个 epoch 中准确率快速上升,说明 VGG-16 能够较快提取马铃薯叶片病害图像中的有效特征。随着训练进行,训练集和测试集准确率逐渐接近并稳定在较高水平,后期基本达到 98% 以上,说明模型具有较好的分类效果。Loss 曲线整体呈下降趋势,训练损失和测试损失都逐渐降低,说明模型在不断收敛。虽然测试损失在第 30 个 epoch 附近出现一次明显波动,但之后又恢复到较低水平,可能是由于少数测试样本被模型高置信度误分类造成的。总体来看,本次训练效果较好,没有明显严重过拟合现象。

2. 指定图片进行预测

from PIL import Image

classes = list(total_data.class_to_idx)

def predict_one_image(image_path, model, transform, classes):

    test_img = Image.open(image_path).convert('RGB')
    plt.imshow(test_img)  # 展示预测的图片

    test_img = transform(test_img)
    img = test_img.to(device).unsqueeze(0)

    model.eval()
    output = model(img)

    _, pred = torch.max(output, 1)
    pred_class = classes[pred]
    print(f'预测结果是:{pred_class}')
# 预测训练集中的某张照片
predict_one_image(image_path='./PotatoPlants/Early_blight/1.JPG',
                  model=model,
                  transform=train_transforms,
                  classes=classes)
预测结果是:Early_blight

在这里插入图片描述

这段代码的意思是

  1. Image.open(image_path).convert('RGB'):用 PIL 打开图片,并转换为 RGB 三通道模式;
  2. transform(test_img):对图片进行和训练时完全相同的预处理(Resize + ToTensor + Normalize);
  3. test_img.to(device).unsqueeze(0):把图片移动到和模型相同的设备,并在第 0 维增加一个 batch 维度;
  4. model.eval():切换到预测模式;
  5. torch.max(output, 1):找到输出中概率最大的类别的索引;
  6. 根据索引从 classes 列表中取出类别名称。

3. 模型评估

best_model.eval()
epoch_test_acc, epoch_test_loss = test(test_dl, best_model, loss_fn)
epoch_test_acc, epoch_test_loss
(0.9907192575406032, 0.04223222262953641)

模型评估结果分析

最终模型在测试集上的准确率为 0.9907,即约 99.07%,测试损失为 0.0422。说明模型能够较准确地区分马铃薯早期疫病、晚期疫病和健康叶片三类图像,且预测结果较为稳定。从训练曲线来看,训练集和测试集准确率均保持在较高水平,loss 整体呈下降趋势,说明模型已经基本收敛,并具有较好的泛化能力。


六、总结

本周最重要的收获

  1. 手动搭建 VGG-16:逐层实现了完整的 VGG-16 网络,包括 5 个卷积块(共 13 个卷积层 + 5 个池化层)和 3 个全连接层。通过亲手搭建,真正理解了 VGG-16 "使用小卷积核(3×3)、增加网络深度"的核心设计思想。

  2. VGG-16 的 shape 推导:掌握了 VGG-16 中 padding=1 的卷积不改变尺寸、只有池化层将尺寸减半的规律。从输入 [3, 224, 224] 到最终展平 512 × 7 × 7 = 25088,每一步的变化都清晰明了。

  3. Adam 优化器的使用:本周改用 Adam 优化器替代了前几周的 SGD。Adam 的自适应学习率特性使得 VGG-16 这种深层大参数网络能够快速稳定地收敛,固定学习率 1e-4 就能达到很好的效果。

  4. 调用官方 VGG-16 预训练模型:在手动搭建之外,也掌握了调用 torchvision.models.vgg16(pretrained=True) 进行迁移学习的方法,理解了冻结参数和微调最后一层的技巧。

  5. 模型参数量分析:手动搭建的 VGG-16 总参数量为 134,272,835(约 1.34 亿),其中绝大部分(约 1.03 亿)集中在第一个全连接层。这是 VGG-16 的主要缺点——参数过多、模型偏大。

  6. 高准确率达成:最终测试准确率达到了 99.1%(Epoch 36 和 Epoch 40 均达到),超过了教程的基本要求,接近拔高目标(100%)。虽然在训练过程中出现了 Epoch 31(测试准确率跌到 90.5%)和 Epoch 33(测试准确率 95.6%)的过拟合波动,但通过 best_acc 机制保存了最佳模型,最终效果非常理想。

  7. 过拟合现象的观察:训练后期(Epoch 30 之后)出现了明显的过拟合波动,这是 VGG-16 这种大参数模型在小数据集上的典型表现。这提示在实际应用中,可以考虑引入 Dropout、数据增强、早停(Early Stopping)或学习率衰减等策略来进一步抑制过拟合。

P6 周与 P7 周核心对比

对比项 P6 周(人脸识别) P7 周(马铃薯病害)
VGG-16 来源 调用官方预训练模型 手动搭建 + 调用官方
优化器 SGD Adam
类别数 17 类 3 类
最终测试准确率 17.2% 99.1%
训练难度 难(类别多、数据少) 相对容易
核心收获 迁移学习、调用官方模型 深入理解 VGG-16 架构

通过七周的学习,我已经从"零基础跑通 MNIST"到"手动搭建 VGG-16 实现复杂图像分类",完成了一个完整的学习路径。接下来可以尝试轻量化 VGG-16(如使用更少的通道数)、尝试其他经典网络架构(如 ResNet、Inception 等),或者将模型部署到实际应用中。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐