news 2026/9/15 15:54:46

VGG19实战:从结构解析到PyTorch实现与调参全指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
VGG19实战:从结构解析到PyTorch实现与调参全指南

前一段时间整理自己深度学习系列的学习笔记,正好写到“深度学习12—VGG19实现”这一篇。VGG19这个模型,现在看起来已经不算最前沿,但它在视觉模型演进里的位置非常特殊:它是“深度”这个概念真正被做到极致的代表作,也是无数预训练模型、迁移学习流程、特征提取任务的默认起点。不管你是准备应付期末试题,还是跟着《动手学深度学习》在啃代码,又或者手头有个图像识别的小项目想找个靠谱 baseline,VGG19都非常值得花时间彻底吃透。

这篇文章我不打算只贴一段能跑通的代码完事。我会把 VGG19 从设计动机、逐层结构、PyTorch 实现、数据预处理,到训练调参、结果分析和常见坑,完整地过一遍。我自己在实现过程中踩过不少坑,比如小尺寸数据集上直接套预训练权重导致 loss 不降、显存溢出、特征图尺寸 mismatch 之类的,这些我都会写进对应的章节里。整个流程走下来,你对卷积神经网络的理解会比之前扎实一大截。

1. 内容整体设计与思路拆解

1.1 为什么到今天还要学 VGG19

很多初学者会有个疑问:现在 ResNet、Transformer 都烂大街了,Vision Transformer 都出了好几年,为什么还要回头学一个 2014 年的 VGG19?说实话,我也是抱着这个疑问开始看它的,等真正动手实现之后才明白,VGG19 在今天依然有用,而且用处非常大。

第一,VGG19 是无数下游任务的骨干网络。你随便打开一个图像检索、风格迁移、目标检测或者语义分割的经典项目,很大概率能看到 VGG 系列的影子。很多模型都直接用 ImageNet 上预训练好的 VGG19 作为特征提取器,不再重新训一个特征提取网络。这种“迁移学习”的玩法,在数据量不够多的场景下几乎是无脑选择。

第二,VGG19 的结构极其规整,非常适合用来建立卷积网络的“骨架感”。它的全部卷积层都用 3x3 卷积核,池化层固定用 2x2 最大池化,层与层之间排列方式高度一致。这种规整性让你一眼就能看懂每层在干什么,不会像 Inception 那样一上来就甩给你一堆并行分支,看得人头晕。把 VGG19 的结构啃明白,后续再看 ResNet、DenseNet 会轻松很多。

第三,VGG19 的训练流程和现代卷积网络的训练流程几乎完全一致。从数据增强到学习率调度,从权重初始化到 Batch Normalization 的引入,你能在这一个小项目里把所有基础功夫都练一遍,而这些技能换到任何其他模型上都照样用得上。

还有一点很实际:很多高校的深度学习期末试题里,VGG 系列的网络结构描述、参数计算、感受野推导都是高频考点。北京交通大学这类学校的期末题里就经常出现“请简要描述 VGG 网络的结构特点”这种题目。所以无论是为了看懂论文还是为了考试,VGG19 都是绕不开的一块基石。

1.2 从 AlexNet 到 VGG:到底改了什么

要说 VGG19 的设计思路,就得先回头看它的前辈 AlexNet。AlexNet 在 2012 年一举拿下 ImageNet 分类冠军,结构上是“大卷积核 + 分组卷积”的风格。它用了 11x11 和 5x5 的大卷积核,参数量很大,计算量也高得吓人。当时 GPU 性能有限,还得把网络切开放在两块显卡上并行训练。

VGG 团队的思路非常直接:既然大卷积核感受野大,那我能不能用一堆小卷积核堆出同样大的感受野,同时把参数总量压下来,把网络做得更深?他们最后选定的是全部使用 3x3 卷积核。这里有一个非常关键的计算:

  • 两层 3x3 卷积串联,感受野等效于一层 5x5 卷积;
  • 三层 3x3 卷积串联,感受野等效于一层 7x7 卷积。

