P7:马铃薯病害识别(VGG-16复现)
- 🍨 本文为🔗365天深度学习训练营中的学习记录博客
- 🍖 原作者:K同学啊
学习目的:
- 自己搭建VGG-16网络框架
- 调用官方的VGG-16网络框架
- 如何查看模型的参数量以及相关指标
一、 前期准备
关于环境
- 语言环境:Python3.13
- 编译器:vsCode
- 深度学习环境:torch==2.11.0+cu130;torchvision==0.26.0+cu130torchvision==0.26.0+cu130
1.设置GPU
设置分析环境:
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
运行结果:
![]()
2. 导入数据
由于本次是本地数据,因此不需要有download代码,只需读取路径里的数据集即可
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
运行结果:
![]()
3.图片预处理
# 关于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
运行结果:

total_data.class_to_idx
代码解读:
total_data.class_to_idx是一个存储了数据集类别和对应索引的字典。在PyTorch的ImageFolder数据加载器中,根据数据集文件夹的组织结构,每个文件夹代表一个类别,class_to_idx字典将每个类别名称映射为一个数字索引。
运行结果:

4. 划分数据集
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
运行结果:

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)
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
运行结果:

查看一个批次的数据形状:
X是图像张量,形状为[32, 3, 224, 224](32 张,3 通道,高宽 224);y是标签,形状为[32],类型为torch.int64。
二、手动搭建VGG-16模型

不适合用来发表的框架图。。。
1.我的手动搭建:
import torch.nn.functional as F
class Model(nn.Module):
def __init__(self):
super(Model, self).__init__()
self.conv1=nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, padding=1), # 64*224*224
nn.ReLU(inplace=True))
self.conv2=nn.Sequential(
nn.Conv2d(64, 64, kernel_size=3, padding=1), # 64*224*224
nn.ReLU(inplace=True))
self.pool1=nn.Sequential(
nn.MaxPool2d(kernel_size=2, stride=2)) # 64*112*112
self.conv3=nn.Sequential(
nn.Conv2d(64, 128, kernel_size=3, padding=1), # 128*112*112
nn.ReLU(inplace=True))
self.conv4=nn.Sequential(
nn.Conv2d(128, 128, kernel_size=3, padding=1), # 128*112*112
nn.ReLU(inplace=True))
self.pool2=nn.Sequential(
nn.MaxPool2d(kernel_size=2, stride=2)) # 128*56*56
self.conv5=nn.Sequential(
nn.Conv2d(128, 256, kernel_size=3, padding=1), # 256*56*56
nn.ReLU(inplace=True))
self.conv6=nn.Sequential(
nn.Conv2d(256, 256, kernel_size=3, padding=1), # 256*56*56
nn.ReLU(inplace=True))
self.conv7=nn.Sequential(
nn.Conv2d(256, 256, kernel_size=3, padding=1), # 256*56*56
nn.ReLU(inplace=True))
self.pool3=nn.Sequential(
nn.MaxPool2d(kernel_size=2, stride=2)) # 256*28*28
self.conv8=nn.Sequential(
nn.Conv2d(256, 512, kernel_size=3, padding=1), # 512*28*28
nn.ReLU(inplace=True))
self.conv9=nn.Sequential(
nn.Conv2d(512, 512, kernel_size=3, padding=1), # 512*28*28
nn.ReLU(inplace=True))
self.conv10=nn.Sequential(
nn.Conv2d(512, 512, kernel_size=3, padding=1), # 512*28*28
nn.ReLU(inplace=True))
self.pool4=nn.Sequential(
nn.MaxPool2d(kernel_size=2, stride=2)) # 512*14*14
self.conv11=nn.Sequential(
nn.Conv2d(512, 512, kernel_size=3, padding=1), # 512*14*14
nn.ReLU(inplace=True))
self.conv12=nn.Sequential(
nn.Conv2d(512, 512, kernel_size=3, padding=1), # 512*14*14
nn.ReLU(inplace=True))
self.conv13=nn.Sequential(
nn.Conv2d(512, 512, kernel_size=3, padding=1), # 512*14*14
nn.ReLU(inplace=True))
self.pool5=nn.Sequential(
nn.MaxPool2d(kernel_size=2, stride=2)) # 512*7*7
self.avgpool = nn.AdaptiveAvgPool2d((7, 7))
self.classifier = nn.Sequential(
nn.Linear(512*7*7, 4096), # 输入是展平后的特征向量 (25088)
nn.ReLU(inplace=True),
nn.Dropout(p=0.5),
nn.Linear(4096, 4096),
nn.ReLU(inplace=True),
nn.Dropout(p=0.5),
nn.Linear(4096, 3), #此处为3个类别
)
def forward(self, x):
batch_size = x.size(0)
x = self.conv1(x) # 卷积-BN-激活
x = self.conv2(x) # 卷积-BN-激活
x = self.pool1(x) # 池化
x = self.conv3(x) # 卷积-BN-激活
x = self.conv4(x) # 卷积-BN-激活
x = self.pool2(x) # 池化
x = self.conv5(x) # 卷积-激活
x = self.conv6(x) # 卷积-激活
x = self.conv7(x) # 卷积-激活
x = self.pool3(x) # 池化
x = self.conv8(x) # 卷积-激活
x = self.conv9(x) # 卷积-激活
x = self.conv10(x) # 卷积-激活
x = self.pool4(x) # 池化
x = self.conv11(x) # 卷积-激活
x = self.conv12(x) # 卷积-激活
x = self.conv13(x) # 卷积-激活
x = self.pool5(x) # 池化
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.classifier(x)
return x
2.K同学手动搭建
import torch.nn.functional as F
class vgg16(nn.Module):
def __init__(self):
super(vgg16, self).__init__()
# 卷积块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
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
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
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
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)
)
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
网络层组织方式
我的为平铺结构:将每一层(Conv、ReLU、Pool)都单独定义为类属性(
self.conv1~self.conv13,self.pool1~self.pool5)。代码冗长,可读性较差,维护成本高。K同学为Block结构:将卷积层+激活+池化封装为 5 个
nn.Sequential块(block1~block5)。代码简洁、模块化程度高,便于迁移学习(如单独提取block4的特征)。前向传播(Forward)的写法
我:forward中逐层手动调用(self.conv1->self.conv2-> ... ->self.pool5)。虽然直观展示了数据流动,但极易写漏层,且在上周学习搭建中直接调用了未定义的fc,此函数无法执行。
K同学:forward中直接依次调用 5 个块,最后flatten后接分类器。简洁精炼。输入尺寸适应与池化策略
我:在pool5之后增加了一层nn.AdaptiveAvgPool2d((7, 7))。这使得网络不强制要求输入为 224x224,任意尺寸输入都会被自适应池化为 7x7,灵活性更高。
K同学:在Linear中输入特征数为512*7*7,硬编码假设输入图片必须为 224x224(经过 5 次下采样得到 7x7)。若输入尺寸改变,会直接报维度错误。激活函数的参数选择
- 我:所有
ReLU()均设置了inplace=True。这能节省显存(直接在原张量上修改),是工业界更常用的写法。- K同学:
ReLU()使用默认参数(inplace=False)。
运行结果:

