简介:面向深度学习初学者的卷积神经网络与残差网络搭建项目,基于Python实现,涵盖从基础卷积网络到残差网络的完整流程。资源主要解决手工实现经典网络结构的代码组织与训练验证问题,适合正在学习计算机视觉或神经网络原理的读者。压缩包共二十四个文件,以Python脚本和HDF5数据集为主,辅以编译文件和IDE配置,其中数据集文件提供训练与测试数据,脚本文件包含网络定义、工具函数与训练入口。整体约25兆字节,结构按“第一周卷积网络”和“第二周残差网络”分目录组织,便于按阶段查阅。已有1099人学习下载,通过该资源,读者可以对照代码理解卷积层、池化层、全连接层以及残差块、跳跃连接的具体实现,学习如何在PyTorch中构建并训练模型,并获得完整的项目目录划分、数据加载和验证思路,为后续调整网络结构或开展图像分类实验打下基础。
1. 先说结论:ResNet 不是 CNN 的对手,而是 CNN 的进化形态
每次有人拿着“resnet是cnn吗”来问我的时候,我第一反应不是急着回答是或不是,而是建议他先把这份源码跑一遍。ResNet 不是 CNN 的对立面,它是 CNN 的一个改进形态——把普通卷积块替换成带跳跃连接的残差块,用一条 shortcut 把梯度直接传回浅层,让网络从十几层堆到几十层甚至上百层都不崩。这份资源正好用两周代码把这个进化过程讲透了:第一周从零写一个能跑通手写符号识别的简易 CNN,第二周在同样的数据集上把网络升级成 ResNet。适合两类人:刚学完反向传播、想看看完整代码长什么样的入门者,以及想在现有网络上动手改结构、换数据集的进阶开发者。两套代码都属于能直接跑出训练曲线、能断点调试、能改着玩的类型。
2. 从源码包看项目结构:两周代码、两套数据集、同一条学习路径
拿到压缩包别急着跑,先把家底盘清楚。这份资源不是单个脚本,而是按“第一周 CNN → 第二周 ResNet”组织好的两个独立工程,每个工程都有自己完整的工具函数、测试脚本和 h5 数据集。
2.1 文件清单:第一周和第二周各交付了什么
解压后你会看到两个目录,没有多余的 README,结构非常直白:
| 目录/文件 | 作用 |
|---|---|
| 1第一周/cnn_utils.py | 模型参数初始化、前向传播、成本函数等通用工具 |
| 1第一周/test.py | 主入口脚本:数据加载、训练、预测、准确率评估 |
| 1第一周/datasets/train_signs.h5 | 手写符号训练集,6 类 0-5,共约一千张 |
| 1第一周/datasets/test_signs.h5 | 对应的测试集,约一百张 |
| 2第二周/resnets_utils.py | ResNet 的残差块定义、数据加载工具 |
| 2第二周/kt_utils.py | 加载 happy 数据集的辅助函数 |
| 2第二周/test.py | ResNet50 的训练与测试主脚本 |
| 2第二周/datasets/train_happy.h5 | 笑脸/非笑脸二分类训练集 |
这里有个容易忽略的点:第一周和第二周都有自己的 test.py,而且第二周用到了一个叫做 kt_utils 的辅助文件,说明第二周同时准备了两份数据——signs 和 happy。训练时你可以任意切换,测试不同数据集对模型能力的要求。两个目录下的 .idea、workspace.xml 是 IDE 的工程配置,对学习没有影响,直接忽略。pycache 也不需要管。
2.2 h5 数据集与预处理:train_signs.h5 里装的是什么
h5 文件不是图片文件,而是一种层级化数据容器。这套代码里的 h5 存取逻辑非常典型,加载后你会得到两组数据:样本数组和标签数组。以 signs 为例,样本 shape 是(num_examples, 64, 64, 3),64×64 是分辨率,3 是 RGB 通道,标签是 one-hot 编码后的矩阵。
这块预处理藏着一个新手最容易翻车的点:数据加载函数返回的是train_x_orig和train_y_orig,其中train_y_orig的 shape 是 (1, num_examples) 或者 (num_classes, num_examples),取决于原始文件的组织方式。代码里通常要做一次转置Y.T,把标签变成 (num_examples, num_classes) 才能喂给损失函数。shape 不匹配时,训练会直接报维度错误,这是这套源码里最常见的启动失败原因。
2.3 技术选型:手写前向传播还是框架自带 API
这份资源的代码风格偏课程教学,用的是 TensorFlow 1.x 的静态图写法——先定义参数、再建前向传播图、最后用 Session 跑。和现在主流的 PyTorch 动态图相比,它多了一层“先构图再执行”的思维负担,但好处是把每一层的输入输出 shape 都暴露在你面前,对理解 CNN 结构非常有帮助。
从文件分工也能看出来:cnn_utils.py 是手写的参数初始化和前向传播函数,test.py 是组装和训练流程。我一般建议初学者先不去碰 keras 的高层封装,而是跟着这种手写方式把每个张量的 shape 在纸上画一遍。一旦你习惯了 NHWC 的通道顺序和 TensorFlow 的 padding 策略,再去切 PyTorch 也就只是换一套 API 的事。这套代码里对 padding 的处理尤其值得注意:SAME模式下卷积不会改变特征图尺寸,池化层会把尺寸减半,这两条规则是后面推导所有层输出 shape 的基础。
3. 第一周核心代码拆解:手写 CNN 从参数初始化到反向传播
第一周的目标很明确:不借助高层框架,用手写的方式搭一个能跑通 6 类符号识别的卷积神经网络。理解了这一周,你就能看懂后面 ResNet 里所有基础组件的来源。
3.1 参数初始化:四组参数的 shape 设计
网络结构是常见的两层卷积 + 两层全连接。第一层卷积用了 8 个 3×3 滤波器,第二层用了 16 个 3×3 滤波器,全连接层先压到 120 维,最后输出 6 类。代码开头长这样:
def initialize_parameters(): W1 = np.random.randn(8, 3, 3, 3) * 0.1 # 8 个滤波器,每个 3x3x3(输入3通道) b1 = np.zeros((1, 1, 1, 8)) W2 = np.random.randn(16, 3, 3, 8) * 0.1 # 16 个滤波器,输入是上一层的 8 通道 b2 = np.zeros((1, 1, 1, 16)) W3 = np.random.randn(4096, 120) * 0.1 # 展平后 4096 维 -> 120 维 b3 = np.zeros((1, 120)) W4 = np.random.randn(120, 6) * 0.1 # 120 -> 6 类输出 b4 = np.zeros((1, 6)) parameters = {"W1": W1, "b1": b1, "W2": W2, "b2": b2, "W3": W3, "b3": b3, "W4": W4, "b4": b4} return parameters这里W1的 shape 是(8, 3, 3, 3),对应 TensorFlow 的卷积核约定:前两维是卷积核高宽,第三维是输入通道数,第四维是输出通道数。* 0.1是缩小初始化尺度,防止激活值过大。最后一个全连接层W4的 6 对应类别数,如果换自己的数据集,这里要跟着改。整个初始化没有用 Xavier 或 He 初始化,而是简单高斯缩放,对浅层网络够用,但如果你把网络加深到 50 层以上,这种朴素初始化会让训练变得很玄学,后面改 ResNet 时要注意。
3.2 前向传播链路:一张图从输入到输出的流动
前向传播是这套代码最值得精读的部分。它完整展示了 CNN 的三段式:卷积提取特征、池化压缩尺寸、全连接做分类。代码逻辑如下:
def forward_propagation(X, parameters): W1 = parameters["W1"]; b1 = parameters["b1"] W2 = parameters["W2"]; b2 = parameters["b2"] W3 = parameters["W3"]; b3 = parameters["b3"] W4 = parameters["W4"]; b4 = parameters["b4"] # 第一层:卷积(8个滤波器) -> ReLU -> 最大池化(2x2) Z1 = tf.nn.conv2d(X, W1, strides=[1,1,1,1], padding='SAME') A1 = tf.nn.relu(Z1) P1 = tf.nn.max_pool(A1, ksize=[1,2,2,1], strides=[1,2,2,1], padding='SAME') # 第二层:卷积(16个滤波器) -> ReLU -> 最大池化(2x2) Z2 = tf.nn.conv2d(P1, W2, strides=[1,1,1,1], padding='SAME') A2 = tf.nn.relu(Z2) P2 = tf.nn.max_pool(A2, ksize=[1,2,2,1], strides=[1,2,2,1], padding='SAME') # 展平后进入全连接层 P2 = tf.contrib.layers.flatten(P2) Z3 = tf.matmul(P2, W3) + b3 A3 = tf.nn.relu(Z3) Z4 = tf.matmul(A3, W4) + b4 return Z4输入是 64×64×3 的图片,经过第一层卷积后由于 padding 是SAME,特征图仍是 64×64×8,池化后变 32×32×8;第二层卷积后 32×32×16,池化后 16×16×16,展平后正好是 16×16×16=4096 维。这个 4096 不是凭空来的,你改输入图片尺寸或卷积层数,这个数字就会变,全连接层的输入维度必须跟着调。tf.contrib.layers.flatten在 TensorFlow 2.x 里已经移除了,如果你用的是新版环境,需要用tf.reshape(P2, [P2.shape[0], -1])替换。
3.3 成本函数与反向传播:梯度到底在每层干了什么
第一周的损失函数和优化器用的是全量梯度下降配交叉熵,代码很短,但它是整条训练链路的终点:
def compute_cost(Z4, Y): logits = tf.transpose(Z4) labels = tf.transpose(Y) cost = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits( logits=logits, labels=labels)) return cost optimizer = tf.train.AdamOptimizer(learning_rate=0.001).minimize(cost)注意这里连着做了两次转置:Z4 的 shape 是 (batch, 6),Y 的 shape 在数据预处理后是 (batch, 6),理论上不需要转置。之所以还要转,是因为数据集里 Y 的布局在不同加载函数下可能不一致,这是很多旧课程代码的常见写法,我在自己项目里会直接统一成(batch, num_classes),省得来回转。
反向传播这部分代码里通常没有手写,而是直接让 TensorFlow 的自动微分来算。这并不代表反向传播不重要——恰恰相反,你在纸上推导卷积层的dW时,会发现它是输入A_prev和梯度dA的卷积操作;全连接层的dW就是简单的矩阵乘法。第一周源码的价值在于让你看到前向传播的长相,而反向传播的细节需要在纸上补课,否则后面 ResNet 的维度推导你会完全懵。
4. 第二周核心代码拆解:ResNet 的残差块到底多做了什么
第二周在第一周的基础上加了一个关键动作:给卷积块接上一条“捷径”。这就是 ResNet 和普通 CNN 的本质区别,也是回答“resnet是cnn吗”的核心证据——它依然是卷积层、池化层、激活函数的组合,只是多了一组跳跃连接。
4.1 identity_block:输入输出维度一致时的捷径
当残差块的输入和输出通道数、尺寸完全一致时,直接用恒等映射作为 shortcut。代码实现如下:
def identity_block(X, f, filters): F1, F2, F3 = filters X_shortcut = X # 主路径:1x1卷积降维 -> 3x3卷积 -> 1x1卷积升维 X = Conv2D(F1, (1, 1), strides=(1, 1), padding='valid')(X) X = BatchNormalization(axis=3)(X) X = Activation('relu')(X) X = Conv2D(F2, (f, f), strides=(1, 1), padding='same')(X) X = BatchNormalization(axis=3)(X) X = Activation('relu')(X) X = Conv2D(F3, (1, 1), strides=(1, 1), padding='valid')(X) X = BatchNormalization(axis=3)(X) # 捷径:直接相加,不经过任何变换 X = Add()([X, X_shortcut]) X = Activation('relu')(X) return X这里filters是一个三元组,比如(64, 64, 256),三个数字分别对应三层卷积的输出通道数。axis=3指定 BN 在通道维做归一化,对应 NHWC 布局。主路径的前两个 1×1 卷积负责降维,减少 3×3 卷积的计算量,最后一个 1×1 卷积负责把通道数升回去,保证和 shortcut 相加时维度一致。
4.2 convolutional_block:维度不匹配时用 1x1 卷积调整捷径
identity_block 要求输入输出相同,但网络在 stage 切换时需要把通道数翻倍、尺寸减半,这时捷径也要跟着变。convolutional_block 的关键就在这个 short-cut 的处理上:
def convolutional_block(X, f, filters, s=2): F1, F2, F3 = filters X_shortcut = X # 主路径,注意第一层卷积 stride 用 s X = Conv2D(F1, (1, 1), strides=(s, s), padding='valid')(X) X = BatchNormalization(axis=3)(X) X = Activation('relu')(X) X = Conv2D(F2, (f, f), strides=(1, 1), padding='same')(X) X = BatchNormalization(axis=3)(X) X = Activation('relu')(X) X = Conv2D(F3, (1, 1), strides=(1, 1), padding='valid')(X) X = BatchNormalization(axis=3)(X) # 捷径:用 1x1 卷积调整尺寸和通道数 X_shortcut = Conv2D(F3, (1, 1), strides=(s, s), padding='valid')(X_shortcut) X_shortcut = BatchNormalization(axis=3)(X_shortcut) X = Add()([X, X_shortcut]) X = Activation('relu')(X) return X对比两段代码能明显看到:identity_block 的X_shortcut = X是零成本恒等映射,而 convolutional_block 的 shortcut 走了一个 1×1 卷积,stride 与主路径第一层一致,输出通道数等于F3。这样做之后,两条路径在相加时的 shape 完全相同。如果漏掉这个 1×1 卷积直接相加,TensorFlow 会直接报维度不匹配,这是改代码时最常踩的坑。
4.3 组装 ResNet:从 conv1 到 Softmax 的完整数据流
把两种残差块串联起来,就得到一个完整的 ResNet 结构。第二周的 test.py 里模型的组装逻辑是:先一个 7×7 卷积加池化做浅层特征提取,后面跟四个 stage,每个 stage 由一个 convolutional_block 开头、若干 identity_block 堆叠,最后是全局平均池化和全连接输出。
| Stage | 块类型 | 输出尺寸 |
|---|---|---|
| conv1 | 7x7, 64, stride=2 | 32x32x64 |
| stage2 | conv_block + 2×identity_block | 16x16x256 |
| stage3 | conv_block + 3×identity_block | 8x8x512 |
| stage4 | conv_block + 5×identity_block | 4x4x1024 |
| stage5 | conv_block + 2×identity_block | 2x2x2048 |
| 输出层 | 全局平均池化 + 全连接 | 6 类 |
这个结构对应的是 50 层左右的深度,其中大部分层来自 stage3 和 stage4 的 identity_block 堆叠。训练 happy 数据集时,只需要把最后的 6 类改成 2 类,其他结构不用动。我建议你在跑之前先把每个 stage 的输出尺寸算一遍,因为这是这份源码里最容易出维度错误的地方——改一处滤波器数量,后面所有块的 shape 都要连锁调整。
5. 常见问题与排查:跑通这套源码的五个关键坑
代码本身能跑,但它在不同环境、不同 Python 版本下的表现会差很多。这一章我按“现象 → 原因 → 解决”整理跑代码时最常遇到的五个问题,基本覆盖了我自己跟进这个项目时踩过的所有坑。
5.1 数据 shape 对不上,报错出现在全连接层
现象:前向传播里卷积和池化都能通过,一到tf.matmul(Flatten, W3)就报维度不匹配。原因:输入图片尺寸或通道数和你初始化的W3第一维对不上。这套代码默认输入是 64×64×3,如果你换成自己的数据是 128×128 或灰度单通道,展平后的维度就变了。解决:先加载数据后打印train_x_orig.shape,然后手算池化后的尺寸,再改W3的初始化参数。这里面“通道数”是个隐蔽变量,灰度图是 1,RGB 是 3,搞错的话第一层卷积核的第四个维度也要跟着改。
5.2 训练时最后一小批数据导致 batch 越界
现象:训练到每个 epoch 快结束时,某一步突然报IndexError或者 loss 变为 NaN。原因:minibatch 梯度下降时,如果总样本数不能被 batch_size 整除,最后一批的实际数量会小于 batch_size。代码里如果直接按固定索引切片,就会越界;如果用了固定维度,NaN 通常来自空批次。解决:取批次时用min(batch_size, m - start)动态计算实际数量,保证最后一个 batch 也能正常前向和反向。
5.3 BN 和 ReLU 的顺序弄反,训练曲线震荡
现象:网络能跑,但 loss 降不下去,偶尔还飙升。原因:ResNet 代码里 BN 应该放在卷积之后、ReLU 之前,如果手欠调换成了先激活后归一化,梯度信号会被严重扭曲。解决:严格按代码顺序执行,即Conv2D -> BatchNormalization -> Activation。这类问题最坑的是不报错,只表现在训练行为上,属于典型的深度学习玄学问题。你只能逐层检查网络定义,没有捷径。
5.4 残差块 shortcut 维度没对齐,Add 报错
现象:报错信息里提到Add操作的两个输入 shape 不一致。原因:在改网络深度或滤波器数量时,只改了主路径的滤波器,忘了同步修改 shortcut 的 1×1 卷积输出通道数。解决:convolutional_block 里 shortcut 的输出通道数必须等于主路径第三个卷积的输出通道数F3,stride 必须等于主路径第一个卷积的 stride。这两个值不一致时,把 shortcut 的Conv2D参数复制主路径的对应值即可。
5.5 损失函数不下降,一开始就卡住
现象:训练 20 个 epoch 后准确率还在随机水平。原因:常见的有三类——学习率过大导致 loss 震荡;参数初始化尺度太大导致激活饱和;数据归一化漏了,像素值还是 0-255。解决:先确认X / 255.这一步做了;再把学习率从 0.001 往下调;最后看初始化代码里有没有* 0.01或* 0.1的缩放。这套代码里如果直接复制的默认参数,换数据集时初始化尺度不变,大概率是能用的,但当你自己把网络加深时,建议改用 He 初始化。
6. 二次开发:把这套代码改造成自己的网络和数据集
跑通源码只是起点,真正有价值的是把它改造成属于你自己的图像分类工具。这一章给三条改造路径,难度从低到高,你可以按需选一条。
6.1 换自己的数据集:图片目录打包成 h5 文件
这套代码的入口都是 h5 文件,所以第一个要掌握的技能就是把一堆图片转成 h5。常见做法是用 h5py 写一个转换脚本,把图片读成 NumPy 数组后存盘。这里给一个最小可用的打包逻辑:
import h5py import numpy as np from PIL import Image import os images, labels = [], [] for label, cls in enumerate(['cat', 'dog']): path = f'data/{cls}' for name in os.listdir(path): img = Image.open(os.path.join(path, name)).resize((64, 64)) images.append(np.array(img)) labels.append(label) X = np.stack(images) Y = np.eye(2)[labels] # one-hot with h5py.File('my_data.h5', 'w') as f: f.create_dataset('train_x', data=X) f.create_dataset('train_y', data=Y)注意图片统一 resize 到 64×64,因为后续全连接层的维度是按这个尺寸推的。标签的 one-hot 编码直接决定了全连接输出层的节点数,比如二分类就是 2,六分类就是 6。这个脚本可以套用在你自己的图片目录上,核心是保证所有图片尺寸一致,否则np.stack会直接报错。
6.2 改网络深度:从 ResNet50 缩到 ResNet18
如果你的数据集只有几千张图,跑 50 层的 ResNet 很容易过拟合。缩到 18 层只需要砍掉 stage4 里多余的 identity_block:把[3, 4, 6, 3]的块数量改成[2, 2, 2, 2]。这个改动涉及的不只是循环次数,stage 的输出通道数也要同步调整,砍薄之后显存占用和训练时间都会大幅下降。改完先跑一个单 batch 过拟合测试,确认 loss 能降到接近 0,再上全量数据。
6.3 迁移到 PyTorch:结构映射和踩坑提醒
习惯了这套代码后想切到 PyTorch 做研究,映射关系其实很清晰:TensorFlow 的Conv2D(filters, (kernel_size))对应torch.nn.Conv2d(in_channels, out_channels, kernel_size);BatchNormalization(axis=3)对应torch.nn.BatchNorm2d,不需要指定 axis,默认就是通道维;Add()([X, shortcut])对应X + shortcut;Flatten对应torch.flatten。最需要注意的是输入布局:PyTorch 默认 NCHW,你在 forward 里要把图片从 (batch, channel, height, width) 传入,这和源码里的 NHWC 顺序相反。很多人在迁移时网络定义完全正确,只是在测试时忘了tensor.permute转维度,导致结果莫名变差。
我把这套源码前前后后梳理过好几遍,每次带着学员做二次开发都会强制自己从头把每个 stage 的输出 shape 推演一遍。当初第一次修改残差块数量时,我没注意 shortcut 的维度联动,Add 报错之后折腾了半个下午才发现是 1×1 卷积的输出通道没跟上,从那以后我每次改网络结构都会先画一张各层 shape 的对照表,再动手写代码,这个习惯帮我避开了至少十次类似的低级错误。希望这份拆解能让你少走这些弯路。
本文还有配套的精品资源,点击获取