首页 / 资讯中心 / 文章详情

从LeNet到GoogLeNet:CNN经典网络结构、参数量与代码复现

从LeNet到GoogLeNet:CNN经典网络结构、参数量与代码复现 ★ FEATURED ARTICLE
前阵子帮一个刚转方向的朋友串卷积网络的知识线他把 LeNet、AlexNet、VGG、GoogLeNet 的模型图都看了一遍代码也能照着敲出来跑通但合上教程就说不清一件事这些结构为什么要长成那样。这个反馈我听过太多次了。这四个网络几乎是所有 CNN 学习路径上的必经节点网上单篇讲结构的、单篇贴代码的都不少缺的是把设计动机、尺寸推演、参数量核算和可直接运行的代码串成一条线的整理。下面我按自己的理解重走一遍重点是那些教程里一句话带过、但实际动手最容易卡住的地方特征图尺寸怎么一步步变小、参数量到底花在了哪、为什么有的网络堆深度有效有的堆了就崩、几个模块的代码写成什么样才算真的复现。1. 先搞清楚这四个网络各自解决了什么问题动手写代码之前把四个网络的定位摆清楚能省掉大量抄完不知道为什么的时间。它们不是四个并列的知识点而是一条有时间顺序的演进链每一代都在回应上一代暴露出来的具体缺陷。看清这条链后面再看任何一个新结构你都能快速判断它大概是在补哪个环节的漏。1.1 一条主线看清四代结构演进LeNet 出现在上世纪九十年代目标非常朴素证明卷积加池化这套组合能在图像上做端到端的分类。它的输入是单通道的手写数字图整体只有七层参数量六万出头。它的历史价值在于确立了卷积提特征、池化降尺寸、全连接做判别这个三段式骨架这个骨架到今天都没变只是每一段都变复杂了。很多人觉得 LeNet 太简单可以直接跳过我的看法相反——它是最适合用来做尺寸推演练习的样本层数少手算一遍不费劲把每一步的张量形状写在纸上对后面理解大网络的感受野和降采样节奏帮助极大。AlexNet 回应的是数据量上来了模型容量不够这个矛盾。它把网络加深加宽到八层五个卷积加三个全连接参数量冲到六千万级别同时引入了 ReLU 激活、Dropout、重叠池化、局部响应归一化这一串工程手段。关键点在于它不是单纯把 LeNet 放大而是解决了放大之后训练不动的问题——Sigmoid 在深层会饱和导致梯度消失ReLU 把这个瓶颈拆掉了全连接层参数量爆炸导致过拟合Dropout 把它压住了。理解这一层你就明白为什么同样的结构在当年是突破放到今天却只是常规操作。VGG 走的是另一条路不再追求花哨的模块而是把用小的卷积核反复堆叠这件事做到极致。整个网络的卷积核统一是 3×3池化统一是 2×2结构规整到可以用一个数字列表来描述。它证明了深度本身带来表征能力的提升代价是参数量飙到一亿三千万以上其中绝大部分压在最后三个全连接层上。VGG 是理解感受野和参数效率这对矛盾最好的教材也是迁移学习里最常用的骨干之一因为它的特征提取层规整、权重好复用。GoogLeNet 面对的是 VGG 留下的烂摊子参数太多、计算太贵。它的思路是横向扩展——在同一个层级上并行放多个不同尺度的卷积分支然后把结果拼接起来这就是 Inception 模块。配合 1×1 卷积做通道降维它把参数量压到了五百万到七百万的量级比 VGG 少了一个数量级以上深度却做到了二十二层。同时它用全局平均池化替掉了庞大的全连接层这个改动后来几乎成了所有现代网络的标配。网络年份量级深度带参数层参数量级核心贡献LeNet1990s7约 6 万确立卷积-池化-全连接骨架AlexNet20128约 6100 万ReLU、Dropout、重叠池化证明深度可训VGG201416/19约 1.38 亿小卷积核堆叠规整可复用GoogLeNet201422约 700 万Inception 多分支、1×1 降维、全局平均池化这张表建议背下来。面试和实际选型时这四个数字6 万、6100 万、1.38 亿、700 万能帮你快速判断一个网络的复杂度量级也能解释为什么某些老结构在移动端完全没法用。1.2 学习顺序和容易走偏的地方我推荐的顺序是严格按时间线走LeNet → AlexNet → VGG → GoogLeNet。理由不是历史顺序好听而是每条后路都建立在前面搭好的认知上。LeNet 让你熟悉张量形状的推演AlexNet 让你理解工程 tricks 的威力激活函数选错深度就是白搭VGG 让你吃透感受野和参数效率的取舍GoogLeNet 让你学会用 1×1 卷积做通道管理。跳过任何一步后面都会出现看得懂但写不出的情况。最容易走偏的有两个地方。第一个是一上来就调库。用框架自带的模型接口加载一个预训练权重几行代码就能出结果但这个过程里你完全没有接触形状推演和参数计算遇到自定义输入尺寸或者要改结构时就彻底懵。我的建议是至少把 LeNet 和 AlexNet 手写一遍一个通道一个通道地对不要复制粘贴。第二个是只关心准确率不关心显存和速度。实际项目里模型能不能塞进目标设备、单次前向要多少毫秒往往比多一个点的准确率重要得多。建议每写完一个网络就用工具统计一下参数量和浮点运算量养成习惯。还有一个隐蔽的坑不同教程里的 AlexNet 结构并不完全一致。原始版本用了分组卷积受当时显存限制两组各占一半通道输入是 224×224 但实际做了裁剪第一层卷积核 11×11 步长 4。后来的实现大多简化成不带分组的版本。这两种写法都对但你得知道差异在哪否则对照论文时会怀疑自己抄错了。提示手写网络时每一层后面都打印一次输出张量的形状不要等最后报错才回头找。形状错误在深层网络里定位成本极高。2. LeNet把卷积网络跑通的最小样本LeNet 的结构简单到可以用一段话描述完但正因为简单它是唯一适合你完全手算的网络。我带的几个新手都是从这一步开始把每一层的输入输出形状、卷积核数量、参数个数列成表格做完这个练习后面再复杂的网络在他们眼里都只是这个表格变长了。2.1 逐层拆解与尺寸、参数量推演原始 LeNet 的输入是 32×32 的单通道图像这个尺寸是为了让 5×5 卷积之后还能留出足够的空间。今天我们做手写数字分类通常用 28×28 的输入只要在第一层加 padding 对齐就行。下面按 28×28 输入、第一层 padding2 的常见改法来推。第一层卷积输入 1×28×286 个 5×5 卷积核padding2 保证输出尺寸不变得到 6×28×28。参数量是 1×6×5×5 6 156。接下来 2×2 平均池化步长 2尺寸砍半到 6×14×14。第二层卷积输入 6×14×1416 个 5×5 卷积核不加 padding输出 16×10×10。参数量 6×16×5×5 16 2416。再池化到 16×5×5。到这里卷积部分结束展平得到 400 维向量。第一个全连接层映射到 120 维参数 400×120 120 48120。第二个全连接 120→84参数 120×84 84 10164。最后一个 84→10参数 84×10 10 850。加总156 2416 48120 10164 850 61706。这个数字很有意思——超过七成的参数集中在第一个全连接层。这个分布在后来的网络里几乎没变过只是绝对值大了几百倍。记住这个规律看到某个网络参数爆炸先去看它的全连接部分。层输入形状操作输出形状参数量conv11×28×285×5, 6 核, pad 26×28×28156pool16×28×282×2 平均, 步长 26×14×140conv26×14×145×5, 16 核16×10×102416pool216×10×102×2 平均, 步长 216×5×50fc1400全连接12048120fc2120全连接8410164fc384全连接10850关于激活函数要单独说一句。原始 LeNet 用的是 tanh 或 sigmoid 这类饱和激活今天的实现几乎都换成了 ReLU。这不是可有可无的替换——在只有五层的网络里差异不明显但在十几层的网络里饱和激活会让反向传播的梯度迅速衰减到接近零训练直接停滞。如果你是为了理解历史原貌用 tanh如果是为了跑通和刷指标用 ReLU。我的做法是先按原版实现一遍跑通再换 ReLU 对比收敛曲线这个对比做一次就永远记住了。2.2 PyTorch 实现与训练坑位代码写成模块化结构特征提取和分类器分开方便后续替换。这个习惯要从 LeNet 就养成后面改 VGG 和 GoogLeNet 时会省很多事。import torch import torch.nn as nn class LeNet(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 6, kernel_size5, stride1, padding2), # 28x28 - 28x28 nn.Tanh(), nn.AvgPool2d(kernel_size2, stride2), # 28 - 14 nn.Conv2d(6, 16, kernel_size5, stride1), # 14 - 10 nn.Tanh(), nn.AvgPool2d(kernel_size2, stride2), # 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))几个实测下来容易出问题的点。第一AvgPool2d在 PyTorch 里默认count_include_padTrue如果你改了 padding池化结果会和预期有偏差手算验证时对不上就会怀疑是自己写错了。第二展平那一步的维度必须是16*5*5一旦你手贱改了输入尺寸或者某层的 padding这里立刻报错所以一定要把输入尺寸和这个常量绑定起来别写死。第三全连接层输入维度用变量算出来更稳妥with torch.no_grad(): dummy torch.zeros(1, 1, 28, 28) flat_dim self.features(dummy).numel()训练部分用交叉熵损失加 SGD 或 Adam 都能收敛。学习率 0.01 配 SGD 加动量 0.9 是我比较常用的组合二十轮左右在标准手写数字数据集上能到 98% 以上的准确率。注意 LeNet 没有 BatchNorm对学习率比较敏感学习率设到 0.1 很容易发散这点和后面的 VGG 不一样。提示跑 LeNet 时把 batch size 开到 128 就够开太大反而因为每个 batch 梯度太稳、更新次数变少而收敛变慢小网络不需要那么大的 batch。3. AlexNet第一次把深变成战斗力从 LeNet 到 AlexNet参数量涨了一千倍但真正带来质变的不是规模而是几处针对深了训不动的工程改造。我第一次读 AlexNet 的时候把它当成 LeNet 的放大版结果自己复现时训练损失横盘不动排查了半天才发现问题出在细节上。这一节把这几个细节拆开讲。3.1 五个真正起作用的改动第一个是 ReLU。这个改动今天看理所当然但当时是革命性的。sigmoid 和 tanh 在输入绝对值较大时导数趋近于零多层叠加之后梯度几乎传不回去。ReLU 在正区间导数恒为 1梯度可以近乎无损地穿过十几层。实测对比同样的结构把 tanh 换成 ReLU收敛速度能差三到五倍。代价是 ReLU 在负区间导数为零可能造成神经元死亡所以学习率不能太大这也是为什么 AlexNet 的初始学习率只有 0.01 而不是 0.1。第二个是 Dropout用在两个 4096 维的全连接层上丢弃率 0.5。这两层的参数量占了整个网络的九成以上是全连接部分过拟合的主要来源。Dropout 在训练时随机把一半神经元置零相当于每次都在训练一个不同的子网络推理时用全部神经元并做缩放。这个操作的效果非常直接不加 Dropout 时训练集准确率几个 epoch 就到 100%验证集卡在 60% 左右加上之后训练集准确率降下来了验证集反而稳步上升。第三个是重叠池化。传统的池化核和步长相等池化窗口不重叠AlexNet 用了 3×3 的池化核配步长 2窗口之间有两行一列的重叠。这个改动让池化后的特征信息损失更少也让输出尺寸的下降节奏更平缓。计算一下55×55 经过 3×3 池化、步长 2输出是 (55-3)/21 27而不是 27 以下的整数。第四个是局部响应归一化。这一层现在几乎没人用了因为后来的 BatchNorm 在效果和实现上都更好。但你要知道它为什么被加进去——它模拟了生物神经元的侧抑制机制对相邻通道的响应做归一化让激活值大的通道更突出。复现时我建议保留它的位置但可以直接省掉对最终精度的影响在一个点以内。第五处是数据增强和训练策略。随机裁剪、水平翻转、色彩抖动这一套组合等效于把训练样本翻了几十倍。我自己的经验是小规模数据集上这套增强带来的收益比换网络结构还明显。3.2 代码实现与显存、增强细节下面是不带分组卷积的简化版本输入 224×224这是现在最常见的写法。class AlexNet(nn.Module): def __init__(self, num_classes1000, dropout0.5): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 96, kernel_size11, stride4, padding2), # 224 - 55 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size3, stride2), # 55 - 27 nn.Conv2d(96, 256, kernel_size5, padding2), # 27 - 27 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size3, stride2), # 27 - 13 nn.Conv2d(256, 384, kernel_size3, padding1), # 13 - 13 nn.ReLU(inplaceTrue), nn.Conv2d(384, 384, kernel_size3, padding1), # 13 - 13 nn.ReLU(inplaceTrue), nn.Conv2d(384, 256, kernel_size3, padding1), # 13 - 13 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size3, stride2), # 13 - 6 ) self.classifier nn.Sequential( nn.Flatten(), nn.Dropout(dropout), nn.Linear(256 * 6 * 6, 4096), nn.ReLU(inplaceTrue), nn.Dropout(dropout), nn.Linear(4096, 4096), nn.ReLU(inplaceTrue), nn.Linear(4096, num_classes), ) def forward(self, x): return self.classifier(self.features(x))参数量的账要算清楚卷积部分约 250 万全连接部分约 5900 万后面三个全连接层占了 96%。这个分布解释了两件事——为什么 AlexNet 对显存的要求主要来自全连接层以及为什么后续网络都在想办法干掉全连接。显存方面全精度训练时单个样本前向加上反向传播的中间激活224×224 输入、batch size 32 大概要 3 到 4 GB。如果显存紧张优先级从高到低是减小 batch size、把输入降到 128、把 fc1 的 4096 砍到 2048。最后这个改动会让精度掉一点点但在显存上省得很明显。数据增强我一般这么写from torchvision import transforms train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])归一化用的那组均值方差是统计大量自然图像得到的经验值直接照用就行不要自己算。特别是你要加载预训练权重的时候这套归一化必须一致否则特征分布对不上精度会莫名其妙掉很多。注意RandomResizedCrop的 scale 下限别设得太狠0.6 是个比较稳的起点设到 0.2 会让模型花大量时间学局部纹理小数据集上反而掉点。4. VGG用小卷积堆出规整结构VGG 的核心思想一句话能说完既然增加深度能提升性能那就把所有卷积核统一成 3×3然后一直堆。听起来简单粗暴但它背后的数学账非常漂亮是理解感受野概念最好的切入口。4.1 3×3 堆叠的数学账先看感受野。一个 3×3 卷积的感受野是 3×3两个 3×3 串联第二层的每个位置能看到第一层 3×3 的范围而第一层的每个位置又覆盖原始 3×3总覆盖率是 5×5三个串联则是 7×7。也就是说n 个 3×3 串联等效于一个 (2n1)×(2n1) 的卷积核。再看参数。假设输入输出通道都是 C一个 7×7 卷积的参数量是 49C²三个 3×3 卷积的参数量是 3×9C² 27C²减少了约 45%。换成两个 3×3 对 5×5是 18C² 对 25C²减少 28%。参数量省下来了感受野却没变。还有第三个好处这个最容易被忽略非线性变多了。一个 7×7 卷积后面只跟一个激活函数三个 3×3 卷积后面跟三个激活函数网络的非线性表达能力明显增强能拟合更复杂的决策边界。这三点加起来构成了用小核堆深这套做法的完整理由。VGG 有两个常见版本16 层和 19 层区别只在于后三段各多了一个卷积。它们的卷积配置可以用列表描述VGG16 是[2, 2, 3, 3, 3]VGG19 是[2, 2, 4, 4, 4]每段后面接一次池化。通道数按 64、128、256、512、512 递增。这个规整的结构让代码写起来特别短。对比项单个 7×7 卷积三个 3×3 卷积感受野7×77×7参数量通道 C49C²27C²非线性层数13实际效果表达单一拟合能力更强参数量方面VGG16 总计约 1.38 亿其中卷积层约 1470 万全连接层约 1.23 亿。也就是说卷积部分只占了 11%剩下 89% 全在最后三个全连接层里。这个数字是理解为什么后来的网络都在做全局平均池化的钥匙。把第一层全连接从 25088 映射到 4096单这一层就是 1.02 亿参数。这层吃掉的显存和计算量远超前面十三层卷积的总和。4.2 代码实现与迁移学习实操用工厂函数生成重复块这是 VGG 最优雅的写法。def vgg_block(in_ch, out_ch, num_convs): layers [] for i in range(num_convs): layers.append(nn.Conv2d(in_ch if i 0 else out_ch, out_ch, kernel_size3, padding1)) layers.append(nn.BatchNorm2d(out_ch)) layers.append(nn.ReLU(inplaceTrue)) layers.append(nn.MaxPool2d(kernel_size2, stride2)) return nn.Sequential(*layers) class VGG16(nn.Module): def __init__(self, num_classes1000, cfg(64, 128, 256, 512, 512), blocks(2, 2, 3, 3, 3)): super().__init__() in_ch 3 feats [] for out_ch, n in zip(cfg, blocks): feats.append(vgg_block(in_ch, out_ch, n)) in_ch out_ch self.features nn.Sequential(*feats) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(4096, num_classes), )我在每个卷积后加了 BatchNorm原始 VGG 是没有的。这个改动的收益很大加了 BN 之后可以用更大的学习率0.01 到 0.1 都行收敛轮数明显减少而且对初始化不敏感。代价是每个卷积层多了一组参数和两次额外运算显存占用增加大概 20%。如果是严格复现论文去掉 BN如果是实际做项目强烈建议加上。迁移学习的实操要点。VGG 的features部分提取的是通用视觉特征前几段的特征几乎和任务无关可以直接冻住后两段的特征开始和具体类别相关需要微调。我的常用策略是先冻结全部卷积层只训分类器十轮左右让新分类头先适应然后解冻最后两段用十分之一的学习率继续训。这样比一上来全放开微调收敛更稳也不容易出现前面的层被小数据集带偏的情况。有一个细节要特别注意VGG 的输入尺寸必须是 224×224原因在于展平后的维度 512×7×7 是和输入尺寸严格绑定的。如果你把输入改成 256×256经过五次池化后特征图是 8×8展平后是 32768 维全连接层立刻报错。有两种解法一是自适应池化到 7×7 再展平二是干脆改成全局平均池化。前者改动小后者更现代。# 解法一自适应池化保留全连接 self.features.add_module(gap, nn.AdaptiveAvgPool2d((7, 7))) # 解法二全局平均池化干掉大全连接 self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(512, num_classes), )解法二把参数量从 1.38 亿压到 1500 万左右精度损失通常在半个点以内某些情况下反而更好因为过拟合的风险大幅降低了。这个改法我在小数据集上跑过多次基本是稳赚不赔。提示加载预训练权重时如果改了分类器的结构记得用strictFalse或者手动过滤掉不匹配的键否则会直接抛出键名不匹配的错误。5. GoogLeNet把一个网络拆成多条路到 VGG 这里加深这条路的性价比已经见底了——参数量涨到亿级精度提升却越来越少。GoogLeNet 换了个方向不比谁更深比谁更会分配计算量。它的核心就是 Inception 模块一个在同一个层级上并行使用多种尺度卷积的结构。5.1 Inception 的动机与 1×1 降维的算术先想一个问题同一张图里目标可能占满整个画面也可能只是角落里的一小块。用固定尺寸的卷积核去提取特征总会对某一种尺度更友好。最直觉的做法是并行放 1×1、3×3、5×5 三个分支最后把输出在通道维度上拼起来让网络自己决定该用哪个尺度。这就是 Inception 模块最朴素的版本。问题是参数爆炸。假设输入是 192 通道每个分支都直接输出 128 通道那 5×5 分支的参数量就是 192×128×25 614400三个分支加起来接近百万一个模块就吃掉这么多堆二十几个模块根本受不了。解法是在 3×3 和 5×5 卷积前面各加一个 1×1 卷积做通道压缩。还是 192 通道输入先把 5×5 分支的输入压到 32 通道192×32×1 6144然后 32×128×25 102400合计 108544比原来的 614400 少了 82%。3×3 分支同样处理压到 96 通道192×96 96×128×9 18432 110592 129024而不降维时是 192×128×9 221184省了 42%。1×1 卷积还有第二个作用跨通道信息整合。它本质上是在通道维度上做一次线性组合把相关的通道特征加权求和这在特征表达上是有实际意义的不只是为了省参数。分支不降维参数量降维后参数量节省比例3×3 分支22118412902442%5×5 分支61440010854482%注意第三个分支是池化。Inception 里也有一个 3×3 最大池化的分支池化本身不改变通道数所以后面必须再跟一个 1×1 卷积把通道压下来否则拼接后的总通道数会失控。5.2 辅助分类器与全局平均池化GoogLeNet 有二十二层带参数的层在那个年代属于非常深的网络。为了让梯度能有效传到前面的层它在中间位置插了两个辅助分类器分别接在第 3 和第 6 个 Inception 模块后面各自带一个小的全连接分类头损失按 0.3 的权重加到总损失里。这个设计的逻辑很直白给中间层一个直接的监督信号避免它们只能通过长长的反向路径学习。辅助分类器的实现要点先做 5×5 平均池化把尺寸降到 4×4接一个 1×1 卷积把通道压到 128然后展平接到 1024 维全连接最后输出类别数。训练时三个损失一起反传推理时把两个辅助头丢掉只用主分类头。这个训练时多用、推理时不用的模式后来被很多结构借鉴。另一个更重要的改动是用全局平均池化替代全连接。在最后一个 Inception 模块后面对每个通道的 7×7 特征图做平均得到一个 1024 维向量直接接一个线性层输出类别。这一步把原本可能上千万参数的全连接层变成了零参数平均池化本身没有可学习参数。这是 GoogLeNet 参数量能压到七百万的关键之一。副作用是可解释性变好了——每个通道的平均激活值可以直接对应到某个类别的置信度。注意全局平均池化要求输入特征图的通道数等于类别数或者后面接一个线性层做映射。如果你直接照抄结构记得检查最后一层的通道配置。5.3 代码实现逐块说明Inception 模块的代码结构很清晰四个分支并行最后拼接。class Inception(nn.Module): def __init__(self, in_ch, c1, c3_reduce, c3, c5_reduce, c5, pool_proj): super().__init__() self.branch1 nn.Sequential( nn.Conv2d(in_ch, c1, kernel_size1), nn.ReLU(inplaceTrue)) self.branch2 nn.Sequential( nn.Conv2d(in_ch, c3_reduce, kernel_size1), nn.ReLU(inplaceTrue), nn.Conv2d(c3_reduce, c3, kernel_size3, padding1), nn.ReLU(inplaceTrue)) self.branch3 nn.Sequential( nn.Conv2d(in_ch, c5_reduce, kernel_size1), nn.ReLU(inplaceTrue), nn.Conv2d(c5_reduce, c5, kernel_size5, padding2), nn.ReLU(inplaceTrue)) self.branch4 nn.Sequential( nn.MaxPool2d(kernel_size3, stride1, padding1), nn.Conv2d(in_ch, pool_proj, kernel_size1), nn.ReLU(inplaceTrue)) def forward(self, x): return torch.cat([self.branch1(x), self.branch2(x), self.branch3(x), self.branch4(x)], dim1)四个分支都用了 padding 保证空间尺寸一致否则拼接时除了通道维度其他维度对不上直接报错。这是实现 Inception 时最常见的错误我见过不少人卡在这里。完整网络的搭建需要注意几个衔接点。开头是一个 7×7 卷积配步长 2把 224 降到 112接 3×3 最大池化降到 56。然后接两个 Inception 模块在第二个后面接一个 3×3 池化降到 28。接着是一串 5 个 Inception中间插池化降到 14。再是 4 个 Inception 加池化降到 7最后接全局平均池化。三个阶段的通道配置分别是 64/128/32/32 起步、128/192/96/64 起步、192/208/48/64 起步每个模块的四个分支通道数都要单独指定这是 Inception 模块最繁琐的地方——它不是用几个超参数就能描述的必须逐个写死。辅助分类器class AuxHead(nn.Module): def __init__(self, in_ch, num_classes): super().__init__() self.pool nn.AdaptiveAvgPool2d((4, 4)) self.conv nn.Conv2d(in_ch, 128, kernel_size1) self.fc nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 1024), nn.ReLU(inplaceTrue), nn.Dropout(0.7), nn.Linear(1024, num_classes), ) def forward(self, x): return self.fc(self.conv(self.pool(x)))Dropout 率 0.7 比主干的全连接层高因为辅助头只用来正则不承担最终判别任务。训练时把辅助损失按 0.3 加权加上去out, aux1, aux2 model(x) loss criterion(out, y) 0.3 * criterion(aux1, y) 0.3 * criterion(aux2, y)推理时只用out。这里有个实现细节辅助头必须放在训练分支里model.eval()时要么直接跳过计算要么算了不用前者能省下一点推理时间。6. 常见问题与排查技巧实录这一节记录的是我复现这四个网络时真实踩过的坑以及后来带人时别人踩的坑。大部分问题有共性按网络特征归类一下会更好排查。6.1 训练不收敛与梯度异常最典型的症状是损失值横盘不动或者在一开始就跳到 nan。按嫌疑程度排查学习率太大是头号原因。VGG 加了 BN 之后对学习率容忍度高AlexNet 和 LeNet 没有 BN学习率开到 0.1 很容易炸。我的做法是从 1e-3 开始试确认能收敛再往上加。第二嫌疑是数据归一化。很多人只做了ToTensor没做Normalize输入值在 0 到 1 之间第一批权重初始化又偏大激活值直接饱和。加上标准归一化能解决大部分训练半天不降的情况。第三是激活函数和初始化的搭配。用 ReLU 配默认初始化一般没问题但如果用了 sigmoid 或者 tanh权重初始化就不能太大否则第一层就饱和了。PyTorch 的默认初始化对 ReLU 做了适配换成其他激活时需要手动考虑。判断梯度是否正常我习惯在网络开头和几处中间层的反向传播里挂 hook打印梯度范数。正常的训练梯度范数应该在 1e-3 到 1e-1 之间波动如果某一层持续接近零说明有梯度消失或者神经元大面积死亡如果持续在 10 以上说明有梯度爆炸需要加梯度裁剪或者降学习率。def grad_hook(name): def hook(grad): print(f{name} grad norm: {grad.norm().item():.6f}) return hook x model.features[0](x) x.register_hook(grad_hook(conv1))6.2 显存、batch 与数据管道显存不够是所有人在跑 AlexNet 和 VGG 时都遇到的。我先说结论这四个网络里显存压力的排序是 VGG16 AlexNet GoogLeNet LeNet而且差距非常大。VGG16 的全连接层在 batch 32 时需要 10 GB 以上的显存而 GoogLeNet 在同样的 batch 下只要 2 GB 出头。降显存的优先级顺序我一般这么排先做全局平均池化替掉大全连接省最多改动中等再减 batch size立竿见影但影响训练稳定性再降输入分辨率省得多但影响精度最后才考虑混合精度或者梯度累积。梯度累积这个技巧值得单独说一句——用小的 batch 分多次前向反向、累积梯度后统一更新能在小显存上模拟大 batch 的效果。实现上就是每 N 步才调一次optimizer.step()并清零梯度。数据管道这块有个隐蔽的瓶颈如果用了太多 CPU 端的数据增强GPU 会一直等数据利用率只有二三成。判断方法是看训练过程中 GPU 利用率是否规律性地掉到零。解法是把num_workers开到 CPU 核数的一半左右并开启pin_memory和persistent_workers。用随机裁剪加色彩抖动这类操作时这个瓶颈尤其明显。6.3 问题速查表与避坑心得现象最可能原因处理办法损失直接变 nan学习率过大或数据未归一化降到 1e-3检查归一化损失长时间横盘激活函数饱和或梯度消失换 ReLU检查初始化训练集 100% 验证集不动过拟合缺正则加 Dropout、加数据增强展平维度报错输入尺寸与全连接维度不匹配用自适应池化或动态计算维度GPU 利用率忽高忽低数据管道成为瓶颈调大 num_workers开 pin_memory加载预训练权重报键名错误分类头结构被改动strictFalse 或手动过滤键Inception 拼接报错四分支空间尺寸不一致统一 padding 和池化步长验证精度远低于预期归一化参数和预训练不一致使用标准的均值方差再补三条心得。第一条别迷信论文里的超参数。论文写学习率 0.01 是基于当年的批量大小和硬件条件你现在的 batch size 和优化器都不一样超参数必须重调我一般先做一轮学习率扫描再定。第二条结构差异不要凭记忆写。AlexNet 是不是分组、VGG 有没有 BN、GoogLeNet 最后一层通道数多少这些细节记混一次就会浪费半天。我的做法是每写一个网络就在旁边开一个表格逐层记录输入输出形状和参数量写完对着总数核对一遍。参数量对了结构基本就对。第三条跑通比精度更重要。复现这几个网络的目的通常不是刷榜而是建立对卷积网络的直觉。第一遍只要能训起来、损失正常下降、验证精度在一个合理范围就已经达到目的了。追求那最后一个点的精度往往需要大量调参和算力性价比不高。我带人的时候LeNet 和 AlexNet 只要能上手写出来、说明白每一层在干什么、参数量能自己算对这一关就算过了VGG 和 GoogLeNet 则要求能讲清楚设计动机能根据显存限制做出合理的裁剪决策。最后分享一个我自己常用的训练验证套路每写完一个网络先用极小规模的数据跑一轮确认能把损失降到接近零这叫过拟合测试。如果连十几个样本都拟合不了说明结构或者代码有 bug这时候再去调超参数纯属浪费时间。这个习惯帮我省掉的排查时间比任何调参技巧都多。
阅读完成 · 觉得有帮助?
咨询建站