1. 程式人生 > >自然語言處理6 -- 情感分析

自然語言處理6 -- 情感分析

系列文章,請多關注
Tensorflow原始碼解析1 – 核心架構和原始碼結構
帶你深入AI(1) - 深度學習模型訓練痛點及解決方法
自然語言處理1 – 分詞
自然語言處理2 – jieba分詞用法及原理
自然語言處理3 – 詞性標註
自然語言處理4 – 句法分析
自然語言處理5 – 詞向量
自然語言處理6 – 情感分析

1 概述

情感分析是自然語言處理中常見的場景,比如淘寶商品評價,餓了麼外賣評價等,對於指導產品更新迭代具有關鍵性作用。通過情感分析,可以挖掘產品在各個維度的優劣,從而明確如何改進產品。比如對外賣評價,可以分析菜品口味、送達時間、送餐態度、菜品豐富度等多個維度的使用者情感指數,從而從各個維度上改進外賣服務。

情感分析可以採用基於情感詞典的傳統方法,也可以採用基於深度學習的方法,下面詳細講解

2 基於情感詞典的傳統方法

2.1 基於詞典的情感分類步驟

基於情感詞典的方法,先對文字進行分詞和停用詞處理等預處理,再利用先構建好的情感詞典,對文字進行字串匹配,從而挖掘正面和負面資訊。如下圖

螢幕快照 2018-09-09 下午1.39.29.png | left | 747x209

2.2 情感詞典

情感詞典包含正面詞語詞典、負面詞語詞典、否定詞語詞典、程度副詞詞典等四部分。如下圖

image.png | left | 747x243

詞典包含兩部分,詞語和權重,如下

正面:
很快  1.75
挺快  1.75
還好  1.2
很萌  1.75
服務到位    1

負面:
無語  2
醉了  2
沒法吃  2
不好  2
太差  5
太油  2.5
有些油  1
鹹   1
一般  0.5

程度副詞:
超級  2
超  2
都   1.75
還   1.5
實在  1.75

否定詞:
不   1
沒   1
無   1
非   1
莫   1
弗   1
毋   1

情感詞典在整個情感分析中至關重要,所幸現在有很多開源的情感詞典,如BosonNLP情感詞典,它是基於微博、新聞、論壇等資料來源構建的情感詞典,以及知網情感詞典等。當然我們也可以通過語料來自己訓練情感詞典。

2.3 情感詞典文字匹配演算法

基於詞典的文字匹配演算法相對簡單。逐個遍歷分詞後的語句中的詞語,如果詞語命中詞典,則進行相應權重的處理。正面詞權重為加法,負面詞權重為減法,否定詞權重取相反數,程度副詞權重則和它修飾的詞語權重相乘。如下圖

螢幕快照 2018-09-09 下午2.05.48.png | left | 747x490

利用最終輸出的權重值,就可以區分是正面、負面還是中性情感了。

2.4 缺點

基於詞典的情感分類,簡單易行,而且通用性也能夠得到保障。但仍然有很多不足

  1. 精度不高。語言是一個高度複雜的東西,採用簡單的線性疊加顯然會造成很大的精度損失。詞語權重同樣不是一成不變的,而且也難以做到準確。
  2. 新詞發現。對於新的情感詞,比如給力,牛逼等等,詞典不一定能夠覆蓋
  3. 詞典構建難。基於詞典的情感分類,核心在於情感詞典。而情感詞典的構建需要有較強的背景知識,需要對語言有較深刻的理解,在分析外語方面會有很大限制。

3 基於深度學習的演算法

近年來,深度學習在NLP領域內也是遍地開花。在情感分類領域,我們同樣可以採用深度學習方法。基於深度學習的情感分類,具有精度高,通用性強,不需要情感詞典等優點。

3.1 基於深度學習的情感分類步驟

基於深度學習的情感分類,首先對語句進行分詞、停用詞、簡繁轉換等預處理,然後進行詞向量編碼,然後利用LSTM或者GRU等RNN網路進行特徵提取,最後通過全連線層和softmax輸出每個分類的概率,從而得到情感分類。

image.png | left | 747x135

3.2 程式碼示例

