8.4 ConvLSTM网络#
在8.3节内容中,我们介绍了几种将CNN和RNN进行结合的时序模型,包括串行的方式将CNN和RNN进行结合、以并行的方式将CNN和RNN进行结合。同时,在这些任务场景中序列样本所拥有的一个共同特点便是对于每个序列中的每个时刻来说,其特征表示均为一个向量。但是在现实情况中,还有一类时序数据是以数据帧的形式而存在,即每一时刻均为一个三维(或二维)矩阵。这样的数据也被称为时空(Spatiotemporal)数据,例如最常见的视频数据。因此,在本节内容中,我们将会介绍另外一种结合CNN和RNN的深度学习模型ConvLSTM来解决这一问题[1]。
8.4.1 ConvLSTM动机#
在气象学领域中,对于如何能够准确地预测未来短时间(如0~6小时)内的降雨情况一直以来就是一个热门的研究方向。通常,研究者会根据实时拍摄得到的雷达回波数据(Radar Echo Data)作为输入序列来预测接下来一段时间内的降雨情况。得益于深度学习的发展,有研究者提出了基于循环神经网络和卷积神经网络的预测模型。尽管通过这样的结合方式也能够建模完成这一预测任务,但是模型并没有充分考虑到时空数据中的空间依赖关系(Spatial Correlation)。
基于这样的动机,施行健等人[1]在2015年提出了一种融合CNN和LSTM的时序预测模型ConvLSTM。ConvLSTM模型的动机是通过将CNN和LSTM结合起来克服传统RNN和CNN各自的局限性。ConvLSTM引入了空间上的卷积操作和时间上的循环操作,同时保留了LSTM中的记忆单元和门控机制,能够捕捉到时间和空间上的特征,考虑到时序数据的长期依赖性和空间结构的局部相关性。因此,ConvLSTM可以有效地用于处理时空数据,例如视频数据、雷达数据等。
8.4.2 ConvLSTM结构#
从整体上看,ConvLSTM的模型结构主要分为两部分:在时序结构上遵循典型的RNN网络结构;在空间结构上遵循CNN的特征提取方式。简单来说,ConvLSTM模型就等价于将LSTM中的所有全连接结构替换为卷积结构,同时采用了基于窥视连接的结构(详见7.4.5节内容)。如图8-5所示便是ConvLSTM的循环记忆单元。

