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_rate=0.0007这个看着很小的学习率,配合的就是这个更新频次——如果误以为一个 epoch 只更新一次,你会觉得学习率小得离谱。
这里有个实操中非常容易被忽略的细节:最后一个小 batch 往往不满。5000 除以 64 余 8,最后一批只有 8 个样本。如果你用的是"每次除以 batch_size"而不是"除以实际样本数"来算平均损失,那么每个 epoch 最后一次更新的梯度幅度会被系统性放大 8 倍。原作业的random_mini_batches里对最后一批做了单独处理,PyTorch 的DataLoader(drop_last=False)默认也会保留这个尾批。我的建议是要么丢掉尾批(drop_last=True),要么在 loss 聚合时用实际样本数,别让它变成隐形的梯度噪声源。
batch size 本身也是个权衡:太小(比如 8)梯度噪声大,收敛曲线抖得厉害,但泛化有时反而更好;太大(比如 4096)梯度平滑,但每个 epoch 更新次数骤减,同样的 epoch 数下学不到东西。原作业选 64 是个很稳的中间值,我在复现时也沿用了它,只在做敏感性对比时才动。
2.2 指数加权平均与偏差修正:beta=0.9 到底意味着什么
指数加权平均(exponentially weighted average)是这一周最核心的数学工具。递推式很朴素:
v_t = beta * v_{t-1} + (1 - beta) * theta_tbeta=0.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)t=1时除以1-0.9=0.1,正好把0.1*theta_1还原成theta_1;t越大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 的全貌,没有更多魔法。作业里的超参数默认值beta1=0.9, beta2=0.999, eps=1e-8基本是行业标准,很少需要动。真要调,优先动lr,其次beta1,beta2和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(seed=42): 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.deterministic=True会牺牲一点速度换确定性,做对照实验必开。如果你的实验只在 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_train=5000, n_test=1000, noise=0.2, seed=42): # 训练集 X_tr, y_tr = make_moons(n_samples=n_train, noise=noise, random_state=seed) # 测试集用不同种子,避免和训练集重叠 X_te, y_te = make_moons(n_samples=n_test, noise=noise, random_state=seed + 1) # 转成 float32,形状 (N, 2) 和 (N, 1) X_tr = torch.tensor(X_tr, dtype=torch.float32) y_tr = torch.tensor(y_tr, dtype=torch.float32).unsqueeze(1) X_te = torch.tensor(X_te, dtype=torch.float32) y_te = torch.tensor(y_te, dtype=torch.float32).unsqueeze(1) return X_tr, y_tr, X_te, y_tenoise=0.2是原作业的取值,我试过 0.1 和 0.3:0.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, seed=None): """对齐原作业的切分逻辑:每个 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_last=True,因为极小的尾批(比如 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_x=2, n_h=5, n_y=1): 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, scale=0.01, seed=42): g = torch.Generator().manual_seed(seed) with torch.no_grad(): for p in model.parameters(): p.copy_(torch.randn(p.shape, generator=g) * 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, lr=0.0007, beta=0.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, lr=0.0007, beta1=0.9, beta2=0.999, eps=1e-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, lr=0.0007), "Momentum": lambda p: torch.optim.SGD(p, lr=0.0007, momentum=0.9), "Adam": lambda p: torch.optim.Adam(p, lr=0.0007, betas=(0.9, 0.999)), }有个容易混淆的地方要提醒:torch.optim.SGD(momentum=0.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, kind="inverse", lr0=0.0007, decay_rate=1.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_lambda=fn)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, scheduler=None, epochs=1000, batch_size=64, seed=42): 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, seed=seed + 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_norm=5.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、noise=0.2、batch size 64、lr=0.0007、1000 个 epoch。为了让差异更明显,我额外加了一组"梯度下降(全批量)"作为基线。记录下来的关键节点如下(loss 是训练集交叉熵,acc 是测试集准确率):
| 优化器 | epoch 100 loss | epoch 300 loss | epoch 1000 loss | 测试准确率 | 总耗时(秒) |
|---|---|---|---|---|---|
| 全批量 GD | 0.681 | 0.652 | 0.402 | 0.842 | 41 |
| Mini-batch SGD | 0.612 | 0.398 | 0.263 | 0.931 | 26 |
| Momentum | 0.501 | 0.271 | 0.188 | 0.962 | 26 |
| Adam | 0.447 | 0.212 | 0.152 | 0.976 | 28 |
| Adam + 衰减 | 0.447 | 0.209 | 0.147 | 0.979 | 28 |
几个观察值得说。全批量 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 超参数敏感性:哪些参数真的值得调
我把几个关键参数单独扫了一遍,每次只动一个,其他保持默认:
| 参数 | 取值 | 现象 | 建议 |
|---|---|---|---|
lr | 0.0001 | 收敛极慢,1000 epoch 才到 0.6 准确率 | 偏小 |
lr | 0.0007 | 平稳收敛,无震荡 | 推荐默认 |
lr | 0.01 | 前期 loss 剧烈震荡,偶发 nan | 过大 |
batch_size | 16 | 曲线抖动明显,最终 loss 略高 | 噪声太大 |
batch_size | 64 | 平滑,收敛快 | 推荐默认 |
batch_size | 512 | 每 epoch 更新次数太少,收敛滞后 | 需要调大 lr |
beta1 | 0.5 | 动量记忆短,接近 SGD | 一般不动 |
beta1 | 0.99 | 前期冲过头,loss 反弹 | 长训练可试 |
momentum | 0.9 | 标准值 | 推荐默认 |
decay_rate | 1.0 | 衰减温和,尾部仍抖 | 推荐默认 |
decay_rate | 5.0 | 后期学习率过小,loss 停滞 | 谨慎 |
最值得注意的是lr和batch_size的耦合关系。经验法则是batch size 翻倍,学习率大约翻倍(线性缩放规则),原因是大的 batch 梯度估计更准,可以放心迈大步。但这个规则在 Adam 上不那么严格,因为 Adam 已经做了尺度归一化,实际调的时候还是得试。我自己在这份作业上的建议是:先固定batch_size=64把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 +BCEWithLogitsLoss,nan 立刻消失。如果还有,就加梯度裁剪和更小的学习率。另外提醒:用float32跑深层网络时,exp相关的操作在极端输入下确实会溢出,float64能缓解但会慢一倍,一般不值得。
故障三:手写 Adam 和内置 Adam 结果对不上。我第一版手写的是p -= lr * v / (sqrt(s) + eps),忘了做偏差修正,前几十步的更新幅度只有内置版本的十分之一左右。加上修正后完全对齐。这个对比验证步骤强烈建议每个人都做一遍,比盯着公式看有效得多。
故障四:每个 epoch 的 loss 曲线呈规律的锯齿状。这是尾批造成的,最后一批只有 8 个样本,loss 估计噪声大。改成drop_last=True或者按实际样本数加权就能平滑。这不是 bug,但会干扰你判断"收敛是否稳定",最好处理掉。
6.2 现象排查速查表
| 现象 | 最可能的原因 | 优先检查 |
|---|---|---|
| loss 恒为 0.693 | 参数没更新 / 数据标签错 | p.grad是否为 None,标签形状 |
| loss 前几步就 nan | 学习率过大 / sigmoid 数值溢出 | 换 logits+BCEWithLogits,加裁剪 |
| 训练 loss 降、测试不降 | 过拟合 | 加正则、减隐藏单元、早停 |
| 曲线每个 epoch 末尖刺 | 尾批样本太少 | drop_last或按样本数加权 |
| 手写优化器不收敛 | 偏差修正漏了 /t记错 | 对比内置优化器参数差值 |
| scheduler 后 loss 停滞 | decay_rate太大 / 调用顺序错 | 打印实际 lr |
| 两次运行结果不同 | 种子没锁全 / DataLoader 多进程 | 固定三个种子,num_workers=0 |
| Adam 不如 SGD 稳 | 学习率没调 / 数据太简单 | Adam 常用更小的 lr |
6.3 几条文档里不会写的经验
第一条,先跑通再跑好。我见过不少人一上来就想把五个优化器、四种衰减策略全对照完,结果一个都没能跑出可用的曲线。正确顺序是:单优化器跑通、确认 loss 正常下降、确认测试集准确率合理、再横向扩展。每加一个变量就重新验证一次,出问题能立刻定位。
第二条,把每次实验的完整配置存下来。用个简单的 json 或者 csv,记录种子、lr、batch、优化器、最终指标。这份作业我有一次对比了六七组配置,回头想复现"最好那组"时发现没记种子,只能全部重跑。后来习惯了,代码里加一行torch.save({"cfg": cfg, "state": model.state_dict()}, path),几秒钟的事,省心很多。
第三条,不要迷信"Adam 永远最好"。在 moons 这种小数据上它确实领先,但在我后来迁移到更大的、带噪声标签的任务上,Adam 有时会更快地过拟合,反而 SGD+Momentum 的泛化更好。优化器选择跟数据规模、任务性质、训练预算都相关,这份作业的价值在于给你一个可复现的实验框架,让你能快速验证自己任务上的优劣,而不是记住"Adam 一定好"。
第四条,关于中英对照的用法。这份笔记的价值不只是翻译,而是英文术语和中文直觉的映射。比如exponentially weighted average对应"指数加权平均",但真正让你理解它的是"记忆长度 1/(1-beta)"这个直觉;bias correction译成"偏差修正"很准确,但你没手写过没修正的版本,就永远体会不到它在修正什么。我的建议是:英文段落先自己读一遍,卡住的地方看中文,然后再回头把关键公式自己推一遍,双语对照只在"术语卡壳"的时候用,不要当成阅读捷径。