☰
ResNet是CNN吗?残差网络如何解决深层网络梯度消失问题
2026/10/18 10:09:11 网站建设 项目流程

简介:面向深度学习初学者的Python实践项目,聚焦卷积神经网络(CNN)与残差网络(ResNet)的搭建与对比,清晰解答“残差网络是不是卷积神经网络”这一基础疑问。项目按“第一周”“第二周”分阶段推进:第一周从输入层、卷积层、激活函数、池化层到全连接层,完整实现简易卷积神经网络;第二周引入残差块与跳跃连接,通过两个卷积层和恒等映射结合批量归一化,演示如何缓解深层网络的梯度消失问题,使训练更深的网络成为可能。压缩包共24个文件,大小约25.27MB,文件类型以Python源码、H5数据集、XML工程配置和pyc缓存为主,其中源码负责模型构建、训练与测试,H5文件提供可直接加载的图像数据集,XML用于开发环境项目管理,目录按周划分便于对照学习。目前已有1099人学习或下载。借助随包代码与数据集,可快速复现并训练这两种模型,直观对比图像分类性能差异,理解残差结构对深层网络优化的价值;工具脚本与训练流程也适合作为课程设计或实验起点。

1. 结论先行:ResNet不是另一种网络,而是给CNN装了一根“后悔药”

很多接触深度学习的人会第一时间冒出这个问题:ResNet是CNN吗?答案有点反直觉——ResNet就是一种CNN,而且是CNN架构的改进版。传统的CNN在层数堆到十几层、几十层时,会出现一个让所有训练者头疼的现象:网络越深,训练误差反而越高,甚至直接崩溃。这不是算力不够,而是梯度在反向传播时被逐层“稀释”干净了。ResNet在2015年给出的解决方案极其朴素却有效:在原CNN的基础上加一条跳跃连接(Skip Connection),让输入可以绕过卷积层直接加到输出上。这条“捷径”看起来只是多了一次加法,却彻底改变了梯度传播的路径。本文从卷积层为什么会失效、残差块为什么能救场,一直拆到源码里identity_block和conv_block的维度处理,把CNN和ResNet这套机制讲透。

2. CNN的堆叠陷阱:为什么层数越多反而越难训

2.1 卷积层到底在做什么:从像素到特征图的维度变迁

CNN的基本单元是卷积层,它不直接处理全连接的784个像素点,而是用一个可滑动的卷积核在二维图像上做局部加权求和。假设输入是(H, W, C)的彩色图像,卷积核是(k, k, C, F),输出特征图的高宽由公式决定:

H_out = floor((H + 2*padding - k) / stride) + 1 W_out = floor((W + 2*padding - k) / stride) + 1

这份源码包第一周给出的cnn_utils.py里,可以明显看到CNN的典型流程:conv -> relu -> pool -> conv -> relu -> pool -> flatten -> dense -> softmax。卷积层在这里做的事情是局部特征抽取,但它的参数量远比全连接层小。比如32x32x3的输入,用一个3x3x3x32的卷积核,参数量只有3*3*3*32 = 864;而如果用全连接层把 3072 个像素展开映射到 1024 个神经元,参数量是3072 * 1024 ≈ 300万。这就是卷积的第一个设计动机:通过局部感受野和权值共享,把参数量压缩几个数量级。

但卷积层本身是线性运算,堆再多层也只是线性变换的复合。所以每个卷积后面必须接激活函数,比如ReLU:y = max(0, x)。源码中cnn_utils.py正是靠relu函数引入非线性。线性变换复合还是线性,只有插入非线性,神经元才真正有表达能力。

2.2 池化层:为什么说它在“丢信息”而不是“抽特征”

池化层常常被误解为“抽象特征”,实际上它的本质是空间降采样。最大池化在每个f x f窗口里取最大值,平均池化取均值。它不引入新参数,但会丢失空间位置信息——也正是这种丢失带来平移不变性。你稍微移动几个像素,最大池化结果可能完全不变,这让网络对微小偏移没那么敏感。

源码里pool = 2, stride = 2的配置很常见,作用就是把特征图长宽各缩小一半。假如前一层是64x64x32,一次池化后变成32x32x32,特征通道数不变,空间分辨率减半。通道数和空间尺寸是CNN里两个说法不同、实际是同一件事的两面,很多人在这里踩坑:以为池化会减少通道数,其实它只改变 W 和 H。要减少通道数,唯一办法是使用更多的卷积核修改F,或者用1x1卷积做通道压缩。源码里到了flatten之后,所有特征图被拉成一维向量,这时维度必须和全连接层输入严格对应,一旦前面任一卷积核数量改了,这里就会直接报维度错误——这是第一个最容易翻车的地方。

