头条项目推荐的相关技术(四):离线文章画像的增量更新及离线文章相似度计算

1. 写在前面

这里是有关于一个头条推荐项目的学习笔记,主要是整理工业上的推荐系统用到的一些常用技术, 这是第四篇, 上一篇文章整理了离线文章画像的计算过程,主要包括TFIDF和TextRank两种技术, 这篇文章介绍下离线画像的增量更新计算(定时更新)以及文章相似度技术的相关计算方法, 由于离线画像计算方法上一篇文章总结了,这里就简单了解下增量更新,也就是新来的文章,应该怎么去计算这些新文章的画像呢? 主要内容如下:

  • 离线增量文章画像计算, 这里要把上一篇文章里面的所有代码写成项目代码并放入pycharm
  • Apscheduler定时更新工具的使用
  • Super进程管理与Apscheduler完成定时文章画像更新
  • 文章的相似度计算(W2V模型原理,spark下完成文章向量计算,上亿级别文章找相似之局部敏感哈希)

Ok, let’s go!

2. 离线增量文章画像计算

这里就是定好更新文章的时间,比如每隔一个小时更新一次, 等, 更新步骤就是:

  1. toutiao 数据库中,news_article_content 与news_article_basic, news_article_channel这三个表的新数据—>更新到article数据库中article_data表,方便操作。 toutiao数据库保存的是Sqoop的迁移数据库。

    • 第一次:所有更新,后面增量每天的数据更新26日:1:00 ~ 2 :00,2:00~3:00,左闭右开,一个小时更新一次(这个根据自己的业务来)
  2. 刚才新更新的文章,通过已有的idf计算出tfidf值以及hive 的textrank_keywords_values

  3. 更新hive的article_profile

逻辑其实也是非常简单,就是把上面的这几步设置成定时执行就可以了, 因为Sqoop已经设置成增量更新了,所以再把上面这一套设置成定时运行就OK了。后面会再介绍一款定时运行的工具。 但在这之前,还要做的一个事情就是更新程序代码整理, 并测试运行。

定时更新新文章设计, 通过Supervisor管理Apscheduler定时运行更新程序, 主要步骤如下:

  1. 更新程序代码整理,并测试运行
  2. Apscheduler设置定时运行时间,并启动日志添加
  3. Supervisor进程管理

2.1 更新程序代码整理,并测试运行

前面都是基于jupyter写的代码,接下来就是要把这个代码写成项目代码的形式,放入pychram中。

打开pycharm, 在offline下面建立一个update_article.py文件。然后把上一篇文章中的jupyter的代码拷贝过来。由于这里大部分代码都是和上一篇里面重复的,我这里不粘贴了,而是整理一些不一样的东西,毕竟这个是增量更新的代码, 和单纯的计算画像还是有些区别的。这里梳理下这个程序的执行逻辑,然后看些细节就完事了。 要不然全是代码反而不好看。

在这里插入图片描述
增量更新的关键就是这三个函数, 而大部分代码和上一篇文章是一样的, 但是改了一点细节,比如merge_article_data这个函数里面,之前是直接三个数据表(news_article_basic, news_article_content和news_channel)合并,抽取出标题,channel_id, 内容等信息。 而这里改的地方就是

在这里插入图片描述
这里会加上指定时间间隔更新的逻辑, sql语句这里指定了start,end之后,就只会拿这段时间的数据进行合并,然后再合并到另外一个表, 后面就是把channel_name, title和content拼接成sentence了,和之前一样就不说了。

这就是上面的第一个函数, 而判断语句的意思是,如果有新文章了,就进行后面的两个函数,如果这一个小时内没有新文章,就啥也不做了。而如果这一个小时有新文章了, 那么就是第二个函数:

  • generate_article_label: 这个函数就是加载训练好的tfidf模型和textrank模型,对新文章的句子列分词,然后分别计算tfidf值和textrank值,并保存到Hive的相应数据表当中。
  • get_article_profile: 这个函数接收的结果是textrank和tfidf计算的关键词的结果, 在这里面完成就是关键词的权重合并,并得到关键词和主题词,把这两个再合并起来,得到最终的文章画像。

这两个和之前的就一模一样了,不详细解释。接下来再说运行的问题, 如果是想在pytcharm中运行上面的.py文件, 目前直接运行会报错,这是因为pycharm也是需要去添加hadoop,spark等运行的环境的, 并且这里的添加和jupyter那里还不太一样,这个要学习一下:配置环境。

os.environ['JAVA_HOME'] = '/opt/bigdata/java/jdk1.8'
os.environ['SPARK_HOME'] = '/opt/bigdata/spark/spark2.2'
os.environ['PYSPARK_PYTHON'] = '/opt/bigdata/anaconda3/envs/bigdata_env/bin/python3.7'
os.environ['PYSPARK_DRIVER_PYTHON'] = '/opt/bigdata/anaconda3/envs/bigdata_env/bin/python3.7'

这是之前notebook里面的指定环境位置的代码, 而在pycharm里面, 需要在环境变量里面加入这些东西:

在这里插入图片描述
这个正好和我这篇文章对应起来,我也一块放过来了。

这样,这个完整的离线文章画像增量更新的代码就测试成功。下面想办法进行定时运行上面这个代码。介绍一个定时运行的工具。

2.2 Apscheduler定时更新文章画像

这个东西是定时运行python代码用的,这个和第二篇里面介绍的supervisor工具是做实时更新等最常用的两个工具了,所以需要学习这两个工具的基本使用。

APScheduler:强大的任务调度工具,可以完成定时任务,周期任务等,它是跨平台的,用于取代Linux下的cron daemon或者Windows下的task scheduler。

下面看下使用:首先在bigdata_env下安装:

pip install APScheduler==3.5.3

这里了解4个基本概念(组件):

  1. triggers: 描述一个任务何时被触发,有按日期、按时间间隔、按cronjob描述式三种触发方式
  2. job stores: 任务持久化仓库,默认保存任务在内存中,也可将任务保存都各种数据库中,任务中的数据序列化后保存到持久化数据库,从数据库加载后又反序列化。
  3. executors: 执行任务模块,当任务完成时executors通知schedulers,schedulers收到后会发出一个适当的事件
  4. schedulers: 任务调度器,控制器角色,通过它配置job stores和executors,添加、修改和删除任务。

