文本情感分类:使用循环神经网络

文本分类是自然语言处理的一个常见任务,它把一段不定长的文本序列变换为文本的类别。本节关注它的一个子问题:使用文本情感分类来分析文本作者的情绪。这个问题也叫情感分析,并有着广泛的应用。例如,我们可以分析用户对产品的评论并统计用户的满意度,或者分析用户对市场行情的情绪并用以预测接下来的行情。

同搜索近义词和类比词一样,文本分类也属于词嵌入的下游应用。本节中,我们将应用预训练的词向量和含多个隐藏层的双向循环神经网络。我们将用它们来判断一段不定长的文本序列中包含的是正面还是负面的情绪。在实验开始前,导入所需的包或模块。

In [1]:
import collections
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, rnn, utils as gutils
import os
import random
import tarfile

文本情感分类数据

我们使用 Stanford’s Large Movie Review Dataset 作为文本情感分类的数据集 [1]。这个数据集分为训练和测试用的两个数据集,分别包含 25,000 条从 IMDb 下载的关于电影的评论。在每个数据集中,标签为”正面”和“负面”的评论数量相等。

读取数据

我们首先下载这个数据集到“../data”路径下,然后解压至“../data/aclImdb”下。

In [2]:
# 本函数已保存在 gluonbook 包中方便以后使用。
def download_imdb(data_dir='../data'):
    url = ('http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz')
    sha1 = '01ada507287d82875905620988597833ad4e0903'
    fname = gutils.download(url, data_dir, sha1_hash=sha1)
    with tarfile.open(fname, 'r') as f:
        f.extractall(data_dir)

download_imdb()

下面,读取训练和测试数据集。每个样本是一条评论和其对应的标签:1 表示“正面”,0 表示“负面”。

In [3]:
def read_imdb(folder='train'):  # 本函数已保存在 gluonbook 包中方便以后使用。
    data = []
    for label in ['pos', 'neg']:
        folder_name = os.path.join('../data/aclImdb/', folder, label)
        for file in os.listdir(folder_name):
            with open(os.path.join(folder_name, file), 'rb') as f:
                review = f.read().decode('utf-8').replace('\n', '').lower()
                data.append([review, 1 if label == 'pos' else 0])
    random.shuffle(data)
    return data

train_data, test_data = read_imdb('train'), read_imdb('test')

预处理数据

我们需要对每条评论做分词,从而得到分好词的评论。这里定义的get_tokenized_imdb函数使用最简单的方法:基于空格进行分词。

In [4]:
def get_tokenized_imdb(data):  # 本函数已保存在 gluonbook 包中方便以后使用。
    def tokenizer(text):
        return [tok.lower() for tok in text.split(' ')]
    return [tokenizer(review) for review, _ in data]

现在,我们可以根据分好词的训练数据集来创建词典了。我们在这里过滤掉了出现次数少于 5 的词。

In [5]:
def get_vocab_imdb(data):  # 本函数已保存在 gluonbook 包中方便以后使用。
    tokenized_data = get_tokenized_imdb(data)
    counter = collections.Counter([tk for st in tokenized_data for tk in st])
    return text.vocab.Vocabulary(counter, min_freq=5)

vocab = get_vocab_imdb(train_data)
'# words in vocab:', len(vocab)
Out[5]:
('# words in vocab:', 46151)

因为每条评论长度不一致使得不能直接组合成小批量,我们定义preprocess_imdb函数对每条评论进行分词,并通过词典转换成词索引,然后通过截断或者补 0 来将每条评论长度固定成 500。

In [6]:
def preprocess_imdb(data, vocab):  # 本函数已保存在 gluonbook 包中方便以后使用。
    max_l = 500  # 将每条评论通过截断或者补 0,使得长度变成 500。

    def pad(x):
        return x[:max_l] if len(x) > max_l else x + [0] * (max_l - len(x))

    tokenized_data = get_tokenized_imdb(data)
    features = nd.array([pad(vocab.to_indices(x)) for x in tokenized_data])
    labels = nd.array([score for _, score in data])
    return features, labels

创建数据迭代器

现在,我们创建数据迭代器。每次迭代将返回一个小批量的数据。

In [7]:
batch_size = 64
train_set = gdata.ArrayDataset(*preprocess_imdb(train_data, vocab))
test_set = gdata.ArrayDataset(*preprocess_imdb(test_data, vocab))
train_iter = gdata.DataLoader(train_set, batch_size, shuffle=True)
test_iter = gdata.DataLoader(test_set, batch_size)

打印第一个小批量数据的形状,以及训练集中小批量的个数。

In [8]:
for X, y in train_iter:
    print('X', X.shape, 'y', y.shape)
    break
'#batches:', len(train_iter)
X (64, 500) y (64,)
Out[8]:
('#batches:', 391)

使用循环神经网络的模型

在这个模型中,每个词先通过嵌入层得到特征向量。然后,我们使用双向循环神经网络对特征序列进一步编码得到序列信息。最后,我们将编码的序列信息通过全连接层变换为输出。具体来说,我们可以将双向长短期记忆在最初时间步和最终时间步的隐藏状态连结,作为特征序列的编码信息传递给输出层分类。在下面实现的BiRNN类中,Embedding实例即嵌入层,LSTM实例即为序列编码的隐藏层,Dense实例即生成分类结果的输出层。

