为了缓解当下数据过载的现象,使用户更加快速的获取自己想要的信息,这就是推荐系统出现的必要性。推荐系统主要从以下三个阶段从海量的数据信息中筛选出用户可能感兴趣的N个物品,即:
本次组队学习的任务重点在于召回阶段,所以本博客所记录的主要内容也以召回为主。召回的特点在上一小节中也有了介绍。
基于规则的召回算法
简介:这种方式原理较为简单,就是提前制定好某种规则来统计目标物品,比如就根据点击率来召回若干项目
基于协同过滤的召回算法
简介:这种一种非常经典,而且目前仍在使用的召回算法类型,核心思想是物以类聚人以群分,细分为基于项目和基于用户的协同过滤,两种算法形式都以若干用户(或者项目)对目标用户的影响强度为基础来建模,最终得到召回列表
后来衍生出了基于模型的协同过滤算法,最经典的就是MF模型,通过在潜在空间对用户和项目进行建模,得到一个低维的embedding
基于向量的召回算法
简介:这类召回算法大多基于深度学习,主要是通过各类神经网络学习到更加精准的用户和项目的embedding表示,比如最近很火的GNN ,通过用户和项目的历史交互数据来完成信息的聚合传播过程,在这一过程中,用户和项目的embedding的精确度得到了很大的提升。
session recommendation 是推荐系统当中的一个子分支,解决的是匿名用户的推荐问题,也就是在网络当中建立一次会话当中生成即时推荐,换句话说就是不知道用户是谁,这种方法可以对一些不需要登陆,或者无法记录用户浏览行为的网站快速产生有效的推荐。
在B站上看到了一个简短但是很有意思的解释RNN的视频:RNN的通俗解释 制作by up主耿直哥、
GRU是一个精细的RNN的单元,被提出用来解决梯度消失的问题,其可以了解何时更新单元的隐藏状态以及具体更新多少个单元,GRU的激活是线性的,由上一阶段的激活和候选激活组成:
h t = ( 1 − z t ) h t − 1 + z t h ^ t \mathbf{h}_{\mathbf{t}}=\left(1-\mathbf{z}_{\mathbf{t}}\right) \mathbf{h}_{\mathbf{t}-1}+\mathbf{z}_{\mathbf{t}} \hat{\mathbf{h}}_{\mathbf{t}} ht=(1−zt)ht−1+zth^t
其中 z t = σ ( W z x t + U z h t − 1 ) \mathbf{z}_{\mathbf{t}}=\sigma\left(W_{z} \mathbf{x}_{\mathbf{t}}+U_{z} \mathbf{h}_{\mathbf{t}-\mathbf{1}}\right) zt=σ(Wzxt+Uzht−1), h t ^ = tanh ( W x t + U ( r t ⊙ h t − 1 ) ) \hat{\mathbf{h}_{\mathbf{t}}}=\tanh \left(W \mathbf{x}_{\mathbf{t}}+U\left(\mathbf{r}_{\mathbf{t}} \odot \mathbf{h}_{\mathbf{t}-\mathbf{1}}\right)\right) ht^=tanh(Wxt+U(rt⊙ht−1))
然后最终得到的输出是
r t = σ ( W r x t + U r h t − 1 ) \mathbf{r}_{\mathbf{t}}=\sigma\left(W_{r} \mathbf{x}_{\mathbf{t}}+U_{r} \mathbf{h}_{\mathbf{t}-1}\right) rt=σ(Wrxt+Urht−1)
下图是GRU的计算示意图:
模型的初始输入是用项目独热编码生成了初始embedding作为输入层,模型的核心是GRU层,可以在最后一层和输出之间增加额外的全连接层层。输出是项目的预测偏好,即每个项目成为会话中下一个项目的可能性。当使用多个GRU层时,前一层的隐藏状态是下一层的输入,下图是模型的框架:
此外,为了使RNN更好地适应推荐任务,本模型还做了以下几点改进:
传统的RNN主要用于NLP的任务场景,通常是基于一个滑动窗口在一个句子的单词上进行滑动,将这些窗口片段相邻的位置片段形成一个小批量。但是由于会话推荐任务中,每个会话的长度是不一样的,而且本模型的最终的目的是捕捉一个会话如何随着时间的推移而演变的,所以传统GRU单元并不能很好的处理会话推荐问题。
本文为了解决这个问题,提出了一种并发式的小批量样本采集。首先,我们为会话创建一个顺序。然后,我们使用前X个会话的第一个事件形成第一个小批处理的输入(期望的输出是活动会话的第二个事件)。第二个小批处理由第二个事件形成,以此类推。如果任何一个会话结束,则将下一个可用的会话放入其位置。假定会话是独立的,因此当发生这种切换时,我们重置适当的隐藏状态,下图是本方法的示意图:
因为推荐任务所设计到的物品数量很多,如果单独为每一个项目都计算一个分数,会花费很多时间,所以本文提出的模型采用了一种抽样的方式来计算一小部分的项目分数,具体采样方式是根据物品的受欢迎程度来采样,这和本模型所采用的BPRloss也是遥相呼应的。
(当然组队学习中给出的代码中有基于Faiss的方法,可以有效解决这一问题),具体的在附录的代码中会有所体现
本模型的最终目的是要完成推荐任务,所以损失函数的选取要贴合推荐任务的背景,本文所选取的损失函数是衡量能够有效衡量排序结果好坏的BPR损失函数:
Loss = ∑ ( u , i , j ) ∈ O − ln σ ( y ( u , i ) − y ( u , j ) ) + β ⋅ ∥ Θ ∥ 2 \text { Loss }=\sum_{(u, i, j) \in O}-\ln \sigma(y(u, i)-y(u, j))+\beta \cdot\|\Theta\|^{2} Loss =(u,i,j)∈O∑−lnσ(y(u,i)−y(u,j))+β⋅∥Θ∥2
其中y(u,i)和y(u,j)各自代表用户对交互过的项目的预测评分,和没有交互过的项目的预测评分, θ \theta θ代表模型中的所有参数, β \beta β是L2正则化参数
本文首次将RNN引入到推荐系统领域,利用GRU单元能够捕捉时序关系的功能,来进行会话推荐任务。而且本文为了更好的完成推荐任务,针对传统GRU进行了三方面的改进,从batch的选择到损失函数的选择都做出了很多优化。本文是RNN在推荐系统领域的一个里程碑的文章,对后来的一些模型产生了深刻的影响
class GRU4Rec(nn.Layer):
def __init__(self, config):
super(GRU4Rec, self).__init__()
self.config = config
self.embedding_dim = self.config['embedding_dim']
self.max_length = self.config['max_length']
self.n_items = self.config['n_items']
self.num_layers = self.config['num_layers']
self.item_emb = nn.Embedding(self.n_items, self.embedding_dim, padding_idx=0)
self.gru = nn.GRU(
input_size=self.embedding_dim,
hidden_size=self.embedding_dim,
num_layers=self.num_layers,
time_major=False,
)
self.loss_fun = nn.CrossEntropyLoss()
self.reset_parameters()
def calculate_loss(self,user_emb,pos_item):
all_items = self.item_emb.weight
scores = paddle.matmul(user_emb, all_items.transpose([1, 0]))
return self.loss_fun(scores,pos_item)
def output_items(self):
return self.item_emb.weight
def reset_parameters(self, initializer=None):
for weight in self.parameters():
paddle.nn.initializer.KaimingNormal(weight)
def forward(self, item_seq, mask, item, train=True):
seq_emb = self.item_emb(item_seq)
seq_emb,_ = self.gru(seq_emb)
user_emb = seq_emb[:,-1,:] #取GRU输出的最后一个Hidden作为User的Embedding
if train:
loss = self.calculate_loss(user_emb,item)
output_dict = {
'user_emb':user_emb,
'loss':loss
}
else:
output_dict = {
'user_emb':user_emb
}
return output_dict
在大规模向量存在的背景下,可以加快对向量的召回效率,更快地找到与目标向量相似的topk个向量,这个方法可以很好的解决本模型在 SAMPLING ON THE OUTPUT 中所提到的问题
def get_predict(model, test_data, hidden_size, topN=20):
item_embs = model.output_items().cpu().detach().numpy()
item_embs = normalize(item_embs, norm='l2')
gpu_index = faiss.IndexFlatIP(hidden_size)
gpu_index.add(item_embs)
test_gd = dict()
preds = dict()
user_id = 0
for (item_seq, mask, targets) in tqdm(test_data):
# 获取用户嵌入
# 多兴趣模型,shape=(batch_size, num_interest, embedding_dim)
# 其他模型,shape=(batch_size, embedding_dim)
user_embs = model(item_seq,mask,None,train=False)['user_emb']
user_embs = user_embs.cpu().detach().numpy()
# 用内积来近邻搜索,实际是内积的值越大,向量越近(越相似)
if len(user_embs.shape) == 2: # 非多兴趣模型评估
user_embs = normalize(user_embs, norm='l2').astype('float32')
D, I = gpu_index.search(user_embs, topN) # Inner Product近邻搜索,D为distance,I是index
# D,I = faiss.knn(user_embs, item_embs, topN,metric=faiss.METRIC_INNER_PRODUCT)
for i, iid_list in enumerate(targets): # 每个用户的label列表,此处item_id为一个二维list,验证和测试是多label的
test_gd[user_id] = iid_list
preds[user_id] = I[i,:]
user_id +=1
else: # 多兴趣模型评估
ni = user_embs.shape[1] # num_interest
user_embs = np.reshape(user_embs,
[-1, user_embs.shape[-1]]) # shape=(batch_size*num_interest, embedding_dim)
user_embs = normalize(user_embs, norm='l2').astype('float32')
D, I = gpu_index.search(user_embs, topN) # Inner Product近邻搜索,D为distance,I是index
# D,I = faiss.knn(user_embs, item_embs, topN,metric=faiss.METRIC_INNER_PRODUCT)
for i, iid_list in enumerate(targets): # 每个用户的label列表,此处item_id为一个二维list,验证和测试是多label的
recall = 0
dcg = 0.0
item_list_set = []
# 将num_interest个兴趣向量的所有topN近邻物品(num_interest*topN个物品)集合起来按照距离重新排序
item_list = list(
zip(np.reshape(I[i * ni:(i + 1) * ni], -1), np.reshape(D[i * ni:(i + 1) * ni], -1)))
item_list.sort(key=lambda x: x[1], reverse=True) # 降序排序,内积越大,向量越近
for j in range(len(item_list)): # 按距离由近到远遍历推荐物品列表,最后选出最近的topN个物品作为最终的推荐物品
if item_list[j][0] not in item_list_set and item_list[j][0] != 0:
item_list_set.append(item_list[j][0])
if len(item_list_set) >= topN:
break
test_gd[user_id] = iid_list
preds[user_id] = item_list_set
user_id +=1
return test_gd, preds
def evaluate(preds,test_gd, topN=50):
total_recall = 0.0
total_ndcg = 0.0
total_hitrate = 0
for user in test_gd.keys():
recall = 0
dcg = 0.0
item_list = test_gd[user]
for no, item_id in enumerate(item_list):
if item_id in preds[user][:topN]:
recall += 1
dcg += 1.0 / math.log(no+2, 2)
idcg = 0.0
for no in range(recall):
idcg += 1.0 / math.log(no+2, 2)
total_recall += recall * 1.0 / len(item_list)
if recall > 0:
total_ndcg += dcg / idcg
total_hitrate += 1
total = len(test_gd)
recall = total_recall / total
ndcg = total_ndcg / total
hitrate = total_hitrate * 1.0 / total
return {f'recall@{topN}': recall, f'ndcg@{topN}': ndcg, f'hitrate@{topN}': hitrate}
# 指标计算
def evaluate_model(model, test_loader, embedding_dim,topN=20):
test_gd, preds = get_predict(model, test_loader, embedding_dim, topN=topN)
return evaluate(preds, test_gd, topN=topN)