下面是个简单的使用, 每个5秒钟打印一次python, 其实这里懂了逻辑之后,就知道应该怎么让上面的那个文章画像增量更新的程序自动运行了。用这个简单例子说明使用方法:

from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.executors.pool import ProcessPoolExecutor

# 这个东西就是告诉scheduler要定期干啥的, 所以如果想定期增量更新文章画像,需要把上面主函数的那个执行逻辑加到这里就完事。
def test_job():
    print("python")

# 创建scheduler,多进程执行
executors = {
    
    
    'default': ProcessPoolExecutor(3)
}

scheduler = BlockingScheduler(executors=executors)
'''
 #该示例代码生成了一个BlockingScheduler调度器,使用了默认的默认的任务存储MemoryJobStore,以及默认的执行器ThreadPoolExecutor,并且最大线程数为10。
'''
scheduler.add_job(test_job, trigger='interval', seconds=5)
'''
 #该示例中的定时任务采用固定时间间隔(interval)的方式,每隔5秒钟执行一次。
 #并且还为该任务设置了一个任务id
'''
scheduler.start()

这个我还测试了下,是能运行成功的。

在这里插入图片描述
到这里,我自己电脑上的探索就差不多了,下面的代码就根据课件整理逻辑了,第一个是因为我这边的设备不足以支撑这些东西,第二个是开着三台虚拟机,我连pycharm都打不开,学习下面的这些东西不方便。所以关掉大数据的所有环境,关掉虚拟机, 打开pycharm,进行下面的探索了。下面把这个东西用到文章画像定时更新的任务。

在大项目toutiao_project目录中创建一个scheduler目录用于启动各种定时任务。然后再这个scheduler目录中建立main.pyupdate.py文件。这个update.py文件呢,就是写我们上面测试代码里面的主函数里面的那个逻辑

from offline.update_article import UpdateArticle

def update_article_profile():
    """
    更新文章画像定时更新的逻辑
    :return:
    """
    ua = UpdateArticle()
    sentence_df = ua.merge_article_data()
    if sentence_df.rdd.collect():
      textrand_keywords_df, keywordsIndex = ua.generate_article_label(sentence_df)
      article_profile = ua.get_article_profile(textrand_keywords_df, keywordsIndex)

就是把测试代码里面的主函数搬到了这里来。这个函数写完了之后,接下来就是让APScheduler定时运行这个函数了,这个函数其实就相当于上面例子里面的test_job函数。

接下来,在main.py里面编写定时运行的代码。

import sys
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(BASE_DIR))
sys.path.insert(0, os.path.join(BASE_DIR, 'reco_sys'))
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.executors.pool import ProcessPoolExecutor
from scheduler.update import update_article_profile


# 创建scheduler,多进程执行
executors = {
    
    
    'default': ProcessPoolExecutor(3)
}

scheduler = BlockingScheduler(executors=executors)

# 添加定时更新任务更新文章画像,每隔一小时更新
scheduler.add_job(update_article_profile, trigger='interval', hours=1)  # 每个1个小时间隔触发一次,即执行下这个函数
scheduler.start()

这里的add_job后面的定时运行函数就换成了上面写好的update_article_profile函数, 每间隔1个小时执行一次这个代码, 而这个代码又正是增量计算文章画像的功能代码, 所以这样就实现了文章画像增量更新。

下面再说下学习到的一点东西,就是导包的那几句代码:其实是用东西的

在这里插入图片描述

这里学到了导包的路径设置问题,看上面左边的项目结构,我们知道这个main.py是位于scheduler目录下面的,而在这里面正常的话,是只能导入本目录下的.py文件的,而看最后一句from scheduler.update import代码,这里竟然可以访问到scheduler目录本身(正常情况下这句话会提示没有scheduler目录, 因为搜索的时候就是在这个目录下面找py文件,怎么能找到它本身?), 还有一点就是我们知道update文件中导包的时候用了from offline.update_article import代码,而这个update.py也是位于scheduler目录下,怎么会访问到offline目录呢? 这里的核心就是这三句话:

# 这是找main.py的父目录的父目录, 找到了toutiao_project, 设置成BASE_DIR
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))  

# 把这个toutiao_project加入到搜索路径中,就可以访问到头条下的任何一个目录了, 比如reco_sys, scheduler, scripts下面的模块都可以导了
sys.path.insert(0, os.path.join(BASE_DIR))  

# 这句话的作用是把头条目录下面的reco_sys目录加入到搜索路径,就可以访问toutiao/reco_sys目录下的所有目录或者.py文件了,比如offline目录
sys.path.insert(0, os.path.join(BASE_DIR, 'reco_sys'))  

所以一定要加入相应的路径,导包才不会出现问题, 这个操作学习到了哈哈, 之前不知道还能这么玩。

还有更骚的操作,就是为了观察定期运行的结果和异常,可以添加日志。这里面可以自定义一些日志格式,来打印运行过程中出现的异常信息等。

在reco_sys目录下面建立一个setting目录,在里面创建一个logging.py文件,在里面写入:

import logging
import logging.handlers
import os
logging_file_dir = '/root/logs/'  # 日志输出位置

def create_logger():
    """
    设置日志
    :param app:
    :return:
    """

    # 离线处理更新打印日志
    trace_file_handler = logging.FileHandler(
        os.path.join(logging_file_dir, 'offline.log')
    )
    trace_file_handler.setFormatter(logging.Formatter('%(message)s'))
    log_trace = logging.getLogger('offline')
    log_trace.addHandler(trace_file_handler)
    log_trace.setLevel(logging.INFO)

这是一个日志拦截的功能, 在这里面我们会指定日志的打印输出位置,日志的信息等。那么这玩意定义完了之后,怎么用呢? 就是create_logger要在哪里加呢?

