在深度学习TensorFlow 框架上使用 LSTM 进行情感分析

时间:2022-05-04
本文章向大家介绍在深度学习TensorFlow 框架上使用 LSTM 进行情感分析,主要内容包括深度学习在自然语言处理中的应用、词向量、Word2Vec、循环神经网络(RNN)、长短期记忆网络(LSTM)、情感分析框架、导入数据、辅助函数、RNN 模型、超参数调整、训练、加载一个预训练的模型、结论、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

在这篇教程中,我们将介绍如何将深度学习技术应用到情感分析中。该任务可以被认为是从一个句子,一段话,或者是从一个文档中,将作者的情感分为积极的,消极的或者中性的。

这篇教程由多个主题组成,包括词向量,循环神经网络和 LSTM。文章的最后给出完整的代码可以通过回复公众号"LSTM"获取。

在讨论具体的概念之前,让我们先来谈谈为什么深度学习适合应用在自然语言处理中。

深度学习在自然语言处理中的应用

自然语言处理是教会机器如何去处理或者读懂人类语言的系统,目前比较热门的方向,包括如下几类:

  • 对话系统 - 比较著名的案例有:Siri,Alexa 和 Cortana。
  • 情感分析 - 对一段文本进行情感识别。
  • 图文映射 - 用一句话来描述一张图片。
  • 机器翻译 - 将一种语言翻译成另一种语言。
  • 语音识别 - 让电脑识别口语。

在未进入深度学习时代,NLP也是一个蓬勃发展的领域。然而,在所有的上述任务中,我们都需要根据语言学的知识去做大量的,复杂的特征工程。如果你去学习这个领域,那么整个四年你都会在从事这方面的研究,比如音素,语素等等。在过去的几年中,深度学习的发展取得了惊人的进步,在一定程度上我们可以消除对语言学的依赖性。由于进入的壁垒降低了,NLP 任务的应用也成为了深度学习研究的一个重大的领域之一。

词向量

为了了解深度学习是如何被应用的,我们需要考虑不同形式的数据,这些数据被用来作为机器学习或者深度学习模型的输入数据。卷积神经网络使用像素值作为输入,logistic回归使用一些可以量化的特征值作为输入,强化学习模型使用奖励信号来进行更新。通常的输入数据是需要被标记的标量值。所有当你处理 NLP 任务时,可能会想到利用这样的数据管道。

但是,如果这样设计管道,那么是存在很多问题的。我们不能像点积或者反向传播那样在一个字符串上执行普通的运算操作。所有,我们不需要将字符串作为输入,而是将句子中的每个词转换成向量。

你可以将输入数据看成是一个 16*D 的一个矩阵。

我们希望这些向量以某种方式创建,而这种方式可以表示单词及其上下文意义。例如,我们希望单词 “love” 和 “adore” 这两个词在向量空间中是有一定的相关性的,因为他们有类似的定义,他们都在类似的上下文中使用。单词的向量表示也被称之为词嵌入。

Word2Vec

为了去得到这些词嵌入,我们使用一个很著名的模型 “Word2Vec”。简单的说,这个模型根据上下文的语境来推断出每个词的词向量。如果两个个词在上下文的语境中,可以被互相替换,那么这两个词的距离就非常近。在自然语言中,上下文的语境对分析词语的意义是非常重要的。比如,之前我们提到的 “adore” 和 “love” 这两个词,我们观察如下上下文的语境。

从句子中我们可以看到,这两个词通常在句子中是表现积极的,而且一般比名词或者名词组合要好。这也说明了,这两个词可以被互相替换,他们的意思是非常相近的。对于句子的语法结构分析,上下文语境也是非常重要的。所有,这个模型的作用就是从一大堆句子(以 Wikipedia 为例)中为每个独一无二的单词进行建模,并且输出一个唯一的向量。Word2Vec 模型的输出被称为一个嵌入矩阵。

这个嵌入矩阵包含训练集中每个词的一个向量。传统来讲,这个嵌入矩阵中的词向量数据会超过三百万。

