推荐阅读:
文章推荐系统 | 一、推荐流程设计
文章推荐系统 | 二、同步业务数据
文章推荐系统 | 三、收集用户行为数据
在上述步骤中,我们已经将业务数据和用户行为数据同步到了推荐系统数据库当中,接下来,我们就要对文章数据和用户数据进行分析,构建文章画像和用户画像,本文我们主要讲解如何构建文章画像。文章画像由关键词和主题词组成,我们将每个词的 TF-IDF 权重和 TextRank 权重的乘积作为关键词权重,筛选出权重最高的 K 个词作为关键词,将 TextRank 权重最高的 K 个词与 TF-IDF 权重最高的 K 个词的共现词作为主题词。
首先,在 Hive 中创建文章数据库 article 及相关表,其中表 article_data 用于存储完整的文章信息,表 idf_keywords_values 用于存储关键词和索引信息,表 tfidf_keywords_values 用于存储关键词和 TF-IDF 权重信息,表 textrank_keywords_values 用于存储关键词和 TextRank 权重信息,表 article_profile 用于存储文章画像信息。
-- 创建文章数据库
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"
)
COMMENT "toutiao news_channel"
LOCATION '/user/hive/warehouse/article.db/article_data';
-- 创建关键词索引信息表
CREATE TABLE idf_keywords_values
(
keyword STRING comment "article_id",
idf DOUBLE comment "idf",
index INT comment "index"
);
-- 创建关键词TF-IDF权重信息表
CREATE TABLE tfidf_keywords_values
(
article_id INT comment "article_id",
channel_id INT comment "channel_id",
keyword STRING comment "keyword",
tfidf DOUBLE comment "tfidf"
);
-- 创建关键词TextRank权重信息表
CREATE TABLE textrank_keywords_values
(
article_id INT comment "article_id",
channel_id INT comment "channel_id",
keyword STRING comment "keyword",
textrank DOUBLE comment "textrank"
);
-- 创建文章画像信息表
CREATE TABLE article_profile
(
article_id INT comment "article_id",
channel_id INT comment "channel_id",
keyword MAP comment "keyword",
topics ARRAY comment "topics"
);
计算文章完整信息
为了计算文章画像,需要将文章信息表(news_article_basic)、文章内容表(news_article_content)及频道表(news_channel)进行合并,从而得到完整的文章信息,通常使用 Spark SQL 进行处理。
通过关联表 news_article_basic, news_article_content 和 news_channel 获得文章完整信息,包括 article_id, channel_id, channel_name, title, content,这里获取一个小时内的文章信息。
spark.sql("use toutiao")
_now = datetime.today().replace(minute=0, second=0, microsecond=0)
start = datetime.strftime(_now + timedelta(days=0, hours=-1, minutes=0), "%Y-%m-%d %H:%M:%S")
end = datetime.strftime(_now, "%Y-%m-%d %H:%M:%S")
basic_content = 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.review_time >= '{}' "
"and a.review_time < '{}' and a.status = 2".format(start, end))
basic_content.registerTempTable("temp_article")
channel_basic_content = spark.sql(
"select t.*, n.channel_name from temp_article t left join news_channel n on t.channel_id=n.channel_id")
channel_basic_content
结果如下所示
利用 concat_ws()
方法,将 channel_name, title, content 这 3 列数据合并为一列 sentence,并将结果写入文章完整信息表 article_data 中
import pyspark.sql.functions as F
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")
)
del basic_content
del channel_basic_content
gc.collect() # 垃圾回收
sentence_df.write.insertInto("article_data")
sentence_df
结果如下所示,文章完整信息包括 article_id, channel_id, channel_name, title, content, sentence,其中 sentence 为 channel_name, title, content 合并而成的长文本内容
计算 TF-IDF
前面我们得到了文章的完整内容信息,接下来,我们要先对文章进行分词,然后计算每个词的 TF-IDF 权重,将 TF-IDF 权重最高的 K 个词作为文章的关键词。首先,读取文章信息
spark.sql("use article")
article_dataframe = spark.sql("select * from article_data")
利用 mapPartitions()
方法,对每篇文章进行分词,这里使用的是 jieba
分词器
words_df = article_dataframe.rdd.mapPartitions(segmentation).toDF(["article_id", "channel_id", "words"])
def segmentation(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的词"""
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:
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
结果如下所示,words 为将 sentence 分词后的单词列表
使用分词结果对词频统计模型(CountVectorizer)进行词频统计训练,并将 CountVectorizer 模型保存到 HDFS 中
from pyspark.ml.feature import CountVectorizer
# vocabSize是总词汇的大小,minDF是文本中出现的最少次数
cv = CountVectorizer(inputCol="words", outputCol="countFeatures", vocabSize=200*10000, minDF=1.0)
# 训练词频统计模型
cv_model = cv.fit(words_df)
cv_model.write().overwrite().save("hdfs://hadoop-master:9000/headlines/models/CV.model")
加载 CountVectorizer 模型,计算词频向量
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)
cv_result
结果如下所示,countFeatures 为词频向量,如 (986, [2, 4, ...], [3.0, 5.0, ...]) 表示总词汇的大小为 986 个,索引为 2 和 4 的词在某篇文章中分别出现 3 次和 5 次,
得到词频向量后,再利用逆文本频率模型( IDF ),根据词频向量进行 IDF 统计训练,并将 IDF 模型保存到 HDFS
from pyspark.ml.feature import IDF
idf = IDF(inputCol="countFeatures", outputCol="idfFeatures")
idf_model = idf.fit(cv_result)
idf_model.write().overwrite().save("hdfs://hadoop-master:9000/headlines/models/IDF.model")
我们已经分别计算了文章信息中每个词的 TF 和 IDF,这时就可以加载 CountVectorizer 模型和 IDF 模型,计算每个词的 TF-IDF
from pyspark.ml.feature import CountVectorizerModel
cv_model = CountVectorizerModel.load("hdfs://hadoop-master:9000/headlines/models/countVectorizerOfArticleWords.model")
from pyspark.ml.feature import IDFModel
idf_model = IDFModel.load("hdfs://hadoop-master:9000/headlines/models/IDFOfArticleWords.model")
cv_result = cv_model.transform(words_df)
tfidf_result = idf_model.transform(cv_result)
tfidf_result
结果如下所示,idfFeatures 为 TF-IDF 权重向量,如 (986, [2, 4, ...], [0.3, 0.5, ...]) 表示总词汇的大小为 986 个,索引为 2 和 4 的词在某篇文章中的 TF-IDF 值分别为 0.3 和 0.5
对文章的每个词都根据 TF-IDF 权重排序,保留 TF-IDF 权重最高的前 K 个词作为关键词
def sort_by_tfidf(partition):
TOPK = 20
for row in partition:
# 找到索引与IDF值并进行排序
_dict = list(zip(row.idfFeatures.indices, row.idfFeatures.values))
_dict = sorted(_dict, key=lambda x: x[1], reverse=True)
result = _dict[:TOPK]
for word_index, tfidf in result:
yield row.article_id, row.channel_id, int(word_index), round(float(tfidf), 4)
keywords_by_tfidf = tfidf_result.rdd.mapPartitions(sort_by_tfidf).toDF(["article_id", "channel_id", "index", "weights"])
keywords_by_tfidf
结果如下所示,每篇文章保留了权重最高的 K 个单词,index 为单词索引,weights 为对应单词的 TF-IDF 权重
接下来,我们需要知道每个词的对应的 TF-IDF 值,可以利用 zip()
方法,将所有文章中的每个词及其 TF-IDF 权重组成字典,再加入索引列,由此得到每个词对应的 TF-IDF 值,将该结果保存到 idf_keywords_values 表
keywords_list_with_idf = list(zip(cv_model.vocabulary, idf_model.idf.toArray()))
def append_index(data):
for index in range(len(data)):
data[index] = list(data[index]) # 将元组转为list
data[index].append(index) # 加入索引
data[index][1] = float(data[index][1])
append_index(keywords_list_with_idf)
sc = spark.sparkContext
rdd = sc.parallelize(keywords_list_with_idf) # 创建rdd
idf_keywords = rdd.toDF(["keywords", "idf", "index"])
idf_keywords.write.insertInto('idf_keywords_values')
idf_keywords
结果如下所示,包含了所有单词的名称、TF-IDF 权重及索引
通过 index 列,将 keywords_by_tfidf
与表 idf_keywords_values 进行连接,选取文章 ID、频道 ID、关键词、TF-IDF 权重作为结果,并保存到 TF-IDF 关键词表 tfidf_keywords_values
keywords_index = spark.sql("select keyword, index idx from idf_keywords_values")
keywords_result = keywords_by_tfidf.join(keywords_index, keywords_index.idx == keywords_by_tfidf.index).select(["article_id", "channel_id", "keyword", "weights"])
keywords_result.write.insertInto("tfidf_keywords_values")
keywords_result
结果如下所示,keyword 和 weights 即为所有词在每个文章中的 TF-IDF 权重
计算 TextRank
前面我们已经计算好了每个词的 TF-IDF 权重,为了计算关键词,还需要得到每个词的 TextRank 权重,接下来,还是先读取文章完整信息
spark.sql("use article")
article_dataframe = spark.sql("select * from article_data")
对文章 sentence 列的内容进行分词,计算每个词的 TextRank 权重,并将每篇文章 TextRank 权重最高的 K 个词保存到 TextRank 结果表 textrank_keywords_values
textrank_keywords_df = article_dataframe.rdd.mapPartitions(textrank).toDF(["article_id", "channel_id", "keyword", "textrank"])
textrank_keywords_df.write.insertInto("textrank_keywords_values")
TextRank 计算细节:分词后只保留指定词性的词,滑动截取长度为 K 的窗口,计算窗口内的各个词的投票数
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]
textrank_keywords_df
结果如下所示,keyword 和 textrank 即为每个单词在文章中的 TextRank 权重
画像计算
我们计算出 TF-IDF 和 TextRank 后,就可以计算关键词和主题词了,读取 TF-IDF 权重
idf_keywords_values = oa.spark.sql("select * from idf_keywords_values")
读取 TextRank 权重
textrank_keywords_values = oa.spark.sql("select * from textrank_keywords_values")
通过 keyword
关联 TF-IDF 权重和 TextRank 权重
keywords_res = textrank_keywords_values.join(idf_keywords_values, on=['keyword'], how='left')
计算 TF-IDF 权重和 TextRank 权重的乘积作为关键词权重
keywords_weights = keywords_res.withColumn('weights', keywords_res.textrank * keywords_res.idf).select(["article_id", "channel_id", "keyword", "weights"])
keywords_weights
结果如下所示
这里,我们需要将相同文章的词都合并到一条记录中,将 keywords_weights
按照 article_id 分组,并利用 collect_list()
方法,分别将关键词和权重合并为列表
keywords_weights.registerTempTable('temp')
keywords_weights = spark.sql("select article_id, min(channel_id) channel_id, collect_list(keyword) keywords, collect_list(weights) weights from temp group by article_id")`
keywords_weights
结果如下所示,keywords 为每篇文章的关键词列表,weights 为关键词对应的权重列表
为了方便查询,我们需要将关键词和权重合并为一列,并存储为 map 类型,这里利用 dict()
和 zip()
方法,将每个关键词及其权重组合成字典
def to_map(row):
return row.article_id, row.channel_id, dict(zip(row.keywords, row.weights))
article_keywords = keywords_weights.rdd.map(to_map).toDF(['article_id', 'channel_id', 'keywords'])
article_keywords
结果如下所示,keywords 即为每篇文章的关键词和对应权重
前面我们计算完了关键词,接下来我们将 TF-IDF 和 TextRank 的共现词作为主题词,将 TF-IDF 权重表 tfidf_keywords_values 和 TextRank 权重表 textrank_keywords_values 进行关联,并利用 collect_set()
对结果进行去重,即可得到 TF-IDF 和 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 = spark.sql(topic_sql)
article_topics
结果如下所示,topics 即为每篇文章的主题词列表
最后,将主题词结果和关键词结果合并,即为文章画像,保存到表 article_profile
article_profile = article_keywords.join(article_topics, article_keywords.article_id==article_topics.article_id2).select(["article_id", "channel_id", "keywords", "topics"])
article_profile.write.insertInto("article_profile")
文章画像数据查询测试
hive> select * from article_profile limit 1;
OK
26 17 {"策略":0.3973770571351729,"jpg":0.9806348975390871,"用户":1.2794959063944176,"strong":1.6488457985625076,"文件":0.28144603583387057,"逻辑":0.45256526469610714,"形式":0.4123994242601279,"全自":0.9594604850547191,"h2":0.6244481634710125,"版本":0.44280276959510817,"Adobe":0.8553618185108718,"安装":0.8305037437573172,"检查更新":1.8088946300014435,"产品":0.774842382276899,"下载页":1.4256311032544344,"过程":0.19827163395829256,"json":0.6423301791599972,"方式":0.582762869780791,"退出应用":1.2338671268242603,"Setup":1.004399549339134} ["Electron","全自动","产品","版本号","安装包","检查更新","方案","版本","退出应用","逻辑","安装过程","方式","定性","新版本","Setup","静默","用户"]
Time taken: 0.322 seconds, Fetched: 1 row(s)
Apscheduler 定时更新
定义离线更新文章画像的方法,首先合并最近一个小时的文章信息,接着计算每个词的 TF-IDF 和 TextRank 权重,并根据 TF-IDF 和 TextRank 权重计算得出文章关键词和主题词,最后将文章画像信息保存到 Hive
def update_article_profile():
"""
定时更新文章画像
:return:
"""
ua = UpdateArticle()
# 合并文章信息
sentence_df = ua.merge_article_data()
if sentence_df.rdd.collect():
textrank_keywords_df, keywordsIndex = ua.generate_article_label()
ua.get_article_profile(textrank_keywords_df, keywordsIndex)
利用 Apscheduler 添加定时更新文章画像任务,设定每隔 1 个小时更新一次
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.executors.pool import ProcessPoolExecutor
# 创建scheduler,多进程执行
executors = {
'default': ProcessPoolExecutor(3)
}
scheduler = BlockingScheduler(executors=executors)
# 添加一个定时更新文章画像的任务,每隔1个小时运行一次
scheduler.add_job(update_article_profile, trigger='interval', hours=1)
scheduler.start()
利用 Supervisor 进行进程管理,配置文件如下
[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
loglevel=info
stopsignal=KILL
stopasgroup=true
killasgroup=true
参考
https://www.bilibili.com/video/av68356229
https://pan.baidu.com/s/1-uvGJ-mEskjhtaial0Xmgw(学习资源已保存至网盘, 提取码:eakp)