文本情感分类:使用卷积神经网络(textCNN)

在“卷积神经网络”一章中我们探究了如何使用二维卷积神经网络来处理二维图像数据。在之前的语言模型和文本分类任务中,我们将文本数据看作是只有一个维度的时间序列,并很自然地使用循环神经网络来处理这样的数据。其实,我们也可以将文本当做是一维图像,从而可以用一维卷积神经网络来捕捉临近词之间的关联。本节将介绍将卷积神经网络应用到文本分析的开创性工作之一:textCNN [1]。首先导入实验所需的包和模块。

In [1]:
import gluonbook as gb
from mxnet import gluon, init, nd
from mxnet.contrib import text
from mxnet.gluon import data as gdata, loss as gloss, nn

一维卷积层

在介绍模型前我们先来解释一维卷积层的工作原理。和二维卷积层一样,一维卷积层使用一维的互相关运算。在一维互相关运算中,卷积窗口从输入数组的最左方开始,按从左往右的顺序,依次在输入数组上滑动。当卷积窗口滑动到某一位置时,窗口中的输入子数组与核数组按元素相乘并求和,得到输出数组中相应位置的元素。如图 10.4 所示,输入是一个宽为 7 的一维数组,核数组的宽为 2。可以看到输出的宽度为 \(7-2+1=6\),且第一个元素是由输入的最左边的宽为 2 的子数组与核数组按元素相乘后再相加得到的。

一维互相关运算。阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:\ :math:`0\times1+1\times2=2`\ 。

一维互相关运算。阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:\(0\times1+1\times2=2\)

下面我们将一维互相关运算实现在corr1d函数里。它接受输入数组X和核数组K,并输出数组Y

In [2]:
def corr1d(X, K):
    w = K.shape[0]
    Y = nd.zeros((X.shape[0] - w + 1))
    for i in range(Y.shape[0]):
        Y[i] = (X[i: i + w] * K).sum()
    return Y

让我们重现图 10.4 中一维互相关运算的结果。

In [3]:
X, K = nd.array([0, 1, 2, 3, 4, 5, 6]), nd.array([1, 2])
corr1d(X, K)
Out[3]:

[ 2.  5.  8. 11. 14. 17.]
<NDArray 6 @cpu(0)>

多输入通道的一维互相关运算也与多输入通道的二维互相关运算类似:在每个通道上,将核与相应的输入做一维互相关运算,并将通道之间的结果相加得到输出结果。图 10.5 展示了含 3 个输入通道的一维互相关运算。

含3个输入通道的一维互相关运算。阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:\ :math:`0\times1+1\times2+1\times3+2\times4+2\times(-1)+3\times(-3)=2`\ 。

含3个输入通道的一维互相关运算。阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:\(0\times1+1\times2+1\times3+2\times4+2\times(-1)+3\times(-3)=2\)

让我们重现图 10.5 中多输入通道的一维互相关运算的结果。

In [4]:
def corr1d_multi_in(X, K):
    # 我们首先沿着 X 和 K 的第 0 维(通道维)遍历。然后使用 * 将结果列表变成 add_n 函数
    # 的位置参数(positional argument)来进行相加。
    return nd.add_n(*[corr1d(x, k) for x, k in zip(X, K)])

X = nd.array([[0, 1, 2, 3, 4, 5, 6],
              [1, 2, 3, 4, 5, 6, 7],
              [2, 3, 4, 5, 6, 7, 8]])
K = nd.array([[1, 2], [3, 4], [-1, -3]])
corr1d_multi_in(X, K)
Out[4]:

[ 2.  8. 14. 20. 26. 32.]
<NDArray 6 @cpu(0)>

由二维互相关运算的定义可知,多输入通道的一维互相关运算可以看作是单输入通道的二维互相关运算。如图 10.6 所示,我们也可以将图 10.5 中多输入通道的一维互相关运算以等价的单输入通道的二维互相关运算呈现。这里核的高等于输入的高。

单输入通道的二维互相关运算。高亮部分为第一个输出元素及其计算所使用的输入和核数组元素:\ :math:`2\times(-1)+3\times(-3)+1\times3+2\times4+0\times1+1\times2=2`\ 。

单输入通道的二维互相关运算。高亮部分为第一个输出元素及其计算所使用的输入和核数组元素:\(2\times(-1)+3\times(-3)+1\times3+2\times4+0\times1+1\times2=2\)

图 10.4 和图 10.5 中的输出都只有一个通道。我们在“多输入通道和多输出通道”一节中介绍了如何在二维卷积层中指定多个输出通道。类似地,我们也可以在一维卷积层指定多个输出通道,从而拓展卷积层中的模型参数。

时序最大池化层

类似地,我们有一维池化层。TextCNN 中使用的时序最大池化层(max-over-time pooling)实际上对应一维全局最大池化层:假设输入包含多个通道,各通道由不同时间步上的数值组成,各通道的输出即该通道所有时间步中最大的数值。因此,时序最大池化层的输入在各个通道上的时间步数可以不同。

为提升计算性能,我们常常将不同长度的时序样本组成一个小批量,并通过在较短序列后附加特殊字符(例如 0)令批量中各时序样本长度相同。这些人为添加的特殊字符当然是无意义的。由于时序最大池化的主要目的是抓取时序中最重要的特征,它通常能使模型不受人为添加字符的影响。

读取和预处理 IMDb 数据集

我们依然使用和上一节中相同的 IMDb 数据集做情感分析。以下读取和预处理数据集的步骤与上一节中的相同。

In [5]:
batch_size = 64
gb.download_imdb()
train_data, test_data = gb.read_imdb('train'), gb.read_imdb('test')
vocab = gb.get_vocab_imdb(train_data)
train_iter = gdata.DataLoader(gdata.ArrayDataset(
    *gb.preprocess_imdb(train_data, vocab)), batch_size, shuffle=True)
test_iter = gdata.DataLoader(gdata.ArrayDataset(
    *gb.preprocess_imdb(test_data, vocab)), batch_size)

TextCNN 模型

TextCNN 主要使用了一维卷积层和时序最大池化层。假设输入的文本序列由 \(n\) 个词组成,每个词用 \(d\) 维的词向量表示。那么输入样本的宽为 \(n\),高为 1,输入通道数为 \(d\)。textCNN 的计算主要分为以下几步:

  1. 定义多个一维卷积核,并使用这些卷积核对输入分别做卷积计算。宽度不同的卷积核可能会捕捉到不同个数的相邻词的相关性。
  2. 对输出的所有通道分别做时序最大池化,再将这些通道的池化输出值连结为向量。
  3. 通过全连接层将连结后的向量变换为有关各类别的输出。这一步可以使用丢弃层应对过拟合。
textCNN的设计。

textCNN的设计。

图 10.7 用一个例子解释了 textCNN 的设计。这里的输入是一个有 11 个词的句子,每个词用 6 维词向量表示。因此输入序列的宽为 11,输入通道数为 6。给定 2 个一维卷积核,核宽分别为 2 和 4,输出通道数分别设为 4 和 5。因此,一维卷积计算后,4 个输出通道的宽为 \(11-2+1=10\),而其他 5 个通道的宽为 \(11-4+1=8\)。尽管每个通道的宽不同,我们依然可以对各个通道做时序最大池化,并将 9 个通道的池化输出连结成一个 9 维向量。最终,我们使用全连接将 9 维向量变换为 2 维输出:正面情感和负面情感的预测。

下面我们来实现 textCNN 模型。跟上一节相比,除了用一维卷积层替换循环神经网络外,这里我们使用了两个嵌入层,一个的权重固定,另一个则参与训练。

In [6]:
class TextCNN(nn.Block):
    def __init__(self, vocab, embed_size, kernel_sizes, num_channels,
                 **kwargs):
        super(TextCNN, self).__init__(**kwargs)
        self.embedding = nn.Embedding(len(vocab), embed_size)
        # 不参与训练的嵌入层。
        self.constant_embedding = nn.Embedding(len(vocab), embed_size)
        self.dropout = nn.Dropout(0.5)
        self.decoder = nn.Dense(2)
        # 时序最大池化层没有权重,所以可以共用一个实例。
        self.pool = nn.GlobalMaxPool1D()
        self.convs = nn.Sequential()  # 创建多个一维卷积层。
        for c, k in zip(num_channels, kernel_sizes):
            self.convs.add(nn.Conv1D(c, k, activation='relu'))

    def forward(self, inputs):
        # 将两个形状是(批量大小,词数,词向量维度)的嵌入层的输出按词向量连结。
        embeddings = nd.concat(
            self.embedding(inputs), self.constant_embedding(inputs), dim=2)
        # 根据 Conv1D 要求的输入格式,将词向量维,即一维卷积层的通道维,变换到前一维。
        embeddings = embeddings.transpose((0, 2, 1))
        # 对于每个一维卷积层,在时序最大池化后会得到一个形状为(批量大小,通道大小,1)的
        # NDArray。使用 flatten 函数去掉最后一维,然后在通道维上连结。
        encoding = nd.concat(*[nd.flatten(
            self.pool(conv(embeddings))) for conv in self.convs], dim=1)
        # 应用丢弃法后使用全连接层得到输出。
        outputs = self.decoder(self.dropout(encoding))
        return outputs

创建一个 TextCNN 实例。它有 3 个卷积层,它们的核宽分别为 3、4 和 5,输出通道数均为 100。

In [7]:
embed_size, kernel_sizes, nums_channels = 100, [3, 4, 5], [100, 100, 100]
ctx = gb.try_all_gpus()
net = TextCNN(vocab, embed_size, kernel_sizes, nums_channels)
net.initialize(init.Xavier(), ctx=ctx)

加载预训练的词向量

同上一节一样,加载预训练的 100 维 GloVe 词向量,并分别初始化嵌入层embeddingconstant_embedding。其中前者参与训练,而后者权重固定。

In [8]:
glove_embedding = text.embedding.create(
    'glove', pretrained_file_name='glove.6B.100d.txt', vocabulary=vocab)
net.embedding.weight.set_data(glove_embedding.idx_to_vec)
net.constant_embedding.weight.set_data(glove_embedding.idx_to_vec)
net.constant_embedding.collect_params().setattr('grad_req', 'null')

训练并评价模型

现在我们可以训练模型了。

In [9]:
lr, num_epochs = 0.001, 5
trainer = gluon.Trainer(net.collect_params(), 'adam', {'learning_rate': lr})
loss = gloss.SoftmaxCrossEntropyLoss()
gb.train(train_iter, test_iter, net, loss, trainer, ctx, num_epochs)
training on [gpu(0), gpu(1), gpu(2), gpu(3), gpu(4), gpu(5), gpu(6), gpu(7)]
epoch 1, loss 0.6031, train acc 0.715, test acc 0.825, time 29.1 sec
epoch 2, loss 0.3646, train acc 0.840, test acc 0.828, time 26.1 sec
epoch 3, loss 0.2639, train acc 0.893, test acc 0.859, time 26.6 sec
epoch 4, loss 0.1798, train acc 0.933, test acc 0.868, time 23.6 sec
epoch 5, loss 0.1101, train acc 0.960, test acc 0.869, time 24.9 sec

下面我们使用训练好的模型对两个简单句子的情感进行分类。

In [10]:
gb.predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great'])
Out[10]:
'positive'
In [11]:
gb.predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'bad'])
Out[11]:
'negative'

小结

  • 我们可以使用一维卷积来处理和分析时序数据。
  • 多输入通道的一维互相关运算可以看作是单输入通道的二维互相关运算。
  • 时序最大池化层的输入在各个通道上的时间步数可以不同。
  • TextCNN 主要使用了一维卷积层和时序最大池化层。

练习

  • 动手调参,从准确率和运行效率比较情感分析的两类方法:使用循环神经网络和使用卷积神经网络。
  • 使用上一节练习中介绍的三种方法:调节超参数、使用更大的预训练词向量和使用 spaCy 分词工具,你能使模型在测试集上的准确率进一步提高吗?
  • 你还能将 textCNN 应用于自然语言处理的哪些任务中?

扫码直达讨论区

参考文献

[1] Kim, Y. (2014). Convolutional neural networks for sentence classification. arXiv preprint arXiv:1408.5882.