如何用 Python 和循環神經網絡(RNN)做中文文本分類?

本文爲你展示,如何使用 fasttext 詞嵌入預訓練模型和循環神經網絡(RNN), 在 Keras 深度學習框架上對中文評論信息進行情感分類。

疑問

回顧一下,之前咱們講了很多關於中文文本分類的內容。

你現在應該已經知道如何對中文文本進行分詞了。

你也已經學習過,如何利用經典的機器學習方法,對分詞後的中文文本,做分類

你還學習過,如何用詞嵌入預訓練模型,以向量,而不是一個簡單的索引數值,來代表詞語,從而讓中文詞語的表徵包含語義級別的信息。

但是,好像還差了點兒什麼。

對,基於深度學習的中文文本分類方法,老師是不是忘了講?

其實沒有。

我一直惦記着,把這個重要的知識點,給你詳細講解一下。但是之前這裏面一直有一條鴻溝,那就是循環神經網絡(Recurrent Neural Network, RNN)。

如果你不知道 RNN 是怎麼回事兒,你就很難理解文本作爲序列,是如何被深度學習模型來處理的。

好在,我已經爲你做了視頻教程,用手繪的方式,給你講了這一部分。

既然現在這道鴻溝,已被跨越了。本文咱們就來嘗試,把之前學過的知識點整合在一起,用 Python 和 Keras 深度學習框架,對中文文本嘗試分類。

數據

爲了對比的便捷,咱們這次用的,還是《如何用Python和機器學習訓練中文文本情感分類模型?》一文中採用過的某商戶的點評數據。

我把它放在了一個 github repo 中,供你使用。

請點擊這個鏈接,訪問咱們的代碼和數據。

我們的數據就是其中的 dianping.csv 。你可以點擊它,看看內容。

每一行是一條評論。評論內容和情感間,用逗號分隔。

1 代表正向情感,0 代表負面情感。

環境

要運行深度學習,你需要有 GPU 或者 TPU 的支持。

我知道,它們不便宜。

好在,Google 爲咱們提供了免費的雲端運行環境,叫做 Google Colab 。我曾經在《如何免費雲端運行Python深度學習框架?》一文中,爲你介紹過它。現在,它不止支持 GPU 了,還包含了 TPU 的選項。

注意,請使用 Google Chrome 瀏覽器來完成以下操作。

因爲你需要安裝一個瀏覽器插件插件,叫做 Colaboratory ,它是 Google 自家的插件,只能在 Chrome 瀏覽器中,才能運行。

點擊這個鏈接,安裝插件。

把它添加到 Google Chrome 之後,你會在瀏覽器的擴展工具欄裏面,看見下圖中間的圖標:

安裝它做什麼用?

它的好處,是讓你可以直接把看到的 Github 源代碼,一鍵挪到 Google Colab 深度學習環境中來使用。

回到本範例的github repo 主頁面,打開其中的 demo.ipynb 文件。

然後,點擊剛剛安裝的 Colaboratory 擴展圖標。Google Chrome 會自動幫你開啓 Google Colab,並且裝載這個 ipynb 文件。

點擊菜單欄裏面的“代碼執行程序”,選擇“更改運行時類型”。

在出現的對話框中,確認選項如下圖所示。

點擊“保存”即可。

下面,你就可以依次執行每一個代碼段落了。

注意第一次執行的時候,可能會有警告提示。

出現上面這個警告的時候,點擊“仍然運行”就可以繼續了。

如果再次出現警告提示,反勾選“在運行前充值所有代碼執行程序”選項,再次點擊“仍然運行”即可。

環境準備好了,下面我們來一步步運行代碼。

預處理

首先,我們準備好 Pandas ,用來讀取數據。

import pandas as pd

我們從前文介紹的github repo裏面,下載代碼和數據。

!git clone https://github.com/wshuyi/demo-chinese-text-classification-lstm-keras.git

下面,我們調用 pathlib 模塊,以便使用路徑信息。

from pathlib import Path

我們定義自己要使用的代碼和數據文件夾。

mypath = Path("demo-chinese-text-classification-lstm-keras")

下面,從這個文件夾裏,把數據文件打開。

