☰
深入PyTorch实践:从Inception到ResNet的CNN进阶架构
2026/10/1 17:46:34 网站建设 项目流程

从第10讲把“卷积+池化+全连接”这套基础流程跑通MNIST之后,很多人拿到第11讲都会有点懵:明明简单CNN在手写数字上已经能到97%以上,为什么还要学GoogLeNet、ResNet这些“大玩意儿”?这正是“PyTorch深度学习实践”这个系列最容易被低估的一讲。第11讲的主题是卷积神经网络的进阶结构,核心落在两个里程碑式架构上——2014年ImageNet冠军GoogLeNet的Inception模块,以及2015年冠军ResNet的残差学习。这篇文章我来拆一拆这一讲的完整脉络:为什么要同时用多个尺寸的卷积核、1×1卷积到底在干什么、残差连接为什么能让几百层网络真正训练起来,以及课程里没细说、但你动手复现时一定会踩的坑。

1. 第11讲到底在解决什么问题:从基础CNN到深度网络的阵痛

1.1 学完第10讲之后,我们对CNN还缺什么

第10讲里我们搭了一个“卷积-池化-卷积-池化-全连接”的简单网络,在MNIST上能做到97%以上的准确率。这个成绩很容易让人产生错觉:CNN好像也没多难,几个卷积叠一叠就能用。但如果你真拿这套结构去做真实场景的图像分类——比如拍照识物、遥感图分类、医学影像判读——很快就会发现两个绕不开的问题。

第一个问题是感受野固定。第10讲的网络每一层卷积都用5×5卷积核,两层叠加后的感受野只有一个固定大小。真实图像里目标的尺度差异非常大,同一张图上可能既有占据大半个画面的主体,也有只有几十个像素的小目标。用固定尺寸的卷积核去扫,本质上是逼着网络用同一双“眼睛”去适应所有尺度,效果自然受限。

第二个问题是通道之间缺少交互。每个卷积核输出一个特征通道,你可以把通道理解成一组从不同角度观察图像的镜头。第10讲的网络在卷积之后直接接池化和全连接,中间缺少“把不同通道的信息混合起来”的步骤。如果这些镜头之间不沟通,后面分类器能利用的信息就打了折扣。GoogLeNet的Inception模块和ResNet的残差结构,恰好是从“宽度”和“深度”两个方向回答了这两个问题。第11讲虽然选用的还是MNIST这个小数据集,但设计思想全部来自ImageNet级别的大规模竞赛经验,这才是这一讲真正的价值。

1.2 网络越深越强的“想当然”为什么是错的

顺着“卷积堆叠”的思路,很多人会本能地认为:网络越深,特征越抽象,效果越好。这也是VGG的发展逻辑——把网络从16层加到19层,然后发现再往上加就非常吃力。2015年ResNet论文放出了一张非常反直觉的实验图:在CIFAR-10上,56层的普通卷积网络,训练误差居然比20层的还高。

注意,这不是过拟合。过拟合的表现是训练误差低、测试误差高,而这里连训练误差都更高。也就是说,更深的网络在训练集上就“学不动”了。论文把这种现象称为退化(degradation)。梯度消失确实是深层网络的老大难,但ResNet面对的这个问题更微妙:即使梯度没有完全消失,让几十层卷积去逼近一个恒等映射f(x)=x,对SGD来说也极为困难。

这里藏着一个很漂亮的思路:既然直接学“输出=输入”很难,那就换个目标,让网络学“输出-输入=F(x)”。如果恒等映射是最优解,残差块只需要把F(x)学成0——把卷积核权重往0推,比精确拟合一个恒等映射容易太多。这就是残差学习的核心动机。理解了这个“为什么”,后面看代码、调结构都会顺畅得多。

2. GoogLeNet的Inception模块:多尺度特征的并联设计

2.1 为什么同时使用1×1、3×3、5×5的卷积核

