学习目的:

  1. 自己搭建VGG-16网络框架
  2. 调用官方的VGG-16网络框架
  3. 如何查看模型的参数量以及相关指标

一、 前期准备

关于环境

  • 语言环境: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.conv13self.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%,几乎可以忽略不计。

六、感想

本周继续学习调用预训练模型,进一步熟悉了关于模型架构的搭建。对比上周的搭建结果,很明显对于模型架构的理解还有待加强,基础知识相对掌握不熟练。同时也进一步学习了对优化器的选择。关于模型轻量化仅简单了解,有待深入学习。

Logo

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

更多推荐