先算参数。一个 3x3 卷积层,输入输出通道都是 C,那参数量是 C x 3 x 3 x C = 9C²。三个这样的层总参数量是 27C²。而一个 7x7 卷积层,参数量是 C x 7 x 7 x C = 49C²。差距非常明显,三层 3x3 比一层 7x7 少了接近一半的参数。

再算非线性。一层 7x7 卷积只经过一次 ReLU,而三层 3x3 卷积会经过三次 ReLU。非线性表达能力更强,网络就能拟合更复杂的特征映射关系。这其实就是后来“网络越深越好”的一个早期有力证据。

还有一个细节,VGG 的池化层比 AlexNet 温和得多。AlexNet 用 3x3 窗口、步长 2 的重叠池化,VGG 统一改成 2x2 窗口、步长 2,刚好让特征图宽高减半,不会出现滑窗重叠带来的信息冗余。

这套“小卷积核堆叠 + 简单池化”的设计,使得 VGG 成为那个年代最容易训练、最容易迁移的网络之一。结构上没有任何花哨的操作,你用任何深度学习框架都能轻松复现出来。

2. VGG19 核心网络结构拆解:每一层到底在干嘛

2.1 VGG19 的分块结构与尺寸变化

VGG 系列有两个常见版本,VGG16 和 VGG19。数字代表带权重的层数,也就是卷积层加全连接层的总数。VGG19 就是 16 个卷积层加上 3 个全连接层,总共 19 个带权重层。VGG16 则是 13 个卷积层加 3 个全连接层,区别在于后面几个卷积块里少堆了几层卷积。

VGG19 的完整结构,从前往后是这样排列的:

  • 卷积块 1:2 个卷积层,通道数 64
  • 最大池化
  • 卷积块 2:2 个卷积层,通道数 128
  • 最大池化
  • 卷积块 3:4 个卷积层,通道数 256
  • 最大池化
  • 卷积块 4:4 个卷积层,通道数 512
  • 最大池化
  • 卷积块 5:4 个卷积层,通道数 512
  • 最大池化
  • 全连接层 4096
  • 全连接层 4096
  • 全连接层 1000(ImageNet 类别数)

除了最后一个全连接层,其他所有卷积层和全连接层后面都带 ReLU 激活函数。

下面这个表展示了输入为 224x224 的 RGB 图像时,每一层之后特征图的尺寸变化情况。这个表非常实用,你写完代码后拿它来核对前向传播结果,能省掉很多 debug 时间。

层块操作输出尺寸 (H x W x C)说明
输入-224x224x3原始图像
conv1_x3x3, 64, 两层224x224x64padding=1 保持尺寸
pool12x2 maxpool112x112x64宽高减半
conv2_x3x3, 128, 两层112x112x128padding=1 保持尺寸
pool22x2 maxpool56x56x128宽高减半
conv3_x3x3, 256, 四层56x56x256padding=1 保持尺寸
pool32x2 maxpool28x28x256宽高减半
conv4_x3x3, 512, 四层28x28x512padding=1 保持尺寸
pool42x2 maxpool14x14x512宽高减半
conv5_x3x3, 512, 四层14x14x512padding=1 保持尺寸
pool52x2 maxpool7x7x512宽高减半
fc6全连接4096需要展平为 7x7x512
fc7全连接4096-
fc8全连接1000分类输出

你可以观察到,VGG19 的空间尺寸遵循一个规律:每经过一次池化,宽高减半;每换一个卷积块,通道数翻倍。从 224 一路减到 7,通道数从 64 一路涨到 512。这个规律太有用了,你自己设计网络时也完全可以照着这个模板来调整。

2.2 参数量分析:VGG19 到底有多大

VGG19 经常被吐槽“重”,这个“重”主要体现在参数总量上。我们来做个简单计算,理解它为什么重。

