推荐系统学习篇(一)—— 基于用户的协同过滤算法

概述

推荐系统中的传统方法之一是:协同过滤算法。
协同过滤算法的思想是:利用用户之前的喜好来寻找与臭味相投的用户,将与其兴趣相似的用户喜好的产品(该用户没有见过的产品)推荐给该用户。
协同过滤算法的特点:协同过滤算法只使用用户的历史行为数据(比如:购买、下载、浏览等)来描述该用户的特点,而不使用该用户的信息(比如:年龄、性别等)或物品的信息(价格、类别等)。

协同过滤算法包括:基于用户的协同过滤算法基于物品的协同过滤算法

本篇来介绍一下基于用户的协同过滤算法。

两个步骤

基于用户的协同过滤算法主要分为两个步骤来进行:
1. 找到与A用户兴趣相同的用户B。计算用户A与其他用户之间的相似程度。
2. 将B喜好的物品推荐给用户A。将用户A没有买过的物品都利用第一步计算出的结果(每个人之间的相似程度)来预估用户A的打分情况,然后将打分高的物品推荐给用户A。

1. 计算用户之间的相似程度


这里我们判断两个用户是否兴趣相投是采用计算两个用户购买物品的相似程度来衡量的。
如上图所示:
我们的目标是计算出Alice对物品5的打分,如果这个打分高我们就将物品5推荐给Alice,如果打分低我们就不推荐物品5。大致分为以下两个步骤:
第一步:计算Alice与其他用户的相似程度。我们利用用户1~4购买物品1~4的情况与Alice对物品1~4的购买情况进行计算相似度,然后会得到Alice与每个用户的相似程度;
第二步:估计Alice对物品5的打分。通过上面计算得到的Alice与每个用户的相似程度作为权重{w_{u1u2}}({w_{u1,u2}}表示用户u1与用户u2的相似程度),我们将{w_{u1u2}}*s_{u2,i}(s_{u,i}是用户u2对物品i的打分)作为用户u2对用户u1在物品i上打分的贡献程度,有点拗口,那么Alice对物品5的打分如何计算呢?
{\sum_{\mathrm{s} \in S}\left(w_{\mathrm{u}, \mathrm{s}} \cdot R_{\mathrm{s}, \mathrm{p}}\right)}
这个式子里面, 权重w_{u,s}是用户u和用户s的相似度, R_{s,p}是用户s对物品p的评分。
但是实际上我们经常会对上述分数取一个加权平均,如下式:
R_{\mathrm{u}, \mathrm{p}}=\frac{\sum_{\mathrm{s} \in S}\left(w_{\mathrm{u}, \mathrm{s}} \cdot R_{\mathrm{s}, \mathrm{p}}\right)}{\sum_{\mathrm{s} \in S} w_{\mathrm{u}, \mathrm{s}}}

作为用户u对物品P的打分。
还有一种方式如下, 这种方式考虑的更加全面, 依然是用户相似度作为权值, 但后面不单纯的是其他用户对物品的评分, 而是该物品的评分与此用户的所有评分的差值进行加权平均, 这时候考虑到了有的用户内心的评分标准不一的情况, 即有的用户喜欢打高分, 有的用户喜欢打低分的情况。
$
P_{i, j}=\bar{R}{i}+\frac{\sum{k=1}^{n}\left(S_{i, k}\left(R_{k, j}-\bar{R}{k}\right)\right)}{\sum{k=1}^{n} S_{j, k}}
上式中:p_{i,j}表示的是用户i对物品j的打分\bar{R}{i}表示的是用户i打的平均分数(将用户i所有的打分取一个平均,代表了用户i平时的打分情况)S{i,k}表示的是用户i和用户k的相似程度R_{k,j}表示的是用户k对物品j的打分情况\bar{R}{k}表示的是用户k的平均打分情况\sum{k=1}^{n} S_{j, k}表示的是所有用户对物品j$的打分总和。
所以这一种计算方式更为推荐。

2. 将物品打分进行排序推荐给用户

这时候, 我们就得到了Alice对物品5的得分是4.87, 根据Alice的打分对物品排个序从大到小:物品1>物品5>物品3=物品4>物品2
这时候,如果要向Alice推荐2款产品的话, 我们就可以推荐物品1和物品5给Alice。

代码实现

代码实现主要参考(其实是学习)fun-rec的实现。

数据处理

数据介绍

RATINGS FILE DESCRIPTION
================================================================================

All ratings are contained in the file "ratings.dat" and are in the
following format:

UserID::MovieID::Rating::Timestamp

