学习RNN(简洁实现)

模型介绍:公式

这里意思是 在循环卷积网络中当前神经元的输出只与xt-1以及ht-1有关,然后计算损失的时候,是将ot与xt进行比较,因为模型的下一个输出就是序列的下一个

不是n元语法,因为这里的因变量ht实际上包含了前面任意长序列的信息,不只是几元。

吴恩达老师那里课件的公式是:h_t = f(W_hh * h_t-1 + W_hx * x_t + b_h) (强推他的讲解)

这里的t不要理解成下标,理解成时间,结合应用你要根据前面一个字(t-1),预测下一个字(t),下标不是重点,标的不同解释不同而已,重要的是结合老师前面那个例子来理解模型

反正理解李沐老师想表达的意思就行,把最下面一行都往右边移动一步 就是和吴恩达老师一致的了

衡量好坏标准:困惑度

t是要预测的序列,i是一共有多少种词组分类选择的下标

  • 困惑度 exp(π):这是对交叉熵取指数,目的是把"信息量(比特/纳特)"还原成"猜测的概率(分支数)",让指标更符合人类直觉。

  • 1表示完美:当模型预测完全准确时,p=1,log(1)=0,exp(0)=1。

困惑度1表示完美,因为意味着平均交叉熵的值为0,log(p)=0, p=1

最好就是n个p都尽可能趋近于1,最差就是n个p全都趋近于0

exp就是取指数 这样数值比较大

什么是"长尾分布"(一句话)

在很多真实系统里:

  • 头部:少数高频词/句式出现千万次("的/是/我/你...")

  • 尾巴 :海量低频词/稀有实体/边缘情况各自只出现几次甚至一次("某化学名词""某地名拼写""罕见 bug 句子")

画出来就是一条很陡的曲线,然后拖着一条很长很长的尾巴------这就是长尾

梯度爆炸->裁剪:重要

深度学习训练过程中的一种常见现象------梯度爆炸 ,以及对应的解决方法------梯度裁剪。为了让你更容易理解,我们可以跳过复杂的数学公式,用大白话来拆解这三个核心点:

  1. 为什么训练会出问题?
  • 现象 :在处理长文本或长时间序列时(比如一句话有100个字),计算机需要一步步反向传播误差。这个过程就像是一个传话游戏,信息要传100遍。

  • 后果 :在传递过程中,数字很容易像滚雪球一样越滚越大(即文字中提到的"数值不稳定"),导致下一次更新模型参数时用力过猛,模型直接"崩溃"或训练失败。这在学术上叫梯度爆炸

  1. 怎么解决?(公式部分)

为了解决这个问题,人们想了一个非常直观的办法,就像图片里写的:"如果梯度长度超过 θ,那么拖影回长度 θ"

  • 梯度的物理意义 :你可以把"梯度"想象成一辆车的行驶速度

  • 限制速度 :θ就是一个设定的最高限速

  • 操作逻辑:如果车子开得太快(梯度太大),我们就强制踩刹车,把速度降到最高限速 θ(裁剪);如果车子开得慢(梯度正常),就不管它,照常行驶。

  1. 具体的数学动作

图片下方的公式 g←min(1,∥g∥θ​)g就是在执行上面的逻辑:

  • 它算出了当前的速度(∥g∥)和限速(θ)的比例。

  • 如果比例大于1(超速了),就按比例缩小当前的速度,保证缩小后的最大速度刚好等于 θ。

  • 这样既能保留前进的方向,又保证了不会因为速度太快而冲出跑道。

简单来说,这段话的核心意思是:为了防止电脑在更新模型时用力过猛,我们设定了一个速度上限,一旦超过这个上限就强行按比例缩小,从而保证训练过程平稳进行。

小结:

简洁实现:

dir(xx):查看该东西所有可用属性和方法

特殊方法(用来支持操作)这些是 Python 内部用来支持特定语法的"幕后功臣":

  • __len__ :这就是你代码里 len(vocab)背后调用的东西,返回词典的大小(不包含特殊符号如 padding 的话可能少几个)。

  • __getitem__ :支持直接用方括号取词,比如 vocab['hello']能直接返回 'hello' 对应的 id。

python 复制代码
# 设置RNN隐藏单元数为256
num_hiddens = 256

# 创建一个单层RNN层,输入维度为词汇表大小(len(vocab)),隐藏层大小为256
rnn_layer = nn.RNN(len(vocab), num_hiddens)

"""nn.RNN是 PyTorch 内置的单层 RNN 模块。
输入维度 = 词汇表大小(len(vocab)),即每个字符用一个 one‑hot 向量表示。
隐藏单元数 = 256,控制记忆容量。
rnn_layer 结果RNN(28, 256)
"""



