
简介面向希望动手实现深度网络的开发者提供基于Python语言编写的简易CNN与ResNet搭建参考。项目按第一周、第二周两个阶段组织第一周从输入层、卷积层、池化层、激活函数和全连接层入手逐步构建CNN并完成数据集上的分类训练第二周聚焦ResNet中的残差块通过跳跃连接将输入与卷积输出相加展示残差网络如何缓解深层CNN的梯度消失与性能退化问题。包内提供cnn_utils、resnets_utils、kt_utils等工具脚本以及train_signs.h5、train_happy.h5、test_happy.h5等预处理数据集可直接加载并运行训练与验证流程。压缩包共24个文件以Python源码、H5数据、XML工程配置和pyc缓存为主要类型整体约25.27MB目录按周次划分结构清晰便于循序渐进对照学习目前已有1099人下载学习。对于深度学习初学者或需要复现经典模型的开发者这套资料既能帮助理解CNN与ResNet的内部机制也可直接作为课程设计、作业或论文实验的参考实现。1. 简易CNN和ResNet搭建前先把“resnet是cnn吗”说透第一次打开 ResNet 源码的初学者多半会问同一个问题resnet 是 cnn 吗它全称里挂的是 Residual Network代码里却到处是 Conv2d、BatchNorm2d、ReLU怎么看都是 CNN 的样子。答案是明确的ResNet 不是 CNN 之外的另一个物种而是专门解决“网络加深后反而不如浅网络”这个矛盾的 CNN 变体。这篇实战笔记用 Python 和 PyTorch 从零搭一套简易 CNN再在同一套代码基础上改造出简易 ResNet数据集用 CIFAR-10。你会看到普通 CNN 堆到十几层时训练损失不降反升也会看到 ResNet 只加一条恒等捷径就把这个退化问题压住额外计算成本不到一层卷积的量。适合刚跑通 MNIST、想在真实数据集上把两个经典结构亲手搭一遍的初学者也适合已经会用 torchvision 但没写过残差块内部实现的熟手。2. 先确认 ResNet 的归属再谈怎么用 Python 把它写出来2.1 组成和机制ResNet 就是 CNN只是多了一条“近路”判断一个网络是不是 CNN看主体结构就够了。ResNet 的每个残差块内部依然是“卷积 批归一化 激活”这三件套和普通 CNN 没有区别。区别在块与块之间普通 CNN 是前一层输出直接送给下一层信息只能沿着这条串行路径走ResNet 在每个块的外面加了一条 shortcut把块的输入直接加到输出上。用公式说就是一个堆叠层原本要学映射 H(x)ResNet 让它学残差 F(x) H(x) - x块的输出变成 F(x) x。当网络已经足够深、继续增加层数对效果没贡献时F(x) 只需要趋近于 0网络就退化成恒等映射性能不劣化。这就是“残差”二字的来历。所以回答标题里的问题一句话ResNet 是带残差连接的 CNN属于 CNN 家族不是另一个网络类型。2.2 为什么需要这条近路先区分“退化”和“过拟合”在一开始把 CIFAR-10 跑起来之前最好先建立判断标准。很多新手把“网络越深效果越差”误当成过拟合然后去加 dropout、加正则结果越调越差。过拟合的典型表现是训练损失低、验证损失高而 ResNet 论文里说的退化现象是网络加深后训练损失和验证损失一起变高。对比关系用表格看比较清楚网络配置训练损失验证损失常见原因普通 CNN 四层低偏高数据量不足过拟合普通 CNN 堆到十几层高高优化困难发生退化ResNet 堆到十几层低低恒等捷径缓解退化正常收敛这张表解释了为什么残差连接不是“提升精度的黑科技”而是“让深网络有机会训练好”的结构保障。日常跑实验时看到训练损失下不去先别急着怪数据先确认是不是网络结构太深、路径太长导致梯度回传到前层时已经不稳定。这也是接下来要先用 Python 亲手搭普通 CNN、再搭 ResNet 对比的原因。2.3 环境准备我为什么推荐用 PyTorch 自己写既然标题里带 Python实现框架我选 PyTorch。选它的理由有三点一是动态图调试方便残差块里 add 之后梯度流向直接从图上能看出来二是 torchvision 自带 CIFAR-10 下载脚本省去手动扒原始文件三是同一个训练循环可以原封不动喂给 CNN 和 ResNet对比实验更干净。环境搭建不用太复杂新建一个独立的 Python 环境避免和系统自带的 Python 打架。我一般这样装conda create -n cnn_resnet python3.9 -y conda activate cnn_resnet pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu后一条命令默认装 CPU 版 PyTorch没有 NVIDIA GPU 的机器也能跑通全部实验。如果本机有 CUDA 显卡把后面的cpu换成cu118或cu121具体看驱动支持的版本。装完后用python -c import torch; print(torch.__version__)验证能输出版本号就说明 Python 环境、torch 和 torchvision 三者的依赖已经对齐。提示官方源下载速度不理想时可以把 pip 的 index-url 换成国内 PyPI 镜像清华和阿里的都比较稳不影响任何包行为。3. 用 Python 从零搭简易 CNNCIFAR-10 分类的完整闭环3.1 数据准备归一化参数不要随手抄CIFAR-10 是 32×32 的彩色小图一共 10 类训练集 50000 张、测试集 10000 张。数据集下载靠 torchvision 的一行调用但预处理参数值得认真写。CIFAR-10 官方常使用的均值标准差是(0.4914, 0.4822, 0.4465)和(0.2470, 0.2435, 0.2616)如果直接用 ToTensor 后不做 Normalize图像像素落在 0 到 1第一层卷积的梯度方向会被整体的亮度偏移带偏收敛会慢不少。import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) train_set torchvision.datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransform_train) test_set torchvision.datasets.CIFAR10( root./data, trainFalse, downloadTrue, transformtransform_test) train_loader torch.utils.data.DataLoader( train_set, batch_size128, shuffleTrue, num_workers2, pin_memoryTrue) test_loader torch.utils.data.DataLoader( test_set, batch_size256, shuffleFalse, num_workers2, pin_memoryTrue)这里要留意的不是downloadTrue而是训练集和测试集的 transform 必须分开写。训练集可以加随机裁剪和随机翻转做数据增强测试集只做 ToTensor 和 Normalize否则验证结果会被数据增强抖动无法稳定评估模型。3.2 简易 CNN 结构四层卷积加一个分类头就够了网络主体不要整得太复杂四个卷积块加一个全连接分类头已经能说明 CNN 的所有关键点卷积提取局部特征、池化降低分辨率、批归一化稳定训练、ReLU 提供非线性。我习惯把特征提取部分和分类部分分开定义这样后面想换成 ResNet 时训练代码可以完全复用。class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), ) self.classifier nn.Linear(256 * 4 * 4, 10) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) x self.classifier(x) return x特征图尺寸变化可以直接手算输入 32×32卷积核 3×3、padding 为 1输出仍是 32×32第一个池化变为 16×16第二个变为 8×8第三个变为 4×4。最后一层卷积不接池化所以展平后是 256×4×44096 个特征分类头把它们映射成 10 个类别的 logits。参数上值得解释的是biasFalse和BatchNorm的配合。我这里卷积层默认带 biasBN 会把卷积输出的均值拉回 0bias 会被 BN 的加性项覆盖留着只是浪费参数。后面写 ResNet 时我会统一在卷积层设biasFalse。3.3 训练循环模型三件事不能漏训练代码对 CNN 和 ResNet 是同一套核心是定义一个通用函数。新手容易漏掉三件事model.train()和model.eval()的切换、每个 batch 都要optimizer.zero_grad()、输入也要同步搬到device上。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, total_correct, total_num 0.0, 0, 0 for inputs, targets in loader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) loss criterion(outputs, targets) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * inputs.size(0) total_correct (outputs.argmax(1) targets).sum().item() total_num inputs.size(0) return total_loss / total_num, total_correct / total_num def evaluate(model, loader, device): model.eval() total_correct, total_num 0, 0 with torch.no_grad(): for inputs, targets in loader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) total_correct (outputs.argmax(1) targets).sum().item() total_num inputs.size(0) return total_correct / total_nummodel.train()和model.eval()影响的是 BN 层训练时用当前 batch 的均值和方差推理时用训练阶段累计的全局统计量。测试时如果不切到 eval遇到 batch_size 较小的验证集BN 统计量会剧烈抖动准确率忽高忽低。torch.no_grad()则是让测试阶段不计算梯度图既省显存也提速。3.4 简易 CNN 的三个必调参数第一个是优化器。小 CNN 上 SGDMomentum 和 Adam 都能收敛但 Adam 对学习率的容忍度高我先用 Adam 把管线跑通默认lr1e-3、weight_decay5e-4。第二个是 batch_size内存够的情况下 128 比 32 稳定BN 的统计量也更可靠。第三个是 epochs在 CIFAR-10 上这个四层 CNN 大概 10 到 20 轮能到 80% 左右的准确率前 3 轮只看训练损失有没有稳定下降不用急着调结构。跑两个 epoch 后如果训练损失降到 1.5 以下说明数据和网络没问题。如果 loss 一直在 2.3 附近不动常见原因是学习率太大导致 loss 震荡或者数据增强把裁剪 padding 设得太大输入信息被破坏。把 lr 降到 3e-4再跑两个 epoch 观察。4. 在简易 CNN 上改造 ResNet残差块与层数翻倍4.1 残差块的 PyTorch 实现核心只有一条 addResNet 的最小组成单元是 BasicBlock两个 3×3 卷积串在一起外面再包一条 shortcut。写这个块时最容易搞错的地方是 shortcut 何时需要额外升维当输入输出通道数不同或者特征图尺寸减半时原输入不能直接和输出相加必须用一个 1×1 卷积把输入投影到目标形状。import torch.nn.functional as F class BasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride1): super().__init__() self.conv1 nn.Conv2d(in_ch, out_ch, 3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_ch) self.conv2 nn.Conv2d(out_ch, out_ch, 3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_ch) self.shortcut nn.Sequential() if stride ! 1 or in_ch ! out_ch: self.shortcut nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stridestride, biasFalse), nn.BatchNorm2d(out_ch) ) def forward(self, x): out F.relu(self.bn1(self.conv1(x)), inplaceTrue) out self.bn2(self.conv2(out)) out out self.shortcut(x) return F.relu(out)多解释一句代码顺序shortcut 是在第二个 BN 之后加的加完再做一次 ReLU。如果把out shortcut放在第一个 ReLU 之前梯度传播路径会多经过一次非线性压缩恒等映射就不再是输入原样透传到输出退化问题又回来了。我一开始写残差块时就踩过这个顺序的坑实际表现为 ResNet 和普通 CNN 的曲线几乎一样完全没有体现出深网络的收益。4.2 把 ResNet 缩小到 CIFAR-10改造后的 ResNet 18标准 ResNet18 是为 224×224 的 ImageNet 设计的输入头是一个 7×7 步长 2 的大卷积加最大池化。放到 32×32 的 CIFAR-10 上一次 7×7 卷积会把特征图压成 16×16丢掉太多细节。常见做法是把输入 stem 换成 3×3 步长 1 的卷积保留原始分辨率后面四个阶段沿用标准设置。class ResNet(nn.Module): def __init__(self, block, num_blocks, num_classes10): super().__init__() self.in_ch 64 self.stem nn.Sequential( nn.Conv2d(3, 64, 3, stride1, padding1, biasFalse), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue) ) self.layer1 self._make_layer(block, 64, num_blocks[0], stride1) self.layer2 self._make_layer(block, 128, num_blocks[1], stride2) self.layer3 self._make_layer(block, 256, num_blocks[2], stride2) self.layer4 self._make_layer(block, 512, num_blocks[3], stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) def _make_layer(self, block, out_ch, blocks, stride): strides [stride] [1] * (blocks - 1) layers [] for s in strides: layers.append(block(self.in_ch, out_ch, strides)) self.in_ch out_ch return nn.Sequential(*layers) def forward(self, x): x self.stem(x) x self.layer1(x) x self.layer2(x) x self.layer3(x) x self.layer4(x) x self.avgpool(x) x x.view(x.size(0), -1) x self.fc(x) return x def resnet18_cifar(): return ResNet(BasicBlock, [2, 2, 2, 2])_make_layer里strides [stride] [1] * (blocks - 1)这一段是关键。每个 stage 的第一个块负责降采样剩下的块都在同一分辨率上做特征提取。比如 layer2 的 stride2第一个 BasicBlock 里 conv1 用步长 2 的 3×3 卷积把特征图从 16×16 缩到 8×8shortcut 里的 1×1 卷积也用 stride2保证两个分支形状对齐。4.3 层数翻倍成本却只多了一条捷径把四层普通 CNN 和这个 ResNet18 放在一起看指标简易 CNNResNet18-CIFAR 版本卷积层数量418 层左右参数量约 100 万约 1100 万结构特点串行无捷径带 BasicBlock 恒等捷径典型表现约 80% 准确率约 88% 准确率参数量变大的主要来源是通道数一路增长到 512而不是 shortcut 本身。普通 CNN 如果想追上 ResNet 的深度需要增加同样多的通道和层数但因为没有捷径梯度回传路径会随层数线性增长前层几乎学不动。ResNet 等于用一条加法操作换来了整个网络的可训练性这正是它能在 ImageNet 上百层深的原因。4.4 训练 ResNet复用之前的循环只改两处训练代码可以直接沿用第 3 章的train_one_epoch和evaluate真正要改的是两点。一是优化器可以把 weight_decay 加大到1e-4ResNet 参数量更大正则太弱容易在 CIFAR-10 这种小数据集上提前过拟合。二是建议用 cosine 或 step 学习率调度不要全程固定 lr。device torch.device(cuda if torch.cuda.is_available() else cpu) model resnet18_cifar().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) for epoch in range(1, 31): train_loss, train_acc train_one_epoch( model, train_loader, optimizer, criterion, device) test_acc evaluate(model, test_loader, device) scheduler.step() print(fepoch{epoch:02d} loss{train_loss:.4f} train_acc{train_acc:.3f} test_acc{test_acc:.3f})CosineAnnealingLR的 T_max 一般设成总 epoch 数让学习率从 1e-3 平滑衰减到接近 0。如果你的目标是从零训练而非调参最简单可靠的做法就是把上述脚本按原样跑 30 轮前 5 轮观察 loss 趋势。训练深度学习经常被说成玄学但大部分训练失败其实都能在 loss 曲线的形态上找原因后面单独说。5. 搭 CNN 和 ResNet 时的五个常见坑现象、原因、解决5.1 残差块报 size mismatchm1 和 m2 维度对不上用 ResNet 训练到第一个降采样 stage 时会报类似size mismatch, m1: [128, 64, 16, 16], m2: [128, 128, 8, 8]的错误。现象很直观前向传播时两个分支相加失败。原因是我在_make_layer里修改了self.in_ch之后下一个块构造时传入了错误的输入通道数也有可能是 shortcut 的 1×1 卷积没有设置对应的 stride。解决方法是检查每个 stage 的第一个块的stride有没有同时在 conv1 和 shortcut 上生效以及self.in_ch out_ch这行代码是否写在循环构造每个块之后。5.2 普通 CNN 加深后训练损失下不去误当成需要更多数据我跑过一版 12 层普通 CNN训练损失停在 2.0 附近验证准确率只有 30% 左右。第一反应是数据增强不够换更强的增强后依然没有变化。后来把第 3 层和第 6 层的输出特征图可视化发现前几层几乎没有差异原因就是网络太深、梯度反传到浅层时已经趋近于零前层学不到有效特征。解决方法是换用带残差连接的结构或者把学习率调大配合合适的初始化重试一次。5.3 BatchNorm 在 batch_size 很小时崩溃损失出现短暂 NaN把 batch_size 设成 2 或 4 节省显存后训练几十步后 BN 的 running_mean 被极端 batch 带偏损失直接出现 NaN。原因是 BN 在小 batch 上统计的均值和方差本身方差极大连续几个偶然 batch 就可以让归一化后的数值溢出。解决方法是 batch_size 尽量保持在 32 以上实在显存不够就把网络结构里的 BN 换成 GroupNorm 或 InstanceNorm它们不依赖 batch 维度单卡小 batch 也能稳定训练。5.4 测试集也做 RandomCrop验证准确率被增强抖动干扰代码刚跑通时我把 train 和 test 用同一个 transform结果测试准确率每轮在 70% 到 85% 之间剧烈跳动。原因是测试集每次计算都被随机裁剪和翻转改变输入模型对同一张图的不同裁剪结果并不稳定。解决方法是严格区分训练 transform 和测试 transform测试阶段只保留 ToTensor 和 Normalize评估结果才能反映模型的真实泛化能力。5.5 CIFAR-10 下载中断后训练直接报错网络波动导致文件不全downloadTrue时如果下载中断程序不会自动重试运行到读文件阶段会因data_batch_1文件缺失抛异常。常见做法是手动从 CIFAR-10 官网下载cifar-10-batches-py.tar.gz解压后把cifar-10-batches-py目录放到root./data目录下再把下载调用里的downloadTrue改成downloadFalse。这样离线机器也能完整跑通全部代码。6. 验证模型是真的变深变好两条曲线加一个迁移学习技巧6.1 用一条训练曲线判断有没有发生退化训练结束后我最先看的不是最终准确率而是把训练损失和验证准确率画在一张图上。画图时如果 epoch 数一多横坐标刻度会挤成一团这是 Python 画图很常见的小问题只需要设置横轴刻度数量import matplotlib.pyplot as plt plt.plot(range(1, len(train_losses) 1), train_losses, labeltrain_loss) plt.plot(range(1, len(test_accs) 1), test_accs, labeltest_acc) plt.locator_params(axisx, nbinsmax(len(train_losses) // 5, 5)) plt.xlabel(epoch) plt.legend() plt.show()看懂曲线比看懂模型结构更重要如果训练损失下降、验证准确率同步上升说明网络容量和数据规模匹配如果训练损失下降但验证准确率停滞说明过拟合优先加 weight_decay 或数据增强如果训练损失平坦说明模型没学进去这时不用急着换网络先检查学习率和 BN 的配置。6.2 不重头训练也能验证 ResNet 的价值加载 resnet 预训练模型从零训练 ResNet18 在 CIFAR-10 上要跑几十个 epoch想快速验证结构有效性可以用 torchvision 自带的预训练模型做迁移学习import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT) model.fc nn.Linear(model.fc.in_features, 10)这个操作只需要把最后一层全连接换成 10 类输出前面所有卷积层参数都保留 ImageNet 上学习到的通用特征。在 CIFAR-10 上即使只训练分类头 5 个 epoch准确率也能到 90% 以上。这是验证“ResNet 特征提取能力比简易 CNN 强”的最快方式也是常见业务中不从头训练的主要原因。6.3 日常选型先明确你的数据规模再决定我的习惯是如果数据集只有几千张图、类别很简单先跑简易 CNN它的参数量和训练时间都更友好如果数据集达到几万张、类别内部差异大再切 ResNet 或直接上预训练迁移。ResNet 的价值在深而深的前提是数据量和训练资源能跟上。最后说一条实在经验判断一个结构有没有必要升级永远先做基准实验再谈优化。我把简易 CNN 和 ResNet 都搭过一遍后最大的教训是别在没跑通普通 CNN 之前就急着引入残差、注意力这些高级结构因为很多训练问题在浅网络上也存在深网络只是把它们放大了。希望这篇笔记能帮你把从概念到代码的最后一公里走顺。本文还有配套的精品资源点击获取