使用深度学习做文本情感分类

摘要: 本次作业是 Kaggle 上的一个影评文本情感分析的赛题:Sentiment Analysis on Movie Reviews,属于 5 分类的文本分类任务。实验中使用循环神经网络训练了 20 个 epoch,在训练集上取得了 81% 的准确率,在验证集上取得了 67% 的准确率。提交结果的账号为 wukan0621,最终在测试集的成绩为 64.54% ,提交时在 public 榜单上排名 145/830。cmp排名

导言

问题背景

文本情感分析是指对文本所表达的情感作情感极性的分类,本题将文本分类为消极、有点消极、中性、有点积极、积极 5 个类别。提供的数据可以在这个页面下载。截取训练集 train.tsv 和测试集 test.tsv 的前七行如下:

PhraseId	SentenceId	Phrase	Sentiment
1	1	A series of escapades demonstrating the adage that what is good for the goose is also good for the gander , some of which occasionally amuses but none of which amounts to much of a story .	1
2	1	A series of escapades demonstrating the adage that what is good for the goose	2
3	1	A series	2
4	1	A	2
5	1	series	2
6	1	of escapades demonstrating the adage that what is good for the goose	2
PhraseId	SentenceId	Phrase
156061	8545	An intermittently pleasing but mostly routine effort .
156062	8545	An intermittently pleasing but mostly routine effort
156063	8545	An
156064	8545	intermittently pleasing but mostly routine effort
156065	8545	intermittently pleasing but mostly routine
156066	8545	intermittently pleasing but

可以看到,每条数据包含四个数据:数据的序号、所属句子的序号、已经分过词的句子片段、对应的情感分类(0~4 分别对应消极~积极)。此外在实验中我发现 PhraseId 为 2006、157451 的两条数据均为空串。为不影响后续训练过程,我手动将其设置成了 <unk> 。由于数据量很大,区区两条数据改动的影响是可以忽略不计的。

解决思路

使用预训练好的词向量 glove.6B

当前,学习词向量表示的方法主要有两种类型:一种是基于全局矩阵分解的方法,另一种是局部上下文窗口的方法。但是这两种方法都有各自的缺陷,其中,前者虽然有效利用了统计信息,但是在词汇类比方面却很差;而后者虽然可以很好地进行词汇类比,但是因为这两种方法是基于一个个局部的上下文窗口方法,因此,没有有效地利用全局的词汇共现统计信息。

为了克服全局矩阵分解和局部上下文窗口的缺陷,在 2014 年,Jeffrey Pennington 等人提出了一种新的 GloVe 方法,该方法基于全局词汇共现的统计信息来学习词向量,从而将统计信息与局部上下文窗口方法的优点都结合起来,并发现其效果确实得到了提升。

curl -O https://apache-mxnet.s3.cn-north-1.amazonaws.com.cn/gluon/embeddings/glove/glove.6B.zip

由于众所周知的原因,下载外网上的资源非常慢,这里我找到了这个资源在国内的镜像。最后下下来一共有 822MB 大小,还是节省了很多时间的。

循环神经网络(Recurrent Neural Network, RNN)

RNN 是一类以序列(sequence)数据为输入,在序列的演进方向进行递归(recursion)且所有节点(循环单元)按链式连接的递归神经网络(recursive neural network)。

rnn

上图描述了一个简单的 RNN 网络,它的工作是不停的接收 $x_t$ 并且输出 $h_t$。这种链式的结构揭示了 RNNs 与序列和列表类型的数据密切相关,好像他们生来就是为了处理序列类型数据的。RNNs 能够学习利用以前的信息来对当前任务进行相应的操作。如上图所示通过输入的 $x_{1}$、$x_{2}$ 信息来预测出 $h_{3}$。

但是,当有用信息与需要进行处理信息的地方之间的距离较远,这样容易导致 RNN 不能学习到有用的信息,最终导致梯度消失和梯度爆炸。理论上 RNNs 是能够通过调参处理这种「长依赖」问题的,但是在实践过程中 RNN 的表现还是很差。

长短期记忆网络(LSTM,Long Short-Term Memory)

LSTM 是一种时间循环神经网络,是为了解决一般的 RNN(循环神经网络)存在的长依赖问题而专门设计出来的,所有的 RNN 都具有一种重复神经网络模块的链式形式。在标准 RNN 中,这个重复的结构模块只有一个非常简单的结构,例如一个 tanh 层。

lstm

