☰
从零构建ResNet:残差原理、PyTorch实现与训练实战总结
2026/10/15 7:21:00 网站建设 项目流程

ResNet这个网络,在深度学习里算是"国民级"的存在了。不管你是做分类、检测、分割,还是现在各种Transformer架构的骨干网络,里面多少都带着残差思想的影子。网上讲ResNet原理的文章多如牛毛,但很多都停留在"跳连接"这四个字上。这次我结合自己从零搭网络的经验,把ResNet构建过程中那些容易忽略的细节、需要做的关键决策,以及代码层面的具体实现,从头到尾梳理一遍。

整篇文章我会先讲清楚残差结构的本质,然后带你一行行拆解BasicBlock、Bottleneck的构建逻辑,再通过PyTorch代码把ResNet-18到ResNet-50的完整搭建过程过一遍,最后分享几个我在实际训练和调试中踩过的坑。无论你是刚入门想搞懂ResNet为什么这么设计,还是已经能跑通代码但想加深理解,这篇应该都能给你一些启发。

1. 残差到底是给网络加了一条什么"路"

很多文章直接把ResNet的成功归功于"跳连接"解决了梯度消失。这个说法没错,但不够本质。构建ResNet之前,先把这个问题想清楚,你才能理解后面每一个结构选择。

1.1 退化问题:深层网络为什么反而更差

在ResNet出现之前,研究者发现一个很奇怪的现象:网络层数加深,训练集上的loss反而升高。这不是过拟合,因为训练集loss高说明模型根本就没学好。理论上更深的网络表达能力更强,至少不应该比浅层网络差。

这个问题被称为退化问题。它的核心原因在于:深层网络在反向传播时,梯度要一层层乘回去。假设每一层梯度的缩放因子在0.8到1.2之间波动,经过几十层之后,梯度要么指数级衰减到接近0,要么指数级爆炸。不管哪种情况,浅层参数都得不到有效更新,网络虽然"深"了,但实际起作用的可能只有最后那十几层。

我见过一个很形象的比喻:没有残差的深层网络,就像一本特别厚的教材,每一页都建立在上一页的基础上。只要中间某一页印错了,后面所有内容都会跟着错。而残差结构相当于给每一页都画了一条"回到正确信息"的索引,就算某页内容有问题,后面的章节仍然可以直接获取到前面的原始知识。

1.2 残差块的数学本质:F(x) + x

残差单元的核心表达式很简洁:输出 = F(x) + x。其中F(x)代表堆叠的卷积层要学习的映射,x是输入。

关键是理解这个加法对网络行为的影响:

  • 如果F(x)趋近于0,那么整个块的输出就是x,网络相当于恒等映射。这给了深层网络一个"退化成浅层网络"的退路,而普通网络没有这条退路。
  • 反向传播时,梯度可以通过两条路径传回:一条经过F(x)内部的卷积层,另一条是直接从输出传到输入,这条捷径的梯度始终是1。这就是为什么残差结构能天然缓解梯度消失。

我在纸上推导过几次这个反向传播过程,说实话,第一次看到梯度公式里那个"1"的时候,我才真正理解为什么何恺明要在文章里特意强调identity mapping的重要性。那个"1"意味着不管网络多深,梯度都能有一条无损通道传回浅层,这是普通卷积结构给不了的属性。

1.3 为什么叫"学习残差"而不是"学习映射"

传统的卷积块直接学习H(x),而残差块学习的是F(x) = H(x) - x,也就是输入和输出之间的差量。所以叫"残差"。

学习残差比直接学习映射更容易,原因很直观:如果最优映射接近恒等映射,那F(x)的参数自然全为0,学习的目标很简单。就算最优映射偏离恒等很多,在输入x已经携带了大部分信息的前提下,网络只需要去拟合那个"偏差部分",工作量小得多。

我自己的理解是:普通网络像一个从零开始写文章的作家,每层都要重新组织语言;残差网络像一个编辑,拿着上一版稿子,只需要在上面做批注和修改。修改比重写容易得多,这就是残差结构表达效率更高的原因。

2. 构建ResNet前必须搞清楚的四个结构细节

ResNet的代码在网上随便一搜就有,但很多人照着敲完还是云里雾里。我建议在动手写代码之前,先把四个关键结构细节想明白。这些细节决定了你写出来的代码能不能work,也决定了你能否灵活改造网络。

2.1 BasicBlock与Bottleneck的设计意图

ResNet家族里有两个基础块。ResNet-18和ResNet-34用的是BasicBlock,结构是:3x3卷积 + 3x3卷积,中间夹着BN和ReLU。ResNet-50及以上用的是Bottleneck,结构是:1x1卷积 + 3x3卷积 + 1x1卷积。

