1. 这份作业的骨架:C2_W2 到底在测什么
C2_W2 是圈内对某门公开课专项课程"第二门课第二周编程作业"的约定式命名,作业本体是一份中英对照的 Notebook,核心任务不是训练出一个多准的模型,而是把四种优化算法从公式手写一遍,再用 PyTorch 复现一遍做交叉验证。如果你拿到的是C2_W2_Assignment_中英_Pytorch这类命名的文件,说明它已经被后来的人翻译过、并且尝试把 numpy 版本改写成了 PyTorch 版本——这中间会踩的坑,比作业本身难得多。
我第一次打开这份作业时以为它很简单:不就是 mini-batch 梯度下降、Momentum、RMSprop、Adam 四个东西吗。结果真正动手才发现,作业里塞了一堆"看起来是细节、实际决定成败"的设计——指数加权平均的偏差校正、mini-batch 尾巴的处理、学习率衰减的三种写法、1/sqrt 还是 1/(1+decay*t)。这些细节在原版里是用 numpy 手推的,每一行都能对上号;换成 PyTorch 之后,框架帮你隐式地做了其中一部分、又故意不做另一部分,你就很容易写出"跑得通但数值对不上"的代码。
这份内容适合三类人看。第一类是正在做这门作业、想搞清楚每行代码为什么这么写的同学;第二类是已经做完但想用 PyTorch 重写一遍、加深理解的从业者;第三类是根本没做过这份作业、但想系统搞明白"深度学习优化器到底怎么实现"的开发者。三类人的共同需求是一样的:要知道框架封装了什么、暴露了什么、哪些地方必须自己对齐。
提醒:作业里用到的网络非常小,隐藏层只有 [5, 2] 这种量级,很多人以为这是"教学简化",其实是为了让四种优化器在几分钟内跑出可对比的损失曲线。不要因为网络小就跳过维度推导,那才是后面所有 bug 的源头。
2. 原始作业的四个模块与它们的设计意图
2.1 模块拆解:除了优化器,还有两件容易被忽略的事
把作业拆开,实际包含四块内容,其中两块是优化器、两块是"配套基础设施",后者反而更容易出问题。
| 模块 | 具体内容 | 在教学上的作用 | 复现难度 |
|---|---|---|---|
| 数据管线 | random_mini_batches打乱并切批 | 训练稳定性的前提 | 中 |
| 参数初始化 | He 初始化,sqrt(2/n[l-1]) | 让梯度不在起点就爆炸 | 低 |
| 优化器 | GD / Momentum / RMSprop / Adam | 对比收敛速度 | 高 |
| 学习率调度 | 分段、指数、1/(1+decay·t) | 观察后期精细下降 | 中高 |
这块表格不是装饰,它对应着我见过的绝大多数"作业跑不通"案例。有人上来就改优化器,结果数据根本没打乱、每个 batch 都是同一批类别,曲线当然平;有人把初始化写成randn * 0.01,梯度小到 Adam 的偏差校正都救不回来。
2.2 为什么原始版本坚持用 numpy 手写
课程讲师故意不给你torch.optim,原因很朴素:优化器的公式只有手写过一遍,你才知道学习率、beta、epsilon 各自在哪个位置起作用。
举个具体例子。Momentum 的更新里有一项偏差校正:
v["dW" + str(l+1)] = beta1 * v["dW" + str(l+1)] + (1 - beta1) * grads["dW" + str(l+1)] v_corrected["dW" + str(l+1)] = v["dW" + str(l+1)] / (1 - np.power(beta1, t)) parameters["W" + str(l+1)] -= learning_rate * v_corrected["dW" + str(l+1)]t是当前迭代次数,从 1 开始。第一轮迭代时v只有(1-beta1)*g这么大,如果不除1-beta1^t,速度会被压到原来的十分之一,前期几乎不动。这个"除以(1-beta1^t)"的动作,在 PyTorch 的torch.optim.SGD(momentum=...)里是不存在的。框架选择用不同的 momentum 定义(buffer 直接累加不乘1-beta),从而绕开了偏差问题。这就是为什么你拿 numpy 版和 torch 版对照,前十步的参数会差很多——不是谁算错了,是定义不同。
2.3 用 PyTorch 重写的真正价值
重写的价值不在于"少写几行",而在于用两套独立实现互相当裁判。我的做法是:同一个随机种子、同一份初始化权重、同一批 mini-batch 顺序,分别用 numpy 和 torch 跑 200 步,把每一步的W1展平后算欧氏距离。如果距离在 1e-10 量级,说明两边数学一致;如果第 3 步就跳到 0.1,那一定是某处的定义不一样。
这个方法比盯着损失曲线看管用得多。损失曲线是"结果",参数轨迹是"过程",过程对不上而结果看起来差不多,往往意味着两个错误互相抵消了,换个数据集立刻现原形。
3. 数据管线:mini-batch 划分是第一个必踩的坑
3.1 从 numpy 数组到 Tensor 的形状约定差异
原作业里的数据是(特征维, 样本数)的布局,这是经典的"列优先"风格:train_X.shape = (12288, 1080),train_Y.shape = (1, 1080)。而 PyTorch 的Dataset/DataLoader默认按"行优先"取样本,即(样本数, 特征维)。
这个转置看起来只是.T一下,但它引发的问题会一路蔓延到:
- 参数初始化维度写反,
W变成(n_out, n_in)和(n_in, n_out)混用; - 反向传播时矩阵乘法顺序错,报错是"mat1 and mat2 shapes cannot be multiplied",但真正错的地方在两小时前写的初始化里;
- batch 切片切到了特征维上,模型还能跑,只是学了个空。
我的建议是:在数据管线入口处做一次性的、显式的转置,并在紧接着的下一行打印形状。不要依赖"我记得转过"这种记忆。
import numpy as np import torch from torch.utils.data import TensorDataset, DataLoader # 原作业布局: (n_features, m) X_np, Y_np = train_X.T, train_Y.T.squeeze() # -> (m, n_features), (m,) X = torch.from_numpy(X_np).float() Y = torch.from_numpy(Y_np).float().unsqueeze(1) print(X.shape, Y.shape) # 顺手打出来,别省这一行 dataset = TensorDataset(X, Y)注意.float()不能省。numpy 默认 float64,PyTorch 的参数默认 float32,两边一相乘就报 dtype 不匹配;就算侥幸跑起来,精度也对不上,你根本没法做前面说的那种 1e-10 级别的比对。
3.2 打乱、切批与"最后一截尾巴"
原作业的random_mini_batches有几个关键动作,逐个看:
np.random.seed(seed) permutation = list(np.random.permutation(m)) shuffled_X = X[:, permutation] shuffled_Y = Y[:, permutation].reshape((1, m)) num_complete_minibatches = math.floor(m / mini_batch_size) for k in range(num_complete_minibatches): mini_batch_X = shuffled_X[:, k*mini_batch_size : (k+1)*mini_batch_size] ... # 处理不足一个 batch 的尾巴 if m % mini_batch_size != 0: mini_batch_X = shuffled_X[:, num_complete_minibatches*mini_batch_size : m]三个细节值得单独拎出来说。
第一个是 X 和 Y 必须用同一个 permutation。我见过有人为了"看起来更随机",对 X 和 Y 各调一次np.random.permutation,结果图像和标签彻底错配,模型训练 100 轮准确率卡在 50% 上下——二分类嘛,瞎猜就是 50%。这个 bug 极难通过看损失曲线发现,因为损失确实在缓慢下降(模型学到了标签的分布先验)。
第二个是尾巴不能丢。作业明确保留了最后不满一批的样本。如果换成drop_last=True,在 m=1080、batch=64 的情况下会丢掉 56 个样本,占比 5%。单次影响不大,但每个 epoch 丢的都是"同一批位置"的样本(因为打乱种子固定的话,被丢的往往是排序后落在末尾的那批),长期下来相当于给数据集做了有偏采样。
第三个是种子的位置。np.random.seed(seed)应该放在函数开头、permutation之前,而不是每个 epoch 重置一次。如果每个 epoch 用同一个种子,你会得到完全相同的打乱顺序,等于没打乱。这个坑我在早期代码里踩过,症状是训练损失下降得异常平滑,好得不像话。
3.3 用 DataLoader 复刻手写采样器的等价性验证
PyTorch 的DataLoader(shuffle=True)内部用的是 torch 自己的随机数发生器,和 numpy 的 permutation 结果不可能一样。所以"数值对齐"这件事要分两步走:
第一步,先关掉随机性做等价性验证。设shuffle=False,手写一个确定性的切批函数,两边跑同样的 batch 顺序,确认单步梯度一致。
第二步,再打开随机性看统计特性。检查每个 epoch 的 batch 数量、最后一个 batch 的大小、整个 epoch 内样本是否被完整覆盖一次。
loader = DataLoader(dataset, batch_size=64, shuffle=True, drop_last=False) for epoch in range(3): seen = 0 for xb, yb in loader: seen += xb.size(0) print(epoch, seen) # 必须等于 m如果这个数不等于 m,说明drop_last开了,或者数据集本身有问题。这行打印是我在每份复现代码里都保留的,成本几乎为零,收益是省掉半天排查。
4. 四个优化器的 PyTorch 写法与数值对照
4.1 baseline:mini-batch 梯度下降的等价写法
先用最朴素的方式跑通 baseline,不引入任何框架优化器,纯手写:
def sgd_step(params, grads, lr): with torch.no_grad(): for p, g in zip(params, grads): p -= lr * g对应原作业里的:
for l in range(L): parameters["W" + str(l+1)] -= learning_rate * grads["dW" + str(l+1)] parameters["b" + str(l+1)] -= learning_rate * grads["db" + str(l+1)]这两段是完全等价的,前提是grads已经由loss.backward()填好。要注意loss.backward()是累加梯度而不是覆盖,所以每个 batch 开头必须optimizer.zero_grad()或手动p.grad = None。手写版本里如果忘了清空,梯度会跨 batch 累积,效果相当于学习率随步数线性增长——前几步看着正常,几十步之后直接发散。这是新手最常见的发散原因之一,而且报错信息完全不会提示你"梯度没清零"。
4.2 Momentum:框架替你做了什么,又漏了什么
这是整份作业里最值得对照的一处。原作业的 Momentum 有偏差校正,PyTorch 的没有,两边定义不同。
| 项目 | 原作业(numpy) | torch.optim.SGD(momentum=β) |
|---|---|---|
| 速度更新 | v = βv + (1-β)g | buf = β·buf + g |
| 偏差校正 | v/(1-β^t) | 无 |
| 参数更新 | p -= lr·v_corrected | p -= lr·buf |
| β=0.9 时前期步长 | 约lr·g(校正后) | 约lr·g/(1-β)≈10·lr·g |
看最后一行:同样的lr,torch 的 momentum 版本前期实际步长是 numpy 版的约 10 倍。很多人把 numpy 代码直接搬成torch.optim.SGD(params, lr=0.01, momentum=0.9),然后发现损失直接飞掉,第一反应是"学习率太大",于是调到 0.001 能跑了——但这只是把两套不同定义的参数硬凑到一起,换台机器、换个数据就又不灵了。
正确的对齐方式有两种,选一种并写进注释:
- 方案 A:把
lr除以(1-β),即lr_torch = lr_numpy / (1 - beta1),让前期步长一致; - 方案 B:自己实现带偏差校正的 momentum,不调用
torch.optim。
我一般用方案 B 做教学对照,方案 A 做工程落地。方案 A 的问题在于后期两者还是会分叉,因为偏差校正的影响随 t 增大指数衰减,前期补齐了、后期又偏了。真要严格一致,只能自己写。
4.3 RMSprop 的 epsilon 位置:分母里还是分母外
RMSprop 的公式看着简单,epsilon放哪儿却是个真问题。
原作业写法:
s["dW"] = beta2 * s["dW"] + (1 - beta2) * np.square(grads["dW"]) s_corrected = s["dW"] / (1 - np.power(beta2, t)) parameters["W"] -= learning_rate * grads["dW"] / (np.sqrt(s_corrected) + epsilon)注意分母是sqrt(s_corrected) + epsilon,即 epsilon 在根号外。PyTorch 的实现同样是sqrt(square_avg) + eps,位置一致,但默认eps=1e-8且没有偏差校正。原作业用epsilon=1e-8,两边默认值刚好对上,这点比 Momentum 幸运。
还有一个隐蔽差异:PyTorch 的 RMSprop 里有alpha参数对应原作业的beta2(注意不是 beta1),默认 0.99,而作业里常用的 RMSprop beta 是 0.999。这两个值差一个数量级,带来的效果差异在前期很明显——0.99 的记忆更短、响应更快,0.999 更平滑但起步慢。抄参数的时候一定要逐个核对,别只看变量名。
4.4 Adam 的两个 beta 与 t 的起始值
Adam 是四个里面最容易"看起来对、实际错"的,因为它有两个偏差校正,而且t的起始值直接决定第一步的步长。
v = beta1 * v + (1 - beta1) * g v_corrected = v / (1 - beta1 ** t) s = beta2 * s + (1 - beta2) * (g ** 2) s_corrected = s / (1 - beta2 ** t) p -= lr * v_corrected / (torch.sqrt(s_corrected) + eps)t从 1 开始,第一步1-beta1^1 = 0.1,v被放大 10 倍;1-beta2^1 = 0.001,s被放大 1000 倍,开根号后 31.6 倍。分子分母同时被放大,比值大致回到 1 附近,所以 Adam 的第一步步长约等于lr。如果你把t从 0 开始、beta**0 = 1,分母变成 0,直接除零报错——或者在某些实现里被静默处理成 NaN,训练三分钟后损失变 NaN,而你还在找数据的问题。
PyTorch 的torch.optim.Adam内部维护step计数器,行为与原作业一致,betas=(0.9, 0.999)、eps=1e-8也都对得上。唯一需要注意的是:如果你手动改过p.grad或者多次调用step(),计数器会往前走,此时偏差校正的幅度和你以为的不一样。做严格对照实验时,我宁愿自己写优化器循环,可控性更高。
5. 学习率衰减的三条路线与实测差异
5.1 作业里的两个公式:1/(1+decay·t) 与 0.95^t
原作业要求实现:
def update_lr(learning_rate0, epoch_num, decay_rate): return 1 / (1 + decay_rate * epoch_num)以及练习题里提到的指数版learning_rate0 * 0.95 ** epoch_num。
这两条曲线的形状完全不同,而且差别在后期才显现:
1/(1+decay·t)是双曲衰减,衰减速度越来越慢,但永不为零。decay=1 时,第 1 轮降一半、第 10 轮降到 1/11、第 100 轮降到 1/101。0.95^t是指数衰减,每轮固定乘 0.95,前期掉得慢、后期掉得"肉眼可见地快",第 50 轮后只剩 7.7%。
我在同一份数据上跑了 50 个 epoch 对比,结论是:中小规模数据集上,双曲衰减更容易在后期继续榨出一点精度,因为它始终保留了一个非零的学习率;指数衰减在后期几乎冻结,适合"早停"策略配合使用。这个结论不绝对,但至少说明不能随手选一个公式了事。
5.2 用 lr_scheduler 复刻作业里的公式
PyTorch 提供了LambdaLR,可以把任意公式塞进去:
import torch.optim.lr_scheduler as lr_scheduler optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9) # 复刻 1/(1+decay*t),注意 scheduler 的 step 从 0 开始 lam = lambda epoch: 1.0 / (1.0 + 1.0 * epoch) scheduler = lr_scheduler.LambdaLR(optimizer, lr_lambda=lam)这里有个非常容易搞错的地方:LambdaLR传给lr_lambda的epoch从 0 开始,而原作业的epoch_num从 1 开始(因为公式里1 + decay*epoch_num在 epoch_num=0 时等于 1,即不衰减)。如果直接套,你的第一轮就会开始衰减,学习率是 0.005 而不是 0.01。
修正方式有两种:lam = lambda e: 1.0 / (1.0 + decay * (e + 1)),或者接受从 0 开始、把公式理解成"第 0 轮不衰减"。两种都行,但要在注释里写清楚,否则半年后回来读代码的你会骂人。
另外,scheduler.step()的调用位置也影响结果。PyTorch 1.1 之后推荐把scheduler.step()放在 epoch 循环里、所有 batch 训练完之后,而不是每个 batch 之后。放错位置的直接后果是:50 个 epoch、每 epoch 17 个 batch,学习率被衰减了 850 次而不是 50 次,学习率瞬间归零,训练直接卡死。
5.3 衰减过猛的症状与排查
衰减出问题的表现很有辨识度:训练损失在前期正常下降,某个点之后变成一条几乎水平的线,但还没有完全平。
和"模型容量不足"的区别在于:容量不足的平线是全程平滑的、下降速率均匀;衰减过猛的平线是突然"折"一下,前后斜率明显不同。我通常这样确认:
lrs = [] for epoch in range(50): train_one_epoch() lrs.append(optimizer.param_groups[0]["lr"]) scheduler.step() print(lrs[:5], lrs[-5:])如果最后几个 epoch 的 lr 已经小到 1e-6 量级,而损失还在 0.3 以上,那就是衰太狠了。这时候要么把decay_rate调小,要么改用双曲衰减。
注意:不要用"最后几轮损失下降了多少"来判断学习率是否合适,因为曲线本身就在变缓。要看的是"损失曲线在 lr 骤降的位置有没有出现明显的斜率断点",这需要把 lr 曲线和 loss 曲线画在同一张图上对照。
6. 中英对照版整理时踩过的三个软坑
6.1 术语翻译分歧:同一个词在不同教材里是两个东西
整理双语版时最耗时的不是翻译正文,是统一术语。举几个我实际改过的:
| 英文 | 常见译法 A | 常见译法 B | 我采用的 | 理由 |
|---|---|---|---|---|
| exponentially weighted average | 指数加权平均 | 指数加权移动平均 | 指数加权平均 | 短,且"移动"在中文语境里容易和滑窗混淆 |
| bias correction | 偏差修正 | 偏差校正 | 偏差校正 | 与"偏差-方差"里的"偏差"区分度更好 |
| mini-batch | 小批量 | 微批次 | 小批量 | 代码注释里长度更短,对齐好看 |
| learning rate decay | 学习率衰减 | 学习率退火 | 学习率衰减 | "退火"在优化领域另有专门含义 |
术语不统一的代价不是"读者看不懂",而是你自己在写注释时会前后矛盾。我一开始列了两栏对照,中途忘了,结果同一个 notebook 里bias_correction的注释一会儿写"偏差修正"一会儿写"偏差校正",后来做全文搜索替换才修掉。
建议做法:在文件最开头放一张术语表,用 Markdown 表格写死;写代码注释时只查表,不凭记忆。
6.2 公式与代码注释的同步维护
双语版里公式一般用 LaTeX 写,代码注释里又要重复一遍关键式子,这两处极易脱节。
我的做法是:公式只在 Markdown 单元里写一次,代码注释只引用编号。
# 对应公式 (3): v = beta1 * v + (1 - beta1) * grad # v_hat = v / (1 - beta1 ** t)这样即使后面调整了符号(比如把beta1改成beta),只需要改公式区和这一行注释,不会出现"注释里写(1-β)、代码里写(1-β1)"的对不上。
另一个实操建议是:把每个优化器的实现拆成独立的 cell,每个 cell 开头一段双语说明、中间代码、结尾一段双语"预期输出"。这样导出成 HTML 或 PDF 时,读者不需要跳到别处找解释。
6.3 文件命名与版本管理
C2_W2_Assignment_中英_Pytorch这种命名的问题在于:里面混了课程编号、语言、框架三个维度,后面一旦要改框架或者加语种,文件名就不够用了。
我现在的习惯是拆开:
c2_w2/ ├── README.md # 术语表 + 环境 + 结论摘要 ├── c2_w2_numpy_ref.ipynb # 原始实现,作为对照基准 ├── c2_w2_torch.ipynb # PyTorch 复现 └── results/ ├── loss_curves.png └── param_diff.csv # 每步参数差的记录把"对照基准"单独存一份,是这套结构里最重要的一条。因为你在改 torch 版的时候,难免会想"是不是 numpy 版写错了",这时候直接跑参考实现,两分钟就能排除怀疑,不用靠脑子回忆。
7. 一份可复用的排查清单
7.1 数值对不上时的五个高频原因
按我实际遇到的频率排序:
| 序号 | 症状 | 常见原因 | 快速验证 |
|---|---|---|---|
| 1 | 第 1 步就对不上 | 初始化不同(种子、缩放系数) | 打印初始W1[0,:5]两边比对 |
| 2 | 前 10 步对不上、之后接近 | Momentum 定义差异(有无偏差校正) | 关掉 momentum,纯 SGD 对比 |
| 3 | 损失为 NaN | Adam 的t从 0 开始,或除以了 0 | 检查beta**t的 t 初值 |
| 4 | 损失震荡剧烈 | batch 没打乱,或梯度没清零 | 打印前 3 个 batch 的标签分布 |
| 5 | 精度卡在 50% | X 与 Y 用了不同的 permutation | 单 batch 内检查 x-y 是否配对 |
第 5 条我单独说明一下怎么验证。取一个 batch,把xb送进模型前向、拿到 logits,然后看xb[i]对应的标签是不是yb[i]。更土但更快的办法是:torch.equal(Y[perm], Y_shuffled)这种检查在打乱逻辑写错时会直接暴露。
7.2 收敛行为的可视化与断言
光看图不够,最好把"应该满足的性质"写成断言,跑一次就过一遍。
# 断言 1: 损失整体单调性(允许小波动) losses = train_history["loss"] violations = sum(1 for i in range(1, len(losses)) if losses[i] > losses[i-1] * 1.2) assert violations < len(losses) * 0.1, f"损失波动过大: {violations} 次" # 断言 2: 梯度范数不应在正常区间外 gn = torch.sqrt(sum((p.grad ** 2).sum() for p in model.parameters())) assert 1e-8 < gn.item() < 1e3, f"梯度范数异常: {gn.item()}" # 断言 3: 参数确实在变 w_before = model[0].weight.detach().clone() train_one_epoch() assert not torch.allclose(w_before, model[0].weight.detach()), "参数没有更新"这三条断言覆盖了我在实操中见过的绝大多数"静默失败":模型在训练但什么都没学到、梯度爆炸但被 clip 掩盖、优化器忘记绑定参数。写这三行的时间是五分钟,能省掉的时间是半天起。
7.3 我个人的几条经验
第一条,先跑通 numpy 版再碰 torch 版。听起来反直觉——既然要用框架,为什么不直接用?因为 numpy 版的每一步都摆在明面上,你可以打印任意中间量;torch 版的autograd把反向传播藏起来了,出问题时你只能猜。用 numpy 版建立"正确结果长什么样"的直觉,再拿 torch 版去逼近,这个顺序不能反。
第二条,对照实验时把随机种子固定到每个可能的地方:np.random.seed、torch.manual_seed、torch.cuda.manual_seed_all、DataLoader(worker_init_fn=...)。我吃过一次亏:单进程跑得好好的,加到 4 个 worker 之后每次结果都不一样,查了一下午才发现是 worker 的随机状态没同步。
第三条,关于那份中英对照版,别追求一次翻译到位。先机翻一遍跑通代码,再逐段改术语,最后统一格式。反过来做(先精翻再跑)的话,代码报错时你会怀疑是翻译改坏了逻辑,排查成本翻倍。
第四条,学习率相关的结论,永远在你自己的数据上验证一遍再信。作业里给的0.0007是配合那个 12288→5→2→1 的小网络调出来的,换到别的网络上大概率不适用。我在另一份 128 维输入的任务上试过,同样的 0.0007 让模型 20 个 epoch 都没动地方,调到 0.01 才正常。
最后分享一个我最近才养成的小习惯:在 notebook 最末尾留一个"随手记录"的 cell,专门存那些"跑出来了但还没想明白为什么"的现象,比如"Adam 在 epoch 15 附近有个 loss 小凸起,换 seed 后消失"。这些东西写进正文太重,丢掉又可惜,放在末尾当工作日志,过一阵回头看往往能串起来。