第一个全连接层 fc6 最夸张。池化之后特征图是 7x7x512,展开后就是 25088 个值,要映射到 4096 维。这一层的权重数量是 25088 x 4096,约等于 1.03 亿个参数。而整个 VGG19 大约有 1.44 亿个参数,光 fc6 一层就占了 71% 左右。如果按 float32 存储,1.44 亿个参数大约是 550MB 的权重文件。所以你去下载 VGG19 的预训练模型时,会看到文件体积非常大,这完全是正常现象。

这也解释了为什么后来很多框架训练 VGG19 时,会倾向于把全连接层砍掉,或者是改成 Global Average Pooling。因为真正的特征提取其实主要由卷积层完成,全连接层只是最后的分类头,参数却占据了绝对大头。

如果你是自己从头训练 VGG19,或是用 CIFAR-10 这样的小数据集做实验,完全可以替换掉后面的全连接层。最常用的做法是把最后三个全连接层替换成 512 维的全连接层和 10 维的输出层。这样参数量骤减,训练速度也能快不少。

2.3 小尺寸图像的适配问题

这里我要特别提醒一个容易踩坑的地方。VGG19 原始设计是面向 224x224 输入的,但很多场景下我们手里的数据是 32x32 的 CIFAR-10,或者是其他小尺寸图像。直接沿用原始结构会出现一个严重问题:输入特征图经过 5 次池化后,从 32 变成 1,从 224 变成 7,这两者的展平维度完全不同,全连接层的输入维度就对不上号了。

具体来说,32x32 的图像经过 5 次减半,变成 1x1,最后一个卷积块输出是 1x1x512,展平后只有 512 个值,而原始 fc6 期望输入是 25088 个值,程序运行到这儿就会直接报 shape mismatch 错误。所以用小尺寸图像训练 VGG19 时,要么把输入先 resize 到 224x224,要么就干脆重写分类头。我自己的经验是:如果只是做课程实验或期末项目,直接把最后全连接层改成适合自己类别数的结构会更方便,训练速度和显存占用都会友好很多。

3. PyTorch 实现 VGG19:从零手写网络结构

3.1 环境准备与依赖版本

动手写代码之前,先把环境确认好。PyTorch 这块,我自己用的是 2.x 版本,但下面的代码在 1.10 以上的版本应该都能直接跑。除了 PyTorch 之外,建议安装 torchvision,后面做数据集加载和数据增强会用到。

  • Python 3.8 或更高版本
  • PyTorch 1.10 或更高版本
  • torchvision 0.11 或更高版本
  • CUDA 环境(如果你有 N 卡,强烈建议用 GPU 训练,否则 VGG19 的训练速度会让人崩溃)

安装命令就不多啰嗦了,直接用 pip 装就可以。如果你是在 Windows 上做配置,记得装对应 CUDA 版本的 PyTorch 轮子,别装成纯 CPU 版本,否则几百个 epoch 下去你可能要等到天荒地老。

3.2 手写 VGG19 的核心代码

torchvision 里其实有现成的 vgg19 模型,直接 vgg19(pretrained=True) 就能加载。但这里我建议你先自己手写一遍,因为自己写一遍才能真正理解网络结构。我下面给出的代码,去掉注释也就五六十行,非常轻量。

import torch import torch.nn as nn class VGG19(nn.Module): def __init__(self, num_classes=1000): super(VGG19, self).__init__() # 定义卷积层部分 self.features = self._make_layers() # 定义分类器部分 self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, num_classes) ) # 权重初始化 self._initialize_weights() def _make_layers(self): layers = [] in_channels = 3 # 每个元素代表卷积块的结构,数字代表该块输出通道数,'M' 代表池化层 cfg = [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'] for v in cfg: if v == 'M': layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) else: layers.append(nn.Conv2d(in_channels, v, kernel_size=3, padding=1)) layers.append(nn.BatchNorm2d(v)) layers.append(nn.ReLU(inplace=True)) in_channels = v return nn.Sequential(*layers) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) x = self.classifier(x) return x def _initialize_weights(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.constant_(m.bias, 0) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0)

