开发文章

基于Spark的大数据精准营销中搜狗搜索引擎的用户画像挖掘

近期参加了CCF举办的“大数据精准营销中搜狗用户画像挖掘”竞赛。正好这学期《机器学习与数据挖掘》课程需要一个实验报告,于是就那它来写了。本博文会在这几周不断的完善更新ing

1. 选题背景与意义

1.1 用户画像与精准营销

“用户画像”是近几年诞生的名词。很多营销项目或很多广告主,在打算投放广告前,都要求媒体提供其用户画像。在以前,大多媒体会针对自身用户做一个分类,但是有了大数据后,企业及消费者行为带来一系列改变与重塑,通过用户画像可以更加拟人化的描述用户特点。
用户画像,即用户信息标签化,就是企业通过收集与分析消费者社会属性、生活习惯、消费行为等主要信息的数据之后,完美地抽象出一个用户的商业全貌,可以看作是企业应用大数据技术的基本方式。用户画像为企业提供了足够的信息基础,能够帮助企业快速找到精准用户群体以及用户需求等更为广泛的反馈信息。
消费方式的改变促使用户迫切希望尽快获取自己想要了解的信息,所以说,基于用户画像上的精准营销不管对企业还是对用户来说,都是有需求的,这会给双方交易带来极大便捷,也为双方平等沟通搭建了一个畅通平台。

1.2 搜索引擎下用户画像的挑战

在搜索引擎下,由于搜索引擎本身使用方式的特殊性、用户的流动性、查询的实时性等,带来了与企业传统的对用户信息进行收集与分析有着巨大的不同、更加艰巨的挑战。
例如,我们实时获取到的是用户的查询语句,而由于用户的流动性,并不能直接获取到如年龄、性别、学历等用户的标签信息。这么一来,也就无法根据用户属性对用户进行分群处理,而后再通过推荐系统进行产品上的优化

1.3 本文内容概要

本文内容概要如下:

  • 第1章:简介用户画像与搜索引擎下用户画像的精准营销的挑战。
  • 第2章:说明实验集群、数据与课题研究目标。
  • 第3章:介绍使用分词工具对用户的搜索词列进行分词,以及相关的优化方案。
  • 第4章:介绍在分词的基础上,对文本进行特征的抽取与转换,以及相关的优化方案。
  • 第5章:介绍在原始特征向量上,进行聚类与降维。
  • 第6章:介绍实验中试验过各分类模型
  • 第7章:介绍模型参数调优
  • 第8章:总结本课题研究中不足与展望后续的优化方案
  • 第9章:参考文献

2. 课题实验准备

2.1 Spark集群

节点 备注
cdh01 8核,32G内存,角色:Spark Master,HDFS NameNode,Spark Worker,HDFS DataNode
cdh02 8核,12G内存,角色:Spark Worker,HDFS DataNode
cdh03 8核,12G内存,角色:Spark Worker,HDFS DataNode
cdh04 8核,12G内存,角色:Spark Worker,HDFS DataNode

2.2 数据集

数据文件 备注
Train.csv 带标注的训练集
Test.csv 测试集

2.3 数据介绍

本数据来源于搜狗搜索数据,ID经过加密,训练集中人口属性数据存在部分未知的情况(需要解决方案能够考虑数据缺失对算法性能的影响)。数据所有字段如下表所示:

字段 说明
ID 加密后的ID
age 0:未知年龄; 1:0-18岁; 2:19-23岁; 3:24-30岁; 4:31-40岁; 5:41-50岁; 6: 51-999岁
Gender 0:未知1:男性2:女性
Education 0:未知学历; 1:博士; 2:硕士; 3:大学生; 4:高中; 5:初中; 6:小学
Query List 搜索词列表

2.4 数据示例

对于train.csv中的数据记录:

00627779E16E7C09B975B2CE13C088CB 4 2 0 钢琴曲欣赏100首 一个月的宝宝眼睫毛那么是黄色 宝宝右眼有眼屎 小儿抽搐怎么办 剖腹产后刀口上有线头 属羊和属鸡的配吗

2.5 课题任务描述

根据提供的用户历史一个月的查询词与用户的人口属性标签(包括性别、年龄、学历)做为训练数据,通过机器学习、数据挖掘技术构建分类算法来对新增用户的人口属性进行判定。

3. 查询词分词

3.1 NLPIR

NLPIR汉语分词系统(又名ICTCLAS2013),主要功能包括中文分词;词性标注;命名实体识别;用户词典功能;支持GBK编码、UTF8编码、BIG5编码。新增微博分词、新词发现与关键词提取;张华平博士先后倾力打造十余年,内核升级10次。
全球用户突破20万,先后获得了2010年钱伟长中文信息处理科学技术奖一等奖,2003年国际SIGHAN分词大赛综合第一名,2002年国内973评测综合第一名。
我们传入每个用户的搜索词列,表经过NLPIR分词工具得到的分词。之后,我们做个进一步的优化策略:

3.1.1 去停用词

我们根据分词后词语所带的词性,对一些特征代表性不够强的词语进行过滤:

复制内容到剪贴板
  1. for (int i = 0; i < sbtmp.length(); ++i) {  
  2.     char cc = sbtmp.charAt(i);  
  3.     if (cc == ' ') {  
  4.         sbtmp.deleteCharAt(i);  
  5.         --i;  
  6.     } else if (cc == '/') {  
  7.   
  8.         // 去词条件  
  9.         Boolean isdel =  
  10.                 // 1. 去标点  
  11.                 (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'w')  
  12.                         // 2. 疑问词  
  13.                         || (i + 2 < sbtmp.length() && sbtmp.charAt(i + 1) == 'r'  
  14.                                 && sbtmp.charAt(i + 2) == 'y')  
  15.                         // 3. 数字  
  16.                         || (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'm')  
  17.                         // 4. 连词  
  18.                         || (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'c')  
  19.                         // 5. 副词  
  20.                         || (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'd')  
  21.                         // 6. 叹词  
  22.                         || (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'e')  
  23.                         // 7. 拟声词  
  24.                         || (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'o')  
  25.                         // 8. 介词  
  26.                         || (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'p')  
  27.                         // 9. 量词  
  28.                         || (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'q')  
  29.                         // 10. 助词  
  30.                         || (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) == 'u')  
  31.                         // 11. 纯动词  
  32.                         || (i + 2 < sbtmp.length() && sbtmp.charAt(i + 1) == 'v'  
  33.                                 && sbtmp.charAt(i + 2) == ' ');  
  34.   
  35.         // 去词  
  36.         if (sbtmp.charAt(i + 1) != 'n' && sbtmp.charAt(i + 1) != 'i' && sbtmp.charAt(i + 1) != 'j'  
  37.                 && sbtmp.charAt(i + 1) != 'h'  
  38.                 && !(i + 2 < sbtmp.length() && sbtmp.charAt(i + 2) == 'n')) {  
  39.             while (i + 1 < sbtmp.length() && sbtmp.charAt(i + 1) != ' ') {  
  40.                 sbtmp.deleteCharAt(i + 1);  
  41.             }  
  42.             while (i >= 0 && sbtmp.charAt(i) != ',') {  
  43.                 sbtmp.deleteCharAt(i);  
  44.                 --i;  
  45.             }  
  46.         }  
  47.         // 若无需去词,把‘/’转为‘,’,并去除随后的词性标志  
  48.         else {  
  49.             sbtmp.setCharAt(i, ',');  
  50.             while (sbtmp.charAt(i + 1) != ' ') {  
  51.                 sbtmp.deleteCharAt(i + 1);  
  52.             }  
  53.         }  
  54.   
  55.     }  
  56. }  
  57. for (int i = 1; i < sbtmp.length() - 1; ++i) {  
  58.     if (sbtmp.charAt(i) == ',' && (sbtmp.charAt(i - 1) == ',' || sbtmp.charAt(i + 1) == ',')) {  
  59.         sbtmp.deleteCharAt(i);  
  60.         --i;  
  61.     }  
  62.     // 去中间单个字  
  63.     else if (sbtmp.charAt(i - 1) == ',' && sbtmp.charAt(i + 1) == ',') {  
  64.         sbtmp.deleteCharAt(i);  
  65.         sbtmp.deleteCharAt(i);  
  66.         --i;  
  67.     }  
  68.     // 去首个单个字  
  69.     else if (sbtmp.charAt(i) == ',' && i == 1) {  
  70.         sbtmp.deleteCharAt(i - 1);  
  71.         sbtmp.deleteCharAt(i - 1);  
  72.         --i;  
  73.     }  
  74. }  

3.1.2 提取关键词

分词并不能很好的将常用的短语提取出来,如词语“用户画像”,使用分词工具更倾向于将其分成“用户”和“画像”,而失去了词语本身的含义。NLPIR还提供了提取一段话的关键词的功能,我们可以使用它:

复制内容到剪贴板
  1. int numofIm = 1000;  
  2. String nativeByte = CLibrary.Instance.NLPIR_GetKeyWords(sInput, numofIm, false);     

