简介:面向深度学习初学者,这份Python源码以简易CNN与ResNet搭建为主线,完整呈现图像分类模型的原理与代码实现,帮助读者理解卷积神经网络的核心组件,并厘清ResNet并非独立于CNN的另一种模型,而是引入残差块与跳跃连接的CNN改进版本。资源共24个文件,包括5个py源码脚本、6个h5格式数据集、8个xml工程配置,以及pyc、iml等辅助文件,整体大小约25.27MB,目录按“第1周/第2周”分阶段组织,结构清晰,便于循次渐进。已有1099人浏览学习。代码基于PyTorch/TensorFlow的设计思想编写,第1周可搭建含卷积层、池化层、全连接层的经典CNN,第2周则通过残差块、批量归一化与跳跃连接实现ResNet,并将两者对比,直观体验残差结构如何缓解深层次网络的梯度消失问题。配套数据集与训练/测试脚本可直接运行,非常适合作为课程设计、入门竞赛或期末项目的参考模板。
1. 别再把 ResNet 当外行:它只是 CNN 加了条“短路”
第一次被问到“ResNet 到底算不算 CNN”时,我差点把它当成文字游戏糊弄过去。直到自己动手搭网络、在 18 层深度的模型上看到 Loss 纹丝不动,才意识到这个问题的分量:ResNet 根本不是 CNN 的“另一个选项”,它本身就是 CNN,只是在卷基层之外多了一条跳跃连接的短路通道。这条看似简单的“加号”,把训练深度网络的稳定性从拼运气变成了可预期的结果。很多人把 cnn+resnet18 当作两套知识分开学,结果既没搞懂卷积层在算什么,也说不清残差连接为什么能 work。这篇笔记把项目里的简易 CNN 和 ResNet 源码拆开,带你从“结论是什么”一路看到“为什么这么设计、代码里怎么落地”。
学习这个机制的价值在于:它解释了两个困扰初学者的问题——为什么卷积网络一定要有池化和激活,为什么网络做深以后不是变强而是先崩掉。理解了这两件事,你再看任何带残差结构的网络,都能在脑子里自动补出一条反向传播的梯度路径,而不是把 ResNet 当成一个需要死记的黑匣子。
2. CNN 特征提取机制:卷积、激活、池化是怎么一步步“看懂”图像的
2.1 卷积层:参数共享让“特征提取”从玄学变成规则计算
卷积层做的事情,本质上是“用一组小滤波器在整张图上做滑动内积”。一张彩色手语符号图进入网络时是 (3, 64, 64) 的三维数组,高度 64、宽度 64、通道数 3。每个卷积核覆盖一个局部窗口,比如 3×3,它一次只看 9 个像素,然后在整张图上按步长滑动,把每个位置的局部特征都扫一遍。输出还是一张二维图,这张图上每个位置的值,就代表“这个局部区域和滤波器模式的相似程度”。
真正让 CNN 高效的,不是卷积本身,而是它的两个约束:局部连接和参数共享。局部连接意味着某个输出像素只受输入局部区域影响;参数共享意味着同一个滤波器在图上所有位置用的是同一组权重。这个设计带来两个收益:一是参数量从全连接的“输入尺寸×输出尺寸”变成“核大小×输入通道×输出通道”,以 64×64×3 的输入、8 个滤波器为例,全连接光一层就有 10 万+ 参数,卷积层只有 3×3×3×8=216 个参数;二是它天然贴合图像的平移不变性——同一个边缘特征在图的左上角和右下角应该由同一组权重识别出来。
这里还有一个必须会算的公式:输出尺寸 = (输入尺寸 - kernel_size + 2×padding) / stride + 1。比如 64×64 输入,3×3 核,stride=1,padding=1,输出还是 64×64;如果用 stride=2,输出变成 32×32。项目里第一周的简易 CNN,第一层选 3×3+padding=1,就是为了让前两层卷积保持特征图尺寸不变,只在池化层做降采样。很多维度报错,根源都是这个公式没算明白。
2.2 激活函数:没有非线性,堆再多层也是线性变换
卷积只是线性运算,两个卷积层叠在一起,数学上等价于一个卷积。要让网络表达能力真正变强,必须在层与层之间插入非线性激活。ReLU 是现在用得最多的选择,实现是 max(0, x),它的优势不只是计算快:在正区间导数恒为 1,不会像 sigmoid 那样在深层出现导数趋近于 0 的饱和区。
批量归一化(BatchNorm)通常和激活层放在一起。它在每个 batch 上对输出做标准化,再乘一个可学习的缩放因子和平移项。标准位置是“卷积 → BN → ReLU”。这里有个容易被忽视的细节:BN 是对通道维度做归一化的,它统计的是每个通道在 batch 内所有位置上的均值方差。也就是说,即使上一层的输出值范围波动很大,进入 ReLU 之前的值已经被拉回标准范围,ReLU 的梯度不会因为输入过大而变为 0。这正是深度网络里激活层不“死”的关键。
2.3 池化层:降采样不是为了省显存,是为了扩大感受野
最大池化把 2×2 窗口里的最大值挑出来,步长通常等于窗口大小,于是特征图直接缩小一半。很多人把池化理解为“压缩数据、省显存”,这没错,但真正的意义在感受野:池化之后,下一层卷积的一个 3×3 窗口看到的区域,对应到原图就是更大的范围,网络因此能逐层从局部纹理走向全局结构。
池化也带来一定的不变性:最大池化只关心窗口内有没有出现强响应,不关心精确位置。对手语符号识别来说,一个手势稍微平移几个像素,池化层会让后续层对这个小偏移不敏感。平均池化则更保守,保留窗口内的整体能量。项目代码里用的是最大池化,因为手势的边缘和轮廓是强特征,“有还是没有”比“分布得均不均匀”更重要。
2.4 全连接层:把特征图压平成分类分数
经过若干轮卷积、激活、池化之后,特征图还是多通道的二维数组。全连接层第一步是展平:把 (C, H, W) 拉成一维向量,然后和权重矩阵相乘,输出每个类别的得分。源码里这行 x.view(x.size(0), -1) 就是把 batch 里每个样本的所有通道像素串起来;-1 表示由 PyTorch 自动推断展平后的长度,比如 16 个通道的 16×16 特征图,展平后就是 4096 维。
把整个网络拆成角色分工来理解会更清楚:卷积层负责找“有没有某种特征”,池化层负责告诉后续层“这个特征大致在哪片区域”,全连接层负责把特征组合成最终的分类决策。这也是为什么 CNN 中间层的通道数往往逐层翻倍——空间分辨率降一半,就用通道数把信息量补回来,避免在前向传播中把图像细节白白丢掉。
2.5 一个能跑的简易 CNN:把机制落到代码
把上面的机制串成完整模型,代码并不长:
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=6): super(SimpleCNN, self).__init__() # 输入形状: (batch, 3, 64, 64) self.conv1 = nn.Conv2d(3, 8, kernel_size=3, stride=1, padding=1) self.bn1 = nn.BatchNorm2d(8) self.relu1 = nn.ReLU() self.pool1 = nn.MaxPool2d(2) # -> (batch, 8, 32, 32) self.conv2 = nn.Conv2d(8, 16, kernel_size=3, stride=1, padding=1) self.bn2 = nn.BatchNorm2d(16) self.relu2 = nn.ReLU() self.pool2 = nn.MaxPool2d(2) # -> (batch, 16, 16, 16) self.fc = nn.Linear(16 * 16 * 16, num_classes) def forward(self, x): x = self.pool1(self.relu1(self.bn1(self.conv1(x)))) x = self.pool2(self.relu2(self.bn2(self.conv2(x)))) x = x.view(x.size(0), -1) # (batch, 4096) x = self.fc(x) # (batch, num_classes) return x参数说明:第一层只有 8 个滤波器,输出 8 个通道,每个通道是一张“激活图”,代表一类简单特征;第二层把通道数翻倍到 16,是因为池化把空间尺寸减半后,需要用更多的通道来补偿信息量。最后一层 Linear 的输入维度是 16×16×16 而不是 16×32×32,就是因为两层池化各做了一次 1/2 降采样。
这个网络如果直接跑,还需要两个配套动作:在训练循环里用交叉熵损失,优化器用 Adam 或 SGD;每次迭代前要调用 model.train() 让 BN 统计当前 batch,求梯度后用 optimizer.step() 更新权重。对于 6 类手语符号分类,这个简化版 CNN 已经能到 80% 以上的验证精度,瓶颈不在深度,而在数据增强和超参数上。
3. 残差网络:跳跃连接为什么能救回梯度
3.1 梯度消失的成因:链式法则里的连乘
先回答一个最基本的问题:为什么网络变深,训练就崩。反向传播计算损失对第 l 层参数的梯度时,要沿计算图从输出一路乘回这一层。每一层都贡献一个因子——权重矩阵的转置乘以激活函数的导数。如果每个因子的谱范数小于 1,几十层连乘下来,梯度就指数级缩水。
数值上更好感知:假设每层梯度缩放系数是 0.5,网络 20 层时梯度缩水到原始值的 2^-20,约百万分之一。这个量级下,靠近输入层的卷积核几乎收不到有效更新信号,整个浅层部分变成随机初始化状态。手写数字那种简单任务还能靠运气,手语符号这种带背景噪声的高维输入,浅层不更新意味着模型只能学到“蒙对”的规律。这就是很多人在第一周 CNN 上跑得好好的,第二周换成 18 层 ResNet 反而 Loss 不降的根源。
3.2 残差块的数学本质:学习残差而不是完整映射
残差块的思路是把一个子网络的输出从“拟合目标 H(x)”改成“拟合目标残差 H(x) - x”,然后通过跳跃连接把输入 x 直接加到输出上。用公式表达:
- 普通层:y = F(x)
- 残差层:y = F(x) + x
其中 F(x) 是两个卷积加激活组成的子网络。为什么这能救梯度?看反向传播。y 对 x 的偏导是 ∂F/∂x + I,这里的 I 来自恒等项 x。即使 F 那部分梯度在深层被连乘压得很小,恒等项 I 依然保证梯度有一个稳定的“旁路”可以直接传回浅层。这条旁路不经过任何权重矩阵,所以不会被缩放。
另一个角度同样关键:让子网络去拟合“残差”本身比拟合“完整映射”更容易。网络输入输出相似度很高时,F(x) 学到的最优解接近零映射;而普通层的权重必须在深层里把单位变换和特征变换一起学出来。训练早期,残差块里 F(x) 输出接近 0,整体近似恒等层,网络基本不退化;随着训练推进,F(x) 再逐步从零出发学出具体特征。
3.3 残差块的实现细节:BN 放哪、短接怎么搭
PyTorch 里实现一个 BasicBlock 并不复杂,但顺序有讲究:
import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(BasicBlock, self).__init__() # 第一个卷积可能做降采样,控制空间尺寸和通道数 self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = downsample # 1x1 卷积,用来对齐维度 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逻辑说明:conv1 的 stride 参数是关键,当 stride=2 时特征图尺寸减半;此时 identity 和 out 的形状对不上,不能直接相加,必须用 downsample 分支把 x 也变换到同尺寸。这个分支通常是一个 kernel_size=1 的卷积,只改变通道数和空间尺寸,不提取额外特征。bias=False 是因为后面紧跟 BN,BN 的平移项已经承担了偏置的作用,再加偏置会造成冗余。
一个争论点是 ReLU 放在加法前还是加法后。上面的实现是第一段 ReLU 在卷积之间,最后做完 add 再补一个 ReLU。这保证了残差块整体输出是激活后的非负值。如果你把最后一个 ReLU 去掉,让跳跃连接直接加在 BN 之后,训练初期梯度会更通畅,但要手动保证后续层对负输入不过敏。项目源码采用标准写法:add 之后再激活,和原始实现一致,稳定且容易对照。
3.4 从 BasicBlock 到 ResNet18:stage 怎么拼
ResNet 不是把一堆 BasicBlock 平铺,而是按 stage 分组。每个 stage 里的第一个 block 负责降采样,后面的 block 保持尺寸。以 ResNet18 为例,常用的结构是:初始一个 3×3 或 7×7 卷积做快速下采样,然后是四个 stage,分别堆叠 2、2、2、2 个 BasicBlock。通道数在 stage 之间翻倍:64 → 128 → 256 → 512。
| stage | 作用 | block 数量 | 输出通道 | 关键操作 |
|---|---|---|---|---|
| 输入层 | 快速下采样 | 1 个卷积 | 64 | stride 2 |
| stage1 | 浅层纹理 | 2 个 block | 64 | 保持尺寸 |
| stage2 | 中层形状 | 2 个 block | 128 | 首次降采样 |
| stage3 | 深层部件 | 2 个 block | 256 | 再次降采样 |
| stage4 | 全局语义 | 2 个 block | 512 | 最后降采样 |
每个 stage 的第一个 block,in_channels 和 out_channels 不同,还带着 stride=2,所以必须构造 downsample。后面的 block 输入输出通道一致、尺寸一致,shortcut 直接原样相加即可。这种分组设计不是随意的:它让网络在最初步就丢掉大量空间冗余,然后在低分辨率高通道数的空间里做深层语义建模,计算量被控制在合理范围。
3.5 对比信号:普通 CNN 和 ResNet 在 18 层时的差异
项目第二周的数据集是 train_happy.h5,二分类,任务简单。如果你把第 2 章那个 SimpleCNN 强行加到 18 层,会发现验证 Loss 在几个 epoch 后反而比 8 层版本差;换成 BasicBlock 堆叠的 ResNet18,同样的层数却能稳定收敛。这个对比是设计取舍最直观的证明。
要注意的是,梯度消失不是深度网络的唯一敌人。特征复用能力同样重要:ResNet 的跳跃连接允许后层直接拿到前层特征,相当于给了网络一条“记忆通道”。这对小数据集特别友好,因为浅层学到的边缘、颜色特征不会被深层重新学一遍,而是直接复用。
4. 从零搭建 CNN 和 ResNet:避坑记录
4.1 现象:Loss 前几个 epoch 纹丝不动,之后突然下降
原因:网络初始化和 BN 统计数据都还没稳定。深层 CNN 在早期有一个“冷启动”阶段,如果用了过大的学习率,BN 统计的均值和方差被少数样本带偏,梯度方向完全被噪声覆盖。更隐蔽的情况是激活层饱和:某些初始化方式会让输入进 ReLU 前全是负数,梯度恒为 0,整个分支不学习。
解决:先用小学习率预热,比如 1e-3 跑 5 个 epoch,再把学习率提高到 1e-2 或按训练计划调整;如果预热后 Loss 仍不动,检查权重初始化,卷积层用 kaiming 均匀分布,不要用默认正态分布。手语符号数据集里存在大量相似背景,第一轮预热本质上是让 BN 统计先稳定。
4.2 现象:forward 里报尺寸不匹配,或者加法时形状对不上
原因:跳跃连接最常见的翻车点。第一个残差块的输入是上一 stage 的输出,通道数是 64,而当前 block 的输出是 128,stride 又让空间尺寸减半,identity 没有做任何变换就直接相加。
解决:把每层输出尺寸写在注释里,每次改通道数就重算一次公式。checkpoint 的方式是把 input 的尺寸 print 出来,然后在 downsample 构造处打印变换后的尺寸,确认两者完全一致。一个偷懒但可靠的办法:downsample 统一用 kernel=1、stride 与主分支相同、输出通道等于 out_channels 的卷积,再在后面加一个 BN。这个 1×1 分支不会改变特征语义,只负责维度对齐。
4.3 现象:训练精度高、验证精度突然崩掉,或者 BatchNorm 统计错误
原因:训练时 BN 用 batch 内的实时均值和方差;切到评估模式后应该用训练阶段累计的全局统计。如果只在训练循环里调了 model.train(),但验证时忘了 model.eval(),BN 会继续用当前 batch 做归一化,小 batch 的波动直接传导到输出,验证结果就忽高忽低。这属于“代码能跑但语义错了”的静默 bug,非常容易漏。
解决:训练和验证脚本里分别显式调用 model.train() 和 model.eval()。如果用了 dropout,这也是同样的纪律。养成习惯后在验证前打印一下 model.training 状态,一秒钟确认,能省掉一晚上的血压波动。
4.4 现象:H5 数据集加载后形状对不上,或者精度一直低于预期
原因:H5 文件里的图像数组可能是通道在前((batch, 3, 64, 64)),也可能是通道在后((batch, 64, 64, 3))。项目里的 cnn_utils.py 和 resnets_utils.py 各自封装了不同的加载逻辑,如果拿第一周的工具去跑第二周的数据,很可能出现 channel 顺序颠倒,模型把 RGB 当成了 depth。
解决:加载后立刻打印一次 x.shape 和 x.max()、x.min(),确认数据值域在 0 到 1 还是 0 到 255。统一的预处理是:转成 float、除以 255 归一化到 [0,1]、转为 (batch, channel, height, width) 张量。图像任务里这类“数据管道 bug”造成的精度损失,常常被误判为模型能力不足,先把数据检查做在前,再开始调网络结构。
4.5 现象:网络加深后显存爆炸,训练直接 OOM
原因:ResNet 的每个 residual block 在跳连之前,b 分支的激活值都要在显存中保留,用于反向传播。批量大小、特征图尺寸和 block 数量三者乘积决定了显存占用,初学者只盯着参数量,忽略了“激活值”才是显存大头。
解决:显存不够时优先减 batch size,而不是把网络改小;如果必须维持 batch size,用更小的输入分辨率,或者把某些 stage 的通道数减半。混合精度训练是另一个思路,但不建议在刚学会搭建时引入,它会掩盖数值问题。先让网络在小 batch 上能跑通,再逐步往上涨资源。
5. 设计取舍:残差不是越多越好,深度要学会收着用
残差连接确实解决了梯度消失,但它不是免费的。每多一个残差块,就多两条计算路径和一个加法节点,前向计算和反向传播的显存占用都会涨。同样是 128 通道、32×32 的特征图,一个 BasicBlock 的一次前向就要保存三份中间激活:主分支两层卷积的输出、BN 的输出、加上 identity 的原始输入。层数翻倍,这个开销跟着线性增长。
所以设计取舍的第一个原则是:深度要匹配数据量。对于只有几千张图的二分类任务,ResNet18 和 ResNet34 的差距几乎不会被验证精度体现出来,反而越深的模型越容易过拟合。第二个原则是:残差块的数量要放在“真正扩大感受野”的位置,而不是均匀堆叠。在特征图尺寸发生变化的 stage 交界处,残差连接带来的收益最大;在一个已经足够深、感受野已经覆盖全图的模块里继续加 block,边际收益会迅速衰减。
验证方法很朴素:固定同一个数据集和同样的超参,分别把第 2 章的 SimpleCNN 改成 8 层、18 层,再和 BasicBlock 版本对比训练曲线。如果 18 层普通 CNN 的验证精度低于 8 层版本,而同样的 18 层残差版本能稳定上升,那你就亲手复现了残差机制的全部价值。
从那以后,我每次搭网络都强制自己先走一遍这个流程:写尺寸注释、确认 BN 模式、打印数据形状、最后才调深度。这套习惯救了我很多次,希望帮到你。如果你想省掉踩坑时间,这份包含两个阶段源码和训练数据集的 Python 项目,正好能让你把每一步机制都对着真实代码验证一遍。
本文还有配套的精品资源,点击获取