推荐系统学习笔记之算法

477 阅读32分钟

「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

一.基于模型的协同过滤

Model-Based协同过滤算法

以下对Model-Based CF算法做一个大致的分类:

  • 基于分类算法、回归算法、聚类算法
  • 基于矩阵分解的推荐
  • 基于神经网络算法
  • 基于图模型算法

二.基于回归模型的协同过滤

如果我们将评分看作是一个连续的值而不是离散的值,那么就可以借助线性回归思想来预测目标用户对某物品的评分。其中一种实现策略被称为Baseline(基准预测)。

2.1 Baseline:基准预测

2.1.1Baseline设计思想

  • 有些用户的评分普遍高于其他用户,有些用户的评分普遍低于其他用户。比如有些用户天生愿意给别人好评,心慈手软,比较好说话,而有的人就比较苛刻,总是评分不超过3分(5分满分)
  • 一些物品的评分普遍高于其他物品,一些物品的评分普遍低于其他物品。比如一些物品一被生产便决定了它的地位,有的比较受人们欢迎,有的则被人嫌弃。

这个用户或物品普遍高于或低于平均值的差值,我们称为偏置(bias)

Baseline目标

  • 找出每个用户普遍高于或低于他人的偏置值bub_u
  • 找出每件物品普遍高于或低于其他物品的偏置值bib_i
  • 我们的目标也就转化为寻找最优的bub_ubib_i

2.1.2 Baseline算法预测步骤

以电影评分为例:

  • 计算所有电影的平均评分μ\mu(即全局平均评分)
  • 计算每个用户评分与平均评分μ\mu偏置值bub_u
  • 计算每部电影所接受的评分与平均评分μ\mu偏置值bib_i
  • 预测用户对电影的评分:rui^=bui=μ+bu+bi\hat{r_{ui}}=b_{ui}=\mu+b_u+b_i
  • 举例:通过Baseline来预测用户A对电影“阿甘正传”的评分
    • 首先计算出整个评分数据集的平均评分μ是3.5分
    • 用户A比较苛刻,普遍比平均评分低0.5分,即用户A的偏置值bi是-0.5;
    • “阿甘正传”比较热门且备受好评,评分普遍比平均评分要高1.2分,“阿甘正传”的偏置是+1.2
    • 因此就可以预测出用户A对电影“阿甘正传”的评分为:3.5+(−0.5)+1.2,也就是4.2分。

​ 对于所有电影的平均评分是直接能计算出的,因此问题在于要测出每个用户的评分偏置和每部电影的得分偏置。对于线性回归问题,我们可以利用平方差构建损失函数如下:

Cost=u,iR(ruirui^)2=u,iR(ruiμbubi)2Cost=\sum_{u,i\in R}(r_{ui}-\hat{r_{ui}})^2=\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i)^2

加入L2正则化:

Cost=u,iR(ruiμbubi)2+λ(ubu2+ibi2)Cost=\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i)^2+\lambda*(\sum_ub_u^2+\sum_ib_i^2)

公式解析:

  • 公式第一部分u,iR(ruiμbubi)2\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i)^2是用来寻找与已知评分数据拟合最好的bub_ubib_i

  • 公式第二部分λ(ubu2+ibi2)\lambda*(\sum_ub_u^2+\sum_ib_i^2)是正则化项,用于避免过拟合现象

对于最小过程的求解,我们一般采用随机梯度下降法或者交替最小二乘法来优化实现。

2.2随机梯度下降法优化

使用随机梯度下降优化算法预测Baseline偏置值

step1梯度下降法推导

J(θ)=Cost=f(bu,bi)J(\theta)=Cost=f(b_u,b_i)

损失函数:(λ\lambda为正则化系数)

J(θ)=u,iR(ruiμbubi)2+λ(ubu2+ibi2)J(\theta)=\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i)^2+\lambda*(\sum_ub_u^2+\sum_ib_i^2)

梯度下降参数更新原始公式:(公式中α\alpha为学习率)

θj:=θjαθjJ(θ)\theta_j:=\theta_j-\alpha\frac{\partial}{\partial\theta_j}J(\theta):

分别对bub_ubib_i求偏导:

buJ(θ)=buf(bu,bi)=2u,iR(ruiμbubi)(1)+2λbu=2u,iR(ruiμbubi)+2λbu\frac{\partial}{\partial b_u} J(\theta)=\frac{\partial}{\partial b_u} f(b_u, b_i) =2\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i)(-1) + 2\lambda{b_u} =-2\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i) + 2\lambda*b_u

代入梯度下降公式:

bu:=buα(u,iR(ruiμbubi)+λbu)=bu+α(u,iR(ruiμbubi)λbu)b_u:=b_u - \alpha*(-\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i) + \lambda * b_u)=b_u + \alpha*(\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i) - \lambda* b_u)

同理可得,梯度下降更新bib_i

bi:=bi+α(u,iR(ruiμbubi)λbi)b_i:=b_i+\alpha*(\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i)-\lambda*b_i)

step2随机梯度下降

由于随机梯度下降法本质上利用每个样本的损失来更新参数,而不用每次求出全部的损失和,因此使用SGD时:

error=ruirui^=rui(μ+bu+bi)=ruiμbubierror=r_{ui}-\hat{r_{ui}}=r_{ui-(\mu+b_u+b_i)}=r_{ui}-\mu-b_u-b_i

单样本损失值参数更新:

bu:=bu+α((ruiμbubi)λbu)=bu+α(errorλbu)bi:=bi+α((ruiμbubi)λbi):=bi+α(errorλbi)b_u:=b_u + \alpha*((r_{ui}-\mu-b_u-b_i) -\lambda*b_u) =b_u + \alpha*(error - \lambda*b_u)b_i:=b_i + \alpha*((r_{ui}-\mu-b_u-b_i) -\lambda*b_i):=b_i + \alpha*(error -\lambda*b_i)

step3算法实现

  • pandas版本不要过低:0.24.2

  • 数据加载

    import pandas as pd
    import numpy as np
    dtype = [("userId", np.int32), ("movieId", np.int32), ("rating", np.float32)]
    dataset = pd.read_csv("ml-latest-small/ratings.csv", usecols=range(3), dtype=dict(dtype))
    
  • 数据初始化

    # 用户评分数据  groupby 分组  groupby('userId') 根据用户id分组 agg(aggregation聚合)
    users_ratings = dataset.groupby('userId').agg([list])
    # 物品评分数据
    items_ratings = dataset.groupby('movieId').agg([list])
    # 计算全局平均分
    global_mean = dataset['rating'].mean()
    # 初始化
    bu = dict(zip(users_ratings.index, np.zeros(len(users_ratings))))
    bi = dict(zip(items_ratings.index, np.zeros(len(items_ratings))))
    
  • 关于zip

    • zip()函数用于将可迭代的对象作为参数,将对象中对应的元素打包成一个个元组,然后返回由这些元组组成的对象,这样做的好处是节约了不少内存。我们可以使用list()转换来输出列表。如果各个迭代器的元素个数 不一致,则返回列表长度与最短的对象相同,利用*号操作符,可以将元组解压成列表

    • 语法:zip([iterable, ...])

  • 更新bub_ubib_i

    #number_epochs 迭代次数 alpha学习率  reg 正则化系数
    for i in range(number_epochs):
        print("iter%d" % i)
        for uid, iid, real_rating in dataset.itertuples(index=False):
            error = real_rating - (global_mean + bu[uid] + bi[iid])
            bu[uid] += alpha * (error - reg * bu[uid])
            bi[iid] += alpha * (error - reg * bi[iid])
    
  • 预测评分

    def predict(uid, iid):
        predict_rating = global_mean + bu[uid] + bi[iid]
        return predict_rating
    
  • 整体封装

    import pandas as pd
    import numpy as np
    
    
    class BaselineCFBySGD(object):
    
        def __init__(self, number_epochs, alpha, reg, columns=["uid", "iid", "rating"]):
            # 梯度下降最高迭代次数
            self.number_epochs = number_epochs
            # 学习率
            self.alpha = alpha
            # 正则参数
            self.reg = reg
            # 数据集中user-item-rating字段的名称
            self.columns = columns
    
        def fit(self, dataset):
            '''
            :param dataset: uid, iid, rating
            :return:
            '''
            self.dataset = dataset
            # 用户评分数据
            self.users_ratings = dataset.groupby(self.columns[0]).agg([list])[[self.columns[1], self.columns[2]]]
            # 物品评分数据
            self.items_ratings = dataset.groupby(self.columns[1]).agg([list])[[self.columns[0], self.columns[2]]]
            # 计算全局平均分
            self.global_mean = self.dataset[self.columns[2]].mean()
            # 调用sgd方法训练模型参数
            self.bu, self.bi = self.sgd()
    
        def sgd(self):
            '''
            利用随机梯度下降,优化bu,bi的值
            :return: bu, bi
            '''
            # 初始化bu、bi的值,全部设为0
            bu = dict(zip(self.users_ratings.index, np.zeros(len(self.users_ratings))))
            bi = dict(zip(self.items_ratings.index, np.zeros(len(self.items_ratings))))
    
            for i in range(self.number_epochs):
                print("iter%d" % i)
                for uid, iid, real_rating in self.dataset.itertuples(index=False):
                    error = real_rating - (self.global_mean + bu[uid] + bi[iid])
    
                    bu[uid] += self.alpha * (error - self.reg * bu[uid])
                    bi[iid] += self.alpha * (error - self.reg * bi[iid])
    
            return bu, bi
    
        def predict(self, uid, iid):
            predict_rating = self.global_mean + self.bu[uid] + self.bi[iid]
            return predict_rating
    
    
    if __name__ == '__main__':
        dtype = [("userId", np.int32), ("movieId", np.int32), ("rating", np.float32)]
        dataset = pd.read_csv("datasets/ml-latest-small/ratings.csv", usecols=range(3), dtype=dict(dtype))
    
        bcf = BaselineCFBySGD(20, 0.1, 0.1, ["userId", "movieId", "rating"])
        bcf.fit(dataset)
    
        while True:
            uid = int(input("uid: "))
            iid = int(input("iid: "))
            print(bcf.predict(uid, iid))
    

