CNN在NLP中的使用

滤波器与卷积核

一般在检索卷积神经网络相关资料时都会看到类似的样例图。

这里需要强调的是,滤波器(Filter)和卷积核(Kernel)并非指的是同一样东西,每个通道的数据都需要对应一个卷积核,若干个卷积核总称为滤波器。在图示中,输入图像数据包含3通道,所以对应的卷积核就要包含3个,这3个卷积核组合成为一个滤波器。
滤波器的个数与输出通道数相对应,若假设输入数据通道数为$\bf{C_{in}}$,out_channels为$\bf{N}$,,每个通道需要对应一个卷积核,那么实际上就有$3\bf{N}$个卷积核。以上图为例,模型输入为三通道,对应三个卷积核对每个通道中的数据进行卷积操作,将最终得到的矩阵相加得到最终结果。

Pytorch中的卷积层定义

引包

1
import torch

Conv1d

1
2
3
4
5
6
torch.nn.Conv1d(
in_channels
, out_channels
, kernel_size
, stride=1, padding=0, dilation=1, groups=1, bias=True, padding_mode='zeros', device=None, dtype=None
)

Conv2d

1
2
3
4
5
6
torch.nn.Conv2d(
in_channels
, out_channels
, kernel_size
, stride=1, padding=0, dilation=1, groups=1, bias=True, padding_mode='zeros', device=None, dtype=None
)

Con1d和Conv2d的函数定义如上所示,in_channels代表着输入数据的通道数,以图像数据为例,如果输入的是3通道,那么in_channels就要为3,输入数据需要与卷积层通道数对齐。out_channels代表滤波器的个数,kernel_size为卷积核的尺寸。kernel_size、dilation、padding可以设定为整型也可以设定为元组。kernel_size输入为整型$\bf{X}$时,卷积核尺寸为$\bf{X}\times\bf{X}$;当输入为(x,y)时,卷积核尺寸即为$\bf{X}\times\bf{Y}$。padding和dilation输入为整型时,意为对行、对列的padding或dilation均相同;当输入为元组时,代表当前卷积层进行padding和dilation时对行、对列设定不同数值。

卷积层的输入

Conv1D与Conv2D的输入略有不同,输入Conv1d的往往为三维数据,即$(\bf{N},\bf{C_{in}},\bf{L})$,输入Conv2d的数据往往为四维,即$(\bf{N},\bf{C_{in}},\bf{H},\bf{W})$。以$(\bf{N},\bf{C_{in}},\bf{L})$为例,$\bf{N}$代表一个batch中有$\bf{N}$条数据,每条数据的长度为$\bf{L}$,数据的通道数为$\bf{C_{in}}$;$(\bf{N},\bf{C_{in}},\bf{H},\bf{W})$中$\bf{N}$和$\bf{C_{in}}$的含义不变,$\bf{H}$和$\bf{W}$分别代表数据的高度和宽度。在自然语言处理相关任务中,输入数据的维度多为$(\bf{N},\bf{L},Embedding_dim)$,并没有“通道”的概念。

CNN在NLP中的应用

这里我们先假设一组embedding之后的数据,batch大小为3,批中语句长度为12,每个字的embedding_dim为7,共3个滤波器,最终得到的数据如代码块所示。

1
2
3
4
5
6
7
8
9
import numpy as np
import torch
from copy import deepcopy
import torch.nn.functional as F
batch = 2
seq_len = 12
bert_hid_size = 7
output_channel = 3
sequence = torch.tensor(np.random.rand(batch,seq_len,bert_hid_size),dtype=torch.float)

前面介绍了再Pytorch中Conv2d层的输入是四个维度,所以需要将embedding之后的数据升1维,变为(batch,1,len,embedding_dim)。这时可以有两种做法,一种是直接将“1”看作数据的通道数,另一种是将原始数据转变维度,使用seq_len或embedding_dim作为通道数。直接将“1”看作通道数的实现如以下代码块所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch
from copy import deepcopy
conv2d_layer = torch.nn.Conv2d(
in_channels = 1
,out_channels = 3
,kernel_size = 3
)
inputs = deepcopy(sequence).unsqueeze(1)
print(f"inputs.shape:{inputs.shape}")
outputs = conv2d_layer(inputs)
print(f"outputs.shape:{outputs.shape}")

# inputs.shape:torch.Size([2, 1, 12, 7])
# outputs.shape:torch.Size([2, 3, 10, 5])

除此之外,还可以将embedding_dim作为通道数进行卷积操作,实现如以下代码块所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
from copy import deepcopy
conv2d_layer = torch.nn.Conv2d(
in_channels = bert_hid_size
,out_channels = 3
,kernel_size = 3
# ,padding = 1
)
inputs = deepcopy(sequence).unsqueeze(1).permute(0,3,1,2)
print(f"inputs.shape:{inputs.shape}")
outputs = conv2d_layer(inputs)
print(f"outputs.shape:{outputs.shape}")