- UserIDs range between 1 and 6040 
- MovieIDs range between 1 and 3952
- Ratings are made on a 5-star scale (whole-star ratings only)
- Timestamp is represented in seconds since the epoch as returned by time(2)
- Each user has at least 20 ratings
def get_data(root_path):
    # 读取数据
    rnames = ['user_id','movie_id','rating','timestamp']
    ratings = pd.read_csv(os.path.join(root_path, 'ratings.dat'), sep='::', engine='python', names=rnames)

    # 分割训练和验证集
    trn_data, val_data, _, _ = train_test_split(ratings, ratings, test_size=0.2)

    """
    将划分好的数据按照userid进行分组之后,将每个userid看过的所有movie_id取出来进行打包成list。之后将每个之前
    的索引号单拎出成为一列。reset_index函数的作用就是将原本的索引号单拎出来变成一列。

    最终形态:
    标号    user_id       movie_id
    0         x1       [1111,2222,3333,4444,...]
    1         x2       [5555,6666,7777,8888,...]
    ...       ...        ......
    """
    trn_data = trn_data.groupby('user_id')['movie_id'].apply(list).reset_index()
    val_data = val_data.groupby('user_id')['movie_id'].apply(list).reset_index()

    trn_user_items = {}
    val_user_items = {}

    # 将数组构造成字典的形式{user_id: [item_id1, item_id2,...,item_idn]}
    for user, movies in zip(*(list(trn_data['user_id']), list(trn_data['movie_id']))):
        trn_user_items[user] = set(movies)

    for user, movies in zip(*(list(val_data['user_id']), list(val_data['movie_id']))):
        val_user_items[user] = set(movies)

    return trn_user_items, val_user_items

UserCF算法实现

def User_CF_Rec(trn_user_items, val_user_items, K, N):
    '''
    trn_user_items: 表示训练数据,格式为:{user_id1: [item_id1, item_id2,...,item_idn], user_id2...}
    val_user_items: 表示验证数据,格式为:{user_id1: [item_id1, item_id2,...,item_idn], user_id2...}
    K: K表示的是相似用户的数量,每个用户都选择与其最相似的K个用户
    N: N表示的是给用户推荐的商品数量,给每个用户推荐相似度最大的N个商品
    '''

    # 建立item->users倒排表
    # 倒排表的格式为: {item_id1: {user_id1, user_id2, ... , user_idn}, item_id2: ...} 也就是每个item对应有那些用户有过点击
    # 建立倒排表的目的就是为了更好的统计用户之间共同交互的商品数量
    print('建立倒排表...')
    item_users = {}
    for uid, items in tqdm(trn_user_items.items()): # 遍历每一个用户的数据,其中包含了该用户所有交互的item
        for item in items: # 遍历该用户的所有item, 给这些item对应的用户列表添加对应的uid
            if item not in item_users:
                item_users[item] = set()
            item_users[item].add(uid)


    # 计算用户协同过滤矩阵
    # 即利用item-users倒排表统计用户之间交互的商品数量,用户协同过滤矩阵的表示形式为:sim = {user_id1: {user_id2: num1}, user_id3:{user_id4: num2}, ...}
    # 协同过滤矩阵是一个双层的字典,用来表示用户之间共同交互的商品数量
    # 在计算用户协同过滤矩阵的同时还需要记录每个用户所交互的商品数量,其表示形式为: num = {user_id1:num1, user_id2:num2, ...}
    sim = {}
    num = {}
    print('构建协同过滤矩阵...')
    for item, users in tqdm(item_users.items()): # 遍历所有的item去统计,用户两辆之间共同交互的item数量
        for u in users:
            if u not in num: # 如果用户u不在字典num中,提前给其在字典中初始化为0,否则后面的运算会报key error
                num[u] = 0
            num[u] += 1 # 统计每一个用户,交互的总的item的数量
            if u not in sim: # 如果用户u不在字典sim中,提前给其在字典中初始化为一个新的字典,否则后面的运算会报key error
                sim[u] = {}
            for v in users:
                if u != v:  # 只有当u不等于v的时候才计算用户之间的相似度 
                    if v not in sim[u]:
                        sim[u][v] = 0
                    sim[u][v] += 1


    # 计算用户相似度矩阵
    # 用户协同过滤矩阵其实相当于是余弦相似度的分子部分,还需要除以分母,即两个用户分别交互的item数量的乘积
    # 两个用户分别交互的item数量的乘积就是上面统计的num字典
    # 这里使用的是余弦相似度
    print('计算相似度...')
    for u, users in tqdm(sim.items()):
        for v, score in users.items():
            sim[u][v] =  score / math.sqrt(num[u] * num[v]) # 余弦相似度分母部分 


    # 对验证数据中的每个用户进行TopN推荐
    # 在对用户进行推荐之前需要先通过相似度矩阵得到与当前用户最相思的前K个用户,
    # 然后对这K个用户交互的商品中除当前测试用户训练集中交互过的商品以外的商品计算最终的相似度分数
    # 最终推荐的候选商品的相似度分数是由多个用户对该商品分数的一个累加和
    print('给测试用户进行推荐...')
    items_rank = {}
    for u, _ in tqdm(val_user_items.items()): # 遍历测试集用户,给测试集中的每个用户进行推荐
        items_rank[u] = {} # 初始化用户u的候选item的字典
        for v, score in sorted(sim[u].items(), key=lambda x: x[1], reverse=True)[:K]: # 选择与用户u最相思的k个用户
            for item in trn_user_items[v]: # 遍历相似用户之间交互过的商品
                if item not in trn_user_items[u]: # 如果相似用户交互过的商品,测试用户在训练集中出现过,就不用进行推荐,直接跳过
                    if item not in items_rank[u]:
                        items_rank[u][item] = 0   # 初始化用户u对item的相似度分数为0
                    items_rank[u][item] += score  # 累加所有相似用户对同一个item的分数

    print('为每个用户筛选出相似度分数最高的N个商品...')
    items_rank = {k: sorted(v.items(), key=lambda x: x[1], reverse=True)[:N] for k, v in items_rank.items()}
    items_rank = {k: set([x[0] for x in v]) for k, v in items_rank.items()} # 将输出整合成合适的格式输出

    return items_rank