step4准确性指标评估

  • 添加test方法,然后使用之前实现的accuracy方法计算准确性指标

    import pandas as pd
    import numpy as np
    
    def data_split(data_path, x=0.8, random=False):
        '''
        切分数据集, 这里为了保证用户数量保持不变,将每个用户的评分数据按比例进行拆分
        :param data_path: 数据集路径
        :param x: 训练集的比例,如x=0.8,则0.2是测试集
        :param random: 是否随机切分,默认False
        :return: 用户-物品评分矩阵
        '''
        print("开始切分数据集...")
        # 设置要加载的数据字段的类型
        dtype = {"userId": np.int32, "movieId": np.int32, "rating": np.float32}
        # 加载数据,我们只用前三列数据,分别是用户ID,电影ID,已经用户对电影的对应评分
        ratings = pd.read_csv(data_path, dtype=dtype, usecols=range(3))
    
        testset_index = []
        # 为了保证每个用户在测试集和训练集都有数据,因此按userId聚合
        for uid in ratings.groupby("userId").any().index:
            user_rating_data = ratings.where(ratings["userId"]==uid).dropna()
            if random:
                # 因为不可变类型不能被 shuffle方法作用,所以需要强行转换为列表
                index = list(user_rating_data.index)
                np.random.shuffle(index)    # 打乱列表
                _index = round(len(user_rating_data) * x)
                testset_index += list(index[_index:])
            else:
                # 将每个用户的x比例的数据作为训练集,剩余的作为测试集
                index = round(len(user_rating_data) * x)
                testset_index += list(user_rating_data.index.values[index:])
    
        testset = ratings.loc[testset_index]
        trainset = ratings.drop(testset_index)
        print("完成数据集切分...")
        return trainset, testset
    
    def accuray(predict_results, method="all"):
        '''
        准确性指标计算方法
        :param predict_results: 预测结果,类型为容器,每个元素是一个包含uid,iid,real_rating,pred_rating的序列
        :param method: 指标方法,类型为字符串,rmse或mae,否则返回两者rmse和mae
        :return:
        '''
    
        def rmse(predict_results):
            '''
            rmse评估指标
            :param predict_results:
            :return: rmse
            '''
            length = 0
            _rmse_sum = 0
            for uid, iid, real_rating, pred_rating in predict_results:
                length += 1
                _rmse_sum += (pred_rating - real_rating) ** 2
            return round(np.sqrt(_rmse_sum / length), 4)
    
        def mae(predict_results):
            '''
            mae评估指标
            :param predict_results:
            :return: mae
            '''
            length = 0
            _mae_sum = 0
            for uid, iid, real_rating, pred_rating in predict_results:
                length += 1
                _mae_sum += abs(pred_rating - real_rating)
            return round(_mae_sum / length, 4)
    
        def rmse_mae(predict_results):
            '''
            rmse和mae评估指标
            :param predict_results:
            :return: rmse, mae
            '''
            length = 0
            _rmse_sum = 0
            _mae_sum = 0
            for uid, iid, real_rating, pred_rating in predict_results:
                length += 1
                _rmse_sum += (pred_rating - real_rating) ** 2
                _mae_sum += abs(pred_rating - real_rating)
            return round(np.sqrt(_rmse_sum / length), 4), round(_mae_sum / length, 4)
    
        if method.lower() == "rmse":
            rmse(predict_results)
        elif method.lower() == "mae":
            mae(predict_results)
        else:
            return rmse_mae(predict_results)
    
    class BaselineCFBySGD(object):
    
        def __init__(self, number_epochs, alpha, reg, columns=["uid", "iid", "rating"]):
            # 梯度下降最高迭代次数
            self.number_epochs = number_epochs
            # 学习率
            self.alpha = alpha
            # 正则参数
            self.reg = reg
            # 数据集中user-item-rating字段的名称
            self.columns = columns
    
        def fit(self, dataset):
            '''
            :param dataset: uid, iid, rating
            :return:
            '''
            self.dataset = dataset
            # 用户评分数据
            self.users_ratings = dataset.groupby(self.columns[0]).agg([list])[[self.columns[1], self.columns[2]]]
            # 物品评分数据
            self.items_ratings = dataset.groupby(self.columns[1]).agg([list])[[self.columns[0], self.columns[2]]]
            # 计算全局平均分
            self.global_mean = self.dataset[self.columns[2]].mean()
            # 调用sgd方法训练模型参数
            self.bu, self.bi = self.sgd()
    
        def sgd(self):
            '''
            利用随机梯度下降,优化bu,bi的值
            :return: bu, bi
            '''
            # 初始化bu、bi的值,全部设为0
            bu = dict(zip(self.users_ratings.index, np.zeros(len(self.users_ratings))))
            bi = dict(zip(self.items_ratings.index, np.zeros(len(self.items_ratings))))
    
            for i in range(self.number_epochs):
                print("iter%d" % i)
                for uid, iid, real_rating in self.dataset.itertuples(index=False):
                    error = real_rating - (self.global_mean + bu[uid] + bi[iid])
    
                    bu[uid] += self.alpha * (error - self.reg * bu[uid])
                    bi[iid] += self.alpha * (error - self.reg * bi[iid])
    
            return bu, bi
    
        def predict(self, uid, iid):
            '''评分预测'''
            if iid not in self.items_ratings.index:
                raise Exception("无法预测用户<{uid}>对电影<{iid}>的评分,因为训练集中缺失<{iid}>的数据".format(uid=uid, iid=iid))
    
            predict_rating = self.global_mean + self.bu[uid] + self.bi[iid]
            return predict_rating
    
        def test(self,testset):
            '''预测测试集数据'''
            for uid, iid, real_rating in testset.itertuples(index=False):
                try:
                    pred_rating = self.predict(uid, iid)
                except Exception as e:
                    print(e)
                else:
                    yield uid, iid, real_rating, pred_rating
    
    if __name__ == '__main__':
    
        trainset, testset = data_split("datasets/ml-latest-small/ratings.csv", random=True)
    
        bcf = BaselineCFBySGD(20, 0.1, 0.1, ["userId", "movieId", "rating"])
        bcf.fit(trainset)
    
        pred_results = bcf.test(testset)
    
        rmse, mae = accuray(pred_results)
    
        print("rmse: ", rmse, "mae: ", mae)
    

2.3交替最小二乘法优化

使用交替最小二乘法优化算法预测Baseline偏置值

step1交替二乘法推导

最小二乘法和梯度下降法一样,可以用于求极值

最小二乘法思想对损失函数求偏导,然后再使偏导为0

同样,损失函数:

