推荐系统入门实践(一)
《动手深度学习》第二版,推荐系统章节的个人理解以及pytorch代码实现。
李沐大神的《动手深度学习》第二版已经在更新了,目前只有英文版。并且推荐系统章节只有mxnet实现,这是本人看完之后的理解以及自己写的pytorch代码实现。代码和mxnet版本大部分相同,有兴趣的可以看一看。
前言
对于推荐系统入门而言,本文不会过多的对协同过滤、召回、排序等概念进行强调。更多的是从推荐二字直观的概念上进行讲解,比较适合入门者学习。
一、什么是推荐?
如果以商品为例,推荐简单来说就是从海量商品中选出用户可能喜欢的少量商品并呈现给用户。这一过程是一个双向共赢的过程,对用户可以节约时间,对企业可以增加商品购买率。那么如何进行推荐系统的设计?
如果你对词嵌入/词向量的概念有所了解的话可能会有一个不错的想法。我们可以将每一个用户用一个
k
k
k维向量
u
∈
R
k
u\in\mathbb R^k
u∈Rk表示,每一个商品用一个
k
k
k维向量
i
∈
R
k
i\in\mathbb R^k
i∈Rk表示。然后对
k
k
k和
i
i
i做内积,如果结果比较大那么可以将该商品推荐给用户,否则就不推荐。事实上,这种思想就是矩阵分解,也是我们首先要说的推荐算法思想。
下面考虑如何找出这些用户向量和商品向量。
二、协同过滤和矩阵分解
假设用户的数量为 m m m, 商品的数量为 n n n。那么我们需要找出的就是用户矩阵 P ∈ R m × k P\in\mathbb R^{m \times k} P∈Rm×k和商品矩阵 Q ∈ R n × k Q\in\mathbb R^{n \times k} Q∈Rn×k。 P P P矩阵的第 i i i行 u i u^i ui代表第 i i i个用户的向量。 Q Q Q矩阵的含义同理。下面讲述具体方法。
1.协同过滤
在讲解协同过滤的概念之前我们首先要有一个假设:每一个用户都对一小部分商品做出过分数评价,每一件商品都被一小部分用户评价过。
根据假设我们可以得到一个以用户为行以商品为列的共现矩阵 M a t r i x ∈ R m × n Matrix\in\mathbb R^{m \times n} Matrix∈Rm×n,如果用户 u u u评价过商品 i i i就将 M a t r i x [ u ] [ i ] Matrix [u][i] Matrix[u][i]设为具体的评分值,否则置为0(含义是未知)。很明显该 m × n m \times n m×n矩阵只有一小部分非零元素。而协同过滤的思想就是用这一小部分的非零元素推出 M a t r i x Matrix Matrix矩阵的未知元素。这是一个协同大家的反馈、评价和意见对海量信息进行过滤的过程,因此被称为协同过滤。
2.矩阵分解
了解了协同过滤的概念之后让我们回到最初的问题。我们的目的是找出代表用户们喜好的用户矩阵
P
∈
R
m
×
k
P\in\mathbb R^{m \times k}
P∈Rm×k和商品矩阵
Q
∈
R
n
×
k
Q\in\mathbb R^{n \times k}
Q∈Rn×k。共现矩阵就是让我们找到这两个矩阵的工具。看下图:
对此图片通俗的解释就是:如果用户
u
u
u对商品
i
i
i做出了评分
s
c
o
r
e
score
score,那么我们就调整
U
U
U矩阵中的
u
u
u向量和
I
I
I中的
i
i
i向量让它们的内积值尽可能接近
s
c
o
r
e
score
score。因此,在我们最初的假设成立的情况下,理论上可以根据有限的评分值调整出合理的用户矩阵和商品矩阵。随后,根据得到的
P
P
P、
Q
Q
Q就可以再计算出
M
a
t
r
i
c
Matric
Matric的所有值。
事实上,这一过程并不适合叫做矩阵分解,因为它更像是一个反向合成的过程。通过不断调整用户向量 u u u和商品向量 i i i使得它们的内积逼近已知的具体评分。不过,因为得到 P P P和 Q Q Q还可以采用奇异值分解的方法,所以这种方法才被称为矩阵分解。
3.代码部分
为了实现代码,我们还需要具体的损失函数公式等信息。这一部分会一边实现一边讲解。
(1)数据集
我们使用的是 MovieLens数据集,该数据集有四列,含义分别是用户编号、电影编号、用户评价分数、时间戳。数据集中共有943名用户、1682部电影以及5个不同评级,经过数据清理后保证每名用户评价了至少二十部电影,最终数据共有100000行。
在开始前,你还需要一份《动手深度学习》第二版官方的工具包(第一版的不行),该工具包是本书前面章节写过的代码的集合。我把链接放在下面。
链接: 百度网盘.
提取码:c6wd
此外,还有一点需要注意,该工具包包含许多Ipython的内容,因此确保你使用的是anaconda以及Juptyer notebook
首先导入包
from d2l import torch as d2l
import torch
import numpy as np
from torch import nn
import os
import pandas as pd
然后调用工具包中的下载程序,返回的是data(100000行四列),用户数量(943),电影数量(1682),此外,data中的数据此时已经按用户评价时间排序。
d2l.DATA_HUB['ml-100k'] = (
'http://files.grouplens.org/datasets/movielens/ml-100k.zip',
'cd4dcac4241c8a4ad7badc7ca635da8a69dddb83')
def read_data_ml100k():
data_dir = d2l.download_extract('ml-100k')
names = ['user_id', 'item_id', 'rating', 'timestamp']
data = pd.read_csv(os.path.join(data_dir, 'u.data'), '\t', names=names,
engine='python')
num_users = data.user_id.unique().shape[0]
num_items = data.item_id.unique().shape[0]
return data, num_users, num_items
分割测试集和训练集,事实上我们接下来使用的都是’seq-aware‘模式。我们将用户最新的评价作为测试集,用过去预测现在。
def split_data_ml100k(data, num_users, num_items,
split_mode='random', test_ratio=0.1):
if split_mode == 'seq-aware':
train_items, test_items, train_list = {}, {}, []
for line in data.itertuples():
u, i, rating, time = line[1], line[2], line[3], line[4]
train_items.setdefault(u, []).append((u, i, rating, time))
if u not in test_items or test_items[u][-1] < time:
test_items[u] = (i, rating, time)
for u in range(1, num_users + 1):
train_list.extend(sorted(train_items[u], key=lambda k: k[3]))
test_data = [(key, *value) for key, value in test_items.items()]
train_data = [item for item in train_list if item not in test_data]
train_data = pd.DataFrame(train_data)
test_data = pd.DataFrame(test_data)
else:
mask = [True if x == 1 else False for x in np.random.uniform(
0, 1, (len(data))) < 1 - test_ratio]
neg_mask = [not x for x in mask]
train_data, test_data = data[mask], data[neg_mask]
return train_data, test_data
加载出协同过滤中的共现矩阵,我们暂时用的是’explicit‘模式。
def load_data_ml100k(data, num_users, num_items, feedback='explicit'):
users, items, scores = [], [], []
inter = torch.zeros((num_items, num_users)) if feedback == 'explicit' else {}
for line in data.itertuples():
user_index, item_index = int(line[1] - 1), int(line[2] - 1)
score = int(line[3]) if feedback == 'explicit' else 1
users.append(user_index)
items.append(item_index)
scores.append(score)
if feedback == 'implicit':
inter.setdefault(user_index, []).append(item_index)
else:
inter[item_index, user_index] = score
return users, items, scores, inter
最后,分割出用于训练和测试的迭代数据集。
def split_and_load_ml100k(split_mode='seq-aware', feedback='explicit',
test_ratio=0.1, batch_size=256):
data, num_users, num_items = read_data_ml100k()
train_data, test_data = split_data_ml100k(
data, num_users, num_items, split_mode, test_ratio)
train_u, train_i, train_r, _ = load_data_ml100k(
train_data, num_users, num_items, feedback)
test_u, test_i, test_r, _ = load_data_ml100k(
test_data, num_users, num_items, feedback)
train_set = torch.utils.data.TensorDataset(torch.tensor(np.array(train_u)), torch.tensor(np.array(train_i)), torch.tensor(np.array(train_r)))
test_set = torch.utils.data.TensorDataset(torch.tensor(np.array(test_u)), torch.tensor(np.array(test_i)), torch.tensor(np.array(test_r)))
train_iter = torch.utils.data.DataLoader(train_set, shuffle=True, batch_size=batch_size)
test_iter = torch.utils.data.DataLoader(test_set, batch_size=batch_size)
return num_users, num_items, train_iter, test_iter
最终我们的数据集会处理成类似三元组的形式(u, i, score),表示用户 u u u对电影 i i i做出了 s c o r e score score的评价。此时,我们已经把离散的共现矩阵拆成了具体的值的形式,但是后面还会用到矩阵形式。
(2)模型部分
具体的模型很简单,只需要调用nn模块的Embedding层,最后计算内积即可。
class MF(nn.Module):
def __init__(self, num_factors, num_users, num_items):
super().__init__()
self.P = nn.Embedding(num_embeddings=num_users, embedding_dim=num_factors)
self.Q = nn.Embedding(num_embeddings=num_items, embedding_dim=num_factors)
self.user_bias = nn.Embedding(num_embeddings=num_users, embedding_dim=1)
self.item_bias = nn.Embedding(num_embeddings=num_items, embedding_dim=1)
def forward(self, user_id, item_id):
user_id = user_id.long()
item_id = item_id.long()
P_u = self.P(user_id)
Q_i = self.Q(item_id)
b_u = self.user_bias(user_id)
b_i = self.item_bias(item_id)
outputs = (P_u * Q_i).sum(dim=-1) + torch.squeeze(b_u) + torch.squeeze(b_i)
return outputs.flatten()
损失函数部分,首先注意模型部分我们还设计了self.user_bias和self.item_bias部分,这两块和普通的bias作用类似分别代表用户偏差和商品偏差。比如,善良的用户可能会给出至少三分的评价,其余用户会给出很低的评分,这种偏差应该由user_bias记录。
根据模型我们知道,我们预测的用户评分公式如下:
R ^ u i = p u q i ⊤ + b u + b i \hat{\mathbf{R}}_{ui} = \mathbf{p}_u\mathbf{q}^\top_i + b_u + b_i R^ui=puqi⊤+bu+bi
损失函数采用均方根误差函数RMSE以及L2正则化,我们计算实际的 s c o r e score score值和预测的值之间的误差。公式如下:
a r g m i n P , Q , b ∑ ( u , i ) ∈ K ∥ R u i − R ^ u i ∥ 2 + λ ( ∥ P ∥ F 2 + ∥ Q ∥ F 2 + b u 2 + b i 2 ) \underset{\mathbf{P}, \mathbf{Q}, b}{\mathrm{argmin}} \sum_{(u, i) \in \mathcal{K}} \| \mathbf{R}_{ui} - \hat{\mathbf{R}}_{ui} \|^2 + \lambda (\| \mathbf{P} \|^2_F + \| \mathbf{Q} \|^2_F + b_u^2 + b_i^2 ) P,Q,bargmin∑(u,i)∈K∥Rui−R^ui∥2+λ(∥P∥F2+∥Q∥F2+bu2+bi2)
由于,pytorch没有默认的RMSE函数,所以我们自己实现。正则化只需要最后在迭代器添加参数即可。
class RMSE(nn.Module):
def forward(self, labels, pred):
outputs = torch.sqrt(torch.mean((labels - pred) ** 2))
return outputs
评估函数同样采用RMSE即可。
def evaluator(net, test_iter, device):
rmse = RMSE()
rmse_list = []
for idx, (users, items, ratings) in enumerate(test_iter):
users = users.to(device)
items = items.to(device)
ratings = ratings.to(device)
r_hat = [net(u, i) for u, i in zip(users, items)]
rmse_list.append(rmse(ratings, torch.tensor(r_hat, device=device)))
return torch.mean(torch.tensor(rmse_list))
最后实现训练函数
def train_recsys_rating(net, train_iter, test_iter, loss, optimizer, num_epochs, devices=torch.device('cuda'), evaluator=None, **kwargs):
timer = d2l.Timer()
net = net.to(devices)
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0, 2],
legend=['train loss', 'test RMSE'])
for epoch in range(num_epochs):
metric, train_l, i = d2l.Accumulator(3), 0, 0
for users, items, scores in train_iter:
i += 1
users = users.to(devices)
items = items.to(devices)
scores = scores.to(devices)
pred = net(users, items)
optimizer.zero_grad()
l = loss(scores.float(), pred)
train_l += l.cpu().item()
l.backward()
optimizer.step()
metric.add(l, 1, users.shape[0])
timer.stop()
if len(kwargs) > 0:
test_rmse = evaluator(net, test_iter, kwargs['inter_mat'],
devices)
else:
test_rmse = evaluator(net, test_iter, devices)
animator.add(epoch + 1, (train_l / i, test_rmse))
print(f'train loss {metric[0] / metric[1]:.3f}, '
f'test RMSE {test_rmse:.3f}')
print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
f'on {str(devices)}')
设置运行参数就可以跑了,没有GPU的要设置成CPU。
devices = torch.device('cuda')
num_users, num_items, train_iter, test_iter = split_and_load_ml100k(
test_ratio=0.1, batch_size=512)
net = MF(30, num_users, num_items)
for parm in net.parameters():
nn.init.normal_(parm, std=0.01)
lr, num_epochs, wd = 0.002, 20, 1e-5
optimizer = torch.optim.Adam(net.parameters(), lr=0.002, weight_decay=wd)
loss = torch.nn.MSELoss()
train_recsys_rating(net, train_iter, test_iter, loss, optimizer, num_epochs,
devices, evaluator)
总结
注意把加载数据的函数复制到工具包中以便后面继续使用,其他的就不用了。