经过分词后,平均每位用户搜索词列所得到的词量在600个左右,这里我们设置提取1000个关键词,但实际上一个用户的关键词提取的数量在60~200左右。由于关键词的很强的特征性,并且提取出的数量又少,若后续我们直接使用如词语的词频作为用户的特征属性进行分类的话,很可能各个用户特征属性有巨大的差异,即用户之间拥有的相同关键词过少。

3.1.3 混合提取

在用户搜索词列分词基础上,在增加N次对其进行M个关键词提取的结果。

3.2 “结巴”分词

jieba,即“结巴”中文分词,一个优秀的开源的分词工具,一直致力于做最好的 Python 中文分词组件。我们直接使用它对用户搜索词列进行1000个关键词的提取,所能提取到的关键词比NLPIR数量有所提高。显然,关键词提取的数量增加,每个关键词的代表性就有所减弱。但在后续的分类实验中证明了,使用该分词方案,对比上节的各个分词方案,在模型相同的情况下,会有2%~5%的准确率的提升。
关键词抽取可基于以下两种算法,后续实验实践证明基于 TF-IDF 算法的关键词的抽取,在该数据集和我们后续所选择的模型中会得到更好的效果。

3.2.1 基于 TF-IDF 算法的关键词抽取

复制内容到剪贴板
  1. import jieba.analyse  
  2. jieba.analyse.extract_tags(sentence, topK=20, withWeight=False, allowPOS=())  
复制内容到剪贴板
  1. sentence 为待提取的文本  
  2. topK 为返回几个 TF/IDF 权重最大的关键词,默认值为 20  
  3. withWeight 为是否一并返回关键词权重值,默认值为 False  
  4. allowPOS 仅包括指定词性的词,默认值为空,即不筛选  
  5. jieba.analyse.TFIDF(idf_path=None) 新建 TFIDF 实例,idf_path 为 IDF 频率文件  

代码示例 (关键词提取)

复制内容到剪贴板
  1. import sys  
  2. sys.path.append('../')  
  3.   
  4. import jieba  
  5. import jieba.analyse  
  6. from optparse import OptionParser  
  7.   
  8. USAGE = "usage:    python extract_tags.py [file name] -k [top k]"  
  9.   
  10. parser = OptionParser(USAGE)  
  11. parser.add_option("-k", dest="topK")  
  12. opt, args = parser.parse_args()  
  13.   
  14.   
  15. if len(args) < 1:  
  16.     print(USAGE)  
  17.     sys.exit(1)  
  18.   
  19. file_name = args[0]  
  20.   
  21. if opt.topK is None:  
  22.     topK = 10  
  23. else:  
  24.     topK = int(opt.topK)  
  25.   
  26. content = open(file_name, 'rb').read()  
  27.   
  28. tags = jieba.analyse.extract_tags(content, topK=topK)  
  29.   
  30. print(",".join(tags))  

3.2.2 基于 TextRank 算法的关键词抽取

复制内容到剪贴板
  1. jieba.analyse.textrank(sentence, topK=20, withWeight=False, allowPOS=(‘ns’, ‘n’, ‘vn’, ‘v’)) 直接使用,接口相同,注意默认过滤词性。  
  2. jieba.analyse.TextRank() 新建自定义 TextRank 实例  
  3. 基本思想[1]:  
  4.     将待抽取关键词的文本进行分词  
  5.     以固定窗口大小(默认为5,通过span属性调整),词之间的共现关系,构建图  
  6.     计算图中节点的PageRank,注意是无向带权图  

. 特征抽取与转换

4.1 TF-IDF

  • TF(Term Frequency) 词频
  • DF (Document Frequency) 词语出现的文档数目
  • N 总共的文档数目
  • IDF (Invert Document Frequency) 逆文档频率:逆文档频率.gif
  • IDF (Invert Document Frequency) 逆文档频率:逆文档频率.gif
  • mathtex.cgi.gif

IDF反映了一个特征词在整个文档集合中的情况,出现的愈多IDF值越低,这个词区分不同文档的能力越差。
示例代码:

复制内容到剪贴板
  1. import org.apache.spark.ml.feature.{HashingTF, IDF, Tokenizer}  
  2.   
  3. val sentenceData = spark.createDataFrame(Seq(  
  4.   (0"Hi I heard about Spark"),  
  5.   (0"I wish Java could use case classes"),  
  6.   (1"Logistic regression models are neat")  
  7. )).toDF("label""sentence")  
  8.   
  9. val tokenizer = new Tokenizer().setInputCol("sentence").setOutputCol("words")  
  10. val wordsData = tokenizer.transform(sentenceData)  
  11. val hashingTF = new HashingTF().setInputCol("words").setOutputCol("rawFeatures").setNumFeatures(20)  
  12. val featurizedData = hashingTF.transform(wordsData)  
  13.   
  14. val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features")  
  15. val idfModel = idf.fit(featurizedData)  
  16. val rescaledData = idfModel.transform(featurizedData)  
  17. rescaledData.select("features""label").take(3).foreach(println)  
  18. /*输出结果为:  
  19. [(20,[0,5,9,17],[0.6931471805599453,0.6931471805599453,0.28768207245178085,1.3862943611198906]),0]  
  20. [(20,[2,7,9,13,15],[0.6931471805599453,0.6931471805599453,0.8630462173553426,0.28768207245178085,0.28768207245178085]),0]  
  21. [(20,[4,6,13,15,18],[0.6931471805599453,0  

值得一提的是,Spark所提供的TF并不是数组,而是一个使用 MurmurHash 3 函数的哈希表。其默认向量维度为 2^18=262,144。我们运行上节示例代码可以发现,我们将哈希表大小设置成了20,第二条sentence:”I wish Java could use case classes”有7个不同的单词,经过hash函数却被映射成了只有5个属性为非零值,即有2个位置放了2个不同的单词。这具有很大的随机性,将两个无关词义的词语,甚至词义相反的词语,如“男”与“女”,映射到哈希表的同一位置,作为相同的用户属性来处理。

4.2 CountVectorizer

为了解决上节所提到的HashingTF哈希函数映射后导致词语重叠问题,我们使用了Spark的CountVectorizer。我们会先想CountVectorizer传入一个互斥的字符串数组,文本经过CountVectorizer转换后,会对该数组中所有的词语进行与属性的一一对应。
我们对互斥的字符串数组进行的优化,过滤掉了词频为1的词语,将CountVectorizer的维度减少到原来的50%,极大的降低了后续训练模型时所需的内存,而且除去的数据噪音,增加了预测的准确度:

复制内容到剪贴板
  1. val diffTrain = Triandata.map { line =>  
  2.       val temp = line.split("\t")  
  3.       if (temp.length == 5) temp(4else ""  
  4.     }  
  5. val diffTest = Testdata.map { line =>  
  6.       val temp = line.split("\t")  
  7.       if (temp.length == 5) temp(1else ""  
  8.     }  
  9. val diffAll = diffTrain.union(diffTest).flatMap(_.split(",")).map((_, 1)).reduceByKey(_ + _).collect.filter(line => line._1 != "" && line._2 > 14).map(line => line._1)  
复制内容到剪贴板
  1. val cvm = new CountVectorizerModel(diffAll).setInputCol(tokenizer.getOutputCol).setOutputCol("features")    
  2.   
  3.     1  

4.3 StopWordsRemover

在上一章中,我们提到了分词时,根据分词结果所带的词性,对其进行去停用词。而后,我们发现使用”结巴”分词进行TF-IDF算法对用户搜索词列进行1000个关键词的提取对于后续的分类模型效果会更好。但是,我们在“结巴”关键词提取的结果却发现了类似于“什么”“即使”等不具有代表性的词语。于是我们1119个停用词,使用Spark的StopWordsRemover,对分词结果进行去停用词:

val Stopdata = sc.textFile("hdfs://cdh01:8020//user/data/sogou2/stop",128).collect()
val remover = new StopWordsRemover().setInputCol("words").setOutputCol("filtered").setStopWords(Stopdata)

4.4 权值规范化

设想两个不同的用户A和用户B,用户A的搜索词列中只有1句查询语句,分词后得到了3个词语W和总共10个词。而用户B的搜索词列中有10句查询语句,分词后得到了10个词语W和总共100个词。很显然,B中W的TF远高于A中的W的TF,但我们知道词语W在A中比在B中更具有代表性。
为了解决上述问题,我们使用了最大-最小规范化:

将所有特征向量线性变换到用户指定最大-最小值之间。但注意在计算时还是一个一个特征向量分开计算的。通常将最大,最小值设置为1和0,这样就归一化到[0,1]。Spark中可以对min和max进行设置,默认就是[0,1]。

权值规范化.gif

mathtex.cgi.gif是某个特征向量所以元素的最大最小值
max,min是用户可以重新自定义的范围,默认为【0,1】,由所有特征共享

 

在后续,当我们对特征矩阵进行聚类后,得到的特征值可能为负值,可是很多分类器模型需要特征值为非负值。使用以上方法也可以解决这个问题。

4.5 同义词替换

设想当一个用户的搜索词列的分词结果中出现了一些意思相近的词语,如“恋爱”与“爱情”、“菠萝”与“凤梨”。而我们的模型将其辨别为不同的特征属性,这无疑大量的增加了特征向量的维度和平分了同一意思的词语具有的代表性。
为了解决上述问题,我们搜集了近4万条同义词词典,将意思相近的词语由1个词语来替换掉。该优化帮助原本的特征向量减少了3万以上的维度,降低了后续训练模型时所需的内存,而且凝聚了属性的代表性,增加了预测的准确度:

复制内容到剪贴板
  1. val sqlContext = new org.apache.spark.sql.SQLContext(sc)  
  2.    import sqlContext.implicits._  
  3.    val train = sc.textFile("hdfs://cdh01:8020//user/data/sogou2/JBtrain"400)  
  4.    val test = sc.textFile("hdfs://cdh01:8020//user/data/sogou2/JBtest"400)  
  5.    val same = sc.textFile("hdfs://cdh01:8020//user/data/sogou2/same"400)  
  6.    same.filter { x => !x.contains('=') }.count()  
  7.    val sameWord = same.map { line =>  
  8.      val valuekey = line.split('=')  
  9.      (valuekey(1), valuekey(0))  
  10.    }.collect()  
  11.    val broadcastVar = sc.broadcast(sameWord)  
  12.    val diffTrain = train.map { line =>  
  13.      val broad = broadcastVar.value  
  14.      val regex = """^\d+$""".r  
  15.      val temp = line.split("\t")  
  16.      val wordArray = temp(4).split(",")  
  17.      var str = ""  
  18.      for (word <- wordArray) {  
  19.        val keyVal = broad.filter(line => line._1.equals(word))  
  20.        if (keyVal.length > 0) {  
  21.          val oneKeyVal = keyVal(0)  
  22.          str = str + "#" + oneKeyVal._2  
  23.        } else if (regex.findFirstMatchIn(word) == None) {  
  24.          str = str + "#" + word  
  25.        }  
  26.      }  
  27.      (temp(0), temp(1), temp(2), temp(3), str)  
  28.    }  
  29.    diffTrain.toDF().coalesce(1).write.csv("hdfs://cdh01:8020//user/data/sogou2/ReplaceJBtrain")  
  30.   
  31.    val diffTest = test.map { line =>  
  32.      val broad = broadcastVar.value  
  33.      val regex = """^\d+$""".r  
  34.      val temp = line.split("\t")  
  35.      val wordArray = temp(1).split(",")  
  36.      var str = ""  
  37.      for (word <- wordArray) {  
  38.        val keyVal = broad.filter(line => line._1.equals(word))  
  39.        if (keyVal.length > 0) {  
  40.          val oneKeyVal = keyVal(0)  
  41.          str = str + "#" + oneKeyVal._2  
  42.        } else if (regex.findFirstMatchIn(word) == None) {  
  43.          str = str + "#" + word  
  44.        }  
  45.      }  
  46.      (temp(0), str)  
  47.    }  
  48.    diffTest.toDF().coalesce(1).write.csv("hdfs://cdh01:8020//user/data/sogou2/ReplaceJBtest")  

4.6 形近词替换

设想当一个用户的搜索词列的分词结果中出现了一些形近的词语,如“iPhone5”与“iPhone6”、“杭州”与“杭州站”。该问题和上节所提出的问题类似,为了解决这个问题,我们先来介绍“编辑距离算法”。
编辑距离算法,是指两个字串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符,删除一个字符。例如,计算X字符串 与 Y字符串 的 编辑距离。dp[i][j] 为 X串的前i个字符 和 Y串的前j个字符 的编辑距离:

复制内容到剪贴板
  1. if(X [i - 1] == Y[j - 1])         
  2.     dp[i][j] = dp[i - 1][j - 1];   //最后字符相同      
  3. else  
  4. {   
  5.     int t1 = dp[i - 1][j];                             //删除X第i个字符      
  6.     t1 = t1 < dp[i][j - 1] ? t1 : dp[i][j - 1];        //删除Y第j个字符      
  7.     t1 = t1 < dp[i - 1][j - 1] ? t1 : dp[i - 1][j - 1];//最后字符改相同  
  8.     dp[i][j] = t1 + 1;  
  9. }  

这里我们所使用的优化方案为:

  • 对整个训练集和测试集的搜索词列做分词后的词频统计表
  • 对每个用户的搜索词列分词后的各个词与词频统计表各词(排除前者自身)进行编辑距离计算。
  • 得到词频统计表中编辑距离与该词编辑距离最小词,在这些词中在选择一个词频最高的词将该词替代。

4.7 额外增加数据量

在大数据时代背景下,只要数据量足够的大,反而我们所选用的不同的算法模型对最终的预测准确率的影响会变小,获取更多数据会使模型更完善更准确。我们这里用不同方案所得到的分词结果,人为的增加训练集的数据。如将10万条记录的训练集进行NLPIR分词得到结果,与进行”结巴”提取关键词得到的结果拼接,就将训练集记录人为的翻倍了。后续的分类实验中证明了,使用该方案,在模型相同的情况下,相比原来会有1%左右的准确率的提升。

5.聚类与降维

2009年结束的Nexfix竞赛表明,很多参数团队用到的高等矩阵因子分解对模型提高预测准确略非常有帮助。模型使用矩阵因子分解方法从特征矩阵中抽取一组潜在的属性,并通过这些属性来描述用户。20世纪80年代后期,利用潜在的”语义”属性的思想被成功的应用于信息检索领域。Deerwesteret al. 在1990年提出使用奇异值分解(SVD)方法发现文档中的潜在的属性。[2]而本课题在实验中会使用到LDA方法。

5.1 LDA

隐含狄利克雷分配(LDA,Latent Dirichlet Allocation)是一种主题模型(Topic Model,即从所收集的文档中推测主题)。 甚至可以说LDA模型现在已经成为了主题建模中的一个标准,是实践中最成功的主题模型之一。那么何谓“主题”呢?,就是诸如一篇文章、一段话、一个句子所表达的中心思想。不过从统计模型的角度来说, 我们是用一个特定的词频分布来刻画主题的,并认为一篇文章、一段话、一个句子是从一个概率模型中生成的。也就是说 在主题模型中,主题表现为一系列相关的单词,是这些单词的条件概率。形象来说,主题就是一个桶,里面装了出现概率较高的单词(参见下面的图),这些单词与这个主题有很强的相关性。

LDA.jpg

LDA可以用来识别大规模文档集或语料库中潜藏的主题信息。它采用了词袋的方法,这种方法将每一篇文档视为一个词频向量,从而将文本信息转化为了易于建模的数字信息。但是词袋方法没有考虑词与词之间的顺序,这简化了问题的复杂性,同时也为模型的改进提供了契机。每一篇文档代表了一些主题所构成的一个概率分布,而每一个主题又代表了很多单词所构成的一个概率分布。
LDA可以被认为是如下的一个聚类过程:

  • 各个主题(Topics)对应于各类的“质心”,每一篇文档被视为数据集中的一个样本。
  • 主题和文档都被认为存在一个向量空间中,这个向量空间中的每个特征向量都是词频(词袋模型)
  • 与采用传统聚类方法中采用距离公式来衡量不同的是,LDA使用一个基于统计模型的方程,而这个统计模型揭示出这些文档都是怎么产生的。

5.1.1 模型训练

Spark API 参数介绍:

  • K:主题数量(或者说聚簇中心数量)
  • maxIterations:EM算法的最大迭代次数,设置足够大的迭代次数非常重要,前期的迭代返回一些无用的(极其相似的)话题,但是继续迭代多次后结果明显改善。我们注意到这对EM算法尤其有效。,至少需要设置20次的迭代,50-100次是更合理的设置,取决于数据集。
  • docConcentration(Dirichlet分布的参数α):文档在主题上分布的先验参数(超参数α)。当前必须大于1,值越大,推断出的分布越平滑。默认为-1,自动设置。
  • topicConcentration(Dirichlet分布的参数β):主题在单词上的先验分布参数。当前必须大于1,值越大,推断出的分布越平滑。默认为-1,自动设置。
  • checkpointInterval:检查点间隔。maxIterations很大的时候,检查点可以帮助减少shuffle文件大小并且可以帮助故障恢复。
复制内容到剪贴板
  1. val lda=new LDA()  
  2.                .setK(20)  
  3.                .setOptimizer("online")  
  4.                .setCheckpointInterval(10)  
  5.                .setMaxIter(100)  
  6.    val model=lda.fit(dataset_lpa)     

5.1.2 模型评价

生成的model不仅存储了推断的主题,还包括模型的评价方法。模型的评价指标:logLikelihood,logPerplexity。logLikelihood越大越好,logPerplexity越小越好

复制内容到剪贴板
  1. val ll = model.logLikelihood(dataset_lpa)  
  2.  val lp = model.logPerplexity(dataset_lpa)  

用评价方法,在online 方法下,对setMaxIter进行调参:

复制内容到剪贴板
  1. for(i<-Array(5,10,20,40,60,120,200,500)){  
  2.     val lda=new LDA()  
  3.                 .setK(3)  
  4.                 .setTopicConcentration(3)  
  5.                 .setDocConcentration(3)  
  6.                 .setOptimizer("online")  
  7.                 .setCheckpointInterval(10)  
  8.                 .setMaxIter(i)  
  9.     val model=lda.fit(dataset_lpa)   
  10.   
  11.     val ll = model.logLikelihood(dataset_lpa)   
  12.     val lp = model.logPerplexity(dataset_lpa)  
  13.   
  14.     println(s"$i $ll")  
  15.     println(s"$i $lp")  
  16.  }  

可以看到,logPerplexity在减小,LogLikelihood在增加,最大迭代次数需要设置50次以上,才能收敛:

LogLikelihood在增加.png

LogLikelihood在增加1.png

5.1.3 对语料的主题进行聚类

复制内容到剪贴板
  1. val topicsProb=model.transform(dataset_lpa)  
  2. topicsProb.select("label""topicDistribution")show(false)  
  3. /**  
  4.     +-----+--------------------------------------------------------------+ 
  5.     |label|topicDistribution                                             | 
  6.     +-----+--------------------------------------------------------------+ 
  7.     |0.0  |[0.523730754859981,0.006564444943344147,0.46970480019667477]  | 
  8.     |1.0  |[0.7825074858166653,0.011001204994496623,0.206491309188838]   | 
  9.     |2.0  |[0.2085069748527087,0.005698459472719417,0.785794565674572]   | 
  10.     ... 
  11.  
  12. */  

