1. 循环神经网络
RNN(循环神经网络)通俗讲解
前面几节我们已经把文字变成了数字、学会了怎么切训练数据。这一节终于要上"真正的模型"了------RNN(循环神经网络) 。这是处理序列数据(文本、语音、时间序列)最经典的神经网络结构。我们先搞懂RNN到底解决了什么问题、核心思想是什么,再逐段看代码。
RNN要解决什么问题?为什么普通神经网络不行?
回忆一下第一节的MLP(多层感知机):输入固定4个数字,输出1个数字。但文本预测有个麻烦------句子长度是不固定的 ,而且离得远的词也可能互相影响(比如"我出生在法国...我的母语是____",要猜空里的词是"法语",得记住前面很远的"法国"这个信息)。
普通神经网络处理不了这种"不定长+需要记住历史"的情况。RNN的解决办法是:引入一个"记忆"(隐藏状态 hidden state),像滚雪球一样,每处理一个新词,就把这个词的信息和之前积累的"记忆"融合,更新出新的记忆,一直往后传递。
核心类比: 想象你在读一本侦探小说,读到第100页时,你脑子里其实存着一个"故事摘要"(记住了前面99页发生的关键情节)。读到第100页新内容时,你会把"新内容"和"脑子里的故事摘要"结合起来,更新出一个"新的故事摘要",然后继续读第101页。这个不断更新的"故事摘要",就是RNN里的隐藏状态(hidden state)。
第一部分:独热编码(One-hot Encoding)
F.one_hot(torch.tensor([0,2]), len(vocab))问题: 前面词表把每个字符变成了一个数字编号(比如"a"=5,"b"=8)。但这些数字编号本身是没有"大小意义"的------编号8不代表比编号5"大"或者"更好",它们只是"标签"。如果直接把编号5、8这样的数字喂给神经网络做矩阵运算,网络会误以为这些数字之间有大小关系,这是错的。
解决办法:独热编码。 把每个编号,转换成一个"只有一个位置是1,其他全是0"的长向量。
比如词表大小是28,编号0就变成:
[1,0,0,0,...0](第0位是1,其余27位是0);编号2就变成:[0,0,1,0,...0](第2位是1)。类比: 就像给每个字符发一张"专属身份证",这张身份证是一长串灯泡,只有属于这个字符的那一盏灯亮着,其他灯都不亮。这样每个字符在网络"眼里"都是完全平等、独立的,不会被误认为有大小/远近关系。
F.one_hot(X.T, 28).shape # torch.Size([5, 2, 28])这里做了个转置
X.T(形状从(批量2,步数5)变成(步数5,批量2)),再做独热编码,得到形状(5, 2, 28)。为什么要把"时间步数"放在最前面? 因为RNN的计算逻辑是"一个时间步一个时间步地处理",把时间步维度放第一位,方便后面直接用
for X in inputs循环,一步步取出每个时间步的数据来处理(马上就会在rnn函数里看到)。第二部分:初始化模型参数(
get_params)
W_xh = normal((num_inputs, num_hiddens)) # 输入 → 隐藏 W_hh = normal((num_hiddens, num_hiddens)) # 隐藏 → 隐藏(这是RNN的关键!) b_h = torch.zeros(num_hiddens) W_hq = normal((num_hiddens, num_outputs)) # 隐藏 → 输出 b_q = torch.zeros(num_outputs)这5个参数是RNN的全部"学习对象",对应3种变换:
W_xh(输入→隐藏): 决定"这一时刻新看到的字符"该如何影响记忆W_hh(隐藏→隐藏): 决定"上一时刻的记忆"该如何延续到这一时刻------这是RNN和普通网络最本质的区别,普通网络没有这种"自己传给自己"的连接W_hq(隐藏→输出): 决定"当前的记忆"该如何转换成"预测下一个字符是什么"的输出类比: 还是"故事摘要"的例子------
W_xh相当于"这一页新内容有多重要,该怎么记下来";W_hh相当于"之前的摘要该保留多少、怎么和新内容融合";W_hq相当于"根据目前的摘要,我该怎么猜接下来会发生什么"。第三部分:RNN的核心计算(
rnn函数)这是全篇最关键的一段代码,一定要吃透:
def rnn(inputs, state, params): W_xh, W_hh, b_h, W_hq, b_q = params H, = state outputs = [] for X in inputs: # 每次取出"一个时间步"的数据 H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h) Y = torch.mm(H, W_hq) + b_q outputs.append(Y) return torch.cat(outputs, dim=0), (H,)这里发生了什么?
inputs是整个序列(比如35个时间步),代码用for X in inputs一步一步遍历,每一步只处理一个时间步的数据:核心公式:
H_新 = tanh(X_当前 · W_xh + H_旧 · W_hh + b_h)这一步在做什么: 拿"当前时间步的输入 X"和"上一时间步传下来的记忆 H_旧",各自做一次线性变换后加起来(再加个偏置b_h),最后套上
tanh激活函数(把结果压缩到-1到1之间,防止数值爆炸,也引入非线性),得到"更新后的记忆 H_新"。然后:
Y = H_新 · W_hq + b_q用这个"刚更新好的记忆",去计算"这一时间步的输出"------也就是"根据目前看到的所有内容,模型猜下一个字符最可能是什么"。
关键点: 这个
H(隐藏状态)在for循环里是不断被覆盖更新、并传递到下一次循环 的------这正是"记忆"在时间步之间流动的具体实现!每处理完一个字符,H就更新一次,带着"到目前为止看过的所有内容的浓缩信息",继续参与下一个字符的计算。类比整个循环: 想象一条传送带,每个时间步传送带上过来一个新字符(X),工人(RNN)拿着"手里的记忆笔记本"(H),把新字符的信息和笔记本上原有的内容融合,更新笔记本(H_新),同时根据更新后的笔记本写一个"预测"(Y),然后带着更新后的笔记本迎接下一个字符。
torch.cat(outputs, dim=0):把所有时间步产生的输出Y,按顺序拼接成一个大张量返回,方便后面统一计算损失。第四部分:把这些函数包装成一个模型类(
RNNModelScratch)
class RNNModelScratch: def __init__(self, ...): self.params = get_params(...) # 初始化参数 self.init_state, self.forward_fn = init_state, forward_fn def __call__(self, X, state): X = F.one_hot(X.T, self.vocab_size).type(torch.float32) # 先独热编码 return self.forward_fn(X, state, self.params) # 再调用rnn函数计算 def begin_state(self, batch_size, device): return self.init_state(batch_size, self.num_hiddens, device) # 返回全零的初始记忆这只是一个"整理箱",把前面写好的
get_params(造参数)、init_rnn_state(造初始记忆,全是0,表示"一开始什么都不记得")、rnn(真正的计算逻辑)三个零件组装到一起,做成一个能直接调用的模型对象。验证形状:
Y.shape, len(new_state), new_state[0].shape # (torch.Size([10, 28]), 1, torch.Size([2, 512]))
Y.shape = (10, 28):10 = 5个时间步 × 2个批量样本(拼在一起了),28是词表大小(每个位置输出"下一个字符是词表里每个字符的可能性得分")new_state[0].shape = (2, 512):2是批量大小,512是隐藏单元数------这就是最后时刻的"记忆"第五部分:预测函数(
predict_ch8)------ 怎么用训练好的模型"写句子"
def predict_ch8(prefix, num_preds, net, vocab, device): state = net.begin_state(batch_size=1, device=device) # 记忆清零,从头开始 outputs = [vocab[prefix[0]]] # 先把用户给的开头第一个字符记下来 get_input = lambda: torch.tensor([outputs[-1]],device=device).reshape(1,1) for y in prefix[1:]: # 把prefix剩下的字符依次"喂"给模型,更新记忆(但不用它的预测结果) _, state = net(get_input(), state) outputs.append(vocab[y]) for _ in range(num_preds): # 开始真正的"自由发挥"生成 y, state = net(get_input(), state) outputs.append(int(y.argmax(dim=1).reshape(1))) # 取模型认为"最可能"的下一个字符 return ''.join([vocab.idx_to_token[i] for i in outputs])这个函数做两件事:
第一步(热身阶段): 把用户提供的开头(比如
"time traveller ")逐字符喂给模型,目的只是为了把这些已知信息"灌"进隐藏状态里,让模型的"记忆"里已经装好了这段开头的内容(这个阶段不需要用模型的输出,因为这些字符是已知的,不用"猜")。第二步(自由生成阶段): 从这里开始,模型每次根据"当前记忆"预测下一个字符最可能是什么(
y.argmax(dim=1)就是找输出向量里得分最高的那个位置,对应哪个字符),然后把这个刚生成的字符当作下一步的新输入,继续生成下一个字符,如此反复,就"写"出了一整段续写文字。类比: 就像手机输入法的联想功能------你打了"今天天气",输入法根据这几个字"猜"你接下来想打"真好",然后你如果接受了"真好",输入法又根据"今天天气真好"接着猜下一个词,一步步往后联想。
训练前的预测(模型是瞎猜的):
predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu()) 'time traveller uupwqaydrq'因为参数还是刚初始化的随机值,所以生成的后续内容是乱码,完全没有意义------这验证了"模型能跑起来",但还没学到任何语言规律。
第六部分:训练技巧------梯度裁剪(
grad_clipping)
norm = torch.sqrt(sum(torch.sum((p.grad**2)) for p in params)) if norm > theta: for param in params: param.grad[:] *= theta / norm问题: RNN因为要把"记忆"沿着时间步不断传递(前面讲的
for X in inputs循环),在反向传播算梯度时,梯度也要沿着这条链条一路传回去。如果序列很长,这个梯度经过很多次连续相乘,很容易变得极其巨大(梯度爆炸)或极其微小(梯度消失)。梯度一旦爆炸,参数更新的步子会迈得特别大,直接把训练"带崩"。解决办法: 算出所有参数梯度合在一起的"总体大小"(
norm,就像算一个向量的长度),如果这个总体大小超过了一个阈值theta,就按比例把所有梯度整体缩小 ,让它的总体大小刚好等于theta。类比: 就像开车时,如果发现油门踩得太猛(车速要失控了),赶紧按比例把油门松一松,让车速回到安全范围内,但不改变"往哪个方向开"(只缩小步子大小,不改变梯度方向)。这是训练RNN几乎必备的一个技巧。
第七部分:训练一轮的过程(
train_epoch_ch8)
for X, Y in train_iter: if state is None or use_random_iter: state = net.begin_state(...) # 重新开始记忆(清零) else: state.detach_() # 保留记忆的数值,但切断"计算图"的历史连接 y = Y.T.reshape(-1) y_hat, state = net(X, state) l = loss(y_hat, y.long()).mean() ...这里有个容易让人懵的细节:
state.detach_()是干什么的?回忆前面讲的"顺序分区"采样方法------相邻两个batch在原文里是紧挨着的,所以我们希望隐藏状态(记忆)能延续到下一个batch,不要每个batch都重新清零(不然就白白浪费了"顺序分区"特意保留的连续性)。
但是,如果直接把上一个batch算出来的
state原封不动地传给下一个batch,PyTorch会记得这个state是"由第一个batch的所有计算一步步得到的",反向传播时会试图把梯度一路传回第一个batch,这样计算图会越滚越大,占用内存爆炸、速度也越来越慢。
detach_()的作用是:保留state当前的数值(记忆的内容还在),但把它和"之前是怎么计算出来的"这段历史"剪断",让它看起来就像是一个全新的、凭空冒出来的初始值。这样下一个batch在反向传播时,只需要往回传播"这一个batch自己的计算",不会牵扯到更早的历史。类比: 就像你读书读到第100页时,脑子里记得"故事内容"(数值保留),但你不需要记得"我是怎么一字一句读到这里的每一个细节过程"(计算过程剪断)------你只需要带着"当前的理解"继续往下读,不需要每次都从头回顾一遍阅读过程。
第八部分:损失函数与困惑度(Perplexity)
loss = nn.CrossEntropyLoss() ... return math.exp(metric[0]/metric[1]), ...交叉熵损失(CrossEntropyLoss): 这是分类任务最常用的损失函数。这里本质上是把"预测下一个字符"看成一个分类问题------词表里有28个字符,每次都要从这28个类别里"猜"哪个是正确答案,交叉熵衡量的就是"模型给正确答案打的概率分数有多低"(分数越低说明模型越自信且猜对了,loss就越小)。
困惑度(Perplexity, ppl): 代码里对loss取了个指数
math.exp(...),这就是"困惑度",是语言模型专用的评价指标。怎么理解困惑度? 可以粗略地理解为:"模型在猜下一个字符时,大概像在几个选项里随机选一样犹豫不定"。困惑度=1,表示模型几乎100%确定下一个字符是什么(完全不"困惑");困惑度越大,表示模型越"迷茫"、猜测的不确定性越高。所以困惑度越低,说明模型学得越好。
训练完之后打印出
困惑度 1.0或1.3,说明模型已经把这本小说的字符规律学得相当好了(几乎能确定地猜出下一个字符)。第九部分:训练完之后的效果对比
顺序分区训练后:
time traveller for so it will be convenient to speak of himwas e这句话已经非常接近原文了("time traveller for so it will be convenient to speak of him"正是原文中真实出现过的句子)!说明模型确实学会了这段文本的字符级规律。
随机采样训练后:
time travellerit s against reason said filbywhat thatlly i so di效果也不错,但因为随机采样时batch之间没有连续性(记忆经常被清零重来),学习效率和最终效果通常会比顺序分区稍差一点点。
第十部分:用PyTorch框架简化实现(
nn.RNN)前面手写的
get_params、rnn函数,本质上PyTorch已经内置好了,直接调用nn.RNN就行,不用自己写公式:
rnn_layer = nn.RNN(len(vocab), num_hiddens)这一行就自动包含了前面手写的所有参数(
W_xh, W_hh, b_h等)和计算逻辑(tanh那个公式),全部封装好了。
Y, state_new = rnn_layer(X, state) Y.shape, state_new.shape # (torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))注意:这里
nn.RNN直接返回的Y形状是(35, 32, 256)------这里的256是隐藏层大小,不是词表大小! 这说明nn.RNN这一层本身只负责计算"记忆的演变" ,还没有做"记忆→输出预测"这最后一步(对应手写版里的W_hq那一步)。所以还需要额外接一个nn.Linear层,把隐藏状态转换成"预测词表里每个字符的得分"。这也是为什么下面要单独包装一个RNNModel类。第十一部分:
RNNModel类------把nn.RNN和输出层拼起来
class RNNModel(nn.Module): def __init__(self, rnn_layer, vocab_size, **kwargs): super(RNNModel, self).__init__(**kwargs) self.rnn = rnn_layer self.linear = nn.Linear(self.num_hiddens, self.vocab_size) # 补上"记忆→输出"这一步 def forward(self, inputs, state): X = F.one_hot(inputs.T.long(), self.vocab_size).to(torch.float32) # 独热编码 Y, state = self.rnn(X, state) # 用框架自带的RNN算出记忆的演变 output = self.linear(Y.reshape((-1, Y.shape[-1]))) # 再用线性层把记忆转换成预测 return output, state这和手写版本的逻辑完全一样,只是把"记忆的更新计算"这部分交给PyTorch内置的
nn.RNN(速度更快、经过底层优化),自己只需要额外补一个线性层做最后的输出转换。为什么要
Y.reshape((-1, Y.shape[-1]))? 因为Y的形状是(时间步数, 批量大小, 隐藏层大小),而线性层需要的输入是"一堆样本,每个样本是一个向量",所以把前两维"时间步×批量"压扁成一维,变成(时间步数×批量大小, 隐藏层大小),这样才能批量地喂给nn.Linear统一计算。
begin_state函数额外处理了LSTM的情况(LSTM是RNN的一个更高级变种,会在后面章节学到,它需要两个状态而不是一个,这里先不用深究,只需要知道这是为了兼容以后要学的LSTM)。整体串起来看这一节的核心
- 独热编码:把字符的"编号"转换成网络能安全处理的向量形式
- RNN的核心公式
H_新 = tanh(X·W_xh + H_旧·W_hh + b_h):这是全篇最重要的一行,实现了"记忆"随时间步不断更新、传递的机制- 梯度裁剪:防止训练时因为长序列梯度爆炸而崩溃
- 顺序分区 + detach:让"记忆"能跨batch延续,同时避免反向传播计算图无限增长
- 困惑度:衡量语言模型好坏的专用指标,越低越好
- 手写版 vs 框架版 :两者算法完全一致,框架版只是把重复的底层公式用
nn.RNN封装好了,实际写论文/项目时通常直接用框架版即可,但理解手写版有助于真正搞懂RNN内部在干什么如果之后要看LSTM或GRU(RNN的改良版,用来解决"长序列记不住早期信息"的问题),这一节的隐藏状态
H的概念是完全通用的基础,吃透了这里,后面会轻松很多。