# 初始化隐状态:形状为 (1, batch_size, num_hiddens)
# 1表示只有一层RNN,batch_size是批大小,num_hiddens是隐藏单元数
state = torch.zeros((1, batch_size, num_hiddens))

state.shape  # 输出形状,验证正确性
结果 torch.Size([1, 32, 256])
#初始化隐状态 state,形状 (层数, 批量大小, 隐藏单元数)。这里层数为1,所以第一个维度是1。




# 构造一个随机输入张量,模拟序列数据
# 形状:(num_steps, batch_size, len(vocab)) ------ 时间步数×批量大小×词表大小
X = torch.rand(size=(num_steps, batch_size, len(vocab)))

# 将输入X和初始状态state传入RNN层,得到输出Y和新状态state_new
Y, state_new = rnn_layer(X, state)

# 查看输出Y的形状:(num_steps, batch_size, num_hiddens)
# 新状态state_new的形状:(1, batch_size, num_hiddens) ------ 最后一层的最终隐状态



Y.shape, state_new.shape
结果(torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))


"""这里Y并不是输出 而是最后一个隐藏层 所以这里的维度是256 而不是len(vocab)
Y是每个时间步的隐状态,这些隐状态可以用作后续输出层的输入 并不是输出 
Y是所有h的集合,rnn循环了35次(时间步35),产生了35个h"""

"""模拟一个随机输入 X:时间步数 × 批量大小 × 词表大小。
经过 RNN 层后:
Y形状 (num_steps, batch_size, num_hiddens):每个时间步的输出(最后一个隐藏层的输出)。
state_new形状 (1, batch_size, num_hiddens):最后一个时间步的隐状态(可用于继续生成)
"""





# @save 标记表示这个类将被保存以便后续复用
class RNNModel(nn.Module):
    """循环神经网络模型"""
    def __init__(self, rnn_layer, vocab_size, **kwargs):
        super(RNNModel, self).__init__(**kwargs)
        self.rnn = rnn_layer          # 传入的RNN层(可以是普通RNN、GRU或LSTM)
        self.vocab_size = vocab_size  # 词汇表大小
        self.num_hiddens = self.rnn.hidden_size  # 获取RNN层的隐藏单元数
        
        # 判断是否为双向RNN,决定输出线性层的输入维度
        if not self.rnn.bidirectional:
            self.num_directions = 1
            # 单向:线性层输入维度 = 隐藏单元数
            self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
        else:
            self.num_directions = 2
            # 双向:线性层输入维度 = 隐藏单元数 × 2(正向+反向)
            self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)

    def forward(self, inputs, state):
        # 将输入进行 one-hot 编码
        # inputs形状:(batch_size, num_steps),T()转置为(num_steps, batch_size)
        # 编码后形状:(num_steps, batch_size, vocab_size)
        X = F.one_hot(inputs.T.long(), self.vocab_size)
        X = X.to(torch.float32)  # 转为float32类型,便于计算
        
        # 通过RNN层,返回输出Y和新状态state
        # Y形状:(num_steps, batch_size, num_hiddens)
        Y, state = self.rnn(X, state)
        #Y是所有时间步的隐藏层状态,state是最后一个时间步的

        # 将Y重塑为二维矩阵:(时间步数×批量大小, 隐藏单元数)
        # 然后通过全连接层映射到词汇表大小,得到每个时间步每个样本的预测分数
        output = self.linear(Y.reshape((-1, Y.shape[-1])))
        # output形状:(num_steps * batch_size, vocab_size)
        return output, state

    def begin_state(self, device, batch_size=1):
        """初始化隐状态(根据RNN类型返回不同结构)"""
        if not isinstance(self.rnn, nn.LSTM):
            # 对于普通RNN或GRU:隐状态是一个张量
            # 形状:(num_directions * num_layers, batch_size, num_hiddens)
            return torch.zeros((self.num_directions * self.rnn.num_layers,
                                batch_size, self.num_hiddens),
                               device=device)
        else:
            # 对于LSTM:隐状态是一个包含两个张量的元组(h0, c0)
            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))


