在之前的文章中,我們基於Embedding+LSTM的結構實現了一個文本分類的應用。本質上,這是循環神經網絡Many-to-One架構下的一種應用。在那種結構中,我們將Embedding後的詞向量依次投入到LSTM Cell中,循環結構依照時序逐步計算並且獲取到整個文本的語義(向量化表示),在此基礎上對文本的語義向量進行SoftMax,得到分類標籤。這種基於循環神經網絡的分類結構最主要的問題在於長距離依賴的問題。儘管我們可以採用LSTM或者GRU這種改進後的Cell,但是當文本較長的時候依然會存在這樣的問題。對此我們可以運用一些trick來解決這些問題,比如Truncated-BPTT來訓練循環神經網絡,這裏我們不討論這些trick的細節,我們希望從另一個角度來重新審視文本分類的問題。這就是這篇文章討論的重點:TextCNN。
在開始描述TextCNN的細節之前,我們先回顧下基於詞袋模型(Bag-Of-Words)+ 分類器來解決文本分類的傳統方法。詞袋模型用於結構化文本特徵。一般我們對所有語料切詞後,統計下詞空間的維度(一般會有幾萬甚至上百萬),然後用One-Hot的方式對語料進行編碼,每個詞會佔據這個達到幾萬甚至幾十萬維的向量中的一個位置。它的值可以是1或者0,也可以是一些類似TF-IDF的算法值。這個特徵表示方式的明顯缺陷在於維度高,並且丟失了時序信息。我們重點看第二點。針對於時序信息的丟失,直接的解決方案就是採用N-gram的語言模型進行擴充。一般,這裏的N可以取2,3,4就可以了。語言模型是通過“綁定”連續的幾個詞來變相解決時序問題的一種方式,但毫無疑問,加入語言模型後的詞空間維度又將上升,這對接下來分類器的訓練會造成很大的影響,無論是存儲還是算力都會有較大的消耗。
卷積神經網絡(CNN)最成功的應用是在機器視覺領域的一些問題,但對於自然語言處理的問題同樣適用。我們可以將語料中的詞進行向量化之後,構成一個大的二維矩陣(每一個矩陣就代表一條語料)。我們將kernel size處理成N x VectorSize的大小,其中N代表N-gram語言模型中的N元的含義,這樣就可以通過卷積操作抽取語料中的文本信息。當然,實際的操作可以是多尺度的kernel size同時對語料進行語義抽取,最終merge在一起就可以得到在不同維度語言模型下的文本語義。這就是TextCNN這篇文章的核心思想(https://arxiv.org/pdf/1408.5882.pdf)。下面看下論文中的截圖:
截圖的結構是Conv + Max-Pool + MLP-Classifier的經典結構。我們重點看Conv層的輸入。這是一個9 x 6 x 1(Height x Width x Channel)的矩陣,該條訓練語料中一共包含9個詞,每個詞的詞向量維度是6。我們可以把這條語料矩陣化後的表示方式認爲是一張9 x 6的灰度圖。卷積操作後的featureMap的數量這裏我們不討論,這個超參數在實踐的時候調優即可。我們需要關注的是每個featureMap其實是一個M x 1的向量。M的大小取決於stride縱向的步長,而featureMap的Width等於1顯然是stride的橫向步長等於vectorSize的結果。這在上面的闡述中我們已經有所提及。下面我們基於Deeplearning4j來搭建這樣的一個TextCNN網絡
public static ComputationGraph getTextCNN(final int vectorSize, final int numFeatureMap,
final int corpusLenLimit){
ComputationGraphConfiguration config = new NeuralNetConfiguration.Builder()
.weightInit(WeightInit.RELU)
.activation(Activation.LEAKYRELU)
.updater(new Adam(0.01))
.convolutionMode(ConvolutionMode.Same)
.l2(0.0001)
.graphBuilder()
.addInputs("input")
.addLayer("2-gram", new ConvolutionLayer.Builder()
.kernelSize(2,vectorSize)
.stride(1,vectorSize)
.nIn(1)
.nOut(numFeatureMap)
.build(), "input")
.addLayer("3-gram", new ConvolutionLayer.Builder()
.kernelSize(3,vectorSize)
.stride(1,vectorSize)
.nIn(1)
.nOut(numFeatureMap)
.build(), "input")
.addLayer("4-gram", new ConvolutionLayer.Builder()
.kernelSize(4,vectorSize)
.stride(1,vectorSize)
.nIn(1)
.nOut(numFeatureMap)
.build(), "input")
.addVertex("merge", new MergeVertex(), "2-gram", "3-gram", "4-gram")
.addLayer("globalPool", new GlobalPoolingLayer.Builder()
.poolingType(PoolingType.MAX)
.dropOut(0.5)
.build(), "merge")
.addLayer("out", new OutputLayer.Builder()
.lossFunction(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.activation(Activation.SOFTMAX)
.nIn(300)
.nOut(2)
.build(), "globalPool")
.setOutputs("out")
.setInputTypes(InputType.convolutional(corpusLenLimit, vectorSize, 1))
.build();
ComputationGraph net = new ComputationGraph(config);
net.init();
return net;
}
我們來剖析一下這段建模邏輯。注意下,我這次用的Deeplearning4j的版本是1.0.0-beta2,部分API和之前的0.8.0版本有微調,需要注意下。
.setInputTypes(InputType.convolutional(corpusLenLimit, vectorSize, 1))
這段設置我們可以看到輸入的結構。這和我們之前說的是類似的,corpusLenLimit x vectorSize代表了語料向量化矩陣的Height x Width。至於通道數,這裏就是1。
.addLayer("2-gram", new ConvolutionLayer.Builder()
.kernelSize(2,vectorSize)
.stride(1,vectorSize)
.nIn(1)
.nOut(numFeatureMap)
.build(), "input")
這段設置的目的從Layer的名稱爲“2-gram”就可以看出這是基於2元語言模型來抽取語料的時序信息。卷積核的size是2 x vectorSize,步長stride爲1 x vectorSize可以保證輸出的featureMap是一個(corpusLen - 1)x 1的向量。corpusLen代表文本語料切詞後的長度。其他的兩個卷積操作是類似的,不過是提取的3-gram和4-gram的信息。
.addVertex("merge", new MergeVertex(), "2-gram", "3-gram", "4-gram")
這個Merge層比較重要。之前的三個卷積層都輸出了numFeatureMap數量的feature map。這個Merge層就是將其合併在一起構成了3 x numFeatureMap數量的全局的feature map。
.addLayer("globalPool", new GlobalPoolingLayer.Builder()
.poolingType(PoolingType.MAX)
.dropOut(0.5)
.build(), "merge")
GlobalPoolingLayer和之前文章中介紹的SubsamplingLayer有相似的地方,都是做池化。但是它們也有不同的地方,就是GlobalPoolingLayer對於feature map會直接從矩陣(TextCNN實際就是一個向量)中抽取值最大的元素(如果是Max-Pool的話)作爲池化結果。實際完成的是: [miniBatchSize, channels, height, width] -> 2d output [miniBatchSize, channels]的計算。因此在經過GlobalPoolingLayer的計算後,TextCNN輸出的其實就是一個3 x numFeatureMap的向量。
以上就是對TextCNN結構中的一些關鍵部分的剖析。下面我們來構建訓練數據集。語料部分和我之前在基於LSTM做文本分類的那篇博客用的語料是一樣的。這裏直接把圖貼一下:
截圖中涉及兩個文件,一個是語料本身(已經用jieba切過詞)並shuffle過,另一個是對應的標註信息。我們來看下數據集的構建。
private static DataSetIterator getDataSetIterator(WordVectors wordVectors, int minibatchSize,
int maxSentenceLength){
String corpusPath = "comment/corpus.txt";
String labelPath = "comment/label.txt";
String line;
List<String> sentences = new LinkedList<>();
List<String> labels = new LinkedList<>();
try(BufferedReader br = new BufferedReader(new FileReader(corpusPath))){
while((line = br.readLine()) != null)sentences.add(line);
}catch(Exception ex){
ex.printStackTrace();
}
//
try(BufferedReader br = new BufferedReader(new FileReader(labelPath))){
while((line = br.readLine()) != null)labels.add(line);
}catch(Exception ex){
ex.printStackTrace();
}
//
LabeledSentenceProvider sentenceProvider = new CollectionLabeledSentenceProvider(sentences, labels);
TokenizerFactory tokenizerFactory = new DefaultTokenizerFactory();
tokenizerFactory.setTokenPreProcessor(new CommonPreprocessor());
System.out.println("DataSetIter 2 Current Num of Classes:" + sentenceProvider.numLabelClasses());
System.out.println("DataSetIter 2 Total Num of samples: " + sentenceProvider.totalNumSentences());
//
return new CnnSentenceDataSetIterator.Builder(Format.CNN2D)
.sentenceProvider(sentenceProvider)
.wordVectors(wordVectors)
.minibatchSize(minibatchSize)
.maxSentenceLength(maxSentenceLength)
.tokenizerFactory(tokenizerFactory)
.useNormalizedWordVectors(false)
.build();
}
這部分的邏輯主要可以分爲兩個部分。第一個是分別從語料文件和標註文件中讀取所有的記錄。第二個部分則是使用Deeplearning4j內置的CnnSentenceDataSetIterator工具類構建訓練數據集。這裏需要注意一點,wordVectors這個對象實例其實是我事先已經使用Word2Vec訓練好的模型實例。我們可以通過下面的邏輯來加載已經訓練好的模型:
WordVectors wordVectors = WordVectorSerializer.loadStaticModel(new File("w2v.mod"));
最後,我們把完整的訓練邏輯貼一下。
final int batchSize = 32;
final int corpusLenLimit = 256;
final int vectorSize = 128;
final int numFeatureMap = 100;
final int nEpochs = Integer.parseInt(args[0]);
//讀取預先訓練好的Word2Vec的模型,並且構建訓練和驗證數據集
WordVectors wordVectors = WordVectorSerializer.loadStaticModel(new File("w2v.mod"));
DataSetIterator trainIter = getDataSetIterator(wordVectors, batchSize, corpusLenLimit);
DataSetIterator testIter = getDataSetIterator(wordVectors, 1, corpusLenLimit);
//生成TextCNN模型,並打印模型結構信息
ComputationGraph net = getTextCNN(vectorSize, numFeatureMap, corpusLenLimit);
System.out.println(net.summary());
//使用單條記錄並且打印每一層網絡的輸入和輸出信息
INDArray input = testIter.next().getFeatures();
System.out.println(input.shapeInfoToString());
Map<String,INDArray> map = net.feedForward(input, false);
for( Map.Entry<String, INDArray> entry : map.entrySet() ){
System.out.println(entry.getKey() + ":" + entry.getValue().shapeInfoToString());
System.out.println();
}
//訓練開始。。。
System.out.println("Starting training");
net.setListeners(new ScoreIterationListener(1));
for (int i = 0; i < nEpochs; i++) {
net.fit(trainIter);
System.out.println("Epoch " + i + " complete. Starting evaluation:");
Evaluation evaluation = net.evaluate(trainIter);
trainIter.reset();
testIter.reset();
System.out.println(evaluation.stats());
}
這段建模的主邏輯大致可以分爲三個部分。首先是超參數的定義。這裏我們直接把batchSize、vectorSize等參數進行硬編碼,用戶可以根據的實際需要進行定製。其次是基於之前提到的數據集構建邏輯來讀取文本中的語料和標註並且生成完整的訓練語料。再者則是生成TextCNN網絡模型並且爲了更好地分析模型,我們從測試集中選取了一條記錄並通過打印在前向傳播過程中每一層網絡的輸入和輸出來得到網絡的信息。最後一部分則是和之前文章中相似,進行模型訓練並在每一輪訓練後進行模型準確性的評估。我們先來看下模型的基本信息截圖。
==========================================================================================================================================================================================================================================================
VertexName (VertexType) nIn,nOut TotalParams ParamsShape Vertex Inputs
==========================================================================================================================================================================================================================================================
input (InputVertex) -,- - - -
2-gram (ConvolutionLayer) 1,100 25700 W:{100,1,2,128}, b:{1,100} [input]
3-gram (ConvolutionLayer) 1,100 38500 W:{100,1,3,128}, b:{1,100} [input]
4-gram (ConvolutionLayer) 1,100 51300 W:{100,1,4,128}, b:{1,100} [input]
merge (MergeVertex) -,- - - [2-gram, 3-gram, 4-gram]
globalPool (GlobalPoolingLayer) -,- 0 - [merge]
out (OutputLayer) 300,2 602 W:{300,2}, b:{1,2} [globalPool]
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Total Parameters: 116102
Trainable Parameters: 116102
Frozen Parameters: 0
==========================================================================================================================================================================================================================================================
這是TextCNN的基本信息截圖。包括每一層網絡的名稱以及超參數的量。下面是我們選取一條記錄後,通過TextCNN每一層前向傳播過程中的輸入輸出數據的截圖。
Rank: 4,Offset: 0
Order: c Shape: [1,1,64,128], stride: [8192,8192,128,1]
3-gram:Rank: 4,Offset: 0
Order: c Shape: [1,100,64,1], stride: [64,64,1,1]
input:Rank: 4,Offset: 0
Order: c Shape: [1,1,64,128], stride: [8192,8192,128,1]
globalPool:Rank: 2,Offset: 0
Order: c Shape: [1,300], stride: [1,1]
4-gram:Rank: 4,Offset: 0
Order: c Shape: [1,100,64,1], stride: [64,64,1,1]
merge:Rank: 4,Offset: 0
Order: c Shape: [1,300,64,1], stride: [19200,64,1,1]
2-gram:Rank: 4,Offset: 0
Order: c Shape: [1,100,64,1], stride: [64,64,1,1]
out:Rank: 2,Offset: 0
Order: c Shape: [1,2], stride: [1,1]
我們先單獨看2-gram、3-gram、4-gram這幾層的輸出數據結構。可以看到,都是1x100x64x1的4維張量。第一個1代表的是mini-batch,因爲我取的是1條數據,所以這裏就是1,否則會隨着batch的數量變化。這裏的100代表的是Channel的數量,實際上也就是feature map的數量。64代表的是該條語料中有64 + 1 = 65 個詞。最後一個1則是代表在經過卷積操作後,feature map的Width等於1,原因上面已經有過分析。接下來我們來看下merge這層的輸出。這是一個1x300x64x1的4維張量。很顯然,這是將上面三個卷積層輸出也就是3個1x100x64x1的張量合成爲一個1x300x64x1的張量。在globalPool之後,輸出是一個1x300的向量。最後在輸出層就是基於這個300維的向量進行分類。
我們來看下訓練20輪後的指標。
從截圖中我們可以看到20輪訓練後達到了相對不錯的指標。這個指標和之前基於LSTM的模型在20輪後結果比較起來相差不大。當然這樣的比較沒有太大的意義,畢竟兩種網絡結構並沒有在一個相對等價的條件下進行比較的,僅供大家參考。
最後總結下這次的工作。在這篇文章中,我們給出了基於CNN的文本分類的解決方案。應當說,TextCNN是基於N-gram語言模型與神經網絡結合的一種文本分類工具。對於傳統2D-CNN結構的神經網絡來說,時序的信息是比較難抽取的。因此,在TextCNN的論文中,作者其實是想通過語言模型來彌補這個缺陷。當然需要指出的是,這樣的做法其實是比較有效的,尤其是針對文本分類的任務來說。文本分類的任務其實很多時候就是局部的兩三個詞的信息就可以決定了這條語料的類別,因此基於語言模型來做確實是一種較好的思路。需要指出的是,我們這裏用的詞向量是預先用Word2Vec訓練好的,而不是直接隨着建模的時候自動訓練出來的。理論上,在訓練的時候加入Embedding會使得最後的分類結果進一步提升,這在論文的結論中也有所提及,有興趣的朋友可以自行嘗試。