TopN推荐

ryluo 2020-09-11 11:08:00

 推荐系统简介

  1. What

    • 用户:推荐系统是一种帮助用户快速发现有用信息的工具
    • 公司:推荐系统是一种增加公司产品与用户接触,购买等行为概率的工具
  2. Why

    • 用户:在用户需求并不十分明确的情况下进行信息的过滤,与搜索系统相比,推荐系统更多的利用用户的各类历史信息猜测其可能喜欢的内容

    • 公司:解决产品能够最大限度地吸引用户,留存用户,增长用户黏性,提高用户转化率,从而达到公司商目标连续增长的目的.

    本质上是一种实现将用户-商品-公司之间利益最大化的手段.

  3. Who

    从上面的1和2可以看出用户与公司是需要推荐系统的主要对象.那么可以在1和2的基础上展开想想什么样子的人需要推荐系统,以及什么样的公司需要推荐系统.

常用评测指标

  1. 用户满意度

    用户是推荐系统中非常重要的参与者,他们的满意度也直接决定了推荐系统的好坏.但是用户满意度这个指标无法离线计算,只能通过用户调查或者在线实验获得.这里在线实验一般是通过用户的线上行为统计得到的,比如电商场景中,用户如果购买了推荐的商品说明一定程度上他们是满意的,因此可以通过购买率度量用户的满意度,与购买率类似的点击率,用户停留时间和转化率等指标都可以用来度量用户的满意度.

  2. 预测准确度

    预测准确度是用来度量用户的实际行为与推荐系统预测结果的准确度,该指标是最重要的离线评价指标,因为可以通过离线计算得到.下面是预测准确度最常用的两个指标.

    1. 评分预测

      预测用户对物品的评分行为成为评分预测,评分预测模型通过对用户的历史物品评分记录进行建模,进而得到用户的兴趣模型,然后使用该模型预测用户未未见过商品的评分.评分预测的预测准确度一般通过均方根误差(RMSE)和平均绝对误差(MAE)计算.对于测试集中的一个用户$u$和物品$i$,令$r_{ui}$是用户$u$对物品$i$的实际评分,而$\hat{r_{ui}}$是推荐模型预测出的评分,那么RMSE可以定义为:

      MAE定义为:

      RMSE由于存在平方项,使得使得用户真实评分与推荐系统预测评分相差较大的用户加大了惩罚,即该评测指标对系统要求更加的苛刻

    2. TopN推荐

      推荐系统在给用户推荐物品的时候,往往会给用户一个列表的推荐物品,这种场景下的推荐成为是TopN推荐,该推荐方式最常用的预测准确率指标一般是精确率(precision)和召回率(recall),令$R(u)$为通过推荐模型得到的推荐列表,$T(u)$为用户在实际场景中(测试集)的行为列表.

      • 精确率(precision): 分类正确的正样本个数占分类器判定为正样本的样本个数比例(这里$R(u)$相当于是模型判定的正样本)

      • 召回率(recall): 分类正确的正样本个数占真正的正样本个数的比例(这里的$T(u)$相当于真正的正样本集合)

      有时候为了更加全面的评估TopN推荐,通常会选取不同的推荐列表长度计算多组精确率与召回率然后分别绘制出精确率曲线和召回率曲线,需要注意的是这里并不是PR曲线,感兴趣的可以了解一下PR曲线相关的知识.

  3. 覆盖率

    覆盖率是用来描述一个推荐系统对物品长尾的发掘能力,一个简单的定义可以是:推荐系统所有推荐出来的商品集合数占总物品集合数的比例.但是对于相同的覆盖率,不同物品的数量分布,或者说是物品的流行度分布是可以不一样的.为了更好的描述推荐系统挖掘长尾的能力,需要统计不同物品出现次数的分布.如果所有的物品都出现在推荐列表中,并且出现的次数都差不多,那么推荐系统发掘长尾的能力就很好.所以可以通过研究物品在推荐列表中出现的次数分布来描述推荐系统挖掘长尾的能力,如果这个分布比较平缓说明推荐系统的覆盖率比较高,而如果分布比较陡说明推荐系统的覆盖率比较低.下面分别使用信息熵和基尼系数来定义覆盖率.

    信息熵定义覆盖率: 其中$p(i)$是物品$i$的流行度除以所有物品流行度之和

    基尼系数定义覆盖率: 其中$i_j$是按照物品流行度p从小到大排序的物品列表中第$j$个物品

  4. 多样性

    人的兴趣爱好通常是比较广泛的,所以一个好的推荐系统得到的推荐列表中应该尽可能多的包含用户的兴趣,只有这样才能增加用户找到感兴趣物品的概率.度量推荐列表中物品的多样性换句话说就是度量推荐列表中所有物品之间的不相似性,可以通过不同的相似性函数来度量推荐列表中商品的相似性,比如商品基于内容的相似,基于协同过滤的相似,这样就可以得到不同角度的多样性.令函数$s(i,j)$为物品$i$和物品$j$的相似性,那么用户推荐列表的多样性可以定义为:

    推荐系统整体的多样性可以定义为所有用户推荐列表多样性的平均值:

  5. 新颖性

    满足推荐的新颖性最简单的方法就是给用户推荐他们之前没有看过的物品,但是每个用户没见过的物品数量是非常庞大的,所以一般会计算推荐物品的平均流行度,流行度越低的物品越有可能让用户觉得新颖,因此,如果推荐结果中的物品平均热门程度比较低说明推荐的结果就可能比较新颖.

  6. 惊喜度

    如果推荐结果与和用户的历史兴趣不相似但却让用户觉得满意,那么就可以说推荐结果的惊喜度很高.而新颖性只是通过用户之前是否见过这个推荐结果,没有见过就说明新颖性比较高.所以惊喜度一方面需要提高用户的满意度(该指标只能通过用户调查或者线上测试得到),另一方面还需要降低推荐结果和用户历史兴趣的相似度.

  7. 信任度

  8. 实时性

  9. 健壮性

