TextCNN(CNN for Sentence Classification)

TextCNN(CNN for Sentence Classification)

图像里,卷积神经网络靠一个小窗口在图片上滑动,捕捉边缘、纹理这类局部模式。文本其实也能这么处理,一个滑动窗口盖住连续几个词,就是在看一个短语片段。TextCNN 是 Kim 2014 年提出的经典模型,用不同宽度的一维卷积同时抓取不同长度的短语特征,再做池化分类,结构简单、训练快,至今仍是文本分类的强基线。

这一节我们先把一维卷积在文本上的形状和计算讲透,包括 padding 和 dilation 这两个容易绕晕的参数,再拼出完整的 TextCNN 网络并跑通代码。读之前需要会用 PyTorch,知道张量维度是怎么回事。

1. 卷积形状

文本进模型第一步是词嵌入,每个词变成一个固定维度的向量。假设一句话有 3 个词,每个词向量 4 维,摆在矩阵里就是 4 行 3 列,一行代表一个维度,一列代表一个词。PyTorch 的 nn.Conv1d 要求输入形状是 批量、通道数、序列长度。这里通道数正好等于词向量维度,序列长度等于词数。我们先跑一段最小例子,看看形状怎么变:

import torch
import torch.nn as nn


def demo():

    batch_size, token_dim, seq_len = 2, 4, 3
    inputs = torch.randn(batch_size, token_dim, seq_len)
    # 输入形状 (batch_size, in_channels, seq_len)
    # in_channels 表示输入 token 的向量维度
    print(inputs.shape)

    conv = nn.Conv1d(in_channels=token_dim, out_channels=8, kernel_size=2)

    # 卷积核参数形状: (out_channel, in_channels, kernel_size)
    # kernel_size: 卷积核在 seq_len 方向上按照 kernel_size 滑动窗口计算
    print(conv.weight.shape)
    outputs = conv(inputs)

    # 输出形状: (batch_size, out_channel, seq_len)
    print(outputs.shape)


if __name__ == '__main__':
    demo()
torch.Size([2, 4, 3])
torch.Size([8, 4, 2])
torch.Size([2, 8, 2])

我们对一遍数字。输入是 (2, 4, 3),卷积核宽度 2,一次窗口盖两个词,在 3 个词的序列上能滑出 2 个位置,所以输出序列长度是 3 减 2 加 1 等于 2,输出形状就是 (2, 8, 2)。卷积核自身的参数形状是 输出通道数、输入通道数、核宽,也就是 (8, 4, 2),和打印结果完全一致。

2. 长度与填充

上面算完我们发现一个现象,3 个词进去出来只剩 2 个位置,序列长度被卷积核啃短了。如果任务是词级标注,要求输出和输入等长,就要靠 padding 在序列两端补零。宽度 2 的核,左右各补一个零列,序列长度从 3 变 5,卷积后长度变成 4:

[[1, 3, 2],
 [8, 2, 2],
 [4, 6, 7],
 [3, 9, 1]]

[[0, 1, 3, 2, 0],
 [0, 8, 2, 2, 0],
 [0, 4, 6, 7, 0],
 [0, 3, 9, 1, 0]]

补完两侧还是差一个,原作者的做法是再切一刀把多余列扔掉。更稳的等长做法是只在序列左侧补 dilation 乘 核宽减 1 个零,右侧不补,这样输出长度自动等于输入长度。这叫因果卷积的补法,不用再手动切片。

还有一个 dilation 参数,控制卷积核的跳跃间隔,默认 1 就是连续看。我们拿上面那个 4 行 3 列的矩阵举例,三个列向量分别是 [1,8,4,3]、[3,2,6,9]、[2,2,7,1]:

[[1, 3, 2],
 [8, 2, 2],
 [4, 6, 7],
 [3, 9, 1]]

dilation 等于 1 时,窗口连续取相邻两列,先算第一列和第二列的组合 [1,8,4,3] 与 [3,2,6,9],再算第二列和第三列的组合 [3,2,6,9] 与 [2,2,7,1]。dilation 等于 2 时,窗口跳过中间一列,直接拿第一列和第三列算,也就是 [1,8,4,3] 与 [2,2,7,1],中间那个列向量被隔了过去。

