[TOC]
协同过滤
介绍
协同过滤包含基于物品的协同过滤和基于用户的协同过滤两种不同形式。
- 基于物品的协同过滤:给用户举荐与他之前购买物品类似的其余物品。
- 基于用户的协同过滤:给用户举荐与他趣味类似的其余用户购买的物品
了解类似度
在下面简略的介绍了协同过滤的概率,咱们发现无论是基于物品的协同过滤还是基于用户的协同过滤,都蕴含一个独特的关键词,就是类似度。类似度掂量了用户与用户之间,物品与物品之间的类似水平。在协同过滤中起着至关重要的作用。
在计算类似度之前,咱们通常都会将物品或者用户用一个向量来示意,比方下表示意了 4 个用户的购买行为,1 示意购买,0 示意没有。则用户 1 能够用向量 $[1, 0, 1, 0]$ 来示意。
商品 1 | 商品 2 | 商品 3 | 商品 4 | |
---|---|---|---|---|
用户 1 | 1 | 0 | 1 | 0 |
用户 2 | 0 | 1 | 0 | 0 |
用户 3 | 1 | 1 | 0 | 0 |
用户 4 | 1 | 1 | 0 | 1 |
用户 5 | 0 | 0 | ? | 1 |
常见的类似度计算方法次要有
- 余弦类似度
$i$ 和 $j$ 示意两个不同的用户向量,则它们之间的余弦间隔能够用下式来示意为
$$
Simularity(i,j)=\frac{i\cdot j}{||i||\cdot ||j||}
$$
余弦间隔表征的是向量之间的夹角大小,夹角越小,余弦间隔约小。
- 皮尔逊相关系数
相比于余弦间隔,皮尔逊相关系数应用用户向量的平均值进行了修改,缩小了偏置的影响。
$$
Simularity(i,j)=\frac{\sum_{p\in P}(R_{i,p}-\bar{R_i})\cdot (R_{j,p}-\bar{R_j})}{\sqrt{\sum_{p\in P}(R_{i,p}-\bar{R_i})^2}\sqrt{\sum_{p\in P}(R_{j,p}-\bar{R_j})^2}}
$$
$P$ 示意所有的物品,$R_{i,p}$ 示意用户 $i$ 对物品 $p$ 的评分。$\\bar{R_i}$ 示意用户 $i$ 对所有物品评分的平均值。
-
基于皮尔逊系数的其余思路
下面的皮尔逊相关系数中,应用了用户的均匀评分对类似度进行了修改,这里同样对类似度进行了修改,只不过应用的是物品的均匀评分。$$
Simularity(i,j)=\frac{\sum_{p\in P}(R_{i,p}-\bar{R_p})\cdot (R_{j,p}-\bar{R_p})}{\sqrt{\sum_{p\in P}(R_{i,p}-\bar{R_p})^2}\sqrt{\sum_{p\in P}(R_{j,p}-\bar{R_p})^2}}
$$$\\bar{R_p}$ 示意物品 $p$ 的所有评分的平均值。
在类似用户的计算过程中,任何正当的类似度计算过程都能够作为类似用户计算的规范。
最终后果的排序
基于用户的协同过滤(UserCF)
有了用户之间的类似度,咱们就能够依据类似度来进行排序,取出 TopN 的最类似的用户。依据这些类似用户的已有评分,咱们就可能对指标用户的偏好进行预测了。
指标用户的评估预测通常应用的是用户之间的类似度加权均匀
$$
R_{up}=\frac{\sum_{s\in S}(W_{us}\cdot R_{sp})}{\sum_{s\in S}W_{us}}
$$
$R_{up}$ 示意指标用户 $u$ 对商品 $p$ 的评分,$W_{us}$ 示意指标用户 $u$ 和用户 $s$ 的类似度,$S$ 示意与用户 $u$ 类似度最高的 TopN 用户列表。
失去指标用户对于每个商品的评分预测后,就能够对所有的评分预测进行排序,最终决定要向用户举荐什么物品了。
基于用户的协同过滤思路很简略,就是类似的用户应该具备相似的趣味偏好,但在技术上存在一些问题
- 互联网平台上,用户数往往远远大于物品数,这使得每次类似度计算须要消耗大量工夫和资源,而且会随着用户的增长,所需的资源和工夫出现 $n^2$ 的增长,这通常是无奈承受的。
- 用户的行为通常是稠密的,找到与指标用户类似的用户是十分困难的,并且很多状况下找到的类似用户并无关系。这使得 UserCF 很难利用在正样本获取艰难的举荐场景,比如说大件商品或奢侈品等等。
基于物品的协同过滤(ItemCF)
因为下面提到的 UserCF 中的两个缺点,在最后的举荐零碎中,都没有采纳 UserCF,而是采纳了 ItemCF。
ItemCF 是基于物品类似度进行举荐的协同举荐算法,通过计算物品之间的类似度来失去物品之间的相似矩阵,而后再找到用户的历史正反馈物品的类似物品进行排序和举荐。
ItemCF 的步骤如下:
- 依据所有用户的历史数据,构建以用户(假如用户数为 m)为行坐标,物品(假如物品数为 n)为列坐标的 $m\\times n$ 维的共现矩阵。
- 计算共现矩阵列向量之间的类似度,失去一个 $n\\times n$ 的一个类似度矩阵。
- 获取用户的历史行为数据中的正反馈物品列表
- 利用物品的类似度矩阵,针对指标用户的历史行为中的正反馈物品,找出类似的 TopK 个物品,组成类似物品汇合。
- 对类似物品汇合,利用类似度分值进行排序,生成最终的举荐列表。
这里,我用本人艰深的语言来了解它,首先,咱们依据共现矩阵($m \\times n$, m 示意用户数,n 示意物品数)计算类似度矩阵,有了类似度矩阵,而后咱们依据用户的历史行为数据失去正反馈的物品列表(就是用户的称心的物品),比如说正反馈的物品列表为 [1, 3, 4]
那么对于物品库中的除正反馈物品外的其余物品,都会计算出一个得分, 计算公式如下所示
$$
R_{u,p}=\sum_{h\in H}(W_{h,p}R_{u,h})
$$
$W_{h,p}$ 示意物品 $h$ 和物品 $p$ 的类似度,$R_{u,h}$ 示意用户 $u$ 对物品 $h$ 的评分,$H$ 示意用户 $u$ 的正反馈物品列表。
将计算出来的得分进行排序,最终失去 TopN 的后果。
UserCF 和 ItemCF 的利用场景
从新回顾一下 UserCF 和 ItemCF 的基本概念,一个是基于类似用户,一个是基于类似物品。
因为 UserCF 的固有个性,使其具备人造的社交属性,对于社交类型的平台,应用 UserCF 更容易发现趣味类似的好友。另外社交属性为热点新闻的流传同样提供了人造的路径,因为新闻自身的趣味点比拟扩散,相比用户对于不同新闻的趣味偏好,新闻的实时性和热点性显得更为重要。UserCF 更容易用来发现热点,跟踪热点。
ItemCF 更适宜趣味变动较为稳固的利用,比方购物 App 等,用户在一段时间内的趣味是绝对固定的。
基于协同过滤的新闻举荐零碎
这里以天池平台上的一个新闻举荐我的项目来学习举荐零碎中的协同过滤算法。
赛题简介
此次较量是新闻举荐场景下的用户行为预测挑战赛,该赛题是以新闻 APP 中的新闻举荐为背景,目标是 要求咱们依据用户历史浏览点击新闻文章的数据信息预测用户将来的点击行为,即用户的最初一次点击的新闻文章,这道赛题的设计初衷是疏导大家理解举荐零碎中的一些业务背景,解决理论问题。
数据详情
该数据来自某新闻 APP 平台的用户交互数据,包含 30 万用户,近 300 万次点击,共 36 万多篇不同的新闻文章,同时每篇新闻文章有对应的 embedding 向量示意。为了保障较量的公平性,从中抽取 20 万用户的点击日志数据作为训练集,5 万用户的点击日志数据作为测试集 A,5 万用户的点击日志数据作为测试集 B。具体数据表和参数,大家能够参考赛题阐明。上面说一下拿到这样的数据如何进行了解,来无效的发展下一步的工作。
评估形式了解
了解评估形式,咱们须要联合着最初的提交文件来看,依据 sample.submit.csv,咱们最初提交的格局是针对每个用户,咱们都会给出五篇文章的举荐后果,依照点击概率从前往后排序。而实在的每个用户最初一次点击的文章只会有一篇的实在答案,所以咱们就看咱们举荐的这五篇外面是否有命中实在答案的。比方对于 user1 来说,咱们的提交会是:
user1, article1, article2, article3, article4, article5.
如果 article1 就是实在的用户点击文章,也就是 article1 命中,则 s(user1,1)=1, s(user1,2-4)都是 0,如果 article2 是用户点击的文章,则 s(user,2)=1/2,s(user,1,3,4,5)都是 0。也就是 score(user)= 命中第几条的倒数。如果都没中,则 score(user1)=0。这个是正当的,因为咱们心愿的就是命中的后果尽量靠前,而此时分数正好比拟高。
$$
score(user) = \sum_{k=1}^5 \frac{s(user, k)}{k}
$$
如果 article1 就是实在的用户点击文章,也就是 article1 命中,则 s(user1,1)=1, s(user1,2-4)都是 0,如果 article2 是用户点击的文章,则 s(user,2)=1/2,s(user,1,3,4,5)都是 0。也就是 score(user)= 命中第几条的倒数。如果都没中,则 score(user1)=0。这个是正当的,因为咱们心愿的就是命中的后果尽量靠前,而此时分数正好比拟高。
好了,在后面的根底和对赛题有一个简要的介绍之后,咱们开始思考如何应用代码来实现它。
这里只应用了基于物品的协同过滤进行文章的举荐。
首先导入一些必要的库
import pandas as pd
import numpy as np
import os
import random
random.seed(0)
import collections
from tqdm.notebook import tqdm
首先简略的查看一下数据。
data_path = 'data/train_click_log.csv'
df = pd.read_csv(data_path, sep=',')
df.head()
print('user count ->', df['user_id'].unique().size)
print('article count ->', df['click_article_id'].unique().size)
user count -> 200000
article count -> 31116
读取数据
# 读取所有用户点击数据
def get_all_user_click(root_dir, offline=True, n_sample=10000):
"""read user click data from file
Params:
root_dir(str): click data root directory
offline(bool): denote current environment, only train click data will be used when in offline
otherwise all click data will be used.
n_sample(int): sample how many user from user click data, if used for test your model.
only valid when offline is True
Returns:
df(pandas.Dataframe): a dataframe after drop duplicate values.
"""df = pd.read_csv(os.path.join(root_dir,'train_click_log.csv'), sep=',')
if not offline:
df = pd.concat([df, pd.read_csv(os.path.join(root_dir, 'test_click_log.csv'), sep=',')], axis=0)
else:
uni_vals = df['user_id'].unique()
if uni_vals.size > n_sample:
sampled_users = random.choices(uni_vals, k=n_sample)
df = df.loc[df['user_id'].isin(sampled_users), :]
df.dropduplicates(subset=['user_id', 'click_article_id', 'click_timestamp'], inplace=True)
return df
# 建设一个字典,键为用户 id,值为他所点击的文章列表
def df_to_item_time_dict(df):
"""get user item-time dictionary from dataframe
Returns:
a dict, it is like {user1: [(article_id, click_timestamp), ...], ...}
"""df = df.sort_values(by='click_timestamp')
# 依照用户 id 分组,并取出 click_article_id 和 click_timestamp
user_item_time_dicts = df.groupby('user_id')['click_article_id', 'click_timestamp']
# group 之后的后果
"""
user_id click_article_id click_timestamp
xxx xxx xxx
xxx xxx
xxx xxx
xxx xxx xxx
xxx xxx
...
"""user_item_time_dicts = user_item_time_dicts.apply(lambda x: list(zip(x['click_article_id'], x['click_timestamp']))).reset_index()
user_item_time_dicts = dict(zip(user_item_time_dicts['user_id'], user_item_time_dicts[0]))
return user_item_time_dicts
# 失去最热门的 n 篇文章
def get_topn_articles(df, n):
"""get top n articles based on occur count"""
return df['click_article_id'].value_counts().index[:n]
# 计算类似度矩阵
def calc_sim_mat(user_item_time_dicts):
"""calculate item simularity matrix, here use dict to save silularity matrix
Params:
user_item_time_dicts(dict): {key: [(item, time)]}
Returns:
a dict, denote simularity matrix
"""
item_sim_mat_dict = {}
item_cnt_dict = collections.defaultdict(int)
"""
{user1: [(article_id, click_timestamp), ...],
user2: [],}
"""
for user, item_time_list in tqdm(user_item_time_dicts):
for item_i, _ in item_time_list:
item_cnt_dict[item] += 1
item_sim_mat_dict[item_i] = {}
for item_j, _ in item_time_list:
if item_j not in item_sim_mat_dict[item_i]:
item_sim_mat_dict[item_i][item_j] = 0
# 用户已点击过的文章不会被举荐,即得分为 0
if item_i == item_j:
continue
# item_sim_mat_dict[item_i][item_j] ----> 文章 j 和文章 i 独特呈现在一个用户的点击列表的次数
item_sim_mat_dict[item_i][item_j] += 1
# 升高热门文章的权重
item_sim_mat_dict[item_i][item_j] /= np.log(len(item_time_list) + 1)
for item_i, item_i_related in tqdm(item_sim_mat_dict.items()):
for item_j, w_ij in item_i_related.items():
# 计算余弦类似度
item_sim_mat_dict[item_i][item_j] /= (np.sqrt(item_cnt_dict[item_i]) * np.sqrt(item_cnt_dict[item_j]))
return item_sim_mat_dict
def item_cf_recommend(user_id, user_item_time_dicts, item_sim_mat_dict, sim_n, recall_n, topn_items):
"""recall article according to item silularity matrix
Params:
user_id(str): user id, a unique value represent a user
user_item_dicts(dict):
item_sim_mat_dict(dict):
sim_n(int):
recall_n(int):
topn_items(list):
Returns:
"""
# 用户历史点击记录
user_his_items = {item for item, _ in user_item_time_dicts[user_id]}
item_rank = {}
# 遍历用户每一个点击文章,每一篇文章都失去 recall_n 个最类似文章(除了它自身)for i, item in enumerate(user_his_items):
# 从相似矩阵中取出 item 与所有物品的类似度,失去一个列向量 item_related_sim_vector
# 将类似度从高到低排序
item_related_sim_vector = sorted(item_sim_mat_dict[item].items(), key=lambda x: x[1], reverse=True)
# 取出前 recall_n 个 item
for item_j, w_ij in item_related_sim_vector[:sim_n]:
if item_j in user_his_items or item_j in recall_items:
continue
item_rank.setdefault(item_j, 0)
item_rank[item_j] += w_ij
# 如果取出的类似文章数小于须要召回的数目, 应用热门文章进行填充
if len(item_rank) < recall_n:
#
for i, item in enumerate(topn_items):
if item in item_rank:
continue
# 让热门文章排在高类似度文章前面
item_rank[item] = -1 - i
# 依据取出来的文章按权重从高到低进行排序, 而后从中选出 recall_n 篇文章
item_rank = sorted(item_rank.items(), key=lambda x: x[1], reverse=True)[:recall_n]
return item_rank
all_df = get_all_user_click(root_dir='data', offline=False)
user_item_time_dict = df_to_item_time_dict(all_df)
item_sim_mat_dict = calc_sim_mat(user_item_time_dict)
sim_n = 10
recall_n = 10
hot_items = get_topn_articles(all_df, recall_n)
user_recalled_items = {}
for user_id in tqdm(all_df['user_id'].unique()):
user_recalled_items[user] = item_cf_recommend(user_id, user_item_time_dict, item_sim_mat_dict,\
sim_n, recall_n, hot_items)
user_item_list = []
for user_id, items in user_recalled_items.items():
for item, score in items:
user_item_list.append([user_id, item, score])
recall_df = pd.DataFrame(user_item_list, columns=['user_id', 'click_article_id', 'pred_score'])
def generate_submit_df(recall_df, topn=5, save_dir='.'):
recall_df = recall_df.sort_values(by=['user_id', 'pred_score'])
# rank 对排名
recall_df['rank'] = recall_df.groupby(['user_id'])['pred_score'].rank(ascending=False, method='first')
# 判断是不是每个用户都有 5 篇文章及以上
tmp = recall_df.groupby('user_id').apply(lambda x: x['rank'].max())
assert tmp.min() >= topk
submit = recall_df.loc[all_df['rank'] <= topk, :].set_index(['user_id', 'rank']).unstack(-1).reset_index()
submit.columns = [int(col) if isinstance(col, int) else col for col in submit.columns.droplevel(0)]
# 依照提交格局定义列名
submit = submit.rename(columns={'':'user_id', 1:'article_1', 2:'article_2',
3: 'article_3', 4: 'article_4', 5: 'article_5'})
save_name = save_dir + '/' + datetime.today().strftime('%m-%d') + '.csv'
submit.to_csv(save_name, index=False, header=True)
# 获取测试集
test_click_df = pd.read_csv(os.path.join(root_path, 'testA_click_log.csv'), sep=',')
uni_user_ids = test_click_df['user_id'].unique()
# 从所有的召回数据中将测试集中的用户选出来
test_recalled = recall_df[recall_df['user_id'].isin(uni_user_ids)]
# 生成提交文件
generate_submit_df(test_recalled, topn=5)
总结
下面的我的项目应用了 ItemCF 的思路构建了一个根本的举荐零碎框架,根本的过程如下
- 依据用户的历史点击数据,计算文章之间的类似度矩阵。为了防止稠密矩阵的。
Reference
[1] 举荐零碎实战
[2] 天池新闻举荐入门赛之【赛题了解 +Baseline】Task01