什么样的推荐系统具有马太效应

马太效应指的是强者更强,弱者更弱.那么如果一个推荐系统会增大热门物品和非热门物品的流行度差距,即让热门的物品变得更加的热门,冷门物品变得更加的冷门,这个推荐系统就具有马太效应.

基于热门推荐

热门推荐是一种非常简单的推荐,就是把用户没有交互过的热门商品推荐给用户,下面通过热门推荐引出推荐系统中常用评价指标的代码实现,及热门推荐的效果:

导入相关包:

import pandas as pd
import numpy as np
import warnings
import random, math, os
from tqdm import tqdm
from sklearn.model_selection import train_test_split
warnings.filterwarnings('ignore')

数据读取:

def get_data(fold=1):
    root_path = './data/ml-1m/'
    rnames = ['user_id','movie_id','rating','timestamp']
    ratings = pd.read_csv(os.path.join(root_path, 'ratings.dat'), sep='::', engine='python', names=rnames)
    ratings['movie_id'] = ratings['movie_id'].astype(str)

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

    trn_data = trn_data.groupby('user_id')['movie_id'].apply(lambda x: ' '.join(list(x))).reset_index()
    val_data = val_data.groupby('user_id')['movie_id'].apply(lambda x: ' '.join(list(x))).reset_index()

    trn_user_items = {}
    val_user_items = {}

    for user, movies in zip(*(list(trn_data['user_id']), list(trn_data['movie_id']))):
        trn_user_items[user] = set(movies.split(' '))

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

    return trn_user_items, val_user_items

评价指标的实现(后面所有的推荐算法的评估都会用到这里的评价指标):

def Recall(Rec_dict, Tst_dict):
    '''
    recall: 推荐系统推荐正确的商品数量占用户实际点击的商品数量
    rec_dict: 推荐算法返回的推荐列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...} 
    rel_dict: 用户实际点击的商品列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...}
    '''
    hit_items = 0
    all_items = 0
    for uid, items in Tst_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)

def Precision(Rec_dict, Tst_dict):
    '''
    recall: 推荐系统推荐正确的商品数量占用户实际点击的商品数量
    rec_dict: 推荐算法返回的推荐列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...} 
    rel_dict: 用户实际点击的商品列表, 形式:{uid: {item1, item2,...}, uid: {item1, item2,...}, ...}
    '''
    hit_items = 0
    all_items = 0
    for uid, items in Tst_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)

# 感觉这个有点问题
# 所有被推荐的用户中,推荐的商品数量占这些用户实际被点击的商品数量
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)

