从零手动搭建ResNet18:PyTorch实战与CNN架构深度解析
1. 项目概述从零搭建ResNet18的实战意义最近在整理自己的深度学习项目库发现很多早期写的模型代码结构比较混乱尤其是ResNet这种经典网络虽然PyTorch官方有现成的实现但直接import torchvision.models然后调参总感觉少了点“手感”。对于想深入理解卷积神经网络架构特别是残差连接Residual Connection这个核心思想的朋友来说亲自动手从零搭建一遍绝对是性价比最高的学习方式。这次我就以ResNet18为例抛开torchvision完全手动顺序搭建一个并附上从数据准备、模型训练到测试评估的全套代码。整个过程更像是一次“代码解剖”你会清晰地看到每一个卷积层、每一个BatchNorm层、每一个ReLU激活函数是如何堆叠起来以及残差块是如何解决梯度消失、让网络得以加深的。无论你是刚入门PyTorch的新手还是想巩固基础的中级开发者跟着走一遍不仅能得到一个可以运行的ResNet18更能建立起对现代CNN架构的直觉。2. 核心思路与设计考量2.1 为什么选择手动搭建ResNet18ResNet18结构清晰层数适中是理解残差网络最合适的起点。手动搭建的目的绝不是为了重复造轮子而是为了达成几个关键目标理解架构细节官方实现往往经过高度优化和封装内部逻辑对初学者是个黑盒。手动搭建迫使你去查阅原始论文理解每一个超参数如卷积核大小、步长、填充的设置原因以及各个模块如BasicBlock的输入输出维度变化。掌握PyTorch模块化编程学习如何将网络结构拆解为可重用的子模块如BasicBlock并用nn.Sequential或自定义nn.Module来组织。这是构建复杂模型的基础能力。调试与掌控力当模型出现梯度爆炸、损失不下降等问题时如果你对每一层的输出形状、参数数量都了如指掌排查问题的效率会高得多。自己写的代码哪里可能出问题心里更有数。2.2 方案选型顺序搭建 vs 模块化搭建在PyTorch中搭建模型主要有两种风格一种是使用nn.Sequential将层按顺序堆叠另一种是自定义nn.Module类来定义更复杂的逻辑。对于ResNet18我将采用一种混合策略主干部分使用nn.Sequential来定义每一组layer内的多个BasicBlock这样代码简洁明了。残差块BasicBlock自定义一个BasicBlock类因为它内部包含了一个捷径连接shortcut需要在forward函数中实现x identity(x)的操作这不是简单的顺序流。整体网络自定义一个ResNet18类它将nn.Sequential和自定义模块组合起来形成完整的网络。这种方式的优势在于既保证了代码的清晰度又灵活处理了残差连接这一非顺序结构。2.3 环境准备与工具选型工欲善其事必先利其器。一个稳定、高效的开发环境能避免很多后续麻烦。PyTorch版本推荐使用较新的稳定版如1.13或2.0。新版本通常有更好的性能、更完善的API和社区支持。你可以通过pip install torch torchvision安装。安装时务必注意与CUDA版本的对应关系如果你有NVIDIA显卡并希望使用GPU加速需要安装对应CUDA版本的PyTorch。例如CUDA 11.8对应torch的安装命令可能是pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118。集成开发环境IDEPyCharm、VS Code或Jupyter Notebook均可。我个人偏好VS Code其Python插件和调试功能非常强大对于查看中间变量形状特别方便。辅助工具torchsummary或torchinfo库必不可少。它们可以一键打印出模型的层次结构、每层的输出形状和参数总量是验证我们搭建是否正确的“照妖镜”。使用前用pip install torchsummary安装。注意如果你在安装PyTorch时遇到速度慢或失败的问题强烈建议配置国内镜像源如清华源、阿里云源。对于conda安装可以修改.condarc文件对于pip可以使用-i参数指定镜像地址。这能节省大量时间。3. ResNet18网络结构深度解析3.1 总览与分阶段设计ResNet18的名称来源于网络深度为18层仅计算带参数的层卷积层和全连接层。其核心设计原则是堆叠多个“残差块”并在几个特定阶段改变特征图的尺寸和通道数。整个网络可以划分为以下几个部分初始卷积层一个较大的卷积核7x7配合较大步长2和填充3对输入图像进行初步的下采样和特征提取。最大池化层进一步压缩空间尺寸。四个阶段Stage每个阶段由多个残差块构成。ResNet18的四个阶段分别包含2, 2, 2, 2个残差块。在每个阶段的第一个残差块中通常会通过步长为2的卷积进行下采样同时通道数翻倍。全局平均池化与全连接层将三维特征图压缩为一维向量并映射到最终的分类类别数。3.2 残差块BasicBlock的奥秘这是ResNet的灵魂。一个BasicBlock包含两条路径主路径通常由两个3x3的卷积层构成每层后接BatchNorm和ReLU。第一个卷积层有时会设置步长stride2以实现下采样。捷径连接如果输入和输出的维度通道数、高、宽一致则捷径连接就是恒等映射identity直接将输入加到主路径的输出上。如果不一致通常发生在每个阶段的第一个块需要下采样和增加通道数则捷径连接需要一个1x1的卷积层同样配合BatchNorm来调整维度使其能与主路径输出相加。这种“跳跃连接”的结构使得网络可以学习残差函数F(x) H(x) - x而非直接学习复杂的原始映射H(x)。论文作者认为学习残差比学习原始映射更容易。当残差为0时该层就相当于恒等映射这保证了网络加深时性能至少不会退化。3.3 维度变化与参数计算理解数据在网络中的流动形状至关重要。假设输入图像为(3, 224, 224)RGB通道高224宽224初始卷积后经过一个7x7卷积64个滤波器stride2, padding3输出形状为(64, 112, 112)。计算公式输出尺寸 floor((输入尺寸 2*padding - kernel_size) / stride) 1。最大池化后3x3池化stride2输出形状为(64, 56, 56)。Stage1两个BasicBlock输入输出均为(64, 56, 56)。Stage2第一个BasicBlock的主路径第一个卷积stride2将尺寸减半为(128, 28, 28)同时捷径连接的1x1卷积将通道数从64提升到128。Stage3 Stage4同理输出形状依次变为(256, 14, 14)和(512, 7, 7)。全局平均池化后将每个通道的7x7特征图取平均得到(512, 1, 1)然后展平为512维的向量。全连接层将512维向量映射到目标类别数例如ImageNet是1000类。手动计算并验证这些维度是确保网络搭建正确的关键一步。一个形状不匹配的错误会在运行时直接导致程序崩溃。4. 手动顺序搭建ResNet18代码实现4.1 定义BasicBlock模块首先我们实现最核心的残差块。这里我将详细解释每一行代码的意图。import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): expansion 1 # 扩展系数BasicBlock不扩展通道数BottleneckBlock会扩展为4倍 def __init__(self, in_channels, out_channels, stride1, downsampleNone): 初始化BasicBlock。 Args: in_channels: 输入特征图的通道数。 out_channels: 主路径中卷积层输出的通道数也是该块的最终输出通道数。 stride: 第一个卷积层的步长。通常为1块内或2每个Stage的第一个块用于下采样。 downsample: 一个可调用模块nn.Sequential当捷径连接需要调整维度时使用。默认为None恒等映射。 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) # inplaceTrue可以节省少量内存 # 主路径的第二个卷积层 self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.downsample downsample self.stride stride def forward(self, x): identity x # 保存输入用于后续的捷径连接 # 主路径前向传播 out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) # 捷径连接处理 if self.downsample is not None: identity self.downsample(x) # 如果需要调整输入的维度 # 核心操作残差相加 out identity out self.relu(out) # 相加后再经过一次激活 return out关键点解析biasFalse在卷积层后紧跟了BatchNorm层BatchNorm本身包含可学习的偏移参数因此卷积层的bias是冗余的设为False可以减少参数并可能提升训练稳定性。downsample参数它是一个nn.Module。当stride!1或in_channels ! out_channels * expansion时我们需要创建这个模块。它通常是一个包含1x1卷积和BatchNorm的nn.Sequential。forward中的加法out identity这是PyTorch张量的原位加法非常高效。它要求out和identity的形状完全一致。4.2 构建完整的ResNet18类现在我们用定义好的BasicBlock来组装整个网络。class ResNet18(nn.Module): def __init__(self, blockBasicBlock, layers[2, 2, 2, 2], num_classes1000): 初始化ResNet18。 Args: block: 残差块类型这里我们使用BasicBlock。 layers: 一个包含4个整数的列表指定每个Stage包含的block数量。ResNet18是[2,2,2,2]。 num_classes: 分类任务的类别数。 super(ResNet18, self).__init__() self.in_channels 64 # 初始通道数经过第一个卷积层后变为64 # 初始部分 self.conv1 nn.Conv2d(3, self.in_channels, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(self.in_channels) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) # 四个Stage self.layer1 self._make_layer(block, 64, layers[0], stride1) self.layer2 self._make_layer(block, 128, layers[1], stride2) self.layer3 self._make_layer(block, 256, layers[2], stride2) self.layer4 self._make_layer(block, 512, layers[3], stride2) # 分类头 self.avgpool nn.AdaptiveAvgPool2d((1, 1)) # 自适应平均池化输出固定为(1,1) self.fc nn.Linear(512 * block.expansion, num_classes) # 权重初始化可选但推荐 self._initialize_weights() def _make_layer(self, block, out_channels, blocks, stride): 构建一个包含多个残差块的Stage。 Args: block: 残差块类。 out_channels: 该Stage中每个block的输出通道数。 blocks: 该Stage包含的block数量。 stride: 该Stage第一个block的步长。 Returns: 一个nn.Sequential容器包含该Stage的所有block。 downsample None # 判断是否需要下采样模块捷径连接需要调整维度 if stride ! 1 or self.in_channels ! out_channels * block.expansion: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels * block.expansion, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels * block.expansion), ) layers [] # 添加该Stage的第一个block可能包含下采样 layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels out_channels * block.expansion # 更新输入通道数供后续block使用 # 添加该Stage剩余的blockstride默认为1无下采样 for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) return nn.Sequential(*layers) # 将列表解包传入nn.Sequential def _initialize_weights(self): 使用Kaiming初始化He初始化来初始化卷积层和线性层的权重。 这对于使用ReLU激活函数的网络很重要有助于缓解梯度消失/爆炸。 for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) if m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.constant_(m.bias, 0) def forward(self, x): x self.conv1(x) x self.bn1(x) x self.relu(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) # 将(B, C, 1, 1)展平为(B, C) x self.fc(x) return x代码要点与心得_make_layer方法是构建的核心它智能地处理了每个Stage第一个块的特殊性可能需要downsample并批量创建后续的普通块。这种设计模式使得构建ResNet-34, -50等变体变得非常容易只需修改layers参数即可。AdaptiveAvgPool2d((1,1))这是一个非常实用的层。它可以将任意空间尺寸的输入池化为固定的1x1大小。这意味着我们的网络可以接受不同尺寸的输入图像如224x224或256x256而无需修改网络结构或全连接层的参数。这比固定的全局平均池化更灵活。权重初始化虽然PyTorch的默认初始化在多数情况下工作良好但对于深层网络采用Kaiming初始化是更稳妥的做法尤其当你不确定后续的调优策略时。这算是一个提升模型收敛稳定性的“小技巧”。4.3 模型验证与结构查看搭建完成后第一件事就是验证模型是否能正确运行并查看其结构。# 实例化模型 model ResNet18(num_classes10) # 假设我们做一个10分类任务如CIFAR-10 # 创建一个随机输入张量模拟一个批次的图像 dummy_input torch.randn(4, 3, 224, 224) # batch_size4, 3通道224x224 # 前向传播测试 try: output model(dummy_input) print(f模型前向传播成功输出形状: {output.shape}) # 应为 torch.Size([4, 10]) except Exception as e: print(f前向传播失败错误信息: {e}) # 使用torchsummary查看模型详情 from torchsummary import summary summary(model, input_size(3, 224, 224), devicecpu)运行summary后你会看到一份详细的报告包括每层的类型、输出形状、参数数量。请仔细核对总的参数数量是否与理论值约1100万接近每个Stage的输出形状是否与我们之前计算的(64,56,56),(128,28,28)... 一致所有层的连接是否正确有没有出现维度不匹配的警告这是确保你的手动搭建没有低级错误的最有效方法。5. 训练代码实战以CIFAR-10为例模型搭好了接下来让它“学”起来。我们选用经典的CIFAR-10数据集它包含10类32x32的彩色小图片非常适合快速验证模型。5.1 数据准备与预处理import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader def get_dataloaders(batch_size128, num_workers4): 创建训练和测试数据加载器。 Args: batch_size: 每个批次的样本数。 num_workers: 用于数据加载的子进程数。 Returns: train_loader, test_loader # 定义数据预处理流程 transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), # 随机裁剪数据增强 transforms.RandomHorizontalFlip(), # 随机水平翻转数据增强 transforms.ToTensor(), # 转换为Tensor并归一化到[0,1] transforms.Normalize((0.4914, 0.4822, 0.4465), # CIFAR-10的RGB均值 (0.2023, 0.1994, 0.2010)) # CIFAR-10的RGB标准差 ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) ]) # 下载并加载数据集 trainset torchvision.datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtransform_train) train_loader DataLoader(trainset, batch_sizebatch_size, shuffleTrue, num_workersnum_workers) testset torchvision.datasets.CIFAR10(root./data, trainFalse, downloadTrue, transformtransform_test) test_loader DataLoader(testset, batch_sizebatch_size, shuffleFalse, num_workersnum_workers) # 类别名称 classes (plane, car, bird, cat, deer, dog, frog, horse, ship, truck) return train_loader, test_loader, classes预处理详解RandomCrop和RandomHorizontalFlip是训练时常用的数据增强手段通过对训练图像进行随机变换来增加数据的多样性可以有效减轻过拟合提升模型泛化能力。测试时绝对不要使用任何随机变换必须保证评估的一致性。Normalize标准化处理。用数据集的均值和标准差将像素值从[0,1]调整到大约[-1,1]的范围。这能加速模型收敛并使优化过程更稳定。这里的数值是CIFAR-10数据集预计算好的。5.2 训练循环与验证import torch.optim as optim import time def train_model(model, train_loader, test_loader, device, epochs50, lr0.1): 训练模型的主函数。 model.to(device) # 定义损失函数和优化器 criterion nn.CrossEntropyLoss() # 使用带动量的SGD这是训练CNN的经典选择。权重衰减L2正则化有助于防止过拟合。 optimizer optim.SGD(model.parameters(), lrlr, momentum0.9, weight_decay5e-4) # 学习率调度器在训练到一定epoch时将学习率乘以gamma例如0.1这是训练ResNet的标准技巧。 scheduler optim.lr_scheduler.MultiStepLR(optimizer, milestones[30, 45], gamma0.1) best_acc 0.0 train_losses, train_accs, test_accs [], [], [] for epoch in range(epochs): print(f\nEpoch {epoch1}/{epochs}) print(- * 60) # 训练阶段 model.train() running_loss 0.0 correct 0 total 0 start_time time.time() for batch_idx, (inputs, targets) in enumerate(train_loader): inputs, targets inputs.to(device), targets.to(device) # 清零梯度 optimizer.zero_grad() # 前向传播 outputs model(inputs) loss criterion(outputs, targets) # 反向传播 loss.backward() # 参数更新 optimizer.step() # 统计 running_loss loss.item() _, predicted outputs.max(1) total targets.size(0) correct predicted.eq(targets).sum().item() # 每100个batch打印一次进度 if (batch_idx 1) % 100 0: print(f Batch {batch_idx1}/{len(train_loader)}, Loss: {loss.item():.4f}) epoch_loss running_loss / len(train_loader) epoch_acc 100. * correct / total train_losses.append(epoch_loss) train_accs.append(epoch_acc) epoch_time time.time() - start_time print(fTraining - Loss: {epoch_loss:.4f}, Acc: {epoch_acc:.2f}%, Time: {epoch_time:.2f}s) # 验证阶段 test_acc evaluate_model(model, test_loader, device) test_accs.append(test_acc) # 保存最佳模型 if test_acc best_acc: best_acc test_acc torch.save(model.state_dict(), resnet18_cifar10_best.pth) print(f ** Best model saved with test acc: {best_acc:.2f}% **) # 更新学习率 scheduler.step() print(f Current LR: {scheduler.get_last_lr()[0]:.6f}) print(f\nTraining finished. Best test accuracy: {best_acc:.2f}%) return train_losses, train_accs, test_accs def evaluate_model(model, test_loader, device): 在测试集上评估模型精度。 model.eval() correct 0 total 0 with torch.no_grad(): # 关闭梯度计算节省内存和计算资源 for inputs, targets in test_loader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) _, predicted outputs.max(1) total targets.size(0) correct predicted.eq(targets).sum().item() acc 100. * correct / total print(fTesting - Acc: {acc:.2f}%) return acc训练技巧与心得优化器选择对于像ResNet这样的CNN带动量Momentum的SGD通常比Adam表现更好尤其是在ImageNet/CIFAR这类标准数据集上。Adam虽然收敛快但最终精度有时略逊于精调过的SGDMomentum。学习率调度MultiStepLR是关键。训练深度网络时我们通常不会使用固定的学习率。在训练中期和后期大幅降低学习率如乘以0.1可以让模型在损失平原上继续“微调”找到更优的解。milestones[30, 45]是一个经验值表示在第30和45个epoch后衰减学习率。model.train()和model.eval()这两个模式切换至关重要。train()会启用Dropout、BatchNorm的训练行为使用当前批次的统计量而eval()会固定Dropout和BatchNorm的统计量使用训练阶段累积的移动平均。在验证和测试时忘记切换为eval()模式会导致结果不一致且通常更差。torch.no_grad()在验证和测试时使用这个上下文管理器可以显著减少内存消耗并加速计算因为它告诉PyTorch不要构建计算图。5.3 主训练脚本将以上所有部分整合并加入设备选择。if __name__ __main__: # 设置随机种子保证可复现性 torch.manual_seed(42) if torch.cuda.is_available(): torch.cuda.manual_seed_all(42) # 选择设备 device torch.device(cuda if torch.cuda.is_available() else cpu) print(fUsing device: {device}) # 获取数据 batch_size 128 train_loader, test_loader, classes get_dataloaders(batch_sizebatch_size) # 实例化模型 model ResNet18(num_classes10).to(device) # 开始训练 train_losses, train_accs, test_accs train_model( model, train_loader, test_loader, device, epochs50, lr0.1 ) # 可以在这里添加绘制损失/准确率曲线的代码 # import matplotlib.pyplot as plt # ... (绘图代码)6. 测试与模型推理代码训练完成后我们需要用独立的测试集评估最终性能并编写一个方便使用的推理函数。6.1 加载最佳模型进行最终测试def final_test(model_path, test_loader, device, num_classes10): 加载保存的最佳模型在测试集上进行最终评估并打印分类报告。 # 重新实例化模型结构 model ResNet18(num_classesnum_classes).to(device) # 加载训练好的权重 model.load_state_dict(torch.load(model_path, map_locationdevice)) model.eval() all_preds [] all_targets [] test_loss 0.0 criterion nn.CrossEntropyLoss() with torch.no_grad(): for inputs, targets in test_loader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) loss criterion(outputs, targets) test_loss loss.item() _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_targets.extend(targets.cpu().numpy()) avg_loss test_loss / len(test_loader) accuracy 100. * (np.array(all_preds) np.array(all_targets)).sum() / len(all_targets) print(f\n Final Test Results ) print(fTest Loss: {avg_loss:.4f}) print(fTest Accuracy: {accuracy:.2f}%) # 打印更详细的分类报告需要sklearn try: from sklearn.metrics import classification_report report classification_report(all_targets, all_preds, target_namesclasses) print(\nClassification Report:) print(report) except ImportError: print(scikit-learn not installed, skipping detailed classification report.) # 计算并打印混淆矩阵可选 # from sklearn.metrics import confusion_matrix # cm confusion_matrix(all_targets, all_preds) # print(cm)6.2 单张图片推理函数在实际应用中我们更常需要对单张图片进行预测。from PIL import Image def predict_single_image(image_path, model, device, transform, class_names): 对单张图片进行预测。 Args: image_path: 图片文件路径。 model: 加载好权重的模型。 device: 计算设备。 transform: 与训练时测试集相同的预处理流程。 class_names: 类别名称列表。 # 加载和预处理图像 image Image.open(image_path).convert(RGB) # 确保为RGB三通道 input_tensor transform(image).unsqueeze(0) # 增加batch维度 - (1, C, H, W) input_tensor input_tensor.to(device) model.eval() with torch.no_grad(): output model(input_tensor) # 获取概率使用softmax probabilities torch.nn.functional.softmax(output[0], dim0) # 获取最可能的类别 predicted_class_idx torch.argmax(probabilities).item() predicted_prob probabilities[predicted_class_idx].item() predicted_label class_names[predicted_class_idx] print(fPredicted: {predicted_label} (index: {predicted_class_idx}) with probability: {predicted_prob:.4f}) # 可以打印Top-K的预测结果 topk_prob, topk_idx torch.topk(probabilities, 3) print(Top-3 predictions:) for i in range(3): print(f {class_names[topk_idx[i].item()]}: {topk_prob[i].item():.4f}) return predicted_label, predicted_prob使用示例# 假设你已经有了训练好的模型和测试集的transform model ResNet18(num_classes10).to(device) model.load_state_dict(torch.load(resnet18_cifar10_best.pth)) model.eval() # 使用测试集的transform注意不要用训练集的因为包含了随机增强 transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) ]) # 对一张图片进行预测 predict_single_image(my_cat_image.jpg, model, device, transform_test, classes)7. 常见问题、调试技巧与避坑指南在实际手动搭建和训练过程中你几乎一定会遇到下面这些问题。这里记录了我踩过的坑和解决方法。7.1 维度不匹配错误这是手动搭建网络时最常遇到的错误通常发生在残差相加 (out identity) 或层与层连接的时候。错误信息RuntimeError: The size of tensor a (128) must match the size of tensor b (64) at non-singleton dimension 1。排查步骤使用torchsummary这是第一道防线。仔细检查每一层的输出形状特别是每个Stage的输入和输出。打印中间变量在forward函数中可疑的位置插入print(x.shape)运行一个批次的前向传播观察形状变化在哪里出现了偏差。重点检查downsample确保在每个Stage的第一个BasicBlock中当stride!1或通道数变化时downsample模块被正确创建并使用了1x1卷积来匹配维度。核对计算公式回顾卷积输出尺寸公式(W - K 2P) / S 1确保你的stride,padding设置与论文或标准实现一致。7.2 损失不下降或准确率极低如果训练一开始损失就居高不下或者准确率随机乱猜10分类约10%可能是以下原因数据预处理错误最常见的问题之一。检查Normalize使用的均值和标准差是否正确。一个快速验证方法是在transform后打印几张图片的像素值范围看看是否在[-2, 2]左右而不是[0, 255]或[0, 1]。模型权重初始化问题尝试使用我们代码中的_initialize_weights方法进行Kaiming初始化替换掉默认初始化。学习率过大或过小学习率是超参数之首。太大可能导致损失NaN太小则下降缓慢。可以从0.01或0.1开始尝试配合学习率调度器。标签错误确保数据加载器返回的targets是整数类型的类别索引并且范围在[0, num_classes-1]内。可以用print(trainset[0][1])查看一个样本的标签。忘记zero_grad()在loss.backward()之前必须调用optimizer.zero_grad()来清空上一轮迭代的梯度否则梯度会累积导致训练不稳定。7.3 训练过程震荡或不稳定Batch Size的影响Batch Size太小可能导致梯度估计噪声大训练曲线震荡。在显存允许的情况下适当增大Batch Size如128, 256通常能使训练更稳定。同时增大Batch Size后可能也需要适当调高学习率。梯度爆炸/消失虽然ResNet通过残差连接很大程度上缓解了此问题但仍可能发生。症状是损失突然变成NaN。可以尝试梯度裁剪在optimizer.step()之前添加torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)。使用更小的学习率。再次检查网络结构确保残差连接正确无误。BatchNorm层的问题在训练初期BatchNorm层估计的均值和方差可能不准确。可以尝试在第一个epoch使用较小的学习率进行“热身”Warm-up或者检查是否在验证/测试时错误地使用了model.train()模式。7.4 过拟合与欠拟合过拟合训练精度远高于测试精度。对策增加数据增强强度如RandomRotation,ColorJitter在模型中添加Dropout层尽管原版ResNet没有增强权重衰减weight_decay系数使用更早的停止策略Early Stopping。欠拟合训练精度和测试精度都很低。对策减少正则化降低weight_decay增加模型容量尝试ResNet34训练更多轮次检查是否模型结构存在严重错误导致无法学习。7.5 GPU内存不足CUDA out of memory减小Batch Size这是最直接有效的方法。使用梯度累积如果不想减小Batch Size影响优化效果可以累积多个小批次的梯度后再进行一次参数更新。这相当于模拟了大Batch Size。accumulation_steps 4 optimizer.zero_grad() for i, (inputs, targets) in enumerate(train_loader): outputs model(inputs) loss criterion(outputs, targets) / accumulation_steps # 损失按累积步数平均 loss.backward() if (i1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()使用混合精度训练使用torch.cuda.amp进行自动混合精度训练可以显著减少显存占用并可能加速训练。但这需要额外的代码包装对新手稍复杂。检查内存泄漏确保在验证循环中使用了with torch.no_grad()并且没有在循环中不必要地累积张量。手动搭建并训练一个ResNet18的过程就像完成一次精密的机械组装。每一个零件层都必须严丝合缝整个系统训练流程才能顺畅运转。当你看到自己从零写出的模型在测试集上的准确率稳步提升最终达到一个不错的结果例如在CIFAR-10上超过90%时那种成就感是直接调用torchvision.models.resnet18()无法比拟的。这份代码和记录不仅仅是一个可运行的模型更是一个理解深度卷积网络设计哲学的路线图。你可以尝试修改BasicBlock的结构调整通道数甚至模仿它去搭建ResNet34、50你会发现一切都有迹可循。