📌 本文属于《Python神经网络入门:零基础保姆级路线图》专栏
上一篇:python神经网络编程入门(十四)——RNN序列数据与循环思想,为什么你的模型需要“记忆”?
下一篇:python神经网络编程入门(十六)——从零实现RNN前向传播:把公式变成能跑的函数
完整目录 & 更新记录:《Python神经网络入门:零基础保姆级路线图(附全系列免费源码)》

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

先花 30 秒回顾一下上一篇(第十四篇)我们干了什么。我们把"序列数据"这个概念请上了台,讲清楚了:

  1. 什么是序列数据——词与词、帧与帧、时刻与时刻之间环环相扣,顺序本身就是信息(“我打你"≠"你打我”);
  2. 为什么 FC 和 CNN 搞不定它——全连接把顺序"拍扁"了、参数爆炸;卷积只有局部视野、没有记忆
  3. RNN 的灵光一现——给网络加一条"循环回路",每个时间步接收当前输入 x t x_t xt 和上一步的记忆 h t − 1 h_{t-1} ht1,再共享同一套权重,像一本"万能公式";
  4. 折叠图与展开图——一个黑盒环、一条时间链,链上每个单元共享参数;
  5. 最后,我们用 NumPy 跑了一个小 Demo,验证了维度的流转 x    ( 32 , 10 , 100 ) → h    ( 10 , 32 , 256 ) → y    ( 10 , 32 , 10 ) x\;(32,10,100) \to h\;(10,32,256) \to y\;(10,32,10) x(32,10,100)h(10,32,256)y(10,32,10)

但是,读到这里你心里一定攒了一堆"为什么":

  • 公式 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) 里的三个权重矩阵,到底谁管谁?各自什么形状?
  • 为什么上一篇反复强调 W h h W_{hh} Whh 必须是方阵?把它改成别的形状会怎样?
  • tanh ⁡ \tanh tanh 把结果压进 ( − 1 , 1 ) (-1,1) (1,1)图什么?换成 s i g m o i d \mathrm{sigmoid} sigmoid R e L U \mathrm{ReLU} ReLU 行不行?
  • 偏置 b h b_h bh 那家伙,存在的意义是什么?删掉它行不行?
  • Batch / Seq / Input / Hidden 这四个维度,到底是怎么流转的?为什么打印出来是 ( 10 , 32 , 256 ) (10, 32, 256) (10,32,256) 而不是 ( 32 , 10 , 256 ) (32, 10, 256) (32,10,256)
  • 以及最实际的一个问题:如果让我来设计一个 RNN 层,hidden_size 怎么拍脑袋定?

上一篇我们是"观其大略",这一篇我们把公式拆到骨头里、把维度看到眼睛里。学完本章,你要能独立回答三件事:

🎯 本章三大目标

  1. 说清楚三个权重矩阵(尤其 W h h W_{hh} Whh)的物理意义与维度,并论证 W h h W_{hh} Whh 为什么必须是方阵;
  2. 徒手推演 B = 32 ,    S = 10 ,    I = 100 ,    H = 256 B=32,\; S=10,\; I=100,\; H=256 B=32,S=10,I=100,H=256 时,每一步张量的 Shape 变化,与代码打印结果逐位对上;
  3. 给定输入输出维度,反推出合理的 hidden_size,并给出参数量预算。

本篇路线图:核心公式解剖手术(第一节)→ 三个权重矩阵身份档案(第二节)→ 四维张量流转(第三节)→ 里程碑:维度推演表(第四节)→ 五大实操(第五节)→ 常见坑与 FAQ(第六节)→ 小结与下章预告(第七节)。


一、解剖一只"RNN 细胞"——核心公式逐项拆解

1.1 宏观视角:一次"记忆更新"的三个动作

我们把 RNN 在任意一个时间步 t t t 干的事写成公式:

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)

这个公式一共就干了三件事,对应三个动作:

动作 数学项 生活化比喻
① 读新信息 W x h ⋅ x t W_{xh} \cdot x_t Wxhxt 老师刚在黑板上写的新知识点
② 回顾旧记忆 W h h ⋅ h t − 1 W_{hh} \cdot h_{t-1} Whhht1 翻看昨天的笔记,看看记得什么
③ 写新笔记 tanh ⁡ ( ⋅ ) \tanh(\cdot) tanh() 把新旧内容在脑子里搅拌、压缩,写下今天的新笔记

其中 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激活之前的"原始想法"(线性组合), tanh ⁡ \tanh tanh 是压缩器,压缩完得到的就是新的隐藏状态 h t h_t ht

📌 为什么单独拎出 z t z_t zt 因为下一章(第三章)手写前向传播时,我们要把 z t z_t zt 存进缓存(cache)供反向传播用—— tanh ⁡ \tanh tanh 的导数 1 − tanh ⁡ 2 ( z ) 1 - \tanh^2(z) 1tanh2(z) 需要它。现在先混个脸熟。

一句话总结这一节:新的记忆 = 新信息的影响 + 旧记忆的影响 + 一个初始倾向,再经过一次压缩。就这么朴素。

1.2 第一项 W x h ⋅ x t W_{xh} \cdot x_t Wxhxt:把"新消息"翻译成"大脑语言"

x t x_t xt 是当前时间步的输入,比如第 t t t 个词的 100 维词向量; W x h W_{xh} Wxh 是"输入 → 隐藏"的权重矩阵。这一项做的事情是:把输入从 input_size 维的空间,线性映射到 hidden_size 维的隐藏空间

如果把矩阵乘法逐元素展开,你会看得很清楚。假设隐藏层有 H H H 个神经元,那么 z t z_t zt 的第 j j j 个分量是:

z t [ j ] = ∑ i = 1 I W x h [ i , j ] ⋅ x t [ i ]    +    ( 旧记忆项 ) j + b h [ j ] z_t[j] = \sum_{i=1}^{I} W_{xh}[i, j] \cdot x_t[i] \;+\; \big(\text{旧记忆项}\big)_j + b_h[j] zt[j]=i=1IWxh[i,j]xt[i]+(旧记忆项)j+bh[j]

注意看这个求和:每个隐藏神经元 j j j,都是对输入所有 I I I 个维度的加权和 W x h W_{xh} Wxh 的第 j j j 列,就是"第 j j j 个隐藏神经元对各输入维度的重视程度"。

