霜庭小筑

9.1 序列模型

9.1 序列模型

让我们先来看看这些场景:

  • 随着时间的推移,人们对电影的看法会发生很大的变化。
  • 预测明天的股价要比过去的股价更困难。
  • 在本质上,音乐、语音、文本和视频都是连续的。
  • 人类之间的互动也是连续的。

与之前处理单个特征向量的模型不同,处理序列时输入变为一个有序的特征向量列表 x1,…,xT\mathbf{x}_1, \dots, \mathbf{x}_T,每个特征向量 xt\mathbf{x}_t 由时间步 tt 索引。

序列预测任务有多种形式:根据序列输入预测固定目标(如情感分类)、根据固定输入预测序列目标(如图像描述)、以及序列到序列的任务(如机器翻译)。最基础的问题是无监督密度建模(也称序列建模),即估计看到任意给定序列的概率 p(x1,…,xT)p(\mathbf{x}_1, \ldots, \mathbf{x}_T)。


自回归模型

以股票价格数据为例,交易者希望根据历史价格预测下一时刻的价格,即估计条件分布:

P(xt∣xt−1,…,x1)P(x_t \mid x_{t-1}, \ldots, x_1)

用信号的历史值来回归该信号当前值的模型,自然被称为自回归模型。核心挑战在于:输入 xt−1,…,x1x_{t-1}, \ldots, x_1 的数量随 tt 变化,每个样本的特征数量不同。而克服这一挑战的策略有:

  • 只使用最近 τ\tau 个观测值 xt−1,…,xt−τx_{t-1}, \ldots, x_{t-\tau},使输入维度固定。
  • 维护一个对过去观测的总结 hth_t,同时更新 hth_t 和预测 x^t\hat{x}_t:x^t=P(xt∣ht)\hat{x}_t = P(x_t \mid h_t),ht=g(ht−1,xt−1)h_t = g(h_{t-1}, x_{t-1})。由于 hth_t 不可观测,这类模型也称为隐变量自回归模型。

图1:隐变量自回归模型


序列模型

有时我们需要估计整个序列的联合概率,这类函数称为序列模型;对于自然语言数据,则称为语言模型。语言模型不仅用于评估似然性,还可用于采样序列和优化最可能的序列。

通过概率链式法则,可以将序列的联合密度分解为从左到右的条件密度乘积:

P(x1,…,xT)=P(x1)∏t=2TP(xt∣xt−1,…,x1)P(x_1, \ldots, x_T) = P(x_1) \prod_{t=2}^T P(x_t \mid x_{t-1}, \ldots, x_1)

对于离散信号(如单词),自回归模型必须是一个概率分类器,输出给定左向上下文时下一个词在整个词表上的概率分布。


马尔科夫模型

如果我们只依赖最近 τ\tau 个时间步 xt−1,…,xt−τx_{t-1}, \ldots, x_{t-\tau} 而非完整历史,且不损失预测能力,则称序列满足马尔可夫条件——即给定近期历史,未来与过去条件独立。

  • τ=1\tau = 1:一阶马尔可夫模型,联合概率分解为 P(x1,…,xT)=P(x1)∏t=2TP(xt∣xt−1)P(x_1, \ldots, x_T) = P(x_1) \prod_{t=2}^T P(x_t \mid x_{t-1})
  • τ=k\tau = k:kk 阶马尔可夫模型

对于离散数据,真正的马尔可夫模型只需统计每个上下文中各词出现的次数,产生相对频率估计。


解码顺序

为什么不从右到左或按其他顺序分解?虽然反向分解也是有效的,但从左到右(阅读方向)的分解有几个优势:

  1. 这是更自然的思考方向——我们每天都在预测下一个词是什么。
  2. 按顺序分解可以用同一个语言模型为任意长度的序列分配概率——只需乘以新增词的条件概率。
  3. 预测相邻词比预测任意位置的词更容易。

此外,对于因果结构的数据(如时间向前流动),改变 xtx_t 可能影响 xt+1x_{t+1},但反过来不成立,因此预测 P(xt+1∣xt)P(x_{t+1} \mid x_t) 通常比预测 P(xt∣xt+1)P(x_t \mid x_{t+1}) 更容易。


训练

这里采用中文版的代码,较新的英文版代码放在最后

使用合成的正弦波数据(外加噪声)进行实验。假设数据满足 τ\tau 阶马尔可夫条件,用过去 τ\tau 个观测值预测 xtx_t。每个样本的标签为 y=xty = x_t,特征为 xt=[xt−τ,…,xt−1]\mathbf{x}_t = [x_{t-\tau}, \ldots, x_{t-1}]。

