推薦系統(tǒng)發(fā)展至今娘锁,已經(jīng)形成了一個相對穩(wěn)定的鏈路胞得。先召回(粗排)——>再排序(重排)节猿。主要原因是隨著推薦數(shù)量的變大只损,需要先通過召回從億萬級別的推薦池中篩選出千百個用戶感興趣的商品用押,然后再進(jìn)行精細(xì)的排序绪爸。所以舶掖,召回模型一般都要處理億萬級別的數(shù)據(jù)玛臂,而排序模型只需要處理千百級別的數(shù)據(jù)量前域。
今天筆者準(zhǔn)備來介紹一個目前推薦系統(tǒng)的比較知名的一個召回算法——DSSM辕近。全稱Deep Structured Semantic Model 深度語義網(wǎng)絡(luò),本身nlp領(lǐng)域是用來做文本相似度計(jì)算的匿垄,現(xiàn)在被推薦領(lǐng)域用來做召回移宅。接下來我們來看看,到底如何使用DSSM做召回椿疗。
雙塔模型細(xì)節(jié)
模型架構(gòu)
模型架構(gòu)入下圖所示:兩個非常標(biāo)準(zhǔn)的神經(jīng)網(wǎng)絡(luò)漏峰,一個用于生成User Embeding,一個用于生成Item Embeding.
這個模型的優(yōu)勢就是: User側(cè)的模型和Item 側(cè)的模型分離,后續(xù)serving時届榄,只需將User側(cè)的模型部署到線上浅乔, Item Embeding存在向量數(shù)據(jù)庫,采用ANN檢索的方式進(jìn)行在線召回铝条,并且可以輕松處理萬億級別的相似度計(jì)算靖苇。
劣勢也是User側(cè)的模型和Item 側(cè)的模型分離,導(dǎo)致無法使用交叉特征班缰,會大大影響模型最后的效果贤壁。
歸一化和溫度系數(shù)
雙塔模型中有兩個非常重要的概念:溫度系數(shù)與歸一化。這兩個概念都出現(xiàn)在雙塔模型的前向運(yùn)算的運(yùn)行過程中:
- 歸一化: 即User Embeding 和 Item Embeding 進(jìn)行L2 歸一化之后鲁捏,再進(jìn)行向量的乘法運(yùn)算芯砸,簡而言之就是進(jìn)行cosine距離的計(jì)算。這一步的目的给梅,其實(shí)是為了與后續(xù)部署過程中采用ANN向量檢索使用的距離保持一致假丧。
- 溫度系數(shù):模型輸出的最終結(jié)果 其實(shí)是 cosine距離/ temperature , 這其實(shí)是歸一化來一個問題,樣本計(jì)算出cosine距離在[-1,1]之間动羽,會使得正負(fù)樣本差異變小包帚,為了讓模型更好學(xué)習(xí),更快的收斂运吓,引入溫度系數(shù)去放大的模型計(jì)算出來的logit渴邦。
torch-rechub簡介
torch-rechub 是一個基于pytorch實(shí)現(xiàn)的推薦算法庫疯趟,目前已經(jīng)實(shí)現(xiàn)很多非常知名的召回和排序的算法,筆者推薦這個repo的原因是這個包的代碼可讀性很高谋梭,我從源碼中學(xué)習(xí)到了很多推薦模型的細(xì)節(jié)信峻。項(xiàng)目地址如下:https://github.com/datawhalechina/torch-rechub。其中主要特性如下圖所示瓮床。
負(fù)采樣的藝術(shù)
負(fù)樣本的采樣再召回領(lǐng)域可謂是重中之重盹舞,這里筆者簡單介紹一下,torch-rechub目前實(shí)現(xiàn)的四種負(fù)樣本采樣算法隘庄。
0.隨機(jī)負(fù)采樣(random sampling):在全局樣本中進(jìn)行隨機(jī)采樣
1.word2vec基于流行度的負(fù)采樣方式(popularity sampling method used in word2vec ):
這種采樣方式其實(shí)是借鑒了NLP領(lǐng)域詞向量訓(xùn)練時的采樣方式,采用x^0.75去處理點(diǎn)擊次數(shù)踢步, 公式如下:
i物品被選為負(fù)樣本的概率 = count_i^0.75 / sum(count_i^0.75 )
count_i 表示 物品i的點(diǎn)擊次數(shù)
2.log(count+1)基于流行度的負(fù)采樣方式(popularity sampling method by log):
這是另外一種采樣方式,采用log(x)去處理物品的點(diǎn)擊次數(shù),公式如下丑掺,
i物品被選為負(fù)樣本的概率 np.log(count_i + 1) + 1e-6 / sum(np.log(count_i + 1) + 1e-6)
count_i 表示 物品i的點(diǎn)擊次數(shù)
3.tencent RALM sampling:
這是騰訊RALM模型提出的一種采用方式获印,公式如下:
i物品被選為負(fù)樣本的概率 = [ 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的點(diǎn)擊次數(shù),items表示物品集合。
具體優(yōu)勢可以去看一下tencent RALM這個模型的原文
但是推薦召回的負(fù)采樣算法不止這些街州,還可以再batch中進(jìn)行采樣兼丰,或者再樣本中加一點(diǎn)hard 負(fù)樣本等等。負(fù)樣本采樣再召回領(lǐng)域非常重要菇肃,一個好的負(fù)樣本采樣算法可以直接將召回率提升很多地粪。深度學(xué)習(xí)領(lǐng)域,一份好的訓(xùn)練數(shù)據(jù)才是重中之重琐谤。接下來直接進(jìn)入實(shí)戰(zhàn)部分。
torch-rechub的DSSM模型實(shí)戰(zhàn)部分
直接通過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
數(shù)據(jù)載入
讀取movielens的的數(shù)據(jù)集,數(shù)據(jù)集處理成下方格式旺聚。其中"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)
特征預(yù)處理以及訓(xùn)練集生成
采用下方代碼去處理上面數(shù)據(jù)唧躲,從下方代碼可知:
用戶塔的輸入:user_cols = ['user_id', 'gender', 'age', 'occupation','zip','hist_movie_id']。這里面'user_id', 'gender', 'age', 'occupation', 'zip'為類別特征碱璃,采用embeding 層映射成8維向量弄痹。'hist_movie_id'為序列特征,將用戶歷史點(diǎn)擊的moive_id 向量取平均嵌器。
物品塔的輸入: item_cols = ['movie_id', "cate_id"]肛真,這里面'movie_id', "cate_id"均為類別特征,采用embeding 層映射成8維向量爽航。
需要注意的是 用戶的hist_movie_id特征和物品的movie_id特征共享一個embeding層權(quán)重蚓让。
負(fù)采樣使用的word2vec的采樣方式乾忱,每個正樣本采樣2個負(fù)樣本。
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:
#負(fù)采樣使用的word2vec的采樣方式历极,每個正樣本采樣2個負(fù)樣本
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 += [
#序列特征窄瘟,用戶歷史點(diǎn)擊的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
結(jié)果如下,差不多20多萬個樣本趟卸,80000+正樣本寞肖,160000+負(fù)樣本。
user_features, item_features, x_train, y_train, all_item, test_user = get_movielens_data(data,load_cache=False)
模型訓(xùn)練
定義好訓(xùn)練參數(shù)衰腌,batch_size,學(xué)習(xí)率等新蟆,就開始訓(xùn)練了。需要注意的是筆者的temperature設(shè)置的為0.02右蕊,意味著將用戶和物品 cosine距離值放大了50倍琼稻,然后去做訓(xùn)練。
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)
訓(xùn)練了5輪饶囚,可以看到loss在逐步下降帕翻。
效果評估
采用下方代碼進(jìn)行效果評估,主要步驟就是:
- 將所有電影的向量通過模型的物品塔預(yù)測出來萝风,并存入到ANN索引中嘀掸,這了采樣了annoy這個ann檢索庫。
- 將測試集的用戶向量通過模型的用戶塔預(yù)測出來规惰。然后在ann索引中進(jìn)行topk距離最近的電影檢索睬塌,返回 作為topk召回。
- 最后看看用戶真實(shí)點(diǎn)擊的電影有多少個在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)
評估結(jié)果如下. Hit@100為0.233. 表示召回的100個電影中歇万,有23個是用戶會點(diǎn)擊觀看的揩晴。
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)
結(jié)語
這個模型的訓(xùn)練過程還可以很多地方去調(diào)整,比如負(fù)采樣的方法和個數(shù)贪磺,比如溫度系數(shù)硫兰,比如用戶塔和物品塔的神經(jīng)元個數(shù)等等。希望大家可以多多嘗試寒锚,優(yōu)化最后的評估指標(biāo)劫映,同時去思考那些因素是對召回模型最重要的。下一篇筆者將介紹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