基于Spark的文本情感分析
date: 2018-08-28 15:06:56
前言
本文以大众点评中餐馆的评论数据为例,实现一个简单的文本情感分析系统。
主要的技术环节:
- 收集数据。这里包括爬虫爬取相应数据,并对数据进行清洗、过滤、抽取等。
- 设计文本的表示模型,选择文本的特征。使用向量来表示文本,首先需要对文本继续中文分词处理,把用户评论转化成词语后,可以使用 TF-IDF(Term Frequency–Inverse Document Frequency,词频-逆文档频率)算法来抽取特征,并计算出特征值。
- 选择分类模型。常用的分类算法有很多,如:决策树、贝叶斯、人工神经网络、K-近邻、支持向量机等等。在文本分类上使用较多的是贝叶斯和支持向量机。本文中,也以这两种方法来进行模型训练。
用到的技术:
主要使用Spark、Spark SQL、Spark MLlib来实现,并且Spark所支持的Scala、Java、Python都已实现。
数据说明
数据描述: 一段时间内,大众点评上广州地区粤菜排名前211家餐厅的用户评论信息,包括评论用户、评论时间、评论内容、点赞数、回复数。
时间范围:2004-7-1 至2017-10-31
数据量:83753
数据格式:csv
数据字段说明:
字段名称 | 字段描述 |
---|---|
Review_ID | 评论id |
Merchant | 评论餐厅名称 |
Rating | 餐厅整体评分 |
Score_taste | 味道评分 |
Score_environment | 环境评分 |
Score_service | 服务评分 |
Price_per_person | 人均价格(Null为空) |
Time | 评论时间 |
Num_thumbs_up | 评论点赞数 |
Num_ response | 评论回复数 |
Content_review | 评论文本 |
Reviewer | 评论人用户名 |
Reviewer_value | 评论人等级 |
Reviewer_rank | 评论人是否为VIP用户(1为是,0为否) |
Favorite_foods | 喜欢的菜 |
样例数据:
377313283,炳胜品味(珠江新城店),3,3,3,2,Null,10-23,2,1,来广州出差,住在附近的康莱德酒店,晚上开完会就慕名而来,大概七点四十分的样子到的,人还有很多。刚入座,服务员就热情地让我点各种前菜,什么黄瓜,目前,凤爪啥的,我们都很不钟意,他们就臭着脸走了,后面的服务,不知道是不是因为我们没接受他们的推销,他们的服务总是有点不让人那么愉快。我们想点很多推荐的菜,什么烧味腊味之类的,结果被告知全没了,各种心塞,然后就情绪爆发地点了一些乱七八糟的东西。黄金煎饺,一共十五个,很小一个,味道很不错。还有个什么汤,我忘记了,广州普通话不是很能适应,但是味道很不错,里面有丝瓜海鲜什么的。一份豆腐煲,豆腐很嫩很入味,我吃了里面很多黄瓜。一份油麦菜,无功无过,最后几乎都剩下了。亮点是他们家的鱼生,我们三个人要了个4.8斤的,量非常大,有很多酱料,要自己调。服务员说了很多先后顺序,我们愣是没懂,觉得复杂,所以我索性让服务员帮我弄了,就是先在特定的玻璃盘子上倒上耗油,抹抹盘底,然后放入鱼生,然后放上姜丝,柠檬草,葱丝等各种配料,任君挑选,然后淋上酱汁,拌一拌,就能吃了,我吃了口一点没吃出鱼腥味,还不错,但也没别人推荐的那么传神。鱼生之外的耍鱼肉做成了砂锅,鱼肉很苏很入味,蛮好吃的,但不是我的菜。鹅肠,非常入味,推荐,而且吃很多也不会很撑,没负罪感。饮料叫了份蔓越莓玫瑰汁,一般般吧。没啥推荐不推荐的。甜品叫了姜汁撞奶?本来就超爱吃,姜汁味非常浓郁,奶很滑嫩,温温的特别棒,是我最喜欢吃的一样。如果不是明天早上还有会,真想去点都德也尝尝早茶,唉,忧桑。,胖子界的瘦子吴,5,1
数据预处理
为了简化实验,我们只抽取餐厅的总体评论和评论内容。
textFile方法默认只支持UTF-8格式,通过封装后的transfer方法读取GBK文件,并讲每一行数据以字符串格式返回RDD[String]:
def transfer(sc:SparkContext, path:String):RDD[String] = {
// 将value的字节码按照GBK的方式读取变成字符串,运行之后能够正常显示
sc.hadoopFile(path, classOf[TextInputFormat], classOf[LongWritable], classOf[Text], 1)
.map(p => new String(p._2.getBytes, 0, p._2.getLength, "GBK"))
}
读取数据,抽取需要的字段,并做过滤处理
val rate_document = transfer(sc, "hdfs://...")
.map(line => {
val rating = line.split(",")(2)
val content_review = line.split(",")(10)
rating + "\t" + content_review
}).filter(line => {
line.split("\t").length == 2 && !"Rating".equals(line.split("\t")(0))
})
统计数据基本信息
打印1到5分数据的数量
for (n <- 1 to 5) {
val NRateDocument = rateDocument.filter(_.split("\t")(0).toInt == n)
println(n + "分的数据有" + NRateDocument.count + "条")
}
五分的数据有 43168 条,4 分、3 分、2 分、1 分的数据分别有27086条,10184条,1438条,1876条。打五分的毫无疑问是好评;考虑到不同人对于评分的不同偏好,对于打四分的数据,本文无法得知它是好评还是坏评;对于打三分及三分以下的是坏评。
生成训练数据集
// 五分的数据
val fiveRateDocument = rateDocument.filter(_.split("\t")(0).toInt == 5)
// 负样本数据
val negativeRateDocument = rateDocument.filter(line => {
line.split("\t")(0).toInt == 1 || line.split("\t")(0).toInt == 2 || line.split("\t")(0).toInt == 3
})
好评和坏评分别有 43168 条和 13498 条,属于非平衡样本的机器模型训练。这里只取部分好评数据,好评和坏评的数量一样,这样训练的正负样本就是均衡的。最后把正负样本放在一起,并把分类标签和文本分开,形成训练数据集。
val posRateDocument = sc.parallelize(fiveRateDocument.take(negativeRateDocument.count.toInt))
// 训练集
val allRateDocument = posRateDocument.union(negativeRateDocument)
分词
分词采用jcseg中文分词方法,具体使用方法可参考官网:jcseg,Java实现,所以在Spark的Java和Scala项目中都能够使用。
这里使用封装好的AnaylyzerTools类,参考:基于Spark上的中文分词算法的实现
val wordRDD = allRateDocument.map(rd => {
val rate = rd.split("\t")(0).toInt
val lable = if (rate > 4) 1 else 0
val document = rd.split("\t")(1)
val words: Array[AnyRef] = AnaylyzerTools.anaylyzerWords(document).toArray
Row(lable, document, words)
})
RDD转换成DataFrame
val schema = StructType(Array(
StructField("lable", IntegerType, true),
StructField("document", StringType, true),
StructField("words", ArrayType(StringType), false)
))
val wordDF = sqlContext.createDataFrame(wordRDD, schema).cache()
训练词频矩阵
出于对大规模数据计算需求的考虑,spark 的词频计算是用特征哈希(HashingTF)来计算的。特征哈希是一种处理高维数据的技术,经常应用在文本和分类数据集上。普通的 k 分之一特征编码需要在一个向量中维护可能的特征值及其到下标的映射,而每次构建这个映射的过程本身就需要对数据集进行一次遍历。这并不适合上千万甚至更多维度的特征处理。
特征哈希是通过哈希方程对特征赋予向量下标的,所以在不同情况下,同样的特征就是能够得到相同的向量下标,这样就不需要维护一个特征值及其下表的向量。
要使用特征哈希来处理文本,需要先实例化一个 HashingTF 对象,将词转化为词频,为了高效计算,本文将后面会重复使用的词频缓存。
val hashingTF = new HashingTF().setInputCol("words").setOutputCol("rawFeatures")
// 用HashingTF的transform()方法把句子哈希成特征向量
val featurizedTF = hashingTF.transform(wordDF).cache()
查看一下训练结果:
featurizedTF.select("words", "rawFeatures").show(20, false)
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|words |rawFeatures
+----------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|[这个, 价位, ,, 这个, 口味, 就, 那样, ,, 说不上, 特别, 好吃] | (262144,[12231,17078,37436,54992,67849,85364,112276,153025,154679],[1.0,1.0,1.0,1.0,1.0,2.0,1.0,2.0,1.0]) |
...
以这行为例,262144代表哈希表的桶数,[12231,17078,37436,54992,67849,85364,112276,153025,154679]代表着单词的哈希值,[1.0,1.0,1.0,1.0,1.0,2.0,1.0,2.0,1.0]为对应单词的出现次数
计算 TF-IDF 矩阵
调用IDF方法来重新构造特征向量的规模,生成的idf是一个Estimator,在特征向量上应用它的fit()方法,会产生一个IDFModel。
val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features")
val idfModel = idf.fit(featurizedTF)
同时,调用IDFModel的transform方法,可以得到每一个单词对应的TF-IDF 度量值。
val rescaledData = idfModel.transform(featurizedTF)
查看一下矩阵样式:
rescaledData.select("words", "features").show(20, false)
|[这个, 价位, ,, 这个, 口味, 就, 那样, ,, 说不上, 特别, 好吃] | (262144,[12231,17078,37436,54992,67849,85364,112276,153025,154679],[2.1893967487159727,1.1084840371472637,5.539300835990577,1.2352357427864076,2.3204250111223765,0.2958972069507029,4.286537867495209,4.6012447676523935,4.286537867495209]) |
...
[2.1893967487159727,1.1084840371472637,...]代表单词的TF-IDF值
生成训练集和测试集
将上面的数据转换成Bayes算法需要的格式
import spark.implicits._
val trainDataRdd: Dataset[LabeledPoint] = rescaledData.select("lable", "features").map { case Row(label: Int, features: Vector) =>
LabeledPoint(label.toInt, Vectors.dense(features.toArray))
}
这里随机取60%为训练集,40%为测试集
val Array(trainingData, testData) = trainDataRdd.randomSplit(Array(0.6, 0.4), seed = 1234L)
训练贝叶斯分类模型
val NBmodel = new NaiveBayes().fit(trainingData)
//对测试数据集使用训练模型进行分类预测
val predictions = NBmodel.transform(testData)
// Select (prediction, true label) and compute test error
val evaluator = new MulticlassClassificationEvaluator()
.setLabelCol("label")
.setPredictionCol("prediction")
.setMetricName("accuracy")
val accuracy: Double = evaluator.evaluate(predictions)
println(s"Test set accuracy = $accuracy")
最后输出结果为“Test set accuracy = 0.7829224857062518”
总结
这里训练结果为78.29%,其实可以更高,这里的分词没有做停顿词的过滤,如果过滤掉效果会更好。还尝试了用SVM训练,正确率确实提升了不少。
本文所有示例代码提交到Github,并且提供了Java和Python版本的实现。
参考