df = pd.read_csv(mypath/'dianping.csv')

看看頭幾行數據:

df.head()

讀取正確,下面我們來進行分詞。

我們先把結巴分詞安裝上。

!pip install jieba

安裝好之後,導入分詞模塊。

import jieba

對每一條評論,都進行切分:

df['text'] = df.comment.apply(lambda x: " ".join(jieba.cut(x)))

因爲一共只有2000條數據,所以應該很快完成。

Building prefix dict from the default dictionary ...
Dumping model to file cache /tmp/jieba.cache
Loading model cost 1.089 seconds.
Prefix dict has been built succesfully.

再看看此時的前幾行數據。

df.head()

如圖所示,text 一欄下面,就是對應的分詞之後的評論。

我們捨棄掉原始評論文本,只保留目前的分詞結果,以及對應的情感標記。

df = df[['text', 'sentiment']]

看看前幾行:

df.head()

好了,下面我們讀入一些 Keras 和 Numpy 模塊,爲後面的預處理做準備:

from keras.preprocessing.text import Tokenizer
from keras.preprocessing.sequence import pad_sequences
import numpy as np

系統提示我們,使用的後端框架,是 Tensorflow 。

Using TensorFlow backend.

下面我們要設置一下,每一條評論,保留多少個單詞。當然,這裏實際上是指包括標點符號在內的“記號”(token)數量。我們決定保留 100 個。

然後我們指定,全局字典裏面,一共保留多少個單詞。我們設置爲 10000 個。

maxlen = 100
max_words = 10000

下面的幾條語句,會自動幫助我們,把分詞之後的評論信息,轉換成爲一系列的數字組成的序列。

tokenizer = Tokenizer(num_words=max_words)
tokenizer.fit_on_texts(df.text)
sequences = tokenizer.texts_to_sequences(df.text)

看看轉換後的數據類型。

type(sequences)

顯示爲:

list

可見, sequences 是列表類型。

我們看看第一條數據是什麼。

sequences[:1]

評論語句中的每一個記號,都被轉換成爲了一個大字典中對應的序號。字典的長度我們前面已經規定了,最多10000條。

但是這裏有個問題——評論句子有長有短,其中包含的記號個數不同啊。

我們探索一下,只看最前面5句話,包含多少個記號(token)。

for sequence in sequences[:5]:
  print(len(sequence))
150
12
16
57
253

果然,不僅長短不一,而且有的還超出我們想要的句子長度。

沒關係,用 pad_sequences 方法裁長補短,我們讓它統一化:

data = pad_sequences(sequences, maxlen=maxlen)

再看看這次的數據:

data
array([[   2,    1,   74, ..., 4471,  864,    4],
       [   0,    0,    0, ...,    9,   52,    6],
       [   0,    0,    0, ...,    1, 3154,    6],
       ...,
       [   0,    0,    0, ..., 2840,    1, 2240],
       [   0,    0,    0, ...,   19,   44,  196],
       [   0,    0,    0, ...,  533,   42,    6]], dtype=int32)

那些長句子,被剪裁了;短句子,被從頭補充了若干個 0 。整齊規範。

我們還希望知道,這些序號分別代表什麼單詞,所以我們把這個字典保存下來。

word_index = tokenizer.word_index

看看索引的類型。

type(word_index)
dict

類型驗證通過。看看內容:

print(word_index)

沒問題了。

中文評論數據,已經被我們處理成一系列長度爲 100 ,其中都是序號的序列了。下面我們要把對應的情感標記,存儲到標記序列 labels 中。

labels = np.array(df.sentiment)

看一下其內容:

labels
array([0, 1, 0, ..., 0, 1, 1])

全部數據都已經備妥了。下面我們來劃分一下訓練集和驗證集。

我們採用的,是把序號隨機化,但保持數據和標記之間的一致性。

indices = np.arange(data.shape[0])
np.random.shuffle(indices)
data = data[indices]
labels = labels[indices]

看看此時的標記:

labels
array([0, 1, 1, ..., 0, 1, 1])

注意順序已經發生了改變。

我們希望,訓練集佔 80% ,驗證集佔 20%。根據總數,計算一下兩者的實際個數:

