☰
Python与PyTorch实战:从零搭建简易CNN与ResNet
2026/10/8 7:41:29 网站建设 项目流程

简介:面向希望动手实现深度网络的开发者,提供基于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 python=3.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 从零搭简易 CNN:CIFAR-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, padding=4), 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', train=True, download=True, transform=transform_train) test_set = torchvision.datasets.CIFAR10( root='./data', train=False, download=True, transform=transform_test) train_loader = torch.utils.data.DataLoader( train_set, batch_size=128, shuffle=True, num_workers=2, pin_memory=True) test_loader = torch.utils.data.DataLoader( test_set, batch_size=256, shuffle=False, num_workers=2, pin_memory=True)

这里要留意的不是download=True,而是训练集和测试集的 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_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), ) 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×4=4096 个特征,分类头把它们映射成 10 个类别的 logits。

参数上值得解释的是bias=False和BatchNorm的配合。我这里卷积层默认带 bias,BN 会把卷积输出的均值拉回 0,bias 会被 BN 的加性项覆盖,留着只是浪费参数。后面写 ResNet 时我会统一在卷积层设bias=False。

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_num

model.train()和model.eval()影响的是 BN 层:训练时用当前 batch 的均值和方差,推理时用训练阶段累计的全局统计量。测试时如果不切到 eval,遇到 batch_size 较小的验证集,BN 统计量会剧烈抖动,准确率忽高忽低。torch.no_grad()则是让测试阶段不计算梯度图,既省显存也提速。

3.4 简易 CNN 的三个必调参数

第一个是优化器。小 CNN 上 SGD+Momentum 和 Adam 都能收敛,但 Adam 对学习率的容忍度高,我先用 Adam 把管线跑通,默认lr=1e-3、weight_decay=5e-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 实现:核心只有一条 add

ResNet 的最小组成单元是 BasicBlock,两个 3×3 卷积串在一起,外面再包一条 shortcut。写这个块时最容易搞错的地方是 shortcut 何时需要额外升维:当输入输出通道数不同,或者特征图尺寸减半时,原输入不能直接和输出相加,必须用一个 1×1 卷积把输入投影到目标形状。

import torch.nn.functional as F class BasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, stride=1, padding=1, bias=False) 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, stride=stride, bias=False), nn.BatchNorm2d(out_ch) ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x)), inplace=True) 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_classes=10): super().__init__() self.in_ch = 64 self.stem = nn.Sequential( nn.Conv2d(3, 64, 3, stride=1, padding=1, bias=False), nn.BatchNorm2d(64), nn.ReLU(inplace=True) ) self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1) self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2) self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2) self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2) 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, stride=s)) 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 的 stride=2,第一个 BasicBlock 里 conv1 用步长 2 的 3×3 卷积把特征图从 16×16 缩到 8×8,shortcut 里的 1×1 卷积也用 stride=2,保证两个分支形状对齐。

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-4,ResNet 参数量更大,正则太弱容易在 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(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) 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(f"epoch={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 mismatch:m1 和 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 下载中断后训练直接报错:网络波动导致文件不全

download=True时如果下载中断,程序不会自动重试,运行到读文件阶段会因data_batch_1文件缺失抛异常。常见做法是手动从 CIFAR-10 官网下载cifar-10-batches-py.tar.gz,解压后把cifar-10-batches-py目录放到root='./data'目录下,再把下载调用里的download=True改成download=False。这样离线机器也能完整跑通全部代码。

6. 验证模型是真的变深变好:两条曲线加一个迁移学习技巧

6.1 用一条训练曲线判断有没有发生退化

训练结束后,我最先看的不是最终准确率,而是把训练损失和验证准确率画在一张图上。画图时如果 epoch 数一多,横坐标刻度会挤成一团,这是 Python 画图很常见的小问题,只需要设置横轴刻度数量:

import matplotlib.pyplot as plt plt.plot(range(1, len(train_losses) + 1), train_losses, label='train_loss') plt.plot(range(1, len(test_accs) + 1), test_accs, label='test_acc') plt.locator_params(axis='x', nbins=max(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(weights=models.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 之前就急着引入残差、注意力这些高级结构,因为很多训练问题在浅网络上也存在,深网络只是把它们放大了。希望这篇笔记能帮你把从概念到代码的最后一公里走顺。

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

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

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

立即咨询