ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从LeNet到GoogLeNet:四个经典CNN逐层拆解与PyTorch复现

从LeNet到GoogLeNet:四个经典CNN逐层拆解与PyTorch复现

很多人上手卷积神经网络,第一步就是找一份现成的深度残差网络或轻量检测器的代码,改改输入尺寸、换个数据集就开跑。跑是能跑,但一旦被问"为什么卷积核是3×3""为什么这个位置要接池化""为什么全连接层这么占参数",往往就卡壳了。我带过几个刚入门的朋友,几乎每个人都在LeNet、AlexNet、VGG、GoogLeNet这四个经典CNN上吃过同样的亏:代码抄下来了,但不知道每一层的设计意图,调参全靠猜,模型不收敛就怀疑人生。这篇就把这四个网络按顺序拆开,从LeNet的通道数为什么是6和16,到AlexNet的输入尺寸怎么对齐,再到VGG的感受野推导和参数分布真相,最后到GoogLeNet的Inception模块怎么手写,每一段都配可运行的PyTorch代码和实际踩过的坑。不管你是刚接触卷积神经网络的小白,还是想回头补基础的老手,这篇都值得从头看到尾。

1. 把这四个网络从头捋一遍,比直接抄一个深度残差网络强在哪

1.1 一张手写数字图和一张百万级照片,逼出了两种完全不同的网络

先讲个直观的对比。LeNet面对的是32×32的灰度手写数字,整张图的像素总数才一千出头,类别只有10个;AlexNet面对的是224×224的彩色照片,像素数量是前者的近150倍,类别有一千个,物体还带着姿态、光照、遮挡的各种变化。这两个任务的信息量差了三个数量级,网络的设计思路自然完全不同。

LeNet时代,算力是硬约束,一张卡训练几十万张图都费劲,所以网络必须做到"极简":卷积核大一点、通道少一点、池化用平均而不是最大、激活用tanh而不是后来才流行的ReLU。它要解决的核心问题只有一个——证明"局部连接加权值共享"这套思路确实能自动学到平移不变的特征,而不是靠人工设计的特征算子。

AlexNet面对的是百万级规模的数据集,数据和算力同时到位,这时候"深度"第一次真正变成了可兑现的收益。它没有发明新组件,但它把ReLU、Dropout、重叠池化、数据增强、GPU并行这些东西组合到了一起,把8层网络的训练跑通了。所以我一直觉得,AlexNet的价值不只在结构,更在于它示范了一套"如何把一个深网络训到收敛"的工程方法。

到了VGG,问题变成了"能不能用一套更规整的规则把网络堆得更深"。它的回答很暴力也很优雅:所有卷积都用3×3,所有池化都用2×2,每过一阶段通道翻倍、尺寸减半。这套规则让网络设计从"手艺"变成了"算术题",你可以拿张纸算出每一层的输出尺寸和参数量。

GoogLeNet则换了个思路:既然不知道某一层到底该用3×3还是5×5,那不如全都用,让网络自己学权重。这个看似"我全都要"的粗暴做法,背后其实是一套非常精密的降维账本。

1.2 四个网络串起来看,是一条清晰的设计主线

把这四个网络按时间排开,你能看到四条主线在同时推进,理解了这四条主线,后面看任何新结构都不会太陌生。

第一条是深度。从LeNet的5层(含可学习参数层),到AlexNet的8层,到VGG16的16层、VGG19的19层,再到GoogLeNet的22层(含辅助分类器)。深度上去了,网络的表达能力确实变强了,但梯度消失和退化问题也随之而来,这也是后来残差连接出现的直接动因。

第二条是感受野。LeNet第一层用5×5卷积,在32×32的图上已经覆盖了不小比例;AlexNet第一层直接上11×11、步长4,是为了在224的大图上快速把尺寸降下来。VGG做了一次系统的反思:用多层小卷积核堆叠,在达到同等感受野的同时参数量更少、非线性更强。这个结论到今天仍然是主流设计的基本假设。

第三条是宽度与分支。GoogLeNet引入的Inception结构,本质是把"宽度"这个维度显式地建模出来,用并行的多尺度分支替代单一路径。这条线后来演化出了分组卷积、深度可分离卷积等一系列变体。

