前言

本篇是我训练营的第六次学习,主要目标是使用 PyTorch 调用官方的 VGG-16 网络框架实现人脸识别任务。P1 周跑通了 MNIST 手写数字识别、P2 周理解了 CIFAR10 彩色图片和 CNN 的 shape 变化、P3 周掌握了本地自定义数据集的加载和 BatchNorm、P4 周学会了保存最佳模型和单张图片预测、P5 周学习了动态学习率和 Dropout 正则化,本周的任务是——从手动搭建 CNN 网络转向调用经典的预训练模型,并在其基础上进行微调,以实现更复杂的图像分类任务。

本周的人脸识别任务一共有 17 个类别,分别是 17 位好莱坞明星:
Angelina JolieBrad PittDenzel WashingtonHugh JackmanJennifer LawrenceJohnny DeppKate WinsletLeonardo DiCaprioMegan FoxNatalie PortmanNicole KidmanRobert Downey JrSandra BullockScarlett JohanssonTom CruiseTom HanksWill Smith

本周的核心学习目标是:

  1. 理解迁移学习的基本思想——利用预训练模型加速训练;
  2. 学会调用 PyTorch 官方的 VGG-16 模型
  3. 掌握冻结模型参数微调最后一层的技巧;
  4. 理解保存最佳模型权重的机制;
  5. 使用官方动态学习率接口进行学习率调度。

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

P1-P6 周整体对比

对比项目 P1 周:MNIST P2 周:CIFAR10 P3 周:天气识别 P4 周:猴痘识别 P5 周:运动鞋识别 P6 周:VGG-16 人脸识别
图像类型 灰度图 RGB 彩色图 RGB 彩色图 RGB 彩色图 RGB 彩色图 RGB 彩色图
输入 shape [32, 1, 28, 28] [32, 3, 32, 32] [32, 3, 224, 224] [32, 3, 224, 224] [32, 3, 224, 224] [32, 3, 224, 224]
类别数 10 类 10 类 4 类 2 类 2 类 17 类
网络来源 手动搭建 CNN 手动搭建 CNN 手动搭建 CNN 手动搭建 CNN 手动搭建 CNN 调用官方 VGG-16 预训练模型
网络深度 2 卷积+2 池化 3 卷积+3 池化 4 卷积+2 池化 4 卷积+2 池化 4 卷积+2 池化 13 卷积+5 池化+3 全连接
正则化手段 BatchNorm BatchNorm BatchNorm+Dropout 预训练权重 + Dropout
学习率 固定 1e-2 固定 1e-2 固定 1e-4 固定 1e-4 动态学习率 动态学习率(官方接口)
特殊功能 基础流程 shape 推导 本地数据加载 保存最佳模型+预测 动态学习率+模型保存加载 迁移学习 + 微调预训练模型

一、准备工作

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

和前面几周一样,首先判断当前设备是否支持 GPU,如果支持就使用 CUDA 加速,否则使用 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')

2. 关于人脸识别数据集

本周使用的是人脸识别数据集,包含 17 位好莱坞明星的人脸图片,需要自己下载并放在本地 6-data/ 文件夹下。文件夹结构如下:

6-data/
├── Angelina Jolie/          # 安吉丽娜·朱莉
├── Brad Pitt/               # 布拉德·皮特
├── Denzel Washington/       # 丹泽尔·华盛顿
├── Hugh Jackman/            # 休·杰克曼
├── Jennifer Lawrence/       # 詹妮弗·劳伦斯
├── Johnny Depp/             # 约翰尼·德普
├── Kate Winslet/            # 凯特·温斯莱特
├── Leonardo DiCaprio/       # 莱昂纳多·迪卡普里奥
├── Megan Fox/               # 梅根·福克斯
├── Natalie Portman/         # 娜塔莉·波特曼
├── Nicole Kidman/           # 妮可·基德曼
├── Robert Downey Jr/        # 小罗伯特·唐尼
├── Sandra Bullock/          # 桑德拉·布洛克
├── Scarlett Johansson/      # 斯嘉丽·约翰逊
├── Tom Cruise/              # 汤姆·克鲁斯
├── Tom Hanks/               # 汤姆·汉克斯
└── Will Smith/              # 威尔·史密斯

数据集共包含 17 个类别,1800 张图片。