label是文档序号,文档中各主题的权重,我们可以将该DataFrame带入后续的分类器中,进行训练。

5.1.4 其他聚类与降维

Spark在基于RDD的MLlib中还提供了SVD、PCA的降维方法,而基于DataFrame的聚类方法还包括k-means、Bisecting k-means和Gaussian Mixture,其中Gaussian Mixture提供的API类似与LDA,可以直接为我们返回文档中各主题的权重,以便于后续的分类。但是由于LDA在主题聚类上的典型性,我们的课题实验只试验了LDA的方案。

6. 分类方案

6.1 Cosine相似度

假设现在我们有一个测试集特征向量A和一个训练集的特征向量B:

A:[1, 2, 2, 1, 1, 1, 0]
B:[1, 2, 2, 1, 1, 2, 1]

到这里,问题就变成了如何计算这两个向量的相似程度。我们可以把它们想象成空间中的两条线段,都是从原点([0, 0, …])出发,指向不同的方向。两条线段之间形成一个夹角,如果夹角为0度,意味着方向相同、线段重合;如果夹角为90度,意味着形成直角,方向完全不相似;如果夹角为180度,意味着方向正好相反。因此,我们可以通过夹角的大小,来判断向量的相似程度。夹角越小,就代表越相似。

代表越相似.png

 a和b是两个向量.png

