序列模型


序列

序列通常被描述为被排成一列的元素对象,是一种最常见的数据结构形式之一。序列模型(Sequence Model)是一类专门用于处理和预测序列数据的模型。这类模型在自然语言处理、音频处理、时间序列分析等领域有着广泛的应用,举例如下

  • 人类语言的语音识别
  • 股市的每日行情预测
  • 大语言模型的文本生成
  • 视频、游戏的画面帧生成

我们以离散时间序列为例,来讨论如何处理和预测序列数据。令时间 t=1,2,t=1,2,\cdots 观察到的随机变量为 xtx_t,那么观察 TT 次后,我们就得到了 TT不一定独立的随机变量。这些随机变量的联合分布律可以表示为

(x1,x2,,xT)P(x)(x_1,x_2,\cdots,x_T)\sim P(\boldsymbol{x})

根据条件概率的乘法公式

P(xixj)=P(xi)P(xjxi)=P(xj)P(xixj)P(x_ix_j)=P(x_i)P(x_j|x_i)=P(x_j)P(x_i|x_j)

因此,可以对 P(x)P(\boldsymbol{x}) 进行概率展开

P(x)=P(x1)P(x2x1)P(x3x1x2)P(xTx1x2xT1)=t=1TP(xtx1xt1)\begin{aligned}P(\boldsymbol{x})&=P(x_1)P(x_2|x_1)P(x_3|x_1x_2)\cdots P(x_T|x_1x_2\cdots x_{T-1})\\&=\prod_{t=1}^T P(x_t|x_{1}\cdots x_{t-1})\end{aligned}

因此,我们需要讨论如何求这 TT 个条件概率。这实际上可以看做一个建模问题,我们需要基于不同的假设给出不同的求法。下面是两个常用的模型假设方案。

马尔科夫假设

对于一个长序列数据,我们是不一定要考虑每一个前置变量的。举例而言,我们要预测每日的天气情况,目前已经预测了325天。如果我们把前面所有325天的天气情况都考虑进来,去预测第326天的天气,这显然是不必要的,因为很久之前的天气和现在的天气已经关系不大了。

一般的,假设当前数据只跟过去 τ\tau 个数据点相关,那么有

P(xTx1xT1)=P(xTxTτxT1)P(x_T|x_1\cdots x_{T-1})=P(x_T|x_{T-\tau}\cdots x_{T-1})

该假设称为马尔科夫假设,此时的预测结果变为

P(x)=t=1TP(xtxtτxt1)P(\boldsymbol{x})=\prod_{t=1}^T P(x_t|x_{t-\tau}\cdots x_{t-1})

若假设数据点满足线性关系

xt=c+ϕ1xt1+ϕ2xt2++ϕτxtτ+εtx_t=c+\phi_1x_{t-1}+\phi_2x_{t-2}+\cdots+\phi_{\tau}x_{t-\tau}+\varepsilon_t

称之为 τ\tau 阶自回归模型(Autoregressive, AR),其中 cc 为偏置项,εtN(0,σ2)\varepsilon_t\sim N(0,\sigma^2) 为白噪声项,ϕi\phi_i 为参数。

τ=1\tau=1 时,称之为一阶马尔科夫模型。

潜变量假设

另一种假设方案是潜变量假设,其核心思想可以抽象如下:事物的发展是由一个不可见的内部隐藏状态 hh 驱动的。我们观测到的数据,只是这个隐藏状态的外在表现 xix_i

假设当前的数据 x\boldsymbol{x} 对应潜变量 hh,现在想要预测下一步的 x\boldsymbol{x}^*,我们可以这样做

  1. 考虑建模问题:通过 x\boldsymbol{x} 和其潜变量 hh 推导出下一时刻的潜变量 hh^*。因为我们在假设中提到,潜变量 hh 才是整个序列发展的关键因素
  2. 基于建模结果,通过预测的潜变量 hh^* 和已有 x\boldsymbol{x},预测外在表现 x\boldsymbol{x}^*