数据集包含 T−τT - \tau 个样本(前 τ\tau 个因缺少足够历史而被丢弃),每个输入的长度为 τ\tau。使用前 600 个样本进行训练,采用标准线性回归模型。

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

svg

接下来,我们将这个序列转换为模型的特征-标签(feature-label)对。

这里的赋值写得真漂亮…

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)

我们使用一个相当简单的架构训练模型: 一个拥有两个全连接层的多层感知机,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
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)
epoch 1, loss: 0.054171
epoch 2, loss: 0.057454
epoch 3, loss: 0.055111
epoch 4, loss: 0.053738
epoch 5, loss: 0.055068
D:\miniforge3\envs\study\Lib\site-packages\d2l\torch.py:3205: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.
Consider using tensor.detach() first. (Triggered internally at C:\actions-runner\_work\pytorch\pytorch\torch\csrc\autograd\generated\python_variable_methods.cpp:839.)
self.data = [a + float(b) for a, b in zip(self.data, args)]

预测

首先是检查模型预测下一个时间步的能力, 也就是单步预测(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))

svg

对于观测序列 x1,…,xtx_1, \ldots, x_t,在时间步 t+kt+k 的预测输出称为 kk 步预测(kk-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)
)
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))

svg

可以看到,绿线的预测显然并不理想。原因是误差累积:第一步有误差 ϵ1=ϵˉ\epsilon_1 = \bar\epsilon,第二步的输入被 ϵ1\epsilon_1 扰动,误差变为 ϵ2=ϵˉ+cϵ1\epsilon_2 = \bar\epsilon + c\epsilon_1,如此不断放大。

基于 k=1,4,16,64k = 1,4,16,64 ,通过对整个序列预测的计算, 让我们更仔细地看一下 kk 步预测的困难。

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

svg


下面是英文版的代码(训练+预测)

class Data(d2l.DataModule):
def __init__(self, batch_size=16, T=1000, num_train=600, tau=4):
self.save_hyperparameters()
self.time = torch.arange(1, T + 1, dtype=torch.float32)
self.x = torch.sin(0.01 * self.time) + torch.randn(T) * 0.2
data = Data()
d2l.plot(data.time, data.x, 'time', 'x', xlim=[1, 1000], figsize=(6, 3))

svg

@d2l.add_to_class(Data)
def get_dataloader(self, train):
features = [self.x[i : self.T-self.tau+i] for i in range(self.tau)]
self.features = torch.stack(features, 1)
self.labels = self.x[self.tau:].reshape((-1, 1))
i = slice(0, self.num_train) if train else slice(self.num_train, None)
return self.get_tensorloader([self.features, self.labels], train, i)
model = d2l.LinearRegression(lr=0.01)
trainer = d2l.Trainer(max_epochs=5)
trainer.fit(model, data)

svg

onestep_preds = model(data.features).detach().numpy()
d2l.plot(data.time[data.tau:], [data.labels, onestep_preds], 'time', 'x',
legend=['labels', '1-step preds'], figsize=(6, 3))

svg

multistep_preds = torch.zeros(data.T)
multistep_preds[:] = data.x
for i in range(data.num_train + data.tau, data.T):
multistep_preds[i] = model(
multistep_preds[i - data.tau:i].reshape((1, -1)))
multistep_preds = multistep_preds.detach().numpy()
d2l.plot([data.time[data.tau:], data.time[data.num_train+data.tau:]],
[onestep_preds, multistep_preds[data.num_train+data.tau:]], 'time',
'x', legend=['1-step preds', 'multistep preds'], figsize=(6, 3))

svg

def k_step_pred(k):
features = []
for i in range(data.tau):
features.append(data.x[i : i+data.T-data.tau-k+1])
# The (i+tau)-th element stores the (i+1)-step-ahead predictions
for i in range(k):
preds = model(torch.stack(features[i : i+data.tau], 1))
features.append(preds.reshape(-1))
return features[data.tau:]
steps = (1, 4, 16, 64)
preds = k_step_pred(steps[-1])
d2l.plot(data.time[data.tau+steps[-1]-1:],
[preds[k - 1].detach().numpy() for k in steps], 'time', 'x',
legend=[f'{k}-step preds' for k in steps], figsize=(6, 3))

svg

小结

  • 插值 vs 外推:两者难度差异巨大。处理序列数据时,必须尊重时间顺序,绝不能用未来的数据训练模型。
  • 序列模型需要专门的统计工具:两种常见选择是自回归模型和隐变量自回归模型。
  • 因果方向:对于因果模型(如时间向前),估计正向方向通常比反向容易得多。
  • kk-step-ahead 预测:随着 kk 增大,误差累积,预测质量急剧下降。
本文由 kaikaikk 原创,发布于 ; 转载请保留原文链接: https://kaikaikk.com/posts/d2l-9-1-sequence-models/