# 使用平均流行度度量新颖度,如果平均流行度很高(即推荐的商品比较热门),说明推荐的新颖度比较低
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)

def rec_eval(val_rec_items, val_user_items, trn_user_items):
    print('recall:',Recall(val_rec_items, val_user_items))
    print('precision',Precision(val_rec_items, val_user_items))
    print('coverage',Coverage(val_rec_items, trn_user_items))
    print('Popularity',Popularity(val_rec_items, trn_user_items))

基于热门推荐算法的实现:

def Most_Popular_Rec(trn_user_items, val_user_items, N):
    '''
    N: 表示的是TopN
    '''
    # 统计每个商品出现的总次数,出现的次数越多说明越热门
    popular_items = {}
    for uid in trn_user_items:
        for item in trn_user_items[uid]:
            if item not in popular_items:
                popular_items[item] = 0
            popular_items[item] += 1

    # 给每个测试用户推荐商品
    val_rec_items = {}
    for uid in val_user_items:
        u_items = trn_user_items[uid]
        rec_items = {k: popular_items[k] for k in popular_items if k not in u_items}
        rec_items = sorted(rec_items.items(), key=lambda x: x[1], reverse=True)[:N]
        rec_items = set([x[0] for x in rec_items])
        val_rec_items[uid] = rec_items

    return val_rec_items

热门推荐算法评估:

trn_user_items, val_user_items = get_data()
val_rec_items = Most_Popular_Rec(trn_user_items, val_user_items, 10)
rec_eval(val_rec_items, val_user_items, trn_user_items)
recall: 5.52
precision 18.3
coverage 1.82
Popularity 7.647

协同过滤算法

协同过滤算法是基于用户行为数据设计的一类算法,常见的协同过滤算法有:

  1. 基于邻域的方法
    1. 基于用户的协同过滤
    2. 基于物品的协同过滤
  2. 隐语义模型
  3. 基于图的随机游走算法

基于用户的协同过滤算法

一句话概括算法核心思想,给用户推荐与他兴趣相似的用户喜欢的物品,

  1. 找到和目标用户相似的用户集合
  2. 找到这个集合中用户喜欢的,且目标用户没有听说过的物品推荐给目标用户

基于余弦相似度的用户相似度计算:

基于余弦相似度的计算方式,本质上相当于是用户u和用户v共同交互的item数量除以,两个用户分别交互的item数量的乘积

def User_CF_Rec(trn_user_items, val_user_items, K, N):

    # 建立item->users倒排表, 也就是每个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数量除以两个用户分别交互的item数量的乘积,这也就是余弦相似度
    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数量的乘积
    print('计算相似度...')
    for u, users in tqdm(sim.items()):
        for v, score in users.items():
            sim[u][v] =  score / math.sqrt(num[u] * num[v]) # 余弦相似度分母部分 

    # 为新用户,通过协同过滤矩阵筛选相似的用户,及这些用户交互的商品,并且这些商品不能出现在推荐用户原来交互过的商品列表中
    # 首先将协同过滤矩阵中的每一个用户,根据与其他用户的相似度大小对相似用户进行排序, 并取出最相似的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]: # 如果相似用户交互过的商品,用户u之间也交互过,就不用进行推荐,直接跳过
                    if item not in items_rank[u]:
                        items_rank[u][item] = 0   # 初始化用户u对item的打分为0
                    items_rank[u][item] += score  # 加权所有相似用户并且未交互过商品的打分和 

    print('为每个用户筛选出得分最高的N个item...')
    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

评估推荐算法结果

rec_items = User_CF_Rec(trn_user_items, val_user_items, 80, 10)
rec_eval(rec_items, val_user_items, trn_user_items)
recall: 10.21
precision 33.83
coverage 19.14
Popularity 7.228

优化热门商品对用户相似度的影响

上述基于余弦相似度的计算存在一个问题,当用户u和用户v都对比较热门的商品进行过交互,那么上述计算公式同样会认为用户u和v之间更加的相似,但是实际上,如果用户之间仅仅由于热门商品而变得相似其实是不妥的,举个书中的例子,如果两个用户都卖了<<新华字典>>能说明这两个用户相似吗?由于中国的大多数人可能都买过新华字典,那么是不是都能说他们之间是相似的呢?