J(θ)=u,iR(ruiμbubi)2+λ(ubu2+ibi2)J(\theta)=\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i)^2+\lambda*(\sum_ub_u^2+\sum_ib_i^2)

经过交替最小二乘:

bu:=u,iR(ruiμbi)λ1+R(u)bu:=\frac{\sum_{u,i\in R}(r_{ui}-\mu-b_i)}{\lambda_1+|R(u)|}

其中R(u)|R(u)|表示用户uu的有过评分数量

同理可得:

bi:=u,iR(ruiμbi)λ2+R(i)b_i:=\frac{\sum_{u,i\in R}(r_{ui}-\mu-b_i)}{\lambda_2+|R(i)|}

bub_ubib_i分别属于用户和物品的偏置,因此他们的正则参数可以分别设置两个独立的参数。

step2交替最小二乘法应用

通过最小二乘推导,我们最终分别得到了bub_ubib_i的表达式,但他们的表达式中却又各自包含对方,因此这里我们将利用一种叫交替最小二乘的方法来计算他们的值:

  • 计算其中一项,先固定其他未知参数,即看作其他未知参数为已知
  • 如求bub_u时,将bib_i看作是已知;求bib_i时,将bub_u看作是已知;如此反复交替,不断更新二者的值,求得最终的结果。这就是交替最小二乘法(ALS)

step3算法实现

  • 迭代更新bu bi

    for i in range(number_epochs):
        print("iter%d" % i)
        for iid, uids, ratings in items_ratings.itertuples(index=True):
            _sum = 0
            for uid, rating in zip(uids, ratings):
                _sum += rating - global_mean - bu[uid]
            bi[iid] = _sum / (reg_bi + len(uids))
    
        for uid, iids, ratings in users_ratings.itertuples(index=True):
            _sum = 0
            for iid, rating in zip(iids, ratings):
                _sum += rating - global_mean - bi[iid]
            bu[uid] = _sum / (reg_bu + len(iids))
    
  • 封装

    import pandas as pd
    import numpy as np
    
    
    class BaselineCFByALS(object):
    
        def __init__(self, number_epochs, reg_bu, reg_bi, columns=["uid", "iid", "rating"]):
            # 梯度下降最高迭代次数
            self.number_epochs = number_epochs
            # bu的正则参数
            self.reg_bu = reg_bu
            # bi的正则参数
            self.reg_bi = reg_bi
            # 数据集中user-item-rating字段的名称
            self.columns = columns
    
        def fit(self, dataset):
            '''
            :param dataset: uid, iid, rating
            :return:
            '''
            self.dataset = dataset
            # 用户评分数据
            self.users_ratings = dataset.groupby(self.columns[0]).agg([list])[[self.columns[1], self.columns[2]]]
            # 物品评分数据
            self.items_ratings = dataset.groupby(self.columns[1]).agg([list])[[self.columns[0], self.columns[2]]]
            # 计算全局平均分
            self.global_mean = self.dataset[self.columns[2]].mean()
            # 调用sgd方法训练模型参数
            self.bu, self.bi = self.als()
    
        def als(self):
            '''
            利用随机梯度下降,优化bu,bi的值
            :return: bu, bi
            '''
            # 初始化bu、bi的值,全部设为0
            bu = dict(zip(self.users_ratings.index, np.zeros(len(self.users_ratings))))
            bi = dict(zip(self.items_ratings.index, np.zeros(len(self.items_ratings))))
    
            for i in range(self.number_epochs):
                print("iter%d" % i)
                for iid, uids, ratings in self.items_ratings.itertuples(index=True):
                    _sum = 0
                    for uid, rating in zip(uids, ratings):
                        _sum += rating - self.global_mean - bu[uid]
                    bi[iid] = _sum / (self.reg_bi + len(uids))
    
                for uid, iids, ratings in self.users_ratings.itertuples(index=True):
                    _sum = 0
                    for iid, rating in zip(iids, ratings):
                        _sum += rating - self.global_mean - bi[iid]
                    bu[uid] = _sum / (self.reg_bu + len(iids))
            return bu, bi
    
        def predict(self, uid, iid):
            predict_rating = self.global_mean + self.bu[uid] + self.bi[iid]
            return predict_rating
    
    
    if __name__ == '__main__':
        dtype = [("userId", np.int32), ("movieId", np.int32), ("rating", np.float32)]
        dataset = pd.read_csv("datasets/ml-latest-small/ratings.csv", usecols=range(3), dtype=dict(dtype))
    
        bcf = BaselineCFByALS(20, 25, 15, ["userId", "movieId", "rating"])
        bcf.fit(dataset)
    
        while True:
            uid = int(input("uid: "))
            iid = int(input("iid: "))
            print(bcf.predict(uid, iid))
    

step4准确性指标评估

import pandas as pd
import numpy as np

def data_split(data_path, x=0.8, random=False):
    '''
    切分数据集, 这里为了保证用户数量保持不变,将每个用户的评分数据按比例进行拆分
    :param data_path: 数据集路径
    :param x: 训练集的比例,如x=0.8,则0.2是测试集
    :param random: 是否随机切分,默认False
    :return: 用户-物品评分矩阵
    '''
    print("开始切分数据集...")
    # 设置要加载的数据字段的类型
    dtype = {"userId": np.int32, "movieId": np.int32, "rating": np.float32}
    # 加载数据,我们只用前三列数据,分别是用户ID,电影ID,已经用户对电影的对应评分
    ratings = pd.read_csv(data_path, dtype=dtype, usecols=range(3))

    testset_index = []
    # 为了保证每个用户在测试集和训练集都有数据,因此按userId聚合
    for uid in ratings.groupby("userId").any().index:
        user_rating_data = ratings.where(ratings["userId"]==uid).dropna()
        if random:
            # 因为不可变类型不能被 shuffle方法作用,所以需要强行转换为列表
            index = list(user_rating_data.index)
            np.random.shuffle(index)    # 打乱列表
            _index = round(len(user_rating_data) * x)
            testset_index += list(index[_index:])
        else:
            # 将每个用户的x比例的数据作为训练集,剩余的作为测试集
            index = round(len(user_rating_data) * x)
            testset_index += list(user_rating_data.index.values[index:])

    testset = ratings.loc[testset_index]
    trainset = ratings.drop(testset_index)
    print("完成数据集切分...")
    return trainset, testset

def accuray(predict_results, method="all"):
    '''
    准确性指标计算方法
    :param predict_results: 预测结果,类型为容器,每个元素是一个包含uid,iid,real_rating,pred_rating的序列
    :param method: 指标方法,类型为字符串,rmse或mae,否则返回两者rmse和mae
    :return:
    '''

    def rmse(predict_results):
        '''
        rmse评估指标
        :param predict_results:
        :return: rmse
        '''
        length = 0
        _rmse_sum = 0
        for uid, iid, real_rating, pred_rating in predict_results:
            length += 1
            _rmse_sum += (pred_rating - real_rating) ** 2
        return round(np.sqrt(_rmse_sum / length), 4)

    def mae(predict_results):
        '''
        mae评估指标
        :param predict_results:
        :return: mae
        '''
        length = 0
        _mae_sum = 0
        for uid, iid, real_rating, pred_rating in predict_results:
            length += 1
            _mae_sum += abs(pred_rating - real_rating)
        return round(_mae_sum / length, 4)

    def rmse_mae(predict_results):
        '''
        rmse和mae评估指标
        :param predict_results:
        :return: rmse, mae
        '''
        length = 0
        _rmse_sum = 0
        _mae_sum = 0
        for uid, iid, real_rating, pred_rating in predict_results:
            length += 1
            _rmse_sum += (pred_rating - real_rating) ** 2
            _mae_sum += abs(pred_rating - real_rating)
        return round(np.sqrt(_rmse_sum / length), 4), round(_mae_sum / length, 4)

    if method.lower() == "rmse":
        rmse(predict_results)
    elif method.lower() == "mae":
        mae(predict_results)
    else:
        return rmse_mae(predict_results)