可以这样想象:你的大脑里有 H H H 个"收音机频道",每个频道用不同的"天线灵敏度"( W x h W_{xh} Wxh 的一列)去接收输入信号 x t x_t xt 的各个维度,最后混出一个频道自己的读数。同一个输入,被 H H H 套不同的"耳朵"听出 H H H 种味道——这就是"输入被翻译成大脑语言"。

维度上: ( B , I ) × ( I , H ) → ( B , H ) (B, I) \times (I, H) \to (B, H) (B,I)×(I,H)(B,H),左乘一个输入行向量,右乘权重矩阵,得到隐藏空间里的向量。 I I I 是进来的口子, H H H 是里面脑子的大小 W x h W_{xh} Wxh 就是那扇"翻译门"。

1.3 第二项 W h h ⋅ h t − 1 W_{hh} \cdot h_{t-1} Whhht1:旧记忆的"加权回放"

这是 RNN 区别于一切前馈网络(FC/CNN)的灵魂

h t − 1 h_{t-1} ht1 是上一步的记忆(隐藏状态), W h h W_{hh} Whh 是"隐藏 → 隐藏"权重矩阵。这一项做的是:把旧记忆线性变换成"对今天有用的部分"

展开看, W h h W_{hh} Whh 是一个 H × H H \times H H×H方阵,那么:

  • 对角线元素 W h h [ j , j ] W_{hh}[j, j] Whh[j,j]:第 j j j 维记忆自己对自己的保留比例。对角线越大,这条记忆线越"恋旧",前面的信息越容易被原样带下去;
  • 非对角线元素 W h h [ j , k ] W_{hh}[j, k] Whh[j,k] j ≠ k j \ne k j=k):记忆第 k k k对第 j j j 维的"交叉贡献"——不同记忆线之间可以互相混合、改写。

打个比方: W h h W_{hh} Whh 像一次"老员工述职会"。昨天笔记本上有 H H H 条要点( h t − 1 h_{t-1} ht1 H H H 个分量),每个要点派一个代表出席今天的会议,会议规则( W h h W_{hh} Whh)决定每条要点被采纳多少、以及不同要点之间怎么互相印证改写,最后形成今天的 H H H 条要点( h t h_t ht H H H 个分量)。

📌 为什么说 W h h W_{hh} Whh 是"记忆接力棒"的物理载体?
回顾展开图: h 0 → h 1 → ⋯ → h t h_0 \to h_1 \to \cdots \to h_t h0h1ht,每一步都乘一次 W h h W_{hh} Whh。所以 t t t 步的记忆里,藏着第 1 步输入经过 t − 1 t-1 t1 W h h W_{hh} Whh 变换后的"幽灵"——这就是为什么 W h h W_{hh} Whh 的连乘会引发梯度消失/爆炸(第四章主角,先立个 flag)。

1.4 第三项 b h b_h bh:给网络一个"初始倾向"

b h b_h bh 是隐藏层的偏置向量,维度 ( H , ) (H,) (H,)。它没有输入,只做逐元素平移:给 z t z_t zt 的每个分量加上一个常数。

它的意义是:即使输入和旧记忆都是 0 0 0,神经元的"原始想法"也不一定是 0 0 0。它相当于给每个隐藏神经元预设了一个"默认立场",让 tanh ⁡ \tanh tanh 工作在一个更合适的工作点上。

打个比方:开会之前会议室就有一股默认氛围(偏置)——偏置大,神经元"更兴奋"( tanh ⁡ \tanh tanh 输出更容易为正);偏置小,神经元"更冷静"。训练过程中, b h b_h bh 会被梯度拉着自动调整到最合适的值。

1.5 加和 z t z_t zt:线性叠加之后,为什么要立刻"上非线性"?

W x h ⋅ x t + W h h ⋅ h t − 1 + b h W_{xh} \cdot x_t + W_{hh} \cdot h_{t-1} + b_h Wxhxt+Whhht1+bh 三者相加,本质上还是一个线性组合。如果到这里就输出,那堆多少个 RNN 层都等价于一层线性变换(矩阵连乘可以合并),网络表达能力就废了

所以必须立刻接一个非线性激活函数,让网络能够表达"非线性的复杂规律"——这就是 tanh ⁡ \tanh tanh 登场的原因。

1.6 tanh ⁡ \tanh tanh:为什么必须"压缩"?

tanh ⁡ \tanh tanh(双曲正切)的函数特性:

  • 值域是 ( − 1 , 1 ) (-1, 1) (1,1):无论输入多大,输出都被压在这个开区间里;
  • 中心对称 tanh ⁡ ( − x ) = − tanh ⁡ ( x ) \tanh(-x) = -\tanh(x) tanh(x)=tanh(x),能同时表达"正记忆"和"负记忆"(增强与抑制);
  • 导数 tanh ⁡ ′ ( x ) = 1 − tanh ⁡ 2 ( x ) \tanh'(x) = 1 - \tanh^2(x) tanh(x)=1tanh2(x),在 0 0 0 附近梯度最大( = 1 =1 =1),越往两端梯度越小。

这带来三个实打实的好处:

  1. 数值稳定 z t z_t zt 累加下去可能变成 100 100 100 1000 1000 1000,不压缩的话数值直接爆炸(NaN);压到 ( − 1 , 1 ) (-1,1) (1,1) 后,信息始终在一个安全区间传递。就像音响里的压缩器(compressor):信号再猛,输出不会削顶失真。
  2. 能表达负信息 s i g m o i d \mathrm{sigmoid} sigmoid 输出 ( 0 , 1 ) (0,1) (0,1) 全是正的,只能表达"有多少"; tanh ⁡ \tanh tanh 能表达"正还是负"——模型可以说"这条信息我要抑制"。这对记忆建模至关重要。
  3. 梯度友好 0 0 0 附近导数大,学习效率高(这一点第四章算 BPTT 时会派上大用场)。

三种常见激活函数对比:

激活函数 值域 中心对称 0 附近导数 用在哪
s i g m o i d \mathrm{sigmoid} sigmoid ( 0 , 1 ) (0, 1) (0,1) 0.25 门控/二分类输出
tanh ⁡ \tanh tanh ( − 1 , 1 ) (-1, 1) (1,1) 1.0 RNN 隐藏状态(经典标配)
R e L U \mathrm{ReLU} ReLU [ 0 , + ∞ ) [0, +\infty) [0,+) 1.0 CNN / 深层网络隐藏层

