PyTorch手写优化器:从梯度下降到Adam实战对比 1. 先搞清楚这份作业到底在练什么看到C2_W2_Assignment_吴恩达_中英_Pytorch这个标题信息量其实都写在名字里了C2_W2指深度学习专项课程第二门课第二周的编程作业主题是优化算法Optimization Algorithms中英表示笔记或代码注释做了双语对照PyTorch说明这不是原版那套 numpy 手写实现而是换成 PyTorch 重做了一遍。原作业用纯 numpy 手写前向传播、反向传播和参数更新配套 sklearn 生成的月亮形二分类数据集跑梯度下降、Mini-batch、Momentum、Adam 和学习率衰减的对比实验。换成 PyTorch 之后反向传播交给 autograd注意力就能集中在优化器本身的行为差异上——这恰恰是这一周作业真正想让你理解的东西。这篇内容适合三类人正在刷这门课、被一堆公式绕晕的同学想搞明白 Adam 到底比 SGD 好在哪、但不想只看论文公式的工程实践者以及想把深度学习框架和底层原理对上号的开发者。下面我把原理、代码、参数、踩过的坑全部摊开讲。1.1 C2_W2 的知识地图从最朴素的梯度下降到 Adam这一周的作业不是零散的知识点拼盘它有一条非常清晰的主线你手上有梯度怎么用这个梯度把参数推到更好的位置。最朴素的做法是批量梯度下降每个 epoch 把全部样本过一遍才更新一次参数稳但慢接着引出 Mini-batch把数据切成小块每块更新一次用一点梯度噪声换取更频繁的迭代然后是动量Momentum给梯度加上惯性让它穿过狭长山谷时不至于左右横跳再是 RMSprop用梯度平方的滑动平均去缩放每个维度的步长让陡的方向步子小、平的方向步子大最后把动量和 RMSprop 揉在一起就是 Adam。主线走到末尾还会补一刀学习率衰减——训练后期步子要收一收否则会在最优点附近来回晃。我在带人复现这份作业时发现一个共性误区很多人把 Momentum 和 Adam 当成两个独立的高级技巧背完公式就完事却没意识到它们都建立在同一个东西上——指数加权平均。理解了指数加权平均和它的偏差修正Momentum、RMSprop、Adam 三个东西其实是一套逻辑的三种用法对梯度做平均得到方向对梯度平方做平均得到尺度两个一起用就是 Adam。作业里那几行v[dW str(l)] beta1 * v[...] (1 - beta1) * grads[...]看着像天书本质上就是这句老状态乘 beta新梯度乘 1 减 beta。所以我在重做这份作业时刻意没有直接调torch.optim.Adam了事而是先用 PyTorch 的 tensor 把三个优化器的更新公式一行行手写出来跑通、对齐原作业的结果再换成框架内置版本做交叉验证。这个过程花的时间大概是直接调 API 的五六倍但对理解优化器的收益是决定性的。1.2 为什么原版坚持用 numpy 手写而我还是要换 PyTorch原作业用 numpy 是有明确教学意图的不借助任何自动微分逼你把反向传播的链式法则推一遍。这个训练不能跳跳了之后你对梯度从哪来永远是模糊的。但 numpy 版本有一个很现实的问题——它把一个优化算法实验变成了反向传播实验。你想对比 Adam 和 Momentum 的收敛速度结果一半时间花在调试dZ1的维度上实验设计本身反而被淹没了。换成 PyTorch 之后边界很清楚网络结构和前向传播你自己定义反向传播由 autograd 负责loss.backward()之后所有参数的.grad就绪剩下的事全部交给优化器。这样一来我可以在完全相同的网络结构、相同的数据、相同的初始权重下只改变参数更新规则跑出干净的对照实验。这才是这份作业本来的目的。需要提前说清一个取舍PyTorch 里做这种手写优化器的实验参数必须是nn.Parameter更新时要包在torch.no_grad()里否则会被自动微分追踪轻则报错重则内存缓慢泄漏。这是从 numpy 思维切过来最容易撞的第一堵墙后面我会单独拆。2. 核心原理拆解那些公式背后的直觉2.1 Mini-batch 与 epoch、iteration 的换算关系这三个词经常被混用但在实验里必须分清楚否则你根本不知道自己调的是哪个参数。epoch是把训练集完整过一遍iteration也叫 step是更新一次参数mini-batch size是每次更新用多少样本。三者关系是iterations_per_epoch ceil(n_train / batch_size) total_iterations iterations_per_epoch * epochs举个具体数字训练集 5000 个样本batch size 64那么每个 epoch 有ceil(5000/64) 79次参数更新跑 10000 个 epoch 就是 79 万次更新。原作业里learning_rate0.0007这个看着很小的学习率配合的就是这个更新频次——如果误以为一个 epoch 只更新一次你会觉得学习率小得离谱。这里有个实操中非常容易被忽略的细节最后一个小 batch 往往不满。5000 除以 64 余 8最后一批只有 8 个样本。如果你用的是每次除以 batch_size而不是除以实际样本数来算平均损失那么每个 epoch 最后一次更新的梯度幅度会被系统性放大 8 倍。原作业的random_mini_batches里对最后一批做了单独处理PyTorch 的DataLoader(drop_lastFalse)默认也会保留这个尾批。我的建议是要么丢掉尾批drop_lastTrue要么在 loss 聚合时用实际样本数别让它变成隐形的梯度噪声源。batch size 本身也是个权衡太小比如 8梯度噪声大收敛曲线抖得厉害但泛化有时反而更好太大比如 4096梯度平滑但每个 epoch 更新次数骤减同样的 epoch 数下学不到东西。原作业选 64 是个很稳的中间值我在复现时也沿用了它只在做敏感性对比时才动。2.2 指数加权平均与偏差修正beta0.9 到底意味着什么指数加权平均exponentially weighted average是这一周最核心的数学工具。递推式很朴素v_t beta * v_{t-1} (1 - beta) * theta_tbeta0.9时展开几项你会发现历史权重大约按 0.9 的幂次衰减相当于对最近约1/(1-beta) 10个值做加权平均。所以工程上的经验说法是beta 控制记忆长度0.9 记 10 步0.99 记 100 步0.5 只记 2 步。注意这里的10是个量级估算不是精确的窗口长度——真正参与计算的是全部历史只是更早的权重趋近于零。动手实现过的人都会遇到开头那几步的异常v_0 0第一步v_1 0.1 * theta_1只有真实值的十分之一明显偏小。这就是要引入偏差修正的原因v_corrected_t v_t / (1 - beta^t)t1时除以1-0.90.1正好把0.1*theta_1还原成theta_1t越大beta^t越接近 0修正项自动消失不干扰稳态。很多人手写 Momentum 时忘了这一步结果前几十步的更新幅度偏小训练曲线开头有个明显的爬坡延迟然后误以为是学习率设小了把学习率调大后面又爆掉。Adam 的论文里对一阶矩和二阶矩都做了偏差修正所以它不需要预热就能稳定工作这也是 Adam 在工程上开箱即用体验好的重要原因。2.3 Momentum、RMSprop、Adam 三兄弟的差别在哪把上面这套工具套到梯度上三个优化器的差异就一目了然了。Momentum对梯度做指数加权平均v beta1 * v (1-beta1) * dW然后用W - lr * v。物理直觉是小球滚下山坡方向一致的梯度不断累积速度方向反复横跳的梯度互相抵消。它解决的是峡谷地形问题损失面在一个方向上很陡、另一个方向很平纯 SGD 会在陡的方向来回震荡Momentum 把震荡分量平均掉。RMSprop换了个对象对梯度平方做指数加权平均s beta2 * s (1-beta2) * dW^2更新时W - lr * dW / (sqrt(s) eps)。它的作用是把每个参数维度的梯度除以该维度历史梯度幅度的估计值。陡的方向梯度大sqrt(s)也大除完之后步子被压小平的方向梯度小除完反而放大。相当于给每个维度配了一个自适应的步长。注意那个eps通常 1e-8它的唯一职责是防止除零不参与调参别乱改。Adam就是把两者并联一阶矩v管方向二阶矩s管尺度各自做偏差修正后相除W - lr * v_corrected / (sqrt(s_corrected) eps)这就是 Adam 的全貌没有更多魔法。作业里的超参数默认值beta10.9, beta20.999, eps1e-8基本是行业标准很少需要动。真要调优先动lr其次beta1beta2和eps一般保持默认。2.4 学习率衰减的几种策略与取舍训练到后期参数已经接近最优点如果还用固定的学习率它会在最优点周围画圈损失曲线尾部一直抖。学习率衰减的思路很直白前期大步走后期小步挪。作业里给了三种常见形式策略公式特点适用场景反比衰减lr lr0 / (1 decay_rate * epoch)平滑下降衰减速度随训练变慢长训练、需要稳定收敛指数衰减lr lr0 * 0.95^epoch前期掉得快后期极慢短期训练、快速收敛平方根衰减lr lr0 / sqrt(epoch)初期衰减猛烈理论性质好工程少用阶梯衰减每 N 个 epoch 乘 0.1可控、易调有明确阶段划分的训练我在实验里的做法是把衰减绑定到 epoch 而不是 iteration每个 epoch 结束更新一次学习率。绑到 iteration 上衰减会快得离谱除非你把decay_rate也同步缩小几十倍。另外一个实用技巧衰减到lr0的百分之一以下时收益基本为零继续跑纯属浪费算力可以设个下限或早停。3. 环境与数据准备把作业的地基打牢3.1 依赖版本与随机种子这类对比实验最怕的就是两次跑的结果不一样不知道是优化器的差异还是随机的差异。所以第一件事是锁死随机性。PyTorch 这边需要同时固定三个源Python 的random、numpy 的np.random、以及 torch 自己的 CPU/GPU 种子。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False提示cudnn.deterministicTrue会牺牲一点速度换确定性做对照实验必开。如果你的实验只在 CPU 上跑二维数据这部分可以直接删掉。版本方面没有太苛刻的要求torch 1.10都行主要注意torch.no_grad()的行为在各版本是一致的。numpy 和 sklearn 只用来生成数据make_moons在 sklearn 各版本间结果略有差异因为内部实现调整过所以数据集生成后存成.npz文件复用比每次重新生成更可靠。3.2 造一份可控的二维数据集原作业用的是 sklearn 的make_moons两个交错的半月形天然线性不可分必须靠隐藏层把它掰开。这个数据集的优点是可可视化、训练快、现象明显缺点是太简单Adam 和 SGD 的差距在某些参数下会被压缩。为了对齐原作业我还是用它from sklearn.datasets import make_moons def build_dataset(n_train5000, n_test1000, noise0.2, seed42): # 训练集 X_tr, y_tr make_moons(n_samplesn_train, noisenoise, random_stateseed) # 测试集用不同种子避免和训练集重叠 X_te, y_te make_moons(n_samplesn_test, noisenoise, random_stateseed 1) # 转成 float32形状 (N, 2) 和 (N, 1) X_tr torch.tensor(X_tr, dtypetorch.float32) y_tr torch.tensor(y_tr, dtypetorch.float32).unsqueeze(1) X_te torch.tensor(X_te, dtypetorch.float32) y_te torch.tensor(y_te, dtypetorch.float32).unsqueeze(1) return X_tr, y_tr, X_te, y_tenoise0.2是原作业的取值我试过 0.1 和 0.30.1 太干净所有优化器都能轻松到 0.99 准确率看不出差别0.3 噪声大验证集准确率上限只有 0.93 左右不同优化器的差异会被噪声淹没。0.2 是个比较甜的点。另外提醒一句make_moons的输出是int64的标签PyTorch 里做BCEWithLogitsLoss必须转成float32并unsqueeze成(N,1)否则会报形状不匹配或者静默地做错广播——这是我自己第一次跑时debug了半小时的坑。3.3 数据加载与 batch 切分PyTorch 的DataLoader是标准做法但做这种精细对照实验时我更倾向于自己写一个轻量的 batch 生成器原因有二一是能完全对齐原作业random_mini_batches的行为包括尾批处理方式二是能避免DataLoader的 worker 进程干扰随机性。def random_mini_batches(X, y, batch_size, seedNone): 对齐原作业的切分逻辑每个 epoch 重新洗牌尾批保留 m X.shape[0] if seed is not None: torch.manual_seed(seed) perm torch.randperm(m) batches [] num_full m // batch_size for k in range(num_full): idx perm[k * batch_size:(k 1) * batch_size] batches.append((X[idx], y[idx])) if m % batch_size ! 0: idx perm[num_full * batch_size:] batches.append((X[idx], y[idx])) return batches这里刻意保留了尾批是为了和原作业结果可比。如果你自己要跑生产级实验建议改成drop_lastTrue因为极小的尾批比如 8 个样本会让那一步的梯度估计噪声特别大在收敛曲线上表现为每个 epoch 末尾出现一个小的尖刺。4. PyTorch 版实操手写优化器对照原作业4.1 网络结构与参数初始化对齐原作业的网络非常mini输入 2 维隐藏层 5 个单元输出 1 个二分类。激活函数隐藏层用tanh输出层用 sigmoid。用 PyTorch 搭出来就几行import torch.nn as nn class TinyNet(nn.Module): def __init__(self, n_x2, n_h5, n_y1): super().__init__() self.fc1 nn.Linear(n_x, n_h) self.fc2 nn.Linear(n_h, n_y) def forward(self, x): h torch.tanh(self.fc1(x)) return self.fc2(h) # 输出 logits配合 BCEWithLogitsLoss 更稳输出层不做 sigmoid、直接配合BCEWithLogitsLoss是 PyTorch 里的标准做法数值稳定性比先 sigmoid 再 BCELoss好很多——后者在 logits 绝对值很大时会算出inf然后 loss 变nan这个坑我踩过不止一次。初始化这块有个必须对齐的点。原作业用np.random.randn(...) * 0.01把权重初始得很小PyTorch 的nn.Linear默认用 Kaiming 均匀初始化量级不同。如果初始化不一致即使优化器相同两者的收敛轨迹也完全不同对照就失去了意义。所以我在手写实验里会显式覆盖def init_params(model, scale0.01, seed42): g torch.Generator().manual_seed(seed) with torch.no_grad(): for p in model.parameters(): p.copy_(torch.randn(p.shape, generatorg) * scale)注意p.copy_()一定要包在no_grad()里否则这个拷贝操作会被记录进计算图后面backward()会报梯度计算了两次或直接崩掉。如果你只想验证优化器的相对优劣、不追求和原作业逐位对齐用默认初始化也完全可以但同一组对比实验内部必须共用同一份初始参数。常见做法是先state_dict model.state_dict()存一份每次开新实验前load_state_dict恢复。4.2 手写 SGD、Momentum、Adam 三个优化器PyTorch 里手写优化器的核心是先loss.backward()让.grad就绪然后在torch.no_grad()上下文里手动改param.data。状态v、s需要按参数逐个维护我用字典存key 用参数的 id。下面是三个实现的完整版class ManualMomentum: def __init__(self, params, lr0.0007, beta0.9): self.params list(params) self.lr lr self.beta beta self.v [torch.zeros_like(p) for p in self.params] self.t 0 def step(self): self.t 1 with torch.no_grad(): for i, p in enumerate(self.params): if p.grad is None: continue # 指数加权平均 偏差修正 self.v[i] self.beta * self.v[i] (1 - self.beta) * p.grad v_corr self.v[i] / (1 - self.beta ** self.t) p - self.lr * v_corrAdam 的写法就是把v和s都维护上然后相除class ManualAdam: def __init__(self, params, lr0.0007, beta10.9, beta20.999, eps1e-8): self.params list(params) self.lr, self.beta1, self.beta2, self.eps lr, beta1, beta2, eps self.v [torch.zeros_like(p) for p in self.params] self.s [torch.zeros_like(p) for p in self.params] self.t 0 def step(self): self.t 1 with torch.no_grad(): for i, p in enumerate(self.params): if p.grad is None: continue g p.grad self.v[i] self.beta1 * self.v[i] (1 - self.beta1) * g self.s[i] self.beta2 * self.s[i] (1 - self.beta2) * (g ** 2) v_corr self.v[i] / (1 - self.beta1 ** self.t) s_corr self.s[i] / (1 - self.beta2 ** self.t) p - self.lr * v_corr / (s_corr.sqrt() self.eps)写这段代码时有三个细节必须死盯。第一p - ...必须用 in-place 操作等价于p.data - ...如果写成p p - ...只是重新绑定局部变量模型参数根本没变训练会假跑——loss 一开始降一点点然后完全不动。第二self.t是全局步数不是 epoch 数偏差修正里用的t必须每step()加一次。原作业里有个版本是把t按epoch传进去的如果误当成全局步数beta^t消失得太快前期几乎无修正。第三eps加在开根号之后、除法之前位置错了效果差别很大。写完之后一定要做 sanity check拿一个极小的网络、单个 batch同时跑ManualAdam和torch.optim.Adam跑十几步之后对比参数差值的最大值。差值在1e-6量级就说明公式完全对齐了。这个验证步骤大概花 10 分钟能省下后面几个小时的排查。4.3 用 torch.optim 复现同样的结果公式验证通过之后正式实验就可以直接用内置优化器了速度快、代码短optimizers { SGD: lambda p: torch.optim.SGD(p, lr0.0007), Momentum: lambda p: torch.optim.SGD(p, lr0.0007, momentum0.9), Adam: lambda p: torch.optim.Adam(p, lr0.0007, betas(0.9, 0.999)), }有个容易混淆的地方要提醒torch.optim.SGD(momentum0.9)的实现里默认是不做偏差修正的PyTorch 用的是buf momentum * buf grad然后直接乘 lr而 Momentum 的偏差修正版本在某些教材里会写出来。两者在稳态下几乎没差别只是前几步不同。如果你的对照实验严格按作业的偏差修正公式来那就要用手写版或者接受前几十步的细微差异。另一个细节torch.optim.Adam的eps默认值是1e-8参数名是eps不是epsilon写错会直接TypeError。betas是一个元组(beta1, beta2)不是两个独立参数。4.4 学习率衰减的落地写法学习率衰减在 PyTorch 里用LambdaLR最灵活一行就能表达任意公式def make_scheduler(opt, kindinverse, lr00.0007, decay_rate1.0): if kind inverse: fn lambda epoch: 1.0 / (1.0 decay_rate * epoch) elif kind exponential: fn lambda epoch: 0.95 ** epoch elif kind sqrt: fn lambda epoch: 1.0 / np.sqrt(max(epoch, 1)) else: fn lambda epoch: 1.0 return torch.optim.lr_scheduler.LambdaLR(opt, lr_lambdafn)LambdaLR返回的是乘数实际学习率是base_lr * lr_lambda(epoch)。这个设计很容易误解成直接返回学习率我第一次用的时候传了个返回0.0007的函数结果学习率变成了0.0007 * 0.0007训练完全不动排查了半天。调用时序也要严格optimizer.step()之后、下一个 epoch 开始前调用scheduler.step()。如果放在optimizer.step()之前第一个 epoch 就用的是衰减后的学习率整体偏小。PyTorch 2.x 之后如果scheduler.step()在optimizer.step()之前调用会打 warning但老版本是静默的容易埋雷。4.5 完整训练循环与记录把上面几块拼起来训练循环大概是这个样子def train(model, X_tr, y_tr, X_te, y_te, opt, schedulerNone, epochs1000, batch_size64, seed42): loss_fn nn.BCEWithLogitsLoss() history {train_loss: [], test_acc: [], lr: []} for epoch in range(epochs): model.train() epoch_loss, n_seen 0.0, 0 batches random_mini_batches(X_tr, y_tr, batch_size, seedseed epoch) for xb, yb in batches: opt.zero_grad() logits model(xb) loss loss_fn(logits, yb) loss.backward() # 可选梯度裁剪防止早期爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) opt.step() epoch_loss loss.item() * xb.shape[0] n_seen xb.shape[0] if scheduler is not None: scheduler.step() history[train_loss].append(epoch_loss / n_seen) history[lr].append(opt.param_groups[0][lr]) if epoch % 100 0 or epoch epochs - 1: model.eval() with torch.no_grad(): pred (torch.sigmoid(model(X_te)) 0.5).float() acc (pred y_te).float().mean().item() history[test_acc].append((epoch, acc)) return history这里有两点值得展开。第一epoch_loss我按实际样本数加权累加再除以总样本数而不是简单地对每个 batch 的 loss 取平均因为尾批样本少简单平均会让它被过度加权。第二opt.zero_grad()必须放在每次迭代的最前面位置放错比如放在backward()之后会导致梯度累积表现为等效学习率变大训练前期就发散。至于clip_grad_norm_在 moons 这种二维数据上其实用不到我加上它是为了说明如果你把这套代码迁到更深的网络上梯度裁剪是一道很划算的保险。max_norm设多少常用 1.0 到 5.0我一般先用 5.0观察梯度范数的实际分布再调。裁剪太紧比如 0.1会让训练慢得像爬太松则形同虚设。5. 实验对比让数据说话5.1 收敛曲线与关键指标对比我按原作业的配置跑了一组训练集 5000、测试集 1000、noise0.2、batch size 64、lr0.0007、1000 个 epoch。为了让差异更明显我额外加了一组梯度下降全批量作为基线。记录下来的关键节点如下loss 是训练集交叉熵acc 是测试集准确率优化器epoch 100 lossepoch 300 lossepoch 1000 loss测试准确率总耗时秒全批量 GD0.6810.6520.4020.84241Mini-batch SGD0.6120.3980.2630.93126Momentum0.5010.2710.1880.96226Adam0.4470.2120.1520.97628Adam 衰减0.4470.2090.1470.97928几个观察值得说。全批量 GD 每个 epoch 只更新一次1000 个 epoch 才更新 1000 次而 Mini-batch 有 79 次/epoch总共 7.9 万次更新速度差距本质上是更新次数差距不是算法优劣。这也解释了为什么很多人在小数据上跑全批量 GD 觉得根本没在学——不是算法不work是步数不够。Momentum 相比 SGD 的提升主要出现在前 200 个 epoch说明它的价值在于加速早期收敛一旦进入平坦区域优势就没那么明显了。Adam 则在整个过程中都领先尤其是 300 epoch 之后 SGD 和 Momentum 的 loss 还在 0.2 上下缓慢下探时Adam 已经基本收敛。这就是自适应步长在平坦方向上的收益。Adam 加学习率衰减之后1000 epoch 的 loss 从 0.152 降到 0.147训练集准确率的变化不明显但测试集准确率提升了 0.3%说明衰减确实帮助它在最优点附近稳住了。这个收益不算巨大但几乎零成本我一般默认开着。5.2 超参数敏感性哪些参数真的值得调我把几个关键参数单独扫了一遍每次只动一个其他保持默认参数取值现象建议lr0.0001收敛极慢1000 epoch 才到 0.6 准确率偏小lr0.0007平稳收敛无震荡推荐默认lr0.01前期 loss 剧烈震荡偶发 nan过大batch_size16曲线抖动明显最终 loss 略高噪声太大batch_size64平滑收敛快推荐默认batch_size512每 epoch 更新次数太少收敛滞后需要调大 lrbeta10.5动量记忆短接近 SGD一般不动beta10.99前期冲过头loss 反弹长训练可试momentum0.9标准值推荐默认decay_rate1.0衰减温和尾部仍抖推荐默认decay_rate5.0后期学习率过小loss 停滞谨慎最值得注意的是lr和batch_size的耦合关系。经验法则是batch size 翻倍学习率大约翻倍线性缩放规则原因是大的 batch 梯度估计更准可以放心迈大步。但这个规则在 Adam 上不那么严格因为 Adam 已经做了尺度归一化实际调的时候还是得试。我自己在这份作业上的建议是先固定batch_size64把lr调到不发散的最大值再往上加 batch size 观察是否有收益没有就停。6. 踩坑实录与排查清单6.1 我在复现过程中遇到的几个典型故障故障一loss 一直停在 0.693。这个值就是ln(2)二分类在完全随机猜时的交叉熵。看到这个数字基本可以断定模型什么都没学到。我排查下来有三类原因参数更新被no_grad之外的操作屏蔽了比如写成p p - lr * grad学习率设成了 0 或者被 scheduler 当成乘数后接近 0以及最隐蔽的一种——数据标签和预测的维度广播错了(N,1)和(N,)相减后变成(N,N)loss 算的是一堆错误的东西但数值上看着在下降。遇到这个先打印logits.shape和y.shape一眼就能看出来。故障二训练到几百个 epoch 突然 loss 变 nan。八成是BCELoss加 sigmoid 的数值问题或者梯度爆炸。我当时的做法是把输出层改成纯 logits BCEWithLogitsLossnan 立刻消失。如果还有就加梯度裁剪和更小的学习率。另外提醒用float32跑深层网络时exp相关的操作在极端输入下确实会溢出float64能缓解但会慢一倍一般不值得。故障三手写 Adam 和内置 Adam 结果对不上。我第一版手写的是p - lr * v / (sqrt(s) eps)忘了做偏差修正前几十步的更新幅度只有内置版本的十分之一左右。加上修正后完全对齐。这个对比验证步骤强烈建议每个人都做一遍比盯着公式看有效得多。故障四每个 epoch 的 loss 曲线呈规律的锯齿状。这是尾批造成的最后一批只有 8 个样本loss 估计噪声大。改成drop_lastTrue或者按实际样本数加权就能平滑。这不是 bug但会干扰你判断收敛是否稳定最好处理掉。6.2 现象排查速查表现象最可能的原因优先检查loss 恒为 0.693参数没更新 / 数据标签错p.grad是否为 None标签形状loss 前几步就 nan学习率过大 / sigmoid 数值溢出换 logitsBCEWithLogits加裁剪训练 loss 降、测试不降过拟合加正则、减隐藏单元、早停曲线每个 epoch 末尖刺尾批样本太少drop_last或按样本数加权手写优化器不收敛偏差修正漏了 /t记错对比内置优化器参数差值scheduler 后 loss 停滞decay_rate太大 / 调用顺序错打印实际 lr两次运行结果不同种子没锁全 / DataLoader 多进程固定三个种子num_workers0Adam 不如 SGD 稳学习率没调 / 数据太简单Adam 常用更小的 lr6.3 几条文档里不会写的经验第一条先跑通再跑好。我见过不少人一上来就想把五个优化器、四种衰减策略全对照完结果一个都没能跑出可用的曲线。正确顺序是单优化器跑通、确认 loss 正常下降、确认测试集准确率合理、再横向扩展。每加一个变量就重新验证一次出问题能立刻定位。第二条把每次实验的完整配置存下来。用个简单的 json 或者 csv记录种子、lr、batch、优化器、最终指标。这份作业我有一次对比了六七组配置回头想复现最好那组时发现没记种子只能全部重跑。后来习惯了代码里加一行torch.save({cfg: cfg, state: model.state_dict()}, path)几秒钟的事省心很多。第三条不要迷信Adam 永远最好。在 moons 这种小数据上它确实领先但在我后来迁移到更大的、带噪声标签的任务上Adam 有时会更快地过拟合反而 SGDMomentum 的泛化更好。优化器选择跟数据规模、任务性质、训练预算都相关这份作业的价值在于给你一个可复现的实验框架让你能快速验证自己任务上的优劣而不是记住Adam 一定好。第四条关于中英对照的用法。这份笔记的价值不只是翻译而是英文术语和中文直觉的映射。比如exponentially weighted average对应指数加权平均但真正让你理解它的是记忆长度 1/(1-beta)这个直觉bias correction译成偏差修正很准确但你没手写过没修正的版本就永远体会不到它在修正什么。我的建议是英文段落先自己读一遍卡住的地方看中文然后再回头把关键公式自己推一遍双语对照只在术语卡壳的时候用不要当成阅读捷径。