这个过程中的潜变量 hh 一般需要通过人为建模找出。比如在股市行情预测中,我们观察到的是股价的时间序列 xtx_t。由于股市行和市场心理是紧密相关的,这是一个抽象的、不可预测的潜变量 hth_t。因此,我们可以通过数学建模的方法整理出 ht=g(xt1,ht1)h_t=g(\boldsymbol{x}_{t-1},h_{t-1}) ,再根据 hth_t 预测下一步的 xt\boldsymbol{x}_t

这种假设称为潜变量序列模型

代码实现

我们尝试把马尔科夫假设模型运用到MLP上,我们使用正弦函数和白噪声生成长度为1000的序列。

1
2
3
4
5
6
7
8
%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,))

想要使用MLP模型,主要难点是把一维的时间序列,变成传统机器学习/深度学习模型所需要的二维矩阵数据(特征矩阵 X\boldsymbol{X} 和标签向量 y\boldsymbol{y})。根据马尔科夫假设,令 τ=4\tau=4,那么就把 xt=(xtτ,,xt1)\boldsymbol{x}_t=(x_{t-\tau},\cdots,x_{t-1}) 作为特征 featuresyt=xty_t=x_t 作为标签 labels。我们采用前600个特征-标签对作为训练数据

1
2
3
4
5
6
7
8
9
10
11
12
13
tau = 4
# 初始化特征矩阵,一共有996对
features = torch.zeros((T - tau, tau))
for i in range(tau):
# 按列进行赋值,因为一共有 T-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)

基于MLP部分,搭建一个简单的多层感知机并执行训练

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
# 初始化网络权重的函数
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}, '
f'loss: {d2l.evaluate_loss(net, train_iter, loss):f}')

net = get_net()
train(net, train_iter, loss, 5, 0.01)

我们根据前 600 对训练数据,考察模型的序列预测能力。对于直到 xtx_t 的观测序列,其在时间 t+kt+k 处的预测输出 x^t+k\hat{x}_{t+k} 称为 kk 步预测。对于单步预测(k=1k=1)而言,我们的预测都是基于已有的真实数据 X 做出的下一步预测,而对于 k>1k>1 的情况,我们需要借助自己模型的预测值去预测。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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))

下图给出了不同步长预测的结果,可以发现 kk 增大的同时,预测能力变差。
不同步长的预测结果

我们提到的 600 对训练数据只是为了训练模型参数,不要和 kk 步预测的使用参数搞混。

文本预处理


文本序列

对于序列数据处理问题,文本处理是最常见例子之一。一篇文章可以被简单地看作一串单词序列,甚至是一串字符序列。本节中,我们将解析文本的常见预处理步骤,这些步骤通常包括以下四点

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

我们从 H. G. Well 的科幻文章 The Time Machine 为例,尝试进行文本预处理。这个文章一共包括约三万个单词,我们忽略其中的标点和字母的大写,把文章读取为若干个文本行 lines

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import collections
import re
from d2l import torch as d2l

d2l.DATA_HUB['time_machine'] = (d2l.DATA_URL + 'timemachine.txt',
'090b5e7e70c295757f55df93cb0a180b9691891a')

# 正则表达式读取
def read_time_machine():
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()

Token 化

为了实现 Token 化,我们将文本行列表 lines 作为输入,其每个元素是一个文本序列(如一条文本行),而每个文本序列又被处理为一个词元列表。词元(Token) 是人工智能模型处理文本信息的最小单位,类型为字符串,我们可以粗略理解为一个单词或语言单位。

例如,英文文本序列 I am a boy. 的 Token 列表可以表示为 ['I', 'am', 'a', 'boy', '.'] ;中文文本序列 姬你太美 的 Token 列表可以表示为 ['姬', '你', '太', '美', '!']。这里只是举个例子,真实的 Token 的划分遵循一套特殊的算法,后续文章会介绍。

1
2
3
4
5
6
7
8
9
10
11
# token 化处理
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)

词表

