python神经网络编程入门(十八)——RNN BPTT算法(下):代码实现与梯度裁剪
引言:纸上推完了公式,这一篇把它变成能跑的代码
先花 30 秒回顾上一篇(第十七篇)。我们在纸上干完了三件事:
- 定义了损失——MSE 损失 L=12∑t∥yt−tt∥2L = \frac{1}{2}\sum_t\|y_t - t_t\|^2L=21∑t∥yt−tt∥2,以及输出层误差 δyt=yt−tt\delta_{y_t} = y_t - t_tδyt=yt−tt;
- 推出了核心递推——δt=(δt+1Whh⊤+δytWhy⊤)⊙(1−ht2)\delta_t = (\delta_{t+1}W_{hh}^{\top} + \delta_{y_t}W_{hy}^{\top}) \odot (1-h_t^2)δt=(δt+1Whh⊤+δytWhy⊤)⊙(1−ht2),从最后一步倒着推回第一步;
- 集齐了三个梯度公式——∂L/∂Why=∑tht⊤δyt\partial L/\partial W_{hy} = \sum_t h_t^{\top}\delta_{y_t}∂L/∂Why=∑tht⊤δyt、∂L/∂Whh=∑tht−1⊤δt\partial L/\partial W_{hh} = \sum_t h_{t-1}^{\top}\delta_t∂L/∂Whh=∑tht−1⊤δt、∂L/∂Wxh=∑txt⊤δt\partial L/\partial W_{xh} = \sum_t x_t^{\top}\delta_t∂L/∂Wxh=∑txt⊤δt,还用数值梯度验算过,误差 10−1110^{-11}10−11 量级。
但这里有个"落差":公式漂亮,代码怎么写? 第十七篇结尾我们放了句狠话——“把递推式一行行翻译成 NumPy”。这一篇就是来兑现的。
打个比方:第十七篇我们拿到了地图(整条推导链),这一篇是真正开车上路——把地图翻译成导航软件的每一步动作;再顺手做两件驾驶员的日常:出发前检查车况(数值梯度体检),下坡时踩刹车(梯度裁剪)。
你可能会想:公式都验证过了,抄成代码还不简单?真不一定。转置写反、漏乘 tanh\tanhtanh 导数、时间步累加少加一项、批次忘了平均——任何一个细节错了,梯度就错得悄无声息,训练要么不动、要么爆炸。所以这一章真正要学的不是"抄代码",而是一套让自己不写错的流程:公式逐行对照、数值梯度体检、最后再上保险(裁剪)。
🎯 本章四大目标
- 手写完整的
rnn_backward:拿着第三章的 cache,倒序遍历时间轴,输出五个梯度;- 用数值梯度(中心差分)给反向传播做"全身体检",误差 <10−6<10^{-6}<10−6 算合格;
- 给梯度装"限速器"(梯度裁剪),亲眼看到裁剪前后训练的天壤之别;
- 把前向 / 反向 / 裁剪 / 更新封装成
RNNLayer类,跑通一个小任务。
本篇路线图:公式变代码的思路(第一节)→ 实操 A:小模型跑通(第二节)→ 数值梯度体检(第三节)→ 梯度裁剪原理(第四节)→ 实操 D:裁剪前后训练对比(第五节)→ 里程碑配图(第六节)→ 封装成 RNNLayer(第七节)→ 常见坑与 FAQ(第八节)→ 小结与下章预告(第九节)。
一、公式变代码:rnn_backward 的骨架
1.1 一张对照表,公式和代码一一对应
先看全貌。第十七篇的推导链,翻译成代码就是下面四件事(顺序不能乱):
| 第17篇的公式 | 代码里的对应动作 |
|---|---|
| δyt=yt−tt\delta_{y_t} = y_t - t_tδyt=yt−tt | delta_y = y_seq - target(每个时间步) |
| δt=(δt+1Whh⊤+δytWhy⊤)⊙(1−ht2)\delta_t = (\delta_{t+1}W_{hh}^{\top} + \delta_{y_t}W_{hy}^{\top})\odot(1-h_t^2)δt=(δt+1Whh⊤+δytWhy⊤)⊙(1−ht2) | 倒序 for 循环里一行矩阵运算 |
| ∂L/∂Why=∑tht⊤δyt\partial L/\partial W_{hy} = \sum_t h_t^{\top}\delta_{y_t}∂L/∂Why=∑tht⊤δyt | dW_hy += h_t.T @ delta_y[t] |
| ∂L/∂Whh=∑tht−1⊤δt\partial L/\partial W_{hh} = \sum_t h_{t-1}^{\top}\delta_t∂L/∂Whh=∑tht−1⊤δt | dW_hh += h_prev_t.T @ delta_z |
| ∂L/∂Wxh=∑txt⊤δt\partial L/\partial W_{xh} = \sum_t x_t^{\top}\delta_t∂L/∂Wxh=∑txt⊤δt | dW_xh += x_t.T @ delta_z |
一眼就能看出两件事:第一,循环必须倒着走(δt\delta_tδt 依赖未来的 δt+1\delta_{t+1}δt+1);第二,梯度是 += 累加出来的(权重被所有时间步共享,每个时间步的贡献都不能少)。
1.2 为什么"倒着走"?——像拆一座积木塔
回想第三章 cache 里存了什么:每个时间步的 xt,ht−1,zt,htx_t, h_{t-1}, z_t, h_txt,ht−1,zt,ht,堆叠成 (S,B,⋅)(S,B,\cdot)(S,B,⋅) 的四摞"口粮"。反向传播就像拆积木塔:只能从塔尖(最后一个时间步)往下拆,因为下面每一块的"承重"都来自上面。写成代码就是一行:
for t in range(S - 1, -1, -1): # 从最后一步倒着走到第 0 步
如果你写成 for t in range(S)(正着走),算 δ0\delta_0δ0 的时候 δ1\delta_1δ1 还没出生——直接算错。这是新手最经典的坑,第八节还会细说。
1.3 完整实现(本章的主角)
import numpy as np
def rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0):
"""前向:x:(B,S,I),返回 (h_seq:(S,B,H), y_seq:(S,B,O), cache)。
与第3章完全一致,每个时间步的中间量都存进 cache。"""
S = x.shape[1]
h_prev = h0
h_seq, y_seq = [], []
cache = {'x': [], 'h_prev': [], 'z': [], 'h': []}
for t in range(S):
x_t = x[:, t, :]
z_t = np.dot(x_t, W_xh) + np.dot(h_prev, W_hh) + b_h
h_t = np.tanh(z_t)
y_t = np.dot(h_t, W_hy) + b_y
for k, v in (('x', x_t), ('h_prev', h_prev), ('z', z_t), ('h', h_t)):
cache[k].append(v)
h_seq.append(h_t); y_seq.append(y_t)
h_prev = h_t
h_seq = np.stack(h_seq, axis=0)
y_seq = np.stack(y_seq, axis=0)
for k in cache:
cache[k] = np.stack(cache[k], axis=0)
return h_seq, y_seq, cache
def mse_loss(y_seq, target):
"""L = 1/2 * Σ_t ||y_t - target_t||^2,target:(B,S,O)"""
tgt = target.transpose(1, 0, 2) # (B,S,O) -> (S,B,O),对齐 y_seq
diff = y_seq - tgt
return 0.5 * np.sum(diff * diff)
def rnn_backward(y_seq, target, cache, W_xh, W_hh, W_hy, B):
"""拿着 cache 倒着走时间轴,输出五个梯度。
第17篇的递推公式,一行行翻译成 NumPy。"""
S = y_seq.shape[0]
tgt = target.transpose(1, 0, 2) # (B,S,O) -> (S,B,O)
delta_y = y_seq - tgt # δ_{y_t} = y_t - target_t (S,B,O)
dW_xh = np.zeros_like(W_xh)
dW_hh = np.zeros_like(W_hh)
dW_hy = np.zeros_like(W_hy)
db_h = np.zeros(W_xh.shape[1])
db_y = np.zeros(W_hy.shape[1])
delta_next = np.zeros((B, W_hh.shape[0])) # δ_{T+1} = 0:最后一步没有"未来"
for t in range(S - 1, -1, -1): # 必须倒序!
h_t = cache['h'][t] # (B,H),cache 里的现成值
d_tanh = 1.0 - h_t * h_t # tanh 闸门 (1-h_t^2)
# 直接路径(经 W_hy)+ 时间路径(经 W_hh 从未来回传),汇合后过闸门
delta_z = (np.dot(delta_next, W_hh.T) +
np.dot(delta_y[t], W_hy.T)) * d_tanh # δ_t
# 各回各家 + 时间步累加
dW_hy += np.dot(cache['h'][t].T, delta_y[t]) # Σ h_t^T δ_y_t
dW_hh += np.dot(cache['h_prev'][t].T, delta_z) # Σ h_{t-1}^T δ_t
dW_xh += np.dot(cache['x'][t].T, delta_z) # Σ x_t^T δ_t
db_h += delta_z.sum(axis=0)
db_y += delta_y[t].sum(axis=0)
delta_next = delta_z # 传递给更早的时间步
# 除以样本数 B:梯度是"整批的合力",平均一下更稳,
# 这样换批次大小也不用改学习率
return {'W_xh': dW_xh / B, 'W_hh': dW_hh / B, 'W_hy': dW_hy / B,
'b_h': db_h / B, 'b_y': db_y / B}
逐行对照第十七篇的公式,你会发现每一行都能在推导里找到出处,没有一行是"凭感觉"写的。这就是把理论翻译成代码的正确姿势:先有公式,再有代码——代码是公式的影子。
两个小细节值得记住:
delta_next的初值是 (B,H)(B, H)(B,H) 的全零矩阵,对应递推的边界条件 δT+1=0\delta_{T+1} = \mathbf{0}δT+1=0——最后一步没有"未来"可回传,这个"零"是从数学里直接搬过来的;d_tanh = 1.0 - h_t * h_t用的是 cache 里的 hth_tht 现算(ht=tanh(zt)h_t = \tanh(z_t)ht=tanh(zt),所以 1−ht21-h_t^21−ht2 就是 tanh\tanhtanh 导数),不用再去翻 ztz_tzt——这就是第三章拼命存 cache 的全部意义。
二、实操 A:同款小模型,梯度逐位对上
理论说得再热闹,不如跑一次。我们还是用第十七篇那套"小到能心算"的模型(I=2,H=3,O=2,B=1,S=2I=2, H=3, O=2, B=1, S=2I=2,H=3,O=2,B=1,S=2),权重、输入、h0h_0h0 原封不动,连目标值都一样——这样第 17 篇手算的每个中间数,都能拿来当"标准答案"。
# ---- 第3章 / 第17章同一套小模型 ----
w_xh = np.array([[0.5, -0.2, 0.3], [-0.1, 0.4, 0.2]]) # (2,3)
w_hh = np.array([[0.8, 0.1, 0.0], [0.2, 0.7, -0.1], [0.0, 0.3, 0.6]]) # (3,3)
w_hy = np.array([[0.5, -0.4], [0.2, 0.3], [-0.1, 0.6]]) # (3,2)
bh = np.array([0.1, -0.1, 0.05])
by = np.array([0.0, 0.0])
x = np.array([[[1.0, -1.0], [0.5, 0.5]]]) # (1,2,2)
h0 = np.zeros((1, 3))
target = np.array([[[1.0, -1.0], [-0.5, 0.5]]]) # (1,2,2)
h_seq, y_seq, cache = rnn_forward(x, w_xh, w_hh, w_hy, bh, by, h0)
print('损失 L =', mse_loss(y_seq, target))
grads = rnn_backward(y_seq, target, cache, w_xh, w_hh, w_hy, B=1)
for k, v in grads.items():
print(k, '\n', v)
运行输出(前向结果与第 17 篇逐位一致,损失 L=0.968125L = 0.968125L=0.968125):
损失 L = 0.9681247416169603
W_xh =
[[-0.05341102 0.06150134 0.09576618]
[ 0.43272213 -0.09239557 -0.43402304]]
W_hh =
[[ 0.22924341 -0.01867148 -0.20443155]
[-0.22924341 0.01867148 0.20443155]
[ 0.05647375 -0.00459969 -0.05036138]]
W_hy =
[[-0.10563271 0.07125223]
[ 0.29262747 -0.22690562]
[ 0.16533215 -0.14172963]]
b_h = [ 0.13624453 0.04605422 -0.07336225]
b_y = [-0.14722793 0.09495948]
拿第十七篇实操 A 手推的"标准答案"(那边打印保留 4 位小数)来逐位核对,五个参数的最大误差:
| 参数 | 最大误差(vs 第17篇手推值) |
|---|---|
| WhyW_{hy}Why | 4.78×10−54.78\times10^{-5}4.78×10−5 |
| WhhW_{hh}Whh | 4.34×10−54.34\times10^{-5}4.34×10−5 |
| WxhW_{xh}Wxh | 3.38×10−53.38\times10^{-5}3.38×10−5 |
| bhb_hbh | 4.58×10−54.58\times10^{-5}4.58×10−5 |
| byb_yby | 4.05×10−54.05\times10^{-5}4.05×10−5 |
误差全在 10−510^{-5}10−5 量级——这不是写错了,而是第 17 篇的手推值只保留到 4 位小数,这点误差就是"四舍五入"本身。也就是说:纸上手推的、代码算的,是同一个东西。递推公式翻译成代码这一步,通过。
三、数值梯度体检:给反向传播"验明正身"
3.1 为什么还要体检?——“自己写的"不等于"对的”
实操 A 只是拿"手推值"对了一遍,等于两个可能同时算错的人互相印证。万一我们的递推式本身有 bug(转置写反、漏乘 ⊙\odot⊙、tanh\tanhtanh 导数写错、少加一个时间步……),那"手推对上了"也说明不了什么。
金标准是数值梯度:不碰任何公式,只靠导数的定义来算。把某个参数的元素拨动一丁点 ε\varepsilonε,看损失怎么变——中心差分(比单边差分更准):
∂L∂θi ≈ L(θi+ε)−L(θi−ε)2ε \frac{\partial L}{\partial \theta_i} \;\approx\; \frac{L(\theta_i + \varepsilon) - L(\theta_i - \varepsilon)}{2\varepsilon} ∂θi∂L≈2εL(θi+ε)−L(θi−ε)
对每个参数元素都这么干一遍,得到"数值梯度";再和 rnn_backward 的"解析梯度"比。差值 <10−6<10^{-6}<10−6 就算合格。
💡 生活类比:解析梯度像精算师(算得快,但可能算错),数值梯度像实验员(慢,但绝对诚实)。让两个职业对同一笔账,对得上就说明精算师没算错。体检通过之后,训练时放心用快的那个——数值梯度要为每个参数元素跑两次前向,序列一长就慢到没法用,它的使命就是当"裁判",不是当"主力"。
3.2 体检代码
def numerical_grads(x, target, W_xh, W_hh, W_hy, b_h, b_y, h0, eps=1e-7):
"""对每个参数元素逐个扰动,用中心差分估计真实梯度"""
params = {'W_xh': W_xh, 'W_hh': W_hh, 'W_hy': W_hy,
'b_h': b_h, 'b_y': b_y}
def loss_with():
_, ys, _ = rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0)
return mse_loss(ys, target)
grads = {}
B = x.shape[0]
for name, P in params.items():
G = np.zeros_like(P)
for idx in np.ndindex(P.shape):
orig = P[idx]
P[idx] = orig + eps
fp = loss_with()
P[idx] = orig - eps
fm = loss_with()
P[idx] = orig
G[idx] = (fp - fm) / (2 * eps) # 中心差分
grads[name] = G / B # 与解析梯度口径一致:对批次平均
return grads
两个细节:
- 每次扰动都要走完整前向:把 P[idx]P[idx]P[idx] 拨成 θ+ε\theta+\varepsilonθ+ε,跑一次完整前向算损失;再拨成 θ−ε\theta-\varepsilonθ−ε 跑一次。不能只动局部——因为 hth_tht 一变,后面所有时间步的输出全变,时间依赖必须"真实发生"(第十七篇实操 B 讲过同样的事)。
- 除以 BBB 保持口径一致:我们的解析梯度对批次做了平均(除以 BBB),数值梯度也必须除以 BBB,不然两边天生差 BBB 倍。这一行最容易漏,漏了体检直接 FAIL,还半天找不到原因。
3.3 实操 B:两轮体检,全部通过
第一轮:还是同款小模型,ε=10−6\varepsilon = 10^{-6}ε=10−6(与第 17 篇口径一致):
B1:小模型(B=1, S=2, eps=1e-6)
W_xh 解析 vs 数值 最大误差 = 1.280e-10 OK
W_hh 解析 vs 数值 最大误差 = 5.897e-11 OK
W_hy 解析 vs 数值 最大误差 = 4.474e-11 OK
b_h 解析 vs 数值 最大误差 = 1.110e-10 OK
b_y 解析 vs 数值 最大误差 = 9.377e-11 OK
这组数字眼熟吗?——和第 17 篇实操 C 的体检结果一模一样。因为模型、权重、目标值全都一样,体检的对象本来就是同一件事。第 17 篇体检的是"手推的递推公式",本篇体检的是"翻译成代码的递推公式",两轮都过关,说明公式 → 代码 → 数值三者完全闭合,推导链没有一个环节出岔子。
第二轮:换一个更大的随机模型,多批次(I=2,H=4,O=2,B=2,S=3I=2, H=4, O=2, B=2, S=3I=2,H=4,O=2,B=2,S=3,共 38 个参数元素),ε=10−7\varepsilon = 10^{-7}ε=10−7,改用相对误差当标准:
B2:随机多批次模型(I=2,H=4,O=2,B=2,S=3, eps=1e-7)
相对误差 = |解析 - 数值| / max(|解析|, |数值|, 1e-8)
W_xh 相对误差 = 1.096e-08 OK
W_hh 相对误差 = 8.986e-08 OK
W_hy 相对误差 = 3.443e-08 OK
b_h 相对误差 = 4.181e-09 OK
b_y 相对误差 = 6.362e-10 OK
全部在 10−810^{-8}10−8 量级,远优于 10−610^{-6}10−6 的合格线。批次、序列长度、隐层大小一起变,体检照样全绿——这说明 rnn_backward 不是"凑巧对小模型成立",而是对任意规模都成立。
💡 体检技巧(这是调试反向传播的日常工作流):先用小随机初始化 + 短序列(S=3S=3S=3)体检,出错容易定位;通过后再逐步加大规模。如果体检 FAIL,先放大 ε\varepsilonε 看是不是浮点精度问题——ε\varepsilonε 太小时 L(θ+ε)L(\theta+\varepsilon)L(θ+ε) 和 L(θ−ε)L(\theta-\varepsilon)L(θ−ε) 差不了多少,误差反而变大;太大时中心差分本身不准。常用区间 10−6∼10−710^{-6}\sim10^{-7}10−6∼10−7。
四、梯度裁剪:给梯度装"限速器"
4.1 回顾:梯度爆炸是怎么发生的
第十七篇讲过,梯度沿时间轴回传是一串连乘:
∂hT∂h1=∏t=1T−1Whh⊤ diag(1−ht2) \frac{\partial h_T}{\partial h_1} = \prod_{t=1}^{T-1} W_{hh}^{\top}\,\mathrm{diag}(1-h_t^2) ∂h1∂hT=t=1∏T−1Whh⊤diag(1−ht2)
谱半径 ρ(Whh)>1\rho(W_{hh}) > 1ρ(Whh)>1 时,这个连乘随时间步数 TTT 指数放大。回传 30 步放大 19 万倍(第 17 篇实操 D),回传 100 步呢?数字大到计算机都存不下。梯度一旦爆炸,参数更新一步就"飞出天际",下一轮前向直接算出一堆 NaN——训练当场暴毙,而且死得悄无声息,只留给你一条崩溃的 loss 曲线。
4.2 限速器的原理:方向不变,只降速
想象你推着购物车冲下坡,速度越来越快,眼看要失控。这时候有人给你装了个限速器:超过某个速度,就只允许你以"限速值"前进——想去哪一排(方向)完全不变,只是速度被按住。车不会失控,也不会把你带偏。
梯度裁剪(Gradient Clipping)干的就是这件事。先把所有梯度拼在一起,算出总长度(L2 范数):
∥g∥=∑θgθ2 \|g\| = \sqrt{\sum_{\theta} g_{\theta}^2} ∥g∥=θ∑gθ2
如果 ∥g∥\|g\|∥g∥ 超过限速值 clip_norm\text{clip\_norm}clip_norm,就把所有梯度等比例缩小,让总长度刚好回到限速值:
g′=g⋅clip_norm∥g∥(当 ∥g∥>clip_norm) g' = g \cdot \frac{\text{clip\_norm}}{\|g\|} \qquad (\text{当 } \|g\| > \text{clip\_norm}) g′=g⋅∥g∥clip_norm(当 ∥g∥>clip_norm)
注意是"等比例缩小"——每个梯度分量都乘同一个系数 clip_norm∥g∥<1\frac{\text{clip\_norm}}{\|g\|} < 1∥g∥clip_norm<1,所以各分量的比例关系一点没变,只是整体变短了。数学上,这不动"该往哪个方向走",只改"走多快"。

