📌 本文属于《Python神经网络入门:零基础保姆级路线图》专栏
上一篇:python神经网络编程入门(十五)——RNN的数学原理与结构剖析:一个公式,如何统治所有时间步?
下一篇:python神经网络编程入门(十七)——RNN BPTT算法(上):梯度沿时间轴传播的链式法则
完整目录 & 更新记录:《Python神经网络入门:零基础保姆级路线图(附全系列免费源码)》

引言:上一章我们"读懂了"公式,这一章我们"运行"它

先花 30 秒回顾一下上一篇(第十五篇)。我们把 RNN 的灵魂公式 h t = tanh ⁡ ( W x h ⋅ x t + W h h ⋅ h t − 1 + b h ) h_t = \tanh(W_{xh} \cdot x_t + W_{hh} \cdot h_{t-1} + b_h) ht=tanh(Wxhxt+Whhht1+bh) 拆到了骨头里:

  1. 三个权重矩阵的身份档案—— W x h    ( I , H ) W_{xh}\;(I,H) Wxh(I,H) 是"翻译门"、 W h h    ( H , H ) W_{hh}\;(H,H) Whh(H,H) 是必须为方阵的"记忆接力引擎"、 W h y    ( H , O ) W_{hy}\;(H,O) Why(H,O) 是"翻译出去";
  2. 四个维度的张量流转—— B B B=平行世界数、 S S S=时间轴长、 I I I=每刻信息量、 H H H=记忆容量,输入 x    ( B , S , I ) x\;(B,S,I) x(B,S,I) 切片成 x t    ( B , I ) x_t\;(B,I) xt(B,I),一路流到 y t    ( B , O ) y_t\;(B,O) yt(B,O)
  3. 参数量与序列长度无关—— S = 10 S=10 S=10 还是 S = 1000 S=1000 S=1000,RNN 层都是同一套 93,962 93{,}962 93,962 个参数,这就是"参数共享"的底气;
  4. h 0 h_0 h0 惯例全零——无历史、无先验、可复现。

上一篇结尾我们立了个 flag:“公式拆明白了、维度看清楚了,下一章就把公式变成函数。” 今天就是兑现的时候。

但你可能还有这些疑问:

  • 一个 for 循环里,记忆接力棒 h t − 1 → h t h_{t-1} \to h_t ht1ht 到底怎么传?传丢了会怎样?
  • 函数该返回什么?为什么除了 h s e q h_{seq} hseq y s e q y_{seq} yseq,还要多返回一个 cache
  • cache 里到底要存什么?少存一个会怎样?多存一个浪费吗?
  • 数学公式写 W x h ⋅ x t W_{xh} \cdot x_t Wxhxt,代码里却写成 np.dot(x_t, W_xh)——乘法顺序怎么反了?
  • 如果序列长度 S = 1 S=1 S=1,RNN 会退化成什么网络?

学完本章,你要能独立完成三件事:

🎯 本章三大目标

  1. 手写出完整的 rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0),跑通前向推理,返回 (h_seq, y_seq, cache)
  2. 说清楚 cache 缓存了什么、为什么是第四章 BPTT 反向传播的"口粮";
  3. np.random 生成假数据,验证输出维度与上一篇的推演表逐位一致

本篇路线图:施工蓝图(第一节)→ 单时间步函数(第二节)→ 时间维循环与完整实现(第三节)→ cache 详解(第四节)→ 五大实操验证(第五节,含本章里程碑)→ 边界思考: S = 1 S=1 S=1 退化实验(第六节)→ 常见坑与 FAQ(第七节)→ 小结与下章预告(第八节)。


一、从公式到函数:先画一张"施工蓝图"

1.1 我们要实现的公式,就两条

回顾第二章,RNN 在任意时间步 t t t 干的事就两步:

z t = W x h ⋅ x t + W h h ⋅ h t − 1 + b h z_t = W_{xh} \cdot x_t + W_{hh} \cdot h_{t-1} + b_h zt=Wxhxt+Whhht1+bh

h t = tanh ⁡ ( z t ) , y t = W h y ⋅ h t + b y h_t = \tanh(z_t), \qquad y_t = W_{hy} \cdot h_t + b_y ht=tanh(zt),yt=Whyht+by

把这两条公式"翻译"成工程需求,我们需要:

工程需求 对应物
一个容器装下整个批次的数据 输入张量 x    ( B , S , I ) x\;(B, S, I) x(B,S,I)
三个权重矩阵 + 两个偏置 W x h , W h h , W h y , b h , b y W_{xh}, W_{hh}, W_{hy}, b_h, b_y Wxh,Whh,Why,bh,by
记忆的起点 初始隐藏状态 h 0    ( B , H ) h_0\;(B, H) h0(B,H),惯例全零
循环 S S S 次的"流水线" 一个 for t in range(S) 循环
每步算完的中间结果 缓存 cache(第四节细讲)
1.2 函数签名:先定接口,再写实现

好的工程习惯是先定接口。本章的签名长这样:

def rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0):
    """返回 (h_seq, y_seq, cache)"""
    ...

为什么把权重全部作为参数传进来,而不是像第二章的教学版那样用全局变量?因为:

  1. 可复用:同一个函数可以被不同的权重组合调用(比如第六节 S = 1 S=1 S=1 退化实验、实操 B 的小模型);
  2. 可测试:参数显式传入,单元测试时想换一组权重就直接传,不用改函数体;
  3. 为第四章铺路:反向传播的 rnn_backward 也要拿同样的权重计算梯度,接口对齐,前后向天然配套。
1.3 一张图看懂完整数据流

下面是本章的"施工总图"——从输入 x x x 出发,经过时间循环,一路流出 h s e q h_{seq} hseq y s e q y_{seq} yseq 和 cache:

在这里插入图片描述

🐍 ① 所有时间步共用同一套权重**(参数共享,体现在图里就是每个单元里都写着同样的 W x h / W h h / W h y W_{xh}/W_{hh}/W_{hy} Wxh/Whh/Why);② 记忆接力——每个单元的 h t − 1 h_{t-1} ht1 来自左边邻居,第 0 0 0 步的"上一步记忆"是 h 0 h_0 h0(全零);③ 每算一步,中间量都往 cache 里存一份,最后堆叠成 ( S , B , ⋅ ) (S, B, \cdot) (S,B,)


二、先写最小单元:单时间步函数 rnn_forward_step

整条流水线里最核心的零件,是单时间步的计算。第二章的 shape.py 里我们已经写过一版,这里把它升级成"本章正式版":