LSTM 也具有这种链式结构,但是它的重复单元不同于标准 RNN 网络里的单元只有一个网络层,它的内部有四个网络层。LSTMs 的结构如上图所示,LSTM 网络能通过一种被称为门的结构对状态进行删除或者添加信息,门能够有选择性的决定让哪些信息通过。其实门的结构很简单,就是一个 sigmoid 层和一个点乘操作的组合。因为 sigmoid 层的输出是 0-1 的值,这代表有多少信息能够流过 sigmoid 层。0 表示都不能通过,1 表示都能通过。

不像普通的 RNN 那样仅有一种记忆叠加方式,LSTM 通过门控状态来控制传输状态,记住需要长时间记忆的,忘记不重要的信息,对很多需要「长期记忆」的任务来说尤其好用。

实验过程

实验环境

我使用的机器上没有 GPU,好在我设计的模型相对比较简单,在 CPU 上也能很快训练完成。

  • Intel(R) Core(TM) i7-6567U CPU @3.30GHZ 3.31GHz
  • 8.00GB RAM
  • Windows 10 2004 19041.264, 64-bit
    • Visual Studio Code 1.45.1
      • Python 2020.5.80290:去年九月底发布的 VSCode Python 插件支持在编辑器窗口内原生运行 juyter nootbook 了,非常赞!
      • Remote - WSL 0.44.2:配合 WSL,在 Windows 上获得 Linux 接近原生环境的体验。
    • Windows Subsystem for Linux [Ubuntu 20.04 LTS]:WSL 是以软件的形式运行在 Windows 下的 Linux 子系统,是近些年微软推出来的新工具,可以在 Windows 系统上原生运行 Linux。
      • Python 3.8.2 64-bit:安装在 WSL 中。
        • jupyter==1.0.0
        • numpy==1.18.4
        • matplotlib==3.2.1
        • pandas==1.0.4
        • tqdm==4.46.1
        • torch==1.5.0+cpu
        • torchtext==0.6.0

这里使用 PyTorch 作为编程框架,torchtext 包用于处理 NLP 问题的一些扩展,pandas 包用于数据清洗工作,tqdm 用于训练时的可视化,matplotlib 用于最后结果的可视化。

import torch
from torch import nn
from torch.nn import functional
from torch import optim
from torchtext import data
from torchtext.vocab import Vectors
from tqdm import tqdm
import pandas

数据处理、构建词汇表

def get_dataset(sample_data, id_field, text_field, label_field, test=False):  # 构造并返回Dataset
    fields = [('PhraseId', id_field), ('Phrase', text_field),
              ('Sentiment', label_field)]
    examples = []
    if test:
        for pid, text in tqdm(zip(sample_data['PhraseId'], sample_data['Phrase'])):
            examples.append(data.Example.fromlist([pid, text, None], fields))
    else:
        for pid, text, label in tqdm(zip(sample_data['PhraseId'], sample_data['Phrase'], sample_data['Sentiment'])):
            examples.append(data.Example.fromlist([pid, text, label], fields))
    return data.Dataset(examples, fields)


test_data = pandas.read_csv('datasets/test.tsv', sep='\t',
                            usecols=['PhraseId', 'Phrase'])  # 157451 行有缺失值,手动处理为unk
raw_train_data = pandas.read_csv('datasets/train.tsv', sep='\t', usecols=[
                                 'PhraseId', 'Phrase', 'Sentiment'])  # 2006 行有缺失值,手动处理为unk

# 划分数据
train_data = raw_train_data.sample(frac=0.8, random_state=0, axis=0)
val_data = raw_train_data[~raw_train_data.index.isin(train_data.index)]

# 得到数据集
PID = data.Field(sequential=False, use_vocab=False)
TEXT = data.Field(sequential=True, tokenize=lambda x: x.split(),
                  lower=True, include_lengths=True)
LABEL = data.Field(sequential=False, use_vocab=False)

train = get_dataset(train_data, PID, TEXT, LABEL)
val = get_dataset(val_data, PID, TEXT, LABEL)
test = get_dataset(test_data, PID, TEXT, None, test=True)

# 构建词汇表
TEXT.build_vocab(train, vectors=Vectors(
    name='.vector_cache/glove.6B.50d.txt', cache='.vector_cache'))

设计模型

如下,这里设计的模型非常简单:

  • 使用预训练的词向量
  • 一个单向连接的 LSTM
  • 使用 ReLU 函数激活
  • 后接一个全连接层,使用简单的线性回归函数
class LSTM(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers):
        super(LSTM, self).__init__()
        self.embedding = nn.Embedding(len(TEXT.vocab), embedding_dim)
        self.embedding.weight.data.copy_(TEXT.vocab.vectors)
        self.lstm = nn.LSTM(input_size=embedding_dim, hidden_size=hidden_dim,
                            num_layers=num_layers, bidirectional=False)
        self.relu = nn.ReLU()
        self.fc = nn.Linear(hidden_dim * num_layers * 1, 5)

    def forward(self, x):
        embedding = self.relu(self.embedding(x[0]))
        packed_embedding = nn.utils.rnn.pack_padded_sequence(embedding, x[1])
        output, (hidden, cell) = self.lstm(packed_embedding)
        hidden = hidden.view(hidden.size()[1], -1)
        out = self.fc(self.relu(hidden))
        return out

训练模型

def binary_acc(preds, y):
    preds = torch.argmax(preds, dim=1)
    correct = torch.eq(preds, y).float()
    return correct.sum()


lstm = LSTM(len(TEXT.vocab), 50, 64, 1)
optimizer = optim.Adam(lstm.parameters())  # 优化器
loss_fn = nn.CrossEntropyLoss()  # 使用交叉熵作为损失函数

train_iter, val_iter = data.BucketIterator.splits(
    (train, val),
    batch_sizes=(64, 64),
    device=-1,  # 使用CPU
    sort_key=lambda x: len(x.Phrase),
    sort_within_batch=True,
    repeat=False)
epochs = 20
loss_acc = []

for epoch in range(epochs):
    train_acc = 0
    train_loss = 0
    for batch in train_iter:
        preds = lstm(batch.Phrase)
        loss = loss_fn(preds, batch.Sentiment)
        train_acc += binary_acc(preds, batch.Sentiment).item()
        train_loss += loss.item()
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    val_acc = 0
    val_loss = 0
    for batch in val_iter:
        preds = lstm(batch.Phrase)
        loss = loss_fn(preds, batch.Sentiment)
        val_acc += binary_acc(preds, batch.Sentiment).item()
        val_loss += loss.item()

    loss_acc.append([train_loss / len(train_iter.dataset), train_acc / len(train_iter.dataset),
                     val_loss / len(val_iter.dataset), val_acc / len(val_iter.dataset)])
    print('Epoch: {}/{} - \tLoss: {:.6f} - \tAcc: {:.6f} - \tVal_Loss: {:.6f} - \tVal_Acc: {:.6f}'.format(
        epoch, epochs, loss_acc[-1][0], loss_acc[-1][1], loss_acc[-1][2], loss_acc[-1][3]))
    # 保存checkpoint
    torch.save(lstm, 'models/epoch{}_checkpoint.pkl'.format(epoch))

训练时的日志如下。

Epoch: 0/20 - 	Loss: 0.016249 - 	Acc: 0.580049 - 	Val_Loss: 0.014228 - 	Val_Acc: 0.624439
Epoch: 1/20 - 	Loss: 0.013150 - 	Acc: 0.651961 - 	Val_Loss: 0.013416 - 	Val_Acc: 0.639850
Epoch: 2/20 - 	Loss: 0.012060 - 	Acc: 0.679170 - 	Val_Loss: 0.012617 - 	Val_Acc: 0.666346
Epoch: 3/20 - 	Loss: 0.011427 - 	Acc: 0.695486 - 	Val_Loss: 0.012568 - 	Val_Acc: 0.670479
Epoch: 4/20 - 	Loss: 0.010960 - 	Acc: 0.707396 - 	Val_Loss: 0.012679 - 	Val_Acc: 0.666506
Epoch: 5/20 - 	Loss: 0.010562 - 	Acc: 0.717913 - 	Val_Loss: 0.012483 - 	Val_Acc: 0.676855
Epoch: 6/20 - 	Loss: 0.010220 - 	Acc: 0.727004 - 	Val_Loss: 0.012710 - 	Val_Acc: 0.676246
Epoch: 7/20 - 	Loss: 0.009930 - 	Acc: 0.734341 - 	Val_Loss: 0.012727 - 	Val_Acc: 0.677336
Epoch: 8/20 - 	Loss: 0.009620 - 	Acc: 0.742319 - 	Val_Loss: 0.012927 - 	Val_Acc: 0.672049
Epoch: 9/20 - 	Loss: 0.009334 - 	Acc: 0.750857 - 	Val_Loss: 0.013012 - 	Val_Acc: 0.674100
Epoch: 10/20 - 	Loss: 0.009086 - 	Acc: 0.757393 - 	Val_Loss: 0.013406 - 	Val_Acc: 0.672594
Epoch: 11/20 - 	Loss: 0.008817 - 	Acc: 0.764394 - 	Val_Loss: 0.013508 - 	Val_Acc: 0.670704
Epoch: 12/20 - 	Loss: 0.008564 - 	Acc: 0.771795 - 	Val_Loss: 0.013677 - 	Val_Acc: 0.669614
Epoch: 13/20 - 	Loss: 0.008300 - 	Acc: 0.779091 - 	Val_Loss: 0.014073 - 	Val_Acc: 0.671312
Epoch: 14/20 - 	Loss: 0.008036 - 	Acc: 0.786292 - 	Val_Loss: 0.014622 - 	Val_Acc: 0.661861
Epoch: 15/20 - 	Loss: 0.007795 - 	Acc: 0.792612 - 	Val_Loss: 0.014941 - 	Val_Acc: 0.665481
Epoch: 16/20 - 	Loss: 0.007557 - 	Acc: 0.799997 - 	Val_Loss: 0.015274 - 	Val_Acc: 0.659778
Epoch: 17/20 - 	Loss: 0.007313 - 	Acc: 0.805892 - 	Val_Loss: 0.015825 - 	Val_Acc: 0.659266
Epoch: 18/20 - 	Loss: 0.007069 - 	Acc: 0.813125 - 	Val_Loss: 0.016284 - 	Val_Acc: 0.660291
Epoch: 19/20 - 	Loss: 0.006830 - 	Acc: 0.818796 - 	Val_Loss: 0.016626 - 	Val_Acc: 0.652249