上图:红色长箭头是原始梯度 ggg(∥g∥=13.42\|g\|=13.42∥g∥=13.42),虚线圆是"限速圈"(半径 =clip_norm=5=\text{clip\_norm}=5=clip_norm=5),蓝色短箭头是裁剪后的 g′g'g′——它被拉回圈内,但角度(方向)和红色箭头完全一致。
4.3 实操 C:亲手捏爆一批梯度
光看公式不过瘾,我们伪造一批"爆炸"梯度,看看限速器怎么工作:
def global_norm(grads):
"""所有梯度拼在一起算出的总长度(L2 范数)"""
total = sum(np.sum(g * g) for g in grads.values())
return float(np.sqrt(total))
def clip_by_norm(grads, clip_norm):
"""范数裁剪:||g|| > clip_norm 时,整体乘以 clip_norm / ||g||"""
norm = global_norm(grads)
if norm > clip_norm:
scale = clip_norm / norm
return {k: v * scale for k, v in grads.items()}, norm, clip_norm
return grads, norm, norm
造一批随机梯度,把总范数"摆"到 13.42(远超市面上常见的默认值 5.0),跑一遍裁剪:
伪造一批"爆炸"梯度,总范数 ||g|| = 13.4200(远超限速 5.0)
裁剪后总范数 = 5.0000(刚好压回限速圈内)
缩放比例 scale = clip_norm / ||g|| = 0.3726
裁剪前后对比(只列前几个元素):
W_xh[0] 3.635562 -> 1.354531
W_xh[1] -1.002022 -> -0.373332
W_xh[2] 0.070581 -> 0.026297
W_xh[4] -1.696619 -> -0.632123
每个元素都精确地缩到了原来的 0.37260.37260.3726 倍——不多不少。这就是"等比例"的含义。
顺带提一句:还有另一种叫按值裁剪的做法——把每个元素单独夹到 [−c,c][-c, c][−c,c](np.clip),简单粗暴。但它有个毛病:不同方向的压缩比例不一样(大的压得多、小的压得少),等于悄悄改了梯度的方向。所以主流框架(比如 PyTorch 的 clip_grad_norm_)都推荐按范数裁剪,我们全文也用这种。
五、实操 D:同一套参数,裁剪前后天壤之别
光看裁剪的数字还不够震撼,我们来训练一个真实的小任务,看"不裁剪"和"裁剪"在训练场上的生死对比。
5.1 任务设定:预测序列的"下一位"
设计一个周期为 6 的数列:[0.3,−0.7,0.5,0.9,−0.4,0.1][0.3, -0.7, 0.5, 0.9, -0.4, 0.1][0.3,−0.7,0.5,0.9,−0.4,0.1] 循环出现。输入 xtx_txt 是当前位置的数,目标 ttt_ttt 是下一个数。序列长度 S=24S=24S=24(模式重复 4 遍),隐层 H=8H=8H=8,学习率 0.1,训练 300 步。
关键操作:初始化时故意把 WhhW_{hh}Whh 的谱半径放大到 1.2(>1>1>1)——按第十七篇的理论,时间轴连乘会放大梯度,这是一颗"定时炸弹"。训练代码:
pattern = np.array([0.3, -0.7, 0.5, 0.9, -0.4, 0.1])
n = 24
idx = np.arange(n) % 6
x = pattern[idx].reshape(1, n, 1) # 输入:当前位置的值
target = pattern[(idx + 1) % 6].reshape(1, n, 1) # 目标:下一个值
# 手工打造一个"危险"的 W_hh:谱半径 1.2(>1,时间轴连乘会放大梯度)
rng = np.random.RandomState(0)
W = rng.randn(8, 8) * 0.5
rho0 = np.max(np.abs(np.linalg.eigvals(W)))
W_hh_hot = W * (1.2 / rho0)
model = RNNLayer(1, 8, 1, seed=0) # 类封装见第七节,先当黑盒用
model.W_hh = W_hh_hot.copy()
losses, norms = [], []
for step in range(300):
_, y_seq = model.forward(x)
loss = mse_loss(y_seq, target)
losses.append(loss)
model.backward(target)
before, after = model.clip_grads(clip_norm=1.0) # 装上限速器
norms.append(after)
if np.isnan(loss) or np.isinf(loss):
print('第 %d 步炸了' % step); break
model.update(lr=0.1)
对照组就是把 model.clip_grads(...) 那一行删掉,其余一字不改。
💡 这里限速值取 1.0 而不是常用的 5.0,是因为我们把 WhhW_{hh}Whh 谱半径刻意放大到 1.2,梯度常年超标——限速紧一点,收敛才看得见。clip_norm 是个旋钮,不是教条,实践中可以试 1、5、10 几个档位。
5.2 不裁剪:loss 冲上天,第 125 步直接 NaN
【不裁剪】梯度一路狂飙(log10 视角):
step 0 loss = 3.7179e+00 ||g|| = 7.7399e+00
step 1 loss = 5.8053e+00 ||g|| = 2.9364e+01
step 2 loss = 6.1179e+01 ||g|| = 1.6916e+02
step 3 loss = 2.8282e+02 ||g|| = 5.3216e+02
step 4 loss = 1.0991e+04 ||g|| = 2.3063e+03
step 6 loss = 1.1318e+06 ||g|| = 3.0159e+05
step 8 loss = 1.0566e+10 ||g|| = 2.1365e+06
step 10 loss = 5.1058e+14 ||g|| = 2.3040e+12
step 16 loss = 1.8003e+29 ||g|| = 8.5661e+15
step 24 loss = 7.1260e+49 ||g|| = 1.7043e+26
step 32 loss = 2.8207e+70 ||g|| = 3.3907e+36
step 40 loss = 1.1165e+91 ||g|| = 6.7460e+46
-> 第 125 步 loss 变成 NaN(梯度爆炸,参数飞出去了)
看清这几行数字的量级:40 步,loss 从 3.7 涨到 109110^{91}1091,梯度范数涨到 104610^{46}1046。指数增长就是这样不讲道理——第 4 步还是 1 万,第 8 步就是 100 亿,第 40 步已经是"91 个 0"。到第 125 步,数值溢出成 NaN,训练当场暴毙。
5.3 裁剪:梯度贴线,loss 稳步下降
【裁剪(clip_norm=1.0)】梯度被按住,loss 稳步下降:
step 0 loss = 3.717908 ||g|| = 1.0000
step 20 loss = 0.297147 ||g|| = 1.0000
step 40 loss = 0.182106 ||g|| = 1.0000
step 60 loss = 0.132999 ||g|| = 1.0000
step 80 loss = 0.107418 ||g|| = 1.0000
step 100 loss = 0.093881 ||g|| = 1.0000
step 140 loss = 0.085506 ||g|| = 1.0000
step 180 loss = 0.080405 ||g|| = 1.0000
step 220 loss = 0.076266 ||g|| = 1.0000
step 260 loss = 0.073468 ||g|| = 1.0000
step 299 loss = 0.063755 ||g|| = 1.0000
最终 loss = 0.063755(从 3.72 降到了 0.06)
注意 ||g|| 那一列——每一步都被按在 1.0 上,这正是限速器在工作:原始梯度动辄 3~8,超速了,就压到 1.0。loss 呢?从 3.72 一路降到 0.064,降幅 98% 以上。同样参数、同样任务、同样初始化,唯一的区别就是裁不裁剪。