GoogLeNet是2014年ImageNet冠军,核心贡献就是Inception模块。它的想法很直白:与其争论“哪个卷积核尺寸最好”,不如把1×1、3×3、5×5的卷积和池化放在同一层并联,各自提取特征后在通道维度拼接,让网络自己学着权衡每个分支的重要性。

这种“并联”设计和传统的串行堆叠有本质区别。串行结构里,每一层只能看到一种尺度的卷积输出,信息一层层传递;而Inception在同一层就让网络同时看到小尺度细节(1×1、3×3)和大尺度上下文(5×5),再配合一个池化带来的“降采样视角”。四个分支合起来,相当于给这一层提供了四种不同“视野”的特征,后面的层可以根据任务需要自由组合。你可以把Inception想成开会时同时叫来四个不同专业背景的顾问,而不是让一个顾问反复改口径。

要保证四个分支的输出能拼在一起,有一个硬性条件:空间尺寸必须完全一致。1×1卷积不改变尺寸;5×5卷积用padding=2、stride=1,尺寸计算为(H+4-5)/1+1=H,保持不变;3×3卷积用padding=1同理;平均池化用kernel_size=3、stride=1、padding=1也保持不变。这里每个数字都不是随便定的,改任何一个都可能让cat在运行时直接报错。

还有一个细节值得单独说:3×3分支里其实串联了两个3×3卷积。两个3×3的感受野叠加起来约等于一个5×5,但参数量是2×3×3=18,比单个5×5的25个参数少,而且中间多了一次ReLU,非线性更强。在Inception里,“大卷积”很大程度是用小卷积堆出来的,这也是现代网络设计里反复出现的手法。

2.2 1×1卷积的真正作用:降低通道数、融合通道信息

很多初学者第一次看到1×1卷积会非常困惑:感受野只有1个像素,这不就是全连接吗?确实,1×1卷积在单个空间位置上做的事情,本质上是对通道做加权线性组合,可以理解成一个作用在通道维度上的全连接层。但它有两点不可替代的价值。

第一是通道信息融合。1×1卷积会把同一位置的所有通道按学习到的权重组合成一个新通道,让不同视角的特征先“碰个面”再交给下一层。前面我说第10讲的网络缺少通道间交互,1×1卷积恰好补上这一环。

第二是降维省计算。在Inception里,5×5卷积之前先接一个输出16通道的1×1卷积。假设输入是20通道,如果不降维,5×5卷积要处理20个输入通道;降维后只需要处理16个通道,通道数减少20%,对应计算量也下降。通道数越大,这个节省越明显。GoogLeNet原论文里,降维比例是非常关键的网络设计参数。1×1卷积的思想最早来自Network in Network,后来被Inception、ResNet的bottleneck、MobileNet等几乎所有主流结构继承。搞懂1×1卷积,后面看很多网络都会顺畅许多。

2.3 Inception模块的PyTorch实现与维度追踪

第11讲给出的Inception模块实现很精简,我用PyTorch整理出来是这样:

import torch import torch.nn as nn import torch.nn.functional as F class InceptionA(nn.Module): def __init__(self, in_channels): super(InceptionA, self).__init__() # 1x1卷积分支 self.branch1x1 = nn.Conv2d(in_channels, 16, kernel_size=1) # 5x5分支:1x1降维 + 5x5卷积 self.branch5x5_1 = nn.Conv2d(in_channels, 16, kernel_size=1) self.branch5x5_2 = nn.Conv2d(16, 24, kernel_size=5, padding=2) # 3x3分支:1x1降维 + 两个3x3卷积 self.branch3x3_1 = nn.Conv2d(in_channels, 16, kernel_size=1) self.branch3x3_2 = nn.Conv2d(16, 24, kernel_size=3, padding=1) self.branch3x3_3 = nn.Conv2d(24, 24, kernel_size=3, padding=1) # 池化分支:平均池化 + 1x1卷积 self.branch_pool = nn.Conv2d(in_channels, 24, kernel_size=1) def forward(self, x): branch1x1 = self.branch1x1(x) branch5x5 = self.branch5x5_1(x) branch5x5 = self.branch5x5_2(branch5x5) branch3x3 = self.branch3x3_1(x) branch3x3 = self.branch3x3_2(branch3x3) branch3x3 = self.branch3x3_3(branch3x3) branch_pool = F.avg_pool2d(x, kernel_size=3, stride=1, padding=1) branch_pool = self.branch_pool(branch_pool) outputs = [branch1x1, branch5x5, branch3x3, branch_pool] return torch.cat(outputs, dim=1)

