资讯动态

NiN网络核心解析:1x1卷积与全局平均池化的设计精髓

发布时间:2026/10/14 13:42:12 来源:尧图企业网站定制
在卷积神经网络这条学习路线上绝大多数人都会在 AlexNet 和 VGG 之后遇到一个有点特别的名字NiN。全称 Network in Network中文一般叫“网络中的网络”。它看起来只是把卷积层后面塞了几个 1x1 卷积最后又用了一个全局平均池化把全连接层整个干掉。就这两个看似简单的改动却影响了一大批后来的主流网络设计思路。这篇笔记是围绕《动手学深度学习》PyTorch 版中 NiN 那一节做的源码注释和思路拆解。我不是来复述网络结构的而是想把 NiN 最核心的两个设计点——1x1 卷积和全局平均池化——背后的动机讲透再把原书代码逐段注释一遍最后分享一些实际训练时踩过的坑。无论你是刚学到卷积神经网络的新手还是想回头补基础的老手这篇内容都能帮你把 NiN 这块拼图放得更稳。1. NiN到底在解决什么问题1.1 卷积层的“线性瓶颈”要理解 NiN得先回到它出现之前的主流做法。AlexNet 和 VGG 的思路很直白把卷积层堆深、堆宽靠更多非线性激活层和更大的参数量来提升表达能力。但这里有一个容易被忽略的问题——卷积操作本身是线性的。单个卷积核做的事本质上就是在局部感受野内做一次加权求和。假设输入是 3 通道图像一个 3x3 卷积核就是在 3x3x3 这么大的区域内做线性组合再加上一个偏置。哪怕你在后面加了 ReLUReLU 只是对线性输出做了一个截断并不会改变“每个位置单独做线性映射”这个本质。那多个卷积层叠加是不是就能解决非线性能解决一部分但很间接。每一层仍然是在自己的特征平面上做线性加权层与层之间虽然有 ReLU 在中间做隔断但网络对“单个位置上的多通道信息”这一块的建模方式还是比较弱的。说得更直白一点卷积层擅长捕捉空间局部相关性但对于同一位置、不同通道之间的复杂非线性关系传统的卷积核并不擅长建模它只会把一个通道组合线性地投到另一个通道组合上。这个问题的本质原论文作者看得很清楚。解决办法也很直接——与其让网络隐式地学习特征之间的组合不如显式地在卷积层里嵌入一个小型全连接网络。这就是“Network in Network”这个名字的由来在网络的每个滑动窗口内部再跑一个小网络。1.2 1x1卷积如何变成mlpconvNiN 的核心模块叫 mlpconv也就是“多层感知卷积层”。它的实现方式非常朴素先做一个正常的空间卷积比如 11x11 或 3x3然后接两个 1x1 卷积每个 1x1 卷积后面都跟一个 ReLU。为什么这样就能等价于“在每个像素点上跑一个 MLP”你仔细想一下 1x1 卷积的计算过程它不改变空间尺寸只是把某个位置上的所有通道值取出来和 1x1 卷积核做内积得到一个输出通道上的值。对输入通道是 C_in、输出通道是 C_out 的 1x1 卷积来说它在每个空间位置上做的事情就是把一个长度为 C_in 的向量映射成一个长度为 C_out 的向量——这不就是全连接层做的事吗更妙的是1x1 卷积的权重在整张特征图上是共享的也就是说对于 HxW 平面上的每一个位置同一个 MLP 被重复应用了 HxW 次。所以 NiN 的实际含义是先把空间信息用普通卷积提取然后在每个位置上用一个三层 MLP 对通道维度做组合。这样做的好处是网络在同样的感受野范围内拥有了比纯线性卷积强得多的表达能力而且因为权重共享参数数量不会像真正的全连接层那样爆炸。我当时看这个设计的时候有一种“原来还能这样”的感觉。大多数网络都在追求层数更深、通道更宽NiN 却选择把一个小网络塞进卷积里这是一种完全不同的思路。1.3 和VGG/AlexNet放在一起看如果把 NiN 和 VGG、AlexNet 放在一起对比差异会更明显。AlexNet 的结构是“若干卷积层 全连接层大头”VGG 是“更深的卷积堆叠 全连接层”。它们对空间的建模方式很相似都是逐层抽特征最后把特征图拉平成向量再接全连接分类。NiN 做了两件“反常规”的事。一是在卷积阶段用 mlpconv 替代普通卷积让通道组合能力更强二是在尾部用全局平均池化替代全连接层让分类输出直接来自特征图的全局统计信息。这两个改动不是孤立的它们共同服务于一个目标减少参数、增强非线性、让网络更不容易过拟合。现在回头看NiN 当年的精度放在今天并不算惊艳但它打开了一个全新的设计方向。你想理解后面 Inception 里的 1x1 卷积为什么能降维想理解 ResNet 里 bottleneck 为什么敢用那么窄的中间层都必须先回到 NiN 这里看它是怎么把 1x1 卷积这个工具带到主流视野里的。2. 全局平均池化做对了什么2.1 全连接层才是参数大户在 NiN 之前几乎所有分类网络都会在卷积层之后接上几个全连接层。全连接层的参数量有多大以 AlexNet 为例第一个全连接层的输入是 6x6x256 的特征图拉平后的 9216 维向量输出是 4096那么这一层的权重矩阵就是 9216x4096约 3770 万个参数。这还只是第一个全连接层。这个问题在 NiN 作者看来属于结构性的浪费。全连接层的本质是“全局特征组合”它把整张特征图的所有位置都拉平这会产生大量冗余参数而且非常容易过拟合你必须配合 Dropout 才能压得住。NiN 的思路是既然我们已经有了多层卷积堆叠出的高维特征为什么不直接在空间上做全局池化把每个特征图压成一个数这样分类器就从“全连接 大量参数”变成了“几乎零参数”。2.2 GAP的数学与语义全局平均池化的操作很简单对最后一层卷积输出的每一个特征图计算全部空间位置的平均值。假设最后一个卷积输出的特征图形状是 CxHxWGAP 之后就变成 C 个实数每个实数对应一个通道的全局平均响应。写成公式就是g_c (1 / (H × W)) * Σᵢ Σⱼ x_{i,j,c}也就是说第 c 个通道的输出值就是这个通道所有像素的平均值。NiN 在最后一个 mlpconv 层上直接把输出通道数设置成类别数。比如 Fashion-MNIST 有 10 类最后一个 mlpconv 的 out_channels 就是 10。GAP 之后得到长度为 10 的向量这 10 个数就可以直接当作各类别的得分接 softmax 就可以算交叉熵损失。这个设计还有一层语义上的好处网络被迫让每个通道去学习“一张图中某个类别是否存在”的全局响应。虽然这个语义关联不是强约束但相比全连接层的“黑箱组合”GAP 的解释性要好很多。后来很多可视化工作里直接把特征图放大叠到原图上靠的也是类似思路。2.3 输入尺寸的额外自由度GAP 还有一个经常被人忽略的优势它让网络对输入尺寸的宽容度变高了。传统网络如果最后是全连接层输入尺寸一变全连接层的输入维度就变整个模型就要重新训练。但使用 GAP 之后不管最后的特征图是 5x5、6x6 还是 10x10GAP 都能把它压成固定的类别数向量。卷积层本身又是滑动窗口的不关心输入尺寸只要尺寸够大、下采样后不会变成负数维度就行。这意味着训练好的 NiN 在一定程度上可以处理不同分辨率的图片虽然效果不一定最好但不会直接报维度错误。这也是后来很多语义分割网络、目标检测网络愿意用 GAP 或类似全局池化的原因之一。3. PyTorch源码逐行拆解3.1 数据准备NiN 的第一个卷积核是 11x11步幅 4这明显是为大图设计的。Fashion-MNIST 默认的图片只有 28x28直接扔进去是没法跑的必须先把图片放大到 224x224。这也是原书里为什么在数据加载那里用了resize224。import torch from torch import nn from torch.utils import data from torchvision import datasets, transforms def load_data(batch_size, resizeNone): # 基本预处理转成Tensor把像素值归一化到 [0,1] trans [transforms.ToTensor()] if resize: # NiN的卷积核较大且步幅大小图跑不了先放大 trans.insert(0, transforms.Resize(resize)) transform transforms.Compose(trans) # 加载Fashion-MNIST train_set datasets.FashionMNIST( root../data, trainTrue, transformtransform, downloadTrue) test_set datasets.FashionMNIST( root../data, trainFalse, transformtransform, downloadTrue) return (data.DataLoader(train_set, batch_sizebatch_size, shuffleTrue), data.DataLoader(test_set, batch_sizebatch_size, shuffleFalse))值得注意的一点transforms.ToTensor()会自动把 HxWxC 的图像变成 CxHxW并且把像素值缩放到 0 到 1这是 PyTorch 约定俗成的输入格式。downloadTrue会在第一次运行时把数据集下载到../data目录之后就直接读本地缓存。3.2 定义nin_blockNiN 的核心模块就是下面这段。一个正常的卷积然后接两个 1x1 卷积三个卷积后面都跟 ReLU。def nin_block(in_channels, out_channels, kernel_size, strides, padding): # 每个NiN块由三部分组成 # 1) 真正的空间卷积kernel_size可以是11、5、3 # 2) 第一个1x1卷积相当于在通道维度上做一个全连接不改变空间尺寸 # 3) 第二个1x1卷积再做一个全连接进一步增加非线性 # 两层1x1卷积合起来就是一个两层MLP作用在每个像素点上 return nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_sizekernel_size, stridestrides, paddingpadding), nn.ReLU(), nn.Conv2d(out_channels, out_channels, kernel_size1), nn.ReLU(), nn.Conv2d(out_channels, out_channels, kernel_size1), nn.ReLU() )为什么要两个 1x1 而不是一个因为一层 1x1 卷积本质上还是线性变换就算接 ReLU也只是对线性结果做截断并没有引入真正的非线性组合。两层 1x1 卷积之间夹着 ReLU才能让通道组合的表达能力上升一个台阶。这就好比你自己搭全连接分类器时肯定也不会只用一层线性层就完事总得加个隐藏层和激活函数。另一个细节两个 1x1 卷积的输入输出通道数都是out_channels也就是说它们不改变通道数量。这一点和后来 Inception 里的降维 1x1 卷积作用不同NiN 这里纯粹是为了增强非线性而不是为了控制计算量。3.3 组装NiN并推演尺寸模型组装部分和原书结构一致但我加上了更详细的注释重点标注每一层的输入输出变化。class NiN(nn.Module): def __init__(self, num_classes10): super().__init__() self.net nn.Sequential( # 输入1x224x224 # 输出96x54x54因为 floor((224-11)/4)1 54 nin_block(1, 96, kernel_size11, strides4, padding0), nn.MaxPool2d(3, stride2), # 96x26x26 # 输入96x26x26padding2保持不变 # 输出256x26x26 nin_block(96, 256, kernel_size5, strides1, padding2), nn.MaxPool2d(3, stride2), # 256x12x12 # 输入256x12x12padding1保持不变 # 输出384x12x12 nin_block(256, 384, kernel_size3, strides1, padding1), nn.MaxPool2d(3, stride2), # 384x5x5 nn.Dropout(0.5), # 输入384x5x5输出通道等于类别数 # 输出10x5x5 nin_block(384, num_classes, kernel_size3, strides1, padding1), # 全局平均池化10x5x5 - 10x1x1 nn.AdaptiveAvgPool2d((1, 1)), # 拉平10x1x1 - 10 nn.Flatten() ) self.init_weights() def init_weights(self): # 用kaiming初始化更适合ReLU实测比默认初始化收敛更稳 for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) if m.bias is not None: nn.init.constant_(m.bias, 0) def forward(self, x): return self.net(x)代码里每个尺寸注释我都算过一遍整理成表格更直观层输入尺寸输出尺寸计算过程nin_block 11×224×22496×54×54(224−11)/4 向下取整 1 54MaxPool2d(3,2)96×54×5496×26×26(54−3)/2 向下取整 1 26nin_block 296×26×26256×26×26padding2尺寸不变MaxPool2d(3,2)256×26×26256×12×12(26−3)/2 向下取整 1 12nin_block 3256×12×12384×12×12padding1尺寸不变MaxPool2d(3,2)384×12×12384×5×5(12−3)/2 向下取整 1 5nin_block 4384×5×510×5×5输出通道设为类别数AdaptiveAvgPool2d10×5×510×1×1全局均值Flatten10×1×110拉平为向量单独看每个公式都不复杂但这张表建议自己动手推一遍。我见过不少同学在改通道数或者换输入尺寸的时候就是因为没算尺寸结果某层卷积之后维度变成负数直接报错。NiN 这种下采样比较猛的网络尺寸推演是必修课。AdaptiveAvgPool2d((1, 1))这个接口也值得说一句。普通AvgPool2d需要你指定 kernel_size 和 stride算起来很麻烦。AdaptiveAvgPool则只要告诉它输出尺寸就行PyTorch 会自动推算出合适的核大小所以在全卷积网络里非常常用。3.4 训练循环与初始化数据准备好、模型搭好剩下的就是标准的训练流程。NiN 用交叉熵损失优化器选择 SGD。def train(net, train_iter, test_iter, epochs, lr, device): net net.to(device) # 动量为0.9比纯SGD收敛快不少 optimizer torch.optim.SGD(net.parameters(), lrlr, momentum0.9) loss_fn nn.CrossEntropyLoss() for epoch in range(epochs): net.train() for X, y in train_iter: X, y X.to(device), y.to(device) optimizer.zero_grad() y_hat net(X) loss loss_fn(y_hat, y) loss.backward() optimizer.step() # 每个epoch结束后做一次评估 net.eval() correct 0 total 0 with torch.no_grad(): for X, y in test_iter: X, y X.to(device), y.to(device) y_hat net(X) correct (y_hat.argmax(dim1) y).sum().item() total y.numel() print(fepoch {epoch 1}, accuracy {correct / total:.3f})初始化那里我用了kaiming_normal_它针对 ReLU 做了适配。原书在一些地方默认用 Xavier但实测在 NiN 这种卷积层较多的网络上Kaiming 初始化对早期收敛速度是有帮助的。4. 实操踩坑与问题速查这一节全是个人经验每个问题都是我实际跑 NiN 时碰到过的放出来给后来人省点时间。4.1 尺寸推算出错最容易犯的错就是把 28x28 的 Fashion-MNIST 直接喂给 NiN。第一个 11x11、步幅 4 的卷积已经接近极限但真正致命的是第二个 MaxPool 之后尺寸会快速缩水。我最初为了省事用 32x32 试过一次结果第二层池化之后特征图变成 0直接报错“负维度”。这类问题最好的排查方式不是反复看代码而是在模型定义里临时加几行打印把每一层的输出形状打出来def forward(self, x): for layer in self.net: x layer(x) print(layer.__class__.__name__, x.shape) return x跑一次就知道哪一层出问题然后把resize调大或者调整池化参数。NiN 用 224 是稳妥的选择但如果你真的想用小输入就得把前面的池化层改掉这不是一行两行能搞定的不如直接换结构。4.2 学习率与优化器我一开始习惯性地把学习率设成 0.1结果训练曲线剧烈震荡。NiN 在小数据集上虽然参数量不大但每个 mlpconv 内部有三层卷积串行梯度流经三次卷积之后波动会被放大。后来我把学习率降到 0.05震荡明显缓解。动量也非常关键。纯 SGD 在这个网络上收敛慢得让人怀疑人生加上momentum0.9之后肉眼可见地变快。如果还是慢可以考虑换成 Adam但 Adam 配合 Kaiming 初始化的时候偶尔会出现前期收敛快、后期精度上不去的情况所以我个人更偏好“SGD 动量 调学习率”这套经典组合。4.3 Dropout放哪里NiN 代码里只有一个 Dropout放在第三个 MaxPool 和最后一个 nin_block 之间。这个位置是在 384 通道的 5x5 特征图上做随机失活作用对象是最后一个 mlpconv 的输入。有同学问为什么不在 GAP 之后再加 Dropout。你想一下GAP 之后特征图已经被压成类别数个实数了10 个实数里随机抹掉几个信息损失太大而且意义不大。Dropout 的初衷是让前面的大特征图不要过度依赖某些通道所以必须放在高维特征图的阶段。这个位置选得越靠前正则化效果越强但也越容易拖慢收敛0.5 这个值目前看来是平衡点。4.4 训练太慢怎么办NiN 虽然参数总量不算夸张但前面几个大卷积核11x11、5x5在 224x224 的输入上计算量一点都不小。用 CPU 跑 Fashion-MNIST 训练 10 个 epoch 会非常痛苦一张卡才是正经选择。如果只有 CPU 可用有两个缓解办法一个是用torch.backends.mkldnn相关的一体化优化另一个是减少resize的尺寸。但第一层 11x11 卷积决定了输入不能太小降到 96x96 左右勉强可行精度会受影响。我的建议是NiN 这种结构本身就是为研究思路准备的不要指望它在小数据集上跑出多漂亮的精度能用 GPU 就用 GPU。我把训练中常遇到的问题整理成了一张速查表现象可能原因解决思路某一层 shape 为负数或报 size mismatch输入尺寸过小下采样太猛增大 resize或调整池化步幅训练 Loss 震荡学习率过大lr 从 0.1 降到 0.05或加 warmup收敛太慢无动量或初始化不合适加 momentum换 Kaiming 初始化GPU 显存溢出224 输入 较大 batch 太大降低 batch size或降 resize过拟合明显GAP 也挡不住小数据集过拟合提高 Dropout或加数据增强5. NiN留下的遗产与学习建议5.1 Inception里的1x1卷积NiN 提出两年之后GoogLeNet 的 Inception 模块把 1x1 卷积用得更加“功利”。Inception 里先用一个 1x1 卷积把通道数降下来再用 3x3、5x5 卷积去提取空间特征最后再升维。这个降维操作直接让计算量大幅下降本质上就是利用了“1x1 卷积可以在通道维度上做压缩”这个性质。如果你理解了 NiN 里 1x1 卷积是“跨通道全连接”那 Inception 的降维就不难理解——把 256 通道压到 64 通道相当于用一个全连接把信息浓缩然后再在较小的通道数上做计算成本自然低了很多。5.2 ResNet的bottleneckResNet 里的 bottleneck 也沿用了同样的套路。经典残差块的中间层先用 1x1 卷积把通道降下来做完 3x3 卷积后再用 1x1 升回去。这不仅控制了计算量还让网络的非线性组合能力保持在一个很高的水平。很多初学者把 ResNet 的 1x1 卷积当成单纯的“维度调整工具”但如果追根溯源NiN 才是那个让 1x1 卷积从“边缘工具”变成“核心组件”的起点。5.3 我的个人建议我的建议是学 NiN 的时候不要只盯着它的精度。这套网络放在今天已经谈不上强大了真正有价值的是它展现出的设计思维在不改变网络整体深度的前提下通过改变单个卷积层的内部结构来提升表达能力。这种思路比单纯背结构更值得记住。看源码的时候把每个 1x1 卷积背后的“全连接视角”想清楚再把全局平均池化为什么能替代全连接想明白NiN 这一章基本就吃透了。以后你再看到 Inception、ResNet 里的 1x1 卷积就不会觉得它们是“为了降维而降维”而是能看出它们背后那层“通道维度上的全连接”的实质。我个人在实际操作中的体会是深度学习里很多看似新颖的设计往前翻都能找到原型。NiN 就是其中被严重低估的一块基石。花一个下午把它的源码注释一遍、把尺寸推演一遍这笔时间花得很值。后面碰到任何用 1x1 卷积的结构你都会比那些只背结论的人多一层理解。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