
1. 从零手写ResNet18为什么我不建议你直接调库刚入门深度学习那会儿我总觉得调包是理所当然的事。torchvision.models.resnet18(pretrainedTrue)一行代码就能拿到一个现成的残差网络何必自己费劲去写直到有一次我需要把ResNet18的BasicBlock改成带注意力机制的结构才发现如果连残差块内部长什么样都说不清楚根本无从下手。从那次之后我就养成了一个习惯任何一个经典网络我都要自己手写一遍不追求性能超越官方实现只求把每一层的来龙去脉搞清楚。这篇文章要聊的就是用PyTorch从零实现ResNet18的完整过程。不是简单地把官方代码复制粘贴过来而是把每个设计决策背后的逻辑讲透——为什么残差块要设计成两层卷积而不是一层shortcut连接什么时候需要做下采样BatchNorm放在卷积前面还是后面这些细节官方代码不会告诉你但恰恰是面试和实际项目中真正拉开差距的地方。适合谁来读如果你已经了解卷积神经网络的基本概念知道卷积层、池化层、全连接层是干什么的但还没有自己完整搭建过一个深层网络那这篇文章就是为你准备的。如果你已经用过ResNet但说不清楚它为什么能解决退化问题这篇文章也能帮你把知识盲区补上。我会尽量用大白话把原理讲清楚同时给出可以直接运行的完整代码你跟着敲一遍对残差网络的理解会上一个台阶。2. 残差网络到底解决了什么问题2.1 深层网络的退化困境在ResNet出现之前深度学习领域有一个让人困惑的现象网络层数增加到一定程度后训练误差反而上升了。注意这里说的不是过拟合导致的验证误差上升而是训练集上的误差本身就变大了。这意味着模型连训练数据都拟合不好显然不是过拟合的问题。这个问题被称为退化问题。按常理推断一个56层的网络应该至少能表现得和20层网络一样好——因为多出来的36层哪怕什么都不做只是恒等映射也不应该让效果变差。但实际训练中让多层非线性变换去逼近一个恒等映射难度远超想象。梯度在反向传播过程中经过层层衰减到了浅层几乎消失殆尽参数更新变得极其缓慢。我做过一个简单的对比实验用同样的数据集分别训练一个20层的普通卷积网络和一个56层的普通卷积网络前者训练loss能降到0.1以下后者在0.5左右就震荡不降了。这不是因为56层网络容量不够恰恰相反是优化难度太大了。2.2 残差学习的核心思想ResNet的解决方案非常优雅既然让网络学习恒等映射很难那就换个思路让网络学习残差。具体来说假设我们希望某几层学到的映射是H(x)传统做法是让这几层直接拟合H(x)。ResNet的做法是让这几层拟合F(x) H(x) - x然后最终的输出变成F(x) x。这个改动的精妙之处在于如果最优的映射就是恒等映射那么网络只需要把F(x)的权重全部推向零就行了这比让一堆非线性层去逼近恒等映射容易得多。用生活化的类比来说传统网络像是让你从零开始画一幅画而残差网络像是给你一张底稿你只需要在上面做修改。修改到什么都不改就是恒等映射这显然更容易。从梯度传播的角度看残差连接提供了一条“高速公路”。反向传播时梯度可以通过shortcut连接直接传到浅层不需要经过中间那些卷积层的层层衰减。这就是为什么ResNet可以训练到上百层甚至上千层而普通网络几十层就训不动了。2.3 ResNet18的网络结构总览ResNet18是ResNet家族中最浅的成员之一18这个数字指的是网络中带有权重的层数——包括卷积层和全连接层不包括池化层和BatchNorm层。具体来说它包含1个7x7的初始卷积层、8个BasicBlock每个Block含2个3x3卷积层共16个卷积层、以及1个全连接层加起来正好18层。整个网络可以分成五个阶段初始卷积阶段、四个残差阶段。初始阶段用一个7x7的大卷积核快速降低特征图尺寸然后每个残差阶段由若干个BasicBlock组成特征图的空间尺寸逐阶段减半通道数逐阶段翻倍。最后通过全局平均池化和全连接层输出分类结果。这个结构设计有几个关键考量。7x7卷积核虽然参数量大但在网络最前面输入通道只有3计算量其实可控而且大感受野有助于捕捉图像的全局信息。四个阶段的通道数分别是64、128、256、512每次翻倍是为了在空间信息减少的同时增加通道容量保持信息的总体表达能力。全局平均池化替代了传统的多层全连接大幅减少了参数量也降低了过拟合风险。3. 手写ResNet18的核心组件拆解3.1 BasicBlock的设计细节BasicBlock是ResNet18的基本构建单元结构并不复杂但有几个容易踩坑的地方。它的主路径是3x3卷积 → BatchNorm → ReLU → 3x3卷积 → BatchNorm然后在输出处与shortcut相加再过一次ReLU。第一个坑是卷积的padding。3x3卷积要保持特征图尺寸不变padding必须设为1。这个不用死记公式是输出尺寸 (输入尺寸 2×padding - kernel_size) / stride 1。代入kernel_size3、stride1、padding1输出尺寸等于输入尺寸。如果padding设成0每经过一个卷积特征图就缩小2网络还没到深处就没了。第二个坑是BatchNorm的位置。标准做法是卷积之后、ReLU之前。BatchNorm放在卷积后面可以对卷积输出做归一化让数据分布更稳定。如果放在ReLU后面被ReLU截断的负值部分就无法被归一化修正了。另外BatchNorm的momentum参数默认是0.1但在小批量训练时可能需要调小这个后面会细说。第三个坑是shortcut的处理。当输入输出通道数相同且特征图尺寸不变时shortcut可以直接用恒等映射。但当通道数需要翻倍或者特征图需要减半时shortcut必须做一个1x1卷积来匹配维度同时用stride2来同步降采样。这个1x1卷积后面也要接BatchNorm但不需要ReLU。import torch import torch.nn as nn class BasicBlock(nn.Module): 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.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.shortcut nn.Sequential() 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 self.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.shortcut(x) out self.relu(out) return out注意卷积层都设置了biasFalse因为后面紧跟着BatchNormBatchNorm的beta参数会起到偏置的作用再加一个卷积偏置是冗余的只会浪费参数。3.2 下采样与通道匹配的逻辑什么时候需要给shortcut加卷积判断条件就两个stride不等于1或者输入通道数不等于输出通道数。在ResNet18中每个残差阶段的第一块需要下采样stride设为2同时通道数翻倍。后面的块保持stride1、通道数不变shortcut就是纯恒等映射。这里有一个细节值得展开为什么下采样用stride2的1x1卷积而不是池化因为池化操作会丢失信息而且池化后的特征图与主路径经过stride2卷积后的特征图在空间位置上可能对不齐。用1x1卷积配合stride2既能完成降采样又能学习到一个线性变换来匹配通道数信息损失更小。还有一个容易忽略的点主路径的第一个3x3卷积也用了stride2这样主路径和shortcut路径的空间降采样是同步的相加时特征图尺寸才能对齐。如果主路径降采样了而shortcut没降相加就会报维度错误。3.3 网络整体架构的搭建有了BasicBlock搭建ResNet18就是按部就班的事了。先定义一个_make_layer方法负责堆叠指定数量的BasicBlock然后按照[2, 2, 2, 2]的配置构建四个阶段。class ResNet18(nn.Module): def __init__(self, num_classes1000): super(ResNet18, self).__init__() self.in_channels 64 self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) self.layer1 self._make_layer(64, 2, stride1) self.layer2 self._make_layer(128, 2, stride2) self.layer3 self._make_layer(256, 2, stride2) self.layer4 self._make_layer(512, 2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) def _make_layer(self, out_channels, num_blocks, stride): strides [stride] [1] * (num_blocks - 1) layers [] for s in strides: layers.append(BasicBlock(self.in_channels, out_channels, s)) self.in_channels out_channels 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.layer4(x) x self.avgpool(x) x torch.flatten(x, 1) x self.fc(x) return x初始卷积的padding设为3因为kernel_size7、stride2代入公式(224 6 - 7) / 2 1 112正好把224x224的输入降到112x112。接着maxpool再降一半到56x56然后四个残差阶段分别输出56、28、14、7的空间尺寸。这个尺寸变化链条是固定的输入不是224x224的话最后全局平均池化之前会得到不同的尺寸但AdaptiveAvgPool2d会自动适配所以网络对输入尺寸有一定灵活性。4. 训练配置与实操全流程4.1 数据准备与增强策略ResNet18原论文是在ImageNet上训练的输入尺寸224x224。如果你用自己的数据集不一定非要缩放到224但建议至少保持128以上否则经过五次降采样后特征图太小信息损失严重。数据增强方面训练阶段我通常用随机裁剪加随机水平翻转。随机裁剪的scale范围设在0.08到1.0之间ratio在3/4到4/3之间这是比较通用的配置。颜色抖动可以加但强度不要太大否则可能破坏图像的关键特征。验证阶段只做中心裁剪或者直接resize不做随机变换。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.08, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])Normalize的均值和标准差是ImageNet的统计值如果你在自己的数据集上训练建议先算一下自己数据集的均值和方差替换掉这两个值。这个操作看起来不起眼但对训练稳定性的影响不小。4.2 学习率与优化器的选择ResNet18的训练我一般用SGD加动量momentum0.9weight_decay1e-4。学习率初始值设0.1配合余弦退火或者阶梯下降。为什么不用AdamAdam在训练初期收敛快但在ResNet这类结构上最终精度往往不如调好的SGD。SGD的噪声更大反而有助于找到更平坦的极小值泛化性能更好。学习率调度我用余弦退火比较多公式是lr lr_min 0.5 × (lr_max - lr_min) × (1 cos(π × epoch / total_epochs))。这个调度方式在训练后期学习率下降得很平滑不会像阶梯下降那样突然跳变导致loss震荡。optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max200, eta_min1e-6)Batch size方面如果有条件用大batch比如256或512训练会更稳定。但大batch通常需要配合更大的学习率有一个经验公式是lr 0.1 × batch_size / 256。如果显存不够只能用32或64的小batch学习率要相应调小同时BatchNorm的momentum可以适当调低到0.01让统计量更新更平滑。4.3 训练循环与关键监控指标训练循环本身不复杂但有几个监控指标必须盯着。第一个是训练loss和验证loss的走势如果训练loss持续下降但验证loss开始上升说明过拟合了需要加正则化或者早停。第二个是学习率的变化曲线确保调度器按预期工作。第三个是梯度范数如果梯度范数突然变得很大可能是遇到了梯度爆炸需要检查数据中是否有异常样本。def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for inputs, labels in dataloader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc验证阶段记得加torch.no_grad()不然会白白消耗显存。另外如果用了BatchNorm验证时的batch size不能太小至少要有8以上否则统计量估计不准验证精度会异常偏低。这个问题我踩过坑当时验证集batch size设成1结果验证精度比训练精度低了十几个百分点排查了半天才发现是BatchNorm的锅。4.4 模型保存与恢复训练保存模型时我习惯同时保存模型参数、优化器状态、当前epoch和学习率。这样万一训练中断可以无缝恢复不用从头再来。只保存state_dict而不是整个模型对象是因为state_dict更轻量也不依赖具体的类定义。checkpoint { epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), best_acc: best_acc } torch.save(checkpoint, fresnet18_epoch{epoch}.pth)恢复训练时先创建模型和优化器实例再加载state_dict。注意scheduler的状态也要恢复否则学习率会从头开始影响训练连续性。5. 常见问题与排查技巧实录5.1 训练不收敛的排查思路训练不收敛是新手最常遇到的问题表现是loss居高不下或者震荡剧烈。排查顺序我一般是这样先检查数据标签有没有对错这个听起来很蠢但确实经常发生然后看学习率是不是太大了把学习率缩小10倍试试再检查BatchNorm的momentum和batch size是否匹配最后看权重初始化有没有问题。ResNet的权重初始化PyTorch默认用Kaiming初始化对ReLU激活函数是合适的。但如果你自己改了激活函数比如换成GELU初始化方式也要相应调整。另外shortcut上的1x1卷积如果初始化不当可能导致残差分支的输出过大或过小影响训练稳定性。5.2 显存不足的优化手段ResNet18本身参数量不大大概1100万左右但训练时的显存占用主要来自中间激活值。如果显存不够有几个立竿见影的手段减小batch size、用混合精度训练、用梯度累积模拟大batch。混合精度训练在PyTorch里用torch.cuda.amp就能实现代码改动很小显存占用能降低30%到50%训练速度也能提升。但要注意混合精度下BatchNorm的统计量可能不够精确如果发现精度下降可以把BatchNorm层强制转回float32。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for inputs, labels in dataloader: optimizer.zero_grad() with autocast(): outputs model(inputs) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()梯度累积的思路是多次前向传播后再统一更新参数相当于变相增大了batch size。比如实际batch size是32累积4次就等效于128。但要注意BatchNorm的统计量是按实际batch size算的梯度累积不能改变这一点所以如果BatchNorm效果不好梯度累积解决不了根本问题。5.3 过拟合的应对策略ResNet18在小型数据集上很容易过拟合训练精度冲到99%但验证精度卡在70%上不去。应对手段按优先级排数据增强、权重衰减、Dropout、早停。数据增强是最有效的尤其是随机裁剪和颜色抖动能显著增加数据的多样性。权重衰减我一般设1e-4如果过拟合严重可以加到5e-4。Dropout在ResNet里通常加在全连接层之前但原论文其实没怎么用Dropout因为BatchNorm本身就有正则化效果。如果加了Dropout发现训练变慢可以试试把Dropout率从0.5降到0.3。早停是最简单粗暴的验证loss连续10个epoch不下降就停。但早停需要你有独立的验证集而且验证集的分布要和测试集一致否则可能停早了或停晚了。5.4 迁移学习的正确打开方式用自己的数据集训练ResNet18时从零开始往往不如用预训练权重微调。微调有两种策略只训练最后的全连接层或者全部层都微调但用较小的学习率。只训练全连接层适合数据集很小几千张以内且和ImageNet分布接近的情况。全部微调适合数据集较大几万张以上或者和ImageNet差异较大的情况。全部微调时前面的层学习率要设小一些后面的层可以大一些因为浅层学的是通用特征不需要大改。model ResNet18(num_classes10) pretrained_dict torch.load(resnet18_pretrained.pth) model_dict model.state_dict() pretrained_dict {k: v for k, v in pretrained_dict.items() if k in model_dict and fc not in k} model_dict.update(pretrained_dict) model.load_state_dict(model_dict) for param in model.parameters(): param.requires_grad True optimizer torch.optim.SGD([ {params: model.conv1.parameters(), lr: 0.001}, {params: model.layer1.parameters(), lr: 0.001}, {params: model.layer2.parameters(), lr: 0.005}, {params: model.layer3.parameters(), lr: 0.01}, {params: model.layer4.parameters(), lr: 0.01}, {params: model.fc.parameters(), lr: 0.05} ], momentum0.9, weight_decay1e-4)这种分层学习率的设置核心逻辑是浅层特征更通用不需要大改深层特征更任务相关需要更大调整。实测下来分层学习率比统一学习率能提升2到3个百分点的精度。5.5 常见问题速查表问题现象可能原因排查方法解决方案训练loss不下降学习率过大或过小打印每层梯度范数调整学习率加warmup验证精度远低于训练精度过拟合对比训练验证loss曲线加数据增强、权重衰减、早停验证精度异常低BatchNorm统计量不准检查验证batch size增大验证batch size显存溢出batch size过大打印显存占用减小batch size或混合精度梯度爆炸学习率过大或数据异常监控梯度范数梯度裁剪、降低学习率训练速度慢数据加载瓶颈检查GPU利用率增加num_workers、预取数据精度比官方低很多预处理不一致对比官方预处理流程统一Normalize参数和输入尺寸6. 从ResNet18延伸出去的几个方向ResNet18虽然结构简单但它是理解更复杂网络的基石。搞懂了BasicBlock再去看BottleneckBlock就很容易了——无非是把两个3x3卷积换成了1x1、3x3、1x1的瓶颈结构目的是在更深的网络里减少计算量。ResNet50、ResNet101用的都是BottleneckBlock。另一个方向是把残差连接的思想用到其他任务上。比如目标检测里的FPN特征金字塔的横向连接本质上也是一种残差式的信息融合。再比如Transformer里的残差连接虽然结构不同但核心思想是一致的给梯度提供一条直接通路让深层网络更容易训练。如果你想进一步提升ResNet18的性能可以试试这几个改动把ReLU换成GELU或SiLU在残差块里加入SE注意力模块或者用GroupNorm替代BatchNorm来适应小batch训练。这些改动我都试过SE模块带来的提升最明显大概能涨1到2个百分点但参数量和计算量也会相应增加。最后分享一个我调试网络时的小习惯每次改完结构先在一个很小的子集上跑几个epoch确认loss能正常下降、梯度没有异常再放到全量数据上训练。这个习惯帮我省了很多时间避免在错误的结构上浪费算力。