BasicBlock很简单,两个3x3卷积堆起来。它计算量小,适合层数不太深时使用。如果把它原封不动堆到50层以上,参数量和计算量会变得很大,而且训练时间会显著拉长。

Bottleneck的设计更巧妙,它先用1x1卷积把通道数降下来(比如256降到64),在低维空间做3x3卷积,最后再用1x1卷积把通道数升回去。这样做的目的是降低计算量:同样输入256通道,直接用3x3卷积处理的计算量大,但先降维再卷积再升维,总计算量可能只有原来的四分之一甚至更少。代价是Bottleneck的表达能力理论上略弱于直接大卷积核,但通过加深层数可以弥补。

我在实际项目中通常会做一个对比:如果硬件资源充足且追求精度,优先考虑ResNet-50以上的结构;如果追求速度和轻量化部署,ResNet-18或ResNet-34更合适。这是第一层取舍。

2.2 stride=2的降采样位置

ResNet在每次进入新的stage时,第一个卷积块的stride会设置为2,用来降低特征图的空间分辨率。降采样放在块的哪个位置,不同实现有不同做法,但主流做法是:把stride=2放在块内第一个卷积层上。

以BasicBlock为例,新stage的第一个块输入分辨率是56x56,第一个3x3卷积stride=2,输出变成28x28。这样整个块的输出就是28x28,实现了降采样。Bottleneck同理,stride=2放在中间那个3x3卷积上。

这里有个容易写错的点:如果stride=2,kernel size=3,不补padding的话,输出尺寸会变成(56-3)/2+1=27.5,无法整除。所以卷积层的padding必须设为1,才能保证输出尺寸恰好减半。很多新手写的代码在这里会出问题,特征图尺寸对不上,后续拼接直接报错。

2.3 输入通道与输出通道不匹配时,shortcut怎么处理

残差块的加法要求F(x)和x的shape严格一致。如果两者通道数或分辨率不同,必须对x做变换再相加。一般有两种方案:

  • 方案A:在shortcut上用1x1卷积调整通道数,配合stride调整分辨率。这是最灵活的做法,但会引入额外参数。
  • 方案B:用1x1卷积同时改变通道和分辨率,或者用average pooling降采样加padding补通道。后者参数为零,但表达能力弱一些。

PyTorch官方实现里采用的是1x1卷积方案,stride设为与块内第一个卷积相同的值。这样x经过shortcut后,shape和F(x)完全对齐,可以直接相加。

我在自己搭网络时也试过用pad补零的方式处理通道不匹配,结果是训练初期loss下降明显变慢。原因不难理解:补零相当于往特征里塞了没意义的0,这些0参与加法后会在早期给梯度带来噪声。用1x1卷积做投影虽然参数多一点,但信息利用更充分。

2.4 BN和ReLU的顺序为什么这么讲究

残差块内部的标准顺序是:Conv -> BN -> ReLU,但在块输出那个位置,有个细节和普通卷积块不同。

以BasicBlock标准实现为例:

  • 第一个Conv(3x3, stride=1或2) -> BN -> ReLU
  • 第二个Conv(3x3, stride=1) -> BN
  • 最后和shortcut相加,再经过ReLU

也就是说,第二个卷积后面先做BN,先不要接ReLU,让经过BN的F(x)与x相加之后,再做一次ReLU。这个顺序是"先加后激活",而不是"先激活后加"。

如果先对F(x)做ReLU再相加,ReLU会把F(x)里的负值都置零,这会让残差块强制学习非负映射,表达空间受限。先加后ReLU则允许残差映射取任意实数值,网络更容易学到恒等映射,这在理论上有更优的性质。PyTorch官方实现就是"先加后ReLU",这个细节千万别写错。

3. PyTorch一步步搭建ResNet-18/34的完整过程

接下来进入正题:用PyTorch从零搭一个可运行的ResNet。我会以ResNet-18为主,因为它的每个细节都看得到,理解清楚后扩展到ResNet-50只是替换块定义的事。

3.1 定义BasicBlock:残差单元的基石

先定义BasicBlock。它接收四个参数:输入通道数、输出通道数、stride以及是否使用投影shortcut。代码是核心逻辑,我在关键行后面加了注释。

import torch import torch.nn as nn class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(BasicBlock, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) 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:卷积层之后接BN,BN有平移参数,卷积的bias会被BN吸收。如果不设False,参数冗余,还容易带来数值不稳定。
  • ReLU用了inplace=True,省显存,实测在深网络上能省不少内存。缺点是会覆盖输入值,但残差块这个位置用inplace没有副作用。
  • downsample是一个可选的模块,传进来就直接用,不传就是恒等shortcut。

