Encoder-decoder 模型在序列到序列的自然语言处理任务(如语言翻译等)中提供了最先进的结果。多步时间序列预测也可以被视为一个 seq2seq 任务,可以使用 encoder-decoder 模型来处理。本文提供了一个用于解决 Kaggle 时间序列预测任务的 encoder-decoder 模型,并介绍了获得前 10% 结果所涉及的步骤。
数据集
所使用的数据集来自过去的 Kaggle 竞赛 —— Store Item demand forecasting challenge,给定过去 5 年的销售数据(从 2013 年到 2017 年)的 50 个商品来自 10 家不同的商店,预测接下来 3 个月(2018 年 1 月 1 日至 2018 年 3 月 31 日)每个商品的销售情况。这是一个多步多元的时间序列预测问题。
特征也非常的少
有500个商店组合,这意味着要预测500个时间序列。
数据预处理
深度学习模型擅长自行发现特征,因此可以将特征工程简化到最少。
从图表中可以看出,我们的数据具有每周和每月的季节性以及每年的趋势,为了捕捉这些特性,可以向模型提供DateTime 特征。为了更好地捕捉每个商品销售的年度趋势,还提供了年度自相关性。
时间的特征是有周期性的,为了将这些信息提供给模型,对 DateTime 特征应用了正弦和余弦变换。
最终的特征如下所示。
神经网络期望所有特征的值都在相同的尺度上,因此数据缩放变得必不可少。每个时间序列的值都是独立归一化的。年度自相关和年份也进行了归一化。
Encoder-decoder 模型接受一个序列作为输入并返回一个序列作为输出,所以需要将数据转为序列
输出序列的长度固定为 90 天,而输入序列的长度必须根据问题的复杂性和可用的计算资源来选择。对于这个问题,可以选择 180 天(6 个月)的输入序列长度。通过在数据集中的每个时间序列上应用滑动窗口来构建序列数据。
数据集和数据加载器
Pytorch 提供了方便的抽象 —— Dataset 和 Dataloader —— 用于将数据输入模型。Dataset 接受序列数据作为输入,并负责构建每个数据点以输入到模型中。Dataloader 则可以读取Dataset 生成批量的数据
class StoreItemDataset(Dataset):
def __init__(self, cat_columns=[], num_columns=[], embed_vector_size=None, decoder_input=True, ohe_cat_columns=False):
super().__init__()
self.sequence_data = None
self.cat_columns = cat_columns
self.num_columns = num_columns
self.cat_classes = {}
self.cat_embed_shape = []
self.cat_embed_vector_size = embed_vector_size if embed_vector_size is not None else {}
self.pass_decoder_input=decoder_input
self.ohe_cat_columns = ohe_cat_columns
self.cat_columns_to_decoder = False
def get_embedding_shape(self):
return self.cat_embed_shape
def load_sequence_data(self, processed_data):
self.sequence_data = processed_data
def process_cat_columns(self, column_map=None):
column_map = column_map if column_map is not None else {}
for col in self.cat_columns:
self.sequence_data[col] = self.sequence_data[col].astype('category')
if col in column_map:
self.sequence_data[col] = self.sequence_data[col].cat.set_categories(column_map[col]).fillna('#NA#')
else:
self.sequence_data[col].cat.add_categories('#NA#', inplace=True)
self.cat_embed_shape.append((len(self.sequence_data[col].cat.categories), self.cat_embed_vector_size.get(col, 50)))
def __len__(self):
return len(self.sequence_data)
def __getitem__(self, idx):
row = self.sequence_data.iloc[[idx]]
x_inputs = [torch.tensor(row['x_sequence'].values[0], dtype=torch.float32)]
y = torch.tensor(row['y_sequence'].values[0], dtype=torch.float32)
if self.pass_decoder_input:
decoder_input = torch.tensor(row['y_sequence'].values[0][:, 1:], dtype=torch.float32)
if len(self.num_columns) > 0:
for col in self.num_columns:
num_tensor = torch.tensor([row[col].values[0]], dtype=torch.float32)
x_inputs[0] = torch.cat((x_inputs[0], num_tensor.repeat(x_inputs[0].size(0)).unsqueeze(1)), axis=1)
decoder_input = torch.cat((decoder_input, num_tensor.repeat(decoder_input.size(0)).unsqueeze(1)), axis=1)
if len(self.cat_columns) > 0:
if self.ohe_cat_columns:
for ci, (num_classes, _) in enumerate(self.cat_embed_shape):
col_tensor = torch.zeros(num_classes, dtype=torch.float32)
col_tensor[row[self.cat_columns[ci]].cat.codes.values[0]] = 1.0
col_tensor_x = col_tensor.repeat(x_inputs[0].size(0), 1)
x_inputs[0] = torch.cat((x_inputs[0], col_tensor_x), axis=1)
if self.pass_decoder_input and self.cat_columns_to_decoder:
col_tensor_y = col_tensor.repeat(decoder_input.size(0), 1)
decoder_input = torch.cat((decoder_input, col_tensor_y), axis=1)
else:
cat_tensor = torch.tensor(
[row[col].cat.codes.values[0] for col in self.cat_columns],
dtype=torch.long
)
x_inputs.append(cat_tensor)
if self.pass_decoder_input:
x_inputs.append(decoder_input)
y = torch.tensor(row['y_sequence'].values[0][:, 0], dtype=torch.float32)
if len(x_inputs) > 1:
return tuple(x_inputs), y
return x_inputs[0], y
模型架构
Encoder-decoder 模型是一种用于解决序列到序列问题的循环神经网络(RNN)。
Encoder-decoder 模型由两个网络组成——编码器(Encoder)和解码器(Decoder)。编码器网络学习(编码)输入序列的表示,捕捉其特征或上下文,并输出一个向量。这个向量被称为上下文向量。解码器网络接收上下文向量,并学习读取并提取(解码)输出序列。
在编码器和解码器中,编码和解码序列的任务由一系列循环单元处理。
编码器
编码器网络的输入形状为(序列长度,特征维度),因此序列中的每个项目由 n 个值组成。在构建这些值时,不同类型的特征被不同对待。
时间依赖特征 — 这些是随时间变化的特征,如销售和 DateTime 特征。在编码器中,每个连续的时间依赖值被输入到一个 RNN 单元中。
数值特征 — 不随时间变化的静态特征,如序列的年度自相关。这些特征在序列的长度中重复,并被输入到 RNN 中。重复和合并值的过程在 Dataset 中处理。
分类特征 — 如商店 ID 和商品 ID 等特征,可以通过多种方式处理,每种方法的实现可以在 encoders.py 中找到。对于最终模型,分类变量进行了独热编码,跨序列重复,并被输入到 RNN 中,这也在 Dataset 中处理。
带有这些特征的输入序列被输入到循环网络 — GRU 中。下面给出了使用的编码器网络的代码。
class RNNEncoder(nn.Module):
def __init__(self, rnn_num_layers=1, input_feature_len=1, sequence_len=168, hidden_size=100, bidirectional=False, device='cpu', rnn_dropout=0.2):
super().__init__()
self.sequence_len = sequence_len
self.hidden_size = hidden_size
self.input_feature_len = input_feature_len
self.num_layers = rnn_num_layers
self.rnn_directions = 2 if bidirectional else 1
self.gru = nn.GRU(
num_layers=rnn_num_layers,
input_size=input_feature_len,
hidden_size=hidden_size,
batch_first=True,
bidirectional=bidirectional,
dropout=rnn_dropout
)
self.device = device
def forward(self, input_seq):
ht = torch.zeros(self.num_layers * self.rnn_directions, input_seq.size(0), self.hidden_size, device=self.device)
if input_seq.ndim < 3:
input_seq.unsqueeze_(2)
gru_out, hidden = self.gru(input_seq, ht)
print(gru_out.shape)
print(hidden.shape)
if self.rnn_directions * self.num_layers > 1:
num_layers = self.rnn_directions * self.num_layers
if self.rnn_directions > 1:
gru_out = gru_out.view(input_seq.size(0), self.sequence_len, self.rnn_directions, self.hidden_size)
gru_out = torch.sum(gru_out, axis=2)
hidden = hidden.view(self.num_layers, self.rnn_directions, input_seq.size(0), self.hidden_size)
if self.num_layers > 0:
hidden = hidden[-1]
else:
hidden = hidden.squeeze(0)
hidden = hidden.sum(axis=0)
else:
hidden.squeeze_(0)
return gru_out, hidden
解码器
解码器接收来自编码器的上下文向量,解码器的输入还包括未来的 DateTime 特征和滞后特征。模型中使用的滞后特征是前一年的值。使用滞后特征的原因是,鉴于输入序列仅限于 180 天,提供超出此时间的重要数据点将有助于模型。
不同于直接使用循环网络(GRU)的编码器,解码器是通过循环一个解码器单元来构建的。这是因为从每个解码器单元获得的预测作为输入传递给下一个解码器单元。每个解码器单元由一个 GRUCell 组成,其输出被输入到一个全连接层,该层提供预测。每个解码器单元的预测被组合形成输出序列。
class DecoderCell(nn.Module):
def __init__(self, input_feature_len, hidden_size, dropout=0.2):
super().__init__()
self.decoder_rnn_cell = nn.GRUCell(
input_size=input_feature_len,
hidden_size=hidden_size,
)
self.out = nn.Linear(hidden_size, 1)
self.attention = False
self.dropout = nn.Dropout(dropout)
def forward(self, prev_hidden, y):
rnn_hidden = self.decoder_rnn_cell(y, prev_hidden)
output = self.out(rnn_hidden)
return output, self.dropout(rnn_hidden)
Encoder-Decoder模型
下面代码将上面2个模型整合完成完整的seq2seq模型
class EncoderDecoderWrapper(nn.Module):
def __init__(self, encoder, decoder_cell, output_size=3, teacher_forcing=0.3, sequence_len=336, decoder_input=True, device='cpu'):
super().__init__()
self.encoder = encoder
self.decoder_cell = decoder_cell
self.output_size = output_size
self.teacher_forcing = teacher_forcing
self.sequence_length = sequence_len
self.decoder_input = decoder_input
self.device = device
def forward(self, xb, yb=None):
if self.decoder_input:
decoder_input = xb[-1]
input_seq = xb[0]
if len(xb) > 2:
encoder_output, encoder_hidden = self.encoder(input_seq, *xb[1:-1])
else:
encoder_output, encoder_hidden = self.encoder(input_seq)
else:
if type(xb) is list and len(xb) > 1:
input_seq = xb[0]
encoder_output, encoder_hidden = self.encoder(*xb)
else:
input_seq = xb
encoder_output, encoder_hidden = self.encoder(input_seq)
prev_hidden = encoder_hidden
outputs = torch.zeros(input_seq.size(0), self.output_size, device=self.device)
y_prev = input_seq[:, -1, 0].unsqueeze(1)
for i in range(self.output_size):
step_decoder_input = torch.cat((y_prev, decoder_input[:, i]), axis=1)
if (yb is not None) and (i > 0) and (torch.rand(1) < self.teacher_forcing):
step_decoder_input = torch.cat((yb[:, i].unsqueeze(1), decoder_input[:, i]), axis=1)
rnn_output, prev_hidden = self.decoder_cell(prev_hidden, step_decoder_input)
y_prev = rnn_output
outputs[:, i] = rnn_output.squeeze(1)
return outputs
训练
模型的性能高度依赖于优化、学习率等超参数和策略
- 验证策略 —— 由于我们的数据是时间依赖的,交叉的训练-验证-测试分割不适用。时间依赖的训练-验证-测试分割存在一个问题,即模型没有在最近的验证数据上进行训练,这影响了模型在测试数据上的表现。为了解决这个问题,模型在过去 3 年的数据(2014 到 2016 年)上进行训练,并预测 2017 年的前 3 个月,这用于验证和实验。最终模型在 2014 到 2017 年的数据上进行训练,并预测 2018 年的前 3 个月。最终模型基于验证模型训练的学习成果,以盲模式(无验证)进行训练。
- 优化器 —— 使用的优化器是 AdamW,它在许多学习任务中提供了最佳结果。另一个探索的优化器是 COCOBOptimizer,它不显式设置学习率。在使用 COCOBOptimizer 训练时,我观察到它比 AdamW 尤其是在初始迭代时收敛更快。但使用 AdamW 和单周期学习得到了最佳结果。
- 学习率调度 —— 使用了 1cycle 学习率调度器。通过使用循环学习的学习率查找器确定了周期中的最大学习率。
- 损失函数 —— 使用的损失函数是均方误差损失(MSE),这与最终测试的损失 —— SMAPE 不同。MSE 损失提供了更稳定的收敛性,优于使用 SMAPE。
- 为编码器和解码器网络使用了不同的优化器和调度器,这带来了结果的改进。
- 除了权重衰减外,还在编码器和解码器中使用了 dropout 来对抗过拟合。
结果
下图显示了该模型对2018年前3个月某家商店单品的预测。
通过绘制所有商品的平均销售额,以及均值预测来去除噪声,可以更好地评估模型。下图来自验证模型对特定日期的预测,可以与实际销售数据进行比较。
这个结果在竞赛排行榜中提供前10%的排名。
总结
本文演示了使用Encoder-Decoder 模型创建多步时间序列预测的完整步骤,但是为了达到这个结果(10%),作者还做了超参数调优。并且这个模型还没有增加注意力机制,所以还可以通过探索注意机制来进一步改进模型,进一步提高模型的记忆能力,应该能获得更好的分数。
本文代码:
https://avoid.overfit.cn/post/242a897692244172ae44adc15a569647
作者:Gautham Kumaran