☰
从零手写VGG:PyTorch实现卷积神经网络设计与训练
2026/10/22 14:47:48 网站建设 项目流程

1. 为什么还要手写一遍VGG

1.1 从一次翻车经历说起

前阵子帮一个刚入门的朋友调一个图像分类的作业,他直接调了现成模型库里的预训练权重,跑出来准确率挺好看,但导师随口问了句“VGG的卷积核为什么全是3×3”,他当场卡壳。这事让我想起自己刚学深度学习那会儿,也是急着跑通、急着看准确率曲线,结果模型结构里的门道一个都没吃透。后来我逼着自己从零手写了一遍VGG,才发现这个看起来“又老又笨”的网络,其实是理解卷积神经网络设计哲学最好的教材。

VGG这个网络结构,2014年由牛津的视觉几何组提出,名字里的“VGG”就是那个组的缩写。它的核心贡献说起来特别朴素:用连续的小卷积核堆叠,替代大卷积核。在它之前,大家还在用7×7、11×11这种大核,VGG用实验证明了,两个3×3卷积堆叠的感受野等价于一个5×5,三个3×3堆叠等价于一个7×7,但参数量更少、非线性更强。这个结论放到今天依然是卷积网络设计的基石之一。

这篇文章适合谁看?如果你已经能跑通MNIST手写数字分类,知道卷积、池化、全连接大概是怎么回事,但还没亲手搭过一个完整的、有深度的卷积网络,那这篇就是写给你的。我会用PyTorch从零实现VGG,把每一层的设计意图、参数计算、踩坑点都掰开讲清楚。代码可以直接抄,但更希望你看完能明白每一行为什么这么写。

1.2 先搞清楚VGG到底长什么样

VGG有好几个版本,最常用的是VGG11、VGG13、VGG16、VGG19,数字代表带权重的层数(卷积层+全连接层)。其中VGG16是经典中的经典,结构规整得像用尺子画出来的。它的整体架构可以分成五段卷积和三层全连接:

  • 第一段:2个卷积层,通道数64,后面接最大池化
  • 第二段:2个卷积层,通道数128,后面接最大池化
  • 第三段:3个卷积层,通道数256,后面接最大池化
  • 第四段:3个卷积层,通道数512,后面接最大池化
  • 第五段:3个卷积层,通道数512,后面接最大池化
  • 然后接三层全连接:4096、4096、1000(ImageNet类别数)

所有卷积层都是3×3卷积、步长1、填充1,所有池化都是2×2最大池化、步长2。这个“填充1”很关键,它保证了卷积操作不改变特征图的空间尺寸,尺寸缩减全部交给池化层来做。整个网络输入224×224的RGB图像,经过五次池化后变成7×7,再展平送入全连接。

提示:很多人第一次看VGG结构图会疑惑,为什么卷积层通道数从64一路翻倍到512?这其实是个经验规律——空间尺寸减半时,通道数翻倍,目的是在压缩空间信息的同时保留甚至增强特征表达能力,让每一层承载的信息量大致平衡。

2. 动手前的环境与数据准备

2.1 依赖库和版本选择

PyTorch实现VGG,依赖其实很少。我习惯用下面这套组合,实测下来很稳:

pip install torch torchvision numpy matplotlib

版本上,PyTorch 1.10以上都可以,torchvision主要用来加载数据集和做图像预处理。如果你有GPU,记得装对应CUDA版本的torch,训练速度差距是数量级的。我试过在纯CPU上跑VGG16训练CIFAR-10,一个epoch要十几分钟,换成入门级GPU直接降到几十秒。

这里有个新手常踩的坑:不要盲目追求最新版本。有次我图新鲜装了某个刚发布的预览版,结果torchvision的transforms接口有变动,预处理代码全报错。生产环境或者学习阶段,选一个稳定发布版就够了。

2.2 数据集的选择与预处理

