Pytorch入门P7周学习打卡:马铃薯病害识别(VGG-16复现)
- 👉 声明:本文为学习记录性文章,参考「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']
这一段的意思是:
pathlib.Path(data_dir):把字符串路径./PotatoPlants/转换成 Path 对象;- 使用
glob('*')获取data_dir路径下的所有子文件夹路径; - 通过
split("\\")对每条路径进行分割,提取出文件夹名称(即类别名称),存入classeNames列表中; - 每一个子文件夹名称就是一个类别;
- 最终得到 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 进行标准化,可以让我们的数据分布与预训练模型的训练数据分布保持一致,这是迁移学习中非常重要的一步。
具体数值:
- 计算均值(Mean):
- Red 通道均值 ≈ 0.485
- Green 通道均值 ≈ 0.456
- Blue 通道均值 ≈ 0.406
- 计算标准差(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 对应索引 0,Late_blight 对应索引 1,healthy 对应索引 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 的主要特点:
- 深度:VGG-16 由 16 个隐藏层组成(13 个卷积层和 3 个全连接层),因此具有较深的网络结构;
- 小卷积核:全部采用
3×3的卷积核和步长为 1 的卷积操作,通过堆叠多个小卷积核来提高非线性建模能力; - 池化层:使用
2×2最大池化层来减少特征图的空间尺寸; - 全连接层:最后接 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 相比,它最大的区别是:
- 用
nn.Sequential()把每个卷积块打包起来; - 每个卷积块中包含多个
Conv2d + ReLU; - 每个卷积块最后接一个
MaxPool2d; - 五个卷积块之后使用
torch.flatten()展平; - 分类器部分使用三个全连接层;
- 最后一层输出维度是
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×3 且 padding=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=1 的 3×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
测试函数和训练函数很像,但是有两个关键区别:
- 测试时不调用
optimizer.step(),所以不会更新模型参数; - 测试时使用
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 优化器可以:
- 自适应学习率:不同参数使用不同的学习率,收敛更稳定;
- 加速收敛:在训练初期就能快速下降损失;
- 减少调参:默认参数通常就能取得不错的效果。
教程中提到:如果将优化器换成 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
训练结果分析:
- 初始阶段(Epoch 1-3):训练准确率约 45-46%,接近随机猜测水平(3 类问题的随机概率为 33.3%),说明模型还在初步探索;
- 关键突破(Epoch 4):训练准确率从 46.5% 突然跃升到 75.2%,测试准确率更是跳到 83.1%,这是模型学习的关键转折点,说明 VGG-16 的深层结构在第 4 个 epoch 开始有效提取到叶片病害的特征;
- 快速收敛(Epoch 5-15):训练准确率从 83.4% 稳步提升到 97.2%,测试准确率也同步上升到 97.9%(Epoch 15),损失值持续下降;
- 高平台期(Epoch 16-30):训练准确率达到 98-100%,测试准确率在 96-98.6% 之间波动,Epoch 30 训练准确率达到 100%;
- 异常波动与过拟合现象:
- Epoch 31:训练准确率 99.3%,但测试准确率突然暴跌到 90.5%,测试损失飙升到 0.639,这是一个典型的过拟合信号——模型在训练集上表现很好,但泛化能力下降;
- Epoch 33:训练准确率 99.7%,测试准确率再次跌到 95.6%,测试损失 0.162;
- 这些波动说明模型在训练后期开始"记住"训练数据,而不是学习通用特征;
- 恢复与稳定(Epoch 32-40):经过波动后,模型逐步恢复,最终 Epoch 40 测试准确率达到 99.1%,这是整个训练过程中的最佳测试准确率之一(与 Epoch 36 持平);
- 最佳模型:由于使用了
best_acc机制保存最佳模型,所以即使最后几个 epoch 有波动,保存的仍然是测试准确率最高的模型(99.1%); - 学习率:使用 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

这段代码的意思是:
Image.open(image_path).convert('RGB'):用 PIL 打开图片,并转换为 RGB 三通道模式;transform(test_img):对图片进行和训练时完全相同的预处理(Resize + ToTensor + Normalize);test_img.to(device).unsqueeze(0):把图片移动到和模型相同的设备,并在第 0 维增加一个 batch 维度;model.eval():切换到预测模式;torch.max(output, 1):找到输出中概率最大的类别的索引;- 根据索引从
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 整体呈下降趋势,说明模型已经基本收敛,并具有较好的泛化能力。
六、总结
本周最重要的收获:
-
手动搭建 VGG-16:逐层实现了完整的 VGG-16 网络,包括 5 个卷积块(共 13 个卷积层 + 5 个池化层)和 3 个全连接层。通过亲手搭建,真正理解了 VGG-16 "使用小卷积核(3×3)、增加网络深度"的核心设计思想。
-
VGG-16 的 shape 推导:掌握了 VGG-16 中
padding=1的卷积不改变尺寸、只有池化层将尺寸减半的规律。从输入[3, 224, 224]到最终展平512 × 7 × 7 = 25088,每一步的变化都清晰明了。 -
Adam 优化器的使用:本周改用 Adam 优化器替代了前几周的 SGD。Adam 的自适应学习率特性使得 VGG-16 这种深层大参数网络能够快速稳定地收敛,固定学习率
1e-4就能达到很好的效果。 -
调用官方 VGG-16 预训练模型:在手动搭建之外,也掌握了调用
torchvision.models.vgg16(pretrained=True)进行迁移学习的方法,理解了冻结参数和微调最后一层的技巧。 -
模型参数量分析:手动搭建的 VGG-16 总参数量为 134,272,835(约 1.34 亿),其中绝大部分(约 1.03 亿)集中在第一个全连接层。这是 VGG-16 的主要缺点——参数过多、模型偏大。
-
高准确率达成:最终测试准确率达到了 99.1%(Epoch 36 和 Epoch 40 均达到),超过了教程的基本要求,接近拔高目标(100%)。虽然在训练过程中出现了 Epoch 31(测试准确率跌到 90.5%)和 Epoch 33(测试准确率 95.6%)的过拟合波动,但通过
best_acc机制保存了最佳模型,最终效果非常理想。 -
过拟合现象的观察:训练后期(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 等),或者将模型部署到实际应用中。
更多推荐

所有评论(0)