到目前为止遇到过两种类型的数据:表格数据和图像数据

卷积神经网络可以有效地处理空间信息,**循环神经网络(recurrent neural network,RNN)**则可以更好地处理序列信息,循环神经网络通过引入状态变量存储过去的信息和当前的输入,从而可以确定当前的输出

许多使用循环网络的例子都是基于文本数据的,因此将在本章中重点介绍语言模型

序列模型

**序列模型(Sequence Model)**是一类能够处理序列数据的模型,可以理解“顺序”这个维度的信息

比如:

  • 一段文字(词语或字母按顺序组成句子)
  • 一段语音(声音信号是随时间变化的)
  • 股票价格(每天的数据有先后关系)
  • 传感器时间序列(如心率随时间的变化)

统计工具

处理序列数据需要统计工具和新的深度神经网络架构,以下图所示的股票价格(富时100指数)为例

ftse100

其中,用$x_t$表示价格,即在时间步(time step)$t \in \mathbb{Z}^+$时,观察到的价格

假设一个交易员想在$t$日的股市中表现良好,于是通过以下途径预测$x_t$
$$
x_t \sim P(x_t \mid x_{t-1}, \ldots, x_1).
$$

自回归模型

为了实现这个预测,交易员可以使用回归模型(如线性回归),但会出现一个问题,输入$x_{t-1}, \ldots, x_1$本身因$t$而异,输入数据的数量会随着时间变化,输入维度不断增加,因此需要一种近似方法来简化计算

主要两种策略:

  • 策略一,序列考虑最近长度为$\tau$的时间范围,这使得模型的参数量保持不变,便于训练一个类似之前的深度网络,这种模型被称为自回归模型(autoregressive models),因为它是对自身过去的数据进行回归

  • 策略二,保留对过去观测的总结$h_t$,在每一步同时更新预测$\hat x_t = P(x_t \mid h_t)$和总结$h_t = g(h_{t-1}, x_{t-1})$,由于$h_t$不可直接观测,这种模型被称为隐变量自回归模型(latent autoregressive models)

    sequence-model

但问题是训练数据如何生成,通常用历史观测来预测下一个时刻的值

一个常见的假设是:虽然$x_t$会变化,但序列的动力学特性保持不变。这个假设是合理的,因为新的动力学只能由新数据决定,而无法用已有数据预测它

统计学上,这种动力学不变的性质被称为平稳性(stationarity),因此整个序列的估计值都将通过以下的方式获得:
$$
P(x_1, \ldots, x_T) = \prod_{t=1}^T P(x_t \mid x_{t-1}, \ldots, x_1).
$$
如果处理的是离散的对象(如单词)而不是连续的数字,则上述的考虑仍然有效,唯一的差别是,对于离散的对象需要使用分类器而不是回归模型来估计$P(x_t \mid x_{t-1}, \ldots, x_1)$

马尔可夫模型

在自回归模型的近似法中使用$x_{t-1}, \ldots, x_{t-\tau}$而不是$x_{t-1}, \ldots, x_1$来估计$x_t$

马尔可夫性(Markov condition):未来只与现在有关,与过去无关,当前状态包含了预测未来所需的全部信息$P(x_{t+1} \mid x_t, x_{t-1}) = P(x_{t+1} \mid x_t)$

在估计当前状态时,只需要知道前一个状态而不用关心更久以前的状态

如果$\tau = 1$,得到一阶马尔可夫模型(first-order Markov model)

序列的联合概率可以写成
$$
P(x_1, \ldots, x_T) = \prod_{t=1}^T P(x_t \mid x_{t-1}) \text{ 当 } P(x_1 \mid x_0) = P(x_1).
$$
当$x_t$是离散值时,可以利用动态规划沿着**马尔可夫链(Markov chain)**精确地计算出结果,“链”指的就是状态之间一步步转移的关系

例如可以高效地计算$P(x_{t+1} \mid x_{t-1})$
$$
\begin{split}\begin{aligned}
P(x_{t+1} \mid x_{t-1})
&= \frac{\sum_{x_t} P(x_{t+1}, x_t, x_{t-1})}{P(x_{t-1})}\\
&= \frac{\sum_{x_t} P(x_{t+1} \mid x_t, x_{t-1}) P(x_t, x_{t-1})}{P(x_{t-1})}\\
&= \sum_{x_t} P(x_{t+1} \mid x_t) P(x_t \mid x_{t-1})
\end{aligned}\end{split}
$$
可以通过中间状态$x_t$把两个相邻状态之间的概率联系起来

在隐马尔可夫模型中,动态规划能高效地计算这些概率

训练

使用正弦函数和一些可加性噪声来生成序列数据

1
2
3
4
5
6
T = 1000  # 总共产生1000个点
time = torch.arange(1, T + 1, dtype=torch.float32)
x = torch.sin(0.01 * time) + torch.normal(0, 0.2, (T,))
plt.figure(figsize=(6, 3))
plt.plot(time, x)
plt.grid()

将这个序列转换为模型的特征-标签对

将数据映射为数据对$y_t = x_t$和$\mathbf{x}t = [x{t-\tau}, \ldots, x_{t-1}]$,比提供的数据样本少了$\tau$个

对于前$\tau$个的解决方案:如果拥有足够长的序列就丢弃这几项;或是用零填充序列

仅使用前600个“特征-标签”对进行训练

1
2
3
4
5
6
7
8
9
10
tau = 4
features = torch.zeros((T - tau, tau)) # 构建特征张量
for i in range(tau):
features[:, i] = x[i: T - tau + i] # 列填充feature
labels = x[tau:].reshape((-1, 1)) # 标签从tau开始

batch_size, n_train = 16, 600
# 只有前n_train个样本用于训练
train_iter = load_array((features[:n_train], labels[:n_train]),
batch_size, is_train=True)

使用一个相当简单的架构训练模型:一个拥有两个全连接层的多层感知机,ReLU激活函数和平方损失

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 初始化网络权重的函数
def init_weights(m):
if isinstance(m, nn.Linear):
nn.init.xavier_uniform_(m.weight) # 对应ReLU激活

# 一个简单的多层感知机
def get_net():
net = nn.Sequential(
nn.Linear(4,10),
nn.ReLU(),
nn.Linear(10, 1)
)
net.apply(init_weights)
return net

# MSELoss计算平方误差时不带系数1/2
loss = nn.MSELoss(reduction='none')

训练代码

1
2
3
4
5
6
7
8
9
10
11
def train(net, train_iter, loss, epochs, lr):
trainer = torch.optim.Adam(net.parameters(), lr=lr) # 这里不再用SGD了
for epoch in range(epochs):
for X, y in train_iter:
trainer.zero_grad()
l = loss(net(X), y)
l.sum().backward()
trainer.step()
print(f"epoch {epoch+1}, loss: {evaluate_loss(net, train_iter, loss):f}")
net = get_net()
train(net, train_iter, loss, 5, 0.01)

Adam比SGD更智能,自动调整学习率步子大小,更适用于自然语言处理

SGD一般用在图像分类,在后期泛化更好

1
2
3
4
5
epoch 1, loss: 0.072635
epoch 2, loss: 0.060879
epoch 3, loss: 0.056276
epoch 4, loss: 0.059151
epoch 5, loss: 0.053477

预测

由于训练损失很小,因此期望模型能有很好的工作效果

首先是检查模型预测下一个时间步的能力,也就是单步预测(one-step-ahead prediction)

1
2
3
4
5
6
onestep_preds = net(features)
plt.figure(figsize=(6, 3))
plt.plot(time, x.detach().numpy(), label="data")
plt.plot(time[tau:], onestep_preds.detach().numpy(),label="1-step preds")
plt.grid()
plt.legend()
202510292303

单步预测效果不错,即使这些预测的时间步超过了600+4(也就是n_train+tau),其结果看起来仍然是可信的

但这里有一个问题,后续的迈进需要一步步,必须使用自己的预测(而不是原始数据)来进行多步预测

对于直到$x_t$的观测序列,其在时间步$t+k$处的预测输出$\hat{x}_{t+k}$称为**$k$步预测(k-step-ahead-prediction)**

1
2
3
4
5
6
7
8
9
10
11
12
multistep_preds = torch.zeros(T) # 初始化
multistep_preds[: n_train + tau] = x[:n_train + tau] # 选取测试集数据
# [n_train : n_train + τ] 测试集开始前的真实值
# [n_train + τ :] 用模型逐步预测后续的所有值
for i in range(n_train + tau, T):
multistep_preds[i] = net(multistep_preds[i-tau:i].reshape(1, -1))
plt.figure(figsize=(6, 3))
plt.plot(time, x.detach().numpy(), label="data")
plt.plot(time[tau:], onestep_preds.detach().numpy(),label="1-step preds")
plt.plot(time[n_train + tau:], multistep_preds[n_train+tau:].detach().numpy(), '-.', label="multistep preds") # 显示利用预测值去预测的结果
plt.grid()
plt.legend()
202510301258

绿线的预测显然并不理想,预测的结果很快就会衰减到一个常数,这是由于错误的积累,误差可能会相当快地偏离真实的观测结果

对于不同的k值,对比结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
max_step = 64 # 定义最大步伐
# 定义特征矩阵,前tau列为历史数据,后max_step存储模型预测值,以最大创建
features = torch.zeros((T-tau-max_step+1, tau+max_step))
for i in range(tau):
features[:, i] = x[i: i+T-tau-max_step+1] # 前tau列填入真实历史值

for i in range(tau, tau + max_step):
# 后tau+max_step填入预测值
features[:, i] = net(features[:, i - tau:i]).reshape(-1)

steps = (1, 4, 16, 64)
plt.figure(figsize=(6, 3))
for i in steps:
plt.plot(time[tau + i - 1 : T - max_step + i], # time长度与y匹配
features[:, tau + i - 1].detach().numpy(),
label=f"{i}-step preds")
plt.legend()
plt.grid()
202510301326

清楚地说明了试图预测更远的未来时,预测的质量是如何变化的

一般超过4步预测跨度的预测几乎就是无用的

小结

  • 内插法(在现有观测值之间进行估计)和外推法(对超出已知观测范围进行预测)在实践的难度上差别很大,对于所拥有的序列数据,在训练时始终要尊重其时间顺序,最好不要基于未来的数据进行训练
  • 序列模型的估计需要专门的统计工具,两种较流行的选择是自回归模型和隐变量自回归模型
  • 着对预测时间$k$值的增加,会造成误差的快速累积和预测质量的极速下降

文本预处理

文本的常见预处理步骤,包括:

  1. 将文本作为字符串加载到内存中
  2. 将字符串拆分为词元(如单词和字符)
  3. 建立一个词表,将拆分的词元映射到数字索引
  4. 将文本转换为数字索引序列,方便模型操作

新增加的导入库

1
2
import collections # 提供了一些比普通字典、列表更灵活的数据结构
import re # 用于字符串匹配、查找、替换、分割等操作

collections —— 高级数据结构工具箱

常见成员:

  • Counter:计数器,用来统计元素出现次数
  • deque(双端队列):高效地在头尾插入或删除元素

re —— 正则表达式库

常用函数:

  • re.search(pattern, text):在文本中搜索匹配项

  • re.findall(pattern, text):找到所有匹配的子串

    1
    2
    re.findall(r"[A-Za-z]+", "Hi 123 there!")  # ['Hi', 'there']
    # +号代表 匹配连续的一个或多个字母
  • re.sub(pattern, repl, text):按规则替换字符串

    1
    re.sub(r"\d+", "X", "Room 404")  # "Room X"

读取数据集

从H.G.Well的The Time Machine中加载文本,这是一个相当小的语料库,只有30000多个单词

下面的函数将数据集读取到由多条文本行组成的列表中,其中每条文本行都是一个字符串,在这里忽略了标点符号和字母大写

数据集导入,和Kaggle类似