3. 导入本地数据集

首先使用 pathlib.Path 读取本地数据文件夹,并提取类别名称。

import os, PIL, random, pathlib

data_dir = './6-data/'
data_dir = pathlib.Path(data_dir)

data_paths  = list(data_dir.glob('*'))
classeNames = [str(path).split("\\")[1] for path in data_paths]
classeNames
['Angelina Jolie',
 'Brad Pitt',
 'Denzel Washington',
 'Hugh Jackman',
 'Jennifer Lawrence',
 'Johnny Depp',
 'Kate Winslet',
 'Leonardo DiCaprio',
 'Megan Fox',
 'Natalie Portman',
 'Nicole Kidman',
 'Robert Downey Jr',
 'Sandra Bullock',
 'Scarlett Johansson',
 'Tom Cruise',
 'Tom Hanks',
 'Will Smith']

这一段的意思是:

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

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] 从数据集中随机抽样计算得到的。
])

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

transforms.Resize([224, 224]) 将不同大小的原始图片统一 resize 成 224 × 224 像素。这是 VGG-16 模型要求的标准输入尺寸。

transforms.ToTensor() 将 PIL Image 或 numpy.ndarray 格式的图片转换为 PyTorch 的 Tensor 格式,同时把像素值从 0-255 缩放到 0-1 之间。

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

5. 使用 ImageFolder 自动生成标签

total_data.class_to_idx
{'Angelina Jolie': 0,
 'Brad Pitt': 1,
 'Denzel Washington': 2,
 'Hugh Jackman': 3,
 'Jennifer Lawrence': 4,
 'Johnny Depp': 5,
 'Kate Winslet': 6,
 'Leonardo DiCaprio': 7,
 'Megan Fox': 8,
 'Natalie Portman': 9,
 'Nicole Kidman': 10,
 'Robert Downey Jr': 11,
 'Sandra Bullock': 12,
 'Scarlett Johansson': 13,
 'Tom Cruise': 14,
 'Tom Hanks': 15,
 'Will Smith': 16}

ImageFolder 会根据文件夹名称自动分配标签,total_data.class_to_idx 是一个存储了数据集类别和对应索引的字典。17 个类别分别对应索引 016


6. 划分训练集和测试集

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

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 0x1f7f209bfb0>,
 <torch.utils.data.dataset.Subset at 0x1f7fef33aa0>)

这一段的意思是

  • train_size = int(0.8 * len(total_data)):训练集大小为总数据量的 80%。总数据量是 1800,所以训练集大小为 int(0.8 × 1800) = 1440
  • test_size = len(total_data) - train_size:测试集大小为剩余的 20%,即 1800 - 1440 = 360
  • torch.utils.data.random_split(total_data, [train_size, test_size]):将数据集随机打乱后,按照 [1440, 360] 的比例划分为训练集和测试集。

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]   ← 本周

可以看出,P6 周和 P3、P4、P5 周在输入图片尺寸上完全一样,都是 224 × 224 的 RGB 彩色图,这是使用 VGG-16 预训练模型的标准输入尺寸。但本周的类别数从 2 类或 4 类变成了 17 类,是一个更复杂的多分类问题。


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

1. 什么是 VGG-16?

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

VGG-16 的主要特点:

  1. 深度:VGG-16 由 16 个卷积层和 3 个全连接层组成,因此具有相对较深的网络结构。这种深度有助于网络学习到更加抽象和复杂的特征。
  2. 卷积层的设计:VGG-16 的卷积层全部采用 3×3 的卷积核和步长为 1 的卷积操作,同时在卷积层之后都接有 ReLU 激活函数。这种设计的好处在于,通过堆叠多个较小的卷积核,可以提高网络的非线性建模能力,同时减少了参数数量,从而降低了过拟合的风险。
  3. 池化层:在卷积层之后,VGG-16 使用最大池化层来减少特征图的空间尺寸,帮助提取更加显著的特征并减少计算量。
  4. 全连接层:VGG-16 在卷积层之后接有 3 个全连接层,最后一个全连接层输出与类别数相对应的向量,用于进行分类。

VGG-16 结构说明:

  • 13 个卷积层(Convolutional Layer),分别用 blockX_convX 表示;
  • 3 个全连接层(Fully connected Layer),用 classifier 表示;
  • 5 个池化层(Pool layer)。

