pytorch推荐库torch-rechub之DSSM模型召回实战

推荐系统发展至今,已经形成了一个相对稳定的链路。先召回(粗排)——>再排序(重排)。主要原因是随着推荐数量的变大,需要先通过召回从亿万级别的推荐池中筛选出千百个用户感兴趣的商品,然后再进行精细的排序。所以,召回模型一般都要处理亿万级别的数据,而排序模型只需要处理千百级别的数据量。

今天笔者准备来介绍一个目前推荐系统的比较知名的一个召回算法——DSSM。全称Deep Structured Semantic Model 深度语义网络,本身nlp领域是用来做文本相似度计算的,现在被推荐领域用来做召回。接下来我们来看看,到底如何使用DSSM做召回。

双塔模型细节

模型架构

模型架构入下图所示:两个非常标准的神经网络,一个用于生成User Embeding,一个用于生成Item Embeding.
这个模型的优势就是: User侧的模型和Item 侧的模型分离,后续serving时,只需将User侧的模型部署到线上, Item Embeding存在向量数据库,采用ANN检索的方式进行在线召回,并且可以轻松处理万亿级别的相似度计算。

劣势也是User侧的模型和Item 侧的模型分离,导致无法使用交叉特征,会大大影响模型最后的效果。


DSSM

归一化和温度系数

双塔模型中有两个非常重要的概念:温度系数与归一化。这两个概念都出现在双塔模型的前向运算的运行过程中:

  • 归一化: 即User Embeding 和 Item Embeding 进行L2 归一化之后,再进行向量的乘法运算,简而言之就是进行cosine距离的计算。这一步的目的,其实是为了与后续部署过程中采用ANN向量检索使用的距离保持一致。
  • 温度系数:模型输出的最终结果 其实是 cosine距离/ temperature , 这其实是归一化来一个问题,样本计算出cosine距离在[-1,1]之间,会使得正负样本差异变小,为了让模型更好学习,更快的收敛,引入温度系数去放大的模型计算出来的logit。

torch-rechub简介

torch-rechub 是一个基于pytorch实现的推荐算法库,目前已经实现很多非常知名的召回和排序的算法,笔者推荐这个repo的原因是这个包的代码可读性很高,我从源码中学习到了很多推荐模型的细节。项目地址如下:https://github.com/datawhalechina/torch-rechub。其中主要特性如下图所示。

torch-rechub

负采样的艺术

负样本的采样再召回领域可谓是重中之重,这里笔者简单介绍一下,torch-rechub目前实现的四种负样本采样算法。
0.随机负采样(random sampling):在全局样本中进行随机采样
1.word2vec基于流行度的负采样方式(popularity sampling method used in word2vec )
这种采样方式其实是借鉴了NLP领域词向量训练时的采样方式,采用x^0.75去处理点击次数, 公式如下:
i物品被选为负样本的概率 = count_i^0.75 / sum(count_i^0.75 )
count_i 表示 物品i的点击次数
2.log(count+1)基于流行度的负采样方式(popularity sampling method by log)
这是另外一种采样方式,采用log(x)去处理物品的点击次数,公式如下,
i物品被选为负样本的概率 np.log(count_i + 1) + 1e-6 / sum(np.log(count_i + 1) + 1e-6)
count_i 表示 物品i的点击次数
3.tencent RALM sampling
这是腾讯RALM模型提出的一种采用方式,公式如下:
i物品被选为负样本的概率 = [ log(count_i + 2) - log(count_i + 1) / log(len(items) + 1) ] / sum([ log(count_i + 2) - log(count_i + 1) / log(len(items) + 1) ])
count_i 表示 物品i的点击次数,items表示物品集合。
具体优势可以去看一下tencent RALM这个模型的原文

但是推荐召回的负采样算法不止这些,还可以再batch中进行采样,或者再样本中加一点hard 负样本等等。负样本采样再召回领域非常重要,一个好的负样本采样算法可以直接将召回率提升很多。深度学习领域,一份好的训练数据才是重中之重。接下来直接进入实战部分。

torch-rechub的DSSM模型实战部分

直接通过pip install torch-rechub就可以安装 torch-rechub,通过下方代码引入模块。

import sys
import os
import numpy as np
import pandas as pd
import torch
from sklearn.preprocessing import MinMaxScaler, LabelEncoder
from torch_rechub.models.matching import DSSM
from torch_rechub.trainers import MatchTrainer
from torch_rechub.basic.features import DenseFeature, SparseFeature, SequenceFeature
from torch_rechub.utils.match import generate_seq_feature_match, gen_model_input
from torch_rechub.utils.data import df_to_dict, MatchDataGenerator
# from movielens_utils import match_evaluation

数据载入

读取movielens的的数据集,数据集处理成下方格式。其中"user_id", "gender", "age", "occupation", "zip"是用户特征,"movie_id", "cate_id","title"是电影特征。

