1

[TOC]

协同过滤

介绍

协同过滤包括基于物品的协同过滤和基于用户的协同过滤两种不同方式。

  • 基于物品的协同过滤:给用户推荐与他之前购买物品相似的其他物品。
  • 基于用户的协同过滤:给用户推荐与他兴趣相似的其他用户购买的物品

理解相似度

在上面简单的介绍了协同过滤的概率,我们发现无论是基于物品的协同过滤还是基于用户的协同过滤,都包含一个共同的关键词,就是相似度。相似度衡量了用户与用户之间,物品与物品之间的相似程度。在协同过滤中起着至关重要的作用。

在计算相似度之前,我们通常都会将物品或者用户用一个向量来表示,比如下表表示了4个用户的购买行为,1表示购买,0表示没有。则用户1可以用向量$[1, 0, 1, 0]$来表示。

商品1商品2商品3商品4
用户11010
用户20100
用户31100
用户41101
用户5001

常见的相似度计算方法主要有

  1. 余弦相似度

$i$和$j$表示两个不同的用户向量,则它们之间的余弦距离可以用下式来表示为

$$ Simularity(i,j)=\frac{i\cdot j}{||i||\cdot ||j||} $$

余弦距离表征的是向量之间的夹角大小,夹角越小,余弦距离约小。

  1. 皮尔逊相关系数

相比于余弦距离,皮尔逊相关系数使用用户向量的平均值进行了修正,减少了偏置的影响。

$$ 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$对所有物品评分的平均值。

  1. 基于皮尔逊系数的其他思路
    上面的皮尔逊相关系数中,使用了用户的平均评分对相似度进行了修正,这里同样对相似度进行了修正,只不过使用的是物品的平均评分。

    $$ 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用户列表。

得到目标用户对于每个商品的评分预测后,就可以对所有的评分预测进行排序,最终决定要向用户推荐什么物品了。

基于用户的协同过滤思路很简单,就是相似的用户应该具有类似的兴趣偏好,但在技术上存在一些问题

  1. 互联网平台上,用户数往往远远大于物品数,这使得每次相似度计算需要耗费大量时间和资源,而且会随着用户的增长,所需的资源和时间呈现$n^2$的增长,这通常是无法接受的。
  2. 用户的行为通常是稀疏的,找到与目标用户相似的用户是十分困难的,并且很多情况下找到的相似用户并无关系。这使得UserCF很难应用在正样本获取困难的推荐场景,比如说大件商品或奢侈品等等。

基于物品的协同过滤(ItemCF)

由于上面提到的UserCF中的两个缺陷,在最初的推荐系统中,都没有采用UserCF,而是采用了ItemCF。

ItemCF是基于物品相似度进行推荐的协同推荐算法,通过计算物品之间的相似度来得到物品之间的相似矩阵,然后再找到用户的历史正反馈物品的相似物品进行排序和推荐。
ItemCF的步骤如下:

  1. 根据所有用户的历史数据,构建以用户(假设用户数为m)为行坐标,物品(假设物品数为n)为列坐标的$m\\times n$维的共现矩阵。
  2. 计算共现矩阵列向量之间的相似度,得到一个$n\\times n$的一个相似度矩阵。
  3. 获取用户的历史行为数据中的正反馈物品列表
  4. 利用物品的相似度矩阵,针对目标用户的历史行为中的正反馈物品,找出相似的TopK个物品,组成相似物品集合。
  5. 对相似物品集合,利用相似度分值进行排序,生成最终的推荐列表。

计算公式如下所示

$$ 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
from datetime import datetime

首先简单的查看一下数据。

data_path = 'data/train_click_log.csv'
df = pd.read_csv(data_path, sep=',')
df.head()

image.png

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.items()):
        
        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 item_rank:
                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_id] = 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() >= topn

    submit = recall_df.loc[recall_df['rank'] <= topn, :].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('%YY-%m-%d') + '.csv'
    submit.to_csv(save_name, index=False, header=True)
    return submit
    
    
root_dir = 'data'

# 获取测试集
test_click_df = pd.read_csv(os.path.join(root_dir, '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)]

# 生成提交文件
submit = generate_submit_df(test_recalled, topn=5)
    
    
# 获取测试集
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)

总结

TODO

Reference

[1] 推荐系统实战

[2] 天池新闻推荐入门赛之【赛题理解+Baseline】Task01


mhxin
84 声望15 粉丝