def rnn_forward_step(x_t, h_prev):
    """单时间步前向:z_t -> h_t -> y_t(教学版:直接使用全局权重)

    x_t    : (B, I)  当前时间步的输入
    h_prev : (B, H)  上一步的隐藏状态
    返回 z_t, h_t, y_t,形状分别为 (B,H), (B,H), (B,O)
    """
    z_t = np.dot(x_t, W_xh) + np.dot(h_prev, W_hh) + b_h   # (B, H) 线性组合
    h_t = np.tanh(z_t)                                      # (B, H) tanh 压缩
    y_t = np.dot(h_t, W_hy) + b_y                           # (B, O) 翻译成输出
    return z_t, h_t, y_t
2.1 逐行体检:每一行都在干什么?

第 1 行:线性组合 z t z_t zt np.dot(x_t, W_xh) 把当前输入翻译进隐藏空间,np.dot(h_prev, W_hh) 把旧记忆"回放"一遍,再加偏置 b h b_h bh。形状是 ( B , H ) (B, H) (B,H)

第 2 行:tanh 压缩。 z t z_t zt 压进 ( − 1 , 1 ) (-1, 1) (1,1)——数值稳定、能表达正负、梯度友好(第二章 1.6 节的三条好处,这里直接兑现)。

第 3 行:翻译输出。 如果这个时间步需要"产出"(many-to-many 任务),就把 h t h_t ht 映射成 y t    ( B , O ) y_t\;(B, O) yt(B,O)

第 4 行:同时返回三个量。 为什么 z t z_t zt 也要返回?因为第四章反向传播需要 tanh ⁡ \tanh tanh 的导数 1 − tanh ⁡ 2 ( z t ) 1 - \tanh^2(z_t) 1tanh2(zt)——第二章埋的伏笔,这里接上了

2.2 一个细节:为什么代码里是 x_t @ W_xh,公式里却写 W x h ⋅ x t W_{xh} \cdot x_t Wxhxt

这是初学 NumPy 最容易被绕晕的地方,值得花 30 秒讲透。

数学公式里默认 x t x_t xt列向量,权重左乘 z t = W x h ⋅ x t z_t = W_{xh} \cdot x_t zt=Wxhxt,此时 W x h W_{xh} Wxh 的形状是 ( H , I ) (H, I) (H,I)。而 NumPy 里我们习惯把输入存成行向量(一个批次就是 ( B , I ) (B, I) (B,I) 的矩阵),权重矩阵形状约定为 ( I , H ) (I, H) (I,H)(第二章速查表),于是:

x t    ( B , I ) ⏟ 行向量 ⋅ W x h    ( I , H ) ⏟ 权重右乘    ⟶    z t    ( B , H ) \underbrace{x_t \;(B, I)}_{\text{行向量}} \cdot \underbrace{W_{xh}\;(I, H)}_{\text{权重右乘}} \;\longrightarrow\; z_t\;(B, H) 行向量 xt(B,I)权重右乘 Wxh(I,H)zt(B,H)

左乘列向量 和 右乘行向量,是同一个线性变换的两种写法(互为转置),内容完全等价。 判断标准只有一个:内层维度必须对齐—— x t x_t xt 的最后一维是 I I I W x h W_{xh} Wxh 的第一维也是 I I I,乘起来就对了。记住这个"内层对齐"口诀,比死记乘法顺序管用得多。

2.3 偏置 b h b_h bh 的"广播"魔法

b h b_h bh 的形状是 ( H , ) (H,) (H,),而 z_t ( B , H ) (B, H) (B,H)。NumPy 的广播(broadcasting) 机制会自动把 ( H , ) (H,) (H,) 的偏置"复制" B B B 份,加到每一行上:

b h    ( H , )    → 广播    ( b h b h ⋮ b h )    ( B , H ) b_h\;(H,) \;\xrightarrow{\text{广播}}\; \begin{pmatrix} b_h \\ b_h \\ \vdots \\ b_h \end{pmatrix}\;(B, H) bh(H,)广播 bhbhbh (B,H)

32 条样本共享同一个偏置——这同样是一种"参数共享"。代价是:如果忘了 h0 的维度是 ( B , H ) (B, H) (B,H) 而写成 ( H , ) (H,) (H,),广播会悄悄把它铺满整个 batch,语义就错了(第七节常见坑 #3)。


三、装上时间轴:完整 rnn_forward 函数

单步函数再漂亮,也只会处理"一个时刻"。RNN 的尊严在于循环。下面是本章的主角——完整的前向传播函数:

def rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0):
    """完整 RNN 前向传播:循环 S 个时间步,返回隐藏状态、输出与 cache。

    参数:
      x    : (B, S, I)  整个批次的输入序列(S 个时间步)
      W_xh : (I, H) 输入→隐藏
      W_hh : (H, H) 隐藏→隐藏(方阵)
      W_hy : (H, O) 隐藏→输出
      b_h  : (H,)  隐藏偏置
      b_y  : (O,)  输出偏置
      h0   : (B, H) 初始隐藏状态(惯例全零)
    返回:
      h_seq : (S, B, H) 所有时间步的隐藏状态
      y_seq : (S, B, O) 所有时间步的原始输出分数
      cache : dict      每个时间步的 x_t / h_{t-1} / z_t / h_t(第4章 BPTT 的口粮)
    """
    S = x.shape[1]                                  # 从输入里读出时间步数
    h_prev = h0                                     # 记忆接力棒从 h_0 出发
    h_seq, y_seq = [], []
    cache = {'x': [], 'h_prev': [], 'z': [], 'h': []}

    for t in range(S):                              # 一个 for 循环统治所有时间步
        x_t = x[:, t, :]                            # 切片:(B, I) 当前时刻的输入
        z_t = np.dot(x_t, W_xh) + np.dot(h_prev, W_hh) + b_h   # (B, H)
        h_t = np.tanh(z_t)                          # (B, H)
        y_t = np.dot(h_t, W_hy) + b_y               # (B, O)

        # —— 缓存:反向传播的口粮,一个都不能少 ——
        cache['x'].append(x_t)
        cache['h_prev'].append(h_prev)
        cache['z'].append(z_t)
        cache['h'].append(h_t)

        h_seq.append(h_t)
        y_seq.append(y_t)
        h_prev = h_t                                # 记忆接力:h_t 成为下一步的 h_prev

    # 列表 → 张量:时间轴放第 0 维,BPTT 倒序遍历最顺手
    h_seq = np.stack(h_seq, axis=0)                 # (S, B, H)
    y_seq = np.stack(y_seq, axis=0)                 # (S, B, O)
    cache['x'] = np.stack(cache['x'], axis=0)       # (S, B, I)
    cache['h_prev'] = np.stack(cache['h_prev'], axis=0)   # (S, B, H)
    cache['z'] = np.stack(cache['z'], axis=0)       # (S, B, H)
    cache['h'] = np.stack(cache['h'], axis=0)       # (S, B, H)
    return h_seq, y_seq, cache
3.1 六个关键动作,逐个过一遍

① 切片 x[:, t, :] 把时间轴"一刀刀切开",每次取出所有样本在时刻 t t t 的输入,形状 ( B , I ) (B, I) (B,I)。这是第二章 3.3 节讲的切片,在这里正式上岗。绝不能用整个 x    ( B , S , I ) x\;(B,S,I) x(B,S,I) 直接去乘 W x h W_{xh} Wxh——内层维度 S S S I I I 对不上,直接报 not aligned

② 记忆接力 h_prev = h_t 这是整段代码的灵魂。每算完一步,当前 h t h_t ht 立刻成为下一步的"上一步记忆"。如果漏掉这一行,每个时间步都拿 h 0 h_0 h0 当输入,记忆就断链了——RNN 退化成一个"没有记忆的假循环"(第七节坑 #3)。

③ 参数共享体现在哪? 整个循环体里, W x h / W h h / W h y / b h / b y W_{xh}/W_{hh}/W_{hy}/b_h/b_y Wxh/Whh/Why/bh/by 从未变过——同一个 for 循环,同一套权重, S S S 次复用。这就是"一个公式统治所有时间步"的代码形态。

④ 列表收集 + np.stack Python 列表逐时间步 append,循环结束一次性 np.stack(..., axis=0) 堆叠成 ( S , B , ⋅ ) (S, B, \cdot) (S,B,)。为什么时间轴放第 0 维?因为第四章 BPTT 要沿时间轴倒序遍历 ( S , B , ⋅ ) (S, B, \cdot) (S,B,)cache['z'][t] 这种下标直来直去,最顺手(第二章 FAQ Q2 的约定,这里落地)。

⑤ cache 四件套。 每步存下 x t / h t − 1 / z t / h t x_t / h_{t-1} / z_t / h_t xt/ht1/zt/ht——第四节专门讲,先记个脸熟。

⑥ 返回三个东西。 隐藏状态、输出分数、缓存,一个都不能少。只返回前两个、丢掉 cache 的版本,到了第四章会"无米下锅"。

3.2 为什么不能"一次矩阵乘法算完"?

你可能想: x    ( B , S , I ) x\;(B,S,I) x(B,S,I) 能不能直接乘一个什么大矩阵,一步出结果?答案是不能。因为每个时间步的 h t h_t ht 都依赖 h t − 1 h_{t-1} ht1,而 h t − 1 h_{t-1} ht1 又依赖 h t − 2 h_{t-2} ht2……时间轴上存在严格的因果依赖,必须串行执行。这正是 RNN 与 CNN 的又一个本质区别:CNN 的卷积可以高度并行,RNN 的循环天然串行——这个代价在第十章做效率对比时会再次登场。


四、cache:给反向传播备好"口粮"

4.1 为什么必须缓存?

反向传播(第四章的 BPTT)需要计算损失 L L L 对所有权重的梯度。用链式法则看:

∂ L ∂ W h h = ∑ t ∂ L ∂ h t ⋅ ∂ h t ∂ z t ⋅ ∂ z t ∂ W h h = ∑ t δ t ⋅ h t − 1 ⊤ \frac{\partial L}{\partial W_{hh}} = \sum_{t} \frac{\partial L}{\partial h_t} \cdot \frac{\partial h_t}{\partial z_t} \cdot \frac{\partial z_t}{\partial W_{hh}} = \sum_{t} \delta_t \cdot h_{t-1}^{\top} WhhL=thtLzthtWhhzt=tδtht1

梯度公式里直接站着 h t − 1 h_{t-1} ht1 z t z_t zt。如果没有缓存,反向阶段只能重新前向跑一遍把这些量再算回来——浪费一倍算力。前向时顺手存一份,反向时直接取用,这就是经典的"空间换时间"策略。 PyTorch 的 autograd 也干同样的事(它把计算图里每个节点的输入都存了下来)。

4.2 cache 里到底存什么?

每个时间步 t t t 存四样东西:

含义 堆叠后形状 反向传播里干什么用
cache['x'] 当前输入 x t x_t xt ( S , B , I ) (S, B, I) (S,B,I) ∂ L / ∂ W x h \partial L/\partial W_{xh} L/Wxh
cache['h_prev'] 上一步记忆 h t − 1 h_{t-1} ht1 ( S , B , H ) (S, B, H) (S,B,H) ∂ L / ∂ W h h \partial L/\partial W_{hh} L/Whh
cache['z'] tanh 之前的线性组合 ( S , B , H ) (S, B, H) (S,B,H) 算 tanh 导数 1 − tanh ⁡ 2 ( z t ) 1-\tanh^2(z_t) 1tanh2(zt)
cache['h'] 当前隐藏状态 h t h_t ht ( S , B , H ) (S, B, H) (S,B,H) ∂ L / ∂ W h y \partial L/\partial W_{hy} L/Why 与误差回传

📌 注意 cache['h_prev'][0] 是谁? 0 0 0 步的"上一步记忆"就是 h 0 h_0 h0(全零)。所以 cache 里其实连 h 0 h_0 h0 也"顺带"存下了——实操 C 会验证这件事。

4.3 一张条带图看穿 cache

把时间轴横过来,cache 就是一张"四行十列"的条带,每个时间步一格、每格四层:

在这里插入图片描述

🐍 橙色的 h t − 1 h_{t-1} ht1 和红色的 h t h_t ht 就是"记忆接力"的实物证据: t t t 步的橙色,恰好是第 t − 1 t-1 t1 步的红色(实操 C 用代码验证这条性质)。

4.4 少存一个会怎样?多存一个浪费吗?
  • 少存 z_t tanh ⁡ \tanh tanh 的导数 1 − tanh ⁡ 2 ( z ) 1-\tanh^2(z) 1tanh2(z) 没原料,只能反解 z = a r c t a n h ( h t ) z = \mathrm{arctanh}(h_t) z=arctanh(ht) 再算——不仅麻烦,数值精度还更差;
  • 少存 h_prev:反向时 W h h W_{hh} Whh 的梯度算不出来;
  • 多存 y_t 呢? 其实没必要—— y t y_t yt h t h_t ht 的线性函数,反向时拿着 h t h_t ht 的梯度可以直接推出来,缓存 y t y_t yt 纯属浪费内存。这也是为什么 cache 里没有 y 这个键。

内存账算一笔:cache 共 S ⋅ B ⋅ ( I + 3 H ) S \cdot B \cdot (I + 3H) SB(I+3H) 个浮点数,以 B = 32 , S = 10 , I = 100 , H = 256 B=32, S=10, I=100, H=256 B=32,S=10,I=100,H=256 为例:

10 × 32 × ( 100 + 3 × 256 ) ≈ 27.8  万 ≈ 1.1  MB(float32) 10 \times 32 \times (100 + 3 \times 256) \approx 27.8\ \text{万} \approx 1.1\ \text{MB(float32)} 10×32×(100+3×256)27.8 1.1 MBfloat32

相对动辄几 GB 的模型训练,这笔"口粮"花得很值。


五、实操验证:rnn_forward.py 带你亲眼见证五个真相

纸上得来终觉浅。这一节把函数跑起来,从维度验证到手算对照,从 cache 自检到退化实验,逐一过堂。

5.1 实操 A:维度验证(本章里程碑)

rnn_forward.py 的实操 A 用 np.random.randn(32, 10, 100) 造一批假数据,跑完整前向,打印所有 shape:

==========================================================================
实操 A:完整前向传播 —— 维度验证(里程碑:与第2章推演表逐位对上)
==========================================================================
输入 x      : (32, 10, 100)
h_seq       : (10, 32, 256)  <- (S,B,H)=(10,32,256) 与推演表一致
y_seq       : (10, 32, 10)  <- (S,B,O)=(10,32,10)  与推演表一致
cache 键    : ['x', 'h_prev', 'z', 'h']
  cache[x] : (10, 32, 100)
  cache[h_prev] : (10, 32, 256)
  cache[z] : (10, 32, 256)
  cache[h] : (10, 32, 256)

对照第二章的维度推演表逐位看: x t x_t xt 切片后是 ( B , I ) = ( 32 , 100 ) (B,I)=(32,100) (B,I)=(32,100) z t / h t z_t/h_t zt/ht ( 32 , 256 ) (32,256) (32,256) y t y_t yt ( 32 , 10 ) (32,10) (32,10),堆叠后 ( S , B , ⋅ ) = ( 10 , 32 , ⋅ ) (S,B,\cdot)=(10,32,\cdot) (S,B,)=(10,32,),全部对得上。 ✅ 里程碑达成——公式是理论,函数是实践,两者在此处握手。

5.2 实操 B:小规模手算对照——函数不是黑盒

为了让你相信函数内部真的在按公式干活,实操 B 用一组小到能"心算"的权重( I = 2 , H = 3 , O = 2 , B = 1 , S = 2 I=2, H=3, O=2, B=1, S=2 I=2,H=3,O=2,B=1,S=2)跑前向,然后把每一步的中间量打印出来:

==========================================================================
实操 B:小规模手算对照 —— I=2, H=3, O=2, B=1, S=2
==========================================================================
t=0: z_t = [ 0.7  -0.7   0.15]
     h_t = [ 0.6044 -0.6044  0.1489]
     y_t = [ 0.1664 -0.3337]
t=1: z_t = [ 0.6626 -0.318   0.4498]
     h_t = [ 0.5801 -0.3077  0.4217]
     y_t = [ 0.1864 -0.0713]
h_seq2.shape = (2, 1, 3)  y_seq2.shape = (2, 1, 2)

跟着手算一遍 t = 0 t=0 t=0 x 0 = [ 1 , − 1 ] x_0 = [1, -1] x0=[1,1] h − 1 = h 0 = [ 0 , 0 , 0 ] h_{-1} = h_0 = [0,0,0] h1=h0=[0,0,0](全零,所以 W h h W_{hh} Whh 项为零,先偷个懒):

第一步(读新信息):x_0 @ W_xh
  第1个隐藏神经元: 0.5×1 + (-0.1)×(-1) = 0.6
  第2个隐藏神经元: -0.2×1 + 0.4×(-1)  = -0.6
  第3个隐藏神经元: 0.3×1 + 0.2×(-1)   = 0.1
  旧记忆: h_0 @ W_hh = 0(全零起点)
  加偏置: z_0 = [0.6+0+0.1, -0.6+0-0.1, 0.1+0+0.05] = [0.7, -0.7, 0.15]   ✅ 对上了
第二步(压缩):h_0 = tanh(z_0) = [0.6044, -0.6044, 0.1489]                ✅ 对上了
第三步(翻译):y_0 = h_0 @ W_hy
  = [0.6044×0.5 + (-0.6044)×0.2 + 0.1489×(-0.1),
    0.6044×(-0.4) + (-0.6044)×0.3 + 0.1489×0.6]
  = [0.1664, -0.3337]                                                     ✅ 对上了

再看 t = 1 t=1 t=1,记忆开始接力。 这一回 h t − 1 = h 0 = [ 0.6044 , − 0.6044 , 0.1489 ] h_{t-1} = h_0 = [0.6044, -0.6044, 0.1489] ht1=h0=[0.6044,0.6044,0.1489] 不再是零:

第一步(读新信息):x_1 = [0.5, 0.5] @ W_xh = [0.20, 0.10, 0.25]
第二步(回顾旧记忆):h_0 @ W_hh
  第1维: 0.6044×0.8 + (-0.6044)×0.2 + 0.1489×0.0 = 0.3626
  第2维: 0.6044×0.1 + (-0.6044)×0.7 + 0.1489×0.3 = -0.3180
  第3维: 0.6044×0.0 + (-0.6044)×(-0.1) + 0.1489×0.6 = 0.1498
加偏置: z_1 = [0.20+0.3626+0.1, 0.10-0.3180-0.1, 0.25+0.1498+0.05]
            = [0.6626, -0.3180, 0.4498]                                   ✅ 对上了
压缩: h_1 = tanh(z_1) = [0.5801, -0.3077, 0.4217]                         ✅ 对上了

看到第 1 维的 0.6626 0.6626 0.6626 了吗?它 = 新信息 0.20 0.20 0.20 + 旧记忆贡献 0.3626 0.3626 0.3626 + 偏置 0.1 0.1 0.1旧记忆不是被丢弃,而是被 W h h W_{hh} Whh "加权回放"进了新状态——这就是 RNN 记性的物理过程,白纸黑字写在数值里。

5.3 实操 C:cache 自检——记忆接力没有断链

前文说" t t t 步的橙色 = t − 1 t-1 t1 步的红色",代码怎么证明?实操 C 直接做三条逐位比对:

==========================================================================
实操 C:cache 自检 —— 缓存的"记忆接力"是否与 h_seq 逐位一致
==========================================================================
max|cache[h] - h_seq|                 = 0.0
max|cache[h_prev][t] - h_seq[t-1]|    = 0.0
max|cache[h_prev][0] - h0|            = 0.0
-> 全部为 0.0:循环里"记忆接力"没有断链,cache 是前向的忠实快照

三条检查全部为 0.0 0.0 0.0,说明:

  • cache['h'][t] 与返回值 h_seq[t] 完全相等(同一个数组的两份引用);
  • cache['h_prev'][t]h_seq[t-1] 完全相等(第 t t t 步的"上一步记忆"确实是第 t − 1 t-1 t1 步的隐藏状态);
  • cache['h_prev'][0] 就是 h0(第 0 0 0 步的起点没错)。

cache 是前向传播的忠实快照——第四章反向传播可以放心地从里面取数据,取到的永远是"当时算出来的真实值"。

5.4 实操 E:最后时间步的数值实况(里程碑截图点)

系列的目录里要求的里程碑截图点是:“打印经过 RNN 前向后的 h 和 y 的 Shape,以及最后时间步的隐藏状态值”。实操 E 正好补上数值部分:

==========================================================================
实操 E:最后时间步的隐藏状态与输出 —— 数值实况(里程碑截图点)
==========================================================================
h_9(最后一步)样本0 前 5 维: [-0.3701 -0.3113 -0.7319 -0.4922 -0.8999]
y_9(最后一步)样本0 前 5 维: [-0.2726  0.3227 -0.0576 -0.3944 -0.0642]
h_seq 全局范围: [-0.9957, 0.9968] —— 严格落在 (-1, 1) 内
y_seq 全局范围: [-1.7455, 1.5333] —— 输出分数无界(softmax 之前的结果)

两个发现:

  1. h s e q h_{seq} hseq 全局范围 [ − 0.9957 , 0.9968 ] [-0.9957, 0.9968] [0.9957,0.9968]——被 tanh ⁡ \tanh tanh 牢牢按在 ( − 1 , 1 ) (-1, 1) (1,1) 里,第二章 1.6 节的"压缩器"理论在真实输出上兑现;
  2. y s e q y_{seq} yseq 的范围 [ − 1.7455 , 1.5333 ] [-1.7455, 1.5333] [1.7455,1.5333] 却无界——因为 y t y_t yt 只是线性变换 W h y h t + b y W_{hy} h_t + b_y Whyht+by它没有经过任何激活函数,是"原始分数"。将来做分类任务时,要在 y t y_t yt 后面接 s o f t m a x \mathrm{softmax} softmax 变成概率,或者接 s i g m o i d \mathrm{sigmoid} sigmoid 做二分类(第十四章 IMDB 实战见分晓)。
5.5 把"记忆实况"画出来:两张配图

图 3:隐藏状态演化。 用一个小模型( H = 8 , S = 12 H=8, S=12 H=8,S=12)跑前向,把每个维度的取值随时间的演化画成折线:
在这里插入图片描述

🐍 注意三件事:① 所有曲线全程被夹在 ± 1 \pm 1 ±1 虚线之间(tanh 压缩器的可视化证据);② 各维度从 h 0 = 0 h_0=0 h0=0 出发迅速分化成各自的轨迹——每个神经元"记"的东西不一样;③ 曲线有起有伏、忽正忽负——记忆在实时演化,不是单调累积。

图 4:输出热力图。 把某条样本 10 个时间步 × 10 个输出通道的 y t y_t yt 画成热力图:
在这里插入图片描述

🐍红蓝配色直观展示 y t y_t yt正负与大小分布——注意这些值完全不受 ( − 1 , 1 ) (-1,1) (1,1) 约束(没有激活函数),最大最小可以超出 ± 1 \pm 1 ±1。这就是"原始分数"该有的样子。


六、边界思考: S = 1 S=1 S=1 时 RNN 退化成什么?

想真正理解 RNN,最好的办法之一是看它的极限情况。把序列长度压缩到 S = 1 S=1 S=1:输入只剩一个时间步,循环只执行一次。这时:

h 0 = tanh ⁡ ( W x h x 0 + W h h h − 1 + b h ) = tanh ⁡ ( W x h x 0 + b h ) ( 因为  h − 1 = h 0 = 0 ) h_0 = \tanh(W_{xh} x_0 + W_{hh} h_{-1} + b_h) = \tanh(W_{xh} x_0 + b_h) \quad (\text{因为 } h_{-1} = h_0 = \mathbf{0}) h0=tanh(Wxhx0+Whhh1+bh)=tanh(Wxhx0+bh)(因为 h1=h0=0)

W h h W_{hh} Whh 项因为乘了全零的 h 0 h_0 h0 直接消失——循环没了,记忆没了,RNN 退化成"输入 → 隐藏层(tanh)→ 输出层"的普通全连接网络。实操 D 用代码验证了这个结论:

==========================================================================
实操 D:边界实验 —— S=1 时 RNN 退化成普通全连接网络
==========================================================================
RNN(S=1) 输出 shape: (1, 32, 10)
全连接对照 shape  : (32, 10)
最大绝对误差      : 0.0
-> 误差为 0.0:S=1 时 RNN 就是"tanh 隐藏层 + 线性输出层"的全连接网络,W_hh 完全没被用到

误差 0.0 0.0 0.0——RNN(S=1) 和"全连接 + tanh 激活"的数学表达式完全相同

这个结论很有用,它揭示了 RNN 与全连接网络的家族关系:

  • FC 是 S = 1 S=1 S=1 的 RNN
  • RNN 是"共享权重的、沿时间展开的 FC"——展开图(第一章)里的每一层,用的都是同一套权重,只是输入变成了"当前输入 + 上一步隐藏状态"。

理解这一点,第四章 BPTT 就容易多了:BPTT 就是"沿时间轴展开的 BP",只不过多了一个"梯度要跨时间步累加"的步骤。


七、常见坑与 FAQ

🕳️ 常见坑
# 现象 解法
1 忘记在时间维上循环,拿整个 x    ( B , S , I ) x\;(B,S,I) x(B,S,I) 直接乘 W x h W_{xh} Wxh np.dotnot aligned(内层维度 S S S vs I I I 必须逐时间步切片 x[:, t, :]先切片再乘
2 h0 维度写成 ( H , ) (H,) (H,) 而非 ( B , H ) (B, H) (B,H) 广播悄悄复制,语义错误(不报错但结果错) 一律 h0 = np.zeros((B, H)),循环前 print(h0.shape) 检查
3 忘记 h_prev = h_t 这一行 每个时间步都用同一个 h 0 h_0 h0,记忆断链,输出全错 循环体最后必须更新记忆接力棒,这是灵魂
4 忘记存 cache 第四章 BPTT “无米下锅”,只能重算前向 每步 append 四件套,循环后统一 np.stack
5 np.dot 顺序写反(W_xh @ x_t (I,H) @ (B,I) 内层维度 H H H vs B B B 对不上,报错 记住"内层对齐"口诀:行向量右乘权重
6 列表堆叠时机错误 np.stack 维度与预期不符 先循环完、后堆叠;堆叠前打印 len(h_seq) 确认收集了 S S S
❓ FAQ

Q1:cache 里为什么不存 y t y_t yt
y t = W h y h t + b y y_t = W_{hy} h_t + b_y yt=Whyht+by h t h_t ht 的线性函数,反向传播时拿着 h t h_t ht 的梯度直接就能算出 W h y / b y W_{hy}/b_y Why/by 的梯度,不需要 y t y_t yt 参与。缓存它纯属浪费内存(实操里你可以自己对比一下多存一个键的显存开销)。

Q2:中间结果为什么存成 ( S , B , ⋅ ) (S,B,\cdot) (S,B,) 而不是 ( B , S , ⋅ ) (B,S,\cdot) (B,S,)
两种都是合法约定,关键是全工程统一。本系列纯 NumPy 阶段统一用 ( S , B , ⋅ ) (S,B,\cdot) (S,B,):第四章 BPTT 沿时间轴倒序遍历时,cache['z'][t] 这种下标最直白。接 PyTorch 时(第十一章起)显式设 batch_first=True,用 ( B , S , ⋅ ) (B,S,\cdot) (B,S,)

Q3:不存 cache,反向时重新前向算一遍行不行?
行,数学结果一样——但白白多跑一遍前向,浪费一倍算力。标准做法是"空间换时间":前向顺手存、反向直接取。PyTorch 的 autograd 也是这么干的。

Q4: h 0 h_0 h0 是函数内部初始化还是外部传参?
本实现选外部传参:训练时统一传全零,推理/测试保持一致,调用方说了算。后续 LSTM 还需要 c 0 c_0 c0(细胞状态)也走同一条路,接口越早统一越省事。

Q5:np.stacknp.array 有什么区别?
np.array(列表) 也能堆叠,但 np.stack(列表, axis=0) 显式声明"沿新轴堆叠",意图更清晰、行为更可控。本系列统一用 np.stack

Q6:为什么说 RNN 前向必须串行、不能像 CNN 那样并行?
h t h_t ht 依赖 h t − 1 h_{t-1} ht1,存在严格的时间因果链。CNN 的每个位置只依赖局部感受野,天然可并行;RNN 的每一步都必须等上一步算完。这是 RNN 的"原罪",也是 LSTM/GRU 也无法根治的问题(第十章做效率对比时细聊)。


八、本章小结与下章预告

本章小结(一句话带走一个知识点)
知识点 一句话带走
函数签名 rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0) -> (h_seq, y_seq, cache)
核心循环 for t in range(S):切片 → 算 z t / h t / y t z_t/h_t/y_t zt/ht/yt → 存 cache → h_prev = h_t 接力
乘法顺序 公式写 W ⋅ x W \cdot x Wx(列向量左乘),代码写 x @ W(行向量右乘),互为转置、内容等价,看内层维度对齐
cache 四件套 每步存 x t / h t − 1 / z t / h t x_t / h_{t-1} / z_t / h_t xt/ht1/zt/ht,堆叠成 ( S , B , I ) / ( S , B , H ) (S,B,I)/(S,B,H) (S,B,I)/(S,B,H),是 BPTT 的"口粮"
为什么缓存 链式法则的梯度公式里直接站着 h t − 1 h_{t-1} ht1 z t z_t zt;空间换时间
维度验证 输出 ( S , B , H ) = ( 10 , 32 , 256 ) (S,B,H)=(10,32,256) (S,B,H)=(10,32,256) ( S , B , O ) = ( 10 , 32 , 10 ) (S,B,O)=(10,32,10) (S,B,O)=(10,32,10),与推演表逐位一致 ✅
S = 1 S=1 S=1 退化 RNN 退化成"tanh 隐藏层 + 线性输出层"的全连接网络(误差 0.0 0.0 0.0
h t h_t ht 取值 严格落在 ( − 1 , 1 ) (-1,1) (1,1) y t y_t yt 是无界原始分数,要过 softmax/sigmoid 才变概率
下章预告:第 4 章《BPTT 算法(上)——梯度沿时间轴传播的链式法则》

前向传播跑通了,cache 也备好了——下一章就是本系列第一个硬核高潮:反向传播。

我们将从损失函数 L L L 出发,用链式法则一路回推:输出层梯度 δ y \delta_y δy → 最后一步 δ T \delta_T δT → 逐步反推 δ t = ( δ t + 1 ⋅ W h h ⊤ + δ y t ⋅ W h y ⊤ ) ⊙ ( 1 − h t 2 ) \delta_t = (\delta_{t+1} \cdot W_{hh}^{\top} + \delta_{y_t} \cdot W_{hy}^{\top}) \odot (1 - h_t^2) δt=(δt+1Whh+δytWhy)(1ht2) → 最后得到三个权重矩阵的梯度。你还会亲眼看到为什么 W h h W_{hh} Whh 的梯度是"沿时间轴的累加和",以及那个让 RNN 闻风丧胆的连乘:

∂ h T ∂ h 1 = ∏ t = 1 T − 1 W h h ⊤   d i a g ( 1 − h t 2 ) \frac{\partial h_T}{\partial h_1} = \prod_{t=1}^{T-1} W_{hh}^{\top}\,\mathrm{diag}(1 - h_t^2) h1hT=t=1T1Whhdiag(1ht2)

这一章的 cache,就是下一章的粮仓。BPTT,我们下篇见!


🧠 思考题与动手练习

思考题(先自己想,再看答案区,答案就在正文里):

  1. 如果把 np.tanh 换成 np.sigmoidh_seq 的取值范围会变成什么? y t y_t yt 呢?(提示:2.3 节与实操 E 的输出范围)
  2. cache 里为什么不需要存 y t y_t yt?如果硬要存,会浪费多少内存?(提示: S ⋅ B ⋅ O S \cdot B \cdot O SBO 个浮点数)
  3. 如果漏写 h_prev = h_t,前向跑出来的结果会有什么特征?(提示:记忆断链,每个时间步都从 h 0 h_0 h0 出发)
  4. 为什么说"RNN 前向必须串行"?这与 CNN 的并行性差异是什么?(提示:时间因果依赖)
  5. 实操 B 里 t = 1 t=1 t=1 z 1 [ 0 ] = 0.6626 z_1[0] = 0.6626 z1[0]=0.6626,其中旧记忆贡献了多少?如果 W h h W_{hh} Whh 全为 0 会怎样?(提示: h 1 h_1 h1 将只由 x 1 x_1 x1 决定)

动手练习(改造 rnn_forward.py):

  1. rnn_forward 加一个 return_cache=True 参数:return_cache=False 时不存 cache,用 time.perf_counter() 对比两种模式的内存与耗时;
  2. 手写一个 rnn_forward_batch_first 版本,把中间结果存成 ( B , S , H ) (B, S, H) (B,S,H),验证数值与本章版本完全一致(只是轴的顺序不同);
  3. H 从 256 改成 512 重跑实操 A,观察 W h h W_{hh} Whh 的 shape 变成 ( 512 , 512 ) (512, 512) (512,512) 以及 cache 内存翻倍;
  4. 在实操 B 的小模型上,把 w_hh 全部改成 0 重跑,验证"记忆断链"后 h 1 h_1 h1 是否只由 x 1 x_1 x1 决定;
  5. np.argmax(y_seq[-1], axis=-1) 看最后一步每个样本"分数最高"的输出通道是几号,理解"原始分数"的含义。

📌 下篇预告:第四章《BPTT 算法(上)——梯度沿时间轴传播的链式法则》——从损失函数出发,手推 ∂ L / ∂ W h h \partial L/\partial W_{hh} L/Whh 的全过程,看看 cache 这袋口粮怎么被反向传播吃干抹净。我们下篇见!

本文为原创,遵循 CC 4.0 BY-SA 版权协议,转载需附原文链接。


# -*- coding: utf-8 -*-
"""
rnn_forward.py — 《从零构建RNN》第3章《从零实现 RNN 前向传播》配套实操脚本

跑一遍 `python rnn_forward.py`,你会亲眼看到:
  A. 完整前向传播:x (B,S,I) → h_seq (S,B,H) / y_seq (S,B,O),与第2章维度推演表逐位对上
  B. 小规模手算对照:I=2, H=3, O=2, S=2 时,函数输出的 z_t / h_t / y_t 与纸笔手算一致
  C. cache 自检:缓存的"记忆接力"是否与 h_seq 逐位一致(循环没有断链)
  D. 边界实验:S=1 时 RNN 退化成普通全连接网络(数值上完全等价)
  E. 最后时间步的隐藏状态与输出:打印具体数值,检查取值范围

超参数与正文一致:Batch=32, Seq=10, Input=100, Hidden=256, Output=10
"""
import numpy as np

# ---------- 超参数(与第2章维度推演表完全一致) ----------
B, S, I, H, O = 32, 10, 100, 256, 10

np.random.seed(42)  # 固定种子,保证每次运行结果一致

# ---------- 权重初始化:全部压在小范围 [-0.1, 0.1],偏置为 0 ----------
W_xh = np.random.uniform(-0.1, 0.1, (I, H))  # 输入→隐藏
W_hh = np.random.uniform(-0.1, 0.1, (H, H))  # 隐藏→隐藏(方阵!)
W_hy = np.random.uniform(-0.1, 0.1, (H, O))  # 隐藏→输出
b_h = np.zeros(H)
b_y = np.zeros(O)

LINE = '=' * 74


# ---------------------------------------------------------------- 核心代码
def rnn_forward_step(x_t, h_prev):
    """单时间步前向:z_t -> h_t -> y_t(教学版:直接使用全局权重)

    x_t    : (B, I)  当前时间步的输入
    h_prev : (B, H)  上一步的隐藏状态
    返回 z_t, h_t, y_t,形状分别为 (B,H), (B,H), (B,O)
    """
    z_t = np.dot(x_t, W_xh) + np.dot(h_prev, W_hh) + b_h   # (B, H) 线性组合
    h_t = np.tanh(z_t)                                      # (B, H) tanh 压缩
    y_t = np.dot(h_t, W_hy) + b_y                           # (B, O) 翻译成输出
    return z_t, h_t, y_t


def rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0):
    """完整 RNN 前向传播:循环 S 个时间步,返回隐藏状态、输出与 cache。

    参数:
      x    : (B, S, I)  整个批次的输入序列(S 个时间步)
      W_xh : (I, H) 输入→隐藏
      W_hh : (H, H) 隐藏→隐藏(方阵)
      W_hy : (H, O) 隐藏→输出
      b_h  : (H,)  隐藏偏置
      b_y  : (O,)  输出偏置
      h0   : (B, H) 初始隐藏状态(惯例全零)
    返回:
      h_seq : (S, B, H) 所有时间步的隐藏状态
      y_seq : (S, B, O) 所有时间步的原始输出分数
      cache : dict      每个时间步的 x_t / h_{t-1} / z_t / h_t(第4章 BPTT 的口粮)
    """
    S = x.shape[1]                                  # 从输入里读出时间步数
    h_prev = h0                                     # 记忆接力棒从 h_0 出发
    h_seq, y_seq = [], []
    cache = {'x': [], 'h_prev': [], 'z': [], 'h': []}

    for t in range(S):                              # 一个 for 循环统治所有时间步
        x_t = x[:, t, :]                            # 切片:(B, I) 当前时刻的输入
        z_t = np.dot(x_t, W_xh) + np.dot(h_prev, W_hh) + b_h   # (B, H)
        h_t = np.tanh(z_t)                          # (B, H)
        y_t = np.dot(h_t, W_hy) + b_y               # (B, O)

        # —— 缓存:反向传播的口粮,一个都不能少 ——
        cache['x'].append(x_t)
        cache['h_prev'].append(h_prev)
        cache['z'].append(z_t)
        cache['h'].append(h_t)

        h_seq.append(h_t)
        y_seq.append(y_t)
        h_prev = h_t                                # 记忆接力:h_t 成为下一步的 h_prev

    # 列表 → 张量:时间轴放第 0 维,BPTT 倒序遍历最顺手
    h_seq = np.stack(h_seq, axis=0)                 # (S, B, H)
    y_seq = np.stack(y_seq, axis=0)                 # (S, B, O)
    cache['x'] = np.stack(cache['x'], axis=0)       # (S, B, I)
    cache['h_prev'] = np.stack(cache['h_prev'], axis=0)   # (S, B, H)
    cache['z'] = np.stack(cache['z'], axis=0)       # (S, B, H)
    cache['h'] = np.stack(cache['h'], axis=0)       # (S, B, H)
    return h_seq, y_seq, cache


# ---------------------------------------------------------------- 实操 A
def demo_A():
    print(LINE)
    print('实操 A:完整前向传播 —— 维度验证(里程碑:与第2章推演表逐位对上)')
    print(LINE)
    x = np.random.randn(B, S, I)                    # (32, 10, 100) 假数据
    h0 = np.zeros((B, H))                           # 全零初始记忆 (32, 256)
    h_seq, y_seq, cache = rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0)
    print('输入 x      :', x.shape)
    print('h_seq       :', h_seq.shape, ' <- (S,B,H)=(10,32,256) 与推演表一致')
    print('y_seq       :', y_seq.shape, ' <- (S,B,O)=(10,32,10)  与推演表一致')
    print('cache 键    :', list(cache.keys()))
    for k in cache:
        print('  cache[%s] :' % k, cache[k].shape)


# ---------------------------------------------------------------- 实操 B
def demo_B():
    print('\n' + LINE)
    print('实操 B:小规模手算对照 —— I=2, H=3, O=2, B=1, S=2')
    print(LINE)
    # 一组小得能"心算"的权重(延续第2章手算用过的数值)
    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])
    x2 = np.array([[[1.0, -1.0], [0.5, 0.5]]])      # B=1, S=2, I=2
    h0 = np.zeros((1, 3))

    h_seq2, y_seq2, cache2 = rnn_forward(x2, w_xh, w_hh, w_hy, bh, by, h0)
    for t in range(2):
        print('t=%d: z_t = %s' % (t, np.round(cache2['z'][t, 0], 4)))
        print('     h_t = %s' % (np.round(h_seq2[t, 0], 4)))
        print('     y_t = %s' % (np.round(y_seq2[t, 0], 4)))
    print('h_seq2.shape =', h_seq2.shape, ' y_seq2.shape =', y_seq2.shape)
    print('(对照第2章手算:t=0 的 z_0=[1.08,-0.75,0.15] 略有不同,'
          '是因为这次换了一组更小的权重,原理一模一样)')


