conv1d与conv2d的区别

看到这个标题,可能不少人的想法都是“博主真是学艺不精,这么简单的区别都要写下来”。说的没错,我的确才疏学浅了。

conv1d与conv2d的区别显然在于一个是一维卷积一个是二维卷积,但是这里的“维度”具体是指哪个维度呢?在Word Embedding上使用conv1d和conv2d是一回事吗?

网络上的许多文章都说这两者完全是一回事,但是他们的理解都是有问题的,几乎把我也误导了,我特此写下这篇文章来记录此事,希望能够帮到思考这个问题的人。

起因

我近日在研究TextCNN时(为什么都2020年了还有人在研究TextCNN? 因为笔者最近才开始正经接触课程作业以外的NLP任务),发现了一种TextCNN的实现使用了conv1d,这是我第一个看到的实现,当我阅读它的代码时即发现了它使用的conv1d,当时我想,原论文中的卷积核显然是二维的(忽略通道数),为什么这里使用了conv1d?然后又看到了keras官方对imdb进行情感分析的CNN示例代码,其中也用的是conv1d。

到这我就疑惑了,上google一顿搜索conv1d和conv2d的区别,当时看到了有人对keras的例子使用conv1d提出了疑问,看上去这个issue里提的问题和我想的一样。问题是:conv1d的卷积核似乎只在word embedding向量行内进行滑动,而不会在word embedding向量之间进行滑动。有人提出的解答是,conv1d卷积核滑动的方向是在向量之间,而不是word embedding的行内。

其实这个人的解答严格来说是没有问题的,这个issue回答的是有关conv1d的卷积核滑动方向问题,无关conv1d本身的卷积核是一维还是二维的。但是我当时理解错了,以为conv1d的卷积核可以是二维的,只不过只在一个方向上滑动。我的错误理解是:conv1d会根据输入数据的shape确认二维卷积核的宽度,手动制定的kernel_size确认了卷积核的高度,如果使用多个向量作为输入(不考虑batch),则卷积核在多个向量间方向进行滑动卷积。我的错误理解中,将conv1d当成了conv2d的一种特殊情况。

其实conv1d是不是conv2d的一种特殊情况呢?是的,但是要注意的是,conv1d的卷积核是1维的,而conv2d的卷积核是2维的。也就是说,如果你的目的是使用二维的卷积核进行卷积,那么你应该使用covn2d而非conv1d。例如一组word embedding作为输入,使用conv1d会独立地在word embedding的每个维度的数据中进行卷积,即如果词向量有300维,它是从第1维的数据的卷积加到第300维数据的卷积,而不是使用二维卷积核同时计算。

数学

如果要看清这一点,我们需要明确地去寻找conv1d的操作定义。而为了对conv1d的定义更加明晰,我们先回顾一下conv2d的定义(因为许多框架中的conv1d是用conv2d实现的)

conv2d

流行的神经网络框架中的conv1d,conv2d的定义是类似的,其中写明数学定义的有pytorch,pytorch中对conv2d的定义如下,

对于输入数据shape为,输出数据的shape为,卷积核的shape为,有个卷积核。卷积操作被描述如下 其中代表二维卷积操作,且这里卷积操作的定义和信号处理中有所区别。为batch size,为通道数,分别为高和宽。

卷积核的通道数与输入数据的通道数是相同的,输出数据的通道数等于卷积核的个数,输出数据的高和宽与及padding方式、stride大小有关。

我们可以看到,输出数据每一通道的二维矩阵是分别计算的,而每个通道的的矩阵的值需要对输入数据每一通道的二维矩阵做卷积后相加得到。

conv1d

当我们回顾了conv2d后再来看conv1d,可以发现定义十分类似。