1
2
3
DATA_HUB['time_machine'] = (  #@save
DATA_URL + 'timemachine.txt',
'090b5e7e70c295757f55df93cb0a180b9691891a')
1
2
3
4
5
6
7
8
9
def read_time_machine():  #@save
"""将时间机器数据集加载到文本行的列表中"""
with open(download('time_machine'), 'r') as f:
# 一次性读取文件中所有行,结果是一个列表,每个元素是一行字符串
lines = f.readlines()
# 正则表达式,匹配所有非英文字母,替换为空格,' '
return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]
# .strip() 去掉字符串首尾多余的空格,中间的保留
# .lower() 转换成小写
1
2
3
4
lines = read_time_machine()
print(f'# 文本总行数: {len(lines)}')
print(lines[0])
print(lines[10])
1
2
3
# 文本总行数: 3221
the time machine by h g wells
twinkled and his usually pale face was flushed and animated the

词元化

tokenize函数将文本行列表(lines)作为输入,列表中的每个元素是一个文本序列(如一条文本行)

每个文本序列又被拆分成一个词元列表,**词元(token)**是文本的基本单位

最后,返回一个由词元列表组成的列表,其中的每个词元都是一个字符串

1
2
3
4
5
6
7
8
def tokenize(lines, token='words'): #@save
"""将文本行拆分为单词或字符词元"""
if token == 'words':
return [line.split() for line in lines] # 根据空格切分字符串
elif token == 'char': # 直接把每行拆成单个字符
return [list(line) for line in lines]
else:
print('unknown token' + token)
1
2
3
tokens = tokenize(lines) # 将刚刚拆开的行放入
for i in range(11): # 前11行
print(tokens[i])
1
2
3
4
5
6
7
8
9
10
11
['the', 'time', 'machine', 'by', 'h', 'g', 'wells']
[]
[]
[]
[]
['i']
[]
[]
['the', 'time', 'traveller', 'for', 'so', 'it', 'will', 'be', 'convenient', 'to', 'speak', 'of', 'him']
['was', 'expounding', 'a', 'recondite', 'matter', 'to', 'us', 'his', 'grey', 'eyes', 'shone', 'and']
['twinkled', 'and', 'his', 'usually', 'pale', 'face', 'was', 'flushed', 'and', 'animated', 'the']

词表

在文本处理中,词元通常是字符串,但深度学习模型只能处理数值输入,需要构建一个词表(vocabulary),用于将每个词元映射为从0开始的整数索引

构建词表的步骤如下:

  1. 将训练集中的所有文本合并,对其中出现的唯一词元进行统计,这个整体称为语料(corpus)
  2. 根据每个词元的出现频率为其分配索引,出现频率过低的词元通常会被舍弃,以降低模型复杂度
  3. 对于语料中未出现或被删除的词元,会统一映射到一个特殊的未知词元("<unk>")

词表中还可以包含一些特殊标记,用于在训练和生成过程中发挥作用

  • "<pad>":填充词元,用于对齐序列长度
  • "<bos>":序列开始标记
  • "<eos>":序列结束标记

构建一个Vocab

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def count_corpus(tokens):  #@save
"""统计词元的频率"""
# 这里的tokens是1D列表或2D列表
if len(tokens) == 0 or isinstance(tokens[0], list):
# 将词元列表展平成一个列表
tokens = [token for line in tokens for token in line]
"""
flat = []
for line in tokens:
for token in line:
flat.append(token)
tokens = flat
"""
# Python 内置的计数器容器类型
# 返回一个字典状的对象:键是词元,值是出现次数
return collections.Counter(tokens)

class Vocab: #@save
"""文本词表"""
# reserved_tokens 包含特殊标记
def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
if tokens is None:
tokens=[]
if reserved_tokens is None:
reserved_tokens=[]
# 按出现频率排序
counter = count_corpus(tokens)
# 变量名前加一个下划线_ 表示这是类的内部属性,外部最好别直接访问
self._token_freqs = sorted(
counter.items(),
key=lambda x: x[1],
reverse=True) # 降序排序,默认升序
# 未知词元的索引为0
self.idx_to_token = ['<unk>'] + reserved_tokens
# 构建反向映射字典:“词元 → 索引”
self.token_to_idx = {token: idx
for idx, token in enumerate(self.idx_to_token)}
for token, freq in self._token_freqs:
if freq < min_freq: # 如果频率太小直接抛弃
break
# 检查当前词是否已在词表中
if token not in self.token_to_idx:
self.idx_to_token.append(token) # 加入 idx_to_token 列表末尾
# 在 token_to_idx 里记录它的索引,长度-1为索引
self.token_to_idx[token] = len(self.idx_to_token) - 1

def __len__(self):
return len(self.idx_to_token)

def __getitem__(self, tokens): # 索引运算符重载
if not isinstance(tokens, (list, tuple)):
# 输入是单个词元
return self.token_to_idx.get(tokens, self.unk)
# .get(tokens, self.unk) 词元在词表里,返回它对应的索引,不在返回unk
# 输入是多个词元(列表or元组)
return [self.__getitem__(token) for token in tokens]

def to_tokens(self, indices): # 将输出转回词元
if not isinstance(indices, (list, tuple)):
return self.idx_to_token[indices]
return [self.idx_to_token[index] for index in indices]

@property # 装饰器
def unk(self): # 未知词元的索引为0
return 0
# 其实它是一个函数,但用 @property 包装后可以当变量用
@property
def token_freqs(self):
return self._token_freqs

使用时光机器数据集作为语料库来构建词表,然后打印前几个高频词元及其索引

1
2
vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[:10])
1
[('<unk>', 0), ('the', 1), ('i', 2), ('and', 3), ('of', 4), ('a', 5), ('to', 6), ('was', 7), ('in', 8), ('that', 9)]

现在可以将每一条文本行转换成一个数字索引列表

1
2
3
for i in [0, 10]:
print('文本:', tokens[i])
print('索引:', vocab[tokens[i]])
1
2
3
4
文本: ['the', 'time', 'machine', 'by', 'h', 'g', 'wells']
索引: [1, 19, 50, 40, 2183, 2184, 400]
文本: ['twinkled', 'and', 'his', 'usually', 'pale', 'face', 'was', 'flushed', 'and', 'animated', 'the']
索引: [2186, 3, 25, 1044, 362, 113, 7, 1421, 3, 1045, 1]

整合

将所有功能打包到load_corpus_time_machine函数中,该函数返回corpus(词元索引列表)和vocab(时光机器语料库的词表)

在这里做了一些改变:

  1. 为了简化训练,使用字符实现文本词元化(字符词表量级小,26个字母+空格)
  2. 时光机器数据集中的每个文本行不一定是一个句子或一个段落,还可能是一个单词,因此返回的corpus仅处理为单个列表,而不是使用多词元列表构成的一个列表
1
2
3
4
5
6
7
8
9
10
11
def load_corpus_time_machine(max_tokens=-1):  #@save
"""返回时光机器数据集的词元索引列表和词表"""
lines = read_time_machine()
tokens = tokenize(lines, 'char') # 每一行的文本被拆成一个个字母
vocab = Vocab(tokens)
# 因为时光机器数据集中的每个文本行不一定是一个句子或一个段落,
# 所以将所有文本行展平到一个列表中
corpus = [vocab[token] for line in tokens for token in line]
if max_tokens > 0:
corpus = corpus[:max_tokens]
return corpus, vocab # 字符索引的序列,词表对象
1
2
corpus, vocab = load_corpus_time_machine()
len(corpus), len(vocab) # (170580, 28)
1
2
print(vocab.idx_to_token)
# ['<unk>', ' ', 'e', 't', 'a', 'i', 'n', 'o', 's', 'h', 'r', 'd', 'l', 'm', 'u', 'c', 'f', 'w', 'g', 'y', 'p', 'b', 'v', 'k', 'x', 'z', 'j', 'q']

思考题

词元化是一个关键的预处理步骤,它因语言而异,尝试找到另外三种常用的词元化文本的方法

方法一:词级分词:直接以空格和标点作为分隔符,把句子切分成单词,最直观、最传统的方式

依赖空格分割,对中文、日文等无空格语言完全失效,标点、缩写(如H.G.)可能导致歧义,会造成词表巨大,模型容易出现未知词,一般用于入门级语言模型

方法二:子词分词,这是现代 NLP 模型最常用的方式,代表算法有:

  • BPE(Byte Pair Encoding)
  • WordPiece
  • SentencePiece

把单词拆成更小的、可重复组合的单元(子词 subword),高效,词表小、覆盖率高且与语言无关

但子词边界不总与语义边界对齐,实现复杂

方法三:中文分词,针对没有空格的语言,必须借助统计或机器学习方法决定词边界,常用工具:

  • jieba(结巴分词)
  • THULAC(清华大学)
  • HanLP(多语言自然语言处理库)

依赖词典,难以处理新词;不同分词标准会造成语义差异

语言模型和数据集

在给定文本序列时,语言模型的目标是估计序列的联合概率,一个理想的语言模型能够基于模型本身生成自然文本

学习语言模型

假设在单词级别对文本数据进行词元化,从基本概率规则开始
$$
P(x_1, x_2, \ldots, x_T) = \prod_{t=1}^T P(x_t \mid x_1, \ldots, x_{t-1}).
$$
包含了四个单词的一个文本序列的概率是:
$$
P(\text{deep}, \text{learning}, \text{is}, \text{fun}) = P(\text{deep}) P(\text{learning} \mid \text{deep}) P(\text{is} \mid \text{deep}, \text{learning}) P(\text{fun} \mid \text{deep}, \text{learning}, \text{is}).
$$
为了训练语言模型,需要计算单词的概率,以及在给定前面几个单词后,下一个单词出现的条件概率。这些概率就是语言模型的参数

假设训练数据集是一个大型的文本语料库,单词的概率可以用它在语料中的相对频率来近似计算

例如,单词 “deep” 的概率可以通过它在文本中出现的次数除以所有单词总数来估计
$$
\hat{P}( \text{deep}) = \frac{n(\text{deep})}{总词数}
$$
对于频繁出现的词这种方法不错,可以尝试估计
$$
\hat{P}(\text{learning} \mid \text{deep}) = \frac{n(\text{deep, learning})}{n(\text{deep})}
$$
其中$n(x)$和$n(x, x’)$分别是单个单词和连续单词对的出现次数

但“deep learning”这样连续词对出现得远比单个词少,因此这种估计在遇到罕见的词组时会不太可靠,因为样本太少,很难得到准确的概率

一种常见的策略是执行某种形式的拉普拉斯平滑(Laplace smoothing),具体方法是在所有计数中添加一个小常量

用$n$表示训练集中的单词总数,用$m$表示词表大小
$$
\begin{split}\begin{aligned}
\hat{P}(x) & = \frac{n(x) + \epsilon_1/m}{n + \epsilon_1}, \\
\hat{P}(x’ \mid x) & = \frac{n(x, x’) + \epsilon_2 \hat{P}(x’)}{n(x) + \epsilon_2}, \\
\hat{P}(x’’ \mid x,x’) & = \frac{n(x, x’,x’’) + \epsilon_3 \hat{P}(x’’)}{n(x, x’) + \epsilon_3}.
\end{aligned}\end{split}
$$
其中$\epsilon_1,\epsilon_2,\epsilon_3$为超参数,当$\epsilon_1 = 0$时不应用平滑,接近无穷大时$\hat{P}(x)$接近均匀概率分布$1/m$

然而这样的模型很容易变得无效

  • 需要存储所有的计数
  • 完全忽略单词的意思

因此一个模型如果只是简单地统计先前“看到”的单词序列频率,那么模型面对长单词序列问题肯定是表现不佳的

马尔可夫模型与n元语法

在语言建模中,若假设$P(x_{t+1} \mid x_t, \ldots, x_1) = P(x_{t+1} \mid x_t)$,则序列满足一阶马尔可夫性质,阶数越高,对应的依赖关系就越长

可以得到不同阶数下的近似形式:
$$
\begin{split}\begin{aligned}
P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2) P(x_3) P(x_4)(零阶)\\
P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2 \mid x_1) P(x_3 \mid x_2) P(x_4 \mid x_3)(一阶)\\
P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2 \mid x_1) P(x_3 \mid x_1, x_2) P(x_4 \mid x_2, x_3)(二阶)
\end{aligned}\end{split}
$$
阶数越高,模型捕捉到的上下文信息越多,但计算与存储开销也随之增加

通常,涉及一个、两个和三个变量的概率公式分别被称为一元语法(unigram)、二元语法(bigram)和三元语法(trigram)模型

自然语言统计

根据时光机器数据集构建词表,并打印前10个最常用的(频率最高的)单词

