推荐模型是推荐系统中最重要的部分,推荐模型的好坏直接决定了最终物品排序的结果,换而言之,推荐模型的好坏也直接影响着推荐效果的优劣。
而且,从某种意义上讲,推荐系统的整体架 构都是围绕着推荐模型搭建的,用于支持推荐模型的上线、训练、评估、服务。
说起推荐模型,我们首先想到的肯定是经典的协同过滤算法。它是最经典的推荐算法。其他模型和它或多或少有关系。因此,掌握协同过滤模型是有必要的。
今天我们来学习一下,经典的协同过滤模型和相关的代码实现。
协同过滤的思想
我们前面已经提过:“用户行为数据是推荐系统最常用,也是最关键的数据。用户的潜在兴趣、用户对物品的评价好坏都反映在用户的行为历史中”
协同过滤算法是一种完全依赖用户和物品之间行为关系的推荐算法。我们从它的名字“协同过滤”中,也可以窥探到它背后的原理,就是 “协同大家的反馈、评价和意见一起对海量的信息进行过滤,从中筛选出用户可能感兴趣的信息”。
具体来说,协同过滤的思路是通过群体的行为来找到某种相似性(用户之间的相似性或者物品之间的相似性),通过该相似性来为用户做决策和推荐。
现实生活中有很多协同过滤的案例及思想体现,我认为人类喜欢追求相亲中的“门当户对”,其实也是一种协同过滤思想的反射,门当户对实际上是建立了相亲男女的一种“相似度”(家庭背景、出身、生活习惯、为人处世、消费观、甚至价值观可能会相似),给自己找一个门当户对的伴侣就是一种“过滤”,当双方”门当户对“时,各方面的习惯及价值观会更相似,未来幸福的概率也会更大。如果整个社会具备这样的传统和风气,以及在真实”案例“中”门当户对“的夫妻确实会更和谐,通过”协同过滤“作用,大家会越来越认同这种方式。我个人也觉得”门当户对“是有一定道理的。
协同过滤的算法原理
我们知道了协同过滤的思想是根据用户之前得喜好以及其他兴趣相近得用户得选择来给用户推荐物品(基于对用户历史行为数据的挖掘发现用户的喜好偏向,并预测用户可能喜好的产品进行推荐),一般仅仅基于用户的行为数据(评价,购买,下载等),而不依赖于物品的任何附加信息(物品自身特征)或者用户的任何附加信息(年龄,性别等)。可以概括为“人以类聚,物以群分”。
协同过滤算法分类
用户协同过滤(UserCF):相似的用户可能喜欢相同物品。如加了好友的两个用户,或者点击行为类似的用户被视为相似用户。如我兄弟和她的太太互加了抖音好友,他们两人各自喜欢的视频,可能会产生互相推荐。
讲过笑话:我有个兄弟,是抖音的点赞狂魔,他的点赞次数高达6924次,而且他大多数的赞都是给那些青春靓丽的小姐姐们,如下图。看他的抖音推荐内容,都是满目的小姐姐唱啊跳啊不亦乐乎,他也觉得甚爽。不过,好景不长,没多久他就跟我说:“我再也不敢再点了,我老婆已经发现我给小姐姐们点了上1000个赞,而且知道我点赞的视频,也会推荐给她”
把好友看过的视频推荐给用户,这就是用户协同过滤。
物品协同过滤(ItemCF):相似的物品可能被同个用户喜欢。这个就是著名的世界杯期间沃尔玛尿布和啤酒的故事了。这里因为世界杯期间,奶爸要喝啤酒看球,又要带娃,啤酒和尿布同时被奶爸所需要,也就是相似商品,可以放在一起销售。
UserCF和ItemCF优缺点的对比
UserCF | ItemCF | |
性能 | 适用于用户较少的场合,如果用户很多,计算用户相似度矩阵代价很大 | 适用于物品数明显小于用户数的场合,如果物品很多(网页),计算物品相似度矩阵代价很大 |
领域 | 时效性较强,用户个性化兴趣不太明显的领域 | 长尾物品丰富,用户个性化需求强烈的领域 |
实时性 | 用户有新行为,不一定造成推荐结果的立即变化 | 用户有新行为,一定会导致推荐结果的实时变化 |
冷启动 | 在新用户对很少的物品产生行为后,不能立即对他进行个性化推荐,因为用户相似度表是每隔一段时间离线计算的 | 新用户只要对一个物品产生行为,就可以给他推荐和该物品相关的其他物品 |
推荐结果 | 很难提供令用户信服的推荐解释 | 利用用户的历史行为给用户做推荐解释,可以令用户比较信服 |
那怎么计算呢?依据是什么?
- 一是用户相似度到底该怎么定义?
- 二是用户评分的预测,即推荐分数该怎么计算呢?
计算用户相似度其实并不是什么难事,因为在共现矩阵中,每个用户对应的行向量其实就可以当作一个用户的 Embedding 向量。相信你早已经熟悉 Embedding 相似度的计算方法,那我们这里依葫芦画瓢就可以知道基于共现矩阵的用户相似度计算方法啦。
最经典的方法就是利用余弦相似度了,它衡量了用户向量 i 和用户向量 j 之间的向量夹角大 小。夹角越小,余弦相似度越大,两个用户越相似,它的定义如下:
除了最常用的余弦相似度之外,相似度的定义还有皮尔逊相关系数、欧式距离等等
用户评分的预测:假设“目标用户与其相似用户的喜好是相似的”,我们可以利用相似用户的已有评价对目标用户的偏好进行预测。最常用的方式是,利用用户相似度和相似用户评价的加权平均值,来获得目标用户的评价预测,公式如下所示。
其中,权重是用户u和用户s的相似度, 是用户s对物品p的评分。
矩阵分解(SVD)使用过程
先看看例子,假设一个平台只有4个用户和4本图书。
1、数据:用户对物品评分1-5分,且有以下评分记录。
2、学习算法SVD:根据线性代数,一个矩阵可以分解为多个矩阵的乘积。在推荐系统领域,可以简单的认为,SVD就是将一个矩阵,在一定的精度损失下,将一个矩阵分解成两个矩阵。运用这个算法,我们可以将上图的矩阵做以下的近似分解:
其中,用户矩阵部分代表着每个用户的偏好在一个二维隐语义空间上的映射。同样地,物品矩阵代表着每本图书的特性在一个二维隐语义空间上的映射。
这两个矩阵也就是模型的结果。这样,我们训练模型的时候,就只需要训练用户矩阵中的8个参数和物品矩阵中的8个参数即可。大大减少了计算量。
模型训练的过程,简单地说,就是通过最小二乘法,不断将用户评分数据迭代入矩阵中计算,直到把均方误差优化到最小。
3、预测决策:通过模型训练,我们得到用户矩阵Q和物品矩阵P后,全部用户对全部图书的评分预测可以通过R = PQ来获得。如上图中,用户A的向量(1.40,-1.18)乘以物品2的向量(2.19,0.73)则可得用户A对物品1的评分预测为:1.40×(-1.18)+2.19×0.73=2.21。
对所有的用户和物品都执行相同操作,可以得到全部用户对全部物品的评分。如下图右侧矩阵:
得到全部的评分预测后,我们就可以对每本图书进行择优推荐。需要注意的是,用户矩阵和物品矩阵的乘积,得到的评分预估值,与用户的实际评分不是全等关系,而是近似相等的关系。如上图中两个矩阵绿色部分,用户实际评分和预估评分都是近似的,有一定的误差。我们给出矩阵分解损失函数的定义如下。
在现在的实际应用中,SVD一般作为协同过滤的离线召回使用。一般地,将需要给用户推荐的物品提前离线计算好,存在HBASE中,在用户有请求的时候,直接读取推荐的结果,放入初排阶段的召回集中
矩阵分解算法的 Pytorch实现
数据集:采用GroupLens提供的MovieLens数据集MovieLens数据集有3个不同版本,本章选用中等大小的数据集该数据集包含6000多名用户对4000多部电影的100万条评分。
数据集格式:
* ratings.dat: UserID::MovieID::Rating::Timestamp
- UserID:用户ID范围从1到6040
- MovieID:电影ID范围从1到3952
- Ratings:评分有1到5的5个等级
- 每个用户最少有20条评分数据
* users.dat: UserID::Gender::Age::Occupation::Zip-code
- Gender: "M"代表男,"F"代表女
- Age: 年龄从下面的范围中选择
* 1: "Under 18"
* 18: "18-24"
* 25: "25-34"
* 35: "35-44"
* 45: "45-49"
* 50: "50-55"
* 56: "56+"
- Occupation: 职业包括下面的类别
* 0: "other" or not specified
* 1: "academic/educator"
* 2: "artist"
* 3: "clerical/admin"
* 4: "college/grad student"
* 5: "customer service"
* 6: "doctor/health care"
* 7: "executive/managerial"
* 8: "farmer"
* 9: "homemaker"
* 10: "K-12 student"
* 11: "lawyer"
* 12: "programmer"
* 13: "retired"
* 14: "sales/marketing"
* 15: "scientist"
* 16: "self-employed"
* 17: "technician/engineer"
* 18: "tradesman/craftsman"
* 19: "unemployed"
* 20: "writer"
* movies.dat: MovieID::Title::Genres
- Genres:电影类别包括以下的类别
* Action
* Adventure
* Animation
* Children's
* Comedy
* Crime
* Documentary
* Drama
* Fantasy
* Film-Noir
* Horror
* Musical
* Mystery
* Romance
* Sci-Fi
* Thriller
* War
* Western
矩阵分解的代码
import torch
import torch.nn as nn
import pandas as pd
import numpy as np
from torch.utils.data import DataLoader, Dataset, TensorDataset
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
from utils.loss_plot import semilogy
from utils.k_fold import get_k_fold_data
# 设置基础参数
batch_size = 1024
device = torch.device('cpu')
num_epochs = 50
learning_rate = 0.0006
weight_decay = 0.1
class MfDataset(Dataset):
def __init__(self, u_id, i_id, rating):
self.u_id = u_id
self.i_id = i_id
self.rating = rating
def __getitem__(self, index):
return self.u_id[index], self.i_id[index], self.rating[index]
def __len__(self):
return len(self.rating)
# 定义模型
class MF(nn.Module):
def __init__(self, num_users, num_items, mean, embedding_size=100):
super(MF, self).__init__()
self.user_emb = nn.Embedding(num_users, embedding_size)
self.user_bias = nn.Embedding(num_users, 1)
self.item_emb = nn.Embedding(num_items, embedding_size)
self.item_bais = nn.Embedding(num_items, 1)
self.user_emb.weight.data.uniform_(0, 0.005) # 0-0.05之间均匀分布
self.user_bias.weight.data.uniform_(-0.01, 0.01)
self.item_emb.weight.data.uniform_(0, 0.005)
self.item_bais.weight.data.uniform_(-0.01, 0.01)
# 将不可训练的tensor转换成可训练的类型parameter,并绑定到module里,net.parameter()中就有了这个参数
self.mean = nn.Parameter(torch.FloatTensor([mean]), False)
def forward(self, u_id, i_id):
U = self.user_emb(u_id)
b_u = self.user_bias(u_id).squeeze()
I = self.item_emb(i_id)
b_i = self.item_bais(i_id).squeeze()
return (U * I).sum(1) + b_i + b_u + self.mean
def train(model, X_train, y_train, X_valid, y_valid, loss_func, num_epochs, learning_rate, weight_decay, batch_size):
train_ls, valid_ls = [], []
train_dataset = MfDataset(X_train[:, 0], X_train[:, 1], y_train)
train_iter = DataLoader(train_dataset, batch_size)
# 使用Adam优化算法
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
model = model.float()
for epoch in range(num_epochs):
model.train() # 如果模型中有Batch Normalization或Dropout层,需要在训练时添加model.train(),使起作用
total_loss, total_len = 0.0, 0
for x_u, x_i, y in train_iter:
x_u, x_i, y = x_u.to(device), x_i.to(device), y.to(device)
y_pred = model(x_u, x_i)
l = loss_func(y_pred, y).sum()
optimizer.zero_grad()
l.backward()
optimizer.step()
total_loss += l.item()
total_len += len(y)
train_ls.append(total_loss / total_len)
if X_valid is not None:
model.eval()
with torch.no_grad():
n = y_valid.shape[0]
valid_loss = loss_func(model(X_valid[:, 0], X_valid[:, 1]), y_valid)
valid_ls.append(valid_loss / n)
print('epoch %d, train mse %f, valid mse %f' % (epoch + 1, train_ls[-1], valid_ls[-1]))
return train_ls, valid_ls
# 训练,k折交叉验证
def train_k_fold(k, X_train, y_train, num_epochs, learning_rate, weight_decay, batch_size, num_users, num_items,
mean_rating):
train_l_sum, valid_l_sum = 0.0, 0.0
loss = torch.nn.MSELoss(reduction="sum").to(device)
for i in range(k):
model = MF(num_users, num_items, mean_rating).to(device)
data = get_k_fold_data(k, i, X_train, y_train)
train_ls, valid_ls = train(model, *data, loss, num_epochs, learning_rate, weight_decay, batch_size)
train_l_sum += train_ls[-1]
valid_l_sum += valid_ls[-1]
if i == k:
semilogy(range(1, num_epochs + 1), train_ls, "epochs", "mse", range(1, num_epochs + 1), valid_ls,
["train", "valid"])
print('fold %d, train mse %f, valid mse %f' % (i, train_ls[-1], valid_ls[-1]))
print("-------------------------------------------")
def main():
# 加载数据
data = pd.read_csv('../dataset/u.data', header=None, delimiter='\t')
X, y = data.iloc[:, :2], data.iloc[:, 2]
# 转换成tensor
X = torch.tensor(X.values, dtype=torch.int64)
y = torch.tensor(y.values, dtype=torch.float32)
# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=2020)
mean_rating = data.iloc[:, 2].mean()
num_users, num_items = max(data[0]) + 1, max(data[1]) + 1
# 交叉验证选择最优超参数
# train_k_fold(8, X_train, y_train, num_epochs=num_epochs, learning_rate=learning_rate,
# weight_decay=weight_decay, batch_size=batch_size, num_users=num_users, num_items=num_items,
# mean_rating=mean_rating)
model = MF(num_users, num_items, mean_rating).to(device)
loss = torch.nn.MSELoss(reduction="sum")
train_ls, test_ls = train(model, X_train, y_train, X_test, y_test, loss, num_epochs, learning_rate, weight_decay, batch_size)
semilogy(range(1, num_epochs + 1), train_ls, "epochs", "mse", range(1, num_epochs + 1), test_ls, ["train", "test"])
print("\nepochs %d, mean train loss = %f, mse = %f" % (num_epochs, np.mean(train_ls), np.mean(test_ls)))
if __name__ == '__main__':
main()