在卷积神经网络这条学习路线上,绝大多数人都会在 AlexNet 和 VGG 之后遇到一个有点特别的名字:NiN。全称 Network in Network,中文一般叫“网络中的网络”。它看起来只是把卷积层后面塞了几个 1x1 卷积,最后又用了一个全局平均池化把全连接层整个干掉。就这两个看似简单的改动,却影响了一大批后来的主流网络设计思路。
这篇笔记是围绕《动手学深度学习》PyTorch 版中 NiN 那一节做的源码注释和思路拆解。我不是来复述网络结构的,而是想把 NiN 最核心的两个设计点——1x1 卷积和全局平均池化——背后的动机讲透,再把原书代码逐段注释一遍,最后分享一些实际训练时踩过的坑。无论你是刚学到卷积神经网络的新手,还是想回头补基础的老手,这篇内容都能帮你把 NiN 这块拼图放得更稳。
1. NiN到底在解决什么问题
1.1 卷积层的“线性瓶颈”
要理解 NiN,得先回到它出现之前的主流做法。AlexNet 和 VGG 的思路很直白:把卷积层堆深、堆宽,靠更多非线性激活层和更大的参数量来提升表达能力。但这里有一个容易被忽略的问题——卷积操作本身是线性的。
单个卷积核做的事,本质上就是在局部感受野内做一次加权求和。假设输入是 3 通道图像,一个 3x3 卷积核就是在 3x3x3 这么大的区域内做线性组合,再加上一个偏置。哪怕你在后面加了 ReLU,ReLU 只是对线性输出做了一个截断,并不会改变“每个位置单独做线性映射”这个本质。
那多个卷积层叠加是不是就能解决非线性?能解决一部分,但很间接。每一层仍然是在自己的特征平面上做线性加权,层与层之间虽然有 ReLU 在中间做隔断,但网络对“单个位置上的多通道信息”这一块的建模方式还是比较弱的。说得更直白一点:卷积层擅长捕捉空间局部相关性,但对于同一位置、不同通道之间的复杂非线性关系,传统的卷积核并不擅长建模,它只会把一个通道组合线性地投到另一个通道组合上。
这个问题的本质,原论文作者看得很清楚。解决办法也很直接——与其让网络隐式地学习特征之间的组合,不如显式地在卷积层里嵌入一个小型全连接网络。这就是“Network in Network”这个名字的由来:在网络的每个滑动窗口内部,再跑一个小网络。
1.2 1x1卷积如何变成mlpconv
NiN 的核心模块叫 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的数学与语义
全局平均池化的操作很简单:对最后一层卷积输出的每一个特征图,计算全部空间位置的平均值。假设最后一个卷积输出的特征图形状是 CxHxW,GAP 之后就变成 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 还是 10x10,GAP 都能把它压成固定的类别数向量。卷积层本身又是滑动窗口的,不关心输入尺寸,只要尺寸够大、下采样后不会变成负数维度就行。
这意味着训练好的 NiN 在一定程度上可以处理不同分辨率的图片,虽然效果不一定最好,但不会直接报维度错误。这也是后来很多语义分割网络、目标检测网络愿意用 GAP 或类似全局池化的原因之一。
3. PyTorch源码逐行拆解
3.1 数据准备
NiN 的第一个卷积核是 11x11,步幅 4,这明显是为大图设计的。Fashion-MNIST 默认的图片只有 28x28,直接扔进去是没法跑的,必须先把图片放大到 224x224。这也是原书里为什么在数据加载那里用了resize=224。
import torch from torch import nn from torch.utils import data from torchvision import datasets, transforms def load_data(batch_size, resize=None): # 基本预处理:转成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', train=True, transform=transform, download=True) test_set = datasets.FashionMNIST( root='../data', train=False, transform=transform, download=True) return (data.DataLoader(train_set, batch_size=batch_size, shuffle=True), data.DataLoader(test_set, batch_size=batch_size, shuffle=False))值得注意的一点,transforms.ToTensor()会自动把 HxWxC 的图像变成 CxHxW,并且把像素值缩放到 0 到 1,这是 PyTorch 约定俗成的输入格式。download=True会在第一次运行时把数据集下载到../data目录,之后就直接读本地缓存。
3.2 定义nin_block
NiN 的核心模块就是下面这段。一个正常的卷积,然后接两个 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_size=kernel_size, stride=strides, padding=padding), nn.ReLU(), nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU(), nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU() )为什么要两个 1x1 而不是一个?因为一层 1x1 卷积本质上还是线性变换,就算接 ReLU,也只是对线性结果做截断,并没有引入真正的非线性组合。两层 1x1 卷积之间夹着 ReLU,才能让通道组合的表达能力上升一个台阶。这就好比你自己搭全连接分类器时,肯定也不会只用一层线性层就完事,总得加个隐藏层和激活函数。
另一个细节:两个 1x1 卷积的输入输出通道数都是out_channels,也就是说它们不改变通道数量。这一点和后来 Inception 里的降维 1x1 卷积作用不同,NiN 这里纯粹是为了增强非线性,而不是为了控制计算量。
3.3 组装NiN并推演尺寸
模型组装部分和原书结构一致,但我加上了更详细的注释,重点标注每一层的输入输出变化。
class NiN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.net = nn.Sequential( # 输入:1x224x224 # 输出:96x54x54,因为 floor((224-11)/4)+1 = 54 nin_block(1, 96, kernel_size=11, strides=4, padding=0), nn.MaxPool2d(3, stride=2), # 96x26x26 # 输入:96x26x26,padding=2保持不变 # 输出:256x26x26 nin_block(96, 256, kernel_size=5, strides=1, padding=2), nn.MaxPool2d(3, stride=2), # 256x12x12 # 输入:256x12x12,padding=1保持不变 # 输出:384x12x12 nin_block(256, 384, kernel_size=3, strides=1, padding=1), nn.MaxPool2d(3, stride=2), # 384x5x5 nn.Dropout(0.5), # 输入:384x5x5,输出通道等于类别数 # 输出:10x5x5 nin_block(384, num_classes, kernel_size=3, strides=1, padding=1), # 全局平均池化: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, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.constant_(m.bias, 0) def forward(self, x): return self.net(x)代码里每个尺寸注释我都算过一遍,整理成表格更直观:
| 层 | 输入尺寸 | 输出尺寸 | 计算过程 |
|---|---|---|---|
| nin_block 1 | 1×224×224 | 96×54×54 | (224−11)/4 向下取整 +1 = 54 |
| MaxPool2d(3,2) | 96×54×54 | 96×26×26 | (54−3)/2 向下取整 +1 = 26 |
| nin_block 2 | 96×26×26 | 256×26×26 | padding=2,尺寸不变 |
| MaxPool2d(3,2) | 256×26×26 | 256×12×12 | (26−3)/2 向下取整 +1 = 12 |
| nin_block 3 | 256×12×12 | 384×12×12 | padding=1,尺寸不变 |
| MaxPool2d(3,2) | 384×12×12 | 384×5×5 | (12−3)/2 向下取整 +1 = 5 |
| nin_block 4 | 384×5×5 | 10×5×5 | 输出通道设为类别数 |
| AdaptiveAvgPool2d | 10×5×5 | 10×1×1 | 全局均值 |
| Flatten | 10×1×1 | 10 | 拉平为向量 |
单独看每个公式都不复杂,但这张表建议自己动手推一遍。我见过不少同学在改通道数或者换输入尺寸的时候,就是因为没算尺寸,结果某层卷积之后维度变成负数直接报错。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(), lr=lr, momentum=0.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(dim=1) == y).sum().item() total += y.numel() print(f'epoch {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 在这个网络上收敛慢得让人怀疑人生,加上momentum=0.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的bottleneck
ResNet 里的 bottleneck 也沿用了同样的套路。经典残差块的中间层,先用 1x1 卷积把通道降下来,做完 3x3 卷积后再用 1x1 升回去。这不仅控制了计算量,还让网络的非线性组合能力保持在一个很高的水平。很多初学者把 ResNet 的 1x1 卷积当成单纯的“维度调整工具”,但如果追根溯源,NiN 才是那个让 1x1 卷积从“边缘工具”变成“核心组件”的起点。
5.3 我的个人建议
我的建议是,学 NiN 的时候不要只盯着它的精度。这套网络放在今天已经谈不上强大了,真正有价值的是它展现出的设计思维:在不改变网络整体深度的前提下,通过改变单个卷积层的内部结构来提升表达能力。这种思路比单纯背结构更值得记住。
看源码的时候,把每个 1x1 卷积背后的“全连接视角”想清楚,再把全局平均池化为什么能替代全连接想明白,NiN 这一章基本就吃透了。以后你再看到 Inception、ResNet 里的 1x1 卷积,就不会觉得它们是“为了降维而降维”,而是能看出它们背后那层“通道维度上的全连接”的实质。
我个人在实际操作中的体会是:深度学习里很多看似新颖的设计,往前翻都能找到原型。NiN 就是其中被严重低估的一块基石。花一个下午把它的源码注释一遍、把尺寸推演一遍,这笔时间花得很值。后面碰到任何用 1x1 卷积的结构,你都会比那些只背结论的人多一层理解。