【推荐算法实战(一)】LFM

个性化召回算法LFM

Posted by Raserting_L on July 14, 2020

[TOC]

一、个性化召回算法综述

1. 什么是个性化召回

召回是从item中选取一部分作为候选集。

​ 这里就存在一个问题,就是说为什么要选取一部分作为作为候选集,而不是全部?其原因在于:

1.不同的用户不会喜欢所有类型的item;

2.基于服务性能的考虑,如果选择了全部的item作为候选集,对于后续的排序就将耗费大量的时间,对于整体推荐的后端,服务响应时间将会是灾难性的。

那么个性化召回就顾名思义,就是根据用户的属性行为上下文等信息从物品全集中选取其感兴趣的物品作为候选集,也就是每个人的候选集都不同,那么如何得到候选集呢?

下面举例说明:

​ 如果某个推荐系统中,物品全集是如下左图中9个item,这里有两个用户A和B,他们分别对不同的item感兴趣。这里拿信息流产品举例,如果user A对体育类新闻感兴趣,user B对娱乐类新闻感兴趣,那就按照简单的类别召回,得到结果如下右图所示:

召回

2. 召回的作用

  1. 召回决定了最终推荐结果的天花板。

    为什么这么说呢?这里先看一下推荐系统的整体架构:

![](https://upload-images.jianshu.io/upload_images/11172737-a0db92d54405304c.PNG?imageMogr2/auto-orient/strip imageView2/2/w/349/format/webp)

​ 工业中的个性化推荐系统中的策略部分的架构主要由一下三部分构成:召回、排序、以及最后的策略调整部分,其中召回部分包括各路个性化召回之后将所有的item merge进入rank部分,rank只是调整召回完item的展现顺序,rank完之后还有一些策略的调整,比如信息流场景中的控制相同作者的数目等等,所以可以看到个性化召回的候选集是多么的重要,因为最终展现给用户的就是从这个候选集中选出来的。那么就可能会有疑问,为什么不能将所有的item进行排序?这是为了保证后端响应时间。

2.个性化召回解析

​ 个性化召回算法分为哪几大类?

​ (1)基于用户行为的:也就是用户基于推荐系统推荐给他的item点击或者没点。这一类的算法主要有CF以及矩阵分解,还有就是基于图的推荐,这一类的个性化召回算法总体来说就是推荐结果的可解释性较强,比较通俗易懂,但是缺少一些新颖性。

​ (2)基于user profile的:经过用户的自然属性,也就是说经过用户的偏好统计,那么基于这个统计的类别去召回。推荐效果不错,但是可扩展性较差。也就是说一旦用户被标上了某一个类别或者某几个类别的标签之后,很难迁移到其余的一些标签。

​ (3)基于隐语义的:新颖性、创新性十足,但是可解释性不是那么强。

3.工业界个性化召回架构

![](https://upload-images.jianshu.io/upload_images/11172737-96905bf688a09c00.PNG?imageMogr2/auto-orient/strip imageView2/2/w/880/format/webp)

​ 整体的召回架构可以分为两大类:第一大类是基于离线的model file算出推荐结果,这些推荐结果可以是用户喜欢哪些item,也可以是item之间的相似度文件,然后写入KV存储,在线的recall server部分直接调用这个结果,拿到ID之后访问detail server得到详情,再往rank部分传递;另一种,如果采用深度学习的一些model,这是需要将model file算出来的item embedding也存入KV,但是在线的时候需要访问recall server去将user embedding成user向量,同时user向量与embedding向量做最近临召回。

二、LFM算法

1. 什么是LFM算法

LFM即latent factor model,通过隐含特征联系用户兴趣和物品,找出潜在的主题和分类。LFM算法其实也是矩阵分解的一种,他的参数是每一个用户的向量表示以及每一个物品的向量表示。所以我们希望得到两个矩阵:潜在因子:用户矩阵Q; 潜在因子:物品矩阵P。

2. 潜在因子如何得到

LFM的建模公式如下: 其中u表示用户,i表示物品。

LFM 的损失函数 D为多有训练样本集合。 并且为了防止过拟合,增加了一些正则化项: 而之后我们进行参数更新时候,要做的就是对

3. 影响因素

  1. 负样本的选取

    比起正样本,负样本的数量是非常多的,因为,展现给用户的item比用户点击的item要多得多。所以我们要有一定的负样本采样规则。在本次小实验中,我使用的方法是对于每个用户,在选取正负样本选取min(len(pos), len(neg)),即对于正负样本,选取数量相同且数量为二者少的那一个。

  2. 隐特征F,正则参数,学习率。其中正则参数与学习率通常设置为0.01-0.05.隐特征个数F通常设置在10-32之间。

4. LFM运用场景

根据上述的内容建模之后,可以得到相应的模型输出,即两个潜在因子矩阵。其中,潜在因子的维度是之前设定的,可以理解为你认为有哪些特征可能会影响user对item的喜好程度。得到模型输出之后,我们有:

  • (1)计算用户toplike:对于与用户没有被展示的item,可以计算出一个用户对item的倾向性得分,取top即toplike,后直接完成用户对item的喜爱度列表,写入离线即可完成对在线的推荐。
  • (2)计算item的topsim:得到item的向量可以用很多表示距离的公式包括cos等等,计算出每一个item的相似度矩阵,该矩阵可以用于线上推荐。当用户点击item之后,给其推荐与该item的topsim item。
  • (3)计算item的topic:根据得到的item向量,可以用聚类的方法,如K-means等等,取出一些隐含的类别。也就是一些隐含的topic能将item分成不同的簇,推荐时按簇推荐。

5. 代码

整体思路可以概括为

  • 读数据,转化为userid, itemid, label的形式,这里要注意负样本的选取问题。
  • 进行LFM模型搭建,通过梯度下降调整用户和物品向量来进行学习潜在特征。其中向量相乘获得概率过程使用了sigmoid函数。
  • 最后得到结果,得出topK的推荐概率,并映射到具体的电影。
import os
import numpy as np

#实现一个sigmoid函数
def sigmoid(z):
        return 1./(1 + np.exp(-z))
        
# 获取movie文件内容
def get_item_info(input_file):
    """
    get item info[title, genre]
    Args(参数):
        input_file:item info file
    Return(输出):
        a dict:{key:itemid, value:[title, genre]}
    """
    if not os.path.exists(input_file):
        return {}
    item_info = {}
    linenum = 0 
    fp = open(input_file)
    for line in fp:
        if linenum == 0:
            linenum += 1
            continue
        item = line.strip().split(',')  # strip方法为移除字符串头尾的指定字符,默认为空格和换行符
        if len(item) < 3:
            continue
        elif len(item) == 3:
            itemid, title, genre = item[0], item[1], item[2]
        elif len(item) > 3:
            itemid = item[0]
            genre = item[-1]
            title = ",".join(item[1:-1])
            
        item_info.update({itemid:[title, genre]})
        
    fp.close()

    return item_info


# 下面获得评分文件
def get_ave_score(input_file):
    """
    get item ave rating score
    Args:
        input file:user rating file
    Return:
        a dict: key:itemid, value:ave_score
    """
    if not os.path.exists(input_file):
        return {}
    record_dict = {}  #用来记录读取的每个电影的评分人数与总分为之后计算平均分作准备,Key:itemid , value:[数量,评分之和]
    score_dict = {}  #之后用来返回平均评分
    linenum = 0 
    fp = open(input_file)
    for line in fp:
        if linenum == 0:
            linenum += 1
            continue
        item = line.strip().split(',')  # strip方法为移除字符串头尾的指定字符,默认为空格和换行符
        if len(item) < 4:
            continue
        userid, itemid, rating = item[0], item[1], float(item[2])
        if itemid not in record_dict:
            record_dict[itemid] = [0, 0]
        record_dict[itemid][0] += 1
        record_dict[itemid][1] += rating
        
            
    for key, value in record_dict.items():
        score_dict.update({key:round(value[1]/value[0], 3)})  # round()方法是用来保留小数的,第二个参数3表示三位小数
        
    fp.close()
    return score_dict

# 为模型提供训练样本
def get_train_data(input_file):
    """
    get train data for LFM model
    Args:
        input file : user item rating file
    Return:
        a list [(userid, itemid, label), (userid1, itemid1, label)]
    """
    if not os.path.exists(input_file):
        return []

    score_dict = get_ave_score(input_file)

    score_thr = 4.0 #正负样本的分数阈值
    linenum = 0 
    pos_dict = {} #正样本字典
    neg_dict = {} #负样本字典
    train_data = [] #输出用的列表
    fp = open(input_file)

    for line in fp:
        if linenum == 0:
            linenum += 1
            continue
        item = line.strip().split(',')
        if len(item) < 4:
            continue
        userid, itemid, rating = item[0], item[1], float(item[2])
        if userid not in pos_dict:
            pos_dict.update({userid:[]})
        if userid not in neg_dict:
            neg_dict.update({userid:[]})
        if rating >= score_thr:  #以score_thr为阈值判断样本正负
            pos_dict.get(userid).append((itemid, 1))
        else:
            score = score_dict.get(itemid, 0) #没取到就返回0
            neg_dict.get(userid).append((itemid, score))
    
    fp.close()
    for key, _ in pos_dict.items():
        datanum = min(len(pos_dict.get(key)), len(neg_dict.get(key)))
        if datanum > 0:
            train_data += [(key, value[0], value[1]) for value in pos_dict.get(key)][:datanum]  #这个datanum是为了不让正负样本数量差距过大,这样的话二者数量一样。
        else:
            continue
        #下面对该userid对应的负样本进行一个按照得分的list降序排序,reverse=True则为降序,默认为False升序
        sorted_neg_list = sorted(neg_dict.get(key), key=lambda element:element[1], reverse=True)[:datanum]  #这个datanum是为了不让正负样本数量差距过大,这样的话二者数量一样。
        train_data += [(key, value[0], 0) for value in sorted_neg_list]
    
    return train_data  #[(userid, itemid, label), (userid, itemid, label), ......]
    
# 首先是模型训练函数
def lfm_train(train_data, F, alpha, lr, epochs): 
    """
    Args:
        train_data: 训练数据, 
        F:          隐语义向量长度, 
        alpha:      正则化参数, 
        lr:         模型学习率, 
        epochs:      迭代次数
    Return :
        dict_user_vec:  {userid : np.ndarray}
        dict_item_vec:  {itemid : np.ndarray}
    """
    user_vec = {}
    item_vec = {}

    # 下面是模型参数训练过程
    for e in range(epochs):
        for data_instance in train_data:
            userid, itemid, label = data_instance
            if userid not in user_vec:
                user_vec.update({userid : init_model(F)})
            if itemid not in item_vec:
                item_vec.update({itemid : init_model(F)})
        delta = label - model_predict(user_vec.get(userid), item_vec.get(itemid)) #损失函数
        for index in range(F):  #进行参数调整
            user_vec.get(userid)[index] += lr*(delta*item_vec.get(itemid)[index]) - lr*user_vec.get(userid)[index]
            item_vec.get(itemid)[index] += lr*(delta*user_vec.get(userid)[index]) - lr*item_vec.get(itemid)[index]
        
        lr = lr * 0.9
    return user_vec, item_vec



# 然后是模型初始化函数
import numpy as np

def init_model(vector_len):
    """
    Args:
        vector_len 隐语义向量长度
    Return:
        a ndarray
    """
    return np.random.randn(vector_len)


# 模型进行打分
def model_predict(user_vec, item_vec):
    """
    Args:
        user_vec:   表示用户向量
        item_vec:   表示物品向量
    Return:
        a num 表示两个向量做出的评分,即相似度
    """

    # 结果就是求cos,两个向量内积除以两个向量模乘积
    return sigmoid(np.dot(user_vec, item_vec) / (np.linalg.norm(user_vec) * np.linalg.norm(item_vec)))



# 得到模型推荐结果
def get_recom_result(user_vec, item_vec, userid):
    """
    Args:
        user_vec:   lfm模型的输出结果
        item_vec:   lfm模型输出结果
        userid:     想得到的某个具体用户的推荐结果
    Return:
        a list :    [(itemid, score), (itemid, score), ......]
    """
    topK = 10 
    record = {}
    recom_list = []

    if userid not in user_vec:
        return []
    user_vector = user_vec.get(userid)
    for itemid, item_vector in item_vec.items():
        res = float(np.dot(user_vector, item_vector) / (np.linalg.norm(user_vector) * np.linalg.norm(item_vector)))
        #print(res)
        record.update({itemid : res})
    
    for value in sorted(record.items(), key=lambda v:v[1], reverse=True)[: topK]:
        # dict.items()相当于将字典变成了一个元组列表,比如[(key1, val1), (key2, val2)]。
        # 所以现在的value是一个元组(key, val)
        recom_list.append((value[0], round(value[1], 3)))
    
    return recom_list



# 对推荐结果进行评估
def ana_recom_result(train_data, userid, recom_list):
    """
    Args:
        train_data: 训练数据
        userid:     用户id
        recom_list: lfm推荐列表
    """
    # 将推荐得到的结果映射到具体的电影名。并与该用户本身postive的电影进行对比看看效果
    item_info = get_item_info("./data/movies.txt") 
    
    print("用户正样本:")
    for data_instance in train_data:
        temp_userid, itemid, label = data_instance
        if temp_userid == userid and label == 1:
            print(item_info.get(itemid))
    
    print("推荐结果:")
    for val in recom_list:
        print(item_info.get(val[0]))



# 最后假设对24号用户进行电影推荐
pred_userid = "24"
train_data = get_train_data("./data/ratings.txt")
user_vec, item_vec =lfm_train(train_data, 50, 0.01, 0.1, 50)
recom_list = get_recom_result(user_vec, item_vec, pred_userid)
ana_recom_result(train_data, pred_userid, recom_list)

效果如下:

用户正样本: [‘Heat (1995)’, ‘Action|Crime|Thriller’] [“Things to Do in Denver When You’re Dead (1995)”, ‘Crime|Drama|Romance’] [‘Die Hard: With a Vengeance (1995)’, ‘Action|Crime|Thriller’] [‘Pulp Fiction (1994)’, ‘Comedy|Crime|Drama|Thriller’] [‘Forrest Gump (1994)’, ‘Comedy|Drama|Romance|War’] [‘True Lies (1994)’, ‘Action|Adventure|Comedy|Romance|Thriller’] [‘“Fugitive, The (1993)”’, ‘Thriller’] [‘Dances with Wolves (1990)’, ‘Adventure|Drama|Western’] [‘Mission: Impossible (1996)’, ‘Action|Adventure|Mystery|Thriller’] 推荐结果: [‘“Crow: City of Angels, The (1996)”’, ‘Action|Thriller’] [‘“Coca-Cola Kid, The (1985)”’, ‘Comedy|Romance’] [‘Marwencol (2010)’, ‘Documentary’] [‘“Rome, Open City (a.k.a. Open City) (Roma, citt脿 aperta) (1945)”’, ‘Drama|War’] [‘Sabrina (1954)’, ‘Comedy|Romance’] [‘Any Which Way You Can (1980)’, ‘Comedy’] [‘American Wedding (American Pie 3) (2003)’, ‘Comedy’] [‘Accattone (1961)’, ‘Drama’] [‘Before Sunrise (1995)’, ‘Drama|Romance’] [‘Backdraft (1991)’, ‘Action|Drama’]