输入数据的shape为,输出数据的shape为,卷积核的shape为为序列的长度,卷积操作被描述为 我们看到,这里的公式与conv2d的计算公式表述一摸一样,为什么会这样呢?因为这个卷积计算符省略了进行卷积运算的矩阵/向量的维度信息。conv2d的卷积运算是在二维矩阵中滑动,而conv1d的卷积运算是在一维向量中滑动。

当我们使用conv2d处理图片时,二维卷积核在图片每一通道的矩阵中滑动卷积,当我们用conv1d处理向量时,一维卷积核在每一通道的向量中滑动卷积。

从数学定义上我们就可以看到,conv2d的卷积核是二维的,conv1d的卷积核是一维的,他们处理的输入数据的形状也是不同的。

文本向量

那么,具体到Word Embedding表示的文本向量中,conv1d又和conv2d有什么样的区别呢?

在TextCNN的原论文中Convolutional Neural Networks for Sentence Classification有张流传度很广的图为Figure 1,可以从中比较直观地看到对一篇文本的卷积,是对这个文本所有单词的词向量叠加成的矩阵进行二维卷积操作。下面我们将更详细地说明这个操作的实现。

Figure 1: TextCNN原论文中解释文本卷积操作的插图

首先,让我们把输入数据描述清楚。设我们的每个batch中有个sentence,每个sentence有个word,每个word的embedding向量的维度为,那么使用conv1d和conv2d时,我们需要把数据变为不同的形状才可以操作。

当使用conv2d时,要求输入数据的形状是,卷积核的形状为,我们将我们的数据reshape为,即,卷积核的形状为,卷积核的数量为这样我们就可以如TextCNN那样描述的那样在一个sentence的embedding向量间进行卷积核的滑动。最后产生的数据形状为

可以看到,在使用conv2d时,我们将embedding向量的维度对应为输入数据的宽,单词数量对应为数据的高(当然也可以将二者反过来),输入的通道数为1,这样我们的二维“图像”矩阵就是一个句子的单词的embedding向量的展开叠加。

那么使用conv1d时呢?要求输入的数据形状是,卷积核的形状为,将我们的数据解析为,即,卷积核的形状为,卷积核的数量为。每一通道的的卷积核是一个长度为的向量,即每一通道的卷积核不再是个矩阵,而每一通道的数据也是一个长度为的向量,卷积在这两个向量上进行,有D个通道的卷积结果相加得到最终的卷积值。

可以注意到,conv1d的每次计算只用到了所有word embedding向量的一维数据,多次滑动的卷积之间是独立的。

从上面的分析就可以清楚地看到,conv1d与conv2d在文本向量上的操作是完全不同的,他们虽然都是卷积,但是完全不是一个运算,每一个通道中,一个的卷积核是1维的向量,一个是2维的矩阵。

代码验证

我们下面将通过代码验证,我们使用tensorflow的tf.nn.conv1d来进行验证。首先确认tf.nn.conv1d与我们之前说的conv1d的操作是相同的,根据官方documentation,conv1d的定义如下

1
2
3
tf.nn.conv1d(
input, filters, stride, padding, data_format='NWC', dilations=None, name=None
)

Given an input tensor of shape [batch, in_width, in_channels] if data_format is "NWC", or [batch, in_channels, in_width] if data_format is "NCW", and a filter / kernel tensor of shape [filter_width, in_channels, out_channels], this op reshapes the arguments to pass them to conv2d to perform the equivalent convolution operation.

Internally, this op reshapes the input tensors and invokes tf.nn.conv2d. For example, if data_format does not start with "NC", a tensor of shape [batch, in_width, in_channels] is reshaped to [batch, 1, in_width, in_channels], and the filter is reshaped to [1, filter_width, in_channels, out_channels]. The result is then reshaped back to [batch, out_width, out_channels] (where out_width is a function of the stride and padding as in conv2d) and returned to the caller.

tf.nn.conv1d的输入数据shape为,卷积核的形状为,这里的定义与pytorch中的定义本质是一样的,不过把卷积核的个数写进shape里了。tf.nn.conv2d的定义也和pytorch大同小异。

