四大经典卷积网络,只要你在做视觉相关的活儿,早晚都会回头翻它们一遍。不是因为它们现在还有多能打,而是因为它们把“怎么设计一个能跑起来的卷积网络”这件事,从零到一地演示了四遍——LeNet演示了范式,AlexNet演示了工程,VGG演示了极简结构的力量,GoogLeNet演示了在算力受限时怎么用结构设计换效率。很多人学 CNN 卡在“能抄代码但不知道为啥这么写”,根本原因就是没把这几套网络拆到骨头里去看。这篇东西写给两类人:刚入门、想搞明白卷积层输出尺寸怎么算、参数量怎么数的新手;以及已经能跑通训练脚本、但一遇到显存爆炸、尺寸对不上、收敛困难就抓瞎的实践者。我会把四套网络的结构逐层拆开、参数一个个算给你看、代码一段段贴出来,再把这些年踩过的坑整理成排查表。看完你至少能做到:拿到任意一个卷积网络结构图,自己算出每层输出尺寸和参数量,并且能判断哪块是性能瓶颈。
1. 四个网络到底该怎么定位
把四个网络当成四个不同年代的“解题思路”,比当成四个模型要有用得多。它们的差别不在准确率上那几个百分点,而在于每一代作者面对的核心约束完全不同:算力、数据量、梯度传播、计算效率。你把约束想清楚了,结构为什么长这样自然就通了。
1.1 四套方案的核心矛盾对照
先给一张对照表,把四者的“时代任务”摆出来,后面所有细节都挂在这张表上。
| 网络 | 出现年代背景 | 要解决的核心矛盾 | 标志性手段 | 参数量级 |
|---|---|---|---|---|
| LeNet-5 | 手写数字识别,算力极度稀缺 | 能不能让网络自己学特征,替代人工设计 | 卷积+池化+全连接的完整范式 | 约 6 万 |
| AlexNet | 大规模图像分类,GPU 刚可用 | 深网络训练不动、过拟合严重 | ReLU、Dropout、数据增强、双卡拆分 | 约 6000 万 |
| VGG | 追求可复现的结构设计 | 网络该多深、卷积核该多大 | 全部 3×3 小核堆叠 | 约 1.38 亿 |
| GoogLeNet | 精度要涨但算力不能涨 | 加深加宽带来的计算量爆炸 | Inception 多分支 + 1×1 降维 | 约 500 万 |
这张表最值得盯的是最后一列。VGG 用 1.38 亿参数换来的精度,GoogLeNet 用 500 万参数就超过了——差了将近 27 倍。这个反差是理解 Inception 模块的全部动机。
1.2 什么场景该翻经典,什么场景直接上预训练
实操里我先问自己一个问题:这次的任务,是学结构还是出结果?
如果目标是学结构、改结构、写论文、做结构对比实验,那这四个网络必须手写一遍。手写的价值在于你必须自己算尺寸、自己对通道数,算错一次就报错一次,报错三次你就记住了。用现成的库函数调用,永远学不会。
如果目标是出结果、赶工期、做业务落地,那直接用成熟的主干网络加预训练权重,四个经典网络里只有 VGG 还偶尔被拿来当特征提取器(因为它的中间层特征语义比较干净、结构规整),其余三个在现代业务里基本没有直接使用的价值。
还有一种中间情况:边缘设备、算力极受限的场景。这种时候 LeNet 那套“小卷积核 + 池化降采样 + 极少通道”的思路反而会被重新捡起来,因为你的输入可能只有 32×32,通道数只有 1 或 3,根本喂不饱大模型。我见过不少工业质检、传感器信号转图像的小项目,最后落地的结构复杂度也就比 LeNet 高一点。
提醒:不要因为“经典”就默认它们适合当 baseline。LeNet 在彩色自然图像上几乎必然欠拟合,AlexNet 的分组卷积设计在今天看是纯粹的工程妥协,VGG 的显存占用会让你的显卡直接跪下。选它们之前先算清楚输入尺寸和显存。
2. LeNet-5:小到能手算参数,却把范式定死了
LeNet-5 最迷人的地方在于它足够小,小到你可以拿张纸把每一层的参数量全部算出来,算完你对卷积网络的理解就完成了第一次闭环。它的结构在今天看平平无奇,但在它出现的年代,“让网络自己从像素里学特征”这个想法本身就是颠覆性的——在此之前,特征是人手工设计的。
2.1 逐层拆解与参数量手算
原始结构接收 32×32 的单通道图像,走完是 10 类输出。我把每一层的尺寸变化和参数量列出来,你拿计算器跟着按一遍。
| 层 | 操作 | 输入尺寸 | 输出尺寸 | 参数量计算 | 参数量 |
|---|---|---|---|---|---|
| C1 | 6 个 5×5 卷积 | 32×32×1 | 28×28×6 | 6×(5×5×1+1) | 156 |
| S2 | 2×2 平均池化 | 28×28×6 | 14×14×6 | 0 | 0 |
| C3 | 16 个 5×5 卷积 | 14×14×6 | 10×10×16 | 16×(5×5×6+1) | 2416 |
| S4 | 2×2 平均池化 | 10×10×16 | 5×5×16 | 0 | 0 |
| C5 | 120 个 5×5 卷积 | 5×5×16 | 1×1×120 | 120×(5×5×16+1) | 48120 |
| F6 | 全连接 84 | 120 | 84 | 84×(120+1) | 10164 |
| 输出 | 全连接 10 | 84 | 10 | 10×(84+1) | 850 |
加起来大约 6.1 万个参数。这个量级放在今天,连一个稍大的全连接层都比它多。但注意 C5 那一层——它吃掉了 4.8 万参数,占了全网的 79%。原因很简单:5×5×16 的输入被拉平成 400 维,再映射到 120 维,这一步是密集连接的。这也解释了为什么后来大家都不喜欢在网络末端堆大卷积核。
尺寸变化用一条公式统一起来,这是全文最重要的一条公式,后面三个网络全靠它:
out = floor((in + 2×padding - kernel_size) / stride) + 1以 C1 为例:in=32,padding=0,kernel=5,stride=1,代入得 (32-5)/1+1 = 28,对上了。C3 输入 14,得 (14-5)+1 = 10,也对上。你自己把每一层代进去验一遍,比看十遍结构图都管用。
2.2 那些结构图里不会写的设计取舍
第一个坑:原始论文的 C3 层并不是全连接的。它用了一张稀疏连接表,规定 16 个输出通道中的每一个只连接前一层 6 个通道里的某几个。这么做是为了打破对称性、减少参数。但现代实现几乎全部改成标准全连接,因为稀疏连接表在 GPU 上算起来非常不友好,省下的那点参数完全不值得。你看到的绝大多数 LeNet 复现代码,C3 的参数都是 2416,那就是全连接版本。
第二个坑:激活函数用的是 tanh 或 sigmoid,不是 ReLU。这不是疏忽,是那个年代 ReLU 还没普及。你复现的时候如果按原版用 tanh,收敛会慢一些;换成 ReLU 也没问题,甚至更好训,但严格意义上那就不叫原版 LeNet-5 了。我在做结构对比实验时会两版都留着,看同一数据集上的收敛曲线差异,这个对比本身就很说明问题。
第三个坑:池化用的是平均池化不是最大池化。原因很朴素——那个年代的硬件实现里,平均池化更容易做,反传也更简单。现代结构里最大池化更常见,因为它保留了显著特征。这个替换对最终精度的影响通常在 1 个百分点以内,但在小数据集上波动会明显一些。
第四个坑,也是最容易踩的:输入尺寸 32×32 是刻意设计的。为什么不用 28×28 直接吃手写数字的原始尺寸?因为经过两次 5×5 卷积和两次 2×2 池化之后,28 会变成 (28-5+1)=24 → 12 → (12-5+1)=8 → 4 → 最后一层 5×5 卷积就无处安放了。32 走完刚好落在 5→1 这个漂亮的边界上。你如果硬改成 28 输入,要么加 padding,要么砍掉一层,要么改最后一层的核大小。
2.3 复现时的实操顺序
我建议的复现顺序是这样的:先把 PyTorch 的官方 MNIST 数据加载跑通,确认数据形状是[N, 1, 28, 28];然后在数据变换里加一个Resize(32)把图撑到 32×32;接着照结构表搭网络;最后用torchsummary之类的工具打印每层输出形状,逐层跟你的手算表格对照。
这一步千万不要跳。我自己第一次复现的时候,池化层写成了AvgPool2d(2, stride=1),尺寸只缩了一半,一路错到全连接层才报维度不匹配,报错信息指向的是全连接层的输入维度,跟真正的错误点差了四层,找了快一个小时。后来我养成了习惯:每加一层就打印一次形状,五六行代码的事,能省下大量时间。
# 逐层验证形状的小工具,比看报错快得多 x = torch.randn(1, 1, 32, 32) for name, layer in net.named_children(): x = layer(x) print(f"{name:12s} -> {tuple(x.shape)}")3. AlexNet:结构不难,难的是让它真的能训起来
很多人第一次看 AlexNet 的结构会觉得“就这?”。确实,单看层的排列,它就是“五个卷积 + 三个全连接”,比后面的网络朴素太多。但它真正的价值根本不在结构上,而在于它把一堆当时零散的训练技巧系统性地组合到了一起,证明了深层网络是可以训出来的。
3.1 结构拆解与关键层的参数账
先按 224×224×3 输入、加 padding 的现代写法把尺寸走一遍。原始论文用的是 227 输入不加 padding,得到的结果一样,只是边界处理不同。
| 层 | 操作 | 输出尺寸 | 参数量 |
|---|---|---|---|
| conv1 | 96 个 11×11,stride 4,pad 2 | 55×55×96 | 96×(11×11×3+1)=34944 |
| pool1 | 3×3 最大池化,stride 2 | 27×27×96 | 0 |
| conv2 | 256 个 5×5,pad 2 | 27×27×256 | 256×(5×5×96+1)=614656 |
| pool2 | 3×3 最大池化,stride 2 | 13×13×256 | 0 |
| conv3 | 384 个 3×3,pad 1 | 13×13×384 | 384×(3×3×256+1)=885120 |
| conv4 | 384 个 3×3,pad 1 | 13×13×384 | 384×(3×3×384+1)=1327488 |
| conv5 | 256 个 3×3,pad 1 | 13×13×256 | 256×(3×3×384+1)=884992 |
| pool3 | 3×3 最大池化,stride 2 | 6×6×256 | 0 |
| fc6 | 4096 | 4096 | 4096×(6×6×256+1)=37752832 |
| fc7 | 4096 | 4096 | 4096×4097=16781312 |
| fc8 | 1000 | 1000 | 1000×4097=4097000 |
总参数量大约 6000 万,其中 fc6 一层就占了 3775 万,超过六成。这个数字很关键——它告诉你 AlexNet 的瓶颈根本不在卷积部分,全在最后那三个全连接层。很多人做迁移学习时把卷积部分冻住只训分类头,结果显存照样爆炸,就是因为没意识到 fc6 这个巨无霸的存在。
conv1 那层的 11×11 大核加 stride 4 也是个很有时代感的操作。大步长意味着第一层就把 224 的空间分辨率砍到 55,计算量急剧下降。现代网络基本不会在第一层用这么大的核,因为大核的参数效率太低——11×11×3 一共 363 个权重只产生一个输出值,而 3×3 的核只需要 27 个。VGG 正是抓住了这一点,把大核全面替换成了小核堆叠。
3.2 论文里一句话带过、实际最关键的四件事
第一件:ReLU 替代饱和激活函数。这个改动看起来最小,影响最大。sigmoid 和 tanh 在输入绝对值较大时会进入饱和区,梯度接近 0,深网络反传几层之后梯度就消失了。ReLU 在正半轴导数恒为 1,梯度能一路传回去。我做过一个对照实验,同一结构下把 ReLU 换成 tanh,在相同训练轮数内,训练损失下降速度大概慢三四倍,而且越深的层越难收敛。
第二件:Dropout 放在全连接层之间。丢弃率 0.5,只作用在 fc6 和 fc7 上。它的作用机理是每次前向随机屏蔽一半神经元,逼着网络学冗余表征,不允许任何单个神经元承担过重的职责。这本质上是一种集成学习的近似。注意 Dropout 只在训练时开启,推理时必须关掉,否则预测结果会不稳定。用 PyTorch 的话,model.train()和model.eval()会自动帮你切换,但如果你自己写了自定义的训练循环又忘了调用这两个方法,就会出现“训练正常、测试结果随机抖动”的诡异现象。
第三件:数据增强。原论文用了随机裁剪(从 256×256 裁到 224×224)、水平翻转、以及一个基于主成分分析的颜色扰动。前两个是常规操作,第三个现在很少用了,实现复杂收益有限。但随机裁剪这一招很值得琢磨:它同时起到了两个作用——扩大有效数据量、以及让网络学会位置不变性。裁剪带来的“同一张图不同位置”的样本,等于免费告诉网络“物体在哪不重要”。
第四件:双卡分组。这是纯工程妥协,当时单卡显存放不下整个模型,作者把网络拆到两张卡上,conv2、conv4、conv5 只连接同一张卡上的特征图。这个设计后来被抽象成了“分组卷积”,在移动端网络里大放异彩。但在 AlexNet 里它就是个被迫的选择,你在单卡上复现时完全不需要这么做,直接写标准卷积即可。
注意:AlexNet 原论文里还有局部响应归一化层,接在 conv1 和 conv2 后面。后来的大量消融实验表明,去掉它精度几乎不掉,现代实现基本都删掉了。你要是照论文复现却发现效果不如预期,先检查是不是这个层引入的额外超参在捣乱。
3.3 从 AlexNet 能学到的三件事
一是结构创新的收益往往小于训练技巧的收益。同样是那个年代,很多结构上更精致的网络并没有跑赢 AlexNet,差距就在训练策略上。做实验的时候,先别急着改结构,把优化器、学习率调度、数据增强、正则化这些调透了,往往能白捡好几个点。
二是显存预算要提前算。AlexNet 的 6000 万参数,如果全用 float32 存,光权重就 240MB,加上梯度、优化器状态、激活值,训练时显存占用是这个的好几倍。全连接层的激活值尤其恐怖:4096 维的向量,batch size 给 128,一层就是 128×4096×4 字节 = 2MB,看着不多,但反传要保留前向的所有中间结果,累计起来就很可观了。
三是别迷信论文里的超参。论文里的学习率、权重衰减系数都是针对特定数据集和特定硬件调出来的,搬到你的任务上大概率不合适。我的习惯是先按论文设置跑一遍当基准,然后固定其他变量,只动学习率做三次扫描,一般就能找到合适的量级。
4. VGG:把结构设计做成了填空题
VGG 的贡献不在“发明”了什么,而在“验证”了什么。它用一组极其规整的实验告诉我们:把卷积核全部换成 3×3 并不断堆叠,网络能深到 16 到 19 层而依然训得动,而且精度随深度单调提升。这个结论在当时是相当反直觉的,因为此前大家普遍认为深网络训不动。
4.1 两个 3×3 为什么能顶一个 5×5
这是 VGG 最核心的理论依据,也是面试里被问得最多的问题。要看两件事:感受野和参数量。
感受野的角度:两层 3×3 卷积堆叠,第二层的每个输出点看到的是第一层的 3×3 区域,而第一层每个点又看到输入的 3×3 区域,合起来第二层看到的是输入的 5×5 区域。三层 3×3 堆叠等效于一个 7×7。这个推导的关键在于中间没有池化层打断——一旦插入池化,感受野的连续性就被破坏了。
参数量的角度,假设输入输出通道数都是 C:
- 一个 5×5 卷积:参数 25C² + C
- 两个 3×3 卷积:参数 2×(9C² + C) = 18C² + 2C
当 C 比较大时(比如 256),前者约 164 万,后者约 118 万,省了将近三成。再看 7×7 对三个 3×3:前者 49C²+C,后者 27C²+3C,省得更多。
但参数量省只是表层原因,更关键的是非线性。两层 3×3 之间夹了两个 ReLU,而一个 5×5 只有一个 ReLU。多出来的非线性让网络的表达能力更强,决策函数更复杂。这个理由比省参数重要得多,也是 VGG 之后“小核堆叠”成为主流设计语言的根本原因。
4.2 VGG16 结构表与显存账
VGG16 的分组很规整:2、2、3、3、3 个卷积块,每块之后接一次 2×2 最大池化,最后三个全连接层。卷积层总数 13,加全连接 3 层,共 16 层,这就是名字的由来。VGG19 是 2、2、4、4、4,卷积 16 层,加全连接共 19 层。
| 阶段 | 卷积配置 | 输出通道 | 输出尺寸(224 输入) |
|---|---|---|---|
| block1 | 3×3 ×2 | 64 | 112×112 |
| block2 | 3×3 ×2 | 128 | 56×56 |
| block3 | 3×3 ×3 | 256 | 28×28 |
| block4 | 3×3 ×3 | 512 | 14×14 |
| block5 | 3×3 ×3 | 512 | 7×7 |
| 分类头 | fc 4096 → 4096 → 1000 | - | - |
参数量的大头在分类头:第一个全连接层的输入是 7×7×512 = 25088 维,映射到 4096 维,参数就是 4096×25089 ≈ 1.03 亿。整个 VGG16 的 1.38 亿参数里,三个全连接层占了 1.24 亿,卷积部分只有约 1470 万。
这个数字直接解释了为什么 VGG 在今天不受待见:一个 224×224 的输入要吃掉大量显存,而绝大部分参数都在最后那三层全连接里。做迁移学习时,标准的做法就是把分类头整个砍掉,换成全局平均池化加一个新的分类层。换完之后参数量从 1.38 亿降到约 1470 万,显存占用减少近九成,而且因为全局平均池化本身带正则化效果,在小数据集上反而不容易过拟合。
提示:VGG 的 block 之间特征尺寸减半、通道数翻倍,这个模式几乎是所有后续网络的默认模板。你在设计自己的网络时,如果输入分辨率每降一次就把通道数提一倍,能保证各层的计算量大致均衡,不会出现某一层突然成为瓶颈。
4.3 复现 VGG 的正确姿势
第一个建议:用循环生成 block,别手动一层层写。VGG 的规整性就是它最大的优势,写成配置列表再拼装,二十几行代码就能把 VGG11 到 VGG19 全部生成出来。我常用的配置格式是[64, 64, 'M', 128, 128, 'M', ...],用 'M' 标记池化位置,这样改深度只需要改一个列表。
第二个建议:通道数的递增规律要理解,不要死记。为什么每个 block 的通道数翻倍?因为空间分辨率减半了,特征图的总元素数保持不变,这时候把通道数翻倍,能让每个 block 的计算量大致相当。如果通道数保持不变,后面的 block 计算量会急剧下降,网络的容量就浪费了。
第三个建议:不要用原版的 4096 维全连接头,除非你在做大规摸数据集且显存充裕。改成全局平均池化 + 单层分类器,精度损失通常在 1 个点以内,训练速度和显存占用改善非常明显。我现在所有用到 VGG 做特征提取的场景,全部用这个改法。
第四个建议:输入尺寸不要随便改。VGG 的全连接层要求固定尺寸输入,因为它用了view把所有空间维度拉平。你如果换成 256×256 输入,走到最后是 8×8×512 = 32768 维,全连接的权重矩阵就对不上了。解决办法要么是加自适应池化,要么是把输入固定。这也是 VGG 被诟病“不够灵活”的原因之一。
5. GoogLeNet:用结构设计硬生生省下九成算力
GoogLeNet 是我认为四个网络里设计感最强的一个。VGG 的思路是“一味加深”,GoogLeNet 的思路是“在同等算力预算下,怎么让网络同时变深变宽”。它给出的答案是 Inception 模块,核心手法是 1×1 卷积降维。这一招后来几乎被所有高效网络继承,包括各种移动端主干。
5.1 Inception 模块到底在解决什么矛盾
矛盾很具体:如果你想让网络变宽,让同一层同时用 3×3、5×5、池化等多种感受野去观察输入,最直接的做法是并行堆叠这些分支然后把输出拼起来。但这么做通道数会爆炸——假设输入是 192 通道,每个分支都直接输出 128 到 256 通道,拼起来轻松超过 500 通道,下一层的计算量直接翻几倍。
Inception 的解法是:在每个分支前面先塞一个 1×1 卷积,把通道数压下去,再做大核卷积。这样就变成了“先降维、再卷积、最后拼接”。以最经典的那个配置为例:输入 28×28×192,四个分支分别是
- 分支一:1×1 卷积直接输出 64 通道
- 分支二:1×1 卷积压到 96 通道,再 3×3 卷积输出 128 通道
- 分支三:1×1 卷积压到 16 通道,再 5×5 卷积输出 32 通道
- 分支四:3×3 最大池化(padding 保持尺寸),再 1×1 卷积输出 32 通道
四个分支的输出在通道维度拼接,得到 64+128+32+32 = 256 通道,空间尺寸还是 28×28。对比一下不分降维的情况:如果直接用 192 通道输入去做 3×3 输出 128,单这一支的参数就是 128×(3×3×192+1) ≈ 22 万;加了 1×1 降维后,变成 96×193 + 128×(3×3×96+1) ≈ 1.85 万 + 11 万 ≈ 12.9 万,省了四成多。5×5 那一支省得更狠,因为 5×5 的参数是 25 倍关系。
5.2 1×1 卷积的三重身份
很多人第一次看到 1×1 卷积会觉得莫名其妙——核大小是 1,那不是什么都看不到吗?实际上它的作用完全不在空间维度上,而在通道维度上。
第一重身份:降维器。1×1 卷积把每个像素位置上所有通道的值做加权求和,输出通道数由卷积核个数决定。输入 192 通道,用 64 个 1×1 核,输出就是 64 通道。这就是跨通道的信息压缩。
第二重身份:非线性增强器。1×1 卷积后面接 ReLU,等于在通道维度上做了一次非线性变换。这个效果在 Network in Network 那篇工作里被重点讨论过,它让网络在同样的空间感受野下有了更强的表达能力。
第三重身份:通道融合器。在多分支结构里,各分支的输出需要拼接,而拼接之后通道之间的信息是割裂的。后面接一个 1×1 卷积,就能把来自不同分支的特征重新混合加权,让网络自己学“哪个分支的信息更重要”。
这三重身份里,第一重最常用,第三重最容易被忽略但很重要。我在做结构改写时,只要涉及多分支拼接,后面一定跟一个 1×1 卷积做融合,效果比直接接下一层稳定得多。
注意:1×1 卷积虽然参数量小,但它的计算量并不为零——它是逐像素的矩阵乘法,空间尺寸越大,乘加次数越多。在 56×56 这种分辨率上堆太多 1×1 卷积,FLOPs 涨得比想象中快。算力预算紧张时,优先在高分辨率阶段少放分支。
5.3 辅助分类器与全局平均池化
GoogLeNet 还有两个设计值得单独说。
辅助分类器:在中间的第三个和第六个 Inception 模块之后,各引出一个小的分类分支,接一个平均池化、一个卷积、两个全连接,输出也是 1000 类。训练时这两个分支的损失以 0.3 的权重加进总损失,推理时直接丢掉。目的很直接——给中间层提供额外的梯度信号,防止深层网络梯度消失。这个手段在今天的深层网络里演化成了各种中间监督,思路是一脉相承的。
全局平均池化:GoogLeNet 最后的分类头不是三个大全连接,而是把 7×7×1024 的特征图做全局平均池化变成 1024 维向量,再映射到 1000 类。这一步把参数从 VGG 那种上亿的量级压到了百万级,而且因为每个通道直接对应一个类别分数,可解释性也更强。这个设计今天已经是标配,几乎看不到不用全局平均池化的现代网络了。
还有一个小细节:GoogLeNet 分类头里的全连接层其实还保留了一个 1024 维的隐层,并且带 Dropout。真正完全去掉全连接的版本是后续的改进型。你复现时用哪种都行,但要知道原始版本不是“纯”全局平均池化。
6. 实战代码:四套网络一次跑通
下面给出四套网络的 PyTorch 实现,都做了适度现代化(用 ReLU、去掉不适用的层),但结构骨架严格对应原版。我把每层的尺寸变化写在注释里,方便你对照手算结果。
6.1 LeNet 与 AlexNet 的实现
import torch import torch.nn as nn class LeNet5(nn.Module): """输入 1x32x32,输出 10 类""" def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, 5), # 32 -> 28 nn.Tanh(), nn.AvgPool2d(2, 2), # 28 -> 14 nn.Conv2d(6, 16, 5), # 14 -> 10 nn.Tanh(), nn.AvgPool2d(2, 2), # 10 -> 5 nn.Conv2d(16, 120, 5), # 5 -> 1 nn.Tanh(), ) self.classifier = nn.Sequential( nn.Linear(120, 84), nn.Tanh(), nn.Linear(84, num_classes), ) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) return self.classifier(x) class AlexNet(nn.Module): """输入 3x224x224(加 padding 后可整除),输出 1000 类""" def __init__(self, num_classes=1000): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 96, 11, stride=4, padding=2), # 224 -> 55 nn.ReLU(inplace=True), nn.MaxPool2d(3, stride=2), # 55 -> 27 nn.Conv2d(96, 256, 5, padding=2), # 27 -> 27 nn.ReLU(inplace=True), nn.MaxPool2d(3, stride=2), # 27 -> 13 nn.Conv2d(256, 384, 3, padding=1), # 13 -> 13 nn.ReLU(inplace=True), nn.Conv2d(384, 384, 3, padding=1), # 13 -> 13 nn.ReLU(inplace=True), nn.Conv2d(384, 256, 3, padding=1), # 13 -> 13 nn.ReLU(inplace=True), nn.MaxPool2d(3, stride=2), # 13 -> 6 ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(256 * 6 * 6, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Linear(4096, num_classes), ) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) return self.classifier(x)AlexNet 这段代码里有个易错点:padding=2是为了把 224 的输入撑到能算出 55 而不是 54。你可以自己验算:不加 padding 时 (224-11)/4+1 向下取整是 54,加了 padding=2 之后 (224+4-11)/4+1 = 55.25 向下取整 55。差这一格,后面整条链路都会跟着错位。
6.2 VGG 与 GoogLeNet 的实现
VGG 用配置列表生成,这样改深度只改一行:
VGG_CFG = { 'vgg11': [64, 'M', 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'], } def make_vgg_layers(cfg, in_channels=3): layers, c = [], in_channels for v in cfg: if v == 'M': layers.append(nn.MaxPool2d(2, 2)) else: layers.append(nn.Conv2d(c, v, 3, padding=1)) layers.append(nn.ReLU(inplace=True)) c = v return nn.Sequential(*layers) class VGG(nn.Module): def __init__(self, depth='vgg16', num_classes=1000, use_gap=True): super().__init__() self.features = make_vgg_layers(VGG_CFG[depth]) self.use_gap = use_gap if use_gap: # 现代改法:全局平均池化,参数量掉一个数量级 self.pool = nn.AdaptiveAvgPool2d((7, 7)) self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(True), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(True), nn.Dropout(0.5), nn.Linear(4096, num_classes), ) else: self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.classifier = nn.Linear(512, num_classes)GoogLeNet 的 Inception 模块是整个复现的核心:
class Inception(nn.Module): """标准 Inception 模块:四分支并行,输出通道拼接""" def __init__(self, in_c, c1, c3_reduce, c3, c5_reduce, c5, pool_proj): super().__init__() self.branch1 = nn.Sequential( nn.Conv2d(in_c, c1, 1), nn.ReLU(inplace=True)) self.branch2 = nn.Sequential( nn.Conv2d(in_c, c3_reduce, 1), nn.ReLU(inplace=True), nn.Conv2d(c3_reduce, c3, 3, padding=1), nn.ReLU(inplace=True)) self.branch3 = nn.Sequential( nn.Conv2d(in_c, c5_reduce, 1), nn.ReLU(inplace=True), nn.Conv2d(c5_reduce, c5, 5, padding=2), nn.ReLU(inplace=True)) self.branch4 = nn.Sequential( nn.MaxPool2d(3, stride=1, padding=1), nn.Conv2d(in_c, pool_proj, 1), nn.ReLU(inplace=True)) def forward(self, x): return torch.cat([self.branch1(x), self.branch2(x), self.branch3(x), self.branch4(x)], dim=1)搭的时候注意,分支四的池化必须用stride=1, padding=1,这样才能保持空间尺寸不变,否则拼接的时候尺寸对不上。这个细节我在第一次复现时踩过,报错信息是Sizes of tensors must match except in dimension 1,一看就知道是通道拼接时空间尺寸不一致,问题定位反而很快。
完整的 GoogLeNet 还需要主干卷积、九个 Inception 模块串联、以及辅助分类器。辅助分类器的实现要注意:它只在训练时产生损失,forward里要返回一个元组,训练循环里判断if isinstance(outputs, tuple)来分别处理。
6.3 对照实验的搭建思路
四个网络放在一起做对照,最大的障碍是输入尺寸不统一。LeNet 吃 32×32,另外三个吃 224×224。我的做法是选一个小分辨率数据集(比如手写数字或小尺寸彩色图),把 LeNet 单独跑一套,另外三个统一缩放到 224 跑一套,两套之间不直接比精度,只比“同参数量级下的收敛速度”和“同训练轮数下的损失曲线形状”。
训练超参我给一套能直接用的基线:
| 参数 | 取值 | 说明 |
|---|---|---|
| 优化器 | SGD + momentum 0.9 | 经典网络配经典优化器,曲线好解释 |
| 初始学习率 | 0.01 | 用 Adam 的话降到 1e-3 |
| 学习率调度 | 每 30 轮乘 0.1 | 阶梯下降,简单有效 |
| batch size | 64 | 显存不够就往下调,别硬撑 |
| 权重衰减 | 5e-4 | VGG 这种大模型必须加,小模型可减半 |
| 训练轮数 | 60 到 100 | 看收敛曲线,别死守数字 |
跑完之后一定要做的一件事:把每个网络的训练曲线和验证曲线画在同一张图上。VGG 大概率是验证精度最高但收敛最慢的,GoogLeNet 是收敛最快但曲线最抖的,LeNet 会很快收敛到一个明显偏低的平台上。这个视觉印象比任何文字说明都深刻。
7. 常见问题与排查技巧实录
这一节全部来自实际调试,按发生频率从高到低排。
7.1 尺寸对不上:一张速查表搞定
| 报错特征 | 大概率原因 | 排查动作 |
|---|---|---|
| 全连接层维度不匹配 | 前面某层池化 stride 或卷积 padding 写错 | 逐层打印形状,从后往前推 |
| 拼接时尺寸不一致 | 多分支池化没保持尺寸 | 检查分支池化是否stride=1, padding=1 |
| 输入通道数报错 | 数据集是灰度图但网络按三通道写 | 检查数据 transform 是否Grayscale |
| 最后一层空间尺寸变 0 或负数 | 卷积核比剩余特征图还大 | 重新算一遍每层输出尺寸 |
| 训练正常推理报错 | 忘了切model.eval() | 检查训练循环 |
我踩过最隐蔽的一次:给 VGG 换了个输入尺寸,卷积部分跑得好好的,到全连接层才炸。原因是全连接层的输入维度是硬编码的512*7*7,输入变成 256×256 之后特征图变成 8×8,维度就成了512*8*8。解决办法是在卷积和全连接之间插一个AdaptiveAvgPool2d((7,7)),强制把空间尺寸拉回 7×7,这样换输入尺寸就再也不用改代码了。
7.2 显存爆炸与收敛困难的处理顺序
显存爆了,按这个顺序处理,别一上来就换模型:
- 降 batch size,最直接,先降到能跑为止
- 检查是不是全连接层占大头,是的话把全连接换成全局平均池化
- 开混合精度训练,显存占用通常能砍掉三四成
- 冻结主干只训分类头,做迁移学习时这一步能省下大量显存
- 梯度累积,用小的 batch 模拟大的 batch 效果
收敛困难,思路完全不同。先看损失曲线的形状:如果损失几乎不动,十有八九是学习率太小或者初始化有问题;如果损失震荡剧烈,学习率太大;如果训练损失降但验证损失升,那就是过拟合,加正则化或者数据增强。
我自己遇到过一个特别迷惑的情况:模型训练损失一路下降,验证精度纹丝不动,看着像过拟合,但加了 Dropout 和权重衰减毫无改善。最后查出来是数据预处理里做了归一化,但验证集用了不同的均值方差,导致训练和验证的数据分布不一致。这类问题不看代码是查不出来的,只能逐行比对两个数据管线的 transform。
提醒:凡是“训练和验证表现差距巨大但正则化无效”的情况,优先怀疑数据管线,包括但不限于:归一化参数不一致、增强只加在训练集但加过头、验证集的 shuffle 设成了 True 导致指标抖动、类别标签映射错位。这些问题比模型结构问题常见得多。
7.3 我个人的几条避坑清单
别在没跑通小数据集之前直接上大数据集。我的固定流程是:先在几百张图上跑通全流程,确认能过拟合(训练精度接近 100%),再放大数据量。如果小数据集都过拟合不了,说明模型或代码有 bug,此时换大数据集只会浪费更多时间。
别一次性把所有超参都调一遍。固定其他变量,一次只动一个,否则你根本不知道收益来自哪里。我一般按“学习率 → batch size → 正则化强度 → 数据增强”这个顺序调,前两个影响最大。
权重初始化别用全零。全零初始化会让同一层的所有卷积核学到完全相同的东西,网络退化成线性模型。用默认的初始化方法就行,PyTorch 的Conv2d默认是均匀分布初始化,够用了。
池化层的 stride 默认值容易记错。MaxPool2d(2)的 stride 默认等于 kernel_size,也就是 2,这是对的;但MaxPool2d(3)的 stride 也是 3,而 AlexNet 里需要的是 stride=2,必须显式写。这个默认值设计坑过不少人。
复现经典网络时,先跑原版再改。我见过太多人一上来就“优化”结构,把池化改了、把激活换了、把初始化调了,最后跑出来效果不如预期,完全无法定位是哪一步引入的问题。老老实实跑一遍原版,记录基准数字,然后再做单变量改动,这样才能说清楚每个改动的贡献。
最后分享一个我自己常用的小技巧:给每个网络实现写一个test_shapes()函数,用随机张量跑一遍前向,断言每层输出形状符合预期。四个网络总共不超过四十行代码,但能挡住 80% 的低级错误,尤其是你在改结构的时候。写完之后,我基本再没遇到过那种“训练半小时才发现维度错了”的糟心事。
这套东西真正的价值不在于四个网络本身,而在于它给了你一把尺子——以后看到任何新结构,你都能用同样的方法拆开:算尺寸、数参数、找瓶颈、判断设计动机。这把尺子用熟了,看论文的速度会完全不一样。