python神经网络编程入门(十五)——RNN的数学原理与结构剖析:一个公式,如何统治所有时间步?
📌 本文属于《Python神经网络入门:零基础保姆级路线图》专栏
上一篇:python神经网络编程入门(十四)——RNN序列数据与循环思想,为什么你的模型需要“记忆”?
下一篇:python神经网络编程入门(十六)——从零实现RNN前向传播:把公式变成能跑的函数
完整目录 & 更新记录:《Python神经网络入门:零基础保姆级路线图(附全系列免费源码)》
引言:上一章我们"看见了"公式,这一章我们"读懂"它
先花 30 秒回顾一下上一篇(第十四篇)我们干了什么。我们把"序列数据"这个概念请上了台,讲清楚了:
- 什么是序列数据——词与词、帧与帧、时刻与时刻之间环环相扣,顺序本身就是信息(“我打你"≠"你打我”);
- 为什么 FC 和 CNN 搞不定它——全连接把顺序"拍扁"了、参数爆炸;卷积只有局部视野、没有记忆;
- RNN 的灵光一现——给网络加一条"循环回路",每个时间步接收当前输入 x t x_t xt 和上一步的记忆 h t − 1 h_{t-1} ht−1,再共享同一套权重,像一本"万能公式";
- 折叠图与展开图——一个黑盒环、一条时间链,链上每个单元共享参数;
- 最后,我们用 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(Wxh⋅xt+Whh⋅ht−1+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怎么拍脑袋定?
上一篇我们是"观其大略",这一篇我们把公式拆到骨头里、把维度看到眼睛里。学完本章,你要能独立回答三件事:
🎯 本章三大目标
- 说清楚三个权重矩阵(尤其 W h h W_{hh} Whh)的物理意义与维度,并论证 W h h W_{hh} Whh 为什么必须是方阵;
- 徒手推演 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 变化,与代码打印结果逐位对上;
- 给定输入输出维度,反推出合理的
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(Wxh⋅xt+Whh⋅ht−1+bh)
这个公式一共就干了三件事,对应三个动作:
| 动作 | 数学项 | 生活化比喻 |
|---|---|---|
| ① 读新信息 | W x h ⋅ x t W_{xh} \cdot x_t Wxh⋅xt | 老师刚在黑板上写的新知识点 |
| ② 回顾旧记忆 | W h h ⋅ h t − 1 W_{hh} \cdot h_{t-1} Whh⋅ht−1 | 翻看昨天的笔记,看看记得什么 |
| ③ 写新笔记 | 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=Wxh⋅xt+Whh⋅ht−1+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) 1−tanh2(z) 需要它。现在先混个脸熟。
一句话总结这一节:新的记忆 = 新信息的影响 + 旧记忆的影响 + 一个初始倾向,再经过一次压缩。就这么朴素。
1.2 第一项 W x h ⋅ x t W_{xh} \cdot x_t Wxh⋅xt:把"新消息"翻译成"大脑语言"
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=1∑IWxh[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} Whh⋅ht−1:旧记忆的"加权回放"
这是 RNN 区别于一切前馈网络(FC/CNN)的灵魂。
h t − 1 h_{t-1} ht−1 是上一步的记忆(隐藏状态), 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} ht−1 的 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 h0→h1→⋯→ht,每一步都乘一次 W h h W_{hh} Whh。所以第 t t t 步的记忆里,藏着第 1 步输入经过 t − 1 t-1 t−1 次 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 Wxh⋅xt+Whh⋅ht−1+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)=1−tanh2(x),在 0 0 0 附近梯度最大( = 1 =1 =1),越往两端梯度越小。
这带来三个实打实的好处:
- 数值稳定: z t z_t zt 累加下去可能变成 100 100 100、 1000 1000 1000,不压缩的话数值直接爆炸(NaN);压到 ( − 1 , 1 ) (-1,1) (−1,1) 后,信息始终在一个安全区间传递。就像音响里的压缩器(compressor):信号再猛,输出不会削顶失真。
- 能表达负信息: s i g m o i d \mathrm{sigmoid} sigmoid 输出 ( 0 , 1 ) (0,1) (0,1) 全是正的,只能表达"有多少"; tanh \tanh tanh 能表达"正还是负"——模型可以说"这条信息我要抑制"。这对记忆建模至关重要。
- 梯度友好: 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=Why⋅ht+by(给后续章节埋伏笔)
记忆更新完之后,如果当前时间步需要"产出"(比如预测下一个词),还要把记忆翻译成输出:
y t = W h y ⋅ h t + b y y_t = W_{hy} \cdot h_t + b_y yt=Why⋅ht+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) ht−1(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 Wxh⋅xt(逐行点乘):
第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} Whh⋅ht−1:
第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.6−0.05−0.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} ht−1∈RH, h t ∈ R H h_t \in \mathbb{R}^{H} ht∈RH,中间的线性变换矩阵必须满足"把 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):全局最小值 − 0.9972 -0.9972 −0.9972、最大值 0.9978 0.9978 0.9978,被 tanh \tanh tanh 牢牢按住;
- 同一个维度在不同时间步取值变化剧烈( 0.0876 → 0.8318 → − 0.7975 0.0876 \to 0.8318 \to -0.7975 0.0876→0.8318→−0.7975)——记忆在实时演化;
- 维度之间互不相同——每个神经元各自"记"不同的东西。
把这段"记忆实况"画成折线图,就是下面这张图( 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} ht−1 是 ( 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 的偏差噪声。业界惯例用全零的理由:
- t = 0 t=0 t=0 之前没有任何输入,"记忆"本来就不存在,全零是最诚实的起点;
- 全零不注入任何先验偏好,对每条样本一视同仁;随机 h 0 h_0 h0 相当于给每条样本塞了不同的"出厂记忆",白白增加训练负担;
- 可复现性:随机 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 方阵带来的"参数量随隐藏维度平方级增长"。把这条曲线画出来:

反推方法论(三步走):
- 看语料规模定起点:语料小(万级)用 H ≤ 64 H \le 64 H≤64,中等(十万级)用 128,大(百万级)才考虑 256 + 256+ 256+;
- 看参数量预算:先算"总参数量 ≈ H 2 \approx H^2 ≈H2",再乘 4 字节(float32)估显存/内存,超预算就降 H H H;
- 以过拟合为准调优:训练集 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.dot 报 not 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) ∂h1∂hT=∏t=1T−1Whh⊤diag(1−ht2) 的连乘)。所以初始化压在小范围 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(Wxh⋅xt+Whh⋅ht−1+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 ht−1,ht,xt,zt 存进 cache——这些缓存正是第四章 BPTT 反向传播的口粮。跑通前向、验证输出 Shape 与本章推演表逐位一致,就是下一章的成功标准。
🧠 思考题与动手练习
思考题(先自己想,再看答案区,答案就在正文里):
- 如果 RNN 每个时间步用不同的权重矩阵,参数量会怎样变化?为什么说参数共享是 RNN 处理变长序列的根基?
- 为什么 W h h W_{hh} Whh 必须是方阵?改成 ( I , H ) (I, H) (I,H) 会报什么错、为什么报这个错?(提示:实操 C)
- h 0 h_0 h0 为什么惯例用全零?用随机值会带来什么问题?(提示:实操 D)
hidden_size从 128 涨到 256,参数量涨多少倍?为什么不是 2 倍?(提示: H 2 H^2 H2 项)- tanh \tanh tanh 和 s i g m o i d \mathrm{sigmoid} sigmoid 都能"压缩",为什么 RNN 选 tanh \tanh tanh?(提示:值域对称性)
动手练习(改造 shape_demo.py):
- 把
H改成 128 重跑,确认推演表所有 Shape 依然成立(只是 H 变 128); - 新增一个"第 t t t 步隐藏状态欧式范数"统计,画出 ∥ h t ∥ \|h_t\| ∥ht∥ 随 t t t 的变化,观察它是否保持稳定(这是第四章"数值稳定性"的伏笔);
- 在实操 C 里把 W h h W_{hh} Whh 改成 ( H , O ) (H, O) (H,O),预测一下报错信息长什么样,再运行验证;
- 把
rnn_forward_step里的 tanh \tanh tanh 换成 s i g m o i d \mathrm{sigmoid} sigmoid 重跑实操 B,观察 h t h_t ht 的取值区间变成什么,理解 1.6 节对比表的含义; - 试着手写一个
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+(O−budget)=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()
更多推荐



所有评论(0)