1. 写在前面
这里是有关于一个头条推荐项目的学习笔记,主要是整理工业上的推荐系统用到的一些常用技术, 这是第三篇, 上一篇文章整理了用户的行为日志收集技术, 第一篇文章整理了数据库的迁移技术,这里呢,就真正开始头条推荐项目了, 本篇介绍的技术是离线文章画像的计算, 首先会介绍离线画像的流程,然后介绍离线的文章画像如何计算,最后真实用spark实践一波,主要包括:
- 离线画像的流程(把握下画像构建在整个推荐系统中所处的位置)
- 离线文章的画像计算原理(文章画像构成, spark tf-idf和TextRank计算工具使用,文章画像计算和构建流程)
- 离线文章画像的计算实践(spark完成文章f-idf值计算, spark完成文章TextRank值计算,spark完成文章画像结果值的计算与存储)
Ok, let’s go!
这里开始就是真正干项目了,我这边由于一直是自己摸索,所以并没有人家的各种环境啥的,只能看看原理自己尝试,但不一定能尝试出来了这次,因为这种项目性的东西关联性太强了,与环境极其相关。 后面也不知道有多少坑要踩了,摸索就完事。下面开始:
首先是我这边的处理, 前面两篇文章是做了一个尝试性的任务, 其实真正的数据人家已经给了,都处理好了,可以直接拿到hdfs上,然后Hive关联就能用。 所以这里为了文件不那么乱套,先把我前面做的尝试,统统删掉,然后真正用人家给的数据。
开启三台虚拟机, 开启Hadoop,然后开始。首先先把给的数据里面的三个数据库article.db, profile.db, toutiao.db
上传到Hdfs的/user/hive/warehouse
目录,这是唯一和人家一样的点了。代码如下:
hadoop fs -put toutiao.db /user/hive/warehouse/
hadoop fs -put article.db /user/hive/warehouse/
hadoop fs -put profile.db /user/hive/warehouse/
然后在pycharm建立远程工程项目toutiao_project, 使用的远程python解释器是我之前的anaconda3里面的bigdata_env, 同步的远程项目库是/icss/workspace/toutiao_project
目录。
然后按照人家的工程目录,造一个:
基于上面这个开始搞事情了哈哈, 搞事情之前,先梳理离线画像流程。
2. 离线画像流程
下面先看下画像构建流程位置:
画像的构建作为推荐系统非常重要的环节,画像可以作为整个产品的推荐或者营销重要依据。需要通过各种方法来构建。
画像构建内容:
- 文章内容标签化: 根据文章内容定性的制定一些了标签,这些标签可以是描述性标签。针对于文章就是文章相关的内容词语。比如文章的主题词,关键词
- 用户标签化:这个过程就是需要研究用户对内容的喜好程度,用户喜欢的内容即当作用户喜好的标签。
- 在用户行为记录表中,我们所记下用户的行为在此时就发挥出重要的作用了。用户的浏览(时长/频率)、点击、分享/收藏/关注、其他商业化或关键信息均不同程度的代表的用户对这个内容的喜好程度。
离线画像的业务介绍完毕,下面就看看如何基于原始的数据库构建出用户和文章的画像库来。一般是先从原始的数据库构建画像库,然后再进行特征工程的相关操作。所以下面的3是围绕着怎么构建文章画像库来,而下面的4是介绍一下特征工程的相关技术和策略。下面开始离线文章的画像计算。
3. 离线文章的画像计算
文章画像,就是给每篇文章定义一些词。以及这些词怎么去筛选它, 主要包括下面两种词:
- 关键词: 文章中一些词的权重(TFIDF与textrank)高的。 用TEXTRANK或者TFIDF计算出的Topk个词以及权重
- 主题词: 进行规范化处理的,文章中出现的同义词,计算结果出现次数高的词。就是说,可以把TFIDF与textrank提取的词再进行一个抽取以及规范化。得到共性的词来代表这篇文章。 TEXTRANK的TOK词与TFIDF计算的TOP词的交集。
主题词与关键词最大的区别就是主题词经过了规范化处理。在给定的数据库里面有个article.db,里面有个article_profile表, 这里面就记录着文章以及它的一些关键词,这里先看一下,这是某篇文章的一些key_words:
["Electron","全自动","产品","版本号","安装包","检查更新","方案","版本","退出应用","逻辑","安装过程","方式","定性","新版本","Setup","静默","用户"]
那么,怎么去提取文章中的关键词呢? 主要有下面三大步骤:
-
原始文章表数据合并得到文章所有的词语句信息(文章标题+文章频道名称+文章内容组成), 而目前这三类信息分布在了三个表里面,所以后面需要先合并这三个表, 按照文章id进行拼接。
-
所有历史文章TFIDF计算, 每个词里面的tf-idf计算
-
所有历史文章的TEXTRank计算
下面开始走。
3.1 原始文章数据合并
为了方便与进行文章数据操作,将文章相关重要信息表合并在一起。通过spark sql 来进行操作,主要分为两步:
- 创建Spark初始化相关配置, 定义一个初始化spark的基类
- 合并三张表的内容到一张表里面去,并写入到Hive。 article数据库用来存放文章计算的一些结果。
3.1.1 创建Spark初始化相关配置
由于Spark初始化,在后面的每个程序里面都会用到,所以可以把这一块初始化配置信息单拿出来,放到一个基类里面去,后面程序具体用的时候,进行继承就可以了,非常方便,这也是在这里又学习到的技巧。之前做那个阿里商品推荐项目的时候,还得每个jupyter前面,都得写那么一大长串配置信息。这里就直接把他放到一个基类里面了。
在_init_
文件中(上面项目代码里面offline下面),创建一个经常用到的基类, 这里面会一般将一些基础的配置信息放到这里面。后面离线的计算都会用到这个公共的基类。 这里我就基于我的环境进行操作了, 在pycharm中先把初始化文件创建,然后把下面配置写了,这个是参考的我之前的商品推荐里面的配置写的:
# spark 配置信息
from pyspark import SparkConf
from pyspark.sql import SparkSession
class SparkSessionBase(object):
SPARK_APP_NAME = None
SPARK_URL = "spark://192.168.56.101:7077"
SPARK_EXECUTOR_MEMORY = "2g"
SPARK_EXECUTOR_CORES = 2
SPARK_EXECUTOR_INSTANCES = 2
ENABLE_HIVE_SUPPORT = False
def _create_spark_session(self):
"""给spark程序创建初始化信息"""
# 1. 创建配置
conf = SparkConf() # 创建spark config对象
config = (
("spark.app.name", self.SPARK_APP_NAME), # 设置启动的spark的app名称,没有提供,将随机产生一个名称
("spark.executor.memory", self.SPARK_EXECUTOR_MEMORY), # 设置该app启动时占用的内存用量,默认2g
("spark.master", self.SPARK_URL), # spark master的地址
("spark.executor.cores", self.SPARK_EXECUTOR_CORES), # 设置spark executor使用的CPU核心数,默认是1核心
("spark.executor.instances", self.SPARK_EXECUTOR_INSTANCES)
)
conf.setAll(config)
# 2. 读取配置初始化
# 利用config对象,创建spark session
if self.ENABLE_HIVE_SUPPORT:
# 如果spark是需要读取Hive里面的信息的,需要在配置里面加这个enableHiveSupport(), 这样spark才能去读取Hive里面的数据并进行处理。
return SparkSession.builder.config(conf=conf).enableHiveSupport().getOrCreate()
else:
return SparkSession.builder.config(conf=conf).getOrCreate()
3.1.2 进行合并计算
在offline下面再新建一个目录,用于进行文章内容相关计算,这里用full_cal命名。下面的操作需要用jupyter去操作,因为pycharm还是太慢了。 所以切换到root,进入master上的toutiao_project项目(pycharm写的都已经同步到上面去了), 然后激活bigdata_env环境,打开远程的jupyter notebook。
在jupyter notebook种先加入一些重要路径和变量, 这里又学习到了一种BASE_DIR的技巧,竟然可以统一导包的路径。
# 导入基本的环境
import os
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'
# 配置spark driver和pyspark运行时,所使用的python解释器路径
import sys # sys.path是python的搜索模块的路径集,是一个list
sys.path.insert(0, os.path.join(BASE_DIR))
sys.path.append('/opt/bigdata/spark/spark2.2/python')
sys.path.append('/opt/bigdata/spark/spark2.2/python/lib/py4j-0.10.4-src.zip')
sys.path.append('/opt/bigdata/anaconda3/envs/bigdata_env/bin/python3.7')
# 如果当前代码文件运行测试需要加入修改路径,避免出现后导包问题
BASE_DIR = os.path.dirname(os.path.dirname(os.getcwd())) # 统一从reco_sys下面去导包 /home/icss/workspace/toutiao_project/reco_sys
# 这样这里就可以直接从offline去导包了
from offline import SparkSessionBase
下面就是创建合并文章的spark会话, 有了上面的基类,这里会发现非常简单了:
# 创建合并文章类,继承sparksessionbase
class OriginArticleData(SparkSessionBase):
SPARK_APP_NAME = "mergeArticle"
SPARK_URL = "spark://192.168.56.101:7077"
ENABLE_HIVE_SUPPORT = True
def __init__(self):
self.spark = self._create_spark_session() # 就会发现这里可以直接建立sparkSession了
# 实例化spark信息
oa = OriginArticleData()
这里运行发现报了个错误:Can only call getServletHandlers on a running MetricsSystem
, 百度查了下,发现spark环境忘了开了,需要开启spark集群。这个就没问题了。
接下来就是文章的合并过程, 我们会通过spark操作Hive(写SQL语句), 我们先操作toutiao.db里面的两个表:news_article_basic和news_article_content,先看下这俩表的字段:
下面做的操作就是将news_article_basic
表里面的article_id, channel_id, title
和news_article_content
表里面的article_id, content
字段按照article_id
进行拼接。这里通过spark.sql操作如下:
# 选一下数据库先
oa.spark.sql('use toutiao')
# 这个操作并没有执行, show的时候才执行
basic_content = oa.spark.sql(
"select a.article_id, a.channel_id, a.title, b.content from news_article_basic a inner join news_article_content b on a.article_id=b.article_id where a.article_id=116636"
)
由于文章数据量太大,这里只拿一篇文章来测试下效果,但是运行,就报了个错说:AnalysisException: Database 'toutiao' not found;Unable to instantiate org.apache.hadoop.hive.ql.metadata.SessionHiveMetaStoreClient
, 这个报错是因为spark找不到hive的元数据,也就是Hive的数据库并没有与spark进行关联。我们知道hive的数据分为元数据和实体数据。其中元数据表示数据的结构信息,可以有三种保存方式,实体数据保存在hdfs中。 之前是将元数据保存到了mysql中。这里解决方案:
只要spark能够获取到hive的元数据,它就能找到hive的实体数据。为了让spark能够识别hive的元数据,需要将hive的hive-site.xml复制一份到spark/conf下,以及mysql-connector-java-5.1.27.jar到spark/jars下。
所以我这边的操作:
# 进hive的目录
cd /opt/bigdata/hive/hive2.1/conf
# 拷贝hive-site.xml
cp hive-site.xml /opt/bigdata/spark/spark2.2/conf
# 然后
cd ..
cd lib
cp mysql-connector-java-5.1.27-bin.jar /opt/bigdata/spark/spark2.2/jars
好了,下面开始合并操作,在这之前,还得现在Hive里面再建立一个数据库,在这里面保存合并后的数据信息。 建立article数据库, 然后在里面创建article_data表。
create database if not exists article comment "artcile information" location '/user/hive/warehouse/article.db/';
CREATE TABLE article_data(
article_id BIGINT comment "article_id",
channel_id INT comment "channel_id",
channel_name STRING comment "channel_name",
title STRING comment "title",
content STRING comment "content",
sentence STRING comment "sentence");
这里由于是测试,我没有导入它原先弄好的数据,只是想看看后面的合并操作是怎么玩的。 下面是新的东西了, 上面的basic_content是已经连接好了news_article_basic
和news_article_content
, 但是这个东西目前是个DataFrame
现在没有执行,所以就没有这个表,那么我们下面应该是让basic_content
与后面的news_channel
表合并去取频道信息了, 但此时没有这个表的话怎么再进行下面的合并呢? 此时的方法:
- basic_content注册成一个表,比如叫temparticle,让这个表合并后面的news_channel。
- 把news_channel的信息取出来弄成一个DataFrame,然后把basic_content合并
这里采用了第一种思路,用到了registerTempTable
函数。
import pyspark.sql.functions as F #这里面提供了DataFrame的一些操作函数
import gc
# 增加channel的名字,后面会使用
basic_content.registerTempTable("temparticle")
channel_basic_content = oa.spark.sql(
"select t.*, n.channel_name from temparticle t left join news_channel n on t.channel_id=n.channel_id")
这样就合并起来了,合并后的这个新表也是个DataFrame,也是没执行。我们这里先show执行下看看结果:
下面将后面的几个地段拼成一列,并写入上面创建的Hive数据表article_data中,这里用到了concat_ws
方法,进行了字段拼接。
# 利用concat_ws方法,将多列数据合并为一个长文本内容(频道, 标题以及内容合并)
oa.spark.sql("use article")
sentence_df = channel_basic_content.select("article_id", "channel_id", "channel_name", "title", "content", \
F.concat_ws(
",", # 分割符号
channel_basic_content.channel_name,
channel_basic_content.title,
channel_basic_content.content
).alias("sentence")) # .alias 是给合并函数返回的结果列取的名称
sentence_df.show() # 这时候才出结果
# 释放内存
del basic_content
del channel_basic_content
gc.collect()
# 写入article数据库的article_data表中
sentence_df.write.insertInto("article_data")
看下结果:
这块探索结束, 得到的以下知识:
- spark如果想操作hive,需要hive告诉spark使用的元数据是啥,也就是需要把hive的一些配置加到spark
- os.spark.sql语句,创建完了之后,并没有执行,而是等到真正查或者写的时候,也就是遇到执行操作的时候才去执行,这个感觉和spark的转换和执行有点类似呀。
- DataFrame与表的合并有两种思路,注册成表然后合并是比较不错的方式。
这里,就把文章的重要内容画像channel_name, title, content
提取了出来,并合并成了一个句子,并写入了一个新表里面article_data
。下面就是删除我这边的article_data,然后重新创建,创建的时候与人家给的数据关联起来。
CREATE TABLE article_data(
article_id BIGINT comment "article_id",
channel_id INT comment "channel_id",
channel_name STRING comment "channel_name",
title STRING comment "title",
content STRING comment "content",
sentence STRING comment "sentence")
COMMENT "toutiao news_channel"
LOCATION '/user/hive/warehouse/article.db/article_data';
这里还遇到一个坑的问题,就是我发现关掉hive之后,再重新打开会报错了,刚才用的好好的,百度了一下,原来是sparksql用hive的元数据信息的时候,每次sparksql写入数据的时候都会把版本改回1.2,所以Caused by: MetaException(message:Hive Schema version 2.1.0 does not match metastore’s schema version 1.2.0 Metastore is not upgraded or corrupt)_2
, 解决方法见这篇博客, 直接在hive-site.xml中加入:
<property>
<name>hive.metastore.schema.verification</name>
<value>false</value>
</property>
这样就OK了。接下来,tf-idf的计算。
3.2 TF-IDF计算
这里简单的回顾下tfidf技术之后,再看具体计算,这个课程的一开始基于内容推荐那里也介绍了下这个技术。TFIDF的原理也是重点知识。
TF-IDF算法便是其中一种在自然语言处理领域中应用比较广泛的一种算法。可用来提取目标文档中,并得到关键词用于计算对于目标文档的权重,并将这些权重组合到一起得到特征向量。
算法原理:
TF-IDF自然语言处理领域中计算文档中词或短语的权值的方法,是词频(Term Frequency,TF)和逆转文档频率(Inverse Document Frequency,IDF)的乘积。
- TF指的是某一个给定的词语在该文件中出现的次数。这个数字通常会被正规化,以防止它偏向长的文件(同一个词语在长文件里可能会比短文件有更高的词频,而不管该词语重要与否)。
- IDF是一个词语普遍重要性的度量,某一特定词语的IDF,可以由总文件数目除以包含该词语之文件的数目,再将得到的商取对数得到。
TF-IDF算法基于一个这样的假设:若一个词语在目标文档中出现的频率高而在其他文档中出现的频率低,那么这个词语就可以用来区分出目标文档。这个假设需要掌握的有两点:
- 在本文档出现的频率高
- 在其他文档中出现的频率低
因此,TF-IDF算法的计算可以分为词频(Term Frequency,TF)和逆转文档频率(Inverse Document Frequency,IDF)两部分,由TF和IDF的乘积来设置文档词语的权重。
- TF指的是一个词语在文档中的出现频率。
假设文档集中包含的文档数是N, 文档集中包含关键词 k i k_i ki的文档数为 n i n_i ni, f i j f_{ij} fij表示关键词 k i k_i ki在文档 d j d_j dj中出现的次数, f d j f_{dj} fdj表示文档 d j d_j dj中出现的词语总数, k i k_i ki在文档 d j d_j dj中的词频 T F i j TF_{ij} TFij定义为: T F i j = f i j f d j TF_{ij}=\frac {f_{ij}}{f_{dj}} TFij=fdjfij。并且注意, 这个数字通常会被正规化,以防止它偏向长的文件(指同一个词语在长文件里可能会比短文件有更高的词频, 而不管该词语重要与否) - IDF是一个词语普遍重要性的度量。 表示某一个词语在整个文档集中出现的频率, 由它计算的结果取对数得到关键词 k i k_i ki的逆文档频率 I D F i IDF_i IDFi: I D F i = l o g N n i IDF_i=log\frac {N}{n_i} IDFi=logniN。注意这个地方是N在分子上, 相当于频率取了个倒数, 这样我们依然是希望 I D F i IDF_i IDFi越大越好。
- 由TF和IDF计算词语的权重为: w i j = T F i j ⋅ I D F i = f i j f d j ⋅ l o g N n i w_{ij}=TF_{ij}·IDF_{i}=\frac {f_{ij}}{f_{dj}}·log\frac {N}{n_i} wij=TFij⋅IDFi=fdjfij⋅logniN
结论:TF-IDF与词语在文档中的出现次数成正比,与该词在整个文档集中的出现次数成反比。
用途:在目标文档中,提取关键词(特征标签)的方法就是将该文档所有词语的TF-IDF计算出来并进行对比,取其中TF-IDF值最大的k个数组成目标文档的特征向量用以表示文档。
注意:文档中存在的停用词(Stop Words),如“是”、“的”之类的,对于文档的中心思想表达没有意义的词,在分词时需要先过滤掉再计算其他词语的TF-IDF值。
有了原理,下面基于article_data
表,计算出每篇文章的词语的TFIDF结果用于抽取画像。训练步骤如下:
- 读取N篇文章数据
- 文章数据进行分词处理, 得到分词结果, TFIDF计算方案:
- 先计算分词之后的每篇文章的词频, 得到CV模型
- 然后根据词频计算IDF以及词, 得到IDF模型
- 利用模型计算N篇文章数据的TFIDF值
这么说太抽象了,后面会一步步的来,首先先看下最终要做的一个结果表长下面这样:
这样就得到了每篇文章的关键词以及tfidf值,后面如果我们按照文章id分组,然后再按照tfidf值排序的话,就能得到最终代表文章的关键词了,也就是文章的画像特征。那么怎么由上面的那个article_data得到这样的一张表呢? 这里需要训练tf-idf模型。
下面开始干,打开三台虚拟机,开启Hadoop集群,spark集群, 打开jupyter notebook。
3.2.1 训练TFIDF模型的步骤
想要用TF-IDF进行计算,首先需要训练一个TFIDF模型,并保存起来,之前写过一篇文章叫做基于内容的推荐,那里面是文章关键词已经找了出来,只需要统计,然后计算TFIDF找出TopK个关键词。那里用的是from gensim.models import TfidfModel
, 这里不太一样了,这里我们面对的数据是源数据(一个句子), 得需要先分词,然后进行统计的各种策略等,然后再训练TF-IDF模型,方法也不一样。所以看看这里应该怎么算。 说明下,课程里面已经给了训练好的模型了,那个是针对13万篇文章训练的,而我这里机器受到限制,选择一部分数据玩一下这个模型是怎么训练的。 等探索完了这块,下一部分计算具体值的时候直接用人家的模型,是这样的一个逻辑。 那么开始。
在reco_sys下面的offline下面的full_cal下面新建立一个compute_tfidf.ipynb文件,然后打开,添加下面的配置, 初始化配置和上面那个jupyter一样,这里建立个spark session:
# 创建计算tf-idf类,继承sparksessionbase
class KeywordsToTfidf(SparkSessionBase):
SPARK_APP_NAME = "keywordsByTFIDF"
SPARK_URL = "spark://192.168.56.101:7077"
ENABLE_HIVE_SUPPORT = True
def __init__(self):
self.spark = self._create_spark_session() # 就会发现这里可以直接建立sparkSession了
ktt = KeywordsToTfidf()
下面进行文章处理,首先这里只是测试,选取了前10篇文章:
# 选一下数据库
ktt.spark.sql('use article')
article_dataframe = ktt.spark.sql("select * from article_data limit 10") # 由于机器限制,这里只取前20条探索
我们先看眼article_data中的数据,上面看的时候没有显示全:
文章id, 频道id, 频道名称,title以及具体的文章句子了。
下面对文章进行分词处理, 这里会使用jieba进行分词,jieba分词需要给他词典,然后他就会按照给定字典将上面的句子切割成词,然后再把词进行过滤,就会得到类似于之前那种文章,然后好多个词的那种形式了。具体看看怎么处理:
首先,先把结巴词典(ITKeywords.txt)以及停用词词典文件(stopwords.txt)传到其他两台slave节点上去,注意要保证三台机器的词典路径一样,这样机器才能找到词典,毕竟计算的时候可是在slave上算的。这里我都保存到了/root/words/.
scp -r ./words/ root@192.168.56.102:/root/words/
scp -r ./words/ root@192.168.56.103:/root/words/
分词代码:
# 分词
def setmentation(partition):
import os
import re
import jieba
import jieba.analyse
import jieba.posseg as pseg
import codecs
abspath = "/root/words"
# 结巴加载用户词典
userDict_path = os.path.join(abspath, "ITKeywords.txt") # 结巴虽然自己有词典,但是这里我们专门给他一个技术的词典、
jieba.load_userdict(userDict_path)
# 停用词文本
stopwords_path = os.path.join(abspath, "stopwords.txt")
def get_stopwords_list():
"""返回stopwords列表"""
stopwords_list = [i.strip() for i in codecs.open(stopwords_path).readlines()]
return stopwords_list
# 所有的停用词列表
stopwords_list = get_stopwords_list()
# 分词
def cut_sentence(sentence):
"""对切割之后的词语进行过滤,去除停用词,保留名词,英文和自定义词库中的词,长度大于2的词"""
# print(sentence,"*"*100)
# eg:[pair('今天', 't'), pair('有', 'd'), pair('雾', 'n'), pair('霾', 'g')]
seg_list = pseg.lcut(sentence)
seg_list = [i for i in seg_list if i.flag not in stopwords_list]
filtered_words_list = []
for seg in seg_list:
# print(seg)
if len(seg.word) <= 1:
continue
elif seg.flag == "eng":
if len(seg.word) <= 2:
continue
else:
filtered_words_list.append(seg.word)
elif seg.flag.startswith("n"):
filtered_words_list.append(seg.word)
elif seg.flag in ["x", "eng"]: # 是自定一个词语或者是英文单词
filtered_words_list.append(seg.word)
return filtered_words_list
for row in partition:
sentence = re.sub("<.*?>", "", row.sentence) # 替换掉标签数据
words = cut_sentence(sentence)
yield row.article_id, row.channel_id, words
具体代码的逻辑就是上面那个,结巴根据传入的词典把传入的句子进行分词并且过滤掉无用的词。看下分词之后的效果:
words_df = article_dataframe.rdd.mapPartitions(segmentation).toDF(["article_id", "channel_id", "words"])
结果如下:
当然我这里一开始报错说没找到jieba库,所以我在master上pip安了之后,还是告诉找不到jieba库,所以后来想起来了,python的这个bigdata_env环境得始终保持三台机器上一致,报的是slave节点是没有jieba库。我这里于是就找到了jieba库的安装路径 bigdata_env/lib/python3.7/site-packages
, 把jiaba这个库scp到slave01和slave02相同的位置。所以环境很重要啊,如果有条件,尽量的三台机器都安装anaconda环境,这样只需要各个机器上pip下就OK了,而我这里由于存储不够,只在master上装了anaconda,其他的都是复制的环境,所以没法直接pip。只能scp,这次长教训了, 先这么干着,目前反正没啥问题。
接下来训练模型, 得到每个文章词的频率Counts结果, 这里需要pyspark的CountVectorizer模型,看下面代码:
# 先计算分词之后的每篇文章的词频,得到cv模型
# 统计所有文章不同的词,组成一个词列表
from pyspark.ml.feature import CountVectorizer
# 总词汇的大小, 文本中必须出现的次数
cv = CountVectorizer(inputCol="words", outputCols="countFeatures", vocabSize=200*10000, minDF=1.0) # 输入的列,输出的列,vocabSize是词典的最大数量,也就是文章中词的最大数量 minDF过滤到词频低于多少的词
# 训练词频统计模型
cv_model = cv.fit(words_df)
cv_model.write().overwrite().save("hdfs://hadoop-master:9000/headlines/models/CV.model")
这样就训练完了cv_model。得到cv模型之后,下面要进行idf模型的训练,idf模型是基于cv模型训练的,因为看上面那个计算公式也会发现,拟文档频率的计算,得去找哪些文章中包含当前词。这里的代码如下:
# 词语与词频统计
# 这里注意个细节就是cv模型建立的时候是用的CountVectorize
# 而保存cv模型后导入的时候,用的是CountVectorizerModel
from pyspark.ml.feature import CountVectorizerModel
cv_model = CountVectorizerModel.load("hdfs://hadoop-master:9000/headlines/models/CV.model")
# 得出词频向量结果
cv_result = cv_model.transform(words_df)
# 训练IDF模型
from pyspark.ml.feature import IDF
idf = IDF(inputCol="countFeatures", outputCol="idfFeatures") # 输入是cv的输出特征
idfModel = idf.fit(cv_result)
idfModel.write().overwrite().save("hdfs://hadoop-master:9000/headlines/models/IDF.model")
先看下cv的结果:
cv模型里面有个vocabulary, 保存了上面的的986个词。
cv模型里面会保存上面每个词在每篇文章中出现的次数, 而idf模型会保存上面每个词的拟文档频率。看下前10个:
也就是通过这两个模型,可以得到每个词在每篇文章的TF值,每个词的idf值,那么就可以根据公式计算每个词在每篇文章中的TFIDF值了。
这就是TFIDF的原理啦。下面就是基于这两个模型,去计算抽取出来的10篇文章的各个词的TF-IDF值。
3.2.2 用训练好的TFIDF模型计算N篇文章数据的TFIDF值
其实拿到上面的idf模型之后,直接通过tansform方法就能得到每篇文章的tf-idf值了:
下面得把上面这个结果进行重构下,首先是对于每篇文章里面的每个词,我们会根据tfidf值进行排序,然后取出最高的前K个来作为本篇文章的关键词。看操作:
def func(partition):
TOPK = 20
for row in partition:
# 找到索引与IDF值并进行排序
_ = list(zip(row.idfFeatures.indices, row.idfFeatures.values))
_ = sorted(_, key=lambda x: x[1], reverse=True)
result = _[:TOPK]
for word_index, tfidf in result:
yield row.article_id, row.channel_id, int(word_index), round(float(tfidf), 4)
_keywordsByTFIDF = tfidf_result.rdd.mapPartitions(func).toDF(["article_id", "channel_id", "index", "tfidf"])
看下结果:
这里就统计出了每个词在每篇文章的tfidf值。 离我们最终结果还差一步,就是这里对应的是index,也就是每个词在词典中的位置,我们下面还得映射到具体词上去。
3.2.3 模型计算得出N篇文章的TFIDF值,IDF索引结果合并得到词
这一步做的事情呢? 就是将上面的index索引映射到词典中的具体词,会用到idf_keywords_values表,这个已经给出了, 我已经在hive中建好了,看下长啥样:
名称,tfidf值和名称一一对应的表,下面就是基于这个表进行上面结果的转换。
# 利用结果索引与”idf_keywords_values“合并知道词
keywordsIndex = ktt.spark.sql("select keyword, index idx from idf_keywords_values")
# 利用结果索引与”idf_keywords_values“合并知道词
keywordsByTFIDF = _keywordsByTFIDF.join(keywordsIndex, keywordsIndex.idx == _keywordsByTFIDF.index).select(["article_id", "channel_id", "keyword", "tfidf"])
#keywordsByTFIDF.write.insertInto("tfidf_keywords_values")
这里就是选择索引和名称字段,然后按照索引字段拼接选出要的列。结果如下:
这样就得到了每篇文章里面tfidf值最高的前topK个词以及他们的tfidf值,得到了我们最后的结果。 搞定。
所以上面这个过程再次总结一下, TFIDF模型的训练步骤:
- 读取N篇文章数据
- 文章数据进行分词处理,得到分词结果
- 分词用的词库, 三台都要上传
- 先计算分词之后的每篇文章的词频, 得到CV模型
- 然后根据词频计算IDF以及词,得到IDF模型
- 训练idf模型,保存
- cv * log(总文章数量 / c1出现文章次数)
- 不同词列表, 所有词的IDF的值
- 利用模型计算N篇文章数据的TFIDF的值
- tfidf_key_words_values: 结果
- 用到idf_keywords_values这个表: 词与索引的对应关系
- 对于每篇文章的每个词的权重做排序筛选
- tfidf_key_words_values: 结果
最后我直接把人家计算好的tfidf_key_words_values导入到Hive中:
这个计算的总流程要会玩哈哈。
3.3 TextRank提取关键词
3.3.1 TextRank简介
TextRank由Mihalcea与Tarau于EMNLP在2014年提出,思想非常简单。 关键词的抽取任务就是从一段给定的文本中自动抽取出若干有意义的词语或者词组。 TextRank算法是利用局部词汇关系(共现窗口)对后续关键词进行排序,直接从文本本身抽取。
- 定义: 通过词之间的相邻关系构建网络,然后用PageRank迭代计算每个节点的rank值,排序rank值即可得到关键词
- 方法:利用图模型来提取文章中的关键词
- 基于TextRank的关键词提取过程步骤:
- 把给定的文本T按照完整句子进行分割,对于每个句子,进行分词和词性标注处理,并过滤掉停用词,只保留指定词性的单词,如名词,动词,形容词,即保留候选关键词
- 构建候选关键词图G=(V,E), 其中V为节点集,上一步生成的候选关键词组成,然后采用共现关系构造任点之间的边,两个节点之间存在边仅当他们对应的词汇在长度为K的窗口中共现,即最多共现K个单词。根据上面的公式,跌倒传播各节点权重,直至收敛
- 对节点权重倒序排序,从而得到最重要的T个单词,作为候选关键词。第二步得到的最重要的T个单词,在原始文本中进行标记,若形成相邻词组,则组合成多词关键词。例如,文本中有句子“MATLAB code for plotting ambiguity function", 如果”MATLAB"和“code"均属于候选关键词,则组合成”MATLAB code“加入关键词序列。
举个栗子啦:例如从下面文本中提取关键词:
程序员(英文Programmer)是从事程序开发,维护的专业人员。一般将程序员分为程序设计人员和程序编码人员,但两者的界限并不非常清楚,特别是在中国。软件从业人员分为初级程序员、高级程序员、系统分析员和项目经理四大类。
对这句话分词,去掉里面的停用词,然后保留词性为动词,名词,形容词,副词的单词。得到实际有用的词语:
程序员, 英文, 程序, 开发,维护, 专业, 人员, 程序员, 分为, 程序, 设计, 人员, 程序, 编码, 人员,界限, 特别, 中国, 软件, 人员, 分为, 程序员、高级, 程序员、系统, 分析员, 项目, 经理
现在建立一个大小为9的窗口,即相当于每个单词要将票投给它身前身后距离为5以内的单词:
开发 = 【专业, 程序员, 维护, 英文, 程序, 人员】
软件 = 【程序员, 分为, 界限, 高级, 中国, 特别, 人员】
程序员 = 【开发,软件,分析员,维护,系统,项目,经理,分为,英文,程序,专业,设计,高级,人员,中国】 这个是因为程序员出现的次数很多,所以才这么长
分析员 = 【程序员,系统,项目,经理,高级】
。。。。
然后开始迭代投票,这个投票过程就类似于PageRank思想,画个图(每个词看看有多少能跳到它这,它又能跳到哪些词上去等) 直至收敛,这样就能算出每个词的权重。
程序员 = 1.9249979
人员 = 1.62
程序=1.402
…
可以看到“程序员”的得票数最多,因而它是整段文本最重要的单词。我们将文本中得票数多的若干单词作为该段文本的关键词,若多个关键词相邻,这些关键词还可以构成关键短语。
有了这个小理论,下面就是计算每篇文章单词的Textrank值了。
3.3.2 文章的TextRank计算
这个实现起来比较简单,TextRank模型jieba库中就有。下面直接看处理代码, 建立texrank处理函数,在里面依然会用到jieba, 依然会导入jieba用的词典,停用词文本。
然后建立textrank模型,这个继承jieba.analyse.TextRank模型, 在这里面定义一些关键的属性,比如窗口大小,单词最小长度, 保留的词性(这里用了简写), 然后定义词的过滤条件等。
最后,就是建立这个模型,对我们传入的数据partition的setence列进行计算,就可以得到关键词以及textrank权重。 这个比tfidf计算要简单,只需下面这一个函数即可:
# 分词
def textrank(partition):
import os
import jieba
import jieba.analyse
import jieba.posseg as pseg
import codecs
abspath = "/root/words"
# 结巴加载用户词典
userDict_path = os.path.join(abspath, "ITKeywords.txt")
jieba.load_userdict(userDict_path)
# 停用词文本
stopwords_path = os.path.join(abspath, "stopwords.txt")
def get_stopwords_list():
"""返回stopwords列表"""
stopwords_list = [i.strip()
for i in codecs.open(stopwords_path).readlines()]
return stopwords_list
# 所有的停用词列表
stopwords_list = get_stopwords_list()
class TextRank(jieba.analyse.TextRank):
def __init__(self, window=20, word_min_len=2):
super(TextRank, self).__init__()
self.span = window # 窗口大小
self.word_min_len = word_min_len # 单词的最小长度
# 要保留的词性,根据jieba github ,具体参见https://github.com/baidu/lac
self.pos_filt = frozenset(
('n', 'x', 'eng', 'f', 's', 't', 'nr', 'ns', 'nt', "nw", "nz", "PER", "LOC", "ORG"))
def pairfilter(self, wp):
"""过滤条件,返回True或者False"""
if wp.flag == "eng":
if len(wp.word) <= 2:
return False
if wp.flag in self.pos_filt and len(wp.word.strip()) >= self.word_min_len \
and wp.word.lower() not in stopwords_list:
return True
# TextRank过滤窗口大小为5,单词最小为2
textrank_model = TextRank(window=5, word_min_len=2)
allowPOS = ('n', "x", 'eng', 'nr', 'ns', 'nt', "nw", "nz", "c")
for row in partition:
tags = textrank_model.textrank(row.sentence, topK=20, withWeight=True, allowPOS=allowPOS, withFlag=False)
for tag in tags:
yield row.article_id, row.channel_id, tag[0], tag[1]
使用也非常简单,这里的格式和TFIDF的是一样的。
这里我依然是直接借助了人家给的结果,存到Hive表中。熟悉一下这个流程就好会话。下面是两种结果的合并。
3.4 文章画像结果
有了前面的两个表,这里就容易了, 我们进行最后文章画像的计算,也就是基于上面的两个表结果进行综合,将tfidf和textrank共现的词作为主题词。
步骤如下:
- 加载IDF, 保留关键词以及权重计算(TextRank*IDF)
- 合并关键词权重得到字典结果
- 将tfidf和textrank共现的词作为主题词
- 将主题词表和关键词表进行合并,插入表
首先,第一步,加载IDF,保留关键词以及权重计算(TextRank*IDF), 这里要注意的是词的权重计算是textrank * idf值也就是乘的逆文档频率的值,因为TextRank计算的结果往往会比TFIDF好,这时候我们保留TextRank的计算结果,然后再考虑下逆文档频率就好啦。当然这里也可以采取别的方式,比如TextRank的结果和tfidf的结果求平均等或者加权平均等。
idf = ktt.spark.sql("select * from idf_keywords_values")
idf = idf.withColumnRenamed("keyword", "keyword1")
result = textrank_keywords_df.join(idf,textrank_keywords_df.keyword==idf.keyword1) # 按照关键词进行合并
keywords_res = result.withColumn("weights", result.textrank * result.idf).select(["article_id", "channel_id", "keyword", "weights"]) # 两个权重相乘,作为每个词的最后的权重
看下结果:
下面合并关键词权重得到字典结果, 代码如下:
# 这哥们要重新注册成一个临时表,后面要进行拼接
keywords_res.registerTempTable("temptable")
merge_keywords = ktt.spark.sql("select article_id, min(channel_id) channel_id, collect_list(keyword) keywords, collect_list(weights) weights from temptable group by article_id")
# sql的这种操作骚啊, 先分组然后collect_list就可以收集成列表
# 合并关键词权重合并成字典
def _func(row):
return row.article_id, row.channel_id, dict(zip(row.keywords, row.weights))
keywords_info = merge_keywords.rdd.map(_func).toDF(["article_id", "channel_id", "keywords"])
下面看下结果:
将tfidf和textrank共现的词作为主题词
topic_sql = """
select t.article_id article_id2, collect_set(t.keyword) topics from tfidf_keywords_values t
inner join
textrank_keywords_values r
where t.keyword=r.keyword
group by article_id2
"""
article_topics = ktt.spark.sql(topic_sql)
这里的逻辑就是tfidf的那个表和textrank的那个表以共现的词连接起来,然后按照article_id分组, 然后把keyword收集成一个集合, 去掉了重复的词。这个不在我这里运行了,它用的全量数据,我这边内存会爆掉。
最后,将主题词表和关键词表进行合并。这个按照article_id进行拼接两个表。
article_profile = keywords_info.join(article_topics, keywords_info.article_id==article_topics.article_id2).select(["article_id", "channel_id", "keywords", "topics"])
# articleProfile.write.insertInto("article_profile")
看下结果:
建立这个表要这样建立,然后导入到Hive:
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';
4. 小总
到这里,才学完了头条推荐项目第一天的内容我的天,我整整用了4个下午+两个晚上整理这三篇博客。太难了呀,东西有点多呀,下面赶紧放个人家整理好的导图,把知识拎起来再说:
在这里,再回想前两篇内容到底记了啥东西,只能回忆起个大概来,又快忘完了直接,唉, 目前的打算是先抓紧跟着走一遍,看看工业上的整个推荐流程是怎么玩的, 走的过程详细记录每个细节,先不要求记住,等过两天走完了之后,再回来好好优化和打磨,还好都一点点的记录了下来,哈哈,继续Rush! 😉