VGG-16 包含了 16 个隐藏层(13 个卷积层和 3 全连接层),故称为 VGG-16。

与前几周手动搭建的 CNN 对比

对比项目 P1-P5 周手动 CNN VGG-16 预训练模型
卷积层数 2-4 层 13 层
池化层数 2-3 层 5 层
全连接层数 1-2 层 3 层
总参数量 约 12 万 - 25 万 约 1.38 亿
训练方式 从头训练 ImageNet 预训练 + 微调
特征提取能力 较弱(数据量小) 很强(预训练过)

VGG-16 的总参数量约为 1.38 亿,远超前几周手动搭建的 CNN。如此庞大的模型从头训练需要大量的数据和计算资源,因此在实际应用中通常采用迁移学习的方式。


2. 迁移学习的基本思想

迁移学习(Transfer Learning) 是深度学习中一种非常重要的技术。其核心思想是:

利用在大规模数据集(如 ImageNet)上预训练好的模型,将其学到的通用特征迁移到新的任务上,从而在新任务上获得更好的性能,同时大幅减少训练时间和数据需求。

为什么迁移学习有效?

  1. 底层特征通用:CNN 的浅层学到的特征(边缘、纹理、颜色等)是通用的,适用于各种图像任务;
  2. 数据效率高:新任务的数据量通常较小,从头训练深层网络容易过拟合;
  3. 训练速度快:只需要微调部分参数,不需要从头训练整个网络。

迁移学习的两种主要方式

方式 做法 适用场景
特征提取 冻结预训练模型的所有参数,只训练新加的分类层 新任务数据集很小,且与预训练数据相似
微调 冻结大部分参数,解冻最后几层进行训练 新任务数据集较大,或需要更精确的特征

本周采用的是特征提取的方式:冻结 VGG-16 模型的所有卷积层参数,只替换并训练最后一层全连接层。


3. 加载并微调 VGG-16 模型

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
Using cpu device
Downloading: "https://download.pytorch.org/models/vgg16-397923af.pth" to C:\Users\zenji/.cache\torch\hub\checkpoints\vgg16-397923af.pth
100.0%

