到目前为止,我们遇到过两种类型的数据:表格数据和图像数据。对于图像数据,我们设计了专门的卷积神经网络架构(cnn)来为这类特殊的数据结构建模。换句话说,如果我们拥有一张图像,我们 需要有效地利用其像素位置,假若我们对图像中的像素位置进行重排,就会对图像中内容的推断造成极大的困难。
最重要的是,到目前为止我们默认数据都来自于某种分布,并且 所有样本都是独立同分布 的(independently and identically distributed)。然而,大多数的数据并非如此。例如,文章中的单词是按顺序写的,如 果顺序被随机地重排,就很难理解文章原始的意思。同样,视频中的图像帧、对话中的音频信号以及网站上 的浏览行为都是有顺序的。因此,针对此类数据而设计特定模型,可能效果会更好。 另一个问题来自这样一个事实:我们不仅仅可以接收一个序列作为输入,而是还可能期望继续猜测这个序列的后续。例如,一个任务可以是继续预测2, 4, 6, 8, 10, . . .。这在时间序列分析中是相当常见的,可以用来预测 股市的波动、患者的体温曲线或者赛车所需的加速度。同理,我们需要能够处理这些数据的特定模型。
简言之,如果说 卷积神经网络可以有效地处理空间信息,那么本章的循环神经网络(recurrent neural network, RNN)则可以更好地处理序列信息。循环神经网络通过引入状态变量存储过去的信息和当前的输入,从而可 以确定当前的输出。 许多使用循环网络的例子都是基于文本数据的,因此我们将在本章中重点介绍语言模型。在对序列数据进行 更详细的回顾之后,我们将介绍文本预处理的实用技术。然后,我们将讨论语言模型的基本概念,并将此讨论作为循环神经网络设计的灵感。
一 序列模型
处理序列数据需要统计工具和新的深度神经网络架构。
1.1 自回归模型
以 预测股票价格为例。
为了实现这个预测,交易员可以使用回归模型。仅有一个主要问题:输入数据的 数量,输入xt−1, . . . , x1本身因t而异。也就是说,输入数据的数量这个数字将会随着我们遇到的数据量的增加 而增加,因此需要一个近似方法来使这个计算变得容易处理。本章后面的大部分内容将围绕着如何有效估计 P(xt | xt−1, . . . , x1)展开。简单地说,它归结为以下两种策略。
第一种策略,假设在现实情况下相当长的序列 xt−1, . . . , x1可能是不必要的,因此我们只需要满足某个长度 为τ的时间跨度,即使用观测序列xt−1, . . . , xt−τ。当下获得的最直接的好处就是参数的数量总是不变的,至少 在t > τ时如此,这就使我们能够训练一个上面提及的深度网络。这种模型被称为自回归模型(autoregressive models),因为它们是对自己执行回归。
第二种策略,如 下图所示,是保留一些对过去观测的总结ht,并且同时更新预测xˆt和总结ht。这就产生了 基于xˆt = P(xt | ht)估计xt,以及公式ht = g(ht−1, xt−1)更新的模型。由于ht从未被观测到,这类模型也被称 为 隐变量自回归模型(latent autoregressive models)。
这两种情况都有一个显而易见的问题:如何生成训练数据?一个经典方法是使用历史观测来预测下一个未来观测。显然,我们并不指望时间会停滞不前。然而,一个常见的假设是虽然特定值xt可能会改变,但是序列 本身的动力学不会改变。这样的假设是合理的,因为新的动力学一定受新的数据影响,而我们不可能用目前 所掌握的数据来预测新的动力学。统计学家称 不变的动力学为静止的(stationary)。
注意,如果我们处理的是离散的对象(如单词),而不是连续的数字,则上述的考虑仍然有效。唯一的差别是, 对于离散的对象,我们需要使用分类器而不是回归模型来估计P(xt | xt−1, . . . , x1)。
1.2 马尔可夫模型
回想一下,在自回归模型的近似法中,我们使用xt−1, . . . , xt−τ 而不是xt−1, . . . , x1来估计xt。只要这种是近似精确的,我们就说序列满足马尔可夫条件(Markov condition)。
当假设xt仅是离散值时,这样的模型特别棒,因为在这种情况下,使用动态规划可以沿着马尔可夫链精确地 计算结果。
利用这一事实,我们只需要考虑过去观察中的一个非常短的历史:P(xt+1 | xt, xt−1) = P(xt+1 | xt)。隐马尔 可夫模型中的动态规划超出了本节的范围,而动态规划这些计算工具已经在控制算法和强化学习算法广泛使用。
1.3 模型训练
在了解了上述统计工具后,让我们在实践中尝试一下!首先,我们生成一些数据:使用 正弦函数和一些可加 性噪声来生成序列数据,时间步为1, 2, . . . , 1000。
%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l
T = 1000
time = torch.arange(1, T + 1, dtype=torch.float32)
x = torch.sin(0.01 * time) + torch.normal(0, 0.2, (T,))
d2l.plot(time, [x], 'time', 'x', xlim=[1, 1000], figsize=(6, 3))
接下来,我们将这个序列转换为 模型的特征-标签(feature‐label)对。基于嵌入维度τ,我们将数据映射为 数据对yt = xt 和xt = [xt−τ , . . . , xt−1]。这比我们提供的数据样本少了τ个,因为我们没有足够的历史记录来 描述前τ个数据样本。一个简单的解决办法是:如果拥有足够长的序列就丢弃这几项;另一个方法是用零填 充序列。在这里,我们 仅使用前600个“特征-标签”对 进行 训练。
tau = 4
features = torch.zeros((T - tau, tau))
for i in range(tau):
features[:, i] = x[i: T - tau + i]
labels = x[tau:].reshape((-1, 1))
batch_size, n_train = 16, 600
train_iter = d2l.load_array((features[:n_train], labels[:n_train]),
batch_size, is_train=True)
def init_weights(m):
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
def get_net():
net = nn.Sequential(nn.Linear(4, 10),
nn.ReLU(),
nn.Linear(10, 1))
net.apply(init_weights)
return net
loss = nn.MSELoss(reduction='none')
def train(net, train_iter, loss, epochs, lr):
trainer = torch.optim.Adam(net.parameters(), lr)
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:{d2l.evaluate_loss(net, train_iter, loss): f}')
net = get_net()
train(net, train_iter, loss, 5, 0.01)
1.4 执行预测
onestep_preds = net(features)
d2l.plot([time, time[tau:]],
[x.detach().numpy(), onestep_preds.detach().numpy()], 'time',
'x', legend=['data', '1-step preds'],
xlim = [1, 1000], figsize=(6, 3))
正如我们所料,单步预测效果不错。即使这些预测的时间步超过了600 + 4(n_train + tau),其结果看起来 仍然是可信的。然而有一个小问题:如果数据观察序列的时间步只到604,我们需要一步一步地向前迈进。
通常,对于直到xt的观测序列,其在时间步t + k处的预测输出xˆt+k 称为k步预测(k‐step‐ahead‐prediction)。 由于我们的观察已经到了x604,它的k步预测是xˆ604+k。换句话说,我们必须使用我们自己的预测(而不是原 始数据)来进行多步预测。
multistep_preds = torch.zeros(T)
multistep_preds[: n_train + tau] = x[: n_train + tau]
for i in range(n_train + tau, T):
multistep_preds[i] = net(multistep_preds[i - tau: i].reshape((1, -1)))
d2l.plot([time, time[tau:], time[n_train + tau:]],
[x.detach().numpy(), onestep_preds.detach().numpy(),
multistep_preds[n_train + tau:].detach().numpy()], 'time',
'x', legend=['data', '1-step preds', 'multistep preds'],
xlim=[1, 1000], figsize=(6, 3))
如上面的例子所示,绿线的预测显然并不理想。经过几个预测步骤之后,预测的结果很快就会衰减到一个常数。为什么这个算法效果这么差呢?事实是由于错误的累积:假设在步骤1之后,我们积累了一些错误ϵ1 = ¯ϵ。 于是,步骤2的输入被扰动了ϵ1,结果积累的误差是依照次序的ϵ2 = ¯ϵ + cϵ1,其中c为某个常数,后面的预测 误差依此类推。因此误差可能会相当快地偏离真实的观测结果。例如,未来24小时的天气预报往往相当准确, 但超过这一点,精度就会迅速下降。
基于k = 1, 4, 16, 64,通过对整个序列预测的计算,让我们更仔细地看一下k步预测的困难。
## 步长使用64做预测
max_steps = 64
features = torch.zeros((T - tau - max_steps + 1, tau + max_steps))
for i in range(tau):
features[:, i] = x[i: i + T - tau - max_steps + 1]
for i in range(tau, tau + max_steps):
features[:, i] = net(features[:, i - tau: i]).reshape(-1)
steps = (1, 4, 16, 64)
d2l.plot([time[tau + i - 1: T - max_steps + i] for i in steps],
[features[:, (tau + i - 1)].detach().numpy() for i in steps], 'time', 'x',
legend=[f'{i}-step preds' for i in steps], xlim=[5, 1000],
figsize=(6, 3))
以上例子清楚地说明了当我们试图预测更远的未来时,预测的质量是如何变化的。虽然“4步预测”看起来仍然不错,但超过这个跨度的任何预测几乎都是无用的。
小结:
- 内插法(在现有观测值之间进行估计)和 外推法(对超出已知观测范围进行预测)在实践的难度上差别很大。因此,对于所拥有的序列数据,在训练时始终要尊重其时间顺序,即最好不要基于未来的数据进 行训练。
- 序列模型的估计需要专门的统计工具,两种较流行的选择是 自回归模型和隐变量自回归模型。
- 对于时间是向前推进的因果模型,正向估计通常比反向估计更容易。
- 对于直到时间步t的观测序列,其在时间步t + k的预测输出是“k步预测”。随着我们对预测时间k值的 增加,会造成误差的快速累积和预测质量的极速下降。
二 文本预处理
对于 序列数据处理 问题,这样的数据存在许多种形式,文本是最常见例子之一。例如,一篇文章可以被简单地看作一串单词序列,甚至是一串字符序列。我们将解析文本的常见预处理步骤。这些步骤通常包括:
- 将文本作为字符串加载到内存中。
- 将字符串拆分为词元(如单词和字符)。
- 建立一个词表,将拆分的词元映射到数字索引。
- 将文本转换为数字索引序列,方便模型操作。
2.1 读取数据集
首先,我们从H.G.Well的 时光机器 中加载文本。这是一个相当小的语料库,只有 30000多个单词,但足够我 们小试牛刀,而现实中的文档集合可能会包含数十亿个单词。下面的函数将数据集读取到由多条文本行组成 的列表中,其中每条文本行都是一个字符串。为简单起见,我们在这里忽略了标点符号和字母大写。
import collections
import re
from d2l import torch as d2l
#@save
d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt',
'090b5e7e70c295757f55df93cb0a180b9691891a')
def read_time_machine(): #@save
with open(d2l.download('time_machine'), 'r') as f:
lines = f.readlines()
return [re.sub('[^A-Za-z]+', ' ', line).strip().lower() for line in lines]
lines = read_time_machine()
print(f'文本总行数:{len(lines)}')
print(lines[0])
print(lines[10])
2.2 词元化
下面的tokenize函数将文本行列表(lines)作为输入,列表中的每个元素是一个文本序列(如一条文本行)。 每个文本序列又被拆分成一个词元列表,词元(token)是文本的基本单位。最后,返回一个由词元列表组成 的列表,其中的每个词元都是一个字符串(string)。
def tokenize(lines, token='word'): #@save
if token == 'word':
return [line.split() for line in lines]
elif token == 'char':
return [list(line) for line in lines]
else:
print('error: 未知词元类型:' + token)
tokens = tokenize(lines)
for i in range(11):
print(tokens[i])
2.3 词表
词元的类型是字符串,而模型需要的输入是数字,因此这种类型不方便模型使用。现在,让我们构建一个字典,通常也叫做词表(vocabulary),用来将字符串类型的词元映射到从0开始的数字索引中。我们先将训练 集中的所有文档合并在一起,对它们的唯一词元进行统计,得到的统计结果称之为语料(corpus)。然后根 据每个唯一词元的出现频率,为其分配一个数字索引。很少出现的词元通常被移除,这可以降低复杂性。另 外,语料库中不存在或已删除的任何词元都将映射到一个特定的未知词元“”。我们可以选择增加一个 列表,用于保存那些被保留的词元,例如:填充词元(“”);序列开始词元(“”);序列结束词元 (“”)。
class Vocab: #@save
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)
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)
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)
return [self.__getitem__(token) for token in tokens]
@property
def unk(self):
return 0
@property
def token_freqs(self):
return self._token_freqs
def count_corpus(tokens): #@save
if len(tokens) == 0 or isinstance(tokens[0], list):
tokens = [token for line in tokens for token in line]
return collections.Counter(tokens)
vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[:10])
for i in [0, 10]:
print('文本:', tokens[i])
print('索引:', vocab[tokens[i]])
2.4 整合所有功能
在使用上述函数时,我们 将所有功能打包到 load_corpus_time_machine 函数中,该函数返回corpus(词元索 引列表)和vocab(时光机器语料库的词表)。我们在这里所做的改变是:
- 为了简化后面章节中的训练,我们使用字符(而不是单词)实现文本词元化;
- 时光机器数据集中的每个文本行不一定是一个句子或一个段落,还可能是一个单词,因此返回 的corpus仅处理为单个列表,而不是使用多词元列表构成的一个列表。
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
corpus, vocab = load_corpus_time_machine()
len(corpus), len(vocab) # (170580, 28)
小结:
- 文本是序列数据 的一种最常见的形式之一。
- 为了对文本进行预处理,我们 通常将文本拆分为词元,构建词表 将词元字符串映射为数字 索引,并 将文本数据转换为词元 索引以供模型操作。
三 语言模型和数据集
在给定这样的文本序列时,语言模型(language model)的目标是 估计序列的联合概率。
为了训练语言模型,我们需要计算单词的概率,以及给定前面几个单词后出现某个单词的条件概率。这些概 率本质上就是语言模型的参数。
这里,我们假设训练数据集是一个大型的文本语料库。比如,维基百科的所有条目、古登堡计划101,或者 所有发布在网络上的文本。训练数据集中词的概率可以根据给定词的相对词频来计算。
一种常见的策略是执行某种形式的 拉普拉斯平滑(Laplace smoothing),具体方法是在所有计数中添加一个 小常量。
3.1 自然语言统计
我们看看在真实数据上如果进行自然语言统计。根据 8.2节中介绍的时光机器数据集构建词表,并打印前10个 最常用的(频率最高的)单词。
import random
import torch
from d2l import torch as d2l
tokens = d2l.tokenize(d2l.read_time_machine())
# 因为每个文本行不一定是一个句子或一个段落,因此我们把所有文本行拼接到一起
corpus = [token for line in tokens for token in line]
vocab = d2l.Vocab(corpus)
vocab.token_freqs[:10]
正如我们所看到的,最流行的词看起来很无聊,这些词通常被称为 停用词(stop words),因此可以被过滤掉。 尽管如此,它们本身仍然是有意义的,我们仍然会在模型中使用它们。
freqs = [freq for token, freq in vocab.token_freqs]
d2l.plot(freqs, xlabel='token: x', ylabel='frequency: n(x)',
xscale='log', yscale='log')
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
bigram_vocab = d2l.Vocab(bigram_tokens)
bigram_vocab.token_freqs[:10]
trigram_tokens = [triple for triple in zip(corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = d2l.Vocab(trigram_tokens)
trigram_vocab.token_freqs[:10]
bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]
d2l.plot([freqs, bigram_freqs, trigram_freqs], xlabel='token: x',
ylabel='frequency: n(x)', xscale='log', yscale='log',
legend=['unigram', 'bigram', 'trigram'])
小结:
- 语言模型 是自然语言处理的关键。
- n元语法 通过截断相关性,为处理长序列提供了一种实用的模型。
- 长序列存在一个问题:它们很少出现或者从不出现。
- 齐普夫定律 支配着单词的分布,这个分布不仅适用于一元语法,还适用于其他n元语法。
- 通过 拉普拉斯平滑法 可以有效地处理 结构丰富而频率不足的低频词词组。
- 读取长序列的主要方式是 随机采样和顺序分区。在迭代过程中,后者可以保证来自两个相邻的小批量中的子序列在原始序列上也是相邻的。
四 循环神经网络
我们介绍了n元语法模型,其中单词xt在时间步t的条件概率仅取决于前面n − 1个单词。对于时 间步t − (n − 1)之前的单词,如果我们想将其可能产生的影响合并到xt上,需要增加n,然而 模型参数的数量也会随之呈指数增长,因为词表V需要存储|V|n个数字,因此与其将P(xt | xt−1, . . . , xt−n+1)模型化,不如使用 隐变量模型。
4.1 无隐状态的神经网络
让我们来看一看只有 单隐藏层的多层感知机。设隐藏层的激活函数为ϕ,给定一个小批量样本X ∈ R n×d,其 中批量大小为n,输入维度为d,则隐藏层的输出H ∈ R n×h通过下式计算。
4.2 有隐状态的循环神经网络
有了隐状态后,情况就完全不同了。假设我们在时间步t有小批量输入Xt ∈ R n×d。换言之,对于n个序列样本 的小批量,Xt的每一行对应于来自该序列的时间步t处的一个样本。接下来,用Ht ∈ R n×h 表示时间步t的隐 藏变量。与多层感知机不同的是,我们在这里保存了前一个时间步的隐藏变量Ht−1,并引入了一个新的权重 参数Whh ∈ R h×h,来描述如何在当前时间步中使用前一个时间步的隐藏变量。具体地说,当前时间步隐藏 变量由当前时间步的输入与前一个时间步的隐藏变量一起计算得出。
比无隐状态相比多添加了一项,从而实例化了。从相邻时间步的隐藏变量Ht和 Ht−1之 间的关系可知,这些变量 捕获并保留了序列直到其当前时间步的历史信息,就如当前时间步下神经网络的状 态或记忆,因此这样的隐藏变量被称为隐状态(hidden state)。由于在当前时间步中,隐状态使用的定义与 前一个时间步中使用的定义相同,因此的计算是循环的(recurrent)。于是基于循环计算的隐状态神 经网络被命名为 循环神经网络(recurrent neural network)。在循环神经网络中执行计算的层称为循环层(recurrent layer)。
import torch
from d2l import torch as d2l
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)
# tensor([[ 1.2156, 0.6346, -2.1131, -1.5739],
# [-1.5890, -0.7256, 1.1961, -0.7566],
# [-1.9251, -1.0113, 0.1320, 1.8175]])
torch.matmul(torch.cat((X, H), 1), torch.cat((W_xh, W_hh), 0))
# tensor([[-3.8297, -2.1686, 1.9846, 6.5896],
# [ 3.0873, 4.4307, -1.2372, -2.8524],
# [ 2.6609, 0.1703, -0.0343, -3.5691]])
小结:
- 对隐状态使用 循环计算的神经网络称为 循环神经网络(RNN)。
- 循环神经网络的隐状态 可以捕获直到当前时间步序列 的历史信息。
- 循环神经网络模型的参数数量不会随着时间步的增加而增加。
- 我们可以使用循环神经网络创建 字符级语言模型。
- 我们可以使用 困惑度 来评价 语言模型的质量。