Keras之文本分類實現

轉自知乎:https://zhuanlan.zhihu.com/p/29201491

寫在前面

從優達DLND畢業後,一直想自己動手做點什麼來着,互助班的導師也鼓勵自己動手寫點心得體驗啥的。之前一直沒怎麼觀看Youtube網紅Siraj老師的課程視頻,他每個視頻最後都會有一個編程挑戰。於是,想着先從自己熟悉的內容着手吧,Siraj老師第三週的編程挑戰是做一個多類別的文本分類器,鏈接在此:Github,那就來試試吧。除了想自己練練手外,也順便把模型都好好梳理一遍。爲了給自己增加些難度,是否有可能把過去幾年內那些大牛們論文中的模型復現出來呢?閱讀這篇文章,需要你對自然語言處理和深度學習的模型有一個基礎的瞭解哦!

另外,需要聲明的是,本文在寫作過程中或多或少參考瞭如下大牛們的博客:

  1. 用深度學習(CNN RNN Attention)解決大規模文本分類問題 - 綜述和實踐
  2. 卷積神經網絡(CNN)在句子建模上的應用
  3. 深度學習在文本分類中的應用
  4. 深度學習與文本分類總結第一篇--常用模型總結
  5. 優達學城深度學習基石納米學位課程

文本多分類

首先我們來看下數據集長什麼樣子吧 :P Let‘s get started!

我們使用pandas來加載數據,數據集來自IGN.com,收集了過去20年各大遊戲廠商發佈的遊戲數據,如發佈日期,發佈平臺,遊戲評價等變量,這裏有一篇關於這個數據集很不錯的分析教程 Kaggle 。而我們現在想分析下游戲名與用戶評價之間的關係,看上去並不合理,我們姑且按照Siraj老師的任務來試試。於是,遊戲名作爲文本變量將作爲模型的輸入X,而用戶評價詞作爲文本類別Y。然後來看看各個類別的數量,爲了避免類別樣本數的不平衡,我們這裏把關於評價爲Disaster的遊戲去除。

df = pd.read_csv('ign.csv').iloc[:, 1:3]
df.score_phrase.value_counts()
df = df[df.score_phrase != 'Disaster']

首先我們先來試試傳統機器學習模型對文本分類任務常見的做法吧

傳統文本分類方法

詞袋模型

由於計算機只能處理數字型的變量,並不能直接把文本丟給計算機然後讓它告訴我們這段文字的類別。於是,我們需要對詞進行one-hot編碼。假設我們總共有N個詞,然後對詞進行索引編碼並構造一個N維零向量,如果這個文本中的某些詞出現,就在該詞索引值位置標記爲1,表示這個文本包含這個詞。於是,我們會得到如下類似的向量

( 0, 0, 1, 0, .... , 1, ... 0, 0, 1, 0)

但是,一般來說詞庫量至少都是百萬級別,因此詞袋模型有個兩個最大的問題:高維度、高稀疏性。這種表示方法還存在一個重要的問題就是”詞彙鴻溝”現象:任意兩個詞之間都是孤立的。光從這兩個向量中看不出兩個詞是否有關係。

共現矩陣

爲了使用上下文來表示單詞間的關係,也有人提出使用基於窗口大小的共現矩陣,但仍然存在數據維度大稀疏的問題。

TF-IDF

TF-IDF 用以評估一字詞對於一個文檔集或一個語料庫中的其中一份文檔的重要程度,是一種計算特徵權重的方法。核心思想即,字詞的重要性隨着它在文檔中出現的次數成正比增加,但同時會隨着它在語料庫中出現的頻率成反比下降。有效地規避了那些高頻卻包含很少信息量的詞。我們這裏也是用TF-IDF 對文本變量進行特徵提取。

分類器

分類器就是常見的機器學習分類模型了,常用的有以下兩種,這裏我不再贅述這兩個模型的原理了。

  • 樸素貝葉斯:從垃圾郵件識別應用開始被廣泛使用
  • 支持向量機:這篇文章 很通俗地解釋了SVM的工作原理

使用Scikit-Learn庫能夠傻瓜似的來實現你的機器學習模型,我們這裏使用TfidfVectorizer函數對文本進行特徵處理,並去除停用詞,模型有多類別樸素貝葉斯和線性SVM分類器。結果很不令人滿意,NB模型結果稍好,準確率爲28%,領先SVM 1%。下面我們來看看深度學習模型強大的性能。

vect = TfidfVectorizer(stop_words='english', token_pattern=r'\b\w{2,}\b',
                       min_df=1, max_df=0.1, ngram_range=(1,2))
mnb = MultinomialNB(alpha=2)
svm = SGDClassifier(loss='hinge', penalty='l2', alpha=1e-3, max_iter=5, random_state=42)
mnb_pipeline = make_pipeline(vect, mnb)
svm_pipeline = make_pipeline(vect, svm)
mnb_cv = cross_val_score(mnb_pipeline, title, label, scoring='accuracy', cv=10, n_jobs=-1)
svm_cv = cross_val_score(svm_pipeline, title, label, scoring='accuracy', cv=10, n_jobs=-1)
print('\nMultinomialNB Classifier\'s Accuracy: %0.5f\n' % mnb_cv.mean())
print('\nSVM Classifier\'s Accuracy: %0.5f\n' % svm_cv.mean())

走進NLP和DL

傳統方法對於文本的特徵表達能力很弱,神經網絡同樣不擅長處理這樣高維度高稀疏性的數據,因此我們需要對文本做進一步的特徵處理,這裏就要講到詞嵌入的概念了。深度學習模型中,一個單詞常常用一個低維且稠密的向量來表示,如下所示:

( 0.286, 0.792, -0.177, -0.107, .... , 0.109, ... 0.349, 0.271, -0.642)

詞向量

主流的詞嵌入實現方法有Mikolov的Word2Vec和斯坦福大學的Glove,也有人做過實驗來比較這兩種方法的優劣,並沒有多大的差異。Word2Vec是基於預測的詞向量模型,簡單來說,就是給定一個詞,去預測這個詞周圍可能出現的詞,或者給定一些詞來確定位於中心位置的詞。而Glove是基於統計方法的,通過對詞-詞共現矩陣裏的非零元素進行訓練。總體來說,Word2Vec使用的是局部信息,而Glove使用的是全局信息,因此前者訓練起來更慢但不佔用內存,而Glove通過更多的計算資源換取訓練速度上的提升。具體的實現細節可以參考這兩篇論文。本文中將使用預訓練的Glove300維詞向量和由自己文本生成的詞向量。

爲了快速實現模型,這篇文章中將使用Keras(TensorFlow的高級API)來完成。要使用Keras前,你必須安裝TensorFlow作爲其後端,Keras目前支持Tensorflow、Theano和CNTK作爲後端,不過我還是推薦大家使用TensorFlow。Keras的中文文檔能夠幫助大家無坑完成安裝過程。

我們先來了解一些基礎的深度學習模型吧!

CNN

深度學習入門必學的兩大模型之一卷積神經網絡。首先我們來理解下什麼是卷積操作?卷積,你可以把它想象成一個應用在矩陣上的滑動窗口函數。下圖中左邊的矩陣表示的是一張黑白圖像。每個方格代表了一個像素,0表示黑色,1表示白色。這個滑動窗口稱作kernel或者filter。這裏我們使用的是一個3*3的filter,將它的值和與其對應的原圖像矩陣進行相乘,然後再將它們相加。這樣我們在整個原圖矩陣上滑動filter來遍歷所有像素後得到一個完整的卷積特徵。

卷積網絡也就是對輸入樣本進行多次卷積操作,提取數據中的局部位置的特徵,然後再拼接池化層(圖中的Pooling層)做進一步的降維操作,最後與全連接層拼接完成對輸入樣本的全新的特徵構造,將新的特徵向量輸送給分類器(以圖片分類爲例)進行預測分類。我們可以把CNN類比N-gram模型,N-gram也是基於詞窗範圍這種局部的方式對文本進行特徵提取,與CNN的做法很類似,在下文中,我們再來看看如何運用CNN對文本數據進行建模。

