从零实现循环神经网络(RNN):D2L 手写字符级语言模型全解析
文档教程人工智能深度学习NLP计算机视觉强化学习【免费下载链接】d2l-enInteractive deep learning book with multi-framework code, math, and discussions. Adopted at 500 universities from 70 countries including Stanford, MIT, Harvard, and Cambridge.项目地址https://gitcode.com/gh_mirrors/d2/d2l-en点击查看免费下载导读本文以 D2L 开源教材d2l-en 仓库中 chapter_recurrent-neural-networks/rnn-scratch.md 为核心完整讲解如何不借助任何高层 RNN API从零手写一个循环神经网络RNN并用它在 H. G. Wells 的《The Time Machine》语料上训练一个字符级语言模型既能评估困惑度perplexity也能根据用户给定的前缀续写文本。读完本文你将掌握 RNN 的隐藏状态传递机制、one-hot 编码、输出层设计、梯度裁剪原理以及 D2L 统一训练框架d2l.Trainer中四框架PyTorch / MXNet / TensorFlow / JAX一致的实现方式。本文所有代码均可从仓库中实际运行并与 d2l/torch.py 等源码中的最终封装实现一一对应。一、任务设定为什么从零实现一个 RNN在前序章节 chapter_recurrent-neural-networks/rnn.md 中已经建立了 RNN 的数学模型通过隐藏状态 $h_t f(x_t, h_{t-1})$ 把历史信息压缩进一个状态向量从而避免 $n$-gram 模型参数随窗口长度指数增长的问题。本节的实战目标是把这套公式落到代码上训练对象字符级语言模型character-level language model即给定前文预测下一个字符训练语料H. G. Wells《The Time Machine》全文约 3 万词数据处理完全沿用 chapter_recurrent-neural-networks/text-sequence.md 中的预处理管线下载原始文本 → 去标点、转小写 → 按字符切分 → 构造词汇表 → 转数值索引评测指标困惑度perplexity即平均交叉熵的指数使不同长度序列的评测结果可比见 chapter_recurrent-neural-networks/language-model.md 的subsec_perplexity小节。文中所有代码块都通过 D2L 的tab机制同时给出 PyTorch、MXNet、TensorFlow、JAX 四个框架的等价实现。仓库中对应的最终封装源码位于 d2l/torch.pyRNNScratch与RNNLMScratch及 d2l/jax.py、d2l/mxnet.py、d2l/tensorflow.py 中的同名校验类。二、RNN 模型手写隐藏状态更新2.1 模型结构与参数初始化RNNScratch类只封装两个权重矩阵和一个偏置全部是裸张量参数不依赖任何框架自带的循环单元参数形状含义W_xh(num_inputs, num_hiddens)当前输入到隐藏状态的权重W_hh(num_hiddens, num_hiddens)上一时刻隐藏状态到当前时刻的权重循环部分b_h(num_hiddens,)隐藏状态偏置num_hiddens隐藏单元数是训练时可调的核心超参数。初始化采用均值为 0、标准差sigma默认0.01的高斯分布故意把初值压小避免训练早期激活饱和。PyTorch 版代码如下MXNet / TensorFlow 版仅把nn.Parameter换成各自框架的变量对象结构完全一致class RNNScratch(d2l.Module): #save The RNN model implemented from scratch. def __init__(self, num_inputs, num_hiddens, sigma0.01): super().__init__() self.save_hyperparameters() self.W_xh nn.Parameter( d2l.randn(num_inputs, num_hiddens) * sigma) self.W_hh nn.Parameter( d2l.randn(num_hiddens, num_hiddens) * sigma) self.b_h nn.Parameter(d2l.zeros(num_hiddens))JAX 版由于使用 Flax 的nn.Module参数在setup()中通过self.param声明但形状与语义完全一致。save_hyperparameters()会把num_inputs、num_hiddens、sigma自动记录便于后续取用如语言模型层需要读取self.rnn.num_hiddens。2.2 forward逐时间步滚动隐藏状态forward是 RNN 的核心沿着输入序列的最外层维度时间步方向逐个字符地更新隐藏状态。每一步的计算是$$h_t \tanh(x_t W_{xh} h_{t-1} W_{hh} b_h)$$d2l.add_to_class(RNNScratch) #save def forward(self, inputs, stateNone): if state is None: # Initial state with shape: (batch_size, num_hiddens) state d2l.zeros((inputs.shape[1], self.num_hiddens), deviceinputs.device) else: state, state outputs [] for X in inputs: # Shape of inputs: (num_steps, batch_size, num_inputs) state d2l.tanh(d2l.matmul(X, self.W_xh) d2l.matmul(state, self.W_hh) self.b_h) outputs.append(state) return outputs, state几个关键设计点初始状态stateNone时用全零张量初始化形状为(batch_size, num_hiddens)并显式放到输入张量所在设备inputs.device上PyTorch 版在 d2l/torch.py 有同样实现循环结构for X in inputs逐时间步迭代每次把上一步的state与当前X一起送入公式产出新的state并收集到outputs列表激活函数统一使用tanh。其值域为(-1, 1)梯度在零点附近最陡能一定程度抑制梯度消失其数学性质见 chapter_multilayer-perceptrons/numerical-stability-and-init.md 的subsec_tanh小节返回值outputs是每个时间步的隐藏状态列表state是最后一步的隐藏状态。注意这里的outputs尚未经过输出层它们是给下游语言模型层用的编码。JAX 版的__call__结构相同区别在于用jax.nn的函数式 API 并通过init_with_output完成参数初始化。2.3 形状自检验证维度不变性RNN 的一个基本要求是隐藏状态的维度在整个序列传播中保持不变。文档用一个形状检查用例验证这一点batch_size, num_inputs, num_hiddens, num_steps 2, 16, 32, 100 rnn RNNScratch(num_inputs, num_hiddens) X d2l.ones((num_steps, batch_size, num_inputs)) outputs, state rnn(X) def check_len(a, n): #save Check the length of a list. assert len(a) n, flist\s length {len(a)} ! expected length {n} def check_shape(a, shape): #save Check the shape of a tensor. assert a.shape shape, \ ftensor\s shape {a.shape} ! expected shape {shape} check_len(outputs, num_steps) check_shape(outputs[0], (batch_size, num_hiddens)) check_shape(state, (batch_size, num_hiddens))即喂入形状为(100, 2, 16)的输入输出 100 个形状为(2, 32)的隐藏状态最终状态也是(2, 32)。这两个辅助函数check_len、check_shape在 d2l/torch.py 中被#save保存为公共工具供后续章节复用。三、RNN 语言模型从隐藏状态到下一字符预测3.1 RNNLMScratch 类的骨架RNNLMScratch继承自d2l.Classifier把手写 RNN作为rnn参数注入。它的输入输出共享同一词汇表因此输入维度与输出维度都等于词表大小vocab_size。模型在隐藏状态之上只额外增加一个全连接输出层参数形状含义W_hq(num_hiddens, vocab_size)隐藏状态到词表 logits 的权重b_q(vocab_size,)输出层偏置class RNNLMScratch(d2l.Classifier): #save The RNN-based language model implemented from scratch. def __init__(self, rnn, vocab_size, lr0.01): super().__init__() self.save_hyperparameters() self.init_params() def init_params(self): self.W_hq nn.Parameter( d2l.randn( self.rnn.num_hiddens, self.vocab_size) * self.rnn.sigma) self.b_q nn.Parameter(d2l.zeros(self.vocab_size))training_step/validation_step统一计算交叉熵损失并把d2l.exp(l)即困惑度绘制到训练看板——这正是 language-model.md 中subsec_perplexity定义的平均交叉熵的指数。JAX 版通过jax.value_and_grad同时返回损失与梯度并同样在plot(ppl, ...)中记录困惑度。3.2 One-Hot 编码把离散索引变成向量词表把每个 token 映射为一个整数索引。一个自然的疑问是能不能直接把索引当标量输入网络文档给出了明确回答——不能。因为数值大小在语义上没有意义词表中第 45 和第 46 个词恰好是 their 和 said两者含义毫不相关但数值上仅相差 1。对类别型数据标准做法是one-hot 编码一个长度为vocab_size的向量对应位置为 1其余全为 0。# 词表大小为 5 时索引 0 和 2 的 one-hot 向量 F.one_hot(torch.tensor([0, 2]), 5) # pytorch npx.one_hot(np.array([0, 2]), 5) # mxnet tf.one_hot(tf.constant([0, 2]), 5) # tensorflow jax.nn.one_hot(jnp.array([0, 2]), 5) # jaxRNNLMScratch.one_hot方法把一批形状为(batch_size, num_steps)的索引序列转置后展开为三维张量(num_steps, batch_size, vocab_size)。转置的目的是让外层维度变成时间步正好匹配RNNScratch.forward中for X in inputs的循环方式保证逐时间步更新隐藏状态d2l.add_to_class(RNNLMScratch) #save def one_hot(self, X): # Output shape: (num_steps, batch_size, vocab_size) return F.one_hot(X.T, self.vocab_size).type(torch.float32)3.3 输出层与整体前向output_layer对每个时间步的隐藏状态 $H$ 做线性变换 $H W_{hq} b_q$再沿时间步维度堆叠得到形状(batch_size, num_steps, vocab_size)的 logitsd2l.add_to_class(RNNLMScratch) #save def output_layer(self, rnn_outputs): outputs [d2l.matmul(H, self.W_hq) self.b_q for H in rnn_outputs] return d2l.stack(outputs, 1) d2l.add_to_class(RNNLMScratch) #save def forward(self, X, stateNone): embs self.one_hot(X) rnn_outputs, _ self.rnn(embs, state) return self.output_layer(rnn_outputs)整个前向链路清晰三段式输入编码one-hot→ RNN 建模隐藏状态滚动→ 输出生成线性层 交叉熵。文档随后用形状断言验证输入(batch_size2, num_steps100)的整数索引输出应为(2, 100, num_inputs16)即每个时间步每个样本一个覆盖整个词表的 logits 向量。这段实现与 d2l/torch.py 中的保存版完全一致。四、梯度裁剪驯服爆炸梯度4.1 序列带来的时间深度与梯度爆炸通常我们说网络深指的是单时间步内输入到输出之间的层数。但序列数据引入了第二种深度第一个时间步的输入要穿过整整 $T$ 个时间步才能影响最后一步的输出。在反向传播中这意味着梯度要穿过一条长度为 $\mathcal{O}(T)$ 的矩阵乘积链——正如 chapter_multilayer-perceptrons/numerical-stability-and-init.md 所述这极易引发数值不稳定梯度要么爆炸要么消失取决于权重矩阵的性质。梯度消失催生了后续章节的专门架构GRU、LSTM 等见 chapter_recurrent-neural-networks/gru.md 与 chapter_recurrent-neural-networks/lstm.md而即使现代 RNN 也常受梯度爆炸困扰。一个不优雅但无处不在的解法就是梯度裁剪gradient clipping。4.2 数学原理Lipschitz 连续与裁剪公式设目标函数 $f$ 对参数 $\mathbf{x}$ 以常数 $L$ 满足Lipschitz 连续$$|f(\mathbf{x}) - f(\mathbf{y})| \leq L |\mathbf{x} - \mathbf{y}|$$在梯度下降更新 $\mathbf{x} \gets \mathbf{x} - \eta \mathbf{g}$$\eta0$ 为学习率$\mathbf{g}$ 为小批量梯度下目标函数单步变化被限制为$$|f(\mathbf{x}) - f(\mathbf{x} - \eta\mathbf{g})| \leq L \eta|\mathbf{g}|$$当 $|\mathbf{g}|$ 过大梯度爆炸时一步更新就可能抵消数千轮训练积累的进展训练常常直接发散即使最终收敛也会伴随损失的大幅尖峰而不稳定。把学习率 $\eta$ 调小能限制 $L\eta|\mathbf{g}|$ 且不引入偏差但代价是在所有步骤上都放慢速度只为应对罕见的大梯度事件。更好的启发式是梯度裁剪把梯度投影到半径为 $\theta$ 的球内$$\mathbf{g} \leftarrow \min\left(1, \frac{\theta}{|\mathbf{g}|}\right) \mathbf{g}$$它保证梯度范数不超过 $\theta$同时保持原梯度方向不变还附带一个好处限制了任意单个小批量及其中的样本对参数向量的影响力给模型带来一定鲁棒性。文档也坦率指出它是一个 hack——裁剪后我们并非始终沿真实梯度下降其副作用难以精确分析但由于极其实用在各大深度学习框架的 RNN 实现中被广泛采用。4.3 四个框架的裁剪实现裁剪的关键是把所有模型参数拼成一个巨型向量来计算整体梯度范数。PyTorch 版如下d2l.add_to_class(d2l.Trainer) #save def clip_gradients(self, grad_clip_val, model): params [p for p in model.parameters() if p.requires_grad] norm torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params)) if norm grad_clip_val: for param in params: param.grad[:] * grad_clip_val / normMXNet 版在 d2l/mxnet.py对model.parameters()中每个参数取.grad平方求和开根缩放公式相同TensorFlow 版在 d2l/tensorflow.py额外处理tf.IndexedSlices类型的梯度并在norm grad_clip_val时返回一份新的缩放梯度列表JAX 版在 d2l/jax.py用jax.tree_util.tree_flatten展平梯度树jnp.vdot(x, x)求各叶子内积再用jnp.where与tree_map做无分支的逐元素缩放保持纯函数式风格。4.4 裁剪在训练循环中的位置梯度裁剪由d2l.Trainer.fit_epoch调用完整顺序是先算梯度 → 再裁剪 → 最后用裁剪后的梯度更新参数。PyTorch 版在 d2l/torch.py 中体现为loss.backward() if self.gradient_clip_val 0: # To be discussed later self.clip_gradients(self.gradient_clip_val, self.model) self.optim.step()TensorFlow 版则把裁剪放在apply_gradients之前对梯度列表预处理见 d2l/tensorflow.py。Trainer的构造参数gradient_clip_val0表示默认不裁剪0被当作关闭开关只有显式传入正数时才启用——这正是本节训练代码传入gradient_clip_val1的原因。五、训练在《The Time Machine》上学习字符级语言模型5.1 数据与训练配置data d2l.TimeMachine(batch_size1024, num_steps32) rnn RNNScratch(num_inputslen(data.vocab), num_hiddens32) model RNNLMScratch(rnn, vocab_sizelen(data.vocab), lr1) trainer d2l.Trainer(max_epochs100, gradient_clip_val1, num_gpus1) trainer.fit(model, data)配置要点逐项说明TimeMachine数据集batch_size1024、num_steps32。num_steps是子序列长度即一次反向传播覆盖的时间步数见 language-model.md 的subsec_partitioning-seqs小节TimeMachine.__init__在 d2l/torch.py 中把语料切成corpus[i:inum_steps1]的滑动窗口前num_steps个字符作输入、最后一个字符作目标并默认num_train10000、num_val5000条子序列做训练/验证划分模型尺寸num_inputs len(data.vocab)即词表大小《The Time Machine》预处理后字符词表远小于 ASCII 的 256 个具体数值运行时可查len(data.vocab)num_hiddens32个隐藏单元学习率lr1配合裁剪使用——裁剪让单步更新幅度受限允许相对激进的学习率max_epochs100、gradient_clip_val1梯度范数上界设为 1TensorFlow 分支把模型创建包进with d2l.try_gpu():上下文优先把参数放到可用 GPU 上。5.2 训练循环与困惑度监测trainer.fit→fit_epoch的流程对应 d2l/torch.pyprepare_data构建训练/验证 DataLoader每个 epoch 遍历训练批执行model.training_step(batch)计算损失并绘制训练困惑度→backward→ 梯度裁剪 →optim.step()验证阶段以model.eval()模式计算validation_step绘制验证困惑度曲线。由于RNNLMScratch.training_step里画的是d2l.exp(l)训练看板纵轴直接就是困惑度理想情况下接近 1最坏情况趋近无穷而均匀分布的基线模型困惑度等于词表大小——任何有效模型都必须明显低于这个上界见 language-model.md。读者可自行运行代码观察 100 个 epoch 内困惑度的下降曲线。六、解码用前缀续写文本6.1 语言模型的两种用法训练好的语言模型不仅能预测下一个 token还能把上一步的预测当作下一步的输入自回归式地持续生成。两种典型用法从零生成从文档开头风格的随机种子开始前缀条件生成更常用例如搜索引擎自动补全、邮件写作辅助把用户已输入的内容作为prefix喂入再生成可能的续写。6.2 predict 方法与 warm-up 机制d2l.add_to_class(RNNLMScratch) #save def predict(self, prefix, num_preds, vocab, deviceNone): state, outputs None, [vocab[prefix[0]]] for i in range(len(prefix) num_preds - 1): X d2l.tensor([[outputs[-1]]], devicedevice) embs self.one_hot(X) rnn_outputs, state self.rnn(embs, state) if i len(prefix) - 1: # Warm-up period outputs.append(vocab[prefix[i 1]]) else: # Predict num_preds steps Y self.output_layer(rnn_outputs) outputs.append(int(d2l.reshape(d2l.argmax(Y, axis2), 1))) return .join([vocab.idx_to_token[i] for i in outputs])关键机制拆解Warm-up预热阶段遍历prefix中的字符时只把隐藏状态一路往后传state在每个时间步被更新并复用不产生任何预测输出真实字符依次进入outputs。这保证生成开始前模型已读入了足够上下文自回归阶段i len(prefix) - 1后把上一个预测字符的索引喂回模型对Y沿axis2取argmax得到最可能的下一字符索引并追加到outputs逐字符拼接最后用词表的idx_to_token把索引序列还原成字符串返回。运行示例生成 20 个续写字符model.predict(it has, 20, data.vocab, d2l.try_gpu()) # mxnet / pytorch model.predict(it has, 20, data.vocab) # tensorflow model.predict(it has, 20, data.vocab, trainer.state.params) # jax显式传入参数注意 JAX 版因纯函数式设计需要把trainer.state.params显式传给predict。predict的实现与 d2l/torch.py 中的保存版本逐行对应。6.3 从零实现 vs 高层 API手写 RNN 的过程极具教学价值你能看到每一个矩阵乘法和状态传递但不方便需要自行管理参数初始化、形状转置、梯度裁剪与设备迁移。下一节 chapter_recurrent-neural-networks/rnn-concise.md 将展示如何用框架内置的nn.RNN等标准架构几行代码搭出等价模型并享受高度优化的库函数带来的性能提升——两者在本仓库中的最终形态可对比 d2l/torch.pyRNN高层封装类与 d2l/torch.py本文的RNNScratch。七、总结与进一步思考本节要点回顾一个简易 RNN 语言模型 输入编码one-hot RNN 建模隐藏状态滚动 输出生成线性输出层三段式结构全部手写、无高层循环 APIRNN 的隐藏状态形状(batch_size, num_hiddens)在整个序列传播中保持不变可通过check_shape断言验证梯度裁剪 $\mathbf{g} \leftarrow \min(1, \theta/|\mathbf{g}|),\mathbf{g}$ 能缓解爆炸梯度训练发散、损失尖峰但不解决消失梯度——后者需要 GRU/LSTM 等专门架构训练时遵循算梯度 → 裁剪 → 更新的顺序裁剪阈值由Trainer(gradient_clip_val1)控制基于前缀的生成依赖warm-up 机制前缀阶段只滚动状态不输出之后逐字符自回归可用于自动补全等应用。可动手验证的练习方向源自文档 Exercises 部分检查当前模型是否真正利用了《The Time Machine》最开头的 token这涉及num_steps截断与隐藏状态重置策略调节num_hiddens、num_steps、学习率、epoch 数看困惑度能压到多低把 one-hot 替换成可学习嵌入层对比效果把argmax改成按概率采样如 $q(x_t) \propto P(x_t)^\alpha$$\alpha1$ 可偏向高概率输出观察生成多样性变化关闭梯度裁剪重跑或把tanh换成 ReLU观察训练行为并思考是否还需要裁剪这与爆炸/消失梯度的成因直接相关。赞分享文档教程人工智能深度学习NLP计算机视觉强化学习【免费下载链接】d2l-enInteractive deep learning book with multi-framework code, math, and discussions. Adopted at 500 universities from 70 countries including Stanford, MIT, Harvard, and Cambridge.项目地址https://gitcode.com/gh_mirrors/d2/d2l-en点击查看免费下载相关推荐从零实现循环神经网络(RNN) - d2l-ai项目深度解析从零实现循环神经网络 RNN d2l ai项目深度解析 引言为什么需要循环神经网络 在传统的机器学习模型中我们通常假设输入数据是独立同分布的。然而在处理文档教程人工智能深度学习NLP计算机视觉强化学习从零实现循环神经网络基于《动手学深度学习》的字符级语言模型实战从零实现循环神经网络基于《动手学深度学习》的字符级语言模型实战 本文围绕《动手学深度学习》d2l zh 循环神经网络章节 https://link.git人工智能深度学习机器学习教程MXNet-R 字符级循环神经网络Char RNN语言模型实战从数据处理到逐字符文本采样MXNet R 字符级循环神经网络Char RNN语言模型实战从数据处理到逐字符文本采样 本教程以 MXNet R 包为主体完整演示如何基于 tinys深度学习人工智能机器学习分布式训练上一篇2025终极指南OpenCV文档生成与注释规范实战手册下一篇终极解密如何快速掌握go-zero云原生微服务框架的核心架构创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考