其实如果两个用户对于冷门商品有共同交互的话更能说明用户之间是相似的,比如两个人都买了西瓜书,是不是说明这两个人都对机器学习感兴趣呢,因为非相关领域的人是不回去买西瓜书看的,所以这里的一个改进是对用户之间交互的热门商品进行惩罚,降低热门商品对用户相似度的贡献,公式如下:

具体在代码中的改进就是第27行将sim[u][v] += 1改为sim[u][v] += 1 / math.log(1 + len(users))

def User_CFIIF_Rec(trn_user_items, val_user_items, K, N):
    # 建立item->users倒排表, 也就是每个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数量除以两个用户分别交互的item数量的乘积,这也就是余弦相似度
    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 
                    sim[u][v] += 1 / math.log(1 + len(users)) 

    # 计算最终的相似度矩阵, 前面计算得到的只是分子部分,还需要除以分母,即两个用户分别交互的item数量的乘积
    print('计算相似度...')
    for u, users in tqdm(sim.items()):
        for v, score in users.items():
            sim[u][v] =  score / math.sqrt(num[u] * num[v]) # 余弦相似度分母部分 

    # 为新用户,通过协同过滤矩阵筛选相似的用户,及这些用户交互的商品,并且这些商品不能出现在推荐用户原来交互过的商品列表中
    # 首先将协同过滤矩阵中的每一个用户,根据与其他用户的相似度大小对相似用户进行排序, 并取出最相似的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]: # 如果相似用户交互过的商品,用户u之间也交互过,就不用进行推荐,直接跳过
                    if item not in items_rank[u]:
                        items_rank[u][item] = 0   # 初始化用户u对item的打分为0
                    items_rank[u][item] += score  # 加权所有相似用户并且未交互过商品的打分和 

    print('为每个用户筛选出得分最高的N个item...')
    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

评估推荐算法结果

rec_items = User_CFIIF_Rec(trn_user_items, val_user_items, 80, 10)
rec_eval(rec_items, val_user_items, trn_user_items)
recall: 10.26
precision 33.98
coverage 20.93
Popularity 7.197

基于物品的协同过滤算法

基于物品的协同过滤算法相比于基于用户的协同过滤算法应用更广泛,基于物品的协同过滤算法的核心思想是给用户推荐与他历史喜欢的物品相似的物品,这里的相似商品并不是通过计算两个商品语义上的相似度(通过两个商品向量计算相似度),而是通过不同用户对商品的行为记录来度量商品之间的相似度,例如物品A和物品B具有很大的相似度是因为喜欢物品A的用户都喜欢物品B.从这里也能看出基于物品的协同过滤算法有比较强的可解释性,这也是相比于基于用户的协同过滤算法的一个优点.

基本步骤:

  1. 计算物品之间的相似度
  2. 根据物品相似度和用户的历史行为给用户生成推荐列表

简单的物品相似度的计算公式可以使用如下公式表示,即喜欢物品i的用户中有多少比例的用户喜欢j,如果比例越大,说明物品i和物品j越相似

但是上述公式存在一个问题,如果物品j是热门物品,也就是很多人都喜欢商品j包括喜欢物品i的人,然而物品i和物品j其实可能并不相似,仅仅是应为物品j比较热门而已,所以为了降低热门商品对物品相似度的影响,可以给物品j加上一个惩罚,公式如下所示(会发现其实就是余弦相似度):

基于余弦相似度的物品协同过滤实现如下:

def Item_CF_Rec(trn_user_items, val_user_items, K, N):
    # 用户到物品的倒排表, 字典中存的是每个用户交互过的所有的商品 输入的原始数据就是,所以不需要进行额外的处理

    # 计算商品之间的相似性矩阵
    sim = {}
    num = {}
    print('构建相似性矩阵...')
    for uid, items in tqdm(trn_user_items.items()): # 遍历所有数据,统计item i和item j同时出现在一个用户下的总次数
        for i in items:    
            if i not in num: # 遍历所有用户的所有商品的同时,记录下每个商品总共被多少用户交互过 
                num[i] = 0
            num[i] += 1
            if i not in sim:
                sim[i] = {}
            for j in items:
                if j not in sim[i]:
                    sim[i][j] = 0
                if i != j:
                    sim[i][j] += 1

    # 计算物品的协同过滤矩阵
    print('计算协同过滤矩阵...')
    for i, items in tqdm(sim.items()):
        for j, score in items.items():
            if i != j:
                sim[i][j] = score / math.sqrt(num[i] * num[j])

    # 给测试用户进行推荐
    items_rank = {}
    print('给用户进行推荐...')
    for uid, _ in tqdm(val_user_items.items()):
        items_rank[uid] = {} # 存储用户候选的推荐商品
        for hist_item in trn_user_items[uid]: # 遍历该用户历史喜欢的商品,用来下面寻找其相似的商品
            for item, score in sorted(sim[hist_item].items(), key=lambda x: x[1], reverse=True)[:K]:
                if item not in trn_user_items[uid]: # 进行推荐的商品一定不能在历史喜欢商品中出现
                    if item not in items_rank[uid]:
                        items_rank[uid][item] = 0
                    items_rank[uid][item] += score

    #给每个用户推荐最相思的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(v) for k, v in items_rank.items()}
    return items_rank

评估推荐算法效果:

rec_items = Item_CF(trn_user_items, val_user_items, 10, 10)
rec_eval(rec_items, val_user_items, trn_user_items)
recall: 9.09
precision 30.12
coverage 18.92
Popularity 7.168

优化活跃用户对物品相似度的影响

对于上述的基于物品的协同过滤算法仍然还存在一定的问题,从上面我们知道了基于物品的协同过滤,主要是通过统计用户同时喜欢物品i和物品j的情况有多少数量,这个数越大,说明物品i和物品j就越相似.但是对于活跃用户来说,可能对很多商品都喜欢,这里的喜欢可能并不是真正意义上的喜欢,拿书中的例子来说,一个开书店的人在当当上买了上万本书,这能说明这个用户就是喜欢这些物品吗?那么由于这样的用户在实际场景中是存在的,就会使得该用户买的所有书的两两相似度比真实的情况要更高,下面的公式是为了优化活跃用户对物品相似度的影响:

代码实现如下(本质上就是将ItemCF算法的第19行将sim[i][j] += 1改成了sim[i][j] += 1/math.log(len(items))):

def Item_CFIUF_Rec(trn_user_items, val_user_items, K, N):
    # 用户到物品的倒排表, 字典中存的是每个用户交互过的所有的商品 输入的原始数据就是,所以不需要进行额外的处理

    # 计算商品之间的相似性矩阵
    sim = {}
    num = {}
    print('构建相似性矩阵...')
    for uid, items in tqdm(trn_user_items.items()): # 遍历所有数据,统计item i和item j同时出现在一个用户下的总次数
        for i in items:    
            if i not in num: # 遍历所有用户的所有商品的同时,记录下每个商品总共被多少用户交互过 
                num[i] = 0
            num[i] += 1
            if i not in sim:
                sim[i] = {}
            for j in items:
                if j not in sim[i]:
                    sim[i][j] = 0
                if i != j:
                    sim[i][j] += 1 / math.log(1 + len(items)) # len(items)刻画了用户的活跃度

    # 计算物品的协同过滤矩阵
    print('计算协同过滤矩阵...')
    for i, items in tqdm(sim.items()):
        for j, score in items.items():
            if i != j:
                sim[i][j] = score / math.sqrt(num[i] * num[j])

    # 给测试用户进行推荐
    items_rank = {}
    print('给用户进行推荐...')
    for uid, _ in tqdm(val_user_items.items()):
        items_rank[uid] = {} # 存储用户候选的推荐商品
        for hist_item in trn_user_items[uid]: # 遍历该用户历史喜欢的商品,用来下面寻找其相似的商品
            for item, score in sorted(sim[hist_item].items(), key=lambda x: x[1], reverse=True)[:K]:
                if item not in trn_user_items[uid]: # 进行推荐的商品一定不能在历史喜欢商品中出现
                    if item not in items_rank[uid]:
                        items_rank[uid][item] = 0
                    items_rank[uid][item] += score

    #给每个用户推荐最相思的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

评估推荐算法效果:

rec_items = Item_CFIUF_Rec(trn_user_items, val_user_items, 10, 10)
rec_eval(rec_items, val_user_items, trn_user_items)
recall: 9.38
precision 31.09
coverage 17.29
Popularity 7.273

