目录
输入数据格式(时间步数,批量大小(每个时间步几个单词),词表大小)
[隐藏状态(Hidden State)------ 大脑的"瞬时记忆"](#隐藏状态(Hidden State)—— 大脑的“瞬时记忆”)
[初始化状态为0 初始化状态函数](#初始化状态为0 初始化状态函数)
[定义了所有需要的函数之后,接下来我们创建一个类来包装这些函数, 并存储从零开始实现的循环神经网络模型的参数。](#定义了所有需要的函数之后,接下来我们[创建一个类来包装这些函数], 并存储从零开始实现的循环神经网络模型的参数。)
检查输出是否具有正确的形状。例如,隐状态的维数是否保持不变。
[预热期循环:for y in prefix1::](#预热期循环:for y in prefix[1:]:)
[例子 预测期循环:for _ in range(num_preds):](#例子 预测期循环:for _ in range(num_preds):)
[返回语句:return ''.join(vocab.idx_to_token\[i for i in outputs])](#返回语句:return ''.join([vocab.idx_to_token[i] for i in outputs]))
[如果时间步不是 1 会怎样?](#如果时间步不是 1 会怎样?)
(拓展,没啥用)多步(每次根据前面几个历史token)代码:
[为什么顺序分区需要 detach?](#为什么顺序分区需要 detach?)
[为什么随机采样不需要 detach?](#为什么随机采样不需要 detach?)
代码前置知识:
epoch和训练集bacth关系*********:
一个 epoch 始终包含对整个数据集的一次完整遍历,无论你将数据集分成多少个 batch(也不论 batch 大小是 1、几十、几百还是整个数据集本身)。
-
Full‑batch:整个数据集作为一个 batch,一个 epoch 只进行一次前向和后向传播。
-
Mini‑batch :数据集被分成多个小批量,一个 epoch 会依次处理所有小批量,每个小批量更新一次参数。
-
Stochastic (batch_size=1) :每个样本是一个 batch,一个 epoch 会遍历所有样本,更新次数等于样本总数。


1d2l出现在最最开始,03安装。是各种内置函数的集合
2问"怎么不用词向量?"的,Word2Vec2013年,RNN198X年
3正态分布 (Normal distribution),也叫高斯分布。它的作用是生成一个服从均值为 0、标准差为 1 的正态分布的随机张量。
具体来说:randn的n就是normal
torch.randn(size=shape, device=device)生成一个形状为shape的张量,其中的每个元素独立地服从标准正态分布(均值 0,方差 1)。
4 F.one_hot(即 torch.nn.functional.one_hot)的主要参数有两个:
tensor(必需):
任意形状的整数张量,其中的每个元素代表一个类别索引(从 0 开始)。例如 torch.tensor(0, 2)。
num_classes(可选,默认 -1):
整数,指定类别总数(即独热向量的长度)。若不提供或设为 -1,则自动取 tensor.max() + 1作为类别数。
返回值是一个形状为 (*tensor.shape, num_classes)的 torch.int64张量,其中对应索引位置为 1,其余为 0。
python
import torch.nn.functional as F
t = torch.tensor([0, 2])
F.one_hot(t, num_classes=5)
# 输出: tensor([[1, 0, 0, 0, 0],
# [0, 0, 1, 0, 0]])
#省略 num_classes,则 F.one_hot(t)会默认 num_classes = 3(因为最大索引是 2)
tensor([[1, 0, 0],
[0, 0, 1]])
5隐藏层单元数(重要)
隐藏层单元数(num_hiddens)决定了 RNN 中隐藏状态向量的长度,也就是网络内部"记忆"的容量
| 影响方面 | 隐藏单元数小(如 16) | 隐藏单元数大(如 1024) |
|---|---|---|
| 记忆容量 | 只能记住简单的模式(如短距离依赖) | 能记住复杂的长期依赖关系 |
| 表达能力 | 弱,可能无法拟合复杂数据 | 强,能捕捉精细的规律 |
| 过拟合风险 | 低 | 高(需要更多数据或正则化) |
| 计算速度 | 快(矩阵运算小) | 慢(矩阵运算大) |
| 参数量 | 少(W_hh是 num_hiddens × num_hiddens,平方增长) |
非常多,容易占用大量显存 |
数学视角
-
隐藏状态
H的形状是(batch_size, num_hiddens)。 -
权重
W_hh的形状是(num_hiddens, num_hiddens)。 -
所以
num_hiddens越大:-
每个样本的隐藏状态向量越长,包含的信息越多。
-
循环连接的参数量呈平方级 增长(
num_hiddens²),训练更慢,也更容易过拟合
-
顺序分区和随机采样区别(重要****)
顺序分区

特点:
-
同一个 epoch 内,小批量之间是连续的 (第二个小批量的第一个样本
[i,j,k,l]紧跟着第一个小批量的最后一个样本[e,f,g,h]之后)。 -
因此,处理完第一个小批量后,RNN 的隐藏状态已经记住了
[a,b,c,d,e,f,g,h]的上下文。这个状态可以传递给第二个小批量作为初始状态,使得信息能在整个 epoch 内流动。 -
梯度处理 :为了避免梯度跨小批量传播(导致计算图过长),需要在每个小批量开始前调用
state.detach_()切断计算图,但状态的值仍然保留。
随机采样

| 特性 | 顺序分区 | 随机采样 |
|---|---|---|
| 样本来源 | 按顺序连续切分 | 随机选取起始位置 |
| 小批量间连续性 | 连续(下一个批量的样本紧接上一个批量的末尾) | 不连续(随机跳跃) |
| 隐藏状态初始化 | 每个 epoch 开始时初始化一次,小批量间传递状态值(但 detach 梯度) | 每个小批量开始时都重新初始化(全零) |
| 梯度传播范围 | 限制在当前小批量内部(因为 detach) | 自然限制在当前小批量 |
| 训练稳定性 | 更好(利用了文本的顺序信息) | 较差(打乱了上下文连贯性) |
| 收敛速度 | 通常更快 | 可能更慢,但有助于泛化 |
输入数据格式(时间步数,批量大小(每个时间步几个单词),词表大小)
我们每次采样的小批量数据形状是二维张量: (批量大小,时间步数)。这里时间步数相当于X的feature number
one_hot函数将这样一个小批量数据转换成三维张量, 张量的最后一个维度等于词表大小(len(vocab))。 我们经常转换输入的维度,以便获得形状为 (时间步数,批量大小,词表大小)的输出。 这将使我们能够更方便地通过最外层的维度(时间步数,沿着时间走,本身rnn也是沿着时间循环的), 一步一步地更新小批量数据的隐状态。
转置的主要目的是方便后边遍历进行计算
python
X = torch.arange(10).reshape((2, 5))#这里时间步数相当于X的feature number
F.one_hot(X.T, 28).shape
#输出torch.Size([5, 2, 28])
本质上RNN的搭建还是比较简单的,MLP的变形,但是数据准备是比较麻烦的
python
def get_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size
#因为输入通过onehot转成vocab_size大小了,输出也是预测哪个概率最大,所以大小也是vocab_size
输入:每个 token 会被转换为 one‑hot 向量,长度为 vocab_size,因此输入维度就是 vocab_size。
输出:网络预测下一个 token 的概率分布,同样是一个长度为 vocab_size的向量(经 softmax 后各元素和为 1),因此输出维度也是 vocab_size。
#torch.randn生成服从标准正态分布(均值 0,方差 1)的随机数。
乘以 0.01将标准差缩小到 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:
param.requires_grad_(True)
return params
隐藏层参数

输出层参数

初始状态是给隐藏状态进行初始化准备的
这里的隐藏状态为什么会与batc_size有关呢?
前面是W和b,所以不用batchsize,这个是隐变量,输出输入都是有batchsize维度
前面w b参数的值跟batch无关,h是网络里的节点,是跟batch有关的,因此两个初始化分开了
因为计算时都是一次计算一批量,而不是一个样本一个样本地算,batchsize是因为一个batch里的每个x在同一时间步t1是各自对应一个h的
隐藏状态(Hidden State)------ 大脑的"瞬时记忆"
-
是什么 :隐藏状态是随时间变化的变量,代表网络在当前时间步的"记忆内容"。它不是一个固定参数,而是根据输入和上一时刻的记忆计算出来的。
-
例子:当你阅读一句话时,每读一个字,你的短期记忆(隐藏状态)就会更新,记住刚才看到的内容。这个记忆会随着新字的出现而变化。
-
特点:
-
依赖输入:不同的输入会产生不同的隐藏状态。
-
依赖时间:同一输入在不同时间步也会导致不同的隐藏状态(因为前面的记忆不同)。
-
与 batch 有关 :每个样本有自己的隐藏状态,所以形状是
(batch_size, num_hiddens)。
-
h_t = tanh( x_t @ W_xh + h_{t-1} @ W_hh + b_h )
↑ ↑ ↑ ↑
当前输入 权重(固定) 上一时刻隐藏状态 偏置(固定)
(参数) (动态变量) (参数)
-
权重 (
W_xh,W_hh,b_h)是常量(训练后不变)。 -
隐藏状态 (
h_t)是变量,每一步都在更新。
one-hot处理的是输入层的x,这里是隐藏层h,不需要one-hot
隐藏层 h(连续数值):
隐藏层是网络内部的记忆。它是由上一时刻的计算结果生成的(通常是浮点数,比如 0.5, -0.2, 0.8...)。既然它已经是数值了,就不需要再转换成 One-hot 了。它是"连续的信号",而不是"离散的类别标签"。
初始化状态为0 初始化状态函数
python
def init_rnn_state(batch_size, num_hiddens, device):#形状为(批量大小,隐藏单元数)
return (torch.zeros((batch_size, num_hiddens), device=device), )
rnn函数
python
def rnn(inputs, state, params):
"""
简单循环神经网络的前向传播。
参数:
inputs : 形状 (T, batch_size, vocab_size)
其中 T 是时间步数(序列长度)
state : 元组,包含一个张量 H,形状 (batch_size, num_hiddens)
表示初始隐藏状态
params : 列表 [W_xh, W_hh, b_h, W_hq, b_q],各参数形状见下方
返回:
outputs : 形状 (T * batch_size, vocab_size)
所有时间步的输出拼接在一起(按行堆叠)
(H,) : 元组,包含最后一个时间步的隐藏状态,形状 (batch_size, num_hiddens)
"""
# 解包参数
W_xh, W_hh, b_h, W_hq, b_q = params
# W_xh : (vocab_size, num_hiddens)
# W_hh : (num_hiddens, num_hiddens)
# b_h : (num_hiddens,)
# W_hq : (num_hiddens, vocab_size)
# b_q : (vocab_size,)
H, = state # H : (batch_size, num_hiddens)
"""
state = (torch.zeros(2, 3),) # 元组中有一个张量
H, = state # H 现在是那个张量,形状 (2,3)
H = state # H 现在是整个元组 (torch.zeros(2,3),)
写成 H, = state等价于 H = state[0]。逗号的作用是告诉 Python:我要把这个单元素元组解包,并把唯一的那个元素赋值给变量 H。
如果不加逗号,写成 H = state,那么 H将会是整个元组本身,而不是里面的张量。
在 RNN 代码中,之所以用元组包裹隐藏状态,是为了与后续可能的多层 RNN(LSTM 等)接口保持一致,即使只有一层也保持元组形式。
"""
outputs = [] # 存储每个时间步的输出
# 遍历每个时间步 这也是为啥之前需要用转置再onehot的原因
for X in inputs: # X : (batch_size, vocab_size)
# 计算当前隐藏状态
# torch.mm(X, W_xh) : (batch_size, num_hiddens)
# torch.mm(H, W_hh) : (batch_size, num_hiddens)
# b_h 广播至 (batch_size, num_hiddens)
H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
# H : (batch_size, num_hiddens)
# 计算当前时间步的输出(logits)
# torch.mm(H, W_hq) : (batch_size, vocab_size)
# b_q 广播至 (batch_size, vocab_size)
Y = torch.mm(H, W_hq) + b_q
# Y : (batch_size, vocab_size)
outputs.append(Y) # 列表中每个元素形状 (batch_size, vocab_size)
# 将所有时间步的输出沿第0维拼接
# outputs 列表长度 T,每个元素 (batch_size, vocab_size)
# cat(dim=0) 后得到 (T * batch_size, vocab_size)
return torch.cat(outputs, dim=0), (H,)
""
在 RNN 的实现中,返回 (H,)而不是直接返回 H,主要是为了接口一致性。
为什么返回元组而不是单个张量?
兼容多层 RNN
如果是多层 RNN(比如 2 层),隐藏状态会是一个包含多个张量的元组,每层一个。即使当前只有一层,也保持元组形式,这样后续代码(如 new_state[0])可以统一处理,无需判断层数。
与 PyTorch 原生 API 对齐
PyTorch 的 nn.RNN、nn.LSTM等模块返回的隐藏状态都是元组(即使只有一层也是 (h_n,)或 (h_n, c_n)),这样做是为了保持一致的接口风格。
解包习惯
调用方通常会用 state = (H,)来初始化,然后用 H, = state解包。如果这里返回 H,那么 init_state返回的也得是 H而非 (H,),前后不一致会增加出错风险。
如果return torch.cat(outputs, dim=0), H
那么调用方 Y, new_state = net(X, state)中,new_state就不再是元组,而是一个形状为 (batch_size, num_hiddens)的张量。此时:
后续如果想用 new_state[0]获取第一层状态,会得到张量的第一行,而不是第一层的隐藏状态(因为张量索引行为变了)。
如果以后想扩展为多层 RNN,就需要修改这里的返回格式和所有相关的解包代码,造成不必要的麻烦。
""
| 符号 | 形状 | 含义 |
|---|---|---|
inputs |
(T, batch_size, vocab_size) |
输入序列,每个时间步是一个 one‑hot 向量 |
state |
(batch_size, num_hiddens)(元组内) |
初始隐藏状态 |
W_xh |
(vocab_size, num_hiddens) |
输入→隐藏权重 |
W_hh |
(num_hiddens, num_hiddens) |
隐藏→隐藏循环权重 |
b_h |
(num_hiddens,) |
隐藏层偏置 |
W_hq |
(num_hiddens, vocab_size) |
隐藏→输出权重 |
b_q |
(vocab_size,) |
输出层偏置 |
X(循环内) |
(batch_size, vocab_size) |
当前时间步的输入 |
H(更新后) |
(batch_size, num_hiddens) |
当前隐藏状态 |
Y(循环内) |
(batch_size, vocab_size) |
当前时间步的输出 |
返回的 outputs |
(T * batch_size, vocab_size) |
所有时间步输出拼接(行堆叠) |
返回的 (H,) |
(batch_size, num_hiddens) |
最后一个时间步的隐藏状态 |
关键点说明
state是一个元组,lstm有两个返回值,但rnn只有一个 所以代码那里才有H,(逗号),其实这个的写法好像标准是x,_=state,默认_为不适用的参量
之前为啥转置,为了方便在连续的时间步上迭代
H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h) 和mlp唯一不同就是torch.mm(H, W_hh)这部分和Y那部分
Y含义:(重要***************)
Y是当前时间步的预测,但预测的目标是下一个时刻的 token。
具体来说:
输入 X是当前时间步(第 t步)的 token 的 one‑hot 向量(或嵌入向量)。隐藏状态 H根据 X和上一时刻的隐藏状态更新,捕获了直到当前时刻的上下文信息。输出 Y是经过 W_hq和 b_q变换得到的 logits,经过 softmax (持保留意见)后给出下一个 token(第 t+1步) 的概率分布。
因此,Y可以被理解为:在看到当前 token 后,对接下来可能出现什么 token 的预测。
在代码中,outputs收集了每个时间步的 Y,最终返回的 outputs形状为 (T * batch_size, vocab_size),对应的目标(label)应该是移位的输入序列------即输入的第 t个 token 对应的目标应是第 t+1个 token。
-
出的
Y是 logits (未归一化的分数),形状为(batch_size, vocab_size)。 -
在训练时,通常使用
nn.CrossEntropyLoss来计算损失,这个损失函数内部已经包含了 softmax + 负对数似然 两步操作,所以我们不需要手动添加 softmax。 -
只有在推理(inference)阶段,如果需要输出概率分布,才会在
Y上手动调用F.softmax(Y, dim=-1)。
最后cat: 就是原来是一个一维list,每个元素是一个Y,然后把这些Y重新按行拼接,Y里面是不动的,所以列数不变 ,行数变了
最后的return H: 这里return的h是给下一个输出用的,相当于这个时刻输出用的上一个时刻的H
说点自己的理解,感觉就是时间步变成了batchsize,然后每次都有inputs个单词,预测input个输出单词
for X in inputs:是否沿着时间步维度迭代?是的,完全正确。 这里的X是类似于for循环里面的i,在这里的意思是时间步的意思
-
inputs的形状是(T, batch_size, vocab_size)或者(sequence_length, batch_size, vocab_size),其中T是时间步数(序列长度)。 -
Python 的
for ... in循环默认遍历张量的第 0 维(即第一个维度)。 -
因此,每次循环取到的
X形状为(batch_size, vocab_size),正好对应一个时间步的所有批量样本。
举例:
假如 inputs形状为 (5, 32, 1000)(T=5, batch_size=32, vocab_size=1000),
则循环会依次取出 5 个形状为 (32, 1000)的张量,分别代表第 0 秒、第 1 秒......第 4 秒的批量输入。
在序列建模(尤其是文本处理)中,时间步对应的是序列中的位置,比如:
-
句子 "I love AI" 有 3 个 token,时间步 t=0 对应 'I',t=1 对应 'love',t=2 对应 'AI'。
-
在字符级 RNN 中,每个时间步就是一个字符。
用"秒"只是为了形象地说明"先后顺序",但容易引起误解。更准确的表述应该是第 0 个 token、第 1 个 token...... 或 第 0 个字符、第 1 个字符......。
-
**为什么返回
torch.cat(outputs, dim=0)而不是torch.stack?**cat和stack原理(前面章节多次应用了,重要记住)
假设
outputs列表里有T个张量,每个张量的形状都是(batch_size, vocab_size)。cat(dim=0)会将它们在第 0 维 上首尾相接,新张量的第 0 维大小 = 所有张量在第 0 维的大小之和 =batch_size + batch_size + ...(共 T 次)=T * batch_size。因此结果形状是(T * batch_size, vocab_size),看起来像是"相乘",实际上是累加 (batch_size 重复 T 次)。与之对比,torch.stack会在指定位置新建一个维度,把所有张量堆叠进去,结果形状是(T, batch_size, vocab_size),不会合并原有维度。-
cat(dim=0)将(T, batch_size, vocab_size)展平成(T(时间步) * batch_size, vocab_size)。这在计算损失时可以直接与目标序列(也展平成长度为T * batch_size的一维索引)对齐,简化交叉熵计算。
操作 输出形状 含义 cat(dim=0)(T * batch_size, vocab_size)将时间步和批量合并为一个维度,适合直接计算损失(目标也需展平) stack(dim=0)(T, batch_size, vocab_size)保留时间步维度,便于逐时间步分析或后续按时间步处理 - 用
torch.stack(dim=0)得到(T, batch_size, vocab_size),则需要先**.reshape(-1, vocab_size)**再传入损失函数,效果相同,只是多了一步显式 reshape。 - 在append的时候这个outputs里面的几个Y是独立的张量,然后通过拼接直接形成了一个包含了所有时间步上信息的一个大的张量 为了后面方便求损失
-
-
隐藏状态
H的更新- 每一步都用新计算的
H覆盖旧值,体现了循环特性。
- 每一步都用新计算的
-
torch.mm与广播torch.mm要求两个矩阵严格二维且维度匹配。偏置b_h和b_q通过广播机制加到每行上。
-
outputs += Y不能代替 outputs.append(Y),两者有本质区别。 原因 outputs.append(Y):将 Y作为一个整体元素添加到列表末尾。列表长度增加 1,每个元素是形状为 (batch_size, vocab_size)的张量。 outputs += Y:等价于outputs.extend(Y),它会将 Y视为可迭代对象,把 Y的每个元素(即每一行)逐个添加到列表中。由于 Y的形状是 (batch_size, vocab_size),Y在第 0 维上有 batch_size行,因此 outputs += Y会将 batch_size个形状为 (vocab_size,)的行向量分别追加到列表中,列表长度增加 batch_size。
python
outputs = []
outputs1 = []
outputs2 = []
Y = torch.tensor([[1,2,3,4], [5,6,7,8], [9,10,11,12]])
# 方法1:append
outputs1.append(Y)
print(len(outputs1)) # 输出 1,列表包含一个 (3,4) 的张量
# 方法2:+=
outputs2 += Y
print(len(outputs2)) # 输出 3,列表包含三个 (4,) 的行向量
#方法3 +=[Y] 等价于append
outputs += [Y]
print(len(outputs)) # 输出 1,列表包含一个 (3,4) 的张量
outputs,outputs1,outputs2
outputs += Y(不加方括号)的情况,它会将 Y视为可迭代对象,把每一行拆开添加。
而加上方括号写成 Y后,+=操作等价于 extend(Y),由于 Y中只有一个元素 Y,所以最终结果就是添加整个 Y作为一个元素,与 append一致。

对后续 torch.cat的影响
-
用
append收集后,outputs包含T个(batch_size, vocab_size)张量,torch.cat(outputs, dim=0)得到(T * batch_size, vocab_size),符合预期。 -
用
+=收集后,outputs包含T * batch_size个(vocab_size,)向量,torch.cat(outputs, dim=0)会得到一个(T * batch_size, vocab_size)的张量,形状上看似相同,但语义错误 :原本每个时间步的输出应该是一个完整的(batch_size, vocab_size)块,现在被打散成单个样本的行,丢失了时间步与批次之间的对应关系,后续无法正确计算损失(目标序列也需要按时间步-批次对齐)。
定义了所有需要的函数之后,接下来我们**创建一个类来包装这些函数**, 并存储从零开始实现的循环神经网络模型的参数。
python
class RNNModelScratch: #@save
"""从零开始实现的循环神经网络模型"""
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 函数初始化所有可学习参数(W_xh, W_hh, b_h, W_hq, b_q)
self.params = get_params(vocab_size, num_hiddens, device)
# 保存初始化隐藏状态的函数和前向传播函数
self.init_state, self.forward_fn = init_state, forward_fn
def __call__(self, X, state):
# 使类的实例可以像函数一样调用:model(X, state)
# X 的形状通常为 (batch_size, seq_len),内容是 token 索引(整数)
# 转置 X 为 (seq_len, batch_size),便于按时间步迭代
# 对每个索引进行 one-hot 编码,形状变为 (seq_len, batch_size, vocab_size)
# .type(torch.float32) 转换为浮点型(one_hot 默认返回 int64)
X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
# 调用前向传播函数(即之前定义的 rnn 函数),返回输出和新的隐藏状态
return self.forward_fn(X, state, self.params)
def begin_state(self, batch_size, device):
# 初始化隐藏状态(全零张量)
# 调用 init_state 函数(即之前定义的 init_rnn_state),返回 (batch_size, num_hiddens) 的全零张量
return self.init_state(batch_size, self.num_hiddens, device)
代码细节:
_call_函数(重要)
__call__是自动调用的,但不是像 __init__那样在创建对象时自动触发,而是在你把对象当作函数来调用时自动触发。
具体来说:
当你写下 model(X, state)时,Python 会自动查找 model所属类中定义的 __call__方法,并执行它。
这让你创建的实例可以像普通函数一样使用,背后其实是 Python 的特殊方法机制在起作用。
当你创建了一个 RNNModelScratch的实例 model后,可以直接写 output, state = model(X, state),这等价于调用 model.__call__(X, state)。
检查输出是否具有正确的形状。例如,隐状态的维数是否保持不变。
python
num_hiddens = 512
# 设置隐藏层神经元数量为 512
net = RNNModelScratch(
len(vocab), # 词汇表大小
num_hiddens, # 隐藏单元数
d2l.try_gpu(), # 设备(优先 GPU)
get_params, # 参数初始化函数
init_rnn_state, # 隐藏状态初始化函数
rnn # 前向传播函数
)
# 创建一个从零实现的 RNN 模型实例 net
state = net.begin_state(X.shape[0], d2l.try_gpu())
# 调用 begin_state 方法,根据批量大小 X.shape[0] 和设备,
# 生成初始隐藏状态(全零张量),保存在 state 中
Y, new_state = net(X.to(d2l.try_gpu()), state)
# 这里调用了 net 的 __call__ 方法(因为 net 是实例,后面跟了括号)
# 首先将输入 X 移到 GPU,然后与 state 一起传入
# __call__ 内部会:
# 1. 将 X 转置并进行 one-hot 编码
# 2. 调用 forward_fn(即 rnn 函数)进行前向传播
(上面开头写的返回return torch.cat(outputs, dim=0), (H,))
# 返回输出 Y 和新的隐藏状态 new_state
new_state是一个元组,长度为 1,所以只有索引 0有效,即 new_state[0]才是真正的隐藏状态张量 H(形状 (batch_size, num_hiddens))
Y.shape, len(new_state), new_state[0].shape
# 查看输出张量 Y 的形状、new_state 的长度(元组中的元素个数)、
# 以及 new_state 中第一个元素的形状
#输出
(torch.Size([10, 28]), 1, torch.Size([2, 512]))
我们可以看到输出形状是(时间步数*批量大小,词表大小), 而隐状态形状保持不变,即(批量大小,隐藏单元数)。


rnn函数返回的第二个元素是 (H,),这是一个只包含一个张量的元组。因此,当执行 Y, new_state = net(...)时,new_state就是这个元组 (H,),而不是一个单独的 H张量。
截图中的 device='cuda:0'是 PyTorch 框架为了方便开发者调试而自动显示的设备元数据,它表明这个张量目前存储在计算机的 GPU(显卡)上,而非 CPU 内存中。
- 为什么会有这个显示?
在 PyTorch 中,当你执行 tensor.device或者在 Jupyter Notebook 等环境中直接输出张量时,系统会默认展示该张量的设备信息。这是一种标准行为,目的是为了防止"设备不匹配"的错误。
new_state是一个元组,长度为 1,所以只有索引 0有效,即 new_state0才是真正的隐藏状态张量 H(形状 (batch_size, num_hiddens))。
new_state 是一个元组(tuple),里面只包含一个元素:(H,)。它的长度是 1。
new_state0 是元组中的第一个元素,也就是真正的隐藏状态张量 H,形状为 (batch_size, num_hiddens)。
代码细节:
X = torch.arange(10).reshape((2, 5))#这里时间步数相当于X的feature number
X.shape0是2
对X的每一个词预测它下一个词是啥,X有2*5十个词,所以就是一共预测10个词(Y的第一维),每个词根据28类0和1组成
预测:
python
def predict_ch8(prefix, num_preds, net, vocab, device): #@save
"""在prefix后面生成新字符"""
# 初始化隐藏状态,batch_size=1(一次只预测一个字符串序列)
#state是用来记录上一个H的历史信息的,训练的H,和预测的H 何谈影响一说
state = net.begin_state(batch_size=1, device=device)
# outputs 列表用于存储生成的所有 token 索引,初始为 prefix 的第一个字符的索引
#这里vocab不是词频表了!仅仅是一个字典map!把token映射出一个数
"""比如输入是你好,世界,那么你预测好,好预测逗号,所以没人预测出你,所以输出默认第一个就是你"""
outputs = [vocab[prefix[0]]]
#正常定义函数是def [函数名],这里因为函数功能太简单,直接简化成了lambda+函数体
# get_input 是一个匿名函数,每次调用返回当前最后一个输出 token 的 one-hot 表示
# 形状为 (1, 1) 的二维张量(batch_size=1, seq_len=1)
get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))
# 预热期:用 prefix[1:] 的真实字符依次更新网络状态,但不使用网络自己的预测
for y in prefix[1:]:
# 将当前最后一个字符(来自真实前缀)作为输入,更新隐藏状态
_, state = net(get_input(), state)
# 将真实的下一个字符索引添加到 outputs(用于后续继续输入真实值)
outputs.append(vocab[y])
# 预测期:从预热结束后的状态开始,自主生成 num_preds 个新字符
for _ in range(num_preds):
# 将当前最后一个输出(可以是真实字符或上一轮预测的字符)输入网络
y, state = net(get_input(), state)
# 取输出 logits 中概率最大的类别索引作为预测结果
# y.argmax(dim=1) 形状 (1,),reshape(1) 后仍是 (1,),int() 转为标量
outputs.append(int(y.argmax(dim=1).reshape(1)))
# 将 outputs 中的索引序列通过 vocab.idx_to_token 映射回字符,并拼接成字符串
return ''.join([vocab.idx_to_token[i] for i in outputs])
python
#将前缀指定为time traveller , 并基于这个前缀生成10个后续字符。 鉴于我们还没有训练网络,它会生成荒谬的预测结果。
predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu())
#输出 'time traveller dhfofnjzay'
关键点说明
挺好理解的,第一次循环,先把给定的prefix的隐状态获取到(第一个for循环是为了求最后一个H隐变量状态),然后第二个循环获取到prefix后续的预测值,state一直做更新就行
-
预热期(Warm-up) :用真实的前缀字符(除第一个外)依次更新网络状态,使网络进入正确的上下文,而不是从全零状态开始盲目预测。预热期间,网络输入是真实的字符,输出被丢弃(
_),但状态被保留。 -
预测期 :从预热结束后的状态开始,每次使用上一次的输出(模型自己的预测)作为下一次的输入,实现自回归生成。
-
get_input:每次调用时,取outputs[-1](最近生成的 token 索引),包装成形状(1,1)的张量,作为网络的输入。注意这里没有进行 one-hot 编码,因为net.__call__内部会做 one-hot 转换。 -
输出处理 :
y.argmax(dim=1)取每个样本(这里 batch=1)的最大值索引,reshape(1)保持维度一致,int()转为 Python 整数以便存入列表。
为什么这样设计?
-
预热期的核心目标 :让 RNN 的隐藏状态
state充分吸收前缀prefix的上下文信息,而不是从全零状态开始预测。 -
输入来源 :每一步的输入都是上一个真实的字符 (
outputs[-1]正好是刚加入的真实字符),确保网络状态按照真实的历史更新。 -
顺序的重要性 :必须先调用
net再用真实字符更新outputs,因为:-
第一次调用时,
outputs中只有第一个字符,输入是第一个字符,状态更新到"看过第一个字符"。 -
然后加入第二个字符,下次调用时输入就是第二个字符,状态更新到"看过前两个字符"。
-
如果颠倒顺序(先 append 再调用 net),第一次调用时输入就会变成第二个字符(因为
outputs[-1]已经是第二个字符了),导致状态跳过第一个字符,预热就错了。
-
预热期结束后
-
state已经记住了整个prefix的上下文。 -
outputs包含了prefix所有字符的索引。 -
进入预测期后,
get_input()仍然取outputs[-1],但此时outputs的最后一个是prefix的最后一个字符(真实值)。预测期的每一次迭代都会将模型自己预测的字符追加到outputs,所以后续的输入就变成了模型自己的预测结果,从而实现自回归生成。
lambda等价于
def get_input():
return torch.tensor(outputs\[-1], device=device).reshape((1, 1))
或者:
def get_input():
last_token = outputs-1 # 获取列表最后一个元素(整数索引)
return torch.tensor(last_token, device=device).reshape((1, 1))
例子(预热)
假设 prefix = "hello",词汇表 vocab将每个字符映射为索引(例如 {'h':0, 'e':1, 'l':2, 'o':3})。
outputs = vocab\['h'] # outputs = 0 outputs中已经存放了第一个字符 'h'的索引。
预热期循环:for y in prefix[1:]:
prefix[1:]是 "ello",依次取出 y = 'e', 'l', 'l', 'o'。
第1次循环(y = 'e')
-
**调用
net(get_input(), state)**-
get_input()取outputs[-1],即当前outputs的最后一个元素:索引0(字符'h')因为母目前就一个元素。 -
网络以
'h'作为输入,更新隐藏状态state,使其记住"已经看到了'h'"。 -
这一步的输出被丢弃(
_),因为我们只需要状态,不需要预测结果。
-
-
**
outputs.append(vocab['e'])**-
将真实的下一个字符
'e'的索引1添加到outputs。 -
现在
outputs = [0, 1]。
-
效果 :网络状态已经更新到"见过 'h'",并且 outputs记录了真实序列的前两个字符。
第2次循环(y = 'l')
-
get_input() 取outputs[-1],即索引1(字符'e')。- 网络以
'e'作为输入,更新状态,使其记住"已经看到了'h','e'"。
- 网络以
-
**
outputs.append(vocab['l'])**-
添加真实第三个字符
'l'的索引2。 -
现在
outputs = [0, 1, 2]。
-
以此类推,直到循环结束,outputs中包含整个 prefix的索引序列 [0,1,2,2,3](对应 h,e,l,l,o)。
例子 预测期循环:for _ in range(num_preds):
背景 :预热期结束后,outputs里已经存好了 prefix所有字符的索引,state也已经记住了整个前缀的上下文。
循环的每一步做的事情:
-
y, state = net(get_input(), state)-
get_input()取outputs[-1](当前列表里最后一个字符的索引),做成形状(1,1)的张量作为输入。 -
网络根据这个输入和当前状态
state,计算出输出y(logits)和新的状态state。 -
注意:第一次进入预测期时,
outputs[-1]是前缀的最后一个真实字符;之后的每次循环,outputs[-1]是上一轮模型自己预测的字符。
-
-
outputs.append(int(y.argmax(dim=1).reshape(1)))-
y的形状是(1, vocab_size),代表网络对下一个字符的预测分数。(见如下解释为啥形状是这个) -
y.argmax(dim=1)找出分数最高的那个索引(即模型认为最可能的下一个字符),结果是形状(1,)的张量。 -
.reshape(1)将其变成(1,)(其实已经是了,但为了保险),然后int()转换成 Python 整数。 -
把这个整数追加到
outputs列表末尾。
-
循环的效果 :每循环一次,模型就自主生成一个新字符的索引,并把它加入 outputs。循环 num_preds次,就生成了 num_preds个新字符。
返回语句:return ''.join([vocab.idx_to_token[i] for i in outputs])
-
outputs现在是一个整数列表,比如[0, 1, 2, 2, 3, 5, 7, ...],前半部分是前缀的索引,后半部分是模型生成的索引。 -
[vocab.idx_to_token[i] for i in outputs]是一个列表推导式:对于outputs里的每一个整数i,通过vocab.idx_to_token[i]找到对应的字符(比如0->'h',1->'e',2->'l',3->'o',5->'w'...)。 -
''.join(...)把这些字符无缝拼接成一个字符串,比如"helloworld"。
最终返回的就是前缀 + 模型生成的新字符组成的完整字符串。
y形状:
| 步骤 | 形状 | 说明 |
|---|---|---|
get_input() |
(1, 1) |
一个 token 索引 |
经过 __call__one-hot |
(1, 1, vocab_size) |
T=1, batch=1 |
rnn循环内 X |
(1, vocab_size) |
单个时间步输入 |
Y = mm(H, W_hq)+b_q |
(1, vocab_size) |
输出 logits |
最终 y |
(1, vocab_size) |
预测分数 |
在 predict_ch8函数中,时间步被固定为 1,这是设计上的刻意安排。
原因
-
预测时,我们希望一步一步地生成字符:每次只看当前最后一个 token,然后预测下一个。
-
get_input()每次只取outputs[-1](一个 token 索引),并包装成形状(1, 1)的张量,即batch_size=1, seq_len=1。 -
因此,每次调用
net(get_input(), state)时,输入的序列长度(时间步数)始终为 1。
如果时间步不是 1 会怎样?
-
如果一次性输入多个时间步(例如
(1, 5)表示一次输入 5 个 token),那么rnn函数会依次处理这 5 个 token,返回的y形状会是(5, vocab_size)(因为torch.cat将 5 个时间步的输出拼接在一起)。 -
但在自回归生成中,我们并不需要一次预测多个未来字符(除非使用非自回归方法),所以逐时间步生成是最自然的做法。 实际上,标准 RNN 的隐藏状态已经能够记住过去的信息,所以没有必要每次输入多个历史 token 。原来的
get_input只输入最近一个 token 是完全正确的,因为隐藏状态state已经包含了之前所有 token 的上下文。增加输入序列长度反而会带来额外的计算开销,且不会显著提升效果(除非模型本身有注意力机制等需要长输入)。
总结
-
在
predict_ch8中,时间步就是 1,因为每次只输入一个字符。 -
这不是偶然,而是为了配合自回归生成的逻辑:用上一个输出作为下一个输入,一步步推进。
-
如果你尝试输入多个时间步,
y的形状会相应变化,但该函数的设计初衷就是单步预测。



(拓展,没啥用)多步(每次根据前面几个历史token)代码:

"""
4. 举个例子
假设 k=3, vocab_size=4,y形状 (3, 4):
y = torch.tensor(\[0.1, 0.2, 0.3, 0.4, # 第1个时间步的输出
0.5, 0.6, 0.1, 0.2, # 第2个时间步的输出
0.2, 0.1, 0.6, 0.1]) # 第3个时间步的输出(最后一个)
y-1:, :得到 \[0.2, 0.1, 0.6, 0.1],形状 (1, 4)。
y-1得到 0.2, 0.1, 0.6, 0.1,形状 (4,)。
因为 RNN 是逐步读取输入的,只有读完所有历史 token 后,隐藏状态才包含了完整的上下文信息,此时的输出才是基于全部历史的最佳预测。
#前 k-1 个时间步的输出是基于不完整历史的预测,不适合用作最终预测,所以说实际上和预测时候单步自回归没区别。
"""
-
初期阶段 :
outputs只有 1 个元素时,recent就是[0],reshape((1, -1))得到(1, 1),不会报错。 -
后期阶段 :
outputs累积超过k后,recent取最后k个,reshape((1, -1))得到(1, k),实现多步上下文输入
-
预热期输入 :预热期循环中,
get_input()调用时,outputs中尚未包含当前要处理的真实字符y,所以输入的是历史序列(不包括当前目标)。 -
预测期输入 :预测期循环中,
get_input()调用时,outputs已经包含了所有已生成的 token(包括真实前缀和之前预测的字符),输入的是最近 k 个已生成 token。 -
输出形状 :由于输入序列长度可能变化(初期短,后期长),
y的形状也会变化,但y[-1:, :]总能正确提取最后一个时间步的预测
测试示例
假设 prefix="hel", k=3, vocab大小合适:
-
预热期第一步:
outputs=[h],输入(1,1),网络输出(1,vocab_size),更新状态,然后outputs=[h,e]。 -
预热期第二步:
outputs=[h,e],输入(1,2),网络输出(2,vocab_size),取最后一行作为预测(但丢弃),然后outputs=[h,e,l]。 -
预测期:
outputs=[h,e,l],输入(1,3),网络输出(3,vocab_size),取最后一行作为预测,得到o,加入outputs=[h,e,l,o],如此继续。
裁剪梯度代码:
python
def grad_clipping(net, theta): #@save
"""裁剪梯度"""
#范数计算
# 判断 net 是否为 PyTorch 的 nn.Module 子类
if isinstance(net, nn.Module):
# 若是,则通过 net.parameters() 获取所有需要梯度的参数
params = [p for p in net.parameters() if p.requires_grad]
else:
# 若不是(如我们手写的 RNNModelScratch 类),则直接使用 net.params 列表
params = net.params
# 计算所有参数梯度的 L2 范数(平方和再开方)
# sum(torch.sum((p.grad ** 2)) for p in params) 先对每个参数求梯度平方和,
# 再用外层 sum 将所有参数的平方和相加,最后开平方根得到总范数
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
"""
p.grad ** 2:对梯度张量每个元素平方。
torch.sum(...):对该参数所有元素的平方求和。
外层 sum(...):将所有参数的平方和相加。
torch.sqrt(...):开平方,得到整个模型的梯度 L2 范数。
"""
#裁剪操作
# 如果梯度总范数超过了阈值 theta,则进行裁剪
if norm > theta:
for param in params:
# 将每个参数的梯度原地缩放为:原梯度 * (theta / norm)
# 这样所有参数的梯度都被等比例缩小,使得总范数变为 theta
param.grad[:] *= theta / norm
"""
param.grad[:]是原地修改(in-place),直接覆盖梯度张量的值。
缩放因子 theta / norm小于 1(因为 norm > theta),所以梯度整体缩小。
裁剪后,所有参数的梯度总范数变为 theta(证明:新范数 = 原范数 × (theta/norm) = theta)。
"""
训练
在训练模型之前,让我们**定义一个函数在一个迭代周期内训练模型** 。 它与我们训练 :numref:sec_softmax_scratch模型的方式有三个不同之处。
- 序列数据的不同采样方法**(随机采样和顺序分区)**将导致隐状态初始化的差异。
- 我们在更新模型参数之前裁剪梯度。 这样的操作的目的是,即使训练过程中某个点上发生了梯度爆炸,也能保证模型不会发散。
- 我们用困惑度来评价模型。如 :numref:
subsec_perplexity所述, 这样的度量确保了不同长度的序列具有可比性。
顺序分区:
具体来说,当使用顺序分区时, 我们只在每个迭代周期的开始位置初始化隐状态。 由于下一个小批量数据中的第i个子序列样本 与当前第i个子序列样本相邻, 因此当前小批量数据最后一个样本的隐状态, 将用于初始化下一个小批量数据第一个样本的隐状态。 这样,存储在隐状态中的序列的历史信息 可以在一个迭代周期内流经相邻的子序列。
然而,在任何一点隐状态的计算, 都依赖于同一迭代周期中前面所有的小批量数据, 这使得梯度计算变得复杂。 为了降低计算量,在处理任何一个小批量数据之前, 我们先分离梯度,使得隐状态的梯度计算总是限制在一个小批量数据的时间步内。
随机采样:
当使用随机抽样时,因为每个样本都是在一个随机位置抽样的, 因此需要为每个迭代周期重新初始化隐状态。 与 :numref:sec_softmax_scratch中的 train_epoch_ch3函数相同, updater是更新模型参数的常用函数。 它既可以是从头开始实现的d2l.sgd函数, 也可以是深度学习框架中内置的优化函数。
训练 RNN 的三个关键点:
-
隐状态初始化:顺序分区时,每个 epoch 开始时初始化一次,小批量间传递状态;随机采样时,每个小批量都重新初始化。
-
梯度裁剪:防止梯度爆炸,保证训练稳定。
-
困惑度评估:使不同长度序列的性能可比。
此外,顺序分区时需在每步前分离梯度,限制计算范围
训练函数
python
#@save
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
"""训练网络一个迭代周期(定义见第8章)"""
# 初始化 state 为 None,计时器开始
state, timer = None, d2l.Timer()
# metric 用于累计:训练损失之和、词元数量
metric = d2l.Accumulator(2)
for X, Y in train_iter: # 遍历每个小批量
# ---------- 隐藏状态初始化/分离 ----------
if state is None or use_random_iter:
# 第一次迭代 或 使用随机采样时,重新初始化隐藏状态 这里的意思就是说,h0需要初始化,如果当前batch和前一个batch之间不连续的话,h0就不能通用,需要重新初始化
state = net.begin_state(batch_size=X.shape[0], device=device)
#这里把计算图detach是因为TBPTT(truncated bptt) 就是把连续的序列分片,不影响正向传播,但反向传播只能在片内更新参数
else:
# 顺序分区时,需要分离计算图以避免跨小批量的梯度传播
if isinstance(net, nn.Module) and not isinstance(state, tuple):
# 对于 nn.GRU 等,state 是单个张量
state.detach_()
else:
# 对于 nn.LSTM 或手写 RNN,state 是元组(每层一个张量)
for s in state:
s.detach_()
# ---------- 准备标签 ----------
#为什么Y要转置?现在Y的大小是(batch_size,num_step),这是从迭代器取出来的
y = Y.T.reshape(-1) # 将目标序列展平为一维 (T*batch_size,)
X, y = X.to(device), y.to(device) # 移到指定设备
# ---------- 前向传播 ----------
#rnn算出来的每个Y是(batch_size, 词向量大小)然后output按照每个时间步垂直拼接。
y_hat, state = net(X, state) # y_hat 形状 (T*batch_size, vocab_size)
# ---------- 损失计算 ----------
l = loss(y_hat, y.long()).mean() # 交叉熵损失(平均)
"""
1
y_hat: (T * batch_size, vocab_size),即每个时间步每个样本都有一个预测分布。
y: (T * batch_size,),即每个时间步每个样本的真实类别索引。
2. 交叉熵损失的输出形状
nn.CrossEntropyLoss有一个参数 reduction,默认值为 'mean'。
若 reduction='mean'(默认),则 loss(y_hat, y)直接返回一个标量(所有样本的平均损失)。此时再调用 .mean()是多余的,但对标量求平均不会报错,结果仍是自身。
若 reduction='none',则 loss(y_hat, y)返回形状为 (T * batch_size,)的向量,每个元素是对应样本的损失。这时 .mean()才有意义,将其聚合成一个标量。
这个代码是先得到形状为 (T * batch_size,)的损失向量。
再 .mean()求平均,得到标量损失。
因为rnn输出的含义可以理解为:当前输入token xt对应的下一个token是各个词元的概率是多少,词元种类对应固定文本是有限的,所以可以理解为对下一个token属于的词元种类的多分类问题
X和 Y都是从数据加载器 train_iter中直接取出的整数张量(形状 (batch_size, num_steps)),每个元素是 token 在词汇表中的索引。
没有对 Y做 one-hot 编码。y只是将 Y转置并展平,得到一维整数序列,作为交叉熵损失的目标标签。
one-hot 编码只发生在 net.__call__内部对输入 X的处理:
数据加载器 train_iter输出的形状是 (batch_size, num_steps),这是因为:
采样方式:无论是顺序分区还是随机采样,每次从语料中取出 batch_size个独立的样本,每个样本是一个长度为 num_steps的连续 token 序列。
表示形式:文本已被预处理为整数索引(每个 token 对应词汇表中的一个 ID),因此每个样本就是一个整数数组。
打包成批次:将这些样本堆叠成一个二维张量,第一维是批量大小,第二维是序列长度,即 (batch_size, num_steps)。
例如,假设 batch_size=2,num_steps=3,语料是 "the quick brown fox jumps",映射后索引为 [0,1,2,3,4]。一次采样可能得到两个样本:
样本1:[0,1,2](对应 "the quick brown")
样本2:[3,4,?](可能需要 padding 或重新采样)
组合成张量形状 (2,3)。
这个形状直接满足 RNN 的输入要求:net.__call__内部会将其转置为 (num_steps, batch_size)再进行 one-hot 编码。
"""
# ---------- 反向传播与参数更新 ----------
if isinstance(updater, torch.optim.Optimizer):
# 使用 PyTorch 内置优化器
updater.zero_grad() # 清空梯度
l.backward() # 反向传播
grad_clipping(net, 1) # 梯度裁剪,阈值=1
updater.step() # 更新参数
else:
# 使用自定义 updater(如 d2l.sgd)
l.backward() # 反向传播
grad_clipping(net, 1) # 梯度裁剪
updater(batch_size=1) # 自定义更新(已平均损失,故 batch_size=1)
"""
为什么有两种分支?
内置优化器分支(如 torch.optim.SGD):
标准流程:zero_grad→ backward→ step。梯度裁剪插入在 backward之后、step之前,防止梯度爆炸。
自定义 updater 分支(如 d2l.sgd):
d2l.sgd是一个手动实现的随机梯度下降函数,其签名通常为 sgd(params, lr, batch_size)。它内部会:
将每个参数的梯度除以 batch_size(因为损失已经取平均,所以此处应传入 batch_size=1避免二次缩放)。用学习率更新参数:param -= lr * grad / batch_size。
不自动清空梯度,因此这里没有显式调用 zero_grad(但 d2l.sgd实现中通常会手动将梯度置零?实际上教材中的 d2l.sgd会先清零梯度再更新,所以不需要外部调用 zero_grad)。
为什么自定义分支中 batch_size=1?
因为损失 l已经通过 .mean()得到了平均损失(每个样本的平均贡献)。如果再把 batch_size设为真实批量大小(比如 32),则 d2l.sgd内部会再次除以 32,导致学习率实际缩小 32 倍,训练失效。所以必须传入 batch_size=1,让 d2l.sgd认为只有一个样本,从而直接使用平均梯度更新。
梯度裁剪的作用
无论在哪个分支,grad_clipping(net, 1)都将所有参数的梯度 L2 范数裁剪到不超过 1,防止 RNN 中常见的梯度爆炸问题。
"""
# ---------- 记录指标 ----------
metric.add(l * y.numel(), y.numel()) # 累计损失总和 与 词元总数
# 返回:困惑度(exp(平均损失))和 每秒处理的词元数
return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()
关键设计说明
-
隐藏状态管理:
-
随机采样时,每个小批量都重新初始化状态,因为样本之间无顺序关系。
-
顺序分区时,只在第一个小批量初始化,后续小批量复用前一个状态的输出,但需调用
.detach_()切断计算图,防止梯度跨小批量传播(减少计算量)。
-
-
梯度裁剪 :调用
grad_clipping(net, 1)将梯度 L2 范数限制在 1 以内,防止梯度爆炸。 -
损失与评估:
-
使用交叉熵损失,取平均值。
-
返回困惑度
exp(平均损失),数值越小越好;同时返回每秒处理的词元数以衡量效率。
-
-
兼容性 :同时支持 PyTorch 内置优化器和自定义
updater(如d2l.sgd),后者因损失已平均,更新时需指定batch_size=1以避免二次缩放。
为什么顺序分区需要 detach?
顺序分区 中,隐藏状态 state会从小批量传递到下一个小批量(因为序列是连续的)。这意味着:
-
正向传播:状态值正常流动,信息可以跨越小批量边界,让网络看到更长的上下文。
-
反向传播:如果不做任何处理,梯度也会沿着状态链反向传播到前一个小批量,形成一条很长的计算图,导致:
-
内存消耗巨大(需要保存所有中间变量)。
-
梯度可能消失或爆炸(长链反向传播)。
-
训练速度极慢。
-
解决方案 :在每个小批量开始前,调用 state.detach_()将状态从计算图中分离。这样:
-
正向传播:状态值仍然保留(作为数值),继续传递到下一个批量。
-
反向传播 :梯度只在小批量内部传播,不会跨过
detach点。相当于把长序列切成若干段,每段独立进行 BPTT,但段的起始状态使用上一段的最终状态(数值)而不参与梯度计算。
这正是 TBPTT 的做法:状态传递,梯度截断。
为什么随机采样不需要 detach?
随机采样 中,每个小批量都是从文本中随机抽取的片段,片段之间没有顺序关系。因此:
-
每个小批量开始时,隐藏状态都会被重新初始化为全零 (
state = net.begin_state(...))。 -
由于状态归零,不存在跨小批量的状态依赖,也就没有跨小批量的梯度路径。
-
因此,不需要
detach,计算图自然只限于当前小批量内部。
示意图*******
顺序分区(TBPTT)
小批量1: a,b,c,d → state1
小批量2: e,f,g,h → state2 = f(state1) ← 状态传递,但梯度在此处截断
小批量3: i,j,k,l → state3 = f(state2) ← 同样截断
-
每个小批量的梯度只更新本小批量内的参数。
-
状态值
state1被detach后,只作为数值输入给小批量2,不参与小批量2的反向传播。
随机采样:
小批量1: d,e,f,g → state = zeros
小批量2: m,n,o,p → state = zeros
小批量3: h,i,j,k → state = zeros
每个小批量独立,没有状态传递,自然无跨批量梯度。
关键点:y_hat和 y必须按照相同的顺序组织,否则损失计算会错位。
形状追踪
-
输入
X:形状(batch_size, num_steps),例如(2, 4)(2个样本,每个样本4个时间步)。 -
net(X, state)内部:-
先
X.T变成(num_steps, batch_size)→(4, 2)。 -
one-hot 后形状
(4, 2, vocab_size)。 -
RNN 前向传播后,
y_hat形状为(num_steps * batch_size, vocab_size)=(8, vocab_size)。 -
注意 :
y_hat的行顺序是 先时间步0的两个样本,再时间步1的两个样本,依此类推 (因为torch.cat(dim=0)按时间步拼接)。
-
-
目标
Y:形状(batch_size, num_steps),例如(2, 4),每一行是一个样本的完整序列,每一列是时间步。
为什么必须转置?(重要)
如果直接 Y.reshape(-1),结果形状为 (8,),但顺序是 先样本0的4个时间步,再样本1的4个时间步 。这与 y_hat的顺序(先时间步0的两个样本,再时间步1的两个样本)不一致 。损失函数 cross_entropy(y_hat, y)要求 y_hat的每一行与 y的对应元素配对,顺序错乱会导致模型学到错误的关系。
转置的作用
Y.T.reshape(-1):
-
Y.T形状变为(num_steps, batch_size)→(4, 2),此时每一列是一个样本,每一行是一个时间步。 -
.reshape(-1)展平为(num_steps * batch_size,)→(8,),顺序变为 先时间步0的两个样本,再时间步1的两个样本 ,与y_hat完全一致。
例子 batch_size=2, num_steps=3,目标索引为:
Y = \[a, b, c, # 样本0
d, e, f] # 样本1
Y.reshape(-1)顺序:a, b, c, d, e, f(样本优先)
Y.T.reshape(-1)顺序:a, d, b, e, c, f(时间步优先)
y_hat的行顺序正是 时间步0的样本0, 时间步0的样本1, 时间步1的样本0, ...,所以必须用第二种顺序。
训练函数
python
#@save
def train_ch8(net, train_iter, vocab, lr, num_epochs, device,
use_random_iter=False):
"""训练模型(定义见第8章)"""
loss = nn.CrossEntropyLoss() # 交叉熵损失,默认 reduction='mean',输出标量
animator = d2l.Animator(xlabel='epoch', ylabel='perplexity',
legend=['train'], xlim=[10, num_epochs])
# 初始化优化器或自定义更新函数
if isinstance(net, nn.Module):
# 若 net 是 PyTorch 内置 Module,使用内置 SGD 优化器
updater = torch.optim.SGD(net.parameters(), lr)
else:
# 否则使用自定义的 d2l.sgd 函数(需要传入 batch_size)
updater = lambda batch_size: d2l.sgd(net.params, lr, batch_size)
# 定义预测函数:给定前缀,生成 50 个字符
predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)
# 训练循环
for epoch in range(num_epochs):
# 执行一个 epoch 的训练,返回困惑度和速度
ppl, speed = train_epoch_ch8(
net, train_iter, loss, updater, device, use_random_iter)
# 每 10 个 epoch 打印一次预测结果并更新动画
if (epoch + 1) % 10 == 0:
print(predict('time traveller'))
animator.add(epoch + 1, [ppl])
# 最终输出
print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')
print(predict('time traveller'))
print(predict('traveller'))

交叉熵损失计算例子:*****