VGG原始论文用的是ImageNet,1000类、上百万张图,普通人根本跑不动。学习阶段我强烈建议用CIFAR-10或者Fashion-MNIST这类小数据集,原因有三:一是下载快、不占空间;二是类别少、收敛快,几十分钟就能看到效果;三是方便你快速迭代调试网络结构。

CIFAR-10是32×32的彩色图像,10个类别。但VGG原始输入是224×224,直接喂32×32的图进去,经过五次池化后只剩1×1,全连接层就没法正常工作了。所以有两个选择:要么把图像上采样到224×224(计算量大但贴合原结构),要么调整网络结构适配小图。我建议学习阶段用后者,把池化次数减少,或者干脆把CIFAR-10放大到64×64,池化五次后是2×2,还能接受。

预处理这块,标准操作是:

transform = transforms.Compose([ transforms.Resize(64), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

那几个均值和标准差是ImageNet统计出来的,用在小数据集上虽然不是最优,但作为起点完全够用。ToTensor()会把像素值从0-255归一化到0-1,Normalize再按通道做标准化,这一步能显著加快收敛。

注意:如果你自己准备数据集,一定要检查图像通道数。有次我混入了几张灰度图,训练时直接报维度不匹配。稳妥的做法是在transform里加一个transforms.Grayscale(num_output_channels=3),强制统一成三通道。

3. 从零搭建VGG网络结构

3.1 用配置列表驱动网络构建

手写VGG最忌讳把每一层硬编码写死,那样改个版本就得重写一遍。我的做法是定义一个配置字典,用数字表示卷积层通道数,用'M'表示池化层,然后循环构建。这样VGG11到VGG19只需要改一个列表。

cfgs = { 'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'], 'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'], 'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M'], 'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'], }

这个设计的好处是,网络结构的“骨架”一目了然。你看vgg16那一行,能清楚数出五段卷积,每段后面跟一个池化。这种数据驱动的构建方式,在后续想尝试自定义结构时特别方便,比如你想加一段或者减一段,改列表就行。

3.2 卷积层的构建细节

构建卷积层的函数长这样:

def make_layers(cfg, batch_norm=False): layers = [] in_channels = 3 for v in cfg: if v == 'M': layers += [nn.MaxPool2d(kernel_size=2, stride=2)] else: conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1) if batch_norm: layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)] else: layers += [conv2d, nn.ReLU(inplace=True)] in_channels = v return nn.Sequential(*layers)

这里有几个点值得展开说。第一,in_channels的更新逻辑,它始终记录着上一层的输出通道数,作为下一层的输入通道数,这是卷积网络串联的核心。第二,padding=1配合kernel_size=3,输出尺寸和输入完全一致,这个前面提过。第三,ReLU(inplace=True)能省一点显存,因为它直接在原张量上修改,不额外开辟空间,但要注意如果后续还需要用到ReLU之前的特征图,就不能用inplace。

关于要不要加BatchNorm,我的建议是:学习阶段先不加,理解原汁原味的VGG;实际项目里加上。BatchNorm能加速收敛、缓解梯度问题,但会掩盖一些网络本身的设计问题。等你把不带BN的版本跑通了,再加BN对比效果,理解会更深刻。

3.3 全连接层与分类头

卷积部分结束后,特征图尺寸是7×7(输入224时),通道512。展平后是512×7×7=25088维,送入第一个全连接层4096。这里参数量巨大,25088×4096约等于1亿个参数,是整个网络参数量的重头。

self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplace=True), nn.Dropout(), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Dropout(), nn.Linear(4096, num_classes), )

Dropout在这里非常关键。全连接层参数太多,极易过拟合,Dropout通过随机丢弃一部分神经元,强迫网络学习更鲁棒的特征。原始论文里dropout概率是0.5,我实测下来0.5确实是个好起点。

提示:如果你用的是小数据集(比如CIFAR-10),第一个全连接层的25088维输入可能太大。这时候可以调整前面的池化策略,或者把全连接层维度降下来,比如改成1024、512。不要死守原始结构,要根据数据规模灵活调整。