注意我在每个卷积层后面加了一层 BatchNorm2d。严格来说,原始 VGG19 是不带 BN 的,但如今在 CIFAR-10 这类小数据集上从头训练,加上 BN 几乎是必须的,否则收敛速度会很慢,甚至出现梯度弥散。如果你追求和原论文完全一致,把 BatchNorm2d 那行删掉就行,但我不建议你这么干。

cfg 这个列表是 VGG 系列最经典的设计方式。你只要改动这个列表,就能组合出不同深度和宽度的 VGG 变体。比如去掉几个数字,改成 VGG16 的配置,代码其他地方完全不用动,这就是结构规整的最大好处。

3.3 用 torchvision 加载预训练模型

有时候你并不想从零开始训练,而是想直接拿 ImageNet 上训练好的权重来做迁移学习。这时可以用 torchvision 里现成的接口:

from torchvision import models model = models.vgg19(weights=models.VGG19_Weights.IMAGENET1K_V1) num_features = model.classifier[6].in_features model.classifier[6] = nn.Linear(num_features, 10)

这段代码会下载官方预训练权重到本地,并自动把最后输出层 1000 改成 10。如果你是在国内网络环境下载权重,可能速度不快,可以提前到官方地址把 pth 文件下下来,放到 torch 缓存目录里,避免每次都卡半天。

这里再解释一下为什么要替换最后一层:ImageNet 有 1000 个类别,而你的任务可能只有 10 个类别,最后分类器的输出维度肯定要对上你的任务类别数。前层的特征提取能力是通用的,无论是猫、狗还是车、船,边缘、纹理、形状这些底层特征都是一样的,所以可以完整保留下来。

4. 数据准备与预处理细节

4.1 CIFAR-10 数据集加载

我这次的实现选择用 CIFAR-10 做实验。它只有 10 个类别、6 万张 32x32 的小图,训练的硬件门槛很低,CPU 都能勉强跑起来,但又能真实反映 VGG19 的各种特性,非常适合学习。

torchvision 自带 CIFAR-10 的数据集类,加载起来很方便:

import torchvision.transforms as transforms from torchvision.datasets import CIFAR10 from torch.utils.data import DataLoader transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) train_dataset = CIFAR10(root='./data', train=True, download=True, transform=transform_train) test_dataset = CIFAR10(root='./data', train=False, download=True, transform=transform_test) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)

这里 Normalize 里的均值方差不是随便写的,它们是 CIFAR-10 数据集所有图像在 RGB 三个通道上的统计值。训练集和测试集要用完全一样的归一化参数,不然模型看到的数据分布不一致,测试结果会失真。

4.2 数据增强的作用解析

有人在随机裁剪和水平翻转上容易忽略其重要性。就这么两行简单的增强,在 CIFAR-10 上通常能带来好几个点的准确率提升。原因很简单:随机裁剪 32x32 的图加 4 个像素的 padding,会让模型在不同位置“看到”物体的不同局部,训练样本的多样性一下子提升了很多。水平翻转则直接让训练样本数量翻倍,并且让模型对左右方向的形变不那么敏感。

这两招对于防止过拟合尤其重要。VGG19 参数量巨大,而 CIFAR-10 只有 5 万张训练图,如果不做增强,模型很快就会把训练集背下来,我们在测试集上的结果会一塌糊涂。

4.3 DataLoader 参数调优心得

DataLoader 里有几个参数值得单独说说。num_workers 表示用几个子进程去加载和预处理数据。如果你的机器性能还行,设成 4 或 8 能让数据加载时间大幅缩短。如果设太大,反而会因为进程切换开销导致变慢,而且还可能吃光内存,这个需要自己试。