1
2
3
4
tokens = tokenize(read_time_machine())
corpus = [token for line in tokens for token in line] # 展平tokens
vocab = Vocab(corpus)
vocab.token_freqs[:10]
1
2
3
4
5
6
7
8
9
10
[('the', 2261),
('i', 1267),
('and', 1245),
('of', 1155),
('a', 816),
('to', 695),
('was', 552),
('in', 541),
('that', 443),
('my', 440)]

会发现,最多的词并没有意义,这些词通常被称为停用词(stop words),因此可以被过滤掉

还有个明显的问题是词频衰减的速度相当地快

可以画出词频图

1
2
3
4
5
6
7
8
freqs = [freq for token, freq in vocab.token_freqs] # 把频率单独拉出来
plt.figure(figsize=(6, 4))
plt.plot(freqs)
plt.xlabel('token: x')
plt.ylabel('freq: n(x)')
plt.xscale('log')
plt.yscale('log')
plt.grid()
202510301716

词频以一种明确的方式迅速衰减

将前几个单词作为例外消除后,剩余的所有单词大致遵循双对数坐标图上的一条直线

这意味着单词的频率满足齐普夫定律(Zipf’s law),即第$i$个最常用单词的频率$n_i$为
$$
n_i \propto \frac{1}{i^\alpha}
$$
等价于
$$
\log n_i = -\alpha \log i + c,
$$
所以通过计数统计和平滑来建模单词是不可行的,因为这样会大大高估尾部单词的频率,也就是所谓的不常用单词

对于二元语法

1
2
3
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])] # 相邻绑定
bigram_vocab = Vocab(bigram_tokens)
bigram_vocab.token_freqs[:10]
1
2
3
4
5
6
7
8
9
10
[(('of', 'the'), 309),
(('in', 'the'), 169),
(('i', 'had'), 130),
(('i', 'was'), 112),
(('and', 'the'), 109),
(('the', 'time'), 102),
(('it', 'was'), 99),
(('to', 'the'), 85),
(('as', 'i'), 78),
(('of', 'a'), 73)]

在十个最频繁的词对中,有九个是由两个停用词组成的,只有“the time”涵盖信息

再进一步看看三元语法的频率是否表现出相同的行为方式

1
2
3
trigram_tokens = [triple for triple in zip(corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = Vocab(trigram_tokens)
trigram_vocab.token_freqs[:10]
1
2
3
4
5
6
7
8
9
10
[(('the', 'time', 'traveller'), 59),
(('the', 'time', 'machine'), 30),
(('the', 'medical', 'man'), 24),
(('it', 'seemed', 'to'), 16),
(('it', 'was', 'a'), 15),
(('here', 'and', 'there'), 15),
(('seemed', 'to', 'me'), 14),
(('i', 'did', 'not'), 14),
(('i', 'saw', 'the'), 13),
(('i', 'began', 'to'), 13)]

直观地对比三种模型中的词元频率:一元语法、二元语法和三元语法

202510301951

这张图非常令人振奋!原因有很多:

  1. 除了一元语法词,单词序列似乎也遵循齐普夫定律,尽管公式中$\alpha$更小(指数大小受序列长度影响)
  2. 尽管可能的$n$元组数量理论上非常庞大,但在实际语料中却远小于理论上限,说明自然语言中存在强烈的结构规律性与约束,使得能够用模型有效地进行语言建模
  3. 大量的$n$元组几乎从未出现,这使得拉普拉斯平滑无法有效处理这种稀疏性,作为替代,将使用基于深度学习的模型

读取长序列数据

序列数据本质上是连续的,在建模时必须解决其长度不定的问题,当序列过长而无法被模型一次性处理时,通常会将其切分成多个较短的片段,以便模型逐段读取

在使用神经网络训练语言模型时,模型一次只能处理长度固定的小批量序列,需要设计一种方法,随机生成小批量的特征与标签对供模型训练

由于文本序列的长度可以任意,任意长序列可以被划分为若干个长度相同的子序列,每个小批量就由这些子序列组成,并输入模型进行学习

切分序列时的起始偏移量可以自由选择,不同的偏移量会产生不同的子序列划分方式,从而提高数据的多样性与模型的泛化能力

timemachine-5gram

可以从随机偏移量开始划分序列,以同时获得覆盖性和随机性

有两种策略:随机采样(random sampling)顺序分区(sequential partitioning)

随机采样

在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列

在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻

对于语言建模,目标是基于到目前为止看到的词元来预测下一个词元,因此标签是移位了一个词元的原始序列

下面的代码每次可以从数据中随机生成一个小批量,参数batch_size指定了每个小批量中子序列样本的数目,参数num_steps是模型在一次前向传播中看到的时间长度

并不是预测num_steps长度,都是用前一个预测后一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def seq_data_iter_random(corpus, batch_size, num_steps): # @save
"""使用随机抽样生成一个小批量子序列"""
# 从随机偏移量开始对序列进行分区,随机范围包括num_steps-1
corpus = corpus[random.randint(0, num_steps - 1):] # 随机偏移量起始,但不能超过num_steps
# 减去1,是因为需要考虑标签
num_subseqs = (len(corpus) - 1) // num_steps
# 长度为num_steps的子序列的起始索引
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
# 在随机抽样的迭代过程中,
# 来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻
random.shuffle(initial_indices) # 打乱起点

def data(pos):
# 返回从pos位置开始的长度为num_steps的序列
return corpus[pos: pos + num_steps]

num_batches = num_subseqs // batch_size
for i in range(0, batch_size * num_batches, batch_size):
initial_indices_per_batch = initial_indices[i : i+batch_size]
X = [data(j) for j in initial_indices_per_batch]
Y = [data(j+1) for j in initial_indices_per_batch] # 后移一位
yield torch.tensor(X), torch.tensor(Y)

生成一个0到34的序列,批量大小为2,时间步为5,可以生成(35-1)/5 = 6个“特征-标签”子序列对,所以只能有3个小批量

1
2
3
my_seq = list(range(35))
for X, Y in seq_data_iter_random(my_seq, batch_size=2, num_steps=5):
print('X: ', X, '\nY:', Y)
1
2
3
4
5
6
7
8
9
10
11
12
X:  tensor([[25, 26, 27, 28, 29],
[15, 16, 17, 18, 19]])
Y: tensor([[26, 27, 28, 29, 30],
[16, 17, 18, 19, 20]])
X: tensor([[10, 11, 12, 13, 14],
[20, 21, 22, 23, 24]])
Y: tensor([[11, 12, 13, 14, 15],
[21, 22, 23, 24, 25]])
X: tensor([[5, 6, 7, 8, 9],
[0, 1, 2, 3, 4]])
Y: tensor([[ 6, 7, 8, 9, 10],
[ 1, 2, 3, 4, 5]])

顺序分区

在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列

下面的代码每次可以从数据中随机生成一个小批量,参数batch_size指定了每个小批量中子序列样本的数目,参数num_steps是每次送入模型的时间步长度

1
2
3
4
5
6
7
8
9
10
11
12
13
def seq_data_iter_sequential(corpus, batch_size, num_steps):
"""使用顺序分区生成一个小批量子序列"""
# 从随机偏移量开始划分序列
offset = random.randint(0,num_steps) # 这里允许偏移等于一个完整的时间窗口
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
Xs = torch.tensor(corpus[offset: offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1: offset + num_tokens + 1])
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
num_batches = Xs.shape[1] // num_steps
for i in range(0, num_steps * num_batches, num_steps):
X = Xs[:, i: i + num_steps]
Y = Ys[:, i: i + num_steps]
yield X, Y

基于相同的设置,通过顺序分区读取每个小批量的子序列的特征X和标签Y

将它们打印出来可以发现:迭代期间来自两个相邻的小批量中的子序列在原始序列中确实是相邻的

1
2
for X, Y in seq_data_iter_sequential(my_seq, batch_size=2, num_steps=5):
print('X: ', X, '\nY:', Y)
1
2
3
4
5
6
7
8
9
10
11
12
X:  tensor([[ 4,  5,  6,  7,  8],
[19, 20, 21, 22, 23]])
Y: tensor([[ 5, 6, 7, 8, 9],
[20, 21, 22, 23, 24]])
X: tensor([[ 9, 10, 11, 12, 13],
[24, 25, 26, 27, 28]])
Y: tensor([[10, 11, 12, 13, 14],
[25, 26, 27, 28, 29]])
X: tensor([[14, 15, 16, 17, 18],
[29, 30, 31, 32, 33]])
Y: tensor([[15, 16, 17, 18, 19],
[30, 31, 32, 33, 34]])

将上面的两个采样函数包装到一个类中, 以便稍后可以将其用作数据迭代器

1
2
3
4
5
6
7
8
9
10
11
12
class SeqDataLoader:  #@save
"""加载序列数据的迭代器"""
def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
if use_random_iter:
self.data_iter_fn = seq_data_iter_random
else:
self.data_iter_fn = seq_data_iter_sequential
self.corpus, self.vocab = load_corpus_time_machine(max_tokens)
self.batch_size, self.num_steps = batch_size, num_steps

def __iter__(self):
return self.data_iter_fn(self.corpus, self.batch_size, self.num_steps)

定义了一个函数load_data_time_machine,它同时返回数据迭代器和词表,因此可以与其他带有load_data前缀的函数类似地使用

1
2
3
4
5
6
def load_data_time_machine(batch_size, num_steps,  #@save
use_random_iter=False, max_tokens=10000):
"""返回时光机器数据集的迭代器和词表"""
data_iter = SeqDataLoader(
batch_size, num_steps, use_random_iter, max_tokens)
return data_iter, data_iter.vocab

循环神经网络

在n元语法模型中,假设单词$x_t$的出现仅依赖于前面$n-1$个单词
$$
P(x_t \mid x_{t-1}, \ldots, x_1) \approx P(x_t \mid x_{t-1},\cdots ,x_{t-n+1}),
$$
如果希望模型考虑更长的上下文,就必须增大$n$

这样虽然能捕捉更复杂的语言结构,但模型的参数量会急剧增加,因为词表需要存储$\mid \mathcal{V}\mid ^n$个概率值,当词表很大时,这几乎无法计算与存储

为了解决这个问题,可以用一个隐变量模型进行近似
$$
P(x_t \mid x_{t-1}, \ldots, x_1) \approx P(x_t \mid h_{t-1})
$$
其中$h_{t-1}$是隐状态(hidden state),也称为隐藏变量(hidden variable),用于总结截至时间步$t-1$的全部上下文信息

在每一步,模型都会更新这个隐藏状态$h_t = f(x_{t}, h_{t-1})$

从而以固定大小的参数捕捉潜在的语言依赖关系

隐藏层和隐状态指的是两个截然不同的概念,隐藏层是在从输入到输出的路径上(以观测角度来理解)的隐藏的层,而隐状态则是在给定步骤所做的任何事情的输入,并且这些状态只能通过先前时间步的数据来计算

**循环神经网络(recurrent neural networks,RNNs)**是具有隐状态的神经网络

隐状态

假设在时间步$t$有小批量输入$\mathbf{X}_t \in \mathbb{R}^{n \times d}$,用$\mathbf{H}_t \in \mathbb{R}^{n \times h}$示时间步的隐藏变量

与多层感知机不同的是,在这里保存了前一个时间步的隐藏变量$\mathbf H_{t-1}$,并引入了一个新的权重参数$\mathbf W_{hh} \in \mathbb R^{h \times h}$,来描述如何在当前时间步中使用前一个时间步的隐藏变量

具体地说,当前时间步隐藏变量由当前时间步的输入与前一个时间步的隐藏变量一起计算得出:
$$
\mathbf H_t = \phi(\mathbf X_t \mathbf W_{xh} + \mathbf H_{t-1} \mathbf W_{hh} + \mathbf b_h)
$$
多添加了一项$\mathbf H_{t-1} \mathbf W_{hh}$,这些变量捕获并保留了序列直到其当前时间步的历史信息,这样的隐藏变量被称为隐状态(hidden state)

隐状态使用的定义与前一个时间步中使用的定义相同,因此计算是循环的,在循环神经网络中执行循环计算的层称为循环层(recurrent layer)

输出层的输出类似于多层感知机中的计算
$$
\mathbf O_t = \mathbf H_t \mathbf W_{hq} + \mathbf b_q.
$$
即使在不同的时间步,循环神经网络也总是使用同样的模型参数,因此循环神经网络的参数开销不会随着时间步的增加而增加

下图展示了循环神经网络在三个相邻时间步的计算逻辑

rnn

在任意时间步隐状态的计算可以被视为:

  1. 拼接当前时间步$t$的输入$\mathbf X_t$和前一时间步$t-1$的隐状态$\mathbf H_{t-1}$
  2. 将拼接的结果送入带有激活函数$\phi$的全连接层,全连接层的输出是当前时间步$t$的隐状态$\mathbf H_{t}$

隐状态中$\mathbf X_t \mathbf W_{xh} + \mathbf H_{t-1} \mathbf W_{hh}$的计算,相当于$\mathbf X_t$和$\mathbf H_{t-1}$的拼接与$\mathbf W_{xh}$和$\mathbf W_{hh}$的拼接的矩阵乘法

用一段简单代码示意

1
2
3
X, W_xh = torch.normal(0, 1, (3, 1)), torch.normal(0, 1, (1, 4))
H, W_hh = torch.normal(0, 1, (3, 4)), torch.normal(0, 1, (4, 4))
torch.matmul(X, W_xh) + torch.matmul(H, W_hh)
1
2
3
tensor([[-1.1493,  6.7741, -5.4517,  0.2577],
[ 2.3512, 1.1124, 0.5166, 0.8442],
[ 2.5654, 1.5250, -0.3778, 1.3976]])

沿列(轴1)拼接矩阵XH,沿行(轴0)拼接矩阵W_xhW_hh

这两个拼接分别产生形状(3,5)和形状(5,4)的矩阵,将这两个拼接的矩阵相乘,得到与上面相同形状(3,4)的输出矩阵

1
torch.matmul(torch.cat((X, H), 1), torch.cat((W_xh, W_hh), 0))
1
2
3
tensor([[-1.1493,  6.7741, -5.4517,  0.2577],
[ 2.3512, 1.1124, 0.5166, 0.8442],
[ 2.5654, 1.5250, -0.3778, 1.3976]])

字符级语言模型

目标是根据过去的和当前的词元预测下一个词元,因此将原始序列移位一个词元作为标签

Bengio等人首先提出使用神经网络进行语言建模 (Bengio et al., 2003)

设小批量大小为1,批量中的文本序列为“machine”,为了简化后续部分的训练,考虑使用字符级语言模型, 将文本词元化为字符而不是单词

下图演示了如何通过基于字符级语言建模的循环神经网络,使用当前的和先前的字符预测下一个字符

rnn-train

输入序列和标签序列分别为“machin”和“achine”

在训练过程中,对每个时间步的输出层的输出进行softmax操作,然后利用交叉熵损失计算模型输出和标签之间的误差

在实践中使用的批量大小$n>1$,每个词元都由一个$d$维向量表示,在时间步$t$输入$X_t$将是一个$n\times d$矩阵

困惑度(Perplexity)

可以通过计算序列的似然概率来度量模型的质量,然而这是一个难以理解、难以比较的数字,因为较短的序列比较长的序列更有可能出现

一个更好的语言模型应该能更准确地预测下一个词元,因此它应该允许压缩序列时花费更少的比特

可以通过一个序列中所有的$n$个词元的交叉熵损失的平均值来衡量
$$
\frac{1}{n} \sum_{t=1}^n -\log P(x_t \mid x_{t-1}, \ldots, x_1)
$$
这使得不同长度的文档的性能具有了可比性

但是自然语言处理的科学家更喜欢使用一个叫做**困惑度(perplexity)**的量,是交叉熵损失的指数
$$
\exp\left(-\frac{1}{n} \sum_{t=1}^n \log P(x_t \mid x_{t-1}, \ldots, x_1)\right).
$$
困惑度可以理解为模型在预测下一个词元时,实际有效选择数量的调和平均数,值越小,模型越自信

  • 理想情况:模型总能完美预测正确词元,模型的困惑度为1,没有任何困惑
  • 最坏情况:模型总是把正确词元的概率估计为0,困惑度趋于无穷,模型失败
  • 基线(均匀分布):如果模型认为所有词元的概率相同,困惑度等于词表大小,相当于盲猜,因此这种方式提供了一个重要的上限$\mid \mathcal{V}\mid$,而任何实际模型都必须超越这个上限

思考题

  1. 如果使用循环神经网络来预测文本序列中的下一个字符,那么任意输出所需的维度是多少?

    模型的输出是对所有可能字符的概率分布,所以输出的维度等于词表中字符的数量

  2. 如果基于一个长序列进行反向传播,梯度会发生什么状况?

    当基于一个很长的序列进行反向传播时,梯度会在时间维度上反复被权重矩阵和激活函数的导数相乘,很可能会出现梯度消失或梯度爆炸,因为如果权重稍小,连乘就容易湮灭,如果权重稍大,容易爆炸,需要 LSTM、GRU 等结构来稳定训练

循环神经网络底层实现

1
2
batch_size, num_steps = 32, 35
train_iter, vocab = load_data_time_machine(batch_size, num_steps)

独热编码

将每个索引映射为相互不同的单位向量,假如词元的索引是整数$i$,创建长度为N的全0向量,并在$i$处设为1,获得独热向量,在0和2处创建独热向量举例:

1
F.one_hot(torch.tensor([0, 2]), len(vocab))

每次采样的小批量数据形状为(batch_size,num_steps),one_hot函数将小批量数据转换成三维张量,张量的最后一个维度等于词表大小(vocab_size)

经常转换输入的维度,获得形状为(num_steps,batch_size,vocab_size)的输出,将能够更方便地通过最外层的维度,一步一步地更新小批量数据的隐状态

初始化模型参数

隐藏单元数num_hiddens是一个可调的超参数,当训练语言模型时,输入和输出来自相同的词表,具有相同的维度,即词表的大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def get_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size

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

网络结构

为了定义循环神经网络模型,首先需要一个init_rnn_state函数在初始化时返回隐状态

函数的返回值是一个张量,全0填充,形状为(batch_size,num_hiddens)

隐状态可能包含多个变量,而使用元组可以更容易地处理些

1
2
3
def init_rnn_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )
# 加了一个逗号,返回值就变成了一个单元素元组

下面的rnn函数定义了如何在一个时间步内计算隐状态和输出

循环神经网络模型通过inputs最外层的维度实现循环,以便逐时间步更新小批量数据的隐状态H

这里使用tanh函数作为激活函数,平均值为0

如果用ReLU,虽然缓解了梯度消失问题,却让梯度爆炸更容易出现,更需要梯度裁剪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def rnn(inputs, state, params):
# inputs: (时间步数, 批量大小, 词表大小)
# state: 隐藏状态 (H,)
W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
# X:(batch_size, vocab_size)
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,)
# torch.cat(outputs, dim=0) 把小张量在时间维拼起来
# (H,)返回最终隐藏状态,作为下一个序列的初始状态

