LFM(Latent factor model)隐语义模型算法原理以及代码实践

简介

协同过滤(collaborative filtering)是一种在推荐系统中广泛使用的技术。该技术通过分析用户或者事物之间的相似性,来预测用户可能感兴趣的内容并将此内容推荐给用户。这里的相似性可以是人口特征的相似性,也可以是历史浏览内容的相似性,还可以是个人通过一定机制给与某个事物的回应。比如,A和B是无话不谈的好朋友,并且都喜欢看电影,那么协同过滤会认为A和B的相似度很高,会将A喜欢但是B没有关注的电影推荐给B,反之亦然。

协同过滤推荐分为3种类型:

  • 基于用户(user-based)的协同过滤(UserCF)
  • 基于物品(item-based)的协同过滤(ItemCF算法)
  • 基于模型(model-based)的协同过滤 (ModelCF算法)

本文主要讲述基于矩阵分解的隐语义(LFM)模型算法的原理以及代码实现。


算法原理

之前两篇文章分别讲过UserCF算法ItemCF算法,首先回想一下这两种方法是如何进行工作的,还是以电影推荐为例。

  • UserCF
    对于用户A,首先找到与其最相似的用户B,然后给A推荐B喜欢的电影。
  • ItemCF
    对于用户A,首先找到与A看过的电影最相似的电影,然后给A推荐这些电影。

总的来说,UserCF是计算用户之间的相似性,ItemCF是计算物品之间的相似性。那还有什么方法可以用于电影推荐么?
我们浏览电影网站的时候,可以看到顶部一般会有很多分类,比如喜剧、动作、科幻、爱情、悬疑、恐怖等。对于我而言,我一般比较喜欢看喜剧、科幻、动作片等,因此如果网站给我推送这几种类型的电影,我是有较大概率去点击观看的。但是如果给推送言情、古装、穿越等题材的电影则根本激不起我的兴趣。
因此我们首先可以对电影进行分类,接着得到用户对每一种电影类型的感兴趣度,然后挑出用户最感兴趣的那个类别,挑选出属于那个类别的电影推荐给用户。
我们首先来量化一下用户的感兴趣程度,使用0到1范围内的数字来代表用户对某种电影类型的感兴趣程度,从0到1依次代表喜欢程度加深。
假如我们现在有8个分类标签,分别是喜剧、动作、科幻、爱情、悬疑、恐怖、剧情、冒险,以用户A为例,A可能会做出以下评分:

喜剧 动作 科幻 爱情 悬疑 恐怖 剧情 冒险
A 0.8 0.5 0.6 0.6 0.4 0.2 0.3 0.6

现在有两部电影,分别是《钢铁侠3》和《傲慢与偏见》,通过看电影介绍,我们知道《钢铁侠3》包含了动作、科幻、冒险3个标签,《傲慢与偏见》包含了剧情、爱情两个标签。
我们假设这两部电影对这8个标签的符合程度如下:

喜剧 动作 科幻 爱情 悬疑 恐怖 剧情 冒险
钢铁侠3 0 0.7 0.9 0 0 0 0 0.6
傲慢与偏见 0 0 0 0.9 0 0 0.9 0

我们可以计算出A对《钢铁侠3》的喜欢程度为: 0.5x0.7+0.9x0.6+0.6x0.6 = 1.25。同理,A对《傲慢与偏见》的喜欢程度为: 0.9x0.6+0.3x0.9=0.81。

具体计算方法参见公式(1)。

可以看到,用户A对《钢铁侠3》的喜爱程度要高于《傲慢与偏见》,故推荐《钢铁侠3》是一个比较明智的选择。

但是,问题是我们如何能够得到用户对每个类别的感兴趣程度呢?以及如何给每个物品进行分类呢?你也会说,我们可以让用户对各种类别标签进行打分,或者请标注人员对每个商品进行分类。这其中有诸多问题,比如标注人员的分类不一定代表整体用户的意见,细粒度很难控制等问题,因此在实际应用中很少会这么做。
我们实际上收集到的数据一般是用户对物品的评分,以上述的电影为例,那么我们得到的原始数据集可能是这样的:

钢铁侠3 傲慢与偏见
A 1.25 0.81

那么我们有没有一种方法,能够通过原始数据集得到用户对每个类别的感兴趣程度以及每个物品的分类呢?为了解决这些问题,研究人员想到为什么我们不从数据出发,自动找到那些类。然后进行个性化推荐呢?于是,隐含语义分析技术出现了。