第四条是参数效率。AlexNet的6100万参数里有5400万堆在两个全连接层上,VGG16的1.38亿参数里有超过1亿在第一个全连接层。GoogLeNet用全局平均池化把这部分彻底砍掉,把总参数压到680万左右,是AlexNet的九分之一、VGG16的二十分之一,而分类精度反而更高。这个转变非常关键,它说明"堆参数"和"涨精度"不是一回事。

下面这张表是我常用来给朋友做整体印象的,不同实现和统计口径会有出入,看量级就好:

网络输入尺寸参数量(量级)参数主要去向结构特征
LeNet-532×32×1约6万全连接层5层,tanh,平均池化
AlexNet224×224×3约6100万两层全连接占约5400万8层,ReLU,Dropout
VGG16224×224×3约1.38亿第一层全连接单独占约1.03亿16层,全3×3堆叠
GoogLeNet224×224×3约680万分散在各Inception分支22层,多尺度并行

看完这张表你应该已经能感觉到,这四个网络并不是"越往后越好"的简单递进,它们在参数效率上的选择差异极大,而这恰恰是最值得学的部分。

2. LeNet-5:卷积网络的第一份可用设计图

2.1 逐层拆解:为什么是6个通道和16个通道

LeNet-5的结构不复杂,但每一层都有讲究。输入是一张32×32的单通道图,第一层用6个5×5的卷积核,步长1,不加填充(原始版本是靠输入本身多出的边框来对齐的),输出6张28×28的特征图。这里第一个问题就来了:为什么是6个通道?

这个问题没有严格的理论答案,但从工程角度看,6是一个在当时算力下足够小、又能提供基本特征多样性的数字。第二层是2×2、步长2的平均池化,输出6张14×14。第三层是16个5×5卷积核,输出16张10×10。为什么从6涨到16?因为经过两次下采样,空间尺寸从32降到10,信息密度已经下降,需要通过增加通道数来补偿表达能力。这个"空间变小、通道变多"的节奏,几乎是所有后续CNN的通用范式。

再往后是两层全连接,120和84个神经元。84这个数字有说法,原始设计里它对应一个7×12的位图编码,是为了和当时的输出编码方式对齐,今天的复现基本不用纠结这个数,取任意合理值都可以。最后一层10个神经元对应10个类别,原始版本用的是欧氏径向基函数而不是softmax,现在复现基本都换成softmax加交叉熵,效果更好也更通用。

有一个特别容易忽略的细节:LeNet用的是平均池化而不是最大池化,激活函数是tanh而不是ReLU。这两点跟今天的主流做法都不一样,很多复现版本为了"效果好看"直接改成最大池化和ReLU,然后发现训出来的东西和论文描述对不上。我的建议是:第一遍严格按原始设计复现,理解每一层的原始意图;第二遍再做现代化改造,对比两次的结果,你对这些组件的实际作用会有完全不同的认识。

2.2 复现代码与三个最容易写错的地方

下面是我常用的LeNet-5实现,按32×32输入写,保留了原始的tanh和平均池化:

import torch import torch.nn as nn class LeNet5(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, stride=1), # 32 -> 28 nn.Tanh(), nn.AvgPool2d(kernel_size=2, stride=2), # 28 -> 14 nn.Conv2d(6, 16, kernel_size=5, stride=1), # 14 -> 10 nn.Tanh(), nn.AvgPool2d(kernel_size=2, stride=2), # 10 -> 5 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(16 * 5 * 5, 120), nn.Tanh(), nn.Linear(120, 84), nn.Tanh(), nn.Linear(84, num_classes), ) def forward(self, x): return self.classifier(self.features(x)) # 快速验证各层输出尺寸 x = torch.randn(1, 1, 32, 32) model = LeNet5() print(model(x).shape) # torch.Size([1, 10])

第一个坑是输入尺寸不匹配。手写数字数据集的原图是28×28,直接送进上面这个网络,第一层卷积后是24×24,池化后12×12,第二层卷积后8×8,池化后4×4,展平后是16×4×4=256,而全连接层期望的是16×5×5=400,直接报维度错。解决办法有两个:一是用transforms.Pad(2)把28×28补到32×32,二是把第一层卷积改成padding=2,让28×28保持到28×28。我一般选后者,改一行就行,但要注意这样输出尺寸序列会整体后移,得重新算全连接层的输入维度。

第二个坑是展平维度的硬编码。上面代码里写死了16 * 5 * 5,一旦你改了输入尺寸或者加了padding,这行就得跟着改。写的时候加个断言或者用自适应池化兜底会舒服很多:

# 在 features 后面接一个自适应池化,把尺寸统一压到 5x5 self.features.add_module("gap", nn.AdaptiveAvgPool2d((5, 5)))

这样无论输入是28还是32,展平后的维度都是固定的400。代价是丢失了一部分原始的空间分辨率信息,做教学复现时我更倾向于老老实实算清楚尺寸,别用这个捷径糊过去。

第三个坑是参数初始化。原始论文用的是一种基于均匀分布的初始化方案,如果你直接用PyTorch默认的Kaiming初始化配tanh激活,收敛会偏慢甚至不稳。做对比实验时,我建议固定一个初始化方案,把变量控制住,否则你没法判断效果差异到底是结构带来的还是初始化带来的。

2.3 跑不通参考精度时,先别怀疑网络结构

经常有人问我,LeNet在手写数字数据集上跑到98%就上不去了,是不是结构有问题。绝大多数情况下不是。这个量级的网络,能达到的上限主要取决于三件事:数据的归一化方式、学习率调度、以及训练轮数。

数据归一化这一步特别容易被跳过。手写数字的像素值在0到255之间,不做归一化直接送进网络,梯度尺度会非常大,前几轮基本在原地打转。用transforms.ToTensor()会自动缩放到0到1,但如果想更稳,建议再减均值除标准差,用数据集整体的统计量而不是0.5这个拍脑袋的值。

学习率方面,LeNet这种小网络对学习率很敏感。我一般从1e-3开始,配一个余弦退火或者阶梯衰减。如果你用的是Adam,1e-3通常偏大,容易在后期震荡,可以降到3e-4试试。批大小别设太大,128就够了,太大会让每个epoch的更新次数太少,小网络反而训不透。

训练轮数上,很多人跑10个epoch看到loss平了就停了,其实这时候往往还在缓慢下降。我一般至少跑30轮,配合学习率衰减,通常能在99%左右收敛。如果30轮还卡在98%,那再回头查结构。

提示:复现老网络时,先把"能不能复现出论文量级的精度"作为独立目标,别急着加自己的改动。改动和复现混在一起做,出了问题你根本分不清是谁的责任。

3. AlexNet:深度第一次兑现成精度

3.1 相对LeNet的六处改动,每一处都在解决具体问题

AlexNet经常被说成"LeNet的加深版",这个说法不算错但太粗糙。它相对LeNet有六处关键改动,每一处都对应一个具体的工程问题。

第一处是激活函数从tanh换成ReLU。tanh在输入绝对值较大时导数趋近于0,深层网络里梯度经过多次连乘后衰减得非常快。ReLU在正区间的导数恒为1,梯度能更完整地传回去。这个改动听起来简单,但对8层网络的训练能不能跑通是决定性的。

第二处是池化从平均换成最大,而且允许重叠。AlexNet的池化核是3×3、步长2,相邻的池化窗口会有重叠区域。重叠带来的好处是特征响应更平滑,不容易因为窗口边界的位置差异而剧烈波动。这个设计后来被更大步长的池化取代了,但在当时是个有效的技巧。

第三处是引入局部响应归一化。这个设计模仿了生物神经元的侧抑制机制,让同一位置上响应强的通道去抑制相邻通道。它的实际收益在今天看比较有限,后面会细说。

第四处是Dropout。两个4096维的全连接层之间加了丢弃率为0.5的Dropout,这是当时对抗过拟合最主要的手段。因为这两层全连接的参数量实在太大了,不加约束几乎必然过拟合。

第五处是数据增强。随机裁剪、水平翻转、颜色抖动,这套组合把有效训练样本扩增了几十倍。这一步对最终精度的影响,我认为不亚于网络结构本身的改动。

第六处是双卡并行训练。原始实现把网络拆到两张卡上,在特定层做跨卡通信。这个设计在今天看是硬件限制下的妥协,但它意外地带来了一点正则化效果,因为每张卡上的通道只能看到部分输入。现在单卡显存足够,没必要复现这个拆分。

3.2 三个被后人重新评价的设计

LRN是最典型的例子。原始论文报告它能带来约1.4%的top-1提升,但后续大量实验发现,把这个模块去掉,用更好的初始化或者批归一化替代,精度并不会下降,甚至略好。原因在于LRN的归一化是局部的、跨通道的,而批归一化的归一化是全局的、跨样本的,后者对训练稳定性的帮助大得多。所以我的建议很明确:复现论文时保留LRN做对照,实际项目里直接用批归一化替换掉,省事又稳。