2.3 梯度消失:CNN加深的真实死因

网络加深为什么反而变差?反向传播用的是链式法则,每一层的梯度是后面所有层梯度的乘积。假设每层梯度在 0.3 左右,50 层相乘就是0.3^50,这个数小到浮点数直接变成 0。梯度没了,前面层数的权重就收不到有效更新,网络空有精度,实际只在最后几层起作用,这就是“梯度消失”。

反过来如果每层梯度大于 1,乘起来会爆炸成天文数字,训练直接发散。传统CNN的训练困难本质上不是参数多,而是这个连乘效应太脆弱。源码第一周和第二周的对比很有说服力:第一周的网络结构浅,训练能收敛;第二周一旦手工加深网络而不加跳跃连接,loss 曲线会出现典型的不降反升。这不是代码 bug,是数学上的必然。

2.4 批归一化的引入:先稳住每一层的分布

源码里 ResNet 部分大量出现BatchNorm层,而且位置固定在卷积之后、ReLU 之前。它做的是:对每一个 batch、每一个通道,计算均值和方差,然后归一化到均值为 0、方差为 1,再用两个可学习参数gamma和beta做缩放和平移:

x_hat = (x - mean) / sqrt(var + eps) y = gamma * x_hat + beta

BN 解决的是“内部协变量偏移”问题。前面层的参数一变,后面层的输入分布就变,训练要不停适应新分布。BN 强制把各层输入拉回标准分布,梯度下降更容易找到有效方向。源码里 ResNet 的实现从第三层开始几乎每个卷积后都跟 BN,这让网络敢把学习率调大几倍而不会发散。BN 还有一个隐藏好处:因为归一化引入了同 batch 其他样本的信息,它自带轻微的正则化效果,能在一定程度上抑制过拟合。

3. 残差网络:把“恒等映射”变成默认选项

3.1 残差块的结构:两条通路和一个加法节点

ResNet 的核心单元叫残差块(Residual Block),源码resnets_utils.py里给出了两种形态:identity_block和conv_block。先看最简单的 identity_block 结构:

def identity_block(X, f, filters): # 保存输入,这是跳跃连接的起点 X_shortcut = X # 第一段:1x1 卷积 + BN + ReLU X = Conv2D(filters[0], (1, 1), padding='same')(X) X = BatchNormalization()(X) X = Activation('relu')(X) # 第二段:f x f 卷积 + BN + ReLU X = Conv2D(filters[1], (f, f), padding='same')(X) X = BatchNormalization()(X) X = Activation('relu')(X) # 第三段:1x1 卷积 + BN(不加激活) X = Conv2D(filters[2], (1, 1), padding='same')(X) X = BatchNormalization()(X) # 跳跃连接:直接把输入加到主路径输出 X = Add()([X, X_shortcut]) X = Activation('relu')(X) return X

这段代码的核心是最后那个Add()。主路径经过三个卷积、三次 BN、两次 ReLU 的输出加上输入本身,再做一次 ReLU。换句话说,网络学习的不再是完整的映射H(x),而是“残差”F(x) = H(x) - x。如果网络认为某个卷积块不需要改变输入,它只需要把F(x)学成 0,加法之后结果仍是 x。学成 0 远比学成恒等映射容易——权重置零即可。

3.2 为什么说残差学习更容易:两个极端情况的对比

如果不加跳跃连接,一个网络层要想实现“输入等于输出”,需要学习出恒等映射y = x。对带偏置和 ReLU 的卷积层来说,恒等映射不是不能学,但要精确逼近很麻烦——权重矩阵必须接近单位阵,而优化的过程中很难精确停在那个点上。

残差结构把这个目标改成了:让主路径输出为 0。输出全 0 只需要把卷积核学成全零即可。从梯度下降的角度看,朝向零点的梯度方向明确,loss 曲面更平缓,优化更容易找对路。这就是“残差学习”的设计动机:不是让网络变得更聪明,而是让目标函数变得更好优化。

对应到源码,identity_block适用的场景是输入输出维度一致,可以直接相加;如果维度不一致,比如通道数变了或者空间尺寸减半,就需要用带卷积的conv_block来调整短接路径。

3.3 梯度路径:从逐层衰减到“高速公路”