对应
$$
\mathbf H_t = \phi(\mathbf X_t \mathbf W_{xh} + \mathbf H_{t-1} \mathbf W_{hh} + \mathbf b_h)\\
\mathbf O_t = \mathbf H_t \mathbf W_{hq} + \mathbf b_q.
$$
定义了所有需要的函数之后,接下来创建一个类来包装这些函数,并存储从零开始实现的循环神经网络模型的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
self.params = get_params(vocab_size, num_hiddens, device)
self.init_state, self.forward_fn = init_state, forward_fn

def __call__(self, X, state): # 前向传播方法
# 先转置变成(num_steps, batch_size),方便按时间步迭代
X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
# 传入forward_fn函数
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)

检查输出是否具有正确的形状,例如,隐状态的维数是否保持不变

1
2
3
4
5
6
7
num_hiddens = 512
X = torch.arange(10).reshape((2, 5))
net = RNNModelScratch(len(vocab), num_hiddens, try_gpu(), get_params,
init_rnn_state, rnn)
state = net.begin_state(X.shape[0], try_gpu())
Y, new_state = net(X.to(try_gpu()), state)
Y.shape, len(new_state), new_state[0].shape
1
(torch.Size([10, 28]), 1, torch.Size([2, 512]))

输出形状是(时间步数×批量大小,词表大小),隐状态形状保持不变(批量大小,隐藏单元数)

预测

首先定义预测函数来生成prefix之后的新字符,其中的prefix是一个用户提供的包含多个字符的字符串,在循环遍历prefix中的开始字符时,不断地将隐状态传递到下一个时间步,但是不生成任何输出

这被称为**预热(warm-up)**期,在此期间模型会自我更新,但不会进行预测

预热期结束后,隐状态的值通常比刚开始的初始值更适合预测,从而预测字符并输出它们

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def predict_ch8(prefix, num_preds, net, vocab, device):  #@save
"""在prefix后面生成新字符"""
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:]: # 预热期
_, state = net(get_input(), state)
outputs.append(vocab[y])
# 正式预测阶段
for _ in range(num_preds): # 预测num_preds步
y, state = net(get_input(), state)
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])

将前缀指定为time traveller,并基于这个前缀生成10个后续字符

还没有训练网络,它会生成荒谬的预测结果

1
predict_ch8('time traveller ', 10, net, vocab, try_gpu())
1
'time traveller pycscscscs'

梯度裁剪

对于长度为$T$序列,在迭代中计算这$T$个时间步上的梯度,将会在反向传播过程中产生长度为$\mathcal{O}(T)$的矩阵乘法链,为了避免$T$较大时导致不稳定,需要额外的方式来支持稳定训练

如果假设目标函数$f$表现良好,在常数下是利普希茨连续的(Lipschitz continuous),对于任意$x,y$有:
$$
|f(\mathbf{x}) - f(\mathbf{y})| \leq L \mid\mid\mathbf{x} - \mathbf{y}\mid\mid
$$
这时可以安全假设
$$
|f(\mathbf{x}) - f(\mathbf{x} - \eta\mathbf{g})| \leq L \eta||\mathbf{g}||
$$
这意味着不会观察到超过$L \eta ||\mathbf{g}||$的变化,这限制了变化大小

有时梯度可能很大,从而优化算法可能无法收敛,可以通过降低学习率来解决这个问题,但这种情况很少发生,一直使用较小的学习率就会让训练速度变得很慢

一个流行的替代方案是通过将梯度$\mathbf{g}$投影回给定半径$\theta$的球来裁剪梯度
$$
\mathbf{g} \leftarrow \min\left(1, \frac{\theta}{||\mathbf{g}||}\right) \mathbf{g}.
$$
这样做梯度的范数永远不会大于$\theta$,更新后的梯度方向与原始梯度保持一致,并且通过限制每个小批量对参数更新的影响,模型的训练过程会更加稳定

梯度裁剪提供了一个快速修复梯度爆炸的方法,虽然它并不能完全解决问题,但它是众多有效的技术之一

定义一个函数来裁剪模型的梯度

1
2
3
4
5
6
7
8
9
10
def grad_clipping(net, theta):  #@save
"""裁剪梯度"""
if isinstance(net, nn.Module):
params = [p for p in net.parameters() if p.requires_grad]
else:
params = net.params
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
if norm > theta:
for param in params:
param.grad[:] *= theta / norm

训练

与之前的训练方式有所不同

  1. 序列数据的不同采样方法(随机采样和顺序分区)将导致隐状态初始化的差异
  2. 在更新模型参数之前裁剪梯度,即使训练过程中某个点上发生了梯度爆炸,也能保证模型不会发散
  3. 用困惑度来评价模型

在顺序分区采样中,只在每个迭代周期开始时初始化隐藏状态,因为下一个小批量与上一个在时间上是连续的,所以上一个小批量最后一个时间步的隐藏状态会被用作下一个小批量的初始状态

但这样在任何一点隐状态的计算都依赖于同一迭代周期中前面所有的小批量数据,这使得梯度计算变得复杂

为了降低计算难度,通常在处理每个小批量数据前切断梯度的反向传播,让隐藏状态的梯度计算仅限于当前小批量的时间范围内

而当使用随机采样时,由于每个小批量样本之间没有时间连续性,必须在每次迭代开始时重新初始化隐藏状态