pin_memory=True 的意思是在 CPU 侧分配锁页内存,GPU 从锁页内存拷数据速度更快。这个参数在只用 CPU 训练时没有意义,但对 GPU 训练非常友好,建议无脑打开。

shuffle 参数通常只在训练集上设为 True,测试集顺序无所谓,保持 False 即可。毕竟测试集不需要做梯度更新,打乱顺序没有任何意义。

5. 训练配置与超参数选择

5.1 损失函数、优化器与学习率调度

图像分类任务最常用的损失函数就是交叉熵,PyTorch 里直接用 nn.CrossEntropyLoss。它会自动把模型输出的原始 logits 做 softmax,再计算交叉熵损失,不需要你手动加 softmax 层。

优化器的选择上,我推荐用 SGD,带 momentum 和 weight decay。很多新手喜欢用 Adam,但 Adam 在 VGG 这类网络上的表现通常不如调好参数的 SGD。原版 VGG 用的是带动量的 SGD,momentum 设 0.9,weight decay 设 5e-4。这个组合是经过大量实验验证的,效果很稳。

学习率这块,初始学习率可以用 0.01,然后配合步进衰减或余弦退火。余弦退火是我自己比较偏爱的策略,它会让学习率从初始值逐步下降到接近 0,在训练后期有助于精细收敛,比固定学习率或阶梯衰减都更省心。

import torch.optim as optim criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=200)

5.2 从头训练还是迁移学习

这里得分情况讨论。如果你手头只有 CIFAR-10 这种 5 万张图片的小数据集,我建议优先考虑迁移学习。用 ImageNet 预训练好的权重做初始化,然后把你自己的分类头换掉。这样做的好处是模型已经在海量数据上学到了非常丰富的特征,你只需要在目标任务上稍微微调一下即可。训练 20 到 30 个 epoch 就能达到不错的准确率,而从头训练可能需要一两百个 epoch 还未必追得上。

如果你是本着学习目的,想把 VGG19 的前向传播、反向传播、梯度流都吃透,那我建议从头训练,并且用我前面给的手写网络那个版本,不加载任何预训练权重。从头训练的过程中你会遇到各种奇奇怪怪的问题,比如 loss 不降、准确率卡住、梯度爆炸之类,解决这些问题的过程才是最有价值的收获。

5.3 完整训练循环代码

下面给出一段完整度比较高的训练循环代码,包含了训练、验证、模型保存这几个最基本的环节。代码不长,但结构和实际项目差别不大,你可以直接拿来用。

def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0 correct = 0 total = 0 for inputs, targets in train_loader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) _, predicted = outputs.max(1) total += targets.size(0) correct += predicted.eq(targets).sum().item() return total_loss / total, 100.0 * correct / total def evaluate(model, test_loader, criterion, device): model.eval() total_loss = 0 correct = 0 total = 0 with torch.no_grad(): for inputs, targets in test_loader: inputs, targets = inputs.to(device), targets.to(device) outputs = model(inputs) loss = criterion(outputs, targets) total_loss += loss.item() * inputs.size(0) _, predicted = outputs.max(1) total += targets.size(0) correct += predicted.eq(targets).sum().item() return total_loss / total, 100.0 * correct / total def train(model, train_loader, test_loader, criterion, optimizer, scheduler, epochs, device): best_acc = 0.0 for epoch in range(1, epochs + 1): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) test_loss, test_acc = evaluate(model, test_loader, criterion, device) scheduler.step() print(f"Epoch {epoch:3d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}% | Test Acc: {test_acc:.2f}%") if test_acc > best_acc: best_acc = test_acc torch.save(model.state_dict(), "best_vgg19_ckpt.pth") print(f"Best Accuracy: {best_acc:.2f}%")