反向传播时,残差块的导数形式比普通块多出一项。设输出y = F(x) + x,对 x 的偏导为:

dy/dx = dF(x)/dx + 1

关键就在那个+1。即使在极坏情况下dF(x)/dx接近 0,梯度仍然可以以 1 的系数原封不动传回上一层。这就是跳跃连接被称作“梯度高速公路”的原因——每一层都自带一条无损通道,梯度至少不会衰减。深到 50 层、100 层甚至上千层,这条通道都能把误差信号直接送回网络前端。

源码第二周resnets_utils.py中还出现了一个细节:keras.layers.Input指定的输入维度,以及resnets_utils.load_dataset()对train_happy.h5和train_signs.h5的处理。训练数据集是“开心表情”和“手势数字”,都是小尺寸图像,非常适合演示深度网络在小数据集上的行为——你会看到即便数据量大不到哪里去,ResNet 也收敛得更快、更稳。

4. Skip Connection 的工程实现:维度匹配和BN的细节

4.1 维度匹配:conv_block 如何把尺寸对齐

跳跃连接要加另一个分支,前提是两个张量的形状完全一致:(batch, height, width, channels)。如果主路径里某个卷积的步长为2,特征图尺寸减半,再直接相加就会报形状错误。源码里的conv_block处理方式是:在主路径卷积的同时,让 short cut 分支也通过一个1x1卷积,并设置相同的 stride 和滤波器数量,把输入强行变换到和主路径输出相同的形状。

def conv_block(X, f, filters): F1, F2, F3 = filters # short cut 分支:1x1 卷积,stride=2 降采样 X_shortcut = Conv2D(F3, (1, 1), strides=(2, 2), padding='same')(X) X_shortcut = BatchNormalization()(X_shortcut) # 主路径第一个卷积也用 stride=2,保持尺寸同步 X = Conv2D(F1, (1, 1), strides=(2, 2), padding='same')(X) X = BatchNormalization()(X) X = Activation('relu')(X) # 后续卷积 stride 恢复为 1 ... X = Add()([X, X_shortcut]) X = Activation('relu')(X) return X

这里的核心设计是:尺寸不减时用identity_block,尺寸减半或通道数改变时用conv_block。1x1卷积在这里不是提取特征,而是做通道映射和尺寸调整的工具。它不改变感受野,只做逐点的线性组合,相当于在 shortcut 分支上安装一颗“维度适配器”。

4.2 BN 与卷积的顺序问题:先归一化还是先激活

在实现残差块时有个细节:BN 应该在卷积之后、ReLU 之前,而不是放在激活之后。理由在于 BN 要求输入的分布接近高斯,卷积输出的原始分布通常没有经过截断,归一化更有效。如果先过 ReLU,负值全被置零,分布变成一个偏置很重的非对称分布,强行归一化后就丢掉了一部分表达空间。源码中所有卷积层的顺序都是Conv2D -> BatchNorm -> Activation('relu'),这个顺序从第一周的简单 CNN 延续到第二周的 ResNet,属于少踩坑的标准格式。

另外,最后一个1x1卷积后面只接 BN,不接 ReLU,这是因为马上要做 Add 操作。如果这里先过 ReLU,主路径输出和 shortcut 输入在数值范围上可能错位,相加之后再做 ReLU 效果更好。这也是一个容易忽略但影响结果的设计。

4.3 反向传播里的梯度路径分析

有了前面这些层,我们来具体推一次残差块的反向传播。前向求出 lossL,回传时到残差块输出y的梯度是dL/dy,对输入x的梯度是:

dL/dx = (dL/dy) * (dy/dx) = (dL/dy) * (dF/dx + 1)

这个式子说明:即使主路径dF/dx非常小,梯度也至少有dL/dy一份直接传回。这与普通卷积结构dL/dx = dL/dy * dF/dx形成鲜明对比。普通结构里两个小于 1 的数相乘,梯度越来越小;残差结构里无数个+1累加,梯度在每一层都获得一份“备份”。

从代码实现的角度,框架里Add层的反向传播会自动把梯度分流到两个输入分支上。X_shortcut那条路径梯度不经过卷积层,直接原样传回。这也是为什么 ResNet 可以轻松训练到 50 层以上,而同样深度的 VGG 式网络连收敛都难。

5. 常见问题与排查:搭CNN和ResNet时的典型翻车记录

5.1 维度不匹配:Add层直接报错

