微信公眾號釋出的文章和一般入口網站的新聞文字型別有所不同,通常不能用現有的文字分類器直接對這些文章進行分類,不過文字分類的原理是相通的,本文以微信公眾號文章為物件,介紹樸素貝葉斯分類器的實現過程。
文字分類的科學原理和數學證明在網上有很多,這裡就不做贅述,本文儘量使用通熟易懂的表述方式,簡明扼要地梳理一下文字分類器的各個知識點。
參考了一下Github,發現少有Java 8風格的實現,所以這裡的實現儘量利用Java 8的特性,相比之前優勢有很多,例如stream在統計聚合等運算上比較方便,程式碼不僅簡潔,而且更加語義化,另外在多執行緒並行控制上也省去不少的工作。
本專案的地址:https://github.com/fullstacky…
一、文字分類器的概述
文字分類器可以看作是一個預測函式,在給定的文字時,在預定的類別集合中,判斷該文字最可能屬於哪個類。
這裡需要注意兩個問題:
- 在文字中含有比較多的標點符號和停用詞(的,是,了等),直接使用整段文字處理肯定會產生很多不必要的計算,而且計算量也非常大,因此需要把給定的文字有效地進行表示,也就是選擇一系列的特徵詞來代表這篇文字,這些特徵詞既可以比較好地反應所屬文字的內容,又可以對不同文字有比較好的區分能力。
- 在進行文字表示之後,如何對這些特徵詞進行預測,這就是分類器的演算法設計問題了,比較常見的模型有樸素貝葉斯,基於支援向量機(SVM),K-近鄰(KNN),決策樹等分類演算法。這裡我們選擇簡單易懂的樸素貝葉斯演算法。在機器學習中,樸素貝葉斯建模屬於有監督學習,因此需要收集大量的文字作為訓練語料,並標註分類結果
綜上,實現一個分類器通常分為以下幾個步驟:
- 收集並處理訓練語料,以及最後測試用的測試語料
- 在訓練集上進行特徵選擇,得到一系列的特徵項(詞),這些特徵項組成了所謂的特徵空間
- 為了表示某個特徵項在不同文件中的重要程度,計算該特徵項的權重,常用的計算方法有TF-IDF,本文采用的是“經典”樸素貝葉斯模型,這裡不考慮特徵項的權重(當然,一定要做也可以)
- 訓練模型,對於樸素貝葉斯模型來說,主要的是計算每個特徵項在不同類別中的條件概率,這點下面再做解釋。
- 預測文字,模型訓練完成之後可以儲存到檔案中,在預測時直接讀入模型的資料進行計算。
二、準備訓練語料
這裡需要的語料就是微信公眾號的文章,我們可以抓取搜狗微信搜尋網站(http://weixin.sogou.com/)首頁上已經分類好的文章,直接採用其分類結果,這樣也省去了標註的工作。至於如何開發爬蟲去抓取文章,這裡就不再討論了。
“熱門”這類別下的文章不具有一般性,因此不把它當作一個類別。剔除“熱門”類別之後,最終我們抓取了30410篇文章,總共20個類別,每個類別的文章數並不均衡,其中最多 的是“養生堂”類別,有2569篇文章,最少的是“軍事”類別,有654篇,大體符合微信上文章的分佈情況。在儲存時,我們保留了文章的標題,公眾號名稱,文章正文。
三、特徵選擇
如前文所述,特徵選擇的目的是降低特徵空間的維度,避免維度災難。簡單地說,假設我們選擇了2萬個特徵詞,也就是說計算機通過學習,得到了一張有2萬個詞的“單詞表”,以後它遇到的所有文字可以夠用這張單詞表中的詞去表示其內容大意。這些特徵詞針對不同的類別有一定的區分能力,舉例來說,“殲擊機”可能來自“軍事”,“越位”可能來自“體育”,“漲停”可能來自“財經”等等,而通常中文詞彙量要比這個數字大得多,一本常見的漢語詞典收錄的詞條數可達數十萬。
常見的特徵選擇方法有兩個,資訊增益法和卡方檢驗法。
3.1 資訊增益
資訊增益法的衡量標準是,這個特徵項可以為分類系統帶來多少資訊量,所謂的資訊增益就是該特徵項包含的能夠幫預測類別的資訊量,這裡所說的資訊量可以用熵來衡量,計算資訊增益時還需要引入條件熵的概念,公式如下
可能有些見到公式就頭大的小夥伴不太友好,不過這個公式雖然看起來有點複雜,其實在計算中還是比較簡單的,解釋一下:
P(Cj):Cj類文件在整個語料中出現的概率;
P(ti):語料中包含特徵項ti的文件的概率,取反就是不包含特徵項ti的文件的概率;
P(Cj|ti):文件包含特徵項ti且屬於Cj類的條件概率,取反就是文件不包含特徵項ti且屬於Cj類的條件概率
上面幾個概率值,都可以比較方便地從訓練語料上統計得到。若還有不明白的小夥伴,推薦閱讀這篇部落格:文字分類入門(十一)特徵選擇方法之資訊增益
3.2 卡方檢驗
卡方檢驗,基於χ2統計量(CHI)來衡量特徵項ti和類別Cj之間的相關聯程度,CHI統計值越高,該特徵項與該類的相關性越大,如果兩者相互獨立,則CHI統計值接近零。計算時需要根據一張相依表(contingency table),公式也比較簡單:
其中N就是文件總數,如果想繼續討論這個公式,推薦閱讀這篇部落格:特徵選擇(3)-卡方檢驗
3.3 演算法實現
不論何種方式都需要對每個特徵項進行估算,然後根據所得的數值進行篩選,通常可以設定一個閾值,低於閾值的特徵項可以直接從特徵空間中移除,另外也可以按照數值從高到低排序,並指定選擇前N個。這裡我們採用後者,總共擷取前2萬個特徵項。
特徵選擇實現類的程式碼如下,其中,不同特徵選擇方法需實現Strategy介面,以獲得不同方法計算得到的估值,這裡在擷取特徵項時為了避免不必要的麻煩,剔除了字串長度為1的詞。
Doc物件表示一篇文件,其中包含了該文件的所屬分類,以及分詞結果(已經濾掉了停用詞等),即Term集合;
Term物件主要包含3個欄位,詞本身的字串,詞性(用於過濾),詞頻TF;
Feature表示特徵項,一個特徵項對應一個Term物件,還包含兩個hashmap,一個用來統計不同類別下該特徵項出現的文件數量(categoryDocCounter),另一個用來統計不同類別下該特徵項出現的頻度(categoryTermCounter)(對應樸素貝葉斯兩種不同模型,下文詳述)
統計時引入FeatureCounter物件,使用stream的reduce方法進行歸約。主要的思想就是把每一個文件中的Term集合,對映為Term和Feature的鍵值對,然後再和已有的Map進行合併,合併時如果遇到相同的Term,則呼叫Feature的Merge方法,該方法會將雙方term的詞頻,以及categoryDocCounter和categoryTermCounter中的統計結果進行累加。最終將所有文件全部統計完成返回Feature集合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
@AllArgsConstructor public class FeatureSelection { interface Strategy { Feature estimate(Feature feature); } private final Strategy strategy; private final static int FEATURE_SIZE = 20000; public List select(List docs) { return createFeatureSpace(docs.stream()) .stream() .map(strategy::estimate) .filter(f -> f.getTerm().getWord().length() > 1) .sorted(comparing(Feature::getScore).reversed()) .limit(FEATURE_SIZE) .collect(toList()); } private Collection createFeatureSpace(Stream docs) { @AllArgsConstructor class FeatureCounter { private final Map featureMap; private FeatureCounter accumulate(Doc doc) { Map temp = doc.getTerms().parallelStream() .map(t -> new Feature(t, doc.getCategory())) .collect(toMap(Feature::getTerm, Function.identity())); if (!featureMap.isEmpty()) featureMap.values().forEach(f -> temp.merge(f.getTerm(), f, Feature::merge)); return new FeatureCounter(temp); } private FeatureCounter combine(FeatureCounter featureCounter) { Map temp = Maps.newHashMap(featureMap); featureCounter.featureMap.values().forEach(f -> temp.merge(f.getTerm(), f, Feature::merge)); return new FeatureCounter(temp); } } FeatureCounter counter = docs.parallel() .reduce(new FeatureCounter(Maps.newHashMap()), FeatureCounter::accumulate, FeatureCounter::combine); return counter.featureMap.values(); } } public class Feature { ... public Feature merge(Feature feature) { if (this.term.equals(feature.getTerm())) { this.term.setTf(this.term.getTf() + feature.getTerm().getTf()); feature.getCategoryDocCounter() .forEach((k, v) -> categoryDocCounter.merge(k, v, (oldValue, newValue) -> oldValue + newValue)); feature.getCategoryTermCounter() .forEach((k, v) -> categoryTermCounter.merge(k, v, (oldValue, newValue) -> oldValue + newValue)); } return this; } } |
資訊增益實現如下,在計算條件熵時,利用了stream的collect方法,將包含和不包含特徵項的兩種情況用一個hashmap分開再進行歸約。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
@AllArgsConstructor public class IGStrategy implements FeatureSelection.Strategy { private final Collection categories; private final int total; public Feature estimate(Feature feature) { double totalEntropy = calcTotalEntropy(); double conditionalEntrogy = calcConditionEntropy(feature); feature.setScore(totalEntropy - conditionalEntrogy); return feature; } private double calcTotalEntropy() { return Calculator.entropy(categories.stream().map(c -> (double) c.getDocCount() / total).collect(toList())); } private double calcConditionEntropy(Feature feature) { int featureCount = feature.getFeatureCount(); double Pfeature = (double) featureCount / total; Map> Pcondition = categories.parallelStream().collect(() -> new HashMap>() {{ put(true, Lists.newArrayList()); put(false, Lists.newArrayList()); }}, (map, category) -> { int countDocWithFeature = feature.getDocCountByCategory(category); //出現該特徵詞且屬於類別key的文件數量/出現該特徵詞的文件總數量 map.get(true).add((double) countDocWithFeature / featureCount); //未出現該特徵詞且屬於類別key的文件數量/未出現該特徵詞的文件總數量 map.get(false).add((double) (category.getDocCount() - countDocWithFeature) / (total - featureCount)); }, (map1, map2) -> { map1.get(true).addAll(map2.get(true)); map1.get(false).addAll(map2.get(false)); } ); return Calculator.conditionalEntrogy(Pfeature, Pcondition.get(true), Pcondition.get(false)); } } |
卡方檢驗實現如下,每個特徵項要在每個類別上分別計算CHI值,最終保留其最大值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
@AllArgsConstructor public class ChiSquaredStrategy implements Strategy { private final Collection categories; private final int total; @ Override public Feature estimate(Feature feature) { class ContingencyTable { private final int A, B, C, D; private ContingencyTable(Feature feature, Category category) { A = feature.getDocCountByCategory(category); B = feature.getFeatureCount() - A; C = category.getDocCount() - A; D = total - A - B - C; } } Double chisquared = categories.stream() .map(c -> new ContingencyTable(feature, c)) .map(ct -> Calculator.chisquare(ct.A, ct.B, ct.C, ct.D)) .max(Comparator.comparingDouble(Double::valueOf)).get(); feature.setScore(chisquared); return feature; } } |
四、樸素貝葉斯模型
4.1 原理簡介
樸素貝葉斯模型之所以稱之“樸素”,是因為其假設特徵之間是相互獨立的,在文字分類中,也就是說,一篇文件中出現的詞都是相互獨立,彼此沒有關聯,顯然文件中出現的詞都是有邏輯性的,這種假設在現實中幾乎是不成立的,但是這種假設卻大大簡化了計算,根據貝葉斯公式,文件Doc屬於類別Ci的概率為:
P(Ci|Doc)是所求的後驗概率,我們在判定分類時,根據每個類別計算P(Ci|Doc),最終把P(Ci|Doc)取得最大值的那個分類作為文件的類別。其中,P(Doc)對於類別Ci來說是常量,在比較大小時可以不用參與計算,而P(Ci)表示類別Ci出現的概率,我們稱之為先驗概率,這可以方便地從訓練集中統計得出,至於P(Doc|Ci),也就是類別的條件概率,如果沒有樸素貝葉斯的假設,那麼計算是非常困難的。
舉例來說,假設有一篇文章,內容為“王者榮耀:兩款傳說品質皮膚將優化,李白最新模型海報爆料”,經過特徵選擇,文件可以表示為Doc=(王者榮耀,傳說,品質,皮膚,優化,李白,最新,模型,海報,爆料),那麼在預測時需要計算P(王者榮耀,傳說,品質,皮膚,優化,李白,最新,模型,海報,爆料|Ci),這樣一個條件概率是不可計算的,因為第一個特徵取值為“王者榮耀”,第二個特徵取值“傳說”……第十個特徵取值“爆料”的文件很可能為沒有,那麼概率就為零,而基於樸素貝葉斯的假設,這個條件概率可以轉化為:
P(王者榮耀,傳說,品質,皮膚,優化,李白,最新,模型,海報,爆料|Ci)=P(王者榮耀|Ci)P(傳說|Ci)……P(爆料|Ci)
於是我們就可以統計這些特徵詞在每個類別中出現的概率了,在這個例子中,遊戲類別中“王者榮耀”這個特徵項會頻繁出現,因此P(王者榮耀|遊戲)的條件概率要明顯高於其他類別,這就是樸素貝葉斯模型的樸素之處,粗魯的聰明。
4.2 多項式模型與伯努利模型
在具體實現中,樸素貝葉斯又可以分為兩種模型,多項式模型(Multinomial)和伯努利模型(Bernoulli),另外還有高斯模型,主要用於處理連續型變數,在文字分類中不討論。
多項式模型和伯努利模型的區別在於對詞頻的考察,在多項式模型中文件中特徵項的頻度是參與計算的,這對於長文字來說是比較公平的,例如上面的例子,“王者榮耀”在遊戲類的文件中頻度會比較高,而伯努利模型中,所有特徵詞都均等地對待,只要出現就記為1,未出現就記為0,兩者公式如下:
在伯努利模型計算公式中,N(Doc(tj)|Ci)表示Ci類文件中特徵tj出現的文件數,|D|表示類別Ci的文件數,P(Ci)可以用類別Ci的文件數/文件總數來計算,
在多項式模型計算公式中,TF(ti,Doc)是文件Doc中特徵ti出現的頻度,TF(ti,Ci)就表示類別Ci中特徵ti出現的頻度,|V|表示特徵空間的大小,也就是特徵選擇之後,不同(即去掉重複之後)的特徵項的總個數,而P(Ci)可以用類別Ci中特徵詞的總數/所有特徵詞的總數,所有特徵詞的總數也就是所有特徵詞的詞頻之和。
至於分子和分母都加上一定的常量,這是為了防止資料稀疏而產生結果為零的現象,這種操作稱為拉普拉斯平滑,至於背後的原理,推薦閱讀這篇部落格:貝葉斯統計觀點下的拉普拉斯平滑
4.3 演算法實現
這裡使用了列舉類來封裝兩個模型,並實現了分類器NaiveBayesClassifier和訓練器NaiveBayesLearner中的兩個介面,其中Pprior和Pcondition是訓練器所需的方法,前者用來計算先驗概率,後者用來計算不同特徵項在不同類別下的條件概率;getConditionProbability是分類器所需的方法,NaiveBayesKnowledgeBase物件是模型資料的容器,它的getPconditionByWord方法就是用於查詢不同特徵詞在不同類別下的條件概率
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
public enum NaiveBayesModels implements NaiveBayesClassifier.Model, NaiveBayesLearner.Model { Bernoulli { @ Override public double Pprior(int total, Category category) { int Nc = category.getDocCount(); return Math.log((double) Nc / total); } @ Override public double Pcondition(Feature feature, Category category, double smoothing) { int Ncf = feature.getDocCountByCategory(category); int Nc = category.getDocCount(); return Math.log((double) (1 + Ncf) / (Nc + smoothing)); } @ Override public List getConditionProbability(String category, List terms, final NaiveBayesKnowledgeBase knowledgeBase) { return terms.stream().map(term -> knowledgeBase.getPconditionByWord(category, term.getWord())).collect(toList()); } }, Multinomial { @ Override public double Pprior(int total, Category category) { int Nt = category.getTermCount(); return Math.log((double) Nt / total); } @ Override public double Pcondition(Feature feature, Category category, double smoothing) { int Ntf = feature.getTermCountByCategory(category); int Nt = category.getTermCount(); return Math.log((double) (1 + Ntf) / (Nt + smoothing)); } @ Override public List getConditionProbability(String category, List terms, final NaiveBayesKnowledgeBase knowledgeBase) { return terms.stream().map(term -> term.getTf() * knowledgeBase.getPconditionByWord(category, term.getWord())).collect(toList()); } }; } |
五、訓練模型
根據樸素貝葉斯模型的定義,訓練模型的過程就是計算每個類的先驗概率,以及每個特徵項在不同類別下的條件概率,NaiveBayesKnowledgeBase物件將訓練器在訓練時得到的結果都儲存起來,訓練完成時寫入檔案,啟動分類時從檔案中讀入資料交由分類器使用,那麼在分類時就可以直接參與到計算過程中。
訓練器的實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
public class NaiveBayesLearner { …… …… public NaiveBayesLearner statistics() { log.info("開始統計..."); this.total = total(); log.info("total : " + total); this.categorySet = trainSet.getCategorySet(); featureSet.forEach(f -> f.getCategoryTermCounter().forEach((category, count) -> category.setTermCount(category.getTermCount() + count))); return this; } public NaiveBayesKnowledgeBase build() { this.knowledgeBase.setCategories(createCategorySummaries(categorySet)); this.knowledgeBase.setFeatures(createFeatureSummaries(featureSet, categorySet)); return knowledgeBase; } private Map createFeatureSummaries(final Set featureSet, final Set categorySet) { return featureSet.parallelStream() .map(f -> knowledgeBase.createFeatureSummary(f, getPconditions(f, categorySet))) .collect(toMap(NaiveBayesKnowledgeBase.FeatureSummary::getWord, Function.identity())); } private Map createCategorySummaries(final Set categorySet) { return categorySet.stream().collect(toMap(Category::getName, c -> model.Pprior(total, c))); } private Map getPconditions(final Feature feature, final Set categorySet) { final double smoothing = smoothing(); return categorySet.stream() .collect(toMap(Category::getName, c -> model.Pcondition(feature, c, smoothing))); } private int total() { if (model == Multinomial) return featureSet.parallelStream().map(Feature::getTerm).mapToInt(Term::getTf).sum();//總詞頻數 else if (model == Bernoulli) return trainSet.getTotalDoc();//總文件數 return 0; } private double smoothing() { if (model == Multinomial) return this.featureSet.size(); else if (model == Bernoulli) return 2.0; return 0.0; } public static void main(String[] args) { TrainSet trainSet = new TrainSet(System.getProperty("user.dir") + "/trainset/"); log.info("特徵選擇開始..."); FeatureSelection featureSelection = new FeatureSelection(new ChiSquaredStrategy(trainSet.getCategorySet(), trainSet.getTotalDoc())); List features = featureSelection.select(trainSet.getDocs()); log.info("特徵選擇完成,特徵數:[" + features.size() + "]"); NaiveBayesModels model = NaiveBayesModels.Multinomial; NaiveBayesLearner learner = new NaiveBayesLearner(model, trainSet, Sets.newHashSet(features)); learner.statistics().build().write(model.getModelPath()); log.info("模型檔案寫入完成,路徑:" + model.getModelPath()); } } |
在main函式中執行整個訓練過程,首先執行特徵選擇,這裡使用卡方檢驗法,然後將得到特徵空間,樸素貝葉斯模型(多項式模型),以及訓練集TrainSet物件作為引數,初始化訓練器,接著訓練器開始進行統計的工作,事實上有一部分的統計工作,在初始化訓練集物件時,就已經完成了,例如總文件數,每個類別下的文件數等,這些可以直接拿過來使用,最終將資料都裝載到NaiveBayesKnowledgeBase物件當中去,並寫入檔案,格式為第一行是不同類別的先驗概率,餘下每一行對應一個特徵項,每一列對應不同類別的條件概率值。
六,測試模型
分類器預測過程就相對於比較簡單了,通過NaiveBayesKnowledgeBase讀入資料,然後將指定的文字進行分詞,匹配特徵項,然後計算在不同類別下的後驗概率,返回取得最大值對應的那個類別。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class NaiveBayesClassifier { …… private final Model model; private final NaiveBayesKnowledgeBase knowledgeBase; public NaiveBayesClassifier(Model model) { this.model = model; this.knowledgeBase = new NaiveBayesKnowledgeBase(model.getModelPath()); } public String predict(String content) { Set allFeatures = knowledgeBase.getFeatures().keySet(); List terms = NLPTools.instance().segment(content).stream() .filter(t -> allFeatures.contains(t.getWord())).distinct().collect(toList()); @AllArgsConstructor class Result { final String category; final double probability; } Result result = knowledgeBase.getCategories().keySet().stream() .map(c -> new Result(c, Calculator.Ppost(knowledgeBase.getCategoryProbability(c), model.getConditionProbability(c, terms, knowledgeBase)))) .max(Comparator.comparingDouble(r -> r.probability)).get(); return result.category; } } |
在實際測試時,我們又單獨抓取了搜狗微信搜尋網站上的文章,按照100篇一組,一共30組進行分類的測試,最終結果每一組的準確率均在90%以上,最高達98%,效果良好。當然正規的評測需要同時評估準確率和召回率,這裡就偷懶不做了。
另外還需要說明一點的是,由於訓練集是來源於搜狗微信搜尋網站的文章,類別僅限於這20個,這不足以覆蓋所有微信公眾號文章的類別,因此在測試其他來源的微信文章準確率一定會有所影響。當然如果有更加豐富的微信文章訓練集的話,也可以利用這個模型重新訓練,那麼效果也會越來越好。
七、參考文獻與引用
- 宗成慶. 統計自然語言處理[M]. 清華大學出版社, 2013.
- T.M.Mitchell. 機器學習[M]. 機械工業出版社, 2003.
- 吳軍. 數學之美[M]. 人民郵電出版社, 2012.
- Raoul-Gabriel Urma, Mario Fusco, Alan Mycroft. Java 8 實戰[M]. 人民郵電出版社, 2016.
- Ansj中文分詞器,https://github.com/NLPchina/a…
- HanLP中文分詞器,https://github.com/hankcs/HanLP