4. 训练流程与关键参数设置

4.1 损失函数与优化器

分类任务用交叉熵损失,这是标配:

criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4)

原始VGG论文用的是SGD加动量,学习率从0.01开始,配合学习率衰减。我试过Adam,收敛确实快,但最终精度往往不如调好的SGD。如果你追求快速看到效果,Adam没问题;如果想复现论文级别的结果,老老实实用SGD。

学习率调度用余弦退火或者阶梯下降都行:

scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)

意思是每30个epoch学习率乘以0.1。这个策略简单粗暴但有效,能让模型在后期精细调整权重。

4.2 训练循环的写法

训练循环本身不复杂,但有几个细节决定成败:

for epoch in range(num_epochs): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step()

optimizer.zero_grad()必须放在反向传播之前,否则梯度会累加。model.train()和后面的model.eval()切换也不能忘,因为Dropout和BatchNorm在训练和推理时行为不同。这些点看起来是常识,但我见过太多人因为漏写zero_grad导致loss不下降,排查半天。

验证阶段:

model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item()

torch.no_grad()能关闭梯度计算,省显存也提速。验证时一定要用model.eval(),否则Dropout还在随机丢弃,结果不稳定。

4.3 参数初始化的门道

VGG论文里提到,他们先用随机初始化训练浅层网络,再用训练好的浅层网络参数去初始化深层网络。这个技巧在当时很重要,因为深层网络直接随机初始化很难收敛。现在有了BatchNorm和更好的初始化方法,这个技巧用得少了,但了解它有助于理解深度网络训练的难点。

PyTorch默认用Kaiming初始化,对ReLU激活函数很友好。如果你想手动控制:

def init_weights(m): if isinstance(m, nn.Conv2d) or isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.constant_(m.bias, 0)

fan_out模式考虑了反向传播时的方差,适合卷积层。偏置初始化为0是常规操作。

5. 常见问题与排查实录

5.1 显存不够怎么办

这是新手最常遇到的问题。VGG16全连接层参数量巨大,显存占用高。几个立竿见影的办法:

问题现象原因解决方案
CUDA out of memorybatch_size太大降到8或16试试
训练中途爆显存特征图缓存未释放用del手动删除中间变量
验证时爆显存未用no_grad加上with torch.no_grad()
模型加载失败全连接层太大减小全连接维度或改用全局平均池化

全局平均池化是个好思路:在最后一个卷积段后,直接对每个通道求平均,得到512维向量,再接全连接。这样参数量骤降,而且有正则化效果。很多现代网络都这么干,VGG之后的设计趋势也是往这个方向走。

5.2 loss不下降或震荡

排查顺序我一般是这样的:先看数据预处理对不对,比如Normalize的均值和标准差是否匹配数据集;再看学习率是不是太大,试试降到0.001;然后检查标签有没有错位,图像和标签是否一一对应;最后看网络结构,是不是某层通道数接错了。

有次我遇到loss一直在2.3左右震荡(CIFAR-10十分类,随机猜是2.3),查了半天发现是数据加载时shuffle没开,每个batch都是同一类别的图,网络根本学不到东西。这种低级错误反而最难发现,因为代码不报错。

5.3 过拟合的识别与应对

训练准确率一路涨,验证准确率涨到某个点后开始跌,这就是过拟合的典型信号。应对手段按优先级排:

  • 加数据增强:随机裁剪、水平翻转、颜色抖动
  • 增大Dropout概率:从0.5提到0.6甚至0.7
  • 加权重衰减:weight_decay从5e-4提到1e-3
  • 早停:验证准确率连续几个epoch不涨就停

数据增强是最根本的解决办法,因为它直接增加了有效数据量。我习惯在transform里加:

transforms.RandomHorizontalFlip(), transforms.RandomCrop(32, padding=4),

这两行几乎不增加计算成本,但效果显著。