Dropout的评价也经历过反复。它在全连接层上的效果是明确的,但在卷积层上加Dropout一直有争议,因为卷积层的参数共享本身就有正则化效果,再叠加Dropout可能抑制不足或过度。后来出现的空间Dropout、随机深度这些变体,本质上都是在重新思考"该在哪里丢弃、丢弃什么粒度"。

重叠池化则基本被淘汰了。现代网络要么用步长等于核大小的非重叠池化,要么干脆用步长2的3×3卷积代替池化。原因很简单:下采样本身是有信息损失的,用可学习的卷积做下采样比用固定的池化规则更灵活。

3.3 复现代码与显存账本

import torch import torch.nn as nn class AlexNet(nn.Module): def __init__(self, num_classes=1000, dropout=0.5): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2), # 224 -> 55 nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), # 55 -> 27 nn.Conv2d(64, 192, kernel_size=5, padding=2), # 27 -> 27 nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), # 27 -> 13 nn.Conv2d(192, 384, kernel_size=3, padding=1), # 13 -> 13 nn.ReLU(inplace=True), nn.Conv2d(384, 256, kernel_size=3, padding=1), # 13 -> 13 nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), # 13 -> 13 nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=3, stride=2), # 13 -> 6 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Dropout(dropout), nn.Linear(256 * 6 * 6, 4096), nn.ReLU(inplace=True), nn.Dropout(dropout), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Linear(4096, num_classes), ) def forward(self, x): return self.classifier(self.features(x))

尺寸推演我建议你自己拿笔算一遍,公式是out = floor((in + 2p - k) / s) + 1。以第一层为例:输入224,卷积核11,步长4,填充2,代入得(224 + 4 - 11) / 4 + 1 = 55.25,向下取整是55。这个55不是随便来的,如果你把输入改成227(有些实现就是这么干的),算出来是(227 + 4 - 11) / 4 + 1 = 56,整条尺寸链就全变了,到最后的展平维度对不上,报错信息还特别难懂。

显存账本也值得算一算。单看参数量,两个4096的全连接层加起来约5400万参数,按float32算就是216MB,光存权重就要这么多。但训练时显存占用的大头其实在激活值,批大小32、输入224的情况下,第一层卷积的输出是32×64×55×55,约620万个float,加上反向传播要保存的中间结果,单层就能吃掉几百MB。这也是为什么AlexNet在今天看来参数量不算夸张,但训练时的显存压力并不小。

注意:nn.ReLU(inplace=True)能省一点显存,但如果你后续要做特征图可视化或者梯度分析,inplace操作会破坏原始张量,导致结果对不上。调试阶段建议先设成False,跑通了再开。

4. VGG:把网络设计变成一道可以验算的算术题

4.1 两个3×3替代一个5×5:感受野和参数量的双重推导

VGG最核心的贡献就是这个结论:两个3×3卷积堆叠,感受野等价于一个5×5卷积。这个结论的推导过程值得亲手算一遍,因为它是后面所有"小卷积核"设计的理论起点。

感受野的递推公式是:RF_i = RF_(i-1) + (k_i - 1) × stride_累计。从第一层开始算,假设前一层感受野为1,步长全为1:第一层3×3卷积,RF = 1 + (3-1)×1 = 3;第二层再叠一个3×3,RF = 3 + (3-1)×1 = 5。两个3×3串起来,感受野确实是5×5。同理,三个3×3串起来是7×7,和一层7×7等价。

参数量上的差异更关键。假设输入输出通道都是C,一个5×5卷积的参数量是5×5×C×C = 25C²,两个3×3卷积的参数量是2 × 3×3×C×C = 18C²。18C²对比25C²,省了28%。通道数越多,这个绝对差值越大。

但参数量的节省只是表面,更重要的差别在非线性数量。一个5×5卷积后面只跟一个激活函数,两个3×3卷积后面跟两个激活函数。多出来的那次非线性变换,让网络的表达能力实质性地增强了。这一点在做消融实验时特别明显:同样感受野、同样参数预算,两层小卷积的精度通常优于一层大卷积。