updater是更新模型参数的常用函数,既可以是自定义函数,也可以是深度学习框架中内置的优化函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#@save
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
"""训练网络一个迭代周期(定义见第8章)"""
state, timer = None, Timer()
metric = Accumulator(2) # 训练损失之和,词元数量
for X, Y in train_iter:
if state is None or use_random_iter:
# 在第一次迭代或使用随机抽样时初始化state
state = net.begin_state(batch_size=X.shape[0], device=device)
else:
if isinstance(net, nn.Module) and not isinstance(state, tuple):
# state对于nn.GRU是个张量
state.detach_()
else:
# state对于nn.LSTM或对于我们从零开始实现的模型是个张量
for s in state:
s.detach_()
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()
if isinstance(updater, torch.optim.Optimizer):
updater.zero_grad()
l.backward()
grad_clipping(net, 1)
updater.step()
else:
l.backward()
grad_clipping(net, 1)
# 因为已经调用了mean函数
updater(batch_size=1)
metric.add(l * y.numel(), y.numel())
return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()

循环神经网络模型的训练函数既支持从零开始实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#@save
def train_ch8(net, train_iter, vocab, lr, num_epochs, device,
use_random_iter=False):
"""训练模型(定义见第8章)"""
loss = nn.CrossEntropyLoss()
animator = Animator(xlabel='epoch', ylabel='perplexity',
legend=['train'], xlim=[10, num_epochs])
# 初始化
if isinstance(net, nn.Module):
updater = torch.optim.SGD(net.parameters(), lr)
else:
updater = lambda batch_size: 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)
if (epoch + 1) % 10 == 0:
print(predict('time traveller'))
animator.add(epoch + 1, [ppl])
animator.show()
print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')
print(predict('time traveller'))
print(predict('traveller'))

训练循环神经网络模型,因为在数据集中只使用了10000个词元,所以模型需要更多的迭代周期来更好地收敛

202510302311
1
2
3
困惑度 1.0, 81491.4 词元/秒 cpu
time traveller with a slight accession ofcheerfulness really thi
travelleryou can show black is white by argument said filby

检查一下使用随机抽样方法的结果

image-20251030231642428
1
2
3
困惑度 1.4, 81358.9 词元/秒 cpu
time traveller smiled abe y ut to she ot soee pincandescendlight
travellerit s against reason said filbywhat for thishing to

输出结果都很奇怪

循环神经网络的简洁实现

1
2
batch_size, num_steps = 32, 35
train_iter, vocab = load_data_time_machine(batch_size, num_steps)

定义模型

高级API提供了循环神经网络的实现,构造一个具有256个隐藏单元的单隐藏层的循环神经网络层rnn_layer

1
2
num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)

使用张量来初始化隐状态,它的形状是(隐藏层数,批量大小,隐藏单元数)

1
2
state = torch.zeros((1, batch_size, num_hiddens))
state.shape # torch.Size([1, 32, 256])

通过一个隐状态和一个输入,就可以用更新后的隐状态计算输出

rnn_layer的“输出”(Y)不涉及输出层的计算:它是指每个时间步的隐状态,这些隐状态可以用作后续输出层的输入

输出 含义 形状
Y 每个时间步的输出序列 (num_steps, batch_size, num_hiddens)
state_new 最后一个时间步的隐藏状态 (num_layers, batch_size, num_hiddens)
1
2
3
4
X = torch.rand(size=(num_steps, batch_size, len(vocab))) # 时间步数,样本数,词表大小
Y, state_new = rnn_layer(X, state)
Y.shape, state_new.shape
# (torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))

为一个完整的循环神经网络模型定义了一个RNNModel类,注意rnn_layer只包含隐藏的循环层,还需要创建一个单独的输出层

模块 功能 输入形状 输出形状
One-hot 编码 把索引变成向量 (num_steps, batch_size) (num_steps, batch_size, vocab_size)
RNN 层 提取时序特征 上一步输出 (num_steps, batch_size, num_hiddens)
Linear 层 预测下一个词 (num_steps * batch_size, num_hiddens) (num_steps * batch_size, vocab_size)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#@save
class RNNModel(nn.Module):
"""循环神经网络模型"""
def __init__(self, rnn_layer, vocab_size, **kwargs):
super().__init__(**kwargs) # 允许传入额外参数,灵活扩展
self.rnn = rnn_layer # 传入已经定义好的循环层
self.vocab_size = vocab_size # 词表大小(用于输出层维度)
self.num_hiddens = self.rnn.hidden_size # 隐藏层的维度
# 判断是否双向,与输出维度有关
# RNN 负责“记忆”,Linear 负责“说出预测结果”
if not self.rnn.bidirectional:
self.num_directions = 1
self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
else:
self.num_directions = 2
self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)

def forward(self, inputs, state):
# 输入 inputs 的形状是 (batch_size, num_steps)
X = F.one_hot(inputs.T.long(), self.vocab_size)
X = X.to(torch.float32)
Y, state = self.rnn(X, state)
# Y:每个时间步的输出(num_steps, batch_size, num_hiddens * num_directions)
# 全连接层首先将Y的形状改为(时间步数*批量大小,隐藏单元数)
# 输出形状是(时间步数*批量大小,词表大小)。
output = self.linear(Y.reshape((-1, Y.shape[-1])))
return output, state

def begin_state(self, device, batch_size=1):
if not isinstance(self.rnn, nn.LSTM):
# nn.GRU以张量作为隐状态
return torch.zeros((self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens),
device=device)
else:
# nn.LSTM以元组作为隐状态
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))

训练与预测

1
2
3
4
5
device = try_gpu()
net = RNNModel(rnn_layer, vocab_size=len(vocab))
net = net.to(device)
num_epochs, lr = 500, 1
train_ch8(net, train_iter, vocab, lr, num_epochs, device)
image-20251030233533524
1
2
3
困惑度 1.3, 196616.9 词元/秒 cpu
time traveller but now you begin to seethe object of my investig
traveller pores ththo dimensions are pussing ain time ascon

与刚刚自定义的随机抽样方法相比,由于深度学习框架的高级API对代码进行了更多的优化,该模型在较短的时间内达到了较低的困惑度

通过时间反向传播

RNN的训练基于时间反向传播(backpropagation through time,BPTT)(Werbos, 1990),利用链式法则计算序列中每个时间步的梯度

要求将循环神经网络的计算图一次展开一个时间步,以获得模型变量和参数之间的依赖关系

但在长序列中,梯度会出现爆炸或消失,因此在实践中通过截断传播与梯度裁剪来保证模型稳定收敛

梯度分析

输入和隐状态可以拼接后与隐藏层中的一个权重变量相乘,分别使用$w_h$和$w_o$来表示隐藏层和输出层的权重,每个时间步的隐状态和输出可以写为:
$$
\begin{split}\begin{aligned}h_t &= f(x_t, h_{t-1}, w_h),\\o_t &= g(h_t, w_o),\end{aligned}\end{split}
$$
有一个链${\ldots, (x_{t-1}, h_{t-1}, o_{t-1}), (x_{t}, h_{t}, o_t), \ldots}$通过循环计算彼此依赖,前向传播相当简单,一次一个时间步的遍历三元组,然后通过一个目标函数在所有$T$个时间步内评估输出和对应的标签之间的差异
$$
L(x_1, \ldots, x_T, y_1, \ldots, y_T, w_h, w_o) = \frac{1}{T}\sum_{t=1}^T l(y_t, o_t).
$$
对于反向传播需要根据链式法则:
$$
\begin{split}\begin{aligned}\frac{\partial L}{\partial w_h} & = \frac{1}{T}\sum_{t=1}^T \frac{\partial l(y_t, o_t)}{\partial w_h} \\& = \frac{1}{T}\sum_{t=1}^T \frac{\partial l(y_t, o_t)}{\partial o_t} \frac{\partial g(h_t, w_o)}{\partial h_t} \frac{\partial h_t}{\partial w_h}.\end{aligned}\end{split}
$$
乘积的第一项和第二项很容易计算,而第三项是困难所在,需要循环地计算参数$w_h$对$h_t$的影响

$h_t$既依赖于$h_{t-1}$又依赖于$w_h$,$h_{t-1}$的计算也依赖于$w_h$,使用链式法则产生:
$$
\frac{\partial h_t}{\partial w_h}= \frac{\partial f(x_{t},h_{t-1},w_h)}{\partial w_h} +\frac{\partial f(x_{t},h_{t-1},w_h)}{\partial h_{t-1}} \frac{\partial h_{t-1}}{\partial w_h}.
$$
假设有三个序列${a_{t}},{b_{t}},{c_{t}}$,当$t=1,2,\ldots$时,序列满足$a_{0}=0$且$a_{t}=b_{t}+c_{t}a_{t-1}$,对于$t\geq 1$就很容易得出:
$$
a_{t}=b_{t}+\sum_{i=1}^{t-1}\left(\prod_{j=i+1}^{t}c_{j}\right)b_{i}.
$$
基于下列公式替换
$$
\begin{split}\begin{aligned}a_t &= \frac{\partial h_t}{\partial w_h},\\
b_t &= \frac{\partial f(x_{t},h_{t-1},w_h)}{\partial w_h}, \\
c_t &= \frac{\partial f(x_{t},h_{t-1},w_h)}{\partial h_{t-1}},\end{aligned}\end{split}
$$
所以刚刚那个复杂的链式法则可以转换为
$$
\frac{\partial h_t}{\partial w_h}=\frac{\partial f(x_{t},h_{t-1},w_h)}{\partial w_h}+\sum_{i=1}^{t-1}\left(\prod_{j=i+1}^{t} \frac{\partial f(x_{j},h_{j-1},w_h)}{\partial h_{j-1}} \right) \frac{\partial f(x_{i},h_{i-1},w_h)}{\partial w_h}.
$$
虽然这样可以计算了,但是$t$很大时这个链会很长

截断时间步

在$\tau$步后截断求和计算,在实践中这种方式工作得很好,通常被称为截断的通过时间反向传播 (Jaeger, 2002)

这样做导致该模型主要侧重于短期影响,而不是长期影响,这在现实中是可取的,因为它会将估计值偏向更简单和更稳定的模型

随机截断

可以用一个随机变量替换$\partial h_t/\partial w_h$,该随机变量在预期中是正确的,但是会截断序列

通过使用序列$\xi_t$来实现,$0 \leq \pi_t \leq 1$,$P(\xi_t = 0) = 1-\pi_t$,期望$E[\xi_t] = 1$,保证整个过程在期望意义上仍是无偏估计

使用它来替换$\partial h_t/\partial w_h$
$$
z_t= \frac{\partial f(x_{t},h_{t-1},w_h)}{\partial w_h} +\xi_t \frac{\partial f(x_{t},h_{t-1},w_h)}{\partial h_{t-1}} \frac{\partial h_{t-1}}{\partial w_h}.
$$
每当$\xi_t = 0$时,递归计算终止在这个时间步,这导致了不同长度序列的加权和,长序列的梯度被截断得更频繁,短序列则更多被完整传播,因此这种方法相当于自适应加权不同长度的梯度贡献

这个想法是由塔莱克和奥利维尔(Tallec and Ollivier, 2017)提出的

比较策略

比较RNN中计算梯度的策略,3行自上而下分别为:随机截断、常规截断、完整计算

truncated-bptt
  • 随机截断:将文本划分为不同长度的片断
  • 常规截断:将文本分解为相同长度的子序列,这也是在循环神经网络实验中一直在做的
  • 通过时间的完全反向传播:产生了在计算上不可行的表达式

虽然随机截断在理论上具有吸引力,但很可能是由于多种因素在实践中并不比常规截断更好

门控循环单元(GRU)

普通 RNN 缺乏对“重要信息的记忆”和“无关信息的屏蔽”能力

三类经典问题:

  • 需要长期记忆:早期观测(如第一个词元)对后续预测至关重要,普通 RNN 难以让这种早期信息在数百步后仍然“保留”,这会导致梯度必须极大才能维持影响,从而引发梯度爆炸
  • 需要遗忘无关信息:某些输入与任务无关,例如网页文本中的 HTML 标签,RNN没法“跳过”这些噪声,它会无差别地更新隐状态,让无意义的信息污染记忆,降低模型性能,需要选择性屏蔽
  • 需要重置记忆:序列中存在逻辑中断或上下文变化,旧的隐藏状态可能对新片段产生负面干扰,需要自适应地清空或重置内部状态