# RuntimeError:Calculated padded input size per channel: (1 x 12). Kernel size: (3 x 3).
# Kernel size can't be greater than actual input size

这里如果没有进行padding操作会报错“其中提到计算出的每个channel输入大小:(1x 12),kernel大小:(3 x3),内核大小不能大于实际输入大小”。这是不是可以说明,将embedding_dim作为通道后,在每个channel中的数据实际上是(batch, 1, 1, seq_len),也就是channel把原来的矩阵“掰碎”了?在原始数据中使用维度为768的向量表示一个token,现在在每个通道中,使用一个维度为1的向量代表一个token,换句话说,卷积核的尺寸设置成3,就已经是3-gram模型了?

在这种情况下,kernel_size设计成(1,seq_len)还是(1,int)?如果设计成(1,3)是对应着3-gram模型吗?
这里如果增加了padding,也是可以运行的,结果为torch.Size([3, 3, 1, 12]),这里每个维度代表的含义?第一个3是batch,第二个3是几个滤波器,12代表seq_len,那最终的1可以理解成将每个token的向量转变成了1个数字吗?

对数据进行padding后,上述报错的代码即可执行,实现效果如下代码块所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
from copy import deepcopy
conv2d_layer = torch.nn.Conv2d(
in_channels = bert_hid_size
,out_channels = 3
,kernel_size = 3
,padding = 1
)
inputs = deepcopy(sequence).unsqueeze(1).permute(0,3,1,2)
print(f"inputs.shape:{inputs.shape}")
outputs = conv2d_layer(inputs)
print(f"outputs.shape:{outputs.shape}")

# inputs.shape:torch.Size([2, 7, 1, 12])
# outputs.shape:torch.Size([2, 3, 1, 12])

这里观察卷积后得到的数据维度可以发现,batch、seq_len、以及升维的channel都没有变,原本7维的词向量缩短为3维。
也可以将seq_len作为channel,程序依旧可以运行,实现如以下代码块所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
from copy import deepcopy
conv2d_layer = torch.nn.Conv2d(
in_channels = seq_len
,out_channels = 3
,kernel_size = 3
,padding = 1
)
inputs = deepcopy(sequence).unsqueeze(1).permute(0,2,1,3)
print(f"inputs.shape:{inputs.shape}")
outputs = conv2d_layer(inputs)
print(f"outputs.shape:{outputs.shape}")

# inputs.shape:torch.Size([2, 12, 1, 7])
# outputs.shape:torch.Size([2, 3, 1, 7])

这里观察输出结果维度发现,batch、channel、embedding维度没有变,但是原本对应句子长度的数值缩减到3。
torch.nn.Conv1d实现起来与Conv2d差不多,下面两个代码块依次以embedding_dim和seq_len作为通道。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
from copy import deepcopy
conv1d_layer = torch.nn.Conv1d(
in_channels = bert_hid_size
,out_channels = 3
,kernel_size = 3
,padding = 1
)
inputs = deepcopy(sequence).permute(0,2,1)
print(f"inputs.shape:{inputs.shape}")
outputs = conv1d_layer(inputs)
print(f"outputs.shape:{outputs.shape}")

# inputs.shape:torch.Size([2, 7, 12])
# outputs.shape:torch.Size([2, 3, 12])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
from copy import deepcopy
conv1d_layer = torch.nn.Conv1d(
in_channels = seq_len
,out_channels = 3
,kernel_size = 3
,padding = 1
)
inputs = deepcopy(sequence)
print(f"inputs.shape:{inputs.shape}")
outputs = conv1d_layer(inputs)
print(f"outputs.shape:{outputs.shape}")

# inputs.shape:torch.Size([2, 12, 7])
# outputs.shape:torch.Size([2, 3, 7])

所以说使用CNN进行NLP相关任务的时候最重要的一个原则,需要沿着embedding_dim那个维度进行卷积,例如输入为(batch,len,dim)时(4维时同理),将dim作为通道数,即(batch,dim,len)每个通道内形状为(1,len)相当于原来的embedding被“降维”成了一个数,此时kernel_size设为(1,3)则是一个3-gram模型。
也有许多博客说Conv1d和Conv2d的区别在于卷积的方向,Conv1D的卷积方向是一维的,只能一个方向,Conv2D的卷积方向是二维的,横着卷完竖着卷。但是在处理文本数据时,理论上应该把每个词的词向量完整的包含在卷积核中,对于一个(batch, len, embedding_dim)的矩阵,卷积核尺寸应该类似于(height, embedding_dim),这时卷积的方向不也是只能向下了嘛?
上述的方法的确可以使用CNN对文本embedding后的矩阵进行运算,但是存在一个比较严重的问题就是模型很难做深