今天筆者來介紹另一個推薦召回領域比較經典的算法YotubeDNN,此論文由YouTube團隊發表于2016年提出,提出了一個完整的采用深度學習進行推薦的架構。其召回模塊目前已經成為深度召回算法中的經典。
YotubeDNN模型架構
YotubeDNN召回模型的架構很簡單,如下圖所示
(1)將用戶特征Eembeding后輸入到DNN,生成用戶向量,讓后和Item的Eembeding向量進行cosine 距離計算。
(2)其中Item的Eembeding向量 也在用戶歷史點擊特征中使用。
(3)訓練過程中,采用softmax 損失 + 負采樣的方式。
(4)部署時為了加快召回的速度,根據user embedding和item imbedding使用ann的方法進行召回
Item向量的共享
YotubeDNN的第一個好處就是item側,沒有使用任何商品的特征,這對于有些很難得到商品特征的場景非常友好,而且YotubeDNN的 item向量和 用戶的歷史點擊item向量權重共享,某種程度上加深了用戶特征和商品特征的交互。
訓練方式采用的是Sampled Softmax
YotubeDNN采用的是softmax多分類進行模型訓練。要計算user和 千萬級別的item 之間的相似度,然后通過softmax層時運算量極大。
所以通過sample負采樣。將正負樣本比例變為大降低了多分類訓練求解過程的計算量。至于為啥不采用 binary cross entropy進行loss計算呢。
筆者在網上找到兩個版本的答案:
- 采樣softmax多分類可以一次性更新多個樣本的Embeding參數。而 binary cross entropy 一次只能更新一個樣本。
- 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)
訓練數據生成
采用下方代碼去處理上面數據,從下方代碼可知:
用戶塔的輸入: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)
模型訓練
定義好訓練參數,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在逐步下降。
模型效果評估
采用下方代碼進行效果評估,主要步驟就是:
將所有電影的向量通過模型的物品塔預測出來,并存入到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個是用戶會點擊觀看的。
實驗以及結語
筆者在 負采樣個數以及采樣方法這兩個因素上進行了一個小小的實驗,發現負采樣的個數越多,模型的評估指標效果越好。猜測可能由于負采樣的個數決定了一次訓練模型更新的item的向量個數。sample softmax 時候負采樣的個數越多,item向量訓練的就充分,最終導致了模型的效果變好,同時我們也可以看到,不同的采樣方式對模型的效果也有著極大的影響。在這份數據集上,流行度的負采樣方式比詞向量負采樣的方式要好。所以,我們可以發現負采樣個數以及負采樣的方法對yutobeDNN的召回效果有著極大的影響。