ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入PyTorch实践:从Inception到ResNet的CNN进阶架构

深入PyTorch实践:从Inception到ResNet的CNN进阶架构 从第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卷积用padding2、stride1尺寸计算为(H4-5)/11H保持不变3×3卷积用padding1同理平均池化用kernel_size3、stride1、padding1也保持不变。这里每个数字都不是随便定的改任何一个都可能让cat在运行时直接报错。还有一个细节值得单独说3×3分支里其实串联了两个3×3卷积。两个3×3的感受野叠加起来约等于一个5×5但参数量是2×3×318比单个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_size1) # 5x5分支1x1降维 5x5卷积 self.branch5x5_1 nn.Conv2d(in_channels, 16, kernel_size1) self.branch5x5_2 nn.Conv2d(16, 24, kernel_size5, padding2) # 3x3分支1x1降维 两个3x3卷积 self.branch3x3_1 nn.Conv2d(in_channels, 16, kernel_size1) self.branch3x3_2 nn.Conv2d(16, 24, kernel_size3, padding1) self.branch3x3_3 nn.Conv2d(24, 24, kernel_size3, padding1) # 池化分支平均池化 1x1卷积 self.branch_pool nn.Conv2d(in_channels, 24, kernel_size1) 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_size3, stride1, padding1) branch_pool self.branch_pool(branch_pool) outputs [branch1x1, branch5x5, branch3x3, branch_pool] return torch.cat(outputs, dim1)这里有几个必须吃透的数字四个分支的输出通道分别是16、24、24、24所以不管输入通道数是多少一个InceptionA的输出通道总数固定是1624242488。池化分支里的平均池化用kernel_size3、stride1、padding1目的只有一个——把空间尺寸保持住避免cat时维度对不上。把它接进完整网络时各层输出的维度变化如下操作输出尺寸备注输入1×28×28MNIST单通道灰度图conv1(5×5, 1→10)10×24×24(28-5)/1124ReLU MaxPool(2)10×12×12长宽减半conv2(5×5, 10→20)20×8×8(24-5)/118ReLU MaxPool(2)20×4×4长宽减半InceptionA(20)88×4×4四分支cat16242424InceptionA(88)88×4×4结构相同输入通道同步更新flatten14084×4×88Linear(1408, 10)1010类输出对应网络定义class InceptionNet(nn.Module): def __init__(self): super(InceptionNet, self).__init__() self.conv1 nn.Conv2d(1, 10, kernel_size5) self.conv2 nn.Conv2d(10, 20, kernel_size5) self.incep1 InceptionA(in_channels20) self.incep2 InceptionA(in_channels88) # 关键等于上一层输出通道数 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_size3、padding1保持空间尺寸不变通道数也不变。这样“加法”就是一个逐元素相加完全不需要引入额外参数。课程里的残差块去掉了原论文常见的BatchNorm保留了最核心的“卷积-ReLU-卷积-加-ReLU”结构。加法发生在第二个卷积之后、最终ReLU之前这个顺序不是随便排的。如果先激活再加那加回来的x就没有经过非线性处理和后续特征的配合会差一些先加再激活网络可以在“对x改造的结果”和“原始x”之间做权衡信息通道始终是开放的。当确实需要改变通道数或下采样时原论文会用一个1×1卷积或直接padding来调整捷径分支让x的维度对齐F(x)这叫projection mapping。课程为了便于理解只在通道数不变的位置放残差块所以没用到这个技巧。你去翻torchvision里resnet18的源码会在每个stage入口看到stride2的卷积配合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_size3, padding1) self.conv2 nn.Conv2d(channels, channels, kernel_size3, padding1) 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×28conv1(5×5, 1→16)16×24×24ReLU MaxPool(2)16×12×12ResidualBlock(16)16×12×12conv2(5×5, 16→32)32×8×8ReLU MaxPool(2)32×4×4ResidualBlock(32)32×4×4flatten512Linear(512, 10)10class ResNetNet(nn.Module): def __init__(self): super(ResNetNet, self).__init__() self.conv1 nn.Conv2d(1, 16, kernel_size5) self.conv2 nn.Conv2d(16, 32, kernel_size5) 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是16rblock2是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, trainTrue, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, shuffleTrue, batch_sizebatch_size) test_dataset datasets.MNIST(root../dataset/mnist, trainFalse, downloadTrue, transformtransform) test_loader DataLoader(test_dataset, shuffleFalse, batch_sizebatch_size) model InceptionNet() # 或 ResNetNet() criterion torch.nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.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, dim1) 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×88Linear输入因此是4×4×881408。如果某天你把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不切换也跑得动但一旦换成真实项目里的标准ResNetBN在训练时用批内统计量、测试时用全局统计量必须靠这两个模式切换。习惯成自然之后可以省掉很多奇怪的现象排查。第三SGD动量和学习率要配套。lr0.01、momentum0.5是这套代码的默认组合在MNIST上很稳。把lr调到0.1loss很容易震荡甚至发散调到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年一路传承下来的设计基因。
返回列表