下面通過程式碼來講解這個過程。下面是我週末寫的,2018年AI Challenger細粒度使用者評論情感分析比賽中的程式碼。專案資料來源於大眾點評,訓練資料10萬條,驗證1萬條。分析大眾點評使用者評論中,關於交通,菜品,服務等20個維度的使用者情感指數。分為正面、負面、中性和未提及四類。程式碼在驗證集上,目前f1 socre可以達到0.62。

3.2.1 分詞和停用詞預處理

資料預處理都放在了PreProcessor類中,主函式是process。步驟如下

  1. 讀取原始csv檔案,解析出原始語句和標註
  2. 錯別字,繁簡體,拼音,語義不明確等詞語的處理
  3. stop words停用詞處理
  4. 分詞,採用jieba分詞進行處理。分詞這兒有個trick,由於分詞後較多口語化的詞語不在詞向量中,所以對這部分詞語從jieba中del掉,然後再進行分詞。直到只有為數不多的詞語不在詞向量中為止。
  5. 構建詞向量到詞語的對映,並對詞語進行數字編碼。這一步比較常規。
class PreProcessor(object):
    def __init__(self, filename, busi_name="location_traffic_convenience"):
        self.filename = filename
        self.busi_name = busi_name
        self.embedding_dim = 256

        # 讀取詞向量
        embedding_file = "./word_embedding/word2vec_wx"
        self.word2vec_model = gensim.models.Word2Vec.load(embedding_file)

    # 讀取原始csv檔案
    def read_csv_file(self):
        reload(sys)
        sys.setdefaultencoding('utf-8')
        print("after coding: " + str(sys.getdefaultencoding()))

        data = pd.read_csv(self.filename, sep=',')
        x = data.content.values
        y = data[self.busi_name].values

        return x, y

    # todo 錯別字處理,語義不明確詞語處理,拼音繁體處理等
    def correct_wrong_words(self, corpus):
        return corpus

    # 去掉停用詞
    def clean_stop_words(self, sentences):
        stop_words = None
        with open("./stop_words.txt", "r") as f:
            stop_words = f.readlines()
            stop_words = [word.replace("\n", "") for word in stop_words]

        # stop words 替換
        for i, line in enumerate(sentences):

            for word in stop_words:
                if word in line:
                    line = line.replace(word, "")
            sentences[i] = line

        return sentences

    # 分詞,將不在詞向量中的jieba分詞單獨挑出來,他們不做分詞
    def get_words_after_jieba(self, sentences):
        # jieba分詞
        all_exclude_words = dict()
        while (1):
            words_after_jieba = [[w for w in jieba.cut(line) if w.strip()] for line in sentences]
            # 遍歷不包含在word2vec中的word
            new_exclude_words = []
            for line in words_after_jieba:
                for word in line:
                    if word not in self.word2vec_model.wv.vocab and word not in all_exclude_words:
                        all_exclude_words[word] = 1
                        new_exclude_words.append(word)
                    elif word not in self.word2vec_model.wv.vocab:
                        all_exclude_words[word] += 1

            # 剩餘未包含詞小於閾值,返回分詞結果,結束。否則新增到jieba del_word中,然後重新分詞
            if len(new_exclude_words) < 10:
                print("length of not in w2v words: %d, words are:" % len(new_exclude_words))
                for word in new_exclude_words:
                    print word,
                print("\nall exclude words are: ")
                for word in all_exclude_words:
                    if all_exclude_words[word] > 5:
                        print "%s: %d," % (word, all_exclude_words[word]),
                return words_after_jieba
            else:
                for word in new_exclude_words:
                    jieba.del_word(word)

        raise Exception("get_words_after_jieba error")

    # 去除不在詞向量中的詞
    def remove_words_not_in_embedding(self, corpus):
        for i, sentence in enumerate(corpus):
            for word in sentence:
                if word not in self.word2vec_model.wv.vocab:
                    sentence.remove(word)
                    corpus[i] = sentence

        return corpus

    # 詞向量,建立詞語到詞向量的對映
    def form_embedding(self, corpus):
        # 1 讀取詞向量
        w2v = dict(zip(self.word2vec_model.wv.index2word, self.word2vec_model.wv.syn0))

        # 2 建立詞語詞典,從而知道文字中有多少詞語
        w2index = dict()        # 詞語為key,索引為value的字典
        index = 1
        for sentence in corpus:
            for word in sentence:
                if word not in w2index:
                    w2index[word] = index
                    index += 1
        print("\nlength of w2index is %d" % len(w2index))

        # 3 建立詞語到詞向量的對映
        # embeddings = np.random.randn(len(w2index) + 1, self.embedding_dim)
        embeddings = np.zeros(shape=(len(w2index) + 1, self.embedding_dim), dtype=float)
        embeddings[0] = 0   # 未對映到的詞語,全部賦值為0

        n_not_in_w2v = 0
        for word, index in w2index.items():
            if word in self.word2vec_model.wv.vocab:
                embeddings[index] = w2v[word]
            else:
                print("not in w2v: %s" % word)
                n_not_in_w2v += 1
        print("words not in w2v count: %d" % n_not_in_w2v)

        del self.word2vec_model, w2v

        # 4 語料從中文詞對映為索引
        x = [[w2index[word] for word in sentence] for sentence in corpus]

        return embeddings, x

    # 預處理,主函式
    def process(self):
        # 讀取原始檔案
        x, y = self.read_csv_file()

        # 錯別字,繁簡體,拼音,語義不明確,等的處理
        x = self.correct_wrong_words(x)

        # stop words
        x = self.clean_stop_words(x)

        # 分詞
        x = self.get_words_after_jieba(x)

        # remove不在詞向量中的詞
        x = self.remove_words_not_in_embedding(x)

        # 詞向量到詞語的對映
        embeddings, x = self.form_embedding(x)

        # 列印
        print("embeddings[1] is, ", embeddings[1])
        print("corpus after index mapping is, ", x[0])
        print("length of each line of corpus is, ", [len(line) for line in x])

        return embeddings, x, y