我将上面的表格数据用矩阵表示,并写成下面的形式:
图1

上图中,我们令R代表用户对电影的评分数据,这是我们已有的数据集。令P代表用户对电影类别的感兴趣程度,令Q为每部电影对每个类别的符合程度,因此可得PQ=R。
由于我们已有的数据集是R,要求P和Q,首先可能会想到SVD矩阵分解,但是有一个很大的问题,就是SVD分解要求矩阵是稠密的,也就是说矩阵的所有位置不能有空白。而我们的原始数据集有很多缺失值,因为不可能每个用户都会对每一件商品进行评分。
下面我们来看下LFM算法是如何解决这个问题的。令R(u,i)代表的是用户u对物品i的感兴趣程度,定义如下:

上式中p_{u}p_{i} 是模型的参数,也就是分别上图中的P矩阵和Q矩阵,其中p_{u,k}代表的含义是用户u对第k个分类的感兴趣程度,q_{k,i}代表的是第i个物品与第k个分类的符合程度。
假如我们的数据集R是3x4大小的,则上式可以用下图描述:

图2

由于用户一般不会对全部商品评分,故上图R中有很多项没有数据。其中K是分类的个数,对比图1,图1中的K值是8,因为我们将电影分成了8个类别,但是这里并没有指定具体的K值,因为在LFM算法中K是可以调整的,即我们可能会产生多个没有具体意义的类别,我想这也是为什么叫隐语义模型的一个原因吧。
我们发现使用了LFM之后:

  1. 我们不需要关心分类的角度,结果都是基于用户行为统计自动聚类的,全凭数据自己说了算。
  2. 不需要关心分类粒度的问题,通过设置LFM的最终分类数就可控制粒度,分类数越大,粒度越细。
  3. 对于一个item,并不是明确的划分到某一类,而是计算其属于每一类的概率,是一种标准的软分类。
  4. 对于一个user,我们可以得到他对于每一个类别的兴趣度,而不是只关心可见列表中的那几个类。
  5. 对于每一个class,我们可以得到类中每个item的权重,权重越高,说明这个item与这个类别的匹配程度越高。

接下去的问题就是如何计算矩阵P和矩阵Q中参数值,一般做法就是通过最优化损失函数来求参数。在定义损失函数之前,我们需要准备一下数据集并对兴趣度的取值做统一说明。
数据集应该包含所有的user和他们有过行为的(也就是喜欢)的item,所有的这些item构成了一个item全集。对于每个user来说,我们把他有过行为的item称为正样本,规定兴趣度R(u,i)=1,此外我们还需要从item全集中随机抽样,选取与正样本数量相当的样本作为负样本,规定兴趣度为R(u,1)=0。因此,兴趣的取值范围为[0,1]。采样之后原有的数据集得到扩充,得到一个新的user-item集K=\{(u,i)\},其中如果(u,i)是正样本,则R(u,i)=1,否则R(u,i)=0

推荐系统的用户行为分为显性反馈和隐形反馈。LFM在显性反馈数据(也就是评分数据)上解决评分预测问题并且达到了很好的精度。不过本文主要讨论的是隐形反馈数据集,这种数据集的特点是只有正样本和负样本,即用户只有喜欢和不喜欢两个选择,而不是对每个物品进行一个评分。

损失函数如下所示:

使用梯度下降算法:

  1. 对损失函数C求p_{u,k}p_{k,i}的偏导,确定最快的下降方向:
  1. 迭代计算更新参数

其中\alpha是学习率,\lambda是惩罚因子,均需要通过反复实验获得。


代码实现

本文将以MovieLens数据集为例,演示下代码具体实现流程。在这其中会略过一些数据预处理的流程,具体可以参考UserCF算法

准备训练集数据

我们收集的原始数据是用户对电影的评分数据,但是这其中并没有用户对不喜欢的电影的评分(用户一般也不会去评分),为了训练我们的模型,我们需要准备一个训练数据集,这其中包含了每个用户喜欢的电影和不喜欢的电影,通过学习这个数据集,就可以获得上面的模型参数。
那么在隐形反馈数据集上应用LFM算法的第一个问题就是如何给用户生成负样本,即用户不喜欢的电影。负样本的生成需要遵循以下原则:

  • 对每个用户,要保证正负样本数量均衡
  • 对每个用户采样负样本时,要选取那些很热门,而用户却没有行为的物品

一般认为,物品很热门而用户却没有行为,更加代表用户对这个物品不感兴趣。因为对于冷门物品,用户可能压根没在网站中发现这个物品,所以谈不上是否感兴趣。
下面代码用于随机选择负样本,对于每个用户喜爱的电影集合,选择数量相等的不感兴趣电影:

def _select_negatives(self, movies):
    """
    选择负样本
    :param movies: 一个用户喜爱的电影集合
    :return: 包含正负样本的电影样本集合
    """
    ret = dict()
    for i in movies: # 记录正样本,兴趣度为1
        ret[i] = 1

    number = 0
    while number < len(movies):
        # 从所有商品集合中随机选取一个当做负样本,兴趣度置为0
        negative_sample = random.choice(self._item_pool)
        if negative_sample in ret:
            continue
        ret[negative_sample] = 0
        number += 1
    return ret

初始化矩阵P和Q

采用均值为0,方差为1的高斯分布数据来初始化矩阵P和Q,代码如下:

def _init_matrix(self):
    '''
    初始化P和Q矩阵,同时选择高斯分布的随机值作为初始值
    '''
    print("start build latent matrix.")
    # User-LF user_p 是一个 m x k 的矩阵,其中m是用户的数量,k是隐含类别的数量
    self.user_p = dict()
    for user in self._trainData.keys():
        self.user_p[user] = np.random.normal(size=(self._k))
    # Item-LF movie_q 是一个 n x k 的矩阵,其中n是电影的数量,k是隐含类别的数量
    self.item_q = dict()
    for movie in self._item_pool:
        self.item_q[movie] = np.random.normal(size=(self._k))

随机梯度优化

采用随机梯度优化算法,在每次迭代中对每个用户的电影列表都重新选择负样本,并且更新参数:

def SGD(self):
    # 随机梯度下降算法
    alpha = self._alpha
    for epoch in range(self._epochs):
        print("start {0}th epoch training...".format(epoch))
        for user, positive_movies in self._trainData.items():
            # 每次迭代都对用户重新选择负样本
            select_samples = self._select_negatives(positive_movies)
            for movie, rui in select_samples.items():
                # 使用模型去预测user对movie的相似度,并且得到与真实值之间的误差
                eui = rui - self.predict(user, movie)
                user_latent = self.user_p[user]
                movie_latent = self.item_q[movie]
                # 更新参数
                self.user_p[user] += alpha * (eui * movie_latent - self._lmbda * user_latent)
                self.item_q[movie] += alpha * (eui * user_latent - self._lmbda * movie_latent)
        alpha *= 0.9 #使学习率线性减小

完整代码

import random
import numpy as np
from operator import itemgetter
from Utils import modelManager