Word2Vec 模型根据数据集中的每个句子进行训练,并且以一个固定窗口在句子上进行滑动,根据句子的上下文来预测固定窗口中间那个词的向量。然后根据一个损失函数和优化方法,来对这个模型进行训练。这个训练的详细过程有点复杂,所有我们这里就先不讨论细节方面的事。但是,对于深度学习模型来说,我们处理自然语言的时候,一般都是把词向量作为模型的输入。

如果你想了解更多有关 Word2Vec 的理论,那么你可以学习这个教程。

https://www.tensorflow.org/tutorials/word2vec

循环神经网络(RNN)

现在,我们已经得到了神经网络的输入数据 —— 词向量,接下来让我们看看需要构建的神经网络。NLP 数据的一个独特之处是它是时间序列数据。每个单词的出现都依赖于它的前一个单词和后一个单词。由于这种依赖的存在,我们使用循环神经网络来处理这种时间序列数据。

循环神经网络的结构和你之前看到的那些前馈神经网络的结构可能有一些不一样。前馈神经网络由三部分组成,输入层,隐藏层和输出层。

前馈神经网络和 RNN 之前的主要区别就是 RNN 考虑了时间的信息。在 RNN 中,句子中的每个单词都被考虑上了时间步骤。实际上,时间步长的数量将等于最大序列长度。

与每个时间步骤相关联的中间状态也被作为一个新的组件,称为隐藏状态向量 h(t) 。从抽象的角度来看,这个向量是用来封装和汇总前面时间步骤中所看到的所有信息。就像 x(t) 表示一个向量,它封装了一个特定单词的所有信息。

隐藏状态是当前单词向量和前一步的隐藏状态向量的函数。并且这两项之和需要通过激活函数来进行激活。

上面的公式中的2个W表示权重矩阵。如果你需要仔细研究这两个矩阵,你会发现其中一个矩阵是和我们的输入 x 进行相乘。另一个是隐藏的装填向量,用来和前一个时间步骤中的隐藏层输出相乘。W(H) 在所有的时间步骤中都是保持一样的,但是矩阵 W(x) 在每个输入中都是不一样的。

这些权重矩阵的大小不但受当前向量的影响,还受前面隐藏层的影响。举个例子,观察上面的式子,h(t) 的大小将会随着 W(x) 和 W(H) 的大小而改变。

让我们来看一个快速例子。当 W(H) 非常大,W(X) 非常小的时候,我们知道 h(t) 受 h(t-1) 的影响比 x(t) 的影响大。换句话说,目前的隐藏状态向量更关心前面句子的一个总和,而不是当前的一个句子。

权重的更新,我们采用 BPTT 算法来进行跟新。

在最后的时刻,隐藏层的状态向量被送入一个 softmax 分类器,进行一个二分类,即判断文本是否是积极情绪或者xiao'ji消极情绪。

长短期记忆网络(LSTM)

长短期记忆网络单元,是另一个 RNN 中的模块。从抽象的角度看,LSTM 保存了文本中长期的依赖信息。正如我们前面所看到的,H 在传统的RNN网络中是非常简单的,这种简单结构不能有效的将历史信息链接在一起。举个例子,在问答领域中,假设我们得到如下一段文本,那么 LSTM 就可以很好的将历史信息进行记录学习。

在这里,我们看到中间的句子对被问的问题没有影响。然而,第一句和第三句之间有很强的联系。对于一个典型的RNN网络,隐藏状态向量对于第二句的存储信息量可能比第一句的信息量会大很多。但是LSTM,基本上就会判断哪些信息是有用的,哪些是没用的,并且把有用的信息在 LSTM 中进行保存。

我们从更加技术的角度来谈谈 LSTM 单元,该单元根据输入数据 x(t) ,隐藏层输出 h(t) 。在这些单元中,h(t) 的表达形式比经典的 RNN 网络会复杂很多。这些复杂组件分为四个部分:输入门,输出门,遗忘门和一个记忆控制器。

每个门都将 x(t) 和 h(t-1) 作为输入(没有在图中显示出来),并且利用这些输入来计算一些中间状态。每个中间状态都会被送入不同的管道,并且这些信息最终会汇集到 h(t) 。为简单起见,我们不会去关心每一个门的具体推导。这些门可以被认为是不同的模块,各有不同的功能。输入门决定在每个输入上施加多少强调,遗忘门决定我们将丢弃什么信息,输出门根据中间状态来决定最终的 h(t) 。为了了解更多有关 LSTM 的信息,你可以查看 Christopher Olah 的博客。