这里有几个必须吃透的数字:四个分支的输出通道分别是16、24、24、24,所以不管输入通道数是多少,一个InceptionA的输出通道总数固定是16+24+24+24=88。池化分支里的平均池化用kernel_size=3、stride=1、padding=1,目的只有一个——把空间尺寸保持住,避免cat时维度对不上。

把它接进完整网络时,各层输出的维度变化如下:

操作输出尺寸备注
输入1×28×28MNIST单通道灰度图
conv1(5×5, 1→10)10×24×24(28-5)/1+1=24
ReLU + MaxPool(2)10×12×12长宽减半
conv2(5×5, 10→20)20×8×8(24-5)/1+1=8
ReLU + MaxPool(2)20×4×4长宽减半
InceptionA(20)88×4×4四分支cat,16+24+24+24
InceptionA(88)88×4×4结构相同,输入通道同步更新
flatten14084×4×88
Linear(1408, 10)1010类输出

对应网络定义:

class InceptionNet(nn.Module): def __init__(self): super(InceptionNet, self).__init__() self.conv1 = nn.Conv2d(1, 10, kernel_size=5) self.conv2 = nn.Conv2d(10, 20, kernel_size=5) self.incep1 = InceptionA(in_channels=20) self.incep2 = InceptionA(in_channels=88) # 关键:等于上一层输出通道数 self.mp = nn.MaxPool2d(2) self.fc = nn.Linear(1408, 10) def forward(self, x): in_size = x.size(0) x = F.relu(self.mp(self.conv1(x))) x = F.relu(self.mp(self.conv2(x))) x = self.incep1(x) x = self.incep2(x) x = x.view(in_size, -1) x = self.fc(x) return x

注意我特意在incep2这里标了“输入通道必须是88”。网上有些课程笔记把这个参数抄成了40,直接跑就会报通道数不匹配的RuntimeError。遇到这种情况别急着改网络结构硬凑,回到特征图尺寸表把通道数推一遍,比自己瞎猜高效得多。

3. ResNet残差结构:用恒等映射打破“退化”魔咒

3.1 退化问题:不是过拟合,而是网络“学不动”了

前面我提到56层普通网络的训练误差高于20层普通网络,这就是退化问题。它和“梯度消失”不完全是一回事:梯度消失是反向传播时梯度随层数指数级衰减,浅层几乎更新不动;而退化现象即使把每一层都做好初始化、把学习率调来调去,依然存在。更准确的说法是,深层网络的优化难度变大了,SGD很难在巨大的参数空间里找到一组让深层部分恰好实现恒等映射的参数。

ResNet的解法是给网络“开外挂”:在每个残差块里,让输入x通过一条捷径直接跳到块末尾,和经过两层卷积后的输出相加,再交给激活函数。这样整个块的映射变成:

H(x) = F(x) + x

其中F(x)是卷积层学出来的部分,x是恒等映射直接传过来的。网络要学的目标从“完整映射H(x)”变成了“残差H(x)-x”。如果某个残差块对当前任务没有贡献,它只需要把F(x)学成接近0——也就是把卷积核权重推向0,这比硬学一个恒等映射容易太多。

从梯度角度看,反向传播时x的梯度除了经过卷积层逐层回传,还有一条捷径可以直接到达前面的层。这相当于给梯度修了一条高速路,深层网络的梯度衰减问题被大幅缓解。这也是为什么ResNet之后,几百层甚至上千层的网络才真正变得可训练。