注意:数据增强只用在训练集,验证集和测试集绝对不能用。有次我不小心把增强加到了验证集上,结果验证准确率波动特别大,排查了好久才反应过来。

6. 几个让效果更好的实战技巧

6.1 迁移学习:站在巨人肩膀上

如果你手头数据不多,从零训练VGG基本没戏。这时候用预训练权重做迁移学习是正解:

model = vgg16(pretrained=True) for param in model.features.parameters(): param.requires_grad = False model.classifier[6] = nn.Linear(4096, num_classes)

冻结卷积层,只训练分类头。等分类头收敛了,再解冻部分卷积层做微调。这个策略在小数据集上效果拔群,我试过用几百张图做五分类,准确率能到90%以上。

6.2 学习率预热

深层网络刚开始训练时,权重是随机的,梯度可能很大。直接上大学习率容易震荡。预热策略是前几个epoch用很小的学习率,慢慢升到设定值:

def warmup_lr(epoch): if epoch < 5: return 0.001 * (epoch + 1) / 5 return 0.01

这个技巧在Transformer里很常见,但用在VGG上同样有效,能让训练初期更稳定。

6.3 梯度裁剪

如果遇到梯度爆炸(loss突然变成nan),梯度裁剪是急救手段:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

放在loss.backward()之后、optimizer.step()之前。max_norm设1.0是个安全值,能防止梯度更新过猛。

7. 我踩过的那些坑

第一个坑是输入尺寸不匹配。有次我拿128×128的图喂给标准VGG16,跑到全连接层报维度错误。原因是五次池化后特征图是4×4,不是7×7,全连接层的输入维度对不上。解决办法要么调整输入尺寸,要么动态计算全连接输入维度。我后来写了个小函数,用一张假数据跑一遍卷积部分,自动算出展平后的维度,省得每次手动算。

第二个坑是BatchNorm的momentum。默认momentum是0.1,但在小batch_size下,统计量估计不准,验证效果会差很多。如果batch_size小于16,建议把momentum调小,比如0.01,让统计量更新更平滑。

第三个坑是模型保存与加载。保存时用torch.save(model.state_dict(), path),加载时先实例化模型再load_state_dict。有次我直接save了整个模型对象,换了个环境加载时报了一堆pickle错误。只保存state_dict是更稳妥的做法。

第四个坑是多GPU训练。用nn.DataParallel包一层确实能加速,但保存的state_dict键名会多一个module.前缀,加载到单GPU模型时会不匹配。解决办法是保存时用model.module.state_dict(),或者加载时做个键名映射。

8. 从VGG延伸出去的学习路径

VGG的价值不仅在于它本身,更在于它开启的一系列设计思路。理解了VGG,你可以顺着往下走:BatchNorm的加入让训练更稳定,ResNet的残差连接解决了更深网络的退化问题,DenseNet把特征复用做到极致,EfficientNet用神经架构搜索找最优缩放比例。这些网络或多或少都站在VGG的肩膀上。

如果你想继续深入,我建议的路径是:先把VGG16在CIFAR-10上训到85%以上准确率,然后尝试加BatchNorm看能提升多少,再换成ResNet-18对比收敛速度和最终精度。这一套走下来,你对卷积网络的理解会完全不一样。

代码写到最后,其实最花时间的不是搭网络,而是调参和排查各种奇怪的问题。我个人的体会是,先把一个简单模型跑通、跑透,比急着上复杂模型收获大得多。VGG结构简单、逻辑清晰,正是那个适合“跑透”的模型。等你把它的每一层、每个参数都摸清楚了,再看其他网络,会有种豁然开朗的感觉。

最后分享一个小技巧:训练时把每个epoch的训练损失、验证损失、验证准确率都记下来,画成曲线。很多时候问题就藏在曲线的形状里——是震荡、是平台、还是突然下跌,不同的形状指向不同的原因。这个习惯帮我省下了大量盲目试错的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询