"""继承 nn.Module,封装整个模型。
__init__
接收外部创建的 rnn_layer(可以是普通 RNN、GRU 或 LSTM)。
self.rnn = rnn_layer
self.vocab_size = vocab_size
self.num_hiddens = self.rnn.hidden_size
判断是否双向:如果是双向,则线性层输入维度需乘以2(正向+反向);否则直接用 num_hiddens。
定义 self.linear = nn.Linear(...):将 RNN 输出映射回词汇表大小,得到每个字符的得分(logits)。

forward
输入 inputs:形状 (batch_size, num_steps),元素是字符索引(整数)。
先做 one‑hot 编码:F.one_hot(inputs.T.long(), self.vocab_size)
.T转置为 (num_steps, batch_size),方便 RNN 按时间步处理。
编码后形状 (num_steps, batch_size, vocab_size),并转成 float32。
送入 self.rnn(X, state),得到 Y(所有时间步输出)和 state(最终隐状态)。
将 Y重塑为二维:Y.reshape((-1, Y.shape[-1])),即 (num_steps * batch_size, num_hiddens)。
通过 self.linear得到 output,形状 (num_steps * batch_size, vocab_size)。
返回 output和 state。

begin_state
用于初始化隐状态,适配不同 RNN 类型:
普通 RNN / GRU:返回一个零张量,形状 (方向数×层数, batch_size, num_hiddens)。
LSTM:返回元组 (h0, c0),两个相同形状的张量。"""




# 尝试使用GPU(如果可用)
device = d2l.try_gpu()

# 创建RNN模型实例,传入之前定义的rnn_layer和词汇表大小
net = RNNModel(rnn_layer, vocab_size=len(vocab))
net = net.to(device)  # 将模型移动到设备(GPU/CPU)

# 使用d2l库中的predict_ch8函数,用当前未训练的模型生成文本
# 给定前缀'time traveller',预测接下来的10个字符
d2l.predict_ch8('time traveller', 10, net, vocab, device)

"""用 GPU(若有)加速。
predict_ch8是 d2l 提供的辅助函数:给定前缀字符串,利用当前模型逐个预测后面10个字符。此时模型未训练,输出是随机的。"""


# 训练参数:迭代500轮,学习率1
num_epochs, lr = 500, 1

# 调用d2l的train_ch8函数进行训练
# train_iter:训练数据迭代器;vocab:词汇表对象;lr:学习率;num_epochs:轮数;device:设备
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)

"""train_iter:从文本数据构建的批量迭代器,每次返回一批 (X, y)字符序列。
训练过程(由 train_ch8内部实现):
在每个 epoch 中遍历所有批次。
初始化隐状态(调用 net.begin_state)。
前向传播得到 output,与目标字符索引计算交叉熵损失。
反向传播更新参数。
每若干步打印困惑度(perplexity)和生成样例。
训练完成后,模型能学会字符之间的统计规律,例如生成像 "time traveller" 风格的新文本。"""

这段代码实现了一个基于RNN的字符级语言模型,用于预测文本中的下一个字符。主要步骤:

  1. 定义RNN层(nn.RNN)并设置隐藏单元数。

  2. 封装为RNNModel类,处理前向传播(包括one-hot编码、RNN计算、全连接输出)和隐状态初始化。

  3. 使用d2l工具包中的predict_ch8测试未训练模型的生成效果(通常乱码)。

  4. 调用train_ch8进行训练,优化模型参数,使其学会根据上下文预测下一个字符。

注意:代码中的vocabtrain_iterbatch_sizenum_steps等变量需要在前面定义(通常来自d2l的数据加载部分)。

时间步概念:


1. 什么是时间步?

核心定义:一次看多少字

num_steps代表模型在一次计算(一次前向传播)中,能够同时处理的连续字符(或单词)的数量。

你可以把它理解为模型的**"视野宽度"** 或**"时间窗口大小"**。

结合图片例子理解

图片中提到:"比如一次看 35 个字符来预测下一个字符"。

  • 如果 num_steps = 5

    模型一次只能看到 5 个字符。比如输入 "hello",模型看到的是 ['h', 'e', 'l', 'l', 'o']

    • 优点:计算快,显存占用少。

    • 缺点:如果句子很长(比如 100 个字),模型只能看到前面 5 个字,看不到更远的上下文,可能会影响预测准确性。

  • 如果 num_steps = 35(如图片所述):

    模型一次能看到 35 个字符。它能根据前面 35 个字的语境来猜下一个字。

    • 优点:上下文信息更丰富,预测更准。

    • 缺点:计算量大,需要更多显存。

在代码和公式中的体现

  • 在输入数据 X

    X的形状通常是 (num_steps, batch_size, vocab_size)

    这意味着你把一长串文本切成了许多个长度为 num_steps的小片段(Segments)。

  • 在 RNN 公式中

    num_steps决定了循环的次数。公式里的 xt会不断迭代,从 t=1一直到 t=num_steps。