class BaselineCFByALS(object):

    def __init__(self, number_epochs, reg_bu, reg_bi, columns=["uid", "iid", "rating"]):
        # 梯度下降最高迭代次数
        self.number_epochs = number_epochs
        # bu的正则参数
        self.reg_bu = reg_bu
        # bi的正则参数
        self.reg_bi = reg_bi
        # 数据集中user-item-rating字段的名称
        self.columns = columns

    def fit(self, dataset):
        '''
        :param dataset: uid, iid, rating
        :return:
        '''
        self.dataset = dataset
        # 用户评分数据
        self.users_ratings = dataset.groupby(self.columns[0]).agg([list])[[self.columns[1], self.columns[2]]]
        # 物品评分数据
        self.items_ratings = dataset.groupby(self.columns[1]).agg([list])[[self.columns[0], self.columns[2]]]
        # 计算全局平均分
        self.global_mean = self.dataset[self.columns[2]].mean()
        # 调用sgd方法训练模型参数
        self.bu, self.bi = self.als()

    def als(self):
        '''
        利用随机梯度下降,优化bu,bi的值
        :return: bu, bi
        '''
        # 初始化bu、bi的值,全部设为0
        bu = dict(zip(self.users_ratings.index, np.zeros(len(self.users_ratings))))
        bi = dict(zip(self.items_ratings.index, np.zeros(len(self.items_ratings))))

        for i in range(self.number_epochs):
            print("iter%d" % i)
            for iid, uids, ratings in self.items_ratings.itertuples(index=True):
                _sum = 0
                for uid, rating in zip(uids, ratings):
                    _sum += rating - self.global_mean - bu[uid]
                bi[iid] = _sum / (self.reg_bi + len(uids))

            for uid, iids, ratings in self.users_ratings.itertuples(index=True):
                _sum = 0
                for iid, rating in zip(iids, ratings):
                    _sum += rating - self.global_mean - bi[iid]
                bu[uid] = _sum / (self.reg_bu + len(iids))
        return bu, bi

    def predict(self, uid, iid):
        '''评分预测'''
        if iid not in self.items_ratings.index:
            raise Exception("无法预测用户<{uid}>对电影<{iid}>的评分,因为训练集中缺失<{iid}>的数据".format(uid=uid, iid=iid))

        predict_rating = self.global_mean + self.bu[uid] + self.bi[iid]
        return predict_rating

    def test(self,testset):
        '''预测测试集数据'''
        for uid, iid, real_rating in testset.itertuples(index=False):
            try:
                pred_rating = self.predict(uid, iid)
            except Exception as e:
                print(e)
            else:
                yield uid, iid, real_rating, pred_rating


if __name__ == '__main__':
    trainset, testset = data_split("datasets/ml-latest-small/ratings.csv", random=True)

    bcf = BaselineCFByALS(20, 25, 15, ["userId", "movieId", "rating"])
    bcf.fit(trainset)

    pred_results = bcf.test(testset)

    rmse, mae = accuray(pred_results)

    print("rmse: ", rmse, "mae: ", mae)

三.基于矩阵分解的CF算法

矩阵分解发展史

3.1 Traditional SVD

​ 通常SVD矩阵分解指的是SVD(奇异值)分解技术,在这我们姑且将其命名为Traditional SVD(传统并经典着)其公式如下:

Mm×n=Um×kk×kVk×nTM_{m\times n}=U_{m\times k}\sum_{k\times k}V_{k\times n}^T

​ Traditional SVD分解的形式为3个矩阵相乘,中间矩阵为奇异值矩阵。如果想运用SVD分解的话,有一个前提是要求矩阵是稠密的,即矩阵里的元素要非空,否则就不能运用SVD分解。

​ 很显然我们的数据其实绝大多数情况下都是稀疏的,因此如果要使用Traditional SVD,一般的做法是先用均值或者其他统计学方法来填充矩阵,然后再运用Traditional SVD分解降维,但这样做明显对数据的原始性造成一定影响。

3.2 FunkSVD(LFM)

​ 刚才提到的Traditional SVD首先需要填充矩阵,然后再进行分解降维,同时存在计算复杂度高的问题,因为要分解成3个矩阵,所以后来提出了Funk SVD的方法,它不在将矩阵分解为3个矩阵,而是分解为2个用户-隐含特征,项目-隐含特征的矩阵,Funk SVD也被称为最原始的LFM模型:

i,j(mijqjTpi)2\sum_{i,j}(m_{ij}-q_j^Tp_i)^2

​ 借鉴线性回归的思想,通过最小化观察数据的平方来寻求最优的用户和项目的隐含向量表示。同时为了避免过度拟合(Overfitting)观测数据,又提出了带有L2正则项的FunkSVD,上公式:

minq,p(i,i)K(ruiqiTpu)2+λ(qi2+pu2)min_{q*,p*}\sum_{(i,i)\in K}(r_{ui}-q_i^Tp_u)^2+\lambda(||q_i||^2+||p_u||^2)

​ 以上两种最优化函数都可以通过梯度下降或者随机梯度下降法来寻求最优解。

3.3 BiasSVD

​ 在FunkSVD提出来之后,出现了很多变形版本,其中一个相对成功的方法是BiasSVD,顾名思义,即带有偏置项的SVD分解:

arg minpi,qji,j(mijμbibjqjTpi)2+λ(pi22+qj22+bi22+bj22)\underbrace{arg\space min}_{p_i,q_j}\sum_{i,j}(m_{ij}-\mu-b_i-b_j-q_j^Tp_i)^2+\lambda(||p_i||_2^2+||q_j||_2^2+||b_i||_2^2+||b_j||_2^2)

​ 它基于的假设和Baseline基准预测是一样的,但这里将Baseline的偏置引入到了矩阵分解中。

3.4 SVD++

​ 人们后来又提出了改进的BiasSVD,被称为SVD++,该算法是在BiasSVD的基础上添加了用户的隐式反馈信息:

arg minpi,qji,j(mijμbibjqjTpiqjTN(i)12sN(i)ys)2+λ(pi22+qj22+bi22+bj22+sN(i)ys22)\underbrace{arg\space min}_{p_i,q_j}\sum_{i,j}(m_{ij}-\mu-b_i-b_j-q_j^Tp_i-q_j^T|N(i)|^{-\frac12}\sum_{s\in N(i)}y_s)^2+\lambda(||p_i||_2^2+||q_j||_2^2+||b_i||_2^2+||b_j||_2^2+\sum_{s\in N(i)}||y_s||_2^2)

​ 显式反馈指的用户的评分这样的行为,隐式反馈指用户的浏览记录、购买记录、收听记录等。

​ SVD++是基于这样的假设:在BiasSVD基础上,认为用户对于项目的历史浏览记录、购买记录、收听记录等可以从侧面反映用户的偏好。

四.基于矩阵分解的CF算法实现:LFM

LFM也就是前面提到的Funk SVD矩阵分解

4.1 LFM原理解析

​ LFM(latent factor model)隐语义模型核心思想是通过隐含特征联系用户和物品,如下图:

1.png

  • P矩阵是User-LF矩阵,即用户和隐含特征矩阵。LF有三个,表示共总有三个隐含特征。
  • Q矩阵是LF-Item矩阵,即隐含特征和物品的矩阵
  • R矩阵是User-Item矩阵,由P*Q得来
  • 能处理稀疏评分矩阵

​ 利用矩阵分解技术,将原始User-Item的评分矩阵(稠密/稀疏)分解为P和Q矩阵,然后利用P∗Q还原出User-Item评分矩阵R。整个过程相当于降维处理,其中:

  • 矩阵值P11表示用户1对隐含特征1的权重值
  • 矩阵值Q11表示隐含特征1在物品1上的权重值
  • 矩阵值R11就表示预测的用户1对物品1的评分,且R11=P1,kQk,1R_{11}=\vec{P_{1,k}}\cdot\vec{Q_{k,1}}

2.png

​ 利用LFM预测用户对物品的评分,kk表示隐含特征数量:

rui^=pukqik=k=1kpukqik\hat{r_{ui}}=\vec{p_{uk}}\cdot\vec{q_{ik}}=\sum_{k=1}^kp_{uk}q_{ik}

​ 因此最终,我们的目标也就是要求出P矩阵和Q矩阵及其当中的每一个值,然后再对用户-物品的评分进行预测。

4.2损失函数

​ 同样对于评分预测我们利用平方差来构建损失函数:

Cost=i,iR(ruirui^)2=u,iR(ruik=1kpukqik)2Cost=\sum_{i,i\in R}(r_{ui}-\hat{r_{ui}})^2=\sum_{u,i\in R}(r_{ui}-\sum_{k=1}^kp_{uk}q_{ik})^2

​ 加入L2正则化:

Cost=u,iR(ruik=1kpukqik)2+λ(Upuk2+Iqik2)Cost=\sum_{u,i\in R}(r_{ui}-\sum_{k=1}^kp_{uk}q_{ik})^2+\lambda(\sum_Up_{uk}^2+\sum_Iq_{ik}^2)

