简介:从零搭建CNN与ResNet的入门实战代码包,面向深度学习初学者和想厘清“ResNet与CNN关系”的开发者,可直接用于计算机视觉自学或课程设计参考。资源共24个文件,包含5个Python训练与工具脚本、6个h5格式数据集文件(交通标志、手势、笑脸等),另有PyCharm工程配置(xml/iml)与运行生成的pyc缓存,压缩包整体约25.27MB,代码按“第一周CNN、第二周ResNet”两阶段组织,目录清晰便于逐周学习。目前已有1099人学习下载。源码完整展现了CNN的卷积层、池化层、全连接层,以及ResNet残差块、跳跃连接和批量归一化的实际写法,可直接运行查看训练过程;配套h5数据集支持从数据加载、模型搭建到损失计算与验证的完整闭环,帮助读者快速掌握PyTorch/TensorFlow建模思路,并深入理解残差连接如何缓解深层网络梯度消失问题。
1. 先解决最大困惑:ResNet到底是不是CNN?
第一次看到“简易CNN和ResNet搭建,Python源码”这份资源时,我脑子里最大的问号不是“怎么搭建”,而是一个更基础的问题:ResNet是CNN吗?如果你也有同款困惑,那这篇入门文章就是写给你的。CNN和ResNet的关系,可以类比成同一道菜的基础版和改良版:CNN是深度学习里最常用的图像识别模型,ResNet是在CNN骨架上加装了“残差块”的升级型号,所以它本质上还是CNN,只不过更聪明、更适合往深了堆。这份资源里的代码用Python带你按两周的节奏搭出这两个模型。今天我们不急着刷原理,我带你把它读成能上手的东西:先搞懂两个概念,再看看资料里到底有什么,最后落在你电脑上能跑通的第一个例子。
2. 先搞懂地基:CNN是怎么“看”一张图的
不管第一周还是第二周,你写的每一段代码都是在做同一件事:让电脑从图片里认出东西来。CNN解决的就是这个问题,它仿照人眼的工作方式,一层一层地从图里找特征——先找边缘,再找纹理,最后拼出“这是一只猫”的结论。要理解ResNet,你得先知道CNN的四个积木块,下面我们一块一块来。
2.1 图像在电脑眼里是一堆数字
你在屏幕上看到的照片,对电脑来说只是一个巨大的数字矩阵。一张灰度图就是一个二维数组,每个数字代表一个像素点的亮度,范围通常是0到255,0是纯黑,255是纯白。彩色图则稍微复杂一点:它由三个矩阵叠在一起组成,分别对应红、绿、蓝三个颜色通道。
你可以用几行代码亲手验证这件事。打开任意一个Python文件,敲进下面这段:
import numpy as np # 用随机数模拟一张 4x4 的灰度图 fake_image = np.random.randint(0, 256, size=(4, 4)) print(fake_image)跑一下你就会发现,图像在程序眼里根本不是“画面”,而是一张写满数字的网格。这里有两个值得记住的点:第一,这张数字网格就是CNN的“输入层”接收的数据;第二,数字的取值范围很关键,后面你训练网络时经常要把这些数字除以255,让它们落在0到1之间,这个操作叫归一化,很多新手漏了这一步导致模型怎么训都不收敛。
2.2 卷积层:用一个小窗口“扫描”整张图
既然图是数字网格,那怎么从中找出特征?最笨的办法是把所有数字拉平成一条长向量,交给全连接层处理。但这样做有个致命问题:一张100×100的图拉平后有一万个数字,全连接层要为每个数字配一大堆权重,参数数量爆炸,训练极慢还容易过拟合。
卷积层换了个思路:它不用看整张图,只用一个小窗口(比如3×3的小方块)在图上滑动,每滑到一个位置就做一次“逐点相乘再求和”,得出来的结果构成一张新的小图,叫特征图。这个小窗口就是滤波器,也叫卷积核。一个滤波器只能找一种特征——有的负责找竖线,有的负责找横线,有的负责找拐角。想让网络认识更复杂的东西,就多放几个滤波器,所以卷积层的输出通常有好多张特征图,每一张是“某个特征在图上哪里出现了”的地图。
这里有两个概念会在代码里反复出现:步长和填充。步长就是窗口每次往右(或往下)挪几格,步长越大,输出图越小;填充是在原图四周补一圈0,用来控制输出尺寸。比如输入是32×32的图,卷积核是3×3,填充设为1,输出还是32×32;如果不填充,输出就变成30×30。动手改代码时常会因为这两个参数没算清楚,导致后面全连接层维度不匹配,这个是后话,你现在先有个印象。
2.3 激活函数和池化层:给特征“表态”再“瘦身”
卷积层是线性操作,就算堆一百层,本质上还是线性变换的组合,学不到复杂规律。这时候就需要激活函数出场。CNN里最常用的是ReLU,公式极其简单:负数全部变0,正数原样保留。
import torch # 模拟ReLU:把负数清零 x = torch.tensor([-2.0, -1.0, 0.0, 1.0, 3.0]) y = torch.relu(x) print(y) # 输出: tensor([0., 0., 0., 1., 3.])为什么要把负数清零?因为清零操作引入了非线性,让网络有能力拟合各种弯弯曲曲的边界。你可以把ReLU理解成“表态”:这个位置没找到特征,就直接闭嘴(输出0),找到了就大声报数。池化层接在卷积和激活后面,最常见的是最大池化:在一个2×2的小窗口里取最大值,然后窗口滑过整张图。这样做的效果是图像尺寸缩小一半,但最强的特征被保留下来,计算量也大幅下降。而且池化让网络对轻微的位移不那么敏感——小猫在照片里稍微偏了半个像素,提取到的特征几乎不变。要注意的是,池化层没有可学习的参数,它是个纯“瘦身”操作,新手常误以为它和卷积一样需要训练。
2.4 全连接层收尾:把特征“拉平投票”
经过几轮“卷积→激活→池化”之后,原始图片已经变成一堆小而深的特征图。接下来要做的,是把这些特征图拉平成一维向量,送进全连接层做最终判断。全连接层就像一个投票委员会:每个输入特征有权重,经过加权求和后,输出每个类别的得分。分类任务通常在最后一层接softmax,把这些得分换算成概率,比如“是猫的概率0.8,是狗的概率0.2”。
完整的CNN流程你可以记成一句话:输入→卷积→激活→池化→(重复若干次)→拉平→全连接→输出概率。下面用一个极简的CNN把这句话落到代码上:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() # 卷积块1:输入3通道(彩色图),输出8个特征图 self.conv1 = nn.Conv2d(in_channels=3, out_channels=8, kernel_size=3, padding=1) self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) # 卷积块2:输入8个特征图,输出16个特征图 self.conv2 = nn.Conv2d(in_channels=8, out_channels=16, kernel_size=3, padding=1) self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 全连接层:把拉平后的特征映射到10个类别 self.fc = nn.Linear(16 * 8 * 8, 10) def forward(self, x): x = torch.relu(self.conv1(x)) # 先卷积,再ReLU激活 x = self.pool1(x) # 池化缩小尺寸 x = torch.relu(self.conv2(x)) x = self.pool2(x) x = x.view(x.size(0), -1) # 拉平成一维向量 x = self.fc(x) # 全连接输出10个类别的分数 return x上面这段代码里的模型假设输入是32×32的彩色图。数据流向是这样的:32×32输入经过conv1(padding=1)还是32×32,经过pool1变成16×16;再走一轮变成8×8,这时有16个特征图;拉平后就是16×8×8=1024个数,全连接层把这1024个数映射到10个类别。如果你想换个数据集,只需要改两处:全连接层的输入维度,以及最后的输出类别数。第一次跑模型时,建议在forward里加一行print(x.shape),看看每一步数据尺寸变成什么样,这是理解CNN最快的方式。
3. ResNet为什么是CNN的升级版:残差块到底在做什么
现在你已经知道CNN的四个积木块了。但你可能有个疑问:既然卷积、池化、全连接一套流程能把猫和狗分开,那为什么还要搞一个ResNet出来?答案很简单:因为人们发现,CNN越深反而越难训练,而ResNet专治这个毛病。这一章我们解决两个问题:普通CNN到底遇到了什么困难,以及残差块是用什么思路化解困难的。
3.1 普通CNN加深后为什么会“翻车”
直觉告诉我们,层数越多,网络应该越强——毕竟能提取更细的特征。但研究者实验后发现,普通CNN在层数超过一定值之后,训练集上的错误率反而升高了,不是过拟合,是根本学不动。我第一学到这里时也不懂,后来才明白主要有两个原因在捣乱。
第一个原因是梯度消失。深度学习靠“反向传播”更新参数,误差要从最后一层传回第一层,每经过一层就要乘一次梯度。如果梯度值小于1,乘上几十次之后,传到前面的梯度已经小到接近0,前面的卷积层几乎学不到东西,陷入“瘫痪”。第二个原因是退化问题:理论上更深的网络至少应该能模仿浅层网络的性能,只要后面几层学“什么都不做”就行,但实践证明让普通网络学会“什么都不做”这件事本身就非常困难。
总结成一句话:层数加深后,网络不是不想学,是传不下去、学不动。这时候需要的不是更复杂的结构,而是一条能让信息“抄近路”的通道。
3.2 残差块的设计思路:y = F(x) + x
ResNet解决这个问题的办法看起来简单到让人怀疑:在模块外面加一条短路,把输入直接加到输出上。一个残差块的输出不是“经过卷积之后的结果”,而是“卷积结果加上原始输入”。用公式写就是:y = F(x) + x,其中F(x)是几层卷积和激活的组合。
这条加法操作网上各种文章叫“跳跃连接”“恒等映射”“shortcut”,听起来很高深,其实你只需要理解:它给了网络一条短路通道,信息可以不经过中间的卷积层直接往后传。这样一来,即使某一层卷积学得很差,原始输入x也能原样保留。更妙的是,如果这一层确实没什么可学的,网络只需要让F(x)趋近于0,输出就约等于输入x,相当于这一层自动变成了“透明层”。相比之下,普通CNN没有这条通道,每一层都必须“硬学”一个结果,学不好反而添乱。
打个比方:普通CNN像一条必须每道工序都做好的流水线,ResNet则在每段工序旁边修了一条应急通道,师傅状态不好时,东西可以直接滑到下一站,不影响整体进度。这就是为什么ResNet能堆到50层、101层甚至更深,而普通CNN堆到20多层就学不动了。
3.3 用一张表回答“ResNet是CNN吗”
很多人纠结这个问题,其实答案很明确:是的,ResNet就是CNN的一种,是CNN的改进版本。那种“ResNet和CNN是两套东西”的感觉,来自对比时把“普通CNN”默认成了“CNN”本身。下面这张对比表帮你把关系看清楚。
| 对比维度 | 普通CNN | ResNet |
|---|---|---|
| 基本积木块 | 卷积、激活、池化、全连接 | 同样是卷积、激活、池化、全连接 |
| 特殊结构 | 没有跳跃连接 | 在卷积块之间加了残差连接(shortcut) |
| 能加深吗 | 十几层后容易梯度消失 | 可以轻松堆到几十上百层 |
| 典型代表 | 简易CNN、VGG系列 | ResNet-18、ResNet-50 |
| 训练难度 | 浅层好训,加深后变难 | 深度较大时依然稳定 |
所以如果你在新手期听人说“ResNet不是CNN,它和CNN是两种网络”,那这话是错的。更准确的说法是:ResNet属于CNN家族,它是为了解决CNN加深时“学不动”的问题,在结构上做的一个巧妙升级。搞懂这一点,你就不会再被这两个词绕晕了。
3.4 动手写一个残差块:BasicBlock
说了这么多,不如直接看代码。下面是PyTorch里一个最基础的残差块,对应的是ResNet-18这类浅层ResNet里使用的结构,通常叫BasicBlock:
import torch import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() # 两个3x3卷积,每个后面跟一个批量归一化 self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) # 当输入输出形状不一致时,用1x1卷积调整x的形状 self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity = x # 先保存原始输入,这就是“跳跃连接”的起点 out = torch.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += identity # 关键一步:卷积结果和原始输入相加 out = torch.relu(out) return out和上一章的SimpleCNN相比,这个残差块多了三样东西:批量归一化、identity变量、相加操作。批量归一化的作用是在每层卷积之后把数据分布拉回正规范围,让训练更稳定,这也是ResNet敢训练得很深的原因之一。identity保存的是刚进入模块时的原始数据,它没有经过任何卷积操作,相当于抄了近路。最后out += identity这一行就是跳跃连接本身,它会先在通道数上对齐两个矩阵(如果通道不一致,shortcut里的1×1卷积会负责把identity调整成和out一样的大小),再逐元素相加。
把几个这样的BasicBlock串起来,最后接一个全连接层,就组成一个微型ResNet。你在第4章会看到,项目第二周做的事情,本质上就是重复练习组装这种残差块。
4. 拿到这份Python资源之后:先认识文件,再准备环境
概念有了,代码也见过了,接下来该真正动手了。但打开资源包的那一刻,很多新手会被吓一跳:又是“1第一周”又是“2第二周”,里面还有一堆以.idea、.iml结尾的陌生文件。别慌,这一章我带你逐个认门,把文件夹里每一类文件是干什么的弄清楚,然后装好环境,让代码跑起来。
4.1 资源里到底有什么:按文件夹认门
这份代码是按两周的学习节奏组织的,两个文件夹的名字已经说明了内容:第一周搭简易CNN,第二周搭ResNet。下面这张表把核心文件列出来,你对照着看就不迷路。
| 文件/文件夹 | 作用 | 备注 |
|---|---|---|
| 1第一周/test.py | 第一周的训练/测试主流程 | 从这里开始看 |
| 1第一周/cnn_utils.py | CNN相关的辅助函数 | 包含数据加载、参数初始化等 |
| 1第一周/datasets/train_signs.h5 | 第一周训练数据集 | 手势数字图片 |
| 1第一周/datasets/test_signs.h5 | 第一周测试数据集 | 用来验证训练效果 |
| 2第二周/resnets_utils.py | ResNet相关的辅助函数 | 第二周的核心工具文件 |
| 2第二周/kt_utils.py | 笑脸数据加载工具 | 配合train_happy使用 |
| 2第二周/datasets/train_happy.h5 | 第二周附加数据集 | 笑脸识别任务 |
| .idea、*.iml、workspace.xml | IDE配置文件 | 可以完全忽略 |
| pycache | Python运行缓存 | 可以完全忽略 |
你可能会注意到,第二周的datasets里也有train_signs.h5。这说明同一个手势数据集被两套模型各跑了一遍,正好可以用来对比普通CNN和ResNet谁的准确率更高、训练过程更稳,这是一个非常直观的体验设计。至于kt_utils.py和train_happy.h5,它们是另一个小任务——区分图片里是笑脸还是非笑脸,适合你在学会ResNet之后拿来做二次练习。
第一次看代码时,我建议你先打开test.py,别看细节,只追主线:加载数据→建立模型→训练→测试。你会发现流程和上一章的SimpleCNN几乎一样,区别只是模型换成了ResNet。辅助函数先不细读,等主线通了再回头看,效率会高很多。另外,资源里的缓存文件名带了cpython-37字样,说明作者在Python 3.7版本下运行过;你现在用更新的Python版本大概率也能跑,但要注意依赖库的版本兼容。
4.2 跑起来之前,先装好三样东西
动手前需要准备的东西不多,就三样:Python环境、PyTorch库和h5py库。如果你的电脑里还没有,打开终端执行下面这条命令可以一次装齐:
pip install torch h5py numpy matplotlib装完之后,建议你先用下面两行代码验证环境是否正常:
import torch import h5py print(torch.__version__)能打印出torch的版本号,说明环境没问题了。这里有个重要的心态调整:新手阶段完全可以用CPU训练,不需要急着买显卡。第一周的CNN模型很小,用CPU跑几分钟也能完成训练,足够你看清楚整个流程。为什么要提这一点?因为经常有新手第一反应是“我没有GPU,是不是学不了”,然后在配环境中卡了三天。事实是,你只需要先把逻辑跑通,GPU是后面提速的事,不是入门的门槛。
如果你电脑上同时装了多个Python环境,建议用虚拟环境工具(比如conda或venv)给这个项目单独开一个环境。虚拟环境相当于给项目一个独立的“小房间”,里面装的库互不干扰,以后出问题了直接扔掉重建,比在全局环境里折腾省心得多。这也是我踩过坑之后才养成的习惯——曾经因为全局环境里某个库版本冲突,连续两次安装失败,后来一律开新环境,再没遇到过这个问题。
4.3 读懂h5数据:项目里的第一道关卡
安装好环境之后,你碰到的第一个实际障碍大概率是数据集文件。这套资源的所有数据都打包在.h5文件里,新手看到这种格式往往不知道从哪下手。简单说,.h5就是一种可以同时存储多个数组的文件格式,里面数据按“键值对”组织,类似一个字典。要查看文件里有哪些键,用h5py写几行代码就行:
import h5py # 先打开文件,看看里面存了什么 with h5py.File('1第一周/datasets/train_signs.h5', 'r') as f: print(list(f.keys()))运行后你会看到类似['train_x', 'train_y']这样的键名列表。这时你再读取就顺理成章了:
import h5py import numpy as np with h5py.File('1第一周/datasets/train_signs.h5', 'r') as f: X = np.array(f['train_x']) # 图像数据 y = np.array(f['train_y']) # 标签 print(X.shape, y.shape)打印出来的形状信息非常重要:一般你会看到(样本数, 高, 宽, 通道数)这种结构。这里埋伏着新手最容易踩的一个坑:h5文件里存的图像维度顺序是“高、宽、通道”,而PyTorch的卷积层要求输入是“通道、高、宽”。如果你不管三七二十一直接把数据喂给模型,会立刻报维度错误。解决办法是交换轴的顺序,代码写成X = np.transpose(X, (0, 3, 1, 2)),同时再除以255做归一化。项目里的utils文件通常已经把这些工作封装好了,所以新手第一周直接用现成的加载函数最稳;等你理解了这个步骤,再自己去写也不迟。
5. 避坑指南:搭CNN和ResNet最容易翻车的五个坑
代码看得再多,不如自己报错一次学得快。我下面列出的五个坑,几乎每个新手都会踩中至少两三个,每一个我都会按“现象→原因→解决”的顺序讲清楚,你碰到时可以一条条对着排查。
5.1 环境与路径:还没开始训练就卡住的两个坑
坑1:找不到数据集文件。现象是运行test.py时抛出FileNotFoundError,提示找不到datasets/train_signs.h5。原因是代码里写的是相对路径,而你是从别的目录启动脚本的,Python按照当前工作目录去找文件,自然找不到。解决方法是先确认你当前的运行目录:在代码里加一行import os; print(os.getcwd()),看输出是不是项目所在目录,如果不是,用os.chdir('项目绝对路径')切换过去,或者把数据路径改成绝对路径。另外提醒一句,文件夹名带了“1第一周”这种中文,极少数情况下在某些开发环境会出编码问题,稳妥的做法是进到文件夹内部运行,不要从外部拼接层次过深的路径。
坑2:提示缺少模块。现象是报ModuleNotFoundError: No module named 'h5py'或类似错误。原因很简单,新环境里没有安装全依赖库。解决方法是执行pip install h5py补齐缺失的库。但我更推荐的做法是:不要挨个手动装,先把环境中已装的库导出成清单文件,以后换电脑直接一键还原。如果你发现torch装好后导入报错,先检查是不是装成了CPU版本但想用GPU,或者反过来;入门阶段统一装CPU版本即可,省去一堆CUDA配置问题。
5.2 维度与数据格式:一跑就报错的经典原因
坑3:矩阵乘法维度不匹配。现象是训练脚本跑到forward时报RuntimeError: size mismatch。原因大多是你改了网络结构或输入图片尺寸,但全连接层的输入维度没有跟着算清楚。我在第2章说过,全连接层的输入等于“最后一个池化层输出的特征图数量×宽×高”,这个值只要卷积、步长、池化任何一处改动,数字就会变。解决方法是给网络写一个“维度探测”流程:在forward里临时插入print(x.shape),跑一次就能看到每一层输出的实际尺寸,确定后再把数字填进全连接层。刚学的时候觉得加print很笨,但它比闭眼硬算快得多。
坑4:图像数据的维度顺序不对。现象是数据能加载,但送给模型时shape是(64, 64, 3)而模型期望(3, 64, 64),报错信息看起来像“Expected 4D input, got 3D input”。原因是h5文件里存的是“高、宽、通道”顺序,而PyTorch卷积层要的是“通道、高、宽”。解决方法是做一次轴交换:np.transpose(X, (0, 3, 1, 2)),把通道维从最后挪到第二位。这也是项目里utils文件的核心功能之一,所以你用现成函数时会觉得很顺,自己重写时就容易翻车。
5.3 训练不收敛:跑起来了但loss不降
坑5:loss一直不下降。现象是训练了好几个epoch,损失函数值几乎没变,准确率还在瞎猜的水平。原因通常是下面几个中的一个:输入数据没归一化(像素还停留在0~255)、学习率设得太离谱(太大震荡、太小蜗牛)、或者输出层接法有问题。解决方法是按顺序排查:检查输入数据有没有除以255;把学习率调到0.001这个常用起点;如果用了CrossEntropyLoss(交叉熵损失),网络最后一层不要再额外加softmax,因为这个损失函数内部已经包含了softmax操作,重复加会导致梯度异常。如果你想让实验结果可复现,还可以在代码开头加上torch.manual_seed(42)固定随机种子,这样每次跑出来的结果基本一致,不会出现“明明没改代码,效果却忽好忽坏”的玄学情况。
6. 下一步怎么学:从跑通代码到真正吃透ResNet
到这里,你已经知道CNN和ResNet是什么关系,也能看懂资源里的文件结构了。接下来的学习路径,我建议你按下面这张表的顺序走:
| 步骤 | 任务 | 目标 |
|---|---|---|
| 第一步 | 运行第一周的test.py | 完整走通“加载数据→训练→测试”流程 |
| 第二步 | 精读cnn_utils.py | 读懂数据加载和参数初始化细节 |
| 第三步 | 自己写一个SimpleCNN | 不看源码也能搭出CNN |
| 第四步 | 运行第二周的ResNet代码 | 感受残差块的训练效果 |
| 第五步 | 改写一个BasicBlock | 能解释每一行在干嘛 |
每一步的验收标准都很明确:能跑通,且能向别人讲清楚代码在做什么。特别是走到第四步时,建议你在同一份手势数据集上,分别用第一周的CNN和第二周的ResNet训练,对比两者的最终准确率和loss曲线。你大概率会发现ResNet收敛得更稳、准确率更高——这种“亲手看到差距”的体验,比背十遍理论都管用。
走完基础流程后,可以试着把数据集换成train_happy.h5,做一次笑脸识别。这仍然属于分类任务,但数据分布变了,你会被迫去检查维度、类别数、归一化方式这些细节。接着可以挑战一下传说中的ResNet-18:它其实就是把你学过的BasicBlock串联4组,每组分别重复2次,再接全连接层。你在第3章见过的每个零件,到这里全部派上用场。如果想更进一步,试着把学习率从0.001改成0.0001,或者把batch size从32改成64,观察训练曲线变化,建立对超参数的“手感”。
最后说点我自己的教训。刚上手时我总喜欢跳过看数据这一步,拿起来就训练,结果经常被维度报错和loss不降折腾一晚上。后来我强制自己每次拿到新项目,先打印数据shape、再打印标签类别数、最后才写模型。这个习惯让我少走了很多弯路。从那以后每次拿到新项目,我都会先走一遍这个流程,再开始跑代码。希望帮到你——现在,放心地去跑你的第一个test.py吧,跑通的那一刻,你会觉得这一切并不难。
本文还有配套的精品资源,点击获取