training_samples = int(len(indices) * .8)
validation_samples = len(indices) - training_samples

其中訓練集包含多少數據?

training_samples
1600

驗證集呢?

validation_samples
400

下面,我們正式劃分數據。

X_train = data[:training_samples]
y_train = labels[:training_samples]
X_valid = data[training_samples: training_samples + validation_samples]
y_valid = labels[training_samples: training_samples + validation_samples]

看看訓練集的輸入數據:

X_train
array([[   0,    0,    0, ...,  963,    4,  322],
       [   0,    0,    0, ..., 1485,   79,   22],
       [   1,   26,  305, ...,  289,    3,   71],
       ...,
       [   0,    0,    0, ...,  365,  810,    3],
       [   0,    0,    0, ...,    1,  162, 1727],
       [ 141,    5,  237, ...,  450,  254,    4]], dtype=int32)

至此,預處理部分就算完成了。

詞嵌入

下面,我們安裝 gensim 軟件包,以便使用 Facebook 提供的 fasttext 詞嵌入預訓練模型。

!pip install gensim

安裝後,我們讀入加載工具:

from gensim.models import KeyedVectors

然後我們需要把 github repo 中下載來的詞嵌入預訓練模型壓縮數據解壓。

myzip = mypath / 'zh.zip'

! 開頭的語句,代表 bash 命令。其中如果需要使用 Python 變量,前面需要加 $

!unzip $myzip
Archive:  demo-chinese-text-classification-lstm-keras/zh.zip
  inflating: zh.vec

解壓完畢。

下面我們讀入詞嵌入預訓練模型數據。

zh_model = KeyedVectors.load_word2vec_format('zh.vec')

看看其中的第一個向量是什麼:

zh_model.vectors[0]

這麼長的向量,對應的記號是什麼呢?

看看前五個詞彙:

list(iter(zh_model.vocab))[:5]
['的', '</s>', '在', '是', '年']

原來,剛纔這個向量,對應的是標記“的”。

向量的維度是多少?也就是,一個向量中,包含多少個數字?

len(zh_model[next(iter(zh_model.vocab))])
300

看來, fasttext 用 300 個數字組成一個向量,代表一個記號(token)。

我們把這個向量長度,進行保存。

embedding_dim = len(zh_model[next(iter(zh_model.vocab))])

然後,以我們規定的字典最大長度,以及每個標記對應向量長度,建立一個隨機矩陣。

embedding_matrix = np.random.rand(max_words, embedding_dim)

看看它的內容:

embedding_matrix

這個隨機矩陣建立的時候,因爲使用了 Numpy 的 random.rand 函數,默認都是從0到1的實數。

然而,我們剛纔已經看過了“的”的向量表示,

請注意,其中的數字,在 -1 到 1 的範圍中間。爲了讓我們隨機產生的向量,跟它類似,我們把矩陣進行一下數學轉換:

embedding_matrix = (embedding_matrix - 0.5) * 2
embedding_matrix

這樣看起來,隨機產生的數據,就和真正的預訓練結果更相似了。

爲什麼做這一步呢?一會兒你就知道了。

我們嘗試,對某個特定標記,讀取預訓練的向量結果:

zh_model.get_vector('的')

但是注意,如果你指定的標記,出現在自己任務文本里,卻在預訓練過程中沒有出現,會如何呢?

試試輸入我的名字:

zh_model.get_vector("王樹義")

不好意思,因爲我的名字,在 fasttext 做預訓練的時候沒有,所以獲取詞嵌入向量,會報錯。

因此,在我們構建適合自己任務的詞嵌入層的時候,也需要注意那些沒有被訓練過的詞彙。

這裏我們判斷一下,如果無法獲得對應的詞向量,我們就乾脆跳過,使用默認的隨機向量。

for word, i in word_index.items():
    if i < max_words:
        try:
          embedding_vector = zh_model.get_vector(word)
          embedding_matrix[i] = embedding_vector
        except:
          pass

這也是爲什麼,我們前面儘量把二者的分佈調整成一致。這樣咱們對於沒見過的詞彙,也可以做成個以假亂真的分佈,一起參加後面的模型訓練過程。

