NiN网络深度解析:1×1卷积与全局平均池化如何重塑CNN设计 在《动手学深度学习》PyTorch版的卷积神经网络章节里NiNNetwork in Network网络中的网络是我认为最容易被低估的一节。学LeNet的时候你会觉得“卷积层加全连接层”是天经地义学AlexNet和VGG的时候你又觉得“更大更深、重复堆叠”就是进步的方向。直到NiN这一节作者团队突然把两个当时没人愿意动的结构给动了一是往卷积层里塞进一个小型MLP二是直接扔掉全连接层改用全局平均池化。这篇笔记是我照着书里代码一行行敲完、把输出尺寸和训练过程都跑通之后整理出来的NiN代码注释与理解心得适合已经知道卷积、池化、全连接是什么但想进一步理解“网络结构为什么能这样设计”的读者。1. 先搞清楚NiN在质疑什么从卷积层到全连接层的两处痛点1.1 普通卷积层为什么不够用先想一个问题一个普通卷积层到底在做什么它的操作本质上是在每个感受野窗口里把输入像素与卷积核做加权求和。哪怕后面接了ReLU这层网络能表达的映射关系仍然是“一次线性变换加一次非线性激活”。卷积核数量决定了它能识别多少种局部模式但每个卷积核只能响应一种固定的线性模式一条边缘、一个色块、一种纹理方向。想表达更复杂的语义就只能加层数、加通道数让更多卷积核在更高层级上做组合。NiN作者团队看到的问题就在这里单个卷积层本身不够“聪明”。如果能在卷积核提取完局部特征之后立刻对每个位置上的特征向量做一次非线性再编码网络对复杂模式的建模能力就会强很多而不必一味靠堆叠层数。这就像批改试卷普通卷积是一个只会做加减乘除的判卷人你希望他在每个得分点上再多做几步推理而不是把整张卷子交上去等下一层老师来处理。所以NiN提出了一种很自然的改进在卷积层输出的每一个空间位置上再接一个微型全连接网络专门对“这个位置的特征通道”做信息混合。这就是MLP卷积层MLPconv的起源。后面你会看到这件事在代码里其实就是两个1×1卷积但背后的思想比代码本身重要得多。1.2 全连接层为什么招人嫌再看另一个痛点。在LeNet、AlexNet这些经典结构里网络最后一定会把特征图拉平成一维向量然后交给几个全连接层做分类。全连接层的矩阵乘法本身没什么问题但它有两个被长期忽略的坏毛病。第一个是参数量爆炸。AlexNet里三层全连接占了整个网络绝大部分参数尤其是第一层全连接要把前面多个通道的高级特征图全部展开成向量权重矩阵动辄上千万个参数。参数一多过拟合风险就直线上升你必须靠Dropout去硬扛。第二个是空间结构的彻底丢失。特征图在卷积阶段还保留着“哪个区域激活了、哪个区域没激活”的空间语义但全连接层一拉平空间位置关系就被揉碎了。这就好比你把一张地图撕成碎片再随机排列然后让模型从碎片堆里判断这是哪个城市难度和可解释性都变得更差。NiN选择用一个非常暴力的办法来解决这两个问题分类阶段不要全连接层改成全局平均池化Global Average Pooling简称GAP。这个操作会在下一章详细拆解但你可以先有个印象它把一个通道整张特征图压缩成一个数值既不引入参数又保留了“这个通道代表的语义被激活得多强”这一层意思可以说是又便宜又直观。1.3 “网络中的网络”的总体思路NiN的整体思路就是同时处理上面两个痛点。前半部分用“大卷积核提取空间特征 连续两个1×1卷积做跨通道非线性映射”来增强每一层卷积的表达能力后半部分用GAP替代全连接层把分类器的参数直接清零。这个过程可以概括成一句话不把卷积层当简单的滤波器堆叠而是把整个网络看成“在空间中滑动的小型网络”也不把最后一层当黑盒分类器而是让每个输出通道天然对应一个类别的激活强度。理解了这两点再看代码就非常顺了。2. 两大杀招逐个拆1×1卷积与全局平均池化2.1 1×1卷积在每一个像素位置上做一次全连接1×1卷积卷积核尺寸是1×1但通道数可以和输入一样多也可以自己指定。它的计算方式非常特殊普通3×3卷积会看周围3×3范围内的像素在空间上做信息交互1×1卷积完全不看周围像素只看当前坐标点在所有通道上的取值然后把这些通道线性组合成新的通道。换句话说如果特征图尺寸是H×W×C那么1×1卷积等于对H×W个位置上的每一个位置都单独做一次“C维向量到C维向量”的全连接。空间结构完全不动通道结构被重新混合。这就是为什么它经常被叫作“逐像素全连接”。NiN真正聪明的地方是把两个1×1卷积连续接在常规卷积后面。常规卷积先从空间上提取模式得到一个特征向量然后第一个1×1卷积把这个向量映射到隐藏维度第二个1×1卷积映射到最终输出通道数。每层后面都有ReLU所以这不只是两个线性操作而是一个两层MLP。你可以理解为NiN在设计时默认“每个空间位置上的特征”都需要经过一个小网络重新加工一下然后再交给下一层。一个小类比普通卷积像一个在九宫格内找模式的“观察员”1×1卷积则像一个只看报表汇总、协调各个部门信息的“调度员”。观察员负责发现问题调度员负责把问题相关信息整合出结论。2.2 全局平均池化把通道语义直接变成分类置信度全局平均池化的操作极其简单对每个通道的特征图把所有空间位置上的值取平均输出一个标量。如果最后一个卷积层输出C个通道GAP就输出C个值再送进softmax就是C个类别的概率。它的绝妙之处在于语义非常干净。假设网络最后一个卷积层有10个通道目标分类是10类那么在训练过程中网络会逐渐让第i个通道对“第i类物体出现的区域”产生强烈激活。某张图片进入网络后如果第i个通道的整体激活强度很高说明图片里很可能出现了第i类物体。于是GAP做的事情本质上就是检查“哪个语义通道被激活得最厉害”这比全连接层的黑盒映射要直观得多。同时GAP使得网络可以接受任意尺寸的输入因为不管特征图是5×5还是7×7我都可以求全局平均得到固定长度的向量。这在后面使用AdaptiveAvgPool2d时会非常省事也是为什么很多现代网络把分类头设计成“GAP 线性层”而不是“全连接层堆叠”。2.3 一张表看全连接层和全局平均池化的差距对比维度全连接层全局平均池化参数量输入维度 × 输出维度可能上千万0不需要学习参数空间结构拉平成向量位置信息丢失每个通道整体求均值保留通道语义但不关心精确位置可解释性黑盒映射很难说每个神经元代表什么可以理解为每个通道对某个类别的响应强度过拟合风险高常常需要Dropout配合低几乎不会因为分类头过拟合输出尺寸适配必须固定输入尺寸任意空间尺寸都能池化成固定长度从这张表可以看出来NiN把GAP放在最后既减少了参数又让模型更聚焦在“特征通道的语义强度”上是一步很划算的设计。3. PyTorch逐行实现NiN源码注释与尺寸推演3.1 nin_block怎么搭为什么是“一个大卷积 两个1×1”先看NiN的基本模块。书里的代码是用一个nin_block函数生成序列代码很短但信息量非常大import torch from torch import nn from d2l import torch as d2l def nin_block(out_channels, kernel_size, strides, padding): return nn.Sequential( nn.LazyConv2d(out_channels, kernel_size, strides, padding), nn.ReLU(), nn.LazyConv2d(out_channels, kernel_size1), nn.ReLU(), nn.LazyConv2d(out_channels, kernel_size1), nn.ReLU() )第一层是真正的空间卷积核负责提取局部模式核大小由参数决定后面两个1×1卷积就是前面说的MLP层负责在每个位置上做跨通道非线性映射。这里有一个容易忽略的点第一个卷积和后面两个卷积的输出通道数都等于out_channels。这意味着1×1卷积只是对特征做“再加工”不会改变特征图的空间尺寸和通道数量整体形状很规整方便串联。另一个值得注意的地方是新版本的书用了nn.LazyConv2d而不是nn.Conv2d。它的作用是第一次前向传播时自动根据输入张量推断输入通道数并初始化权重这样nin_block就不需要手动传入上一层的通道数定义网络时会更简洁。如果你的PyTorch版本比较老没有LazyConv2d就需要自己改成nn.Conv2d(in_channels, out_channels, ...)并手动维护每一层的通道数变化写起来会繁琐一些。3.2 主网络NiN从224×224到10个类别的完整过程主网络的结构如下class NiN(nn.Module): def __init__(self, num_classes10): super().__init__() self.net nn.Sequential( nin_block(96, kernel_size11, strides4, padding0), nn.MaxPool2d(3, stride2), nin_block(256, kernel_size5, strides1, padding2), nn.MaxPool2d(3, stride2), nin_block(384, kernel_size3, strides1, padding1), nn.MaxPool2d(3, stride2), nn.Dropout(0.5), nin_block(num_classes, kernel_size3, strides1, padding1), nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten() ) def forward(self, x): return self.net(x)如果你把输入固定为1×224×224的灰度图逐层推演尺寸变化会得到下面这个表层输出尺寸输入1×224×224nin_block(96, 11, 4, 0)96×54×54MaxPool2d(3, 2)96×26×26nin_block(256, 5, 1, 2)256×26×26MaxPool2d(3, 2)256×12×12nin_block(384, 3, 1, 1)384×12×12MaxPool2d(3, 2)384×5×5Dropout(0.5)384×5×5nin_block(10, 3, 1, 1)10×5×5AdaptiveAvgPool2d((1, 1))10×1×1Flatten10这个表特别好用建议每次调试网络时都用同样的方式打印一遍。第一个卷积的输出尺寸计算方法是(224 - 11) / 4 1结果取整后是54。池化后的尺寸是(54 - 3) / 2 1结果取整后是26。后面几层同理可以自己手推一遍会出现“原来如此”的感觉。最后的AdaptiveAvgPool2d((1, 1))等价于全局平均池化无论前面特征图是5×5还是别的尺寸都能压成1×1。用AdaptiveAvgPool2d而不是普通AvgPool2d是因为前者不需要手动计算池化核大小对输入尺寸的适应性更好。3.3 为什么d2l要把Fashion-MNIST放大到224×224这里有一个特别关键、也特别容易踩坑的细节。NiN原始论文是针对ImageNet这种224×224大图设计的第一个卷积核就是11×11、stride为4。如果你直接把Fashion-MNIST的28×28小图喂进去会发生什么第一个卷积后特征图从28×28变成5×5第一轮池化后变成2×2第二个nin_block里5×5卷积加padding后勉强保持2×2第二轮池化2×2经过3×3、stride为2的池化就直接算出非正数尺寸了网络在中间就会崩溃。所以作者团队在训练NiN时使用了d2l.load_data_fashion_mnist(batch_size, resize224)先把Fashion-MNIST的图片统一放大到224×224再送入网络。这当然会明显增加计算量和显存占用但目的是完整保留NiN原版结构不在模型结构上打折扣。理解了这一点你也就理解了为什么在动手复现经典网络之前必须先确认“你的输入尺寸是否满足这个网络设计时的最小输入约束”。4. 训练实测与让NiN跑起来的参数细节4.1 训练参数怎么定lr0.1、batch_size128背后的考量NiN的训练代码本身非常短lr, num_epochs, batch_size 0.1, 10, 128 train_iter, test_iter d2l.load_data_fashion_mnist(batch_size, resize224) d2l.train_ch6(NiN(), train_iter, test_iter, num_epochs, lr, d2l.try_gpu())学习率取0.1乍一看有点大。实际上在这本书的AlexNet、VGG、NiN这几个基于ImageNet设计的网络上0.1都是常见起点。原因在于这些网络都比较深批量大SGD更新时如果学习率太小前几个epoch的loss下降会慢得让你怀疑是不是哪里写错了。当然0.1也不是绝对安全如果你自己复现时发现loss剧烈震荡甚至出现NaN优先把学习率降到0.03或0.05试试。batch_size取128也是权衡后的结果。NiN放大到224×224之后前面的特征图分辨率高、通道数多显存压力比LeNet大了很多。如果你的显卡显存不够可以把batch_size降到64或32代价是训练步数变多、收敛稳定性略差但不会影响最终结构理解。4.2 训练过程中的几个真实观察我把这套配置跑下来之后有几个现象印象很深。第一个现象是loss下降速度比LeNet慢。你可能会看到一个epoch结束后loss还挂在高位但准确率已经慢慢爬起来了。这并不奇怪NiN的前面几层大卷积核都在放大后的图片上工作需要更多迭代才能把特征提取器调整好而最后的GAP分类头几乎没有参数准确率反而容易在早期就快速上升。第二个现象是显存占用确实大。由于输入尺寸从28×28变成224×224第一层特征图是96×54×54一个batch就是96×54×54×128个float数叠加反向传播的中间变量显存一下子就不够看了。如果你只有普通笔记本GPU建议先把resize从224改成96或者更小先跑通流程确认维度没有问题再上完整配置。第三个现象是训练时间远高于LeNet但最终测试精度并没有拉开很大差距。在Fashion-MNIST这种相对简单的数据集上NiN的精度一般在0.87到0.89附近和AlexNet、VGG处于同一水平。这不代表NiN没有价值而是说明经典数据集的简单性降低了网络结构差异的区分度。真正能拉开差距的场景往往在更大、更杂的真实图像数据集上。4.3 和LeNet/AlexNet/VGG放在一起看差距为了更直观地理解NiN的位置我把自己在相同数据增强条件下跑出来的典型结果整理成了表格不同机器、不同初始化会有波动仅供参考网络核心设计参数量特点Fashion-MNIST测试精度典型训练感受LeNet卷积 全连接小0.85左右快CPU都能轻松跑AlexNet更深更大 ReLU Dropout全连接占比极高0.89左右慢适合GPUVGG重复3×3卷积块卷积通道大全连接也不少0.89左右慢结构规整但参数多NiNMLP卷积 GAP分类头无参数0.88左右较慢显存压力大从这组对比可以看出一件事准确率并不是NiN的最终追求它更大的意义是告诉你卷积层的表达能力和分类器的设计方式都可以被重构。你不一定要在竞赛里拿第一但你必须知道原来这些模块还能这样替换。5. NiN留下的两笔遗产与我的学习建议5.1 1×1卷积在GoogLeNet、ResNet、SENet中的延续NiN在2013年提出时并没有立刻统治视觉领域但它顺手发明的两个组件后来几乎成了现代卷积网络的通用零件。1×1卷积最著名的继承者是GoogLeNet的Inception模块。Inception模块要在同一层里同时跑不同大小的卷积核直接把所有分支拼起来计算量会爆炸于是GoogLeNet在每个分支前面先加一个1×1卷积做通道降维。ResNet的Bottleneck也延续了这个思路先用1×1把通道降下来再用3×3做空间卷积最后用1×1升回去这样整个模块的计算量被控制得很低层数却可以堆到很深。即使到了MobileNet时代1×1卷积也作为pointwise卷积继续承担跨通道信息混合的职责。GAP的继承者同样不少。SENet里的SE模块先用全局平均池化把每个通道压缩成一个值再接两个全连接层去计算通道权重本质上就是在NiN这个思路上衍生出来的通道注意力机制。可以说如果你现在回头看NiN你会发现它不是一个过时的网络而是一份现代卷积网络组件的“原产地说明书”。5.2 复现NiN时值得注意的小细节与调试习惯我在自己复现时养成了一个习惯写完好网络的第一件事不是马上训练而是先打印每一层的输出尺寸。你可以用下面这段代码快速检查net NiN() X torch.randn(1, 1, 224, 224) for layer in net.net: X layer(X) print(layer.__class__.__name__, X.shape)输出会像这样Sequential torch.Size([1, 96, 54, 54]) MaxPool2d torch.Size([1, 96, 26, 26]) Sequential torch.Size([1, 256, 26, 26]) ... Flatten torch.Size([1, 10])如果某层尺寸变成0或者负数问题通常出在“输入尺寸太小”或者“卷积/池化参数与输入尺寸不匹配”而不必去怀疑别的复杂原因。这个调试习惯在后来的GoogLeNet、ResNet等更复杂网络里会救你无数次。另外再提醒一个细节如果你按老版本的d2l写代码nin_block里用的是nn.Conv2d而不是nn.LazyConv2d那么第一个nin_block的输入通道数在Fashion-MNIST灰度图场景下是1后面的nin_block输入通道数必须和上一层的out_channels严格对上。一旦写错PyTorch报错信息会比较难懂建议优先使用新版代码或者把输入通道数作为参数显式传进去。最后再说两句我的心路体会NiN这节是我把整本书卷积神经网络章节读完一遍之后回头再看仍然觉得最有启发的一节。它不像AlexNet那样靠规模和算力取胜也不像VGG那样把结构推到了极致规整它是在“网络组件能不能换一种方式连接”这个问题上做了非常大胆的尝试。我看完这节的感受是以后设计网络时不要默认“卷积后面就必须接全连接”也不要默认“通道之间只能用3×3卷积做空间混合”。这两个默认想法一旦被打破你对很多现代网络的理解都会顺畅很多。如果让我给一个具体建议我会说复现NiN之前准备好GPU别用CPU硬跑真想在电脑上快速体验就先把resize设小、batch_size设小把维度和训练流程跑通再考虑跑完整配置。把书里这个例子的代码注释过一遍再亲手把每层尺寸推一遍你收获的东西会比单纯记住一个准确率高得多。