在Apscheduler的main文件中加入,运行时初始化一次

from settings import logging as lg
lg.create_logger()

并在需要打日志的文件中如update_article.py中加入:

import logging

logger = logging.getLogger('offline')

# 把下面这句话嵌入到想要打印日志的地方去,比如报错信息的位置, 打印异常的位置啊等
# 如果编写那种捕获异常的代码的时候, 往往在发生异常的里面加入下面这句话,报错说发生了什么异常
logger.info("自己组建日志信息格式")
logger.warn

这里学到了当代码运行出问题的时候,如果通过日志的方式告诉用户错误信息。这个我觉得在以后的工作中还是很重要的,尤其是这种定时运行的程序,又没有管的,万一运行出状况了,再没有用户盯着,报错都不知道哪里去找,所以通过让他打印日志的方式,可以帮助我们去监控错误等。 这里它在这里加了一句提示:

在这里插入图片描述
每次运行到这里 ,就会输出这样的一句提示信息在日志里面。但一般打印日志是打印错误或者异常,这里只是为了做个演示怎么用。

上面就是Apscheduler定时更新文章画像的逻辑了, 我们最终创建了个main.py文件,只要运行这个文件, 在pycharm中run即可(但前提要加hadoop和spark环境到环境变量), 就会触发定时器,每隔一段时间去执行我们写好的update_article_profile函数,而这个函数里面,我们完成的是文章画像的增量更新。 所以通过上面这一整套,就能够定时更新文章画像啦。

但是,我们这个定时更新文章画像是应该部署到服务器上的,而服务器上的黑窗口环境中万一没有pycharm怎么办,那我们这个main.py文件怎么运行起来呢? 并且我们既然是做实时增量更新文章画像,这个main.py是希望一直运行着的, 那怎么实现这个功能呢?

哈哈,对了,Supervisor呀, 第二篇文章里面已经介绍过这个东西,把flume的启动程序放到了后台,让flume实时的收集用户行为数据, 那么我们就可以用Supervisor工具来将运行main.py的文件放到后台,这样不就能一直运行了吗?

2.3 Supervisor进程管理

这个的目的就是能在服务器上一致运行着上面的main.py文件,完成文章画像定时增量更新的任务。怎么实现呢? 关于这个的具体使用,可以参考第二篇文章。

在reco.conf中添加如下:

[program:offline]
environment=JAVA_HOME=/root/bigdata/jdk,SPARK_HOME=/root/bigdata/spark,HADOOP_HOME=/root/bigdata/hadoop,PYSPARK_PYTHON=/miniconda2/envs/reco_sys/bin/python,PYSPARK_DRIVER_PYTHON=/miniconda2/envs/reco_sys/bin/python
command=/miniconda2/envs/reco_sys/bin/python /root/toutiao_project/scheduler/main.py
directory=/root/toutiao_project/scheduler
user=root
autorestart=true
redirect_stderr=true
stdout_logfile=/root/logs/offlinesuper.log   # 这个日志是supervisor自己的,和我们上面创建的日志没有任何关系
loglevel=info
stopsignal=KILL
stopasgroup=true
killasgroup=true

注意,这里运行main.py的时候,使用的python命令, 但是还是得加入spark,hadoop等环境,当然上面这个不是我大数据里面的环境, 而是人家课用的环境, 如果要用的话,需要把environment里面的各个变量改成自己环境里面的, command里面的python命令,也用自己环境里面的才可以。

3. 离线文章相似度计算

3.1 文章相似度

在头条推荐中,有很多地方需要推荐相似文章,包括首页频道可以推荐相似的文章,详情页猜你喜欢等。那么这些相似文章是怎么计算出来的呢?

首页频道推荐,每个频道推荐的时候,会通过计算两两文章相似度,快速达到在线推荐的效果,比如用户点击文章,我们可以将离线计算好相似度的文章排序快速推荐给该用户。此方式也就可以解决冷启动的问题

主要有两种计算文章相似度的方式:

  1. 计算两两文章TFIDF之间的相似度(共性关键词都有权重,组成向量计算相似)
  2. 计算两两文章的word2vec或者doc2vec向量相似度

比较常用且效果好的是第二种方式,所以下面就是学习第二种方式。关于这两个技术呢? 我在AI上推荐 之 基于内容的推荐(ContentBasedRecommend)也介绍过,重复的东西这里就不整理了,比如W2V的原理, 词向量是啥等, 这里整理点新的东西, 比如如何用spark完成文章相似度计算,spark下Word2Vec模型如何用, 还有局部敏感哈希的一些知识。

3.2 文章词向量训练

这里是通过大量的历史文章数据, 训练词的词向量, 由于文章数据过多,在开始设计的时候我们会分频道进行词向量训练,每个频道一个词向量模型。步骤如下:

  • 根据频道内容, 读取不同频道号,获取相应频道数据并进行分词
  • Spark中训练Word2Vec模型并保存

由于我拿到的数据,人家也是把w2v模型训练好了,后面可以直接用人家这个分频道训练的模型, 这里主要是看下是怎么训练这种模型的。

根据频道内容,读取不同频道号,获取相应频道数据。

在setting目录汇总创建一个default.py文件, 保存默认一些配置,如频道:

channelInfo = {
    
    
            1: "html", 2: "开发者资讯", 3: "ios", 4: "c++", 5: "android", 6: "css", 7: "数据库", 8: "区块链", 9: "go", 10: "产品", 11: "后端", 12: "linux", 13: "人工智能", 14: "php", 15: "javascript", 16: "架构", 17: "前端", 18: "python", 19: "java", 20: "算法", 21: "面试", 22: "科技动态", 23: "js", 24: "设计", 25: "数码产品"
        }

之所以分开频道训练模型,是因为不同频道下的词语即使相同,说的也可能不是一件事情,并且如果都合在一块训练,数据量非常大且效果不好。所以这里会得到25个词向量模型。

这里为了看清楚这个w2v模型的训练细节,我这里依然是走了一遍这个流程,选择了一点数据简单训练下看了效果,下面开始。