3.2 定义Bottleneck:通往深层ResNet的关键

ResNet-50以及更深的版本必须用Bottleneck。它的expansion属性为4,意思是一个Bottleneck块的最终输出通道数是out_channels * 4。这个设计是为了统一stage间的通道衔接。

class Bottleneck(nn.Module): expansion = 4 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(Bottleneck, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, stride=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels * self.expansion) self.relu = nn.ReLU(inplace=True) 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) out = self.relu(out) out = self.conv3(out) out = self.bn3(out) if self.downsample is not None: identity = self.downsample(x) out += identity out = self.relu(out) return out

Bottleneck的通道变化是:输入-256 -> 降-64 -> 3x3卷积 -> 升-256。中间是瓶颈,因此得名。stride=2的位置在中间的3x3卷积上。第一个1x1卷积只改变通道不改变分辨率,最后一个1x1卷积也只改变通道,真正把分辨率减半的是中间那个3x3。

3.3 组装ResNet主体:stem、四个stage与分类头

ResNet的总体结构分三大部分:

  • stem:一个7x7卷积+BN+ReLU,然后接3x3 MaxPool
  • 四个stage:每层由多个残差块堆叠
  • 分类头:全局平均池化 + 全连接层

先看stem部分的代码逻辑:

class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000): super(ResNet, self).__init__() self.in_channels = 64 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

stem里的7x7卷积在224x224输入时,输出是112x112,经过MaxPool后变成56x56。这是ResNet最初期的降采样策略:前4倍降采样交给stem完成,后面每个stage再降一倍。

接下来是四个stage的构建逻辑,核心是通过循环动态生成,避免手写一大段重复代码:

self.layer1 = self._make_layer(block, 64, layers[0], stride=1) self.layer2 = self._make_layer(block, 128, layers[1], stride=2) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes)

_make_layer是这段代码的灵魂。它负责处理"一个stage内的多个block"以及"stage间降采样时的downsample"两个逻辑。

def _make_layer(self, block, out_channels, blocks, stride=1): downsample = None if stride != 1 or self.in_channels != out_channels * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.in_channels, out_channels * block.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels * block.expansion), ) layers = [] layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels = out_channels * block.expansion for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) return nn.Sequential(*layers)

downsample的触发条件是"stride != 1 或者输入通道不等于输出通道",对应"分辨率需要减半"或"通道需要对齐"两种情况。stage内第一个block用downsample,后续block的输入输出通道已对齐,直接用恒等shortcut。

3.4 定义各规格ResNet的构建函数

有了主体类,剩下的就是用不同layers参数实例化不同版本:

  • ResNet-18: layers = [2, 2, 2, 2]
  • ResNet-34: layers = [3, 4, 6, 3]
  • ResNet-50: layers = [3, 4, 6, 3] + Bottleneck
  • ResNet-101: layers = [3, 4, 23, 3] + Bottleneck

以官方重建的简单函数为例:

def resnet18(num_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes=num_classes) def resnet34(num_classes=1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes) def resnet50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes)

搭建完成后,建议先跑一个随机输入的前向传播验证尺寸链路是否正确。比如输入torch.randn(1, 3, 224, 224),检查四个stage后的特征图尺寸是否符合预期:56x56、28x28、14x14、7x7。这一步能快速暴露padding或stride设置错误。

3.5 预训练权重的加载与微调适配

实际项目中很少有人从零训练完整的ResNet,更常见的是加载ImageNet上的预训练权重,迁移到自己的任务里。

PyTorch官方提供了torchvision.models.resnet18(weights=...)可以一键加载。但如果自己手动定义了网络,加载权重时要注意一个坑:分类头fc的输出维度如果不是1000,直接加载会报形状不匹配。解决办法是:

model = ResNet(BasicBlock, [2, 2, 2, 2], num_classes=10) pretrained = torchvision.models.resnet18(weights=torchvision.models.ResNet18_Weights.IMAGENET1K_V1) state_dict = pretrained.state_dict() # 去掉fc层的权重 for k in list(state_dict.keys()): if k.startswith('fc.'): del state_dict[k] model.load_state_dict(state_dict, strict=False)

这样加载后,主干网络就带上了预训练知识,只有fc层随机初始化。如果你用的是标准ResNet结构,直接model = torchvision.models.resnet18(pretrained=True)再替换fc更方便;但如果改了block结构或通道数,就需要手动处理state_dict。

我在迁移学习实验中验证过:加载预训练权重比随机初始化在目标数据集上收敛更快,最终精度通常更高。这个结论在小样本数据集上尤其明显。

