简介:简易卷积神经网络与残差网络搭建项目面向深度学习初学者与进阶开发者,提供纯Python语言实现的自建网络源码,覆盖图像识别场景下从零搭建卷积模型及借助残差结构改善深层网络训练的核心问题。资源共24个文件,含5个功能脚本、6个模型权重文件、配置类文件及编译缓存,整体压缩包约25.27MB,其中脚本负责模型定义与训练流程,权重文件保存训练好的网络参数,便于直接加载测试。项目按“第一周”“第二周”分阶段组织,第一周演示卷积层、激活函数、池化层、全连接层的标准卷积网络搭建,第二周围绕残差块与跳跃连接,实现残差网络改进结构,并涉及批量归一化等工程细节。所有代码基于主流深度学习框架实践,附带训练与测试数据集,可运行验证。已有1099人学习浏览,适合需要动手掌握卷积神经网络与残差网络原理、理清两者关系并参考完整代码尝试调整超参数的深度学习学习者。
1. 先从一个大坑说起:ResNet到底还是不是CNN
很多朋友拿着“简易CNN和ResNet搭建”这个需求来找我,第一句话往往是“我会搭CNN了,ResNet是不是另一种新网络?”我直接说结论:ResNet就是CNN,而且是CNN里最成功的一支。它没有发明卷积,没有推翻池化,只是把原本“一条路走到黑”的卷积堆叠,改成了一条主路加一条“捷径”。这条捷径叫残差连接,它让梯度可以抄近道反传,从而把网络从十几层推到了上百层。这篇文章就按“先搞清关系、再手写两个模型、最后填坑”的顺序走,你能拿到的是一份可以照着敲的PyTorch代码,以及我在调参时踩过的几个真坑。
2. ResNet与CNN的关系:残差连接为什么没有改变CNN本质
2.1 你所认识的CNN:卷积、池化、全连接这三板斧
CNN的核心不是“深度”,而是它的三个基本操作。卷积层用一组可学习的卷积核在输入上滑动,每个位置做一次加权求和,得到一张特征图。池化层负责降采样,常见的是最大池化和平均池化,它的作用不是提取新特征,而是把特征图变小、把位置信息做一次粗粒度压缩。最后是全局平均池化加全连接层,把二维特征图展平成向量,分类就靠它。
ResNet没有改动这三板斧。你去看ResNet-50的结构,第一层照样是7x7卷积加最大池化,中间照样是卷积和池化交替,最后照样是全局平均池化和全连接输出1000类。残差块里的两个卷积层用的还是3x3卷积,没有任何新算子。所以第一个问题的答案很明确:ResNet是CNN,它只是把CNN的“组织方式”改了,没改“基本单元”。
那为什么很多人觉得ResNet不是CNN?因为看网络结构图时,残差块的“分叉”让注意力全放在那条跳跃的shortcut上,忘了旁边那个“主路径”本身就是普通卷积。你用“resnet是cnn吗”搜到的各种帖子,争议其实都集中在“ResNet算不算一种新架构”,而不是“ResNet的元素是否来自CNN”。我的判断是:属于CNN,属于深度CNN,属于那种把CNN推向真正深度的架构。
2.2 残差块到底改了什么:从拟合目标到梯度流动
普通CNN的卷积层在拟合什么?假设我们希望网络学习一个映射H(x),那么堆叠的每层都在努力逼近这个目标函数。层数一深,问题就来了:如果某些层已经学到了很好的特征,后面再堆层,理想情况是新层应该学到一个“恒等映射”,也就是输出等于输入,但让卷积核去学“什么都不干”这件事本身就很别扭。
残差块的做法是把目标拆成两部分。主路径去学一个残差F(x),然后和输入x相加,整个块的输出是H(x)=F(x)+x。如果某层已经够好了,那F(x)只需要逼近0,也就是让卷积核学“输出全零”,比学“输出等于输入”容易得多。另一个关键点是梯度:损失对x的梯度,通过加法直接传回了一部分,不需要经过主路径的卷积层。这就是“捷径”的本意——不是让数据抄近道,是让梯度抄近道。
这个设计有一个副作用,就是残差块天然要求输入和输出的维度一致。如果通道数变了,或者特征图尺寸减半,x和F(x)没法直接相加。所以你在读ResNet代码时,会看到一种叫shortcut投影的操作,用一个1x1卷积把x的维度也变成F(x)的维度,再做加法。这在你后来自定义网络时是个必踩的坑,后面第五章我会专门写。
2.3 用一张表看明白ResNet与普通CNN的异同
| 对比项 | 普通CNN(如VGG) | ResNet |
|---|---|---|
| 基本单元 | 卷积+激活+池化堆叠 | 残差块(主路径+shortcut) |
| 是否使用卷积 | 是 | 是 |
| 是否使用池化 | 是 | 是(卷积层内常配合stride=2降采样) |
| 深度上限 | 十几层,再深易退化 | 几十到上百层,靠残差块缓解退化 |
| 梯度流动 | 只依赖反向传播逐层回传 | 提供逐层回传之外的“快速通道” |
| 设计动机 | 手工调结构拟合目标 | 让深层网络不因过深而退化 |
这张表想说明的判断是:ResNet的一切变化都围绕“让CNN更深”,而不是“让CNN变成另一种东西”。你在用PyTorch写代码时,普通CNN的卷积层代码和ResNet残差块里的卷积层代码完全一样,区别只在是否有那条shortcut分支。所以接下来我会先写一个简易CNN,再到它的基础上升级成简易ResNet,这样你能直观看到“diff”在哪里。
3. 用PyTorch从零搭一个简易CNN:CIFAR-10实战
3.1 环境准备:Python、PyTorch和CUDA的版本匹配
在动手写模型之前,先把环境理顺。我见过太多人在“环境问题”上卡一整天,其实核心只有三个版本要匹配:Python版本、PyTorch版本、CUDA版本。Python我建议装3.9或3.10,这两个版本对PyTorch各大版本兼容性最好,3.11和3.12在部分老版本PyTorch下会报“找不到匹配的wheel”。PyTorch用CPU版起步就行,跑CIFAR-10这种小数据集,CPU几毫秒一步,完全够用。将来如果你要跑224x224的ImageNet迁移学习,再回来装CUDA版,命令也简单。
# 建议先建虚拟环境再装包,避免污染全局环境 python -m venv cnn_resnet_env source cnn_resnet_env/bin/activate # Windows下用 cnn_resnet_env\Scripts\activate # 安装PyTorch CPU版,目前稳定大版本是2.x pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu装完后验证是否成功,我一般会跑这三行命令看输出:
import torch print(torch.__version__) # 版本号,例如 2.1.0 print(torch.backends.mps.is_available()) # Mac用户看这个 print(torch.cuda.is_available()) # 有NVIDIA显卡且装好CUDA版Torch的话返回True参数说明:torch.__version__能确认torch本体有没有装进去;torch.backends.mps.is_available()只对Apple Silicon用户有意义,x86的Mac和Windows忽略它;torch.cuda.is_available()是CUDA版的关键判断,返回False不代表你不能训练,只代表你当前只能用CPU。这里有个坑:如果你先装了CPU版,后面想换CUDA版,直接pip install覆盖会留下残留,我建议先pip uninstall torch torchvision再重装。
3.2 搭建简易CNN:3个卷积组加1个全连接头
我用的是PyTorch的nn.Module写法,这是最常见的做法。这个简易CNN参考了VGG的“块”思想,但不照搬VGG,用三组卷积,每组里两个3x3卷积,中间用ReLU,组与组之间用最大池化降采样。最后一组卷积完后,接一个全局平均池化,把特征图压成向量,再接全连接层输出10类。
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super(SimpleCNN, self).__init__() # 卷积组1:输入3通道(RGB),输出64通道,特征图尺寸不变 self.conv1 = nn.Sequential( nn.Conv2d(3, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # 尺寸减半 ) # 卷积组2:通道数翻倍到128,尺寸再减半 self.conv2 = nn.Sequential( nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) # 卷积组3:通道数再翻倍到256 self.conv3 = nn.Sequential( nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) # 全局平均池化 + 全连接分类头 self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(256, num_classes) def forward(self, x): x = self.conv1(x) x = self.conv2(x) x = self.conv3(x) x = self.avgpool(x) x = torch.flatten(x, 1) # 把 [B, 256, 1, 1] 压成 [B, 256] x = self.fc(x) return x逻辑说明:这是一个能直接跑通的最小CNN。输入x的形状是[B, 3, 32, 32],B是batch size。经过conv1后变成[B, 64, 16, 16],经conv2变[B, 128, 8, 8],经conv3变[B, 256, 4, 4]。AdaptiveAvgPool2d((1,1))不管输入特征图多大,都强行池化成1x1,所以这个模型对输入分辨率不太挑,换到64x64也能跑。参数方面,我特意在卷积后加了BatchNorm2d而不是只用ReLU,这是血泪经验:不加BN的浅层CNN在CIFAR-10上很容易陷入“震荡不收敛”,加了之后10个epoch内准确率就明显爬升。
关于inplace=True这里说一句:它表示ReLU直接修改输入张量,省内存。对于训练来说没风险,但如果你之后做模型导出或者需要保留中间特征做可视化,建议把它设成False,否则原图会被覆盖。
3.3 训练脚本:loss、acc和checkpoint怎么落代码
模型写完,要训练才知道好坏。我习惯把训练逻辑写成一个函数,方便后续换数据集、换模型。下面这段代码覆盖了数据加载、训练循环、验证循环和模型保存四个环节。
import torch import torch.optim as optim import torch.nn.functional as F from torchvision import datasets, transforms # 数据增强:随机翻转加标准化,CIFAR-10的常用组合 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)), ]) # 第一次跑需要下载CIFAR-10,大约160MB train_set = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) test_set = 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) test_loader = torch.utils.data.DataLoader(test_set, batch_size=128, shuffle=False, num_workers=2) # 实例化模型、损失函数、优化器 model = SimpleCNN(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) # 训练函数:跑一个epoch,返回平均loss def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss = 0.0 for images, labels in loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) return total_loss / len(loader.dataset) # 验证函数:跑一遍测试集,返回top-1准确率 def evaluate(model, loader): model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return 100.0 * correct / total # 训练20个epoch,每个epoch后打印loss和测试准确率 for epoch in range(20): train_loss = train_one_epoch(model, train_loader, optimizer, criterion) acc = evaluate(model, test_loader) print(f"Epoch {epoch+1:02d} | Loss {train_loss:.4f} | Test Acc {acc:.2f}%") # 训练完保存checkpoint,方便后续继续训练或做推理 torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'epoch': epoch + 1, }, './simple_cnn_checkpoint.pth')逻辑说明:这段代码里有三个关键点。第一,数据增强只加在训练集上,测试集只做标准化不做增强。测试集加RandomCrop会导致验证指标虚高但实际推理效果差。第二,优化器用SGD而不是Adam,因为CIFAR-10这种小规模数据集上,SGD配momentum加weight_decay能收敛到更好的极值点,Adam前期快但后期容易在最优解附近震荡。第三,torch.save保存的是字典而不是model本身,这是推荐做法,因为字典里可以存多个状态,恢复时也灵活。
参数说明:batch_size=128是我在8GB内存的MacBook上试过不溢出的值,如果你显存或内存紧张,调到64也行,但要注意调小batch后学习率最好也等比例调小,否则收敛不稳。lr=0.1配合momentum=0.9在CIFAR-10上是经典配置,不建议新手改。num_workers=2在Windows下如果报错,改成0,这是PyTorch在Windows上的已知坑。
4. 把CNN升级成ResNet:最短的残差块实现
4.1 残差块的PyTorch实现:BasicBlock与维度匹配
从简易CNN升级到ResNet,核心是写一个残差块。我见过最短的实现不到20行,里面包含两个卷积、两个BatchNorm和一个shortcut判断。这个Block被称为BasicBlock,你去看resnet18和resnet34的源码,用的就是它。
import torch import torch.nn as nn class BasicBlock(nn.Module): # expansion是残差块输出通道和输入通道的比值,BasicBlock是1,Bottleneck是4 expansion = 1 def __init__(self, in_channels, out_channels, stride=1): super(BasicBlock, self).__init__() 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) self.shortcut = nn.Sequential() # 默认是恒等映射,不做任何操作 # 只有两种情况需要处理shortcut: # 1. stride不为1,说明特征图尺寸变了 # 2. in_channels不等于out_channels,说明通道数变了 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): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) # 残差连接:主路径输出 + shortcut输出 out = F.relu(out) return out逻辑说明:这段代码里最关键的是self.shortcut = nn.Sequential()和后面那个if判断。默认情况下shortcut是一个空操作,前向时self.shortcut(x)直接返回x本身,所以out += x就能完成恒等映射。只有当stride不为1或通道数不匹配时,才用1x1卷积把x变成和out一样的尺寸。这个设计是为了让残差块“即插即用”,你不用操心上一层的输出尺寸是什么,Block自己会判断。
参数说明:conv1里的stride=stride是降采样的入口。比如某层第一个Block的stride=2,那么特征图尺寸减半,后面所有Block都保持stride=1。conv2固定stride=1,不在第二个卷积里做降采样,这是ResNet的标准做法,原因是如果两个卷积都降采样,信息丢失太严重。bias=False是因为后面跟着BatchNorm,bias在这里是多余的,BN会做归一化操作,有没有bias计算结果几乎一致,而且去掉bias能省参数。
4.2 从CNN到ResNet:替换主干、保留分类头
有了BasicBlock,搭ResNet就只是“按层数组装”的问题了。我写一个mini版ResNet,它参考ResNet18的结构,但把每层的Block数削减,方便新手快速跑通。这个改动很有必要,因为原版ResNet18在CIFAR-10这种32x32小图上,第一层7x7卷积的stride=2会把图片直接降到16x16,特征信息丢失很多,准确率反而不如我们用3x3卷积起步的版本。
class MiniResNet(nn.Module): def __init__(self, block, num_blocks, num_classes=10): super(MiniResNet, self).__init__() self.in_channels = 64 # 输入stem:3通道变成64通道,只用一个3x3卷积,stride=1保持尺寸不变 self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) # 四个Layer,通道数逐层翻倍,分辨率在第2个Layer起减半 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 * block.expansion, num_classes) def _make_layer(self, block, out_channels, num_blocks, stride): # num_blocks代表这一层的Block数量,第一个Block负责降采样 strides = [stride] + [1] * (num_blocks - 1) layers = [] for s in strides: layers.append(block(self.in_channels, out_channels, stride=s)) self.in_channels = out_channels * block.expansion return nn.Sequential(*layers) def forward(self, x): x = self.relu(self.bn1(self.conv1(x))) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x # 用这个函数创建一个小型resnet,四个阶段Block数分别是[2, 2, 2, 2] def mini_resnet18(num_classes=10): return MiniResNet(BasicBlock, [2, 2, 2, 2], num_classes) # 实例化并验证输出尺寸 model = mini_resnet18(num_classes=10) test_input = torch.randn(1, 3, 32, 32) # 模拟一张32x32的RGB图片 output = model(test_input) print(output.shape) # 应该输出 torch.Size([1, 10])逻辑说明:这段代码对比前面SimpleCNN,最大的不同是_make_layer这个辅助函数。它负责把一个block重复num_blocks次,并且只让第一个block承担降采样任务。strides = [stride] + [1] * (num_blocks - 1)这行是精髓,比如layer2传入stride=2,num_blocks=2,那strides就是[2, 1],第一个block把分辨率减半,第二个block保持分辨率不变,但通道数已经被第一个block扩到了128,所以第二个block的in_channels和out_channels相等,走恒等shortcut。这个设计让通道数和分辨率的变化都集中在每个Layer的第一块,后面的块专心学习残差。
参数说明:num_blocks=[2,2,2,2]是参考ResNet18的比例。如果你想加深,改成[2,2,2,2]以上会明显增加参数量,但在CIFAR-10上收益不一定成正比。block.expansion这里恒等于1,所以512 * block.expansion还是512。如果你以后用Bottleneck块,expansion=4,这一行会自动变成2048,不需要手动改。
4.3 训练参数怎么调:学习率、weight decay和epoch
MiniResNet的训练代码和SimpleCNN几乎一样,但参数上有三个地方我会调整。第一是epoch数,ResNet结构比SimpleCNN深,需要更多迭代来收敛,CIFAR-10上我建议直接跑50epoch以上。第二是学习率,配合阶梯下降策略使用,常见做法是初始lr=0.1,在第30和第40个epoch时各除以10。第三是weight decay,原版ResNet论文用的是1e-4,但小模型可以放宽到5e-4,正则力度太强会让小模型欠拟合。
import torch.optim as optim model = mini_resnet18(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) # 阶梯下降学习率:分别在30和40个epoch时缩小10倍 scheduler = optim.lr_scheduler.MultiStepLR(optimizer, milestones=[30, 40], gamma=0.1) for epoch in range(50): train_loss = train_one_epoch(model, train_loader, optimizer, criterion) acc = evaluate(model, test_loader) print(f"Epoch {epoch+1:02d} | Loss {train_loss:.4f} | Test Acc {acc:.2f}%") scheduler.step() # 每个epoch后更新学习率 torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), }, './mini_resnet_checkpoint.pth')逻辑说明:MultiStepLR的作用是让学习率按里程碑跳变,而不是连续衰减。前30个epoch用大学习率快速搜索好的参数区域,30之后把学习率降到0.01,在更小的范围内精细调整,40之后再降到0.001做最后的收敛。这种策略比固定小学习率从头跑到尾效果好得多,也比余弦退火好调参。如果你在跑的时候发现测试准确率在第30个epoch前就停滞了,别慌,等学习率下降那一轮,准确率通常会跳一个台阶。
参数说明:milestones=[30, 40]是我的习惯值,不是最优,你可以按自己数据量调整比例。如果你的数据集比CIFAR-10小,比如只有5000张图,里程碑改成[15, 25]更合适。gamma=0.1的意思是把当前学习率乘以0.1,这是PyTorch的默认设定,一般不用改。scheduler.step()必须放在每个epoch结束后、打印完指标再调用,位置错了会导致学习率提前下降。
5. 搭建与训练中的5个避坑记录
5.1 现象:RuntimeError: shape ‘[128, 256, 4, 4]‘ is invalid for input of size…
这是新手最容易碰到的报错,发生在torch.flatten之后接全连接层时。原因是全连接层的输入维度写错了。比如你把输入图改成64x64,前面卷积组的输出特征图就不是4x4,而是8x8,那个固定写死的nn.Linear(256, 10)就匹配不上了。解决方法是把全连接层的输入维度改成动态计算。我一般会写一行测试代码,直接往前传一次看下feature大小。还有一种更省心的做法:在__init__里先用一个假的输入跑一次forward,拿到维度再初始化fc层。这个手法叫“哑数据初始化”,在模型搭建阶段极其好用。
def _init_fc_with_dummy_input(self, dummy_input): # 在__init__里调用,作为全连接层初始化的代替方案 with torch.no_grad(): x = self.features(dummy_input) flat_dim = x.view(x.size(0), -1).size(1) self.fc = nn.Linear(flat_dim, num_classes)原因层面说一句:这种报错的本质是“维度硬编码”问题。模型设计阶段,每层特征图的尺寸是由输入分辨率、卷积stride、padding共同决定的,人工推演容易出错,用代码跑一遍最可靠。
5.2 现象:训练时loss不下降,准确率稳定在10%左右
CIFAR-10有10个类,准确率稳定在10%说明模型“完全没学”,跟随机猜一样。常见原因有两个:第一个是学习率过大,SGD在lr=0.1时如果batch size调小了,梯度震荡会非常剧烈,loss曲线像锯齿一样不下降;第二个是数据没有归一化,忘了做Normalize,像素值在0到255之间,卷积层第一层的权重需要很长时间才能适应这么大的输入尺度。解决办法:先把标准化补上,再把lr降到0.01,跑几个epoch看loss是否开始下降。如果还不降,检查一下是不是label从1开始而模型输出从0开始,这种情况在自定义数据集里很常见,CIFAR-10不会。
5.3 现象:训练集loss正常下降,但验证集准确率很低
这是典型的过拟合信号,在简易CNN上尤其明显,因为模型参数多但CIFAR-10训练集只有5万张,足够模型“背下来”了。我的排查顺序是:第一步,加数据增强,RandomCrop和RandomHorizontalFlip是最低成本的手段;第二步,加weight_decay,从1e-4试到5e-4;第三步,降低模型复杂度,把conv3那层的通道数从256减到128。注意顺序不要反了,数据增强优先,因为它不只是抑制过拟合,还在提高数据多样性。如果你已经加了前两步还不行,模型容量可能确实过大了,直接减深度而不是加正则。
5.4 现象:残差块加上去之后,网络反而比不加更差
这个坑我踩过不只一次。原因是残差块里的BatchNorm和shortcut的交互:输入x是未归一化的,但残差分支的输出out已经经过BatchNorm了,两者相加后,结果又送进ReLU。如果shortcut没有对应的BatchNorm,或者残差块的初始权重设置不合理,相加后的分布可能偏离ReLU的激活区间。解决方法是检查shortcut分支是否同步带了BatchNorm,上面BasicBlock代码里,当channel不匹配时shortcut包了一层BatchNorm2d,这是必要的。另外,初始化也有讲究:残差块的最后一个BatchNorm的gamma可以初始化为0,这样训练开始时残差分支输出为0,整个块就是恒等映射,训练更稳定。
5.5 现象:换到GPU训练后,准确率波动比CPU大
很多人在CPU上训练得好好的,一搬到GPU上就发现结果不稳定,甚至NaN。现象背后的原因常常是batch size变化了。GPU显存大,你把batch_size从128调到了512,但学习率没变,梯度更新幅度被平均稀释,有效学习率反而变小了,收敛变慢。更严重的是,如果开了混合精度训练,某些激活函数在FP16下可能溢出。我的习惯是:搬到GPU时保持batch_size不变,只改model = model.cuda(),等跑通后再逐步调大batch并同步调学习率,学习率按batch的倍数开平方关系调整。
6. 检验你的模型是否真的学到了:三类验证技巧
6.1 用特征图可视化看卷积核在干什么
训练完后别急着收工,先验证模型到底学到了什么。我对“学没学到”的判断不看准确率,看特征图。方法很简单:把测试集里某张图喂给模型,取出第一层卷积的输出,用torch.save或者直接matplotlib画出来。你看到前几层特征是边缘、纹理,这就是正常的;如果看到一团模糊的噪声,说明模型基本没学。
import matplotlib.pyplot as plt def visualize_feature_maps(model, image_tensor): # 只取前两层卷积的输出 activations = [] def hook_fn(module, input, output): activations.append(output.detach()) handle = model.conv1.register_forward_hook(hook_fn) with torch.no_grad(): _ = model(image_tensor.unsqueeze(0)) handle.remove() # 拿第一层的前8个通道展示 feature_maps = activations[0].squeeze(0)[:8] fig, axes = plt.subplots(2, 4, figsize=(10, 5)) for i, ax in enumerate(axes.flat): ax.imshow(feature_maps[i].cpu().numpy(), cmap='viridis') ax.axis('off') plt.show()逻辑说明:这段代码用了register_forward_hook这个钩子机制,PyTorch专门用来偷看中间层输出。image_tensor不需要太大,32x32的CIFAR图就可以。squeeze(0)去掉batch维度,[:8]取通道里排在前面的8个。注意handle.remove()必须执行,否则每次前向都会触发钩子,内存越攒越多。
6.2 用预训练ResNet做迁移学习:换头微调
如果你对简易ResNet的效果不满意,常见的做法是直接用torchvision里在ImageNet上预训练好的resnet18,把最后一层全连接换成自己的分类头,然后冻结前面的层只训练新头部。这个手段能让你在小数据集上拿到大模型的收益,前提是你数据量太小,从头训练容易过拟合。
from torchvision import models model_pretrained = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # 把最后一层从1000维换成10维 model_pretrained.fc = nn.Linear(model_pretrained.fc.in_features, 10) # 冻结除fc以外的所有层 for name, param in model_pretrained.named_parameters(): if name.startswith('fc'): param.requires_grad = True else: param.requires_grad = False # 优化器只更新fc层的参数 optimizer = optim.SGD(filter(lambda p: p.requires_grad, model_pretrained.parameters()), lr=0.01, momentum=0.9)参数说明:weights=models.ResNet18_Weights.DEFAULT是PyTorch 2.0之后的标准写法,老版本用pretrained=True会弹警告说不推荐。冻结层的关键是requires_grad,设为False后该参数不计算梯度,前向时仍然参与计算,但反向时梯度不会传到它头上。filter(lambda p: p.requires_grad, ...)配合优化器使用,确保SGD不会更新冻结参数的梯度。这种情况下lr不用太大,0.01甚至0.001就够。
6.3 一个习惯:每次改结构后先跑5个epoch看loss曲线
我做了几年模型搭建,养成了一个习惯:无论模型怎么改,第一件事不是跑完整训练,而是先跑5个epoch,看loss曲线形状。loss曲线有固定的“健康形状”:前2个epoch下降很快,之后缓慢下降。如果曲线呈现“先降后升”,说明学习率太大;如果“一直不怎么降”,说明学习率太小或模型结构有问题。这个习惯帮我省掉大量跑长训练的时间。具体做法是:把训练脚本里的epoch参数临时改成5,打印每步的loss,不要等完整训练。确认loss健康后,再用完整配置跑。
最终我想起自己第一次搭ResNet时的经历:BN层用错位置、shortcut忘记处理通道数、lr没跟着batch size调整,一路翻车到深夜。后来我把每个坑都记录下来,形成了上面这套“先看关系、后写代码、再调参数、最后验证”的流程。如果你顺着这条路走,搭建和训练一个属于你自己的简易CNN和ResNet,不会超过一个周末。希望帮到你。
本文还有配套的精品资源,点击获取