🧠 小思考:为什么 RNN 不用 R e L U \mathrm{ReLU} ReLU R e L U \mathrm{ReLU} ReLU 无上界, z t z_t zt 大时输出也大,在 W h h W_{hh} Whh 循环连乘下极易梯度爆炸;而 tanh ⁡ \tanh tanh 有界,天然兜底。LSTM/GRU 时代门控里用的 s i g m o i d \mathrm{sigmoid} sigmoid tanh ⁡ \tanh tanh 分工,第七章再细聊。

1.7 输出公式 y t = W h y ⋅ h t + b y y_t = W_{hy} \cdot h_t + b_y yt=Whyht+by(给后续章节埋伏笔)

记忆更新完之后,如果当前时间步需要"产出"(比如预测下一个词),还要把记忆翻译成输出:

y t = W h y ⋅ h t + b y y_t = W_{hy} \cdot h_t + b_y yt=Whyht+by

W h y W_{hy} Why 是"隐藏 → 输出"权重,shape 为 ( H , O ) (H, O) (H,O)注意:这个公式是可选的——有的任务只需要最后一个时间步的输出(many-to-one,比如情感分析:读完整个句子才给结论);有的任务每个时间步都要输出(many-to-many,比如逐词翻译)。本系列第三章先实现"每个时间步都算 y t y_t yt"的版本,第十四章做情感分析时改成"只用最后一步的 h T h_T hT"。

1.8 图 1 + 一个"慢动作回放":手算一遍完整公式

下面这张图把整个公式的结构、每个量的 Shape 都画出来了:

在这里插入图片描述

🐍 这张图完整标注了 x t    ( B , I ) x_t\;(B,I) xt(B,I) W x h    ( I , H ) W_{xh}\;(I,H) Wxh(I,H) h t − 1    ( B , H ) h_{t-1}\;(B,H) ht1(B,H) W h h    ( H , H ) W_{hh}\;(H,H) Whh(H,H) b h    ( H , ) b_h\;(H,) bh(H,) z t    ( B , H ) z_t\;(B,H) zt(B,H) h t    ( B , H ) h_t\;(B,H) ht(B,H) W h y    ( H , O ) W_{hy}\;(H,O) Why(H,O) y t    ( B , O ) y_t\;(B,O) yt(B,O) 以及"记忆接力"回路。

光看图还不过瘾,我们用手算一遍。设 I = 2 ,    H = 3 I=2,\; H=3 I=2,H=3(维度缩到最小方便手算):

x_t    = [1, -1]                      # 当前输入(2维)
h_{t-1}= [0.5, -0.2, 0.1]             # 上一步记忆(3维)
W_xh   = [[ 0.5, -0.2,  0.3],
          [-0.1,  0.4,  0.2]]         # (2, 3)
W_hh   = [[ 0.8,  0.1,  0.0],
          [ 0.2,  0.7, -0.1],
          [ 0.0,  0.3,  0.6]]         # (3, 3) 方阵
b_h    = [0.1, -0.1, 0.05]            # (3,)

第一步:读新信息。 W x h ⋅ x t W_{xh} \cdot x_t Wxhxt(逐行点乘):

第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
→ [0.6, -0.6, 0.1]

第二步:回顾旧记忆。 W h h ⋅ h t − 1 W_{hh} \cdot h_{t-1} Whhht1

第1行: 0.8×0.5 + 0.1×(-0.2) + 0.0×0.1   = 0.38
第2行: 0.2×0.5 + 0.7×(-0.2) + (-0.1)×0.1 = -0.05
第3行: 0.0×0.5 + 0.3×(-0.2) + 0.6×0.1   = 0.00
→ [0.38, -0.05, 0.00]

第三步:加偏置。 z t = [ 0.6 + 0.38 + 0.1 , − 0.6 − 0.05 − 0.1 , 0.1 + 0 + 0.05 ] = [ 1.08 , − 0.75 , 0.15 ] z_t = [0.6+0.38+0.1, -0.6-0.05-0.1, 0.1+0+0.05] = [1.08, -0.75, 0.15] zt=[0.6+0.38+0.1,0.60.050.1,0.1+0+0.05]=[1.08,0.75,0.15]

第四步:压缩。 h t = tanh ⁡ ( z t ) = [ 0.7932 , − 0.6351 , 0.1489 ] h_t = \tanh(z_t) = [0.7932, -0.6351, 0.1489] ht=tanh(zt)=[0.7932,0.6351,0.1489]

看! z t z_t zt 里第 0 0 0 维的 1.08 1.08 1.08(很大),被 tanh ⁡ \tanh tanh 压成 0.7932 0.7932 0.7932 − 0.75 -0.75 0.75 压成 − 0.6351 -0.6351 0.6351每一个分量都乖乖待在 ( − 1 , 1 ) (-1,1) (1,1)。这就是"一个公式,统治所有时间步"——第 1 步和 第 100 步用的完全是同一套 W x h / W h h / b h W_{xh}/W_{hh}/b_h Wxh/Whh/bh,只是输入和记忆不同。


二、三个权重矩阵的"身份档案"——物理意义与维度

2.1 先上一张速查表
矩阵 物理意义 Shape 为什么是这么个形状 参数量
W x h W_{xh} Wxh 输入 → 隐藏(翻译门) ( I , H ) (I, H) (I,H) 要把 I I I 维的 x x x 映射成 H H H 维的 h h h I × H I \times H I×H
W h h W_{hh} Whh 隐藏 → 隐藏(记忆接力) ( H , H ) (H, H) (H,H) h h h 自己映射回自己,维度不变 → 必须是方阵 H × H H \times H H×H
W h y W_{hy} Why 隐藏 → 输出(翻译出去) ( H , O ) (H, O) (H,O) H H H 维的 h h h 映射成 O O O 维的 y y y H × O H \times O H×O
b h b_h bh 隐藏偏置 ( H , ) (H,) (H,) 每个隐藏神经元一个平移量 H H H
b y b_y by 输出偏置 ( O , ) (O,) (O,) 每个输出通道一个平移量 O O O
2.2 W x h W_{xh} Wxh:输入 → 隐藏空间的"门票"
  • 形状 ( I , H ) (I, H) (I,H)。行数 = 输入维度,列数 = 隐藏维度。
  • 逐列解读:第 j j j 列 = “第 j j j 个隐藏神经元对各输入维度的权重”。整列一起看,就是"第 j j j 个频道用怎样的灵敏度听输入"。
  • 参数量 I × H I \times H I×H。我们案例里 100 × 256 = 25,600 100 \times 256 = 25{,}600 100×256=25,600
  • 初始化:通常用小范围随机数(如 U ( − 0.1 , 0.1 ) U(-0.1, 0.1) U(0.1,0.1)),配合 tanh ⁡ \tanh tanh 让初始 z t z_t zt 落在 0 0 0 附近、梯度大。绝对不能用全零——全零会让所有隐藏神经元对称、永远学不出差异。