看看我們產生的“混合”詞嵌入矩陣:

embedding_matrix

模型

詞嵌入矩陣準備好了,下面我們就要搭建模型了。

from keras.models import Sequential
from keras.layers import Embedding, Flatten, Dense, LSTM

units = 32

model = Sequential()
model.add(Embedding(max_words, embedding_dim))
model.add(LSTM(units))
model.add(Dense(1, activation='sigmoid'))
model.summary()

注意這裏的模型,是最簡單的順序模型,對應的模型圖如下:

如圖所示,我們輸入數據通過詞嵌入層,從序號轉化成爲向量,然後經過 LSTM (RNN 的一個變種)層,依次處理,最後產生一個32位的輸出,代表這句評論的特徵。

這個特徵,通過一個普通神經網絡層,然後採用 Sigmoid 函數,輸出爲一個0到1中間的數值。

Sigmoid 函數,大概長成這個樣子:

這樣,我們就可以通過數值與 0 和 1 中哪個更加接近,進行分類判斷。

但是這裏注意,此處搭建的神經網絡裏,Embedding 只是一個隨機初始化的層次。我們需要把剛剛構建的詞嵌入矩陣導入。

model.layers[0].set_weights([embedding_matrix])
model.layers[0].trainable = False

我們希望保留好不容易獲得的單詞預訓練結果,所以在後面的訓練中,我們不希望對這一層進行訓練,因而,trainable 參數設定爲 False

因爲是二元分類,因此我們設定了損失函數爲 binary_crossentropy

我們訓練模型,保存輸出爲 history ,並且把最終的模型結構和參數存儲爲 mymodel.h5

好了,開始訓練吧:

model.compile(optimizer='rmsprop',
              loss='binary_crossentropy',
              metrics=['acc'])
history = model.fit(X_train, y_train,
                    epochs=10,
                    batch_size=32,
                    validation_data=(X_valid, y_valid))
model.save("mymodel.h5")

機器認認真真,替我們跑了10個來回。

因爲有 TPU 的幫助,所以這個過程,應該很快就能完成。

討論

對於這個模型的分類效果,你滿意嗎?

如果單看最終的結果,訓練集準確率超過 90%, 驗證集準確率也超過 80%,好像還不錯嘛。

但是,我看到這樣的數據時,會有些擔心。

我們把這些訓練中獲得的結果數值,用可視化的方法,顯示一下:

import matplotlib.pyplot as plt

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(1, len(acc) + 1)

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()

plt.figure()

plt.plot(epochs, loss, 'bo', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()

plt.show()

上圖是準確率曲線。虛線是訓練集,實線是驗證集。我們看到,訓練集準確率一路走高,但是驗證集準確率在波動——即便最後一步剛好是最高點。

看下面的圖,會更加清晰。

上圖是損失數值對比。我們可以看到,訓練集上,損失數值一路向下,但是,從第2個 epoch 開始,驗證集的損失數值,就沒有保持連貫的顯著下降趨勢。二者發生背離。

這意味着什麼?

這就是深度學習中,最常見,也是最惱人的問題——過擬合(overfitting)。

如何用機器學習處理二元分類任務?》一文中,我曾經就這個問題,爲你做過詳細的介紹。這裏不贅述了。

但是,我希望你能夠理解它出現的原因——相對於你目前使用的循環神經網絡結構,你的數據量太小了。

深度學習,可以讓你端到端操作,不需要手動繁複去做特徵工程。但是,它對於數據數量和質量的需求,都很高。

有沒有辦法,可以讓你不需要這麼多的數據,也能避免過擬合,取得更好的訓練結果呢?

這個問題的答案,我在《如何用 Python 和深度遷移學習做文本分類?》一文中已經爲你介紹過,如果你忘記了,請複習一下吧。

小結

本文,我們探討了如何用 Python 和循環神經網絡處理中文文本分類問題。讀過本文並且實踐之後,你應該已經能夠把下列內容融會貫通了:

  • 文本預處理
  • 詞嵌入矩陣構建
  • 循環神經網絡模型搭建
  • 訓練效果評估

希望這份教程,可以在你的科研和工作中,幫上一些忙。

祝(深度)學習愉快!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章