在学术界已经提出了许多方法来解决这类问题,其中最早的方法是“长短期记忆”(long-short-term memory,LSTM) (Hochreiter and Schmidhuber, 1997),门控循环单元(gated recurrent unit,GRU)(Cho et al., 2014) 是一个稍微简化的变体,通常能够提供同等的效果,并且计算(Chung et al., 2014)的速度明显更快

门控隐状态

门控循环单元(GRU)与普通循环神经网络的主要区别在于:它增加了门控机制,能自动学习在什么时候更新或重置隐藏状态,从而更好地控制信息的保留与遗忘

重置门和更新门

**重置门(reset gate)更新门(update gate)**是$(0,1)$区间的向量,可以对旧状态和新状态进行加权融合(凸组合)

重置门控制要保留多少来自过去的记忆;更新门控制当前状态中有多少直接来自旧状态

下图描述了门控循环单元中的重置门和更新门的输入,输入是当前输入和前一时刻的隐藏状态,输出由带sigmoid激活函数的全连接层计算得到

gru-1

假设输入是一个小批量$\mathbf X_t \in \mathbb R^{n \times d}$,样本个数为$n$,输入特征维度$d$,上一个时间步的隐状态是$\mathbf H_{t-1} \in \mathbb R^{n \times h}$

那么重置门$\mathbf R_t \in \mathbb R^{n \times h}$和更新门$\mathbf Z_t \in \mathbb{R}^{n \times h}$的计算如下所示
$$
\begin{split}\begin{aligned}
\mathbf R_t = \sigma(\mathbf X_t \mathbf W_{xr} + \mathbf H_{t-1} \mathbf W_{hr} + \mathbf b_r)\\
\mathbf Z_t = \sigma(\mathbf X_t \mathbf W_{xz} + \mathbf H_{t-1} \mathbf W_{hz} + \mathbf b_z)
\end{aligned}\end{split}
$$
其中$\mathbf W_{xr}, \mathbf W_{xz} \in \mathbb{R}^{d \times h}$和$\mathbf W_{hr}, \mathbf W_{hz} \in \mathbb{R}^{h \times h}$是权重参数,$\mathbf b_r, \mathbf b_z \in \mathbb{R}^{1 \times h}$是偏置参数,使用sigmoid函数将输出值转换到区间$(0,1)$

候选隐状态

将重置门$\mathbf R_t$与常规隐状态更新机制集成,得到在时间步$t$的候选隐状态(candidate hidden state)$\tilde H_t \in \mathbb R^{n \times h}$
$$
\tilde H_t = \tanh(\mathbf X_t \mathbf W_{xh} + \left(\mathbf R_t \odot \mathbf H_{t-1}\right) \mathbf W_{hh} + \mathbf b_h),
$$
符号$\odot$是Hadamard积(按元素乘积)运算符,使用tanh非线性激活函数来确保候选隐状态中的值保持在区间$(-1,1)$

$\mathbf R_t$和$\mathbf H_{t-1}$的元素相乘可以减少以往状态的影响

  • 重置门$\mathbf R_t$中的项接近1时,恢复普通的循环神经网络
  • 重置门$\mathbf R_t$中的项接近0时,候选隐状态是以$\mathbf X_t$作为输入的多层感知机的结果,因此任何预先存在的隐状态都会被重置为默认值

下图说明了应用重置门之后的计算流程

gru-2

隐状态

上述的计算结果只是候选隐状态,仍然需要结合更新门$\mathbf Z_t$的效果,这一步确定新的隐状态$\mathbf H_t \in \mathbb{R}^{n \times h}$在多大程度上来自旧的状态$\mathbf H_{t-1}$和新的候选状态$\tilde H_t $,更新门仅实现凸组合,得出了门控循环单元的最终更新公式:
$$
\mathbf H_t = \mathbf Z_t \odot \mathbf H_{t-1} + (1 - \mathbf Z_t) \odot \tilde{\mathbf H}_t.
$$

  • 更新门$\mathbf Z_t$接近1时,模型就倾向只保留旧状态,来自$\mathbf X_t$的信息被忽略,从而有效地跳过了依赖链条中的时间步
  • 更新门$\mathbf Z_t$接近0时,新的隐状态就会接近候选隐状态

这些设计可以帮助处理循环神经网络中的梯度消失问题,并更好地捕获时间步距离很长的序列的依赖关系

如果整个子序列的所有时间步的更新门都接近于1,无论序列的长度如何,在序列起始时间步的隐状态将很容易保留并传递到序列结束

下图说明了更新门起作用后的计算流

gru-3

总结:

门控循环单元具有以下两个显著特征

  • 重置门有助于捕获序列中的短期依赖关系
  • 更新门有助于捕获序列中的长期依赖关系

底层实现

读取之前使用的时间机器数据集:

1
2
batch_size, num_steps = 32, 35
train_iter, vocab = load_data_time_machine(batch_size, num_steps)

初始化模型参数

标准差为0.01的高斯分布中提取权重,并将偏置项设为0,超参数num_hiddens定义隐藏单元的数量, 实例化与更新门、重置门、候选隐状态和输出层相关的所有权重和偏置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def get_params(vocab_size, num_hiddens, device):
# 输入和输出的维度都等于词表大小(因为这是语言模型)
num_inputs = num_outputs = vocab_size

def normal(shape):
# 从标准正态分布采样,并缩放为较小的随机值(标准差=0.01)
return torch.randn(size=shape, device=device) * 0.01

def three():
# 返回一组循环层参数:(W_x, W_h, b)
# 分别对应:输入权重矩阵、隐藏状态权重矩阵、偏置项
return (normal((num_inputs, num_hiddens)), # W_x* — 输入到隐藏层
normal((num_hiddens, num_hiddens)), # W_h* — 隐藏层到隐藏层
torch.zeros(num_hiddens, device=device)) # b* — 偏置项

# ---- 门控循环单元的参数 ----
# 更新门 (update gate) 参数
W_xz, W_hz, b_z = three()
# 重置门 (reset gate) 参数
W_xr, W_hr, b_r = three()
# 候选隐状态 (candidate hidden state) 参数
W_xh, W_hh, b_h = three()

# 输出层参数(将隐藏状态映射到词表维度)
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)

# ---- 收集全部可训练参数 ----
params = [W_xz, W_hz, b_z,
W_xr, W_hr, b_r,
W_xh, W_hh, b_h,
W_hq, b_q]

# 启用自动梯度跟踪
for param in params:
param.requires_grad_(True)

return params

定义模型

定义隐状态的初始化函数init_gru_state,与之前定义的init_rnn_state函数一样,此函数返回一个形状为(批量大小,隐藏单元个数)的张量,张量的值全部为零

1
2
def init_gru_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )

准备定义门控循环单元模型,模型的架构与基本的循环神经网络单元是相同的,只是权重更新公式更为复杂

1
2
3
4
5
6
7
8
9
10
11
12
def gru(inputs, state, params):
W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
for X in inputs:
Z = torch.sigmoid((X @ W_xz) + (H @ W_hz) + b_z)
R = torch.sigmoid((X @ W_xr) + (H @ W_hr) + b_r)
H_tilda = torch.tanh((X @ W_xh) + ((R * H) @ W_hh) + b_h)
H = Z * H + (1 - Z) * H_tilda
Y = H @ W_hq + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H,) # 习惯写法,因为LSTM(H,C)

训练与预测

训练和预测的工作方式与普通RNN完全相同

训练结束后,分别打印输出训练集的困惑度,以及前缀“time traveler”和“traveler”的预测序列上的困惑度

1
2
3
4
5
vocab_size, num_hiddens, device = len(vocab), 256, try_gpu()
num_epochs, lr = 500, 1
model = RNNModelScratch(len(vocab), num_hiddens, device, get_params,
init_gru_state, gru)
train_ch8(model, train_iter, vocab, lr, num_epochs, device)
202510311655
1
2
3
困惑度 1.1, 57687.6 词元/秒 cpu
time traveller for so it will be convenient to speak of himwas e
travelleryou can show black is white by argument said filby

简洁实现

高级API包含了前文介绍的所有配置细节,可以直接实例化门控循环单元模型

这段代码的运行速度要快得多,因为它使用的是编译好的运算符而不是Python来处理之前阐述的许多细节

1
2
3
4
5
num_inputs = vocab_size
gru_layer = nn.GRU(num_inputs, num_hiddens)
model = RNNModel(gru_layer, len(vocab))
model = model.to(device)
train_ch8(model, train_iter, vocab, lr, num_epochs, device)
image-20251101151204768
1
2
3
困惑度 1.0, 85331.8 词元/秒 cpu
time travelleryou can show black is white by argument said filby
travelleryou can show black is white by argument said filby

虽然困惑度降到1.0了,但是训练样本少,模型还没泛化,输出句子只是记忆,不是真正语言建模的泛化效果

长短期记忆网络(LSTM)

隐变量模型存在着长期信息保存和短期输入缺失的问题,解决这一问题的最早方法之一是长短期存储器**(long short-term memory,LSTM)**(Hochreiter and Schmidhuber, 1997)

它有许多与门控循环单元一样的属性,但长短期记忆网络的设计比门控循环单元稍微复杂一些,却比门控循环单元早诞生了近20年

门控记忆元

LSTM的设计灵感源自计算机中的逻辑门结构,它在传统循环神经网络的基础上,引入了一个用于保存信息的记忆元(memory cell),简称为单元(cell)

有些文献认为,记忆元是一种特殊形式的隐状态,与隐状态具有相同的形状,但专门用于长期信息的保存

为了有效地控制记忆元的信息流动,LSTM 设计了多种门机制

  • 输入门(input gate):决定何时将新的信息写入单元;
  • 遗忘门(forget gate):决定何时清除旧的信息;
  • 输出门(output gate):控制何时从单元中输出信息

门机制

就如在门控循环单元中一样,当前时间步的输入和前一个时间步的隐状态作为数据送入长短期记忆网络的门中,它们由三个具有sigmoid激活函数的全连接层处理,以计算输入门、遗忘门和输出门的值

这三个门的值都在(0,1)范围内

lstm-0

假设有$h$个隐藏单元,批量大小为$n$,输入数为$d$,输入为$\mathbf X_t \in \mathbb{R}^{n \times d}$,前一时间步的隐状态为$\mathbf H_{t-1} \in \mathbb{R}^{n \times h}$,相应的输入门是$\mathbf I_t \in \mathbb{R}^{n \times h}$,遗忘门是$\mathbf F_t \in \mathbb{R}^{n \times h}$,输出门是$\mathbf O_t \in \mathbb{R}^{n \times h}$

它们的计算方法如下:
$$
\begin{split}\begin{aligned}
\mathbf I_t &= \sigma(\mathbf X_t \mathbf W_{xi} + \mathbf H_{t-1} \mathbf W_{hi} + \mathbf b_i),\\
\mathbf F_t &= \sigma(\mathbf X_t \mathbf W_{xf} + \mathbf H_{t-1} \mathbf W_{hf} + \mathbf b_f),\\
\mathbf O_t &= \sigma(\mathbf X_t \mathbf W_{xo} + \mathbf H_{t-1} \mathbf W_{ho} + \mathbf b_o),
\end{aligned}\end{split}
$$
其中$\mathbf W_{xi}, \mathbf W_{xf}, \mathbf W_{xo} \in \mathbb{R}^{d \times h}$,$\mathbf W_{hi}, \mathbf W_{hf}, \mathbf W_{ho} \in \mathbb{R}^{h \times h}$

候选记忆元

候选记忆元(candidate memory cell)$\tilde{\mathbf C}_t \in \mathbb{R}^{n \times h}$

它的计算与上面描述的三个门的计算类似,但是使用$\tanh$函数作为激活函数,函数的值范围为(-1,1)
$$
\bf\tilde{C_t} = \text{tanh}(\mathbf X_t \mathbf W_{xc} + \mathbf H_{t-1} \mathbf W_{hc} + \mathbf b_c),
$$
候选记忆元如图所示

lstm-1

记忆元