2.3 W h h W_{hh} Whh:隐藏状态的自回归引擎(本章主角)
  • 形状 ( H , H ) (H, H) (H,H)方阵,且行数、列数都等于隐藏维度。
  • 为什么必须是方阵——数学论证 h t − 1 ∈ R H h_{t-1} \in \mathbb{R}^{H} ht1RH h t ∈ R H h_t \in \mathbb{R}^{H} htRH,中间的线性变换矩阵必须满足"把 H H H 维向量映到 H H H 维向量",即 ( H , H ) (H, H) (H,H)。你不可能用一个 ( I , H ) (I, H) (I,H) ( H , O ) (H, O) (H,O) 的矩阵去把 H H H 维映回 H H H 维——维度对不上,矩阵乘法直接报错(第五节实操 C 会现场演示这个报错)。
  • 对角线的秘密:对角线越大 → 记忆越"恋旧",信息沿时间轴传得越远(但也越容易梯度爆炸);对角线小 → 记忆更新越快,但前文信息丢失也快。训练的过程,本质就是让网络自动学会"该记多少、该忘多少"
  • 参数量 H × H H \times H H×H。案例里 256 × 256 = 65,536 256 \times 256 = 65{,}536 256×256=65,536——一个 RNN 层里最大的参数块,比 W x h W_{xh} Wxh 的两倍还多。这也是为什么 hidden_size 稍微调大,总参数量就指数级上涨(第五节实操 E 会看到证据)。
2.4 W h y W_{hy} Why:隐藏 → 输出的"翻译出去"
  • 形状 ( H , O ) (H, O) (H,O)。行数 = 隐藏维度,列数 = 输出维度。
  • 物理意义:把"大脑里的记忆"翻译成外界需要的形式(比如 10 个候选词各自的分数,再过 s o f t m a x \mathrm{softmax} softmax 就是概率)。
  • 参数量 H × O H \times O H×O。案例里 256 × 10 = 2,560 256 \times 10 = 2{,}560 256×10=2,560
2.5 两个偏置 b h b_h bh b y b_y by
  • b h b_h bh:给每个隐藏神经元一个默认平移量,shape ( H , ) (H,) (H,)
  • b y b_y by:给每个输出通道一个默认平移量,shape ( O , ) (O,) (O,)
  • 参数量分别只有 H H H O O O,跟权重矩阵比是九牛一毛,但别删——删了模型表达力会下降(决策边界被钉死在过原点)。
2.6 图 2:三个权重矩阵的"长相"

下图把三个矩阵画出来,注意 W h h W_{hh} Whh正方形,另外两个是长方形(红色方框特别强调了方阵的身份):

在这里插入图片描述

2.7 参数量实例:一个 RNN 层到底有多少参数?

以本系列贯穿的配置(输入维度 I = 100 I=100 I=100、隐藏维度 H = 256 H=256 H=256、输出维度 O = 10 O=10 O=10)为例:

参数量 = W_xh + W_hh + b_h + W_hy + b_y
       = 100×256 + 256×256 + 256 + 256×10 + 10
       = 25,600 + 65,536 + 256 + 2,560 + 10
       = 93,962 ≈ 9.4 万

📌 注意:这个数字与序列长度 S S S 无关 S = 10 S=10 S=10 还是 S = 1000 S=1000 S=1000,参数量都是 93,962——这就是"参数共享"的威力,也是 RNN 能处理变长序列的底气(上一篇埋的坑,这里填上了)。


三、四个维度的"张量流转"——Batch / Seq / Input / Hidden 一网打尽

前面我们一直挂在嘴边的 ( B , S , I , H ) (B, S, I, H) (B,S,I,H) 到底是什么?这一节把四个维度逐个请上台。

3.1 四个维度的定义与比喻
维度 符号 案例值 一句话解释 比喻
Batch_Size B B B 32 一批同时喂给网络的独立样本数 32 个"平行世界",各演各的
Seq_Len S S S 10 每条样本的时间步数(词的个数) 剧情的"集数"
Input_Size I I I 100 每个时间步输入向量的维度 每集给大脑的"信息量"
Hidden_Size H H H 256 隐藏状态的维度(记忆容量) 大脑笔记本的"页数"

关键直觉: B B B 是"有多少个平行世界", S S S 是"时间轴有多长", I I I 是"每刻喂多少信息", H H H 是"记忆容量有多大"

3.2 输入张量 x x x:为什么是 ( B , S , I ) (B, S, I) (B,S,I) 而不是别的顺序?

整个批次的输入是一个三维张量:

x.shape = (Batch_Size, Seq_Len, Input_Size) = (32, 10, 100)

为什么把 B B B 放最前面、 S S S 放中间?这是 PyTorch / NumPy 的主流约定(batch-first 风格):先把"样本"拎在最外层,方便整体切片和 GPU 并行。有些框架(如早期的 TensorFlow 静态图)喜欢 ( S , B , I ) (S, B, I) (S,B,I),顺序不同但内容一样,只是"坐标系的朝向"不同。只要前后端约定一致,用哪个都行

📌 后续我们统一:输入用 ( B , S , I ) (B, S, I) (B,S,I),保存中间结果用 ( S , B , … ) (S, B, \ldots) (S,B,)(这样时间步是第 0 维,第 4 章 BPTT 反向沿时间轴遍历时最顺手)。两种都是合法的工程选择,别被"哪种是标准答案"绕晕。

3.3 切片 x[:, t, :]:把"时间"一刀刀切开

RNN 是按时间步循环的,所以我们要把 x x x 沿第 1 维(时间)切成 S S S 片:

x_t = x[:, t, :]   # 所有样本的第 t 个词 → shape (32, 100)

这一步做完,三维张量变成了二维 ( B , I ) (B, I) (B,I)。可以理解为"32 个平行世界,在 t t t 这个时间点上同时收到的消息"。

3.4 隐藏状态 h t h_t ht ( B , H ) (B, H) (B,H),batch 内互不干扰

h t h_t ht 的形状永远是 ( B , H ) (B, H) (B,H)。这里有个重要的直觉:矩阵乘法天然是"按行并行"的——np.dot(x_t, W_xh) 是一批 32 个行向量,各自独立地与同一个 W x h W_{xh} Wxh 相乘。所以:

  • 32 条样本共享同一套权重(参数共享!);
  • 各自的记忆完全独立:第 3 条样本的 h t h_t ht 不会污染第 7 条样本的 h t h_t ht

