循环神经网络(RNN)
到目前为止,我们遇到过两种类型的数据:表格数据和图像数据。 对于图像数据,我们设计了专门的卷积神经网络架构来为这类特殊的数据结构建模。 换句话说,如果我们拥有一张图像,我们需要有效地利用其像素位置, 假若我们对图像中的像素位置进行重排,就会对图像中内容的推断造成极大的困难。
最重要的是,到目前为止我们默认数据都来自于某种分布, 并且所有样本都是独立同分布的 (independently and identically distributed,i.i.d.)。 然而,大多数的数据并非如此。 例如,文章中的单词是按顺序写的,如果顺序被随机地重排,就很难理解文章原始的意思。 同样,视频中的图像帧、对话中的音频信号以及网站上的浏览行为都是有顺序的。 因此,针对此类数据而设计特定模型,可能效果会更好。
另一个问题来自这样一个事实: 我们不仅仅可以接收一个序列作为输入,而是还可能期望继续猜测这个序列的后续。这在时间序列分析中是相当常见的,可以用来预测股市的波动、 患者的体温曲线或者赛车所需的加速度。 同理,我们需要能够处理这些数据的特定模型。简言之,如果说卷积神经网络可以有效地处理空间信息, 那么本章的循环神经网络(recurrent neural network,RNN)则可以更好地处理序列信息。 循环神经网络通过引入状态变量存储过去的信息和当前的输入,从而可以确定当前的输出。
1.序列模型
想象一下有人正在看网飞(Netflix,一个国外的视频网站)上的电影。 一名忠实的用户会对每一部电影都给出评价, 毕竟一部好电影需要更多的支持和认可。 然而事实证明,事情并不那么简单。 随着时间的推移,人们对电影的看法会发生很大的变化。 事实上,心理学家甚至对这些现象起了名字:
- 锚定效应(anchoring):基于其他人的意见做出评价。 例如,奥斯卡颁奖后,受到关注的电影的评分会上升,尽管它还是原来那部电影。 这种影响将持续几个月,直到人们忘记了这部电影曾经获得的奖项。
- 享乐适应(hedonic adaption):人们迅速接受并适应一种更好或者更坏的情况作为新的常态。 例如,在看了很多好电影之后,人们会强烈期望下部电影会更好。 因此,在许多精彩的电影被看过之后,即使是一部普通的也可能被认为是糟糕的。
- 季节性(seasonality):少有观众喜欢在八月看圣诞老人的电影。
简而言之,电影评分决不是固定不变的。 因此,使用时间动力学可以得到更准确的电影推荐.当然,序列数据不仅仅是关于电影评分的。预测明天的股价要比过去的股价更困难,尽管两者都只是估计一个数字。 毕竟,先见之明比事后诸葛亮难得多。 在统计学中,前者(对超出已知观测范围进行预测)称为外推法(extrapolation), 而后者(在现有观测值之间进行估计)称为内插法(interpolation)。
1.1 统计工具
处理序列数据需要统计工具和新的深度神经网络架构。 为了简单起见,我们以 下图所示的股票价格(富时100指数)为例。
其中,用
x
t
x_t
xt 表示价格,即在时间步(time step) $t\in \mathbb{Z} ^+ $ 时,观察到的价格
x
t
x_t
xt 。请注意,t对于本文中的序列通常是离散的,并在整数或其子集上变化。 假设一个交易员想在t日的股市中表现良好,于是通过以下途径预测:
x
t
∼
P
(
x
t
∣
x
t
−
1
,
…
,
x
1
)
x_t \sim P(x_t|x_{t-1},\dots ,x_1)
xt∼P(xt∣xt−1,…,x1)
1.1.1 自回归模型
为了实现这个预测,交易员可以使用回归模型。仅有一个主要问题:输入数据的数量, 输入
x
t
−
1
,
…
,
x
1
x_{t-1},\dots ,x_1
xt−1,…,x1 本身因t而异。 也就是说,输入数据的数量这个数字将会随着我们遇到的数据量的增加而增加, 因此需要一个近似方法来使这个计算变得容易处理。
第一种策略,假设在现实情况下相当长的序列
x
t
−
1
,
…
,
x
1
x_{t-1},\dots ,x_1
xt−1,…,x1 可能是不必要的, 因此我们只需要满足某个长度为 $\tau $ 的时间跨度,即使用观测序列
x
t
−
1
,
…
,
x
t
−
τ
x_{t-1},\dots ,x_{t- \tau }
xt−1,…,xt−τ。当下获得的最直接的好处就是参数的数量总是不变的,至少在 $t> \tau
时如此,这就使我们能够训练一个上面提及的深度网络,这种模型被称为自回归模型(
a
u
t
o
r
e
g
r
e
s
s
i
v
e
m
o
d
e
l
s
),因为它们是对自己执行回归。第二种策略,如下图所示,是保留一些对过去观测的总结
时如此,这就使我们能够训练一个上面提及的深度网络,这种模型被称为自回归模型(autoregressive models),因为它们是对自己执行回归。 第二种策略,如下图所示, 是保留一些对过去观测的总结
时如此,这就使我们能够训练一个上面提及的深度网络,这种模型被称为自回归模型(autoregressivemodels),因为它们是对自己执行回归。第二种策略,如下图所示,是保留一些对过去观测的总结h_t
,并且同时更新预测
, 并且同时更新预测
,并且同时更新预测\hat{x_{t} }
和总结
和总结
和总结h_t
。这就产生了基于
。 这就产生了基于
。这就产生了基于\hat{x_{t} }=P(x_t|h_t) $估计
x
t
x_t
xt, 以及公式
h
t
=
g
(
h
t
−
1
,
x
t
−
1
)
h_t=g(h_{t-1},x_{t-1})
ht=g(ht−1,xt−1) 更新的模型。 由于
h
t
h_t
ht从未被观测到,这类模型也被称为隐变量自回归模型(latent autoregressive models)。
这两种情况都有一个显而易见的问题:如何生成训练数据? 一个经典方法是使用历史观测来预测下一个未来观测。 显然,我们并不指望时间会停滞不前。 然而,一个常见的假设是虽然特定值
x
t
x_t
xt可能会改变, 但是序列本身的动力学不会改变这样的假设是合理的,因为新的动力学一定受新的数据影响, 而我们不可能用目前所掌握的数据来预测新的动力学。 统计学家称不变的动力学为静止的(stationary)。 因此,整个序列的估计值都将通过以下的方式获得:
P
(
x
1
,
…
,
x
T
)
=
∏
t
=
1
T
P
(
x
t
∣
x
t
−
1
,
…
,
x
1
)
P\left(x_{1}, \ldots, x_{T}\right)=\prod_{t=1}^{T} P\left(x_{t} \mid x_{t-1}, \ldots, x_{1}\right)
P(x1,…,xT)=t=1∏TP(xt∣xt−1,…,x1)
注意,如果我们处理的是离散的对象(如单词), 而不是连续的数字,则上述的考虑仍然有效。 唯一的差别是,对于离散的对象, 我们需要使用分类器而不是回归模型来估计
P
(
x
t
∣
x
t
−
1
,
…
,
x
1
)
P(x_t|x_{t-1},\dots ,x_1)
P(xt∣xt−1,…,x1)。
1.1.2 马尔可夫模型
回想一下,在自回归模型的近似法中, 我们使用 $x_{t-1},\dots ,x_{t- \tau } $ 来估计
x
t
x_t
xt 。 只要这种是近似精确的,我们就说序列满足马尔可夫条件(Markov condition)。特别是,如果
τ
=
1
\tau = 1
τ=1,得到一个 一阶马尔可夫模型(first-order Markov model),
P
(
x
)
P(x)
P(x)由下式给出:
P
(
x
1
,
…
,
x
T
)
=
∏
t
=
1
T
P
(
x
t
∣
x
t
−
1
)
当
P
(
x
1
∣
x
0
)
=
P
(
x
1
)
P\left(x_{1}, \ldots, x_{T}\right)=\prod_{t=1}^{T} P\left(x_{t} \mid x_{t-1}\right)当P(x_1|x_0)=P(x_1)
P(x1,…,xT)=t=1∏TP(xt∣xt−1)当P(x1∣x0)=P(x1)
当假设
x
t
x_t
xt 仅是离散值时,这样的模型特别棒, 因为在这种情况下,使用动态规划可以沿着马尔可夫链精确地计算结果。 例如,我们可以高效地计算
P
(
x
t
+
1
∣
x
t
−
1
)
P(x_{t+1}|x_{t-1})
P(xt+1∣xt−1):
P
(
x
t
+
1
∣
x
t
−
1
)
=
∑
x
t
P
(
x
t
+
1
,
x
t
,
x
t
−
1
)
P
(
x
t
−
1
)
=
∑
x
t
P
(
x
t
+
1
∣
x
t
,
x
t
−
1
)
P
(
x
t
,
x
t
−
1
)
P
(
x
t
−
1
)
=
∑
x
t
P
(
x
t
+
1
∣
x
t
)
P
(
x
t
∣
x
t
−
1
)
\begin{aligned} P\left(x_{t+1} \mid x_{t-1}\right) & =\frac{\sum_{x_{t}} P\left(x_{t+1}, x_{t}, x_{t-1}\right)}{P\left(x_{t-1}\right)} \\ & =\frac{\sum_{x_{t}} P\left(x_{t+1} \mid x_{t}, x_{t-1}\right) P\left(x_{t}, x_{t-1}\right)}{P\left(x_{t-1}\right)} \\ & =\sum_{x_{t}} P\left(x_{t+1} \mid x_{t}\right) P\left(x_{t} \mid x_{t-1}\right) \end{aligned}
P(xt+1∣xt−1)=P(xt−1)∑xtP(xt+1,xt,xt−1)=P(xt−1)∑xtP(xt+1∣xt,xt−1)P(xt,xt−1)=xt∑P(xt+1∣xt)P(xt∣xt−1)
1.2 训练
在了解了上述统计工具后,让我们在实践中尝试一下! 首先,我们生成一些数据:使用正弦函数和一些可加性噪声来生成序列数据, 时间步为1,2,…,1000.
#生成数据
import torch
from torch import nn
import matplotlib.pyplot as plt
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.numpy())
plt.xlabel('time')
plt.ylabel('x')
plt.xlim(1, 1000)
plt.grid(True)
plt.show()
接下来,将这个序列转换为模型的特征-标签(feature-label)对。基于嵌入维度 $\tau ,我们将数据映射为数据对 ,我们将数据映射为数据对 ,我们将数据映射为数据对y_t=x_t$ 和 X t = [ x t − τ , … , x t − 1 ] X_t=[x_{t-\tau }, \dots ,x_{t-1}] Xt=[xt−τ,…,xt−1]。这比我们提供的数据样本少了$\tau 个,因为我们没有足够的历史记录来描述前 个, 因为我们没有足够的历史记录来描述前 个,因为我们没有足够的历史记录来描述前\tau $个数据样本。 一个简单的解决办法是:如果拥有足够长的序列就丢弃这几项; 另一个方法是用零填充序列。 在这里,我们仅使用前600个“特征-标签”对进行训练。
from torch.utils import data
def load_array(data_arrays, batch_size, is_train=True):
dataset = data.TensorDataset(*data_arrays)
return data.DataLoader(dataset, batch_size, shuffle=is_train)
#转换序列
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
# 只有前n_train个样本用于训练
train_iter = load_array((features[:n_train], labels[:n_train]),batch_size, is_train=True)
在这里,我们使用一个相当简单的架构训练模型: 一个拥有两个全连接层的多层感知机,ReLU激活函数和平方损失。
# 初始化网络权重的函数
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
#平方损失。注意:MSELoss计算平方误差时不带系数1/2
loss = nn.MSELoss(reduction='none')
class Accumulator: #@save
"""在n个变量上累加"""
def __init__(self, n):
self.data = [0.0] * n
def add(self, *args):
self.data = [a + float(b) for a, b in zip(self.data, args)]
def reset(self):
self.data = [0.0] * len(self.data)
def __getitem__(self, idx):
return self.data[idx]
def evaluate_loss(net, data_iter, loss):
metric = Accumulator(2)
for X, y in data_iter:
out = net(X)
y = y.reshape(out.shape)
l = loss(out, y)
metric.add(l.sum(), l.numel())
return metric[0] / metric[1]
#训练模型
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},'
f'loss: {evaluate_loss(net, train_iter, loss): f}')
#模型训练
net = get_net()
train(net, train_iter, loss, 5, 0.01)
得到结果:
epoch 1,loss: 0.078812
epoch 2,loss: 0.058272
epoch 3,loss: 0.054267
epoch 4,loss: 0.052520
epoch 5,loss: 0.053899
1.3 预测
由于训练损失很小,因此我们期望模型能有很好的工作效果。 让我们看看这在实践中意味着什么。 首先是检查模型预测下一个时间步的能力, 也就是单步预测(one-step-ahead prediction)。
#单步预测
onestep_preds = net(features)
plt.figure(figsize=(6, 3))
plt.plot(time.numpy(), x.numpy(), label='data')
plt.plot(time[tau:].numpy(), onestep_preds.detach().numpy(), label='1-step preds')
plt.xlabel('time')
plt.ylabel('x')
plt.legend()
plt.xlim(1, 1000)
plt.show()
单步预测效果不错。通常,对于直到 x t x_t xt 的观测序列,其在时间步t+k处的预测输出 x ^ t + k \hat{x}_{t+k} x^t+k 称为k步预测(k-step-ahead-prediction)。 使用我们自己的预测(而不是原始数据)来进行多步预测。 让我们看看效果如何。
#多步预测
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)))
plt.figure(figsize=(6, 3))
plt.plot(time.numpy(), x.numpy(), label='data')
plt.plot(time[tau:].numpy(), onestep_preds.detach().numpy(), label='1-step preds')
plt.plot(time[n_train + tau:].numpy(), multistep_preds[n_train + tau:].detach().numpy(), label='multistep preds')
plt.xlabel('time')
plt.ylabel('x')
plt.legend()
plt.xlim(1, 1000)
plt.grid(True)
plt.show()
如上面的例子所示,绿线的预测显然并不理想。 经过几个预测步骤之后,预测的结果很快就会衰减到一个常数。 这是由于错误的累积,误差可能会相当快地偏离真实的观测结果。基于k=1,4,16,64,通过对整个序列预测的计算, 让我们更仔细地看一下k步预测的困难。、
#基于k=1,4,16,64的k步预测
max_steps = 64
features = torch.zeros((T - tau - max_steps + 1, tau + max_steps))
# 填充 features
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)
fig, ax = plt.subplots(figsize=(6, 3))
for step in steps:
ax.plot(time[tau + step - 1: T - max_steps + step],
features[:, (tau + step - 1)].detach().numpy(),
label=f'{step}-step preds')
ax.set_xlabel('time')
ax.set_ylabel('x')
ax.legend()
ax.set_xlim(5, 1000)
plt.grid(True)
plt.show()
以上例子清楚地说明了当我们试图预测更远的未来时,预测的质量是如何变化的。 虽然“4步预测”看起来仍然不错,但超过这个跨度的任何预测几乎都是无用的。
2.文本预处理
对于序列数据处理问题,我们在上一节评估了所需的统计工具和预测时面临的挑战。 这样的数据存在许多种形式,文本是最常见例子之一。 例如,一篇文章可以被简单地看作一串单词序列,甚至是一串字符序列。 文本的常见预处理步骤。 这些步骤通常包括:
- 将文本作为字符串加载到内存中;
- 将字符串拆分为词元(如单词和字符);
- 建立一个词表,将拆分的词元映射到数字索引;
- 将文本转换为数字索引序列,方便模型操作
2.1 读取数据集
首先,我们从H.G.Well的时光机器中加载文本。 这是一个相当小的语料库,只有30000多个单词,但足够我们小试牛刀, 而现实中的文档集合可能会包含数十亿个单词。 下面的函数将数据集读取到由多条文本行组成的列表中,其中每条文本行都是一个字符串。 为简单起见,我们在这里忽略了标点符号和字母大写。
import collections
import re
import os
import hashlib
import requests
#读取数据集
DATA_HUB = dict()
DATA_URL = 'http://d2l-data.s3-accelerate.amazonaws.com/'
DATA_HUB['time_machine'] = (DATA_URL + 'timemachine.txt','090b5e7e70c295757f55df93cb0a180b9691891a')
#下载数据集
def download(name, cache_dir=os.path.join('..','data')):
assert name in DATA_HUB, f'{name} 不存在 {DATA_HUB}'
url, sha1_hash = DATA_HUB[name]
os.makedirs(cache_dir, exist_ok=True)
fname = os.path.join(cache_dir, url.split('/')[-1])
if os.path.exists(fname):
sha1 = hashlib.sha1()
with open(fname, 'rb') as f:
while True:
data = f.read(1048576)
if not data:
break
sha1.update(data)
if sha1.hexdigest() == sha1_hash:
return fname
print(f'正在从{url}下载{fname}...')
r = requests.get(url, stream=True, verify=True)
with open(fname, 'wb') as f:
f.write(r.content)
return fname
def read_time_machine():
"""将时间机器数据集加载到文本行的列表中"""
with open(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])
可见:
正在从http://d2l-data.s3-accelerate.amazonaws.com/timemachine.txt下载..\data\timemachine.txt...
# 文本总行数: 3221
the time machine by h g wells
twinkled and his usually pale face was flushed and animated the
2.2 词元化
下面的tokenize函数将文本行列表(lines)作为输入, 列表中的每个元素是一个文本序列(如一条文本行)。 每个文本序列又被拆分成一个词元列表,词元(token)是文本的基本单位。 最后,返回一个由词元列表组成的列表,其中的每个词元都是一个字符串(string)。
#词元化
def tokenize(lines, token='word'):
"""将文本行拆分为单词或字符词元"""
if token == 'word':
return [line.split() for line in lines]
elif token == 'char':
return [list(line) for line in lines]
else:
print('错误:未知词元类型:' + token)
tokens = tokenize(lines)
for i in range(11):
print(tokens[i])
得到结果:
['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']
2.3 词表
词元的类型是字符串,而模型需要的输入是数字,因此这种类型不方便模型使用。 现在,让我们构建一个字典,通常也叫做词表(vocabulary), 用来将字符串类型的词元映射到从0开始的数字索引中。我们先将训练集中的所有文档合并在一起,对它们的唯一词元进行统计,得到的统计结果称之为语料(corpus)。 然后根据每个唯一词元的出现频率,为其分配一个数字索引。很少出现的词元通常被移除,这可以降低复杂性。 另外,语料库中不存在或已删除的任何词元都将映射到一个特定的未知词元“ < u n k > <unk> <unk> ”。 我们可以选择增加一个列表,用于保存那些被保留的词元, 例如:填充词元(“ < p a d > <pad> <pad>”); 序列开始词元(“ < b o s > <bos> <bos>”); 序列结束词元(“ < e o s > <eos> <eos>”)。
#搭建词表
class Vocab:
"""文本词表"""
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)
self.token_to_idx[token] = len(self.idx_to_token) - 1
def __len__(self):
return len(self.idx_to_token)
def __getitem__(self, indices):
if not isinstance(indices, (listm 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
def token_freqs(self):
return self._token_freqs
def count_corpus(tokens):
"""统计词元的频率"""
#这里的tokens是1D列表或2D列表
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])
得到结果为:
[('<unk>', 0), ('the', 1), ('i', 2), ('and', 3), ('of', 4), ('a', 5), ('to', 6), ('was', 7), ('in', 8), ('that', 9)]
现在,我们可以将每一条文本行转换成一个数字索引列表。
for i in [0, 10]:
print('文本:', tokens[i])
print('索引:', vocab[tokens[i]])
得到输出结果为:
文本: ['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]
2.4 整合以上所有功能
在使用上述函数时,我们将所有功能打包到load_corpus_time_machine函数中, 该函数返回corpus(词元索引列表)和vocab(时光机器语料库的词表)。 在这里所做的改变是:
- 使用字符(而不是单词)实现文本词元化;
- 时光机器数据集中的每个文本行不一定是一个句子或一个段落,还可能是一个单词,因此返回的corpus仅处理为单个列表,而不是使用多词元列表构成的一个列表。
#整合
def load_corpus_time_machine(max_tokens=-1):
"""返回时光机器数据集的词元索引列表和词表"""
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)。
3. 语言模型和数据集
3.1 自然语言统计
根据上节中介绍的时光机器数据集构建词表, 并打印前10个最常用的(频率最高的)单词。
#自然语言统计
import random
import torch
tokens = tokenize(read_time_machine())
# 因为每个文本行不一定是一个句子或一个段落,因此我们把所有文本行拼接到一起
corpus = [token for line in tokens for token in line]
vocab = Vocab(corpus)
vocab.token_freqs[:10]
可以得到:
[('the', 2261),
('i', 1267),
('and', 1245),
('of', 1155),
('a', 816),
('to', 695),
('was', 552),
('in', 541),
('that', 443),
('my', 440)]
正如我们所看到的,最流行的词看起来很无聊, 这些词通常被称为停用词(stop words),因此可以被过滤掉。 尽管如此,它们本身仍然是有意义的,我们仍然会在模型中使用它们。 此外,还有个明显的问题是词频衰减的速度相当地快。 例如,最常用单词的词频对比,第10个还不到第1个的1/5。 为了更好地理解,我们可以画出的词频图:
#绘制词频图
import matplotlib.pyplot as plt
freqs = [freq for token, freq in vocab.token_freqs]
freqs.sort(reverse=True)
plt.figure()
plt.plot(range(len(freqs)), freqs, marker='', linestyle='-')
plt.xlabel('token: x')
plt.ylabel('frequency: n(x)')
plt.xscale('log')
plt.yscale('log')
plt.grid(True)
plt.show()
通过此图我们可以发现:词频以一种明确的方式迅速衰减。 将前几个单词作为例外消除后,剩余的所有单词大致遵循双对数坐标图上的一条直线。 这意味着单词的频率满足齐普夫定律(Zipf’s law). 这告诉我们想要通过计数统计和平滑来建模单词是不可行的, 因为这样建模的结果会大大高估尾部单词的频率,也就是所谓的不常用单词。 那么其他的词元组合,比如二元语法、三元语法等等,又会如何呢? 我们来看看二元语法的频率是否与一元语法的频率表现出相同的行为方式。
#二元语法
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
bigram_vocab = Vocab(bigram_tokens)
bigram_vocab.token_freqs[: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”有关。 我们再进一步看看三元语法的频率是否表现出相同的行为方式。
#三元语法
trigram_tokens = [triple for triple in zip(corpus[:-2], corpus[1:-1], corpus[2:])]
trigram_vocab = Vocab(trigram_tokens)
trigram_vocab.token_freqs[: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)]
将三种模型放在一起进行词元频率对比有:
#模型对比
bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]
plt.figure()
plt.plot(range(len(freqs)), freqs, label='unigram')
plt.plot(range(len(bigram_freqs)), bigram_freqs, label='bigram')
plt.plot(range(len(trigram_freqs)), trigram_freqs, label='trigram')
plt.xlabel('token: x')
plt.ylabel('frequency: n(x)')
plt.xscale('log')
plt.yscale('log')
plt.legend()
plt.grid(True)
plt.show()
3.2 读取长序列数据
由于序列数据本质上是连续的,因此我们在处理数据时需要解决这个问题。当序列变得太长而不能被模型一次性全部处理时,我们可能希望拆分这样的序列方便模型读取。 假设我们将使用神经网络来训练语言模型,模型中的网络一次处理具有预定义长度 (例如n个时间步)的一个小批量序列。现在的问题是如何随机生成一个小批量数据的特征和标签以供读取。
首先,由于文本序列可以是任意长的, 例如整本《时光机器》(The Time Machine), 于是任意长的序列可以被我们划分为具有相同时间步数的子序列。 当训练我们的神经网络时,这样的小批量子序列将被输入到模型中。 假设网络一次只处理具有n个时间步的子序列。下图展示了从原始文本序列获得子序列的所有不同的方式,其中n=5,并且每个时间步的词元对应于一个字符。 请注意,因为我们可以选择任意偏移量来指示初始位置,所以我们有相当大的自由度。
上图中不同的偏移量在实际应用中效果一样,我们可以自由选择。 然而,如果我们只选择一个偏移量, 那么用于训练网络的、所有可能的子序列的覆盖范围将是有限的。 因此,我们可以从随机偏移量开始划分序列, 以同时获得覆盖性(coverage)和随机性(randomness)。 下面,我们将描述如何实现随机采样(random sampling)和 顺序分区(sequential partitioning)策略。
3.2.1 随机采样
在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻。 对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。下面的代码每次可以从数据中随机生成一个小批量。 在这里,参数batch_size指定了每个小批量中子序列样本的数目, 参数num_steps是每个子序列中预定义的时间步数。
#随机采样
def seq_data_iter_random(corpus, batch_size, num_steps):
"""使用随机抽样生成一个小批量子序列"""
# 从随机偏移量开始对序列进行分区,随机范围包括num_steps-1
corpus = corpus[random.randint(0, num_steps - 1):]
# 减去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包含子序列的随机起始索引
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个“特征-标签”子序列对。 如果设置小批量大小为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)
输出:
X: tensor([[28, 29, 30, 31, 32],
[13, 14, 15, 16, 17]])
Y: tensor([[29, 30, 31, 32, 33],
[14, 15, 16, 17, 18]])
X: tensor([[ 3, 4, 5, 6, 7],
[18, 19, 20, 21, 22]])
Y: tensor([[ 4, 5, 6, 7, 8],
[19, 20, 21, 22, 23]])
X: tensor([[ 8, 9, 10, 11, 12],
[23, 24, 25, 26, 27]])
Y: tensor([[ 9, 10, 11, 12, 13],
[24, 25, 26, 27, 28]])
3.2.2 顺序分区
在迭代过程中,除了对原始序列可以随机抽样外, 我们还可以保证两个相邻的小批量中的子序列在原始序列上也是相邻的。 这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称为顺序分区。
#顺序分区
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 + 1 + num_tokens])
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_stpes * num_batches, num_steps):
X = Xs[:, i: i + num_steps]
Y = Ys[:, i: i + num_steps]
yield X, Y
基于相同的设置,通过顺序分区读取每个小批量的子序列的特征X和标签Y。 通过将它们打印出来可以发现: 迭代期间来自两个相邻的小批量中的子序列在原始序列中确实是相邻的。
for X, Y in seq_data_iter_sequential(my_seq, batch_size=2, num_steps=5):
print('X: ', X, '\nY:', Y)
输出为:
X: tensor([[ 2, 3, 4, 5, 6],
[18, 19, 20, 21, 22]])
Y: tensor([[ 3, 4, 5, 6, 7],
[19, 20, 21, 22, 23]])
X: tensor([[ 7, 8, 9, 10, 11],
[23, 24, 25, 26, 27]])
Y: tensor([[ 8, 9, 10, 11, 12],
[24, 25, 26, 27, 28]])
X: tensor([[12, 13, 14, 15, 16],
[28, 29, 30, 31, 32]])
Y: tensor([[13, 14, 15, 16, 17],
[29, 30, 31, 32, 33]])
现在,我们将上面的两个采样函数包装到一个类中, 以便稍后可以将其用作数据迭代器。
class SeqDataLoader:
"""加载序列数据的迭代器"""
def __init__(self, batch_size, num_steps, use_random_iter, max_tokens):
if use_random_iter:
self.data_iter_fn = d2l.seq_data_iter_random
else:
self.data_iter_fn = d2l.seq_data_iter_sequential
self.corpus, self.vocab = d2l.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前缀的函数类似地使用。
def load_data_time_machine(batch_size, num_steps,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