PyTorch从零训练ResNet18,CIFAR-10准确率95.46%实战解析
简介一份面向深度学习初学者、图像分类竞赛玩家及开发者的PyTorch项目资源核心解决了在不使用任何预训练模型参数的前提下仅依靠数据增强与网络结构修改让ResNet18在Cifar10测试集上达到95.46%准确率的问题。资源完整覆盖了从数据读取、Cutout增强、模型定义到训练和测试的五个Python脚本各模块职责分明压缩包仅7KB代码量小巧且精要便于快速阅读、修改和迁移到其他数据集。该方案曾在Kaggle Cifar10竞赛中接受30万张超大训练集的检验同样保持95.46%准确率对理解数据增强手段、损失函数影响、超参数调整以及从零复现经典网络均有直接帮助。目前已有2351人学习下载适合希望从底层掌握卷积网络训练流程、想快速复现高精度基线并二次开发的读者可作为入门进阶或竞赛参考的实用工具包。包含完整可运行代码和必要的注释可直接在本地或云端环境复现实验也可替换为自己的数据集进行迁移学习。1. 从0训练ResNet18到95.46%为什么不用预训练模型也够打把Cifar10测试集准确率跑到95.46%听起来像是个需要预训练模型才能完成的任务。但用Pytorch从零初始化ResNet18不加载任何权重文件只靠随机梯度下降在50000张训练图上迭代这个数字完全可达。这件事的价值不在于“干翻预训练”而在于它证明了残差结构、数据增强和学习率调度三者配合时即使在30万参数级别的网络上也能学到足够强的特征。这个实验适合三类人刚入门Pytorch、想验证自己是否真正理解ResNet的读者做嵌入式部署、不想引入预训练权重体积和许可证约束的工程师以及长期被“不微调就达不到高精度”困扰、想确认数据管线是否靠谱的算法同学。本文按数据准备、网络搭建、训练配置、踩坑记录、进阶复现一条线走完所有命令和配置都能直接抄到项目里。2. 数据管线Cifar10下载、归一化与增强的组合2.1 数据集加载与归一化把像素从0-255拉到0附近Cifar10的原始图像是32x32的RGB三通道图直接用原始亮度喂给ResNet18会导致训练早期梯度不稳定因为ReLU之后特征向量的方差会随时间累积。所以第一步是归一化到均值为0、方差为1的分布。torchvision.datasets.Cifar10封装了下载逻辑但下载过程经常卡在服务器连接上下面这个写法把root指定到本地目录一旦下载中断重跑时会尽量复用已存在的部分文件。import torch import torchvision import torchvision.transforms as transforms data_dir ./data/cifar10 transform_base transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean(0.4914, 0.4822, 0.4465), # Cifar10训练集的RGB均值 std(0.2470, 0.2435, 0.2616) # RGB标准差 ) ]) train_set torchvision.datasets.CIFAR10( rootdata_dir, trainTrue, downloadTrue, transformtransform_base ) test_set torchvision.datasets.CIFAR10( rootdata_dir, trainFalse, downloadTrue, transformtransform_base ) train_loader torch.utils.data.DataLoader( train_set, batch_size128, shuffleTrue, num_workers8, pin_memoryTrue, drop_lastTrue ) test_loader torch.utils.data.DataLoader( test_set, batch_size256, shuffleFalse, num_workers8, pin_memoryTrue )这里最关键的是target transform也用了相同的Normalize参数因为测试集应该走和训练集完全一样的预处理。root建议用绝对路径避免工程在不同工作目录下启动时重复下载。num_workers在Linux上给8Windows上给0或者2否则子进程队列容易报错。drop_lastTrue保证每个batch都是128张避免最后一个batch太小导致BatchNorm统计不稳定。2.2 训练增强随机裁剪、水平翻转和Cutout是涨点主力上面的代码只有归一化直接跑ReNet18从零训练验证集最高只能到92%左右。要冲击95.46%必须在训练集上做三种改名随机裁剪到32x32、随机水平翻转、随机的橡皮擦RandomErasing。因为Cifar10本身只有32x32像素很多人误以为“已经够小不需要增强”但实际上正是这种小尺寸让模型更容易记住背景噪声增强的作用比在ImageNet上更明显。import random import torchvision.transforms.functional as F class Cutout(object): 随机擦除一个固定大小的矩形区域并补零。 def __init__(self, n_holes1, length16): self.n_holes n_holes self.length length def __call__(self, img): h, w img.size(1), img.size(2) mask torch.ones((h, w), dtypetorch.float32) for _ in range(self.n_holes): y_center random.randint(0, h - 1) x_center random.randint(0, w - 1) y1 max(0, y_center - self.length // 2) y2 min(h, y_center self.length // 2) x1 max(0, x_center - self.length // 2) x2 min(w, x_center self.length // 2) mask[y1:y2, x1:x2] 0 img img * mask return img 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)), Cutout(n_holes1, length16) ])RandomCrop的padding4表示先把原图向外补4圈0像素再随机裁剪回32x32这样相当于引入了2像素以内的平移。Cutout擦除16x16的区域迫使网络不要只依赖某一小块局部特征。这段代码直接写在训练集的transform_train中验证集继续用transform_base千万不要把增强套在验证集上否则测试指标会虚高。3. 从零搭ResNet18把残差块写出来不碰torchvision里那个预训练入口3.1 BasicBlockCifar10上不需要三层瓶颈两层就够了ResNet18在ImageNet上是标准的三层结构但Cifar10图像只有32x32第一层7x7卷积会损失大量空间信息所以从业者常用变体把第一层7x7 stride2换成3x3 stride1。网上某些从零训练Cifar10的实现直接调用torchvision内置ResNet18再reload成随机权重这虽然也算“不加载预训练”但代码里还遗留了最大池化和7x7卷积导致准确率上不去。正确做法是自己实现BasicBlock。import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): expansion 1 def __init__(self, in_planes, planes, stride1): super(BasicBlock, self).__init__() self.conv1 nn.Conv2d( in_planes, planes, kernel_size3, stridestride, padding1, biasFalse ) self.bn1 nn.BatchNorm2d(planes) self.conv2 nn.Conv2d( planes, planes, kernel_size3, stride1, padding1, biasFalse ) self.bn2 nn.BatchNorm2d(planes) self.shortcut nn.Sequential() if stride ! 1 or in_planes ! planes: self.shortcut nn.Sequential( nn.Conv2d(in_planes, planes, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(planes) ) def forward(self, x): out F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.shortcut(x) out F.relu(out) return out这里conv层统一不设bias因为后面紧跟BatchNormBias会被BN层的平移参数抵消掉留着只会增加参数。shortcut在通道数不变且stride1时是恒等映射只有尺寸变化时才用1x1卷积调整。注意这里的stride对Cifar10变体非常重要从layer1到layer2时feature map从32降到16shortcut必须用1x1卷积且stride2否则维度对不上forward直接报错。3.2 ResNet主干换成第一层3x3卷积精确复现95.46%的常见设置完整的ResNet18由四层残差块组成每层重复BasicBlock的次数分别是2、2、2、2。为了适配Cifar10我在torchvision原版基础上改掉两处第一层用3x3卷积步长1去掉stem后接的MaxPool。这样第一个卷积层输出是32x32而非16x16后面每层减半后正好在最后得到4x4特征图全局平均池化后为512维向量。class ResNet18_Cifar10(nn.Module): def __init__(self, num_classes10): super(ResNet18_Cifar10, self).__init__() self.in_planes 64 self.conv1 nn.Conv2d(3, 64, kernel_size3, stride1, padding1, biasFalse) self.bn1 nn.BatchNorm2d(64) self.layer1 self._make_layer(BasicBlock, 64, 2, stride1) self.layer2 self._make_layer(BasicBlock, 128, 2, stride2) self.layer3 self._make_layer(BasicBlock, 256, 2, stride2) self.layer4 self._make_layer(BasicBlock, 512, 2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) def _make_layer(self, block, planes, num_blocks, stride): strides [stride] [1] * (num_blocks - 1) layers [] for s in strides: layers.append(block(self.in_planes, planes, strides)) self.in_planes planes * block.expansion return nn.Sequential(*layers) def forward(self, x): out F.relu(self.bn1(self.conv1(x))) out self.layer1(out) out self.layer2(out) out self.layer3(out) out self.layer4(out) out self.avgpool(out) out out.view(out.size(0), -1) out self.fc(out) return out在实例化时不需要任何pretrainedTrue。想确认网络确实从零开始可以打印第一个卷积权重范数新生成的Conv2d默认权重是均匀分布随机数范数通常近似一个小常数如果是预训练权重第一个卷积的权重会带有某种结构化特征。fc层的bias默认是存在的不需要额外处理。4. 训练配置与超参SGD加余弦退火是95%准确率的地基4.1 优化器与lossmomentum0.9和weight_decay5e-4是主流基线从零训练ResNet18优化器首选SGD而不是Adam。原因是Cifar10每类只有5000张训练图Adam的自适应学习率会放大前期噪声容易让小数据集的训练在早期就陷入尖锐局部极小。PyTorch官方仓库里面的经典Cifar10示例也是用SGD我沿用了那套配置momentum0.9、weight_decay5e-4。weight_decay不能省它就是L2正则能防止512维全连接层记住噪声。import torch.optim as optim model ResNet18_Cifar10(num_classes10).cuda() criterion nn.CrossEntropyLoss() optimizer optim.SGD( model.parameters(), lr0.1, momentum0.9, weight_decay5e-4, nesterovTrue )nesterov设为True其实是在普通momentum基础上多算一步梯度会带来零点几个百分点的提升但代价是每个step多一次反向传播计算。对于ResNet18和Cifar10这种规模显存和算力都能承受。如果你用的是小显存显卡建议改成False优先保住batch size不要为了这个选项牺牲batch。4.2 学习率调度5个epoch热身再余弦退火到零lr0.1对随机初始化的ResNet18来说偏大如果从第一个iteration就这样跑loss可能会在前期震荡甚至变成NaN。常见做法是前5个epoch从0线性增到0.1之后用余弦退火曲线逐渐降到0。下面这段代码不用额外装包纯Pytorch也能写一个LambdaLR做热身再串联一个CosineAnnealingLR注意后者起始学习率会自动前者的末尾值。import math def warmup_cosine_lambda(epoch): if epoch 5: return (epoch 1) / 5.0 # 0.2, 0.4, ..., 1.0 else: # 余弦退火从1.0降到0.001总250个epoch progress (epoch - 5) / (250 - 5) return 0.001 0.5 * (1 math.cos(math.pi * progress)) * (0.999) scheduler torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambdawarmup_cosine_lambda)这个lambda函数本身就是一个多项式分段在epoch5时返回1.0即lr0.1epoch250时返回0.001。余弦退火的目的是让模型在后期以很小的步长慢慢把loss压到最低点。注意这里我故意没有调用CosineAnnealingLR类因为LambdaLR更容易搭配后面要说的“阶段性验证”。4.3 完整训练循环保存验证集上最优的checkpoint有了数据、网络和调度器训练循环本身没什么神秘但有个细节训练时model.train()验证时model.eval()而且验证必须包在torch.no_grad()里。最容易被忽略的是保存条件不能只看最后一个epoch而要看验证集top1准确率是否超过历史最大值这个best_ckpt才是你测试集上真正拿到95.46%的那个模型。best_acc 0.0 for epoch in range(250): model.train() running_loss 0.0 for inputs, labels in train_loader: inputs, labels inputs.cuda(), labels.cuda() optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) scheduler.step() model.eval() correct 0 total 0 with torch.no_grad(): for inputs, labels in test_loader: inputs, labels inputs.cuda(), labels.cuda() outputs model(inputs) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() acc 100.0 * correct / total print(fEpoch {epoch}: Acc {acc:.2f}%) if acc best_acc: best_acc acc torch.save({ epoch: epoch, model_state_dict: model.state_dict(), acc: acc, }, ./best_resnet18_cifar10.pth)这段循环中scheduler.step()必须放在每个epoch结束后而不是每个batch后。如果你在某个epoch只看了一次验证集的acc发现它低于上一epoch别急着回滚因为余弦退火会让lr变化很慢单epoch的波动不代表整体趋势。我自己习惯把best_ckpt的路径写成一个绝对路径变量放在脚本开头的config字典里因为后续评估和部署都要引用同一个checkpoint。5. 避坑与常见问题从0训练翻车现场整理5.1 学习率0.1直接让loss爆炸成NaN现象前几个iteration loss就变成nan训练无法继续。原因随机初始化的网络输出分布方差大softmax交叉熵在初始阶段会出现极端概率梯度乘以lr0.1后让权重一步跳到无效区域。解决给每个batch的loss做梯度裁剪或者更根本的是把warmup从5个epoch加到10个epoch。我推荐后者因为梯度裁剪会掩盖真实问题。5.2 训练到第50个epoch验证集仍然卡在85%左右现象训练loss在下降但验证准确率一直不涨。原因大概率是数据增强只写了BasicBlock里的RandomCrop和HorizontalFlip但忘记了Cutout。Cifar10从零训练非常吃增强强烈依赖空间遮挡带来的泛化。解决把第2.2节的Cutout加上并确认它在RandomCrop之后、Normalize之前。如果还是上不去检查是否用了原版ImageNet的ResNet18结构第一层7x7步长2会直接丢掉一半空间信息。5.3 batch size设成32后验证准确率波动剧烈现象loss曲线更平滑但验证集top1像心电图一样跳。原因BatchNorm在64张以内的统计不够稳定测试时使用的是训练期间累计的running_mean和running_var小batch会让这两个统计量偏移。解决batch size至少64最好128这几乎不影响单卡显存。如果你真因为显存受限只能32就把BatchNorm换成GroupNorm或在最后几个epoch冻结BN。5.4 下载Cifar10数据集时总是连接超时现象脚本第一次跑卡在Downloading进度条不动然后报URLError。原因torchvision默认从国外镜像下载网络路径不稳定。解决手动用浏览器访问https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz下载后解压到data/cifar10目录并把downloadFalse。这时PyTorch读到的就是本地数据不再发起网络请求。5.5 测试时忘记model.eval()导致准确率比预期低很多现象训练完加载checkpoint直接在测试集循环里用model.predict准确率只有80%。原因模型还在training模式BatchNorm会使用当前batch的均值和方差而当前batch数据没按训练分布来算同时Cutout和RandomCrop如果还在transform里也会干扰。解决加载checkpoint后第一次forward之前一定调用model.eval()并确保测试集的transform里没有随机增强。6. 冲95.46%的最后几招EMA模型和可复现的评估习惯95.46%这个数字不是靠碰运气出来的它通常出现在用200到300个epoch、余弦退火到接近0的SGD训练之后。如果你使用的配置和上面完全一致最终约在94.5%到94.8%之间。剩下那零点几个百分点我给你两个从业者常用的手段。第一用指数移动平均EMA替代最终模型参数。EMA的思路是保存一个shadow_model每个iter更新为shadow 0.999 * shadow (1 - 0.999) * online_model验证时用shadow_model而非在线模型。这个操作能消除SGD后期在局部极小附近的震荡把测试集准确率稳定抬升0.2到0.3。实现很简单不需要改动训练循环只需额外定义一份shadow state_dict。shadow_model ResNet18_Cifar10(num_classes10).cuda() shadow_model.load_state_dict(model.state_dict()) def update_ema(shadow, online, alpha0.999): with torch.no_grad(): for s_param, o_param in zip(shadow.parameters(), online.parameters()): s_param.data.mul_(alpha).add_(o_param.data, alpha1 - alpha)第二固定随机种子和CUDA确定性。我在脚本最开始设置torch.manual_seed(0)、torch.cuda.manual_seed(0)和cudnn.benchmark False这样每次复现得到的准确率波动会小很多。测试集评估时我习惯对每个测试batch做一次模型forward但绝不对测试集做多次推理取argmax投票——那已经属于提准确率的另一种训练技巧不属于“测试集准确率”的正常定义。我踩过硬凑准确率的坑有一次为了把数字从94.3%提到95%偷偷在训练前让模型看一遍测试集做feature scaling结果数字确实好看了但项目上线后换一批真实图片就崩了。后来养成一个习惯心态上只把95.46%当方向实际验收时单独跑一个固定精度脚本确保模型参数、数据预处理、随机种子全部一致才敢把这个准确率报给团队。希望这段经验能帮你在追求指标的路上一路走稳。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →