序列數據的處理,從語言模型 N-gram 模型說起,然后着重談談 RNN,並通過 RNN 的變種 LSTM 和 GRU 來實戰文本分類。
語言模型 N-gram 模型
一般自然語言處理的傳統方法是將句子處理為一個詞袋模型(Bag-of-Words,BoW),而不考慮每個詞的順序,比如用朴素貝葉斯算法進行垃圾郵件識別或者文本分類。在中文里有時候這種方式沒有問題,因為有些句子即使把詞的順序打亂,還是可以看懂這句話在說什么,比如:
T:研究表明,漢字的順序並不一定能影響閱讀,比如當你看完這句話后。
F:研表究明,漢字的序順並不定一能影閱響讀,比如當你看完這句話后。
但有時候不行,詞的順序打亂,句子意思就變得讓人不可思議了,例如:
T:我喜歡吃燒烤。
F:燒烤喜歡吃我。
那么,有沒有模型是考慮句子中詞與詞之間的順序的呢?有,語言模型中的 N-gram 就是一種。
N-gram 模型是一種語言模型(Language Model,LM),是一個基於概率的判別模型,它的輸入是一句話(詞的順序序列),輸出是這句話的概率,即這些詞的聯合概率(Joint Probability)。
使用 N-gram 語言模型思想,一般是需要知道當前詞以及前面的詞,因為一個句子中每個詞的出現並不是獨立的。比如,如果第一個詞是“空氣”,接下來的詞是“很”,那么下一個詞很大概率會是“新鮮”。類似於我們人的聯想,N-gram 模型知道的信息越多,得到的結果也越准確。
基於 sklearn 的詞袋模型,嘗試加入抽取 2-gram
和 3-gram
的統計特征,把詞庫的量放大,獲得更強的特征。
通過 ngram_range 參數來控制,代碼如下:
from sklearn.feature_extraction.text import CountVectorizer vec = CountVectorizer( analyzer='word', # tokenise by character ngrams ngram_range=(1,4), # use ngrams of size 1 and 2 max_features=20000, # keep the most common 1000 ngrams )
因此,N-gram 模型,在自然語言處理中主要應用在如詞性標注、垃圾短信分類、分詞器、機器翻譯和語音識別、語音識別等領域。
然而 N-gram 模型並不是完美的,它存在如下優缺點:
-
優點:包含了前 N-1 個詞所能提供的全部信息,這些詞對於當前詞的出現概率具有很強的約束力;
-
缺點:需要很大規模的訓練文本來確定模型的參數,當 N 很大時,模型的參數空間過大。所以常見的 N 值一般為1,2,3等。還有因數據稀疏而導致的數據平滑問題,解決方法主要是拉普拉斯平滑和內插與回溯。
所以,根據 N-gram 的優缺點,它的進化版 NNLM(Neural Network based Language Model)誕生了。
NNLM 由 Bengio 在2003年提出,它是一個很簡單的模型,由四層組成,輸入層、嵌入層、隱層和輸出層,模型結構如下圖:
NNLM 接收的輸入是長度為 N 的詞序列,輸出是下一個詞的類別。首先,輸入是詞序列的 index 序列,例如詞“我”在字典(大小為|V|)中的 index 是10,詞“是”的 index 是23, “小明”的 index 是65,則句子“我是小明”的 index 序列就是 10、 23、65。嵌入層(Embedding)是一個大小為 |V|×K
的矩陣,從中取出第10、23、65行向量拼成 3×K 的矩陣就是 Embedding 層的輸出了。隱層接受拼接后的 Embedding 層輸出作為輸入,以 tanh 為激活函數,最后送入帶 softmax 的輸出層,輸出概率。
NNLM 最大的缺點就是參數多,訓練慢,要求輸入定長 N 這一點很不靈活,同時不能利用完整的歷史信息。
因此,針對 NNLM 存在的問題,Mikolov 在2010年提出了 RNNLM,有興趣可以閱讀相關論文,其結構實際上是用 RNN 代替 NNLM 里的隱層,這樣做的好處,包括減少模型參數、提高訓練速度、接受任意長度輸入、利用完整的歷史信息。同時,RNN 的引入意味着可以使用 RNN 的其他變體,像 LSTM、BLSTM、GRU 等等,從而在序列建模上進行更多更豐富的優化。
以上,從詞袋模型說起,引出語言模型 N-gram 以及其優化模型 NNLM 和 RNNLM,后續內容從 RNN 說起,來看看其變種 LSTM 和 GRU 模型如何處理類似序列數據。
RNN 以及變種 LSTM 和 GRU 原理
RNN 為序列數據而生
RNN 稱為循環神經網路,因為這種網絡有“記憶性”,主要應用在自然語言處理(NLP)和語音領域。RNN 具體的表現形式為網絡會對前面的信息進行記憶並應用於當前輸出的計算中,即隱藏層之間的節點不再無連接而是有連接的,並且隱藏層的輸入不僅包括輸入層的輸出還包括上一時刻隱藏層的輸出。
理論上,RNN 能夠對任何長度的序列數據進行處理,但由於該網絡結構存在“梯度消失”問題,所以在實際應用中,解決梯度消失的方法有:梯度裁剪(Clipping Gradient)和 LSTM(Long Short-Term Memory
)。
下圖是一個簡單的 RNN 經典結構:
RNN 包含輸入單元(Input Units),輸入集標記為 \{x_0,x_1,...,x_t,x_t...\}{x0,x1,...,xt,xt...};輸出單元(Output Units)的輸出集則被標記為 \{y_0,y_1,...,y_t,...\}{y0,y1,...,yt,...};RNN 還包含隱藏單元(Hidden Units),我們將其輸出集標記為 \{h_0,h_1,...,h_t,...\}{h0,h1,...,ht,...},這些隱藏單元完成了最為主要的工作。
LSTM 結構
LSTM 在1997年由“Hochreiter & Schmidhuber”提出,目前已經成為 RNN 中的標准形式,用來解決上面提到的 RNN 模型存在“長期依賴”的問題。
LSTM 通過三個“門”結構來控制不同時刻的狀態和輸出。所謂的“門”結構就是使用了 Sigmoid 激活函數的全連接神經網絡和一個按位做乘法的操作,Sigmoid 激活函數會輸出一個0~1之間的數值,這個數值代表當前有多少信息能通過“門”,0表示任何信息都無法通過,1表示全部信息都可以通過。其中,“遺忘門”和“輸入門”是 LSTM 單元結構的核心。下面我們來詳細分析下三種“門”結構。
-
遺忘門,用來讓 LSTM“忘記”之前沒有用的信息。它會根據當前時刻節點的輸入 X_tXt、上一時刻節點的狀態 Ct−1Ct−1 和上一時刻節點的輸出 h_{t-1}ht−1 來決定哪些信息將被遺忘。
-
輸入門,LSTM 來決定當前輸入數據中哪些信息將被留下來。在 LSTM 使用遺忘門“忘記”部分信息后需要從當前的輸入留下最新的記憶。輸入門會根據當前時刻節點的輸入 X_tXt、上一時刻節點的狀態 C_{t-1}Ct−1 和上一時刻節點的輸出 h_{t-1}ht−1 來決定哪些信息將進入當前時刻節點的狀態 C_tCt,模型需要記憶這個最新的信息。
-
輸出門,LSTM 在得到最新節點狀態 C_tCt 后,結合上一時刻節點的輸出 h_{t-1}ht−1 和當前時刻節點的輸入 X_tXt 來決定當前時刻節點的輸出。
GRU 結構
GRU(Gated Recurrent Unit)是2014年提出來的新的 RNN 架構,它是簡化版的 LSTM。下面是 LSTM 和 GRU 的結構比較圖(來自於網絡):
在超參數均調優的前提下,據說效果和 LSTM 差不多,但是參數少了1/3,不容易過擬合。如果發現 LSTM 訓練出來的模型過擬合比較嚴重,可以試試 GRU。
基於 Keras 的 LSTM 和 GRU 文本分類
上面講了那么多,但是 RNN 的知識還有很多,比如雙向 RNN 等,這些需要自己去學習,下面,我們來實戰一下基於 LSTM 和 GRU 的文本分類。
本次開發使用 Keras 來快速構建和訓練模型,使用的數據集還是第06課使用的司法數據。
整個過程包括:
- 語料加載
- 分詞和去停用詞
- 數據預處理
- 使用 LSTM 分類
- 使用 GRU 分類
第一步,引入數據處理庫,停用詞和語料加載:
# 引入包 import random import jieba import pandas as pd # 加載停用詞 stopwords = pd.read_csv('stopwords.txt', index_col=False, quoting=3, sep="\t", names=['stopword'], encoding='utf-8') stopwords = stopwords['stopword'].values # 加載語料 laogong_df = pd.read_csv('beilaogongda.csv', encoding='utf-8', sep=',') laopo_df = pd.read_csv('beilaogongda.csv', encoding='utf-8', sep=',') erzi_df = pd.read_csv('beierzida.csv', encoding='utf-8', sep=',') nver_df = pd.read_csv('beinverda.csv', encoding='utf-8', sep=',') # 刪除語料的nan行 laogong_df.dropna(inplace=True) laopo_df.dropna(inplace=True) erzi_df.dropna(inplace=True) nver_df.dropna(inplace=True) # 轉換 laogong = laogong_df.segment.values.tolist() laopo = laopo_df.segment.values.tolist() erzi = erzi_df.segment.values.tolist() nver = nver_df.segment.values.tolist()
第二步,分詞和去停用詞:
#定義分詞和打標簽函數preprocess_text #參數content_lines即為上面轉換的list #參數sentences是定義的空list,用來儲存打標簽之后的數據 #參數category 是類型標簽 def preprocess_text(content_lines, sentences, category): for line in content_lines: try: segs=jieba.lcut(line) segs = [v for v in segs if not str(v).isdigit()]#去數字 segs = list(filter(lambda x:x.strip(), segs)) #去左右空格 segs = list(filter(lambda x:len(x)>1, segs))#長度為1的字符 segs = list(filter(lambda x:x not in stopwords, segs)) #去掉停用詞 sentences.append((" ".join(segs), category))# 打標簽 except Exception: print(line) continue #調用函數、生成訓練數據 sentences = [] preprocess_text(laogong, sentences,0) preprocess_text(laopo, sentences, 1) preprocess_text(erzi, sentences, 2) preprocess_text(nver, sentences, 3)
第三步,先打散數據,使數據分布均勻,然后獲取特征和標簽列表:
# 打散數據,生成更可靠的訓練集 random.shuffle(sentences) # 控制台輸出前10條數據,觀察一下 for sentence in sentences[:10]: print(sentence[0], sentence[1]) # 所有特征和對應標簽 all_texts = [sentence[0] for sentence in sentences] all_labels = [sentence[1] for sentence in sentences]
第四步,使用 LSTM 對數據進行分類:
# 引入需要的模塊 from keras.preprocessing.text import Tokenizer from keras.preprocessing.sequence import pad_sequences from keras.utils import to_categorical from keras.layers import Dense, Input, Flatten, Dropout from keras.layers import LSTM, Embedding, GRU from keras.models import Sequential # 預定義變量 MAX_SEQUENCE_LENGTH = 100 # 最大序列長度 EMBEDDING_DIM = 200 # embdding 維度 VALIDATION_SPLIT = 0.16 # 驗證集比例 TEST_SPLIT = 0.2 # 測試集比例 # keras的sequence模塊文本序列填充 tokenizer = Tokenizer() tokenizer.fit_on_texts(all_texts) sequences = tokenizer.texts_to_sequences(all_texts) word_index = tokenizer.word_index print('Found %s unique tokens.' % len(word_index)) data = pad_sequences(sequences, maxlen=MAX_SEQUENCE_LENGTH) labels = to_categorical(np.asarray(all_labels)) print('Shape of data tensor:', data.shape) print('Shape of label tensor:', labels.shape) # 數據切分 p1 = int(len(data) * (1 - VALIDATION_SPLIT - TEST_SPLIT)) p2 = int(len(data) * (1 - TEST_SPLIT)) x_train = data[:p1] y_train = labels[:p1] x_val = data[p1:p2] y_val = labels[p1:p2] x_test = data[p2:] y_test = labels[p2:] # LSTM訓練模型 model = Sequential() model.add(Embedding(len(word_index) + 1, EMBEDDING_DIM, input_length=MAX_SEQUENCE_LENGTH)) model.add(LSTM(200, dropout=0.2, recurrent_dropout=0.2)) model.add(Dropout(0.2)) model.add(Dense(64, activation='relu')) model.add(Dense(labels.shape[1], activation='softmax')) model.summary() # 模型編譯 model.compile(loss='categorical_crossentropy', optimizer='rmsprop', metrics=['acc']) print(model.metrics_names) model.fit(x_train, y_train, validation_data=(x_val, y_val), epochs=10, batch_size=128) model.save('lstm.h5') # 模型評估 print(model.evaluate(x_test, y_test))
訓練過程結果為:
第五步,使用 GRU 進行文本分類,上面就是完整的使用 LSTM 進行 文本分類,如果使用 GRU 只需要改變模型訓練部分:
model = Sequential() model.add(Embedding(len(word_index) + 1, EMBEDDING_DIM, input_length=MAX_SEQUENCE_LENGTH)) model.add(GRU(200, dropout=0.2, recurrent_dropout=0.2)) model.add(Dropout(0.2)) model.add(Dense(64, activation='relu')) model.add(Dense(labels.shape[1], activation='softmax')) model.summary() model.compile(loss='categorical_crossentropy', optimizer='rmsprop', metrics=['acc']) print(model.metrics_names) model.fit(x_train, y_train, validation_data=(x_val, y_val), epochs=10, batch_size=128) model.save('lstm.h5') print(model.evaluate(x_test, y_test))
訓練過程結果:
1 # 引入包 2 import random 3 import jieba 4 import pandas as pd 5 6 # 加載停用詞 7 stopwords = pd.read_csv('./data6/stopwords.txt', index_col=False, quoting=3, sep="\t", names=['stopword'], encoding='utf-8') 8 stopwords = stopwords['stopword'].values 9 10 # 加載語料 11 laogong_df = pd.read_csv('./data6/beilaogongda.csv', encoding='utf-8', sep=',') 12 laopo_df = pd.read_csv('./data6/beilaogongda.csv', encoding='utf-8', sep=',') 13 erzi_df = pd.read_csv('./data6/beierzida.csv', encoding='utf-8', sep=',') 14 nver_df = pd.read_csv('./data6/beinverda.csv', encoding='utf-8', sep=',') 15 16 # 刪除語料的nan行 17 laogong_df.dropna(inplace=True) 18 laopo_df.dropna(inplace=True) 19 erzi_df.dropna(inplace=True) 20 nver_df.dropna(inplace=True) 21 # 轉換 22 laogong = laogong_df.segment.values.tolist() 23 laopo = laopo_df.segment.values.tolist() 24 erzi = erzi_df.segment.values.tolist() 25 nver = nver_df.segment.values.tolist() 26 27 28 # 定義分詞和打標簽函數preprocess_text 29 # 參數content_lines即為上面轉換的list 30 # 參數sentences是定義的空list,用來儲存打標簽之后的數據 31 # 參數category 是類型標簽 32 def preprocess_text(content_lines, sentences, category): 33 for line in content_lines: 34 try: 35 segs = jieba.lcut(line) 36 segs = [v for v in segs if not str(v).isdigit()] # 去數字 37 segs = list(filter(lambda x: x.strip(), segs)) # 去左右空格 38 segs = list(filter(lambda x: len(x) > 1, segs)) # 長度為1的字符 39 segs = list(filter(lambda x: x not in stopwords, segs)) # 去掉停用詞 40 sentences.append((" ".join(segs), category)) # 打標簽 41 except Exception: 42 print(line) 43 continue 44 45 # 調用函數、生成訓練數據 46 47 48 sentences = [] 49 preprocess_text(laogong, sentences, 0) 50 preprocess_text(laopo, sentences, 1) 51 preprocess_text(erzi, sentences, 2) 52 preprocess_text(nver, sentences, 3) 53 54 # 打散數據,生成更可靠的訓練集 55 random.shuffle(sentences) 56 57 # 控制台輸出前10條數據,觀察一下 58 for sentence in sentences[:10]: 59 print(sentence[0], sentence[1]) 60 # 所有特征和對應標簽 61 all_texts = [sentence[0] for sentence in sentences] 62 all_labels = [sentence[1] for sentence in sentences] 63 64 # 引入需要的模塊 65 from keras.preprocessing.text import Tokenizer 66 from keras.preprocessing.sequence import pad_sequences 67 from keras.utils import to_categorical 68 from keras.layers import Dense, Input, Flatten, Dropout 69 from keras.layers import LSTM, Embedding, GRU 70 from keras.models import Sequential 71 import numpy as np 72 73 # 預定義變量 74 MAX_SEQUENCE_LENGTH = 100 # 最大序列長度 75 EMBEDDING_DIM = 200 # embdding 維度 76 VALIDATION_SPLIT = 0.16 # 驗證集比例 77 TEST_SPLIT = 0.2 # 測試集比例 78 # keras的sequence模塊文本序列填充 79 tokenizer = Tokenizer() 80 tokenizer.fit_on_texts(all_texts) 81 sequences = tokenizer.texts_to_sequences(all_texts) 82 word_index = tokenizer.word_index 83 print('Found %s unique tokens.' % len(word_index)) 84 data = pad_sequences(sequences, maxlen=MAX_SEQUENCE_LENGTH) 85 labels = to_categorical(np.asarray(all_labels)) 86 print('Shape of data tensor:', data.shape) 87 print('Shape of label tensor:', labels.shape) 88 89 # 數據切分 90 p1 = int(len(data) * (1 - VALIDATION_SPLIT - TEST_SPLIT)) 91 p2 = int(len(data) * (1 - TEST_SPLIT)) 92 x_train = data[:p1] 93 y_train = labels[:p1] 94 x_val = data[p1:p2] 95 y_val = labels[p1:p2] 96 x_test = data[p2:] 97 y_test = labels[p2:] 98 99 # LSTM訓練模型 100 model = Sequential() 101 model.add(Embedding(len(word_index) + 1, EMBEDDING_DIM, input_length=MAX_SEQUENCE_LENGTH)) 102 model.add(LSTM(200, dropout=0.2, recurrent_dropout=0.2)) 103 model.add(Dropout(0.2)) 104 model.add(Dense(64, activation='relu')) 105 model.add(Dense(labels.shape[1], activation='softmax')) 106 model.summary() 107 # 模型編譯 108 model.compile(loss='categorical_crossentropy', 109 optimizer='rmsprop', 110 metrics=['acc']) 111 print(model.metrics_names) 112 model.fit(x_train, y_train, validation_data=(x_val, y_val), epochs=10, batch_size=128) 113 model.save('lstm.h5') 114 # 模型評估 115 print(model.evaluate(x_test, y_test)) 116 117 model = Sequential() 118 model.add(Embedding(len(word_index) + 1, EMBEDDING_DIM, input_length=MAX_SEQUENCE_LENGTH)) 119 model.add(GRU(200, dropout=0.2, recurrent_dropout=0.2)) 120 model.add(Dropout(0.2)) 121 model.add(Dense(64, activation='relu')) 122 model.add(Dense(labels.shape[1], activation='softmax')) 123 model.summary() 124 125 model.compile(loss='categorical_crossentropy', 126 optimizer='rmsprop', 127 metrics=['acc']) 128 print(model.metrics_names) 129 model.fit(x_train, y_train, validation_data=(x_val, y_val), epochs=10, batch_size=128) 130 model.save('lstm.h5') 131 132 print(model.evaluate(x_test, y_test))