PyTorch手写优化算法:C2_W2中英对照与数值对拍
1. 先把标题拆开这份作业到底在练什么C2_W2 这个编号在不少人的学习笔记里都出现过它指向某门经典深度学习公开课第二课第二周的课后作业。整份作业的英文原版要求你用 NumPy 从零手写几种优化算法我把整份内容搬到了 PyTorch 上用中英对照的双语注释重写了一遍核心关键词就是 C2_W2、PyTorch、中英对照、优化算法。它练的不是调包而是把 Mini-batch 梯度下降、Momentum、RMSprop、Adam、学习率衰减这几个东西的内部更新公式一行一行敲出来再拿同一个浅层网络跑对照实验看谁的收敛曲线更好看。这件事解决了一个很实际的问题绝大多数人用torch.optim.Adam(model.parameters())用了一两年却说不清betas(0.9, 0.999)这两个数分别控制什么、eps到底是干嘛的、为什么 Adam 在训练初期会冲得特别猛。这份作业逼着我把这些参数一个个手写实现写完再看官方实现会有一瞬间的哦原来就这点东西。适合谁看正在刷这门课、被 NumPy 版作业卡住的人已经会用 PyTorch 但想搞懂优化器内部机制的中级使用者以及想拿一份可跑通的中英对照代码当模板的新手。前置要求不高知道梯度下降的基本形式、会写几行 PyTorch 的nn.Module剩下的都能边看边补。1.1 C2_W2 对应的知识地图这一周的作业主线非常集中就是让梯度下降跑得更快更稳。原版作业里给了一个三层全连接的二分类网络输入是打平后的图像向量输出一个概率损失用二元交叉熵。真正的教学点全在参数更新那一步怎么写。我把整份作业的知识点摊开成这几块mini-batch 切分逻辑怎么打乱、怎么补最后一个不满的 batch、指数加权平均与偏差修正这是 Momentum 和 Adam 共同的数学地基、Momentum 的一阶动量、RMSprop 的二阶动量、Adam 把两者合体、以及按 epoch 做时间衰减的学习率策略。这六块是有依赖关系的指数加权平均看不懂后面三个优化器就全是背公式。我个人的建议是别跳过第 3.2 节那种手推温度序列的小练习。听起来很幼稚但它是唯一能让你直观感受 β0.9 到底等于平均了多少天的数据的方式——答案是大约 1/(1-0.9)10 天。这个换算关系记住了后面调betas就不会瞎试。1.2 为什么值得用 PyTorch 重写一遍原版用 NumPy好处是纯粹坏处是你会花大量时间在np.zeros_like、转置方向、np.squeeze这些跟算法本身无关的地方。用 PyTorch 重写的理由有三个我按重要性排一下。第一是张量方向和自动求导省事。NumPy 版里你得手动写linear_activation_backward六层链式法则写下来极容易错而错一行就是loss 不降这种没有信息量的报错。PyTorch 里loss.backward()一把梭我可以把全部注意力放在优化算法本身。第二是能直接对着torch.optim的官方实现做数值比对手写版本算出的参数和torch.optim.Adam差多少一眼就能看出来这是最好的验证手段。第三是双语注释在 PyTorch 语境下更自然因为 PyTorch 社区的文档词汇如param_groups、state_dict本身就是英文对照着写不容易产生歧义。注意用 PyTorch 重写不代表可以跳过 NumPy 版。反向传播那部分我建议还是老老实实用 NumPy 写一遍否则你对梯度是怎么来的会一直有层雾。我自己的做法是 NumPy 版写正向和反向PyTorch 版只替换优化器部分两边对拍数值。2. 环境、数据与目录组织动手之前先把两件事定死张量方向和数据归一化。这两件事没定好后面 90% 的诡异 bug 都从这儿来。2.1 数据形态与张量方向的约定原版作业的约定是X形状为(n_x, m)也就是特征在前、样本在后Y是(1, m)。这是 NumPy 时代的习惯跟 PyTorch 的(batch, feature)正好相反。我在重写时做了一件事内部实现全部沿用原作业的列向量约定只在模型入口处转置。这样代码和课程讲义能一行一行对上看讲义的时候不用在脑子里做转置。代价是多写两行.T收益是调试时不会精神分裂。具体做法是在Dataset封装里负责转换训练循环里拿到的一律是(n_x, batch_size)。数据本身是课程提供的二分类图像数据集每张图被拉平成向量维度是 64×64×312288。样本量不大训练集几千条量级。这个小样本量很关键它直接决定了后面 Adam 会过拟合——不是算法的问题是数据太少。2.2 目录结构与可复现性配置我的目录长这样data/放原始数据optim/放四个手写优化器train.py是主入口compare.py专门跑对照实验并画曲线notes/里放双语对照的公式推导。分成optim/单独一个包而不是全塞进一个文件是因为我想让每个优化器都能被单独 import 出来做单元测试。可复现性这块踩过坑说三个必须固定的东西torch.manual_seed、torch.Generatormini-batch 打乱用独立 generator不要污染全局随机状态、以及 DataLoader 的worker_init_fn如果你用多进程。我一开始只设了全局种子结果每次跑出来曲线都有细微差别排查了半天才发现是torch.randperm用的是全局状态而我在中间插了一句别的随机操作打乱了序列。import torch SEED 42 torch.manual_seed(SEED) torch.cuda.manual_seed_all(SEED) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False提示cudnn.deterministicTrue会掉性能但在做算法对照实验时值得。你要比较的是 Momentum 和 Adam 的差别不是随机噪声的差别。3. 优化算法逐个落地这一章是全文的重心。我按照依赖顺序写先 mini-batch再指数加权平均然后三个优化器最后学习率衰减。每段代码都配双语注释注释风格是上英文原式、下中文意图。3.1 Mini-batch 切分把整批数据剁成小块Mini-batch 的本质是把一次看全部数据算一个梯度改成一次看一小撮数据算一个梯度。好处有两个单步计算量下降以及梯度的随机噪声本身有正则化效果。坏处是噪声会让 loss 曲线不那么平滑以及最后一个不满的 batch 会引入额外噪声。原版作业在最后那个 batch 上做了一个细节如果剩余样本数小于 batch_size会从打乱后的序列头部补足到 batch_size而不是留一个小的。这个设计有点反直觉我一开始觉得是 bug后来理解是为了让每个 batch 形状一致避免某些框架在形状变化时出问题。在 PyTorch 里其实不需要这么做动态形状完全支持所以我保留了最后一个小 batch的做法并在注释里说明了这个差异。import math import torch def random_mini_batches(X, Y, batch_size64, seed0): X: (n_x, m) Y: (1, m) -- 沿用原作业的列向量约定 EN: shuffle then slice; the tail batch is kept as-is CN: 先整体打乱再切片尾部不足一个 batch 的直接保留 g torch.Generator().manual_seed(seed) m X.shape[1] perm torch.randperm(m, generatorg) X_s, Y_s X[:, perm], Y[:, perm] n_complete m // batch_size batches [] for k in range(n_complete): sl slice(k * batch_size, (k 1) * batch_size) batches.append((X_s[:, sl], Y_s[:, sl])) if m % batch_size ! 0: sl slice(n_complete * batch_size, m) batches.append((X_s[:, sl], Y_s[:, sl])) return batchesbatch_size 怎么选我试过 16、32、64、128、256 五档。64 是这个数据集上的甜点再小梯度噪声太大Adam 尤其容易抖再大单 epoch 的更新次数掉得厉害在训练轮数受限的情况下收敛反而慢。经验公式是让每个 epoch 有 30 到 100 次更新比较舒服样本几千条64 正好落在这个区间。注意torch.randperm每次调用都会推进 generator 状态所以同一个 seed 下第 2 个 epoch 的批次顺序和第 1 个不同——这是期望行为。但如果你在每个 epoch 都重新manual_seed那就变成每个 epoch 顺序一模一样mini-batch 的随机性直接没了这是个很隐蔽的错。3.2 指数加权平均与偏差修正这块是地基。指数加权平均的递推式只有一行v β·v (1-β)·θ。它的物理含义是给越久远的数据越小的权重权重按 β 的幂次衰减。β0.9 意味着当前值占 10%前一天占 9%再前一天占 8.1%累加起来的有效窗口大约是 10 天。偏差修正解决的是从零起步的问题。v 初值是 0第一步算出来v_1 0.1·θ_1这明显偏小只有真实值的十分之一。修正的办法是除以1-β^tt 是步数。第 1 步除 0.1正好还原第 10 步除 0.651影响已经不大第 100 步几乎可以忽略。所以偏差修正是一个只在开头重要的操作。def exp_weighted_avg(theta, beta0.9): EN: v_t beta * v_{t-1} (1 - beta) * theta_t CN: 历史值按 beta 幂次衰减加权越近权重越大 EN: corrected v_t / (1 - beta ** t) # bias correction CN: 除以 (1 - beta^t)修正从 0 起步导致的系统性偏小 v, out_raw, out_corr 0.0, [], [] for t, x in enumerate(theta, start1): v beta * v (1 - beta) * x out_raw.append(v) out_corr.append(v / (1 - beta ** t)) return out_raw, out_corr拿一段温度序列跑一下你会看到未修正的曲线在前 10 个点明显贴着 0 走修正之后立刻贴合真实数据。这个视觉冲击比看公式有用得多。提示Adam 里的偏差修正对象是两路动量而且分母是1-β1^t和1-β2^t注意 β1 和 β2 用的是同一个 t别写错。我见过把二阶动量的修正写成1-β1^t的实现在小 batch 下不报错但收敛会差一点属于非常难查的软错误。3.3 Momentum给梯度加个惯性Momentum 的直觉是滚球不是每一步都朝当前梯度方向走而是保留一部分上一步的速度。这样在梯度方向一致的区域会加速在来回震荡的方向上会互相抵消。实现上就是把梯度换成梯度的指数加权平均。def init_momentum(params): EN: v initialized to zeros, same shape as each parameter CN: 动量缓存初始化为全零形状与参数一致 return {k: torch.zeros_like(p) for k, p in params.items()} def update_momentum(params, grads, v, lr0.01, beta0.9): EN: v beta * v (1 - beta) * dW ; W W - lr * v CN: 先累积历史梯度得到速度再沿速度方向更新 for k in params: v[k] beta * v[k] (1 - beta) * grads[k] params[k] - lr * v[k] return params, v这里有个坑必须说清楚原版作业的 Momentum 更新写的是v beta*v (1-beta)*dW然后W - lr*v。而 PyTorch 的torch.optim.SGD(momentum0.9)内部写的是buf momentum*buf grad然后p - lr*buf少了那个 (1-beta)。两者不等价前者相当于把学习率乘了一个 (1-β) 的系数。如果你要和官方对齐lr得相应放大 1/(1-β) 倍也就是大约 10 倍。我在做数值对拍时就栽在这儿一度以为自己的实现错了。3.4 RMSprop按维度自己调步长RMSprop 解决的是另一个问题不同参数方向的梯度量级可能差好几个数量级统一学习率对某些维度太大、对另一些太小。它的做法是维护梯度的平方的指数加权平均然后用它开根号去除相当于给每个参数一个自适应的步长。def update_rmsprop(params, grads, s, lr0.001, beta20.999, eps1e-8): EN: s beta2 * s (1 - beta2) * dW**2 ; W W - lr * dW / (sqrt(s) eps) CN: 累积梯度平方用它开根号做分母实现逐参数缩放 for k in params: s[k] beta2 * s[k] (1 - beta2) * grads[k] ** 2 params[k] - lr * grads[k] / (torch.sqrt(s[k]) eps) return params, seps的作用是防止分母为 0尤其在训练最初几步 s 还是接近 0 的时候。这个值不要设太大1e-8 是标准设成 1e-3 会明显削弱自适应效果。我在一个实验里图省事写了 1e-4结果 RMSprop 的表现跟不开自适应差不多。注意RMSprop 在 PyTorch 官方实现里其实也带了 momentum 参数默认 0原版作业里的版本是没有动量的纯 RMSprop。做对照实验时要么明确关掉动量要么在报告里注明不然结论会串。3.5 Adam把惯性和自适应步长合起来Adam 就是把 3.3 的一阶动量和 3.4 的二阶动量合到一起各自做偏差修正然后相除。它的参数最多也是实际项目里最常用的默认选择。def init_adam(params): v {k: torch.zeros_like(p) for k, p in params.items()} # 一阶矩 s {k: torch.zeros_like(p) for k, p in params.items()} # 二阶矩 return v, s def update_adam(params, grads, v, s, t, lr0.01, beta10.9, beta20.999, eps1e-8): t: 全局步数从 1 开始跨 epoch 连续累加 EN: first moment - velocity, second moment - scale CN: 一阶矩累积方向二阶矩累积量级 EN: both are bias-corrected by 1 - beta**t CN: 两路都做偏差修正修正因子用的是同一个 t for k in params: v[k] beta1 * v[k] (1 - beta1) * grads[k] v_hat v[k] / (1 - beta1 ** t) s[k] beta2 * s[k] (1 - beta2) * grads[k] ** 2 s_hat s[k] / (1 - beta2 ** t) params[k] - lr * v_hat / (torch.sqrt(s_hat) eps) return params, v, s这里最容易错的地方是t的作用域。t必须跨 epoch 连续累加不能在每个 epoch 开头重置为 1。一旦重置偏差修正因子在每次 epoch 开始都会突然变小导致开头几步的更新幅度被异常放大曲线会呈现规律的锯齿。我第一次跑出来 loss 图上每 100 步一个小尖峰以为是数据问题查了两小时才发现是t写在了 epoch 循环里面。eps在 Adam 里建议放在根号外面sqrt(s_hat) eps这和原版论文一致。有些实现放在里面sqrt(s_hat eps)两者在 s 很小时差别明显做对拍时要注意。3.6 学习率衰减后期收窄步子固定学习率的问题在于前期需要大步快走后期需要小步微调。原版作业用的是时间衰减lr lr0 / (1 decay_rate * epoch)。这个公式前期下降快、后期趋于平缓比阶梯衰减平滑得多。def lr_at(epoch, lr00.0007, decay_rate1.0): EN: time-based decay, lr shrinks as 1/(1 decay_rate * epoch) CN: 按 epoch 做时间衰减前期降得快后期趋缓 return lr0 / (1.0 decay_rate * epoch)如果不想自己算用torch.optim.lr_scheduler.LambdaLR更省事但要注意scheduler.step()的调用位置。PyTorch 1.1 之后scheduler 在 optimizer.step() 之后调用如果你在 epoch 开头调用学习率会提前衰减一轮整体偏小。这个坑我在迁移代码时踩过曲线整体下移找原因找了很久。还有一点课程里 Adam 的初始学习率给的是 0.0007这个数比常见的 1e-3 略小原因是用了 lr 衰减前期实际步长会比标称值大一点。我做对照实验时统一用 0.0007 起步保证三个优化器可比。4. 完整训练循环与中英对照注释写法单个优化器写对不难难的是把 mini-batch、优化器、学习率衰减、步数计数揉进一个循环里还要保证三个版本共享同一套代码骨架只换更新函数。这一章给出我实际用的结构。4.1 训练器骨架思路是把优化器做成可插拔的模块训练循环不关心内部实现只调用step。这样对照实验时只需要替换一个字符串。class Trainer: def __init__(self, model, opt_nameadam, lr07e-4, batch_size64): self.model model self.opt_name opt_name self.lr0 lr0 self.batch_size batch_size self.t 0 # 全局步数跨 epoch 累加 p dict(model.named_parameters()) self.v {k: torch.zeros_like(x) for k, x in p.items()} self.s {k: torch.zeros_like(x) for k, x in p.items()} self.loss_fn torch.nn.BCELoss() def step(self, params, grads, lr): if self.opt_name gd: for k in params: params[k] - lr * grads[k] elif self.opt_name momentum: params, self.v update_momentum(params, grads, self.v, lr) elif self.opt_name adam: # EN: t increments once per update, not per epoch # CN: t 每更新一次加一而不是每个 epoch 加一 self.t 1 params, self.v, self.s update_adam( params, grads, self.v, self.s, self.t, lr) return paramsself.t放在step里加一而不是在循环外层加一这个位置关系很关键。放在外层你就会在每个 batch 里用同一个 t偏差修正完全失效。学习率按 epoch 更新所以在外层循环开头算一次lr lr_at(epoch, self.lr0)然后整个 epoch 内保持不变。如果你想做 step 级别的衰减cosine 之类就得挪进内层逻辑完全不同。4.2 双语注释的排版约定注释这件事我摸索出一套自己觉得舒服的规则分享出来。上英文下中文前面加EN:和CN:前缀。英文只写公式或者 PyTorch 文档里的原话中文写为什么这么写而不是这行是什么。比如# EN: bias correction配# CN: 修正从零起步的偏小比直译偏差纠正有信息量得多。第二条规则是公式行必须英文。因为你要在论文、官方文档、源码注释之间来回看公式用英文能直接搜到出处。第三条是陷阱行必须中文。所有跟直觉相反的地方比如t的作用域、eps在根号内外、momentum 的 (1-β) 差异全部用中文写清楚这些是搜不到的、只能靠踩坑积累的东西。双语注释还有个额外好处当我把代码发给做同样作业的朋友看时习惯中文的直接看中文习惯读英文的看英文不用来回解释。这份代码后来被我当成模板复用了好几次。5. 三组实验的对照与调参心得写完不算完作业真正的产出是那三条对照曲线。这一章讲我跑出来的结果和几个跟直觉相反的发现。5.1 收敛曲线与耗时对比同样的网络结构、同样的 batch_size64、同样的初始学习率 7e-4跑 5000 个 batch 量级三者的表现差异非常明显。优化器前期下降速度后期稳定性单步耗时训练集最终 loss测试集表现纯 GD最慢前 500 步几乎平很稳无震荡最低最高一般Momentum快仅次于 Adam稳定偶有轻微过冲略高中等较好Adam最快前 100 步掉得最狠后期容易抖最高最低出现过拟合具体说 Adam 的过拟合。这个数据集样本量小、特征维度高12288 维Adam 收敛太快训练集 loss 很快压到很低测试集精度反而在某个点之后开始下降。我观察到大概在第 3000 到 5000 步之间出现拐点。Momentum 没有这个问题因为它的步长是整体缩放的没有逐维度的自适应放大效应。这个现象在原版作业里也提到过属于这个任务的固有特性不是实现错误。耗时方面Adam 单步比 GD 慢大约 3 倍要算两路动量加两路偏差修正但因为收敛需要的步数少得多总墙钟时间反而接近甚至更短。5.2 超参数敏感性我做了几组单变量扫描结论如下。第一beta10.9相对稳定改成 0.8 或 0.95 影响都在可接受范围但beta2很敏感课程默认 0.999我试过 0.99二阶动量窗口从 1000 步缩到 100 步前期震荡明显加剧。原因是这个任务的梯度噪声本来就大需要更长的平滑窗口。第二eps一旦大于 1e-6Adam 的更新幅度会被整体压小曲线整体向右平移看起来像学习率调小了。所以eps不是随便给个小数它在梯度很小时会主导分母。第三学习率衰减率decay_rate不宜过大。我试过设成 5第 10 个 epoch 学习率就掉到不到原来的五分之一训练后期基本停滞。设成 1 是比较稳的选择。超参数试过的取值最终采用选择理由batch_size16/32/64/128/25664每 epoch 更新次数落在 30-100 的舒适区beta10.8/0.9/0.950.90.8 加速不足0.95 过冲beta20.99/0.999/0.99990.9990.99 太抖0.9999 反应太慢lr05e-4/7e-4/1e-37e-4配衰减使用1e-3 前期会炸eps1e-8/1e-6/1e-41e-8大于 1e-6 开始压制更新幅度6. 踩过的坑与排查速查表这部分是我认为整份作业最值钱的地方因为前面那些公式在任何一篇教程里都能找到而这些坑只能自己踩。6.1 典型故障现象与定位现象可能原因处理方式loss 直接变 nan学习率过大、未做输入归一化、损失里出现了 log(0)先降 lr 十倍再检查输入是否缩放到同一量级BCELoss 前把输出 clamp 到 [1e-7, 1-1e-7]loss 完全不动参数没参与计算图、学习率过小、梯度被 detach打印几个参数的 grad 看是不是 None 或全零每个 epoch 末尾 loss 回升尾部小 batch 噪声大丢掉最后一个不满的 batch或者把 batch_size 调成能整除Adam 训练集极低但测试集差小样本 高维特征导致过拟合加 L2、降网络宽度、早停各次运行结果差异很大种子没固定、mini-batch generator 被复用污染见 2.2 的种子配置手写 Adam 与官方实现数值对不上偏差修正的 t 写错、eps 位置不同、momentum 少了 (1-β)逐参数打印差值先对拍单步再对拍多步有一类问题特别值得单拎出来说loss 不降但也不报错。这属于最难查的一类因为没有任何异常信息。我的排查顺序固定为先看参数梯度是否非零再看单步更新后参数是否真的变了再看 loss 对参数的敏感度。三步下来基本能定位到是梯度断了还是梯度太小还是更新方向反了。6.2 数值稳定性细节BCELoss 在预测值接近 0 或 1 时log 会趋向负无穷梯度爆炸。PyTorch 的实现内部做了log-sum-exp的稳定化处理但你自己写 NumPy 版的时候很容易漏。我在 NumPy 版里踩过loss 在第 800 步左右突然变成 nan原因就是某个样本的预测恰好是 1.0。另一个细节是torch.sqrt对负数会返回 nan 而不是报错。RMSprop 和 Adam 里按理说 s 不会为负但如果你不小心在别处复用了同一个字典、或者 s 初始化成了别的值sqrt 会安静地给你一个 nan然后整个参数变 nan一路传播下去。我现在的习惯是在初始化时加一句断言把形状和数值范围都检查一遍代价是几行代码收益是省下几小时的排查。提示调试优化器时先关掉学习率衰减、关掉 mini-batchbatch_size 全量用 1 到 2 个 epoch 做单步对拍。确认单步更新数值一致之后再逐个把变量加回来。这个控制变量的顺序比随机试错快得多。还有个跟性能相关的细节手写循环里对每个参数做 Python 层循环在参数量大的时候会很慢。这份作业的网络很小影响不大但如果拿去跑真实模型建议用torch._foreach_*系列函数或者直接转成官方优化器。手写版本的定位是教学与验证不是生产可用。6.3 与官方优化器对拍的实操方法我最后做的一件事是把每个手写优化器跟torch.optim的对应实现做数值对拍。方法是构造一个固定的小网络注入固定梯度跑一步比较参数差值。p_hand {k: v.clone() for k, v in model.named_parameters()} p_torch {k: v.clone() for k, v in model.named_parameters()}需要注意的学习率换算手写 Momentum 因为带了(1-β)要跟官方对齐得把 lr 乘1/(1-β)。Adam 的官方实现里eps放在根号外和原版一致直接对拍即可。RMSprop 官方实现默认带momentum0且分母是sqrt(s) eps也对得上。对拍通过之后我对这份手写代码的信心才真正建立起来。这也是我建议每个人做的一步不是怀疑自己写错而是通过另一个独立实现来确认自己理解了每个参数的含义。7. 几句实际操作中的体会写完整份作业最大的收获不是记住了 Adam 的公式而是搞清楚了几个参数之间的耦合关系。学习率、batch_size、β 这三者是互相牵制的batch_size 减半梯度噪声翻倍你就得相应降低学习率或者提高 β 来平滑反之亦然。这个联动关系在任何文档里都不会写得很清楚只能通过反复调参建立肌肉记忆。另一个体会是关于中英对照这件事本身。一开始我纯粹是为了方便自己看论文写完之后发现双语注释最大的价值是强迫你把每行代码的意图说清楚。中文那句为什么如果写不出来通常说明你其实没搞懂这行。我大概有三四处注释写了一半卡住回头补数学推导才补上那几处恰恰就是后来最容易出错的地方。如果要把这份作业继续往下做我个人的方向是加两个东西一个是把二阶梯度的思路扩展到 AdamW权重衰减和 L2 正则不是一回事另一个是把 mini-batch 的顺序做成可复现的采样器方便做严格的消融实验。这两块都不难但能把这份教学代码往实用方向推一步。