见过最频繁的报错是operands could not be broadcast together with shapes,这个问题在identity_block和conv_block的衔接处高发。原因是上一块输出的通道数和下一块 short cut 分支输出的通道数不一致,比如上一块是(64, 64, 32),下一块主路径输出(32, 32, 64),两个张量完全不同,加法没法执行。

排查思路是先翻回网络定义,检查每个块的filters列表。conv_block里的三个滤波器数量从前往后依次是:第一个1x1的通道数、中间主卷积的通道数、最后一个1x1的通道数。其中filters[2]必须和 shortcut 分支的Conv2D(F3, ...)保持一致,因为它决定最终输出通道数。我一般会顺手打印每层输出的shape,养成习惯后这类问题一眼就能定位。

5.2 训练 loss 不下降:不是代码问题,是初始化

搭建浅层 CNN 时,如果是自己用 numpy 实现(第一周代码的cnn_utils.py就有手写卷积反传的部分),会发现 loss 在一两个 epoch 之后就卡住不动。原因通常是权重初始化用了全零或者过大的方差。卷积核如果全零,反向传播时梯度为零,网络根本不会更新;如果初始化方差过大,ReLU 的输出分布可能出现大量死区,梯度卡在 0。

标准做法是用he_normal初始化配合 ReLU,把方差设为sqrt(2 / fan_in)。在框架里这通常是一行参数:Conv2D(..., kernel_initializer='he_normal')。手动实现时,要关心卷积核的初始化分布,否则两层之后梯度就没了。

5.3 梯度爆炸:BN 顺序放错的连锁反应

有的同学把残差块内部顺序写成Conv -> ReLU -> BN,结果训练 loss 出现nan。原因前面已经提过,ReLU 把负值截断后,BN 输入分布严重偏斜,归一化后的数值不稳定,梯度异常大。翻车现场一般是前几个 epoch 数值正常,突然某一步 loss 变成nan,之后再也回不来。

排查方式是打印每一层的输出均值,确认 BN 是否真的作用在卷积之后的线性输出上。这个 bug 的表现很隐蔽,因为网络结构看起来“差不多”,但训练曲线完全不同。从那以后我每次搭建残差块都强制走一遍Conv -> BN -> ReLU的顺序,顺序错了宁可重写。

5.4 小数据集过拟合:ResNet 也救不了

第二周的train_happy.h5是一个表情分类的小数据集,图像数量有限。用深 ResNet 跑它,容易在验证集上出现震荡,训练集 acc 接近 100% 而验证集卡在 60% 上下。这是典型的过拟合。ResNet 本身不包含太多正则化能力,它解决的是优化问题,不是泛化问题。

解决思路是数据增强(旋转、翻转、缩放)和早停。源码里没有直接写数据增强,但自己在ImageDataGenerator里加几行rotation_range就能大幅稳定验证集。核心认知是:不要把过拟合归咎于网络结构,深度网络天生参数多,小数据上正则化手段必须跟上。

6. 深度不是免费午餐:ResNet 的设计取舍与使用边界

ResNet 把 CNN 从“不敢做深”变成了“能做很深”,但深度的边际收益在快速递减。以 CIFAR-10 级别的任务为例,20 层的 ResNet 和 56 层的 ResNet 精度差距可能只有一个点,而训练时间差了几倍。源码包里的resnets_utils.py给出的是一个可自定义层数的框架,这本身就是设计取舍的体现:深度是超参数,不是越深越好。

残差块也存在容量上限。超过一定深度后,即使有跳跃连接,优化难度仍在缓慢上升,只是把崩溃点向后推迟。另一个取舍是参数量:一个conv_block比identity_block多一个1x1卷积,在通道数较大的层会显著增加内存开销。做移动端或边缘部署时,常常用 MobileNet 之类的轻量结构替代,而不是无脑堆 ResNet。

还有一个常被忽略的点:ResNet 适合的任务是图像分类、检测这类有明确空间结构的任务,如果输入本身不具备局部相关性,比如表格数据或序列数据,把 ResNet 硬套上去收益有限。跳跃连接的思想后来被 Transformer 吸收,变成了 Transformer 块里的残差结构——它证明了这条设计原则的普适性。遇到一个新任务,我会先问一句:这里到底需不需要深度?如果 10 层就能解决,就别为了架构好看上 50 层。从那以后我每次调网络,都强制自己先写一个浅层 baseline,再逐步加深对比收益曲线,用数据决定深度,而不是用流行程度决定结构。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询