情感分析是一种流行的文本分析技术,用来对文本中的主观信息进行自动识别和分类。它被广泛用于量化观点、情感等通常以非结构化方式记录的信息,而这些信息也因此很难用其他方式量化。情感分析技术可被用于多种文本资源,例如调查报告、评论、社交媒体上的帖子等。
情感分析最基本的任务之一是极性分类,换句话说,该任务需要判断语言所表达的观点是正面的、负面的还是中性的。具体而言,可能有三个以上的类别,例如:极其正面、正面、中性、消极、极其消极。这有些类似于你使用某些网站时的评价行为(比如 Amazon),人们可以用星星数表示 5 个等级来对物品进行评论(产品、电影或其他任何东西)。
斯坦福的情感分析树库(TreeBank)
目前,研究人员发布了一些公开的情感分类数据集。在本文中,我们将使用斯坦福的情感分析树库(或称 SST),这可能是最广为使用的情感分析数据集之一。SST 与其它数据集最大的不同之处是,在 SST 中情感标签不仅被分配到句子上,句子中的每个短语和单词也会带有情感标签。这使我们能够研究单词和短语之间复杂的语义交互。例如,对下面这个句子的极性进行分析:
This movie was actually neither that funny, nor super witty.
这个句子肯定是消极的。但如果只看单个单词(「funny」、「witty」)可能会被误导,认为它的情感是积极的。只关注单个单词的朴素词袋分类器很难对上面的例句进行正确的分类。要想正确地对上述例句的极性进行分类,你需要理解否定词(neither ... nor ...)对语义的影响。由于 SST 具备这样的特性,它被用作获取句子句法结构的神经网络模型的标准对比基准(https://nlp.stanford.edu/~socherr/EMNLP2013_RNTN.pdf)。
Pytorch 和 AllenNLP
PyTorch 是我最喜欢的深度学习框架。它提供了灵活、易于编写的模块,可动态运行,且速度相当快。在过去一年中,PyTorch 在科研社区中的使用实现了爆炸性增长。
尽管 PyTorch 是一个非常强大的框架,但是自然语言处理往往涉及底层的公式化的事务处理,包括但不限于:阅读和编写数据集、分词、建立单词索引、词汇管理、mini-batch 批处理、排序和填充等。尽管在 NLP 任务中正确地使用这些构建块是至关重要的,但是当你快速迭代时,你需要一次又一次地编写类似的设计模式,这会浪费很多时间。而这正是 AllenNLP 这类库的亮点所在。
AllenNLP 是艾伦人工智能研究院开发的开源 NLP 平台。它的设计初衷是为 NLP 研究和开发(尤其是语义和语言理解任务)的快速迭代提供支持。它提供了灵活的 API、对 NLP 很实用的抽象,以及模块化的实验框架,从而加速 NLP 的研究进展。
本文将向大家介绍如何使用 AllenNLP 一步一步构建自己的情感分类器。由于 AllenNLP 会在后台处理好底层事务,提供训练框架,所以整个脚本只有不到 100 行 Python 代码,你可以很容易地使用其它神经网络架构进行实验。
代码地址:https://github.com/mhagiwara/realworldnlp/blob/master/examples/sentiment/sst_classifier.py
接下来,下载 SST 数据集,你需要将数据集分割成 PTB 树格式的训练集、开发集和测试集,你可以通过下面的链接直接下载:https://nlp.stanford.edu/sentiment/trainDevTestTrees_PTB.zip。我们假设这些文件是在 data/stanfordSentimentTreebank/trees 下进行扩展的。
注意,在下文的代码片段中,我们假设你已经导入了合适的模块、类和方法(详情参见完整脚本)。你会注意到这个脚本和 AllenNLP 的词性标注教程非常相似——在 AllenNLP 中很容易在只进行少量修改的情况下使用不同的模型对不同的任务进行实验。
数据集读取和预处理
AllenNLP 已经提供了一个名为 StanfordSentimentTreeBankDatasetReader 的便捷数据集读取器,它是一个读取 SST 数据集的接口。你可以通过将数据集文件的路径指定为为 read() 方法的参数来读取数据集:
reader = StanfordSentimentTreeBankDatasetReader() train_dataset = reader.read('data/stanfordSentimentTreebank/trees/train.txt') dev_dataset = reader.read('data/stanfordSentimentTreebank/trees/dev.txt')
几乎任何基于深度学习的 NLP 模型的第一步都是指定如何将文本数据转换为张量。该工作包括把单词和标签(在本例中指的是「积极」和「消极」这样的极性标签)转换为整型 ID。在 AllenNLP 中,该工作是由 Vocabulary 类来处理的,它存储从单词/标签到 ID 的映射。
# You can optionally specify the minimum count of tokens/labels. # `min_count={'tokens':3}` here means that any tokens that appear less than three times # will be ignored and not included in the vocabulary. vocab = Vocabulary.from_instances(train_dataset + dev_dataset, min_count={'tokens': 3})
下一步是将单词转换为嵌入。在深度学习中,嵌入是离散、高维数据的连续向量表征。你可以使用 Embedding 创建这样的映射,使用 BasicTextFieldEmbedder 将 ID 转换为嵌入向量。
token_embedding = Embedding(num_embeddings=vocab.get_vocab_size('tokens'), embedding_dim=EMBEDDING_DIM) # BasicTextFieldEmbedder takes a dict - we need an embedding just for tokens, # not for labels, which are used unchanged as the answer of the sentence classification word_embeddings = BasicTextFieldEmbedder({"tokens": token_embedding})
句子分类模型
LSTM-RNN 句子分类模型
现在,我们来定义一个句子分类模型。这段代码看起来很多,但是别担心,我在代码片段中添加了大量注释:
# Model in AllenNLP represents a model that is trained. class LstmClassifier(Model): def __init__(self, word_embeddings: TextFieldEmbedder, encoder: Seq2VecEncoder, vocab: Vocabulary) -> None: super().__init__(vocab) # We need the embeddings to convert word IDs to their vector representations self.word_embeddings = word_embeddings # Seq2VecEncoder is a neural network abstraction that takes a sequence of something # (usually a sequence of embedded word vectors), processes it, and returns it as a single # vector. Oftentimes, this is an RNN-based architecture (e.g., LSTM or GRU), but # AllenNLP also supports CNNs and other simple architectures (for example, # just averaging over the input vectors). self.encoder = encoder # After converting a sequence of vectors to a single vector, we feed it into # a fully-connected linear layer to reduce the dimension to the total number of labels. self.hidden2tag = torch.nn.Linear(in_features=encoder.get_output_dim(), out_features=vocab.get_vocab_size('labels')) self.accuracy = CategoricalAccuracy() # We use the cross-entropy loss because this is a classification task. # Note that PyTorch's CrossEntropyLoss combines softmax and log likelihood loss, # which makes it unnecessary to add a separate softmax layer. self.loss_function = torch.nn.CrossEntropyLoss() # Instances are fed to forward after batching. # Fields are passed through arguments with the same name. def forward(self, tokens: Dict[str, torch.Tensor], label: torch.Tensor = None) -> torch.Tensor: # In deep NLP, when sequences of tensors in different lengths are batched together, # shorter sequences get padded with zeros to make them of equal length. # Masking is the process to ignore extra zeros added by padding mask = get_text_field_mask(tokens) # Forward pass embeddings = self.word_embeddings(tokens) encoder_out = self.encoder(embeddings, mask) logits = self.hidden2tag(encoder_out) # In AllenNLP, the output of forward() is a dictionary. # Your output dictionary must contain a "loss" key for your model to be trained. output = {"logits": logits} if label is not None: self.accuracy(logits, label) output["loss"] = self.loss_function(logits, label) return output
这里的关键是 Seq2VecEncoder,它基本上使用张量序列作为输入,然后返回一个向量。我们在这里使用 LSTM-RNN 作为编码器(如有需要,可参阅文档 https://allenai.github.io/allennlp-docs/api/allennlp.modules.seq2vec_encoders.html#allennlp.modules.seq2vec_encoders.pytorch_seq2vec_wrapper.PytorchSeq2VecWrapper)。
lstm = PytorchSeq2VecWrapper( torch.nn.LSTM(EMBEDDING_DIM, HIDDEN_DIM, batch_first=True)) model = LstmClassifier(word_embeddings, lstm, vocab)
训练
一旦你定义了这个模型,其余的训练过程就很容易了。这就是像 AllenNLP 这样的高级框架的亮点所在。你只需要指定如何进行数据迭代并将必要的参数传递给训练器,而无需像 PyTorch 和 TensorFlow 那样编写冗长的批处理和训练循环。
optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-5) iterator = BucketIterator(batch_size=32, sorting_keys=[("tokens", "num_tokens")]) iterator.index_with(vocab) trainer = Trainer(model=model, optimizer=optimizer, iterator=iterator, train_dataset=train_dataset, validation_dataset=dev_dataset, patience=10, num_epochs=20) trainer.train()
这里的 BucketIterator 会根据 token 的数量对训练实例进行排序,从而使得长度类似的实例在同一个批中。注意,我们使用了验证集,在测试误差过大时采用了早停法避免过拟合。
如果将上面的代码运行 20 个 epoch,则模型在训练集上的准确率约为 0.78,在验证集上的准确率约为 0.35。这听起来很低,但是请注意,这是一个 5 类的分类问题,随机基线的准确率只有 0.20。
测试
为了测试刚刚训练的模型是否如预期,你需要构建一个预测器(predictor)。predictor 是一个提供基于 JSON 的接口的类,它被用于将输入数据传递给你的模型或将输出数据从模型中导出。接着,我便写了一个句子分类预测器(https://github.com/mhagiwara/realworldnlp/blob/master/realworldnlp/predictors.py#L10),将其用作句子分类模型的基于 JSON 的接口。
tokens = ['This', 'is', 'the', 'best', 'movie', 'ever', '!'] predictor = SentenceClassifierPredictor(model, dataset_reader=reader) logits = predictor.predict(tokens)['logits'] label_id = np.argmax(logits) print(model.vocab.get_token_from_index(label_id, 'labels'))
运行这段代码后,你应该看到分类结果为「4」。「4」对应的是「非常积极」。所以你刚刚训练的模型正确地预测出了这是一个非常正面的电影评论。