这正是上一篇说的"每条样本一条自己的记忆通道,互不干扰"。Batch 越大,同一批里并行处理的平行世界越多,训练效率越高(代价是显存/内存占用变大)。

3.5 输出与堆叠: h s e q h_{seq} hseq 为什么是 ( S , B , H ) (S, B, H) (S,B,H)

每循环一步,我们把 h t h_t ht y t y_t yt 存进 Python 列表,最后 np.array(...) 堆叠:

h_seq = np.array(h_seq)   # (10, 32, 256)   → (Seq, Batch, Hidden)
y_seq = np.array(y_seq)   # (10, 32, 10)    → (Seq, Batch, Output)

列表里存了 10 个 ( 32 , 256 ) (32, 256) (32,256) 的数组,堆叠成第 0 维长度为 10 的三维张量——所以是 ( S , B , H ) (S, B, H) (S,B,H)。这个顺序对第 3 章的 cache(缓存所有时间步的中间结果)和第 4 章的 BPTT 都极其顺手。

3.6 图 3:张量流转全景图

下面这张图把"四维张量 → 切片 → 单步计算 → 堆叠"的完整旅程画了出来:

在这里插入图片描述

用一句话"口头动画"复述这张图:

32 条样本同时进厂( B = 32 B=32 B=32)→ 流水线共有 10 个工位( S = 10 S=10 S=10)→ 每个工位塞进一个 100 维的"原材料"( I = 100 I=100 I=100)→ 每个工位内部有一个 256 页的"记忆笔记本"( H = 256 H=256 H=256)→ 笔记本按"共享的更新规则"(同一套 W W W)翻页 → 每个工位吐出一个 10 维的"产品"( O = 10 O=10 O=10)→ 10 个工位的笔记本页与产品分别打包成 ( 10 , 32 , 256 ) (10,32,256) (10,32,256) ( 10 , 32 , 10 ) (10,32,10) (10,32,10)


四、里程碑:维度推演表自动生成(附代码验证)

4.1 设定

沿用全系列贯穿的配置:

Batch_Size  B = 32
Seq_Len     S = 10
Input_Size  I = 100
Hidden_Size H = 256
Output_Size O = 10
4.2 图 4:维度推演表(本章核心截图点)

这张表把所有中间量(含 W x h / W h h / W h y W_{xh}/W_{hh}/W_{hy} Wxh/Whh/Why 两个偏置、 z t z_t zt)的 Shape、物理意义、验证状态一次性列出,颜色按"输入/权重/状态/输出"分组:

在这里插入图片描述

4.3 代码打印验证:让 Shape 和理论"逐位对上"

纸上推演千遍,不如 print 一遍。下面是 shape_demo.py 实操 A 的真实运行输出(脚本代码在第五节给出):

==========================================================================
实操 A:维度流转 —— 每一步 Shape 都能和理论对得上
==========================================================================
权重矩阵维度:
  W_xh : (100, 256)  (输入→隐藏)
  W_hh : (256, 256)  (隐藏→隐藏)  <- 注意是方阵!
  W_hy : (256, 10)  (隐藏→输出)
  b_h  : (256,)  (隐藏偏置)
  b_y  : (10,)  (输出偏置)

按时间步循环前向:
  t= 0 | x_t (32, 100) | z_t (32, 256) | h_t (32, 256) | y_t (32, 10)
  t= 1 | x_t (32, 100) | z_t (32, 256) | h_t (32, 256) | y_t (32, 10)
  t= 9 | x_t (32, 100) | z_t (32, 256) | h_t (32, 256) | y_t (32, 10)
堆叠保存: h_seq (10, 32, 256), y_seq (10, 32, 10)

对照图 4 逐行看: x t x_t xt 永远是 ( 32 , 100 ) (32, 100) (32,100) z t z_t zt h t h_t ht 永远是 ( 32 , 256 ) (32, 256) (32,256) y t y_t yt 永远是 ( 32 , 10 ) (32, 10) (32,10),堆叠后与推演表完全一致。✅ 至此,"维度流转"这个初学 RNN 最大的坑,被我们用表 + 代码双重钉死了。


五、动手实操:shape_demo.py 带你亲眼见证五个真相

纸上得来终觉浅。这一节我们把第一节的"手算慢动作"全部交给代码,并补上四个"试验": tanh ⁡ \tanh tanh 压缩实况、非方阵报错、 h 0 h_0 h0 实验、hidden_size 反推。

5.1 实操 A:逐时间步打印 Shape(代码骨架)

shape_demo.py 的核心就是上一个 Demo 的"增强版",把 z t z_t zt 也拆出来看:

import numpy as np

# ---------- 超参数(与维度推演表完全一致) ----------
B, S, I, H, O = 32, 10, 100, 256, 10
np.random.seed(42)

# ---------- 权重初始化:小范围随机,偏置为 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)

def rnn_forward_step(x_t, h_prev):
    """单时间步前向:z_t -> h_t -> y_t"""
    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)
    return z_t, h_t, y_t

x = np.random.randn(B, S, I)          # 输入批次 (32, 10, 100)
h_t = np.zeros((B, H))                # h_0 全零 (32, 256)
h_seq, y_seq = [], []
for t in range(S):
    x_t = x[:, t, :]                  # 切片 (32, 100)
    z_t, h_t, y_t = rnn_forward_step(x_t, h_t)
    h_seq.append(h_t)
    y_seq.append(y_t)
h_seq = np.array(h_seq)               # (10, 32, 256)
y_seq = np.array(y_seq)               # (10, 32, 10)

🐍 完整脚本(含下面四个实操)见 shape_demo.py,直接 python shape_demo.py 就能复现第五节所有输出。

5.2 实操 B: tanh ⁡ \tanh tanh 把隐藏状态压进 ( − 1 , 1 ) (-1,1) (1,1) —— 实况检查

运行后你会看到(shape_demo.py 实操 B):

打印 h_1、h_5、h_9 前 5 个维度的取值:
  t= 1: [ 0.0876 -0.0752 -0.1641  0.8393 -0.3406] ...
  t= 5: [ 0.8318 -0.8688  0.3756  0.2691 -0.3191] ...
  t= 9: [-0.7975 -0.1859 -0.1773  0.4761  0.3348] ...
  h_seq 全局最小值 = -0.9972, 全局最大值 = 0.9978(严格落在 (-1,1) 内)

