简介:面向深度学习初学者与进阶者,提供用Python搭建简易CNN与ResNet的完整实现,并阐明ResNet并非另一类模型,而是CNN的残差扩展;覆盖卷积层、池化层、全连接层、激活函数以及残差块、跳跃连接等核心概念,内容按“第一周CNN、第二周ResNet”渐进组织,可直接当课程设计或自学项目参考。资源共24个文件,约25.27MB,以py源码、h5数据集/权重、pyc编译缓存和xml/iml工程配置为主;其中的py文件用于定义模型与训练流程,h5用于保存训练数据及已训练权重,辅助文件便于工程复现与二次修改,目录层次清楚。已有1099人浏览学习。配套代码能帮助读者由浅入深对比普通CNN与ResNet的区别,理解残差连接如何解决深层网络梯度消失问题,学会在Python环境中完成数据加载、模型搭建、训练验证和超参数调整。
1. 新手用 Python 搭 CNN 与 ResNet:先搞清 resnet 是不是 cnn 这件事
经常有人拿到一个“简易 CNN 和 ResNet 搭建”的需求就懵:CNN 我懂,卷积神经网络嘛,可 ResNet 又是什么,它俩是并列关系还是父子关系?答案是:ResNet 就是 CNN 家族里的一员,它没有发明新的层类型,而是用“残差块+跳跃连接”把卷积层组织得更深。你用 Python 搭一个 50 层的 CNN 可能梯度爆炸,但换成 ResNet 就能稳稳训练。这篇文章适合刚学会用 PyTorch 写卷积网络、又想在真实任务里把网络加深的人,目标是用最少的代码把两者都跑通,并知道参数怎么设、坑在哪。
2. 用 PyTorch 搭一个简易 CNN:从零开始的最小可用结构
2.1 CNN 的最小组成:卷积、池化、全连接,缺一不可
CNN 之所以能处理图像,核心是卷积操作。它用一个可学习的卷积核在图像上滑动,提取局部特征。和全连接网络不同,卷积层天然具备“权值共享”和“局部连接”两个特性,这也是为什么它参数量远低于全连接网络,却对图像的空间结构更敏感。一个最简单的 CNN 通常由三块组成:卷积层负责提特征,池化层负责压缩空间尺寸以减小计算量,最后用全连接层把展开的特征映射到类别概率。
搭建 CNN,我一般建议新手优先用 PyTorch 而不是 TensorFlow。原因有三个:一是它的动态计算图适合调试,print 一个张量的 shape 就能知道模型哪层出了问题;二是它自带 torchvision,里面预置了现成的 CNN 骨架和预训练模型,后面做 ResNet 迁移学习时省事;三是社区教程和代码片段多,遇到问题很容易检索到。当然,Python 环境必须先装好,如果你还没装,建议直接用 Miniconda 建一个虚拟环境,conda create -n cv python=3.10,然后pip install torch torchvision即可,版本没必要追最新,稳定即可。
2.2 简易 CNN 的完整代码:用手写数字识别跑通第一个模型
为了让你对“简易”有一个可复现的参照,我这里用手写数字 MNIST 数据集演示。MNIST 是 28×28 的单通道灰度图,类别 10 个,非常适合验证 CNN 管道是否通畅。下面这个模型是业界最常见的 LeNet 变体:两层卷积、两层池化、两层全连接。它参数量不到几十万,CPU 上训练一个 epoch 也只需几十秒。
import torch import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super(SimpleCNN, self).__init__() # 第一层卷积:输入1通道,输出8通道,核3*3 self.conv1 = nn.Conv2d(1, 8, kernel_size=3, stride=1, padding=1) # 第二层卷积:输入8通道,输出16通道 self.conv2 = nn.Conv2d(8, 16, kernel_size=3, stride=1, padding=1) # 池化层:2*2窗口,不重叠 self.pool = nn.MaxPool2d(kernel_size=2, stride=2) # 全连接层:16*7*7是第二次池化后的特征图尺寸 self.fc1 = nn.Linear(16 * 7 * 7, 64) self.fc2 = nn.Linear(64, num_classes) def forward(self, x): # 28*28 -> 28*28(padding=1保持尺寸)-> 14*14 x = F.relu(self.conv1(x)) x = self.pool(x) # 14*14 -> 14*14 -> 7*7 x = F.relu(self.conv2(x)) x = self.pool(x) # 展平:保留batch维度 x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.fc2(x) return x这段代码里最值得关注的是16 * 7 * 7这个数。输入 28×28,经过第一次池化变成 14×14,第二次池化变成 7×7,通道数从 1 变 8 再变 16,所以展平后是 16×7×7=784。如果你后面把输入图片改成 32×32,这个数就要跟着改,否则模型 forward 时就会报维度不匹配的错。padding=1的作用是让卷积不改变特征图的宽高,这样你就能用公式输出尺寸 = 输入尺寸 / stride直接推算池化后的尺寸。
训练时的套路也固定:用交叉熵损失nn.CrossEntropyLoss(),优化器用 Adam 或 SGD,学习率从 0.001 起步。MNIST 这种简单任务,通常 3~5 个 epoch 就能到 99% 左右,如果你的结果迟迟上不去,先检查数据归一化:一定要把像素值除以 255 换成 [0,1] 区间,原始 0~255 的输入会让梯度更新很不稳定。
2.3 四个必调参数:kernel_size、stride、padding、通道数怎么搭配
搭建 CNN 时真正让新手翻车的从来不是代码本身,而是这组参数怎么配。kernel_size决定每个卷积核看多大区域,3×3 是基准选择,因为两个 3×3 叠加的感受野等于一个 5×5,参数量却更少。stride决定卷积核每次滑几步,stride=1 是下采样前的默认值,如果 stride=2,特征图宽高直接砍半,可以替代池化层,但一般的简易模型不必追求这种替代。padding用来控制边界效应,padding=1配合 3×3 核恰好保持尺寸不变,padding=0则会让每层卷积后的图像缩小 2 像素,堆到第三层你再去算fc1输入维度会非常痛苦。
通道数则是“每一层提取多少特征”的自由度。一般从 8 或 16 起步,每过一个 stage 翻倍到 32、64、128。为什么翻倍?因为池化后空间尺寸减半,为了不丢失信息,用更多通道去补偿。但要注意,通道数翻倍带来的显存和计算量也翻倍,入门阶段控制在 64 以内最稳。如果你想要更直观地理解这些参数的影响,建议打印出每一层输出的 shape 来对照:
import torch model = SimpleCNN() dummy = torch.randn(4, 1, 28, 28) # 4张28*28灰度图 with torch.no_grad(): x = model.conv1(dummy) print("after conv1:", x.shape) x = model.pool(x) print("after pool1:", x.shape)这个验错习惯非常推荐保留。当你从网上下载别人搭的网络结构代码,或者自己改输入尺寸时,先用随机张量前向跑一遍,维度对不对立刻暴露,比训练时报错好定位得多。
3. ResNet 到底是不是 CNN:残差结构的来龙去脉
3.1 从 LeNet 到 VGG 再到 ResNet:血缘关系不可分割
要回答“resnet 是 cnn 吗”,不能只看名字。把 LeNet、AlexNet、VGG、ResNet 这四代网络并排看一下就明白了:LeNet 是 5 层的卷积+池化+全连接组合,AlexNet 把同样的结构做得更大更深,VGG 把卷积核统一成 3×3 并通过堆叠卷积层来提深度,而 ResNet 做的事情,是在 VGG 风格的卷积层堆叠之间加了一条“高速公路”——跳跃连接。从头到尾,它的基础组成单位仍然是nn.Conv2d、nn.BatchNorm2d、nn.ReLU、nn.MaxPool2d、nn.Linear,这些层没有一个是新发明。
所以严谨的结论是:ResNet 不是 CNN 的“替代品”,而是 CNN 的一种具体架构。它解决的核心问题是“网络加深后效果反而变差”,也就是论文里说的退化问题。你不加残差连接直接堆 34 层卷积,训练集上的 loss 都会比 18 层更高,这不是过拟合,而是梯度在反向传播过程中层层衰减,浅层参数几乎得不到有效更新。ResNet 的跳跃连接给梯度提供了一条“近路”,让深层网络第一次变得可训练。
3.2 残差块的设计直觉:恒等映射为什么有用
残差块的核心公式是output = F(x) + x,其中F(x)是两个 3×3 卷积加激活函数组成的子网络。这一加号看起来平淡无奇,但它带来了两个关键收益。第一,网络初始时可以把F(x)的权重推向 0,这样整个模块等价于恒等映射,模型不会因为加深而变差;第二,反向传播时梯度可以从输出直接流回输入,绕开卷积层的连乘效应,缓解梯度消失。
理解残差块后,你会明白为什么它没有破坏 CNN 的性质:卷积仍然是卷积,只是多了一条跨层连接。在 PyTorch 里实现一个 BasicBlock,正好是这个公式的直接翻译。这也是为什么很多从业者把 ResNet 当作“CNN 的进阶版”来学习——你学懂了普通 CNN 的卷积和池化,再加入一条 if 判断就能写出 ResNet。
3.3 用 Python 实现一个 BasicBlock:代码比你想的更短
import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(BasicBlock, self).__init__() # 第一个3*3卷积:可能升维或降采样 self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) # 第二个3*3卷积:保持尺寸与通道,用于特征提取 self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) # downsample用于调整输入x的通道数或尺寸,让F(x)与x能相加 self.downsample = downsample def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) if self.downsample is not None: identity = self.downsample(x) out += identity # 残差连接:梯度“近路” out = self.relu(out) return out这里有三个参数值得较真。bias=False是必须的,因为后面接了BatchNorm2d,BN 层自带偏置项,卷积层再加 bias 是冗余的,也会让模型收敛更慢。stride参数控制这个块是否对空间尺寸下采样,通常在 stage 交界处设为 2。downsample是一个 1×1 卷积,作用是把输入 x 的通道数或宽高对齐到卷积分支的输出,否则out += identity会因为维度不一致直接报错。
小白最容易忽略的坑是identity = x被放在了最前面,而x本身没有经过任何处理。如果conv1已经用 stride=2 把尺寸减半,那么identity也必须做相同步长的下采样,否则加法时 shape 对不上。这也是 ResNet 代码里最容易出现维度报错的位置,后面会专门讲。
4. 搭建简易 ResNet-18:结构拆解与完整代码
4.1 ResNet-18 的层结构:四个 stage 怎么组织
ResNet-18 的数字“18”表示带权重的层数(卷积+全连接,不算 BN 和 ReLU),它的骨架就是 BasicBlock 的四个 stage:第一层是 7×7 卷积加最大池化做快速下采样,后面四个 stage 各包含 2 个 BasicBlock,最后接全局平均池化和一个全连接分类层。每个 stage 的通道数依次是 64、128、256、512,这是 ResNet 最有价值的经验值:从 64 开始,每过一个 stage 翻倍,直到 512 就不再扩大。
这种安排的底层逻辑是把网络划分成不同分辨率的“特征级别”。浅层保留高分辨率、关注边缘和纹理,深层空间分辨率低、关注语义类别。你在自己的任务里可以用更小的通道数(比如 32、64、128、256)来换取速度和显存,换来的效果差异通常不大。简易搭建的意思正是这样:不要照搬 101 层的复杂变体,而是把 ResNet-18 吃透。
4.2 从 BasicBlock 组装 ResNet-18:完整模型代码
class ResNet18(nn.Module): def __init__(self, num_classes=10): super(ResNet18, self).__init__() self.in_channels = 64 # 第一层7*7卷积,stride=2,输入3通道(RGB图) self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) # 最大池化:进一步下采样 self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 四个stage,每组2个BasicBlock,后三个需要下采样 self.layer1 = self._make_layer(64, 2, stride=1) self.layer2 = self._make_layer(128, 2, stride=2) self.layer3 = self._make_layer(256, 2, stride=2) self.layer4 = self._make_layer(512, 2, stride=2) # 全局平均池化 + 分类全连接 self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, num_classes) def _make_layer(self, out_channels, blocks, stride): downsample = None # 当通道数变化或尺寸减半时,需要一个1*1卷积对齐 if stride != 1 or self.in_channels != out_channels: downsample = nn.Sequential( nn.Conv2d(self.in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels), ) layers = [] layers.append(BasicBlock(self.in_channels, out_channels, stride, downsample)) self.in_channels = out_channels for _ in range(1, blocks): layers.append(BasicBlock(out_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x = self.relu(self.bn1(self.conv1(x))) x = self.maxpool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x这段代码的关键在_make_layer。它先判断当前 stage 的第一个 BasicBlock 是否需要downsample,判断条件是stride != 1或in_channels != out_channels。以从 layer1 到 layer2 为例,输入通道 64,输出通道 128,且 stride=2,两个条件都满足,所以需要构造一个 1×1 卷积,把输入的 64 通道投影到 128 通道并把尺寸减半。
self.in_channels = out_channels这个小细节很反直觉但异常重要:它必须在循环构造第一个块之后再更新,否则后续每个块都会错误地触发 downsample。很多新手直接把self.in_channels写在_make_layer外面初始化后就忘了更新,结果搭出来的 ResNet 层数越多性能反而越差。如果你要训练 CIFAR-10 这类单通道或小尺寸数据集,记得把conv1的输入通道从 3 改成 1(灰度图)或保持 3 但去掉maxpool,因为 32×32 的图经一次 7×7 stride=2 卷积加池化后空间尺寸就只剩 8×8 了,特征损失太严重。
4.3 输出尺寸对照表:拿它去排查维度问题
| 层名 | 输入尺寸 | 操作 | 输出尺寸 |
|---|---|---|---|
| conv1 | 224×224×3 | 7×7, stride=2, pad=3 | 112×112×64 |
| maxpool | 112×112×64 | 3×3, stride=2, pad=1 | 56×56×64 |
| layer1 | 56×56×64 | 2×BasicBlock(64) | 56×56×64 |
| layer2 | 56×56×64 | 2×BasicBlock(128, stride=2) | 28×28×128 |
| layer3 | 28×28×128 | 2×BasicBlock(256, stride=2) | 14×14×256 |
| layer4 | 14×14×256 | 2×BasicBlock(512, stride=2) | 7×7×512 |
| avgpool | 7×7×512 | 全局平均 | 1×1×512 |
| fc | 1×1×512 | 全连接 | 10 |
这张表对应的是 224×224 的 RGB 输入,是 torchvision 里预训练模型的默认尺寸。如果你自定义输入尺寸,最需要盯住的是每个 stride=2 的层会让宽高减半,以及全连接层的输入维度必须是512 × 最后特征图宽 × 高。在使用AdaptiveAvgPool2d((1,1))后,宽高已经被强行压成 1,所以fc的输入永远是 512,这也正是为什么 ResNet 在fc之前对图片尺寸不敏感。
4.4 torchvision 预训练模型:用还是不用
训练一个 ResNet-18 从零到收敛在 CIFAR-10 上大约需要几十分钟,而 ImageNet 上的完整训练在单卡上要跑一周,这对绝大多数想快速验证的人来说成本太高。所以业界的常见做法是直接用 torchvision 加载预训练模型,再微调最后一层。做法是:
import torchvision.models as models model = models.resnet18(pretrained=True) # 替换最后一层,把1000类改成你自己的类别数 model.fc = nn.Linear(512, num_classes)这里有一个容易忽略的“坑中坑”:“pretrained”本质是把 ImageNet 上学习的卷积核权重拿过来迁移,如果你的任务数据集和 ImageNet 图像风格差异太大(如医学图像、卫星图),冻结前几层往往效果不好。我的经验是第一轮冻结所有层只训练fc,看验证集准确率;如果涨势明显,就解冻最后两个 stage 用小学习率微调。这个策略能在数据和算力有限时把迁移学习的效果拉满。
5. 搭建路上的 5 个常见问题与排查:维度、权重与训练不收敛
5.1 输入输出维度对不上:模型一 forward 就报错
现象:只要把图片喂进模型,PyTorch 直接抛出RuntimeError: size mismatch,甚至有时在训练第一个 epoch 中途崩掉。我见过不少同事把网上 224×224 的 ResNet 代码直接拿去跑 28×28 的 MNIST,报错后第一反应是改fc层而不是改前段网络。
原因:ResNet 第一个 7×7 卷积 stride=2,加上 3×3 最大池化,会把 28×28 的图像压缩到 6×6 甚至更小,和后面AdaptiveAvgPool2d((1,1))的衔接没问题,但如果手动写了fc1 = nn.Linear(512*7*7, 10),那 28×28 输入得到的特征图就不是 7×7,整个网络直接断掉。
解决:任何模型拿到手上,第一件事先用随机张量前向验证。我之前说的dummy = torch.randn(4, 3, H, W)脚本要养成肌肉记忆。如果报错来自fc层输入维度,最省事的办法是把self.avgpool后的torch.flatten(x, 1)打印出来看实际是多少维,然后直接改fc的输入数。如果报错来自卷积层,问题多半在 stride 或 padding 组合,稳妥做法是减少一个池化层,或者把输入图片 resize 到 64×64 以上。
5.2 残差块的 shortcut 通道不匹配:out += identity 加法失败
现象:代码明明照着 BasicBlock 写的,训练时却报错,报错信息集中在out += identity这一行,提示两个张量的 shape 不一致,比如 [16, 128, 28, 28] 和 [16, 64, 28, 28]。
原因:stage 交界处的第一个 BasicBlock 把输出通道从 64 翻倍到 128,同时 stride=2 把空间尺寸减半。如果downsample没有把输入的 64 通道投影到 128,或者没有执行 stride=2 下采样,identity就和out对不上。
解决:确认_make_layer里 downsample 的构造条件是stride != 1 or self.in_channels != out_channels,并且 downsample 是一个包含 1×1 卷积和 BN 的nn.Sequential。另外特别提醒:1×1 卷积的 stride 必须和第一个 BasicBlock 的 stride 相同。很多复制粘贴来的代码只在downsample里写了kernel_size=1,却忘了写stride=stride,结果是通道数对齐了但尺寸没对齐,报错依旧。
5.3 训练 loss 居高不下或直接 NaN:学习率才是罪魁祸首
现象:loss 在第一个 epoch 就从 2 左右掉到 0.7,然后就卡住不动,或者干脆变 NaN。新手第一反应是改网络结构,把卷积核从 3×3 换成 5×5,问题是整个网络本来就没做错。
原因:ResNet 里大量使用 BatchNorm,它对学习率非常敏感。用默认的 0.1 学习率配合 batch size 64,在前几层容易引发梯度爆炸;而改成 0.0001 又太慢,一个 epoch 下来 loss 几乎不动。另一个隐蔽原因是数据没有归一化,输入像素值在 0~255 时 BN 的均值和方差估算会被拉偏。
解决:ResNet 训练的标准学习率是 0.1 配合 batch size 256,如果你显存只够放 32 或 64,学习率要等比例下调到 0.01 或 0.005。用手写数字这类简单任务时,直接从 0.001 开始用 Adam 更省心。如果出现 NaN,先打印第一个 batch 的预测值和 loss,多数情况是Conv2d的权重初始化方差过大,解决办法是在self.conv1上使用nn.init.kaiming_normal_手动初始化一次。
5.4 CPU 训练太慢:batch size 与验证策略调整
现象:用笔记本 CPU 训练 ResNet-18,一个 epoch 要二十分钟,肉眼可见地等到怀疑人生。这时很多人会想加大 batch size,让每个 epoch 跑得更快,结果发现内存直接爆掉。
原因:ResNet-18 虽然只有约 1100 万参数,但中间特征图的尺寸大、显存占用高。CPU 上的计算瓶颈主要在卷积的矩阵乘法,batch size 从 4 加到 16 反而会让 CPU 缓存命中率下降,速度提升并不线性。
解决:CPU 环境里最实际的组合是 batch size=8,图片 resize 到 64×64 或 128×128,训练 epoch 数减到 5~10 个。另外把验证集的评估从每个 epoch 一次改成每 100 个 step 一次,可以及时发现问题。更直接的方案是用 torchvision 自带的数据集做小规模实验,确认模型能收敛后,再上 GPU 跑完整训练,这也是我做深度学习项目时的通用顺序。
5.5 dropout 与 BN 混用:ResNet 不该乱加正则化
现象:为了防过拟合,有人在 ResNet 的每个 BasicBlock 里加了nn.Dropout(p=0.5),结果训练集准确率正常,验证集全程不涨,甚至还不如不加。
原因:ResNet 依赖 BN 层的统计特性来稳定训练,而 Dropout 会随机切断神经元,破坏 BN 计算出的均值和方差,两者的冲突会让网络在推理时行为不一致。这也是 ResNet 原论文里没有在卷积块内使用 Dropout 的原因。
解决:ResNet 的防过拟合手段首选是数据增强和权重衰减(weight_decay=1e-4),而不是 Dropout。如果你实在想在fc层前加一点 Dropout,概率请设在 0.2 以下,并且保证它的位置在全局池化之后而不是卷积块内。这个教训是我自己从一次 Kaggle 比赛里“翻车”之后才悟出来的,后来凡是项目里出现 BN+Dropout 混用,我第一反应就是先删掉 Dropout 再看效果。
6. 让简易模型真正可用:预训练迁移与特征图可视化技巧
到了这个阶段,你已经能把 CNN 和 ResNet 从零写出来并在小数据集上跑通,但这还只是开始。真正的落地场景往往是数据少、算力有限,这时让模型变好用,靠的是下面两个技巧,而不是换更大的网络。
第一个技巧是预训练迁移,它的价值常常被低估。你在 torchvision 里加载的resnet18(pretrained=True),权重是在 ImageNet 上训出来的,模型的前五层卷积已经学会了边缘、纹理、色块这些通用特征。对于你的新任务,这些特征绝大多数是可复用的。常见做法是冻结前 3 个 stage,只训练最后一个 stage 和全连接层,用 0.001 的学习率跑 5 个 epoch,然后再解冻全部层用 0.0001 微调 5 个 epoch。这套流程能让你用很小的数据集达到不错的准确率,前提是数据分布不要偏离自然图像太远。
第二个技巧是特征图可视化,这是排查“模型到底学到了什么”最直接的手段。用一行 Python 代码就能把第一层卷积核和中间特征图输出成图片:
import torch import torchvision.models as models import matplotlib.pyplot as plt model = models.resnet18(pretrained=True) model.eval() dummy = torch.randn(1, 3, 224, 224) with torch.no_grad(): feats = model.layer1[:2](dummy) # layer1里的一个BasicBlock输出 feat = feats[0, 0].numpy() # 取第一个通道 plt.imshow(feat, cmap='gray') plt.savefig("feature_map.png")如果特征图看起来一片噪声或全部黑掉,说明前面层的权重没有学到有效信息;如果能看到清晰的边缘和纹理,说明迁移学习的效果是好的。我个人的习惯是,任何一个数据集的第一次训练结束后,都会分别看一下最后一层卷积的特征图和全连接层的输出分布,这会帮你判断问题出在特征提取还是分类头上。
最后说一句掏心窝的话:我见过太多人把这篇文章里的代码跑通后,就急着去换 ResNet-50、甚至 EfficientNet,结果训练时间和显存翻了几倍,准确率却没涨多少。真正让我少走弯路的习惯是把 ResNet-18 当作一个可靠的“基线模型”,先用它验证数据和训练流程,再决定要不要换更深的网络。无论你的任务最终用多复杂的模型,先把简易 CNN 和 ResNet 的维度计算、残差连接、训练参数调对,后面的路会顺得多。希望帮到你。
本文还有配套的精品资源,点击获取