IMDB Sentiment Classification from scratch

Author: Beyond

Time: 2019.04.26

情感分析是上手NLP的最簡單的任務之一,它就是一個簡單的文本分類問題,判斷一段文本的情感極性。最簡單的就是二分類,判斷是積極的還是消極的;更難一點的就是三分類,除了積極消極還有無情感傾向的;更加複雜的就比如情感打分,例如電影打1~5分,這就是五分類。但本質上都一樣,無非類別太多更難以學習罷了。

IMDB是一個專業的電影評論網站,類似國內的豆瓣,IMDB的電影評論數據是大家經常使用來練手的情感分析數據集,也是各種比賽,如Kaggle,和各種學者做研究常用的數據集。

本文嘗試用這個數據做一個情感二分類,作為一個NLP的練手。具體涉及到:

  1. 文本預處理;
  2. 預訓練詞向量的載入;
  3. 採用RNNs訓練模型

數據集地址:ai.stanford.edu/~amaas/

本文採用Keras作為框架在進行模型搭建。

本文目錄:

一、文本預處理&訓練測試集的準備

1.數據集

①關於數據集

其實,keras自帶了IMDB的已經進行很好的預處理的數據集,可以一行代碼下載,不需要進行任何的處理就可以訓練,而且效果比較好。但是,這樣就太沒意思了。在真實場景中,我們拿到的都是臟髒的數據,我們必須自己學會讀取、清洗、篩選、分成訓練集測試集。而且,從我自己的實踐經驗來看,數據預處理的本事才是真本事,模型都好搭,現在的各種框架已經讓搭建模型越來越容易,但是數據預處理只能自己動手。所有往往實際任務中,數據預處理花費的時間、精力是最多的,而且直接影響後面的效果。

另外,我們要知道,對文本進行分析,首先要將文本數值化。因為計算機不認字的,只認數字。所以最後處理好的文本應該是數值化的形式。而keras自帶的數據集全都數值化了,而它並不提供對應的查詢字典讓我們知道每個數字對應什麼文字,這讓我們只能訓練模型,看效果,無法拓展到其他語料上,也無法深入分析。綜上,我上面推薦的數據集,是原始數據集,都是真實文本,當然,為了方便處理,也已經被斯坦福的大佬分好類了。但是怎麼數值化,需要我們自己動手。

下載後解壓,會看到有兩個文件夾,testtrain

我們點進train中,會發現正樣本和負樣本已經分好類了:

negpos分別是負樣本和正樣本,unsup是未標註的樣本,可用後續需要採用。其他的都自己去看看吧。

打開pos文件,看看裡面啥樣:

都是一個個文本。

注意到,這些文本一般都不短...

數據集中,共有5w條文本,test集和train集各半,每個集合中,pos和neg也是各半。

當然,他們劃分的train和test,你不一定真的要這樣用。例如本文中,我為了方便,就吧train集合當做我所有的數據,在這2.5w條數據中再按照7:3劃分train set和test set.

②導入數據集的代碼

import os
datapath = rdatasetsaclImdb_v1 rain
pos_files = os.listdir(datapath+/pos)
neg_files = os.listdir(datapath+/neg)
print(len(pos_files))
print(len(neg_files))

輸出:

12500
12500

所以我們總共有12500個正樣本和12500個負樣本。

import numpy as np
pos_all = []
neg_all = []
for pf,nf in zip(pos_files,neg_files):
with open(datapath+/pos+/+pf,encoding=utf-8) as f:
s = f.read()
pos_all.append(s)
with open(datapath+/neg+/+nf,encoding=utf-8) as f:
s = f.read()
neg_all.append(s)
print(len(pos_all))
print(len(neg_all))
X_orig = np.array(pos_all+neg_all)
Y_orig = np.array([1 for _ in range(12500)] + [0 for _ in range(12500)])
print("X_orig:",X_orig.shape)
print("Y_orig:",Y_orig.shape)

上面代碼的主要作用是把一個個樣本放進正負樣本對應的列表中,同時配上對應的label。代碼很好理解。

輸出:

12500
12500
X_orig: (25000,)
Y_orig: (25000,)

2.文本數值化

①文本數值化的思路

前面提到過,NLP問題比CV問題更難的一部分原因,就是文本都是離散化的數據,不像圖像數據都是連續的數值數據,所以我們要想辦法把一系列文本轉化成一系列數字。

這裡的方法很多,我們這裡採用的方法是,給辭彙表中每一個詞一個index,用index代替那個詞。如一個語料庫共有1w個詞,那麼就設置1w個index,每個詞直接替換程index就行。