开启三台虚拟机,spark集群,hadoop集群以及jupyter notebook, 并把配置加好(这个可参考前面的文章)。

在offline/full_cal目录下面创建word2vec.ipynb文件,用来训练模型:

# 创建计算w2v训练类,继承sparksessionbase
class TrainWord2VecModel(SparkSessionBase):

    SPARK_APP_NAME = "Word2Vec"
    SPARK_URL = "spark://192.168.56.101:7077" 

    ENABLE_HIVE_SUPPORT = True

    def __init__(self):
        self.spark = self._create_spark_session()

w2v = TrainWord2VecModel()

创建会话类, 然后选择数据库和数据:

# 选一下数据库  
w2v.spark.sql("use article")
article = w2v.spark.sql("select * from article_data where channel_id=18 limit 2")   # 由于机器限制,这里用18号频道的2篇文章
words_df = article.rdd.mapPartitions(segmentation).toDF(["article_id", "channel_id", "words"])

这里看下结果:

在这里插入图片描述

下面就是训练spark中的Word2Vec模型,并进行保存。

模块:from pyspark.ml.feature import Word2Vec
API:class pyspark.ml.feature.Word2Vec(vectorSize=100, minCount=5, numPartitions=1, stepSize=0.025, maxIter=1, seed=None, inputCol=None, outputCol=None, windowSize=5, maxSentenceLength=1000)

  • vectorSize=100: 词向量长度
  • minCount:过滤次数小于默认5次的词
  • windowSize=5:训练时候的窗口大小
  • inputCol=None:输入列名
  • outputCol=None:输出列名

W2V模型的训练非常简单, 从pyspark中导入, 然后只需要.fit就OK了:

from pyspark.ml.feature import Word2Vec

new_word2Vec = Word2Vec(vectorSize=100, inputCol="words", outputCol="model", minCount=3)
new_model = new_word2Vec.fit(words_df)

#new_model.save("hdfs://hadoop-master:9000/headlines/models/test.word2vec")

这样模型就已经训练好了, 接下来就是拿上面这个模型去求出18号频道每篇文章里面词的词向量,看看这个要怎么用。

3.3 文章词向量计算

有了词向量模型之后,我们会得到每篇文章关键词的词向量, 而有了这些关键词向量之后,我们就可以得到一篇文章的向量了,为了后面快速使用文章的向量,我们会将每个频道所有的文章向量保存起来。

目的: 保存所有历史训练的文章向量

步骤:

  1. 加载某个频道模型,得到每个词的向量
  2. 获取频道的文章画像,得到文章画像的关键词(接着之前增量更新的文章article_profile)
  3. 计算得到文章每个词的向量
  4. 计算得到文章的平均词向量即文章的向量

下面也是分步骤来演示。

3.3.1 加载某个频道模型,得到每个词的向量

这里就是通过训练完的Word2Vec模型, 来得到每篇文章中每个词的词向量, 上面演示了模型训练到底是怎么训练的,而这里就直接用人家训练好的模型去得到词的词向量了,因为我上面只用了两篇文章,词太少了,肯定不全,而人家用的是18号频道下的所有文章数据训练的模型。

我这边需要先把本地的数据传到hdfs上,执行命令:

hadoop fs -put modelsbak /headlines/

这里就拿到了所有训练好的各个频道的w2v模型:
在这里插入图片描述
这里会用第18频道的这个模型,我们导入,然后获取它的所有词向量信息:

from pyspark.ml.feature import Word2VecModel
channel_id = 18
channel = "python"
wv_model = Word2VecModel.load(
                "hdfs://hadoop-master:9000/headlines/modelsbak/channel_%d_%s.word2vec" % (channel_id, channel))
vectors = wv_model.getVectors()

这里看下词向量的信息:

在这里插入图片描述

这个vectors保存的就是每个词以及它的词向量信息。 我这里导入它的模型出现了问题,没有解决,所以我还是基于我那个小测试模型探索吧,原理都是一样的。

3.3.2 获取新增的文章画像,得到文章画像的关键词

这里的逻辑是把文章的每个关键词对应的词向量拿到, 我这里首先选了一篇文章:

# 选出新增的文章的画像做测试,上节计算的画像中有不同频道的,我们选取Python频道的进行计算测试
profile = w2v.spark.sql("select * from article_profile where channel_id=18 and article_id=43877")  # 我这里只挑一篇文章了,因为训练模型就拿两篇文章训练的

因为我上面模型里面是拿两篇文章训练的,这里测试拿其中的一篇,这样才能保证这里面出现的每个词在我训练的模型的词里面找到,这个长下面这样:

在这里插入图片描述
下面本来想着运行这段代码, 去找到文章每个词的词向量

profile.registerTempTable("incremental")
articleKeywordsWeights = w2v.spark.sql(
    "select article_id, channel_id, keyword, weight from incremental LATERAL VIEW explode(keywords) AS keyword, weight"
)   # 这里的 LATERAL VIEW explode(keywords)的意思是把这一列中的数据拆分成两列数据,并命名为keyword和weight

但是报了个错误, 说不能拆分,因为我的keywords不是个字典而是个字符串,因为在上一篇文章中创建这个article_profile的时候,keywords的类型指定错了。 所以我这里需要删除下article_profile,然后重新建立,并重新指定字段类型。

create table article_profile(
 article_id int comment "article_id",
 channel_id int comment "channel_id",
 keywords map<string, double>  comment "keywords",   # 这里要指定成字典格式
 topics array<string> comment "topics")
 Location '/user/hive/warehouse/article.db/article_profile';

看下结果:这里把原来的keywords拆分成了两列, 前提字典格式才能用这个拆分函数

在这里插入图片描述
接下来, 就是将这个表和上面的vectors表在word层面上拼接,就得到每个词对应的词向量了

_article_profile = articleKeywordsWeights.join(vectors, vectors.word==articleKeywordsWeights.keyword, "inner")

这样就得到了结果:
在这里插入图片描述

3.3.3 计算得到文章每个词的向量

这里用词的权重 * 词的向量 = weights x vector=new_vector