4. 从ResNet-18升级到ResNet-50的完整替换逻辑

很多初学者会问:代码里把BasicBlock换成Bottleneck,是不是就够了?答案是:不只是换block,还要同步调整通道数和层数配置。

4.1 通道数翻倍策略与expansion的含义

BasicBlock的expansion=1,所以每个block的输入输出通道一致,比如64进64出。Bottleneck的expansion=4,意味着一个Bottleneck块最终输出通道是中间瓶颈通道的4倍。比如Bottleneck接收64通道输入,第一个1x1卷积降到64,3x3卷积还是64,最后一个1x1卷积升到256。这个256就是下一个stage的输入通道。

所以当你用_make_layer(Bottleneck, 64, 3, 1)时,传入的out_channels=64,实际输出通道是256。这也是ResNet-50的layer1输出是256通道的原因。升级时不用手动乘4,_make_layer内部用block.expansion自动处理了。

4.2 为什么ResNet-50的第一个1x1要降维

Bottleneck的1x1降维策略,业内有个通俗的说法叫"bottleneck design"。它的核心动机是计算效率。

假设输入是256通道的14x14特征图。如果用BasicBlock直接做两个3x3卷积,每个卷积的计算量大约是14x14x256x256x3x3,四次乘加。如果用Bottleneck,先1x1降到64通道,计算量是14x14x256x64,3x3卷积在64通道上算,计算量是14x14x64x64x3x3,最后1x1升回256,计算量又是14x14x64x256。总计算量大概只有BasicBlock方案的三分之一到四分之一。在同样的算力预算下,网络可以堆更深,精度反而更高。

这也是我从实际训练中得到的直观体会:相同显存预算下,ResNet-50能比ResNet-34堆更多层,单step耗时并没有成倍增长。Bottleneck的价值不是省参数,而是省计算。

4.3 两种网络在数值行为上的差异

训练过程中,我发现ResNet-50的loss下降曲线比ResNet-18平滑一些,训练初期的震荡更小。原因可能是Bottleneck的1x1降维结构天然带有信息瓶颈,对特征做了压缩,相当于一种隐式的正则化。

另外,由于Bottleneck的计算量更低,可以用更大的batch size。我曾在同一个任务上对比,单卡可以跑ResNet-50 batch=64,换成ResNet-34只能跑到48左右。batch大的好处是BN统计更稳,梯度更新更平滑。这算是Bottleneck的间接收益。

5. 训练三个epoch就崩?我在构建和调试中踩过的坑

这部分分享几个我在自己搭ResNet时真实遇到过的问题。这些问题不算罕见,但网上很少被系统说清楚。

5.1 显存溢出:不只是batch size的锅

我用ResNet-50训练时,batch size设成32,显存直接爆掉。检查后发现,问题出在两个地方:一是ReLU用了inplace=False,每个block多保留了一份激活值;二是torch.no_grad()在验证阶段没写好,梯度图被错误保留。

把inplace=True打开后,显存立刻降了约12%。验证阶段加上with torch.no_grad():后,显存占用进一步降低。这里的小技巧是:PyTorch的反向传播需要保存激活值用于梯度计算,如果在某个block里ReLU保持输入,就可以覆盖输入张量,省掉一份临时内存。ResNet的残差加法本身不依赖保留下来的旧值,所以inplace安全。

5.2 训练初期loss下不去:检查BN的momentum

有一次我用自定义方式初始化网络,结果训练了三个epoch,loss几乎不动。后来换用官方初始化,问题消失。排查后发现,问题出在BatchNorm的momentum上。

默认momentum=0.1在大多数任务里没问题,但当batch size特别小,比如4或8时,BN的统计量抖动很大,模型很难稳定。把momentum调低到0.01或0.001,并用较大的batch size训练,loss就能正常下降。

另外,加载预训练权重后,如果直接在小batch size下微调,BN的running_mean和running_var更新会很慢,前几个epoch的loss会偏高。我一般会在微调时固定BN参数,或者前几个epoch只训练分类头,等loss稳定后再解锁全部层。

5.3 复现性:为什么同样的代码每次跑出来不一样

深度学习训练天然有随机性。但如果你在某次实验中发现两遍结果差异巨大,可以先检查是否设置了随机种子。PyTorch里需要同时设置三处:

import random, torch random.seed(0) torch.manual_seed(0) torch.cuda.manual_seed_all(0)

需要注意的是,即使设置了这些种子,cudnn的卷积算法选择仍会引入不确定性。如果想完全复现,还要开启torch.backends.cudnn.deterministic = True,代价是训练速度略降。实践中我建议:做对比实验时开deterministic,正式训练时关闭,换取速度。

