
简介面向深度学习初学者与需要调参实践的读者这套资源围绕SGD和Adam优化器在卷积神经网络中的效果差异整理了一套完整的对比实验方案内容集中于图像分类任务场景。作者基于ResNet18系统探究batch size、学习率、权重初始化对模型精度的影响并在LeNet、AlexNet、ResNet18三种经典网络上对比两种优化器的训练速度与泛化表现覆盖了模型训练中最受关注的高频问题。压缩包共29个文件容量仅1.5MB包含4个Python脚本、10张实验结果图、14个txt记录文件以及1份11页的中文分析报告代码模块划分清晰图片直观呈现不同配置下的收敛曲线文档则对实验设计、结果对比和结论做了系统整理。目前已有1943人学习下载。整套资源既能支撑直接复现实验又能通过文档讲解超参数调整与优化器选择的关键思路帮助读者深入理解SGD与Adam在深层网络中的实际表现无论是课程设计、论文复现还是自学实践都很适用。1. 从换了个优化器模型就不收敛讲起同一个卷积神经网络把优化器从 SGD 换成 Adam训练曲线可能直接从稳步下降变成前期猛冲、后期震荡甚至验证准确率一路低于 SGD。这不是玄学而是两种优化器在梯度更新规则上的本质差异被 CNN 的高维参数空间放大了。这个标题要解决的就是一整套可复现的对比实验方案怎么设计公平的对照组、怎么写训练代码、怎么记录和整理结果最后让文档和代码能直接交给别人复跑。适合正在做课程设计、组内技术分享或者在图像分类任务里排查收敛问题的人。你不需要完整复现 ImageNet 级别的训练只要把 CIFAR-10 上的一组对比跑明白就能看清两者在收敛速度、泛化能力和调参敏感度上的真实差别。2. SGD 与 Adam 在 CNN 更新路径上的根本差异这一章先把理论说透。CNN 的参数空间包含卷积核权重、BatchNorm 的 scale 和 bias、全连接层矩阵不同层梯度尺度差异很大。SGD 和 Adam 对这份梯度做了完全不同的处理后再更新参数路径自然不同。2.1 从普通 SGD 到动量 SGDCNN 里为什么 momentum 几乎必开普通 SGD 的更新公式是θ θ - lr * g其中g是当前 mini-batch 的梯度。问题在于mini-batch 采样带来的梯度噪声会让参数在最优解附近来回抖动尤其在 BatchNorm 层刚初始化、梯度尺度不稳定的时候。动量momentum解决的是这个问题它维护一个速度变量v每次更新时把历史方向和当前梯度做指数加权平均v momentum * v - lr * g θ θ vmomentum一般取 0.9相当于把最近约 10 步的梯度方向做了平滑。如果连续几步梯度方向一致v会越来越大加速穿越平坦区域如果方向相反v会被抵消减少震荡。在 CNN 里卷积层的梯度经常在空间维度上呈现平滑变化动量能明显减少 loss 曲线的锯齿。实践中几乎不会用裸 SGD至少会配 momentum我的对比实验基线也会用SGD(momentum0.9)。第二个改动是 Nesterov 动量PyTorch 里 SGD 的nesterovTrue会在计算梯度前先走一步θ momentum * v再用这个近似位置的梯度去修正方向。它在 CNN 训练上通常比标准动量收敛略快但收益不如从无动量到有动量那么显著。2.2 Adam 的一阶矩与二阶矩自适应学习率的代价Adam 本质上是在动量基础上额外维护一个梯度平方的指数移动平均v_t然后对每个参数独立缩放学习率。更新公式可以简化为m_t β1 * m_{t-1} (1 - β1) * g_t v_t β2 * v_{t-1} (1 - β2) * g_t^2 θ_t θ_{t-1} - lr * m_t / (sqrt(v_t) eps)β1默认 0.9控制一阶矩的平滑程度β2默认 0.999控制二阶矩的历史窗口eps默认 1e-8防止除零。由于v_t是按梯度平方累积的梯度较大的参数会被缩小学习率梯度较小的参数会被放大这就是自适应的含义。这个机制在 CNN 上的直接后果是前几轮收敛极快因为每个参数都按照自己的尺度获得了合适的更新步长。但代价有两个。第一lr / sqrt(v_t)的缩放让更新步长对 loss 曲面的曲率不敏感容易在陡峭方向走过头、在平坦方向又停不下来第二二阶矩估计在小 batch 下有偏虽然 Adam 做了偏差校正bias correction但后期梯度平方的均值仍在持续波动导致训练后期 loss 曲线出现明显抖动。2.3 泛化差距为什么 SGD 在 CNN 上最终表现往往更好很多人在 CIFAR-10 或 ImageNet 上做过这样的实验Adam 把训练 loss 压得更低但验证集准确率不如 SGD。这个现象背后有几种机制解释。第一Adam 的权重更新等价于对每个参数施加了依赖梯度历史的隐式学习率变化这种变化会抑制参数的尖端方向相当于引入了一种各向异性的噪声而这种噪声与 SGD 中由 mini-batch 采样引入的噪声在梯度协方差结构上不同。SGD 的更新方向与真实全批量梯度方向更一致落在平坦极小值flat minima的概率更高Adam 更倾向于找到陡峭且窄的极小值泛化性能相对弱。第二weight decay 在两者中的实现方式不同。PyTorch 里SGD(weight_decaywd)是把wd * θ直接加进梯度而Adam(weight_decaywd)默认也是加进梯度但 Adam 随后会除以sqrt(v_t) eps导致实际的正则化强度被自适应缩放抵消。所以同一份weight_decay值在 SGD 和 Adam 上的效果完全不对等——这一点在写对比实验时必须注意否则你会把正则强度不公平误判成优化器差异。下面这个表可以做快速对照维度SGD含动量Adam更新规则全局同一学习率逐参数自适应收敛速度慢依赖 lr 调度前期很快泛化性通常更好往往不如同配置 SGD对 lr 敏感度非常敏感相对不敏感内存占用2 倍参数权重动量4 倍参数权重一阶二阶weight decay 效果直接生效被自适应缩放削弱这就是为什么标题里的对比实验不能简单把 optimizer 换掉就跑下一章会说明如何做公平的对照组设计。3. 卷积神经网络上的公平对比实验设计跑对比实验最容易翻车的地方不是代码写不出来而是你以为你在比较优化器实际上在比较别的变量。这一章讲清楚数据、网络结构、超参数和统计口径怎么定。3.1 为什么选 CIFAR-10 而不是更大的数据集CIFAR-10 有 6 万张 32x32 彩色图片、10 个类别适合做这个实验的原因有三个。一是训练成本可控一张普通 GPU如 RTX 3060跑 50 个 epoch 大约 15 到 30 分钟CPU 也能在几小时内跑完二是图像尺寸小CNN 结构可以做得较浅方便把变量收敛到只是优化器不同三是 CIFAR-10 的分类难度足够暴露优化器差异——太简单的任务如 MNIST任何优化器都能收敛到接近 99%对比不出差异。数据集划分要固定。直接torchvision.datasets.CIFAR10(trainTrue, downloadTrue)拿到的 5 万张图默认已经做了均匀采样训练集和测试集标签分布基本一致不需要额外划分验证集。如果你后续要做早停可以从训练集里切 10% 出来做验证集用torch.utils.data.random_split并固定generator的随机种子。3.2 网络结构浅层 CNN 加 BatchNorm排除模型能力不够的干扰网络用三层卷积加全连接层就够。太深的 ResNet 对优化器差异不明显因为残差连接缓解了梯度尺度问题太浅又很难在准确率上拉开差距。建议的结构如下import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 16x16 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 8x8 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2) # 4x4 ) self.classifier nn.Sequential( nn.Flatten(), nn.Dropout(0.3), nn.Linear(128 * 4 * 4, 256), nn.ReLU(inplaceTrue), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))卷积核、步长、填充、池化核都列在注释里。BatchNorm 必须加因为不加 BatchNorm 的裸 CNN 在 CIFAR-10 上对学习率极其敏感SGD 和 Adam 的表现会被初始化运气主导你很难归因。Dropout 放在全连接前给两个优化器相同的正则压力。模型初始化也要固定在 main 函数入口处对模型执行一次reset_parameters()或直接调model.apply(init_weights)确保 SGD 和 Adam 两组从完全相同的初始权重出发。3.3 超参数表哪些必须一致哪些应该不同公平对比有两种口径我推荐你写成两组实验都跑。口径一两个优化器用完全相同的超参lr0.01、batch size128、epoch50、weight decay1e-4、无 learning rate scheduler口径二分别用各自常用的默认超参SGD lr0.1, momentum0.9Adam lr1e-3各做一次简单的 lr 扫描后选各自最优结果再对比。口径一能说明同样的超参下优化器的行为差异口径二能说明实际使用时哪个更容易调到比较好的结果。标题里写的是结果对比实验所以两种口径都记录但代码里只需要支持从命令行传入优化器类型和 lr。# config.py from dataclasses import dataclass dataclass class Config: dataset_root: str ./data batch_size: int 128 epochs: int 50 lr: float 0.01 # SGD 建议先跑 0.1Adam 建议 1e-3 momentum: float 0.9 weight_decay: float 1e-4 optimizer: str sgd # sgd / adam seed: int 42 log_dir: str ./logs表格里需要记录的参数项参数SGDAdam说明learning rate0.11e-3两个优化器的有效尺度差异最大momentum / betas0.9(0.9, 0.999)两者都有动量机制但含义不同weight decay1e-41e-4必须相同但注意 Adam 实际效果更弱batch size128128必须一致影响梯度噪声epochs5050必须一致最好再用 lr schedulelr schedulecosinecosine后期衰减很重要需要强调的一点上一节那个允许 lr 不一致的结论容易被人理解成随便设置学习率所以在实验文档里要把两种情况分开记录并对为什么采用不同 lr写明理由这样代码整理出来才有说服力。4. 可复现的完整代码与结果整理流程第三节的设计要落到代码上。核心训练脚本需要做到支持--optimizer sgd和--optimizer adam自动生成带时间戳的日志目录每个 epoch 记录训练 loss、训练准确率、验证准确率到 CSV。下面给出主训练循环的完整结构。4.1 训练循环以 optimizer 为参数的主入口import argparse, csv, os import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms from model import SimpleCNN from config import Config def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for inputs, targets in loader: inputs, targets inputs.to(device), targets.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, targets) loss.backward() optimizer.step() total_loss loss.item() * inputs.size(0) correct (outputs.argmax(1) targets).sum().item() total targets.size(0) return total_loss / total, correct / total torch.no_grad() def evaluate(model, loader, device): model.eval() correct, total 0, 0 for inputs, targets in loader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) correct (outputs.argmax(1) targets).sum().item() total targets.size(0) return correct / total def main(): parser argparse.ArgumentParser() parser.add_argument(--optimizer, choices[sgd, adam], requiredTrue) parser.add_argument(--lr, typefloat, defaultNone) args parser.parse_args() cfg Config() lr args.lr if args.lr is not None else (0.1 if args.optimizer sgd else 1e-3) device torch.device(cuda if torch.cuda.is_available() else cpu) torch.manual_seed(cfg.seed) ...这段代码里train_one_epoch返回该 epoch 的平均 loss 和 top-1 准确率evaluate在测试集上评测不计算梯度。主函数里根据--optimizer分支创建优化器if args.optimizer sgd: optimizer torch.optim.SGD( model.parameters(), lrlr, momentumcfg.momentum, weight_decaycfg.weight_decay) else: optimizer torch.optim.Adam( model.parameters(), lrlr, betas(0.9, 0.999), weight_decaycfg.weight_decay)优化器创建后把 lr、optimizer 名称、seed 写进一个meta.json随训练过程一起保存。这样 logs 目录里每个实验都是自描述的后面做文档整理时不需要回头猜参数。4.2 每轮记录和 checkpoint 保存训练过程中需要保存三类东西CSV 指标文件、模型权重、训练配置。训练主循环里这样写log_dir os.path.join(cfg.log_dir, f{args.optimizer}_lr{lr}_seed{cfg.seed}) os.makedirs(log_dir, exist_okTrue) csv_path os.path.join(log_dir, metrics.csv) csv_file open(csv_path, w, newline) writer csv.writer(csv_file) writer.writerow([epoch, train_loss, train_acc, test_acc, lr]) best_acc 0.0 for epoch in range(1, cfg.epochs 1): train_loss, train_acc train_one_epoch( model, train_loader, optimizer, criterion, device) test_acc evaluate(model, test_loader, device) scheduler.step() current_lr optimizer.param_groups[0][lr] writer.writerow([epoch, f{train_loss:.4f}, f{train_acc:.4f}, f{test_acc:.4f}, f{current_lr:.6f}]) csv_file.flush() if test_acc best_acc: best_acc test_acc torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), epoch: epoch, acc: best_acc, }, os.path.join(log_dir, best.pt))注意两个细节。第一csv_file.flush()保证意外中断时已写入的数据不会丢先落盘再继续训练第二checkpoint 里同时保存模型参数和优化器 state_dictSGD 的动量和 Adam 的一阶二阶矩都带着后续做从断点恢复才有意义否则恢复后优化器状态全零相当于重头开始对比实验的连续性就破坏了。训练结束后把 best.pt 记录的acc和最后一轮test_acc都打印出来python train.py --optimizer sgd --lr 0.1 python train.py --optimizer adam --lr 1e-3建议两组实验各跑 3 个不同 seed统计mean ± std。单次跑出来的结果说服力不足因为 CIFAR-10 上的随机波动能达到 0.5 到 1 个百分点如果两组准确率差距在 1% 以内必须靠多次重复判断。4.3 结果记录模板让对比文档保持同一口径训练结束后把每个 seed 的结果汇总成下面的表。注意这个表是空白模板你要用自己的数据填充而不是我替你预设结果——这也是整理文档时最容易犯错的地方直接抄别人的结论数字导致实验记录失真。优化器lrseed 1 最佳准确率seed 2 最佳准确率seed 3 最佳准确率平均 ± 标准差SGD momentum0.1SGD momentum0.01Adam1e-3Adam5e-4除了记录最佳准确率还要额外保存两个指标训练到验证准确率不再提升的 epoch早停时刻和训练结束后对 checkpoint 重新跑同一测试集的结果。前者反映收敛速度后者排除保存时刚好碰上测试集波动高点的偶然性。这两个值会直接写进 README 的分析段落里。4.4 画图脚本把两张 loss 曲线叠在一张图上对比文档没有图就很干。准备一个独立的plot_results.py读两个实验目录下的metrics.csvimport pandas as pd import matplotlib.pyplot as plt fig, axes plt.subplots(1, 2, figsize(10, 4)) for opt_name, color in [(sgd, tab:blue), (adam, tab:orange)]: path flogs/{opt_name}_lr0.1_seed42/metrics.csv # 按你的实际路径改 df pd.read_csv(path) axes[0].plot(df[epoch], df[train_loss], colorcolor, labelopt_name) axes[1].plot(df[epoch], df[test_acc], colorcolor, labelopt_name) axes[0].set_title(Training Loss) axes[1].set_title(Test Accuracy) axes[0].legend(); axes[1].legend() plt.tight_layout() plt.savefig(compare.png, dpi150)画图时用train_loss而不是eval loss并且把 Adam 的 loss 曲线用半透明粗线表示——因为 Adam 第二条曲线抖动更频繁半透明能展示趋势而不是噪声点。compare.png直接放进 README 的实验结果章节。5. 收尾结果验证方法与文档整理的几个实用技巧实验跑完只是第一步。把文档代码整理这件事做好才能在复盘、答辩或交接时经得起追问。验证可复现性核心是三件事固定三个随机种子PyTorch、NumPy、Python random、关闭 cudnn 的自动 tuning、固定数据加载器的 worker 随机种子。线性分类层初始化差异造成的噪声往往大于 0.5%不固定 seed 的实验对比没有可信度。建议在 main 开头这样写import random random.seed(cfg.seed) import numpy as np np.random.seed(cfg.seed) torch.manual_seed(cfg.seed) torch.cuda.manual_seed_all(cfg.seed) if device.type cuda: torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsecudnn.benchmark False会损失一点训练速度但换来的是 cuDNN 算法选择路径保持一致否则每次训练可能在卷积实现上选不同的算法结果对不齐。第二个技巧是记录学习率调度器的状态。用 cosine schedule 时每轮 lr 由当前 epoch 决定不需要存但如果将来换成ReduceLROnPlateau它的内部状态必须跟着 checkpoint 一起保存。另外要注意Adam 的param_groups[0][lr]和 scheduler 里的 lr 是两个值打印时以 optimizer 里的为准。第三个技巧是文档整理的目录结构。建议代码仓库里包含train.py、model.py、config.py、plot_results.py、README.md以及跑完生成的logs/和compare.png。README 里写四段如何运行、超参设置表、结果表、分析结论。分析结论不要在文档里写Adam 更好或SGD 更好而是写该模型、该数据集、该超参条件下SGD 在验证集上高 X 个百分点Adam 提前 Y 个 epoch 达到相近水平同时附上未调参的原始结果。这也是区分对比实验整理和论文式结论的关键把实验条件一并展示让看文档的人能做判断。实验数据永久保存在 logs 目录下不要每次重跑覆盖。比如logs/sgd_lr0.1_seed42/metrics.csv这个路径本身就携带了完整参数信息比单独维护一份 Excel 记录更不容易漏。换数据集或网络结构时用同样的目录命名规则生成新的 log 目录这样plot_results.py可以直接指定目录对比不用改代码。如果你的对照组比较多可以用一个简单的 shell 脚本顺序跑完所有组合for opt in sgd adam; do if [ $opt sgd ]; then lr0.1; else lr1e-3; fi for seed in 42 123 2024; do python train.py --optimizer $opt --lr $lr --seed $seed done done最后再提醒一次训练完成后把 optimizer 的state_dict和模型一起保存。恢复训练时如果丢了优化器状态SGD 的动量缓冲区会被清零Adam 的一阶二阶矩也要重头估计后半段训练曲线会跟完整训练完全不同——这个细节在论文复现和代码整理里经常被忽略但它直接影响实验的可复现性。本文还有配套的精品资源点击获取