In [9]:
class BiRNN(nn.Block):
    def __init__(self, vocab, embed_size, num_hiddens, num_layers, **kwargs):
        super(BiRNN, self).__init__(**kwargs)
        self.embedding = nn.Embedding(len(vocab), embed_size)
        # bidirectional 设 True 即得到双向循环神经网络。
        self.encoder = rnn.LSTM(num_hiddens, num_layers=num_layers,
                                bidirectional=True, input_size=embed_size)
        self.decoder = nn.Dense(2)

    def forward(self, inputs):
        # inputs 的形状是(批量大小,词数),因为 LSTM 需要将序列作为第一维,所以将输入转
        # 置后再提取词特征,输出形状为(词数,批量大小,词向量维度)。
        embeddings = self.embedding(inputs.T)
        # states 形状是(词数,批量大小,2 * 隐藏单元个数)。
        states = self.encoder(embeddings)
        # 连结初始时间步和最终时间步的隐藏状态作为全连接层输入。它的形状为(批量大小,
        # 4 * 隐藏单元个数)。
        encoding = nd.concat(states[0], states[-1])
        outputs = self.decoder(encoding)
        return outputs

创建一个含两个隐藏层的双向循环神经网络。

In [10]:
embed_size, num_hiddens, num_layers, ctx = 100, 100, 2, gb.try_all_gpus()
net = BiRNN(vocab, embed_size, num_hiddens, num_layers)
net.initialize(init.Xavier(), ctx=ctx)

加载预训练的词向量

由于情感分类的训练数据集并不是很大,为应对过拟合,我们将直接使用在更大规模语料上预训练的词向量作为每个词的特征向量。这里,我们为词典vocab中的每个词加载 100 维的 GloVe 词向量。

In [11]:
glove_embedding = text.embedding.create(
    'glove', pretrained_file_name='glove.6B.100d.txt', vocabulary=vocab)

然后我们将用这些词向量作为评论中每个词的特征向量。注意预训练词向量的维度需要跟创建的模型中的嵌入层输出大小embed_size一致。此外,在训练中我们不再更新这些词向量。

In [12]:
net.embedding.weight.set_data(glove_embedding.idx_to_vec)
net.embedding.collect_params().setattr('grad_req', 'null')

训练并评价模型

这时候我们可以开始训练了。

In [13]:
lr, num_epochs = 0.01, 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.5699, train acc 0.690, test acc 0.821, time 139.7 sec
epoch 2, loss 0.3955, train acc 0.831, test acc 0.843, time 140.7 sec
epoch 3, loss 0.3493, train acc 0.852, test acc 0.852, time 141.3 sec
epoch 4, loss 0.3044, train acc 0.873, test acc 0.854, time 140.8 sec
epoch 5, loss 0.2765, train acc 0.887, test acc 0.853, time 141.8 sec

最后,定义预测函数。

In [14]:
# 本函数已保存在 gluonbook 包中方便以后使用。
def predict_sentiment(net, vocab, sentence):
    sentence = nd.array(vocab.to_indices(sentence), ctx=gb.try_gpu())
    label = nd.argmax(net(sentence.reshape((1, -1))), axis=1)
    return 'positive' if label.asscalar() == 1 else 'negative'

然后使用训练好的模型对两个简单句子的情感进行分类。

In [15]:
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great'])
Out[15]:
'positive'
In [16]:
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'bad'])
Out[16]:
'negative'

小结

  • 文本分类把一段不定长的文本序列变换为文本的类别。它属于词嵌入的下游应用。
  • 我们可以应用预训练的词向量和循环神经网络对文本的情感进行分类。

练习

  • 增加迭代周期。你的模型能在训练和测试数据集上得到怎样的准确率?再调节其他超参数试试?
  • 使用更大的预训练词向量,例如 300 维的 GloVe 词向量,能否提升分类准确率?
  • 使用 spaCy 分词工具,能否提升分类准确率?你需要安装 spaCy:pip install spacy,并且安装英文包:python -m spacy download en。在代码中,先导入 spacy:import spacy。然后加载 spacy 英文包:spacy_en = spacy.load('en')。最后定义函数def tokenizer(text): return [tok.text for tok in spacy_en.tokenizer(text)]并替换原来的基于空格分词的tokenizer函数。需要注意的是,GloVe 的词向量对于名词词组的存储方式是用“-”连接各个单词,例如词组“new york”在 GloVe 中的表示为“new-york”。而使用 spaCy 分词之后“new york”的存储可能是“new york”。

扫码直达讨论区

参考文献

[1] Maas, A. L., Daly, R. E., Pham, P. T., Huang, D., Ng, A. Y., & Potts, C. (2011, June). Learning word vectors for sentiment analysis. In Proceedings of the 49th annual meeting of the association for computational linguistics: Human language technologies-volume 1 (pp. 142-150). Association for Computational Linguistics.