data_path = "./"
unames = ['user_id', 'gender', 'age', 'occupation', 'zip']
user = pd.read_csv(data_path+'ml-1m/users.dat',sep='::', header=None, names=unames)
rnames = ['user_id', 'movie_id', 'rating','timestamp']
ratings = pd.read_csv(data_path+'ml-1m/ratings.dat', sep='::', header=None, names=rnames)
mnames = ['movie_id', 'title', 'genres']
movies = pd.read_csv(data_path+'ml-1m/movies.dat', sep='::', header=None, names=mnames)
data = pd.merge(pd.merge(ratings,movies),user)#.iloc[:10000]
# data = data.sample(100000)
data

特征预处理以及训练集生成

采用下方代码去处理上面数据,从下方代码可知:

  • 用户塔的输入:user_cols = ['user_id', 'gender', 'age', 'occupation','zip','hist_movie_id']。这里面'user_id', 'gender', 'age', 'occupation', 'zip'为类别特征,采用embeding 层映射成8维向量。'hist_movie_id'为序列特征,将用户历史点击的moive_id 向量取平均。

  • 物品塔的输入: item_cols = ['movie_id', "cate_id"],这里面'movie_id', "cate_id"均为类别特征,采用embeding 层映射成8维向量。

需要注意的是 用户的hist_movie_id特征和物品的movie_id特征共享一个embeding层权重。

负采样使用的word2vec的采样方式,每个正样本采样2个负样本。

def get_movielens_data(data, load_cache=False):
    data["cate_id"] = data["genres"].apply(lambda x: x.split("|")[0])
    sparse_features = ['user_id', 'movie_id', 'gender', 'age', 'occupation', 'zip', "cate_id"]
    user_col, item_col = "user_id", "movie_id"

    feature_max_idx = {}
    for feature in sparse_features:
        lbe = LabelEncoder()
        data[feature] = lbe.fit_transform(data[feature]) + 1
        feature_max_idx[feature] = data[feature].max() + 1
        if feature == user_col:
            user_map = {encode_id + 1: raw_id for encode_id, raw_id in enumerate(lbe.classes_)}  #encode user id: raw user id
        if feature == item_col:
            item_map = {encode_id + 1: raw_id for encode_id, raw_id in enumerate(lbe.classes_)}  #encode item id: raw item id
    np.save("./data/raw_id_maps.npy", np.array((user_map, item_map), dtype=object))

    user_profile = data[["user_id", "gender", "age", "occupation", "zip"]].drop_duplicates('user_id')
    item_profile = data[["movie_id", "cate_id"]].drop_duplicates('movie_id')

    if load_cache:  #if you have run this script before and saved the preprocessed data
        x_train, y_train, x_test, y_test = np.load("./data/data_preprocess.npy", allow_pickle=True)
    else:
        #负采样使用的word2vec的采样方式,每个正样本采样2个负样本
        df_train, df_test = generate_seq_feature_match(data,
                                                       user_col,
                                                       item_col,
                                                       time_col="timestamp",
                                                       item_attribute_cols=[],
                                                       sample_method=2,
                                                       mode=0,
                                                       neg_ratio=2,
                                                       min_item=0)
        x_train = gen_model_input(df_train, user_profile, user_col, item_profile, item_col, seq_max_len=20)
        y_train = x_train["label"]
        x_test = gen_model_input(df_test, user_profile, user_col, item_profile, item_col, seq_max_len=20)
        y_test = x_test["label"]
        np.save("./data/data_preprocess.npy", np.array((x_train, y_train, x_test, y_test), dtype=object))

    user_cols = ['user_id', 'gender', 'age', 'occupation', 'zip']
    item_cols = ['movie_id', "cate_id"]

    user_features = [
         #类别特征
        SparseFeature(feature_name, vocab_size=feature_max_idx[feature_name], embed_dim=8) for feature_name in user_cols
    ]
    user_features += [
       #序列特征,用户历史点击的moive 向量取平均
        SequenceFeature("hist_movie_id",
                        vocab_size=feature_max_idx["movie_id"],
                        embed_dim=8,
                        pooling="mean",
                        shared_with="movie_id")
    ]

    item_features = [
        SparseFeature(feature_name, vocab_size=feature_max_idx[feature_name], embed_dim=8) for feature_name in item_cols
    ]

    all_item = df_to_dict(item_profile)
    test_user = x_test
    return user_features, item_features, x_train, y_train, all_item, test_user

结果如下,差不多20多万个样本,80000+正样本,160000+负样本。

user_features, item_features, x_train, y_train, all_item, test_user = get_movielens_data(data,load_cache=False)
train data

模型训练

定义好训练参数,batch_size,学习率等,就开始训练了。需要注意的是笔者的temperature设置的为0.02,意味着将用户和物品 cosine距离值放大了50倍,然后去做训练。