卷積網絡開始嶄露頭角是在CV領域,2012年的ImageNet競賽中,大大降低了圖片分類的錯誤率。爲什麼CNN在計算機視覺領域這麼厲害呢?直觀的感受就是:

  • 它能夠學習識別基本的直線,曲線,然後是形狀,點塊,然後是圖片中更復雜的物體。最終CNN分類器把這些大的,複雜的物體綜合起來識別圖片
  • 在下圖中的例子中,可以看作這樣的層級關係:
    • 簡單的形狀,如橢圓,暗色圓圈
    • 複雜的物體(簡單形狀的組合),例如眼睛,鼻子,毛髮
    • 狗的整體(複雜物體的組合)

RNN

而循環網絡與CNN不同的是,CNN學習空間位置上局部位置的特徵表示,而RNN學習的是時間順序上的特徵,用來處理序列數據,如股價,文本等。RNN之所以稱爲循環神經網路,即一個序列當前的輸出與前面的輸出也有關。具體的表現形式爲網絡會對前面的信息進行記憶並應用於當前輸出的計算中,即隱藏層之間的節點是相互連接的,並且隱藏層的輸入不僅包括輸入層的輸出還包括上一時刻隱藏層的輸出。

就像我們說話一樣,我們不能把說的話倒過來表示,這樣會變得毫無意義,並不會明白你在說什麼,也就是說文本中的每個詞是包含順序信息的,由此可以使用RNN對文本數據進行建模。

但是,隨着時間的不斷增加,你的隱藏層一次又一次地乘以權重W。假如某個權重w是一個接近於0或者大於1的數,隨着乘法次數的增加,這個權重值會變得很小或者很大,造成反向傳播時梯度計算變得很困難,造成梯度爆炸或者梯度消失的情況,模型難以訓練。也就是說一般的RNN模型對於長時間距離的信息記憶很差,比如人老了會忘記某件很久發生的事情一樣,於是,LSTM和GRU 應運而生。LSTM與GRU很相似,以LSTM爲例。

LSTM又稱爲長短期記憶網絡,LSTM 單元乍看起來很複雜。關鍵的新增部分就在於標記爲 C 的單元狀態。在這個單元中,有四個顯示爲黃色框的網絡層,每個層都有自己的權重,如以 σ 標記的層是 sigmoid 層。這些紅圈表示逐點或逐元素操作。單元狀態在通過 LSTM 單元時幾乎沒有交互,使得大部分信息得以保留,單元狀態僅通過這些控制門(gate)進行修改。第一個控制門是遺忘門,用來決定我們會從單元狀態中丟棄什麼信息。第二個門是更新們,用以確定什麼樣的新信息被存放到單元狀態中。最後一個門是輸出門,我們需要確定輸出什麼樣的值。總結來說 LSTM 單元由單元狀態和一堆用於更新信息的控制門組成,讓信息部分傳遞到隱藏層狀態。更直觀的來講,把LSTM看作是一部電影,可以把單元狀態看作是劇情主線,而隨着劇情的發展,有些不必要的事件會被遺忘,而一些更加影響主線的劇情會被加入到單元狀態中來,不斷更新劇情然後輸出新的劇情發展。

Attention機制

基於Attention的模型在NLP領域首先被應用於自然語言生成問題中,用於改進機器翻譯任務的性能。我們這裏也以機器翻譯爲例來解釋下注意力機制的原理。我們可以把翻譯任務是一個序列向另一個序列轉換的過程。

上圖就是Seq2Seq模型的基本結構,由編碼器(Encoder)和解碼器(Decoder)組成。編碼器負責將輸入的單詞按順序進行信息提取,在最後一步生成的隱藏狀態即固定長度的句子的特徵向量。然後解碼器從這個句子向量中獲取信息對文本進行翻譯。由於解碼器的主要信息來源就是最後一步的隱藏狀態,這個h3向量必須儘可能地包含句子的所有必要的信息。這個向量說白了就是句子嵌入(類比詞嵌入)。假如我們需要翻譯的文本不是很長,這個模型已經能達到很不錯的性能。假如我們現在要翻譯一句超過50個單詞的句子,似乎這個模型很難再hold住,即使你在訓練的時候使用了LSTM去提取句子特徵,去儘可能保留過去的記憶,但還是達不到想要的結果。

而注意力機制恰恰是爲了解決長距離依賴的問題,我們不再需要固定長度的句子向量,而是讓解碼器自己去輸入文本中尋找想要關注的被翻譯文本。比如把”I am learning deep learning model“成中文時,我們讓解碼器去與輸入文本中的詞對齊,翻譯deep的時候去關注deep這個詞,而不是平等對待每個有可能的詞,找到與輸入文本相對應的相同語義的詞,而不再是對句子進行特徵提取。

上圖我們可以看到,解碼器在翻譯下一個詞時,需要依賴之前已經翻譯好的文本和與輸入文本相對齊的那個詞。簡單描述的話,用解碼器t時刻的隱藏狀態去和輸入文本中的每個單詞對應的隱藏狀態去比對,通過某個函數f去計算帶翻譯的單詞yi與每個輸入單詞對齊的可能性。而編碼器由Bi-LSTM模型組成。不同的語言的f函數可能會有差別,就像中文和英文,語法結構差異很大,很難按順序單詞一一對齊。由此可以得出結論,注意力機制的核心思想是在翻譯每個目標詞(或對文本進行分類時)所用的上下文是不同的,這樣的考慮顯然是更合理的。具體實現請見這篇論文。而如何將注意力機制運用到文本分類中來,下文會介紹。

深度學習文本分類模型

TextCNN

這是CNN首次被應用於文本分類任務的開山之作,可以說,之後很多論文都是基於此進行拓展的。它是由Yoon Kim於2014年發表的,你可以在github上找到各種不同深度學習框架對於這個模型的實現。下面我們來細細品讀這篇論文吧。

上圖很好地詮釋了模型的框架。假設我們有一句句子需要對其進行分類。句子中每個詞是由n維詞向量組成的,也就是說輸入矩陣大小爲m*n,其中m爲句子長度。CNN需要對輸入樣本進行卷積操作,對於文本數據,filter不再橫向滑動,僅僅是向下移動,有點類似於N-gram在提取詞與詞間的局部相關性。圖中共有三種步長策略,分別是2,3,4,每個步長都有兩個filter(實際訓練時filter數量會很多)。在不同詞窗上應用不同filter,最終得到6個卷積後的向量。然後對每一個向量進行最大化池化操作並拼接各個池化值,最終得到這個句子的特徵表示,將這個句子向量丟給分類器進行分類,至此完成整個流程。