这里有几个关键点提醒一下。model.train() 和 model.eval() 的切换必须要做,因为模型里有 Dropout 和 BatchNorm,这两种层在训练和推理阶段的行为不同。Dropout 在训练时随机丢掉一部分神经元,测试时要保留全部神经元。BatchNorm 在训练时要统计当前 batch 的均值和方差,测试时要使用训练阶段累积的全局统计量。

我在保存模型时用的是 model.state_dict(),而不是整个 model 对象。这样保存的只是参数和 buffer,文件体积更小,加载也方便。加载时先创建一个结构相同的模型实例,再用 load_state_dict 把参数填进去即可。

5.4 GPU 环境下的显存优化

VGG19 的显存占用在经典网络里算是比较高的,主要原因是中间特征图数量庞大。如果你用的是 12GB 以下显存的显卡,batch size 设 64 可能会直接 OOM。解决思路有几个。

第一个办法是调小 batch size,从 64 改到 32 或者 16,显存占用基本和 batch size 成正比。第二个办法是开启自动混合精度,PyTorch 自带的 GradScaler 和 autocast 可以把部分计算改成半精度,显存占用差不多能省一半,而且训练速度还能提升不少。第三个办法是减少输入图像的尺寸,比如把 224x224 改成 128x128,这会显著降低中间特征图的大小,但准确率可能会受一些影响。

我个人的建议是,如果你的 GPU 显存在 8GB 以上,优先用混合精度加 batch size 32;如果显存更小,就直接用 CPU 跑,或者干脆换一个轻量级网络来做实验。VGG19 在 CPU 上跑 CIFAR-10 一个 epoch 可能要十几二十分钟,做实验非常痛苦。

6. 训练结果分析与模型可视化

6.1 训练曲线怎么读

如果你按照上面流程从头训练 VGG19 在 CIFAR-10 上,正常情况 200 个 epoch 左右,测试准确率能到 92% 到 94%。前 20 个 epoch 里训练 loss 会快速下降,验证准确率也会迅速爬升到 80% 左右。之后会进入一个缓慢提升的阶段,每 10 个 epoch 可能才涨一两个点。到了后期,稍微不注意学习率就可能出现震荡,验证准确率徘徊不前的现象。

读曲线的时候,重点观察训练 loss 和验证准确率之间的相对关系。如果训练 loss 一直降,验证准确率却不涨甚至往下掉,那就是过拟合。VGG19 在这种小数据集上非常容易过拟合,我的经验是 dropout 一定要保留,数据增强一定要做足,否则过拟合几乎必然发生。

另外建议大家顺手把混淆矩阵打印出来,看看模型具体哪些类别分不清楚。CIFAR-10 里猫和狗经常互相错认,鸟和鹿、汽车和卡车也会偶发混淆。混淆矩阵能帮你快速定位模型的能力边界,比只看一个总准确率有用得多。

6.2 卷积核可视化

这里很多人会把它当做一个炫酷的展示,但它背后确实有实际意义。第一层卷积核尺寸是 3x3x3,对应 RGB 三通道输入。你可以把第一个卷积层 64 个卷积核的权重拿出来,可视化出来看看,会发现它们分别对水平边缘、垂直边缘、对角边缘、颜色块等不同模式有响应。

可视化代码非常简单,就是把 conv1 的 weight 拿出来画成图片。具体来说,每个卷积核是 3x3x3 的,在 RGB 图上就是一个小色块。用 torchvision.utils.make_grid 可以很方便地把 64 个卷积核拼成一张 8x8 的网格图。

更深层的卷积核由于输入输出通道数很多,没法直接可视化。但你可以换一种方式:输入一张图,提取中间层特征图,按网格排列展示。卷积网络早期层提取的是边缘纹理,中期层能提取出物体的局部部件,后期层则越来越接近语义信息。你拿一张猫的图片输进去,看最后一层卷积输出的 512 张特征图里,哪些位置响应高,基本就能判断模型到底在关注猫的哪个部位。