余弦值越接近1,就表明夹角越接近0度,也就是两个向量越相似,这就叫”余弦相似度”
我们这个方案,计算出一条测试集的特征向量与训练集各个特征向量的余弦相似度,将该条测试集的类别标记为与其余弦相似度最大的训练集特征向量所对应的类别。

6.2 One-vs-Rest

One-vs-Rest将只能用于二分问题的分类(如Logistic回归、SVM)方法扩展到多类。这种方法基本思想为:

训练时依次把某个类别的样本归为一类,其他剩余的样本归为另一类,这样k个类别的样本就构造出了k个binary分类器。分类时将未知样本分类为具有最大分类函数值的那类。
假如我有四类要划分(也就是4个Label),他们是A、B、C、D。于是在抽取训练集的时候,分别抽取
(1)A所对应的向量作为正集,B,C,D所对应的向量作为负集
(2)B所对应的向量作为正集,A,C,D所对应的向量作为负集;
(3)C所对应的向量作为正集,A,B,D所对应的向量作为负集;
(4)D所对应的向量作为正集,A,B,C所对应的向量作为负集;
使用这四个训练集分别进行训练,然后的得到四个训练结果文件。在测试的时候,把对应的测试向量分别利用这四个训练结果文件进行测试。最后每个测试都有一个结果f1(x),f2(x),f3(x),f4(x)。于是最终的结果便是这四个值中最大的一个作为分类结果。

