軟件:IDEA2014、Maven、HanLP、JDK;
用到的知識(shí):HanLP、Spark TF-IDF、Spark kmeans、Spark mapPartition;
用到的數(shù)據(jù)集:http://www.threedweb.cn/thread-1288-1-1.html(不需要下載,已經(jīng)包含在工程里面);
工程下載:https://github.com/fansy1990/hanlp-test 。
1、問(wèn)題描述
現(xiàn)在有一個(gè)中文文本數(shù)據(jù)集,這個(gè)數(shù)據(jù)集已經(jīng)對(duì)其中的文本做了分類,如下:
其中每個(gè)文件夾中含有個(gè)數(shù)不等的文件,比如環(huán)境有200個(gè),藝術(shù)有248個(gè);同時(shí),每個(gè)文件的內(nèi)容基本上就是一些新聞報(bào)道或者中文描述,如下:
現(xiàn)在需要做的就是,把這些文檔進(jìn)行聚類,看其和原始給定的類別的重合度有多少,這樣也可以反過(guò)來(lái)驗(yàn)證我們聚類算法的正確度。
2.、解決思路:
2.1 文本預(yù)處理:
1. 由于文件的編碼是GBK的,讀取到Spark中全部是亂碼,所以先使用Java把代碼轉(zhuǎn)為UTF8編碼; ?
2. 由于文本存在多個(gè)文件中(大概2k多),使用Spark的wholeTextFile讀取速度太慢,所以考慮把這些文件全部合并為一個(gè)文件,這時(shí)又結(jié)合1.的轉(zhuǎn)變編碼,所以在轉(zhuǎn)變編碼的時(shí)候就直接把所有的數(shù)據(jù)存入同一個(gè)文件中;
其存儲(chǔ)的格式為: 每行: 文件名.txt\t文件內(nèi)容
如: ?41.txt 【 日 ?期 】199601....
這樣子的話,就可以通過(guò).txt\t 來(lái)對(duì)每行文本進(jìn)行分割,得到其文件名以及文件內(nèi)容,這里每行其實(shí)就是一個(gè)文件了。
2.2 分詞
分詞直接采用HanLP的分詞來(lái)做,HanLP這里選擇兩種:Standard和NLP(還有一種就是HighSpeed,但是這個(gè)木有用戶自定義詞典,所以前期考慮先用兩種),具體參考:https://github.com/hankcs/HanLP ;
2.3 詞轉(zhuǎn)換為詞向量
在Kmeans算法中,一個(gè)樣本需要使用數(shù)值類型,所以需要把文本轉(zhuǎn)為數(shù)值向量形式,這里在Spark中有兩種方式。其一,是使用TF-IDF;其二,使用Word2Vec。這里暫時(shí)使用了TF-IDF算法來(lái)進(jìn)行,這個(gè)算法需要提供一個(gè)numFeatures,這個(gè)值越大其效果也越好,但是相應(yīng)的計(jì)算時(shí)間也越長(zhǎng),后面也可以通過(guò)實(shí)驗(yàn)驗(yàn)證。
2.4 使用每個(gè)文檔的詞向量進(jìn)行聚類建模
在進(jìn)行聚類建模的時(shí)候,需要提供一個(gè)初始的聚類個(gè)數(shù),這里面設(shè)置為10,因?yàn)槲覀兊臄?shù)據(jù)是有10個(gè)分組的。但是在實(shí)際的情況下,一般這個(gè)值是需要通過(guò)實(shí)驗(yàn)來(lái)驗(yàn)證得到的。
2.5 對(duì)聚類后的結(jié)果進(jìn)行評(píng)估
這里面采用的思路是:
1. 得到聚類模型后,對(duì)原始數(shù)據(jù)進(jìn)行分類,得到原始文件名和預(yù)測(cè)的分類id的二元組(fileName,predictId);
2. 針對(duì)(fileName,predictId),得到(fileNameFirstChar ,fileNameFirstChar.toInt - predictId)的值,這里需要注意的是fileNameFirstChar其實(shí)就是代表這個(gè)文件的原始所屬類別了。
3. 這里有一個(gè)一般假設(shè),就是使用kmeans模型預(yù)測(cè)得到的結(jié)果大多數(shù)是正確的,所以fileNameFirstChar.toInt-predictId得到的眾數(shù)其實(shí)就是分類的正確的個(gè)數(shù)了(這里可能比較難以理解,后面會(huì)有個(gè)小李子來(lái)說(shuō)明這個(gè)問(wèn)題);
4. 得到每個(gè)實(shí)際類別的預(yù)測(cè)的正確率后就可以去平均預(yù)測(cè)率了。
5. 改變numFeatuers的值,看下是否numFeatures設(shè)置的比較大,其正確率也會(huì)比較大?
3、具體步驟:
3.1 開(kāi)發(fā)環(huán)境--Maven
首先第一步,當(dāng)然是開(kāi)發(fā)環(huán)境了,因?yàn)橛玫搅?/span>Spark和HanLP,所以需要在pom.xml中加入這兩個(gè)依賴:
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
其版本為:
3.2 文件轉(zhuǎn)為UTF-8編碼及存儲(chǔ)到一個(gè)文件
?
這部分內(nèi)容可以直接參考:src/main/java/demo02_transform_encoding.TransformEncodingToOne 這里的實(shí)現(xiàn),因?yàn)槭?/span>Java基本的操作,這里就不加以分析了。
3.3 Scala調(diào)用HanLP進(jìn)行中文分詞
Scala調(diào)用HanLP進(jìn)行分詞和Java的是一樣的,同時(shí),因?yàn)檫@里有些詞語(yǔ)格式不正常,所以把這些特殊的詞語(yǔ)添加到自定義詞典中,其示例如下:
1.import com.hankcs.hanlp.dictionary.CustomDictionary
2.import com.hankcs.hanlp.dictionary.stopword.CoreStopWordDictionary
3.import com.hankcs.hanlp.tokenizer.StandardTokenizer
4.import scala.collection.JavaConversions._
5./**
6.* Scala 分詞測(cè)試
7.* Created by fansy on 2017/8/25.
?8.*/
9.object SegmentDemo {
10.def main(args: Array[String]) {
11.val sentense = "41,【 日 期 】19960104 【 版 號(hào) 】1 【 標(biāo) 題 】合巢蕪高速公路巢蕪段竣工 【 作 者 】彭建中 【 正 文 】 安徽合(肥)巢(湖)蕪(湖)高速公路巢蕪段日前竣工通車并投入營(yíng)運(yùn)。合巢蕪 高速公路是國(guó)家規(guī)劃的京福綜合運(yùn)輸網(wǎng)的重要干線路段,是交通部確定1995年建成 的全國(guó)10條重點(diǎn)公路之一。該條高速公路正線長(zhǎng)88公里。(彭建中)"
12.CustomDictionary.add("日 期")
13.CustomDictionary.add("版 號(hào)")
14.CustomDictionary.add("標(biāo) 題")
15.CustomDictionary.add("作 者")
16.CustomDictionary.add("正 文")
17.val list = StandardTokenizer.segment(sentense)
18.CoreStopWordDictionary.apply(list)
19.println(list.map(x => x.word.replaceAll(" ","")).mkString(","))
20.}
21.}
運(yùn)行完成后,即可得到分詞的結(jié)果,如下:
考慮到使用方便,這里把分詞封裝成一個(gè)函數(shù):
1./**
2.* String 分詞
3.* @param sentense
4.* @return
5.*/
6.def transform(sentense:String):List[String] ={
7.val list = StandardTokenizer.segment(sentense)
8.CoreStopWordDictionary.apply(list)
9.list.map(x => x.word.replaceAll(" ","")).toList
10.}
11.}
?
輸入即是一個(gè)中文的文本,輸出就是分詞的結(jié)果,同時(shí)去掉了一些常用的停用詞。
3.4 求TF-IDF
在Spark里面求TF-IDF,可以直接調(diào)用Spark內(nèi)置的算法模塊即可,同時(shí)在Spark的該算法模塊中還對(duì)求得的結(jié)果進(jìn)行了維度變換(可以理解為特征選擇或“降維”,當(dāng)然這里的降維可能是提升維度)。代碼如下:
1.val docs = sc.textFile(input_data).map{x => val t = x.split(".txt\t");(t(0),transform(t(1)))}
2..toDF("fileName", "sentence_words")
3.
4.// 3. 求TF
5.println("calculating TF ...")
6.val hashingTF = new HashingTF()
7..setInputCol("sentence_words").setOutputCol("rawFeatures").setNumFeatures(numFeatures)
8.val featurizedData = hashingTF.transform(docs)
9.
10.// 4. 求IDF
11.println("calculating IDF ...")
12.val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features")
13.val idfModel = idf.fit(featurizedData)
14.val rescaledData = idfModel.transform(featurizedData).cache()
變量docs是一個(gè)DataFrame[fileName, sentence_words] ,經(jīng)過(guò)HashingTF后,變成了變量 featurizedData ,同樣是一個(gè)DataFrame[fileName,sentence_words, rawFeatures]。這里通過(guò)setInputCol以及SetOutputCol可以設(shè)置輸入以及輸出列名(列名是針對(duì)DataFrame來(lái)說(shuō)的,不知道的可以看下DataFrame的API)。
接著,經(jīng)過(guò)IDF模型,得到變量 rescaledData ,其DataFrame[fileName,sentence_words, rawFeatures, features] 。
執(zhí)行結(jié)果為:
3.5 建立KMeans模型
?
直接參考官網(wǎng)給定例子即可:
1.println("creating kmeans model ...")
2.val kmeans = new KMeans().setK(k).setSeed(1L)
3.val model = kmeans.fit(rescaledData)
4.// Evaluate clustering by computing Within Set Sum of Squared Errors.
5.println("calculating wssse ...")
6.val WSSSE = model.computeCost(rescaledData)
7.println(s"Within Set Sum of Squared Errors = $WSSSE")
這里有計(jì)算cost值的,但是這個(gè)值評(píng)估不是很準(zhǔn)確,比如我numFeature設(shè)置為2000的話,那么這個(gè)值就很大,但是其實(shí)其正確率會(huì)比較大的。
3.6 模型評(píng)估
這里的模型評(píng)估直接使用一個(gè)小李子來(lái)說(shuō)明:比如,現(xiàn)在有這樣的數(shù)據(jù):
其中,1開(kāi)頭,2開(kāi)頭和4開(kāi)頭的屬于同一類文檔,后面的0,3,2,1等,代表這個(gè)文檔被模型分類的結(jié)果,那么可以很容易的看出針對(duì)1開(kāi)頭的文檔,
其分類正確的有4個(gè),其中("123.txt",3)以及(“126.txt”,1)是分類錯(cuò)誤的結(jié)果,這是因?yàn)?,在這個(gè)類別中預(yù)測(cè)的結(jié)果中0是最多的,所以0是和1開(kāi)頭的文檔對(duì)應(yīng)起來(lái)的,這也就是前面的假設(shè)。
1. 把同一類文檔分到同一個(gè)partition中;
1.val data = sc.parallelize(t)
2.val file_index = data.map(_._1.charAt(0)).distinct.zipWithIndex().collect().toMap
3.println(file_index)
4.val partitionData = data.partitionBy(MyPartitioner(file_index))
這里的file_index,是對(duì)不同類的文檔進(jìn)行編號(hào),這個(gè)編號(hào)就對(duì)應(yīng)每個(gè)partition,看MyPartitioner的實(shí)現(xiàn):
1.case class MyPartitioner(file_index:Map[Char,Long]) extends Partitioner
2.override def getPartition(key: Any): Int = key match {
3.case _ => file_index.getOrElse(key.toString.charAt(0),0L).toInt
4.}
?
5..override def numPartitions: Int = file_index.size
6.}
2. 針對(duì)每個(gè)partition進(jìn)行整合操作:
在整合每個(gè)partition之前,我們先看下我們自定義的MyPartitioner是否在正常工作,可以打印下結(jié)果:
1.val tt = partitionData.mapPartitionsWithIndex((index: Int, it: Iterator[(String,Int)]) => it.toList.map(x => (index,x)).toIterator)
2.tt.collect().foreach(println(_))
運(yùn)行如下:
其中第一列代表每個(gè)partition的id,第二列是數(shù)據(jù),發(fā)現(xiàn)其數(shù)據(jù)確實(shí)是按照預(yù)期進(jìn)行處理的;接著可以針對(duì)每個(gè)partition進(jìn)行數(shù)據(jù)整合:
1.// firstCharInFileName , firstCharInFileName - predictType
2.val combined = partitionData.map(x =>( (x._1.charAt(0), Integer.parseInt(x._1.charAt(0)+"") - x._2),1) )
3..mapPartitions{f => var aMap = Map[(Char,Int),Int]();
4.for(t <- f){
5.if (aMap.contains(t._1)){
6.aMap = aMap.updated(t._1,aMap.getOrElse(t._1,0)+1)
7.}else{
8.aMap = aMap + t
9.}
10.}
11.val aList = aMap.toList
12.val total= aList.map(_._2).sum
13.val total_right = aList.map(_._2).max
14.List((aList.head._1._1,total,total_right)).toIterator
15.// aMap.toIterator //打印各個(gè)partition的總結(jié)
16. }
在整合之前先執(zhí)行一個(gè)map操作,把數(shù)據(jù)變成((fileNameFirstChar, fileNameFirstChar.toInt - predictId), 1),其中fileNameFirstChar代表文件的第一個(gè)字符,其實(shí)也就是文件的所屬實(shí)際類別,后面的fileNameFirstChar.toInt-predictId 其實(shí)就是判斷預(yù)測(cè)的結(jié)果是否對(duì)了,這個(gè)值的眾數(shù)就是預(yù)測(cè)對(duì)的;最后一個(gè)值代碼前面的這個(gè)鍵值對(duì)出現(xiàn)的次數(shù),其實(shí)就是統(tǒng)計(jì)屬于某個(gè)類別的實(shí)際文件個(gè)數(shù)以及預(yù)測(cè)對(duì)的文件個(gè)數(shù),分別對(duì)應(yīng)上面的total和total_right變量;輸出結(jié)果為:
(4,6,3)
(1,6,4)
(2,6,4)
發(fā)現(xiàn)其打印的結(jié)果是正確的,第一列代表文件名開(kāi)頭,第二個(gè)代表屬于這個(gè)文件的個(gè)數(shù),第三列代表預(yù)測(cè)正確的個(gè)數(shù)
這里需要注意的是,這里因?yàn)槲谋镜膶?shí)際類別和文件名是一致的,所以才可以這樣處理,如果實(shí)際數(shù)據(jù)的話,那么mapPartitions函數(shù)需要更改。
3. 針對(duì)數(shù)據(jù)結(jié)果進(jìn)行統(tǒng)計(jì):
最后只需要進(jìn)行簡(jiǎn)單的計(jì)算即可:
1.for(re <- result ){
2.println("文檔"+re._1+"開(kāi)頭的 文檔總數(shù):"+ re._2+",分類正確的有:"+re._3+",分類正確率是:"+(re._3*100.0/re._2)+"%")
3.}
4.val averageRate = result.map(_._3).sum *100.0 / result.map(_._2).sum
5.println("平均正確率為:"+averageRate+"%")
輸出結(jié)果為:
4. 實(shí)驗(yàn)
??設(shè)置不同的numFeature,比如使用200和2000,其對(duì)比結(jié)果為:
所以設(shè)置numFeatures值越大,其準(zhǔn)確率也越高,不過(guò)計(jì)算也比較復(fù)雜。
?5. 總結(jié)
1. HanLP的使用相對(duì)比較簡(jiǎn)單,這里只使用了分詞及停用詞,感謝開(kāi)源;
2. Spark里面的TF-IDF以及Word2Vector使用比較簡(jiǎn)單,不過(guò)使用這個(gè)需要先分詞;
3. 這里是在IDEA里面運(yùn)行的,如果使用Spark-submit的提交方式,那么需要把hanpl的jar包加入,這個(gè)有待驗(yàn)證
?
評(píng)論