Spark:HanLP+Word2Vec+LSH实现文本推荐(kotlin)

文本推荐的基本流程就是首先对目标本文进行关键词提取,接着把关键词转成词向量,再计算词向量的相似性进行推荐。这三个步骤都有现成的模型和算法来实现,本文介绍的就是基于spark用hanlp+word2vec+lsh实现文本推荐。

下面先介绍每个步骤所用的模型和算法。

1.HanLP:提取中文文本的关键词

1.HanLP是一系列模型与算法组成的NLP工具包,目标是普及自然语言处理在生产环境中的应用。

2.HanLP工具包功能非常丰富,中文分词,词性标注,命名实体识别,关键词提取,自动摘要,短语提取,拼音转换,简繁转换,文本推荐.

3.这些功能的很多都是基于机器学习深度学习的模型,只不过已经训练好,我们直接调用即可

4.附上HanLP github链接: https://github.com/hankcs/HanLP

2.Word2Vec:把提取后的关键词转成词向量

1.Word2Vec是Google开源的一款用于词向量计算的工具,该工具得到的训练结果——词向量(word embedding),可以很好地度量词与词之间的相似性。

2.word2vec算法的背后其实是一个浅层神经网络,另外word2vec是一个计算word vector的开源工具并不是一个算法或者模型,可以理解为动词形式 “把word 转成 vector”,背后是word vector的CBoW模型和Skip-gram模型做了转换工作。

3.Word2Vec的发展历程以及内部算法其实算比较比较复杂,如果展开来讲可能一篇博客都讲不完,笔者也不能完全理解,简而言之,word2vec背后是一个神经网络模型用来预测目标语句出现的概率返回词库中的向量,然后计算输入向量和返回向量的相似度再归一化。Word2Vec主要用于机器翻译和推荐系统领域。

4.这里贴上一份比较详细的word2vec介绍的文章

3.LSH:将向量进行哈希分桶,使得原语义上相似的文本大概率被哈希到同一个桶中,同个桶内的文本可以认为是大概率是相似的

LSH:局部敏感哈希算法,是一种针对海量高维数据的快速最近邻查找算法,主要有如下用法

1.近似重复的检测: LSH 通常用于对大量文档,网页和其他文件进行去重处理。
2.全基因组的相关研究:生物学家经常使用 LSH 在基因组数据库中鉴定相似的基因表达。
3.大规模的图片搜索: Google 使用 LSH 和 PageRank 来构建他们的图片搜索技术VisualRank。
4.音频/视频指纹识别:在多媒体技术中,LSH 被广泛用于 A/V 数据的指纹识别。

在推荐系统等应用中,经常会遇到的一个问题就是面临着海量的高维数据,查找最近邻。如果使用线性查找对于高维数据计算量和耗时都是灾难性的。为了解决这样的问题,出现了一种特殊的hash函数,使得2个相似度很高的数据以较高的概率映射成同一个hash值,而令2个相似度很低的数据以极低的概率映射成同一个hash值。我们把这样的函数,叫做LSH(局部敏感哈希),注意是这样的函数不是一种。

LSH中的哈希函数的数学定义

我们将这样的一族hash函数 H={h:S→U} 称为是(r1,r2,p1,p2)敏感的,如果对于任意H中的函数h,满足以下2个条件:
1.如果d(O1,O2)<r1d(O1,O2)<r1,那么Pr[h(O1)=h(O2)]≥p1Pr[h(O1)=h(O2)]≥p1
2.如果d(O1,O2)>r2d(O1,O2)>r2,那么Pr[h(O1)=h(O2)]≤p2
其中,O1,O2∈S,表示两个具有多维属性的数据对象,d(O1,O2)为2个对象的相异程度,也就是1 - 相似度

为了避免陷入数学漩涡,概括一下就是,LSH的哈希函数必须满足这样一个功能,当两个目标值足够相似时,映射为同一hash值的概率足够大,而足够不相似时,映射为同一hash值的概率足够小。

针对不同的相似度测量方法,局部敏感哈希的算法也不同,我们主要看看在两种最常用的相似度下,两种不同的LSH(spark也支持这两种模型)

1.使用Jaccard系数度量数据相似度时的min-hash ,对应spark的MinHashLSH

2.使用欧氏距离度量数据相似度时的P-stable hash,对应spark的BucketedRandomProjectionLSH

两种方法都是将高维数据降维到低维数据,且在一定程度上还能保持原数据的相似度不变,LSH的哈希相似度不变性也是概率性的不是确定性的,好在LSH的设计能够通过相应的参数控制出现这种错误的概率,这也是LSH为什么被广泛应用的原因

min-hash (通过度量Jaccard系数)

Jaccard系数主要用来解决的是非对称二元属性相似度的度量问题,常用的场景是度量2个集合之间的相似度,就是2个集合的交集比2个集合的并集(很容易理解吧)

