背景介紹
在和實驗室導師討論構建旅游文本倉庫的時候,老師的一記操作讓我很吃驚...
wget --mirror some ip
這個操作老師稱此為一鍋端,是將某個網址域名下的所有網址內容都遞歸wget到...先不考慮反爬蟲措施,假設真的能夠將這個旅游網站的所有游記文本都順利拿到,這些文本數據必定是海量的,在這成千上外的文本數據中,如何構建我們自己需要的文本倉庫,涉及到一級一級嚴謹高效的pipeline,以后有空會把這其中的構思寫成博文分享。
在這里,首先第一步,必定是對這海量的文本進行文本聚類的操作,聚類后的文本,能夠輔助文本倉庫的打標簽工作。之前筆者曾經做過python文本聚類分析的若干實驗,感興趣的讀者可以前往文本聚類頁面。面對海量文本數據,python多進程似乎可以解決效率的問題,但考慮到資源分配、同步異步、異常處理等實際操作中會遇到的問題,憑空造python的分布式輪子耗時過久。此時,Spark工具就進入了筆者的考慮范圍了。
在大數據開發領域,Spark的大名如雷貫耳,其RDD(彈性分布式數據集)/DataFrame的內存數據結構,在機器學習“迭代”算法的場景下,速度明顯優于Hadoop磁盤落地的方式,此外,Spark豐富的生態圈也使得使用它為核心能夠構建一整套大數據開發系統。
本文將采用Spark,利用tf-idf作為文本特征,k-means算法進行聚類,各工具版本信息如下:
Spark 2.0.0
scala 2.11.8
java 1.8
hanlp 1.5.3
實現流程
參考里面的博客所采用的數據集是已經預處理過的,每個類別的文件都按照1,2,3這樣的數據開頭,這里的1,2,3就代表類別1,類別2,類別3.這樣會遇到一個問題,也是該博客實現過程中的一個bug,類別10的開頭第一個字母也是‘1’,導致類別1的判定是存在爭議的。但為了省事,筆者這里就只用其中的9類文本作為聚類文本,由已知標簽,從而判斷聚類效果。
參考中的博客采用的Spark版本偏老,為Spark1.6,現在Spark的版本已經邁進了2代,很多使用方法都不建議了,比如SQLContext,HiveContext和java2scala的一些數據結構轉換。本文立足2.0版本的spark,將其中過時的地方代替,更加適合新手入門上手。
開發環境
開發環境采用idea+maven(雖然SBT在spark業界更加流行)
下面是筆者的maven配置,放在pom.xml文件中:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>HanLP</groupId>
<artifactId>myHanLP</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<spark.version>2.0.0</spark.version>
<scala.version>2.11</scala.version>
</properties>
<dependencies>
<!-- scala環境,有了spark denpendencies后可以省略 -->
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>2.11.8</version>
</dependency>
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-compiler</artifactId>
<version>2.11.8</version>
</dependency>
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-reflect</artifactId>
<version>2.11.8</version>
</dependency>
<!-- 日志框架 -->
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.12</version>
</dependency>
<!-- 中文分詞框架 -->
<dependency>
<groupId>com.hankcs</groupId>
<artifactId>hanlp</artifactId>
<version>portable-1.5.3</version>
</dependency>
<!-- Spark dependencies -->
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_${scala.version}</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-streaming_${scala.version}</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-sql_${scala.version}</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-hive_${scala.version}</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-mllib_${scala.version}</artifactId>
<version>${spark.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.scala-tools</groupId>
<artifactId>maven-scala-plugin</artifactId>
<version>2.15.2</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.19</version>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>
其中需要注意的有兩個地方,第一個地方是scala.version,不要具體寫到2.11.8,這樣的話是找不到合適的spark依賴的,直接寫2.11就好。第二個地方是maven-scala-plugin,這個地方主要是為了使得項目中java代碼和scala代碼共存的,畢竟它們倆是不一樣的語言,雖然都能在jvm中跑,但編譯器不一樣呀...所以這個地方非常重要.
java目錄功能介紹
java目錄下的文件主要有兩個功能:
- 測試Hanlp
- 轉換編碼、合并文件
測試hanlp工具,這是個開源的java版本分詞工具,文件中分別測試了不同的分詞功能。另一個是將所有文件從GBK編碼模式轉換成UTF-8,再將這些小文件寫到一個大文件中。轉換編碼是為了文件讀取順利不報編碼的錯誤。大文件是為了提高Spark或Hadoop這類工具的效率,這里涉及到它們的一些實現原理,簡單來說,文件輸入到Spark中還會有分塊、切片的操作,大文件在這些操作時,效率更高。
scala目錄功能介紹
scala目錄下總共有4個子目錄,分別是用來測試scala編譯運行是否成功,調用Spark MLlib計算tf-idf,計算TF-IDF再利用K-means聚類,工具類。這里的工具類是原博客作者設計的,設計的目的是確定Spark是在本地測試,還是在集群上火力全來跑,并且適用于Window系統。因為我去掉了其封裝的SQLContext(已不建議使用),所以這個工具類在我Linux操作系統下意義也不是很大...
求TF-IDF
求TF-IDF采用SparkSession替代SparkContext,如下:
package test_tfidf
import org.apache.spark.ml.feature.{HashingTF, IDF, Tokenizer}
import org.apache.spark.sql.SparkSession
//import utils.SparkUtils
/**
*測試Spark MLlib的tf-idf
* Created by zcy on 18-1-4.
*/
object TFIDFDemo {
def main(args: Array[String]) {
val spark_session = SparkSession.builder().appName("tf-idf").master("local[4]").getOrCreate()
import spark_session.implicits._ // 隱式轉換
val sentenceData = spark_session.createDataFrame(Seq(
(0, "Hi I heard about Spark"),
(0, "I wish Java could use case classes"),
(1, "Logistic regression models are neat")
)).toDF("label", "sentence")
// 分詞
val tokenizer = new Tokenizer().setInputCol("sentence").setOutputCol("words")
println("wordsData----------------")
val wordsData = tokenizer.transform(sentenceData)
wordsData.show(3)
// 求TF
println("featurizedData----------------")
val hashingTF = new HashingTF()
.setInputCol("words").setOutputCol("rawFeatures").setNumFeatures(2000) // 設置哈希表的桶數為2000,即特征維度
val featurizedData = hashingTF.transform(wordsData)
featurizedData.show(3)
// 求IDF
println("recaledData----------------")
val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features")
val idfModel = idf.fit(featurizedData)
val rescaledData = idfModel.transform(featurizedData)
rescaledData.show(3)
println("----------------")
rescaledData.select("features", "label").take(3).foreach(println)
}
}
上面TF轉換特征向量的代碼設置了桶數,即特征向量的維度,這里將每個文本用2000個特征向量表示。
這里有一個非常好的博文,詳細的介紹了使用Spark MLlib計算TF-IDF,傳送門在這,我就不多介紹辣:
調用K-means模型
這里和參考博客一樣,參考官網教程即可:
// Trains a k-means model.
println("creating kmeans model ...")
val kmeans = new KMeans().setK(k).setSeed(1L)
val model = kmeans.fit(rescaledData)
// Evaluate clustering by computing Within Set Sum of Squared Errors.
println("calculating wssse ...")
val WSSSE = model.computeCost(rescaledData)
println(s"Within Set Sum of Squared Errors = $WSSSE")
評價方式
假設最終得到的文件和預測結果如下:
val t = List(
("121.txt",0),("122.txt",0),("123.txt",3),("124.txt",0),("125.txt",0),("126.txt",1),
("221.txt",3),("222.txt",4),("223.txt",3),("224.txt",3),("225.txt",3),("226.txt",1),
("421.txt",4),("422.txt",4),("4.txt",3),("41.txt",3),("43.txt",4),("426.txt",1)
文件名的第一個字符是否和聚類類別一致,統計結果來判斷,是否聚類成功,最終得到整體的聚類準確率,這里提供demo例子如下:
package test_scala
import org.apache.spark.Partitioner
import utils.SparkUtils
/**
* Created by zcy on 18-1-4.
*/
object TestPartition {
def main(args: Array[String]): Unit ={
val t = List(
("121.txt",0),("122.txt",0),("123.txt",3),("124.txt",0),("125.txt",0),("126.txt",1),
("221.txt",3),("222.txt",4),("223.txt",3),("224.txt",3),("225.txt",3),("226.txt",1),
("421.txt",4),("422.txt",4),("4.txt",3),("41.txt",3),("43.txt",4),("426.txt",1)
) // 文檔開頭代表類別,后一個數字代表預測類型
val sc = SparkUtils.getSparkContext("test partitioner",true) //本地測試:true
val data = sc.parallelize(t)
val file_index = data.map(_._1.charAt(0)).distinct.zipWithIndex().collect().toMap
println("file_index: " + file_index) // key:begin of txt, value:index
val partitionData = data.partitionBy(MyPartitioner(file_index))
val tt = partitionData.mapPartitionsWithIndex((index: Int, it: Iterator[(String,Int)]) => it.toList.map(x => (index,x)).toIterator)
println("map partitions with index:")
tt.collect().foreach(println(_)) // like this: (0,(421.txt,4))
// firstCharInFileName , firstCharInFileName - predictType
val combined = partitionData.map(x =>( (x._1.charAt(0), Integer.parseInt(x._1.charAt(0)+"") - x._2),1) )
.mapPartitions{f => var aMap = Map[(Char,Int),Int]();
for(t <- f){
if (aMap.contains(t._1)){
aMap = aMap.updated(t._1,aMap.getOrElse(t._1,0)+1)
}else{
aMap = aMap + t
}
}
val aList = aMap.toList
val total= aList.map(_._2).sum
val total_right = aList.map(_._2).max
List((aList.head._1._1,total,total_right)).toIterator
// aMap.toIterator //打印各個partition的總結
}
val result = combined.collect()
println("results: ")
result.foreach(println(_)) // (4,6,3) 類別4,總共6個,3個正確
for(re <- result ){
println("文檔"+re._1+"開頭的 文檔總數:"+ re._2+",分類正確的有:"+re._3+",分類正確率是:"+(re._3*100.0/re._2)+"%")
}
val averageRate = result.map(_._3).sum *100.0 / result.map(_._2).sum
println("平均正確率為:"+averageRate+"%")
sc.stop()
}
}
case class MyPartitioner(file_index:Map[Char,Long]) extends Partitioner{
override def getPartition(key: Any): Int = key match {
case _ => file_index.getOrElse(key.toString.charAt(0),0L).toInt //將value轉換成int
}
override def numPartitions: Int = file_index.size
}
結果展示
最終,在筆者本地Spark偽集群環境下,用4個進程模擬4臺主機,輸出結果如下:
文檔4開頭的 文檔總數:214,分類正確的有:200,分類正確率是:93.45794392523365%
文檔8開頭的 文檔總數:249,分類正確的有:221,分類正確率是:88.75502008032129%
文檔6開頭的 文檔總數:325,分類正確的有:258,分類正確率是:79.38461538461539%
文檔2開頭的 文檔總數:248,分類正確的有:170,分類正確率是:68.54838709677419%
文檔7開頭的 文檔總數:204,分類正確的有:200,分類正確率是:98.03921568627452%
文檔5開頭的 文檔總數:200,分類正確的有:185,分類正確率是:92.5%
文檔9開頭的 文檔總數:505,分類正確的有:504,分類正確率是:99.8019801980198%
文檔3開頭的 文檔總數:220,分類正確的有:114,分類正確率是:51.81818181818182%
文檔1開頭的 文檔總數:450,分類正確的有:448,分類正確率是:99.55555555555556%
平均正確率為:87.95411089866157%
這里已經排除了參考博文中類別1與類別11的影響,還有某一類別中有一個文件開頭不是數字的尷尬問題..初學者可以直接用我github庫中的data文件夾,參考博客的有一些無傷大雅的小問題。
從整個運行結果來看,正確率還是很高的,值得信賴,但和參考博客比,某些類別還是不夠準確,畢竟k-means算法有一定的隨機性,這種誤差我們還是可以接受的。并且從整體運行時間上來說,真的非常快(估計在十幾秒),這個時間還包括了啟動Spark,初始化等等過程,和python處理相比,不僅高效,還更加可靠。強推...
寫了這么多,不知道導師的一鍋端操作搞定沒有...搞定了的話,正好練練手,打成jar包,submit到實驗室的spark集群上去跑跑...
- 參考資料
詳細代碼見筆者的github:文本聚類Spark版本
××××××××××××××××××××××××××××××××××××××××××
本文屬于筆者(EdwardChou)原創
轉載請注明出處
××××××××××××××××××××××××××××××××××××××××××