但是,很多問題中,辭彙量巨大,但是可能大部分詞都是低頻詞,對訓練模型的貢獻很小,反而會嚴重拖累模型的訓練。所以,一般我們可以分析一下文本辭彙的詞頻分布特徵,選取詞頻佔大頭的一批詞就行了。

例如,在本文的任務中,數據集共涉及到的辭彙量有8~9w,這樣訓練起來會很慢。經過分析,發現大概2w個詞就已經覆蓋了絕大部分篇幅,所以我就選取詞典大小為2w。然後,對文本數值化的時候,那些低頻詞就直接過濾掉了,只留下高頻詞。這樣,模型訓練起來效率就會大大提高。

詞向量

如果你接觸過詞向量,那麼一定會想到可以使用詞向量吧文本轉化成數值類型。不錯,我們在本文中也會這麼做。但是,如果直接吧文本轉化成詞向量,輸入進模型的話,我們可能無法繼續調優(fine-tune),詞向量相當於是對文本的特徵的一種表示,本身性質已經很好了。但是對於特定任務場景,我們一般都希望可以在訓練好的詞向量的基礎上,繼續用對應領域的數據對詞向量進一步進行優化。所以,今天我們會探索,如果在加入詞向量後,可以接著fine-tune。

②文本數值化,詞向量導入的代碼

keras自帶的文本預處理的工具十分好用,具體可參加我單獨寫的一個短文:beyondguo.github.io/201

我們設置詞典大小為20000,文本序列最大長度為200.

from keras.preprocessing.text import text_to_word_sequence,one_hot,Tokenizer
from keras.preprocessing.sequence import pad_sequences
import time
vocab_size = 20000
maxlen = 200
print("Start fitting the corpus......")
t = Tokenizer(vocab_size) # 要使得文本向量化時省略掉低頻詞,就要設置這個參數
tik = time.time()
t.fit_on_texts(X_orig) # 在所有的評論數據集上訓練,得到統計信息
tok = time.time()
word_index = t.word_index # 不受vocab_size的影響
print(all_vocab_size,len(word_index))
print("Fitting time: ",(tok-tik),s)
print("Start vectorizing the sentences.......")
v_X = t.texts_to_sequences(X_orig) # 受vocab_size的影響
print("Start padding......")
pad_X = pad_sequences(v_X,maxlen=maxlen,padding=post)
print("Finished!")

上面的代碼可以第一次讀會比較難理解,這裡稍微解釋一下:

Tokenizer是一個類,可以接收一個vocab_size的參數,也就是詞典大小。設置了詞典大小後,在後面生成文本的向量的時候,會把那些低頻詞(詞頻在20000開外的)都篩掉。

定義了Tokenizer的一個實例t,然後調用方法t.fit_on_texts(X_orig)的作用,就是把我們所有的預料丟進去,讓t去統計,它會幫你統計詞頻,給每個詞分配index,形成字典等等。

想獲取index和詞的對照字典的話,就使用t.word_index方法。注意,獲取字典的時候,不會篩掉那些低頻詞,是所有詞的一個字典。

然後,想把一個句子、段落,轉化成對應的index表示的向量怎麼辦呢?Tokenizer也提供了便捷的方法,不用你自己去慢慢查表,直接使用t.texts_to_sequences(X_orig)方法,就可以獲取每句話的index組成的向量表示。注意,這裡,就已經吧低頻詞給過濾掉了,比如一句話有100個詞,其中有30個低頻詞,那麼經過這個函數,得到的就是長度為70的一個向量。

得到每個句子的向量後,會發現大家長度各有不同,長的長短的短,這樣在後面的RNNs訓練時,就不方便批處理。所以,我們還需要對句子進行一個padding(填白,補全),把所有句子弄程統一長度,短的補上0,長的切掉。用的方法就是pad_sequences

上面代碼的輸出是:

Start fitting the corpus......
all_vocab_size 88582
Fitting time: 9.10555362701416 s
Start vectorizing the sentences.......
Start padding......
Finished!

可以看到,我們2.5w個文本,幾百萬詞,丟進去統計,效率還是挺高的,不到10秒就統計好了。

剛剛說了,獲取字典的時候,不會篩掉那些低頻詞,是所有詞的一個字典。但後面我們需要只保留那些高頻詞的一個字典,所以需要進行這樣一個操作,形成一個高頻詞字典:

import copy
x = list(t.word_counts.items())
s = sorted(x,key=lambda p:p[1],reverse=True)
small_word_index = copy.deepcopy(word_index) # 防止原來的字典也被改變了
print("Removing less freq words from word-index dict...")
for item in s[20000:]:
small_word_index.pop(item[0])
print("Finished!")
print(len(small_word_index))
print(len(word_index))

輸出:

Removing less freq words from word-index dict...
Finished!
20000
88582

詞向量的導入:

import gensim
model_file = ../big_things/w2v/GoogleNews-vectors-negative300.bin
print("Loading word2vec model......")
wv_model = gensim.models.KeyedVectors.load_word2vec_format(model_file,binary=True)

這裡採用Google發布的使用GoogleNews進行訓練的一個300維word2vec詞向量。這個讀者可以自行去網上下載。如果無法下載,可以到公眾號留言申請。

現在,我們需要把這個詞向量,跟我們本任務中的辭彙的index對應起來,也就是構建一個embedding matrix這樣就可以通過index找到對應的詞向量了。方法也很簡單:

先隨機初始化一個embedding matrix,這裡需要注意的是,我們的辭彙量vocab_size雖然是20000,但是訓練的時候還是會碰到不少詞不在辭彙表裡,也在詞向量也查不到,那這些詞怎麼處理呢?我們就需要單獨給這些未知詞(UNK)一個index,在keras的文本預處理中,會默認保留index=0給這些未知詞。

