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的实现(不用框架)
%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)# 独热编码 # 打印词汇表的大小 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]])
# 小批量形状是(批量大小,时间步数) # 创建一个张量,形状为(2, 5),表示批量大小为2,时间步数为5 X = torch.arange(10).reshape((2,5)) # 对X的转置进行独热编码,其中28表示编码长度,返回独热编码后的形状 F.one_hot(X.T, 28).shapetorch.Size([5, 2, 28])
# 初始化循环神经网络模型的模型参数 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# 一个init_rnn_state函数在初始化时返回隐藏状态 def init_rnn_state(batch_size, num_hiddens, device): # 返回一个包含隐藏状态的元组,元组中的唯一元素是一个形状为(批量大小, 隐藏单元个数)的全零张量 return (torch.zeros((batch_size, num_hiddens), device=device),)# 下面的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,)# 创建一个类来包装这些函数 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)# 检查输出是否具有正确的形状 # 设置隐藏单元个数为 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]))
# 首先定义预测函数来生成用户提供的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'
① 梯度裁剪
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# 定义一个函数来训练只有一个迭代周期的模型 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()# 训练函数支持从零开始或使用高级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'))# 现在我们可以训练循环神经网络模型 # 设置迭代周期数和学习率 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
# 最后,让我们检查一下使用随即抽样方法的结果 # 调用训练函数进行模型训练,使用训练数据迭代器、词汇表、学习率、迭代周期数、设备信息和随机抽样标志位作为输入 # 设置 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的实现(用框架)
# 导入 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)# 定义模型 # 设置隐藏单元的数量为 256 num_hiddens = 256 # 使用 nn.RNN 类定义一个循环神经网络层 # 输入大小为词汇表的大小,隐藏单元数量为 num_hiddens # 将该循环神经网络层赋值给 rnn_layer rnn_layer = nn.RNN(len(vocab), num_hiddens)# 使用张量来初始化隐藏状态 # 创建一个形状为 (1, batch_size, num_hiddens) 的张量,用于初始化隐藏状态 # 全部元素初始化为 0 # 将该张量赋值给变量 state state = torch.zeros((1, batch_size, num_hiddens)) # 打印隐藏状态张量的形状 state.shapetorch.Size([1, 32, 256])
# 通过一个隐藏状态和一个输入,我们可以用更新后的隐藏状态计算输出 # 创建一个形状为 (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]))
# 我们为一个完整的循环神经网络模型定义一个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))# 用一个具有随即权重的模型进行预测 # 尝试使用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'
# 使用高级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