1. RNN的实现(不用框架)
python
%matplotlib inline
import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
# 定义批量大小和时间步数
batch_size, num_steps = 32, 35
# 加载时间机器数据并创建词汇表
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
python
# 独热编码
# 打印词汇表的大小
print(len(vocab))
# 使用独热编码将 [0, 2] 表示的物体下标转换为独热向量,其中0表示第一个元素,2表示第3个元素
F.one_hot(torch.tensor([0,2]),len(vocab)) # [0,2] 表示物体下标,0表示第一个元素,2表示第3个元素
28
tensor([[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0]])
python
# 小批量形状是(批量大小,时间步数)
# 创建一个张量,形状为(2, 5),表示批量大小为2,时间步数为5
X = torch.arange(10).reshape((2,5))
# 对X的转置进行独热编码,其中28表示编码长度,返回独热编码后的形状
F.one_hot(X.T, 28).shape
torch.Size([5, 2, 28])
python
# 初始化循环神经网络模型的模型参数
def get_params(vocab_size, num_hiddens, device):
# 设置输入和输出的维度为词汇表大小
num_inputs = num_outputs = vocab_size
# 定义normal函数用于生成服从正态分布的随机张量,并乘以0.01进行缩放
def normal(shape):
return torch.randn(size=shape, device=device) * 0.01
# 初始化模型参数
# 输入到隐藏层的权重矩阵,形状为(词汇表大小, 隐藏单元个数)
W_xh = normal((num_inputs, num_hiddens))
# 隐藏层到隐藏层的权重矩阵,形状为(隐藏单元个数, 隐藏单元个数)
W_hh = normal((num_hiddens, num_hiddens))
# 隐藏层的偏置向量,形状为(隐藏单元个数,)
b_h = torch.zeros(num_hiddens, device=device)
# 隐藏层到输出层的权重矩阵,形状为(隐藏单元个数, 词汇表大小)
W_hq = normal((num_hiddens, num_outputs))
# 输出层的偏置向量,形状为(词汇表大小,)
b_q = torch.zeros(num_outputs, device=device)
# 将所有参数放入列表中
params = [W_xh, W_hh, b_h, W_hq, b_q]
# 遍历所有参数
for param in params:
# 设置参数的requires_grad为True,用于梯度计算
param.requires_grad_(True)
# 返回模型的参数
return params
python
# 一个init_rnn_state函数在初始化时返回隐藏状态
def init_rnn_state(batch_size, num_hiddens, device):
# 返回一个包含隐藏状态的元组,元组中的唯一元素是一个形状为(批量大小, 隐藏单元个数)的全零张量
return (torch.zeros((batch_size, num_hiddens), device=device),)
python
# 下面的rnn函数定义了如何在一个时间步计算隐藏状态和输出
def rnn(inputs, state, params):
# 从参数元组中解包获取输入到隐藏层的权重矩阵 W_xh,
# 隐藏层到隐藏层的权重矩阵 W_hh,
# 隐藏层的偏置向量 b_h,
# 隐藏层到输出层的权重矩阵 W_hq,
# 输出层的偏置向量 b_q
W_xh, W_hh, b_h, W_hq, b_q = params
# 从状态元组中解包获取隐藏状态 H
# 注意这里使用逗号是为了确保 H 为一个元组
H, = state
# 创建一个空列表用于存储输出
outputs = []
# 对于输入序列中的每个输入 X
# 输入序列通常是一个时间步的数据,可以是单个时间步的特征向量或者是嵌入向量
for X in inputs:
# 计算新的隐藏状态 H,使用双曲正切函数作为激活函数
# 根据当前输入 X、上一时间步的隐藏状态 H、以及权重矩阵和偏置向量来计算
H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
# 计算输出 Y,通过隐藏状态 H 与权重矩阵 W_hq 相乘并加上偏置向量 b_q 得到
Y = torch.mm(H, W_hq) + b_q
# 将输出 Y 添加到输出列表中
outputs.append(Y)
# 将输出列表中的输出张量沿着行维度进行拼接,得到一个形状为 (时间步数 * 批量大小, 输出维度) 的张量
# 返回拼接后的输出张量和最后一个时间步的隐藏状态 H
return torch.cat(outputs, dim=0), (H,)
python
# 创建一个类来包装这些函数
class RNNModelScratch:
# 初始化模型参数
def __init__(self, vocab_size, num_hiddens, device, get_params,
init_state, forward_fn):
# 保存词汇表大小和隐藏单元个数作为类的属性
self.vocab_size, self.num_hiddens = vocab_size, num_hiddens
# 调用 get_params 函数初始化模型的参数,并保存为类的属性
# 参数包括输入到隐藏层的权重矩阵、隐藏层到隐藏层的权重矩阵、隐藏层的偏置向量、隐藏层到输出层的权重矩阵、输出层的偏置向量
self.params = get_params(vocab_size, num_hiddens, device)
# 初始化隐藏状态的函数和前向传播函数
self.init_state, self.forward_fn = init_state, forward_fn
def __call__(self, X, state):
# 将输入序列 X 进行独热编码,形状为 (时间步数, 批量大小, 词汇表大小)
# 并将数据类型转换为浮点型
X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
# 调用前向传播函数进行模型计算,并返回输出
return self.forward_fn(X, state, self.params)
def begin_state(self, batch_size, device):
# 返回初始化的隐藏状态,用于模型的初始时间步
return self.init_state(batch_size, self.num_hiddens, device)
python
# 检查输出是否具有正确的形状
# 设置隐藏单元个数为 512
num_hiddens = 512
# 创建一个 RNNModelScratch 的实例 net,指定词汇表大小、隐藏单元个数、设备、获取参数函数、初始化隐藏状态函数和前向传播函数
net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,
init_rnn_state, rnn)
# 获取模型的初始隐藏状态,输入的批量大小为 X 的行数,设备使用与 X 相同的设备
state = net.begin_state(X.shape[0], d2l.try_gpu())
# 使用输入 X 和初始隐藏状态进行前向传播计算,得到输出张量 Y 和更新后的隐藏状态 new_state
# 将输入和状态都移动到与 X 相同的设备上进行计算
Y, new_state = net(X.to(d2l.try_gpu()),state)
# 输出 Y 的形状,new_state 的长度(即元素个数)和 new_state 中第一个元素的形状
Y.shape, len(new_state), new_state[0].shape
(torch.Size([10, 28]), 1, torch.Size([2, 512]))
python
# 首先定义预测函数来生成用户提供的prefix之后的新字符
def predict_ch8(prefix, num_preds, net, vocab, device):
"""在 'prefix' 后面生成新字符。"""
# 获取模型的初始隐藏状态,批量大小为 1,设备为指定的设备
state = net.begin_state(batch_size=1, device=device)
# 将 prefix 的第一个字符索引添加到输出列表中
outputs = [vocab[prefix[0]]]
# 定义一个函数 get_input,用于获取输入序列的张量表示
# 输入序列只包含一个字符,将该字符的索引转换为张量,并进行形状调整为 (1, 1)
get_input = lambda: torch.tensor([outputs[-1]],device=device).reshape(1,1)
# 对于 prefix 中除第一个字符之外的每个字符 y
for y in prefix[1:]:
# 使用当前输入字符和隐藏状态进行前向传播计算,得到输出和更新后的隐藏状态
_, state = net(get_input(), state)
# 将字符 y 的索引添加到输出列表中
outputs.append(vocab[y])
# 生成指定数量的新字符
for _ in range(num_preds):
# 使用当前输入字符和隐藏状态进行前向传播计算,得到输出和更新后的隐藏状态
y, state = net(get_input(), state)
# 将输出张量中概率最大的字符索引添加到输出列表中
outputs.append(int(y.argmax(dim=1).reshape(1)))
# 将输出列表中的字符索引转换为对应的字符,并拼接成一个字符串返回
return ''.join([vocab.idx_to_token[i] for i in outputs])
# 生成以 'time traveller ' 为前缀的 10 个新字符
# 注意:由于模型尚未训练,这里的预测结果是随机初始化后的预测
predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu())
'time traveller uupwqaydrq'
① 梯度裁剪