注意三点:

  1. 所有值都严格落在 ( − 1 , 1 ) (-1, 1) (1,1):全局最小值 − 0.9972 -0.9972 0.9972、最大值 0.9978 0.9978 0.9978,被 tanh ⁡ \tanh tanh 牢牢按住;
  2. 同一个维度在不同时间步取值变化剧烈( 0.0876 → 0.8318 → − 0.7975 0.0876 \to 0.8318 \to -0.7975 0.08760.83180.7975)——记忆在实时演化
  3. 维度之间互不相同——每个神经元各自"记"不同的东西。

把这段"记忆实况"画成折线图,就是下面这张图( H = 8 , S = 12 H=8, S=12 H=8,S=12 的小号模型,方便看清每条线):

在这里插入图片描述

可以看到: h 0 = 0 h_0 = \mathbf{0} h0=0(全零)出发,第 0 步的取值只由当前输入 x 0 x_0 x0 决定(此时旧记忆为空);从第 1 步起,新信息与旧记忆开始混合,各维度迅速分化成各自的轨迹,且全程被压在 ± 1 \pm 1 ±1 虚线之内。这就是 tanh ⁡ \tanh tanh 压缩器的可视化证据。

5.3 实操 C:把 W h h W_{hh} Whh 故意改成非方阵——“血的教训”

纸上论证了 N 遍" W h h W_{hh} Whh 必须是方阵",不如亲眼看一次报错。shape_demo.py 实操 C 故意把 W h h W_{hh} Whh 初始化成 ( I , H ) = ( 100 , 256 ) (I, H) = (100, 256) (I,H)=(100,256)

  NumPy 报错:shapes (32,256) and (100,256) not aligned: 256 (dim 1) != 100 (dim 0)

翻译一下: h t − 1 h_{t-1} ht1 ( 32 , 256 ) (32, 256) (32,256),它要乘一个矩阵得到 ( 32 , 256 ) (32, 256) (32,256) h t h_t ht中间矩阵的左维度(行数)必须等于 256;而 ( 100 , 256 ) (100, 256) (100,256) 的行数是 100,对不上,于是报错。

📌 这个报错信息以后你会经常见到,请记住它的长相:not aligned 就是"两个矩阵的内侧维度对不上"。看到它,第一反应是检查矩阵形状,而不是去翻代码逻辑。

5.4 实操 D: h 0 h_0 h0 = 全零 vs h 0 h_0 h0 = 随机——思考题的代码证据

目录里留了一道思考题:"为什么 h 0 h_0 h0 通常初始化为全零向量?如果初始化成随机值会怎样?"代码给出证据(实操 D):

  h0=全零 → h_1 前 3 维: [-0.0049  0.0547 -0.6674]
  h0=随机 → h_1 前 3 维: [-0.5668 -0.3098 -0.6256]
  差别的范数 ||h1_zeros - h1_rand|| = 34.698

随机 h 0 h_0 h0 让第一层记忆从一开始就注入了 34.7 34.7 34.7 的偏差噪声。业界惯例用全零的理由:

  1. t = 0 t=0 t=0 之前没有任何输入,"记忆"本来就不存在,全零是最诚实的起点;
  2. 全零不注入任何先验偏好,对每条样本一视同仁;随机 h 0 h_0 h0 相当于给每条样本塞了不同的"出厂记忆",白白增加训练负担;
  3. 可复现性:随机 h 0 h_0 h0 意味着同一份数据每次跑结果不同(除非固定 h 0 h_0 h0 的随机种子),全零则天然稳定。

🧠 进阶:LSTM/GRU 里 c 0 c_0 c0(细胞状态)同样初始化为全零,道理一模一样——第八章见分晓。

5.5 实操 E:给定输入输出,如何反推 hidden_size

最后来解决"拍脑袋"问题。shape_demo.py 实操 E 固定 I = 100 , O = 10 I=100, O=10 I=100,O=10,枚举不同 H H H 打印参数量:

固定 I=100, O=10,参数量 = I*H + H*H + H + H*O + O:
  H        参数量          相对 H=128           适合场景
  32       4586         +0.1 倍             短序列/小语料
  64       11210        +0.4 倍             短序列/小语料
  128      30602        +1.0 倍             中等语料(本系列实战默认)
  256      93962        +3.1 倍             长序列/大语料
  512      318986       +10.4 倍            谨慎:极易过拟合

看到 H H H 从 128 翻到 256,参数量不是翻 2 倍而是 3 倍(因为 H 2 H^2 H2 项)——这正是 W h h W_{hh} Whh 方阵带来的"参数量随隐藏维度平方级增长"。把这条曲线画出来:

在这里插入图片描述

反推方法论(三步走):

  1. 看语料规模定起点:语料小(万级)用 H ≤ 64 H \le 64 H64,中等(十万级)用 128,大(百万级)才考虑 256 + 256+ 256+
  2. 看参数量预算:先算"总参数量 ≈ H 2 \approx H^2 H2",再乘 4 字节(float32)估显存/内存,超预算就降 H H H
  3. 以过拟合为准调优:训练集 Loss 降但验证集不降 → H H H 太大,降;两边都不降 → H H H 太小,升。从 128 起步,过拟合就降,欠拟合就升,这是最朴素也最有效的经验法则。

六、常见坑与 FAQ

🕳️ 常见坑
# 现象 解法
1 维度顺序搞混 以为 h s e q h_{seq} hseq ( B , S , H ) (B,S,H) (B,S,H),切片取错轴 记牢:本系列输入 ( B , S , I ) (B,S,I) (B,S,I)、中间结果 ( S , B , H ) (S,B,H) (S,B,H);先 print(x.shape) 再动手
2 W h h W_{hh} Whh 初始化成 ( I , H ) (I,H) (I,H) np.dotnot aligned 背口诀:记忆映射自己,必须是方阵 ( H , H ) (H,H) (H,H)
3 h 0 h_0 h0 忘记初始化 np.dot(h_t, W_hh) h t h_t ht 未定义 循环前先 h_t = np.zeros((B, H));注意广播成 ( H , ) (H,) (H,) 会报错
4 拿整个 x    ( B , S , I ) x\;(B,S,I) x(B,S,I) 直接乘 W x h W_{xh} Wxh 维度报错或语义错误 必须逐时间步切片 x[:, t, :],先切片再乘(第三章会专门封装循环)
5 tanh ⁡ \tanh tanh 写成 s i g m o i d \mathrm{sigmoid} sigmoid / 放错位置 隐藏状态只能取正,表达力下降 记住分工: tanh ⁡ \tanh tanh 管"状态", s i g m o i d \mathrm{sigmoid} sigmoid 管"门"(第七章 LSTM 详述)
6 hidden 拍脑袋太大 参数量爆炸、过拟合、训练慢 用 5.5 的三步法:先估参数量,再从小往上调
❓ FAQ