articleKeywordVectors = _article_profile.rdd.map(lambda row: (row.article_id, row.channel_id, row.keyword, row.weight * row.vector)).toDF(["article_id", "channel_id", "keyword", "weightingVector"])

这样就得到了43877文章中每个词的最终词向量:

在这里插入图片描述

3.3.4 计算得到文章的平均词向量用来表示文章向量

这里就是把该篇文章中所有词向量求个平均,得到最终的文章词向量。这里会按照article_id分组,然后把keyword的词向量收集成一个集合,然后求平均:这些操作都看看起来挺骚的

def avg(row):
    x = 0
    for v in row.vectors:
        x += v
    #  将平均向量作为article的向量
    return row.article_id, row.channel_id, x / len(row.vectors)

articleKeywordVectors.registerTempTable("tempTable")
articleVector = ua.spark.sql(
    "select article_id, min(channel_id) channel_id, collect_set(weightingVector) vectors from tempTable group by article_id").rdd.map(
    avg).toDF(["article_id", "channel_id", "articleVector"])  # 这个min(channel_id)是取一个,因为合并的时候,会有很多个相同的频道id

结果如下:

在这里插入图片描述
我这里由于内存的限制,只演示了一篇文章求文章向量的方法,其实这个就能把整个流程说明白了。再回忆下就是先把文章的信息表合并,重要信息合并成一个句子,然后分词处理,基于这个分词训练word2vec模型,然后就能取出每个词的词向量, 由于之前用过tfidf和textrank模型,所以每个词还会有对应的权重信息, 把权重信息与w2v的词向量相乘得到最终的词向量。 再把词向量求个平均,就是最终文章的词向量。只不过这里面具体实现,会用到很多sql的语句,这个是需要学习的,比较新,之前一直是用pandas或者Spark里面的DataFrame, 直接sql处理这些东西还是第一次玩。

下面就是对计算出的”articleVector“列进行处理,该列为Vector类型,不能直接存入HIVE,HIVE不支持该数据类型。需要转成array。这个Vector是一个DataFrame类型,在Hive中只支持数组类型。

def toArray(row):
    return row.article_id, row.channel_id, [float(i) for i in row.articleVector.toArray()]

articleVector = articleVector.rdd.map(toArray).toDF(['article_id', 'channel_id', 'articleVector'])

最终计算出这个18号Python频道的所有文章向量,保存到固定的表当中。

在这里插入图片描述

由于这里人家都计算好了,我这里创建表然后关联就OK了:

CREATE TABLE article_vector(
article_id INT comment "article_id",
channel_id INT comment "channel_id",
articlevector ARRAY<double> comment "keyword")
 Location '/user/hive/warehouse/article.db/article_vector';

这里面就是文章id, 频道id以及100维的词向量了。有了文章的词向量,下面就可以进行文章相似度计算了。

3.4 文章相似度计算

离线部分我们会先把相似文章计算好缓存起来,到时候推荐的时候,直接取相似的文章就完事了,所以我们这里的目的就是计算每个频道里面两两文章的相似度,并进行保存。

下面有两个问题需要解决:

  1. 是否需要某频道计算所有文章两两相似度?
  2. 相似度结果数值如何保存?

3.4.1 是否需要某频道计算所有文章两两相似度?

我们在推荐相似文章的时候,其实并不会用到所有文章,也就是TOPK个相似文章会被推荐出去,经过排序之后的结果。如果我们的设备资源、时间也真充足的话,可以进行某频道全量所有的两两相似度计算。但是事实当文章量达到千万级别或者上亿级别,特征也会上亿级别,计算量就会很大。

这种情况下有两种解决方案(这个可能会被问到,就是假设文章有上亿级别的话,如何求文章相似呢?)

  1. 每个频道的文章先进行聚类
    可以对每个频道内N个文章聚成M类别,那么类别数越多每个类别的文章数量越少。如下pyspark代码

    bkmeans = BisectingKMeans(k=100, minDivisibleClusterSize=50, featuresCol="articleVector", predictionCol='group')
    bkmeans_model = bkmeans.fit(articleVector)
    bkmeans_model.save(
                    "hdfs://hadoop-master:9000/headlines/models/articleBisKmeans/channel_%d_%s.bkmeans" % (channel_id, channel))
    

    但是对于每个频道聚成多少类别这个M是超参数,并且聚类算法的时间复杂度并不小,当然可以使用一些优化的聚类算法二分、层次聚类。

  2. 局部敏感哈希LSH(Locality Sensitive Hashing) — 常常使用的方式
    从海量数据库中寻找到与查询数据相似的数据是一个很关键的问题。比如在图片检索领域,需要找到与查询图像相似的图,文本搜索领域都会遇到。如果是低维的小数据集,我们通过线性查找(Linear Search)就可以容易解决,但如果是对一个海量的高维数据集采用线性查找匹配的话,会非常耗时,因此,为了解决该问题,我们需要采用一些类似索引的技术来加快查找过程,通常这类技术称为最近邻查找(Nearest Neighbor,AN),例如K-d tree;或近似最近邻查找(Approximate Nearest Neighbor, ANN),例如K-d tree with BBF, Randomized Kd-trees, Hierarchical K-means Tree。而LSH是ANN中的一类方法

    基本思想:LSH算法基于一个假设,如果两个文本在原有的数据空间是相似的,那么分别经过哈希函数转换以后的它们也具有很高的相似度, 这个很重要

    平时使用的哈希函数,我们总是希望尽量的避免冲突。而LSH却依赖于冲突,想让相似的文章在经过哈希函数之后,冲突在一起,在解决NNS(Nearest neighbor search )时,我们期望:

    在这里插入图片描述

    • 离得越近的对象,发生冲突的概率越高(绿色和红色映射后落在同一个桶里面了,看右边)
    • 离得越远的对象,发生冲突的概率越低(橘色和蓝色)


    总结:那么我们在该数据集合中进行近邻查找就变得容易了,我们只需要将查询数据进行哈希映射得到其桶号,然后取出该桶号对应桶内的所有数据,再进行线性匹配即可查找到与查询数据相邻的数据。这个其实类似于聚类,只不过效率要高。时间复杂度很小啊这种方式。

    落入相同的桶内的哈希函数需要满足以下两个条件:

    1. 如果 d ( O 1 , O 2 ) < r 1 d(O_1,O_2)<r_1 d(O1,O2)<r1,那么 P r [ h ( O 1 ) = h ( O 2 ) ] ≥ p 1 Pr[h(O_1)=h(O_2)] ≥ p_1 Pr[h(O1)=h(O2)]p1 , d d d表示距离, P r Pr Pr表示冲突
    2. 如果 d ( O 1 , O 2 ) > r 2 d(O_1,O_2)>r_2 d(O1,O2)>r2,那么 P r [ h ( O 1 ) = h ( O 2 ) ] ≤ p 2 Pr[h(O_1)=h(O_2)] ≤ p_2 Pr[h(O1)=h(O2)]p2


    LSH在线查找时间由两个部分组成:

    1. 通过LSH hash functions计算hash值(桶号)的时间;
    2. 将查询数据与桶内的数据进行比较计算的时间。第(2)部分的耗时就从 O ( N ) O(N) O(N)变成了 O ( l o g N ) O(logN) O(logN) O ( 1 ) O(1) O(1)(取决于采用的索引方法)。


    注:LSH并不能保证一定能够查找到与query data point最相邻的数据,而是减少需要匹配的数据点个数的同时保证查找到最近邻的数据点的概率很大

    那么这玩意是怎么实现的呢? 怎么能够让相似的文章经过哈希函数之后,尽量冲突(到一个桶)? 这里走个例子了。

    mini hashing: 通过签名向量的方式达到如果两篇文章相似,会分到同一个桶的概率越大,下面看究竟怎么实现的:

    假设我们有如下四个文档D1,D2,D3,D4的集合情况,每个文档有相应的词项,用{w1,w2,…w7}表示。若某个文档存在这个词项,则标为1,否则标为0。
    在这里插入图片描述
    过程

    1. 对所有文档,对应不同的词进行文档标记
    2. 对默认所有的词的顺序做一个随机打乱,然后文章对应的词重新标记(特征矩阵按行进行一个随机的排列后)
      在这里插入图片描述
    3. 重复上述操作:
      • 矩阵按行进行多次置换,每次置换之后统计每一列(对应的是每个文档)第一个不为0位置的行号,这样每次统计的结果能构成一个与文档数等大的向量,我们称之为签名向量。比如上面第一次统计会得到的签名向量【1,2,1,2】, 第二次打乱统计得到【1,1,2,1】等,随机打乱多次。得到多个这样的签名向量。如果是打乱N次, 假设K篇文档的话,就会得到一个N行K列的矩阵,这样的矩阵就是签名矩阵。
        在这里插入图片描述
      • 如果两个文档足够相似,也就是说这两个文档中有很多元素是共有的,这样置换出来的签名向量,相应的元素值相同的概率也很高(也就是右边签名矩阵两列中行对应位置的数相同的概率很大)
    4. ①对签名矩阵按行分割成若干brand(一个brand若干行)②每个band计算hash值(hash函数可以md5,sha1任意),我们需要将这些hash值做处理,使之成为事先设定好的hash桶的tag,然后把这些band“扔”进hash桶中。
      在这里插入图片描述
      这个地方感觉得解释下这个过程了,就是把签名矩阵按行分成若干个brand, 那么每个brand里面包含的行数为r/b(假设原来r行分成了b个brand),接下来,就是每个brand里面的这n列(也就是n篇文档)的每一列(一个r/b长的向量)都通过共同的一个哈希函数进行哈希映射到桶里面去(哈希函数可以任意选普通的就行), 因为上面分析过,如果两篇文章很相似的话,每一列对应位置的元素相同概率会很大,那么如果按照行进行切割成小段之后(就是这里的划分brand),通过哈希函数,分到一个桶的概率会特别大。 这样,我们就能统计对于当前brand,两篇文章(其中的两列)映射后是否分到同一个桶,而对于所有的brand都进行上面的这个做法,就能统计两篇文章有多少个brand,经过映射后到了同一个桶中,比如k个。 那么这两篇文章相似的概率就是k/b。 这个过程再体会下吧,相对还是简单的。如果语言不够清晰,还写了段代码:

      在这里插入图片描述
      显然, r和b会影响这个相似度的。

      概率 1 − ( 1 − s r ) b 1−(1−s^r)^b 1(1sr)b就是最终两个文档被映射到同一个hash bucket中的概率。我们发现,这样一来,实际上可以通过控制参数r,b值来控制两个文档被映射到同一个哈希桶的概率。而且效果非常好。比如,令b=20,r=5, s∈[0,1]是这两个文档的相似度,等于给的文档前提条件下:
      1. 当s=0.8时,两个文档被映射到同一个哈希桶的概率是:
        P r ( L S H ( O 1 ) = L S H ( O 2 ) ) = 1 − ( 1 − 0.85 ) 5 = 0.9996439421094793 Pr(LSH(O1)=LSH(O2))=1−(1−0.85)5=0.9996439421094793 Pr(LSH(O1)=LSH(O2))=1(10.85)5=0.9996439421094793
      2. 当s=0.2时,两个文档被映射到同一个哈希桶的概率是: P r ( L S H ( O 1 ) = L S H ( O 2 ) ) = 1 − ( 1 − 0.25 ) 5 = 0.0063805813047682 Pr(LSH(O1)=LSH(O2))=1−(1−0.25)5=0.0063805813047682 Pr(LSH(O1)=LSH(O2))=1(10.25)5=0.0063805813047682

    相似性和分到同一个桶的概率分布:

    在这里插入图片描述

    • 相似度高于某个值的时候,到同一个桶概率会变得非常大,并且快速靠近1
    • 当相似度低于某个值的时候,概率会变得非常小,并且快速靠近0

    Random Projection: Random Projection是一种随机算法.随机投影的算法有很多,如PCA、Gaussian random projection - 高斯随机投影。随机桶投影是用于欧几里德距离的 LSH family。其LSH family将x特征向量映射到随机单位矢量v,并将映射结果分为哈希桶中。哈希表中的每个位置表示一个哈希桶。, 这玩意就类似降维之后求相似度,然后映射到桶里面哈哈。 所以局部敏感哈希有这mini hash和randomprojection两种方法, 前一个比较重要。