文档中描述到,conv1d将会调用conv2d来实现,将输入数据reshape为,卷积核reshape为,然后调用conv2d。那么这说明conv1d不过是conv2d的简洁用法吗?在TextCNN上,显然不是的,我们上面的理论分析也分析过了,text embedding的原始输入形状为, 我们希望的conv2d的输入形状为,但是按照conv1d的方法,会变为,这样的运算得到的结果显然不是我们想要的。虽然最后输出的形状是相同的,但是结果是不同的。

下面我们用代码验证,conv1d的计算即为用卷积核与输入word embedding的各个通道的数据分别卷积相加获得。

下面的代码首先定义了一个text embedding数据,一共4个单词,每个单词的embedding向量是3维的。首先使用一个3通道,kernel_size为2的的卷积核对其使用conv1d,得到一个结果。然后分别对该数据每一通道的数据使用每一通道的卷积核使用conv1d,将结果相加,最后得到的结果是一样的。这就说明了对于embedding向量每一维度的数据卷积,只会用到一个通道的一维卷积核进行计算,这与我们在TextCNN中期待的二维卷积计算是不一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
text1 = [0.7, 0.4, 0.5]
text2 = [0.2, -0.1, 0.1]
text3 = [-0.5, 0.4, 0.1]
text4 = [0.6, 0.3, 0.5]
sentence_matrix = np.array([text1, text2, text3, text4], dtype=np.float32)

# input shape (batche_size=1, in_width=4, in_channel=3)
sentence = tf.constant(sentence_matrix.reshape(1, 4, 3))
print("input data is \n", sentence)

# kernel shape (filter_size=2, in_channel=3, out_channel=1)
filter_1d = tf.transpose(tf.constant([[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]]), (2, 1, 0))
print("filter_1d is \n", filter_1d)
# output:
# tf.Tensor(
# [[[0.1]
# [0.3]
# [0.5]]

# [[0.2]
# [0.4]
# [0.6]]], shape=(2, 3, 1), dtype=float32)

# output shape (batche_size=1, out_width= in_width - filter_size + 1 = 4 - 2 + 1 = 3, out_channel=1)
output=tf.nn.conv1d(sentence, filter_1d, stride=1, padding="VALID")
output = tf.transpose(output, (0, 2, 1))
print("output is\n", output)
# output:
# tf.Tensor([[[0.5 0.16000001 0.66 ]]], shape=(1, 1, 3), dtype=float32)

# kernel shape (filter_size=2, in_channel=1, out_channel=1)
kernels = [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]

result = tf.constant([[[0.0], [0.0], [0.0]]])
for i, kernel in enumerate(kernels):
# kernel shape (filter_size=2, in_channel=1, out_channel=1)
filter_kernel = tf.transpose(tf.constant([[kernel]]), (2, 1, 0))

# for each channel value in sentence_matrix columns:
temp_sen = sentence_matrix[:, i]

# input shape (batch_size=1, in_width=4, in_channel=1)
temp_sen = tf.reshape(tf.constant(temp_sen), (1, 4, 1))
temp_out = tf.nn.conv1d(temp_sen, filter_kernel, stride=1, padding="VALID")
result = result + temp_out
result = tf.transpose(result, (0, 2 ,1))
print("result = \n", result)
# output:
# tf.Tensor([[[0.5 0.16000001 0.66 ]]], shape=(1, 1, 3), dtype=float32)

尾记

网上有不少误人子弟的文章,差点也误了我,就写篇文章总结这件事。

感谢LX大佬和我的讨论。

下面是TextCNN的一些实现参考。

论文作者的实现https://github.com/yoonkim/CNN_sentence

作者推荐的tensorflow实现https://github.com/dennybritz/cnn-text-classification-tf

尚可的一种实现https://github.com/bhaveshoswal/CNN-text-classification-keras