前面已经写过不少时间序列预测的文章:
由于博主本人读研期间研究的是图神经网络及其在社交网络上的应用,而近几年使用GNN进行时间序列预测的文章也不在少数,为此这篇文章主要讲解一下如何使用GNN进行多变量时间序列预测。
假设我们利用前24小时的13个变量预测后1个小时的13变量,传统的时间序列模型只能从时间这个维度进行卷积,多个变量序列间的关系没有被考虑。因此,一个很自然的想法就是通过GNN挖掘出不同变量(空间)之间的关系,然后再使用其他RNN类模型挖掘出序列维度间的关系,实现时间+空间两个维度上的卷积。
这篇文章没有考虑时间上的卷积,使用GNN挖掘出变量间的关系后直接通过一个MLP就得到输出。GNN-LSTM组合模型后面的文章会更新。
在下面的讨论中,我们假设变量总数为input_size=13
,我们使用前24个小时的13个变量预测接下来1个小时的13个变量,即seq_len=24, output_size=1
。当然,这些参数都是可调节的。
使用GNN的一个重要前提是建立图。由于我们已经确定使用变量作为节点,因此我们现在只需要确定各个变量间如何进行连接以构成边。
我们知道,社交网络中两个相连的用户节点通常具有相似的性质,很多图嵌入模型也是以一阶或者二阶邻近度作为优化标准。因此,一个很自然的想法就是计算不同的变量序列间的相关系数,然后使用一个阈值进行判断,如果两个节点(变量)它们的序列间的相关系数大于这个阈值,那么两个变量节点间就存在边。
为此,我们先使用训练集上13个变量的序列来计算两两之间的相关系数,进而构造图:
def create_graph(num_nodes, data):
features = torch.randn((num_nodes, 256))
edge_index = [[], []]
# data (x, num_nodes)
for i in range(num_nodes):
for j in range(i + 1, num_nodes):
x, y = data[:, i], data[:, j]
corr = calc_corr(x, y)
if corr >= 0.4:
edge_index[0].append(i)
edge_index[1].append(j)
edge_index = torch.LongTensor(edge_index)
graph = Data(x=features, edge_index=edge_index)
graph.edge_index = to_undirected(graph.edge_index, num_nodes=num_nodes)
return graph
可以发现我们设置阈值为0.4,也就是训练集中两个变量序列间的相关系数大于0.4时两个变量间就存在链接。
输出graph:
Data(x=[13, 256], edge_index=[2, 40])
可以发现构建的图中共13个节点,对应13个变量,每个变量有一个长度为256的初始随机化向量,图中13个节点间一共40条边。显然,降低阈值后边数会增加。
在利用训练集构建好图后,在接下来的训练、验证以及测试过程中我们保持图的整体结构不变。换句话说,我们使用了静态图,图中的关系是通过训练集中的数据集确定的。如果我们想要实现动态图,一个很自然的想法是在构造数据集时,每次都利用一个大小为(13, 24)
的矩阵计算出图中的各个参数。这样操作后每一个样本都对应一个图,图中的节点数为13,节点的初始特征都为长度为24的向量,图中的边通过13个长度为24的向量间的相关系数来确定。
使用前面提到的方法构建出数据集:
def nn_seq(num_nodes, seq_len, B, pred_step_size):
data = pd.read_csv('data/data.csv')
data.drop([data.columns[0]], axis=1, inplace=True)
# split
train = data[:int(len(data) * 0.6)]
val = data[int(len(data) * 0.6):int(len(data) * 0.8)]
test = data[int(len(data) * 0.8):len(data)]
# normalization
scaler = MinMaxScaler()
train = scaler.fit_transform(data[:int(len(data) * 0.8)].values)
val = scaler.transform(val.values)
test = scaler.transform(test.values)
graph = create_graph(num_nodes, data[:int(len(data) * 0.8)].values)
def process(dataset, batch_size, step_size, shuffle):
dataset = dataset.tolist()
seq = []
for i in tqdm(range(0, len(dataset) - seq_len - pred_step_size, step_size)):
train_seq = []
for j in range(i, i + seq_len):
x = []
for c in range(len(dataset[0])): # 前24个时刻的13个变量
x.append(dataset[j][c])
train_seq.append(x)
# 下一时刻的13个变量
train_labels = []
for j in range(len(dataset[0])):
train_label = []
for k in range(i + seq_len, i + seq_len + pred_step_size):
train_label.append(dataset[k][j])
train_labels.append(train_label)
# tensor
train_seq = torch.FloatTensor(train_seq)
train_labels = torch.FloatTensor(train_labels)
seq.append((train_seq, train_labels))
seq = MyDataset(seq)
seq = DataLoader(dataset=seq, batch_size=batch_size, shuffle=shuffle, num_workers=0, drop_last=False)
return seq
Dtr = process(train, B, step_size=1, shuffle=True)
Val = process(val, B, step_size=1, shuffle=True)
Dte = process(test, B, step_size=pred_step_size, shuffle=False)
return graph, Dtr, Val, Dte, scaler
可以发现,这里的数据处理与 PyTorch搭建LSTM实现多变量输入多变量输出时间序列预测(多任务学习)中一致。这是因为我们只需要利用图中的edge_index
信息,在训练过程中我们可以将当前batch的x
赋给graph
,然后进行卷积。
首先我们需要搭建一个GAT,当然这里的GAT可以换成GCN或GraphSAGE等其他模型:
class GAT(torch.nn.Module):
def __init__(self, in_feats, h_feats, out_feats):
super(GAT, self).__init__()
self.conv1 = GATConv(in_feats, h_feats, heads=4, concat=False)
self.conv2 = GATConv(h_feats, out_feats, heads=4, concat=False)
def forward(self, x, edge_index):
x = F.elu(self.conv1(x, edge_index))
x = self.conv2(x, edge_index)
return x
然后搭建时间序列预测模型:
class GAT_MLP(nn.Module):
def __init__(self, args, graph):
super(GAT_MLP, self).__init__()
self.args = args
self.out_feats = 128
self.edge_index = graph.edge_index
self.gat = GAT(in_feats=args.seq_len, h_feats=64, out_feats=self.out_feats)
self.fcs = nn.ModuleList()
self.graph = graph
for k in range(args.input_size):
self.fcs.append(nn.Sequential(
nn.Linear(128, 64),
nn.ReLU(),
nn.Linear(64, args.output_size)
))
def forward(self, x):
# x(batch_size, seq_len, input_size)
x = x.permute(0, 2, 1)
# 1.gat
# (batch_size, input_size, out_feats)
out = torch.zeros(x.shape[0], x.shape[1], self.out_feats).to(device)
for k in range(x.shape[0]):
self.graph.x = x[k, :, :]
out[k, :, :] = self.gat(x[k, :, :], self.edge_index)
preds = []
# print(out.shape) # 256 13 128
for k in range(out.shape[1]):
preds.append(self.fcs[k](out[:, k, :]))
pred = torch.stack(preds, dim=0)
# print(pred.shape)
return pred
我们输入的维度和一般时序预测一致,即:
x (batch_size, seq_len, input_size) = (256, 24, 13)
因为GAT的输入只能是二维的,也就是(num_nodes=input_size, seq_len)
,因此我们首先需要进行维度交换:
x = x.permute(0, 2, 1)
此时的x
维度变为:
x (batch_size, input_size, seq_len) = (256, 13, 24)
此时,为了将一个batch的数据经过GAT,我们只能从batch这个维度遍历x
:
for k in range(x.shape[0]):
self.graph.x = x[k, :, :]
out[k, :, :] = self.gat(x[k, :, :], self.edge_index)
此时的输出维度为:
out (batch_size, input_size, hidden_size) = (256, 13, 128)
经过GAT卷积后,此时的out中每个变量的输出都有其他变量的一部分。
最后,为了得到13个变量大小为(batch_size, output_size)
的输出,我们可以选择将out
经过一个MLP或13个MLP,这里选择使用13个MLP依次处理以便得到13个变量的输出:
for k in range(out.shape[1]):
preds.append(self.fcs[k](out[:, k, :]))
观察上述这个过程,我们发现这种方式存在一种很明显的缺点:无法并行处理一整个batch的数据,这让整个模型的训练过程变得非常缓慢。为此,我们可以考虑另一种思路。
上面提到,图神经网络模型只能接受二维的数据,也就是(num_nodes=input_size, seq_len)
。但是在利用GNN做各种图分类任务时,我们往往涉及到多张图,PyG中为了加快模型的训练过程,设计了一种并行策略:将多张图进行拼接以得到一张大图,然后再将一整个大图送入GAT中进行训练:
loader = torch_geometric.loader.DataLoader(graphs, batch_size=batch_size,
shuffle=shuffle, drop_last=False)
其中graphs
是图的列表。为了实现这种策略,我们可以选择在构建每一个样本时都构造一个graph,这个graph
的初始特征为(13, 24)
,edge_index
为利用训练集得到的边(静态)或利用x=(13, 24)
得到的边(动态),而图对应的y
就为下一个时刻的13个变量值:
def nn_seq_gat(num_nodes, seq_len, B, pred_step_size):
data = pd.read_csv('data/data.csv')
data.drop([data.columns[0]], axis=1, inplace=True)
# split
train = data[:int(len(data) * 0.6)]
val = data[int(len(data) * 0.6):int(len(data) * 0.8)]
test = data[int(len(data) * 0.8):len(data)]
# normalization
scaler = MinMaxScaler()
train = scaler.fit_transform(data[:int(len(data) * 0.8)].values)
val = scaler.transform(val.values)
test = scaler.transform(test.values)
graph = create_graph(num_nodes, data[:int(len(data) * 0.8)].values)
def process(dataset, batch_size, step_size, shuffle):
dataset = dataset.tolist()
graphs = []
for i in tqdm(range(0, len(dataset) - seq_len - pred_step_size, step_size)):
train_seq = []
for j in range(i, i + seq_len):
x = []
for c in range(len(dataset[0])):
x.append(dataset[j][c])
train_seq.append(x)
# 下几个时刻的所有变量
train_labels = []
for j in range(len(dataset[0])):
train_label = []
for k in range(i + seq_len, i + seq_len + pred_step_size):
train_label.append(dataset[k][j])
train_labels.append(train_label)
# tensor
train_seq = torch.FloatTensor(train_seq)
train_labels = torch.FloatTensor(train_labels)
# print(train_seq.shape, train_labels.shape) # 24 13, 13 1
temp = Data(x=train_seq.T, edge_index=graph.edge_index, y=train_labels)
# print(temp)
graphs.append(temp)
loader = torch_geometric.loader.DataLoader(graphs, batch_size=batch_size,
shuffle=shuffle, drop_last=False)
return loader
Dtr = process(train, B, step_size=1, shuffle=True)
Val = process(val, B, step_size=1, shuffle=True)
Dte = process(test, B, step_size=pred_step_size, shuffle=False)
return graph, Dtr, Val, Dte, scaler
任意输出一个样本对应的图:
Data(x=[13, 24], edge_index=[2, 40], y=[13, 1])
输出loader中任意一个batch的图数据:
DataBatch(x=[3328, 24], edge_index=[2, 10240], y=[3328, 1], batch=[3328], ptr=[257])
可以看到此时图的特征依然是二维的,但这个图是256个样本对应的小图拼接得到的大图,3328=256*13
,10240=256*40
。在上一种方式中,我们是将256个
Data(x=[13, 24], edge_index=[2, 40], y=[13, 1])
依次送入GAT,第二种方式则是将256个图拼接到一起送入GAT,大大提高了计算效率。
第二种方式的模型搭建如下:
class GAT_MLP(nn.Module):
def __init__(self, args, graph):
super(GAT_MLP, self).__init__()
self.args = args
self.out_feats = 128
self.edge_index = graph.edge_index
self.gat = GAT(in_feats=args.seq_len, h_feats=64, out_feats=self.out_feats)
self.fc = nn.Sequential(
nn.Linear(128, 64),
nn.ReLU(),
nn.Linear(64, args.output_size)
)
self.fcs = nn.ModuleList()
self.graph = graph
for k in range(args.input_size):
self.fcs.append(nn.Sequential(
nn.Linear(128, 64),
nn.ReLU(),
nn.Linear(64, args.output_size)
))
def forward(self, data):
x, edge_index, batch = data.x, data.edge_index, data.batch
batch_size = torch.max(batch).item() + 1
x = self.gat(x, edge_index) # 3328 128 = 256 * (13, 128) # y = 3328 1 = 256 * (13 1)
batch_list = batch.cpu().numpy()
# split
xs = [[] for k in range(batch_size)]
ys = [[] for k in range(batch_size)]
for k in range(x.shape[0]):
xs[batch_list[k]].append(x[k, :])
ys[batch_list[k]].append(data.y[k, :])
xs = [torch.stack(x, dim=0) for x in xs]
ys = [torch.stack(x, dim=0) for x in ys]
x = torch.stack(xs, dim=0)
y = torch.stack(ys, dim=0)
pred = x.permute(1, 0, 2) # 13 256 128
pred = self.fc(pred)
return pred, y
值得注意的是,我们在利用GAT得到一整个大图的输出后,需要重新将大图分割为256个小图,然后进行拼接以得到大小为(256, 13 128)
的输出,然后再经过1个或13个MLP得到最终的输出。
这一部分与前面文章一致,不再细述。