评价指标

  • 召回率
    对用户u推荐N个物品记为R(u), 令用户u在测试集上喜欢的物品集合为T(u), 那么召回率定义为:
    \operatorname{Recall}=\frac{\sum_{u}|R(u) \cap T(u)|}{\sum_{u}|T(u)|}
    这个意思就是说, 在用户真实购买或者看过的影片里面, 我模型真正预测出了多少, 这个考察的是模型推荐的一个全面性。
  • 准确率
    \operatorname{Precision}=\frac{\sum_{u} \mid R(u) \cap T(u)|}{\sum_{u}|R(u)|}
    这个意思是说,在我推荐的所有物品中,用户真正看的有多少,这个考察的是我模型推荐的一个准确性。
    为了提高准确率,模型需要把非常有把握的才对用户进行推荐,所以这时候就减少了推荐的数量,而这往往就损失了全面性,真正预测出来的会非常少,所以实际应用中应该综合考虑两者的平衡。
  • 覆盖率
    覆盖率反映了推荐算法发掘长尾的能力, 覆盖率越高, 说明推荐算法越能将长尾中的物品推荐给用户。
    \text { Coverage }=\frac{\left|\bigcup_{u \in U} R(u)\right|}{|I|}
    I是指全部的物品集合。
    该覆盖率表示最终的推荐列表中包含多大比例的物品。如果所有物品都被给推荐给至少一个用户, 那么覆盖率是100%。
  • 新颖度
    用推荐列表中物品的平均流行度度量推荐结果的新颖度。 如果推荐出的物品都很热门, 说明推荐的新颖度较低。 由于物品的流行度分布呈长尾分布, 所以为了流行度的平均值更加稳定, 在计算平均流行度时对每个物品的流行度取对数。
    Recall
# 评价指标
# 推荐系统推荐正确的商品数量占用户实际点击的商品数量
def Recall(Rec_dict, Val_dict):
    '''
    Rec_dict: 推荐算法返回的推荐列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...} 
    Val_dict: 用户实际点击的商品列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...}
    '''
    hit_items = 0
    all_items = 0
    for uid, items in Val_dict.items():
        rel_set = items
        rec_set = Rec_dict[uid]
        for item in rec_set:
            if item in rel_set:
                hit_items += 1
        all_items += len(rel_set)

    return round(hit_items / all_items * 100, 2)

Precision

# 推荐系统推荐正确的商品数量占给用户实际推荐的商品数
def Precision(Rec_dict, Val_dict):
    '''
    Rec_dict: 推荐算法返回的推荐列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...} 
    Val_dict: 用户实际点击的商品列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...}
    '''
    hit_items = 0
    all_items = 0
    for uid, items in Val_dict.items():
        rel_set = items
        rec_set = Rec_dict[uid]
        for item in rec_set:
            if item in rel_set:
                hit_items += 1
        all_items += len(rec_set)

    return round(hit_items / all_items * 100, 2)

Coverage

# 所有被推荐的用户中,推荐的商品数量占这些用户实际被点击的商品数量
def Coverage(Rec_dict, Trn_dict):
    '''
    Rec_dict: 推荐算法返回的推荐列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...} 
    Trn_dict: 训练集用户实际点击的商品列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...}
    '''
    rec_items = set()
    all_items = set()
    for uid in Rec_dict:
        for item in Trn_dict[uid]:
            all_items.add(item)
        for item in Rec_dict[uid]:
            rec_items.add(item)
    return round(len(rec_items) / len(all_items) * 100, 2)

Popularity

# 使用平均流行度度量新颖度,如果平均流行度很高(即推荐的商品比较热门),说明推荐的新颖度比较低
def Popularity(Rec_dict, Trn_dict):
    '''
    Rec_dict: 推荐算法返回的推荐列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...} 
    Trn_dict: 训练集用户实际点击的商品列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...}
    '''
    pop_items = {}
    for uid in Trn_dict:
        for item in Trn_dict[uid]:
            if item not in pop_items:
                pop_items[item] = 0
            pop_items[item] += 1

    pop, num = 0, 0
    for uid in Rec_dict:
        for item in Rec_dict[uid]:
            pop += math.log(pop_items[item] + 1) # 物品流行度分布满足长尾分布,取对数可以使得平均值更稳定
            num += 1  
    return round(pop / num, 3)

到此,基于用户的协同过滤算法就介绍完了!

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注