3.4.2 基于spark去计算文章相似度

下面是计算的18号python频道的文章之间的相似度的demo, 首先是读取数据,进行类型处理(数组到Vector), 然后直接用BPR进行FIT(相似度计算)

这里我也是用一点数据测试了下, 把上面的2篇取样那里换成了4篇, 也就是算4篇文章的相似度, 然后执行下面的代码:

from pyspark.ml.linalg import Vectors
# 选取部分数据做测试
train = articleVector.select(['article_id', 'articleVector'])

# 把数组类型再转回Vector,这样才能计算相似度
def _array_to_vector(row):
    return row.article_id, Vectors.dense(row.articleVector)

train = train.rdd.map(_array_to_vector).toDF(['article_id', 'articleVector'])

选出来的train结果如下:

在这里插入图片描述

接下来,BRP进行FIT, BRP是一种BucketedRandomProjectionLSH函数

class pyspark.ml.feature.BucketedRandomProjectionLSH(inputCol=None, outputCol=None, seed=None, numHashTables=1, bucketLength=None)

  • inputCol=None:输入特征列
  • outputCol=None:输出特征列
  • numHashTables=1:哈希表数量,几个hash function对数据进行hash操作
  • bucketLength=None:桶的数量,值越大相同数据进入到同一个桶的概率越高
  • method:
    • approxSimilarityJoin(df1, df2, 2.0, distCol=‘EuclideanDistance’)
      计算df1每个文章相似的df2数据集的数据

看操作:

from pyspark.ml.feature import BucketedRandomProjectionLSH

brp = BucketedRandomProjectionLSH(inputCol='articleVector', outputCol='hashes', numHashTables=4.0, bucketLength=10.0)  # 这个numHashTables和bucketlength不要乱取,用他默认的值就好
model = brp.fit(train)

计算相似的文章以及相似度

similar = model.approxSimilarityJoin(test, train, 2.0, distCol='EuclideanDistance')

similar.sort(['EuclideanDistance']).show()

具体求相似度的时候,就是这个approSimilarityJoin函数, 这里注意有个test,其实是和train一模一样的, 因为我们求相似度, 是两两彼此求相似,即求当前文章与其他所有文章的相似度, 这样的话就是复制一份train给test,然后对test的每篇文章,都去和train的每篇文章求一个相似度然后得到结果。 函数里面的2.0是相似度结果的默认值。而我这里直接就把test用train来代替算就行啦。结果如下:

在这里插入图片描述
一共16个结果, 因为我4篇文章, 两两之间求得相似度距离。并按照距离从小到大排的序。

这样,就把某篇文篇与其他所有文章的相似度求了出来, 接下来就是保存最相似的几篇,供后面的推荐使用。这里又有新东西了。

3.4.3 文章相似度存储

对于计算出来的相似度,是要在推荐的时候使用。那么我们所知的是,HIVE只适合在离线分析时候使用,因为运行速度慢,所以只能将相似度存储到HBASE当中, 这时候才能实时推荐

自从安了HBASE之后,我也没有用过了,正好借着这个机会看看咋玩, 看下面的操作。先启动, 具体启动方式我之前写过了大数据开发环境搭建系列四:Zookeeper和HBase环境搭建

# 开启hadoop集群
start-all.sh

# 开启zookeeper   在icss用户
cd 
/bin/zookeeper_manage.sh start

# 开启hbase
cd /opt/bigdata/hbase/hbase1.2/bin
start-hbase.sh

# 进入hbase
hbase shell

# list 这里出现了个报错:
ERROR: Can't get master address from ZooKeeper; znode data == null
# 这个报错没解决目前

这样,环境就启动了, 下面开始干。

目的:将所有文章对应相似度文章及其相似度保存

步骤:

  1. 调用foreachPartition: foreachPartition不同于map和mapPartition,主要用于离线分析之后的数据落地,如果想要返回新的一个数据DF,就使用map及mapPartition

    foreachPartition和mapPartition的区别:mapPartition是会返回一个新的rdd或者说数据DF, 而foreachPartition是不返回任何结果的。

首先,我们需要建立一个HBase存储文章相似度的表:

create 'article_similar', 'similar'   # 建立表和列族

# 存储格式如下:key:为article_id, 'similar:article_id', 结果为相似度
put 'article_similar', '1', 'similar:1', 0.2
put 'article_similar', '1', 'similar:2', 0.34
put 'article_similar', '1', 'similar:3', 0.267
put 'article_similar', '1', 'similar:4', 0.56
put 'article_similar', '1', 'similar:5', 0.7
put 'article_similar', '1', 'similar:6', 0.819
put 'article_similar', '1', 'similar:8', 0.28

定义保存HBASE的函数, 这里用的是happybase操作的HBase数据库, 保证Thrift服务打开(hbase-daemon.sh start thrift)。保存代码如下:

def save_hbase(partitions):
	import happybase    # 这里导入happybase, 注意这里要在里面导入,因为spark运行的时候,是与环境隔开的
	pool = happybase.ConnectionPool(size=3, host='master')
	with pool.connection() as conn:
		article_similar = conn.table('article_similar')   # 这里要获取到上面HBase中建立的表
		for row in partitions:
			# 这里遇到datasetA里面和datasetB 里面是同一篇文章的情况,这时候我们不保存
			if row.datasetA.article_id == row.datasetB.article_id:
				continue
			# 否则,就把文章按照上面那种格式存入到hbase
			# 注意数值型存入HBase必须以bits的形式写进去, 所以这里要b''下
			table.put(str(row.datasetA.article_id).encode(),
                         {
    
    "similar:{}".format(row.datasetB.article_id).encode(): b'%0.4f' % (row.EuclideanDistance)})  