class LFM(object):
    def __init__(self, trainData, alpha, regularization_rate, number_LatentFactors=10, number_epochs=10):
        self._trainData = trainData # User-Item表
        self._alpha = alpha # 学习率
        self._lmbda = regularization_rate # 正则化惩罚因子
        self._k = number_LatentFactors # 隐语义类别数量
        self._epochs = number_epochs # 训练次数
        self._item_pool = self._getAllItems() # 所有物品集合
        self._init_matrix()

    def _getAllItems(self):
        # 获取全体物品列表
        print("start collect all items...")
        items_pool = set()
        for user, items in self._trainData.items():
            for item in items:
                items_pool.add(item)
        return list(items_pool)

    def _init_matrix(self):
        '''
        初始化P和Q矩阵,同时选择高斯分布的随机值作为初始值
        '''
        print("start build latent matrix.")
        # User-LF user_p 是一个 m x k 的矩阵,其中m是用户的数量,k是隐含类别的数量
        self.user_p = dict()
        for user in self._trainData.keys():
            self.user_p[user] = np.random.normal(size=(self._k))
        # Item-LF movie_q 是一个 n x k 的矩阵,其中n是电影的数量,k是隐含类别的数量
        self.item_q = dict()
        for movie in self._item_pool:
            self.item_q[movie] = np.random.normal(size=(self._k))

    def predict(self, user, item):
        # 通过公式 Rui = ∑P(u,k)Q(k,i)求出user对item的感兴趣程度
        return np.dot(self.user_p[user], self.item_q[item])

    def _select_negatives(self, movies):
        """
        选择负样本
        :param movies: 一个用户喜爱的电影集合
        :return: 包含正负样本的电影样本集合
        """
        ret = dict()
        for i in movies: # 记录正样本,兴趣度为1
            ret[i] = 1

        number = 0
        while number < len(movies):
            # 从所有商品集合中随机选取一个当做负样本,兴趣度置为0
            negative_sample = random.choice(self._item_pool)
            if negative_sample in ret:
                continue
            ret[negative_sample] = 0
            number += 1
        return ret

    def _loss(self):
        C = 0.
        for user, user_latent in self.user_p.items():
            for movie, movie_latent in self.item_q.items():
                rui = 0
                for u, m in self._trainData.items():
                    if user == u:
                        if movie in m: # 如果movie出现在了user的喜爱列表里面,则rui=1
                            rui = 1
                        break
                    else:
                        continue

                eui = rui - self.predict(user, movie)
                C += (np.square(eui) +
                      self._lmbda * np.sum(np.square(self.user_p[user])) +
                      self._lmbda * np.sum(np.square(self.item_q[movie])))
        return C

    def SGD(self):
        # 随机梯度下降算法
        alpha = self._alpha
        for epoch in range(self._epochs):
            print("start {0}th epoch training...".format(epoch))
            for user, positive_movies in self._trainData.items():
                # 每次迭代都对用户重新选择负样本
                select_samples = self._select_negatives(positive_movies)
                for movie, rui in select_samples.items():
                    # 使用模型去预测user对movie的相似度,并且得到与真实值之间的误差
                    eui = rui - self.predict(user, movie)
                    # print("error : ", eui)
                    user_latent = self.user_p[user]
                    movie_latent = self.item_q[movie]
                    # 更新参数
                    self.user_p[user] += alpha * (eui * movie_latent - self._lmbda * user_latent)
                    self.item_q[movie] += alpha * (eui * user_latent - self._lmbda * movie_latent)
            alpha *= 0.9
            print("{0}td training finished, loss: {1}".format(epoch, self._loss()))

    def recommend(self, user, N):
        """
        给user推荐N个商品
        :param user: 被推荐的用户user
        :param N: 推荐的商品个数
        :return: 按照user对推荐物品的感兴趣程度排序的N个商品
        """
        recommends = dict()

        for movie in self._item_pool:
            recommends[movie] = self.predict(user, movie)

        # 根据被推荐物品的相似度逆序排列,然后推荐前N个物品给到用户
        return dict(sorted(recommends.items(), key=itemgetter(1), reverse=True)[:N])

    def train(self):
        try:
            print("start load latent factor matrix P and Q")
            model = modelManager.load("../Models/lfm.pkl", 3)
            self.user_p = model[0]
            self.item_q = model[1]
            self._item_pool = model[2]
        except BaseException as e:
            print("Exception occurs: " + str(e))
            print("load latent factor matrix failed, start train...")
            self.SGD()
            modelManager.save("../Models/lfm.pkl", self.user_p, self.item_q, self._item_pool)

测试代码

以下代码对测试集中3个用户进行了Top5推荐,即每个用户推荐5部电影,输出结果按照用户对电影的感兴趣程度从大到小排序。LFM算法学习率采用0.02,正则化参数0.01,隐类设置为10,迭代次数为30。代码如下:

from Utils import movielens_loader
from LFM import lfm

if __name__ == "__main__":
    #######
    # LFM
    #######
    train, test = movielens_loader.LoadMovieLensData("../Data/ml-1m/ratings.dat", 0.8)
    print("train data size: %d, test data size: %d" % (len(train), len(test)))
    lfm = lfm.LFM(train, 0.02, 0.01, 10, 30)
    lfm.train()
    print(lfm.user_p)
    print(lfm.item_q)

    # 给测试集中的用户推荐5部电影
    print(lfm.recommend(list(test.keys())[0], 5))
    print(lfm.recommend(list(test.keys())[1], 5))
    print(lfm.recommend(list(test.keys())[2], 5))

结果如下,输出了每个用户感兴趣程度最高的5部电影:
推荐结果

完整代码见https://github.com/HeartbreakSurvivor/RsAlgorithms/blob/main/Test/lfm_test.py


总结

LFM算法的优缺点如下:

  • 优点

    1. LFM具有比较好的理论基础,它是一种学习方法,通过优化一个设定的指标建立最优的模型。
    2. LFM只是存储user向量和Item向量,空间占用较小。
    3. 泛化能力强,在一定程度上解决了数据稀疏问题。
    4. 更好的扩展性和灵活性。
  • 缺点

    1. 不支持在线实时推荐
    2. 可解释性差
    3. 不方便加入用户、物品和上下文相关的特征。

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,047评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,807评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,501评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,839评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,951评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,117评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,188评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,929评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,372评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,679评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,837评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,536评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,168评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,886评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,129评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,665评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,739评论 2 351

推荐阅读更多精彩内容