代码实例:

复制内容到剪贴板
  1. //定义一个binary分类器,如:LogisticRegression   
  2. LogisticRegression lr=new LogisticRegression()  
  3.                 .setMaxIter(10)  
  4.                 .setRegParam(0.3)  
  5.                 .setElasticNetParam(0.2)                  
  6.                 .setThreshold(0.5);  
  7. //建立一对多多分类器model                  
  8. OneVsRestModel model=new OneVsRest()  
  9.                 .setClassifier(lr)//将binary分类器用这种办法加入  
  10.                 .fit(training);  
  11. //利用多分类器model预测  
  12. Dataset<Row>predictions=model.transform(test);    

很遗憾的是,目前Spark基于DataFrame的MLlib binary分类器中并没有实现SVM,而基于RDD的MLlib有实现SVM,却没有实现One-vs-Rest。

6.3 朴素贝叶斯

朴素贝叶斯分类是一种思想比较简单的分类算法,朴素贝叶斯的思想基础是这样的:对于给出的待分类项,求解在此项出现的条件下各个类别出现的概率,哪个最大,就认为此待分类项属于哪个类别。
朴素贝叶斯分类的正式定义如下:

正式定义.png

val lr = new NaiveBayes().setSmoothing(0.1).setFeaturesCol("features")