3. 查看模型详情
# 统计模型参数量以及其他指标
import torchsummary as summary
summary.summary(model, (3, 224, 224))
运行结果:

三、调用官方模型
from torchvision.models import vgg16
device = "cuda" if torch.cuda.is_available() else "cpu"
print("Using {} device".format(device))
# 加载预训练模型,并且对模型进行微调
model = vgg16(pretrained = True).to(device) # 加载预训练的vgg16模型
for param in model.parameters():
param.requires_grad = False # 冻结模型的参数,这样子在训练的时候只训练最后一层的参数
# 修改classifier模块的第6层(即:(6): Linear(in_features=4096, out_features=2, bias=True))
# 注意查看我们下方打印出来的模型
model.classifier._modules['6'] = nn.Linear(4096,len(classeNames)) # 修改vgg16模型中最后一层全连接层,输出目标类别个数
model.to(device)
model
此处类比为3
四、训练模型
1. 编写训练函数
# 训练循环
def train(dataloader, model, loss_fn, optimizer):
size = len(dataloader.dataset) # 训练集的大小
num_batches = len(dataloader) # 批次数目, (size/batch_size,向上取整)
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) # 计算网络输出和真实值之间的差距,targets为真实值,计算二者差值即为损失
# 反向传播
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
2. 编写测试函数
测试函数和训练函数大致相同,但是由于不进行梯度下降对网络权重进行更新,所以不需要传入优化器
def test (dataloader, model, loss_fn):
size = len(dataloader.dataset) # 测试集的大小
num_batches = len(dataloader) # 批次数目, (size/batch_size,向上取整)
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
3. 正式训练
import copy
optimizer = torch.optim.Adam(model.parameters(), lr= 1e-4)
loss_fn = nn.CrossEntropyLoss() # 创建损失函数
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')
运行结果:

4.关于优化器选择
选用SGD运行结果:

显然,使用SGD后准确率卡在42.6%不再提升,为什么会这样呢?(deep seek)
1. 缺少动量(Momentum),陷入“鞍点”陷阱
VGG-16 拥有约 1.38 亿 参数,损失函数曲面极为崎岖。纯 SGD(无动量)每次更新只依赖当前 batch 的梯度,在遇到鞍点(梯度接近 0 但不是极小值)或平坦区域时,更新极其缓慢,甚至“原地踏步”。而 Adam 内置了一阶动量(类似惯性),能帮模型冲过平坦区域,继续下降。2. 全局学习率不匹配,梯度“旱的旱死,涝的涝死”
VGG-16 不同层的梯度幅值差异巨大(浅层卷积层梯度小,深层全连接层梯度大)。
Adam 对每个参数单独计算二阶动量(梯度平方的指数移动平均),自动为大梯度降低学习率,为小梯度提高学习率,非常“适配”VGG。
SGD 使用全局固定的学习率。如果设为
0.01,浅层可能几乎不更新;如果设为0.1,深层可能直接梯度爆炸。设错一步,准确率就卡在某个数值(如 30% 或 50%)无法动弹。3. 初始化与 Batch Size 的耦合效应
VGG-16 参数量巨大,纯 SGD 对初始化权重和Batch Size极其敏感。若 Batch Size 较小(如 32),梯度噪声大,SGD 会在局部极小值附近剧烈震荡,始终无法收敛;而 Adam 通过指数平滑(移动平均)天然降低了梯度噪声,所以能稳定下降。
四、 结果可视化
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()
运行结果:

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)
运行结果:

五、轻量化模型
1.替换全连接层为全局平均池化(GAP)
这是VGG-16轻量化最应该做的一步。VGG-16的3个全连接层(FC)占了整个模型约 90% 的参数(约1.2亿)。
-
做法:移除最后的
Flatten+FC(4096)+FC(4096)+FC(1000),改为GlobalAveragePooling2D()直接接FC(1000)。 -
效果:参数量瞬间从 1.38亿 降至 ~1500万。
-
精度影响:由于GAP相当于强制让每个特征图对应一个类别,不仅不掉点,反而能提升泛化能力(防止全连接层过拟合)。在ImageNet上原版VGG-16 Top-1准确率约71.5%,改为GAP后约71.2% (仅降0.3%,但体积缩小十倍)。
2.通道剪枝(Channel Pruning)—— 针对卷积层
移除3个FC后,剩下的2000万参数主要来自卷积层。VGG-16的通道数(64→128→256→512)存在大量冗余。
-
做法:在训练后,计算每个卷积核的 L1/L2范数(权重绝对值之和),剪掉贡献最小(范数最低)的20%~30%通道。然后在小学习率下微调(Fine-tune)3~5个epoch。
-
效果:FLOPs(计算量)减少约 30%~40%。
-
精度影响:微调后,精度通常能 恢复到原模型的98%~99%(即Top-1下降约0.5%~1%)。
3.SVD 矩阵分解 —— 专门针对全连接层(如果你坚持保留FC)
如果你不愿用GAP,仍想保留FC层,可以用 SVD(奇异值分解) 对 FC(4096) 的权重矩阵进行低秩近似。
-
做法:将
4096 x 25088的巨型矩阵拆解为两个小矩阵相乘:中间插入一个低秩层(如FC(1024)),将参数量从 1亿 压缩到 4000万。 -
精度影响:无需重新训练,直接分解后进行单精度微调,精度损失可控制在 0.2% 以内。
4.替换为深度可分离卷积(Depthwise Separable Conv)—— 针对卷积块
这是最激进的轻量化方式,将每个 3x3 Conv 替换为 深度卷积(DW)+ 逐点卷积(PW)。
-
做法:把
Conv3-64替换为DepthwiseConv2D(3x3)+Conv2D(1x1),计算量可减少约 8~9倍。 -
精度影响:如果从头训练,精度下降明显(约5%~8%)。但如果你先训练好原版VGG-16,然后进行“知识蒸馏”(用原版作为教师网络,指导学生网络),精度可维持在 71% 左右,几乎不掉点。
5.INT8 量化(纯推理阶段)—— 完全不影响模型结构
这是唯一能在“不改动任何网络结构”前提下,实现轻量化的方法。
-
做法:训练完成后,使用 TensorRT 或 PyTorch 的
torch.quantization将权重和激活从FP32转为INT8。 -
效果:模型体积缩小为原来的 1/4,推理速度提升 2~3倍(在CPU上尤为明显)。
-
精度影响:通过“校准(Calibration)”数据集,在ImageNet上INT8量化后,Top-1精度损失通常 < 0.5%,几乎可以忽略不计。
六、感想
本周继续学习调用预训练模型,进一步熟悉了关于模型架构的搭建。对比上周的搭建结果,很明显对于模型架构的理解还有待加强,基础知识相对掌握不熟练。同时也进一步学习了对优化器的选择。关于模型轻量化仅简单了解,有待深入学习。
更多推荐




所有评论(0)