专栏:神经网络复现目录
循环神经网络
到目前为止,我们遇到过两种类型的数据:表格数据和图像数据。 对于图像数据,我们设计了专门的卷积神经网络架构来为这类特殊的数据结构建模。 换句话说,如果我们拥有一张图像,我们需要有效地利用其像素位置, 假若我们对图像中的像素位置进行重排,就会对图像中内容的推断造成极大的困难。
最重要的是,到目前为止我们默认数据都来自于某种分布, 并且所有样本都是独立同分布的 (independently and identically distributed,i.i.d.)。 然而,大多数的数据并非如此。 例如,文章中的单词是按顺序写的,如果顺序被随机地重排,就很难理解文章原始的意思。 同样,视频中的图像帧、对话中的音频信号以及网站上的浏览行为都是有顺序的。 因此,针对此类数据而设计特定模型,可能效果会更好。
另一个问题来自这样一个事实: 我们不仅仅可以接收一个序列作为输入,而是还可能期望继续猜测这个序列的后续。 例如,一个任务可以是继续预测。 这在时间序列分析中是相当常见的,可以用来预测股市的波动、 患者的体温曲线或者赛车所需的加速度。 同理,我们需要能够处理这些数据的特定模型。
简言之,如果说卷积神经网络可以有效地处理空间信息, 那么本章的循环神经网络(recurrent neural network,RNN)则可以更好地处理序列信息。 循环神经网络通过引入状态变量存储过去的信息和当前的输入,从而可以确定当前的输出。
许多使用循环网络的例子都是基于文本数据的,因此我们将在本章中重点介绍语言模型。 在对序列数据进行更详细的回顾之后,我们将介绍文本预处理的实用技术。 然后,我们将讨论语言模型的基本概念,并将此讨论作为循环神经网络设计的灵感。 最后,我们描述了循环神经网络的梯度计算方法,以探讨训练此类网络时可能遇到的问题。
文章目录
- 循环神经网络
- 序列模型
- 自回归模型
- 定义
- 两种策略
- 马尔可夫模型
- 实践
- 生成数据
- 训练
- 预测
- 文本预处理
- 数据集
- 词元化
- 词表
- 整合
- 语言模型和数据集
- 学习语言模型
- 自然语言统计
- 读取长序列数据
- 随机采样
- 顺序分区
- 封装
序列模型
自回归模型
定义
自回归模型(AutoRegressive Model)是一种常见的时间序列预测模型,它基于序列中过去的值来预测未来的值。自回归模型假设序列中的每个值都是其前面一些值的线性组合,因此它可以被表示为如下形式:
y t = c + ∑ i = 1 p ϕ i y t − i + ϵ t y_t = c + \sum_{i=1}^{p}\phi_i y_{t-i} + \epsilon_t yt=c+∑i=1pϕiyt−i+ϵt
其中, y t y_t yt 是时间序列在时刻 t t t 的观测值, c c c 是常数, ϕ i \phi_i ϕi 是回归系数, p p p 是滞后阶数, ϵ t \epsilon_t ϵt 是白噪声随机误差。
自回归模型的核心思想是利用过去的值来预测未来的值,因此需要选择合适的滞后阶数 p p p。在实际应用中,可以通过观察序列的自相关函数(ACF)和偏自相关函数(PACF)来确定滞后阶数。
两种策略
- 设在现实情况下相当长的序列 可能是不必要的, 因此我们只需要满足某个长度为l的时间跨度, 即使用观测序列x(t-1),x(t-2),… x(t-l),当下获得的最直接的好处就是参数的数量总是不变的, 至少在t>l时如此
- 隐变量自回归模型(Hidden Markov Model,HMM)是一种经典的时间序列建模方法,它将序列的随机过程建模为一个具有隐藏状态的马尔可夫链,并且假设每个状态下产生的观测值满足一个自回归模型。
具体来说,隐变量自回归模型由以下三个要素构成:
(1) 隐藏状态序列:隐变量自回归模型假设序列的每个时间步都处于某个状态 s t s_t st,其中状态是不可观测的隐藏变量。
(2) 观测序列:在每个时间步,隐变量自回归模型会根据当前的状态生成一个观测值 y t y_t yt。
(3) 转移概率和观测概率:隐变量自回归模型假设每个时间步的状态 s t s_t st 是由前一个时间步的状态 s t − 1 s_{t-1} st−1 转移而来的,因此需要定义状态转移概率 P ( s t ∣ s t − 1 ) P(s_t|s_{t-1}) P(st∣st−1)。同时,隐变量自回归模型还假设在每个时间步的状态 s t s_t st 下,生成观测值 y t y_t yt 的概率是一个自回归模型 P ( y t ∣ s t ) P(y_t|s_t) P(yt∣st)。隐变量自回归模型通常采用前向-后向算法或维特比算法来进行模型学习和推理。前向-后向算法用于计算在给定观测序列下的状态概率分布,而维特比算法用于寻找最可能的状态序列。
隐变量自回归模型具有广泛的应用,例如语音识别、自然语言处理、股票价格预测等领域。
这两种情况都有一个显而易见的问题:如何生成训练数据? 一个经典方法是使用历史观测来预测下一个未来观测。 显然,我们并不指望时间会停滞不前。 然而,一个常见的假设是虽然特定值可能会改变, 但是序列本身的动力学不会改变。 这样的假设是合理的,因为新的动力学一定受新的数据影响, 而我们不可能用目前所掌握的数据来预测新的动力学。 统计学家称不变的动力学为静止的(stationary)。 因此,整个序列的估计值都将通过以下的方式获得:
马尔可夫模型
回想一下,在自回归模型的近似法中, 我们使用
x
t
−
1
x_{t-1}
xt−1…
x
t
−
l
x_{t-l}
xt−l,而不是
x
t
−
1
x_{t-1}
xt−1…
x
1
x_{1}
x1来估计
x
t
x_t
xt,只要这种事近似精确的,我们就说序列满足马尔可夫条件,特别是,如果l=1,得到一个 一阶马尔可夫模型(first-order Markov model),公式如下:
当
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
)
=
P
(
x
t
+
1
∣
x
t
)
P(x_{t+1}|x_{t-1})=P(x_{t+1}|x_{t})
P(xt+1∣xt−1)=P(xt+1∣xt)
实践
让我们在实践中尝试一下! 首先,我们生成一些数据:使用正弦函数和一些可加性噪声来生成序列数据
生成数据
%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l
T = 1000 # 总共产生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))
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 = d2l.load_array((features[:n_train], labels[:n_train]),
batch_size, is_train=True)
这里的 tau = 4 表示我们将原始的时间序列数据按照每个样本包含 4 个时间步的方式进行划分。具体来说,我们把前 4 个时间步作为一个样本的特征,将第 5 个时间步作为该样本的标签,然后往后滑动一个时间步,重复以上操作,直到遍历完整个时间序列。
接下来,我们使用 torch.zeros((T - tau, tau)) 创建了一个大小为 n × τ n \times \tau n×τ 的零矩阵 features,其中 n = T − τ n=T-\tau n=T−τ 是样本数。然后,我们使用一个循环,将时间序列数据中每个长度为 τ \tau τ 的滑动窗口中的数据复制到 features 的对应列上。具体来说,对于每个 i = 0 , … , τ − 1 i=0,\ldots,\tau-1 i=0,…,τ−1,我们将 x[i: T - tau + i] 中的元素复制到 features 的第 i i i 列,第 j j j 行对应着时间为 j + i j+i j+i 的样本。
最后,我们将 x[tau:] 中的元素作为标签 labels,并将其重塑为一个 n × 1 n\times1 n×1 的列向量。注意,这里我们只选取了从第 τ + 1 \tau+1 τ+1 个时间步开始的数据作为标签,因为前 τ \tau τ 个时间步是用于构造特征的,而不是作为标签。
最后,我们将前 n train = 600 n_{\text{train}}=600 ntrain=600 个样本用于训练,每个小批量包含 16 16 16 个样本。具体地,我们使用 d2l.load_array 函数将特征和标签数据转换为迭代器 train_iter,以便进行模型训练。
训练
在这里,我们使用一个相当简单的架构训练模型: 一个拥有两个全连接层的多层感知机,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')
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: {d2l.evaluate_loss(net, train_iter, loss):f}')
net = get_net()
train(net, train_iter, loss, 5, 0.01)
预测
由于训练损失很小,因此我们期望模型能有很好的工作效果。 让我们看看这在实践中意味着什么。 首先是检查模型预测下一个时间步的能力, 也就是单步预测(one-step-ahead prediction)。
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))
之后,我们必须使用我们自己的预测(而不是原始数据)来进行多步预测。 让我们看看效果如何。
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之后,我们积累了一些错误。 于是,步骤2的输入被扰动了, 结果积累的误差是依照次序的, 其中为某个常数,后面的预测误差依此类推。 因此误差可能会相当快地偏离真实的观测结果。
基于 k = 1 , 4 , 16 , 64 k=1,4,16,64 k=1,4,16,64,通过对整个序列预测的计算, 让我们更仔细地看一下 k k k步预测的困难。
max_steps = 64
features = torch.zeros((T - tau - max_steps + 1, tau + max_steps))
# 列i(i<tau)是来自x的观测,其时间步从(i)到(i+T-tau-max_steps+1)
for i in range(tau):
features[:, i] = x[i: i + T - tau - max_steps + 1]
# 列i(i>=tau)是来自(i-tau+1)步的预测,其时间步从(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))
文本预处理
文本就是典型的序列数据,例如,一篇文章可以被简单地看作一串单词序列,甚至是一串字符序列。 本节中,我们将解析文本的常见预处理步骤。 这些步骤通常包括:
-
将文本作为字符串加载到内存中。
-
将字符串拆分为词元(如单词和字符)。
-
建立一个词表,将拆分的词元映射到数字索引。
-
将文本转换为数字索引序列,方便模型操作。
数据集
首先,我们从H.G.Well的时光机器中加载文本。 这是一个相当小的语料库,只有30000多个单词,但足够我们小试牛刀, 而现实中的文档集合可能会包含数十亿个单词。 下面的函数将数据集读取到由多条文本行组成的列表中,其中每条文本行都是一个字符串。 为简单起见,我们在这里忽略了标点符号和字母大写。
#@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])
词元化
下面的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('错误:未知词元类型:' + token)
tokens = tokenize(lines)
for i in range(11):
print(tokens[i])
词表
词元的类型是字符串,而模型需要的输入是数字,因此这种类型不方便模型使用。 现在,让我们构建一个字典,通常也叫做词表(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)
# 未知词元的索引为0
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]
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
def token_freqs(self):
return self._token_freqs
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]
return collections.Counter(tokens)
这段代码定义了一个名为 Vocab 的类,用于处理文本词表。它具有以下方法:
init: 用于初始化类实例。它接受三个参数:tokens,代表需要处理的文本数据,min_freq,代表词元最小出现次数,reserved_tokens,代表保留的词元列表。它将文本数据进行词频统计,并按照词频从高到低排序。然后,它将未知词元的索引设置为0,并将保留的词元添加到词典中。最后,它将出现次数大于等于min_freq的词元添加到词典中。
len: 返回词典的大小,即词元数量。
getitem: 用于获取指定词元的索引。如果输入的是一个词元,则返回其索引;如果输入的是一个词元列表,则返回每个词元的索引列表。
to_tokens: 用于获取指定索引的词元。如果输入的是一个索引,则返回对应的词元;如果输入的是一个索引列表,则返回每个索引对应的词元列表。
unk: 用于返回未知词元的索引。
token_freqs: 返回词典中每个词元的出现次数。
另外,代码中还定义了一个名为 count_corpus 的函数,用于对文本数据进行词频统计。它接受一个列表作为参数,这个列表可以是一个一维列表,也可以是一个二维列表。如果是一个二维列表,则将它展平成一个一维列表,然后进行词频统计,最后返回词频统计结果。
vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[:10])
for i in [0, 10]:
print('文本:', tokens[i])
print('索引:', vocab[tokens[i]])
整合
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)
语言模型和数据集
学习语言模型
显而易见,我们面对的问题是如何对一个文档, 甚至是一个词元序列进行建模。 假设在单词级别对文本数据进行词元化, 我们可以依靠在刚才中对序列模型的分析。 让我们从基本概率规则开始:
P
(
w
1
,
w
2
,
…
,
w
n
)
=
∏
i
=
1
n
P
(
w
i
∣
w
1
,
…
,
w
i
−
1
)
\displaystyle P(w_1,w_2,\ldots,w_n) = \prod_{i=1}^{n} P(w_i|w_1,\ldots,w_{i-1})
P(w1,w2,…,wn)=i=1∏nP(wi∣w1,…,wi−1)
例如,包含了四个单词的一个文本序列的概率是:
P
(
d
e
e
p
,
l
e
a
r
n
i
n
g
,
i
s
,
f
u
n
)
=
P
(
d
e
e
p
)
P
(
l
e
a
r
n
i
n
g
∣
d
e
e
p
)
P
(
i
s
∣
d
e
e
p
,
l
e
a
r
n
i
n
g
)
P
(
f
u
n
∣
d
e
e
p
.
l
e
a
r
n
i
n
g
,
i
s
)
\displaystyle P(deep,learning,is,fun) =\displaystyle P(deep)P(learning|deep)P(is|deep,learning)P(fun|deep.learning,is)
P(deep,learning,is,fun)=P(deep)P(learning∣deep)P(is∣deep,learning)P(fun∣deep.learning,is)
为了训练语言模型,我们需要计算单词的概率, 以及给定前面几个单词后出现某个单词的条件概率。 这些概率本质上就是语言模型的参数。
训练数据集中词的概率可以根据给定词的相对词频来计算。 例如,可以将估计值计算为
P
^
(
d
e
e
p
)
\hat{P}(deep)
P^(deep)任何以单词“deep”开头的句子的概率。 一种(稍稍不太精确的)方法是统计单词“deep”在数据集中的出现次数, 然后将其除以整个语料库中的单词总数。 这种方法效果不错,特别是对于频繁出现的单词。 接下来,我们可以尝试估计:
P
^
(
l
e
a
r
n
i
n
g
∣
d
e
e
p
)
=
n
(
d
e
e
p
,
l
e
a
r
n
i
n
g
)
n
(
d
e
e
p
)
\hat{P}(learning|deep)=\frac{n(deep,learning)}{n(deep)}
P^(learning∣deep)=n(deep)n(deep,learning)
这里使用了贝叶斯公式:
P
(
c
∣
w
)
=
P
(
w
∣
c
)
P
(
c
)
P
(
w
)
\displaystyle P(c|w) = \frac{P(w|c)P(c)}{P(w)}
P(c∣w)=P(w)P(w∣c)P(c)
其中
n
(
x
)
n(x)
n(x)和
n
(
x
,
x
’
)
n(x,x’)
n(x,x’)分别是单个单词和连续单词对的出现次数。 不幸的是,由于连续单词对“deep learning”的出现频率要低得多, 所以估计这类单词正确的概率要困难得多。 特别是对于一些不常见的单词组合,要想找到足够的出现次数来获得准确的估计可能都不容易。 而对于三个或者更多的单词组合,情况会变得更糟。 许多合理的三个单词组合可能是存在的,但是在数据集中却找不到。 除非我们提供某种解决方案,来将这些单词组合指定为非零计数, 否则将无法在语言模型中使用它们。 如果数据集很小,或者单词非常罕见,那么这类单词出现一次的机会可能都找不到。
一种常见的策略是执行某种形式的拉普拉斯平滑,具体方法是在所有计数中添加一个小常量。 用n表示训练集中的单词总数,用m表示唯一单词的数量。 此解决方案有助于处理单元素问题,例如通过:
然而,这样的模型很容易变得无效,原因如下: 首先,我们需要存储所有的计数; 其次,这完全忽略了单词的意思。 例如,“猫”(cat)和“猫科动物”(feline)可能出现在相关的上下文中, 但是想根据上下文调整这类模型其实是相当困难的。 最后,长单词序列大部分是没出现过的, 因此一个模型如果只是简单地统计先前“看到”的单词序列频率, 那么模型面对这种问题肯定是表现不佳的。
自然语言统计
根据上面介绍的时光机器数据集构建词表, 并打印前个最常用的(频率最高的)单词。
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 = Vocab(corpus)
vocab.token_freqs[:10]
正如我们所看到的,最流行的词看起来很无聊, 这些词通常被称为停用词(stop words),因此可以被过滤掉。 尽管如此,它们本身仍然是有意义的,我们仍然会在模型中使用它们。 此外,还有个明显的问题是词频衰减的速度相当地快。 例如,最常用单词的词频对比,第10个还不到第1个的1/5。 为了更好地理解,我们可以画出的词频图:
freqs = [freq for token, freq in vocab.token_freqs]
d2l.plot(freqs, xlabel='token: x', ylabel='frequency: n(x)',
xscale='log', yscale='log')
通过此图我们可以发现:词频以一种明确的方式迅速衰减。 将前几个单词作为例外消除后,剩余的所有单词大致遵循双对数坐标图上的一条直线。 这意味着单词的频率满足齐普夫定律(Zipf’s law), 即第
i
i
i个最常用单词的频率
n
i
n_i
ni为:
n
i
∝
1
i
α
n_i \propto \frac{1}{i^\alpha}
ni∝iα1
等价于:
l
o
g
n
i
=
−
α
l
o
g
i
+
c
logn_i=-\alpha logi + c
logni=−αlogi+c
其中
α
\alpha
α是刻画分布的指数,
c
c
c是常数。 这告诉我们想要通过计数统计和平滑来建模单词是不可行的, 因为这样建模的结果会大大高估尾部单词的频率,也就是所谓的不常用单词。 那么其他的词元组合,比如二元语法、三元语法等等,又会如何呢? 我们来看看二元语法的频率是否与一元语法的频率表现出相同的行为方式。
bigram_tokens = [pair for pair in zip(corpus[:-1], corpus[1:])]
bigram_vocab = Vocab(bigram_tokens)
bigram_vocab.token_freqs[:10]
这里值得注意:在十个最频繁的词对中,有九个是由两个停用词组成的, 只有一个与“the time”有关。 我们再进一步看看三元语法的频率是否表现出相同的行为方式。
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元组的数量并没有那么大,这说明语言中存在相当多的结构, 这些结构给了我们应用模型的希望
读取长序列数据
首先,由于文本序列可以是任意长的, 例如整本《时光机器》(The Time Machine), 于是任意长的序列可以被我们划分为具有相同时间步数的子序列。 当训练我们的神经网络时,这样的小批量子序列将被输入到模型中。 假设网络一次只处理具有
n
n
n个时间步的子序列。 下图画出了 从原始文本序列获得子序列的所有不同的方式, 其中
n
=
5
n=5
n=5,并且每个时间步的词元对应于一个字符。 请注意,因为我们可以选择任意偏移量来指示初始位置,所以我们有相当大的自由度。
事实上,他们都一样的好。 然而,如果我们只选择一个偏移量, 那么用于训练网络的、所有可能的子序列的覆盖范围将是有限的。 因此,我们可以从随机偏移量开始划分序列, 以同时获得覆盖性(coverage)和随机性(randomness)。 下面,我们将描述如何实现随机采样(random sampling)和 顺序分区(sequential partitioning)策略。
随机采样
在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻。 对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。
def seq_data_iter_random(corpus, batch_size, num_steps): #@save
"""使用随机抽样生成一个小批量子序列"""
# 从随机偏移量开始对序列进行分区,随机范围包括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到num_steps-1中随机选择的,num_steps是子序列的长度。这个步骤的目的是确保每个小批量的子序列是从整个文本序列中随机选择的。
将切分后的文本序列分成长度为num_steps的子序列。在每个小批量中,我们需要随机选择batch_size个子序列。因此,我们需要知道在切分后的序列中有多少个这样的子序列,这个数量可以通过整个序列的长度减去1,然后除以num_steps得到。在这个步骤中,我们还计算了每个子序列的起始索引。
在每个小批量的迭代过程中,我们随机选择batch_size个子序列,这些子序列的起始索引从切分后的文本序列中的所有起始索引中随机选择。为了在迭代过程中随机选择这些起始索引,我们使用Python内置的random.shuffle函数打乱了initial_indices列表,这个列表包含了所有子序列的起始索引。
对于每个小批量,我们使用initial_indices_per_batch列表中的起始索引来选择batch_size个子序列,然后将这些子序列作为小批量的输入和标签。具体来说,对于每个起始索引,我们将其后的num_steps个字符作为输入子序列,并将其后的num_steps个字符作为标签子序列。这样,我们可以训练模型来预测每个输入子序列的下一个字符。
最后,函数返回每个小批量的输入和标签子序列,这些子序列被转换为PyTorch张量。
假设原始的文本序列是 “The quick brown fox jumps over the lazy dog.”,num_steps为10。在这种情况下,该函数会随机选择一个偏移量作为开始位置,例如,偏移量为3,则从文本序列的第四个字符 “q” 开始生成子序列。因此,生成的子序列是 “quick brow”。注意,由于我们从偏移量开始,所以子序列的长度可能小于 num_steps,这并不重要,因为后面的代码会确保每个小批量的子序列都有相同的长度。所以,这个函数会根据一个随机的偏移量对原始序列进行切分,从而生成一个随机的子序列,以便后续生成小批量的子序列。
顺序分区
顺序分区是指按照数据在原始序列中出现的顺序对数据进行分区。在自然语言处理中,这通常是一个常见的数据分区方式。例如,我们可以将一个文本分成若干段子序列,并对它们进行分区,然后在模型训练过程中依次将这些分区作为训练样本输入模型。这种分区方式在序列数据中很常见,例如在语音识别中,我们可以将一段音频分为若干段子序列,并对它们进行分区,然后依次将这些分区作为训练样本输入模型。顺序分区的好处是可以更好地保留原始数据的顺序,从而更好地训练模型。
def seq_data_iter_sequential(corpus, batch_size, num_steps): #@save
"""使用顺序分区生成一个小批量子序列"""
# 从随机偏移量开始划分序列
offset = random.randint(0, num_steps-1)
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_steps * num_batches, num_steps):
X = Xs[:, i: i + num_steps]
Y = Ys[:, i: i + num_steps]
yield X, Y
这段代码实现了一个使用顺序分区生成小批量子序列的函数。具体来说,该函数接收三个参数:corpus表示原始序列,batch_size表示每个小批量的样本数量,num_steps表示每个样本序列的长度。函数的实现如下:
从随机偏移量开始划分序列
offset = random.randint(0, num_steps)
首先随机生成一个偏移量,用于确定从原始序列的哪个位置开始划分序列。
计算序列的长度
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
然后计算划分后序列的长度,具体来说,先减去偏移量1,再整除batch_size得到的结果再乘以batch_size,即为最终序列的长度,这样可以保证最终得到的序列长度是batch_size的整数倍。
将划分后的序列转换为张量形式
Xs = torch.tensor(corpus[offset: offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
将划分后的序列转换为张量形式,Xs表示输入数据,Ys表示标签数据,Ys中的每个元素都是Xs中对应元素的下一个元素。
将张量形式的数据重新组合为batch_size个样本
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
将张量形式的数据重新组合为batch_size个样本,每个样本长度为num_tokens/batch_size,-1表示自动计算这个维度的大小。
生成小批量子序列
for i in range(0, num_steps * num_batches, num_steps):
X = Xs[:, i: i + num_steps]
Y = Ys[:, i: i + num_steps]
根据num_steps将每个样本划分为若干个小批量子序列,每个小批量子序列的长度为num_steps,最后返回X和Y,它们分别表示输入数据和标签数据。
封装
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)
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