来自IJCAI 2018的一篇论文:《Spatio-Temporal Graph Convolutional Networks: A Deep Learning Framework for Traffic Forecasting 》
使用Kipf & Welling 2017的近似谱图卷积得到的图卷积作为空间上的卷积操作,时间上使用一维卷积TCN对所有顶点进行卷积,两者交替进行,组成了时空卷积块,在加州PeMS和北京市的两个数据集上做了验证。论文中图的构建方法并不是基于实际路网,而是通过数学方法构建了一个基于距离关系的网络。
在交通研究中,交通流的基本变量,也就是速度、流量和密度(实际中,还有排队长度,时间占有率,空间占有率,车头时距等多个变量),这些变量通常作为监控当前交通状态以及未来预测的指标。根据预测的长度,主要是指预测时间窗口的大小,交通预测大体分为两个尺度:短期(5~30min),中和长期预测(超过30min)。大多数流行的统计方法(比如,线性回归)可以在短期预测上表现的很好。然而,由于交通流的不确定性和复杂性,这些方法在相对长期的预测上不是很有效。
中长期交通预测上的研究可以分为两类:动态建模和数据驱动的方法。
深度学习方法:深度学习已经广泛且成功地应用于各式各样的交通任务中,并取得了很显著的成果,比如层叠自编码器(SAE)。然而,这些全连接神经网络很难从输入中提取空间和时间特征。而且,空间属性的严格限制甚至完全缺失,这些网络的表示能力被限制的很严重。为了充分利用空间特征,一些研究者使用了卷积神经网络来捕获交通网络中的临近信息,同时也在时间轴上部署了循环神经网络。通过组合LSTM和1维卷积,比如特征层面融合的架构CLTFP来预测短期交通状况。CLTFP是第一个尝试对时间和空间规律性对齐的方法。后来,有学者提出带有嵌入卷积层的全连接网络:ConvLSTM。但是常规卷积操作只适合处理规则化的网络结构,比如图像或者视频,而大多数领域是非结构化的,比如社交网络,交通路网等。此外,RNN对于序列的学习需要迭代训练,这会导致误差的积累。并且还有难以训练和耗时的缺点。
针对以上问题和缺陷:该文引入了一些策略来有效的对交通流的时间动态和空间依赖进行建模。为了完全利用空间信息,利用广义图对交通网络建模,而不是将交通流看成各个离散的部分(比如网格或碎块)。为了处理循环神经网络的缺陷,我们在时间轴上部署了一个全卷积结构来加速模型的训练过程。综上所述,该文提出了一个新的神经网络架构-时空图卷积网络,来预测交通状况。这个架构由多个时空图卷积块组成。
主要贡献:
文章首先将网格数据改为图数据作为输入,图可以用邻接矩阵来表示,图中的W就是图的邻接矩阵,实验中使用的数据集PeMSD7(M)共有228个数据点,相当于一个具有228个顶点的图,因为这个模型主要是对速度进行预测,所以每个顶点只有一个特征就是:速度。
网络架构是本文的重点部分。如下图所示,STGCN有多个时空卷积块组成,每一个都是像一个“三明治”结构的组成,有两个门序列卷积层和一个空间图卷积层在中间。
组成结构:STGCN 有两个ST-Conv Block(淡蓝色部分)快和一个全连接输出layer(绿色部分),其中每个ST-Conv Block块有包括两个时间卷积块(橙色部分)和一个空间卷积块(浅黄色部分)
提取时间特征的图卷积神经网络,即对应网络结构中的Spatial Graph-Conv 模块。
交通路网是非结构化的图像,为了捕获空间上的相关性,本篇论文采用的是切比雪夫近似与一阶近似后的图卷积公式。只需要看最终的那个卷积公式,其中D为图的度矩阵,A_hat为图的邻接矩阵+单位矩阵,为的是在卷积过程中不仅考虑邻居节点的状态,也考虑自身的状态。
提取空间特征的图卷积神经网络,即对应网络结构中的Temporal Gated-Conv 模块。
论文提到,每个ST—Conv模块包含两个时间门控卷积层和一个空间图卷积层。
在时间维度上,论文提到采用使用GLU实现的1维因果卷积,就不再像采用RNN的方法依赖于之前的输出,并且还可以对数据进行并行处理,这样使得模型训练速度更快。GLU是在这篇论文中提出的:Language Modeling with Gated Convolutional Networks。在STGCN这篇论文,文章只是简单提到采用这种操作可以缓解梯度消失等现象还可以保留模型的非线性能力。
在阅读论文源码后,我的理解是:采用GLU实现了在时间维度上的卷积操作,卷积核大小是13,即HW,H表示节点数量,W表示时间长度,因此在每个节点上使用GLU来捕获时间上的依赖关系,也就是利用GLU来实现TCN能够达到的效果——1D卷积和并行计算的功能
将以上的图卷积和门控CNN组合成如图所示的结构,其中使用了瓶颈策略来实现尺度压缩和特征压缩。并在每层之后接归一化层来防止过拟合。
ST-Conv Block的公式就是图的另一个解释,输入数据先做时间维度卷积,输出结果再做图卷积,图卷积的输出结果经过一个RELU,在进行一个时间维度卷积,就是整个ST-Conv Block的输出。
最后的模型是堆叠两个St-Conv Block之后接一个输出层,其中输出层首先用时间维度的卷积将之前的输出数据的时间维度进行合并,合并之后在经过一个卷积输出最终的预测数据,预测数据就是下一个时间维度的一张图[1,228,1]。模型采用的是L2损失。
源码以Pytorch为例
原始数据时间窗口长度是5min一个数据片,即:每5分钟记录该时间窗口内,各个路网节点的数据信息,每条数据信息包含2个维度信息。
读取数据,并且利用 Z-score method进行归一化:
X = X - means.reshape(1, -1, 1)和 X = X / stds.reshape(1, -1, 1),最终返回数据格式为:X:[207, 2, 34272],表示有图中有207个节点,34272个时间片,每个时间片对应的节点数据维度是2
def load_metr_la_data():
if (not os.path.isfile("data/adj_mat.npy")
or not os.path.isfile("data/node_values.npy")):
with zipfile.ZipFile("data/METR-LA.zip", 'r') as zip_ref:
zip_ref.extractall("data/")
A = np.load("data/adj_mat.npy")
X = np.load("data/node_values.npy").transpose((1, 2, 0))
X = X.astype(np.float32)
# Normalization using Z-score method
means = np.mean(X, axis=(0, 2))
X = X - means.reshape(1, -1, 1)
stds = np.std(X, axis=(0, 2))
X = X / stds.reshape(1, -1, 1)
return A, X, means, stds
读取邻接矩阵,并返回度信息
def get_normalized_adj(A):
"""
Returns the degree normalized adjacency matrix.
"""
A = A + np.diag(np.ones(A.shape[0], dtype=np.float32))
D = np.array(np.sum(A, axis=1)).reshape((-1,))
D[D <= 10e-5] = 10e-5 # Prevent infs
diag = np.reciprocal(np.sqrt(D))
A_wave = np.multiply(np.multiply(diag.reshape((-1, 1)), A),
diag.reshape((1, -1)))
return A_wave
def generate_dataset(X, num_timesteps_input, num_timesteps_output):
"""
Takes node features for the graph and divides them into multiple samples
along the time-axis by sliding a window of size (num_timesteps_input+
num_timesteps_output) across it in steps of 1.
:param X: Node features of shape (num_vertices, num_features,
num_timesteps)
:return:
- Node features divided into multiple samples. Shape is
(num_samples, num_vertices, num_features, num_timesteps_input).
- Node targets for the samples. Shape is
(num_samples, num_vertices, num_features, num_timesteps_output).
"""
# Generate the beginning index and the ending index of a sample, which
# contains (num_points_for_training + num_points_for_predicting) points
indices = [(i, i + (num_timesteps_input + num_timesteps_output)) for i
in range(X.shape[2] - (
num_timesteps_input + num_timesteps_output) + 1)]
# Save samples
features, target = [], []
for i, j in indices:
features.append(
X[:, :, i: i + num_timesteps_input].transpose(
(0, 2, 1)))
target.append(X[:, 0, i + num_timesteps_input: j])
return torch.from_numpy(np.array(features)), \
torch.from_numpy(np.array(target))
这里batch_size设置为50,同时为了比较快速清晰的查看各个模块的输出,我们就以一个batch为例,即:输入的总时间片长度为50
STGCN包含三个模型,其中两个时空STGCNBlock模块和一个TimeBlock模块和最后一个全连接层,而一个STGCNBlock又包含两个temporal(temporal)模块,(也就是TimeBlock模块模块)和 一个Theta(spatial)模块,一个TimeBlock模块包含因果卷积操作目的就是为了提取时间信息(主要是W维度上的)TimeBlock模块主要是为了提取时间信息,利用的是一维卷积,kernel_size=3,卷积核大小为(1,kernel_size=3,分别对应H,W)。
TimeBlock模块:
class TimeBlock(nn.Module):
"""
Neural network block that applies a temporal convolution to each node of
a graph in isolation.
"""
def __init__(self, in_channels, out_channels, kernel_size=3):
"""
:param in_channels: Number of input features at each node in each time
step.
:param out_channels: Desired number of output channels at each node in
each time step.
:param kernel_size: Size of the 1D temporal kernel.
"""
super(TimeBlock, self).__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, (1, kernel_size))
self.conv2 = nn.Conv2d(in_channels, out_channels, (1, kernel_size))
self.conv3 = nn.Conv2d(in_channels, out_channels, (1, kernel_size))
def forward(self, X):
"""
:param X: Input data of shape (batch_size, num_nodes, num_timesteps,
num_features=in_channels)
:return: Output data of shape (batch_size, num_nodes,
num_timesteps_out, num_features_out=out_channels)
"""
# Convert into NCHW format for pytorch to perform convolutions.
X = X.permute(0, 3, 1, 2)
temp = self.conv1(X) + torch.sigmoid(self.conv2(X))
out = F.relu(temp + self.conv3(X))
# Convert back from NCHW to NHWC
out = out.permute(0, 2, 3, 1)
return out
STGCNBlock模块
class STGCNBlock(nn.Module):
"""
Neural network block that applies a temporal convolution on each node in
isolation, followed by a graph convolution, followed by another temporal
convolution on each node.
"""
def __init__(self, in_channels, spatial_channels, out_channels,
num_nodes):
"""
:param in_channels: Number of input features at each node in each time
step.
:param spatial_channels: Number of output channels of the graph
convolutional, spatial sub-block.
:param out_channels: Desired number of output features at each node in
each time step.
:param num_nodes: Number of nodes in the graph.
"""
super(STGCNBlock, self).__init__()
self.temporal1 = TimeBlock(in_channels=in_channels,
out_channels=out_channels)
self.Theta1 = nn.Parameter(torch.FloatTensor(out_channels,
spatial_channels))
self.temporal2 = TimeBlock(in_channels=spatial_channels,
out_channels=out_channels)
self.batch_norm = nn.BatchNorm2d(num_nodes)
self.reset_parameters()
def reset_parameters(self):
stdv = 1. / math.sqrt(self.Theta1.shape[1])
self.Theta1.data.uniform_(-stdv, stdv)
def forward(self, X, A_hat):
"""
:param X: Input data of shape (batch_size, num_nodes, num_timesteps,
num_features=in_channels).
:param A_hat: Normalized adjacency matrix.
:return: Output data of shape (batch_size, num_nodes,
num_timesteps_out, num_features=out_channels).
"""
t = self.temporal1(X)
lfs = torch.einsum("ij,jklm->kilm", [A_hat, t.permute(1, 0, 2, 3)])
# t2 = F.relu(torch.einsum("ijkl,lp->ijkp", [lfs, self.Theta1]))
t2 = F.relu(torch.matmul(lfs, self.Theta1))
t3 = self.temporal2(t2)
return self.batch_norm(t3)
# return t3
STGCN模块:
class STGCN(nn.Module):
"""
Spatio-temporal graph convolutional network as described in
https://arxiv.org/abs/1709.04875v3 by Yu et al.
Input should have shape (batch_size, num_nodes, num_input_time_steps,
num_features).
"""
def __init__(self, num_nodes, num_features, num_timesteps_input,
num_timesteps_output):
"""
:param num_nodes: Number of nodes in the graph.
:param num_features: Number of features at each node in each time step.
:param num_timesteps_input: Number of past time steps fed into the
network.
:param num_timesteps_output: Desired number of future time steps
output by the network.
"""
super(STGCN, self).__init__()
self.block1 = STGCNBlock(in_channels=num_features, out_channels=64,
spatial_channels=16, num_nodes=num_nodes)
self.block2 = STGCNBlock(in_channels=64, out_channels=64,
spatial_channels=16, num_nodes=num_nodes)
self.last_temporal = TimeBlock(in_channels=64, out_channels=64)
self.fully = nn.Linear((num_timesteps_input - 2 * 5) * 64,
num_timesteps_output)
def forward(self, A_hat, X):
"""
:param X: Input data of shape (batch_size, num_nodes, num_timesteps,
num_features=in_channels).
:param A_hat: Normalized adjacency matrix.
"""
out1 = self.block1(X, A_hat)
out2 = self.block2(out1, A_hat)
out3 = self.last_temporal(out2)
out4 = self.fully(out3.reshape((out3.shape[0], out3.shape[1], -1)))
return out4