Q1:为什么 RNN 隐藏层不用 R e L U \mathrm{ReLU} ReLU
R e L U \mathrm{ReLU} ReLU 无上界, z t z_t zt 一大会导致输出很大,在 W h h W_{hh} Whh 循环连乘下梯度极易爆炸,训练直接 NaN; tanh ⁡ \tanh tanh 有界 ( − 1 , 1 ) (-1,1) (1,1),天然兜底。现代 RNN 变体(LSTM/GRU)内部其实也有 tanh ⁡ \tanh tanh + s i g m o i d \mathrm{sigmoid} sigmoid 的搭配,但没有裸 R e L U \mathrm{ReLU} ReLU

Q2: ( S , B , H ) (S,B,H) (S,B,H) ( B , S , H ) (B,S,H) (B,S,H) 到底谁对?
都对,是"坐标系朝向"问题。PyTorch 的 nn.RNN(batch_first=True) 返回 ( B , S , H ) (B,S,H) (B,S,H)batch_first=False(默认)返回 ( S , B , H ) (S,B,H) (S,B,H)关键不是选哪种,而是全工程统一一种。本系列前 10 章纯 NumPy 统一用 ( S , B , … ) (S,B,\ldots) (S,B,) 存中间结果,从第十一章接 PyTorch 时会显式设置 batch_first=True

Q3: y t y_t yt 每个时间步都要算吗?
不一定。many-to-many(逐词翻译/生成)每个时间步都输出;many-to-one(情感分类)只要最后一步 h T h_T hT。第三章先实现"全输出"版本,第十四章实战改成"最后一步输出",两者只差一行。

Q4: W h h W_{hh} Whh 里为什么不能都是大数?
W h h W_{hh} Whh 谱范数(最大奇异值)决定记忆能传多远:太大 → 梯度爆炸,太小 → 梯度消失(第四章会推导 ∂ 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) 的连乘)。所以初始化压在小范围 U ( − 0.1 , 0.1 ) U(-0.1, 0.1) U(0.1,0.1),让初始谱范数接近 1 1 1


七、本章小结与下章预告

本章小结(一句话带走一个知识点)
知识点 一句话带走
核心公式 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) = 读新信息 + 回顾旧记忆 + 加偏置,再压缩
W x h W_{xh} Wxh ( I , H ) (I, H) (I,H),把输入翻译进隐藏空间,参数量 I × H I \times H I×H
W h h W_{hh} Whh ( H , H ) (H, H) (H,H) 方阵,记忆接力引擎,参数量 H 2 H^2 H2(最大参数块)
W h y W_{hy} Why ( H , O ) (H, O) (H,O),把记忆翻译成输出
tanh ⁡ \tanh tanh 把值压进 ( − 1 , 1 ) (-1,1) (1,1):数值稳定 + 能表达正负 + 梯度友好
四个维度 B B B=平行世界数、 S S S=时间轴长、 I I I=每刻信息量、 H H H=记忆容量
张量流转 x    ( B , S , I ) → x t    ( B , I ) → h t    ( B , H ) → y t    ( B , O ) → x\;(B,S,I) \to x_t\;(B,I) \to h_t\;(B,H) \to y_t\;(B,O) \to x(B,S,I)xt(B,I)ht(B,H)yt(B,O) 堆叠 ( S , B , H ) / ( S , B , O ) (S,B,H)/(S,B,O) (S,B,H)/(S,B,O)
参数量 与序列长度无关,只由 I I I H H H O O O 决定; H H H 是平方级开销
h 0 h_0 h0 全零:无历史、无先验、可复现
hidden 反推 看语料规模定起点(128 起步),过拟合降、欠拟合升
下章预告:第 3 章《从零实现 RNN 前向传播》

公式拆明白了、维度看清楚了,下一章我们就把公式变成函数:手写 rnn_forward(x, W_xh, W_hh, W_hy, b_h, b_y, h0),处理时间维度的循环逻辑,并把每个时间步的 h t − 1 , h t , x t , z t h_{t-1}, h_t, x_t, z_t ht1,ht,xt,zt 存进 cache——这些缓存正是第四章 BPTT 反向传播的口粮。跑通前向、验证输出 Shape 与本章推演表逐位一致,就是下一章的成功标准。


🧠 思考题与动手练习

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

  1. 如果 RNN 每个时间步用不同的权重矩阵,参数量会怎样变化?为什么说参数共享是 RNN 处理变长序列的根基?
  2. 为什么 W h h W_{hh} Whh 必须是方阵?改成 ( I , H ) (I, H) (I,H) 会报什么错、为什么报这个错?(提示:实操 C)
  3. h 0 h_0 h0 为什么惯例用全零?用随机值会带来什么问题?(提示:实操 D)
  4. hidden_size 从 128 涨到 256,参数量涨多少倍?为什么不是 2 倍?(提示: H 2 H^2 H2 项)
  5. tanh ⁡ \tanh tanh s i g m o i d \mathrm{sigmoid} sigmoid 都能"压缩",为什么 RNN 选 tanh ⁡ \tanh tanh?(提示:值域对称性)

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

  1. H 改成 128 重跑,确认推演表所有 Shape 依然成立(只是 H 变 128);
  2. 新增一个"第 t t t 步隐藏状态欧式范数"统计,画出 ∥ h t ∥ \|h_t\| ht t t t 的变化,观察它是否保持稳定(这是第四章"数值稳定性"的伏笔);
  3. 在实操 C 里把 W h h W_{hh} Whh 改成 ( H , O ) (H, O) (H,O),预测一下报错信息长什么样,再运行验证;
  4. rnn_forward_step 里的 tanh ⁡ \tanh tanh 换成 s i g m o i d \mathrm{sigmoid} sigmoid 重跑实操 B,观察 h t h_t ht 的取值区间变成什么,理解 1.6 节对比表的含义;
  5. 试着手写一个 reverse_hidden_size(input_size, output_size, param_budget) 函数:给定预算,解出 H H H 的上限(用一元二次方程 H 2 + ( I + O + 1 )   H + ( O − b u d g e t ) = 0 H^2 + (I+O+1)\,H + (O - \mathrm{budget}) = 0 H2+(I+O+1)H+(Obudget)=0 求根)。