3.2.2 詞向量編碼

詞向量編碼步驟主要有:

  1. 載入詞向量。詞向量可以從網上下載或者自己訓練。網上下載的詞向量獲取簡單,但往往缺失特定場景的詞語。比如大眾點評菜品場景下的魚香肉絲、幹鍋花菜等詞語,而且往往這些詞語在特定場景下還十分重要。而自己訓練則需要幾百G的語料,在高效能伺服器上連續訓練好幾天,成本較高。可以將兩種方法結合起來,也就是載入下載好的詞向量,然後利用補充語料進行增量訓練。
  2. 建立詞語到詞向量的對映,也就是找到文字中每個詞語的詞向量
  3. 對文字進行詞向量編碼,可以通過keras的Embedding函式,或者其他深度學習庫來搞定。
    前兩步在上面程式碼中已經展示了,詞向量編碼程式碼示例如下
Embedding(input_dim=len(embeddings),
                    output_dim=len(embeddings[0]),
                    weights=[embeddings],
                    input_length=self.max_seq_length,
                    trainable=False,
                    name=embeddings_name))

3.2.3 構建LSTM網路

LSTM網路主要分為如下幾層

  1. 兩層的LSTM。
  2. dropout,防止過擬合
  3. 全連線,從而可以輸出類別
  4. softmax,將類別歸一化到[0, 1]之間
    LSTM網路是重中之重,這兒可以優化的空間很大。比如可以採用更優的雙向LSTM,可以加入注意力機制。這兩個trick都可以提高最終準確度。另外可以建立分詞和不分詞兩種情況下的網路,最終通過concat合併。
