pytorch推薦庫(kù)torch-rechub之DSSM模型召回實(shí)戰(zhàn)

推薦系統(tǒng)發(fā)展至今,已經(jīng)形成了一個(gè)相對(duì)穩(wěn)定的鏈路。先召回(粗排)——>再排序(重排)。主要原因是隨著推薦數(shù)量的變大,需要先通過(guò)召回從億萬(wàn)級(jí)別的推薦池中篩選出千百個(gè)用戶感興趣的商品,然后再進(jìn)行精細(xì)的排序。所以,召回模型一般都要處理億萬(wàn)級(jí)別的數(shù)據(jù),而排序模型只需要處理千百級(jí)別的數(shù)據(jù)量。

今天筆者準(zhǔn)備來(lái)介紹一個(gè)目前推薦系統(tǒng)的比較知名的一個(gè)召回算法——DSSM。全稱Deep Structured Semantic Model 深度語(yǔ)義網(wǎng)絡(luò),本身nlp領(lǐng)域是用來(lái)做文本相似度計(jì)算的,現(xiàn)在被推薦領(lǐng)域用來(lái)做召回。接下來(lái)我們來(lái)看看,到底如何使用DSSM做召回。

雙塔模型細(xì)節(jié)

模型架構(gòu)

模型架構(gòu)入下圖所示:兩個(gè)非常標(biāo)準(zhǔn)的神經(jīng)網(wǎng)絡(luò),一個(gè)用于生成User Embeding,一個(gè)用于生成Item Embeding.
這個(gè)模型的優(yōu)勢(shì)就是: User側(cè)的模型和Item 側(cè)的模型分離,后續(xù)serving時(shí),只需將User側(cè)的模型部署到線上, Item Embeding存在向量數(shù)據(jù)庫(kù),采用ANN檢索的方式進(jìn)行在線召回,并且可以輕松處理萬(wàn)億級(jí)別的相似度計(jì)算。

劣勢(shì)也是User側(cè)的模型和Item 側(cè)的模型分離,導(dǎo)致無(wú)法使用交叉特征,會(huì)大大影響模型最后的效果。


DSSM

歸一化和溫度系數(shù)

雙塔模型中有兩個(gè)非常重要的概念:溫度系數(shù)與歸一化。這兩個(gè)概念都出現(xiàn)在雙塔模型的前向運(yùn)算的運(yùn)行過(guò)程中:

  • 歸一化: 即User Embeding 和 Item Embeding 進(jìn)行L2 歸一化之后,再進(jìn)行向量的乘法運(yùn)算,簡(jiǎn)而言之就是進(jìn)行cosine距離的計(jì)算。這一步的目的,其實(shí)是為了與后續(xù)部署過(guò)程中采用ANN向量檢索使用的距離保持一致。
  • 溫度系數(shù):模型輸出的最終結(jié)果 其實(shí)是 cosine距離/ temperature , 這其實(shí)是歸一化來(lái)一個(gè)問(wèn)題,樣本計(jì)算出cosine距離在[-1,1]之間,會(huì)使得正負(fù)樣本差異變小,為了讓模型更好學(xué)習(xí),更快的收斂,引入溫度系數(shù)去放大的模型計(jì)算出來(lái)的logit。

torch-rechub簡(jiǎn)介

torch-rechub 是一個(gè)基于pytorch實(shí)現(xiàn)的推薦算法庫(kù),目前已經(jīng)實(shí)現(xiàn)很多非常知名的召回和排序的算法,筆者推薦這個(gè)repo的原因是這個(gè)包的代碼可讀性很高,我從源碼中學(xué)習(xí)到了很多推薦模型的細(xì)節(jié)。項(xiàng)目地址如下:https://github.com/datawhalechina/torch-rechub。其中主要特性如下圖所示。

torch-rechub

負(fù)采樣的藝術(shù)

負(fù)樣本的采樣再召回領(lǐng)域可謂是重中之重,這里筆者簡(jiǎn)單介紹一下,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)練時(shí)的采樣方式,采用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)勢(shì)可以去看一下tencent RALM這個(gè)模型的原文

但是推薦召回的負(fù)采樣算法不止這些,還可以再batch中進(jìn)行采樣,或者再樣本中加一點(diǎn)hard 負(fù)樣本等等。負(fù)樣本采樣再召回領(lǐng)域非常重要,一個(gè)好的負(fù)樣本采樣算法可以直接將召回率提升很多。深度學(xué)習(xí)領(lǐng)域,一份好的訓(xùn)練數(shù)據(jù)才是重中之重。接下來(lái)直接進(jìn)入實(shí)戰(zhàn)部分。

torch-rechub的DSSM模型實(shí)戰(zhàn)部分

直接通過(guò)pip install torch-rechub就可以安裝 torch-rechub,通過(guò)下方代碼引入模塊。

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)
data

特征預(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特征共享一個(gè)embeding層權(quán)重。

負(fù)采樣使用的word2vec的采樣方式,每個(gè)正樣本采樣2個(gè)負(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的采樣方式,每個(gè)正樣本采樣2個(gè)負(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多萬(wàn)個(gè)樣本,80000+正樣本,160000+負(fù)樣本。

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

模型訓(xùn)練

定義好訓(xùn)練參數(shù),batch_size,學(xué)習(xí)率等,就開(kāi)始訓(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在逐步下降。


train

效果評(píng)估

采用下方代碼進(jìn)行效果評(píng)估,主要步驟就是:

  • 將所有電影的向量通過(guò)模型的物品塔預(yù)測(cè)出來(lái),并存入到ANN索引中,這了采樣了annoy這個(gè)ann檢索庫(kù)。
  • 將測(cè)試集的用戶向量通過(guò)模型的用戶塔預(yù)測(cè)出來(lái)。然后在ann索引中進(jìn)行topk距離最近的電影檢索,返回 作為topk召回。
  • 最后看看用戶真實(shí)點(diǎn)擊的電影有多少個(gè)在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)

評(píng)估結(jié)果如下. Hit@100為0.233. 表示召回的100個(gè)電影中,有23個(gè)是用戶會(huì)點(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)
eval

結(jié)語(yǔ)

這個(gè)模型的訓(xùn)練過(guò)程還可以很多地方去調(diào)整,比如負(fù)采樣的方法和個(gè)數(shù),比如溫度系數(shù),比如用戶塔和物品塔的神經(jīng)元個(gè)數(shù)等等。希望大家可以多多嘗試,優(yōu)化最后的評(píng)估指標(biāo),同時(shí)去思考那些因素是對(duì)召回模型最重要的。下一篇筆者將介紹YotubeDNN召回模型,看看YotubeDNN再召回過(guò)程中和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

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

推薦閱讀更多精彩內(nèi)容