4.3随机梯度下降法优化

梯度下降更新参数pukp_{uk}qikq_{ik}:(α\alpha学习率,λ\lambda正则化系数)

puk:=puk+α[u,iR(ruik=1kpukqik)qikλpuk]qik:=qik+α[u,iR(ruik=1kpukqik)pukλqik]p_{uk}:=p_{uk}+\alpha[\sum_{u,i\in R}(r_{ui}-\sum_{k=1}^kp_{uk}q_{ik})q_{ik}-\lambda p_{uk}] q_{ik}:=q_{ik}+\alpha[\sum_{u,i\in R}(r_{ui}-\sum_{k=1}^kp_{uk}q_{ik})p_{uk}-\lambda q_{ik}]

梯度下降:向量乘法,每一个分量相乘,求和:

puk:=puk+α[(ruik=1kpukqik)qikλ1puk]qik:=qik+α[(ruik=1kpukqik)pukλ2qik]p_{uk}:=p_{uk}+\alpha[(r_{ui}-\sum_{k=1}^kp_{uk}q_{ik})q_{ik}-\lambda_1p_{uk}]q_{ik}:=q_{ik}+\alpha[(r_{ui}-\sum_{k=1}^kp_{uk}q_{ik})p_{uk}-\lambda_2q_{ik}]

4.4算法实现

  • 数据加载

    import pandas as pd
    import numpy as np
    dtype = [("userId", np.int32), ("movieId", np.int32), ("rating", np.float32)]
    dataset = pd.read_csv("ml-latest-small/ratings.csv", usecols=range(3), dtype=dict(dtype))
    
  • 数据初始化

    # 用户评分数据  groupby 分组  groupby('userId') 根据用户id分组 agg(aggregation聚合)
    users_ratings = dataset.groupby('userId').agg([list])
    # 物品评分数据
    items_ratings = dataset.groupby('movieId').agg([list])
    # 计算全局平均分
    global_mean = dataset['rating'].mean()
    # 初始化P Q  610  9700   K值  610*K    9700*K
    # User-LF  10 代表 隐含因子个数是10个
    P = dict(zip(users_ratings.index,np.random.rand(len(users_ratings),10).astype(np.float32)))
    # Item-LF
    Q = dict(zip(items_ratings.index,np.random.rand(len(items_ratings),10).astype(np.float32)
    ))
    
  • 梯度下降优化损失函数

    #梯度下降优化损失函数
    for i in range(15):
        print('*'*10,i)
        for uid,iid,real_rating in dataset.itertuples(index = False):
            #遍历 用户 物品的评分数据 通过用户的id 到用户矩阵中获取用户向量
            v_puk = P[uid]
            # 通过物品的uid 到物品矩阵里获取物品向量
            v_qik = Q[iid]
            #计算损失
            error = real_rating-np.dot(v_puk,v_qik)
            # 0.02学习率 0.01正则化系数
            v_puk += 0.02*(error*v_qik-0.01*v_puk)
            v_qik += 0.02*(error*v_puk-0.01*v_qik)
    
            P[uid] = v_puk
            Q[iid] = v_qik
    
  • 评分预测

    def predict(self, uid, iid):
        # 如果uid或iid不在,我们使用全剧平均分作为预测结果返回
        if uid not in self.users_ratings.index or iid not in self.items_ratings.index:
            return self.globalMean
        p_u = self.P[uid]
        q_i = self.Q[iid]
    
        return np.dot(p_u, q_i)
    
  • 封装

    # LFM Model
    
    import pandas as pd
    import numpy
    
    # 评分预测    1-5
    class LFM(object):
    
        def __init__(self, alpha, reg_p, reg_q, number_LatentFactors=10, number_epochs=10, columns=["uid", "iid", "rating"]):
            self.alpha = alpha # 学习率
            self.reg_p = reg_p    # P矩阵正则
            self.reg_q = reg_q    # Q矩阵正则
            self.number_LatentFactors = number_LatentFactors  # 隐式类别数量
            self.number_epochs = number_epochs    # 最大迭代次数
            self.columns = columns
    
        def fit(self, dataset):
            '''
            fit dataset
            :param dataset: uid, iid, rating
            :return:
            '''
    
            self.dataset = pd.DataFrame(dataset)
    
            self.users_ratings = dataset.groupby(self.columns[0]).agg([list])[[self.columns[1], self.columns[2]]]
            self.items_ratings = dataset.groupby(self.columns[1]).agg([list])[[self.columns[0], self.columns[2]]]
    
            self.globalMean = self.dataset[self.columns[2]].mean()
    
            self.P, self.Q = self.sgd()
    
        def _init_matrix(self):
            '''
            初始化P和Q矩阵,同时为设置0,1之间的随机值作为初始值
            :return:
            '''
            # User-LF
            P = dict(zip(
                self.users_ratings.index,
                np.random.rand(len(self.users_ratings), self.number_LatentFactors).astype(np.float32)
            ))
            # Item-LF
            Q = dict(zip(
                self.items_ratings.index,
                np.random.rand(len(self.items_ratings), self.number_LatentFactors).astype(np.float32)
            ))
            return P, Q
    
        def sgd(self):
            '''
            使用随机梯度下降,优化结果
            :return:
            '''
            P, Q = self._init_matrix()
    
            for i in range(self.number_epochs):
                print("iter%d"%i)
                error_list = []
                for uid, iid, r_ui in self.dataset.itertuples(index=False):
                    # User-LF P
                    ## Item-LF Q
                    v_pu = P[uid] #用户向量
                    v_qi = Q[iid] #物品向量
                    err = np.float32(r_ui - np.dot(v_pu, v_qi))
    
                    v_pu += self.alpha * (err * v_qi - self.reg_p * v_pu)
                    v_qi += self.alpha * (err * v_pu - self.reg_q * v_qi)
    
                    P[uid] = v_pu 
                    Q[iid] = v_qi
    
                    # for k in range(self.number_of_LatentFactors):
                    #     v_pu[k] += self.alpha*(err*v_qi[k] - self.reg_p*v_pu[k])
                    #     v_qi[k] += self.alpha*(err*v_pu[k] - self.reg_q*v_qi[k])
    
                    error_list.append(err ** 2)
                print(np.sqrt(np.mean(error_list)))
            return P, Q
    
        def predict(self, uid, iid):
            # 如果uid或iid不在,我们使用全剧平均分作为预测结果返回
            if uid not in self.users_ratings.index or iid not in self.items_ratings.index:
                return self.globalMean
    
            p_u = self.P[uid]
            q_i = self.Q[iid]
    
            return np.dot(p_u, q_i)
    
        def test(self,testset):
            '''预测测试集数据'''
            for uid, iid, real_rating in testset.itertuples(index=False):
                try:
                    pred_rating = self.predict(uid, iid)
                except Exception as e:
                    print(e)
                else:
                    yield uid, iid, real_rating, pred_rating
    
    if __name__ == '__main__':
        dtype = [("userId", np.int32), ("movieId", np.int32), ("rating", np.float32)]
        dataset = pd.read_csv("datasets/ml-latest-small/ratings.csv", usecols=range(3), dtype=dict(dtype))
    
        lfm = LFM(0.02, 0.01, 0.01, 10, 100, ["userId", "movieId", "rating"])
        lfm.fit(dataset)
    
        while True:
            uid = input("uid: ")
            iid = input("iid: ")
            print(lfm.predict(int(uid), int(iid)))
    

五.基于矩阵分解的CF算法实现:BiasSvd

BiasSvd其实就是前面提到的Funk SVD矩阵分解基础上加上偏置项

5.1 BiasSvd

利用BiasSvd预测用户对物品的评分,kk表示隐含特征数量:

rui^=μ+bu+bi+pukqki=μ+bu+bi+k=1kpukqik\hat{r_{ui}}=\mu+b_u+b_i+\vec{p_{uk}}\cdot\vec{q_{ki}}=\mu+b_u+b_i+\sum_{k=1}^kp_{uk}q_{ik}

5.2损失函数

同样对于评分预测我们利用平方差来构建损失函数

Cost=u,iR(ruirui^)2=u,iR(ruiμbubik=1kpukqik)2Cost=\sum_{u,i\in R}(r_{ui}-\hat{r_{ui}})^2=\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})^2

加入L2正则化:

Cost=u,iR(ruiμbubik=1kpukqik)2+λ(Ubu2+Ibi2+Upuk2+Iqik2)Cost=\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})^2+\lambda(\sum_Ub_u^2+\sum_Ib_i^2+\sum_Up_{uk}^2+\sum_Iq_{ik}^2)

5.3随机梯度下降法优化

梯度下降更新参数pukp_{uk}:

puk:=puk+α[u,iR(ruiμbubik=1kpukqik)qikλpuk]p_{uk}:=p_{uk}+\alpha[\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})q_{ik}-\lambda p_{uk}]

同理:

qik:=qik+α[u,iR(ruiμbubik=1kpukqik)pukλqik]bu:=bu+α[u,iR(ruiμbubik=1kpukqik)λbu]bi:=bi+α[u,iR(ruiμbubik=1kpukqik)λbi]q_{ik}:=q_{ik}+\alpha[\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})p_{uk}-\lambda q_{ik}] b_u:=b_u+\alpha[\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})-\lambda b_u]\\ b_i:=b_i+\alpha[\sum_{u,i\in R}(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})-\lambda b_i]

随机梯度下降:

puk:=puk+α[(ruiμbubik=1kpukqik)qikλ1puk]qik:=qik+α[(ruiμbubik=1kpukqik)pukλ2qik]bu:=bu+α[(ruiμbubik=1kpukqik)λ3bu]bi:=bi+α[(ruiμbubik=1kpukqik)λ4bi]p_{uk}:=p_{uk}+\alpha[(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})q_{ik}-\lambda_1p_{uk}] q_{ik}:=q_{ik}+\alpha[(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})p_{uk}-\lambda_2q_{ik}] b_u:=b_u+\alpha[(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})-\lambda_3b_u] b_i:=b_i+\alpha[(r_{ui}-\mu-b_u-b_i-\sum_{k=1}^kp_{uk}q_{ik})-\lambda_4b_i]

5.4算法实现

'''
BiasSvd Model
'''
import math
import random
import pandas as pd
import numpy as np

class BiasSvd(object):

    def __init__(self, alpha, reg_p, reg_q, reg_bu, reg_bi, number_LatentFactors=10, number_epochs=10, columns=["uid", "iid", "rating"]):
        self.alpha = alpha # 学习率
        self.reg_p = reg_p
        self.reg_q = reg_q
        self.reg_bu = reg_bu
        self.reg_bi = reg_bi
        self.number_LatentFactors = number_LatentFactors  # 隐式类别数量
        self.number_epochs = number_epochs
        self.columns = columns

    def fit(self, dataset):
        '''
        fit dataset
        :param dataset: uid, iid, rating
        :return:
        '''

        self.dataset = pd.DataFrame(dataset)

        self.users_ratings = dataset.groupby(self.columns[0]).agg([list])[[self.columns[1], self.columns[2]]]
        self.items_ratings = dataset.groupby(self.columns[1]).agg([list])[[self.columns[0], self.columns[2]]]
        self.globalMean = self.dataset[self.columns[2]].mean()

        self.P, self.Q, self.bu, self.bi = self.sgd()

    def _init_matrix(self):
        '''
        初始化P和Q矩阵,同时为设置0,1之间的随机值作为初始值
        :return:
        '''
        # User-LF
        P = dict(zip(
            self.users_ratings.index,
            np.random.rand(len(self.users_ratings), self.number_LatentFactors).astype(np.float32)
        ))
        # Item-LF
        Q = dict(zip(
            self.items_ratings.index,
            np.random.rand(len(self.items_ratings), self.number_LatentFactors).astype(np.float32)
        ))
        return P, Q

    def sgd(self):
        '''
        使用随机梯度下降,优化结果
        :return:
        '''
        P, Q = self._init_matrix()

        # 初始化bu、bi的值,全部设为0
        bu = dict(zip(self.users_ratings.index, np.zeros(len(self.users_ratings))))
        bi = dict(zip(self.items_ratings.index, np.zeros(len(self.items_ratings))))

        for i in range(self.number_epochs):
            print("iter%d"%i)
            error_list = []
            for uid, iid, r_ui in self.dataset.itertuples(index=False):
                v_pu = P[uid]
                v_qi = Q[iid]
                err = np.float32(r_ui - self.globalMean - bu[uid] - bi[iid] - np.dot(v_pu, v_qi))

                v_pu += self.alpha * (err * v_qi - self.reg_p * v_pu)
                v_qi += self.alpha * (err * v_pu - self.reg_q * v_qi)

                P[uid] = v_pu 
                Q[iid] = v_qi

                bu[uid] += self.alpha * (err - self.reg_bu * bu[uid])
                bi[iid] += self.alpha * (err - self.reg_bi * bi[iid])

                error_list.append(err ** 2)
            print(np.sqrt(np.mean(error_list)))

        return P, Q, bu, bi

    def predict(self, uid, iid):

        if uid not in self.users_ratings.index or iid not in self.items_ratings.index:
            return self.globalMean

        p_u = self.P[uid]
        q_i = self.Q[iid]

        return self.globalMean + self.bu[uid] + self.bi[iid] + np.dot(p_u, q_i)


if __name__ == '__main__':
    dtype = [("userId", np.int32), ("movieId", np.int32), ("rating", np.float32)]
    dataset = pd.read_csv("datasets/ml-latest-small/ratings.csv", usecols=range(3), dtype=dict(dtype))

    bsvd = BiasSvd(0.02, 0.01, 0.01, 0.01, 0.01, 10, 20)
    bsvd.fit(dataset)

    while True:
        uid = input("uid: ")
        iid = input("iid: ")
        print(bsvd.predict(int(uid), int(iid)))

六.基于内容的推荐算法

6.1简介

​ 基于内容的推荐方法是非常直接的,它以物品的内容描述信息为依据来做出的推荐,本质上是基于对物品和用户自身的特征或属性的直接分析和计算。

​ 例如,假设已知电影A是一部喜剧,而恰巧我们得知某个用户喜欢看喜剧电影,那么我们基于这样的已知信息,就可以将电影A推荐给该用户。

6.2基于内容的推荐实现步骤

  • 画像构建:顾名思义,画像就是刻画物品或用户的特征。本质上就是给用户或物品贴标签

    • 物品画像:例如给电影《战狼2》贴标签,可以有哪些? 3.png

      "动作"、"吴京"、"吴刚"、"张翰"、"大陆电影"、"国产"、"爱国"、"军事"等等一系列标签都可以贴上

    • 用户画像:例如已知用户的观影历史是:"《战狼1》"、"《战狼2》"、"《建党伟业》"、"《建军大业》"、"《建国大业》"、"《红海行动》"、"《速度与激情1-8》"等,我们是不是就可以分析出该用户的一些兴趣特征如:"爱国"、"战争"、"赛车"、"动作"、"军事"、"吴京"、"韩三平"等标签。

  • 物品的标签来自哪里

    • PGC 物品画像--冷启动
      • 物品自带的属性(物品一产生就具备的):如电影的标题、导演、演员、类型等等
      • 服务提供方设定的属性(服务提供方为物品附加的属性):如短视频话题、微博话题(平台拟定)
      • 其他渠道:如爬虫
    • UGC 冷启动问题
      • 用户在享受服务过程中提供的物品的属性:如用户评论内容,微博话题(用户拟定)

根据PGC内容构建的物品画像的可以解决物品的冷启动问题

6.3基于内容推荐的算法流程

  • 根据PGC/UGC内容构建物品画像
  • 根据用户行为记录生成用户画像
  • 根据用户画像从物品中寻找最匹配的TOP-N物品进行推荐

6.4物品冷启动处理

  • 根据PGC内容构建物品画像
  • 利用物品画像计算物品间两两相似情况
  • 为每个物品产生TOP-N最相似的物品进行相关推荐:如与该商品相似的商品有哪些?与该文章相似文章有哪些?

七.基于内容的电影推荐:物品画像

7.1基于TF-IDF的特征提取技术

​ 前面提到,物品画像的特征标签主要都是指的如电影的导演、演员、图书的作者、出版社等结构话的数据,也就是他们的特征提取,尤其是体征向量的计算是比较简单的,如直接给作品的分类定义0或者1的状态。

​ 但另外一些特征,比如电影的内容简介、电影的影评、图书的摘要等文本数据,这些被称为非结构化数据,首先他们本应该也属于物品的一个特征标签,但是这样的特征标签进行量化时,也就是计算它的特征向量时是很难去定义的。