📌 下篇预告:第三章《从零实现 RNN 前向传播》——rnn_forward 函数 + cache 缓存 + 输出维度验证。我们下篇见!

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

# -*- coding: utf-8 -*-
"""
shape.py — 《从零构建RNN》第2章《RNN的数学原理与结构剖析》配套实操脚本

跑一遍 `python shape.py`,你会亲眼看到:
  A. 四个维度(Batch/Seq/Input/Hidden)如何一步步流转 —— 每个时间步的 shape
  B. tanh 把数值压进 (-1, 1) 的实况 —— 隐藏状态的真实取值
  C. 把 W_hh 故意改成非方阵会怎样 —— 为什么 W_hh 必须是方阵的"血的教训"
  D. h0 = 全零 vs 随机 的差别 —— 思考题"h0 为什么用全零"的代码证据
  E. 给定输入输出维度,如何反推 hidden_size —— 参数权衡表

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

# ---------- 超参数(与维度推演表完全一致) ----------
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"""
    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
    return z_t, h_t, y_t


def main():
    # ================= 实操 A:维度流转 =================
    print(LINE)
    print('实操 A:维度流转 —— 每一步 Shape 都能和理论对得上')
    print(LINE)
    print('权重矩阵维度:')
    print('  W_xh : %s  (输入→隐藏)' % (W_xh.shape,))
    print('  W_hh : %s  (隐藏→隐藏)  <- 注意是方阵!' % (W_hh.shape,))
    print('  W_hy : %s  (隐藏→输出)' % (W_hy.shape,))
    print('  b_h  : %s  (隐藏偏置)' % (b_h.shape,))
    print('  b_y  : %s  (输出偏置)' % (b_y.shape,))

    x = np.random.randn(B, S, I)          # (32, 10, 100)
    h_t = np.zeros((B, H))                # h_0 全零 (32, 256)
    h_seq, y_seq = [], []
    print('\n按时间步循环前向:')
    for t in range(S):
        x_t = x[:, t, :]                  # (32, 100)
        z_t, h_t, y_t = rnn_forward_step(x_t, h_t)
        h_seq.append(h_t)
        y_seq.append(y_t)
        if t in (0, 1, S - 1):            # 只打印首、次、末三步,避免刷屏
            print('  t=%2d | x_t %s | z_t %s | h_t %s | y_t %s' %
                  (t, x_t.shape, z_t.shape, h_t.shape, y_t.shape))

    h_seq = np.array(h_seq)               # (10, 32, 256)
    y_seq = np.array(y_seq)               # (10, 32, 10)
    print('堆叠保存: h_seq %s, y_seq %s' % (h_seq.shape, y_seq.shape))

    # ================= 实操 B:tanh 的压缩实况 =================
    print('\n' + LINE)
    print('实操 B:tanh 把隐藏状态压进 (-1, 1) —— 实况检查')
    print(LINE)
    print('打印 h_1、h_5、h_9 前 5 个维度的取值:')
    for t in (1, 5, 9):
        vals = h_seq[t, 0, :5]
        print('  t=%2d: %s ...' % (t, np.round(vals, 4)))
    print('  h_seq 全局最小值 = %.4f, 全局最大值 = %.4f(严格落在 (-1,1) 内)'
          % (h_seq.min(), h_seq.max()))

    # ================= 实操 C:非方阵 W_hh 报错实验 =================
    print('\n' + LINE)
    print('实操 C:把 W_hh 故意改成非方阵,会发生什么?')
    print(LINE)
    W_hh_wrong = np.random.uniform(-0.1, 0.1, (I, H))   # 错误:(100, 256)
    try:
        _ = np.dot(np.zeros((B, H)), W_hh_wrong)        # (32,256) x (100,256)
    except ValueError as e:
        print('  NumPy 报错:%s' % e)
    print('  结论:h_{t-1} 是 (B, H),想乘出 (B, H) 的 h_t,')
    print('        中间矩阵的右维度必须等于 H,即 W_hh 只能是 (H, H) 方阵。')

    # ================= 实操 D:h0 全零 vs 随机 =================
    print('\n' + LINE)
    print('实操 D:h0 = 全零 vs h0 = 随机 —— 思考题的代码证据')
    print(LINE)
    x_1 = np.random.randn(B, I)                        # 只看第 1 步
    h_zeros = np.zeros((B, H))
    h_rand = np.random.uniform(-1, 1, (B, H))
    _, h1_zeros, _ = rnn_forward_step(x_1, h_zeros)
    _, h1_rand, _ = rnn_forward_step(x_1, h_rand)
    print('  h0=全零 → h_1 前 3 维: %s' % np.round(h1_zeros[0, :3], 4))
    print('  h0=随机 → h_1 前 3 维: %s' % np.round(h1_rand[0, :3], 4))
    print('  差别的范数 ||h1_zeros - h1_rand|| = %.3f' %
          np.linalg.norm(h1_zeros - h1_rand))
    print('  为什么业界惯例用全零?')
    print('    1) t=0 之前没有任何输入,"记忆"本来就不存在;')
    print('    2) 全零不注入任何先验偏好,对任何样本一视同仁;')
    print('    3) 随机 h0 会向第一层记忆注入噪声,且每批样本需固定同一 h0 才可复现。')

    # ================= 实操 E:反推 hidden_size =================
    print('\n' + LINE)
    print('实操 E:给定输入输出,如何反推 hidden_size?')
    print(LINE)
    print('固定 I=100, O=10,参数量 = I*H + H*H + H + H*O + O:')
    print('  %-8s %-12s %-18s %s' % ('H', '参数量', '相对 H=128', '适合场景'))
    p128 = I * 128 + 128 * 128 + 128 + 128 * O + O   # 基准:H=128
    for Hh in (32, 64, 128, 256, 512):
        p = I * Hh + Hh * Hh + Hh + Hh * O + O
        print('  %-8d %-12d %-18s %s' %
              (Hh, p, '%+.1f 倍' % (p / p128), '短序列/小语料' if Hh <= 64 else
               ('中等语料(本系列实战默认)' if Hh == 128 else
                ('长序列/大语料' if Hh == 256 else '谨慎:极易过拟合'))))
    print('  反推口诀:hidden 决定"记忆容量",先看语料规模,再按参数量预算选;')
    print('            实战经验:从 128 起步,过拟合就降,欠拟合就升。')


if __name__ == '__main__':
    main()

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