推荐系统入门实践(三)
《动手深度学习》第二版,推荐系统章节的个人理解以及pytorch代码实现。
李沐大神的《动手深度学习》第二版已经在更新了,目前只有英文版。并且推荐系统章节只有mxnet实现,这是本人看完之后的理解以及自己写的pytorch代码实现。对于入门推荐系统很有帮助,有兴趣的可以看一看。
前言
前面两节介绍的矩阵分解和AutoRec方法都基于协同过滤的 M a t r i x Matrix Matrix共现矩阵实现。而共现矩阵有一个重要的假设:每一个用户都对一小部分商品做出过分数评价,每一件商品都被一小部分用户评价过。
我们不得不考虑这里的假设并不总是成立的,因为很多用户的习惯是只浏览商品而不轻易给出评价。这就导致了两个问题:
- M a t r i x Matrix Matrix矩阵可能过于稀疏以至于无法进行有效的补全。
- 大量的用户浏览数据无法得到有效利用。
一、显性反馈和隐性反馈
所谓显性反馈就是指用户给出的具体反馈,比如,评分、点赞、收藏等操作。这种反馈可以直接传达用户是否喜欢以及喜欢的程度。
隐性反馈通常指的是浏览等中性操作,对此类数据不能像显性反馈一样使用。但是我们仍然给出一个比较合理的假设:如果用户浏览了某个商品,那他对这件商品的喜欢程度至少胜过那些他尚未见过的商品。
这个假设当然不是总成立的,因为从事实的角度来看,在海量的未被发现的商品中很可能会存在用户更喜欢的商品,而这正是推荐系统需要找出的。不过,看到后面我们会发现这种假设也颇为合理。
二、损失函数
在显性反馈的条件下,我们的损失函数可以定义为预测评分 h ( R ∗ i ) h(\mathbf{R}_{*i}) h(R∗i)与真实评分 R ∗ i \mathbf{R}_{*i} R∗i的均方根误差函数。由此训练出的模型会预测出用户对每一件商品的评分值,然后我们可以简单的根据评分值的大小排序筛选出需要推荐给用户的商品。
那对于隐性反馈应该怎么做?在没有评分值的情况下又如何定义损失函数?
1.贝叶斯个性化排名损失函数
这里我们一开始的假设的作用就体现出来了。
和未见过的商品相比我们认为用户会更喜欢自己曾经浏览过的商品,而现在我们是有浏览商品数据和未见过的商品数据的。
所以我们只需要把数据处理成三元组的形式 ( u , i , j ) (u, i, j) (u,i,j)。这里 u u u是用户, i i i是浏览数据中的一个(用户更喜欢), j j j是未曾观察过的数据中的一个。当这三组数据分别通过embedding矩阵生成embedding向量时自然就有 u i ⊤ \mathbf{ui}^\top ui⊤大于 u j ⊤ \mathbf{uj}^\top uj⊤。因为用户喜欢商品 i i i,所以内积自然要更大一些。
现在,再考虑两个问题。
- 这种大于关系要大多少合适?越大越好,但由于商品关系错综复杂这种差距很难拉开。
- 如果未观察过的商品得分都偏低,我们还如何推荐? 对神经网络而言无论如何它的输出都会是数字,所以在一堆低分中也必然存在着大小关系(和浏览过的商品类似的未知商品分值总会大一些),只要我们不把那些用户已经浏览过的商品放入候选推荐列表,通过矮子里拔状元的方式就可以选出来了。
现在来看损失函数具体的形式:
Loss = ∑ ( u , i , j ∈ D ) ln σ ( y ^ u i − y ^ u j ) − λ Θ ∥ Θ ∥ 2 \text{Loss}=\sum_{(u, i, j \in D)} \ln \sigma(\hat{y}_{ui} - \hat{y}_{uj}) - \lambda_\Theta \|\Theta \|^2 Loss=∑(u,i,j∈D)lnσ(y^ui−y^uj)−λΘ∥Θ∥2
这里的含义很明显, y ^ u i \hat{y}_{ui} y^ui是用户和喜欢商品(浏览过)的内积, y ^ u j \hat{y}_{uj} y^uj是用户和不喜欢商品(还未见到)的内积,最后一项是正则化项。很明显两个内积的差值越大损失函数越小。
这里重新给出补充过的工具包d2l,如果对前面协同过滤部分不感兴趣就不用回去看前两节了。
链接: 百度网盘.
提取码:vucr
代码如下:
from d2l import torch as d2l
import torch
from torch import nn
import os
import pandas as pd
import numpy as np
import random
class BPRLoss(nn.Module):
def __init__(self):
super().__init__()
def forward(self, positive, negative):
distances = positive - negative
loss = - torch.sum(torch.log(torch.sigmoid(distances)), dim=0, keepdim=True)
return loss
2.铰链损失函数
含义和贝叶斯类似,具体公式如下:
∑ ( u , i , j ∈ D ) max ( m − y ^ u i + y ^ u j , 0 ) \sum_{(u, i, j \in D)} \max( m - \hat{y}_{ui} + \hat{y}_{uj}, 0) ∑(u,i,j∈D)max(m−y^ui+y^uj,0)
这里 m m m是一个超参数(这里设为1),整个函数含义也很简单:两个内积的差值要尽量大于 m m m,否则就要受惩罚。
代码如下
class HingeLossbRec(nn.Module):
def __init__(self):
super().__init__()
def forward(self, positive, negative, margin=1):
distances = positive - negative
loss = torch.sum(torch.max(margin - distances, torch.tensor(0., device=torch.device('cuda'))))
return loss
3.评估函数
前两节我们的评估函数采用的是和损失函数一样的RMSE,这里需要重新设计。
这里先重新说明一下数据的加载形式,和前两节相比,我们直接将用户评分过的电影(除了最后一个评价的)当作浏览过,把未评分以及最后一部评分的电影当作未知电影。
那么我们的评测标准就是计算用户和所有未知电影(实际包含一个最新浏览过的)的内积,然后对这些内积排序。如果模型训练良好,那么那个事实上被浏览过的电影 n e w e s t newest newest排名应该比较靠前。
这里需要注意的是, n e w e s t newest newest并不一定靠的很前,因为大量未知电影中很可能事实上隐藏着用户更喜欢的。
这里评估函数主要包括两部分,公式如下:
Hit @ ℓ = 1 m ∑ u ∈ U 1 ( r a n k u , g u < = ℓ ) \text{Hit}@\ell = \frac{1}{m} \sum_{u \in \mathcal{U}} \textbf{1}(rank_{u, g_u} <= \ell) Hit@ℓ=m1∑u∈U1(ranku,gu<=ℓ)
AUC = 1 m ∑ u ∈ U 1 ∣ I \ S u ∣ ∑ j ∈ I \ S u 1 ( r a n k u , g u < r a n k u , j ) \text{AUC} = \frac{1}{m} \sum_{u \in \mathcal{U}} \frac{1}{|\mathcal{I} \backslash S_u|} \sum_{j \in I \backslash S_u} \textbf{1}(rank_{u, g_u} < rank_{u, j}) AUC=m1∑u∈U∣I\Su∣1∑j∈I\Su1(ranku,gu<ranku,j)
公式比较晦涩,我做一个通俗的解释。
第一个是说,我们从未知电影排名中选出前 ℓ \ell ℓ(这里是50)个,如果 n e w e s t newest newest在里面那评分就是1,否则是0。这里前面解释过, n e w e s t newest newest未必很靠前,所以 ℓ \ell ℓ要大一些。
第二个是比如 n e w e s t newest newest排第一,那么值就是(50-1+1)/50 =1, 如果是第32, 那么值就是(50-32+1)/50,最小在 n e w e s t newest newest未出现在排名中值为0.
代码如下:
def hit_and_auc(rankedlist, test_matrix, k):
hits_k = [(idx, val) for idx, val in enumerate(rankedlist[:k])
if val in set(test_matrix)]
hits_all = [(idx, val) for idx, val in enumerate(rankedlist)
if val in set(test_matrix)]
max = len(rankedlist) - 1
auc = 1.0 * (max - hits_all[0][0]) / max if len(hits_all) > 0 else 0
return len(hits_k), auc
def evaluate_ranking(net, test_input, seq, candidates, num_users, num_items, device):
ranked_list, ranked_items, hit_rate, auc = {}, {}, [], []
all_items = set([i for i in range(num_items)])
for u in range(num_users):
neg_items = list(all_items - set(candidates[int(u)]))
user_ids, item_ids, x, scores = [], [], [], []
[item_ids.append(i) for i in neg_items]
[user_ids.append(u) for _ in neg_items]
x.extend([torch.tensor(user_ids)])
if seq is not None:
x.append(seq[user_ids, :])
x.extend([torch.tensor(item_ids)])
x = torch.utils.data.TensorDataset(*x)
test_data_iter = torch.utils.data.DataLoader(x, batch_size=1024, shuffle=False)
for values in test_data_iter:
values = [value.to(device) for value in values]
scores.extend([net(*values)])
scores = [item for sublist in scores for item in sublist]
item_scores = list(zip(item_ids, scores))
ranked_list[u] = sorted(item_scores, key=lambda t: t[1], reverse=True)
ranked_items[u] = [r[0] for r in ranked_list[0]]
temp = hit_and_auc(ranked_items[u], test_input[u], 50)
hit_rate.append(temp[0])
auc.append(temp[1])
return torch.mean(torch.tensor(hit_rate)), torch.mean(torch.tensor(auc))
4.数据准备
为了生成三元组形式的数据我们需要重新写一个数据类。
代码很简单:
class PRDataset(torch.utils.data.Dataset):
def __init__(self, users, items, candidates, num_items):
self.users = users
self.items = items
self.cand = candidates
self.all = set([i for i in range(num_items)])
def __len__(self):
return len(self.users)
def __getitem__(self, idx):
neg_items = list(self.all - set(self.cand[int(self.users[idx])]))
indices = random.randint(0, len(neg_items) - 1)
return torch.tensor(self.users[idx]), torch.tensor(self.items[idx]), torch.tensor(neg_items[indices])
5.模型
模型是一个比较简单的部分。看一看模型图加代码很容易就能理解。模型事实上只有嵌入层和线性层两种,NeuMF层就是一个普通线性层。
class NeuMF(nn.Module):
def __init__(self, num_factors, num_users, num_items, nums_hiddens):
super().__init__()
self.P = nn.Embedding(num_users, num_factors)
self.Q = nn.Embedding(num_items, num_factors)
self.U = nn.Embedding(num_users, num_factors)
self.V = nn.Embedding(num_items, num_factors)
self.mlp = nn.Sequential(nn.Linear(in_features=2 * num_factors, out_features=nums_hiddens[0]),
nn.ReLU())
for i, _ in enumerate(nums_hiddens):
if i == 0:
continue
self.mlp.add_module('linear' + str(i), nn.Linear(in_features=nums_hiddens[i - 1], out_features=nums_hiddens[i]))
self.mlp.add_module('act' + str(i), nn.ReLU())
self.prediction_layer = nn.Sequential(nn.Linear(in_features=num_factors + nums_hiddens[-1], out_features=1, bias=False),
nn.Sigmoid())
def forward(self, user_id, item_id):
p_mf = self.P(user_id)
q_mf = self.Q(item_id)
gmf = p_mf * q_mf
p_mlp = self.U(user_id)
q_mlp = self.V(item_id)
mlp = self.mlp(torch.cat([p_mlp, q_mlp], dim=1))
con_res = torch.cat([gmf, mlp], dim=1)
return self.prediction_layer(con_res)
6.训练
先写一个训练函数,和前两节差不多,只是我习惯每次重写。
这里着重说明一下,由于未知电影数据集太大,所以这里的评估函数运行极慢,所以我把代码注释掉了,大家看一下进行了。想跑也可以试一试。
def train_ranking(net, train_iter, test_iter, loss, optimizer, test_seq_iter,
num_users, num_items, num_epochs, device, evaluator, candidates, eval_step=1):
timer, hit_rate, auc = d2l.Timer(), 0, 0
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0, 1],
legend=['test hit rate', 'test AUC'])
net = net.to(device)
for epoch in range(num_epochs):
metric, train_l, i = d2l.Accumulator(3), 0, 0
for u, item, neg_i in train_iter:
i += 1
u, item, neg_i = u.to(device), item.to(device), neg_i.to(device)
p_pos = net(u, item)
p_neg = net(u, neg_i)
optimizer.zero_grad()
l = loss(p_pos, p_neg)
train_l += l
l.backward()
optimizer.step()
metric.add(train_l, u.shape[0], u.shape[0])
timer.stop()
if (epoch + 1) % eval_step == 0:
continue
#hit_rate, auc = evaluator(net, test_iter, test_seq_iter,
# candidates, num_users, num_items, device)
#animator.add(epoch + 1, (1, 1))
print(f'train loss {metric[0] / metric[1]:.3f}, '
f'test hit rate {float(hit_rate):.3f}, test AUC {float(auc):.3f}')
print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
f'on {str(devices)}')
做一些初始化就可以跑了。
batch_size = 1024
df, num_users, num_items = d2l.read_data_ml100k()
train_data, test_data = d2l.split_data_ml100k(df, num_users, num_items, 'seq-aware')
users_train, items_train, ratings_train, candidates = d2l.load_data_ml100k(
train_data, num_users, num_items, feedback='implicit')
users_test, items_test, ratings_test, test_iter = d2l.load_data_ml100k(test_data,
num_users, num_items, feedback='implicit')
train_set = PRDataset(users_train, items_train, candidates, num_items)
train_iter = torch.utils.data.DataLoader(train_set, batch_size=batch_size, shuffle=True, drop_last=True)
device = torch.device('cuda')
net = NeuMF(10, num_users, num_items, nums_hiddens=[10, 10, 10])
for param in net.parameters():
nn.init.normal_(param, std=0.01)
train_ranking(net, train_iter, test_iter, loss, optimizer, None, num_users,
num_items, num_epochs, devices, evaluate_ranking, candidates)