时间步是序列数据中的"位置编号"。

  • 在处理文本时,一个句子由多个字符组成,比如 "hello"有 5 个字符,每个字符就是一个时间步。

  • 在代码中,num_steps就是一次处理多少个连续的时间步(比如一次看 35 个字符来预测下一个字符?)。

形象理解:时间步就像电影胶片的一帧一帧画面,按顺序播放。


2. 什么是隐藏层?

隐藏层是神经网络中负责计算的"层级结构"。

  • 在 RNN 中,隐藏层由一组带权重的神经元组成,每个时间步都共享同一套权重

  • nn.RNN(len(vocab), num_hiddens)创建了一个单隐藏层 的 RNN,其中 num_hiddens是该隐藏层的神经元数量(即隐藏单元数)。

  • 如果是多层 RNN(比如 nn.RNN(..., num_layers=2)),就有两个隐藏层堆叠起来,上一层输出作为下一层输入。

形象理解:隐藏层就像一个加工车间,每个时间步送进来的原料(输入)都要经过这个车间加工,车间内部的机器(权重)是不变的,但车间的"工作台状态"(隐状态)会随着每个时间步更新。

整体流程:(重要)

输入: inputs (batch_size, num_steps) # 字符索引

↓ one-hot 编码

X: (num_steps, batch_size, vocab_size)

↓ RNN 层

Y: (num_steps, batch_size, num_hiddens)

↓ reshape 成 (-1, num_hiddens)

Y_flat: (num_steps * batch_size , num_hiddens)

↓ Linear 层

output: (num_steps * batch_size, vocab_size)

例子:(重要)

假设:

  • batch_size = 2(同时处理两个句子)

  • num_steps = 3(每个句子只看前 3 个字符)

  • vocab_size = 4(只有 4 种字符:a,b,c,d)

原始输入 inputs(形状 2×3):

句子1: 0, 1, 2 (a, b, c)

句子2: 1, 2, 3 (b, c, d)

转置后(3×2):

时间步0: 0, 1

时间步1: 1, 2

时间步2: 2, 3

one-hot 后(3×2×4):

时间步0: \[1,0,0,0, 0,1,0,0] # 句子1的第一个字是a,句子2的第一个字是b

时间步1: \[0,1,0,0, 0,0,1,0]

时间步2: \[0,0,1,0, 0,0,0,1]

经过 RNN 后,Y形状 (3,2,num_hiddens)(假设num_hiddens=5)。 激活过程在rnn_layer中实现了,用的tanh函数激活

reshape 成 (-1,5)→ 形状 (6,5)。这 6 行对应:

先取完所有批次(句子)的第 0 个时间步 -> 再取所有批次的第 1 个时间步 -> ...

