深度学习28RNN循环神经网络

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.01.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_paramsrnn函数,本质上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)。

整体串起来看这一节的核心

  1. 独热编码:把字符的"编号"转换成网络能安全处理的向量形式
  2. RNN的核心公式 H_新 = tanh(X·W_xh + H_旧·W_hh + b_h):这是全篇最重要的一行,实现了"记忆"随时间步不断更新、传递的机制
  3. 梯度裁剪:防止训练时因为长序列梯度爆炸而崩溃
  4. 顺序分区 + detach:让"记忆"能跨batch延续,同时避免反向传播计算图无限增长
  5. 困惑度:衡量语言模型好坏的专用指标,越低越好
  6. 手写版 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
相关推荐
2301_8184744418 分钟前
福建中小微企业云 ERP 落地实践:轻量化信息化项目实施指南
大数据·人工智能
咕泡科技18 分钟前
咕泡科技×创业酵母俞头私享会:AI时代,组织如何长出“破局力”?
大数据·人工智能·科技
啊阿狸不会拉杆20 分钟前
《自然语言处理:基于大语言模型的方法》第1章 绪论 读书笔记
人工智能·自然语言处理·nlp·easyui·智能体
我是大AI21 分钟前
实战解析:基于多源交叉验证的AI幻觉治理架构与GEO行业解决方
人工智能·架构
LTD营销SaaS23 分钟前
22站点智能成功申请“AI 创建业务型网站”发明专利
人工智能·ai建站·站点智能·22集团·ai创建业务型网站
EQUINOX127 分钟前
【论文精读】| LLaVA
论文阅读·人工智能·深度学习
2601_9578793327 分钟前
电商在用什么 AI 视频创作工具?2026年从“做视频”到“做素材库”的工具变化
大数据·人工智能
yangdaxiageo27 分钟前
AI搜索广告的商业化底座:GEO技术架构的三层模型详解
人工智能·架构
QZSJTR43 分钟前
2026年AI搜索引擎技术原理与GEO优化实战:从RAG到AI Agent
人工智能·搜索引擎·chatgpt