简介:面向深度学习初学者与图像识别入门者,资源完整演示了简易卷积神经网络和残差网络的搭建思路,直接回应了残差网络是否为卷积神经网络的概念困惑。内容以两周递进式项目组织:第一周聚焦卷积神经网络基础组件,包括卷积层、激活函数、池化层与全连接层;第二周深入残差网络的残差块、跳跃连接与批量归一化实现,帮助读者理解为何残差结构能缓解深层网络的梯度消失问题。资源共24个文件,主要包含可运行Python脚本、h5格式数据集与项目配置文件,压缩包约25.27MB,目录按第一周与第二周清晰划分,便于按模块加载与调试。目前已有1099人学习下载。读者可获得可直接运行的模型源码和配套训练数据,也能从代码中理清模型构建、数据加载到训练验证的完整流程,适合作为课程设计或入门深度学习的参考资料。
1. 先回答那个最常被问错的问题:resnet 是 cnn 吗
刚入坑深度学习的人第一次搜到“resnet 是 cnn 吗”这个问题时,多半是已经看了一堆论文里的缩写,心里发虚;又或者试了几天 Python 搭网络,发现用 torchvision 直接 load 一个 resnet18 比自己从零写容易太多,反而开始分不清这两个词的关系。先给结论:ResNet 就是 CNN 的一种具体架构,它没有把卷积替换成别的东西,只是在原本串成一串的卷积层之间加了“抄近道”的残差连接。本文按最省事的 PyTorch 路线,先把简易 CNN 跑通,再把同一份代码改成简易 ResNet,最后讲几个我实际训练时踩过的报错。适合刚跑通 Python 基础、想自己搭网络做图像分类的入门者,也适合需要快速给小组出一版 baseline 的工程师。
2. ResNet 与 CNN 的关系:残差连接没有改变本质
2.1 CNN 的组成到底是什么
CNN 卷积神经网络解决的核心问题,是让模型自己从像素里学特征,而不是人工去设计边缘、纹理这些规则。一个典型 CNN 由三类部件组成:卷积层负责提取局部特征,池化层负责压缩分辨率,全连接层在最后做分类或回归。
以一张 32×32 的彩色图片为例,输入是 3 个通道。第一层卷积输出 16 个通道的特征图,每个通道都在用一堆 3×3 的卷积核扫描图片。卷积核的厉害之处在于参数共享:一个 3×3×3 的卷积核只有 27 个参数,却要覆盖整张图的每个位置,这让 CNN 比同等规模的全连接网络轻量得多。池化层则在 2×2 的窗口里取最大值或平均值,把分辨率降一半,让后面的卷积层能看到更大范围的语义。
直接把这几层串起来,就是一个最朴素的 CNN。这种结构有效,但它有一个天然瓶颈:层数越深,梯度从最后一层传回第一层时要经过的乘法就越多。在反向传播里,梯度会反复乘以小于 1 的数,传到前面几乎归零,这就是梯度消失。
2.2 ResNet 为什么仍然属于 CNN
ResNet 的全称是 Residual Network,残差网络。它没有改变 CNN 的底层零件——卷积核、批量归一化、ReLU 激活、池化、全连接全都还在。它改的是这些零件之间的连接方式。
在普通 CNN 里,每一层的输出是上一层的直接映射:x 经过卷积后得到 H(x),然后继续往后传。ResNet 的残差块做了一件看起来很小的事:把输入 x 也加一份到输出上,让最终输出变成 H(x) + x。这样网络实际要学的函数从 H(x) 变成了 H(x) − x,也就是“残差”。如果某一层已经学得很好了,它只要把残差学到 0,输出就等于输入,网络不会因为增加这一层而变差。
有人会把 CNN 和 RNN 摆在一起比较,说 CNN 擅长空间特征、RNN 擅长序列特征,然后想当然把 ResNet 当成另一类模型。这是理解上的偏差。ResNet 只是 CNN 的一个变种,它从属于 CNN 这个大类。类比来说,MPV 是汽车的一种,你不会因为 MPV 多了后排空间就说它不是汽车。
2.3 自建网络与加载预训练模型怎么选
很多教程一上来就torchvision.models.resnet18(pretrained=True),几行代码就把模型拿走了。但如果你是从零学,我不建议这么干。直接加载预训练权重,你看到的是一个封装好的黑匣子,后面要改网络结构、要打印中间特征图、要排查维度报错时,你会无从下手。
我一般的做法分两步:先手写一个极小的 CNN 在 CIFAR-10 或者自己的小数据集上跑通,确认损失下降、准确率能上去;然后在这个基础上,把普通卷积串改造成残差块,变成简易 ResNet。这一步走通了,再去碰 torchvision 里那个全尺寸 ResNet,你会发现剩下的改动只是堆叠更多层。
这里给出两者对照,方便你按自己的目标选:
| 场景 | 自建简易 CNN / ResNet | torchvision 加载预训练 |
|---|---|---|
| 理解反向传播与维度流 | 有,能逐层打印 | 无,只能看输入输出 |
| 小数据集训练 | 可控 | 容易过拟合 |
| 图像分类基线 | 够用 | 更强 |
| 修改网络结构 | 随意 | 需要读懂源码 |
如果你只是要一个高精度分类器,直接加载预训练没问题;如果你想以后能改网络、写论文里的对比实验,第一步一定是从自建开始。
2.4 一个常见误用:以为 CNN 只能用在图像上
CNN 的输入可以是一维序列、二维图像、三维体素。一维卷积常被用在时间序列和文本分类里,它的卷积核只在时间轴上滑动。所以“简易 CNN 搭建”并不局限于图片分类,你可以把同样的结构套到任意二维矩阵数据上,比如频谱图、灰度热力图。
但要注意一点,CNN 假设输入是有空间局部性的。如果数据的特征在排列上完全没有相邻关系,比如随机打乱后的表格字段,卷积就失去了“局部感受野”的意义,这时候 MLP 或树模型更合适。判断一个任务要不要用 CNN,先问自己:相邻位置的特征是否相关?相关就用,不相关就别硬套。
3. 用 Python 搭一个简易 CNN:最小模型与训练循环
3.1 从零写一个可训练的简易 CNN
选 PyTorch 而不是 TensorFlow 做这件事,是因为 PyTorch 的nn.Module机制对新手最友好:你只需要定义__init__里有什么层,以及forward里数据怎么流。下面这个SimpleCNN是 3 层卷积加全连接的最小实现,输入 3 通道 224×224 图片,输出 10 类得分:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( # 3x3卷积 + padding=1,特征图尺寸保持不变 nn.Conv2d(3, 16, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 224 -> 112 nn.Conv2d(16, 32, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 112 -> 56 nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 56 -> 28 ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), # 不管输入多大,都池化成 1x1 nn.Flatten(), nn.Linear(64, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x这里有几个参数值得说明。padding=1是为了让 3×3 卷积不缩小特征图,否则每过一个卷积层分辨率就减 2,网络加深后很快就到负数了。BatchNorm2d放在卷积和 ReLU 之间是 PyTorch 里的主流做法,它把每批数据的激活值拉回均值为 0、方差为 1,能显著缓解梯度消失,让我能把学习率开得更大。AdaptiveAvgPool2d((1, 1))是替代 Flatten 的好东西,它不管输入特征图是 28×28 还是 7×7,一律池化成 1×1,再进全连接,省去了手动计算 Flatten 后向量长度的麻烦。
3.2 数据加载、损失函数与优化器怎么配
网络只是骨架,训练循环才是让参数动起来的地方。以 CIFAR-10 为例,标准做法是用torchvision.datasets.CIFAR10下载数据,再用transforms做预处理。注意 PyTorch 的图片张量是(C, H, W)顺序,和 OpenCV 读出来的(H, W, C)不一样,不改会直接报通道数错误。
from torchvision import datasets, transforms from torch.utils.data import DataLoader transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.4914, 0.4822, 0.4465], std=[0.2470, 0.2435, 0.2616]), ]) train_data = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform) train_loader = DataLoader(train_data, batch_size=32, shuffle=True, num_workers=2) model = SimpleCNN(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5)shuffle=True必须开,否则每个 epoch 模型看到的样本顺序都一样,收敛会变慢。num_workers=2是让数据加载开两个子进程,避免 GPU 在等 CPU 喂数据。CrossEntropyLoss接受的是模型输出的原始 logits,不需要手动做 softmax,PyTorch 会在损失函数内部完成 softmax 和交叉熵计算,你如果在forward里提前 softmax 了,反而会得到偏低的损失值。
3.3 训练主循环:一版可以直接照抄的模板
训练循环的写法基本固定:清零梯度、前向传播、算损失、反向传播、更新参数。下面这版加了每轮的 loss 和准确率打印,方便你确认模型真的在学东西:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) for epoch in range(50): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() # 清零上一步的梯度 outputs = model(images) # 前向传播,形状 [32, 10] loss = criterion(outputs, labels) loss.backward() # 反向传播,计算梯度 optimizer.step() # 用梯度更新参数 running_loss += loss.item() * images.size(0) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) train_acc = correct / total print(f"epoch {epoch+1:2d} loss {running_loss/total:.4f} acc {train_acc:.4f}") scheduler.step() # 每 20 轮把学习率乘 0.5注意model.train()这行不能省。它影响 BatchNorm 和 Dropout 的行为:训练时 BN 用当前批次的统计量,验证时应该改用全局统计量。很多新手把model.eval()忘在验证阶段,导致推理结果忽高忽低,就是这个原因。
3.4 验证阶段别忘的三个细节
验证代码容易踩三个细节坑,顺便一起写完整。第一,验证要用torch.no_grad()包起来,否则 PyTorch 会为验证过程建计算图,白白占显存;第二,要把model.eval()加上,理由上面已经说了;第三,验证集也要做同样的 Normalize,但不应该做随机增强,否则不同 epoch 之间指标会抖动。
val_loader = DataLoader(val_data, batch_size=32, shuffle=False) model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) print(f"val acc: {correct/total:.4f}")到这里,一个能收敛的简易 CNN 就跑通了。如果你的数据不是图片而是表格,把Conv2d换成Conv1d、输入从 3 通道变成特征列数,其他代码可以原样复用。
4. 把 CNN 升级成简易 ResNet:残差块与维度投影
4.1 BasicBlock 残差块怎么实现
有了可以收敛的简易 CNN,升级 ResNet 的核心就是新增一个BasicBlock类。它由两个 3×3 卷积组成,外加一条 shortcut 连接。当输入输出尺寸一致时,shortcut 直接返回输入 x;尺寸不一致时,用一个 1×1 卷积把 x 变换到目标尺寸:
import torch.nn.functional as F class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_planes, planes, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(planes) self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(planes) self.shortcut = nn.Sequential() # 两种情况需要投影:空间尺寸变了(stride=2) 或 通道数变了 if stride != 1 or in_planes != planes: self.shortcut = nn.Sequential( nn.Conv2d(in_planes, planes, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(planes), ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out = out + self.shortcut(x) # 残差相加 out = F.relu(out) return out这里bias=False是个容易忽略的细节。因为卷积后面紧跟 BatchNorm,BN 里有自己的偏置项,卷积的 bias 就是多余的了,留着只会浪费参数且可能干扰 BN 的统计。stride=2用在下采样时:主分支第一个卷积把空间尺寸减半,shortcut 分支用同样 stride 的 1×1 卷积把输入也减半,两边形状才能对得上。
4.2 组装 MiniResNet:通道数与分辨率对齐
有了BasicBlock,网络主体就是把多个残差块串起来。ResNet-18 有 4 个 stage,每层两个残差块;这里为了演示方便,我先做一个 3 stage 的 Mini 版本:
class MiniResNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # stage1: 分辨率不变,64 -> 64 self.layer1 = self._make_layer(64, 64, blocks=2, stride=1) # stage2: 分辨率减半,64 -> 128 self.layer2 = self._make_layer(64, 128, blocks=2, stride=2) # stage3: 分辨率再减半,128 -> 256 self.layer3 = self._make_layer(128, 256, blocks=2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(256, num_classes) def _make_layer(self, in_planes, planes, blocks, stride): layers = [BasicBlock(in_planes, planes, stride)] for _ in range(1, blocks): layers.append(BasicBlock(planes, planes, stride=1)) return nn.Sequential(*layers) def forward(self, x): x = self.relu(self.bn1(self.conv1(x))) x = self.maxpool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x手动推一遍维度假设输入 224×224:conv1 步长 2 变 112×112,maxpool 步长 2 变 56×56;layer1 的 stride=1,保持 56×56;layer2 的第一个 BasicBlock stride=2,变 28×28;layer3 变 14×14。最后 avgpool 变成 1×1。这个链路在代码里跑通后,维度报错大多集中在_make_layer的参数传递上。
4.3 从梯度流动看 ResNet 为什么更稳
同样深度的普通 CNN 和 ResNet,在同样的数据上跑,前者经常遇到训练准确率上不去、甚至层数越深越差的情况。原因不是过拟合,而是反向传播时梯度经过太多层连续相乘,传到前面已经很弱。ResNet 的 shortcut 给了梯度一条“高速公路”:输出对输入的导数恒包含一个 1,即使残差部分梯度消失,输入端的梯度也不会归零。
我自己的经验是,简易 CNN 在 CIFAR-10 上到 30 轮左右 accuracy 上升就开始明显变慢;同样的训练参数换成 MiniResNet,后面十几个 epoch 还能继续往上走。这不是玄学,而是残差结构降低了深层网络的优化难度。要验证这一点也很简单,把两个网络放进同一份训练代码里,保持学习率、batch size、epoch 完全一致,打印每个 epoch 的 loss 对比,ResNet 的 loss 下降曲线通常更平滑。
如果你后续要换 ResNet-50,只需要把BasicBlock换成瓶颈块 Bottleneck,它的结构是 1×1 降维 → 3×3 → 1×1 升维,中间 3×3 的卷积通道数只有输出的四分之一,计算量小很多。但那属于扩充阶段,先把这里的小号 ResNet 跑通,再看 torchvision 里的官方实现就容易多了。
5. 搭建 ResNet 的避坑排查:不收敛、维度报错的 5 条记录
5.1 loss 变成 nan,第一反应不是调参而是查数据
现象:训练到第几轮 loss 突然变成nan,后续所有指标全部失效,模型输出也是nan。
原因:最常见的是学习率开太大,参数更新一步跨得太远,梯度爆炸;其次是输入图片没做归一化,像素值直接落在 0 到 255 的范围,和初始化权重不匹配,前向传播数值越来越大,最后溢出。
解决:先把学习率降到 1e-3 以下,ResNet 这种带 BN 的网络我一般从 1e-3 起步就够。然后检查数据范围,直接在训练循环里print(images.min(), images.max()),确认在 0 到 1 之间。如果训练中途才变 nan,大概率是某 batch 的数据异常,可以打开torch.autograd.set_detect_anomaly(True)定位到具体是哪一步反向传播出了问题。此外遇到较长训练直接把torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5)加上,给梯度上个保险,能避免大部分梯度爆炸场景。
5.2 shortcut 形状对不上,报错说 “Sizes of tensors must match”
现象:从普通 CNN 升级到 ResNet 时,前向传播报错,提示两个张量尺寸不匹配,通常卡在残差相加那一行。
原因:stride>1 时,主分支的卷积把特征图空间尺寸减半了,但 shortcut 分支如果没有对应地做下采样,x 还是原来的大尺寸,两边一相加就报错;另一种情况是通道数从 64 涨到 128,主分支输出 128 通道,x 还是 64 通道。
解决:检查 shortcut 分支是否加了 stride 一致的 1×1 卷积投影。有一个实用技巧,在forward里加一行断言,尽早暴露问题:
assert out.shape == self.shortcut(x).shape, \ f"shape mismatch: {out.shape} vs {self.shortcut(x).shape}"这样报错信息会直接告诉你主分支和捷径分支各是什么形状,不用反回去猜是哪层的问题。维度对齐是 ResNet 搭建里最容易翻车的点,新手十个有八个栽在这里。
5.3 训练在跑,loss 在降,准确率却锁死在 10%
现象:十分类数据集,loss 从 2.3 降到了 0.7,但训练准确率和验证准确率都稳定在 0.1 左右,怎么调都不动。
原因:类别标签和数据没对齐,最常见的是用ImageFolder加载数据时,它按子目录名的字母顺序排序生成标签,你的目录顺序恰好不是你想的 0 到 9。也就是说模型可能在用“0 号样本对应 5 号标签”这种错位关系去学,loss 下降只是因为它记住了这种错位。
解决:训练前先打印数据集的class_to_idx字段,确认每个类别名对应的索引,再抽查几个 batch 的图片和标签对应关系。用shuffle=True时错位不容易暴露,但一旦出现准确率锁死的情况,这个方向最值得排查。
5.4 CUDA out of memory,不一定是显存不够
现象:跑小 CNN 没问题,换成 ResNet 后没几个 batch 就CUDA out of memory,甚至 24G 显存也顶不住。
原因:显存占用大头是中间特征图,不是模型参数。输入图 224×224 还好,如果你直接塞 1024×1024 的大图,第一层特征图就有上百万个元素,乘以批大小和通道数,显存瞬间被打爆。另一个常见原因是验证阶段忘了包torch.no_grad(),PyTorch 给验证过程也建了计算图,白占一份显存。
解决:优先把 batch size 减半,这是最快见效的。其次检查输入尺寸,不一定非要 224×224,自己的数据集 160×160 往往也够用,分辨率减半,显存占用直接降到四分之一。最后在整轮训练开始前调一次torch.cuda.empty_cache(),释放 PyTorch 缓存但不再用的显存碎片。如果你需要跑大批量,可以考虑梯度累积,攒几个 batch 再更新一次参数,效果等价于大 batch,但显存占用不变。
5.5 加载预训练后全面微调,结果反而不如冻结 backbone
现象:用 torchvision 加载resnet18(pretrained=True)后全参数微调,在自己几千张的小数据集上,验证准确率居然不如随机初始化从头训练。
原因:小数据集 + 全参数微调,相当于放弃预训练权重已经学好的通用特征,让它们去适配一个新的小分布,基本必过拟合。再加上很多人微调时还用大学习率,把预训练特征直接冲垮了。这不是模型问题,是训练策略问题。
解决:先冻结 backbone,只训练最后的全连接层,学习率用 1e-3 跑 10 个 epoch,等分类头收敛后再解冻部分浅层,学习率降到 1e-4 做精调。最简单可靠的技巧:在加载预训练权重前,把model.fc换成随机初始化的新线性层,然后用一个比从头训练更小的学习率,比如 1e-4,去微调整个网络。经验值是数据越少,越要优先冻结更多层。
6. 验证浅层网络真的学到了:混淆矩阵与特征图 hook
6.1 用混淆矩阵判断模型是学懂了还是蒙对了
只看 val acc 不够,一个在 10 类上准确率 80% 的模型,可能对其中两类完全分不清。用 sklearn 的混淆矩阵能立刻看出哪些类别互相混淆:
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import numpy as np y_true, y_pred = [], [] with torch.no_grad(): for images, labels in val_loader: images = images.to(device) pred = model(images).argmax(dim=1).cpu().numpy() y_true.extend(labels.numpy()) y_pred.extend(pred) cm = confusion_matrix(y_true, y_pred) ConfusionMatrixDisplay(cm).plot()如果对角线上数值明显高于其他位置,说明每个类别都在正常区分;如果某两类的数值对称地高,说明模型把这两类特征学混了,例如把“猫”和“狗”混在一起,就需要回看数据增强和数据量,而不是急着改网络结构。
6.2 用 hook 抓中间层特征图,确认模型在看什么
ResNet 前面层学的是边缘纹理,后面层学的是语义部件。想看网络到底有没有学到有意义的东西,可以挂一个 hook 把某个卷积层的输出取出来,转成灰度图看一下:
feature_map = {} def hook_fn(module, input, output): feature_map['layer2'] = output.detach().cpu() model.layer2.register_forward_hook(hook_fn) with torch.no_grad(): model(images[:1].to(device)) # 取第一个样本的前 8 个通道,缩放到 0-255 maps = feature_map['layer2'][0, :8].numpy() maps = (maps - maps.min()) / (maps.max() - maps.min() + 1e-6) * 255把特征图存成图片后,你会看到浅层通道里大多是边缘和色块,深层通道会对应某些物体的局部轮廓。如果特征图一片均匀没有结构,或者全是噪点,说明模型训练不到位,需要回头查数据增强和学习率。这是我判断网络是否值得继续调参最常用的手段,比只看曲线更直观。
最后说一个我的习惯:每次搭完网络,把model.modules()里每一层的输出形状打印一遍,留档保存。这样后面改结构、加分支、做剪枝,都能快速找到改动影响的范围。这个小习惯帮我省了无数次排查维度报错的时间,希望帮到你。
本文还有配套的精品资源,点击获取