归一化物品相似度

有人研究发现,将ItemCF的相似度矩阵,按照对大值进行归一化,可以提高推荐的准确率,具体公式如下:

下面是归一化物品相似度的物品协同过滤算法的实现:
本质上就是对计算好的相似度在进行归一化

def Item_CF_Norm_Rec(trn_user_items, val_user_items, K, N):
    # 用户到物品的倒排表, 字典中存的是每个用户交互过的所有的商品 输入的原始数据就是,所以不需要进行额外的处理

    # 计算商品之间的相似性矩阵
    sim = {}
    num = {}
    print('构建相似性矩阵...')
    for uid, items in tqdm(trn_user_items.items()): # 遍历所有数据,统计item i和item j同时出现在一个用户下的总次数
        for i in items:    
            if i not in num: # 遍历所有用户的所有商品的同时,记录下每个商品总共被多少用户交互过 
                num[i] = 0
            num[i] += 1
            if i not in sim:
                sim[i] = {}
            for j in items:
                if j not in sim[i]:
                    sim[i][j] = 0
                if i != j:
                    sim[i][j] += 1 / math.log(1 + len(items)) # len(items)刻画了用户的活跃度

    # 计算物品的协同过滤矩阵
    print('计算协同过滤矩阵...')
    for i, items in tqdm(sim.items()):
        for j, score in items.items():
            if i != j:
                sim[i][j] = score / math.sqrt(num[i] * num[j])

    # 物品相似度归一化
    for i, items in tqdm(sim.items()):
        max_v = sorted(items.items(), key=lambda x: x[1], reverse=True)[0][1] # 将物品按照相似度进行排序取第一个值
        for j, _ in items.items():
            sim[i][j] /= max_v

    # 给测试用户进行推荐
    items_rank = {}
    print('给用户进行推荐...')
    for uid, _ in tqdm(val_user_items.items()):
        items_rank[uid] = {} # 存储用户候选的推荐商品
        for hist_item in trn_user_items[uid]: # 遍历该用户历史喜欢的商品,用来下面寻找其相似的商品
            for item, score in sorted(sim[hist_item].items(), key=lambda x: x[1], reverse=True)[:K]:
                if item not in trn_user_items[uid]: # 进行推荐的商品一定不能在历史喜欢商品中出现
                    if item not in items_rank[uid]:
                        items_rank[uid][item] = 0
                    items_rank[uid][item] += score

    #给每个用户推荐最相思的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

矩阵分解

首先回顾一下什么是奇异值分解:

任何矩阵A都可以通过奇异值分解成三个矩阵的乘积,即$A=USV$,其中矩阵$U\in R^{mm}$,$S\in R^{mn}$,$V\in R^{nn}$. 使用奇异值分解来实现数据的将维其实就是取矩阵$S$中较大的$k$个值来表示整个矩阵的信息,进而$A\approx U_{mk}S_{kk}V_{kn}$.在推荐系统中这里的$A$就可以是用户-物品矩阵,分解之后$U_{mk}$和$V_{kn}$可以分别表示用户矩阵和商品矩阵,那么中间还剩下一个$S_{k*k}$,其实这个矩阵是可以直接合并到用户矩阵或者商品矩阵中去的.所以我们可以认为最终的用户-物品矩阵就分解成了两个矩阵,分别是用户矩阵和物品矩阵.

但是直接使用上述奇异值分解的方式应用与推荐系统仍然会存在一些问题

  1. 奇异值分解要求被分解的矩阵是稠密的,但是推荐系统场景下大部分场景都是极度稀疏的
  2. 奇异值分解的复杂度达到了$O(mn^2)$,当用户或者物品数量非常大的时候,工程上是无法接收的.

针对上述的两个问题,便引入了使用梯度下降的方法来进行矩阵分解,使用梯度下降进行矩阵分解的目标函数

下面是代码实践(下列代码没有使用正则化)

消除用户和物品的打分偏差的矩阵分解模型的损失函数及实现.

导包

import pandas as pd 
import os, gc, warnings
import tensorflow.keras as keras
from tensorflow.keras.layers import *
from tensorflow.keras.models import *
import tensorflow.keras.backend as K
from sklearn.model_selection import train_test_split
from tensorflow.keras.callbacks import *
from tqdm import tqdm
warnings.filterwarnings('ignore')