文中作者還提出了動態的詞向量,即將詞向量也作爲權重變量進行訓練,而我們平時常用的產生詞向量的方法有從當前數據集中自己產生的詞向量和使用預訓練好的word2vec或glove詞向量,都屬於靜態詞向量範疇,即它們不再網絡訓練時發生變化。文中實驗表明,動態的詞向量表現更好。有時間的話之後來嘗使用Tensorflow來實現這種動態詞向量。另外,這篇論文 A Sensitivity Analysis of (and Practitioners' Guide to) Convolutional Neural Networks for Sentence Classification詳細地闡述了關於TextCNN模型的調參心得。

DCNN

這篇論文的亮點在於採用的動態的K-max Pooling,而不是我們常見的Max Pooling層。模型細節主要分爲以下幾個部分:

  1. 寬卷積:卷積分爲兩種,窄卷積和寬卷積。窄卷積即從第一個元素開始卷積操作,這樣第一個元素和最後一個元素只能被filter掃過一次。而寬卷積爲了彌補這一點,就在第一個元素前和最後一個元素後增加0作爲補充,因此寬卷積又叫做補零法。當filter長度相對輸入向量的長度較大時,你會發現寬卷積很有用,或者說很有必要。
  2. 動態的K-Max Pooling:下圖中可以看到兩個個池化層的K是不確定的,即動態的,具體的取值依賴於輸入和網絡的其他參數。文中提到,K的取值與輸入文本的長度,網絡中總共的卷積數,上一層卷積的個數有關,具體可以查看論文(始終沒有解決數學公式的顯示問題,所以不展開了)。因此文中的pooling的結果不是返回一個最大值,而是返回k組最大值,這些最大值是原輸入的一個子序列
  3. Folding層:圖中很清楚地看到,Folding層將詞向量的維度縮減一半,即將兩行的向量相加,可能考慮相鄰兩行之前某種未知的聯繫吧。不確定是否真的有效,第一次看到卷積網絡中出現這樣縮減維度的層。

Bi-LSTM

本篇論文由復旦大學的邱錫鵬教授的團隊於2015年發表,文中詳細地闡述了RNN模型用於文本分類任務的各種變體模型。最簡單的RNN用於文本分類如下圖所示,這是LSTM用於網絡結構原理示意圖,示例中的是利用最後一個詞的結果直接接全連接層softmax輸出就完成了。詳情可見這裏的閱讀筆記鏈接

CLSTM

論文A C-LSTM Neural Network for Text Classification中將CNN和RNN混合使用作爲文本的分類器。其實就是將CNN訓練得到的新的特徵作爲LSTM的輸入,模型的簡單描述如下:

  • Feature maps指不同詞窗經過不同過濾層即卷積操作後得到的特徵集合
  • Window feature sequence是指CNN不再經過Max-pooling操作,而是將特徵集合重新排列,得到同一詞窗在經過不同卷積操作後的綜合特徵向量,即把相同顏色的放在一個序列裏面,然後依次排列下來
  • 在window feature sequence層的每個序列,其實和原始句子中的序列是對應的,保持了原有的相對順序,只不過是中間進行了卷積的操作,將這些新的向量作爲LSTM的輸入變量。

RCNN

這篇Recurrent Convolutional Neural Networks for Text Classification是由中科院與2015年發表在AAAI上的一篇文章。文中將RNN和CNN以另外一種方式呈現。

我們可以發現,這個模型是把CNN模型中的卷積的部分使用RNN代替了,最後加上池化層。而這個RNN層做的事情是,將每一個詞分別和左邊的詞以及右邊的詞進行融合。每以文本先經過1層雙向LSTM,該詞的左側的詞正向輸入進去得到一個詞向量,該詞的右側反向輸入進去得到一個詞向量。再結合該詞的詞向量,生成一個 3k維的組合詞向量。然後再將這些新的詞向量傳入全連接層,緊接着是最大化池化層進行特徵降維。最後接上全連接層,便完成多分類任務。

FastText

FastText是Facebook於2016年發表的論文中提出的一種簡單快速實現的 文本分類模型。可能你已經被前面那些複雜的模型搞得七葷八素了,那麼這個模型你很快地理解,令人意外的是,它的性能並不差。輸入變量是經過embedding的詞向量,這裏的隱藏層只是一個簡單的平均池化層,然後把這個池化過的向量丟給softmax分類器就完成了。另外,這裏的X並不僅僅是單個單詞,也可以加入N-gram組合的詞作爲輸入的一部分,文中將2-元和3-元的特徵也加入到了模型中。本文的思想在於通過簡單的特徵線性組合就可以達到不錯的分類性能,我們可以把fasttext當作是工業界一種快速實現模型的產物。

Hierarchical Attention Networks(HAN)

本文最大的特點是結合了注意力機制,併成功運用到文本分類任務中。模型如下圖所示,分爲兩大部分,分別是對句子建模和對文檔建模。之前提到的模型基本上都是在對句子進行建模,通過對句子中的詞進行特徵組合,形成句子向量。本文更進一步的是,對句子的上一級篇章進行建模。我們假設評論中有好幾句話,那麼我們首先要切分句子然後再切分詞,對於長評論的分類是一個不錯的選擇。

首先,詞向量會經過雙向LSTM網絡完成編碼,將隱藏層的輸出和注意力機制相結合,形成對句子的特徵表示。然後每一個句子相當於一個詞,再重複一次前一步詞到句子的建模過程,完成句子到文檔的建模過程。而注意力機制在這裏發揮的作用相當於去尋找這句句子中的核心詞或者這篇文檔中的核心句子。具體實現的過程可參照我接下來的代碼。這裏有關於在Keras中完成Attention層的構建的詳細討論。遊戲標題只涉及單句,因此構不成文檔,只需要 word-level 這一層的注意力即可。加入Attention之後最大的好處自然是能夠直觀的解釋各個句子和詞對分類類別的重要性。

Aspect Level Sentiment

首先來介紹先aspect的概念,給定一個句子和句子中出現的某個aspect,aspect-level 情感分析的目標是分析出這個句子在給定aspect上的情感傾向。例如:"Great food but the service was dreadful!" 在“food”這個aspect上,情感傾向爲正,而在 “service”這個aspect上情感傾向爲負。Aspect level的情感分析相對於文檔級別來說粒度更細。

這裏有兩篇閱讀筆記比較詳細地描述了兩篇相關論文:

    1. Aspect Level Sentiment Classification with Deep Memory Network
    2. Attention-based LSTM for Aspect-level Sentiment Classification

實戰!

首先我們先要對文本數據進行編碼,因爲模型只能接受數值型的數據。常見的編碼之前有提到過有One-Hot和詞嵌入。第一步,來劃分訓練樣本和測試樣本。如果需要將你的模型部署到產品中去,則需要更加複雜的劃分,詳情請見吳恩達最新的AI課程中提及的機器學習項目中必須注意的一些問題。這裏,我們不需要考慮太多的細節,就簡單劃分訓練集和測試集即可。

# 導入使用到的庫
from keras.preprocessing.sequence import pad_sequences
from keras.preprocessing.text import Tokenizer
from keras.layers.merge import concatenate
from keras.models import Sequential, Model
from keras.layers import Dense, Embedding, Activation, merge, Input, Lambda, Reshape
from keras.layers import Convolution1D, Flatten, Dropout, MaxPool1D, GlobalAveragePooling1D
from keras.layers import LSTM, GRU, TimeDistributed, Bidirectional
from keras.utils.np_utils import to_categorical
from keras import initializers
from keras import backend as K
from keras.engine.topology import Layer
from sklearn.naive_bayes import MultinomialNB
from sklearn.linear_model import SGDClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
import pandas as pd
import numpy as np

# 劃分訓練/測試集
X_train, X_test, y_train, y_test = train_test_split(title, label, test_size=0.1, random_state=42)

# 對類別變量進行編碼,共10類
y_labels = list(y_train.value_counts().index)
le = preprocessing.LabelEncoder()
le.fit(y_labels)
num_labels = len(y_labels)
y_train = to_categorical(y_train.map(lambda x: le.transform([x])[0]), num_labels)
y_test = to_categorical(y_test.map(lambda x: le.transform([x])[0]), num_labels)

# 分詞,構建單詞-id詞典
tokenizer = Tokenizer(filters=’!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n,lower=True,split=" ")
tokenizer.fit_on_texts(title)
vocab = tokenizer.word_index

# 將每個詞用詞典中的數值代替
X_train_word_ids = tokenizer.texts_to_sequences(X_train)
X_test_word_ids = tokenizer.texts_to_sequences(X_test)

# One-hot
x_train = tokenizer.sequences_to_matrix(X_train_word_ids, mode=‘binary’)
x_test = tokenizer.sequences_to_matrix(X_test_word_ids, mode=‘binary’)

# 序列模式
x_train = pad_sequences(X_train_word_ids, maxlen=20)
x_test = pad_sequences(X_test_word_ids, maxlen=20)

Keras提供兩大類模型框架。第一種是Sequential模式,就像搭積木一樣,將你想要的網絡層拼接起來,可以理解爲串聯。而另外一種是Model模式,需要你指定模型的輸入和輸出格式,更加靈活地組合你的網絡層,可以理解爲串聯加並聯。搭建完模型結構後,你需要對模型進行編譯,這一步你需要指定模型的損失函數,本文是文本多分類任務,所以損失函數是多類別的交叉熵函數。另外, 需要確定損失函數的優化算法和模型評估指標。Adam優化器是目前公認的各項任務中性能最優的,所以本文將全部使用Adam作爲優化器。這裏有一篇實戰教程對不同優化器做了性能評估。接着就是模型的訓練,使用fit函數,這裏需要指定的參數有輸入數據,批量大小,迭代輪數,驗證數據集等。然後,你就能看到你的模型開始愉快地運行起來了。Keras作爲Tensforflow的高級API,對很多細節進行了封裝,可以讓深度學習小白快速上手,如果你需要實現更加複雜的模型的話,就需要去好好研究下Tensorflow了。下一篇博文目標是用Tensorflow來實現簡單的機器翻譯任務,也爲接下來準備參加的AI Challenger比賽做準備!

One-Hot + MLP

MLP翻譯過來叫多層感知機,其實就是多隱藏層的網絡。如此簡單的模型,結果居然出奇的好!賣個關子,最後看看各個模型的最終準確率排名 = =

model = Sequential()
# 全連接層
model.add(Dense(512, input_shape=(len(vocab)+1,), activation=‘relu’))
# DropOut層
model.add(Dropout(0.5))
# 全連接層+分類器
model.add(Dense(num_labels,activation=‘softmax’))

model.compile(loss=‘categorical_crossentropy’,
optimizer=‘adam’,
metrics=[‘accuracy’])

model.fit(x_train, y_train,
batch_size=32,
epochs=15,
validation_data=(x_test, y_test))

CNN

模仿LeNet-5

LeNet-5是卷積神經網絡的作者Yann LeCun用於MNIST識別任務提出的模型。模型很簡單,就是卷積池化層的堆疊,最後加上幾層全連接層。我們依樣畫葫蘆,將它運用在文本分類任務中,只是模型的輸入不同。

# 模型結構:嵌入-卷積池化*2-dropout-BN-全連接-dropout-全連接
model.add(Embedding(len(vocab)+1, 300, input_length=20))
model.add(Convolution1D(256, 3, padding=‘same’))
model.add(MaxPool1D(3,3,padding=‘same’))
model.add(Convolution1D(128, 3, padding=‘same’))
model.add(MaxPool1D(3,3,padding=‘same’))
model.add(Convolution1D(64, 3, padding=‘same’))
model.add(Flatten())
model.add(Dropout(0.1))
model.add(BatchNormalization()) # (批)規範化層
model.add(Dense(256,activation=‘relu’))
model.add(Dropout(0.1))
model.add(Dense(num_labels,activation=‘softmax’))

model.compile(loss=‘categorical_crossentropy’,
optimizer=‘adam’,
metrics=[‘accuracy’])

model.fit(X_train_padded_seqs, y_train,
batch_size=32,
epochs=15,
validation_data=(X_test_padded_seqs, y_test))

TextCNN(這裏需要使用Model模式)

# 模型結構:詞嵌入-卷積池化3-拼接-全連接-dropout-全連接
main_input = Input(shape=(20,), dtype=‘float64’)
# 詞嵌入(使用預訓練的詞向量)
embedder = Embedding(len(vocab) + 1, 300, input_length = 20, weights = [embedding_matrix], trainable = False)
embed = embedder(main_input)
# 詞窗大小分別爲3,4,5
cnn1 = Convolution1D(256, 3, padding=‘same’, strides = 1, activation=‘relu’)(embed)
cnn1 = MaxPool1D(pool_size=4)(cnn1)
cnn2 = Convolution1D(256, 4, padding=‘same’, strides = 1, activation=‘relu’)(embed)
cnn2 = MaxPool1D(pool_size=4)(cnn2)
cnn3 = Convolution1D(256, 5, padding=‘same’, strides = 1, activation=‘relu’)(embed)
cnn3 = MaxPool1D(pool_size=4)(cnn3)
# 合併三個模型的輸出向量
cnn = concatenate([cnn1,cnn2,cnn3], axis=-1)
flat = Flatten()(cnn)
drop = Dropout(0.2)(flat)
main_output = Dense(num_labels, activation=‘softmax’)(drop)
model = Model(inputs = main_input, outputs = main_output)

DCNN(佔坑)

RNN

LSTM(你也可以換成GRU,經過多次試驗GRU的性能較LSTM稍好)

GRU採用與LSTM相似的單元結構用於控制信息的更新與保存,它將遺忘門和輸入門合成了一個單一的更新門,最終的模型比標準的 LSTM 模型要簡單,也是非常流行的變體。

# 模型結構:詞嵌入-LSTM-全連接
model = Sequential()
model.add(Embedding(len(vocab)+1, 300, input_length=20))
model.add(LSTM(256, dropout=0.2, recurrent_dropout=0.1))
model.add(Dense(num_labels, activation=‘softmax’))

Bi-GRU

需要注意的是,你如果需要堆疊多層RNN,需要在前一層返回序列,設置return_sequences參數爲True即可。Bi即雙向RNN結構,模型會從正向讀取文本,也會逆向讀取文本,從兩個角度去獲取文本的順序信息。

# 模型結構:詞嵌入-雙向GRU2-全連接
model = Sequential()
model.add(Embedding(len(vocab)+1, 300, input_length=20))
model.add(Bidirectional(GRU(256, dropout=0.2, recurrent_dropout=0.1, return_sequences=True)))
model.add(Bidirectional(GRU(256, dropout=0.2, recurrent_dropout=0.1)))
model.add(Dense(num_labels, activation=‘softmax’))

CNN+RNN

C-LSTM串聯(將CNN的輸出直接拼接上RNN)

# 模型結構:詞嵌入-卷積池化-GRU2-全連接
model = Sequential()
model.add(Embedding(len(vocab)+1, 300, input_length=20))
model.add(Convolution1D(256, 3, padding=‘same’, strides = 1))
model.add(Activation(‘relu’))
model.add(MaxPool1D(pool_size=2))
model.add(GRU(256, dropout=0.2, recurrent_dropout=0.1, return_sequences = True))
model.add(GRU(256, dropout=0.2, recurrent_dropout=0.1))
model.add(Dense(num_labels, activation=‘softmax’))

並聯(將CNN的輸出和RNN的輸出合併成一個輸出)(論文

# 模型結構:詞嵌入-卷積池化-全連接 —拼接-全連接
# -雙向GRU-全連接
main_input = Input(shape=(20,), dtype=‘float64’)
embed = Embedding(len(vocab)+1, 300, input_length=20)(main_input)
cnn = Convolution1D(256, 3, padding=‘same’, strides = 1, activation=‘relu’)(embed)
cnn = MaxPool1D(pool_size=4)(cnn)
cnn = Flatten()(cnn)
cnn = Dense(256)(cnn)
rnn = Bidirectional(GRU(256, dropout=0.2, recurrent_dropout=0.1))(embed)
rnn = Dense(256)(rnn)
con = concatenate([cnn,rnn], axis=-1)
main_output = Dense(num_labels, activation=‘softmax’)(con)
model = Model(inputs = main_input, outputs = main_output)

RCNN

# 模型結構:詞嵌入3-LSTM*2-拼接-全連接-最大化池化-全連接
# 我們需要重新整理數據集
left_train_word_ids = [[len(vocab)] + x[:-1] for x in X_train_word_ids]
left_test_word_ids = [[len(vocab)] + x[:-1] for x in X_test_word_ids]
right_train_word_ids = [x[1:] + [len(vocab)] for x in X_train_word_ids]
right_test_word_ids = [x[1:] + [len(vocab)] for x in X_test_word_ids]

# 分別對左邊和右邊的詞進行編碼
left_train_padded_seqs = pad_sequences(left_train_word_ids, maxlen=20)
left_test_padded_seqs = pad_sequences(left_test_word_ids, maxlen=20)
right_train_padded_seqs = pad_sequences(right_train_word_ids, maxlen=20)
right_test_padded_seqs = pad_sequences(right_test_word_ids, maxlen=20)

# 模型共有三個輸入,分別是左詞,右詞和中心詞
document = Input(shape = (None, ), dtype = “int32”)
left_context = Input(shape = (None, ), dtype = “int32”)
right_context = Input(shape = (None, ), dtype = “int32”)

# 構建詞向量
embedder = Embedding(len(vocab) + 1, 300, input_length = 20)
doc_embedding = embedder(document)
l_embedding = embedder(left_context)
r_embedding = embedder(right_context)

# 分別對應文中的公式(1)-(7)
forward = LSTM(256, return_sequences = True)(l_embedding) # 等式(1)
# 等式(2)
backward = LSTM(256, return_sequences = True, go_backwards = True)(r_embedding)
together = concatenate([forward, doc_embedding, backward], axis = 2) # 等式(3)

semantic = TimeDistributed(Dense(128, activation = “tanh”))(together) # 等式(4)
# 等式(5)
pool_rnn = Lambda(lambda x: backend.max(x, axis = 1), output_shape = (128, ))(semantic)
output = Dense(10, activation = “softmax”)(pool_rnn) # 等式(6)和(7)
model = Model(inputs = [document, left_context, right_context], outputs = output)

model.compile(loss=‘categorical_crossentropy’,
optimizer=‘adam’,
metrics=[‘accuracy’])

model.fit([X_train_padded_seqs, left_train_padded_seqs, right_train_padded_seqs], y_train,
batch_size=32,
epochs=12,
validation_data=([X_test_padded_seqs, left_test_padded_seqs, right_test_padded_seqs], y_test))

Attention

HAN

由於Keras目前還沒有現成的Attention層可以直接使用,我們需要自己來構建一個新的層函數。Keras自定義的函數主要分爲四個部分,分別是:

  • init:初始化一些需要的參數
  • bulid:具體來定義權重是怎麼樣的
  • call:核心部分,定義向量是如何進行運算的
  • compute_output_shape:定義該層輸出的大小
class Attention(Layer):
def init(self, attention_size, kwargs):
self.attention_size = attention_size
super(Attention, self).init(kwargs)
<span class="k">def</span> <span class="nf">build</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">input_shape</span><span class="p">):</span>
    <span class="c1"># W: (EMBED_SIZE, ATTENTION_SIZE)</span>
    <span class="c1"># b: (ATTENTION_SIZE, 1)</span>
    <span class="c1"># u: (ATTENTION_SIZE, 1)</span>
    <span class="bp">self</span><span class="o">.</span><span class="n">W</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">add_weight</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="s2">"W_</span><span class="si">{:s}</span><span class="s2">"</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">name</span><span class="p">),</span>
                             <span class="n">shape</span><span class="o">=</span><span class="p">(</span><span class="n">input_shape</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">],</span> <span class="bp">self</span><span class="o">.</span><span class="n">attention_size</span><span class="p">),</span>
                             <span class="n">initializer</span><span class="o">=</span><span class="s2">"glorot_normal"</span><span class="p">,</span>
                             <span class="n">trainable</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
    <span class="bp">self</span><span class="o">.</span><span class="n">b</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">add_weight</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="s2">"b_</span><span class="si">{:s}</span><span class="s2">"</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">name</span><span class="p">),</span>
                             <span class="n">shape</span><span class="o">=</span><span class="p">(</span><span class="n">input_shape</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="mi">1</span><span class="p">),</span>
                             <span class="n">initializer</span><span class="o">=</span><span class="s2">"zeros"</span><span class="p">,</span>
                             <span class="n">trainable</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
    <span class="bp">self</span><span class="o">.</span><span class="n">u</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">add_weight</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="s2">"u_</span><span class="si">{:s}</span><span class="s2">"</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">name</span><span class="p">),</span>
                             <span class="n">shape</span><span class="o">=</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">attention_size</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span>
                             <span class="n">initializer</span><span class="o">=</span><span class="s2">"glorot_normal"</span><span class="p">,</span>
                             <span class="n">trainable</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
    <span class="nb">super</span><span class="p">(</span><span class="n">Attention</span><span class="p">,</span> <span class="bp">self</span><span class="p">)</span><span class="o">.</span><span class="n">build</span><span class="p">(</span><span class="n">input_shape</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">call</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
    <span class="c1"># input: (BATCH_SIZE, MAX_TIMESTEPS, EMBED_SIZE)</span>
    <span class="c1"># et: (BATCH_SIZE, MAX_TIMESTEPS, ATTENTION_SIZE)</span>
    <span class="n">et</span> <span class="o">=</span> <span class="n">K</span><span class="o">.</span><span class="n">tanh</span><span class="p">(</span><span class="n">K</span><span class="o">.</span><span class="n">dot</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">W</span><span class="p">)</span> <span class="o">+</span> <span class="bp">self</span><span class="o">.</span><span class="n">b</span><span class="p">)</span>
    <span class="c1"># at: (BATCH_SIZE, MAX_TIMESTEPS)</span>
    <span class="n">at</span> <span class="o">=</span> <span class="n">K</span><span class="o">.</span><span class="n">softmax</span><span class="p">(</span><span class="n">K</span><span class="o">.</span><span class="n">squeeze</span><span class="p">(</span><span class="n">K</span><span class="o">.</span><span class="n">dot</span><span class="p">(</span><span class="n">et</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">u</span><span class="p">),</span> <span class="n">axis</span><span class="o">=-</span><span class="mi">1</span><span class="p">))</span>
    <span class="k">if</span> <span class="n">mask</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
        <span class="n">at</span> <span class="o">*=</span> <span class="n">K</span><span class="o">.</span><span class="n">cast</span><span class="p">(</span><span class="n">mask</span><span class="p">,</span> <span class="n">K</span><span class="o">.</span><span class="n">floatx</span><span class="p">())</span>
    <span class="c1"># ot: (BATCH_SIZE, MAX_TIMESTEPS, EMBED_SIZE)</span>
    <span class="n">atx</span> <span class="o">=</span> <span class="n">K</span><span class="o">.</span><span class="n">expand_dims</span><span class="p">(</span><span class="n">at</span><span class="p">,</span> <span class="n">axis</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
    <span class="n">ot</span> <span class="o">=</span> <span class="n">atx</span> <span class="o">*</span> <span class="n">x</span>
    <span class="c1"># output: (BATCH_SIZE, EMBED_SIZE)</span>
    <span class="n">output</span> <span class="o">=</span> <span class="n">K</span><span class="o">.</span><span class="n">sum</span><span class="p">(</span><span class="n">ot</span><span class="p">,</span> <span class="n">axis</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">output</span>

<span class="k">def</span> <span class="nf">compute_mask</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="nb">input</span><span class="p">,</span> <span class="n">input_mask</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
    <span class="k">return</span> <span class="kc">None</span>

<span class="k">def</span> <span class="nf">compute_output_shape</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">input_shape</span><span class="p">):</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">input_shape</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">input_shape</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">])</span>

定義好Attention層,直接調用即可,我們來看下:

# 模型結構:詞嵌入-雙向GRU-Attention-全連接
inputs = Input(shape=(20,), dtype=‘float64’)
embed = Embedding(len(vocab) + 1,300, input_length = 20)(inputs)
gru = Bidirectional(GRU(100, dropout=0.2, return_sequences=True))(embed)
attention = AttLayer()(gru)
output = Dense(num_labels, activation=‘softmax’)(attention)
model = Model(inputs, output)

有知友私信我,如何可視化attention權重,這裏給出示例:

# 需要導入兩個模型,分別是句子級別的和篇章級別的,以及預處理後的文本序列
def get_attention(sent_model, doc_model, sequences, topN=5):
sent_before_att = K.function([sent_model.layers[0].input, K.learning_phase()],
[sent_model.layers[2].output])
cnt_reviews = sequences.shape[0]
<span class="c1"># 導出這個句子每個詞的權重</span>
<span class="n">sent_att_w</span> <span class="o">=</span> <span class="n">sent_model</span><span class="o">.</span><span class="n">layers</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span><span class="o">.</span><span class="n">get_weights</span><span class="p">()</span>
<span class="n">sent_all_att</span> <span class="o">=</span> <span class="p">[]</span>
<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">cnt_reviews</span><span class="p">):</span>
    <span class="n">sent_each_att</span> <span class="o">=</span> <span class="n">sent_before_att</span><span class="p">([</span><span class="n">sequences</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="mi">0</span><span class="p">])</span>
    <span class="n">sent_each_att</span> <span class="o">=</span> <span class="n">cal_att_weights</span><span class="p">(</span><span class="n">sent_each_att</span><span class="p">,</span> <span class="n">sent_att_w</span><span class="p">,</span> <span class="n">model_name</span><span class="p">)</span>
    <span class="n">sent_each_att</span> <span class="o">=</span> <span class="n">sent_each_att</span><span class="o">.</span><span class="n">ravel</span><span class="p">()</span>
    <span class="n">sent_all_att</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">sent_each_att</span><span class="p">)</span>
<span class="n">sent_all_att</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">array</span><span class="p">(</span><span class="n">sent_all_att</span><span class="p">)</span>

<span class="n">doc_before_att</span> <span class="o">=</span> <span class="n">K</span><span class="o">.</span><span class="n">function</span><span class="p">([</span><span class="n">doc_model</span><span class="o">.</span><span class="n">layers</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">input</span><span class="p">,</span> <span class="n">K</span><span class="o">.</span><span class="n">learning_phase</span><span class="p">()],</span>
                            <span class="p">[</span><span class="n">doc_model</span><span class="o">.</span><span class="n">layers</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span><span class="o">.</span><span class="n">output</span><span class="p">])</span>
<span class="c1"># 找到重要的分句</span>
<span class="n">doc_att_w</span> <span class="o">=</span> <span class="n">doc_model</span><span class="o">.</span><span class="n">layers</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span><span class="o">.</span><span class="n">get_weights</span><span class="p">()</span>
<span class="n">doc_sub_att</span> <span class="o">=</span> <span class="n">doc_before_att</span><span class="p">([</span><span class="n">sequences</span><span class="p">,</span> <span class="mi">0</span><span class="p">])</span>
<span class="n">doc_att</span> <span class="o">=</span> <span class="n">cal_att_weights</span><span class="p">(</span><span class="n">doc_sub_att</span><span class="p">,</span> <span class="n">doc_att_w</span><span class="p">,</span> <span class="n">model_name</span><span class="p">)</span>

<span class="k">return</span> <span class="n">sent_all_att</span><span class="p">,</span> <span class="n">doc_att</span>

# 使用numpy重新計算attention層的結果
def cal_att_weights(output, att_w, model_name):
if model_name == ‘HAN’:
eij = np.tanh(np.dot(output[0], att_w[0]) + att_w[1])
eij = np.dot(eij, att_w[2])
eij = eij.reshape((eij.shape[0], eij.shape[1]))
ai = np.exp(eij)
weights = ai / np.sum(ai)
return weights

這樣我們就得到經過attention層後的權重了, 具體如何可視化可參考plotly包中的熱力圖,需要自定義熱力圖的展示方式,這裏貼下我導出的熱力圖。

FastText(模型很簡單,比較複雜的是構造輸入數據)

# 模型結構:詞嵌入(n-gram)-最大化池化-全連接
# 生成n-gram組合的詞(以3爲例)
ngram = 3
# 將n-gram詞加入到詞表
def create_ngram(sent, ngram_value):
return set(zip(*[sent[i:] for i in range(ngram_value)]))
ngram_set = set()
for sentence in X_train_padded_seqs:
for i in range(2, ngram+1):
set_of_ngram = create_ngram(sentence, i)
ngram_set.update(set_of_ngram)
# 給n-gram詞彙編碼
start_index = len(vocab) + 2
token_indice = {v: k + start_index for k, v in enumerate(ngram_set)} # 給n-gram詞彙編碼
indice_token = {token_indice[k]: k for k in token_indice}
max_features = np.max(list(indice_token.keys())) + 1
# 將n-gram詞加入到輸入文本的末端
def add_ngram(sequences, token_indice, ngram_range):
new_sequences = []
for sent in sequences:
new_list = sent[:]
for i in range(len(new_list) - ngram_range + 1):
for ngram_value in range(2, ngram_range + 1):
ngram = tuple(new_list[i:i + ngram_value])
if ngram in token_indice:
new_list.append(token_indice[ngram])
new_sequences.append(new_list)
return new_sequences

x_train = add_ngram(X_train_word_ids, token_indice, ngram)
x_test = add_ngram(X_test_word_ids, token_indice, ngram)
x_train = pad_sequences(x_train, maxlen=25)
x_test = pad_sequences(x_test, maxlen=25)

model = Sequential()
model.add(Embedding(max_features, 300, input_length=25))
model.add(GlobalAveragePooling1D())
model.add(Dense(num_labels, activation=‘softmax’))

Char-Level

我分別測試了TextCNN和RNN模型採用字符作爲輸入時的模型性能,發現不盡如人意,究其原因,可能是字級別粒度太細,而且文本是短文本,並不能反映出什麼有效的信息。前者能達到0.4勉強及格的準確率,而後者只能達到0.28的準確率。因此,不再嘗試對短文本進行字粒度的考證。這裏想簡單應用字符級別的輸入,只需對原始文本稍作改變即可。

all_sent = [] # 用於存放新的文本 如 A m y ’ s   J i g s a w   S c r a p b o o k
for sent in title.tolist():
new = []
for word in sent:
for char in word:
new.append(word)
new_sent = " ".join(new)
all_sent.append(new_sent)

To-do list

還有一些論文還沒有實現,接下來我會繼續更新,敬請期待。。。

  • Aspect Level Sentiment
  • Dynamic K-max Pooling

模型結果比較

每個模型我都嘗試了使用預訓練的Glove詞向量,效果都不如直接從原文本訓練來的好,可能的原因是,文本內容比較簡單,而300維的Glove詞向量是根據海量語料訓練的,看來並不能簡單使用Word2Vec和Glove的訓練好的向量。需要注意的是,我將最後一個epoch(總共12個epoch)的結果最爲模型最終的準確率而且並沒有做交叉驗證,這樣的做法不太合理,可能存在有的模型過擬合了,有的還是欠擬合狀態的,簡單粗暴吧。每個模型的訓練時間和準確率如下圖:

我們可以看到CNN模型的訓練一般都比較快,性能也不算太差。可能是由於語料相對簡單,簡單的模型如FastText和MLP在準確率上名列前茅,精心設計的模型反而不如簡單的模型,有點小題大作的意思。接下來,我們看看更加複雜的推特語料,這些複雜的模型會不會給我們帶來一些驚喜呢?

推文情感分析

之前我提到其實,這個遊戲標題分類任務並沒有什麼實際的意義,只是爲了熟悉模型,小試牛刀,接下來我們來試試更大的數據集,比如推文的情感分析任務。標註好的情感分析任務可以當做文本分任務來處理。數據集來自密歇根大學的課程作業SI650 - Sentiment Classification 和由Niek Sanders收集的推文數據集,總共有大約157W條推文,目標變量爲0或1,表示消極和積極的情感,是一個大數據集的二分類任務。文中提到使用樸素貝葉斯分類器可以達到75%的準確率,爲了驗證,我分別使用NB和SVM模型對全部數據集做了測試,NB的準確率在77.5%,SVM爲73%。我們來看看深度學習模型是否會有大幅度的提升呢?

數據讀入

# 導入要使用到的庫
import csv
import pandas as pd
from nltk.tokenize import TweetTokenizer # a tweet tokenizer from nltk.

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_selection import SelectKBest, chi2
from sklearn.linear_model import LogisticRegressionCV
from sklearn.pipeline import Pipeline
from sklearn.model_selection import train_test_split

from keras.preprocessing.sequence import pad_sequences
from keras.preprocessing.text import Tokenizer
from keras.utils.np_utils import to_categorical
from keras.models import Sequential, Model
from keras.layers import Dense, Embedding, Activation, merge, Input, Lambda, Reshape
from keras.layers import Convolution1D, Flatten, Dropout, MaxPool1D, GlobalAveragePooling1D
from keras.layers import BatchNormalization

# 出師不利 碰到推文中有逗號,pandas無法解析
with open(“tweets.csv”, “r”) as infile, open(“quoted.csv”, “wb”) as outfile:
reader = csv.reader(infile)
writer = csv.writer(outfile)
for line in reader:
newline = [’,’.join(line[:-3])] + line[-3:]
writer.writerow(newline)

df = pd.read_csv(‘quoted.csv’)
# 發現兩個沒有正確解析的樣本,直接忽略好了
df = df.drop(df.index[[8834,535880]])
df[‘Sentiment’] = df[‘Sentiment’].map(int)
df.reset_index(inplace=True, drop=True)
# 爲了之後讀取方便,建議保存成python特有的pkl格式的文件
# 去除無效的列
df.drop([‘ItemID’, ‘SentimentSource’], axis=1, inplace=True)
pd.to_pickle(df, ‘tweet_dataset.pkl’)

輸入文本處理

# 剛開始想直接應用之前的處理方法,發現渣本由於顯存太小,根本跑不起來
# 於是減少一些詞彙量和輸入文本的大小
# 隨機抽取10W條推文
df = df.sample(100000)
# 先處理目標變量
Y = df.Sentiment.values
Y = to_categorical(Y)
# 分詞
# 另外,nltk包還有一個內置的專門處理推特文本的分詞器
tokenizer = Tokenizer(filters=’!"#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n,lower=True,split=" ")
tokenizer.fit_on_texts(df.SentimentText)
vocab = tokenizer.word_index
# 劃分train/test set
x_train, x_test, y_train, y_test = train_test_split(df.SentimentText, Y, test_size=0.2, random_state=2017)
# 對文本進行編碼(40是所有文本的90%左右的長度,超過的部分直接截去,不足的以0補足)
x_train_word_ids = tokenizer.texts_to_sequences(x_train)
x_test_word_ids = tokenizer.texts_to_sequences(x_test)
x_train_padded_seqs = pad_sequences(x_train_word_ids, maxlen=64)
x_test_padded_seqs = pad_sequences(x_test_word_ids, maxlen=64)

跑模型(以TextCNN爲例)

這裏我參照的是知乎看山杯比賽第一名的模型,是TextCNN的擴展版本。他在原先模型的基礎上

  • 使用兩層卷積
  • 使用更多的卷積核,更多尺度的卷積核
  • 使用了BatchNorm
  • 分類的時候使用了兩層的全連接

總之就是更深更復雜,所以我也來試試,依樣畫葫蘆。

main_input = Input(shape=(64,), dtype=‘float64’)
embedder = Embedding(len(vocab) + 1, 256, input_length = 64)
embed = embedder(main_input)
# cnn1模塊,kernel_size = 3
conv1_1 = Convolution1D(256, 3, padding=‘same’)(embed)
bn1_1 = BatchNormalization()(conv1_1)
relu1_1 = Activation(‘relu’)(bn1_1)
conv1_2 = Convolution1D(128, 3, padding=‘same’)(relu1_1)
bn1_2 = BatchNormalization()(conv1_2)
relu1_2 = Activation(‘relu’)(bn1_2)
cnn1 = MaxPool1D(pool_size=4)(relu1_2)
# cnn2模塊,kernel_size = 4
conv2_1 = Convolution1D(256, 4, padding=‘same’)(embed)
bn2_1 = BatchNormalization()(conv2_1)
relu2_1 = Activation(‘relu’)(bn2_1)
conv2_2 = Convolution1D(128, 4, padding=‘same’)(relu2_1)
bn2_2 = BatchNormalization()(conv2_2)
relu2_2 = Activation(‘relu’)(bn2_2)
cnn2 = MaxPool1D(pool_size=4)(relu2_2)
# cnn3模塊,kernel_size = 5
conv3_1 = Convolution1D(256, 5, padding=‘same’)(embed)
bn3_1 = BatchNormalization()(conv3_1)
relu3_1 = Activation(‘relu’)(bn3_1)
conv3_2 = Convolution1D(128, 5, padding=‘same’)(relu3_1)
bn3_2 = BatchNormalization()(conv3_2)
relu3_2 = Activation(‘relu’)(bn3_2)
cnn3 = MaxPool1D(pool_size=4)(relu3_2)
# 拼接三個模塊
cnn = concatenate([cnn1,cnn2,cnn3], axis=-1)
flat = Flatten()(cnn)
drop = Dropout(0.5)(flat)
fc = Dense(512)(drop)
bn = BatchNormalization()(fc)
main_output = Dense(2, activation=‘sigmoid’)(bn)
model = Model(inputs = main_input, outputs = main_output)

model.compile(loss=‘binary_crossentropy’,
optimizer=‘adam’,
metrics=[‘accuracy’])

history = model.fit(x_train_padded_seqs, y_train,
batch_size=32,
epochs=5,
validation_data=(x_test_padded_seqs, y_test))

可視化loss和準確率(更高級的可視化工具是Tensorflow的Tensorboard工具,這裏只是簡單看下)

import matplotlib.pyplot as plt

plt.subplot(211)
plt.title(“Accuracy”)
plt.plot(history.history[“acc”], color=“g”, label=“Train”)
plt.plot(history.history[“val_acc”], color=“b”, label=“Test”)
plt.legend(loc=“best”)

plt.subplot(212)
plt.title(“Loss”)
plt.plot(history.history[“loss”], color=“g”, label=“Train”)
plt.plot(history.history[“val_loss”], color=“b”, label=“Test”)
plt.legend(loc=“best”)

plt.tight_layout()
plt.show()

但是結果卻不盡如人意,出現了嚴重的過擬合,最終取第二個batch結束後的準確率爲76.75%,訓練時間爲520秒。由於這個模型比之前的模型都要複雜,即使使用了BN技術加速訓練,仍舊需要10分鐘左右的訓練時間。本文使用的GPU爲GTX1060(3GB)。什麼時候能夠擁有一臺跑模型的服務器啊? T T

我們使用了複雜的模型和Adam優化器使得模型在訓練集上表現出色,準確率不斷提升。但是卻在測試集上出現了過擬合。過擬合的解決思路一般有以下三點:

  • 加入正則化技術,如L2正則項,dropout,BatchNormalization等
  • 更多的訓練數據,這裏我們只使用了10%不到的數據,就已經超越了樸素貝葉斯使用全部數據集的準確率
  • 調整超參數,這個是玄學,憑經驗吧 - -

之前由於對幾乎沒有語義的遊戲標題短文本,預訓練的Glove詞向量,並沒有發揮作用。這次,我們再來看看是否有效?

讀入Glove詞向量

# 打開詞向量文件,每一行的第一個變量是詞,後面的一串數字是對應的詞向量
GLOVE_DIR = “D:\python\kaggle\game_reviews\glove”
embeddings_index = {}
f = open(os.path.join(GLOVE_DIR, ‘glove.6B.200d.txt’), encoding = ‘utf-8’)
for line in f:
values = line.split()
word = values[0]
coefs = np.asarray(values[1:], dtype=‘float32’)
embeddings_index[word] = coefs
f.close()
# 預訓練的詞向量中沒有出現的詞用0向量表示
embedding_matrix = np.zeros((len(vocab) + 1, 200))
for word, i in vocab.items():
embedding_vector = embeddings_index.get(word)
if embedding_vector is not None:
embedding_matrix[i] = embedding_vector
# 模型中需要修改的僅僅是這裏
embedder = Embedding(len(vocab) + 1, 200, input_length = 64, weights = [embedding_matrix], trainable = False)

神奇的詞向量,模型不再嚴重過擬合了,準確率進一步提升到了77.63%,訓練時間縮短了一半!

爲了證明之前的防過擬合策略是正確的,我們增加一倍的訓練集,來看看效果。不出意外,果然提升了!至此,模型基本達到了我們想要的狀態,準確進一步提升來到了79%,估計進行完整的調參的話,可以上到80%以上。

京東評論

我們再來試試中文的文本分類。中文天生有個坑需要去跨越,那就是分詞,對於一般的文本,現有的分詞工具已經能夠出色地完成任務了。而面對網絡文本,目前還沒有看到有效的解決方案,這篇論文嘗試使用Bi-LSTM+CRF的方法來實現深度學習模型分詞器,有空可以來研究下。所以,本文目前還是使用的主流的分詞工具結巴分詞。

根據用戶給的評分,5分視作好評,2,3,4分視作中評,1分視作差評。於是,我們便得到了目標類別,共三類。其實,中評的定義很模糊,用戶自身也很難去判斷,中評的文本中會有一定的好評或者差評的傾向性,對於文本分類造成一定的困難。

傳統機器學習方法(TF-IDF + 樸素貝葉斯/SVM)

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfTransformer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import Pipeline
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import SGDClassifier
from sklearn import metrics
from sklearn.model_selection import train_test_split
from sklearn.model_selection import cross_val_score
from data_helper_ml import load_data_and_labels
import numpy as np

categories = [‘good’, ‘bad’, ‘mid’]
# 我在data_helper_ml文件中定義了一些文本清理任務,如輸入文本處理,去除停用詞等
x_text, y = load_data_and_labels("./data/good_cut_jieba.txt", “./data/bad_cut_jieba.txt”, “./data/mid_cut_jieba.txt”)
# 劃分數據集
x_train, x_test, y_train, y_test = train_test_split(x_text, y, test_size=0.2, random_state=42)
y = y.ravel()
y_train = y_train.ravel()
y_test = y_test.ravel()

print(“Train/Test split: {:d}/{:d}”.format(len(y_train), len(y_test)))

“”" Naive Bayes classifier “”"
# sklearn有一套很成熟的管道流程Pipeline,快速搭建機器學習模型神器
bayes_clf = Pipeline([(‘vect’, CountVectorizer()),
(‘tfidf’, TfidfTransformer()),
(‘clf’, MultinomialNB())
])
bayes_clf.fit(x_train, y_train)
“”" Predict the test dataset using Naive Bayes"""
predicted = bayes_clf.predict(x_test)
print(‘Naive Bayes correct prediction: {:4.4f}’.format(np.mean(predicted == y_test)))
# 輸出f1分數,準確率,召回率等指標
print(metrics.classification_report(y_test, predicted, target_names=categories))

“”" Support Vector Machine (SVM) classifier"""
svm_clf = Pipeline([(‘vect’, CountVectorizer()),
(‘tfidf’, TfidfTransformer()),
(‘clf’, SGDClassifier(loss=‘hinge’, penalty=‘l2’, alpha=1e-3, max_iter= 5, random_state=42)),
])
svm_clf.fit(x_train, y_train)
predicted = svm_clf.predict(x_test)
print(‘SVM correct prediction: {:4.4f}’.format(np.mean(predicted == y_test)))
print(metrics.classification_report(y_test, predicted, target_names=categories))
# 輸出混淆矩陣
print(“Confusion Matrix:”)
print(metrics.confusion_matrix(y_test, predicted))
print(\n)

“”" 10-折交叉驗證 “”"
clf_b = make_pipeline(CountVectorizer(), TfidfTransformer(), MultinomialNB())
clf_s= make_pipeline(CountVectorizer(), TfidfTransformer(), SGDClassifier(loss=‘hinge’, penalty=‘l2’, alpha=1e-3, n_iter= 5, random_state=42))

bayes_10_fold = cross_val_score(clf_b, x_text, y, cv=10)
svm_10_fold = cross_val_score(clf_s, x_text, y, cv=10)

print(‘Naives Bayes 10-fold correct prediction: {:4.4f}’.format(np.mean(bayes_10_fold)))
print(‘SVM 10-fold correct prediction: {:4.4f}’.format(np.mean(svm_10_fold)))

結果如下圖:

深度學習模型

# 讀取經過jiaba分詞後的三個數據文件
good_examples = list(open(good_data_file, “r”, encoding=‘utf-8’).readlines())
bad_examples = list(open(bad_data_file, “r”, encoding=‘utf-8’).readlines())
mid_examples = list(open(mid_data_file, “r”, encoding=‘utf-8’).readlines())
# 清理一些無效字符,clean爲自定義函數
good_examples = [clean(sent) for sent in good_examples]
bad_examples = [clean(sent) for sent in bad_examples]
mid_examples = [clean(sent) for sent in mid_examples]
# 刪除前後空格
good_examples = [i.strip() for i in good_examples]
bad_examples = [i.strip() for i in bad_examples]
mid_examples = [i.strip() for i in mid_examples]
# 去除清理過後的空文本, sent_filter爲自定義函數
good_examples = sent_filter(good_examples)
bad_examples = sent_filter(bad_examples)
mid_examples = sent_filter(mid_examples)
X = good_examples + bad_examples + mid_examples
# 處理目標變量
good_labels = [[1, 0, 0] for in good_examples]
bad_labels = [[0, 1, 0] for in bad_examples]
mid_labels = [[0, 0, 1] for _ in mid_examples]
Y = np.concatenate([good_labels, bad_labels, mid_labels], 0)

爲了照顧類別的平衡,在爬取數據的時候儘可能保持各個類別數量的相等,每個類別大約5200條評論左右。之後的步驟就很類似了,由於目前沒有很好的中文預訓練好的詞向量文件,我們這裏直接從原文本訓練詞向量。

這裏我們依舊仿照知乎看山杯的冠軍作者來實現一個新的模型架構Inception,Inception的結構源於2015年ImageNet競賽中的冠軍模型的縮小版。模型和之前的TextCNNBN版本很相似,如下圖所示:

大家也可以自己動手來試試搭建這樣一個Inception模型,代碼我就不貼出來了:P

始終沒有解決過擬合的問題,可能是數據問題,模型之間的差距並不明顯,最終結果如下:

寫在最後

我們來回顧下,我們至此學到了哪些內容。

  • 文本分類任務的處理流程(文本清理編碼-數據集的劃分-模型搭建-模型運行評估)
  • 傳統機器學習模型的處理方法(Baseline:TF-IDF + 分類器)
  • 常見的深度學習模型(CNN,RNN,Attention)
  • 歷年經典論文中的模型復現(TextCNN,RCNN,HAN等)
  • 實戰(多分類的小數據集,推文情感分析,京東評論好中差評)

最後給大家推薦一些高質量的學習資源:

  • NLP大牛 Chris Manning 和 Richard Socher 在 Stanford 合開的課程CS224d【課程鏈接】(一直沒有靜下心來認真學習這門課程 T T)
  • Google Brain成員Christopher Olah大神的理論博客
  • 另一位Google Brain成員Denny Britz大神關於NLP的博客 WILDML
  • MOOC課程資源:優達學城深度學習基石納米學位和吳恩達三大項目之一deeplearning.ai
  • 方得智能大神brightmart的Github Repo
  • 最近亞馬遜的DL大牛李沐老師於近期也推出了基於MXNet的Gluon框架的系列課程

希望大家能夠有所收穫,第一次自己動手碼字,有很多論文和模型的理解還不夠深刻!關於代碼部分,我接下來會整理好放到我的github上。歡迎大家向我提出寶貴的意見!最後祝大家玩的開心!😛

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