这个结论也不是没有边界。当你需要非常大的感受野时,比如要覆盖整张图的全局上下文,堆叠小卷积的层数会变得不可接受,这也是后来空洞卷积和自注意力机制出现的原因之一。所以"小卷积核更好"这句话要加个前提:在感受野需求适中的常规卷积层里。

4.2 VGG16配置表逐段拆解与参数分布真相

VGG的结构可以用一张很规整的表描述,每一段都是"若干个3×3卷积 + 一个2×2最大池化",池化后通道翻倍、尺寸减半:

阶段卷积配置输出通道输出尺寸(224输入)
第1段conv3-64 × 264224 → 224 → 112
第2段conv3-128 × 2128112 → 112 → 56
第3段conv3-256 × 325656 → 56 → 28
第4段conv3-512 × 351228 → 28 → 14
第5段conv3-512 × 351214 → 14 → 7

有意思的是,VGG16的前面几个阶段参数量反而很小。第一段的两个卷积层,参数量分别是3×3×3×64 = 1728和3×3×64×64 = 36864,加起来不到4万。真正的大头在第四、第五段,每个512×512的卷积层参数量是3×3×512×512 = 2359296,约236万,这两个阶段加起来就有十几层这么多。

但即便如此,卷积层的参数量总和也只有约1470万。剩下的一亿多去哪了?全在第一个全连接层:输入是512×7×7 = 25088维,输出4096维,参数量25088×4096 = 102,760,448,超过一亿,占整个VGG16参数量的74%左右。第二个全连接层1677万,第三个409万,三个加起来约1.23亿。

这个分布说明了一个很反直觉的事实:VGG16看起来是个很"深"的卷积网络,但它75%的参数其实在最后的分类头上。这也解释了为什么VGG16做迁移学习时,把分类头一换,参数量能瞬间掉到一千多万。

4.3 全连接层是这个结构最沉重的包袱

既然全连接层占了这么多参数,为什么原始设计不砍掉它?因为当时的认知是"卷积层提特征、全连接层做决策",全连接层的密集连接被认为对最终的类别判别有帮助。这个认知后来被GoogLeNet的全局平均池化打破了。

实际项目里,处理VGG的全连接层有几种常见做法,我按推荐程度排一下。

第一种,直接砍掉整个分类头,换成全局平均池化加一层线性层。这样参数量从1.38亿降到约1470万,模型文件从500多MB降到60MB左右,推理速度也快很多。代价是要重新训练,精度通常会掉零点几个百分点,但可以通过微调补回来。

第二种,把第一个全连接层的输入改成自适应池化。比如把7×7的输入池化到4×4,那么全连接层输入就变成512×16 = 8192,参数量直接降到3355万,省了三分之二。这个做法保留了全连接的结构,改动最小,适合不想动太多代码的场景。

第三种,用1×1卷积替代全连接层。1×1卷积在参数量上和全连接层等价(都是输入通道乘输出通道),但它保留了空间维度,可以做全卷积推理,适合需要处理任意尺寸输入的场景。

代码上,改造分类头大概长这样:

import torch.nn as nn from torchvision import models # 加载预训练权重,只替换分类头 vgg = models.vgg16(weights="DEFAULT") in_features = vgg.classifier[0].in_features # 25088 vgg.classifier = nn.Sequential( nn.Linear(in_features, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, 256), nn.ReLU(inplace=True), nn.Linear(256, 10), ) # 冻结前面的卷积层,只训分类头 for p in vgg.features.parameters(): p.requires_grad = False trainable = sum(p.numel() for p in vgg.parameters() if p.requires_grad) print(f"可训练参数: {trainable / 1e6:.2f} M")

冻结卷积层这一步在小数据集上几乎是必须的。如果你的数据只有几千张,直接全网络微调,前面几层的预训练特征会被破坏得一塌糊涂,最后训出来的效果还不如只训分类头。经验值是数据量低于一万张时先冻结前两个阶段,观察几轮loss再决定要不要解冻。

提示:VGG的输入尺寸必须是224的倍数关系才能对齐到7×7,如果你手头的数据是其他尺寸,要么统一resize到224,要么把最后的自适应池化改成AdaptiveAvgPool2d((7, 7))强行对齐。后者的好处是不用改数据预处理,坏处是会损失一部分细节。

5. GoogLeNet:当"该用多大卷积核"这个问题没法回答时

5.1 Inception模块的并行分支到底在赌什么