6.4 前馈神经网络

Spark MLlib中实现了MultilayerPerceptronClassifier(MLPC),这是一个基于前馈神经网络的分类器,它是一种在输入层与输出层之间含有一层或多层隐含结点的具有正向传播机制的神经网络模型。

神经网络模型.gif

中间的节点使用sigmoid (logistic)函数,输出层的节点使用softmax函数。输出层的节点的数目表示分类器有几类。MLPC学习过程中使用BP算法,优化问题抽象成logistic loss function并使用L-BFGS进行优化。
下面我们来介绍下MultilayerPerceptronClassifier所设计到的参数:

复制内容到剪贴板
  1. val lr = new MultilayerPerceptronClassifier().setMaxIter(51).setLayers(Array[Int](vector_len, 6, 5, classfiy_num)).setSeed(1234L).setFeaturesCol("features").setLabelCol("label").setPredictionCol("prediction")  
  • layers:各层的节点数,第1层的个数必须为特征向量的维度数,最后1层的个数必须为类别数,中间为各隐藏层的节点数,可以任意设置。对于隐藏成节点数的优化,我们做了一下的方案:由于我们的年龄、性别、学历的类别数分别是6、2、6。假设我们在预测年龄时,我们可以将隐藏的第1层节点数设为6 * 2 * 6 = 72,即根据先将其分为3个label都互斥的类别,。然后,将隐藏的第2层节点数设为6 * 2 = 12,即根据先将其分为年龄和性别3个label都互斥的类别。最后,再分到我们想预测的年龄的6个类别上。
  • maxIter:最大迭代次数。进行参数调优时,主要是在优化这个参数。
  • Seed:随机种子
  • tol:(代码中未设置)允许误差。一般取0.001~0.00001,当迭代结果的误差小于该值时,结束迭代计算,给出结果。
  • solver:(代码中未设置)优化器。有两种算法可供选择: l-bfgs和gd。默认为 l-bfgs算法,因为gd算法比l-bfgs算法需要多得多的迭代次数,即使在提高学习步长和提高允许误差tol的情况下,还是慢很多:

    .stepSize=0.03,tol=0.0001
    l-bfgs:上很快能收敛,大约20次,训练速度也更快
    maxIter = 5 accuracy = 0.35 training time = 267ms
    maxIter = 10 accuracy = 0.74 training time = 429ms
    maxIter = 20 accuracy = 0.91 training time = 657ms
    maxIter = 50 accuracy = 0.92 training time = 941ms
    maxIter = 100 accuracy = 0.92 training time = 914ms
    maxIter = 500 accuracy = 0.92 training time = 1052ms

    大约20次.png

    gd算法:基本上要比l-bfgs算法慢10以上
    stepsize=0.2,tol=0.001
    maxIter = 100 accuracy = 0.55 training time = 4209ms
    maxIter = 500 accuracy = 0.92 training time = 11216ms
    maxIter = 1000 accuracy = 0.92 training time = 14540ms
    maxIter = 2000 accuracy = 0.92 training time = 14708ms
    maxIter = 5000 accuracy = 0.92 training time = 14669ms

 

  • stepSize:学习步长。一般来说学习步长越大,权重变化越大,收敛越快;但训练速率过大,会引起系统的振荡。太高的学习步长,可以减少网络训练的时间,但是容易导致网络的不稳定与训练误差的增加,会引起系统的振荡。

6.5 预分类迭代预测

6.6 Boosting

7. 参数调优

7.1 交叉验证法

7.2 划分训练集验证法

8. 回顾与展望

9. 参考文献

【1】Mihalcea R, Tarau P. TextRank: Bringing order into texts[C]. Association for Computational Linguistics, 2004.
【2】Dietmar J, Markus Z. Recommender systems: An introduction[M]. Cambridge University Press, 2010: 35.

感谢 卓寿杰_SoulJoy 支持 磐实编程网 原文地址:
blog.csdn.net/u011239443/article/details/53735609

文章信息

发布时间:2016-12-22

作者:卓寿杰_SoulJoy

发布者:aquwcw

浏览次数: