pytorch推薦庫torch-rechub之YotubeDNN模型召回實戰

今天筆者來介紹另一個推薦召回領域比較經典的算法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
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,443評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,530評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,407評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,981評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,759評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,204評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,263評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,415評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,955評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,650評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,892評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,675評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,967評論 2 374

推薦閱讀更多精彩內容