# 保存相似文章以及相似性		
similar.foreachPartition(save_hbase)	

3.4.4 文章相似度增量更新

每天、每小时都会有大量的新文章过来,当后端审核通过一篇文章之后,我们推荐给用户后,一旦发生点击行为了,就会有相应相似文章计算需求(新文章HBase中是没有存储与他相似的文章的)。没有选择去在线计算中处理,新文章来了之后立即推荐召回给用户,这样增加新文章推荐曝光的比例,达到一定速度的曝光。在线需要用用到离线仓库中部分数据,所以速度会受影响。 那么我们应该啥时候更新新文章的相似度呢?

设计:按照之前增量更新文章画像那样的频率,按小时更新增量文章的相似文章
做法:批量新文章,需要与历史数据进行相似度计算
目标:对每次新增的文章,计算完画像后,计算向量,在进行与历史文章相似度计算
步骤:

  1. 新文章数据,按照频道去计算文章所在频道的相似度
  2. 求出新文章向量,保存
  3. BucketedRandomProjectionLSH计算相似度

这里其实是接着增量更新文章画像那里的逻辑过来的, 上面2里面我们已经知道了如何定期的更新文章画像, 而这里在上面的基础上,拿到每个小时的新来的文章之后,首先按照频道给他划分开, 然后导入相应频道的w2v模型,textrank等求它的关键词以及权重,最后得到文章的词向量并更新到Hive表中。接下来,顺便在该频道下把与该文章相似的N篇文章的相似度计算了,这个基于LSH的方式, 得到与当前新来的文章最相似的N篇文章存入HBase即可。

具体代码如下:

 def compute_article_similar(self, articleProfile):
        """
        计算增量文章与历史文章的相似度 word2vec
        :return:
        """
        # 得到要更新的新文章通道类别(不采用)
        # all_channel = set(articleProfile.rdd.map(lambda x: x.channel_id).collect())
        def avg(row):
            x = 0
            for v in row.vectors:
                x += v
            #  将平均向量作为article的向量
            return row.article_id, row.channel_id, x / len(row.vectors)

        for channel_id, channel_name in CHANNEL_INFO.items():

            profile = articleProfile.filter('channel_id = {}'.format(channel_id))
            wv_model = Word2VecModel.load(
                "hdfs://hadoop-master:9000/headlines/models/channel_%d_%s.word2vec" % (channel_id, channel_name))
            vectors = wv_model.getVectors()

            # 计算向量
            profile.registerTempTable("incremental")
            articleKeywordsWeights = ua.spark.sql(
                "select article_id, channel_id, keyword, weight from incremental LATERAL VIEW explode(keywords) AS keyword, weight where channel_id=%d" % channel_id)

            articleKeywordsWeightsAndVectors = articleKeywordsWeights.join(vectors,
                                                            vectors.word == articleKeywordsWeights.keyword, "inner")
            articleKeywordVectors = articleKeywordsWeightsAndVectors.rdd.map(
                lambda r: (r.article_id, r.channel_id, r.keyword, r.weight * r.vector)).toDF(
                ["article_id", "channel_id", "keyword", "weightingVector"])

            articleKeywordVectors.registerTempTable("tempTable")
            articleVector = self.spark.sql(
                "select article_id, min(channel_id) channel_id, collect_set(weightingVector) vectors from tempTable group by article_id").rdd.map(
                avg).toDF(["article_id", "channel_id", "articleVector"])

            # 写入数据库
            def toArray(row):
                return row.article_id, row.channel_id, [float(i) for i in row.articleVector.toArray()]
            articleVector = articleVector.rdd.map(toArray).toDF(['article_id', 'channel_id', 'articleVector'])
            articleVector.write.insertInto("article_vector")

            import gc
            del wv_model
            del vectors
            del articleKeywordsWeights
            del articleKeywordsWeightsAndVectors
            del articleKeywordVectors
            gc.collect()

            # 得到历史数据, 转换成固定格式使用LSH进行求相似
            train = self.spark.sql("select * from article_vector where channel_id=%d" % channel_id)

            def _array_to_vector(row):
                return row.article_id, Vectors.dense(row.articleVector)
            train = train.rdd.map(_array_to_vector).toDF(['article_id', 'articleVector'])
            test = articleVector.rdd.map(_array_to_vector).toDF(['article_id', 'articleVector'])

            brp = BucketedRandomProjectionLSH(inputCol='articleVector', outputCol='hashes', seed=12345,
                                              bucketLength=1.0)
            model = brp.fit(train)
            similar = model.approxSimilarityJoin(test, train, 2.0, distCol='EuclideanDistance')

            def save_hbase(partition):
                import happybase
                for row in partition:
                    pool = happybase.ConnectionPool(size=3, host='hadoop-master')
                    # article_similar article_id similar:article_id sim
                    with pool.connection() as conn:
                        table = connection.table("article_similar")
                        for row in partition:
                            if row.datasetA.article_id == row.datasetB.article_id:
                                pass
                            else:
                                table.put(str(row.datasetA.article_id).encode(),
                                          {
    
    b"similar:%d" % row.datasetB.article_id: b"%0.4f" % row.EuclideanDistance})
                        conn.close()
            similar.foreachPartition(save_hbase)

添加函数到update_article.py文件中,修改update更新代码

ua = UpdateArticle()
sentence_df = ua.merge_article_data()      # 合并文章数据
if sentence_df.rdd.collect():
    rank, idf = ua.generate_article_label(sentence_df)   # 用w2v,textrank等得到文章关键词和权重
    articleProfile = ua.get_article_profile(rank, idf)   # 增量更新文章画像
    # 这里新增一个计算相似度的函数
    ua.compute_article_similar(articleProfile)          # 增量更新文章相似度

4. 小总

这里简单的总结下离线文章画像计算以及增量更新,也就是这篇文章的内容,信息量还是有些大。还是一个思维导图把知识拎起来:
在这里插入图片描述

参考:

猜你喜欢

转载自blog.csdn.net/wuzhongqiang/article/details/114763092