3.2 残差块的数学原理与短路连接设计

残差块在结构上必须满足一个前提:x和F(x)的尺寸要能直接相加。所以常规残差块里,两个卷积都用kernel_size=3、padding=1,保持空间尺寸不变,通道数也不变。这样“加法”就是一个逐元素相加,完全不需要引入额外参数。

课程里的残差块去掉了原论文常见的BatchNorm,保留了最核心的“卷积-ReLU-卷积-加-ReLU”结构。加法发生在第二个卷积之后、最终ReLU之前,这个顺序不是随便排的。如果先激活再加,那加回来的x就没有经过非线性处理,和后续特征的配合会差一些;先加再激活,网络可以在“对x改造的结果”和“原始x”之间做权衡,信息通道始终是开放的。

当确实需要改变通道数或下采样时,原论文会用一个1×1卷积或直接padding来调整捷径分支,让x的维度对齐F(x),这叫projection mapping。课程为了便于理解,只在通道数不变的位置放残差块,所以没用到这个技巧。你去翻torchvision里resnet18的源码,会在每个stage入口看到stride=2的卷积配合1×1投影,原理和这里完全一样,只是多了一步维度对齐。

3.3 ResidualBlock的PyTorch实现与细节说明

class ResidualBlock(nn.Module): def __init__(self, channels): super(ResidualBlock, self).__init__() self.conv1 = nn.Conv2d(channels, channels, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(channels, channels, kernel_size=3, padding=1) def forward(self, x): y = F.relu(self.conv1(x)) y = self.conv2(y) return F.relu(x + y)

这段代码非常短,却包含了残差结构的全部要点:两个3×3卷积保持维度、加法跳跃连接、跳跃之后统一激活。第一个卷积之后有ReLU,第二个卷积之后先做加法再ReLU,保证残差块的输出永远是激活后的非负值,也方便下一个残差块直接使用。

接入完整网络的维度变化:

操作输出尺寸
输入1×28×28
conv1(5×5, 1→16)16×24×24
ReLU + MaxPool(2)16×12×12
ResidualBlock(16)16×12×12
conv2(5×5, 16→32)32×8×8
ReLU + MaxPool(2)32×4×4
ResidualBlock(32)32×4×4
flatten512
Linear(512, 10)10
class ResNetNet(nn.Module): def __init__(self): super(ResNetNet, self).__init__() self.conv1 = nn.Conv2d(1, 16, kernel_size=5) self.conv2 = nn.Conv2d(16, 32, kernel_size=5) self.mp = nn.MaxPool2d(2) self.rblock1 = ResidualBlock(16) self.rblock2 = ResidualBlock(32) self.fc = nn.Linear(512, 10) def forward(self, x): in_size = x.size(0) x = self.mp(F.relu(self.conv1(x))) x = self.rblock1(x) x = self.mp(F.relu(self.conv2(x))) x = self.rblock2(x) x = x.view(in_size, -1) x = self.fc(x) return x

这里有个细节值得注意:rblock1放在第一次池化之后、conv2之前,输入是12×12的特征图;rblock2放在第二次卷积和池化之后,处理4×4特征图。两个残差块的channels参数必须与前面卷积输出通道对齐——rblock1是16,rblock2是32,一旦写错,加法那一步直接报错。这也是“在哪里插残差块”时最需要留意的逻辑。

4. 完整训练流程与网络结构测试:在MNIST上对比三种方案

4.1 数据准备与超参数设置

训练代码和前面几讲基本复用,但有几个点值得单独说明。

MNIST归一化用的是整个数据集的均值0.1307和标准差0.3081。这个数字不是随便拍的,是统计出来的。把像素从0~1范围转换到接近标准正态分布,能让不同像素的取值范围一致,梯度下降时每一步的更新方向更稳。如果不用归一化,或者把均值标准差填错,训练速度和最终精度都会受影响。

损失函数直接用CrossEntropyLoss。这个损失在PyTorch里内置了Softmax运算,所以模型最后一层输出10个原始logits即可,不需要在网络里手动加Softmax。这跟很多教程里“最后一层接Softmax”的写法不同,但PyTorch官方推荐的就是这种,数值上更稳定,反向传播时也不用担心Softmax+交叉熵的梯度被吃掉。

优化器用SGD加momentum,学习率0.01,动量0.5。动量可以理解为给梯度更新加了一个“惯性”,让参数更新方向在拐弯处更平滑、少震荡。这个系列课没用Adam,一方面是教学上想让结构本身成为重点,另一方面SGD+动量在小数据集上本来就够用,超参数也更直观。

4.2 训练代码与损失曲线观察

完整训练流程我合并在一起写,替换model那一行就能在普通CNN、InceptionNet、ResNetNet之间切换。

import torch from torch.utils.data import DataLoader from torchvision import transforms, datasets batch_size = 64 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST(root='../dataset/mnist', train=True, download=True, transform=transform) train_loader = DataLoader(train_dataset, shuffle=True, batch_size=batch_size) test_dataset = datasets.MNIST(root='../dataset/mnist', train=False, download=True, transform=transform) test_loader = DataLoader(test_dataset, shuffle=False, batch_size=batch_size) model = InceptionNet() # 或 ResNetNet() criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.5) def train(epoch): running_loss = 0.0 for batch_idx, (inputs, target) in enumerate(train_loader, 0): optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, target) loss.backward() optimizer.step() running_loss += loss.item() if batch_idx % 300 == 299: print('[%d, %5d] loss: %.3f' % (epoch + 1, batch_idx + 1, running_loss / 300)) running_loss = 0.0 def test(): correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs.data, dim=1) total += labels.size(0) correct += (predicted == labels).sum().item() print('Test accuracy: %d %%' % (100 * correct / total)) if __name__ == '__main__': for epoch in range(10): train(epoch) test()

实际跑起来,第一个epoch的loss一般在0.3左右,到第10个epoch会降到0.05以下。测试阶段用torch.no_grad()包裹是必须的,这里不需要反向传播,去掉的话PyTorch会把中间变量一直存着,内存占用明显上升。训练时间一长,这处差异就会非常明显。

4.3 结果对比:普通CNN vs Inception vs ResNet

我把第10讲的普通CNN、InceptionNet、ResNetNet放在相同超参数下各跑10个epoch,测试准确率大致如下(不同机器、不同随机种子会有小幅波动,看趋势就好):

模型测试准确率(约10 epoch)训练稳定性表现
普通CNN(第10讲)97.8%~98.3%正常收敛,后期波动略大
InceptionNet98.5%~98.9%收敛平稳,后期波动小
ResNetNet98.5%~98.8%收敛平稳,前期上升快

看完这个表,千万别得出结论说“GoogLeNet、ResNet不过如此”。MNIST是单通道、数字居中、背景干净的任务,那多出来的零点几个百分点基本来自结构自带的正则化效果和更稳的梯度流。这两个结构真正的威力体现在数据量大、类别多、图像复杂的场景——在ImageNet上,ResNet把错误率从VGG的7.3%压到3.57%,这是几代架构设计积累下来的结果。

所以从学习角度,这一讲的目标不是“在MNIST上刷分”,而是亲手实现两个经典结构,理解它们的设计动机。等以后在真实项目里看到torchvision.models.resnet50(),或者某个模型里出现1×1卷积、跳跃连接时,你会有一种“哦,我在第11讲见过”的熟悉感,而不是面对黑盒一脸懵。

5. 实战心得与常见问题排查

5.1 维度计算是写网络前必须做的功课

写任何CNN之前,先把“输入一张图,逐层推一遍维度”这个动作做完再动键盘,能省下大量“尺寸不匹配”的报错时间。卷积输出尺寸公式是:

(H - kernel + 2×padding) / stride + 1

对InceptionNet来说,两次池化后特征图是4×4×20,经过两个Inception模块后变成4×4×88,Linear输入因此是4×4×88=1408。如果某天你把conv1的kernel从5改成3,或者多加一个池化层,这个数字就变了。记住一个调试技巧:新建模型后,先塞一个随机张量看输出shape对不对,再决定fc的输入维度。

dummy = torch.randn(1, 1, 28, 28) model = InceptionNet() print(model(dummy).shape) # 期望 torch.Size([1, 10])

如果输出不是[1, 10],说明某处维度算错了。用print(model)逐层检查,或者在forward里每个中间步骤打印shape,比盯着报错信息猜快得多。我复现这个实验时,一开始把第二个Inception的输入通道写成20,结果跑到第8层直接报错——后来才发现是漏算了第一个Inception的88通道输出。这种错误几乎每个人都至少犯过一次,关键是怎么快速定位。

5.2 训练稳定性的细节处理

有几个细节直接影响训练效果,视频里没展开,但我建议你务必注意。

第一,归一化参数不能写错。MNIST用均值0.1307、标准差0.3081,换成CIFAR-10就得换成另一组统计量,而且RGB三通道各有各的均值和标准差。从官方文档或训练集统计里拿,不要图省事随便填(0.5, 0.5)。

第二,养成model.train()和model.eval()的切换习惯。这一讲的模型没有BN和Dropout,不切换也跑得动,但一旦换成真实项目里的标准ResNet,BN在训练时用批内统计量、测试时用全局统计量,必须靠这两个模式切换。习惯成自然之后,可以省掉很多奇怪的现象排查。

第三,SGD动量和学习率要配套。lr=0.01、momentum=0.5是这套代码的默认组合,在MNIST上很稳。把lr调到0.1,loss很容易震荡甚至发散;调到0.001,收敛会变得很慢。调参时每次只改一个变量,记录结果再动下一个。

第四,有GPU就别让CPU干等。课程全程CPU演示,MNIST单张图很小,CPU跑10个epoch也不慢,但有GPU的话,记得把model和每个batch的inputs、target都.to(device)。我习惯在文件开头写:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

后面统一用model.to(device)和inputs, target = inputs.to(device), target.to(device),这样同一套代码在两种环境下都能跑。

5.3 把这两个结构用在真实项目中的建议

最后说点实战层面的想法。

在真实项目里,绝大多数情况下你不会从零手写Inception或ResNet。torchvision自带了googlenet、resnet18/34/50/101等预训练模型,加载预训练权重做迁移学习,效果比从头训练好得多,也快得多。第11讲里从零实现的核心目的,是理解原理和设计动机。这个阶段别急着“优化”,先把代码跑通、把维度表推明白,比什么都强。

如果你确实需要自己搭这类结构,记住三条硬规则:残差块的通道数必须和输入对齐;Inception的输出通道总数决定下一层的输入通道数;1×1卷积永远是降维的第一选择。第11讲的极简代码里没有BN,在MNIST上够用,但换到真实图片分类,建议每个卷积后都加BatchNorm,收敛速度和稳定性都会有明显提升。这也是为什么我在实践中更倾向于参考ResNet“基础块加BN”的版本,而不是照搬课程里的极简版。

这一讲学到的两个思想,其实是“通杀”的。1×1卷积在EfficientNet、MobileNet里都是核心操作,残差连接在Transformer的每一个encoder层里都出现。花一个下午把这两段代码彻底弄懂,后面学注意力机制、学目标检测里的backbone,都会顺很多。我自己后来看论文时,只要看到projection、shortcut、dimensionality reduction这些词,脑子里浮现的就是第11讲这两个模块的样子。这就是基础结构带来的长期收益——它不只是让你会写两个网络,而是让你看任何现代模型时,都能迅速认出那些从2014年、2015年一路传承下来的设计基因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询