预测结果

这里选择第五个 epoch 的结果用于预测结果,原因详见结果分析部分。

test_iter = data.Iterator(test, batch_size=8, device=-1,
                          sort_key=lambda x: len(x.Phrase), sort_within_batch=True, repeat=False)
# 加载已保存模型
lstm = torch.load('models/epoch5_checkpoint.pkl')
# 创建csv文件
dataframe = pandas.DataFrame(columns=['PhraseId', 'Sentiment'])
dataframe.to_csv('datasets/sampleSubmission.csv', index=False)
# 预测
for batch in test_iter:
    preds = lstm(batch.Phrase)
    preds = torch.argmax(preds, dim=1)
    res = list(zip(batch.PhraseId.detach().numpy(), preds.detach().numpy()))
    df = pandas.DataFrame(res)
    df.to_csv('datasets/sampleSubmission.csv',
              mode='a+', header=False, index=False)
# 根据PhraseId对数据进行重排
df = pandas.read_csv('datasets/sampleSubmission.csv')
df.sort_values(['PhraseId'], ascending=True, inplace=True)
df.to_csv('datasets/sampleSubmission1.csv', index=False)

结果分析

将训练过程做可视化如下,其中蓝线是训练集上的数据,黄线是验证集上的数据。

lossacc

import numpy
from matplotlib import pyplot
locc_acc = numpy.array(loss_acc).T.tolist()
pyplot.plot(loss_acc[0])
pyplot.plot(loss_acc[2])
pyplot.xlabel('epoch')
pyplot.ylabel('loss')
pyplot.xticks([i for i in range(20)])
pyplot.show()
pyplot.plot(loss_acc[1])
pyplot.plot(loss_acc[3])
pyplot.xlabel('epoch')
pyplot.ylabel('acc')
pyplot.xticks([i for i in range(20)])
pyplot.show()

可以看到训练前五个 epoch 是比较正常的,Loss 在训练集和测试集上都能下降、准确率均有提升,但是训练后期验证集上的损失不再下降、准确率不再提升,出现了过拟合的现象。我也尝试了提交其他几个 epoch 的结果,结果并没有 epoch5 的准确率高。

cmp

总结

用传统方法的解决文本情感分类思路简单易懂,而且稳定性也比较强,然而存在着一个难以克服的局限性:传统思路需要事先提取好情感词典,而这一步骤,往往需要人工操作才能保证准确率,换句话说,做这个事情的人,不仅仅要是数据挖掘专家,还需要语言学家,这个背景知识依赖性问题会阻碍着自然语言处理的进步。庆幸的是,深度学习解决了这个问题(至少很大程度上解决了),它允许我在几乎「零背景」的前提下,为这个的实际问题建立模型。

最后,由于我的的机器没有 GPU,对神经网络的训练只能在 CPU 上进行,因此我没能设计更复杂的网络模型了。榜单上最佳成绩足足做到了 76%,可能需要建立更加复杂的模型,从而使模型有更强的泛化能力。

主要参考文献