在门控循环单元中,有一种机制来控制输入和遗忘(或跳过),在长短期记忆网络中,也有两个门用于这样的目的
$$
\mathbf C_t = \mathbf F_t \odot \mathbf C_{t-1} + \mathbf I_t \odot \bf\tilde{C_t}
$$
如果遗忘门始终为1且输入门始终为0,则过去的记忆元$\mathbf C_{t-1}$将随时间被保存并传递到当前时间步

引入这种设计是为了缓解梯度消失问题,并更好地捕获序列中的长距离依赖关系

这样就得到了计算记忆元的流程图

lstm-2

隐状态

需要定义如何计算隐状态$\mathbf H_t \in \mathbb{R}^{n \times h}$,这就是输出门发挥作用的地方

在长短期记忆网络中,隐状态是“经过输出门调制的记忆元快照”,是记忆元的$\tanh$的门控版本,确保$\mathbf H_t$的值始终在区间(-1,1)内
$$
\mathbf H_t = \mathbf O_t \odot \tanh(\mathbf C_t).
$$
只要输出门接近1,就能够有效地将所有记忆信息传递给预测部分,而对于输出门接近0,只保留记忆元内的所有信息,而不需要更新隐状态

lstm-3

输出表示为:
$$
\mathbf Y_t = \mathbf H_t \mathbf W_{hq} +\mathbf b_q
$$

总结与对比

组件 功能 类比
遗忘门 决定丢弃多少旧记忆 清空一部分硬盘内容
输入门 决定写入多少新信息 把新数据写入硬盘
候选记忆 新的候选内容 新文件内容
记忆元 存储长期信息 硬盘本体
输出门 控制输出多少记忆 决定要不要从硬盘读出来
隐状态 当前对外可见的输出 屏幕上显示的内容

与 GRU 的对比

特征 LSTM GRU
门数量 3 个(输入、遗忘、输出) 2 个(重置、更新)
记忆元 独立 与隐状态合一
结构复杂度 稍高,性能更强 简洁高效,参数更少
学习能力 适合复杂依赖 适合中短期依赖

输出门的存在是为了控制信息暴露:

  • 当网络认为当前时刻的信息还不成熟或不重要时,关上输出门;
  • 这样隐状态不会被下游层使用;
  • 但记忆元仍然积累经验,为未来的时间步准备

这种设计是 LSTM 相比 GRU 更“细腻”的地方:它能明确地区分“内部记忆”和“外部输出”

底层实现

1
2
batch_size, num_steps = 32, 35
train_iter, vocab = load_data_time_machine(batch_size, num_steps)

初始化模型参数

超参数num_hiddens定义隐藏单元的数量,按照标准差0.01的高斯分布初始化权重,并将偏置项设为0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 初始化网络参数
def get_lstm_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size

def normal(shape):
return torch.randn(size = shape, device=device)*0.01

def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))

W_xi, W_hi, b_i = three() # 输入门参数
W_xf, W_hf, b_f = three() # 遗忘门参数
W_xo, W_ho, b_o = three() # 输出门参数
W_xc, W_hc, b_c = three() # 候选记忆元参数

# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 加上梯度
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q]
for param in params:
param.requires_grad_(True)

return params

定义模型

在初始化函数中,长短期记忆网络需要初始化隐藏状态和记忆元,单元的值为0,形状均为(batch_size, num_hiddens)

1
2
3
4
# 初始化隐藏状态和记忆元
def init_lstm_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device),
torch.zeros((batch_size, num_hiddens), device=device))

实际模型的定义与前面讨论的一样:提供三个门和一个额外的记忆元,只有隐状态才会传递到输出层,而记忆元不直接参与输出计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def lstm(inputs, state, params):
"""
参数:
inputs: 输入序列,形状为 (num_steps, batch_size, input_size)
state: 初始状态 (H, C)
params: 所有模型参数(权重与偏置),包含14个张量
返回:
outputs: 所有时间步的输出拼接结果
(H, C): 当前时间步的隐状态和记忆元,用于下一批次
"""
# 解包参数,对应四个门 + 输出层
[W_xi, W_hi, b_i, # 输入门参数
W_xf, W_hf, b_f, # 遗忘门参数
W_xo, W_ho, b_o, # 输出门参数
W_xc, W_hc, b_c, # 候选记忆参数
W_hq, b_q] = params # 输出层参数(hidden → output)

(H, C) = state
outputs = []

for X in inputs:
I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c)
C = F * C + I * C_tilda
H = O * torch.tanh(C)
Y = (H @ W_hq) + b_q
outputs.append(Y)
# outputs: 所有时间步拼接后的输出 (num_steps * batch_size, output_size)
# (H, C): 当前时间步的最终状态
return torch.cat(outputs, dim=0), (H, C)

训练和预测

引入的RNNModelScratch类来训练一个长短期记忆网络

1
2
3
4
vocab_size, num_hiddens, device = len(vocab), 256, try_gpu()
num_epochs, lr = 500, 1
model = RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params, init_lstm_state, lstm)
train_ch8(model, train_iter, vocab, lr, num_epochs, device)
202511011607
1
2
3
困惑度 1.1, 45695.4 词元/秒 cpu
time traveller of the samm thack is of space and thating haid th
traveller curiessions of spaceing we care the gotither so i

简洁实现

使用高级API,可以直接实例化LSTM模型

1
2
3
4
5
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = RNNModel(lstm_layer, len(vocab))
model = model.to(device)
train_ch8(model, train_iter, vocab, lr, num_epochs, device)
image-20251101161336227
1
2
3
困惑度 1.0, 125343.0 词元/秒 cpu
time traveller with a slight accession ofcheerfulness really thi
travelleryou can show black is white by argument said filby

长短期记忆网络是典型的具有重要状态控制的隐变量自回归模型,多年来已经提出了其许多变体,例如,多层、残差连接、不同类型的正则化

然而,由于序列的长距离依赖性,训练长短期记忆网络和其他序列模型(例如门控循环单元)的成本是相当高的,将使用更高级的替代模型,比如Transformer

深度循环神经网络

到目前为止,只讨论了具有一个单向隐藏层的循环神经网络

隐变量和观测值与具体的函数形式的交互方式是相当随意的,对一个单层来说,这可能具有相当的挑战性,之前在线性模型中,通过添加更多的层来解决这个问题

可以将多层循环神经网络堆叠在一起,通过对几个简单层的组合,产生了一个灵活的机制

下图描述了一个具有$L$个隐藏层的深度循环神经网络,每个隐状态都连续地传递到当前层的下一个时间步和下一层的当前时间步

deep-rnn

函数依赖关系

将$l^\mathrm{th}$隐藏层($l=1,\ldots,L$)的隐状态设为$\mathbf H_t^{(l)} \in \mathbb{R}^{n \times h}$,设$\mathbf H_t^{(0)} = \mathbf X_t$,第$l$个隐藏层的隐状态使用激活函数$\phi_l$
$$
\mathbf H_t^{(l)} = \phi_l(\mathbf H_t^{(l-1)} \mathbf W_{xh}^{(l)} + \mathbf H_{t-1}^{(l)} \mathbf W_{hh}^{(l)} + \mathbf b_h^{(l)}),
$$
输出层的计算仅基于第$l$个隐藏层最终的隐状态
$$
\mathbf O_t = \mathbf H_t^{(L)} \mathbf W_{hq} + \mathbf b_q
$$
与多层感知机一样隐藏层数目$L$和隐藏单元数目$h$都是超参数

简洁实现

实现多层循环神经网络所需的许多逻辑细节在高级API中都是现成的,以长短期记忆网络模型为例,与之前的模型代码相似,唯一的区别是指定了层的数量,而不是使用单一层这个默认值

1
2
3
4
5
6
7
8
batch_size, num_steps = 32, 35
train_iter, vocab = load_data_time_machine(batch_size, num_steps)
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
device = try_gpu()
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers) # 新增参数
model = RNNModel(lstm_layer, len(vocab))
model = model.to(device)

训练与预测

由于使用了长短期记忆网络模型来实例化两个层,使用GPU能加快训练速度

1
2
num_epochs, lr = 500, 2
train_ch8(model, train_iter, vocab, lr*1.0, num_epochs, device)
image-20251101171722334
1
2
3
困惑度 1.0, 60333.7 词元/秒 cpu
time traveller for so it will be convenient to speak of himwas e
travelleryou can show black is white by argument said filby

双向循环神经网络

在序列学习中,以往假设的目标是在给定观测的情况下对下一个输出进行建模,但还可能出现填空的任务

隐马尔可夫的动态规划

设计一个隐变量模型:在任意时间步假设存在某个隐变量$h_t$,通过概率$P(x_t \mid h_t)$控制观测到的$x_t$,任何$h_t \to h_{t+1}$转移都是由一些状态转移概率$P(h_{t+1} \mid h_{t})$给出

下图为隐马尔可夫模型(hidden Markov model,HMM)

hmm

在观测状态和隐状态上具有以下联合概率分布:
$$
P(x_1, \ldots, x_T, h_1, \ldots, h_T) = \prod_{t=1}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t), \text{ where } P(h_1 \mid h_0) = P(h_1).
$$
假设观测到所有的$x_i$,除了$x_j$,目标是计算$P(x_j \mid x_{-j})$,其中$x_{-j} = (x_1, \ldots, x_{j-1}, x_{j+1}, \ldots, x_{T})$,即$x_{-j}$代表除了$x_j$以外的所有观测值

如果任何$h_i$可以接受$k$个不同的值(有限的状态数),这意味着需要对$k^T$个项求和,要直接计算上面的和,计算量爆炸!

有个巧妙的解决方案:动态规划(dynamic programming)

结合**前向递归(forward recursion)后向递归(backward recursion)**能够计算
$$
P(x_j \mid x_{-j}) \propto \sum_{h_j} \pi_j(h_j) \rho_j(h_j) P(x_j \mid h_j).
$$
用前向量$\pi$和后向量$\rho$就能高效地计算任意时刻观测的概率分布

双向模型

希望在循环神经网络中拥有一种机制,使之能够提供与隐马尔可夫模型类似的前瞻能力,需要修改循环神经网络的设计

只需要增加一个“从最后一个词元开始从后向前运行”的循环神经网络,而不是只有一个在前向模式下“从第一个词元开始运行”的循环神经网络

**双向循环神经网络(bidirectional RNNs)**添加了反向传递信息的隐藏层,以便更灵活地处理此类信息,下图描述具有单个隐藏层的双向循环神经网络的架构

birnn

这与隐马尔可夫模型中的动态规划的前向和后向递归没有太大区别

相当于两个RNN,正向 RNN 从头看,反向 RNN 从尾看

定义

双向循环神经网络是由(Schuster and Paliwal, 1997)提出的,关于各种架构的详细讨论请参阅 (Graves and Schmidhuber, 2005)

对于任意时间步,在双向架构中,设该时间步的前向和反向隐状态分别为$\overrightarrow{\bf{H}}_t \in \mathbb{R}^{n \times h}$和$\overleftarrow{\bf{H}}_t \in \mathbb{R}^{n \times h}$

Snipaste_2025-11-01_17-47-37

将前向隐状态和反向隐状态连接起来,获得需要送入输出层的隐状态$\bf{H}_t \in \mathbb{R}^{n \times 2h}$

在具有多个隐藏层的深度双向循环神经网络中,该信息作为输入传递到下一个双向层

输出层计算得到的输出为
$$
\mathbf O_t = \mathbf H_t \mathbf W_{hq} + \mathbf b_q.
$$
权重矩阵$\bf{W}_{hq} \in \mathbb{R}^{2h \times q}$和偏置$\bf{b}_q \in \mathbb{R}^{1 \times q}$是输出层的模型参数

模型的问题

双向循环神经网络的一个关键特性是:使用来自序列两端的信息来估计输出

在训练期间,能够利用过去和未来的数据来估计现在空缺的词;而在测试期间,只有过去的数据,因此预测精度将会很差

另一个严重问题是,双向循环神经网络的计算速度非常慢。其主要原因是网络的前向传播需要在双向层中进行前向和后向递归,并且网络的反向传播还依赖于前向传播的结果

双向层的使用在实践中非常少,并且仅仅应用于部分场合

错误应用

由于双向循环神经网络使用了过去的和未来的数据,所以不能盲目地将这一语言模型应用于任何预测任务,尽管模型产出的困惑度是合理的,该模型预测未来词元的能力却可能存在严重缺陷