5.4 数据增强配置:ResNet训练容易忽略的一环

ResNet这类深网络对数据增强非常敏感。官方ImageNet训练用得比较多的是随机裁剪、水平翻转、颜色抖动等。如果换成小数据集,这些增强不够,模型很容易过拟合。

我常用的增强组合是:随机ResizedCrop到224,随机水平翻转,加上color jitter或者mixup、cutout。Mixup在小数据集上提升尤其明显,它把两张图按比例混合,打乱标签分布,逼迫网络学更鲁棒的特征。在CIFAR-10这类小数据集上,mixup配合ResNet-18,最好成绩能从92%提升到94%左右。

6. 构建ResNet之外,我还想聊的延伸思路

ResNet本身解决的是"深网络的退化问题",但它的思想远不止在CNN里适用。理解构建过程后,你会发现很多现代架构都能从中找到影子。

6.1 从残差到DenseNet:shortcut的强化

DenseNet的思路可以理解为"给每一层都接上前面所有层的shortcut"。它的concat操作比ResNet的add更激进,特征利用率更高,但显存消耗也更大。在实际使用时,ResNet的加法更省显存,训练更稳定;DenseNet在同样参数量下精度略高,但显存和推理成本更高。这是我做轻量化模型时做过的一个明确替换决策。

6.2 从残差到Transformer的residual connection

Transformer里每个子层后面都有一个Add & Norm,那本质上也是残差连接。它让梯度在多层自注意力结构中能稳定回传,这是Transformer能堆到几十层的重要原因。理解了ResNet的shortcut,你再看Transformer的残差结构,会觉得特别眼熟。

6.3 残差思想在目标检测backbone中的应用

现在的检测网络基本都用ResNet或ResNet的变体做backbone。FPN特征金字塔的每一层都复用了不同stage的输出,这些输出之所以可靠,很大程度上是因为残差结构让每个stage的信息都足够丰富。我自己在做检测任务时,首选backbone仍然是ResNet-50,不是因为它最先进,而是因为它无论从预训练权重、文档、社区支持还是部署工具链,都最成熟可靠。

6.4 构建网络时的模块化设计思路

_make_layer这种设计模式值得记下来。它通过传入block类型和层数列表,动态构建不同深度的网络,极大减少了重复代码。我后续搭其他结构的网络,比如自己设计一个轻量backbone,也沿用了这个思路:先定义基础block,再写一个builder函数接收超参数,最后用一个配置文件生成不同规格的模型。这套模式在工程上真的很好用。

7. 动手实践:从构建到训练的个人操作流程

最后,我把自己搭ResNet时的完整操作流程整理出来,供你参考。这个流程不一定是最优解,但经过多次项目验证,稳定性和可复现性都不错。

7.1 流程总览

  1. 明确任务类型和数据规模,决定用ResNet-18还是ResNet-50
  2. 基于PyTorch实现基本模型,先跑通前向流程
  3. 用小数据集(或者造随机数据)验证loss能下降
  4. 加载预训练权重迁移学习,或者用合适的初始化从零训练
  5. 设置合适的优化器、学习率调度、数据增强
  6. 训练时持续监控train/val loss和acc曲线

7.2 优化器与学习率建议

我常用的配置是:SGD with momentum=0.9,weight_decay=1e-4,初始学习率0.1(batch=256时),采用cosine annealing或step decay。Adam在这些配置下通常收敛更快但最终精度略低,可能是因为Adam对BN统计的影响更复杂。

在batch size比较小时,学习率要按比例缩小。一个可参考的规则是:线性缩放原则。batch 256配lr=0.1,batch 64配lr=0.025左右。这个规则不完美,但作为起点很靠谱。

7.3 监控指标与常见信号

训练时我主要盯着几个信号:

  • train loss稳步下降说明模型在学习
  • val loss先降后升说明过拟合开始,此时应增强数据增强或减小模型
  • BN running_mean/running_var偏离初始值太多,说明分布偏移严重,需要更大的batch或更低的lr

这组监控习惯帮我省了很多调试时间。训练是个系统工程,每个环节都可能在影响全局。ResNet这个模型虽然经典,但把它跑好,靠的还是这些细节。

构建过程中我印象最深的不是精度数字,而是第一次自己把shortcut加进去后,亲眼看到梯度在深层网络里还能稳定流动时的那种踏实感。希望这篇分析也能让你在搭网络的时候,少踩一些我已经踩过的坑,更从容地把精力和时间花在真正有意思的问题上。

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

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

立即咨询