model_name="dssm"
epoch=2 
learning_rate=0.001 
batch_size=48
weight_decay=0.00001 
device="cpu" 
save_dir="./result" 
seed=1024
if not os.path.exists(save_dir):
    os.makedirs(save_dir)
torch.manual_seed(seed)

dg = MatchDataGenerator(x=x_train, y=y_train)

model = DSSM(user_features,
             item_features,
             temperature=0.02,
             user_params={
                 "dims": [128, 64],
                 "activation": 'prelu',  # important!!
             },
             item_params={
                 "dims": [128, 64],
                 "activation": 'prelu',  # important!!
             })

trainer = MatchTrainer(model,
                       mode=0,
                       optimizer_params={
                           "lr": learning_rate,
                           "weight_decay": weight_decay
                       },
                       n_epoch=epoch,
                       device=device,
                       model_path=save_dir)

train_dl, test_dl, item_dl = dg.generate_dataloader(test_user, all_item, batch_size=batch_size)
trainer.fit(train_dl)

训练了5轮,可以看到loss在逐步下降。


train

效果评估

采用下方代码进行效果评估,主要步骤就是:

  • 将所有电影的向量通过模型的物品塔预测出来,并存入到ANN索引中,这了采样了annoy这个ann检索库。
  • 将测试集的用户向量通过模型的用户塔预测出来。然后在ann索引中进行topk距离最近的电影检索,返回 作为topk召回。
  • 最后看看用户真实点击的电影有多少个在topK召回中
"""
    util function for movielens data.
"""

import collections
import numpy as np
import pandas as pd
from torch_rechub.utils.match import Annoy
from torch_rechub.basic.metric import topk_metrics
from collections import Counter


def match_evaluation(user_embedding, item_embedding, test_user, all_item, user_col='user_id', item_col='movie_id',
                     raw_id_maps="./data/raw_id_maps.npy", topk=100):
    print("evaluate embedding matching on test data")
    annoy = Annoy(n_trees=10)
    annoy.fit(item_embedding)

    #for each user of test dataset, get ann search topk result
    print("matching for topk")
    user_map, item_map = np.load(raw_id_maps, allow_pickle=True)
    match_res = collections.defaultdict(dict)  # user id -> predicted item ids
    for user_id, user_emb in zip(test_user[user_col], user_embedding):
        if len(user_emb.shape)==2:
            #多兴趣召回
            items_idx = []
            items_scores = []
            for i in range(user_emb.shape[0]):
                temp_items_idx, temp_items_scores = annoy.query(v=user_emb[i], n=topk)  # the index of topk match items
                items_idx += temp_items_idx
                items_scores += temp_items_scores
            temp_df = pd.DataFrame()
            temp_df['item'] = items_idx
            temp_df['score'] = items_scores
            temp_df = temp_df.sort_values(by='score', ascending=True)
            temp_df = temp_df.drop_duplicates(subset=['item'], keep='first', inplace=False)
            recall_item_list = temp_df['item'][:topk].values
            match_res[user_map[user_id]] = np.vectorize(item_map.get)(all_item[item_col][recall_item_list])
        else:
            #普通召回
            items_idx, items_scores = annoy.query(v=user_emb, n=topk)  #the index of topk match items
            match_res[user_map[user_id]] = np.vectorize(item_map.get)(all_item[item_col][items_idx])

    #get ground truth
    print("generate ground truth")

    data = pd.DataFrame({user_col: test_user[user_col], item_col: test_user[item_col]})
    data[user_col] = data[user_col].map(user_map)
    data[item_col] = data[item_col].map(item_map)
    user_pos_item = data.groupby(user_col).agg(list).reset_index()
    ground_truth = dict(zip(user_pos_item[user_col], user_pos_item[item_col]))  # user id -> ground truth

    print("compute topk metrics")
    out = topk_metrics(y_true=ground_truth, y_pred=match_res, topKs=[topk])
    print(out)

评估结果如下. Hit@100为0.233. 表示召回的100个电影中,有23个是用户会点击观看的。

print("inference embedding")
user_embedding = trainer.inference_embedding(model=model, mode="user", data_loader=test_dl, model_path=save_dir)
item_embedding = trainer.inference_embedding(model=model, mode="item", data_loader=item_dl, model_path=save_dir)
match_evaluation(user_embedding, item_embedding, test_user, all_item, topk=100)
eval

结语

这个模型的训练过程还可以很多地方去调整,比如负采样的方法和个数,比如温度系数,比如用户塔和物品塔的神经元个数等等。希望大家可以多多尝试,优化最后的评估指标,同时去思考那些因素是对召回模型最重要的。下一篇笔者将介绍YotubeDNN召回模型,看看YotubeDNN再召回过程中和DSSM有哪些不同之处。

参考:
https://github.com/datawhalechina/torch-rechub
https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/cikm2013_DSSM_fullversion.pdf
https://zhuanlan.zhihu.com/p/165064102

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容