# ---------------------------------------------------------------- 实操 C
def demo_C():
    print('\n' + LINE)
    print('实操 C:cache 自检 —— 缓存的"记忆接力"是否与 h_seq 逐位一致')
    print(LINE)
    x = np.random.randn(B, S, I)
    h0 = np.zeros((B, H))
    h_seq, y_seq, cache = rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0)
    err_h = np.abs(cache['h'] - h_seq).max()                       # cache[h][t] 应 ≡ h_seq[t]
    err_prev = np.abs(cache['h_prev'][1:] - h_seq[:-1]).max()      # cache[h_prev][t] 应 = h_{t-1}
    err_h0 = np.abs(cache['h_prev'][0] - h0).max()                 # 第0步的"上一步记忆"就是 h_0
    print('max|cache[h] - h_seq|                 =', err_h)
    print('max|cache[h_prev][t] - h_seq[t-1]|    =', err_prev)
    print('max|cache[h_prev][0] - h0|            =', err_h0)
    print('-> 全部为 0.0:循环里"记忆接力"没有断链,cache 是前向的忠实快照')


# ---------------------------------------------------------------- 实操 D
def demo_D():
    print('\n' + LINE)
    print('实操 D:边界实验 —— S=1 时 RNN 退化成普通全连接网络')
    print(LINE)
    x1 = np.random.randn(B, 1, I)                  # 序列长度只剩 1 步
    h_seq1, y_seq1, _ = rnn_forward(x1, W_xh, W_hh, W_hy, b_h, b_y,
                                    np.zeros((B, H)))
    # 对照:只用一层 W_xh + tanh + 一层 W_hy 的全连接(没有 W_hh、没有循环)
    fc_out = np.tanh(np.dot(x1[:, 0, :], W_xh) + b_h)
    fc_out = np.dot(fc_out, W_hy) + b_y
    diff = np.abs(y_seq1[0] - fc_out).max()      # y_seq1[0] 即唯一时间步的输出 (32, 10)
    print('RNN(S=1) 输出 shape:', y_seq1.shape)
    print('全连接对照 shape  :', fc_out.shape)
    print('最大绝对误差      :', diff)
    print('-> 误差为 0.0:S=1 时 RNN 就是"tanh 隐藏层 + 线性输出层"的全连接网络,'
          'W_hh 完全没被用到')


# ---------------------------------------------------------------- 实操 E
def demo_E():
    print('\n' + LINE)
    print('实操 E:最后时间步的隐藏状态与输出 —— 数值实况(里程碑截图点)')
    print(LINE)
    x = np.random.randn(B, S, I)
    h_seq, y_seq, _ = rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y,
                                  np.zeros((B, H)))
    print('h_9(最后一步)样本0 前 5 维:', np.round(h_seq[-1, 0, :5], 4))
    print('y_9(最后一步)样本0 前 5 维:', np.round(y_seq[-1, 0, :5], 4))
    print('h_seq 全局范围: [%.4f, %.4f] —— 严格落在 (-1, 1) 内'
          % (h_seq.min(), h_seq.max()))
    print('y_seq 全局范围: [%.4f, %.4f] —— 输出分数无界(softmax 之前的结果)'
          % (y_seq.min(), y_seq.max()))


def main():
    demo_A()
    demo_B()
    demo_C()
    demo_D()
    demo_E()
    print('\n' + LINE)
    print('全部实操跑通!下一步:第4章 BPTT 反向传播将消费这些 cache 口粮。')


if __name__ == '__main__':
    main()

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