资讯中心

ResNet就是CNN:PyTorch从零搭建残差网络实战与避坑

📅 2026/9/28 2:53:39
ResNet就是CNN:PyTorch从零搭建残差网络实战与避坑
简介简易卷积神经网络与残差网络搭建项目面向深度学习初学者与进阶开发者提供纯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如VGGResNet基本单元卷积激活池化堆叠残差块主路径shortcut是否使用卷积是是是否使用池化是是卷积层内常配合stride2降采样深度上限十几层再深易退化几十到上百层靠残差块缓解退化梯度流动只依赖反向传播逐层回传提供逐层回传之外的“快速通道”设计动机手工调结构拟合目标让深层网络不因过深而退化这张表想说明的判断是ResNet的一切变化都围绕“让CNN更深”而不是“让CNN变成另一种东西”。你在用PyTorch写代码时普通CNN的卷积层代码和ResNet残差块里的卷积层代码完全一样区别只在是否有那条shortcut分支。所以接下来我会先写一个简易CNN再到它的基础上升级成简易ResNet这样你能直观看到“diff”在哪里。3. 用PyTorch从零搭一个简易CNNCIFAR-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 搭建简易CNN3个卷积组加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_classes10): super(SimpleCNN, self).__init__() # 卷积组1输入3通道RGB输出64通道特征图尺寸不变 self.conv1 nn.Sequential( nn.Conv2d(3, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 尺寸减半 ) # 卷积组2通道数翻倍到128尺寸再减半 self.conv2 nn.Sequential( nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) # 卷积组3通道数再翻倍到256 self.conv3 nn.Sequential( nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) # 全局平均池化 全连接分类头 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内准确率就明显爬升。关于inplaceTrue这里说一句它表示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, 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)), ]) # 第一次跑需要下载CIFAR-10大约160MB train_set datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtransform_train) test_set datasets.CIFAR10(root./data, trainFalse, downloadTrue, transformtransform_test) train_loader torch.utils.data.DataLoader(train_set, batch_size128, shuffleTrue, num_workers2) test_loader torch.utils.data.DataLoader(test_set, batch_size128, shuffleFalse, num_workers2) # 实例化模型、损失函数、优化器 model SimpleCNN(num_classes10) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-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(fEpoch {epoch1: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_size128是我在8GB内存的MacBook上试过不溢出的值如果你显存或内存紧张调到64也行但要注意调小batch后学习率最好也等比例调小否则收敛不稳。lr0.1配合momentum0.9在CIFAR-10上是经典配置不建议新手改。num_workers2在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是1Bottleneck是4 expansion 1 def __init__(self, in_channels, out_channels, stride1): super(BasicBlock, self).__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) 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_size1, stridestride, biasFalse), 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里的stridestride是降采样的入口。比如某层第一个Block的stride2那么特征图尺寸减半后面所有Block都保持stride1。conv2固定stride1不在第二个卷积里做降采样这是ResNet的标准做法原因是如果两个卷积都降采样信息丢失太严重。biasFalse是因为后面跟着BatchNormbias在这里是多余的BN会做归一化操作有没有bias计算结果几乎一致而且去掉bias能省参数。4.2 从CNN到ResNet替换主干、保留分类头有了BasicBlock搭ResNet就只是“按层数组装”的问题了。我写一个mini版ResNet它参考ResNet18的结构但把每层的Block数削减方便新手快速跑通。这个改动很有必要因为原版ResNet18在CIFAR-10这种32x32小图上第一层7x7卷积的stride2会把图片直接降到16x16特征信息丢失很多准确率反而不如我们用3x3卷积起步的版本。class MiniResNet(nn.Module): def __init__(self, block, num_blocks, num_classes10): super(MiniResNet, self).__init__() self.in_channels 64 # 输入stem3通道变成64通道只用一个3x3卷积stride1保持尺寸不变 self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) # 四个Layer通道数逐层翻倍分辨率在第2个Layer起减半 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 * 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, strides)) 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_classes10): return MiniResNet(BasicBlock, [2, 2, 2, 2], num_classes) # 实例化并验证输出尺寸 model mini_resnet18(num_classes10) 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传入stride2num_blocks2那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块expansion4这一行会自动变成2048不需要手动改。4.3 训练参数怎么调学习率、weight decay和epochMiniResNet的训练代码和SimpleCNN几乎一样但参数上有三个地方我会调整。第一是epoch数ResNet结构比SimpleCNN深需要更多迭代来收敛CIFAR-10上我建议直接跑50epoch以上。第二是学习率配合阶梯下降策略使用常见做法是初始lr0.1在第30和第40个epoch时各除以10。第三是weight decay原版ResNet论文用的是1e-4但小模型可以放宽到5e-4正则力度太强会让小模型欠拟合。import torch.optim as optim model mini_resnet18(num_classes10) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) # 阶梯下降学习率分别在30和40个epoch时缩小10倍 scheduler optim.lr_scheduler.MultiStepLR(optimizer, milestones[30, 40], gamma0.1) for epoch in range(50): train_loss train_one_epoch(model, train_loader, optimizer, criterion) acc evaluate(model, test_loader) print(fEpoch {epoch1: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]更合适。gamma0.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在lr0.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(), cmapviridis) 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(weightsmodels.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()), lr0.01, momentum0.9)参数说明weightsmodels.ResNet18_Weights.DEFAULT是PyTorch 2.0之后的标准写法老版本用pretrainedTrue会弹警告说不推荐。冻结层的关键是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不会超过一个周末。希望帮到你。本文还有配套的精品资源点击获取

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案