下面我们来简单举个例子理解min-hash的原理

假设有4个文档,每个文档有相应的词项{w1,w2,…,w7},若某个文档存在这个词项,则标为1,否则标0.那么我们就可以得到如下矩阵1

hanlp做文本推荐_文本推荐


接着我们进行一个随机的行置换可以得到如下矩阵2

hanlp做文本推荐_sprak_02


可以确定的是,对这个矩阵按行进行多次置换,每次置换之后,统计每一列(其实对应的就是每个文档)第一个不为0的位置(行号),这样每次统计的结果能构成一个与文档数等大的向量,这个向量,我们称之为签名向量。比如文档1的签名向量 [1,2,1,2],文档2的签名向量 [1,1,2,1]

min-hash的思想就是如果两个文档足够相似,那也就是说这两个文档中有很多元素是共有的,换句话说,这样置换之后统计出来的签名向量,这些文档所对应的签名向量的相应的元素,值相同的概率就很高。概括一下就是如果两个文档相似,那么分词后含有的词项应该交集很多,那么进行随机置换后文档所在列的第一个不为0的位置很大概率是相同的,能理解吧

通过多次置换,求取向量,构建了一组hash函数。也就是最终得到了一个签名矩阵. 为了控制相似度与映射概率之间的关系,我们需要按下面的操作进行,一共三步

(1) 将signature matrix水平分割成一些区块(记为band),每个band包含了signature matrix中的r行。需要注意的是,同一列的每个band都是属于同一个文档的。

hanlp做文本推荐_文本推荐_03


(2) 对每个band计算hash值,hash函数没有特殊要求,MD5,SHA1等等均可。但需要将这些hash值做处理,使之成为事先设定好的hash桶的tag,然后把这些band“扔”进hash桶中。如下图所示。

hanlp做文本推荐_sprak_04


(3) 如果某两个文档的同一水平方向上的band,映射成了同一hash值(如果你选的hash函数比较安全,抗碰撞性好,那这基本说明这两个band是一样的),我们就将这两个文档映射到同一个hash bucket中,也就是认为这两个文档是足够相近的。

其中bands 和 rows 就是可以调节的两个参数,通过调节b和r两个参数可以得到一个很好的效果。

P-stable hash

不同的相似度判别方法,对应着不同的LSH,那对于最常见的Lp范数下的欧几里得空间,应该用怎样的LSH呢

P-stable hash涉及到p稳定分布的概念,这里不说数学自行百度,概括一下,p稳定分布一个重要的应用,就是可以估计给定向量vv在欧式空间下的p范数的长度,也就是||v||p||v||p。

p-stable 分布LSH函数族构造

1.将空间中的一条直线分成长度为r的,等长的若干段

2.通过一种映射函数(也就是我们要用的hash函数),将空间中的点映射到这条直线上,给映射到同一段的点赋予相同的hash值。不难理解,若空间中的两个点距离较近,他们被映射到同一段的概率也就越高。

3.可以得到这样一个结论:空间中两个点距离:近到一定程度时,应该被hash成同一hash值,而向量点积的性质,正好保持了这种局部敏感性。因此,可以用点积来设计hash函数族。

接下来我们来看看具体的代码实现 spark+kotlin

首选创建SparkSession,提交到spark平台的不需要设置master,顺便贴一下spark参数

val spark = SparkSession
        .builder()
        .appName("NlpW2VectorLsh")
        .enableHiveSupport()
        .getOrCreate()

顺便提一下提交spark任务的一些常见参数,hive-site.xml定义了hive表,如果缺少此文件无法操作hive表

--master yarn 
--deploy-mode cluster 
--driver-memory 8G 
--executor-memory 8G 
--num-executors 8 
--files /usr/local/spark/conf/hive-site.xml

调用类方法获取推荐结果,再把推荐结果保存在hdfs文件。
"\u0001"是hdfs的列分隔符(根据平台而定)
dataDS需要分区写入不然很大概率内存不够卡死

val dataDS = NlpW2VectorLsh().run(spark, DATA_HDFS_URL)
    LOGGER.warn("NlpVectorLsh dataDS success..........")

    dataDS.mapPartitions(MapPartitionsFunction<Row, String> { listRow ->
        val result = mutableListOf<String>()
        listRow.forEach { row ->
            val hiveRowText = row.getString(0) + "\u0001" +
                    row.getString(1) + "\u0001" +
                    row.getString(2) + "\u0001" +
                    row.getString(3) + "\u0001" +
                    row.getDouble(4)
            result.add(hiveRowText)
        }
        return@MapPartitionsFunction result.iterator()
    }, Encoders.javaSerialization(String::class.java)).rdd().saveAsTextFile(SAVE_HDFS_URL)
    LOGGER.warn("NlpVectorLsh saveAsTextFile success.............")

    spark.sql(LOAD_SPARK_SQL)
    LOGGER.warn("NlpVectorLsh load sql success.............")