http://colah.github.io/posts/2015-08-Understanding-LSTMs/

我们再来看看第一个问题,“What is the sum of the two numbers?",该模型必须接受类似的问题和答案来进行训练。LSTM 就会认为任何没有数字的句子都是没有意义的,因此遗忘门就会丢弃这些不必要的信息。

情感分析框架

如前所述,情感分析的任务是去分析一个输入单词或者句子的情绪是积极的,消极的还是中性的。我们可以把这个特定的任务(和大多数其他NLP任务)分成 5个不同的组件。

1) Training a word vector generation model (such as Word2Vec) or loading pretrained word vectors 2) Creating an ID's matrix for our training set (We'll discuss this a bit later) 3) RNN (With LSTM units) graph creation 4) Training 5) Testing

导入数据

首先,我们需要去创建词向量。为了简单起见,我们使用训练好的模型来创建。

作为该领域的一个最大玩家,Google 已经帮助我们在大规模数据集上训练出来了 Word2Vec 模型,包括 1000 亿个不同的词!在这个模型中,谷歌能创建 300 万个词向量,每个向量维度为 300。

https://code.google.com/archive/p/word2vec/#Pre-trained_word_and_phrase_vectors

在理想情况下,我们将使用这些向量来构建模型,但是因为这个单词向量矩阵相当大(3.6G),我们将使用一个更加易于管理的矩阵,该矩阵由 GloVe 进行训练得到。矩阵将包含 400000 个词向量,每个向量的维数为 50。

我们将导入两个不同的数据结构,一个是包含 400000 个单词的 Python 列表,一个是包含所有单词向量值得 400000*50 维的嵌入矩阵。

import numpy as np
wordsList = np.load('wordsList.npy')
print('Loaded the word list!')
wordsList = wordsList.tolist() #Originally loaded as numpy arraywordsList = [word.decode('UTF-8') for word in wordsList] #Encode words as UTF-8wordVectors = np.load('wordVectors.npy')
print ('Loaded the word vectors!')

请确保上面的程序能正常运行,我们可以查看词汇表的维度和词向量的维度。

print(len(wordsList))
print(wordVectors.shape)

我们也可以在词库中搜索单词,比如 “baseball”,然后可以通过访问嵌入矩阵来得到相应的向量,如下:

baseballIndex = wordsList.index('baseball')
wordVectors[baseballIndex]

现在我们有了向量,我们的第一步就是输入一个句子,然后构造它的向量表示。假设我们现在的输入句子是 “I thought the movie was incredible and inspiring”。为了得到词向量,我们可以使用 TensorFlow 的嵌入函数。这个函数有两个参数,一个是嵌入矩阵(在我们的情况下是词向量矩阵),另一个是每个词对应的索引。接下来,让我们通过一个具体的例子来说明一下。

import tensorflow as tfmaxSeqLength = 10 #Maximum length of sentence
numDimensions = 300 #Dimensions for each word vector
firstSentence = np.zeros((maxSeqLength), dtype='int32')
firstSentence[0] = wordsList.index("i")
firstSentence[1] = wordsList.index("thought")
firstSentence[2] = wordsList.index("the")
firstSentence[3] = wordsList.index("movie")
firstSentence[4] = wordsList.index("was")
firstSentence[5] = wordsList.index("incredible")
firstSentence[6] = wordsList.index("and")
firstSentence[7] = wordsList.index("inspiring")
#firstSentence[8] and firstSentence[9] are going to be 0print(firstSentence.shape)print(firstSentence) #Shows the row index for each word

数据管道如下图所示:

输出数据是一个 10*50 的词矩阵,其中包括 10 个词,每个词的向量维度是 50。

with tf.Session() as sess:
    print(tf.nn.embedding_lookup(wordVectors,firstSentence).eval().shape)

在整个训练集上面构造索引之前,我们先花一些时间来可视化我们所拥有的数据类型。这将帮助我们去决定如何设置最大序列长度的最佳值。在前面的例子中,我们设置了最大长度为 10,但这个值在很大程度上取决于你输入的数据。