读取数据

def get_data(fold=1):
    root_path = './data/ml-1m/'
    rnames = ['user_id','movie_id','rating','timestamp']
    ratings = pd.read_csv(os.path.join(root_path, 'ratings.dat'), sep='::', engine='python', names=rnames)
    ratings['movie_id'] = ratings['movie_id'].astype(str)

    trn_data, val_data, _, _ = train_test_split(ratings, ratings, test_size=0.2)

    # 训练用户向量和商品向量所要用到的
    users = trn_data['user_id'].unique()
    movies = trn_data['movie_id'].unique()

    user_to_idx = {}
    for i, u in enumerate(users):
        user_to_idx[u] = i

    movies_to_idx = {}
    for i, m in enumerate(movies):
        movies_to_idx[m] = i

    train_data = trn_data[['user_id', 'movie_id', 'rating']]
    train_data['user_id'] = train_data['user_id'].map(user_to_idx)
    train_data['movie_id'] = train_data['movie_id'].map(movies_to_idx)

    # 验证推荐效果
    trn_data = trn_data.groupby('user_id')['movie_id'].apply(lambda x: ' '.join(list(x))).reset_index()
    val_data = val_data.groupby('user_id')['movie_id'].apply(lambda x: ' '.join(list(x))).reset_index()

    trn_user_items = {}
    val_user_items = {}

    for user, movies in zip(*(list(trn_data['user_id']), list(trn_data['movie_id']))):
        trn_user_items[user] = set(movies.split(' '))

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

    return trn_user_items, val_user_items, user_to_idx, movies_to_idx, train_data

定义模型

def MF(user_to_idx, movies_to_idx, dims=32):
    K.clear_session()
    input_users = Input(shape=[None, ])
    users_emb = Embedding(len(user_to_idx), dims)(input_users)

    input_movies = Input(shape=[None, ])
    movies_emb = Embedding(len(movies_to_idx), dims)(input_movies)

    users = BatchNormalization()(users_emb)
    users = Reshape((dims, ))(users)

    movies = BatchNormalization()(movies_emb)
    movies = Reshape((dims, ))(movies)

    output = Dot(1)([users, movies])
    model = Model(inputs=[input_users, input_movies], outputs=output)
    model.compile(loss='mse', optimizer='adam')
    model.summary()
    return model

模型训练

trn_user_items, val_user_items, user_to_idx, movies_to_idx, train_data = get_data()

# 定义模型训练的时的一些参数
filepath = 'MF.h5'
checkpoint = ModelCheckpoint(filepath, monitor='val_loss', verbose=1, save_best_only=True, 
                             mode='min', save_weights_only=True)
reduce_lr = ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=3, min_lr=0.0001, verbose=1)
earlystopping = EarlyStopping(monitor='val_loss', min_delta=0.0001, patience=5, verbose=1, mode='min')
callbacks = [checkpoint, reduce_lr, earlystopping]

model = MF(user_to_idx, movies_to_idx)

print('nums of users: ', len(user_to_idx))
print('nums of movies: ', len(movies_to_idx))

# 模型训练
hist = model.fit([train_data['user_id'].values, train_data['movie_id'].values], train_data['rating'], 
                 batch_size=256, epochs=5, validation_split=0.2,callbacks=callbacks, verbose=1, shuffle=True)

电影推荐

N = 10 # 推荐评分最高的10个电影
model.load_weights(filepath) # 加载刚才训练好的用户和电影向量
items_rank = {}
for u, _ in tqdm(val_user_items.items()): # 遍历验证集上所有的用户
    items_rank[u] = {}
    visited_movies = trn_user_items[u] # 训练集中该用户看过的电影

    for m, _ in movies_to_idx.items(): # 遍历所有电影
        if m not in visited_movies: # 推荐用户之前没有看过的电影
            if m not in items_rank[u]:
                items_rank[u][m] = 0.0
            items_rank[u][m] = float(model.predict([[user_to_idx[u]], [movies_to_idx[m]]]))

# 根据评分的高低返回TopN个推荐结果
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()} # 将输出整合成合适的格式输出

模型测试

rec_eval(items_rank, val_user_items, trn_user_items)