python
def grad_clipping(net, theta):
"""裁剪梯度。"""
# 如果 net 是 nn.Module 的实例(即使用 PyTorch 构建的模型)
if isinstance(net, nn.Module):
# 获取所有需要计算梯度的参数列表
params = [p for p in net.parameters() if p.requires_grad]
# 如果 net 是自定义的模型(例如上述的 RNNModelScratch)
else:
# 获取自定义模型的参数列表
params = net.params
# 计算参数梯度的范数,即所有参数梯度平方和的平方根
norm = torch.sqrt(sum(torch.sum((p.grad**2)) for p in params))
# 如果梯度范数超过指定阈值 theta
if norm > theta:
# 对于每个参数
for param in params:
# 将参数的梯度值裁剪至指定范围内,保持梯度范数不超过 theta
param.grad[:] *= theta / norm
python
# 定义一个函数来训练只有一个迭代周期的模型
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
"""训练模型一个迭代周期"""
# 初始化隐藏状态和计时器
state, timer = None, d2l.Timer()
# 初始化度量指标的累加器,用于计算损失和样本数量
metric = d2l.Accumulator(2)
# 遍历训练迭代器中的每个批次数据
for X, Y in train_iter:
# 如果隐藏状态为空或使用随机迭代器
if state is None or use_random_iter:
# 初始化隐藏状态,批量大小为 X 的行数,设备为指定的设备
state = net.begin_state(batch_size=X.shape[0],device=device)
else:
# 如果 net 是 nn.Module 的实例且隐藏状态不是元组类型
if isinstance(net, nn.Module) and not isinstance(state, tuple):
# 分离隐藏状态的计算图
state.detach_()
else:
# 对于隐藏状态中的每个元素
for s in state:
# 分离隐藏状态的计算图,用于减少内存占用和加速计算
s.detach_()
# 将目标序列 Y 转置并展平为一维张量
y = Y.T.reshape(-1)
# 将输入序列和目标序列移动到指定的设备上
X, y = X.to(device), y.to(device)
# 使用输入序列和隐藏状态进行前向传播计算,得到预测值和更新后的隐藏状态
y_hat, state = net(X, state)
# 计算预测值与目标值之间的损失
l = loss(y_hat, y.long()).mean()
# 如果使用 PyTorch 内置的优化器
if isinstance(updater, torch.optim.Optimizer):
# 清空优化器中的梯度
updater.zero_grad()
# 反向传播计算梯度
l.backward()
# 裁剪梯度
grad_clipping(net,1)
# 执行一步参数更新
updater.step()
else:
# 反向传播计算梯度
l.backward()
# 裁剪梯度
grad_clipping(net,1)
# 执行自定义的参数更新函数
updater(batch_size=1)
# 累加损失和样本数量
metric.add(l * y.numel(), y.numel())
# 计算平均损失和每秒处理的样本数,返回平均损失的指数形式(以 e 为底)和每秒样本处理速度
return math.exp(metric[0]/metric[1]), metric[1]/timer.stop()
python
# 训练函数支持从零开始或使用高级API实现的循环神经网络模型
def train_ch8(net, train_iter, vocab, lr, num_epochs, device, use_random_iter=False):
"""训练模型"""
# 定义损失函数为交叉熵损失
loss = nn.CrossEntropyLoss()
# 创建动画对象,用于可视化训练过程的损失变化
animator = d2l.Animator(xlabel='epoch', ylabel='perplexity', legend=['train'],xlim=[10,num_epochs])
# 如果模型是 nn.Module 的实例
if isinstance(net, nn.Module):
# 使用 PyTorch 的优化器 SGD 进行参数更新
updater = torch.optim.SGD(net.parameters(), lr)
else:
# # 否则,使用自定义的梯度下降函数进行参数更新
updater = lambda batch_size: d2l.sgd(net.params, lr, batch_size)
# 定义一个预测函数,用于生成给定前缀之后的新字符序列
predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)
# 遍历每个迭代周期
for epoch in range(num_epochs):
# 训练一个迭代周期,并返回困惑度和每秒样本处理速度
ppl, speed = train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter)
# 每隔 10 个迭代周期生成
if (epoch + 1) % 10 == 0:
# 打印以 'time traveller' 为前缀的新字符序列
print(predict('time traveller'))
# 将当前迭代周期的困惑度添加到动画中进行可视化
animator.add(epoch + 1, [ppl])
# 打印最终的困惑度和每秒样本处理速度
print(f'困惑度 {ppl:.1f}, {speed:.1f} 标记/秒 {str(device)}')
# 生成并打印以 'time traveller' 为前缀的新字符序列
print(predict('time traveller'))
# 生成并打印以 'traveller' 为前缀的新字符序列
print(predict('traveller'))
python
# 现在我们可以训练循环神经网络模型
# 设置迭代周期数和学习率
num_epochs, lr = 500, 1
# 调用训练函数进行模型训练,使用训练数据迭代器、词汇表、学习率、迭代周期数和设备信息作为输入
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu())
困惑度 1.0, 74455.3 标记/秒 cuda:0
time traveller for so it will be convenient to speak of himwas e
traveller with a slight accession ofcheerfulness really thi