​ 因此这时就需要借助一些自然语言处理、信息检索等技术,将如用户的文本评论或其他文本内容信息的非结构化数据进行量化处理,从而实现更加完善的物品画像/用户画像。

​ TF-IDF算法便是其中一种在自然语言处理领域中应用比较广泛的一种算法。可用来提取目标文档中,并得到关键词用于计算对于目标文档的权重,并将这些权重组合到一起得到特征向量。

7.2算法原理

​ TF-IDF自然语言处理领域中计算文档中词或短语的权值的方法,是词频(Term Frequency,TF)和逆转文档频率(Inverse Document Frequency,IDF)的乘积。TF指的是某一个给定的词语在该文件中出现的次数。这个数字通常会被正规化,以防止它偏向长的文件(同一个词语在长文件里可能会比短文件有更高的词频,而不管该词语重要与否)。IDF是一个词语普遍重要性的度量,某一特定词语的IDF,可以由总文件数目除以包含该词语之文件的数目,再将得到的商取对数得到。

​ TF-IDF算法基于一个这样的假设:若一个词语在目标文档中出现的频率高而在其他文档中出现的频率低,那么这个词语就可以用来区分出目标文档。这个假设需要掌握的有两点:

  • 在本文档出现的频率高;
  • 在其他文档出现的频率低。

​ 因此,TF-IDF算法的计算可以分为词频(Term Frequency,TF)和逆转文档频率(Inverse Document Frequency,IDF)两部分,由TF和IDF的乘积来设置文档词语的权重。

​ TF指的是一个词语在文档中出现的频率。假设文档集包含的文档数为NN,文档集中包含关键词kik_i的文档数为nin_ifijf_{ij}表示关键词kik_i在文档djd_j中出现的次数,fdjf_{dj}表示文档djd_j中出现的词语总数,kik_i在文档djd_j中的词频TFijTF_{ij}定义为:TFij=fijfdjTF_{ij}=\frac{f_{ij}}{f_{dj}}。并且注意,这个数字通常会被正规化,以防止它偏向长的文件(指同一个词语在长文件里可能会比短文件有更高的词频,而不管该词语重要与否)。

​ IDF是一个词语普遍重要性的度量。表示某一词语在整个文档集中出现的频率,由它计算的结果取对数得到关键词kik_i的逆文档频率IDFi:IDFi=logNniIDF_i:IDF_i=log\frac{N}{n_i}

​ 由TF和IDF计算词语的权重为:wij=TFijIDFi=fijfdjlogNniw_{ij}=TF_{ij}\cdot IDF_i=\frac{f_{ij}}{f_{dj}}\cdot log\frac{N}{n_i}

结论:TF-IDF与词语在文档中的出现次数成正比,与该词在整个文档集中的出现次数成反比。

用途:在目标文档中,提取关键词(特征标签)的方法就是将该文档所有词语的TF-IDF计算出来并进行对比,取其中TF-IDF值最大的k个数组成目标文档的特征向量用以表示文档。

​ 注意:文档中存在的停用词(Stop Words),如“是”、“的”之类的,对于文档的中心思想表达没有意义的词,在分词时需要先过滤掉再计算其他词语的TF-IDF值。

7.3算法举例

​ 对于计算影评的TF-IDF,以电影“加勒比海盗:黑珍珠号的诅咒”为例,假设它总共有1000篇影评,其中一篇影评的总词语数为200,其中出现最频繁的词语为“海盗”、“船长”、“自由”,分别是20、15、10次,并且这3个词在所有影评中被提及的次数分别为1000、500、100,就这3个词语作为关键词的顺序计算如下。

  1. 将影评中出现的停用词过滤掉,计算其他词语的词频。以出现最多的三个词为例进行计算如下:
    • “海盗”出现的词频为20/200=0.1
    • “船长”出现的词频为15/200=0.075
    • “自由”出现的词频为10/200=0.05;
  2. 计算词语的逆文档频率如下:
    • “海盗”的IDF为:log(1000/1000)=0
    • “船长”的IDF为:log(1000/500)=0.3 “自由”的IDF为:log(1000/100)=1
  3. 由1和2计算的结果求出词语的TF-IDF结果,“海盗”为0,“船长”为0.0225,“自由”为0.05。

​ 通过对比可得,该篇影评的关键词排序应为:“自由”、“船长”、“海盗”。把这些词语的TF-IDF值作为它们的权重按照对应的顺序依次排列,就得到这篇影评的特征向量,我们就用这个向量来代表这篇影评,向量中每一个维度的分量大小对应这个属性的重要性。

​ 将总的影评集中所有的影评向量与特定的系数相乘求和,得到这部电影的综合影评向量,与电影的基本属性结合构建视频的物品画像,同理构建用户画像,可采用多种方法计算物品画像和用户画像之间的相似度,为用户做出推荐。

7.4加载数据集

import pandas as pd
import numpy as np
'''
- 利用tags.csv中每部电影的标签作为电影的候选关键词
- 利用TF·IDF计算每部电影的标签的tfidf值,选取TOP-N个关键词作为电影画像标签
- 并将电影的分类词直接作为每部电影的画像标签
'''

def get_movie_dataset():
    # 加载基于所有电影的标签
    # all-tags.csv来自ml-latest数据集中
    # 由于ml-latest-small中标签数据太多,因此借助其来扩充
    _tags = pd.read_csv("datasets/ml-latest-small/all-tags.csv", usecols=range(1, 3)).dropna()
    tags = _tags.groupby("movieId").agg(list)

    # 加载电影列表数据集
    movies = pd.read_csv("datasets/ml-latest-small/movies.csv", index_col="movieId")
    # 将类别词分开
    movies["genres"] = movies["genres"].apply(lambda x: x.split("|"))
    # 为每部电影匹配对应的标签数据,如果没有将会是NAN
    movies_index = set(movies.index) & set(tags.index)
    new_tags = tags.loc[list(movies_index)]
    ret = movies.join(new_tags)

    # 构建电影数据集,包含电影Id、电影名称、类别、标签四个字段
    # 如果电影没有标签数据,那么就替换为空列表
    # map(fun,可迭代对象)
    movie_dataset = pd.DataFrame(
        map(
            lambda x: (x[0], x[1], x[2], x[2]+x[3]) if x[3] is not np.nan else (x[0], x[1], x[2], []), ret.itertuples())
        , columns=["movieId", "title", "genres","tags"]
    )

    movie_dataset.set_index("movieId", inplace=True)
    return movie_dataset

movie_dataset = get_movie_dataset()
print(movie_dataset)
  • map函数

    • 描述

      map()会根据提供的函数对指定序列做映射。

      第一个参数function以参数序列中的每一个元素调用function函数,返回包含每次function函数返回值的新列表

    • 语法

      map()函数语法:

      map(function, iterable,...)

    • 参数

      • unction--函数
      • iterable--一个或多个序列
    • 返回值

      python2.x返回列表

      python3.x返回迭代器

    • 示例

      >>>def square(x) :            # 计算平方数
      ...     return x ** 2
      ... 
      >>> map(square, [1,2,3,4,5])   # 计算列表各个元素的平方
      [1, 4, 9, 16, 25]
      >>> map(lambda x: x ** 2, [1, 2, 3, 4, 5])  # 使用 lambda 匿名函数
      [1, 4, 9, 16, 25]
      
      # 提供了两个列表,对相同位置的列表数据进行相加
      >>> map(lambda x, y: x + y, [1, 3, 5, 7, 9], [2, 4, 6, 8, 10])
      [3, 7, 11, 15, 19]
      

7.5基于TF.IDF提取TOP-N关键词,构建电影画像

  • gensim介绍

    • python 三方库 自然语言处理利器
    • 支持包括TF-IDF,word2vec在内的多种主题模型算法
    • 安装 pip install gensim
  • gensim基本概念

    • 语料(Corpus):一组原始文本的集合,在Gensim中,Corpus通常是一个可迭代的对象(比如列表)。每一次迭代返回一个可用于表达文本对象的(稀疏)向量。
    • 向量(Vector):由一组文本特征构成的列表。是一段文本在Gensim中的内部表达。
    • 模型(Model)
  • 词袋模型(BOW bag of words)

    文本特征提取有两个非常重要的模型:

    • 词集模型:单词构成的集合,集合自然每个元素都只有一个,也即词集中的每个单词都只有一个。
    • 词袋模型:在词集的基础上如果一个单词在文档中出现不止一次,统计其出现的次数(频数)。

    两者本质上的区别,词袋是在词集的基础上增加了频率的维度,词集只关注有和没有,词袋还要关注有几个。