左图:红色线(不裁剪)几步之内冲出屏幕,第 125 步(虚线处)直接 NaN;蓝色线(裁剪)稳稳下降。右图:训练 300 步后,模型的预测(橙色虚线)几乎完全盖住目标序列(绿色实线)——它是真的学会了"下一位"规律,而不是瞎猜。
💡 一个诚实的补充:裁剪组的梯度全程贴着限速线,说明这个任务里梯度长期"超速"。这不是 bug——等 loss 降到足够小,梯度自然会跌破限速线,蓝色线才会离开 1.0 往下走。限速器是"按需工作"的:超速才介入,不超速绝不打扰。
六、体检正确 + 梯度稳定
第 5 章在系列目录里的里程碑是"梯度校验对比图"——用一张双面板图同时回答两个问题:左图证明"梯度算对了",右图证明"梯度稳住了"。

- 左图:把实操 B2 那 38 个参数元素的"数值梯度"当横轴、"解析梯度"当纵轴,一个点代表一个参数。所有点都死死贴在 y=xy=xy=x 对角线上——这说明
rnn_backward输出的每一个梯度分量,都和数值梯度分毫不差。"写对了"不是嘴上说说,是 38 个点替你作证。 - 右图:训练时的梯度总范数。红色是裁剪前的原始梯度,火箭一样冲上云霄(对数坐标下依然触目惊心);蓝色是裁剪后的梯度,整条线贴死在限速线上。第 125 步红色曲线戛然而止——因为参数已经变成 NaN,曲线画不出来了。
七、封装成 RNNLayer:让代码有"人样"
每次训练都要手动传一堆权重和偏置,太啰嗦。就像把散落的工具收进工具箱,我们把前向、反向、裁剪、更新包进一个类:
class RNNLayer(object):
"""迷你 RNN 层:forward / backward / clip_grads / update 一套齐活"""
def __init__(self, input_size, hidden_size, output_size, seed=None):
rng = np.random.RandomState(seed)
self.I, self.H, self.O = input_size, hidden_size, output_size
limit = 1.0 / np.sqrt(hidden_size) # 小范围均匀初始化
self.W_xh = rng.uniform(-limit, limit, (input_size, hidden_size))
self.W_hh = rng.uniform(-limit, limit, (hidden_size, hidden_size))
self.W_hy = rng.uniform(-limit, limit, (hidden_size, output_size))
self.b_h = np.zeros(hidden_size)
self.b_y = np.zeros(output_size)
def forward(self, x, h0=None):
B = x.shape[0]
if h0 is None:
h0 = np.zeros((B, self.H))
self.x = x
self.h_seq, self.y_seq, self.cache = rnn_forward(
x, self.W_xh, self.W_hh, self.W_hy, self.b_h, self.b_y, h0)
return self.h_seq, self.y_seq
def backward(self, target):
self.grads = rnn_backward(self.y_seq, target, self.cache,
self.W_xh, self.W_hh, self.W_hy,
B=self.x.shape[0])
return self.grads
def clip_grads(self, clip_norm):
"""范数裁剪,返回 (裁剪前范数, 裁剪后范数)"""
self.grads, before, after = clip_by_norm(self.grads, clip_norm)
return before, after
def update(self, lr):
for name in ('W_xh', 'W_hh', 'W_hy', 'b_h', 'b_y'):
setattr(self, name, getattr(self, name) - lr * self.grads[name])
训练循环立刻清爽多了:
model = RNNLayer(1, 8, 1, seed=0)
model.W_hh = W_hh_hot # 换上那颗"定时炸弹"
for step in range(300):
model.forward(x)
loss = mse_loss(model.y_seq, target)
model.backward(target)
model.clip_grads(clip_norm=1.0) # 装限速器
model.update(lr=0.1)
三行循环体:算账(forward)→ 记账(backward)→ 还账(update),中间夹一道限速(clip_grads)。实操 D 的完整实验就是拿这个类跑的。
几个设计细节,也是面试常考点:
- 为什么梯度不存成实例属性累加、而是
backward每次返回新字典? 因为我们每次backward都从零重算(时间步累加是"从零加起"),天然不需要zero_grad()。等你以后用 PyTorch,optimizer.zero_grad()是因为框架默认"梯度累加到旧值上"——那是另一种设计哲学,不是必须。 update为什么是weight - lr * grad? 梯度指向 loss 增大的方向,更新要"反着走",所以是减号。这就是梯度下降的本体,第三章的根基。seed参数有什么用? 固定随机种子,保证每次跑结果完全一样——调试、复现、写文章全靠它。
八、常见坑与 FAQ
🕳️ 常见坑
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | 反向循环写成 for t in range(S) |
δt\delta_tδt 依赖 δt+1\delta_{t+1}δt+1,正着走要么报错要么全错 | 必须 range(S-1, -1, -1) 倒序 |
| 2 | 忘记把 target 转置对齐 |
y_seq 是 (S,B,O)(S,B,O)(S,B,O)、target 是 (B,S,O)(B,S,O)(B,S,O),直接减形状对不上 |
target.transpose(1,0,2) 后再减 |
| 3 | 梯度忘除以 BBB | 解析梯度比数值梯度大 BBB 倍,体检 FAIL | 反向末尾 grads / B;数值梯度同样 / B,口径一致 |
| 4 | 漏乘 tanh\tanhtanh 导数 1−ht21-h_t^21−ht2 | 只有最后一步梯度对,中间步全错 | 递推式最后一步必须 * d_tanh,用 cache 里的 hth_tht 现算 |
| 5 | 转置写反:WhhδW_{hh}\deltaWhhδ 还是 δWhh⊤\delta W_{hh}^{\top}δWhh⊤ | 维度对不上或梯度算错 | 行向量约定下梯度右乘转置:np.dot(delta_next, W_hh.T) |
| 6 | 时间步累加少 += |
只算了最后一步的贡献,体检立刻 FAIL | 每个时间步都 +=,一个都不能少(t=0t=0t=0 贡献为零也要走一遍流程) |
| 7 | 裁剪了却没用裁剪结果 | 打印了裁剪但更新用的还是原梯度,白剪 | clip_grads 会原地替换 self.grads,改完再 update |
| 8 | ε\varepsilonε 取太小或太大 | 太小浮点误差大、太大中心差分不准 | 常用 10−6∼10−710^{-6}\sim10^{-7}10−6∼10−7 |
❓ FAQ
Q1:数值梯度那么准,为什么训练时不用它?
每算一个参数元素的数值梯度要跑两次前向。WhhW_{hh}Whh 有 H2H^2H2 个元素就要跑 2H22H^22H2 次前向,序列一长就是天文数字。它的角色是"体检工具":小模型上验证解析梯度没问题,然后放心用解析梯度。
Q2:为什么裁剪能救爆炸,救不了消失?
裁剪只处理"梯度太大"(爆炸);梯度太小(消失)时它不介入——限速器只管超速,不管龟速。消失的解药在下一章:LSTM 用一条"传送带"绕开连乘。
Q3:裁剪会影响最终学到的参数吗?
会,但通常是好的影响。裁剪后的梯度方向和原始梯度一致(等比例缩小),只是步长被限制,相当于"每一步都走得谨慎一点"。坏处是限速太紧收敛会变慢——所以 clip_norm 是个要调的旋钮。
Q4:PyTorch 里的梯度裁剪在哪?torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm) 就是范数裁剪,和我们的 clip_by_norm 一个道理(注意 PyTorch 按参数分组算范数,细节略有差异)。面试时能讲出"等比例缩放、方向不变",就过关了。
Q5:怎么快速定位反向传播的 bug?
先跑数值梯度体检,FAIL 就二分:把模型调到最小(S=2,H=2S=2, H=2S=2,H=2),单独查一个参数;通过后再逐步放大。多数 bug 逃不出上表 8 个坑。
九、本章小结与下章预告
本章小结(一句话带走一个知识点)
| 知识点 | 一句话带走 |
|---|---|
| rnn_backward 骨架 | 倒序 for t in range(S-1,-1,-1),拿 cache 里的 hth_tht 现算 tanh\tanhtanh 闸门,+= 累加五个梯度 |
| 递推的代码形态 | δt=(δt+1Whh⊤+δytWhy⊤)⊙(1−ht2)\delta_t = (\delta_{t+1}W_{hh}^{\top} + \delta_{y_t}W_{hy}^{\top})\odot(1-h_t^2)δt=(δt+1Whh⊤+δytWhy⊤)⊙(1−ht2),一行矩阵运算对应一个公式 |
| 对批次平均 | 梯度是整批合力,除以 BBB 变平均梯度;数值梯度也要同口径除 BBB |
| 数值梯度 | 中心差分 L(θ+ε)−L(θ−ε)2ε\frac{L(\theta+\varepsilon)-L(\theta-\varepsilon)}{2\varepsilon}2εL(θ+ε)−L(θ−ε),独立裁判,误差 <10−6<10^{-6}<10−6 合格 |
| 体检结果 | 小模型误差 10−1010^{-10}10−10 量级、随机大模型相对误差 10−810^{-8}10−8 量级,全绿 ✅ |
| 梯度裁剪 | ∣g∣>clip_norm|g| > \text{clip\_norm}∣g∣>clip_norm 时整体乘 clip_norm∣g∣\frac{\text{clip\_norm}}{|g|}∣g∣clip_norm:方向不变,只降速 |
| 裁剪的效果 | 不裁剪:40 步 loss 冲到 109110^{91}1091、125 步 NaN;裁剪:300 步 loss 从 3.72 降到 0.064 |
| 按值 vs 按范数 | 按值裁剪会压扁方向,推荐按范数裁剪(PyTorch clip_grad_norm_ 同款) |
下章预告:第 6 章《直面梯度消失——LSTM 的核心思想》
这一章我们把"爆炸"收拾了(裁剪),但"消失"还摆在那:普通 RNN 记不住长句子,病根就是第十七篇那个连乘。第六章的主角 LSTM 出场——它用一条"传送带"(细胞状态)绕开连乘,让信息在长序列里也能"无损"传递。为什么 LSTM 记性那么好?传送带和收费站(门控)是怎么配合的? 到时候见分晓。
BPTT(下),完结。反向传播这一关,算是彻底打通了——下一关,LSTM。
🧠 思考题与动手练习
思考题(先自己想,答案都在正文里):
- 为什么
rnn_backward必须倒序遍历时间轴?如果正着写,数学上会出什么问题? - 梯度为什么要除以 BBB?如果不除,数值梯度体检会显示什么现象?(提示:两边恰好差 BBB 倍)
- 裁剪系数 clip_norm∥g∥\frac{\text{clip\_norm}}{\|g\|}∥g∥clip_norm 小于 1 时,为什么说"方向不变"?改成按值裁剪后,方向为什么就变了?
- 裁剪实验里,为什么梯度全程贴着限速线?等 loss 足够小之后,梯度范数会怎么变?
- 裁剪能救爆炸,为什么救不了消失?消失的解药会在哪一章出现?
动手练习(改造本章代码):
- 把实操 D 的
clip_norm改成 0.5 和 10 各跑一遍,对比 loss 曲线——体会"限速太紧收敛慢、太松压不住"; - 把谱半径从 1.2 改成 1.05 和 1.5,重跑实操 D,看"不裁剪"爆炸的快慢如何随 ρ\rhoρ 变化;
- 在实操 B2 里故意删掉
delta_z里的* d_tanh,跑体检,看哪个参数的误差最先暴露、误差多大; - 把 MSE 换成二分类交叉熵(yty_tyt 过 sigmoid),改
rnn_backward第一行的 δyt\delta_{y_t}δyt,再跑一遍全身体检; - 用
RNNLayer类在 S=60S=60S=60 的序列上训练(模式重复 10 遍),对比裁剪前后,体会序列变长后爆炸来得有多快。
📌 下篇预告:第六章《直面梯度消失——LSTM 的核心思想》——普通 RNN 为什么记不住长句子?LSTM 的"传送带"如何绕开梯度消失?我们下篇见!
本文为原创,遵循 CC 4.0 BY-SA 版权协议,转载需附原文链接。
🐍 附:实操代码运行
# -*- coding: utf-8 -*-
"""
rnn_backward.py — 《从零构建RNN》第5章《BPTT算法(下)》配套实操代码
内容:
实操 A 把第4章推导的递推公式翻译成 rnn_backward,在同款小模型上逐位核对梯度
实操 B 数值梯度"全身体检":中心差分逐个扰动所有参数,与解析梯度对比
实操 C 梯度裁剪(范数裁剪)的原理演示:超长梯度被"拉回"限速圈内
实操 D 周期序列"下一步预测"任务:不裁剪 -> 梯度爆炸 Loss 变 NaN;
裁剪 -> 梯度被按住,Loss 稳步下降
实操 E 把前向 / 反向 / 裁剪 / 更新封装成 RNNLayer 类
依赖:numpy
"""
import numpy as np
# ============================================================
# 基础:前向传播(与第3章完全一致,每个时间步的中间量都存进 cache)
# ============================================================
def rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0):
"""前向:x:(B,S,I),返回 (h_seq:(S,B,H), y_seq:(S,B,O), cache)"""
S = x.shape[1]
h_prev = h0
h_seq, y_seq = [], []
cache = {'x': [], 'h_prev': [], 'z': [], 'h': []}
for t in range(S):
x_t = x[:, t, :]
z_t = np.dot(x_t, W_xh) + np.dot(h_prev, W_hh) + b_h
h_t = np.tanh(z_t)
y_t = np.dot(h_t, W_hy) + b_y
for k, v in (('x', x_t), ('h_prev', h_prev), ('z', z_t), ('h', h_t)):
cache[k].append(v)
h_seq.append(h_t)
y_seq.append(y_t)
h_prev = h_t
h_seq = np.stack(h_seq, axis=0) # (S,B,H)
y_seq = np.stack(y_seq, axis=0) # (S,B,O)
for k in cache:
cache[k] = np.stack(cache[k], axis=0)
return h_seq, y_seq, cache
def mse_loss(y_seq, target):
"""L = 1/2 * Σ_t ||y_t - target_t||^2,target:(B,S,O)"""
tgt = target.transpose(1, 0, 2)
diff = y_seq - tgt
return 0.5 * np.sum(diff * diff)
# ============================================================
# 第5章主角:BPTT 反向传播(第4章公式逐行翻译成 NumPy)
# ============================================================
def rnn_backward(y_seq, target, cache, W_xh, W_hh, W_hy, B):
"""
拿着 cache 倒着走时间轴:
δ_y_t = y_t - target_t (输出层误差,MSE 的导数)
δ_t = (δ_{t+1} W_hh^T + δ_y_t W_hy^T) ⊙ (1 - h_t^2) (核心递推)
梯度 = Σ_t (陪乘项^T · δ) / B (时间步累加 + 对批次求平均)
"""
S = y_seq.shape[0]
tgt = target.transpose(1, 0, 2) # (B,S,O) -> (S,B,O)
delta_y = y_seq - tgt # 输出层误差 (S,B,O)
dW_xh = np.zeros_like(W_xh)
dW_hh = np.zeros_like(W_hh)
dW_hy = np.zeros_like(W_hy)
db_h = np.zeros(W_xh.shape[1])
db_y = np.zeros(W_hy.shape[1])
delta_next = np.zeros((B, W_hh.shape[0])) # δ_{T+1} = 0:最后一步没有"未来"
for t in range(S - 1, -1, -1): # 必须倒序!
h_t = cache['h'][t] # (B,H)
d_tanh = 1.0 - h_t * h_t # tanh 闸门 (1-h_t^2)
# 直接路径(经 W_hy)+ 时间路径(经 W_hh 从未来回传),汇合后过闸门
delta_z = (np.dot(delta_next, W_hh.T) +
np.dot(delta_y[t], W_hy.T)) * d_tanh # δ_t
# 各回各家 + 时间步累加
dW_hy += np.dot(cache['h'][t].T, delta_y[t]) # Σ h_t^T δ_y_t
dW_hh += np.dot(cache['h_prev'][t].T, delta_z) # Σ h_{t-1}^T δ_t
dW_xh += np.dot(cache['x'][t].T, delta_z) # Σ x_t^T δ_t
db_h += delta_z.sum(axis=0)
db_y += delta_y[t].sum(axis=0)
delta_next = delta_z
# 梯度是"整批的合力",除以样本数 B 变成平均梯度:
# 这样换批次大小不用改学习率,也更稳定
return {'W_xh': dW_xh / B, 'W_hh': dW_hh / B, 'W_hy': dW_hy / B,
'b_h': db_h / B, 'b_y': db_y / B}
# ============================================================
# 数值梯度(中心差分):金标准体检工具
# ============================================================
def numerical_grads(x, target, W_xh, W_hh, W_hy, b_h, b_y, h0, eps=1e-7):
"""对每个参数元素逐个扰动,用 (L(x+e)-L(x-e))/2e 估计真实梯度"""
params = {'W_xh': W_xh, 'W_hh': W_hh, 'W_hy': W_hy,
'b_h': b_h, 'b_y': b_y}
def loss_with():
_, ys, _ = rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0)
return mse_loss(ys, target)
grads = {}
B = x.shape[0]
for name, P in params.items():
G = np.zeros_like(P)
for idx in np.ndindex(P.shape):
orig = P[idx]
P[idx] = orig + eps
fp = loss_with()
P[idx] = orig - eps
fm = loss_with()
P[idx] = orig
G[idx] = (fp - fm) / (2 * eps) # 中心差分
grads[name] = G / B # 与解析梯度口径一致:对批次平均
return grads
def max_error(a, b):
return np.max(np.abs(a - b))
# ============================================================
# 梯度裁剪(范数裁剪):太长就等比例缩短,方向不变
# ============================================================
def global_norm(grads):
"""所有梯度拼在一起算出的总长度(L2 范数)"""
total = sum(np.sum(g * g) for g in grads.values())
return float(np.sqrt(total))
def clip_by_norm(grads, clip_norm):
"""
范数裁剪:||g|| > clip_norm 时,整体乘以 clip_norm / ||g||
—— 像给购物车装限速器:超速只降速,不改方向
"""
norm = global_norm(grads)
if norm > clip_norm:
scale = clip_norm / norm
return {k: v * scale for k, v in grads.items()}, norm, clip_norm
return grads, norm, norm
def clip_by_value(grads, c):
"""按值裁剪:每个元素单独夹到 [-c, c](简单粗暴版)"""
return {k: np.clip(v, -c, c) for k, v in grads.items()}
# ============================================================
# 实操 A:第4章同款小模型,验证 rnn_backward 与手推结果逐位一致
# ============================================================
def demo_A():
print('=' * 78)
print('实操 A:rnn_backward 落地 —— 第4章同款小模型(I=2, H=3, O=2, B=1, S=2)')
print('=' * 78)
w_xh = np.array([[0.5, -0.2, 0.3], [-0.1, 0.4, 0.2]]) # (2,3)
w_hh = np.array([[0.8, 0.1, 0.0], [0.2, 0.7, -0.1], [0.0, 0.3, 0.6]]) # (3,3)
w_hy = np.array([[0.5, -0.4], [0.2, 0.3], [-0.1, 0.6]]) # (3,2)
bh = np.array([0.1, -0.1, 0.05])
by = np.array([0.0, 0.0])
x = np.array([[[1.0, -1.0], [0.5, 0.5]]]) # (1,2,2)
h0 = np.zeros((1, 3))
target = np.array([[[1.0, -1.0], [-0.5, 0.5]]]) # (1,2,2)
h_seq, y_seq, cache = rnn_forward(x, w_xh, w_hh, w_hy, bh, by, h0)
print('前向结果(与第4章逐位一致):')
print(' y_seq =', y_seq.reshape(2, 2).tolist())
print(' 损失 L =', mse_loss(y_seq, target))
grads = rnn_backward(y_seq, target, cache, w_xh, w_hh, w_hy, B=1)
print()
print('rnn_backward 输出的梯度(B=1,无需平均):')
for k, v in grads.items():
print(' %-5s =' % k)
print(v)
# 与第4章手推结果对照(取第4章实操A的数值)
expect = {
'W_hy': np.array([[-0.1056, 0.0713], [0.2926, -0.2269], [0.1653, -0.1417]]),
'W_hh': np.array([[0.2292, -0.0187, -0.2044],
[-0.2292, 0.0187, 0.2044],
[0.0565, -0.0046, -0.0504]]),
'W_xh': np.array([[-0.0534, 0.0615, 0.0958], [0.4327, -0.0924, -0.434]]),
'b_h': np.array([0.1362, 0.0461, -0.0734]),
'b_y': np.array([-0.1472, 0.095]),
}
print()
print('与第4章手推值(保留4位小数)逐位对照,最大误差:')
for k in expect:
print(' %-5s 最大误差 = %.3e %s' %
(k, max_error(grads[k], expect[k]),
'OK' if max_error(grads[k], expect[k]) < 1e-4 else 'FAIL'))
# ============================================================
# 实操 B:数值梯度全身体检(小模型 + 大一点的多批次模型)
# ============================================================
def demo_B():
print()
print('=' * 78)
print('实操 B:数值梯度体检 —— 解析梯度 vs 数值梯度')
print('=' * 78)
# ---- B1:第4章同款小模型,eps=1e-6(与第4章口径一致) ----
w_xh = np.array([[0.5, -0.2, 0.3], [-0.1, 0.4, 0.2]])
w_hh = np.array([[0.8, 0.1, 0.0], [0.2, 0.7, -0.1], [0.0, 0.3, 0.6]])
w_hy = np.array([[0.5, -0.4], [0.2, 0.3], [-0.1, 0.6]])
bh = np.array([0.1, -0.1, 0.05])
by = np.array([0.0, 0.0])
x = np.array([[[1.0, -1.0], [0.5, 0.5]]])
h0 = np.zeros((1, 3))
target = np.array([[[1.0, -1.0], [-0.5, 0.5]]])
_, y_seq, cache = rnn_forward(x, w_xh, w_hh, w_hy, bh, by, h0)
an = rnn_backward(y_seq, target, cache, w_xh, w_hh, w_hy, B=1)
nu = numerical_grads(x, target, w_xh, w_hh, w_hy, bh, by, h0, eps=1e-6)
print('B1:小模型(B=1, S=2, eps=1e-6)')
for k in an:
print(' %-5s 解析 vs 数值 最大误差 = %.3e %s' %
(k, max_error(an[k], nu[k]),
'OK' if max_error(an[k], nu[k]) < 1e-6 else 'FAIL'))
# ---- B2:更大的随机模型(多批次),用相对误差标准 ----
rng = np.random.RandomState(42)
I, H, O, B, S = 2, 4, 2, 2, 3
W_xh = rng.randn(I, H) * 0.5
W_hh = rng.randn(H, H) * 0.5
W_hy = rng.randn(H, O) * 0.5
b_h = rng.randn(H) * 0.1
b_y = rng.randn(O) * 0.1
x2 = rng.randn(B, S, I) * 0.8
h02 = np.zeros((B, H))
target2 = rng.randn(B, S, O) * 0.8
_, y2, cache2 = rnn_forward(x2, W_xh, W_hh, W_hy, b_h, b_y, h02)
an2 = rnn_backward(y2, target2, cache2, W_xh, W_hh, W_hy, B=B)
nu2 = numerical_grads(x2, target2, W_xh, W_hh, W_hy, b_h, b_y, h02,
eps=1e-7)
print()
print('B2:随机多批次模型(I=2,H=4,O=2,B=2,S=3, eps=1e-7)')
print(' 相对误差 = |解析 - 数值| / max(|解析|, |数值|, 1e-8)')
for k in an2:
rel = np.max(np.abs(an2[k] - nu2[k]) /
np.maximum(np.abs(nu2[k]), 1e-8))
print(' %-5s 相对误差 = %.3e %s' %
(k, rel, 'OK' if rel < 1e-6 else 'FAIL'))
return an2, nu2, (x2, target2, W_xh, W_hh, W_hy, b_h, b_y, h02)
# ============================================================
# 实操 C:梯度裁剪原理演示
# ============================================================
def demo_C():
print()
print('=' * 78)
print('实操 C:梯度裁剪 —— 范数超限就整体缩短,方向不变')
print('=' * 78)
rng = np.random.RandomState(7)
fake = {'W_xh': rng.randn(2, 3), 'W_hh': rng.randn(3, 3) * 2,
'W_hy': rng.randn(3, 2), 'b_h': rng.randn(3), 'b_y': rng.randn(2)}
# 整体缩放,把总范数"摆"到 13.42,制造一场声势浩大的爆炸
target_norm = 13.42
norm0 = global_norm(fake)
fake = {k: v * (target_norm / norm0) for k, v in fake.items()}
norm = global_norm(fake)
clip_norm = 5.0
clipped, before, after = clip_by_norm(fake, clip_norm)
print('伪造一批"爆炸"梯度,总范数 ||g|| = %.4f(远超限速 5.0)' % norm)
print('裁剪后总范数 = %.4f(刚好压回限速圈内)' % global_norm(clipped))
print('缩放比例 scale = clip_norm / ||g|| = %.4f' % (clip_norm / norm))
print()
print('裁剪前后对比(只列前 6 个元素):')
names = list(fake.keys())
total = 0
for k in names:
a, b = fake[k].ravel(), clipped[k].ravel()
for i in range(min(6, len(a))):
total += 1
if total <= 6:
print(' %-5s[%d] %.6f -> %.6f' % (k, i, a[i], b[i]))
# 按值裁剪 vs 按范数裁剪
vc = clip_by_value(fake, 1.0)
print()
print('按值裁剪(每个元素夹到 [-1,1])后范数 = %.4f —— 简单粗暴,但'
'各方向被"压扁"的幅度不一样' % global_norm(vc))
# ============================================================
# 实操 D:周期序列"下一步预测" —— 裁剪前 vs 裁剪后
# ============================================================
def make_periodic_seq(n):
"""周期 6 的序列:x_t 是第 t 个数,target_t 是下一个数"""
pattern = np.array([0.3, -0.7, 0.5, 0.9, -0.4, 0.1])
idx = np.arange(n) % 6
x = pattern[idx].reshape(1, n, 1)
target = pattern[(idx + 1) % 6].reshape(1, n, 1)
return x, target
class RNNLayer(object):
"""迷你 RNN 层:forward / backward / clip_grads / update 一套齐活"""
def __init__(self, input_size, hidden_size, output_size, seed=None):
rng = np.random.RandomState(seed)
self.I, self.H, self.O = input_size, hidden_size, output_size
limit = 1.0 / np.sqrt(hidden_size) # 小范围均匀初始化
self.W_xh = rng.uniform(-limit, limit, (input_size, hidden_size))
self.W_hh = rng.uniform(-limit, limit, (hidden_size, hidden_size))
self.W_hy = rng.uniform(-limit, limit, (hidden_size, output_size))
self.b_h = np.zeros(hidden_size)
self.b_y = np.zeros(output_size)
self.cache = None
def forward(self, x, h0=None):
B = x.shape[0]
if h0 is None:
h0 = np.zeros((B, self.H))
self.x = x
self.h_seq, self.y_seq, self.cache = rnn_forward(
x, self.W_xh, self.W_hh, self.W_hy, self.b_h, self.b_y, h0)
return self.h_seq, self.y_seq
def backward(self, target):
self.grads = rnn_backward(self.y_seq, target, self.cache,
self.W_xh, self.W_hh, self.W_hy,
B=self.x.shape[0])
return self.grads
def clip_grads(self, clip_norm):
"""范数裁剪,返回 (裁剪前范数, 裁剪后范数)"""
self.grads, before, after = clip_by_norm(self.grads, clip_norm)
return before, after
def update(self, lr):
for name in ('W_xh', 'W_hh', 'W_hy', 'b_h', 'b_y'):
setattr(self, name, getattr(self, name) - lr * self.grads[name])
def train(use_clip, steps=300, lr=0.1, clip_norm=1.0, seed=0, rho=1.2):
"""
训练"下一步预测"小任务。
故意把 W_hh 的谱半径放大到 rho=1.2(>1)—— 不裁剪时梯度会爆炸。
返回 (loss_list, grad_norm_list, step_to_nan)
"""
x, target = make_periodic_seq(n=24)
# 手工打造一个"危险"的 W_hh:谱半径 rho=1.2(>1,时间轴连乘会放大梯度)
rng = np.random.RandomState(seed)
W = rng.randn(8, 8) * 0.5
rho0 = np.max(np.abs(np.linalg.eigvals(W)))
W_hh_hot = W * (rho / rho0)
model = RNNLayer(1, 8, 1, seed=seed)
model.W_hh = W_hh_hot.copy()
losses, norms, step_nan = [], [], None
for step in range(steps):
h_seq, y_seq = model.forward(x)
loss = mse_loss(y_seq, target)
losses.append(loss)
model.backward(target)
before, after = model.clip_grads(clip_norm) if use_clip \
else (global_norm(model.grads), global_norm(model.grads))
norms.append(after if use_clip else before)
if np.isnan(loss) or np.isinf(loss):
step_nan = step
break
model.update(lr)
return np.array(losses), np.array(norms), step_nan
def demo_D():
print()
print('=' * 78)
print('实操 D:周期序列"下一步预测"(S=24, H=8, 谱半径 1.2, lr=0.1, 300 步)')
print('=' * 78)
no_clip_loss, no_clip_norm, nan_step = train(use_clip=False)
print()
print('【不裁剪】梯度一路狂飙(log10 视角):')
for s in [0, 1, 2, 3, 4, 6, 8, 10, 12, 16, 24, 32, 40]:
if s >= len(no_clip_loss):
break
print(' step %3d loss = %.4e ||g|| = %.4e (log10: %.1f / %.1f)' %
(s, no_clip_loss[s], no_clip_norm[s],
np.log10(no_clip_loss[s]), np.log10(no_clip_norm[s])))
print(' -> 第 %s 步 loss 变成 NaN(梯度爆炸,参数飞出去了)' % nan_step)
clip_loss, clip_norm_curve, _ = train(use_clip=True)
print()
print('【裁剪(clip_norm=1.0)】梯度被按住,loss 稳步下降:')
for s in [0, 20, 40, 60, 80, 100, 140, 180, 220, 260, 299]:
if s >= len(clip_loss):
break
print(' step %3d loss = %.6f ||g|| = %.4f' %
(s, clip_loss[s], clip_norm_curve[s]))
print(' 最终 loss = %.6f(学得七七八八:从 3.72 降到了 0.06)' % clip_loss[-1])
return (no_clip_loss, no_clip_norm, nan_step), (clip_loss, clip_norm_curve)
# ============================================================
# main:串起所有实操
# ============================================================
if __name__ == '__main__':
demo_A()
an2, nu2, _ = demo_B()
demo_C()
demo_D()
更多推荐



所有评论(0)