VGG的思路是把所有卷积统一成3×3,用一个规则解决所有问题。GoogLeNet的思路完全相反:既然我无法确定某个位置到底该用1×1、3×3还是5×5,那我就把这几条路并行铺开,让网络在训练中自己学每条路的权重。

一个标准的Inception模块有四条并行分支:

  • 分支一:1×1卷积,负责捕捉最局部的通道间关系;
  • 分支二:1×1卷积降维后接3×3卷积,负责中等尺度的空间模式;
  • 分支三:1×1卷积降维后接5×5卷积,负责更大尺度的空间模式;
  • 分支四:3×3最大池化后接1×1卷积,负责保留最强的响应并做通道投影。

四条分支的输出在通道维度上拼接起来,作为整个模块的输出。这个设计的好处很明显:不同尺度的特征被显式地并行提取,而不是靠网络自己去"选"一个尺度。对于包含不同大小物体的自然图像,这种多尺度并行的设计天然有优势。

但天真的实现会遇到一个致命问题:如果直接对输入做3×3和5×5卷积,通道数会爆炸。假设输入是192通道,3×3分支输出128通道,5×5分支输出32通道,光这两条分支的参数量就是192×128×9 + 192×32×25 = 221184 + 153600 = 374784,而整个模块的预算可能只有十几万。所以必须降维。

5.2 1×1卷积的降维账:拿3a模块算一遍

1×1卷积在这里的作用不是提特征,而是压缩通道。它在不改变空间尺寸的前提下,把通道数从192压到96或16,然后再做3×3和5×5卷积,参数量立刻降下来。

以第一个Inception模块(3a)为例,输入192通道,四条分支的配置是:1×1输出64;1×1输出96接3×3输出128;1×1输出16接5×5输出32;池化后1×1输出32。总输出通道64+128+32+32=256。逐条算参数量:

分支计算过程参数量
1×1192 × 6412288
降维1×1 + 3×3192×96 + 96×128×918432 + 110592 = 129024
降维1×1 + 5×5192×16 + 16×32×253072 + 12800 = 15872
池化 + 1×1192 × 326144
合计—约 163328

对比一下不降维的情况:3×3分支直接192×128×9 = 221184,5×5分支直接192×32×25 = 153600,两条加起来就是37万,是整个降维方案的2.3倍。这就是1×1卷积的价值——它用一个廉价的线性变换把通道数砍下来,让后面的空间卷积在低维空间里做。

这个技巧在今天的各种轻量网络里被反复使用,名字变成了"瓶颈结构"或者"降维投影",但本质没变:先用1×1把通道压缩到一个可控的范围,做完空间卷积再升回去或拼接。

5.3 全局平均池化和辅助分类器,两个省参数又稳训练的设计

全局平均池化是GoogLeNet最漂亮的一笔。VGG那个一亿参数的全连接层,在GoogLeNet里被一个操作替代了:对每个通道的所有空间位置取平均,得到512维的向量,然后直接接一个线性层到1000类。这一层的参数量是512×1000 = 512000,约51万,对比VGG的1.03亿,差了200倍。

有人担心这样会损失空间信息,实际上分类任务本来就需要位置无关的特征,全局平均恰好强制网络学到"哪里有这个物体"而不是"在哪个固定位置有这个物体"。副作用是网络的定位能力会弱一些,这也是后来做检测任务时要在全局平均池化之前引出特征图的原因。

辅助分类器是另一个容易忽略的设计。在网络的中间位置,GoogLeNet插入了两个辅助分类头,各自接一个小的分类损失,最终损失是主损失加上这两个辅助损失的加权和(权重0.3)。这个设计的初衷是缓解深层网络的梯度消失,让中间层也能收到有效的梯度信号。

但后续研究发现,辅助分类器的作用其实更接近正则化,而不是解决梯度问题。在训练充分的情况下,把它去掉精度基本不受影响,甚至有轻微提升。我在复现时的做法是:训练时可以加,方便观察中间层的收敛情况;最终部署时去掉,减少计算量。

5.4 手写Inception模块并对齐通道