跳跃看的好处是不加深网络就能覆盖更长的上下文,参数量不涨。代价是输出长度会跟着变,同样要用 padding 补回来。把几个参数合起来,输出长度的公式是:输出长度等于输入长度加两倍 padding,减 dilation 乘 核宽减 1,再减 1,除以步长后加 1。步长固定为 1 时,想保持等长,padding 就按这个公式反推。

(待补图:原文此处有 padding 计算公式截图,上面的文字公式已覆盖该内容。)

3. 网络结构

卷积参数搞清楚了,拼 TextCNN 就是顺手的事。它的设计很直白。词嵌入之后,同时铺开几种不同宽度的卷积核,常见 3、4、5 三种,每种各出 100 个特征图。宽 3 的核专抓三字短语,宽 5 的抓五字片段,多宽度并行等于同时看不同粒度的搭配。每种卷积过一遍 ReLU 激活,然后对各自的序列维度做最大池化,整张特征图只留最显眼的那个值。三种池化结果拼在一起,过 dropout,接全连接层输出类别。

我们为什么用最大池化?因为一句话里最有判别力的短语可能出现在任何位置,池化把每个特征图最突出的响应抽出来,不管它在第几个词,句子长短不一也不影响。这正好绕开了上一节说的长度变化问题,TextCNN 本身其实不需要 padding。

4. 代码实现

结构讲完,我们直接上可跑的代码。下面这段是经典实现,卷积核宽取 3、4、5,每个宽度 100 个过滤器:

import torch
import torch.nn as nn
import torch.nn.functional as F


class TextCNN(nn.Module):
    def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes=[3, 4, 5], num_filters=100, dropout=0.5):
        super(TextCNN, self).__init__()

        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.convs = nn.ModuleList([
            nn.Conv1d(in_channels=embed_dim, out_channels=num_filters, kernel_size=k) for k in kernel_sizes
        ])
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes)

    def forward(self, inputs):
        # 输入形状:(batch_size, seq_len, embed_dim)
        inputs = self.embedding(inputs)
        # 装换形状:(batch_size, embed_dim, seq_len)
        inputs = inputs.permute(0, 2, 1)
        # 应用不同尺度文本卷积
        inputs = [ conv(inputs) for conv in self.convs ]
        # 应用 ReLU 激活函数
        inputs = [ F.relu(i) for i in inputs ]
        # [torch.Size([1, 100, 6]), torch.Size([1, 100, 5]), torch.Size([1, 100, 4])]
        # print([i.shape for i in inputs])

        # 应用最大池化
        inputs = [ i.max(dim=2)[0] for i in inputs ]
        # [torch.Size([1, 100]), torch.Size([1, 100]), torch.Size([1, 100])]
        # print([i.shape for i in inputs])

        inputs = torch.cat(inputs, dim=1)  # 拼接不同卷积核的输出
        print(inputs.shape)

        inputs = self.dropout(inputs)
        return self.fc(inputs)


if __name__ == '__main__':
    estimator = TextCNN(vocab_size=100, embed_dim=8, num_classes=2)
    inputs = torch.randint(0, 100, size=(1, 8))
    logits = estimator(inputs)
    print(logits)
torch.Size([1, 300])
tensor([[0.1518, 0.4898]], grad_fn=<AddmmBackward0>)

我们再对一遍形状。输入是一个长度 8 的 token 序列,嵌入后转成 (1, 8, 8),再把维度转置给卷积用。三种核宽分别卷完,序列长度变成 6、5、4,各 100 个通道。最大池化把序列维度压掉,各得 100 维,拼成 300 维,再过全连接层输出两个类别的得分,和打印结果一致。

5. 误区澄清

第一个坑,是把 in_channels 理解成卷积核个数。它在文本任务里等于词向量维度,是输入的通道数,真正决定输出多少种特征的是 out_channels。

第二个坑,是以为 TextCNN 能捕捉长距离依赖。它的窗口最多覆盖五六个词,长句里相隔很远的两个词它看不见,长程关系得靠注意力机制。

第三个坑,是觉得池化只取最大值是浪费信息。这正是论文的有意设计,分类只需要最关键的那个短语信号,经验上最大池化在句子分类上效果通常好于平均池化。

我们回到开头的问题。TextCNN 用多宽度一维卷积并行抓不同长度的短语特征,最大池化把不定长的序列压成定长向量,再接全连接分类。它的卷积参数少、训练快,效果却能打过不少复杂模型,至今是文本分类的入门标配。原论文见 https://arxiv.org/abs/1408.5882