美文网首页
pytorch推荐库torch-rechub之YotubeDNN

pytorch推荐库torch-rechub之YotubeDNN

作者: 王同学死磕技术 | 来源:发表于2022-10-25 16:03 被阅读0次

    今天笔者来介绍另一个推荐召回领域比较经典的算法YotubeDNN,此论文由YouTube团队发表于2016年提出,提出了一个完整的采用深度学习进行推荐的架构。其召回模块目前已经成为深度召回算法中的经典。

    YotubeDNN模型架构

    YotubeDNN召回模型的架构很简单,如下图所示
    (1)将用户特征Eembeding后输入到DNN,生成用户向量,让后和Item的Eembeding向量进行cosine 距离计算。
    (2)其中Item的Eembeding向量 也在用户历史点击特征中使用。
    (3)训练过程中,采用softmax 损失 + 负采样的方式。
    (4)部署时为了加快召回的速度,根据user embedding和item imbedding使用ann的方法进行召回


    image.png

    Item向量的共享

    YotubeDNN的第一个好处就是item侧,没有使用任何商品的特征,这对于有些很难得到商品特征的场景非常友好,而且YotubeDNN的 item向量和 用户的历史点击item向量权重共享,某种程度上加深了用户特征和商品特征的交互。

    训练方式采用的是Sampled Softmax

    YotubeDNN采用的是softmax多分类进行模型训练。要计算user和 千万级别的item 之间的相似度,然后通过softmax层时运算量极大。
    所以通过sample负采样。将正负样本比例变为大降低了多分类训练求解过程的计算量。至于为啥不采用 binary cross entropy进行loss计算呢。
    笔者在网上找到两个版本的答案:

    1. 采样softmax多分类可以一次性更新多个样本的Embeding参数。而 binary cross entropy 一次只能更新一个样本。
    2. softmax多分类可以理解list-wise的训练过程,有对比学习的思想在其中,将正负样本的差异性拉大。

    笔者文章的结语部分也做了一个简单的实验,去验证了负采样的样本个数对模型的效果有很大的影响。

    实战部分

    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,YoutubeDNN
    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
    

    数据加载

    通过下方代码进行数据加载

    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)
    
    image.png

    训练数据生成

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

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

    物品塔的输入: item_cols = ['movie_id',],这里面'movie_id',采用embeding 层映射成16维向量。

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

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

    需要注意的是这版实现的 yotubeDnn 最终将输入输出处理成下方的样式:
    输入 :[ 正样本,负样本1,负样本2, ... , 负样本40 ]
    label : [1,0,0,...,0] (1个1,40零)
    从而实现Sampled Softmax 训练。

    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:
            df_train, df_test = generate_seq_feature_match(data,
                                                           user_col,
                                                           item_col,
                                                           time_col="timestamp",
                                                           item_attribute_cols=[],
                                                           sample_method=2,
                                                           mode=2,
                                                           neg_ratio=40,
                                                           min_item=0)
            x_train = gen_model_input(df_train, user_profile, user_col, item_profile, item_col, seq_max_len=20)
            y_train = np.array([0] * df_train.shape[0])  #label=0 means the first pred value is positive sample
            x_test = gen_model_input(df_test, user_profile, user_col, item_profile, item_col, seq_max_len=20)
            y_test= np.array([0] * df_test.shape[0])
            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(name, vocab_size=feature_max_idx[name], embed_dim=16) for name in user_cols]
        user_features += [
            SequenceFeature("hist_movie_id",
                            vocab_size=feature_max_idx["movie_id"],
                            embed_dim=16,
                            pooling="mean",
                            shared_with="movie_id")
        ]
    
        item_features = [SparseFeature('movie_id', vocab_size=feature_max_idx['movie_id'], embed_dim=16)]
        neg_item_feature = [
            SequenceFeature('neg_items',
                            vocab_size=feature_max_idx['movie_id'],
                            embed_dim=16,
                            pooling="concat",
                            shared_with="movie_id")
        ]
    
    
        all_item = df_to_dict(item_profile)
        test_user = x_test
        return user_features, item_features, neg_item_feature, x_train, y_train, all_item, test_user
    
    
    user_features, item_features, neg_item_feature, x_train, y_train, all_item, test_user = get_movielens_data(data,load_cache=False)
    dg = MatchDataGenerator(x=x_train, y=y_train)
    
    image.png

    模型训练

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

    model_name="yotube"
    epoch=2
    learning_rate=0.01
    batch_size=48
    weight_decay=0.0001 
    device="cpu" 
    save_dir="./result" 
    seed=1024
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)
    torch.manual_seed(seed)
    model = YoutubeDNN(user_features, item_features, neg_item_feature, user_params={"dims": [128, 64, 16]}, temperature=0.5)
    
    #mode=1 means pair-wise learning
    trainer = MatchTrainer(model,
                           mode=2,
                           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)
    

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


    image.png

    模型效果评估

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

    将所有电影的向量通过模型的物品塔预测出来,并存入到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)
    
    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)
    

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


    image.png

    实验以及结语

    笔者在 负采样个数以及采样方法这两个因素上进行了一个小小的实验,发现负采样的个数越多,模型的评估指标效果越好。猜测可能由于负采样的个数决定了一次训练模型更新的item的向量个数。sample softmax 时候负采样的个数越多,item向量训练的就充分,最终导致了模型的效果变好,同时我们也可以看到,不同的采样方式对模型的效果也有着极大的影响。在这份数据集上,流行度的负采样方式比词向量负采样的方式要好。所以,我们可以发现负采样个数以及负采样的方法对yutobeDNN的召回效果有着极大的影响。

    image.png

    相关文章

      网友评论

          本文标题:pytorch推荐库torch-rechub之YotubeDNN

          本文链接:https://www.haomeiwen.com/subject/sztszrtx.html