from gensim.models import TfidfModel

import pandas as pd
import numpy as np

from pprint import pprint

# ......

def create_movie_profile(movie_dataset):
    '''
    使用tfidf,分析提取topn关键词
    :param movie_dataset: 
    :return: 
    '''
    dataset = movie_dataset["tags"].values

    from gensim.corpora import Dictionary
    # 根据数据集建立词袋,并统计词频,将所有词放入一个词典,使用索引进行获取
    dct = Dictionary(dataset)
    # 根据将每条数据,返回对应的词索引和词频
    corpus = [dct.doc2bow(line) for line in dataset]
    # 训练TF-IDF模型,即计算TF-IDF值
    model = TfidfModel(corpus)

    movie_profile = {}
    for i, mid in enumerate(movie_dataset.index):
        # 根据每条数据返回,向量
        vector = model[corpus[i]]
        # 按照TF-IDF值得到top-n的关键词
        movie_tags = sorted(vector, key=lambda x: x[1], reverse=True)[:30]
        # 根据关键词提取对应的名称
        movie_profile[mid] = dict(map(lambda x:(dct[x[0]], x[1]), movie_tags))

    return movie_profile

movie_dataset = get_movie_dataset()
pprint(create_movie_profile(movie_dataset))

7.6完善画像关键词

from gensim.models import TfidfModel

import pandas as pd
import numpy as np

from pprint import pprint

# ......

def create_movie_profile(movie_dataset):
    '''
    使用tfidf,分析提取topn关键词
    :param movie_dataset:
    :return:
    '''
    dataset = movie_dataset["tags"].values

    from gensim.corpora import Dictionary
    # 根据数据集建立词袋,并统计词频,将所有词放入一个词典,使用索引进行获取
    dct = Dictionary(dataset)
    # 根据将每条数据,返回对应的词索引和词频
    corpus = [dct.doc2bow(line) for line in dataset]
    # 训练TF-IDF模型,即计算TF-IDF值
    model = TfidfModel(corpus)

    _movie_profile = []
    for i, data in enumerate(movie_dataset.itertuples()):
        mid = data[0]
        title = data[1]
        genres = data[2]
        vector = model[corpus[i]]
        movie_tags = sorted(vector, key=lambda x: x[1], reverse=True)[:30]
        topN_tags_weights = dict(map(lambda x: (dct[x[0]], x[1]), movie_tags))
        # 将类别词的添加进去,并设置权重值为1.0
        for g in genres:
            topN_tags_weights[g] = 1.0
        topN_tags = [i[0] for i in topN_tags_weights.items()]
        _movie_profile.append((mid, title, topN_tags, topN_tags_weights))

    movie_profile = pd.DataFrame(_movie_profile, columns=["movieId", "title", "profile", "weights"])
    movie_profile.set_index("movieId", inplace=True)
    return movie_profile

movie_dataset = get_movie_dataset()
pprint(create_movie_profile(movie_dataset))

为了根据指定关键词迅速匹配到对应的电影,因此需要对物品画像的标签词,建立倒排索引

倒排索引介绍

通常数据存储数据,都是以物品的ID作为索引,去提取物品的其他信息数据

而倒排索引就是用物品的其他数据作为索引,去提取它们对应的物品的ID列表

# ......

'''
建立tag-物品的倒排索引
'''

def create_inverted_table(movie_profile):
    inverted_table = {}
    for mid, weights in movie_profile["weights"].iteritems():
        for tag, weight in weights.items():
            #到inverted_table dict 用tag作为Key去取值 如果取不到就返回[]
            _ = inverted_table.get(tag, [])
            #将电影的id 和 权重 放到一个tuple中 添加到list中
            _.append((mid, weight))
            #将修改后的值设置回去 
            inverted_table.setdefault(tag, _)
    return inverted_table

inverted_table = create_inverted_table(movie_profile)
pprint(inverted_table)

八.基于内容的电影推荐:用户画像

用户画像构建步骤:

  • 根据用户的评分历史,结合物品画像,将有观影记录的电影的画像标签作为初始标签反打到用户身上
  • 通过对用户观影标签的次数进行统计,计算用户的每个初始标签的权重值,排序后选取TOP-N作为用户最终的画像标签

8.1用户画像建立

import pandas as pd
import numpy as np
from gensim.models import TfidfModel

from functools import reduce
import collections

from pprint import pprint

# ......

'''
user profile画像建立:
1. 提取用户观看列表
2. 根据观看列表和物品画像为用户匹配关键词,并统计词频
3. 根据词频排序,最多保留TOP-k个词,这里K设为100,作为用户的标签
'''

def create_user_profile():
    watch_record = pd.read_csv("datasets/ml-latest-small/ratings.csv", usecols=range(2), dtype={"userId":np.int32, "movieId": np.int32})

    watch_record = watch_record.groupby("userId").agg(list)
    # print(watch_record)

    movie_dataset = get_movie_dataset()
    movie_profile = create_movie_profile(movie_dataset)

    user_profile = {}
    for uid, mids in watch_record.itertuples():
        record_movie_prifole = movie_profile.loc[list(mids)]
        counter = collections.Counter(reduce(lambda x, y: list(x)+list(y), record_movie_prifole["profile"].values))
        # 取出出现次数最多的前50个词
        interest_words = counter.most_common(50)
        # 取出出现次数最多的词 出现的次数
        maxcount = interest_words[0][1]
        # 利用次数计算权重 出现次数最多的词权重为1
        interest_words = [(w,round(c/maxcount, 4)) for w,c in interest_words]
        user_profile[uid] = interest_words

    return user_profile

user_profile = create_user_profile()
pprint(user_profile)

8.2reduce函数

  • 描述

    reduce()函数会对参数序列中元素进行累积。

    函数将一个数据集合(链表、元组等)中的所有数据进行下列操作:用传给reduce中的函数function(有两个参数)先对集合中的第1、2个元素进行操作,得到的结果再与第三个数据用function函数运算,最后得到一个结果。

  • 语法

    reduce()函数语法:

    reduce(function, iterable[, initializer])
    
  • 参数

    • function--函数,有两个参数
    • Iterable--可迭代对象
    • Initializer--可选,初始参数
  • 返回值

    返回函数计算结果

  • 示例

    >>>def add(x, y) :            # 两数相加
    ...     return x + y
    ... 
    >>> reduce(add, [1,2,3,4,5])   # 计算列表和:1+2+3+4+5
    15
    >>> reduce(lambda x, y: x+y, [1,2,3,4,5])  # 使用 lambda 匿名函数
    15
    

8.3使用collections.Counter类统计列表元素出现次数

from collections import Counter
names = ["Stanley", "Lily", "Bob", "Well", "Peter", "Bob", "Well", "Peter", "Well", "Peter", "Bob","Stanley", "Lily", "Bob", "Well", "Peter", "Bob", "Bob", "Well", "Peter", "Bob", "Well"]
names_counts = Counter(names)

九.基于内容的电影推荐:为用户产生TOP-N推荐结果

# ......

user_profile = create_user_profile()

watch_record = pd.read_csv("datasets/ml-latest-small/ratings.csv", usecols=range(2),dtype={"userId": np.int32, "movieId": np.int32})

watch_record = watch_record.groupby("userId").agg(list)

for uid, interest_words in user_profile.items():
    result_table = {} # 电影id:[0.2,0.5,0.7]
    for interest_word, interest_weight in interest_words:
        related_movies = inverted_table[interest_word]
        for mid, related_weight in related_movies:
            _ = result_table.get(mid, [])
            _.append(interest_weight)    # 只考虑用户的兴趣程度
            # _.append(related_weight)    # 只考虑兴趣词与电影的关联程度
            # _.append(interest_weight*related_weight)    # 二者都考虑
            result_table.setdefault(mid, _)

    rs_result = map(lambda x: (x[0], sum(x[1])), result_table.items())
    rs_result = sorted(rs_result, key=lambda x:x[1], reverse=True)[:100]
    print(uid)
    pprint(rs_result)
    break

    # 历史数据  ==>  历史兴趣程度 ==>  历史推荐结果       离线推荐    离线计算
    # 在线推荐 ===>    娱乐(王思聪)   ===>   我 ==>  王思聪 100%  
    # 近线:最近1天、3天、7天           实时计算