VGG(
  (features): Sequential(
    (0): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (1): ReLU(inplace=True)
    (2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (3): ReLU(inplace=True)
    (4): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (5): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (6): ReLU(inplace=True)
    (7): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (8): ReLU(inplace=True)
    (9): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (10): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (11): ReLU(inplace=True)
    (12): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (13): ReLU(inplace=True)
    (14): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (15): ReLU(inplace=True)
    (16): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (17): Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (18): ReLU(inplace=True)
    (19): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (20): ReLU(inplace=True)
    (21): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (22): ReLU(inplace=True)
    (23): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
    (24): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (25): ReLU(inplace=True)
    (26): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (27): ReLU(inplace=True)
    (28): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
    (29): ReLU(inplace=True)
    (30): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
  )
  (avgpool): AdaptiveAvgPool2d(output_size=(7, 7))
  (classifier): Sequential(
    (0): Linear(in_features=25088, out_features=4096, bias=True)
    (1): ReLU(inplace=True)
    (2): Dropout(p=0.5, inplace=False)
    (3): Linear(in_features=4096, out_features=4096, bias=True)
    (4): ReLU(inplace=True)
    (5): Dropout(p=0.5, inplace=False)
    (6): Linear(in_features=4096, out_features=17, bias=True)
  )
)

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

步骤 1:加载预训练模型

model = vgg16(pretrained = True).to(device)
  • vgg16(pretrained=True):加载在 ImageNet 数据集上预训练好的 VGG-16 模型;
  • .to(device):将模型放到 GPU 或 CPU 上。

步骤 2:冻结模型参数

for param in model.parameters():
    param.requires_grad = False
  • param.requires_grad = False冻结所有参数,在训练时不计算这些参数的梯度,也不更新这些参数;
  • 这样做的好处是:保持预训练模型学到的特征提取能力不变,只训练新替换的分类层;
  • 训练速度大幅提升:因为大部分参数不需要更新,反向传播的计算量大大减少。

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

model.classifier._modules['6'] = nn.Linear(4096, len(classeNames))
  • VGG-16 原来的最后一层是 Linear(4096, 1000),输出 1000 个类别(ImageNet 有 1000 类);
  • 我们的任务只有 17 个类别,所以需要把最后一层替换为 Linear(4096, 17)
  • len(classeNames) 等于 17;
  • model.classifier._modules['6'] 是 VGG-16 分类器部分的第 6 个子模块(也就是最后一层全连接层)。

VGG-16 的完整结构解析

输入图片:[3, 224, 224]
↓
features(特征提取部分)
  ├─ Conv2d(3→64) + ReLU
  ├─ Conv2d(64→64) + ReLU
  ├─ MaxPool2d                    ← 112×112
  ├─ Conv2d(64→128) + ReLU
  ├─ Conv2d(128→128) + ReLU
  ├─ MaxPool2d                    ← 56×56
  ├─ Conv2d(128→256) + ReLU
  ├─ Conv2d(256→256) + ReLU
  ├─ Conv2d(256→256) + ReLU
  ├─ MaxPool2d                    ← 28×28
  ├─ Conv2d(256→512) + ReLU
  ├─ Conv2d(512→512) + ReLU
  ├─ Conv2d(512→512) + ReLU
  ├─ MaxPool2d                    ← 14×14
  ├─ Conv2d(512→512) + ReLU
  ├─ Conv2d(512→512) + ReLU
  ├─ Conv2d(512→512) + ReLU
  └─ MaxPool2d                    ← 7×7
↓
avgpool: AdaptiveAvgPool2d(output_size=(7, 7))
↓
Flatten: 512 × 7 × 7 = 25088
↓
classifier(分类器部分)
  ├─ Linear(25088, 4096) + ReLU + Dropout(0.5)
  ├─ Linear(4096, 4096) + ReLU + Dropout(0.5)
  └─ Linear(4096, 17)             ← 修改为17类(原来是1000类)

三、训练模型

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

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

第一步:optimizer.zero_grad() 清空梯度

PyTorch 中梯度默认会累加,所以每个 batch 开始训练前,需要先把上一轮的梯度清空。

第二步:loss.backward() 反向传播

根据当前损失值,自动计算每个参数的梯度。注意:由于我们冻结了大部分参数(requires_grad=False),所以只有最后一层全连接层的参数会计算梯度。

第三步:optimizer.step() 更新参数

优化器根据梯度更新模型参数。因为只有最后一层的 requires_grad=True,所以实际上只更新了最后一层的参数


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

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

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

3. 设置动态学习率(本周重点)

动态学习率在 P5 周已经学习过,本周使用的是官方动态学习率接口

自定义动态学习率函数(P5 周的方式)

# def adjust_learning_rate(optimizer, epoch, start_lr):
#     # 每 2 个epoch衰减到原来的 0.98
#     lr = start_lr * (0.92 ** (epoch // 2))
#     for param_group in optimizer.param_groups:
#         param_group['lr'] = lr

learn_rate = 1e-4 # 初始学习率
# optimizer  = torch.optim.SGD(model.parameters(), lr=learn_rate)

调用官方动态学习率接口(本周使用的方式)

learn_rate = 1e-4

lambda1 = lambda epoch: 0.92 ** (epoch // 4)

optimizer = torch.optim.SGD(
    model.parameters(), 
    lr=learn_rate
)

scheduler = torch.optim.lr_scheduler.LambdaLR(
    optimizer,
    lr_lambda=lambda1
)

这段代码的意思是:

  1. lambda1 = lambda epoch: 0.92 ** (epoch // 4):定义一个学习率衰减函数,每 4 个 epoch,学习率衰减为原来的 0.92 倍;
  2. torch.optim.SGD(model.parameters(), lr=learn_rate):创建 SGD 优化器;
  3. torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lambda1):使用 PyTorch 官方的 LambdaLR 学习率调度器,按照 lambda1 函数的规则自动调整学习率。

P5 周自定义函数 vs P6 周官方接口对比

对比项 P5 周自定义函数 P6 周官方接口
实现方式 手动编写函数,每个 epoch 调用 使用 lr_scheduler.LambdaLR
更新方式 在训练循环中手动修改 param_group['lr'] 调用 scheduler.step() 自动更新
衰减周期 每 2 个 epoch 每 4 个 epoch
衰减系数 0.92 0.92
复杂度 需要手动管理 更简洁,推荐方式

官方接口示例代码

model = [torch.nn.Parameter(torch.randn(2, 2, requires_grad=True))]
optimizer = SGD(model, 0.1)
scheduler = ExponentialLR(optimizer, gamma=0.9)

for epoch in range(20):
    for input, target in dataset:
        optimizer.zero_grad()
        output = model(input)
        loss = loss_fn(output, target)
        loss.backward()
        optimizer.step()
    scheduler.step()

更多的官方动态学习率设置方式可参考:https://pytorch.org/docs/stable/optim.html


4. 正式训练(含保存最佳模型)

import copy

loss_fn    = nn.CrossEntropyLoss() # 创建损失函数
epochs     = 40

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

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

for epoch in range(epochs):
    # 更新学习率(使用自定义学习率时使用)
    # adjust_learning_rate(optimizer, epoch, learn_rate)
    
    model.train()
    epoch_train_acc, epoch_train_loss = train(train_dl, model, loss_fn, optimizer)
    scheduler.step() # 更新学习率(调用官方动态学习率接口时使用)
    
    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(best_model.state_dict(), PATH)

print('Done')
Epoch: 1, Train_acc:5.1%,Train_loss:2.943,Test_acc:7.8%,Test_loss:2.850,L人:1.00E-Epoch: 2, Train_acc:5.1%,Train_loss:2.907,Test_acc:9.2%,Test_loss:2.806,L人:1.00-04

Epoch: 3, Train_acc:6.5%,Train_loss:2.875,Test_acc:10.8%,Test_loss:2.780,L人:1.00E-
Epoch: 4, Train_acc:9.7%,Train_loss:2.823,Test_acc:12.5%,Test_loss:2.748,r:9.20E-05
Epoch: 5, Train_acc:9.2%,Train_loss:2.797,Test_acc:16.1%,Test_loss:2.733,L人:9.20E-
Epoch: 6, Train_acc:9.7%,Train_loss:2.799,est_acc:16.4%,Test_loss:2.713,L人:9.20E-
Epoch: 7, Train_acc:10.9%,Train_loss:2.756,est_acc:18.6%,Test_loss:2.697,L人:9.20E-
Epoch: 8, Train_acc:13.8%,Train_loss:2.739,Test_acc:19.4%,Test_loss:2.672,L人:8.46-05

Epoch: 9, Train_acc:13.1%,Train_loss:2.716,Test_acc:20.3%,Test_loss:2.644,L人:8。46E-
Epoch:10, Train_acc:15.1%,Train_loss:2.712,Test_acc:19.7%,Test_loss:2.625,L人:8.46E-
Epoch:11, Train_acc:14.9%,Train_loss:2.672,Test_acc:19.4%,Test_loss:2.626,r:8.46E-05Epoch:12, Train_acc:14.7%,Train_loss:2.667,est_acc:19.4%,Test_loss:2.619,L人:7.79E-
Epoch:13, Train_acc:15.4%,Train_loss:2.651,Test_acc:19.4%,Test_loss:2.598,L人:7.79E-
Epoch:14, Train_acc:14.7%,Train_loss:2.646,Test_acc:20.0%,Test_loss:2.597,L人:7.79E-
Epoch:15, Train_acc:15.9%,Train_loss:2.643,Test_acc:19.7%,Test_loss:2.569,L人:7.79E-
Epoch:16, Train_acc:14.8%,Train_loss:2.628,Test_acc:19.7%,Test_loss:2.569,L人:7.16E-
Epoch:17, Train_acc:15.3%,Train_loss:2.607,Test_acc:19.7%,Test_loss:2.539,L人:7.16E-
Epoch:18, Train_acc:16.5%,Train_loss:2.607,Test_acc:19.7%,Test_loss:2.543,L人:7.16E-
Epoch:19, Train_acc:16.3%,Train_loss:2.596,Test_acc:19.7%,Test_loss:2.544,L人:7.16E-
Epoch:20, Train_acc:16.9%,Train_loss:2.568,Test_acc:20.3%,Test_loss:2.529,r:6.59E-05
Epoch:21, Train_acc:16.3%,Train_loss:2.589,Test_acc:20.3%,Test_loss:2.510,L人:6.59E-
Epoch:22, Train_acc:16.7%,Train_loss:2.565,Test_acc:20.3%,Test_loss:2.507,L人:6.59E-
Epoch:23, Train_acc:17.6%,Train_loss:2.548,Test_acc:20.3%,Test_loss:2.506,L人:6.59E-
Epoch:24, Train_acc:17.4%,Train_loss:2.546,Test_acc:20.3%,Test_loss:2.495,L人:6.06E-
Epoch:25, Train_acc:17.6%,Train_loss:2.533,Test_acc:20.3%,Test_loss:2.475,L人:6.06E-
Epoch:26, Train_acc:16.9%,Train_loss:2.529,Test_acc:20.3%,Test_loss:2.478,L人:6.06E-
Epoch:27, Train_acc:18.0%,Train_loss:2.515,Test_acc:20.3%,Test_loss:2.489,L人:6.06E-
Epoch:28, Train_acc:19.1%,Train_loss:2.507,Test_acc:20.0%,Test_loss:2.464,L人:5.58E-
Epoch:29, Train_acc:19.2%,Train_loss:2.503,Test_acc:20.0%,Test_loss:2.479,L人:5.58E-
Epoch:30, Train_acc:19.8%,Train_loss:2.488,Test_acc:20.0%,Test_loss:2.464,L人:5.58E-
Epoch:31, Train_acc:18.5%,Train_loss:2.490,Test_acc:20.3%,Test_loss:2.452,L人:5.58E-
Epoch:32, Train_acc:19.2%,Train_loss:2.466,Test_acc:20.3%,Test_loss:2.462,L人:5.13E-
Epoch:33, Train_acc:19.2%,Train_loss:2.467,Test_acc:20.6%,Test_loss:2.445,L人:5.13E-
Epoch:34, Train_acc:20.4%,Train_loss:2.465,Test_acc:20.6%,Test_loss:2.437,L人:5.13E-
Epoch:35, Train_acc:19.4%,Train_loss:2.484,Test_acc:20.6%,Test_loss:2.440,L人:5.13E-
Epoch:36, Train_acc:18.7%,Train_loss:2.461,Test_acc:20.6%,Test_loss:2.418,L人:4.72E-
Epoch:37, Train_acc:17.3%,Train_loss:2.470,Test_acc:20.6%,Test_loss:2.418,L人:4.72E-
Epoch:38, Train_acc:19.5%,Train_loss:2.451,Test_acc:21.1%,Test_loss:2.416,L人:4.72E-
Epoch:39, Train_acc:18.5%,Train_loss:2.445,Test_acc:20.6%,Test_loss:2.403,L人:4.72E-
Epoch:40, Train_acc:18.7%,Train_loss:2.443,Test_acc:20.6%,Test_loss:2.413,L人:4.34E-
Done


训练过程中关键新增部分的解析

(1)保存最佳模型到内存

best_acc = 0

if epoch_test_acc > best_acc:
    best_acc   = epoch_test_acc
    best_model = copy.deepcopy(model)
  • best_acc:记录当前达到的最高测试准确率;
  • copy.deepcopy(model):深拷贝当前模型,保存到 best_model 变量中;
  • 只有当当前 epoch 的测试准确率超过历史最高值时,才更新最佳模型。

为什么要用 copy.deepcopy()

如果直接写 best_model = model,那么 best_modelmodel 指向的是同一个对象。当 model 继续训练更新参数时,best_model 的参数也会跟着变,这样就失去了"保存最佳模型"的意义。copy.deepcopy() 会创建一个完全独立的副本,后续训练不会影响它。

(2)保存最佳模型到文件

PATH = './best_model.pth'
torch.save(best_model.state_dict(), PATH)
  • torch.save():将模型的状态字典保存到指定路径;
  • best_model.state_dict():获取模型的参数状态(不包括模型结构);
  • 保存的文件是 .pth 格式,这是 PyTorch 模型的标准保存格式。

PyTorch 保存模型的两种方式对比

方式 代码 保存内容 适用场景
保存状态字典(推荐) torch.save(model.state_dict(), PATH) 仅参数 通用推荐,文件小,跨平台
保存整个模型 torch.save(model, PATH) 参数+结构 快速保存,但可能兼容性差

(3)TensorFlow vs PyTorch 保存最佳模型的异同

对比项 TensorFlow PyTorch
保存方式 model.save_weights() / callbacks.ModelCheckpoint torch.save() + copy.deepcopy()
回调机制 内置 ModelCheckpoint 回调 需手动在训练循环中实现
保存格式 .h5 / .ckpt .pth / .pt
灵活性 回调自动处理 更灵活,可自定义保存逻辑

四、结果可视化

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='./6-data/Angelina Jolie/001_fe3347c0.jpg', 
                  model=model, 
                  transform=train_transforms, 
                  classes=classes)
预测结果是 Megan Fox

在这里插入图片描述

这段代码的意思是

  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 维度。因为模型输入要求是 [N, C, H, W],单张图片预处理后是 [C, H, W],所以需要用 unsqueeze(0) 变成 [1, C, H, W]
  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.17222222222222222, 2.457642674446106)