训练集我们使用的是 IMDB 数据集。这个数据集包含 25000 条电影数据,其中 12500 条正向数据,12500 条负向数据。这些数据都是存储在一个文本文件中,首先我们需要做的就是去解析这个文件。正向数据包含在一个文件中,负向数据包含在另一个文件中。下面的代码展示了具体的细节:

from os import listdir
from os.path import isfile, joinpositiveFiles = ['positiveReviews/' + f for f in listdir('positiveReviews/') if isfile(join('positiveReviews/', f))]
negativeFiles = ['negativeReviews/' + f for f in listdir('negativeReviews/') if isfile(join('negativeReviews/', f))]
numWords = []for pf in positiveFiles:
    with open(pf, "r", encoding='utf-8') as f:        line=f.readline()
        counter = len(line.split())
        numWords.append(counter)       
print('Positive files finished')for nf in negativeFiles:
    with open(nf, "r", encoding='utf-8') as f:        line=f.readline()
        counter = len(line.split())
        numWords.append(counter)  print('Negative files finished')

numFiles = len(numWords)print('The total number of files is', numFiles)print('The total number of words in the files is', sum(numWords))print('The average number of words in the files is', sum(numWords)/len(numWords))

我们也可以使用 Matplot 将数据进行可视化。

import matplotlib.pyplot as plt%matplotlib inlineplt.hist(numWords, 50)plt.xlabel('Sequence Length')plt.ylabel('Frequency')plt.axis([0, 1200, 0, 8000])plt.show()

从直方图和句子的平均单词数,我们认为将句子最大长度设置为 250 是可行的。

maxSeqLength = 250

接下来,让我们看看如何将单个文件中的文本转换成索引矩阵,比如下面的代码就是文本中的其中一个评论。

fname = positiveFiles[3] #Can use any valid index (not just 3)with open(fname) as f:    for lines in f:
        print(lines)        exit

接下来,我们将它转换成一个索引矩阵。

# Removes punctuation, parentheses, question marks, etc., and leaves only alphanumeric charactersimport re
strip_special_chars = re.compile("[^A-Za-z0-9 ]+")

def cleanSentences(string):    string = string.lower().replace("<br />", " ")    return re.sub(strip_special_chars, "", string.lower())
firstFile = np.zeros((maxSeqLength), dtype='int32')with open(fname) as f:
    indexCounter = 0
    line=f.readline()
    cleanedLine = cleanSentences(line)    split = cleanedLine.split()    for word in split:        try:
            firstFile[indexCounter] = wordsList.index(word)
        except ValueError:
            firstFile[indexCounter] = 399999 #Vector for unknown words
        indexCounter = indexCounter + 1firstFile

现在,我们用相同的方法来处理全部的 25000 条评论。我们将导入电影训练集,并且得到一个 25000 * 250 的矩阵。这是一个计算成本非常高的过程,因此我在这边提供了一个我处理好的索引矩阵文件。

辅助函数

下面你可以找到几个辅助函数,这些函数在稍后训练神经网络的步骤中会使用到。

RNN 模型

现在,我们可以开始构建我们的 TensorFlow 图模型。首先,我们需要去定义一些超参数,比如批处理大小,LSTM的单元个数,分类类别和训练次数。

batchSize = 24lstmUnits = 64numClasses = 2iterations = 100000

与大多数 TensorFlow 图一样,现在我们需要指定两个占位符,一个用于数据输入,另一个用于标签数据。对于占位符,最重要的一点就是确定好维度。

标签占位符代表一组值,每一个值都为 [1,0] 或者 [0,1],这个取决于数据是正向的还是负向的。输入占位符,是一个整数化的索引数组。

import tensorflow as tftf.reset_default_graph()

labels = tf.placeholder(tf.float32, [batchSize, numClasses])
input_data = tf.placeholder(tf.int32, [batchSize, maxSeqLength])

一旦,我们设置了我们的输入数据占位符,我们可以调用 tf.nn.embedding_lookup() 函数来得到我们的词向量。该函数最后将返回一个三维向量,第一个维度是批处理大小,第二个维度是句子长度,第三个维度是词向量长度。更清晰的表达,如下图所示:

data = tf.Variable(tf.zeros([batchSize, maxSeqLength, numDimensions]),dtype=tf.float32)data = tf.nn.embedding_lookup(wordVectors,input_data)

现在我们已经得到了我们想要的数据形式,那么揭晓了我们看看如何才能将这种数据形式输入到我们的 LSTM 网络中。首先,我们使用

tf.nn.rnn_cell.BasicLSTMCell 函数,这个函数输入的参数是一个整数,表示需要几个 LSTM 单元。这是我们设置的一个超参数,我们需要对这个数值进行调试从而来找到最优的解。然后,我们会设置一个 dropout 参数,以此来避免一些过拟合。

最后,我们将 LSTM cell 和三维的数据输入到 tf.nn.dynamic_rnn ,这个函数的功能是展开整个网络,并且构建一整个 RNN 模型。

lstmCell = tf.contrib.rnn.BasicLSTMCell(lstmUnits)
lstmCell = tf.contrib.rnn.DropoutWrapper(cell=lstmCell, output_keep_prob=0.75)
value, _ = tf.nn.dynamic_rnn(lstmCell, data, dtype=tf.float32)

堆栈 LSTM 网络是一个比较好的网络架构。也就是前一个LSTM 隐藏层的输出是下一个LSTM的输入。堆栈LSTM可以帮助模型记住更多的上下文信息,但是带来的弊端是训练参数会增加很多,模型的训练时间会很长,过拟合的几率也会增加。如果你想了解更多有关堆栈LSTM,可以查看TensorFlow的官方教程。

dynamic RNN 函数的第一个输出可以被认为是最后的隐藏状态向量。这个向量将被重新确定维度,然后乘以最后的权重矩阵和一个偏置项来获得最终的输出值。

weight = tf.Variable(tf.truncated_normal([lstmUnits, numClasses]))bias = tf.Variable(tf.constant(0.1, shape=[numClasses]))value = tf.transpose(value, [1, 0, 2])last = tf.gather(value, int(value.get_shape()[0]) - 1)prediction = (tf.matmul(last, weight) + bias)

接下来,我们需要定义正确的预测函数和正确率评估参数。正确的预测形式是查看最后输出的0-1向量是否和标记的0-1向量相同。

correctPred = tf.equal(tf.argmax(prediction,1), tf.argmax(labels,1))
accuracy = tf.reduce_mean(tf.cast(correctPred, tf.float32))

之后,我们使用一个标准的交叉熵损失函数来作为损失值。对于优化器,我们选择 Adam,并且采用默认的学习率。

loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(logits=prediction, labels=labels))
optimizer = tf.train.AdamOptimizer().minimize(loss)

如果你想使用 Tensorboard 来可视化损失值和正确率,那么你可以修改并且运行下列的代码。

import datetime

tf.summary.scalar('Loss', loss)
tf.summary.scalar('Accuracy', accuracy)
merged = tf.summary.merge_all()
logdir = "tensorboard/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "/"writer = tf.summary.FileWriter(logdir, sess.graph)

超参数调整

选择合适的超参数来训练你的神经网络是至关重要的。你会发现你的训练损失值与你选择的优化器(Adam,Adadelta,SGD,等等),学习率和网络架构都有很大的关系。特别是在RNN和LSTM中,单元数量和词向量的大小都是重要因素。

  • 学习率:RNN最难的一点就是它的训练非常困难,因为时间步骤很长。那么,学习率就变得非常重要了。如果我们将学习率设置的很大,那么学习曲线就会波动性很大,如果我们将学习率设置的很小,那么训练过程就会非常缓慢。根据经验,将学习率默认设置为 0.001 是一个比较好的开始。如果训练的非常缓慢,那么你可以适当的增大这个值,如果训练过程非常的不稳定,那么你可以适当的减小这个值。
  • 优化器:这个在研究中没有一个一致的选择,但是 Adam 优化器被广泛的使用。
  • LSTM单元的数量:这个值很大程度上取决于输入文本的平均长度。而更多的单元数量可以帮助模型存储更多的文本信息,当然模型的训练时间就会增加很多,并且计算成本会非常昂贵。
  • 词向量维度:词向量的维度一般我们设置为50到300。维度越多意味着可以存储更多的单词信息,但是你需要付出的是更昂贵的计算成本。