python
# 最后,让我们检查一下使用随即抽样方法的结果
# 调用训练函数进行模型训练,使用训练数据迭代器、词汇表、学习率、迭代周期数、设备信息和随机抽样标志位作为输入
# 设置 use_random_iter 参数为 True,表示使用随机抽样方法进行训练
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu(), use_random_iter=True)
困惑度 1.3, 69106.4 标记/秒 cuda:0
time travellerit s against reason said filbywhat thatlly i so di
travellerit s against reason said filbywhat thatlly i so di

2. RNN的实现(用框架)
python
# 导入 PyTorch 库
import torch
# 导入 nn 模块,用于定义神经网络模型的基类
from torch import nn
# 导入 functional 模块,用于定义神经网络模型中的激活函数等功能
from torch.nn import functional as F
# 导入 d2l.torch 模块,包含了与深度学习相关的工具函数和类
from d2l import torch as d2l
# 设置批量大小和时间步数
batch_size, num_steps = 32, 35
# 调用 load_data_time_machine 函数加载时间机器数据集,返回训练数据迭代器和词汇表
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
python
# 定义模型
# 设置隐藏单元的数量为 256
num_hiddens = 256
# 使用 nn.RNN 类定义一个循环神经网络层
# 输入大小为词汇表的大小,隐藏单元数量为 num_hiddens
# 将该循环神经网络层赋值给 rnn_layer
rnn_layer = nn.RNN(len(vocab), num_hiddens)
python
# 使用张量来初始化隐藏状态
# 创建一个形状为 (1, batch_size, num_hiddens) 的张量,用于初始化隐藏状态
# 全部元素初始化为 0
# 将该张量赋值给变量 state
state = torch.zeros((1, batch_size, num_hiddens))
# 打印隐藏状态张量的形状
state.shape
torch.Size([1, 32, 256])
python
# 通过一个隐藏状态和一个输入,我们可以用更新后的隐藏状态计算输出
# 创建一个形状为 (num_steps, batch_size, len(vocab)) 的随机张量 X
# 用于表示输入的序列,每个时间步的输入为一个词汇表大小的独热编码向量
X = torch.rand(size=(num_steps, batch_size, len(vocab)))
# 将输入 X 和初始隐藏状态 state 作为输入传递给循环神经网络层 rnn_layer 进行前向计算
# 返回输出张量 Y 和更新后的隐藏状态 state_new
Y, state_new = rnn_layer(X, state)
# 打印输出张量 Y 和更新后的隐藏状态 state_new 的形状
Y.shape, state_new.shape
(torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))
python
# 我们为一个完整的循环神经网络模型定义一个RNNModel类
class RNNModel(nn.Module):
"""循环神经网络模型"""
# 初始化函数
def __init__(self, rnn_layer, vocab_size, **kwargs):
# 调用父类的构造函数,初始化继承的属性
super(RNNModel, self).__init__(**kwargs)
# 循环神经网络层
self.rnn = rnn_layer
# 词汇表大小
self.vocab_size = vocab_size
# 隐藏状态的大小
self.num_hiddens = self.rnn.hidden_size
# 如果循环神经网络不是双向的
if not self.rnn.bidirectional:
# 方向数量为1
self.num_directions = 1
# 线性层的输入大小为隐藏状态大小,输出大小为词汇表大小
self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
# 如果循环神经网络是双向的
else:
# 方向数量为2
self.num_directions = 2
# 线性层的输入大小为隐藏状态大小的两倍,输出大小为词汇表大小
self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)
# 前项传播函数
def forward(self, inputs, state):
# 将输入的索引序列转换为独热编码张量 X
X = F.one_hot(inputs.T.long(), self.vocab_size)
# 将 X 转换为 float32 类型
X = X.to(torch.float32)
# 使用循环神经网络层 rnn 进行前向计算,返回输出张量 Y 和更新后的隐藏状态 state
Y, state = self.rnn(X, state)
# 将输出张量 Y 展平并通过线性层 linear 进行变换得到最终的输出
output = self.linear(Y.reshape((-1, Y.shape[-1])))
# 返回最终输出和更新后的隐藏状态
return output, state
# 创建循环神经网络的初始隐藏状态
def begin_state(self, device, batch_size=1):
# 如果循环神经网络不是LSTM类型
if not isinstance(self.rnn, nn.LSTM):
# 创建全零的隐藏状态张量
return torch.zeros((self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens),
device=device)
# 如果循环神经网络是LSTM类型
else:
# 创建全零的隐藏状态张量和记忆单元张量
# 第一个张量是全零的隐藏状态张量,第二个张量是全零的记忆单元张量
return (torch.zeros((self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens),
device=device),
torch.zeros((self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens),
device=device))
python
# 用一个具有随即权重的模型进行预测
# 尝试使用GPU设备,如果不可用则使用CPU
device = d2l.try_gpu()
# 创建RNN模型实例
net = RNNModel(rnn_layer, vocab_size=len(vocab))
# 将模型移动到指定设备上
net = net.to(device)
# 对模型进行预测,生成文本
d2l.predict_ch8('time traveller', 10, net, vocab, device)
'time travellerrrrrrrrrrr'
python
# 使用高级API训练模型
# 设置训练的迭代周期数和学习率
num_epochs, lr = 500, 1
# 使用高级API训练模型,传入模型、训练数据迭代器、词汇表、学习率和迭代周期数进行训练
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)
perplexity 1.3, 270684.1 tokens/sec on cuda:0
time travelleryon oug heally a chifily exper and have at atw yo
travelleryon o have e tern whis vewy cand scascolleredit to