import torch import torch.nn as nn class Inception(nn.Module): def __init__(self, in_ch, ch1x1, ch3x3_red, ch3x3, ch5x5_red, ch5x5, pool_proj): super().__init__() self.b1 = nn.Sequential( nn.Conv2d(in_ch, ch1x1, kernel_size=1, bias=False), nn.BatchNorm2d(ch1x1), nn.ReLU(inplace=True), ) self.b2 = nn.Sequential( nn.Conv2d(in_ch, ch3x3_red, kernel_size=1, bias=False), nn.BatchNorm2d(ch3x3_red), nn.ReLU(inplace=True), nn.Conv2d(ch3x3_red, ch3x3, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(ch3x3), nn.ReLU(inplace=True), ) self.b3 = nn.Sequential( nn.Conv2d(in_ch, ch5x5_red, kernel_size=1, bias=False), nn.BatchNorm2d(ch5x5_red), nn.ReLU(inplace=True), nn.Conv2d(ch5x5_red, ch5x5, kernel_size=5, padding=2, bias=False), nn.BatchNorm2d(ch5x5), nn.ReLU(inplace=True), ) self.b4 = nn.Sequential( nn.MaxPool2d(kernel_size=3, stride=1, padding=1), nn.Conv2d(in_ch, pool_proj, kernel_size=1, bias=False), nn.BatchNorm2d(pool_proj), nn.ReLU(inplace=True), ) def forward(self, x): return torch.cat([self.b1(x), self.b2(x), self.b3(x), self.b4(x)], dim=1) # 对应论文中的 3a 模块 block = Inception(192, 64, 96, 128, 16, 32, 32) out = block(torch.randn(2, 192, 28, 28)) print(out.shape) # torch.Size([2, 256, 28, 28])

这里有三个必须对齐的细节,错一个就报维度错或者拼接失败。

第一,空间尺寸必须保持不变。四条分支的输出尺寸必须一致才能cat。3×3卷积配padding=1,5×5配padding=2,池化用3×3、步长1、padding=1,这样四条分支的空间维度全都是28×28。如果你把某条的padding写错了,拼接时就只有通道对得上、尺寸对不上。

第二,输出通道数必须等于四条分支之和。上面例子里64+128+32+32=256,所以下一个模块的输入通道必须写成256,否则连不上。

第三,bias=False配合批归一化。卷积层后接BN时,偏置项会被BN的减均值操作抵消掉,纯属浪费参数,所以设成False。这个小细节能省下不少参数,也避免了BN和bias在数值上的重复。

再补充一个降维模块的写法。GoogLeNet在两个Inception段之间会插入降维模块,用步长2的卷积和池化同时把尺寸减半、通道加倍:

class InceptionDown(nn.Module): def __init__(self, in_ch, ch3x3, ch5x5, pool_proj): super().__init__() self.b1 = nn.Sequential( nn.Conv2d(in_ch, ch3x3, kernel_size=3, stride=2, padding=1, bias=False), nn.BatchNorm2d(ch3x3), nn.ReLU(inplace=True), ) self.b2 = nn.Sequential( nn.Conv2d(in_ch, ch5x5, kernel_size=1, bias=False), nn.BatchNorm2d(ch5x5), nn.ReLU(inplace=True), nn.Conv2d(ch5x5, ch5x5, kernel_size=3, stride=2, padding=1, bias=False), nn.BatchNorm2d(ch5x5), nn.ReLU(inplace=True), ) self.b3 = nn.Sequential( nn.MaxPool2d(kernel_size=3, stride=2, padding=1), ) def forward(self, x): return torch.cat([self.b1(x), self.b2(x), self.b3(x)], dim=1)

这个模块只有三条分支,因为降维时不需要1×1那条路——它没法下采样。拼完之后尺寸从28变14,通道数从256涨到480左右。把这个拼装逻辑理清楚,你就能从零搭出一个完整的GoogLeNet,而不是只会调用现成接口。

6. 四个网络横向对比与选型、踩坑

6.1 参数量、计算量、精度的对照

光看参数量会误导人,因为参数量和计算量不是一回事。VGG16参数量大头在全连接层,但计算量的大头其实在卷积层。下面这张表是我在不同项目里实测后整理的,不同实现和硬件会有出入,看相对关系就好:

网络参数量前向计算量(224输入)相对精度定位适合场景
LeNet-5约6万可忽略手写数字级别教学、极轻量分类
AlexNet约6100万约0.7 GFLOPs中等理解深度网络训练方法
VGG16约1.38亿约15.5 GFLOPs较高特征提取、风格迁移
GoogLeNet约680万约1.5 GFLOPs较高资源受限的分类任务

这张表里最值得琢磨的是VGG16和GoogLeNet的对比:VGG16参数量是后者的20倍,计算量是后者的10倍,但精度并没有压倒性优势。这说明参数和算力堆到一定程度后,边际收益急剧递减,结构设计的效率才是关键。这个认知直接催生了后来的移动端轻量网络。

另一个反直觉的点是AlexNet的计算量其实只有0.7 GFLOPs,比VGG16小得多,但参数量只比VGG16少一半。原因是它的第一层用了步长4的大卷积,把空间尺寸快速压下去,后续层的计算量就很小了;而参数量主要堆在全连接层,那部分不产生多少计算量。所以"参数量大"不等于"跑得慢",评估部署成本时一定要看计算量。

6.2 迁移学习时backbone怎么选

这个问题我被问过很多次,我的回答基本固定:

如果数据量小(几千张以内)、任务和自然图像接近,用GoogLeNet或者更现代的轻量结构。参数少意味着不容易过拟合,训练也快。VGG在这个场景下反而不合适,那一个多亿的参数在小数据上很容易记住训练集。

如果要做的是特征提取而不是端到端分类,比如风格迁移、感知损失、图像检索,VGG16依然是个好选择。它的特征层次清晰、每一层的语义明确,社区里的预训练权重也最成熟,各种论文里的对比基准都是它。虽然参数多,但推理时只用到前面的卷积层,全连接层可以直接扔掉,成本并不高。

如果要复现或者对比某些历史方法,那就得按原论文的结构来,别自作主张改。我见过有人为了"效果更好"把VGG的池化改成步长卷积,然后发现和参考文献的数字对不上,白折腾好几天。

一个实操上的经验:迁移学习时不要一上来就解冻全部层。正确的顺序是先冻结卷积层、只训分类头,训到loss平稳,再逐段解冻。每解冻一段,学习率降一个量级。这个流程看起来繁琐,但比一次性全解冻稳定得多,尤其是在数据量不大的情况下。批归一化层的统计量在微调初期不要更新(设成eval模式),否则小批次的统计量会把预训练学到的分布带偏。

6.3 我在实际项目里踩过的几个坑

第一个坑是输入尺寸和预训练权重的冲突。有一次做迁移学习,数据是医疗影像,尺寸是512×512,我图省事直接resize到224,结果小病灶全部糊掉了,精度比从头训练还差。后来改成保留原尺寸、把网络的最后几个下采样层去掉,精度立刻上来了。教训是:预训练权重的输入尺寸是个硬约束,但如果你有充分的数据,改尺寸重新训往往比强行迁就更好。

第二个坑是批归一化的momentum设错。默认值是0.1,在做小批量(比如批大小8)训练时,统计量的更新会非常不稳定,表现为loss剧烈震荡。把momentum调到0.01或者改用GroupNorm能明显缓解。这个问题在VGG这种不含BN的原始结构里不存在,但你一旦给它加上BN就会遇到。

第三个坑是把预训练权重的预处理搞混。不同的预训练权重对应不同的归一化参数,均值方差有的是0.485/0.456/0.406那一组,有的是0.5那一组。用错了不会报错,只是精度莫名其妙掉几个点,排查起来特别费劲。我的习惯是在数据加载的地方写死注释,标明这组参数对应哪个权重来源。

第四个坑是学习率没跟着批大小调整。把批大小从32调到128,学习率不跟着放大,训练会明显变慢甚至卡住。经验法则是批大小翻倍、学习率也翻倍(线性缩放),但超过一定批大小后要改用平方根缩放。这个细节在复现论文时特别重要,因为论文里的学习率往往是针对特定批大小调的。

第五个坑是过早下结论说某个结构不行。我曾经因为AlexNet在某个小数据集上表现不如一个三层小网络,就断言"深度没用"。后来发现是训练轮数不够、学习率没调好。深度网络需要的训练预算比浅层网络大得多,用同样的轮数去比,结论必然是错的。

最后分享一个小技巧:复现这四个网络时,建议把每一层的输出尺寸和参数量打印出来,做成一张表贴在手边。我自己的习惯是在__init__里加一段调试代码,构造一个假输入跑一遍,把每层的shape和numel都打出来。这个习惯帮我抓出过无数次尺寸对不上的问题,比看报错信息猜测快得多。而且当你亲手把这些数字算过一遍之后,再看到任何一个新网络的结构图,脑子里会自动浮现出它的参数量级和计算瓶颈在哪,这种直觉是看多少篇解读文章都换不来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进