# 查看是否与我们记录的最高准确率一致
epoch_test_acc
0.17222222222222222

模型评估结果分析

  • 使用保存的最佳模型在测试集上进行评估,准确率为 17.2%
  • 这个准确率略高于随机猜测(17 个类别随机猜的概率约为 5.9%),说明模型学到了一些特征,但效果还不够好;
  • 为什么 VGG-16 预训练模型的效果不够好?
    1. 只训练了最后一层:冻结了所有卷积层参数,只训练了最后一层全连接层,特征提取能力没有针对人脸任务进行优化;
    2. 数据量不足:1800 张图片对于 17 个类别来说,每个类别平均只有约 100 张,数据量偏小;
    3. 学习率可能不够合适:需要进一步调整学习率和衰减策略;
    4. 可以尝试微调更多层:解冻 VGG-16 的最后几个卷积层,让它们也参与训练。

达到 60% 准确率的优化方向

  1. 解冻更多层进行微调:不冻结所有参数,只冻结前面的卷积层,解冻后面的卷积层;
  2. 增加数据增强:使用随机翻转、裁剪、旋转等数据增强手段;
  3. 调整学习率:尝试更大的初始学习率或不同的衰减策略;
  4. 增加训练轮数:可能需要更多 epoch 才能收敛;
  5. 手动搭建 VGG-16:从头训练一个更适合本任务的 VGG-16 变体。