训练

训练过程的基本思路是,我们首先先定义一个 TensorFlow 会话。然后,我们加载一批评论和对应的标签。接下来,我们调用会话的 run 函数。这个函数有两个参数,第一个参数被称为 fetches 参数,这个参数定义了我们感兴趣的值。我们希望通过我们的优化器来最小化损失函数。第二个参数被称为 feed_dict 参数。这个数据结构就是我们提供给我们的占位符。我们需要将一个批处理的评论和标签输入模型,然后不断对这一组训练数据进行循环训练。

我们不在这里对模型进行训练(因为至少需要花费几个小时),我们加载一个预训练好的模型。

如果你决定使用你自己的机器去训练这个网络,那么你可以使用 TensorBoard 来查看这个训练过程。你可以打开终端,然后在里面运行 tensorboard --logdir=tensorboard ,之后你就可以在 http://localhost:6006/ 中查看到整个训练过程。

# sess = tf.InteractiveSession()# saver = tf.train.Saver()# sess.run(tf.global_variables_initializer())# for i in range(iterations):#    #Next Batch of reviews#    nextBatch, nextBatchLabels = getTrainBatch();#    sess.run(optimizer, {input_data: nextBatch, labels: nextBatchLabels})#    #Write summary to Tensorboard#    if (i % 50 == 0):#        summary = sess.run(merged, {input_data: nextBatch, labels: nextBatchLabels})#        writer.add_summary(summary, i)#    #Save the network every 10,000 training iterations#    if (i % 10000 == 0 and i != 0):#        save_path = saver.save(sess, "models/pretrained_lstm.ckpt", global_step=i)#        print("saved to %s" % save_path)# writer.close()

加载一个预训练的模型

在训练过程中,这个预训练模型的正确率和损失值如下所示:

查看上面的训练曲线,我们发现这个模型的训练结果还是不错的。损失值在稳定的下降,正确率也不断的在接近 100% 。然而,当分析训练曲线的时候,我们应该注意到我们的模型可能在训练集上面已经过拟合了。过拟合是机器学习中一个非常常见的问题,表示模型在训练集上面拟合的太好了,但是在测试集上面的泛化能力就会差很多。也就是说,如果你在训练集上面取得了损失值是 0 的模型,但是这个结果也不一定是最好的结果。当我们训练 LSTM 的时候,提前终止是一种常见的防止过拟合的方法。基本思路是,我们在训练集上面进行模型训练,同事不断的在测试集上面测量它的性能。一旦测试误差停止下降了,或者误差开始增大了,那么我们就需要停止训练了。因为这个迹象表明,我们网络的性能开始退化了。

导入一个预训练的模型需要使用 TensorFlow 的另一个会话函数,称为 Server ,然后利用这个会话函数来调用 restore 函数。这个函数包括两个参数,一个表示当前的会话,另一个表示保存的模型。

sess = tf.InteractiveSession()
saver = tf.train.Saver()
saver.restore(sess, tf.train.latest_checkpoint('models'))

然后,从我们的测试集中导入一些电影评论。请注意,这些评论是模型从来没有看见过的。你可以通过以下的代码来查看每一个批处理的正确率。

iterations = 10for i in range(iterations):
    nextBatch, nextBatchLabels = getTestBatch();    print("Accuracy for this batch:", (sess.run(accuracy, {input_data: nextBatch, labels: nextBatchLabels})) * 100)

结论

在这篇文章中,我们通过深度学习方法来处理情感分析任务。我们首先设计了我们需要哪些模型组件,然后来编写我们的 TensorFlow 代码,来具体实现这些组件,并且我们需要设计一些数据管道来作为数据的流通渠道。最后,我们训练和测试了我们的模型,以此来查看是否能在电影评论集上面正常工作。

在 TensorFlow 的帮助下,你也可以来创建自己的情感分析模型,并且来设计一个真实世界能用的模型。

作者:chen_h 链接:http://www.jianshu.com/p/d443aab9bcb1