如图8-5所示便是ConvLSTM的记忆单元结构图,总体上同LSTM类似包含有4个门结构,因此这部分内容不在赘述参考7.3节内容即可。对于ConvLSTM来说,其唯一变化的地方在于各个门控单元的计算方式,具体计算过程如式(8-1)所示。
$$ \begin{aligned} f_t&=\sigma([h_{t-1},x_t,C_{t-1}]\ast W_f+b_f)\\[2ex] i_t&=\sigma([h_{t-1},x_t,C_{t-1}]\ast W_i+b_i)\\[2ex] \tilde{C_t}&=\tanh([h_{t-1},x_t]\ast W_c+b_c)\\[2ex] C_t&=f_t\odot C_{t-1}\oplus i_t\odot\tilde{C_t}\\[2ex] o_t&=\sigma([h_{t-1},x_t,C_{t}]\ast W_o+b_o)\\[2ex] h_t&=o_t\odot\tanh(C_t) \end{aligned}\tag{8-1} $$在式(8-1)中,$\ast$ 表示卷积操作,$W_f$、$W_i$、$W_c$和$W_o$均为卷积核,因此ConvLSTM模型的输入将是一个5维张量,即[batch_size,time_step,in_channels,height,width]。由此可知,$x_t$的形状为[batch_size,in_channels,height,width];$h_t$和$C_t$的形状均为[batch_size,out_channels,height,width];$[h_{t-1},x_t]$的形状为[batch_size,in_channels+out_channels,height,width];$[h_{t-1},x_t,C_{t-1}]$的形状为[batch_size,in_channels+out_channels*2,height,width]。
同时,由于循环神经网络可以在时间维度和网络层数两个方向展开,因此在ConvLSTM记忆单元中每次卷积之前都会进行填充,以保证每次卷积后特征图的长和宽不发生改变,所以$f_t$、$i_t$和$o_t$的形状均为[batch_size, out_channels, height, width]。对于ConvLSTM来说,其同样类似于RNN模型,因此也可以根据7.1.4节中的结构来构造网络模型并完成相关下游任务。
8.4.3 ConvLSTM实现#
在清楚ConvLSTM模型的相关原理之后,我们再来看如何借助PyTorch快速实现ConvLSTM模型。由于PyTorch框架中的nn模块并没有实现ConvLSTM模型,因此需要我们自己动手进行实现。以下完整示例代码可以参见Code/Chapter08/C05_ConvLSTM/ConvLSTM.py文件。
1. ConvLSTMCell实现
为了便于实现这里以不带窥视连接的结构进行介绍。首先,需要实现一个单独的ConvLSTM记忆单元的前向传播过程,示例代码如下所示:
1 class ConvLSTMCell(nn.Module):
2 def __init__(self, in_channels, out_channels, kernel_size, bias):
3 super(ConvLSTMCell, self).__init__()
4 self.in_channels = in_channels
5 self.out_channels = out_channels
6 self.kernel_size = kernel_size
7 self.padding = kernel_size[0] // 2, kernel_size[1] // 2
8 self.bias = bias
9 self.conv = nn.Conv2d(in_channels=self.in_channels + self.out_channels,
10 out_channels=4 * self.out_channels,kernel_size=self.kernel_size,
11 padding=self.padding,bias=self.bias)在上述代码中,第1行中in_channels表示输入特征图的通道数,out_channels表示输出特征图的通道数,kernel_size表示卷积核的窗口大小为一个元组。第7行用于计算填充的数量,以保证每次卷积后特征图的大小不发生变化,其计算规则可见4.3.2节内容;同时,为了提高计算效率,对于ConvLSTM中所有卷积操作可以在一个Conv2d实例中完成,第9~10行中in_channels和out_channels两个参数的传入值便是这一点的体现。
进一步,整个前向传播计算过程的示例代码如下所示:
1 def forward(self, input_tensor, last_state):
2 h_last, c_last = last_state
3 combined_input = torch.cat([input_tensor, h_last], dim=1)
4 combined_conv = self.conv(combined_input)
5 cc_i, cc_f, cc_o, cc_g = torch.split(combined_conv, self.out_channels, dim=1)
6 i = torch.sigmoid(cc_i)
7 f = torch.sigmoid(cc_f)
8 o = torch.sigmoid(cc_o)
9 g = torch.tanh(cc_g)
10 c_next = f * c_last + i * g
11 h_next = o * torch.tanh(c_next)
12 return h_next, c_next
13
14 def init_hidden(self, batch_size, image_size):
15 height, width = image_size
16 return (torch.zeros(batch_size, self.out_channels,
17 height, width, device=self.conv.weight.device),
18 torch.zeros(batch_size, self.out_channels,
19 height, width, device=self.conv.weight.device))在上述代码中,第1行input_tensor表示当前时刻的输入形状为[batch_size, in_channels, height, width]。第2行last_state表示上一个时刻的输出,包含$h_{t-1}$和$C_{t-1}$两个部分形状均为[batch_size, out_channels, height, width]。第3行表示将$h_{t-1}$和$x_t$进行拼接,形状为[batch_size, in_channels+out_channels, height, width]。第4行为同时计算4个部分的卷积运算。第5行是将卷积运算后的整体结果在dim=1这个维度上按照self.out_channels的大小分割,即分割成4个部分,因为卷积运算后的通道数为4 * self.out_channels。第6~12行则是进行相关状态的计算输出。第14~19行是定义一个方法来实现初始时刻的初始化过程。
2. ConvLSTM实现
在完成ConvLSTMCell模块的实现之后我们便可以基于此来完成ConvLSTM模块的实现,即完成在时间和网络层数两个维度的计算过程。在这之前需要实现两个辅助方法来完成相关参数的扩展与合法性检验,示例代码如下所示:
1 @staticmethod
2 def _check_kernel_size_consistency(kernel_size):
3 if not (isinstance(kernel_size, tuple) or
4 (isinstance(kernel_size, list) and
5 all([isinstance(elem, tuple) for elem in kernel_size]))):
6 raise ValueError('kernel_size must be tuple or list of tuples')
7
8 @staticmethod
9 def _extend_for_multilayer(param, num_layers):
10 if not isinstance(param, list):
11 param = [param] * num_layers
12 return param在上述代码中,第2~6行_check_kernel_size_consistency()方法用来检验参数kernel_size的合法性,即对于多层的ConvLSTM来,传入的kernel_size要么是一个元组如(3,3),要么是一个包含有多个元组的列表如[(3,3),(5,5)],前者表示所有层的卷积核窗口大小均为(3,3),后者表示在两层的ConvLSTM中卷积核的窗口大小分别为(3,3)和(5,5)。第9~12行则是对相关参数进行延展,例如kernel_size=(3,3)且num_layers=2,那么将会返回[(3,3),(3,3)]这样一个结果。
进一步,可以开始实现ConvLSTM模块,示例代码如下所示:
1 class ConvLSTM(nn.Module):
2 def __init__(self, in_channels, out_channels, kernel_size, num_layers,
3 batch_first=False, bias=True, return_all_layers=False):
4 super(ConvLSTM, self).__init__()
5 self._check_kernel_size_consistency(kernel_size)
6 kernel_size = self._extend_for_multilayer(kernel_size, num_layers)
7 out_channels = self._extend_for_multilayer(out_channels, num_layers)
8 if not len(kernel_size) == len(out_channels) == num_layers:
9 raise ValueError('参数不合法')
10 self.in_channels = in_channels
11 self.out_channels = out_channels
12 self.kernel_size = kernel_size
13 self.num_layers = num_layers
14 self.batch_first = batch_first
15 self.bias = bias
16 self.return_all_layers = return_all_layers
17 cell_list = []
18 for i in range(0, self.num_layers):
19 cur_in_channels = self.in_channels if i == 0 else self.out_channels[i - 1]
20 cell_list.append(ConvLSTMCell(in_channels=cur_in_channels,bias=self.bias,
21 out_channels=self.out_channels[i],kernel_size=self.kernel_size[i]))
22 self.cell_list = nn.ModuleList(cell_list)在上述代码中,第2行in_channels为输出样本的通道数为整型,out_channels为每一层的输出通道数可以是整型或者列表,kernel_size为每一层的卷积核窗口大小可以是元组或者为包含元组的列表,num_layers表示网络的层数。第2行return_all_layers表示是否返回每一层的计算结果。第5~9行是分别检验相关参数的合法性以及进行扩展。第18~22行则是开始实例化每一层对应的ConvLSTM记忆单元。
紧接着,整个前向传播的计算过程示例代码如下所示:
1 def forward(self, input_tensor, hidden_state=None):
2 if not self.batch_first:
3 input_tensor = input_tensor.permute(1, 0, 2, 3, 4)
4 batch_size, time_step, _, height, width = input_tensor.size()
5 if hidden_state is not None:
6 raise NotImplementedError()
7 else:
8 hidden_state = self._init_hidden(batch_size,(height, width))
9 layer_output_list, last_state_list = [], []
10 cur_layer_input = input_tensor
11 for layer_idx in range(self.num_layers):
12 h, c = hidden_state[layer_idx]
13 output_inner = []
14 cur_layer_cell = self.cell_list[layer_idx]
15 for t in range(time_step):
16 h, c = cur_layer_cell(cur_layer_input[:, t, :, :, :], [h, c])
17 output_inner.append(h)
18 layer_output = torch.stack(output_inner, dim=1)
19 cur_layer_input = layer_output
20 layer_output_list.append(layer_output)
21 last_state_list.append([h, c])
22 if not self.return_all_layers:
23 layer_output_list = layer_output_list[-1:]
24 last_state_list = last_state_list[-1:]
25 return layer_output_list, last_state_list在上述代码中,第2~3行用于判断批大小是否为第1个维度,不是则进行维度交互。第4行是获取输出张量各个维度的数值。第5~8行是对初始状态进行初始化。第9行中,layer_output_list用于保存每一层的所有输出$h$,每个元素的形状均为[batch_size, time_step, out_channels, height, width],last_state_list用于保存每一层最后一个时刻的输出$h$和$c$,即形容[(h,c),(h,c)...]。第11~12行开始遍历每一层的记忆单元并取对应的初始值。第14行当前层对应的ConvLSTMCell实例化对象。第15~17行开始在时间维度对当前层进行展开计算,其中output_inner用于报错当前时刻计算的得到的$h$值。第18行表示将当前层所有时刻的输出$h$进行堆叠以便作为下一层每个时刻的输入,形状为[batch_size, time_step, out_channels, height, width]。第20~21行则是分别保存对应的输出结果。第22~25行为按照条件返回部分或全部的计算结果,其中last_states[-1][0]表示最后一层最后一个时刻的输出$h$,形状为[batch_size, out_channels, height, width]。
最后,可以通过如下方式进行使用:
1 def example1():
2 out_channels = [5, 6, 7]
3 kernel_size = [(3, 3), (5, 5), (7, 7)]
4 in_channels, num_layers = 3, 3
5 batch_size, time_step = 1, 4
6 height, width = 16, 16
7 x = torch.rand((batch_size, time_step, in_channels, height, height))
8 model = ConvLSTM(in_channels=in_channels,out_channels=out_channels,
9 kernel_size=kernel_size,num_layers=num_layers,
10 batch_first=True,bias=True,return_all_layers=True)
11 layer_output_list, last_states = model(x)
12 print(last_states[-1][0])
13 print(layer_output_list[-1][:, -1])上述代码运行结束后,将会输出类似如下结果:
1 tensor([[[[-0.0171, -0.0154, -0.0130, ..., -0.0129, -0.0135, -0.0143],
2 [-0.0158, -0.0149, -0.0130, ..., -0.0157, -0.0164, -0.0172],
3 [-0.0129, -0.0133, -0.0091, ..., -0.0123, -0.0132, -0.0146],
4 ...,]]]])
5
6 tensor([[[[-0.0171, -0.0154, -0.0130, ..., -0.0129, -0.0135, -0.0143],
7 [-0.0158, -0.0149, -0.0130, ..., -0.0157, -0.0164, -0.0172],
8 [-0.0129, -0.0133, -0.0091, ..., -0.0123, -0.0132, -0.0146],
9 ...,]]]])8.4.4 KTH数据集构建#
在完成ConvLSTM模型的实现之后,我们再来看如何基于ConvLSTM网络模型完成KTH数据集这一视频分类任务。以下完整示例代码可以参见Code/utils/data_helper.py文件。
1. 数据集介绍
KTH数据集是一个广泛应用于动作识别和行为分析的计算机视觉数据集,它是由瑞典皇家工学院(KTH Royal Institute of Technology)收集和发布的,旨在提供用于动作识别和行为分析的标准测试数据[2]。KTH数据集包含有6个不同的动作类别,包括Boxing(拳击)、Handclapping(鼓掌)、Handwaving(挥手)、Jogging(慢跑)、Running(快跑)和Walking(行走),由 25 名受试者在4种不同的场景中进行多次拍摄得到,即一共包含有$25\times6\times4=600$个视频文件。