1
2
3
4
5
6
7
8
9
10
11
batch_size, num_steps, device = 32, 35, try_gpu()
train_iter, vocab = load_data_time_machine(batch_size, num_steps)
# 通过设置“bidirective=True”来定义双向LSTM模型
vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers, bidirectional=True)
model = RNNModel(lstm_layer, len(vocab))
model = model.to(device)
# 训练模型
num_epochs, lr = 500, 1
train_ch8(model, train_iter, vocab, lr, num_epochs, device)

会出现很抽象的结果

1
2
3
困惑度 1.1, 23421.1 词元/秒 cpu
time travellerererererererererererererererererererererererererer
travellerererererererererererererererererererererererererer

机器翻译与数据集

语言模型是自然语言处理的关键,而机器翻译是语言模型最成功的基准测试

机器翻译(machine translation)指的是将序列从一种语言自动翻译成另一种语言,正是将输入序列转换成输出序列的序列转换模型(sequence transduction)

下载和预处理数据集

下载一个由Tatoeba项目的双语句子对 组成的“英-法”数据集,数据集中的每一行都是制表符分隔的文本序列对,序列对由英文文本序列和翻译后的法语文本序列组成

每个文本序列可以是一个句子,也可以是包含多个句子的一个段落

在这个问题中,英语是源语言(source language),法语是目标语言(target language)

1
2
3
4
5
6
7
8
9
10
DATA_HUB['fra-eng'] = (  #@save
DATA_URL + 'fra-eng.zip',
'94646ad1522d915e7b0f9296181140edcf86a4f5')

def read_data_nmt(): #@save
"""载入“英语-法语”数据集"""
data_dir = download_extract('fra-eng')
with open(os.path.join(data_dir, 'fra.txt'), 'r',
encoding='utf-8') as f:
return f.read()
1
2
raw_text = read_data_nmt()
print(raw_text[:75])
1
2
3
4
5
6
7
正在从http://d2l-data.s3-accelerate.amazonaws.com/fra-eng.zip下载../data\fra-eng.zip...
Go. Va !
Hi. Salut !
Run! Cours !
Run! Courez !
Who? Qui ?
Wow! Ça alors !

下载数据集后,原始文本数据需要经过几个预处理步骤

需要用空格代替不间断空格(non-breaking space),使用小写字母替换大写字母,并在单词和标点符号之间插入空格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def preprocess_nmt(text):  #@save
"""预处理“英语-法语”数据集"""
# 判断是否应该在标点前加空格
# 如果标点前面没空格,就给它补上空格
def no_space(char, prev_char):
return char in set(',.!?') and prev_char != ' '

# 把不间断空格(\u202f, \xa0)替换成普通空格
# 使用小写字母替换大写字母
text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
# 在单词和标点符号之间插入空格
out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char
for i, char in enumerate(text)]
# out 是列表,用 join() 把它们拼回去
return ''.join(out)
1
2
text = preprocess_nmt(raw_text)
print(text[:80])
1
2
3
4
5
6
go .	va !
hi . salut !
run ! cours !
run ! courez !
who ? qui ?
wow ! ça alors !

词元化

与之前字符级词元化不同,在机器翻译中更喜欢单词级词元化

下面的tokenize_nmt函数对前num_examples个文本序列对进行词元,每个词元要么是一个词,要么是一个标点符号

此函数返回两个词元列表:sourcetarget

``source[i]是源语言第$i$个文本序列的词元列表,target[i]`是目标语言第$i$个文本序列的词元列表

1
2
3
4
5
6
7
8
9
10
11
12
13
def tokenize_nmt(text, num_examples=None):  #@save
"""词元化“英语-法语”数据数据集"""
source, target = [], []
# 把整个文本按换行符拆成行
for i, line in enumerate(text.split('\n')):
# i为行号,line为内容
if num_examples and i > num_examples:
break
parts = line.split('\t') # 把一行用制表符分成两部分
if len(parts) == 2:
source.append(parts[0].split(' ')) # 按空格切成单词
target.append(parts[1].split(' '))
return source, target
1
2
source, target = tokenize_nmt(text)
source[:6], target[:6]
1
2
3
4
5
6
7
8
9
10
11
12
13
([['go', '.'],
['hi', '.'],
['run', '!'],
['run', '!'],
['who', '?'],
['wow', '!']],

[['va', '!'],
['salut', '!'],
['cours', '!'],
['courez', '!'],
['qui', '?'],
['ça', 'alors', '!']])

绘制每个文本序列所包含的词元数量的直方图,在这个数据集中,大多数文本序列的词元数量少于20个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#@save
def show_list_len_pair_hist(legend, xlabel, ylabel, xlist, ylist):
"""绘制列表长度对的直方图"""
# 计算每个句子的长度(词元数)
x_lengths = [len(l) for l in xlist]
y_lengths = [len(l) for l in ylist]
# 设置图形大小
plt.figure(figsize=(6, 4))
# 绘制两个直方图
counts, bins, patches = plt.hist(
[x_lengths, y_lengths],
bins=20, # 直方图柱子数量(可调整)
color=['skyblue', 'salmon'],
label=legend,
)
# 给第二组加上斜线纹理(
for patch in patches[1]:
patch.set_hatch('/')

plt.xlabel(xlabel)
plt.ylabel(ylabel)
plt.legend()
plt.tight_layout()
plt.show()
1
show_list_len_pair_hist(['source', 'target'], '# tokens per sequence', 'count', source, target);
202511011950

词表

可以分别为源语言和目标语言构建两个词表,使用单词级词元化时,词表大小将明显大于使用字符级词元化时的词表大小

为减少词表规模,把出现次数少于2次的词都当作同一个未知词<unk>,还添加了几个特殊符号:

  • <pad>:在小批量训练时,用于把句子填充到相同长度;
  • <bos>:表示句子开始;
  • <eos>:表示句子结束

这些特殊词元在自然语言处理任务中比较常用

1
2
3
src_vocab = Vocab(source, min_freq=2, 
reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab) # 10012

加载数据集

语言模型中的序列样本都有一个固定的长度,这个固定长度是由num_steps(时间步数或词元数量)参数指定的

在机器翻译中,每个样本都是由源和目标组成的文本序列对,其中的每个文本序列可能具有不同的长度

为了提高计算效率,仍然可以通过**截断(truncation)填充(padding)**方式实现一次只处理一个小批量的文本序列

在一个小批量中,所有序列的长度都设为相同的num_steps,如果太短在末尾补上 <pad> 直到其长度达到num_steps;如果太长只保留前 num_steps 个词元

下面的truncate_pad函数将截断或填充文本序列

1
2
3
4
5
def truncate_pad(line, num_steps, padding_token): #@save
"""截断或填充文本序列"""
if len(line) > num_steps:
return line[:num_steps] # 截断
return line + [padding_token] * (num_steps - len(line)) # 填充
1
truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])
1
[47, 4, 1, 1, 1, 1, 1, 1, 1, 1]

现在定义一个函数,可以将文本序列转换成小批量数据集用于训练

将特定的<eos>词元添加到所有序列的末尾,用于表示序列的结束

当模型通过一个词元接一个词元地生成序列进行预测时,生成的<eos>词元说明完成了序列输出工作,此外还记录了每个文本序列的长度,统计长度时排除了填充词元,在稍后将要介绍的一些模型会需要这个长度信息

1
2
3
4
5
6
7
8
9
10
def build_array_nmt(lines, vocab, num_steps): # #@save
"""将机器翻译的文本序列转换成小批量"""
lines = [vocab[l] for l in lines]
# 在每个句子末尾加上 <eos>
lines = [l + [vocab['<eos>']] for l in lines]
array = torch.tensor([truncate_pad(
l, num_steps, vocab['<pad>']) for l in lines])
# 用bool值转为int累加即为长度
valid_len = (array != vocab['<pad>']).type(torch.int32).sum(1)
return array, valid_len

训练模型

定义load_data_nmt函数来返回数据迭代器,以及源语言和目标语言的两种词表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#@save
def load_data_nmt(batch_size, num_steps, num_examples=600):
"""返回翻译数据集的迭代器和词表"""
# 1. 读取原始英法语料并清洗文本
text = preprocess_nmt(read_data_nmt())
# 2. 将文本拆分为词元列表
source, target = tokenize_nmt(text, num_examples)
# 3. 分别构建源语言和目标语言词表
src_vocab = Vocab(source, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
tgt_vocab = Vocab(target, min_freq=2,
reserved_tokens=['<pad>', '<bos>', '<eos>'])
# 4. 把句子转为统一长度的索引张量
src_array, src_valid_len = build_array_nmt(source, src_vocab, num_steps)
tgt_array, tgt_valid_len = build_array_nmt(target, tgt_vocab, num_steps)
# 5. 打包所有张量并生成可迭代数据加载器
data_arrays = (src_array, src_valid_len, tgt_array, tgt_valid_len)
data_iter = load_array(data_arrays, batch_size)
# 6. 返回训练数据迭代器及词表
return data_iter, src_vocab, tgt_vocab

读出“英语-法语”数据集中的第一个小批量数据

1
2
3
4
5
6
7
train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
print('X:', X.type(torch.int32))
print('X的有效长度:', X_valid_len)
print('Y:', Y.type(torch.int32))
print('Y的有效长度:', Y_valid_len)
break
1
2
3
4
5
6
X: tensor([[31,  0,  4,  3,  1,  1,  1,  1],
[39, 19, 4, 3, 1, 1, 1, 1]], dtype=torch.int32)
X的有效长度: tensor([4, 4])
Y: tensor([[77, 23, 0, 4, 3, 1, 1, 1],
[92, 12, 5, 3, 1, 1, 1, 1]], dtype=torch.int32)
Y的有效长度: tensor([5, 4])

编码器-解码器架构

机器翻译是典型的序列到序列转换问题,输入和输出的长度都可能不同

为了解决这个问题,使用一种由两个部分组成的结构:

  • 编码器(encoder):把输入序列转化为一个固定长度的编码状态;
  • 解码器(decoder):根据这个表示生成输出序列

这被称为**编码器-解码器(encoder-decoder)**架构

encoder-decoder

由于“编码器-解码器”架构是形成不同序列转换模型的基础,将把这个架构转换为接口方便后面的代码实现

编码器

在编码器接口中,只指定长度可变的序列作为编码器的输入X,任何继承这个Encoder基类的模型将完成代码实现

1
2
3
4
5
6
7
8
class Encoder(nn.Module):   #@save
"""编码器-解码器架构的基本编码器接口"""
def __init__(self, **kwargs):
super().__init__(**kwargs)

def forward(self, X, *args):
# 只是定义接口,不做实现,如果继承却没写自己的 forward就报错
raise NotImplementedError

解码器

在下面的解码器接口中,新增一个init_state函数,用于将编码器的输出(enc_outputs)转换为编码后的状态

此步骤可能需要额外的输入,例如输入序列的有效长度

为了逐个地生成长度可变的词元序列,解码器在每个时间步都会将输入(例如:在前一时间步生成的词元)和编码后的状态映射成当前时间步的输出词元

1
2
3
4
5
6
7
8
9
10
class Decoder(nn.Module):  #@save
"""编码器-解码器架构的基本解码器接口"""
def __init__(self, **kwargs):
super().__init__(**kwargs)

def init_state(self, enc_outputs, *args):
raise NotImplementedError

def forward(self, X, state):
raise NotImplementedError

合并编码器和解码器

“编码器-解码器”架构包含了一个编码器和一个解码器,并且还拥有可选的额外的参数

在前向传播中,编码器的输出用于生成编码状态,这个状态又被解码器作为其输入的一部分

1
2
3
4
5
6
7
8
9
10
11
class EncoderDecoder(nn.Module):  #@save
"""编码器-解码器架构的基类"""
def __init__(self, encoder, decoder, **kwargs):
super().__init__(**kwargs)
self.encoder = encoder
self.decoder = decoder

def forward(self, enc_X, dec_X, *args):
enc_outputs = self.encoder(enc_X, *args)
dec_state = self.decoder.init_state(enc_outputs, *args)
return self.decoder(dec_X, dec_state)

“编码器-解码器”体系架构中的术语状态会启发人们使用具有状态的神经网络来实现该架构