6.3 用 hook 提取中间层特征

PyTorch 提取中间层特征最优雅的方式是注册 forward hook。hook 会在前向传播执行到指定模块时被调用,你可以把当时的输入输出记录下来,而不需要改动网络结构。

activation = {} def get_activation(name): def hook(model, input, output): activation[name] = output.detach() return hook model.features[0].register_forward_hook(get_activation('conv1_1')) model.features[14].register_forward_hook(get_activation('conv3_1'))

注册好之后,随便跑一次前向传播,activation 字典里就记录了指定层输出的特征图。这一步能帮你直观理解“深层特征更抽象”这个说法到底是什么意思,比干巴巴地看结构图印象深得多。

7. 常见问题与排查技巧实录

7.1 训练 loss 不降的几个原因

这是新人最容易遇到的情况,辛辛苦苦把代码写完,结果 loss 稳如泰山,动都不动一下。我遇到过的情况主要有三种:

学习率过大或过小。学习率太大,loss 会在初始值附近震荡,甚至直接发散成 NaN。学习率太小,loss 下降得极其缓慢,看起来就像没降。解决办法是调出 log,看前几个 step 的 loss 变化趋势,再决定往哪个方向调学习率。

权重初始化出了乱子。如果你用的是我前面给的初始化方法,一般不会有问题。但如果你手动改了初始化方式,比如全零初始化,那所有神经元的输出都一样,梯度更新就失效了,loss 永远不变。

标签和数据没对齐。曾经有一次我在自定义数据集时,类别索引从 1 开始编号,而模型输出是 0 到 9 的索引,导致 loss 一直很高。这种错误很隐蔽,建议写代码时先跑一个 batch 的数据,人工检查一下样本和标签是否对应。

7.2 显存溢出 OOM 怎么办

报错信息一般是 CUDA out of memory。最常见的解决办法是降低 batch size,不够的话再把输入分辨率降下来。还有一种思路是使用梯度累积,即每跑几个小 batch 再更新一次梯度,模拟更大的 batch size,但显存占用不会增加。

如果训练中途突然 OOM,而之前一直好好的,那多半是有别的进程占了显存。用 nvidia-smi 看一下 GPU 占用情况,把不需要的进程杀掉就行。我自己就干过训练跑了一整天,最后一不小心开了个别的程序导致 OOM 的蠢事,白白丢了一次训练进度,所以提醒大家定期保存 checkpoint。

7.3 特征图尺寸 mismatch 的排查方法

这个问题几乎人人都遇到过。报错的核心信息是比如 size mismatch for classifier.0.weight,期望是 [4096, 25088],实际却是 [4096, 512]。当你的输入不是 224x224 时,展平后的维度就和全连接层不匹配了。解决办法我在前面已经说过了,把分类器的第一层维度改成展平后的实际维度即可。怎么算?拿输入尺寸过一遍卷积和池化的宽高变化规律,每一步减半几次,最后乘上输出通道数,那就是展平后的维度。

排查这类问题,最实用的方法是用一行测试代码把每一层的输出尺寸打印出来:

model = VGG19() x = torch.randn(1, 3, 32, 32) for name, layer in model.features.named_children(): x = layer(x) print(name, x.shape)

这样你一眼就能看出哪个环节的尺寸和预期不符。等确认 features 部分没问题,再来看全连接层的输入维度。

7.4 关于预训练权重下载的补充

使用 torchvision 的 vgg19 接口时,权重文件较大,加载慢或者网络不稳定都可能导致下载失败。如果多次失败,建议手动下载权重文件放到本地,然后修改代码从本地路径加载。torchvision 权重文件默认存放在用户目录下的 .cache/torch/hub/checkpoints 里,你把它放进去之后,接口会自动找到,不会再重复下载。

加载本地权重还有一种更保险的写法:

model.load_state_dict(torch.load("vgg19-dcbb9e9d.pth"))

