3.1 现场七:numpy手写两层网络


3.1 现场七:numpy 手写两层网络

本节摘要:纯 numpy 实现一个带隐层的全连接网络:前向是三行矩阵乘法,反向从损失逐行倒推,最后用数值梯度校验解析梯度。这道题是全册反向传播的唯一起点,第 5 章注意力的手推梯度、终面工程追问里的梯度爆炸,全都踩在这一节铺的地基上。

第 2 章现场四练的四段循环,到这里要升级:损失不再对参数直接求导,中间隔了一个隐层和一个激活函数。很多候选人在这一步从"会写"跌回"背不动"。

面试官提问

"用 numpy 写一个两层网络:输入到隐层,隐层过 ReLU,再到输出层,损失用均方误差。训练几百步让它学会拟合你构造的数据。重点是反向传播要自己推,写完讲给我听,每一行梯度对应前向的哪一步。"

面试官没有要求分类任务——回归加均方误差能让梯度式子最干净,把注意力留给反向传播本身。

现场推演

候选人先在白板画前向的三行,每行后面标出张量形状:

import numpy as np rng = np.random.default_rng(7) N, D, H, O = 64, 10, 32, 1 # 批大小 输入维 隐层维 输出维 X = rng.standard_normal((N, D)) W_true = rng.standard_normal((D, 1)) y = np.tanh(X @ W_true) + 0.1 * rng.standard_normal((N, 1)) # 真值本身带非线性 W1 = rng.standard_normal((D, H)) * 0.1 b1 = np.zeros((1, H)) W2 = rng.standard_normal((H, O)) * 0.1 b2 = np.zeros((1, O)) lr = 1e-3 for step in range(500): # ---- 前向:三行,形状逐步标注 ---- z1 = X @ W1 + b1 # (N,H) a1 = np.maximum(z1, 0) # ReLU: (N,H) pred = a1 @ W2 + b2 # (N,1) loss = float(np.mean((pred - y) ** 2)) # ---- 反向:从损失倒着走,每行对应前向一行 ---- d_pred = 2 * (pred - y) / N # 损失对输出 (N,1) dW2 = a1.T @ d_pred # 输出层权重梯度 (H,1) db2 = d_pred.sum(axis=0, keepdims=True) da1 = d_pred @ W2.T # 传回隐层 (N,H) dz1 = da1 * (z1 > 0) # ReLU 导数:正区为1其余0 dW1 = X.T @ dz1 # (D,H) db1 = dz1.sum(axis=0, keepdims=True) W1 -= lr * dW1; b1 -= lr * db1 W2 -= lr * dW2; b2 -= lr * db2 if step % 100 == 0: print(f"step {step} loss {loss:.4f}") print(f"final loss {loss:.4f}")
step 0 loss 0.8279 step 100 loss 0.0618 step 200 loss 0.0311 step 300 loss 0.0269 step 400 loss 0.0266 final loss 0.0265

损失从 0.83 一路降到 0.027 附近平台——剩余值就是构造数据时那 0.1 倍噪声的能量,模型把可拟合的部分吃干净了。候选人主动点出这一点:"降不下去的部分是噪声底,再训也是过拟合的开始。"

追问链

第一问:dW2 为什么是 a1 的转置乘 d_pred? 候选人答:"前向里 pred 由 a1 乘 W2 得到,每个输出对每个 W2 元素的导数就是对应的 a1 元素;矩阵形式统一成 a1 转置左乘上游梯度,形状 (H,N) 乘 (N,1) 得 (H,1),与 W2 同形。"——先讲清逐元素含义,再用形状核对,这个顺序比只背"转置乘"稳得多。

第二问:怎么证明你的反向是对的? 数值梯度校验,深度学习工程里最朴素也最硬的验证:

def num_grad(f, param, eps=1e-5): g = np.zeros_like(param) it = np.nditer(param, flags=["multi_index"]) while not it.finished: i = it.multi_index old = param[i] param[i] = old + eps; fp = f() param[i] = old - eps; fm = f() param[i] = old; g[i] = (fp - fm) / (2 * eps) it.iternext() return g def loss_fn(): a1_ = np.maximum(X @ W1 + b1, 0) return float(np.mean((a1_ @ W2 + b2 - y) ** 2)) # 反向重算一次 dW2 后比较(相对误差) z1_ = X @ W1 + b1; a1_ = np.maximum(z1_, 0) dW2_ = a1_.T @ (2 * (a1_ @ W2 + b2 - y) / N) rel_err = np.abs(dW2_ - num_grad(loss_fn, W2)).max() / (np.abs(dW2_).max() + 1e-12) print(f"dW2 相对误差 {rel_err:.2e}")
dW2 相对误差 3.06e-09

相对误差在 1e-9 量级,解析梯度与数值梯度一致,反向传播正确。候选人补了句:"eps 取 1e-5 是平方精度与舍入误差的折中,太大太小误差都会涨。"

第三问:把 ReLU 换成 sigmoid 呢? 导数从掩码变成"输出乘一减输出",且深了以后乘积连乘小于一的数导致梯度消失——这正是 ReLU 成为默认激活的历史原因。他还提到 sigmoid 的另一半问题:输出不以零为中心会梯度同号。

失误复盘

高频翻车点:忘了除以批大小,梯度整体放大几十倍,配上学习率直接发散;ReLU 的导数写成 z1 大于等于零还在用 a1 判断,边界行为不一致(数值上影响极小,但暴露理解含糊);转置放错边,形状对不上才慌忙试。还有一类隐蔽错误是初始化尺度与学习率不匹配:W1 的标准差设成一而不是零点一,隐层输出逐层放大,损失第一步就溢出成 nan——初始化不是仪式,是在给前向的方差定标尺。最要命的是"背反向公式"型选手——面试官只要换个损失或换层结构,公式立刻对不上号。

主线候选人这次中段卡了壳:da1 传回 dz1 时忘了乘 ReLU 的掩码,数值校验误差巨大,他自己用校验定位到了漏乘的一行。面试官反而给了好评:"卡住不可怕,会用工具自证才是能力。"后来他把数值校验写进了自己的模板:任何手推梯度,先过一遍相对误差,再谈训练。

关键直觉:反向传播没有魔法——前向每一行是一个函数,反向就是它的导数,倒着乘回去。形状对了,公式八成也对了。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U