class Model(object):
    def __init__(self, busi_name="location_traffic_convenience"):
        self.max_seq_length = 100
        self.lstm_size = 128
        self.max_epochs = 10
        self.batch_size = 128

        self.busi_name = busi_name
        self.model_name = "model/%s_seq%d_lstm%d_epochs%d.h5" % (self.busi_name, self.max_seq_length, self.lstm_size, self.max_epochs)
        self.yaml_name = "model/%s_seq%d_lstm%d_epochs%d.yml" % (self.busi_name, self.max_seq_length, self.lstm_size, self.max_epochs)

    def split_train_data(self, x, y):
        x_train, x_val, y_train, y_val = train_test_split(x, y, test_size=0.1)

        # 超長的部分設定為0,截斷
        x_train = sequence.pad_sequences(x_train, self.max_seq_length)
        x_val = sequence.pad_sequences(x_val, self.max_seq_length)

        # y弄成4分類,-2未提及,-1負面,0中性,1正面
        y_train = keras.utils.to_categorical(y_train, num_classes=4)
        y_val = keras.utils.to_categorical(y_val, num_classes=4)

        return x_train, x_val, y_train, y_val

    def build_network(self, embeddings, embeddings_name):
        model = Sequential()
        model.add(Embedding(input_dim=len(embeddings),
                            output_dim=len(embeddings[0]),
                            weights=[embeddings],
                            input_length=self.max_seq_length,
                            trainable=False,
                            name=embeddings_name))
        model.add(LSTM(units=self.lstm_size, activation='tanh', return_sequences=True, name='lstm1'))
        model.add(LSTM(units=self.lstm_size, activation='tanh', name='lstm2'))
        model.add(Dropout(0.1))
        model.add(Dense(4))
        model.add(Activation('softmax'))

        return model

    def train(self, embeddings, x, y):
        model = self.build_network(embeddings, "embeddings_train")
        model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"])

        # 訓練,採用k-folder交叉訓練
        for i in range(0, self.max_epochs):
            x_train, x_val, y_train, y_val = self.split_train_data(x, y)
            model.fit(x_train, y_train, batch_size=self.batch_size, validation_data=(x_val, y_val))

        # 儲存model
        yaml_string = model.to_yaml()
        with open(self.yaml_name, 'w') as outfile:
            outfile.write(yaml.dump(yaml_string, default_flow_style=True))

        # 儲存model的weights
        model.save_weights(self.model_name)


    def predict(self, embeddings, x):
        # 載入model
        print 'loading model......'
        with open(self.yaml_name, 'r') as f:
            yaml_string = yaml.load(f)
        model = model_from_yaml(yaml_string)

        # 載入權重
        print 'loading weights......'
        model.load_weights(self.model_name, by_name=True)
        model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"])

        # 預測
        x = sequence.pad_sequences(x, self.max_seq_length)
        predicts = model.predict_classes(x)         # 得到分類結果,它表徵的是類別序號

        # 轉換
        classes = [0, 1, -2, -1]
        predicts = [classes[item] for item in predicts]
        np.set_printoptions(threshold=np.nan)       # 全部列印
        print(np.array(predicts))

        return predicts

3.2.4 softmax輸出類別

這一部分上面程式碼已經講到了,不在贅述。softmax只是一個歸一化,講資料歸一化到[0, 1]之間,從而可以得到每個類別的概率。我們最終取概率最大的即可。

3.3 基於深度學習的情感分析難點

基於深度學習的情感分析難點也很多

  1. 語句長度太長。很多使用者評論都特別長,分詞完後也有幾百個詞語。而對於LSTM,序列過長會導致計算複雜、精度降低等問題。一般解決方法有進行停用詞處理,無關詞處理等,從而縮減文字長度。或者對文字進行摘要,抽離出語句主要成分。
  2. 新詞和口語化的詞語特別多。使用者評論語句不像新聞那樣規整,新詞和口語化的詞語特別多。這個問題給分詞和詞向量帶來了很大難度。一般解決方法是分詞方面,建立使用者詞典,從而提高分詞準確度。詞向量方面,對新詞進行增量訓練,從而提高新詞覆蓋率。

4. 總結

文字情感分析是NLP領域一個十分重要的問題,對理解使用者意圖具有決定性的作用。通過基於詞典的傳統演算法和基於深度學習的演算法,可以有效的進行情感分析。當前情感分析準確率還有待提高,任重而道遠!

系列文章,請多關注
Tensorflow原始碼解析1 – 核心架構和原始碼結構
帶你深入AI(1) - 深度學習模型訓練痛點及解決方法
自然語言處理1 – 分詞
自然語言處理2 – jieba分詞用法及原理
自然語言處理3 – 詞性標註
自然語言處理4 – 句法分析
自然語言處理5 – 詞向量
自然語言處理6 – 情感分析