总结

本周学习的是 PyTorch 入门第 P6 周:调用官方 VGG-16 模型实现人脸识别。这是从前几周"手动搭建 CNN"到"使用预训练模型"的重要转折点,也是实际深度学习项目中最常用的技术路线之一。

本周最重要的收获

  1. 迁移学习的理解:明白了为什么要用预训练模型——底层特征通用、数据效率高、训练速度快。VGG-16 在 ImageNet 上训练好的 1.38 亿参数中,大部分卷积层的特征提取能力可以直接迁移到新任务上。

  2. VGG-16 模型结构:深入理解了 VGG-16 的完整结构——13 个卷积层 + 5 个池化层 + 3 个全连接层。卷积层全部采用 3×3 卷积核,通过堆叠增加深度,这是 VGG 系列的核心设计思想。

  3. 冻结参数与微调:学会了使用 param.requires_grad = False 冻结模型参数,只训练最后一层全连接层。这是迁移学习中最常用的策略之一,可以在小数据集上快速获得不错的效果。

  4. 修改预训练模型的输出层:掌握了如何替换 VGG-16 最后一层全连接层,使其适应新任务的类别数。通过 model.classifier._modules['6'] = nn.Linear(4096, 17) 完成替换。

  5. 保存最佳模型:学会了在训练过程中使用 copy.deepcopy() 保存最佳模型,并在训练结束后将其保存到 .pth 文件中。这是模型管理的重要技能。

  6. 官方动态学习率接口:从 P5 周的自定义函数方式升级为使用 torch.optim.lr_scheduler.LambdaLR 官方接口,代码更简洁规范。

  7. 17 类人脸识别的挑战:本周任务比前几周更复杂——17 个类别、只训练最后一层、数据量有限。虽然基础代码的准确率还有很大提升空间,但理解了迁移学习的基本框架和优化方向。

通过六周的学习,我已经从"零基础跑通 MNIST"到"调用 VGG-16 预训练模型进行复杂图像分类",完成了一个完整的学习路径。接下来可以尝试解冻更多层进行微调、手动搭建 VGG-16 网络、尝试其他预训练模型(如 ResNet、EfficientNet 等),进一步提升模型性能。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