词元的类型是字符串,而模型需要的输入是数字,因此词元类型不方便模型使用。幸运的是,我们可以借助 Python 等工具创建一个词表(Vocabulary),将字符串类型的词元映射为从 00 开始的数字索引中。

首先,我们需要将 Token 化后所有的词元进行整合与统计,得到一个语料库(Corpus),其记录了每个出现词元的频数。我们一般按照词元的频率高低分配对应的数字索引,而频率高的词一般优先分配,频率低的词靠后分配。特别地,对于一些很少出现的词元,我们一般将其剔除,原因有二

  • 可以降低复杂性,减少内存占用
  • 防止过拟合

当模型的训练文本数据较少时,面对某个新的未知输入词元,我们可能无法匹配一个语料库中的已有索引,此时我们规定一个未知词元 <unk>,将语料库中不存在或者被删除的词元映射为 <unk>

与此同时,我们可以增加一些特殊功能词元。

  • <pad>:填充词元,作为占位无意义词
  • <bos>:序列开始词元,作为文本启动词
  • <eos>:序列结束词元,作为文本休止词
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
# 文本词表
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, 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):
# 这里的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)

我们尝试获取 The Time Machine 的语料库和词库大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
def load_corpus_time_machine(max_tokens=-1):  #@save
lines = read_time_machine()
# 这里采用字符 Token 化
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()
print(len(corpus), len(vocab))

这里,代码会自动下载文本文件 timemachine.txt 在如下路径。

1
2
3
4
5
Root\
├── code\
│ └── TextPre.ipynb
└── data\
└── timemachine.txt

语言模型


模型目标

对于某个文本序列,其词元分别为 x1,x2,,xTx_1,x_2,\cdots,x_T,那么 xt(1tT)x_t(1\leqslant t\leqslant T) 可以视作文本序列在时间 tt 处的值。因此,给定这样一个文本序列,语言模型(Language Model)要做的就是估计序列的联合概率

P(x1,x2,,xT)=t=1TP(xtxtτxt1)P(x_1,x_2,\cdots,x_T)=\prod_{t=1}^T P(x_t|x_{t-\tau}\cdots x_{t-1})

根据前文有关时间序列的讨论,为了训练语言模型,我们需要计算单词的概率,以及给定前面几个单词后出现某个单词的条件概率。这些概率本质上就是语言模型的参数。假设 n(xi)n(x_i) 表示词元 xix_i 出现的频数,n(xi,xj,)n(x_i,x_j,\cdots) 表示连续词元对 xi,xj,x_i,x_j,\cdots 出现的频数,那么有估计概率

P^(x2x1)=n(x1,x2)n(x1)\hat{P}(x_2|x_1)=\frac{n(x_1,x_2)}{n(x_1)}

N元语法

当序列很长时,由于文本量不够大,通常会导致某个连续多词元对的出现频数 n(xi,xj,)1n(x_i,x_j,\cdots)\leqslant 1,因此上述估计通常是不准确的。此时可以考虑马尔科夫假设。对于最简单的情况,如果每个词元之间相互独立,那么就有一元语法模型(Unigram)

P(x1,x2,,xT)=P(x1)P(x2)P(xT)P(x_1,x_2,\cdots,x_T)=P(x_1)P(x_2)\cdots P(x_T)

这通常是不成立的,因为文本序列是一种高度依赖前后文的序列。进一步的,假设每个词元之和前一个词元有关,这就是一个二元语法模型(Bigram)。模型可以表示为

P(x1,x2,,xT)=P(x1)t=2TP(xtxt1)P(x_1,x_2,\cdots,x_{T})=P(x_1)\prod_{t=2}^T P(x_t|x_{t-1})

根据大数定律,当训练数据足够多时有

P(xtxt1)n(xt1,xt)n(xt1)P(x_t|x_{t-1})\approx \frac{n(x_{t-1},x_t)}{n(x_{t-1})}

如果假设每个词元和前两个词元有关,便得到一个三元语法模型(Trigram)

