01 / 本课学习路线
本课学习路线
阅读与推演约 90 分钟,练习约 75 分钟,进阶练习另需约 15 分钟
02 / 学习目标与先修自测
学完本课你能做什么,以及开始前需要会什么
两道题都是「按题目给的顺序读参数,再用同一组参数逐个时间步递推」。计算本身只有矩阵乘向量、加偏置、套激活函数三步;容易出错的是顺序——参数块的顺序、时间步的顺序、四个门的顺序。
| 学完后能做的事 | 正文位置 | 检查方式 |
|---|---|---|
| 写出 RNN 单步公式并说出每个矩阵的维度 | 第 04 节 | 自查第 2 条、练习 1 |
| 按 i、f、g、o 顺序说出 LSTM 四个门的激活与作用 | 第 04、05 节 | 自查第 3 条、练习 3 |
| 手算复现标量例的 c_1=0.287649、h_1=0.174270 | 第 04 节 | 自查第 4 条、练习 4 |
| 说出双向拼接为什么要按原时间位置对齐 | 第 04 节补充学习、第 08 节 | 自查第 5 条、练习 5 |
| 通过 AI032 与 AI033,输出前核对两位小数与 −0.00 | 第 05、06 节 | 自查第 1、6 条 |
先修自测:下面 5 题请先自己写答案,再展开对照。答不出的按括号里的位置补看再回来。本课假定你已完成模块 6 的「矩阵维度与矩阵乘法」「浮点精度与稳定 softmax」和模块 7 的「注意力机制与 Q/K/V 计算」。
| 题号 | 题目 | 补看位置 |
|---|---|---|
| 自测 1 | math.tanh(0.5) 和 1 / (1 + math.exp(-0.5)) 各约等于多少(保留 6 位)?tanh 的取值范围是什么? | 模块与导入 |
| 自测 2 | W = [[0.5, 0.0], [0.0, 0.5]],x = [1.0, -1.0]:sum(W[1][j] * x[j] for j in range(2)) 是多少? | 常用内置函数 |
| 自测 3 | W_x 是 dH 行 dIn 列,x_t 有 dIn 个数:W_x·x_t 有几个数?W_h 必须是几行几列才能加到一起? | 矩阵维度与矩阵乘法 |
| 自测 4 | f"{-0.001:.2f}" 打出什么?题目要求「−0.00 输出 0.00」时怎么改? | 浮点精度与稳定 softmax |
| 自测 5 | 函数 return c_new, h_new 返回什么?调用处写 c, h = f(...) 时哪个变量拿到 c_new? | 函数定义与返回值 |
展开先修自测答案
自测 1:tanh(0.5) = 0.462117,sigmoid(0.5) = 0.622459——正是第 04 节标量例里的 g 与 i、f、o。tanh 的取值范围是 (−1, 1),所以 AI032 的输出绝对值不会超过 1;sigmoid 的范围是 (0, 1),门的值可以理解成「通过的比例」。
自测 2:W[1] = [0.0, 0.5],与 x 的内积是 0.0×1.0 + 0.5×(−1.0) = −0.5。这就是 AI032 样例第 2 步第 2 维的 W_x·x_2。
自测 3:W_x·x_t 有 dH 个数(每一行与 x_t 做一次内积)。W_h 乘的是长度 dH 的 h_{t−1},结果也要是 dH 个数才能逐项相加,所以 W_h 是 dH 行 dH 列。AI032 输入先给 dH 行 W_x、再给 dH 行 W_h,行数相同、每行个数不同。
自测 4:-0.00。先格式化成字符串,再把 "-0.00" 换成 "0.00"(第 06 节 fmt);直接 round 或先判断 v < 0 都不可靠——−0.004 舍入后也是 −0.00。
自测 5:返回一个含两项的元组 (c_new, h_new);c, h = f(...) 按顺序解包,c 拿到 c_new。本课的 lstm_step 返回顺序是 (c, h),「代码自测」的断言按这个顺序解包。
03 / 概念与术语
时间步、隐藏状态、权重矩阵;四个门、细胞状态、逐元素乘、饱和
RNN 的词只有一组公式里的符号;LSTM 在同一组符号上再加四个门和一条细胞状态。
| 术语 | 含义 | 代码里的位置 |
|---|---|---|
| 时间步 t | 序列的第 t 个位置,t = 1..T 正向推进;每一步用同一组权重 | for _ in range(T) |
| 隐藏状态 h_t | 长度 dH 的向量,是「读到第 t 步为止」的摘要;h_0 由输入给出 | h = take(dH),之后每步被 rnn_step 覆盖 |
| 输入 x_t 与维度 dIn / dH | x_t 长度 dIn;W_x 是 dH×dIn,W_h 是 dH×dH,b 与 h 长度 dH | Wx = [take(dIn) for _ in range(dH)] |
| RNN 单步 | h_t = tanh(W_x·x_t + W_h·h_{t−1} + b):先算线性部分 z,再逐项套 tanh | rnn_step |
| sigmoid σ 与 tanh | σ(z) = 1/(1+e^{−z}) ∈ (0,1),做门;tanh(z) ∈ (−1,1),做状态值 | sigmoid / math.tanh |
| 四个门 i、f、g、o | 输入门 i、遗忘门 f、候选值 g、输出门 o;每个门有自己的 (W_x, W_h, b);AI033 按 i、f、g、o 顺序给出 | params[0..3] |
| 细胞状态 c_t | c_t = f⊙c_{t−1} + i⊙g:旧状态按 f 保留、新候选按 i 写入 | c_new = [f[r] * c[r] + i[r] * g[r] ...] |
| 逐元素乘 ⊙ | 两个同长向量对应位置相乘,不是矩阵乘 | f[r] * c[r] |
| 隐藏状态 h_t(LSTM) | h_t = o⊙tanh(c_t):先把本步的 c_t 过 tanh,再按 o 放出 | h_new = [o[r] * math.tanh(c_new[r]) ...] |
| 饱和 | |z| 很大时 tanh → ±1、σ → 0 或 1,输出几乎不再随 z 变化;AI033 用偏置 ±10 制造全开 / 全关的门 | 样例里的 10.00 |
两个「上一步 / 本步」的约定要记牢:W_h 乘的是上一步的 h_{t−1}(本步的 h_t 还没算出来);LSTM 的 h_t 用的是本步刚更新完的 c_t(不是 c_{t−1})。第 07 节错误表的第 1 行和第 8 行分别对应这两处写反的后果。
04 / 递推与四门
先确认初始状态、递推方向和门的顺序
循环网络题出现答案错误(WA)时,可先检查这三件事,再核对维度、初始化和输出格式。写代码前把它们从题目说明里记录下来,放在旁边随时核对。
| 符号 | 形状 | 在 AI032 输入里的位置 | 含义 |
|---|---|---|---|
| W_x | dH 行 × dIn 列 | 第 2 行起的 dH 行、每行 dIn 个 | 把输入 x_t 变成 dH 个数 |
| W_h | dH 行 × dH 列 | 接着的 dH 行、每行 dH 个 | 把上一步的 h_{t−1} 变成 dH 个数 |
| b | 长度 dH | 接着 1 行 | 偏置,逐项加 |
| h_0 | 长度 dH | 再接着 1 行 | 初始状态,题目给定、不一定是 0 |
| x_1..x_T | 各长度 dIn | 最后 T 行 | 每个时间步的输入 |
| h_t | 长度 dH | 输出第 t 行 | tanh(W_x·x_t + W_h·h_{t−1} + b) |
「先写维度,再写循环」:z 的第 r 项 = b[r] + Σ_j W_x[r][j]·x[j] + Σ_j W_h[r][j]·h[j],两个 Σ 的 j 分别跑 dIn 和 dH。矩阵按行读入,每一行与向量做内积,这与模块 6 · 第 1 课 的矩阵乘向量是同一件事。
| 门 | 线性部分 | 激活 | 取值 | 作用 |
|---|---|---|---|---|
| 输入门 i | W_x^i·x_t + W_h^i·h_{t−1} + b^i | sigmoid | (0, 1) | 本步的候选值 g 写入 c 的比例 |
| 遗忘门 f | W_x^f·x_t + W_h^f·h_{t−1} + b^f | sigmoid | (0, 1) | 上一步的 c_{t−1} 保留的比例 |
| 候选值 g | W_x^g·x_t + W_h^g·h_{t−1} + b^g | tanh | (−1, 1) | 本步想写进 c 的内容 |
| 输出门 o | W_x^o·x_t + W_h^o·h_{t−1} + b^o | sigmoid | (0, 1) | tanh(c_t) 放出到 h_t 的比例 |
四个门的线性部分形式完全一样,都用同一个 x_t 和同一个 h_{t−1},差别只在各自的参数和激活函数。之后两行更新:c_t = f⊙c_{t−1} + i⊙g(用上一步的 c),h_t = o⊙tanh(c_t)(用本步的 c)。AI033 的参数按 i、f、g、o 的固定顺序给出,每个门依次是 d×d 的 W_x、d×d 的 W_h、长度 d 的 b——先用题面示例检查 g 与 o 是否错位,再用各门参数不同的输入检查其他顺序错误。
LSTM 标量例(全部权重 0.5、偏置 0、x=1、h0=c0=0)
四门线性部分 z = 0.5×1 + 0.5×0 = 0.5 f = i = o = sigmoid(0.5) = 0.622459 g = tanh(0.5) = 0.462117 c_1 = f·c_0 + i·g = 0 + 0.622459×0.462117 = 0.287649 h_1 = o·tanh(c_1) = 0.622459×tanh(0.287649) = 0.174270
从空文件实现 RNN 单步函数(rnn_step)与 LSTM 单步函数(lstm_step)
Python# 练习模板:标有「待完成」的部分需要补全
import sys, math
def sigmoid(z: float) -> float:
return 1.0 / (1.0 + math.exp(-z))
def rnn_step(x, h, Wx, Wh, b):
# 待完成:h_new[r] = tanh( Σ Wx[r][j]*x[j] + Σ Wh[r][j]*h[j] + b[r] )
# 注意 Wh 乘的是上一步的 h,不是刚算出的 h_new
...
def lstm_step(x, h, c, params):
# params = [(W_x, W_h, b) × 4],按 AI033 的固定顺序 i、f、g、o;
# 待完成:i、f、o 使用 sigmoid,g 使用 tanh;c_new = f*c + i*g;h_new = o*tanh(c_new)
# 返回 (c_new, h_new),与本页断言的解包顺序一致
...
def main():
data = sys.stdin.read().split()
# 待完成:按题目说明的顺序解析 W_x、W_h、b、h_0、x_1..x_T,逐步递推;
# 每个数四舍五入保留两位小数,舍入后 -0.00 输出 0.00
...
main()模板中的两处待完成部分可通过下方两组断言验证:rnn_step 复现 AI032 样例的 (0.46,0.46)→(0.50,−0.43);lstm_step 复现标量例的 0.287649 与 0.174270。复杂度 O(T·dH·(dIn+dH)),根据题目数据范围核对运算量并在目标语言中验证运行时间,重点检查实现正确性。
两个现成的自查特例
权重全 0 时 RNN 的 h_t = tanh(b) 对所有 t 恒定——该输入可核对偏置与激活;再用非零 W_h 和变化的多步输入检查是否始终读取上一步状态。LSTM 里把遗忘门偏置推到 +10(全开)时,细胞状态 c 会在各时间步持续累加输入贡献;推到 −10(全关)时旧状态 c_{t−1} 被抹掉,c_t ≈ i⊙g 只剩当前输入这一项(f≈0、i=1、g=0.5、c_{t−1}=7 时 c_t≈0.5003,不是 0;只有输入贡献也为 0 时 c_t 才为 0)——用它检验 f 和 i 有没有接反。
为什么 W_h 必须乘上一步的 h:递推的定义是「第 t 步只依赖第 t−1 步的结果和本步输入」。若把本步刚算出的 h_t 的前几项又用到后几项的计算里,第 r 项就依赖同一步的第 0..r−1 项,结果与题目定义不同,而且随行的顺序而变——第 07 节错误表第 1 行用一个 dH=2 的小输入把这个差别打了出来(0.91 与 0.94)。实现上最稳的做法是把新值先放进单独的列表(h_new),整步算完再整体替换。
补充学习(选学)循环网络、LSTM 与注意力:三代序列模型的对照约 6 分钟序列依赖的建模方式、能否并行训练、长距离信息的路径长度
注意力出现之前,序列建模的主流是循环神经网络:逐个时间步读入,把「读到目前为止的全部历史」压进一个固定长度的隐藏状态,再一步一步往后传。这个设计有两处代价:第 t 步必须等第 t−1 步算完,一条序列内部无法并行;历史被反复压缩,离得远的信息传到后面已经被稀释。
LSTM 给隐藏状态加了门控:遗忘门决定丢掉多少旧信息,输入门决定写入多少新信息,细胞状态 c 提供了一条受门控调节、衰减更慢的通路。它明显缓解了遗忘,但递推方式没变——各时间步仍然串行。注意力换了另一条路:每个位置直接对所有可见位置打分取值,任意两个位置之间的信息路径长度是常数,整张打分表可以一次算完。
| 性质 | 循环神经网络(RNN) | LSTM | 注意力 |
|---|---|---|---|
| 序列依赖的建模方式 | 把历史压进固定长度的隐藏状态逐步传递 | 同样逐步传递,但用门控决定写入与遗忘 | 每个位置对可见位置直接打分加权,不经过逐步压缩 |
| 训练时序列内部能否并行 | 不能,第 t 步依赖第 t−1 步 | 不能,门控没有改变串行结构 | 能,整张打分表一次算完 |
| 两个远距离位置之间的信息路径 | 路径长度随距离线性增长 | 同样线性增长,但衰减更慢 | 路径长度为常数 |
| 主要成本 | 长序列训练慢 | 参数量约为循环网络的四倍 | 打分表随序列长度平方增长,即 O(n²) |
两点补充,避免记成绝对结论:训练时序列内部可以并行,不表示自回归生成也能并行——生成阶段仍要一步一步来,这正是模块 7 · 第 3 课 的 KV 缓存要解决的问题;信息路径短也不自动保证长距离依赖一定学得好,它只是去掉了逐步压缩这一处限制。
补充学习(选学)双向拼接:方向独立,位置对齐约 5 分钟反向链的状态要放回原时间位置,不是倒序直接拼
双向 = 两条独立的链:正向链从 t=1 推到 T,反向链从 t=T 推到 1,各有独立权重与初始状态。第 t 步的输出是 [正向 h_t ; 反向 h_t] 拼接,维度 2·dH。
最常见的错误:反向链算完后按计算顺序(T→1)直接拼——序列 [a,b,c] 的 t=1 位置会拿到 t=3 的状态。正确做法是反向链在时刻 t 的状态放回输出序列的第 t 个位置。多层、双向、整数取模的变式题,容易出错的也是这一步——位置对齐加取模。
05 / 题面示例逐步
AI032 样例的两步逐维表、AI033 样例的门值表
AI032「循环网络前向传播」:第一行 T dIn dH,随后 dH 行 W_x、dH 行 W_h、1 行 b、1 行 h_0、T 行 x_t;输出 T 行 h_t,两位小数。题面示例:T=2、dIn=dH=2,W_x = 0.5 的对角、W_h = 0.1 的对角、b = 0、h_0 = (0, 0),x_1 = (1, 1)、x_2 = (1, −1) → 0.46 0.46 / 0.50 -0.43。
| 步 · 维 | W_x·x_t 的这一项 | W_h·h_{t−1} 的这一项 | b | z | tanh(z) | 打印 |
|---|---|---|---|---|---|---|
| t=1 · 第 1 维 | 0.5×1 = 0.5 | 0.1×0 = 0 | 0 | 0.5 | 0.462117 | 0.46 |
| t=1 · 第 2 维 | 0.5×1 = 0.5 | 0.1×0 = 0 | 0 | 0.5 | 0.462117 | 0.46 |
| t=2 · 第 1 维 | 0.5×1 = 0.5 | 0.1×0.462117 = 0.046212 | 0 | 0.546212 | 0.497676 | 0.50 |
| t=2 · 第 2 维 | 0.5×(−1) = −0.5 | 0.1×0.462117 = 0.046212 | 0 | −0.453788 | −0.425008 | −0.43 |
对角矩阵让每一维只和自己那一维相乘,两个 Σ 各只剩一项,便于手算;第 2 步的 W_h 项用的是第 1 步的输出 0.462117,不是本步的。题库里的「权重全 0」用例(b = (−1.16, 1.64, −0.58))四步输出都是 -0.82 0.93 -0.52 = tanh(b),与 x、h_0 无关;「权重偏置全 0」用例输出全是 0.00。
AI033「LSTM 单元前向传播」:第一行 T d,随后按 i、f、g、o 顺序四个参数块(每块 d 行 W_x、d 行 W_h、1 行 b),再 1 行 h_0、1 行 c_0、T 行 x_t;只输出最后一步的 h_T 与 c_T 两行。题面示例:T=1、d=1,四个门的 W_x = W_h = 0,偏置分别是 10、10、2、10;h_0 = 0、c_0 = 1、x_1 = 1 → 0.96 / 1.96。
| 量 | 线性部分 z | 激活 | 值(6 位) | 说明 |
|---|---|---|---|---|
| 输入门 i | 0×1 + 0×0 + 10 = 10 | sigmoid | 0.999955 | 几乎全开 |
| 遗忘门 f | 10 | sigmoid | 0.999955 | 几乎全开:c_0 几乎原样保留 |
| 候选值 g | 0×1 + 0×0 + 2 = 2 | tanh | 0.964028 | 本步想写进去的值 |
| 输出门 o | 10 | sigmoid | 0.999955 | 几乎全开 |
| c_1 | f·c_0 + i·g = 0.999955×1 + 0.999955×0.964028 | — | 1.963938 | 打印 1.96 |
| h_1 | o·tanh(c_1) = 0.999955×tanh(1.963938) = 0.999955×0.961389 | — | 0.961346 | 打印 0.96 |
偏置 10 让三个 sigmoid 门都接近 1。把第 3、4 块当成 o、g 读,会用 sigmoid(2) 当输出门、tanh(10) 当候选值,得到 0.85 / 2.00。这个样例能暴露 g/o 互换,但 i、f、o 的参数块相同,不能区分它们之间的交换;再用第 07 节各门偏置不同的小输入检查 i/o 互换。
06 / 从两个单步函数到程序
参考实现与两份完整程序,每一步落在哪几行
先用标量断言验证 LSTM 单步函数(lstm_step),再组装读入与输出——整段从零写,最容易在门顺序和时序上出错。
| 步骤 | AI032 | AI033 |
|---|---|---|
| 读入 | T dIn dH;dH 行 W_x;dH 行 W_h;b;h_0;T 行 x | T d;四个门各 (d 行 W_x, d 行 W_h, b);h_0;c_0;T 行 x |
| 单步 | rnn_step:z 逐行算,套 tanh | lstm_step:四个 affine → i/f/o 用 sigmoid、g 用 tanh → c_new → h_new |
| 递推 | h 每步整体替换(新列表) | c, h = lstm_step(x, h, c, params) 按 (c, h) 顺序解包 |
| 输出 | 每步一行 dH 个两位小数 | 只输出最后一步:先 h_T 一行,再 c_T 一行 |
| 负零 | fmt:"-0.00" → "0.00" | 同左 |
展开参考实现:rnn_step 与 lstm_step(自带断言;先自己写完再对照)
rnn_step 与 lstm_step 的参考实现(自带断言)
Pythonimport math
def sigmoid(z):
if z >= 0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
def rnn_step(x, h, Wx, Wh, b):
# h_new[r] = tanh( Σ_j Wx[r][j]·x[j] + Σ_j Wh[r][j]·h[j] + b[r] );Wh 乘的是上一步的 h
h_new = []
for r in range(len(b)):
z = b[r] + sum(Wx[r][j] * x[j] for j in range(len(x))) + sum(Wh[r][j] * h[j] for j in range(len(h)))
h_new.append(math.tanh(z))
return h_new
def affine(gate, x, h):
Wx, Wh, b = gate
return [b[r] + sum(Wx[r][j] * x[j] for j in range(len(x))) + sum(Wh[r][j] * h[j] for j in range(len(h)))
for r in range(len(b))]
def lstm_step(x, h, c, params):
# params = [(W_x, W_h, b) × 4],顺序固定 i、f、g、o;i/f/o 用 sigmoid,g 用 tanh
i = [sigmoid(z) for z in affine(params[0], x, h)]
f = [sigmoid(z) for z in affine(params[1], x, h)]
g = [math.tanh(z) for z in affine(params[2], x, h)]
o = [sigmoid(z) for z in affine(params[3], x, h)]
c_new = [f[r] * c[r] + i[r] * g[r] for r in range(len(c))] # 用上一步的 c
h_new = [o[r] * math.tanh(c_new[r]) for r in range(len(c))] # 用本步的 c_new
return c_new, h_new
# 标量例:全部权重 0.5、偏置 0、x=1、h0=c0=0
gate = ([[0.5]], [[0.5]], [0.0])
c1, h1 = lstm_step([1.0], [0.0], [0.0], [gate, gate, gate, gate])
assert abs(c1[0] - 0.287649) < 1e-6 and abs(h1[0] - 0.174270) < 1e-6
# AI032 题面示例两步 + 练习 1 的第三步 x_3 = (0, 1)
Wx, Wh, b = [[0.5, 0.0], [0.0, 0.5]], [[0.1, 0.0], [0.0, 0.1]], [0.0, 0.0]
h = rnn_step([1.0, 1.0], [0.0, 0.0], Wx, Wh, b)
assert [round(v, 2) for v in h] == [0.46, 0.46]
h = rnn_step([1.0, -1.0], h, Wx, Wh, b)
assert [round(v, 2) for v in h] == [0.50, -0.43]
h = rnn_step([0.0, 1.0], h, Wx, Wh, b)
assert [round(v, 2) for v in h] == [0.05, 0.43]
# 权重全 0 时 h_t = tanh(b),与 x、h 无关
z = [[0.0, 0.0]] * 2
assert [round(v, 2) for v in rnn_step([3.81, 0.72], [9.0, -9.0], z, z, [-1.16, 1.64])] == [-0.82, 0.93]
# AI033 题面示例:偏置 10 把 i、f、o 推到接近 1,g = tanh(2)
zero = [[0.0]]
params = [(zero, zero, [10.0]), (zero, zero, [10.0]), (zero, zero, [2.0]), (zero, zero, [10.0])]
c1, h1 = lstm_step([1.0], [0.0], [1.0], params)
assert round(c1[0], 2) == 1.96 and round(h1[0], 2) == 0.96
# 练习 3:输出门偏置改成 −10,c 不变、h 归零
params[3] = (zero, zero, [-10.0])
c1, h1 = lstm_step([1.0], [0.0], [1.0], params)
assert round(c1[0], 2) == 1.96 and round(h1[0], 2) == 0.0断言覆盖:标量例 0.287649 / 0.174270、AI032 样例两步与练习 1 的第三步、权重全 0 时 h = tanh(b)、AI033 样例 1.96 / 0.96、练习 3 关闭输出门。sigmoid 用分段写法,z 很负时不先算 exp(−z)。
展开完整参考程序 1:AI032 循环网络前向传播
完整程序:AI032(标准输入 → 标准输出)
Pythonimport math
import sys
def fmt(v):
# 两位小数;舍入后的 -0.00 归一为 0.00
s = f"{v:.2f}"
return "0.00" if s == "-0.00" else s
def rnn_step(x, h, Wx, Wh, b):
# h_new[r] = tanh( Σ_j Wx[r][j]·x[j] + Σ_j Wh[r][j]·h[j] + b[r] )
# Wh 乘的是上一步的 h(参数 h),不是正在算的 h_new
h_new = []
for r in range(len(b)):
z = b[r]
z += sum(Wx[r][j] * x[j] for j in range(len(x)))
z += sum(Wh[r][j] * h[j] for j in range(len(h)))
h_new.append(math.tanh(z))
return h_new
def main():
data = sys.stdin.read().split()
pos = 0
def take(n):
nonlocal pos
vals = [float(v) for v in data[pos:pos + n]]
pos += n
return vals
T, dIn, dH = (int(v) for v in data[:3])
pos = 3
Wx = [take(dIn) for _ in range(dH)] # dH 行、每行 dIn 个
Wh = [take(dH) for _ in range(dH)] # dH 行、每行 dH 个
b = take(dH)
h = take(dH) # h_0 由输入给出
lines = []
for _ in range(T): # t = 1..T 正向推进
x = take(dIn)
h = rnn_step(x, h, Wx, Wh, b)
lines.append(" ".join(fmt(v) for v in h))
print("\n".join(lines))
main()适用于 T 到 50、dIn 与 dH 到 16 的全部数据范围。take(n) 按顺序切出 n 个数,读入顺序由调用顺序保证:W_x → W_h → b → h_0 → x_1..x_T。
展开完整参考程序 2:AI033 LSTM 单元前向传播
完整程序:AI033(标准输入 → 标准输出)
Pythonimport math
import sys
def fmt(v):
s = f"{v:.2f}"
return "0.00" if s == "-0.00" else s
def sigmoid(z):
# 分段写法:z 很负时不会先算 exp(-z) 上溢
if z >= 0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
def affine(gate, x, h):
# 一个门的线性部分:W_x·x + W_h·h + b,逐行算
Wx, Wh, b = gate
return [b[r] + sum(Wx[r][j] * x[j] for j in range(len(x))) + sum(Wh[r][j] * h[j] for j in range(len(h)))
for r in range(len(b))]
def lstm_step(x, h, c, params):
# params = [(W_x, W_h, b) × 4],顺序固定为 i、f、g、o
i = [sigmoid(z) for z in affine(params[0], x, h)] # 输入门
f = [sigmoid(z) for z in affine(params[1], x, h)] # 遗忘门
g = [math.tanh(z) for z in affine(params[2], x, h)] # 候选值
o = [sigmoid(z) for z in affine(params[3], x, h)] # 输出门
c_new = [f[r] * c[r] + i[r] * g[r] for r in range(len(c))] # 用上一步的 c
h_new = [o[r] * math.tanh(c_new[r]) for r in range(len(c))] # 用本步的 c_new
return c_new, h_new
def main():
data = sys.stdin.read().split()
pos = 0
def take(n):
nonlocal pos
vals = [float(v) for v in data[pos:pos + n]]
pos += n
return vals
T, d = int(data[0]), int(data[1])
pos = 2
params = []
for _ in range(4): # 依次读 i、f、g、o
Wx = [take(d) for _ in range(d)]
Wh = [take(d) for _ in range(d)]
b = take(d)
params.append((Wx, Wh, b))
h = take(d) # h_0
c = take(d) # c_0
for _ in range(T):
x = take(d)
c, h = lstm_step(x, h, c, params)
print(" ".join(fmt(v) for v in h)) # 只输出最后一步
print(" ".join(fmt(v) for v in c))
main()适用于 T 到 30、d 到 8 的全部数据范围。四个门用同一个 affine 算线性部分,参数列表的下标 0..3 就是题目规定的 i、f、g、o。
07 / 边界、反例与复杂度
用小输入定位参数顺序与递推错误
把参考程序改成表中的错误写法,比较修改前后的输出。输入中的斜杠表示换行;标为「题面示例」的行使用第 05 节所述示例,可在题目页复制完整输入。
| 错误做法 | 输入 | 错误输出 | 正确输出 | 判题结果 |
|---|---|---|---|---|
| AI032 W_h 乘了本步刚算出的 h(把新值写回同一列表) | 1 1 2 / 1.00 / 1.00 / 0.00 0.00 / 1.00 0.00 / 0.00 0.00 / 0.50 0.50 / 1.00(W_h 第 2 行用到第 1 维) | 0.76 0.94 | 0.76 0.91 | 答案错误(WA) |
| AI032 无视输入的 h_0、从零开始 | 2 1 1 / 1 / 1 / 0 / 1 / 0 / 1(W_x=W_h=1、b=0、h_0=1,两个输入为 0、1) | 0.00 / 0.76 | 0.76 / 0.94 | 答案错误(WA) |
| AI032 先读 W_h 再读 W_x | 题面示例 | 0.10 0.10 / 0.15 -0.05 | 0.46 0.46 / 0.50 -0.43 | 答案错误(WA) |
| AI032 不把 −0.00 归一 | 1 1 1 / -0.01 / 0.00 / 0.00 / 0.00 / 0.10(h_1 = tanh(−0.001)) | -0.00 | 0.00 | 答案错误(WA) |
| AI033 候选值 g 也用 sigmoid | 题面示例 | 0.95 / 1.88 | 0.96 / 1.96 | 答案错误(WA) |
| AI033 把第 3、4 个参数块当成 o、g(读成 i、f、o、g) | 题面示例 | 0.85 / 2.00 | 0.96 / 1.96 | 答案错误(WA) |
| AI033 把 i 与 o 的参数块读反 | 1 1 / 0 / 0 / 0 / 0 / 0 / -1 / 0 / 0 / 1 / 0 / 0 / 2 / 0 / 0 / 0(四门权重全 0,i/f/g/o 偏置为 0/−1/1/2,h_0=c_0=x_1=0) | 0.29 / 0.67 | 0.32 / 0.38 | 答案错误(WA) |
| AI033 h_t 用旧的 c_{t−1} 过 tanh | 题面示例 | 0.76 / 1.96 | 0.96 / 1.96 | 答案错误(WA) |
| AI033 漏掉 f⊙c_{t−1}(c_t = i⊙g) | 题面示例 | 0.75 / 0.96 | 0.96 / 1.96 | 答案错误(WA) |
| AI033 先输出 c_T 再输出 h_T | 题面示例 | 1.96 / 0.96 | 0.96 / 1.96 | 答案错误(WA) |
第 1 行:dIn=1、dH=2,W_x 两行都是 1,W_h = [[0,0],[1,0]],h_0 = (0.5, 0.5),x_1 = 1。正确:第 1 维 tanh(1) = 0.7616,第 2 维 tanh(1 + 1×0.5) = tanh(1.5) = 0.9051;写回同一列表时第 2 维读到的是刚算出的 0.7616,得 tanh(1.7616) = 0.9427。第 2 行:先得 tanh(1),再得 tanh(1+tanh(1)),能检查 h_0 与跨步递推。第 7 行:c_1 = sigmoid(0)·tanh(1),h_1 = sigmoid(2)·tanh(c_1);交换 i/o 会先改变 c_1,再改变 h_1。第 4 行按题目要求把 −0.00 输出为 0.00。逐行核对完整输入、运算步骤和格式。
| 做法 | 时间 | 本课规模下 |
|---|---|---|
| AI032 每步两个矩阵乘向量 | O(T·dH·(dIn+dH)) | 按范围估算:最多 50×16×32 = 25,600 个乘加项 |
| AI033 每步四个门各两个矩阵乘向量 | O(T·4·d·2d) | 按范围估算:最多 30×4×8×16 = 15,360 个乘加项 |
| 每个时间步重新构造参数矩阵 | 多一个 O(T·d²) 的复制 | 避免重复复制:参数读一次、用 T 次 |
08 / 渐进练习与参考答案
跟做 → 改一个条件 → 独立实现 → 迁移
每题先在纸上或文件里做完,再展开答案。
练习 1(跟做):给 AI032 题面示例加第三步 x_3 = (0, 1)。按第 05 节表格的格式写出两维的 W_x·x_3、W_h·h_2、z、tanh(z) 与打印值。
展开练习 1 答案
h_2 = (0.497676, −0.425008)。第 1 维:0.5×0 = 0,0.1×0.497676 = 0.049768,z = 0.049768,tanh = 0.049727 → 0.05;第 2 维:0.5×1 = 0.5,0.1×(−0.425008) = −0.042501,z = 0.457499,tanh = 0.428044 → 0.43。第三行输出 0.05 0.43(参考实现里有这条断言)。
练习 2(改一个条件):把 AI032 题面示例的 h_0 从 (0, 0) 改成 (1, 1),两步输出各是什么?为什么第 1 步就变了?
展开练习 2 答案
第 1 步:z = 0.5×1 + 0.1×1 = 0.6,两维都是 tanh(0.6) = 0.537050 → 0.54 0.54。第 2 步:第 1 维 z = 0.5 + 0.1×0.537050 = 0.553705 → tanh = 0.503292 → 0.50;第 2 维 z = −0.5 + 0.053705 = −0.446295 → tanh = −0.418849 → -0.42。h_0 参与第 1 步的 W_h 项,所以第 1 步就不同;把 h_0 当成 0 的程序在题库「非零 h0」用例上直接出错(第 07 节第 2 行)。
练习 3(改一个条件):把 AI033 题面示例输出门的偏置从 10 改成 −10,输出两行各是什么?哪一行不变,为什么?
展开练习 3 答案
o = sigmoid(−10) = 0.0000454,h_1 = o·tanh(1.963938) = 0.0000436 → 0.00;c_1 仍是 1.963938 → 1.96。输出门只控制「c 里的内容放出多少到 h」,不参与 c 的更新,所以第二行不变。反过来,把遗忘门偏置改成 −10 则 c_1 ≈ i·g = 0.964,两行都变(参考实现最后一组断言只做了输出门的版本,遗忘门的版本可以自己加一条)。
练习 4(独立实现):不看第 06 节,完成模板里的 rnn_step 与 lstm_step,并通过「代码自测」的断言;再加两条:权重全 0 时 rnn_step 返回 tanh(b),AI033 样例返回 (1.96, 0.96)。
展开练习 4 答案
rnn_step 与 lstm_step 的参考实现(自带断言)
Pythonimport math
def sigmoid(z):
if z >= 0:
return 1.0 / (1.0 + math.exp(-z))
e = math.exp(z)
return e / (1.0 + e)
def rnn_step(x, h, Wx, Wh, b):
# h_new[r] = tanh( Σ_j Wx[r][j]·x[j] + Σ_j Wh[r][j]·h[j] + b[r] );Wh 乘的是上一步的 h
h_new = []
for r in range(len(b)):
z = b[r] + sum(Wx[r][j] * x[j] for j in range(len(x))) + sum(Wh[r][j] * h[j] for j in range(len(h)))
h_new.append(math.tanh(z))
return h_new
def affine(gate, x, h):
Wx, Wh, b = gate
return [b[r] + sum(Wx[r][j] * x[j] for j in range(len(x))) + sum(Wh[r][j] * h[j] for j in range(len(h)))
for r in range(len(b))]
def lstm_step(x, h, c, params):
# params = [(W_x, W_h, b) × 4],顺序固定 i、f、g、o;i/f/o 用 sigmoid,g 用 tanh
i = [sigmoid(z) for z in affine(params[0], x, h)]
f = [sigmoid(z) for z in affine(params[1], x, h)]
g = [math.tanh(z) for z in affine(params[2], x, h)]
o = [sigmoid(z) for z in affine(params[3], x, h)]
c_new = [f[r] * c[r] + i[r] * g[r] for r in range(len(c))] # 用上一步的 c
h_new = [o[r] * math.tanh(c_new[r]) for r in range(len(c))] # 用本步的 c_new
return c_new, h_new
# 标量例:全部权重 0.5、偏置 0、x=1、h0=c0=0
gate = ([[0.5]], [[0.5]], [0.0])
c1, h1 = lstm_step([1.0], [0.0], [0.0], [gate, gate, gate, gate])
assert abs(c1[0] - 0.287649) < 1e-6 and abs(h1[0] - 0.174270) < 1e-6
# AI032 题面示例两步 + 练习 1 的第三步 x_3 = (0, 1)
Wx, Wh, b = [[0.5, 0.0], [0.0, 0.5]], [[0.1, 0.0], [0.0, 0.1]], [0.0, 0.0]
h = rnn_step([1.0, 1.0], [0.0, 0.0], Wx, Wh, b)
assert [round(v, 2) for v in h] == [0.46, 0.46]
h = rnn_step([1.0, -1.0], h, Wx, Wh, b)
assert [round(v, 2) for v in h] == [0.50, -0.43]
h = rnn_step([0.0, 1.0], h, Wx, Wh, b)
assert [round(v, 2) for v in h] == [0.05, 0.43]
# 权重全 0 时 h_t = tanh(b),与 x、h 无关
z = [[0.0, 0.0]] * 2
assert [round(v, 2) for v in rnn_step([3.81, 0.72], [9.0, -9.0], z, z, [-1.16, 1.64])] == [-0.82, 0.93]
# AI033 题面示例:偏置 10 把 i、f、o 推到接近 1,g = tanh(2)
zero = [[0.0]]
params = [(zero, zero, [10.0]), (zero, zero, [10.0]), (zero, zero, [2.0]), (zero, zero, [10.0])]
c1, h1 = lstm_step([1.0], [0.0], [1.0], params)
assert round(c1[0], 2) == 1.96 and round(h1[0], 2) == 0.96
# 练习 3:输出门偏置改成 −10,c 不变、h 归零
params[3] = (zero, zero, [-10.0])
c1, h1 = lstm_step([1.0], [0.0], [1.0], params)
assert round(c1[0], 2) == 1.96 and round(h1[0], 2) == 0.0见第 06 节展开区(同一份代码)。
练习 5(迁移):用 rnn_step 写双向函数 bidir(xs, h0_f, h0_b, Pf, Pb),返回每个位置的 [正向 h_t ; 反向 h_t]。用两个自查输入验证:① 权重全 0、两条链偏置 0.5 与 −0.5;② W_x = 1、W_h = 0.5、b = 0、序列 [1, 0, −1],检查第 1 个位置的反向分量是 0.6741 而不是 −0.7616。
展开练习 5 答案
反向链按 t = T..1 计算,算完把第 t 步的结果写进 bwd[t],而不是 append 到列表末尾。序列 [1, 0, −1]:正向 (0.7616, 0.3634, −0.6741),反向从末尾起 (−0.7616, −0.3634, 0.6741) 分别放回第 3、2、1 个位置,所以位置 1 的输出是 [0.7616 ; 0.6741]。若按计算顺序直接拼,位置 1 会拿到 −0.7616——这就是「进阶练习 1」要检查的对齐错误。
bidir 的参考实现(自带断言)
Pythonimport math
def rnn_step(x, h, Wx, Wh, b):
h_new = []
for r in range(len(b)):
z = b[r] + sum(Wx[r][j] * x[j] for j in range(len(x))) + sum(Wh[r][j] * h[j] for j in range(len(h)))
h_new.append(math.tanh(z))
return h_new
def bidir(xs, h0_f, h0_b, Pf, Pb):
# Pf / Pb = (W_x, W_h, b):正向链与反向链各自独立的参数
# 返回长度 T 的列表,第 t 项 = [正向 h_t ; 反向 h_t](拼接,维度 2·dH)
T = len(xs)
fwd = []
h = h0_f
for t in range(T): # 正向:t = 1 → T
h = rnn_step(xs[t], h, *Pf)
fwd.append(h)
bwd = [None] * T
h = h0_b
for t in range(T - 1, -1, -1): # 反向:t = T → 1
h = rnn_step(xs[t], h, *Pb)
bwd[t] = h # 写回原时间位置,不是 append
return [fwd[t] + bwd[t] for t in range(T)]
R = lambda vs: [round(v, 4) for v in vs]
# 自查 1:权重全 0、两条链偏置不同 → 每个位置都是 [tanh(b_f), tanh(b_b)]
Z = [[0.0]]
out = bidir([[1.0], [2.0], [3.0]], [0.0], [0.0], (Z, Z, [0.5]), (Z, Z, [-0.5]))
assert all(R(o) == [0.4621, -0.4621] for o in out)
# 自查 2:W_x = 1、W_h = 0.5、b = 0,序列 [1, 0, −1]:反向链的第 1 个位置拿到的是它在 t=1 的状态 0.6741,而不是 t=3 的 −0.7616
P = ([[1.0]], [[0.5]], [0.0])
out = bidir([[1.0], [0.0], [-1.0]], [0.0], [0.0], P, P)
assert R(out[0]) == [0.7616, 0.6741]
assert R(out[1]) == [0.3634, -0.3634]
assert R(out[2]) == [-0.6741, -0.7616]
# 拼接后的维度是 2·dH
assert len(out[0]) == 2两组自查各三条断言,最后一条检查拼接后的维度是 2·dH。
09 / 读题要求与复习自评
两道题的要求对照,以及完成本课之后怎么复习
提交前把下表过一遍;题目页的题面与样例是最终依据。
| 项目 | AI032 循环网络 | AI033 LSTM 单元 |
|---|---|---|
| 输入顺序 | T dIn dH;W_x(dH×dIn);W_h(dH×dH);b;h_0;x_1..x_T | T d;i、f、g、o 四块,每块 W_x(d×d)、W_h(d×d)、b;h_0;c_0;x_1..x_T |
| 初始状态 | h_0 由输入给出 | h_0、c_0 由输入给出 |
| 激活 | tanh | i、f、o 用 sigmoid;g 用 tanh;h_t = o⊙tanh(c_t) |
| 输出 | T 行、每行 dH 个两位小数 | 两行:h_T、c_T,各 d 个两位小数 |
| 负零 | −0.00 输出 0.00 | 同左 |
| 数据范围 | T ≤ 50;dIn, dH ≤ 16;|权重| ≤ 2;|x| ≤ 4 | T ≤ 30;d ≤ 8;|权重、状态、x| ≤ 4;|偏置| ≤ 10 |
| 示例 | 两步对角权重 → 0.46 0.46 / 0.50 -0.43 | d=1 门近全开 → 0.96 / 1.96 |
需要对照解法时,展开本课第 06 节的两份完整参考程序,或在题目页查看题解。完成条件:AI032、AI033 两道必做题都通过判题,并勾选下方「学习完成检查」的六条,本课才记为完成;进阶练习单独统计,不影响完成状态。六条自查是自评,勾选不改变题目的通过(AC)状态。复习时用三个问题自测:① 不看正文,写出 RNN 单步公式并说出 W_x、W_h 的形状;② 不看表格,重算 AI032 样例第 2 步第 2 维的 z 与 tanh(z);③ 按 i、f、g、o 的顺序说出各门的激活与作用,并说出 c_t、h_t 两行更新各用的是「上一步」还是「本步」的量。答不出哪一条,就回到对应的节重读,再做第 08 节对应的练习。
10 / 练习
按顺序完成本课的任务
必做题已通过 0/2 道
lstm_step),再组装读入与输出——整段从零写,最容易在门顺序和时序上出错。参考实现与完整程序在第 06 节的展开区里:先自己写完并提交一次,再展开对照。标量例复现:0.287649 与 0.174270
代码自测自主练习练习重点:LSTM 单步函数(lstm_step)的四门顺序与细胞状态 c、隐藏状态 h 的更新公式;预计用时:15 分钟
完成标准:两个断言通过,且能说明 c_1、h_1 各是怎么算出来的
需要时查看提示
四门的线性部分完全相同(都是 0.5),差别只在激活:i、f、o 使用 sigmoid,g 使用 tanh。若断言未通过,可输出四个门的值,并与上方的推演过程逐项比较。第 04 节有推演,参考实现在第 06 节。
自测代码(复制到你的代码文件中运行,检查输出是否一致)
# 参考值:全部权重 0.5、偏置 0、x=1、h0=c0=0 时
# 四门线性部分都是 0.5*1 + 0.5*0 = 0.5
# f = i = o = sigmoid(0.5) = 0.622459,g = tanh(0.5) = 0.462117
# c1 = f*c0 + i*g = 0.287649,h1 = o*tanh(c1) = 0.174270
# 下面直接调用你写的 lstm_step;params 按 AI033 题目说明的 i、f、g、o 顺序,
# 每个门是 (W_x, W_h, b),d=1 时 W_x=W_h=[[0.5]]、b=[0.0]
gate = ([[0.5]], [[0.5]], [0.0])
params = [gate, gate, gate, gate] # 顺序:i, f, g, o
c1, h1 = lstm_step(x=[1.0], h=[0.0], c=[0.0], params=params)
assert abs(c1[0] - 0.287649) < 1e-6
assert abs(h1[0] - 0.174270) < 1e-6
# rnn_step 同样直接调用:AI032 样例(W_x=0.5I、W_h=0.1I、b=0、h0=0)
Wx, Wh, b = [[0.5, 0.0], [0.0, 0.5]], [[0.1, 0.0], [0.0, 0.1]], [0.0, 0.0]
h1r = rnn_step([1.0, 1.0], [0.0, 0.0], Wx, Wh, b)
h2r = rnn_step([1.0, -1.0], h1r, Wx, Wh, b)
assert [round(v, 2) for v in h1r] == [0.46, 0.46]
assert [round(v, 2) for v in h2r] == [0.50, -0.43]AI032 · 循环网络前向传播
必做任务 1练习重点:读入顺序 W_x→W_h→b→h_0→x 序列;正向递推输出每步 h_t;预计用时:30 分钟
完成标准:能解释 W_h 为什么乘 h_{t−1} 而不是本步的 h_t
需要时查看提示
输出 T 行、每行 dH 个数,四舍五入保留两位小数,−0.00 要打成 0.00。tanh 饱和到 ±1 附近是正常现象。先用「权重全 0 ⇒ h_t 恒等于 tanh(b)」核对偏置和激活,再用非零 W_h 与不同时间步的输入检查递推。第 05 节有样例的逐维表。
AI033 · LSTM 单元前向传播
必做任务 2练习重点:按 i、f、g、o 固定顺序读参数;只输出最终 h_T 与 c_T 两行;预计用时:30 分钟
完成标准:样例(c_0=1、门全开、g=tanh(2))的 0.96 / 1.96 能手算出来
需要时查看提示
每个门的参数块依次是 W_x(d×d)、W_h(d×d)、b(d)。样例的三个 sigmoid 门偏置均为 10,候选值偏置为 2,可核对 g 与 o 是否读反;再用第 07 节的非对称小输入区分 i 与 o。约束里权重、状态、输入的绝对值 ≤4、偏置的绝对值 ≤10,偏置范围更宽是为了得到全开或全关的门值。输出两位小数、−0.00 归一为 0.00。第 05 节有样例的门值表。
双向拼接:把反向链放回原位置
进阶练习 1进阶练习自主练习练习重点:用 RNN 单步函数(rnn_step)计算正向与反向两条链,第 t 步输出拼成 [正向 h_t ; 反向 h_t];预计用时:15 分钟
完成标准:序列 [a,b,c] 的 t=1 位置拿到的是反向链在 t=1 的状态,不是 t=3 的
需要时查看提示
反向链按 t=T…1 计算,算完把第 t 步结果写进输出序列的第 t 个位置(out[t]),而不是追加(append)到列表末尾。用权重全 0、偏置不同的两条链自查:正向链输出恒为 tanh(b_f)、反向链恒为 tanh(b_b),拼接顺序错了两半会对调。第 08 节练习 5 有带断言的参考实现。
提交结果
提交结果说明与处理方法
- WA
答案错误
三个常见错误:四门顺序与权重块对应错、W_h 乘了本步刚算出的 h、初始状态没用输入给的值。第 07 节的表给出了每种错误在题库用例上的输出
- PE
格式错误
每行 dH 个数、单空格、两位小数;−0.00 必须打成 0.00
- RE
运行错误
参数块解析位置发生偏移:先核对 W_x、W_h、b 的元素数量,再逐个读取
- TLE
超时
核对实现复杂度是否为 O(T·d²),并检查是否在每个时间步重复构造参数矩阵
- AC
通过
再使用权重全为 0 和遗忘门接近 0 的两个特例验证边界
11 / 学习完成检查
本课学习完成检查
完成本课需要:必做题全部通过,并勾选本课的全部学习完成检查;进阶练习、基础加练与复习题单独统计,不影响完成状态。登录后,勾选记录会保存到账号,并更新课程总览的完成状态。