总之,下载权重这事虽然不难,但在网络条件不太好的环境下确实很烦人,提前把权重文件备好能省不少时间。

8. 项目扩展思路与后续方向

8.1 从 VGG19 到 VGG19_BN

如果你想进一步提升训练速度和稳定性,可以尝试 VGG19_BN,也就是每个卷积层后面加 BatchNorm 的版本。torchvision 里直接有 vgg19_bn 接口,加载方式和 vgg19 完全一致。加了 BN 之后,网络对学习率不那么敏感,收敛速度明显加快,而且精度通常也有小幅提升。我在 CIFAR-10 上从头训练 VGG19_BN,200 个 epoch 能比不带 BN 的高 1 到 2 个百分点。

不过要注意的是,带 BN 的版本在推理时会有一些额外开销,而且模型文件也会稍大一点。如果你的硬件条件比较紧张,可以优先跑不带 BN 的原版。

8.2 用 VGG19 做特征提取器

VGG19 卷积层的输出可以作为通用图像特征。常见的做法是去掉最后三个全连接层,把 pool5 的输出 7x7x512 展平,得到一个 25088 维的特征向量。下游任务可以是图像检索、小样本分类,也可以是图像风格迁移。

很多风格迁移经典代码就是拿 VGG19 的某些层来定义内容损失和风格损失。内容损失用比较高层的特征,风格损失用多个低中高层的 Gram 矩阵统计。这个玩法在理解 VGG19 之后会变得非常顺手,因为你已经知道每一层大概提取了什么层次的特征,才能明白为什么要选那些层来做损失计算。

8.3 与 ResNet 对比,理解网络进化

把 VGG19 和 ResNet 放到一起对比,能让你对深度学习的发展脉络有更清晰的认识。VGG19 凭借极深的网络和巨大的参数量达到高性能,但训练困难和参数冗余问题也很明显。ResNet 引入残差结构,让信息可以跨层直接传递,在更深的网络上反而更好训练,参数效率也更高。

我自己做完 VGG19 项目后再去看 ResNet,最大的感受是:VGG19 的“笨办法”把深度网络推到了一个临界点,而 ResNet 则提供了突破这个临界点的关键思路。没有 VGG 的铺垫,残差网络的出现就不会那么顺理成章。

我个人在实际操作中的一个体会是,学习经典模型最好是连跑带上手改,不要只看结构图。VGG19 虽然“老”,但它身上几乎浓缩了一个视觉从业者必备的所有基本功:结构设计、参数计算、训练调参、迁移学习、特征可视化。等到你能不假思索地手写出 VGG19 的网络结构,并且不用看报错就能预估每一层输出尺寸时,你的基础就算是真正打牢了。

最后再分享一个小技巧:做实验时别只盯着测试准确率,多记录一下训练时间、显存占用、每 epoch 的耗时。这些工程指标在真实项目里往往比那零点几个点的准确率更重要,而且面试聊项目时,这些细节恰恰最能体现你确实亲手跑过模型,而不是背了一堆理论就完事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 15:54:12

三步用 ipatool 下载 iOS IPA 包:新手完整的上手指南

三步用 ipatool 下载 iOS IPA 包:新手完整的上手指南 【免费下载链接】ipatool Command-line tool that allows you to search for iOS, iPadOS, tvOS, visionOS, and macOS apps on the App Store, and download .ipa or macOS .pkg app packages. 项目地址: htt…

作者头像 李华
网站建设 2026/9/15 15:52:26

KMP跨平台瀑布流实战:基于Compose Multiplatform的LazyVerticalStaggeredGrid

最近在做KMP项目的瀑布流时踩了不少坑,整理成这篇东西,希望能让后来的人少走弯路。先说清楚,这里的KMP是Kotlin Multiplatform的缩写,不是数据结构里那个KMP字符串匹配算法。如果你看到标题第一反应是“求next数组”,那…

作者头像 李华