- 文档
- 教程
- 人工智能
- 深度学习
- NLP
- 计算机视觉
- 强化学习
【免费下载链接】d2l-en
Interactive deep learning book with multi-framework code, math, and discussions. Adopted at 500 universities from 70 countries including Stanford, MIT, Harvard, and Cambridge.
深度循环神经网络(Deep RNN)是在"时间方向"之外,沿着"输入到输出方向"(即网络深度方向)堆叠多个循环层以增强建模能力的标准方案。本文以 chapter_recurrent-modern/deep-rnn.md 为主线,完整推导多层 RNN 的数学定义,并结合 d2l 模块中RNNScratch、RNNLMScratch、RNNLM、GRU等类的真实源码(d2l/torch.py、d2l/mxnet.py、d2l/tensorflow.py、d2l/jax.py),给出从零实现与高层 API 简洁实现两种路径的完整代码与训练配置。读完本文,你将掌握深度 RNN 的架构原理、隐藏层与隐藏单元的超参数选取规律,并能用多种深度学习框架独立搭建并训练一个多层 GRU 语言模型。
为什么需要"深度"RNN:沿时间轴 vs 沿输入输出轴
在 chapter_recurrent-neural-networks 各章节介绍的普通 RNN 中,网络只包含"输入 → 单个循环隐藏层 → 输出层"这一条路径。严格说,这类网络在时间方向上已经是"深"的:第 1 个时间步的输入需要经过 $T$ 次循环层运算($T$ 常达数百甚至上千)才能影响最终时刻 $T$ 的输出。
但仅靠时间方向的深度不足以刻画所有复杂关系。许多任务要求网络同时表达同一时间步内、输入到输出之间的复杂映射,例如语言建模中同时出现的语义与语法约束。这就需要沿着"输入到输出方向"(与 MLP、深度 CNN 中"深度"的含义一致)再叠加若干循环层。
标准做法极其简洁:将 RNN 逐层堆叠(stack)。对长度为 $T$ 的输入序列,第一层 RNN 输出一个同样长度为 $T$ 的输出序列,这个序列再作为第二层 RNN 的输入,依此类推。由此得到包含 $L$ 个隐藏层的深度 RNN,如 img/deep-rnn.svg 所示:任意时刻 $t$ 的每个循环单元(白色方框)同时依赖同一层上一时刻的隐藏状态与上一层同一时刻的隐藏状态,这正是循环层堆叠时信息传递的两条通道。
深度 RNN 的数学形式:从输入到输出的逐层递推
设时间步 $t$ 的批量输入为 $\mathbf{X}_t \in \mathbb{R}^{n \times d}$($n$ 为样本数,$d$ 为每个样本的输入维数);第 $l$ 层($l=1,\ldots,L$)的隐藏状态为 $\mathbf{H}_t^{(l)} \in \mathbb{R}^{n \times h}$($h$ 为隐藏单元数);输出层变量为 $\mathbf{O}_t \in \mathbb{R}^{n \times q}$($q$ 为输出数)。
约定 $\mathbf{H}_t^{(0)} = \mathbf{X}_t$,则采用激活函数 $\phi_l$ 的第 $l$ 层隐藏状态为:
$$\mathbf{H}t^{(l)} = \phi_l(\mathbf{H}t^{(l-1)} \mathbf{W}{\textrm{xh}}^{(l)} + \mathbf{H}{t-1}^{(l)} \mathbf{W}{\textrm{hh}}^{(l)} + \mathbf{b}\textrm{h}^{(l)})$$
其中权重 $\mathbf{W}{\textrm{xh}}^{(l)} \in \mathbb{R}^{h \times h}$、$\mathbf{W}{\textrm{hh}}^{(l)} \in \mathbb{R}^{h \times h}$ 与偏置 $\mathbf{b}_\textrm{h}^{(l)} \in \mathbb{R}^{1 \times h}$ 是第 $l$ 层隐藏层的模型参数。注意两点关键区别:
- 每个隐藏层拥有独立的参数 $\mathbf{W}{\textrm{xh}}^{(l)}、\mathbf{W}{\textrm{hh}}^{(l)}、\mathbf{b}_\textrm{h}^{(l)}$,层间参数不共享;
- 中间层的输入来自上一层同一时刻的输出 $\mathbf{H}_t^{(l-1)}$,而非常规 RNN 中"当前时刻原始输入 + 上一时刻隐藏状态"的组合。因此首层用 $\mathbf{H}_t^{(0)} = \mathbf{X}_t$ 衔接输入,中间各层天然以 $\mathbf{H}_t^{(l-1)}$ 作为输入特征,这正是每层输入维数都取 $h$ 的原因。
最终输出层只依赖最后一层(第 $L$ 层)的隐藏状态:
$$\mathbf{O}t = \mathbf{H}t^{(L)} \mathbf{W}{\textrm{hq}} + \mathbf{b}\textrm{q}$$
其中输出层参数为 $\mathbf{W}{\textrm{hq}} \in \mathbb{R}^{h \times q}$ 与 $\mathbf{b}\textrm{q} \in \mathbb{R}^{1 \times q}$。
与 MLP 类似,隐藏层数 $L$ 与隐藏单元数 $h$ 都是可调超参数。原文档给出的实践范围是:常见 RNN 层宽度 $h$ 落在 $(64, 2056)$,常见深度 $L$ 落在 $(1, 8)$。此外,只要把上式中的隐藏状态计算替换为 LSTM 或 GRU 的隐藏状态计算,就能轻松得到"深度门控 RNN"——这也是下文实战部分选择 GRU 的原因。
从零实现:StackedRNNScratch 逐层堆叠
模块结构:每层一个 RNNScratch 实例
从零实现多层 RNN 的思路是把每一层当作一个拥有独立可学习参数的RNNScratch实例。RNNScratch的基础实现定义在 d2l/torch.py(以及 d2l/mxnet.py、d2l/tensorflow.py、d2l/jax.py),其核心是三个参数——输入到隐藏权重W_xh(形状num_inputs × num_hiddens)、隐藏到隐藏权重W_hh(形状num_hiddens × num_hiddens)与偏置b_h,初始化时用标准差sigma=0.01的高斯分布缩放,前向传播则按state = tanh(X @ W_xh + state @ W_hh + b_h)逐时间步推进。
StackedRNNScratch 正是在此基础上堆叠:第 0 层接收num_inputs维输入,其余层接收上一层的num_hiddens维输出。
# %%tab mxnet, tensorflow class StackedRNNScratch(d2l.Module): def __init__(self, num_inputs, num_hiddens, num_layers, sigma=0.01): super().__init__() self.save_hyperparameters() self.rnns = [d2l.RNNScratch(num_inputs if i==0 else num_hiddens, num_hiddens, sigma) for i in range(num_layers)]# %%tab pytorch class StackedRNNScratch(d2l.Module): def __init__(self, num_inputs, num_hiddens, num_layers, sigma=0.01): super().__init__() self.save_hyperparameters() self.rnns = nn.Sequential(*[d2l.RNNScratch( num_inputs if i==0 else num_hiddens, num_hiddens, sigma) for i in range(num_layers)])# %%tab jax class StackedRNNScratch(d2l.Module): num_inputs: int num_hiddens: int num_layers: int sigma: float = 0.01 def setup(self): self.rnns = [d2l.RNNScratch(self.num_inputs if i==0 else self.num_hiddens, self.num_hiddens, self.sigma) for i in range(self.num_layers)]num_inputs:首层输入维数(此处取len(data.vocab),即词表大小);num_hiddens:每层隐藏单元数;num_layers:堆叠的循环层层数,即公式中的 $L$;sigma:参数初始化缩放系数,默认0.01,与 d2l/torch.py 中RNNScratch的d2l.randn(...) * sigma保持一致。
前向计算:逐层串行推进
多层前向计算只需按层顺序依次执行:每层以上一层的输出序列作为输入,并维护自己独立的状态列表Hs。
# %%tab all @d2l.add_to_class(StackedRNNScratch) def forward(self, inputs, Hs=None): outputs = inputs if Hs is None: Hs = [None] * self.num_layers for i in range(self.num_layers): outputs, Hs[i] = self.rnnsi outputs = d2l.stack(outputs, 0) return outputs, Hs实现细节与公式一一对应:状态Hs是长度为num_layers的列表,每层一份初始状态;第 $i$ 层的输入即上一层输出outputs;d2l.stack(outputs, 0)把该层按时间步收集的输出列表沿时间轴堆叠成(num_steps, batch_size, num_hiddens)张量,作为下一层的序列输入。RNNScratch.forward的源码注释(d2l/torch.py)印证了这一数据布局:输入形状为(num_steps, batch_size, num_inputs),初始状态形状为(batch_size, num_hiddens)。
在 Time Machine 数据集上训练深度 GRU
以 chapter_recurrent-neural-networks/rnn-scratch.md 中的Time Machine数据集为例训练一个 2 层深度 GRU 模型。数据集类TimeMachine定义在 d2l/torch.py,其预处理会把文本规整为仅含字母、小写化的序列,并按num_steps切分样本。
# %%tab all data = d2l.TimeMachine(batch_size=1024, num_steps=32) if tab.selected('mxnet', 'pytorch', 'jax'): rnn_block = StackedRNNScratch(num_inputs=len(data.vocab), num_hiddens=32, num_layers=2) model = d2l.RNNLMScratch(rnn_block, vocab_size=len(data.vocab), lr=2) trainer = d2l.Trainer(max_epochs=100, gradient_clip_val=1, num_gpus=1) if tab.selected('tensorflow'): with d2l.try_gpu(): rnn_block = StackedRNNScratch(num_inputs=len(data.vocab), num_hiddens=32, num_layers=2) model = d2l.RNNLMScratch(rnn_block, vocab_size=len(data.vocab), lr=2) trainer = d2l.Trainer(max_epochs=100, gradient_clip_val=1) trainer.fit(model, data)关键配置说明:
num_layers=2:为控制篇幅与训练成本,只堆叠 2 层(完整公式支持任意 $L$);num_hiddens=32:每层隐藏单元数,远小于工程上常用的 $(64, 2056)$ 区间,足以验证堆叠逻辑;lr=2:学习率,RNNLMScratch的默认学习率为0.01(见 d2l/torch.py),此处显式调大以适配语言建模任务;gradient_clip_val=1:梯度裁剪阈值。Trainer.fit_epoch(d2l/torch.py)在每步反向传播后执行clip_gradients:当所有参数梯度的 L2 范数超过阈值时,按比例缩放梯度。深度 RNN 沿时间展开后梯度极易爆炸,裁剪是保证收敛的关键手段;num_gpus=1:使用 1 块 GPU(Trainer的 GPU 分支见 d2l/torch.py)。
语言模型封装RNNLMScratch(d2l/torch.py)负责将 RNN 块输出的每个时刻隐藏状态经output_layer(W_hq @ H + b_q)映射到词表维度,并以困惑度(perplexity,即exp(loss))作为训练/验证指标;预测时采用自回归方式逐个生成 token。
简洁实现:复用高层 API 的多层 GRU
高层 API 已经封装了多层 RNN 的大量实现细节。以下GRU类继承自d2l.RNN,相比 chapter_recurrent-modern/gru.md 的单层版本,新增了显式的num_layers与dropout参数。
MXNet(Gluon)
# %%tab mxnet class GRU(d2l.RNN): #@save """The multilayer GRU model.""" def __init__(self, num_hiddens, num_layers, dropout=0): d2l.Module.__init__(self) self.save_hyperparameters() self.rnn = rnn.GRU(num_hiddens, num_layers, dropout=dropout)rnn.GRU直接接收层数参数,dropout控制层间 Dropout。源码对应 d2l/mxnet.py,MXNet 版RNNLM的输出层为nn.Dense(vocab_size, flatten=False)(d2l/mxnet.py)。
PyTorch
# %%tab pytorch class GRU(d2l.RNN): #@save """The multilayer GRU model.""" def __init__(self, num_inputs, num_hiddens, num_layers, dropout=0): d2l.Module.__init__(self) self.save_hyperparameters() self.rnn = nn.GRU(num_inputs, num_hiddens, num_layers, dropout=dropout)PyTorch 版需额外传入num_inputs,nn.GRU内部自动完成多层堆叠。源码对应 d2l/torch.py,其中RNNLM用nn.LazyLinear惰性初始化输出层(d2l/torch.py),因此GRU构造时无需预先知道vocab_size。
TensorFlow(Keras)
# %%tab tensorflow class GRU(d2l.RNN): #@save """The multilayer GRU model.""" def __init__(self, num_hiddens, num_layers, dropout=0): d2l.Module.__init__(self) self.save_hyperparameters() gru_cells = [tf.keras.layers.GRUCell(num_hiddens, dropout=dropout) for _ in range(num_layers)] self.rnn = tf.keras.layers.RNN(gru_cells, return_sequences=True, return_state=True, time_major=True) def forward(self, X, state=None): outputs, *state = self.rnn(X, state) return outputs, stateKeras 的做法是为每一层创建一个GRUCell并组成列表,再整体包进tf.keras.layers.RNN。return_sequences=True保留每个时间步的输出序列;return_state=True返回各层最终状态;time_major=True与 d2l 全程使用的(num_steps, batch_size, features)时间主序数据布局一致(RNNScratch注释同样标明该形状)。源码对应 d2l/tensorflow.py。
JAX(Flax)
Flax 对 RNN 采用极简主义:原生不提供层数参数,也默认不带 Dropout。因此 JAX 版需要手工叠加:用nn.scan对nn.GRUCell做时间维扫描,并在除最后一层外的每个 GRU 层之后插入nn.Dropout。
# %%tab jax class GRU(d2l.RNN): #@save """The multilayer GRU model.""" num_hiddens: int num_layers: int dropout: float = 0 @nn.compact def __call__(self, X, state=None, training=False): outputs = X new_state = [] if state is None: batch_size = X.shape[1] state = [nn.GRUCell.initialize_carry(jax.random.PRNGKey(0), (batch_size,), self.num_hiddens)] * self.num_layers GRU = nn.scan(nn.GRUCell, variable_broadcast="params", in_axes=0, out_axes=0, split_rngs={"params": False}) # Introduce a dropout layer after every GRU layer except last for i in range(self.num_layers - 1): layer_i_state, X = GRU()(state[i], outputs) new_state.append(layer_i_state) X = nn.Dropout(self.dropout, deterministic=not training)(X) # Final GRU layer without dropout out_state, X = GRU()(state[-1], X) new_state.append(out_state) return X, jnp.array(new_state)nn.GRUCell.initialize_carry为每层初始化(batch_size, num_hiddens)的零状态,共num_layers份;nn.scan沿时间轴(in_axes=0, out_axes=0)展开循环,参数在各时间步间共享(variable_broadcast="params");- Dropout 只加在前
num_layers - 1层之后,最后一层不设 Dropout,training标志控制随机失活是否生效(推理时deterministic=True)。
该实现完整对应 d2l/jax.py 中RNN/RNNLM的接口约定:RNNLM.forward调用self.rnn(embs, state, self.training),将训练标志传入循环层。
简洁实现的超参数选择与训练
简洁实现的架构决策与单层 GRU 基本一致:输入输出维数等于词表大小vocab_size,隐藏单元数仍取 32,唯一区别是显式指定num_layers=2。
# %%tab mxnet gru = GRU(num_hiddens=32, num_layers=2) model = d2l.RNNLM(gru, vocab_size=len(data.vocab), lr=2) # Running takes > 1h (pending fix from MXNet) # trainer.fit(model, data) # model.predict('it has', 20, data.vocab, d2l.try_gpu())MXNet 分支因耗时超过 1 小时而注释掉训练代码(原文档注明待 MXNet 修复)。
# %%tab pytorch, tensorflow, jax if tab.selected('tensorflow', 'jax'): gru = GRU(num_hiddens=32, num_layers=2) if tab.selected('pytorch'): gru = GRU(num_inputs=len(data.vocab), num_hiddens=32, num_layers=2) if tab.selected('pytorch', 'jax'): model = d2l.RNNLM(gru, vocab_size=len(data.vocab), lr=2) if tab.selected('tensorflow'): with d2l.try_gpu(): model = d2l.RNNLM(gru, vocab_size=len(data.vocab), lr=2) trainer.fit(model, data)训练完成后即可用前缀生成文本:PyTorch 为model.predict('it has', 20, data.vocab, d2l.try_gpu()),TensorFlow 为model.predict('it has', 20, data.vocab),JAX 需显式传入训练得到的参数model.predict('it has', 20, data.vocab, trainer.state.params)——这是因为 JAX 的函数式风格要求参数显式传递(见 d2l/jax.py 的predict实现)。
训练深度 RNN 的关键注意事项
- 信息传递路径:深度 RNN 中,隐藏状态信息同时流向当前层的下一时间步与下一层的当前时间步。层间堆叠带来更强的特征抽象能力,但参数总量随 $L$ 线性增长;
- 梯度传播与裁剪:沿时间轴展开后梯度需回传 $T \times L$ 次,范数可能爆炸。
Trainer的gradient_clip_val参数(d2l/torch.py)在每步更新前把梯度范数截断到阈值内,是深度 RNN 收敛的基本保障; - 学习率与初始化:
RNNLMScratch参数由init_params以sigma=0.01的缩放初始化(d2l/torch.py),配合较大学习率(此处lr=2)才能较快收敛;总体而言,深度 RNN 需要付出可观的工作量(学习率调节、梯度裁剪等)才能保证正确收敛; - 门控变体:将公式中的隐藏状态计算替换为 LSTM 或 GRU 即可得到深度门控 RNN,各框架高层 API 均已内置这类多层变体,无需手工堆叠;
- 模型初始化需谨慎:深层堆叠放大了初始化质量对训练稳定性的影响,这也是各框架版本统一采用小方差初始化(
sigma=0.01)的动机。
小结
- 深度 RNN 通过在"输入到输出方向"上堆叠 $L$ 个循环层实现,任意时刻每个单元依赖同层上一时刻与上一层同一时刻的隐藏状态;
- 逐层递推公式 $\mathbf{H}t^{(l)} = \phi_l(\mathbf{H}t^{(l-1)} \mathbf{W}{\textrm{xh}}^{(l)} + \mathbf{H}{t-1}^{(l)} \mathbf{W}{\textrm{hh}}^{(l)} + \mathbf{b}\textrm{h}^{(l)})$ 是深度 RNN 的通用骨架,输出层只作用于第 $L$ 层隐藏状态;
- 常见层宽度 $h \in (64, 2056)$、层数 $L \in (1, 8)$,$L$ 与 $h$ 都是可调超参数;
- 从零实现只需逐层实例化
RNNScratch;简洁实现可复用各框架高层 API(Gluon/PyTorch 原生多层 GRU、Keras GRUCell 列表、Flax 手写扫描 + Dropout); - 深度 RNN 有 vanilla RNN、LSTM、GRU 等多种形态,均可在高层 API 中获得;训练时需特别注意学习率与梯度裁剪。
练习
- 将本节示例中的 GRU 替换为 LSTM,比较准确率与训练速度的差异(
RNNScratch/RNNLMScratch的替换只需调整隐藏状态计算公式)。 - 增大训练数据,将多本书籍合并作为语料,观察困惑度(perplexity)最低能做到多少。
- 建模文本时是否应该合并不同作者的语料?思考这样做为何有利,以及可能带来哪些问题(提示:写作风格与词汇分布差异带来的混合效应)。
延伸阅读:本文依赖的前置章节包括 chapter_recurrent-neural-networks/rnn-scratch.md(从零实现 RNN)、chapter_recurrent-neural-networks/rnn-concise.md(高层 API 实现)与 chapter_recurrent-modern/gru.md(单层 GRU)。
- 文档
- 教程
- 人工智能
- 深度学习
- NLP
- 计算机视觉
- 强化学习
【免费下载链接】d2l-en
Interactive deep learning book with multi-framework code, math, and discussions. Adopted at 500 universities from 70 countries including Stanford, MIT, Harvard, and Cambridge.
相关推荐
d2l-en 卷积神经网络实战:LeNet-5 架构详解与多框架(PyTorch/MXNet/TensorFlow/JAX)实现指南
d2l en 卷积神经网络实战:LeNet 5 架构详解与多框架(PyTorch/MXNet/TensorFlow/JAX)实现指南 LeNet 5 是最早一批
文档教程人工智能深度学习NLP计算机视觉强化学习d2l-en 双向循环神经网络(Bidirectional RNN)完全指南:架构、数学原理与多框架实现
d2l en 双向循环神经网络(Bidirectional RNN)完全指南:架构、数学原理与多框架实现 本文是《动手学深度学习》(d2l en)中 双向循环神
文档教程人工智能深度学习NLP计算机视觉强化学习深度循环神经网络(Deep RNN):多层隐状态堆叠的原理与实战
深度循环神经网络(Deep RNN):多层隐状态堆叠的原理与实战 《动手学深度学习》从零到一实现多层循环神经网络(Deep RNN):理解为什么单一隐藏层难以建
人工智能深度学习机器学习教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考