所以,(6, 5)的这 6 行具体对应关系是:

  • 第 0 行 :时间步 0,句子 1 (原 Y[0, 0]

  • 第 1 行 :时间步 0,句子 2 (原 Y[0, 1]

  • 第 2 行 :时间步 1,句子 1 (原 Y[1, 0]

  • 第 3 行 :时间步 1,句子 2 (原 Y[1, 1]

  • 第 4 行 :时间步 2,句子 1 (原 Y[2, 0]

  • 第 5 行 :时间步 2,句子 2 (原 Y[2, 1]

线性层(5(因为之前形状是(6,5)→4)后,output形状 (6,4)。

5(输入维度 in_features

  • 这是 RNN 层输出的隐藏状态大小(num_hiddens)。

  • 意思是:模型内部在当前这一步,提取出了 5个特征​ 来描述当前的语境。

4(输出维度 out_features

  • 这是你想要预测的目标词汇表大小(vocab_size)。

  • 意思是:我们要把这 5 个抽象特征,翻译成给 4个具体字符​ 打分。

  • 对应图中 output形状 (6, 4)中的 4

比如 output[0]就是模型根据句子1的前1个字符(a)预测的下一个字符的分数(对应 b,c,d,a 的可能性)。output 含义:把时间和批量合并成一个维度,每一行代表一个具体位置(哪个句子的哪个时间步)的预测结果,方便一次性计算损失和梯度。

代码细节:

super(RNNModel, self).init(**kwargs)这句怎么解读
  1. 为什么需要调用父类的 __init__

RNNModel****继承自 nn.Module,而 nn.Module本身有自己的初始化方法,用于:

  • 管理模型的参数(parameters()

  • 注册子模块(比如 self.rnnself.linear

  • 支持 model.to(device)model.train()model.eval()等功能

如果不调用 父类的 __init__,这些基础功能就会缺失,模型就无法正常使用。


  1. super(RNNModel, self).__init__(**kwargs)的具体含义
  • super(RNNModel, self)找到 RNNModel的父类(即 nn.Module),并返回一个代理对象

  • .__init__(**kwargs):调用父类的 __init__方法,并将 **kwargs中的所有关键字参数传递给父类。

**kwargs的作用

RNNModel__init__签名是 def __init__(self, rnn_layer, vocab_size, **kwargs),其中 **kwargs收集了所有额外的关键字参数(比如 device='cuda'dtype=torch.float32等)。将这些参数传给父类,可以让父类处理一些通用的配置。

1️⃣ nn.Linear(in_features, out_features)的两个参数

nn.Linear是 PyTorch 中的全连接层(也叫线性层),它做的事情就是:

output=input×WT+b

其中:

  • in_features:输入特征的数量(每个样本有多少个数字)。

  • out_features:输出特征的数量(希望输出的每个样本有多少个数字)。

  • W是形状为 (out_features, in_features)的权重矩阵,b是偏置向量。

在代码中:

  • self.num_hiddens(比如 256)是 RNN 每个时间步输出的隐藏状态维度,也就是线性层的输入特征数

  • self.vocab_size(比如 10000)是词汇表的大小,我们希望把 RNN 的输出映射到词汇表上,得到每个字符的"分数",所以输出特征数等于词汇表大小。

所以 nn.Linear(256, 10000)就代表:把一个 256 维的向量变成 10000 维的向量。

2️⃣ 为什么需要这个线性层?

RNN 的输出 Y的形状是 (num_steps, batch_size, num_hiddens),也就是说每个时间步、每个样本都有一个 256 维的向量。但这个向量还不是最终的预测结果------我们需要知道下一个字符是什么(从 10000 个候选字符里选一个)。

所以线性层的作用就是将 256 维的隐藏状态"翻译"成 10000 维的分数向量(logits),之后可以用 softmax 转换成概率,选择概率最高的字符作为预测。

3️⃣ 为什么要先 reshape 再送入 linear?
复制代码
Y.reshape((-1, Y.shape[-1]))
  • Y.shape(num_steps, batch_size, num_hiddens)

  • Y.shape[-1]就是 num_hiddens(最后那个维度)。

  • reshape((-1, num_hiddens))会把前两个维度(num_stepsbatch_size)合并成一个维度,结果形状变为 (num_steps * batch_size, num_hiddens)

为什么这么做?

因为 nn.Linear期望输入的形状是 (任意批量大小, in_features)。这里我们把所有时间步和所有样本拼在一起当成一个大的"批量",一次性完成所有映射,效率更高。

之后得到的 output形状就是 (num_steps * batch_size, vocab_size),每个"样本"对应原来某个时间步的某个样本的预测分数

one-hot作用:

X = F.one_hot(inputs.T.long(), self.vocab_size)

inputs的原始形状是 (batch_size, num_steps),即每行是一个样本序列,每列是一个时间步。

.T做了转置,变成 (num_steps, batch_size),即每行是一个时间步,每列是一个样本。

然后 F.one_hot会对每个元素(每个字符索引)转换为一个长度为 vocab_size的 one-hot 向量。

所以最终形状变成了 (num_steps, batch_size, vocab_size)。

为什么特意要转置?

因为 PyTorch 的 nn.RNN默认要求输入的第一维是时间步,第二维是批量大小。如果不转置,直接对 (batch_size, num_steps)做 one-hot 会得到 (batch_size, num_steps, vocab_size),不符合 RNN 的要求。所以代码先转置,让时间步在最前面。

output 形状 (num_steps * batch_size, vocab_size)是什么意思?

代码和最开始的公式对应关系

相关推荐
德迅--文琪2 小时前
当前 2026 年 AI 狂潮时代,抗 DDoS 产品公司品牌推荐
人工智能·ddos
机器之心2 小时前
Claude Fable 5四日惊魂
人工智能·openai
机器之心2 小时前
打破SWE-bench唯分数论,首个独立测量harness的基准开源了
人工智能·openai
江畔柳前堤2 小时前
github实战指南07-CLI 与高级技巧
前端·人工智能·chrome·深度学习·github·caffe·issue
右耳朵猫AI2 小时前
GitHub周趋势2026W23 | last30days-skill AI搜索、headroom令牌压缩、apple/container开源
人工智能·开源·github
ZhengEnCi3 小时前
09ba-斯坦福CS336作业一-前馈网络
人工智能
武子康3 小时前
调查研究-175 Supermemory:AI 时代的 Memory API,不只是另一个向量数据库
人工智能·openai
寒山李白3 小时前
人工智能训练师报考指南
人工智能·ai·证书·职称·训练师
知南x3 小时前
【DPDK例程学习】(4) l2fwd
学习·word