再来看下run方法实现了什么
定制词库,在分词前对词库进行调整,此调整适用全局

/**
     * 动态修改NLP词典
     * CustomDictionary是一份全局的用户自定义词典,可以随时增删,影响全部分词器
     */
    private fun nlpDictionary(){
        // 动态增减词典
        CustomDictionary.add("和田玉")
        // 强行插入
        CustomDictionary.insert("白富美", "nz 1024")
        // 删除词语(注释掉试试)
        CustomDictionary.remove("攻城狮")
    }

接着从hdfs文件读入数据,在读入数据的时候顺便进行分词和数据清洗

//获取样本数据 Nlp分词
    val dataDF = this.createDataset(spark, hdfsUrl)
    LOGGER.warn("NlpVectorLsh createDataset success..........")

用获取到的data训练word2vec模型
vectorSize是词向量的维度,minCount是忽略小于该词频的词向量

/**
     * 训练word2vec模型
     */
    fun trainingWord2Vector(spark: SparkSession, data: Dataset<Row>, vectorSize: Int, minCount: Int): Word2VecModel {
        val word2Vec = Word2Vec()
            .setInputCol("words")
            .setOutputCol("wordvec")
            .setVectorSize(vectorSize)
            .setMinCount(minCount)
        return word2Vec.fit(data)
    }

用训练好的word2vec模型把文本转向量

//文本转向量
    val vectorDF = w2vModel.transform(dataDF)
    LOGGER.warn("NlpVectorLsh word2Vector transform success..........")

训练LSH模型出来,这里的两个参数直接影响推荐的精度,先弄清楚这些参数的意义然后再去调整

/**
     * 创建LSH模型 用模型哈希分桶
     */
    fun trainingLSH2hashModel(w2vDf: Dataset<Row>, bucketLength: Double, numHashTables: Int): BucketedRandomProjectionLSHModel {
        //桶长度可用于控制哈希桶的平均大小(因此也可用于控制桶的数量)。
        // 较大的桶长度(即,更少的桶)增加了将特征哈希到相同桶的概率(增加真实和假阳性的数量)
        val brp = BucketedRandomProjectionLSH()
            .setBucketLength(bucketLength)
            .setNumHashTables(numHashTables)
            .setInputCol("wordvec")
            .setOutputCol("hashes")
        return brp.fit(w2vDf)
    }

用训练好的LSH模型把词向量都进行哈希分桶

//LSH 哈希分桶
     val lshHashDF = lshModel.transform(vectorDF).cache()
     LOGGER.warn("NlpVectorLsh lSH2hashModel transform success..........")

获取推荐结果呢有两种方法,approxSimilarityJoin 和 approxNearestNeighbors
approxSimilarityJoin是连表获取所有相似对,会有较多的重复,数据量也很大,用该方法在计算的时候要进行分区计算,approxNearestNeighbors是获取指定个数的最近邻的结果,为了避免再重新计算哈希,传入的数据应该是哈希后的数据,比如我们可以用以下方法来避免再次进行哈希运算

//传入转化好的特征向量lshHashDF可以避免重计算哈希
        val recommendDF = lshModel.approxSimilarityJoin(lshHashDF, lshHashDF,0.015)
            .select(
                col("datasetA.productSeq").alias("originSeq"),
                col("datasetA.productName").alias("originName"),
                col("datasetB.productSeq").alias("recommendSeq"),
                col("datasetB.productName").alias("recommendName"),
                col("distCol").alias("distance")
            ).filter("distance > 0")
        LOGGER.warn("NlpVectorLsh recommendDF success..........")

大概整个流程就是如此
这里引入一下LSH在识别欺诈行为的性能测试结果

为了衡量性能,我们在WEX数据集上测试了MinHashLSH的实现。使用AWS云,我们使用16个executors(m3.xlarge 实例)执行WEX数据集样本的近似最近邻搜索和近似相似连接。

hanlp做文本推荐_sprak_05


使用numHashTables = 5,近似最近邻的速度比完全扫描快2倍。在numHashTables = 3的情况下,近似相似连接比完全连接和过滤要快3-5倍。

在上面的表格中,我们可以看到哈希表的数量被设置为5时,近似最近邻的运行速度完全扫描快2倍;根据不同的输出行和哈希表数量,近似相似连接的运行速度快了3到5倍。

我们的实验结果还表明,尽管当前算法的运行时间很短,但与暴力方法的结果相比仍有较高的精度。近似最近邻搜索对于40个返回行达到了85%的正确率,而我们的近似相似连接成功地找到了93%的邻近行。这种速度与精度的折中算法,证明了LSH能从每天TB级数据中检测欺诈行为的强大能力。