P(x1,x2,,xT)=P(x1,x2)t=3TP(xtxt1xt2)P(x_1,x_2,\cdots,x_{T})=P(x_1,x_2)\prod_{t=3}^T P(x_t|x_{t-1}x_{t-2})

一般的,假设某个词元只和前 n1n-1 个词元有关,那么该预测模型为 nn 元语法模型。但是,当 nn 变大时,连续词元组合仍然有可能很少甚至不出现,这就导致模型再次失效。一种常见的策略是执行拉普拉斯平滑,对每个计数加上一个小正数(通常为1)来避免零概率问题出现。

齐普夫定律困境

我们根据前文的 The Time Machine 词表,画出双对数坐标下的词频图。
词频图

首先考虑单个词元(一元语法),第 ii 个最常用词元的频率 nin_i 近似满足

ni1iαn_i\propto \frac{1}{i^{\alpha}}

这一统计规律通常记为齐普夫定律(Zipf’s Law),其表明在自然语言文本中,一个词出现的频率(的幂次)与它在频率表里的排名近似成反比。我们可以打印出 The Time Machine 词表中前十个频数最高的词元如下

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)]

这些词通常称为高频停用词,他们在文本含义上通常简简单单,但是出现的频率是很高的;相反的,对于更多具有直观含义的词语,他们出现的次数可能只有一两次,但是这样的词有很多。这在常规坐标下的频数分布图上体现为长尾分布

从上图中还可以看出,除了一元语法词,多元单词序列似乎也遵循齐普夫定律。由于 nn 元词元组通常很少出现,这使得拉普拉斯平滑也非常不适合语言建模。因此我们需要使用基于深度学习的模型来解决这一问题。

读取长序列

由于序列数据具有连续性,当序列变得太长而不能被模型一次性全部处理时,我们可能希望拆分这样的序列方便模型读取。下面,我们将描述如何借助随机采样(Random Sampling)和顺序分区(Sequential Partitioning)策略实现长序列的读取。

  • 在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻。对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元,因此标签是移位了一个词元的原始序列。
  • 在顺序分区中,我们保证两个相邻的小批量中的子序列在原始序列上也是相邻的。这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称为顺序分区。

循环神经网络


隐状态

前面我们提到,nn 元语法模型通常不是一个理想的语言模型,因此我们回过头来看第一节中提出的潜变量假设。通常,我们可以基于当前输入 xt\boldsymbol{x}_{t} 和先前隐状态 ht1h_{t-1} 来计算时间步 tt 处的任何时间的隐状态(Hidden State)

ht=f(xt,ht1)h_t=f(\boldsymbol{x}_t,h_{t-1})

循环神经网络(*Recurrent Neural Network,RNN)是具有隐状态的神经网络。在介绍循环神经网络模型之前,我们首先回顾一下多层感知机模型。对于一个单隐藏层的MLP而言,其模型可以表示为

H=σ(XWxh+bh)\boldsymbol{H}=\sigma(\boldsymbol{XW}_{xh}+\boldsymbol{b}_h)

O=HWhq+bq\boldsymbol{O}=\boldsymbol{HW}_{hq}+\boldsymbol{b}_q

其中参数矩阵 Wxh,bh\boldsymbol{W}_{xh},\boldsymbol{b}_h 表示其将输入 X\boldsymbol{X} 转化为隐藏层变量 H\boldsymbol{H}σ()\sigma(\cdot) 是激活函数,Whq,bq\boldsymbol{W}_{hq},\boldsymbol{b}_q 表示其将隐藏层变量 H\boldsymbol{H} 转化为输出层 O\boldsymbol{O}。对于一个分类问题,我们可以把 softmax(O)\mathrm{softmax}(\boldsymbol{O}) 作为输出类别的概率分布。

如果我们要引入隐状态,即对于时间步 tt 而言有小批量输入 XtRm×n\boldsymbol{X}_t\in\mathbb{R}^{m\times n},其每一行对应该序列在时间步 tt 的一个样本。令该时间步的隐变量为 HtRm×h\boldsymbol{H}_t\in\mathbb{R}^{m\times h},并引入新的权重参数 WhhRh×h\boldsymbol{W}_{hh}\in\mathbb{R}^{h\times h} 来描述前一个时间步的 Ht1\boldsymbol{H}_{t-1} 对当前时间步的贡献。具体而言有

Ht=σ(XtWxh+Ht1Whh+bh)\boldsymbol{H}_t=\sigma(\boldsymbol{X}_t\boldsymbol{W}_{xh}+\boldsymbol{H}_{t-1}\boldsymbol{W}_{hh}+\boldsymbol{b}_h)

从上述关系式中看出,隐藏变量 Ht\boldsymbol{H}_{t} 等捕获并保留了序列直到其当前时间步的历史信息,就如当前时间步下神经网络的状态或记忆,因此这样的隐藏变量被称为隐状态(Hidden State)。对于每一个时间步 tt 而言,其输出可以表示为

Ot=HtWhq+bq\boldsymbol{O}_t=\boldsymbol{H}_t\boldsymbol{W}_{hq}+\boldsymbol{b}_q

对于序列模型而言,每一个时间步 t=1,2,t=1,2,\cdots 都应该有对应的输出。值得一提的是,即使在不同的时间步,循环神经网络也总是使用这些模型参数。 因此,循环神经网络的参数开销不会随着时间步的增加而增加。

具有隐状态的循环神经网络

注意到,在隐状态的循环神经网络中,我们将当前输入和上一步的隐藏变量用加法链接,即 XtWxh+Ht1Whh\boldsymbol{X}_t\boldsymbol{W}_{xh}+\boldsymbol{H}_{t-1}\boldsymbol{W}_{hh}。事实上,这可以看做输入和隐藏变量的拼接矩阵运算,即有分块矩阵形式

XtWxh+Ht1Whh=[XtHt1][WxhWhh]\boldsymbol{X}_t\boldsymbol{W}_{xh}+\boldsymbol{H}_{t-1}\boldsymbol{W}_{hh}=\begin{bmatrix}\boldsymbol{X}_t&\boldsymbol{H}_{t-1}\end{bmatrix}\begin{bmatrix}\boldsymbol{W}_{xh}\\\boldsymbol{W}_{hh}\end{bmatrix}

这从某种程度上,更直观的展示了循环神经网络和隐状态的融合使用。

字符级语言建模

Bengio 等人首先提出了使用神经网络进行语言建模的概念。在一个循环神经网络模型中,设小批量大小为1,批量中的文本序列为一个单词。为了简化后续部分的训练,我们考虑使用字符级语言模型(Character-level Language Model),将文本词元化为字符而不是单词。下图演示了如何通过基于字符级语言建模的循环神经网络, 使用当前的和先前的字符预测下一个字符。

字符级语言建模

实践中,我们使用批量大小为 m>1m>1,每个词元由一个 nn 维向量表示,因此输入 XRm×n\boldsymbol{X}\in\mathbb{R}^{m\times n}

困惑度

最后,让我们讨论如何度量语言模型的质量,这将在后续部分中用于评估基于循环神经网络的模型。信息论知识表明,一个长度为 TT 序列的交叉熵可以表示为

H=1Tt=1TlogP(xtx1xt1)H=-\frac{1}{T}\sum_{t=1}^T \log P(x_t|x_1\cdots x_{t-1})

定义其困惑度(Perplexity)为

PP=2HPP=2^{H}

困惑度的最好的理解是“下一个词元的实际选择数的调和平均数”,其等价形式为

PP=P(x1,x2,,xT)1TPP=P(x_1,x_2,\cdots,x_T)^{-\frac{1}{T}}

  • 如果困惑度较低,说明语言模型对序列的预测较为准确
  • 如果困惑度较高,说明模型在预测下一个词时存在较大的不确定性,性能较差

代码


主播先run去期末周复习了,有缘更新哦(づ ̄3 ̄)づ╭❤~