embedding_matrix = np.random.uniform(size=(vocab_size+1,300)) # +1是要留一個給index=0
print("Transfering to the embedding matrix......")
# sorted_small_index = sorted(list(small_word_index.items()),key=lambda x:x[1])
for word,index in small_word_index.items():
try:
word_vector = wv_model[word]
embedding_matrix[index] = word_vector
except:
print("Word: [",word,"] not in wvmodel! Use random embedding instead.")
print("Finished!")
print("Embedding matrix shape:
",embedding_matrix.shape)

通過上面的操作,所有的index都對應上了詞向量,那些不在word2vec中的詞和index=0的詞,詞向量就是隨機初始化的值。

3.劃分訓練集和測試集

劃分訓練集和測試集,當然使用經典的sklearn的train_test_split了。

廢話少說,直接上代碼:

from sklearn.model_selection import train_test_split
np.random.seed = 1
random_indexs = np.random.permutation(len(pad_X))
X = pad_X[random_indexs]
Y = Y_orig[random_indexs]
print(Y[:50])
X_train, X_test, y_train, y_test = train_test_split(X,Y,test_size=0.2)
print("X_train:",X_train.shape)
print("y_train:",y_train.shape)
print("X_test:",X_test.shape)
print("y_test:",y_test.shape)
print(list(y_train).count(1))
print(list(y_train).count(0))

輸出:

[0 0 1 0 0 1 0 0 0 1 0 1 0 0 0 1 1 1 0 1 1 0 1 0 1 1 0 0 0 0 0 0 0 0 1 0 1
1 0 1 0 1 1 0 1 0 0 1 1 0]
X_train: (20000, 200)
y_train: (20000,)
X_test: (5000, 200)
y_test: (5000,)
9982
10018

訓練樣本2w,測試樣本5k.

唯一值得注意的一點就是,由於前面我們載入數據集的時候,正樣本和負樣本都聚在一塊,所以我們在這裡要把他們隨機打亂一下,用的就是numpy的random.permutation方法。這些都是慣用伎倆了。

恭喜!您已閱讀本文80%的內容!

二、搭建模型跑起來

做完了數據的預處理,後面的東西,就都是小菜一碟了。那麼多框架是幹嘛的?就是為了讓你用儘可能少的代碼把那些無聊的事情給做了!Keras尤其如此。

1.模型的結構設計

處理NLP問題,最常用的模型的就是RNN系列,LSTM和GRU隨便用。然後,一般還會在前面加一個embedding層。

之前我一直以為embedding層就是把預訓練好的詞向量加進去,實際上不是。即使沒有訓練好的詞向量,我們也可以使用embedding層。因為我們可以用我們的訓練數據,來訓練出詞的embedding,只不過這個embedding不同於word2vec的那種表達詞的含義的embedding,更多的是針對特定場景下的一個embedding。(不知道這樣說有沒有說清楚...)

所以,我們直接配置一個embedding層,不提供詞向量都可以訓練。如果提供了詞向量,這樣可以加速我們的訓練,相當於我們已經有一個訓練好的參數,提供給了模型,模型無非就需要接著改一改即可,而不是從一個隨機的狀態來慢慢訓練。

2. 模型的搭建

Talk is cheap, the code below is also cheap:

import keras
from keras.models import Sequential,Model
from keras.layers import Input,Dense,GRU,LSTM,Activation,Dropout,Embedding
from keras.layers import Multiply,Concatenate,Dot

inputs = Input(shape=(maxlen,))
use_pretrained_wv = True
if use_pretrained_wv:
wv = Embedding(VOCAB_SIZE+1,wv_dim,input_length=MAXLEN,weights=[embedding_matrix]) (inputs)
else:
wv = Embedding(VOCAB_SIZE+1,wv_dim,input_length=MAXLEN)(inputs)

h = LSTM(128)(wv)
y = Dense(1,activation=sigmoid)(h)
m = Model(input=inputs,output=y)
m.summary()

m.compile(optimizer=adam,loss=binary_crossentropy,metrics=[accuracy])
m.fit(X_train,y_train,batch_size=32,epochs=3,validation_split=0.15)

從上面的代碼可以知道,想要把預訓練的word2vec詞向量加入到模型中,就是把詞向量作為embedding層的參數(weights),具體我們需要先構建一個embedding matrix,這個我們在前面已經構建好了,然後傳進embedding層即可。

運行!輸出:

_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_7 (InputLayer) (None, 200) 0
_________________________________________________________________
embedding_7 (Embedding) (None, 200, 128) 2560128
_________________________________________________________________
lstm_7 (LSTM) (None, 128) 131584
_________________________________________________________________
dense_7 (Dense) (None, 1) 129
=================================================================
Total params: 2,691,841
Trainable params: 2,691,841
Non-trainable params: 0
_________________________________________________________________
Train on 17000 samples, validate on 3000 samples
Epoch 1/3
17000/17000 [==============================] - 178s 10ms/step - loss: 0.6711 - acc: 0.5692 - val_loss: 0.6701 - val_acc: 0.5697
Epoch 2/3
17000/17000 [==============================] - 168s 10ms/step - loss: 0.5964 - acc: 0.6479 - val_loss: 0.5072 - val_acc: 0.7940
Epoch 3/3
17000/17000 [==============================] - 169s 10ms/step - loss: 0.5104 - acc: 0.7171 - val_loss: 0.4976 - val_acc: 0.7943

可以發現,參數的大部分,都是embedding層的參數。所以,讀者可以嘗試一下將詞向量參數固定,可以發現訓練速度會快得多。但是效果可能會略差一些。

建議讀者對比一下:

①不使用word2vec作為embedding的參數

②使用word2vec作為embedding的參數並固定參數

③使用word2vec作為embedding的參數並繼續fine-tune

相信會有一些有意思的發現。

但是你可能沒時間(~~多半是懶!~~),所以這裡我也告訴大家我的實驗結果:

①效果最差,時間最長

②效果最好,時間較長

③效果中等,時間最快


本文帶著讀者詳細的了解了使用keras進行文本預處理,如何將詞向量加入到訓練模型中提升性能,動手的讀者更可以體會到不同詞向量使用方法的差別。

這裡,我們差不多直觀上感受到了NLP是啥感覺,後面的文章,會主要探討一下Attention機制在這個基礎上的應用,然後我們還會嘗試使用CNN來做一下同樣的任務,看看效果如何。相信我們會有新的發現!

如果喜歡我的教程,歡迎關注我的專欄

【 DeepLearning.ai學習筆記】

和我一起一步步學習深度學習。 歡迎來微信公眾號 SimpleAI 踩踩喲ヾ(????)?"~專欄文章目錄:Hello NLP(1)——詞向量Why&How【DL筆記1】Logistic回歸:最基礎的神經網路【DL筆記2】神經網路編程原則&Logistic Regression的演算法解析【DL筆記3】一步步用python實現Logistic回歸【DL筆記4】神經網路詳解,正向傳播和反向傳播【DL筆記5】TensorFlow搭建神經網路:手寫數字識別【DL筆記6】從此明白了卷積神經網路(CNN)【DL筆記7】他山之玉——窺探CNN經典模型【DL筆記8】如果你願意一層一層剝開CNN的心【DL筆記9】搭建CNN哪家強?TF,Keras誰在行?【DL碎片1】神經網路參數初始化的學問【DL碎片2】神經網路中的優化演算法【DL碎片3】神經網路中的激活函數及其對比【DL碎片4】深度學習中的的超參數調節【DL碎片5】深度學習中的正則化Regularization
推薦閱讀:
相关文章