文本相似推荐是推荐系统中最常见的一个方向,之前的文章中我们介绍过基于LSH模型推荐的主要逻辑,今天我们介绍一个基于余弦相似度的文本推荐实现,其中主要会有一些关键实现代码的介绍和主要函数、方法的说明。
我们对文章的内容作了分词处理,把分词后每篇文章的词列表做最原始的横向张开,一次在文章中出现多少次,在分词后的列表中就输出多少次。如下图,列表第一位是文章的唯一标识ID, 后续跟着每篇文章出现的每个词的重复N次。
我们对每一行的数据做处理,一共形成两个列,列名分别是article_id, words.

1、HashingTF (特征HASH-频数)
HashingTF 是一个Transformer,在文本处理中,接收词条的集合然后把这些集合转化成固定长度的特征向量。这个算法在哈希的同时会统计各个词条的词频。因为数据量大的原因,把词hash到有限的空间里,但是一般针对于小数据量的话,直接不用此方法。

val hashingTF = new HashingTF(Math.pow(2, 18).toInt) val newSTF = data.rdd.map{row => val tf = hashingTF.transform(row.getAs[String]("words").split(",").toList) (row.getAs[Long]("article_id"), tf) }
上述代码处理后的数据基本输出如下,是(article_id, 稀疏向量)的格式。
MLlib的向量主要分为两种,DenseVector和SparseVector,前者是用来保存稠密向量,后者是用来保存稀疏向量。稀疏向量和密集向量都是向量的表示方法,我们用到的稀疏向量。
用稀疏格式表示为(262144,[15036,19364,47826,83116,97232,121425,148594,155955,178252,213110],[3.0,2.0,2.0,6.0,4.0,2.0,5.0,2.0,3.0,6.0]) 第一个262144表示向量的长度(元素个数),[15036,19364,47826,83116,97232,121425,148594,155955,178252,213110]就是indices数组,[3.0,2.0,2.0,6.0,4.0,2.0,5.0,2.0,3.0,6.0]是values数组 表示向量0的位置的值是3.0,2的位置的值是2.0,以此类推后续…,而其他没有表示出来的位置都是0
密集向量的值就是一个普通的Double数组 而稀疏向量由两个并列的 数组indices和values组成 例如:向量(1.0,0.0,1.0,3.0)用密集格式表示为[1.0,0.0,1.0,3.0]
(1254041,(262144,[15036,19364,47826,83116,97232,121425,148594,155955,178252,213110],[3.0,2.0,2.0,6.0,4.0,2.0,5.0,2.0,3.0,6.0])) (1254702,(262144,[13250,45738,74579,83816,114706,140294,168038,178252,179286,228860],[6.0,2.0,3.0,2.0,3.0,2.0,4.0,2.0,3.0,2.0])) (1270204,(262144,[23837,89849,118991,128753,141701,144329,154077,181588,206007,216577],[8.0,9.0,7.0,30.0,6.0,9.0,8.0,8.0,2.0,6.0])) (1274990,(262144,[1825,47533,66014,72996,128753,137905,176245,230362,246875,261184],[2.0,4.0,2.0,6.0,7.0,2.0,3.0,3.0,5.0,4.0])) (1275492,(262144,[453,3346,23862,48581,81453,97906,177907,192248,211981,222855],[5.0,7.0,2.0,6.0,7.0,3.0,8.0,4.0,2.0,5.0])) (1276010,(262144,[24180,37181,38884,83812,139707,170385,183537,186838,210495,252680],[3.0,6.0,6.0,8.0,3.0,4.0,2.0,2.0,6.0,15.0])) (1284572,(262144,[4080,34822,41345,46525,55782,114461,137175,137700,141004,257853],[5.0,9.0,9.0,12.0,16.0,9.0,20.0,4.0,9.0,19.0])) (1285584,(262144,[50361,54739,60330,119368,127547,208981,219001,248279,255987,257167],[5.0,3.0,7.0,7.0,3.0,8.0,7.0,6.0,10.0,3.0])) (1286389,(262144,[85281,89699,118991,133021,165941,179035,193877,224875,243109,255164],[2.0,4.0,3.0,2.0,5.0,3.0,3.0,2.0,4.0,2.0])) (1305106,(262144,[30697,36373,65307,73404,82006,86639,123217,142848,168038,178252],[3.0,4.0,5.0,11.0,5.0,17.0,19.0,4.0,11.0,5.0])) (1315406,(262144,[42610,64869,74579,134320,200367,205339,240419,241590,246953,259244],[7.0,6.0,6.0,11.0,6.0,2.0,7.0,5.0,3.0,10.0])) (1321265,(262144,[38382,39825,48154,48615,156641,189005,191338,201265,216118,245329],[13.0,9.0,4.0,2.0,9.0,6.0,4.0,8.0,7.0,6.0])) (1338600,(262144,[10344,36515,76239,82006,115775,148514,165030,190556,213678,226409],[8.0,7.0,9.0,3.0,6.0,3.0,25.0,4.0,11.0,3.0])) (1343798,(262144,[17834,44919,75299,90387,112873,116611,124084,144329,232930,235221],[2.0,8.0,14.0,2.0,12.0,2.0,2.0,15.0,7.0,5.0])) (1348953,(262144,[6878,15912,27529,32639,55938,129711,145474,200367,215919,232930],[7.0,7.0,5.0,13.0,7.0,14.0,13.0,14.0,17.0,10.0])) (1349040,(262144,[32887,49573,55355,64662,111523,157281,228604,240842,252200,261899],[2.0,7.0,5.0,3.0,2.0,3.0,3.0,7.0,3.0,3.0])) (1376022,(262144,[69651,86071,103299,150372,153082,163559,175435,218723,220682,253252],[3.0,6.0,7.0,9.0,7.0,6.0,3.0,6.0,9.0,2.0])) (1404146,(262144,[17964,45478,98252,119251,146330,170312,192362,203817,215919,232930],[2.0,6.0,6.0,8.0,6.0,6.0,6.0,5.0,10.0,7.0])) (1423152,(262144,[6878,47048,74579,110350,123974,137905,145474,200367,215919,242312],[3.0,6.0,13.0,11.0,6.0,6.0,9.0,18.0,12.0,2.0])) (1439223,(262144,[65855,68559,71276,82006,115775,174947,202636,217083,226336,231174],[10.0,21.0,2.0,5.0,11.0,35.0,9.0,25.0,16.0,4.0]))
2、IDF model构建
为了方便展示推荐结果的效果,我们对数据集做过滤,只留下两篇文章,最终输出的结果是
[1254702,1423152,1315406,1305106]
[1270204,1286389,1274990,1343798]
我们对第一行 1254702的分词数据做去重处理后 得到如下,虽然分词的效果很low,但是通过比较low的分词计算的结果是正常逻辑的。
1270204 [‘开发商’, ‘莆田’, ‘别墅’, ‘府邸’, ‘公寓’, ‘福建延寿山庄酒店管理有限公司’, ‘用地’, ‘藏珑’, ’10’, ‘1’, ‘独栋’, ‘建售’]
1315406 [‘普查’, ‘调控’, ‘时评’, ‘房产税’, ‘房屋’, ‘刚需’, ‘人口’, ‘存量’, ’10’, ‘1’, ‘房价’, ‘地毯式’]
1423152 [‘调控’, ‘房地产’, ‘长效’, ‘限贷’, ‘限购’, ‘蛮劲’, ’10’, ‘限价’, ‘1’, ‘房价’, ‘网民’, ‘楼市’]
1305106 [‘资金’, ‘房产’, ‘全款’, ‘背负’, ‘购房者’, ‘买房’, ‘房子’, ’10’, ‘一身’, ‘1’, ‘怎么’, ‘贷款’]
//构建idf model val idf = new IDF().fit(newSTF.values) //将tf向量转换成tf-idf向量 val newsIDF = newSTF.mapValues(v => idf.transform(v)) val bIDF = spark.sparkContext.broadcast(newsIDF.collect()) // 过滤最后的结果 val docSims = newsIDF.filter(x => Seq(1254702, 1270204).toList.contains(x._1)).flatMap{ case (id1, idf1) => val idfs = bIDF.value.filter(_._1 != id1) val sv1 = idf1.asInstanceOf[SV] //构建向量1 val bsv1 = new SparseVector[Double](sv1.indices, sv1.values, sv1.size) //取相似度最大的前10个 idfs.map { case (id2, idf2) => val sv2 = idf2.asInstanceOf[SV] //构建向量2 val bsv2 = new SparseVector[Double](sv2.indices, sv2.values, sv2.size) //计算两向量点乘除以两向量范数得到向量余弦值 val cosSim = bsv1.dot(bsv2) / (norm(bsv1) * norm(bsv2)) (id1, id2, cosSim) }.sortWith(_._3>_._3).take(3) }.toDF("article_id1","article_id2", "score"). filter(x => x.getAs[Double]("score") > 0.0). select($"article_id1",$"article_id2"). groupBy("article_id1").agg(functions.concat_ws(",", functions.collect_set($"article_id2".cast("string"))).as("ids")) docSims.rdd.foreach(println)