清华大学王晨阳:轻量级Top-K推荐框架及相关论文介绍
本文內容整理自 PaperWeekly 和 biendata 在 B 站組織的直播回顧,點擊文末閱讀原文即可跳轉至 B 站收看本次分享完整視頻錄像,如需嘉賓課件,請在?PaperWeekly 公眾號回復關鍵詞課件下載獲取下載鏈接。
構建一個公平的推薦算法“合唱團”,這也是框架名稱 ReChorus 的由來 圖片出處:pixabay
作者簡介:王晨陽,清華大學計算機系人智所信息檢索課題組二年級博士生,研究方向為推薦系統中用戶的動態需求,主要包括序列推薦、引入知識及時間動態性的意圖理解等,在WWW、SIGIR等會議發表多篇論文。
推薦系統中基于深度學習的方法近幾年來層出不窮,然而不同工作之間實驗設定和實現細節的差異使得我們很難直接比較不同論文的相對效果。有論文針對推薦領域中實驗的可復現性提出了質疑,認為百花齊放的表象背后的實際情況是推薦系統領域長時間的停滯不前。
為此,我們基于最近發表在 SIGIR’20 工作的代碼,整理出了一個輕量級的 Top-K推薦框架 ReChorus,旨在分離模型間共同的實驗設定和不同的模型設計,使得各個模型能夠在一個公平的 benchmark 上進行對比。ReChorus 足夠簡單易上手,既適合初學者了解推薦領域的經典模型,也適合研究者快速實現自己的想法;同時 ReChorus 足夠靈活,可以輕松適配個性化的數據格式和評測流程。
本文還會介紹目前 ReChorus 中表現最好的模型——引入商品關系和時間動態性的商品表示。這個工作顯式建模了目標商品和近期交互商品之間的關系,以及不同關系所產生的影響如何隨時間變化。實驗表明該方法得到的商品表示可以靈活地應用于各種推薦算法并取得顯著的效果提升。
推薦系統領域的劣幣驅逐良幣
在開始介紹 ReChorus 前,讓我們先思考幾個問題。
第一個問題,如上圖所示,簡單回憶的話,推薦系統領域所用的 baseline 是不是往往就那么幾個?可能研究者們不再像過去那樣傾向于同某個領域或問題設定下最好的?baseline?做比較,轉而和比較流行的 baseline 做比較。只做到了比某個流行的baseline 表現好一點,研究者就宣稱自己達到了?state-of-the-art(SOTA) 的性能。如果你去找那些非常強的 SOTA?的模型做比較,提升就會相對變小,論文相對更難發,而這樣的狀況很可能導致劣幣驅逐良幣的趨勢。
近期就有這樣的一批論文發表,研究者們都自稱達到了 SOTA 的性能。可以預見的是后續的研究者撰寫新一批論文,選擇?baseline?進行比較時會更傾向于選這批論文中表現比較差的,由此對比出他們的論文算法有了比較大的提升。雖然這樣會讓整體的論文發表呈現百家爭鳴、百花齊放的表象。但是這樣的機制會使真正高質量?baseline?在浪潮中被淹沒。
在推薦領域,即使是比較資深的專家,也很難指出在某個任務設定下 SOTA 模型到底是哪一個。或許你會覺得這有什么難的,直接將新論文同一個 baseline 相對提升的幅度做比較不就可以了?
這就引出來了第二個問題,比較后會發現一個詭異的現象,同一組的?baseline?在不同的論文中相對的優劣是不一樣的。雖然有時候審稿人會指出這些問題,但是在很多已發表的論文中也能觀察到這樣的現象,雖然數據集在其中造成了一定的影響,但是我覺得很大程度上還是因為沒有把參數調好。
現在很多研發者不會下功夫對 baseline 做調整,調出一個差不多的結果后就做罷了。但這樣的后果是,如果想比較兩個自身達到了?SOTA?性能的模型,其相對提升就不會有特別明顯的可比性,可能其中一個 baseline?調的非常好,另外一個則沒有。這時就很難比較到底哪個模型在整個領域上達到了一個更優的效果。
那么,將在同一個數據集上進行過實驗的模型拿來比較效果不就行了嗎?這就引出了第三個問題:很多論文即使是在同一個數據集上,實驗結果也不太有可比性。其中的原因有很多,我們來看幾個比較有代表性的例子:
1. 推薦領域比較常見的數據預處理,是否去除了那些交互數量比較少的用戶item(常見去掉小于等于5的)?
2. 是否去掉了比較 popular 的 item ?
3. 在數據劃分時,直接用 leave-one-out 的方法把每個用戶的最后一個序列作為測試集,還是為了防止時間泄露,設置一個時間來做一刀切來做數據集?
4. 在負例選取上是直接選用戶沒有交互過的作為負例,還是選擇一個按照 item popular 的程度進行加權的負例采樣?
上述例子看起來都是一些細節的設定,可能并不會非常明確的在論文中體現,但它們對模型的效果卻有很大的影響。在同一個數據集上,兩篇論文的實驗結果可能會因為這些細節設定的差別存在跨數量級的差別。
最近?RecSys?的一篇論文 Are we really making much progress? A worrying analysis of recent neural recommendation approaches?也講了這個問題。在推薦系統中,實驗設定有很多具有分歧的地方,在這篇論文中就總結了多達8個分歧點,看起來不多,但假如說每一個分歧點至少有兩種選擇,2的8次方就是256種實驗組合。
雖然實際中可能并不會這么多,常見的情況可能接近10種。這依然意味著很難保證想比較的兩篇論文采取了完全一樣的實驗設定,也就導致即使兩篇在同一個數據集上進行實驗的論文,它們的結果也無法做比較科學的比較。
可能你會產生新的疑問,現在代碼不都開源嗎?我直接將代碼在我的實驗設定下跑一跑就可以了。我們來看看會發生什么:假設你準備下周一跟導師開組會匯報,你和導師說這周要把一個開源的?baseline?在自己的實驗設定下跑一跑結果。周二你下載了代碼,但發現把它改到能在自己的實驗設定下去跑是非常困難的。
這其實是現在比較常見的一個現象,按說代碼開源要滿足的最低要求是可以復現論文的結果,先不說有些開源代碼連這個最低的要求都沒有實現,即使是達到最低要求的代碼可能也很難匹配你的實驗設定。我遇到過一個最極端的例子,當時要去補充一個帶商品關系的 baseline(具體的名字就不說了)。
拿到代碼后首先發現這篇論文有兩個數據集,但奇怪的是代碼載入時好像只有一個數據集的相關的內容。再看模型就更奇怪了,這份商品關系的baseline帶有一個知識圖譜,其中所有關于商品關系的代碼,都按商品關系的數量去寫了多份,比如數據其實有兩種關系,就需要把同樣的代碼段寫兩份,把變量名做一個像X1、X2這樣的區分。
我當時非常震驚,第二個數據集怎么辦?果然有其他人問了論文作者同樣的問題,作者怎么回應呢?給了一個百度網盤的鏈接用來下載第二個數據集的代碼,我下載后發現第二個數據集有3種關系,類似第一個數據集,作者又把所有跟這種商品關系相關的代碼段寫了3遍,所有的變量用X1、X2、X3來替代,這讓我非常崩潰,我的數據集有十幾種關系,如果像他這樣寫,就要寫上一個幾千行且bug非常多的程序。
其實整個開源領域的代碼質量非常參差不齊。回到上文假設的場景,整整一周你都沒有把開源的?baseline 在自己的實驗設定下真正跑起來。但和跟老師匯報時,老師可能會質疑你這一周都干了啥?既然是開源代碼,為什么一周連一個實驗的結果都沒跑出來,你到底有沒有在做實驗?你只能一肚子的委屈。
上面的這些問題,也是一、兩年間我們在做推薦領域研究中觀察到的。上圖右邊,去年 RecSys 上的 best paper 也討論了這些問題,在推薦系統領域,我們是否在真正的 making progress ?作者選擇了18篇推薦領域的論文,但他只成功復現了其中的7篇,這7篇中效果能超過優質傳統模型的又少之又少。
在目前看來,推薦系統論文百花齊放的表象下,確實很有可能隱藏著一個長期的停滯不前,或至少是一個比較緩慢的前行實際情況。
ReChorus推薦框架介紹
2.1 ReChorus推薦框架介紹
如何改善這樣的狀況呢?我們認為關鍵點在于是提供一個比較公平的 benchmark 評測平臺,讓不同的模型在同樣的設定下進行評測。研究者可以直觀、清晰地看到不同的模型之間的優劣關系。好比看家用顯卡天梯圖一樣:我基于現在的預算,就知道我該選什么樣的顯卡。
類似的,有了評測平臺提供的模型天梯圖,研究者就能知道基于自己的實驗設定,應該選擇哪個 baseline 去作為我對比的目標、超越的對象,同時也可以幫助初學者更快地了解常見推薦算法。
我們基于上述的想法,同時在整理這次 SIGIR 一篇論文的代碼時,就思考如何做成更通用的操作,可以推薦框架,促進領域中的模型來做公平的對比,從而構建一個真正的推薦算法“合唱團”,這也是框架名稱 ReChorus 的由來。
在設計框架時,最主要的核心思想是如何分離模型間共同的實驗設定到共享的類中,突出不同的模型的細節,從而讓不同的模型可以在完全相同的實驗設定下進行對比。另外我們希望框架具有以下四個特點:
輕量:易上手,代碼self-contain;
高效:盡可能加速通用的訓練和評測過程;
靈活:適配不同的數據輸入格式和實驗設定;
專注:實現新模型時只需要關注一個文件。
針對第4點再補充說明幾句,為什么很多開源出來不是特別好的代碼,都是一個文件把一個模型寫完?因為這樣的好處非常明顯,使得研究者在調試時非常方便,只關注這一個文件,哪里有問題直接翻到那去找。
而一些框架會分很多很多類,非常面向對象。研究者可能寫一個模型代碼,在數據準備時要翻到前面去,看看所用的類如何適配自己的模型,這需要翻很多其他的文件,甚至還要對文件做改動,牽一發而動全身,研究者又要顧及改動會不會影響自己構建的其他模型,構建模型的思路就會被打亂。
我們希望實現把模型間不同的部分盡可能都集中到統一文件中去。
上圖顯示了已有的模型?目前實現的模型主要是基于 SIGIR 那篇論文的 baseline ,添加了一些常見的模型,還在繼續擴充當中,上圖的右面的二維碼指向GitHub的鏈接,歡迎查看。
可以看到目前實現的模型包括從2009年比較經典的BPR,到后續的2016年、2017年、2018年、2019年、2020年的算法,既包括傳統的模型,也有序列的模型,同時結合知識圖譜、結合時間信息的也有了一些實踐,性能的對比、各自的特點和運行時間列在了上圖右側。后文還會再講這個結果,這里先不做詳細分析。
2.2?框架主體
如上圖,首先把框架分成了兩個類型的類,核心的模型類和幫助類。核心的模型類以 model 結尾,主要用來定義模型的細節,也就是體現模型之間差異化的內容,以及如何構建輸入的batch,這些都放在同一個文件里面,而且并不長。
幫助類分reader 和 runner 。reader 從硬盤中讀取文件、數據集放到內存里,然后進行統一的預處理。runner 控制模型訓練和評測的過程,會和所用到的深度學習框架訓練和評測的代碼有關,這里是基于pytorch的一個實現。
從上圖可以看出,模型可以共享幫助類。雖然目前幫助類只實現了兩個(base reader 和 base runner),如果我們的實驗有變化,比如數據集的格式有變化,我們可以實現新的 reader,也可以用其他的 runner 來實現不同的評測的機制。這些 reader 和 runner 幫助類都是可以指定給每個模型,有點像 OOP 里面的設計模式,這些就像它的“廚師”,可以把它指定給每一個模型,實現靈活的適配。
接下來帶大家從代碼的層面梳理一遍 ReChorus 框架。
文件夾層面大概分data、log和src,log包含輸出的log文件,src包含主要的模型代碼。下面快速看一下data中的內容,數據集大概長什么樣。
上面的代碼非常簡單,包含四個文件,其中train、test和dev 這三個文件比較重要,關鍵數值是user ID、item ID,還有每個的時間戳。
對于測試集和驗證集來說,測試的時候我們一般會 sample 一些負例,和正例組成 candidate set,然后把正例和負例一起做排序,看正例到底排在第幾位,所以train、test和dev這三個文件是必須的。后面選擇性的提供 item 的、特征知識圖譜的一些信息。r_complement 部分代表第一個item跟這一類item有互補的關系。
r_complement 部分代表第一個item跟這一類item有互補的關系。到這個 src 中的代碼層面。主要分為三部分,一個是前文說的幫助類,實現了 base reader 和 base runner,第二部分 models 層面除了 base model 是一個基本的類以外,可以理解為一個抽象類,后面對于每個模型實現一個類,繼承這個 base model 來實現它具體的功能。第三部分 util 層面是一些工具性的函數。函數主要入口在main,從main開始來看一下完整的框架。
# -*- coding: UTF-8 -*-import os import sys import pickle import logging import argparse import numpy as np import torchfrom models import * from helpers import * from utils import utilsdef parse_global_args(parser):parser.add_argument('--gpu', type=str, default='0',help='Set CUDA_VISIBLE_DEVICES')parser.add_argument('--verbose', type=int, default=logging.INFO,help='Logging Level, 0, 10, ..., 50')parser.add_argument('--log_file', type=str, default='',help='Logging file path')parser.add_argument('--random_seed', type=int, default=2019,help='Random seed of numpy and pytorch.')parser.add_argument('--load', type=int, default=0,help='Whether load model and continue to train')parser.add_argument('--train', type=int, default=1,help='To train the model or not.')parser.add_argument('--regenerate', type=int, default=0,help='Whether to regenerate intermediate files.')return parserdef main():logging.info('-' * 45 + ' BEGIN: ' + utils.get_time() + ' ' + '-' * 45)exclude = ['check_epoch', 'log_file', 'model_path', 'path', 'pin_memory','regenerate', 'sep', 'train', 'verbose']logging.info(utils.format_arg_str(args, exclude_lst=exclude))# Random seednp.random.seed(args.random_seed)torch.manual_seed(args.random_seed)torch.cuda.manual_seed(args.random_seed)# GPUos.environ["CUDA_VISIBLE_DEVICES"] = args.gpulogging.info("# cuda devices: {}".format(torch.cuda.device_count()))# Read datacorpus_path = os.path.join(args.path, args.dataset, model_name.reader + '.pkl')if not args.regenerate and os.path.exists(corpus_path):logging.info('Load corpus from {}'.format(corpus_path))corpus = pickle.load(open(corpus_path, 'rb'))else:corpus = reader_name(args)logging.info('Save corpus to {}'.format(corpus_path))pickle.dump(corpus, open(corpus_path, 'wb'))# Define modelmodel = model_name(args, corpus)logging.info(model)model = model.double()model.apply(model.init_weights)model.actions_before_train()if torch.cuda.device_count() > 0:model = model.cuda()# Run modeldata_dict = dict()for phase in ['train', 'dev', 'test']:data_dict[phase] = model_name.Dataset(model, corpus, phase)runner = runner_name(args)logging.info('Test Before Training: ' + runner.print_res(model, data_dict['test']))if args.load > 0:model.load_model()if args.train > 0:runner.train(model, data_dict)logging.info(os.linesep + 'Test After Training: ' + runner.print_res(model, data_dict['test']))model.actions_after_train()logging.info(os.linesep + '-' * 45 + ' END: ' + utils.get_time() + ' ' + '-' * 45)首先定義了一些global的參數,主要控制整體的,比如 manual_seed的問題。主函數部分還包含一些比較通用的設置,像隨機數參數(隨機數的種子)、具體用哪一個GPU。我調用 reade r這個類去進行 corpus 的構建。
有一些預處理會比較花費時間,所以默認把讀入數據進行存儲,也可以修改比如 regenerate 這樣的參數,讓它實現每一次都進行一個重復的預處理。還可以定義 model,根據所定義的model的內容,來做初始化參數的操作以及決定是否輸入到顯卡中。
之后調用 runner 類對模型進行評測和訓練。還定義了每個的 dataset ,也就是pytorch 面內置的 dataset 一個集成的類,可以看到我把 dataset 寫到了 model 中作為一個內部類。
為什么不把準備batch寫到reader里面去?基于前文說過的設計框架指導原則,就是要把模型間不同的地方都集中到一個文件里,其實準備batch不同模型往往非常不一樣,所以我就把它集成到了模型這類里面去。runner 通過 runner.train 這行代碼控制整個訓練的過程,看一下訓練結果這部分就結束了。以上,main主要就是把所有的部分串聯起來。
class BaseReader(object):@staticmethoddef parse_data_args(parser):parser.add_argument('--path', type=str, default='../data/',help='Input data dir.')parser.add_argument('--dataset', type=str, default='Grocery_and_Gourmet_Food',help='Choose a dataset.')parser.add_argument('--sep', type=str, default='\t',help='sep of csv file.')parser.add_argument('--history_max', type=int, default=20,help='Maximum length of history.')return parserdef __init__(self, args):self.sep = args.sepself.prefix = args.pathself.dataset = args.datasetself.history_max = args.history_maxt0 = time.time()self._read_data()self._append_info()logging.info('Done! [{:<.2f} s]'.format(time.time() - t0) + os.linesep)def _read_data(self):logging.info('Reading data from \"{}\", dataset = \"{}\" '.format(self.prefix, self.dataset))self.data_df, self.item_meta_df = dict(), pd.DataFrame()self._read_preprocessed_df()logging.info('Formating data type...')for df in list(self.data_df.values()) + [self.item_meta_df]:for col in df.columns:df[col] = df[col].apply(lambda x: eval(str(x)))logging.info('Constructing relation triplets...')self.triplet_set = set()relation_types = [r for r in self.item_meta_df.columns if r.startswith('r_')]heads, relations, tails = [], [], []for idx in range(len(self.item_meta_df)):head_item = self.item_meta_df['item_id'][idx]for r_idx, r in enumerate(relation_types):for tail_item in self.item_meta_df[r][idx]:heads.append(head_item)relations.append(r_idx + 1)tails.append(tail_item)self.triplet_set.add((head_item, r_idx + 1, tail_item))self.relation_df = pd.DataFrame()self.relation_df['head'] = headsself.relation_df['relation'] = relationsself.relation_df['tail'] = tailslogging.info('Counting dataset statistics...')self.all_df = pd.concat([self.data_df[key][['user_id', 'item_id', 'time']] for key in ['train', 'dev', 'test']])self.n_users, self.n_items = self.all_df['user_id'].max() + 1, self.all_df['item_id'].max() + 1self.n_relations = self.relation_df['relation'].max() + 1logging.info('"# user": {}, "# item": {}, "# entry": {}'.format(self.n_users, self.n_items, len(self.all_df)))logging.info('"# relation": {}, "# triplet": {}'.format(self.n_relations, len(self.relation_df)))def _append_info(self):"""Add history info to data_df: item_his, time_his, his_length! Need data_df to be sorted by time in ascending order:return:"""logging.info('Adding history info...')user_his_dict = dict() # store the already seen sequence of each userfor key in ['train', 'dev', 'test']:df = self.data_df[key]i_history, t_history = [], []for uid, iid, t in zip(df['user_id'], df['item_id'], df['time']):if uid not in user_his_dict:user_his_dict[uid] = []i_history.append([x[0] for x in user_his_dict[uid]])t_history.append([x[1] for x in user_his_dict[uid]])user_his_dict[uid].append((iid, t))df['item_his'] = i_historydf['time_his'] = t_historyif self.history_max > 0:df['item_his'] = df['item_his'].apply(lambda x: x[-self.history_max:])df['time_his'] = df['time_his'].apply(lambda x: x[-self.history_max:])df['his_length'] = df['item_his'].apply(lambda x: len(x))self.user_clicked_set = dict()for uid in user_his_dict:self.user_clicked_set[uid] = set([x[0] for x in user_his_dict[uid]])def _read_preprocessed_df(self):for key in ['train', 'dev', 'test']:self.data_df[key] = pd.read_csv(os.path.join(self.prefix, self.dataset, key + '.csv'), sep=self.sep)item_meta_path = os.path.join(self.prefix, self.dataset, 'item_meta.csv')if os.path.exists(item_meta_path):self.item_meta_df = pd.read_csv(item_meta_path, sep=self.sep)if __name__ == '__main__':logging.basicConfig(level=logging.INFO)parser = argparse.ArgumentParser()parser = BaseReader.parse_data_args(parser)args, extras = parser.parse_known_args()args.path = '../../data/'corpus = BaseReader(args)corpus_path = os.path.join(args.path, args.dataset, 'Corpus.pkl')logging.info('Save corpus to {}'.format(corpus_path))pickle.dump(corpus, open(corpus_path, 'wb'))接著看上面 base reader 的代碼,先看如何把數據集加載到內存里,其中兩個函數 read_data 和 append_info。read_data 把數據讀到內存中,轉成 dataframe 的形式,可能會去根據 item_meta_data 構建三元組的形式,也會做整個數據集的統計特征。
appen_info 主要做統一的、之后模型可能都會用到的預處理。具體工作主要包括把item交互的歷史拼到對應的dataframe里面去,tradeoff 整個的訓練過程非常快,不過可能比較占內存,對于更大一點數據集可以考慮把它放到 dataset 里面在多線程準備的時動態的找對應的這個歷史。
補充一點說明,需要把數據一次性讀到內存里面去嗎?確實是,推薦領域中,至少在研究中很少很少像 CV 領域,可能因為圖片都比較大,無法一次全部裝載到內存里。在推薦領域,如上文展示的那種數據集的格式,主要是 ID 和一些特征,直接講 CSV 裝載到內存還是比較方便的。
如果整個數據集比較大,無法預先的把歷史和一些特征先準備好的話,可以之后寫在 batch 里做動態的準備,犧牲一點時間來減少內存的使用。?
總結base reader 這部分就是講數據讀到 dataframe 里,做一個統一的預處理。
class BaseRunner(object):@staticmethoddef parse_runner_args(parser):parser.add_argument('--epoch', type=int, default=100,help='Number of epochs.')parser.add_argument('--check_epoch', type=int, default=1,help='Check some tensors every check_epoch.')parser.add_argument('--early_stop', type=int, default=5,help='The number of epochs when dev results drop continuously.')parser.add_argument('--lr', type=float, default=1e-3,help='Learning rate.')parser.add_argument('--l2', type=float, default=0,help='Weight decay in optimizer.')parser.add_argument('--batch_size', type=int, default=256,help='Batch size during training.')parser.add_argument('--eval_batch_size', type=int, default=256,help='Batch size during testing.')parser.add_argument('--optimizer', type=str, default='Adam',help='optimizer: GD, Adam, Adagrad, Adadelta')parser.add_argument('--num_workers', type=int, default=5,help='Number of processors when prepare batches in DataLoader')parser.add_argument('--pin_memory', type=int, default=1,help='pin_memory in DataLoader')parser.add_argument('--topk', type=str, default='[5,10]',help='The number of items recommended to each user.')parser.add_argument('--metric', type=str, default='["NDCG","HR"]',help='metrics: NDCG, HR')return parserdef __init__(self, args):self.epoch = args.epochself.check_epoch = args.check_epochself.early_stop = args.early_stopself.learning_rate = args.lrself.batch_size = args.batch_sizeself.eval_batch_size = args.eval_batch_sizeself.l2 = args.l2self.optimizer_name = args.optimizerself.num_workers = args.num_workersself.pin_memory = args.pin_memoryself.topk = eval(args.topk)self.metrics = [m.strip().upper() for m in eval(args.metric)]self.main_metric = '{}@{}'.format(self.metrics[0], self.topk[0]) # early stop based on main_metricself.time = None # will store [start_time, last_step_time]def _check_time(self, start=False):if self.time is None or start:self.time = [time()] * 2return self.time[0]tmp_time = self.time[1]self.time[1] = time()return self.time[1] - tmp_timedef _build_optimizer(self, model):optimizer_name = self.optimizer_name.lower()if optimizer_name == 'gd':logging.info("Optimizer: GD")optimizer = torch.optim.SGD(model.customize_parameters(), lr=self.learning_rate, weight_decay=self.l2)elif optimizer_name == 'adagrad':logging.info("Optimizer: Adagrad")optimizer = torch.optim.Adagrad(model.customize_parameters(), lr=self.learning_rate, weight_decay=self.l2)elif optimizer_name == 'adadelta':logging.info("Optimizer: Adadelta")optimizer = torch.optim.Adadelta(model.customize_parameters(), lr=self.learning_rate, weight_decay=self.l2)elif optimizer_name == 'adam':logging.info("Optimizer: Adam")optimizer = torch.optim.Adam(model.customize_parameters(), lr=self.learning_rate, weight_decay=self.l2)else:raise ValueError("Unknown Optimizer: " + self.optimizer_name)return optimizerdef train(self, model, data_dict):main_metric_results, dev_results, test_results = list(), list(), list()self._check_time(start=True)try:for epoch in range(self.epoch):# Fitself._check_time()loss = self.fit(model, data_dict['train'], epoch=epoch + 1)training_time = self._check_time()# Observe selected tensorsif len(model.check_list) > 0 and self.check_epoch > 0 and epoch % self.check_epoch == 0:utils.check(model.check_list)# Record dev and test resultsdev_result = self.evaluate(model, data_dict['dev'], self.topk[:1], self.metrics)test_result = self.evaluate(model, data_dict['test'], self.topk[:1], self.metrics)testing_time = self._check_time()dev_results.append(dev_result)test_results.append(test_result)main_metric_results.append(dev_result[self.main_metric])logging.info("Epoch {:<5} loss={:<.4f} [{:<.1f} s]\t dev=({}) test=({}) [{:<.1f} s] ".format(epoch + 1, loss, training_time, utils.format_metric(dev_result),utils.format_metric(test_result), testing_time))# Save model and early stopif max(main_metric_results) == main_metric_results[-1] or \(hasattr(model, 'stage') and model.stage == 1):model.save_model()if self.early_stop and self.eval_termination(main_metric_results):logging.info("Early stop at %d based on dev result." % (epoch + 1))breakexcept KeyboardInterrupt:logging.info("Early stop manually")exit_here = input("Exit completely without evaluation? (y/n) (default n):")if exit_here.lower().startswith('y'):logging.info(os.linesep + '-' * 45 + ' END: ' + utils.get_time() + ' ' + '-' * 45)exit(1)# Find the best dev result across iterationsbest_epoch = main_metric_results.index(max(main_metric_results))logging.info(os.linesep + "Best Iter(dev)={:>5}\t dev=({}) test=({}) [{:<.1f} s] ".format(best_epoch + 1, utils.format_metric(dev_results[best_epoch]),utils.format_metric(test_results[best_epoch]), self.time[1] - self.time[0]))model.load_model()def fit(self, model, data, epoch=-1):gc.collect()torch.cuda.empty_cache()if model.optimizer is None:model.optimizer = self._build_optimizer(model)data.negative_sampling() # must sample before multi thread startmodel.train()loss_lst = list()dl = DataLoader(data, batch_size=self.batch_size, shuffle=True, num_workers=self.num_workers,collate_fn=data.collate_batch, pin_memory=self.pin_memory)for batch in tqdm(dl, leave=False, desc='Epoch {:<3}'.format(epoch), ncols=100, mininterval=1):batch = utils.batch_to_gpu(batch)model.optimizer.zero_grad()prediction = model(batch)loss = model.loss(prediction)loss.backward()model.optimizer.step()loss_lst.append(loss.detach().cpu().data.numpy())return np.mean(loss_lst)def eval_termination(self, criterion):if len(criterion) > 20 and utils.non_increasing(criterion[-self.early_stop:]):return Trueelif len(criterion) - criterion.index(max(criterion)) > 20:return Truereturn Falsedef evaluate(self, model, data, topks, metrics):"""Evaluate the results for an input dataset.:return: result dict (key: metric@k)"""predictions = self.predict(model, data)return utils.evaluate_method(predictions, topks, metrics)def predict(self, model, data):"""The returned prediction is a 2D-array, each row corresponds to all the candidates,and the ground-truth item poses the first.Example: ground-truth items: [1, 2], 2 negative items for each instance: [[3,4], [5,6]]predictions order: [[1,3,4], [2,5,6]]"""model.eval()predictions = list()dl = DataLoader(data, batch_size=self.eval_batch_size, shuffle=False, num_workers=self.num_workers,collate_fn=data.collate_batch, pin_memory=self.pin_memory)for batch in tqdm(dl, leave=False, ncols=100, mininterval=1, desc='Predict'):prediction = model(utils.batch_to_gpu(batch))predictions.extend(prediction.cpu().data.numpy())return np.array(predictions)def print_res(self, model, data):"""Construct the final result string before/after training:return: test result string"""result_dict = self.evaluate(model, data, self.topk, self.metrics)res_str = '(' + utils.format_metric(result_dict) + ')'return res_str上面是是 base runner 部分,主要控制整個訓練的流程和評測。通過設置參數控制整個訓練的流程,如check time是一些工具性的函數,通過build_optimizer 去構建具體的優化器。
訓練方面,可以看到主要調用的是 train 函數,去調用后面的fit的函數,對訓練集做參數的更新,它主要解決驗證集上的結果,在測試集上的結果進行一個輸出,看是否在驗證集上達到最好。達到最好的話需要 save model,是否滿足 early_stop 的條件,如果滿足就 break ,這里其實也檢測了手動的 Ctrl C break ,可能訓練到中間的某一個輪次覺得這個明顯不會好,所以就先去掉。
去掉了之后,它會問你是否要真正退出,如果最后想要再評測一下看最后效果、最后的指標,可以不退出,如果連最后指標都不想看,可以完全退出。
最后訓練完,我會找到在驗證集上最優的一輪,去做模型的load ,方便后續進行測試。fit 這部分是剛才 train 中去調用的,代碼都是 pytorch 用戶非常熟悉的。
每一個 batch 參數的更新,前面是準備性工作,包括訓練的時候因為是 top_k 的訓練,應是一個 ranking loss ,會采樣一些負例。這里還會定義 dataloader,dataloader 是 pytorch 內置的類,是 dataset 相應的那個類,它會返回一個迭代器,當你每次去迭代它的時候,它會多線程從 data 中去準備相應的 batch。
這個 batch 具體是什么樣是要靠你在 dataset 類里面去設定的。它會根據參數的不同,是否 shuffle ,每次返回對應的batch,這就相當于是 for dataloader 得到對應的batch之后,讓model 得到 batch prediction 的結果,進行參數的更新,以上就是fit 這部分代碼所做的工作。
def eval_termination 開始這部分是之前調用的、判斷是否 early_stop 的標準,evaluate 這部分這些比較簡單,直接調用 prediction ,得到 predictions 之后,用到工具類中寫到的評測函數去進行評測。這個評測其實也針對目前的 topk 實驗設定進行了相應優化,會讓整個算 NDCG、算 HR 都會非常快。
predict 與 fit 比較像,不需要進行參數的更新,也是定義相應的 dataloader ,每一個 batch 得到預測的結果即可,最后規范輸出的 string 格式。
整個 baserunner 大概不到200行,整個核心框架不到800行,所以說這是一個非常容易上手的框架。上述很多是比較細節的信息,希望幫助新手能更快上手。
class BaseModel(torch.nn.Module):reader = 'BaseReader'runner = 'BaseRunner'extra_log_args = []@staticmethoddef parse_model_args(parser):parser.add_argument('--model_path', type=str, default='',help='Model save path.')parser.add_argument('--num_neg', type=int, default=1,help='The number of negative items during training.')parser.add_argument('--dropout', type=float, default=0.2,help='Dropout probability for each deep layer')parser.add_argument('--buffer', type=int, default=1,help='Whether to buffer feed dicts for dev/test')return parser@staticmethoddef init_weights(m):if 'Linear' in str(type(m)):torch.nn.init.normal_(m.weight, mean=0.0, std=0.01)if m.bias is not None:torch.nn.init.normal_(m.bias, mean=0.0, std=0.01)elif 'Embedding' in str(type(m)):torch.nn.init.normal_(m.weight, mean=0.0, std=0.01)def __init__(self, args, corpus):super(BaseModel, self).__init__()self.model_path = args.model_pathself.num_neg = args.num_negself.dropout = args.dropoutself.buffer = args.bufferself.item_num = corpus.n_itemsself.optimizer = Noneself.check_list = list() # observe tensors in check_list every check_epochself._define_params()self.total_parameters = self.count_variables()logging.info('#params: %d' % self.total_parameters)"""Methods must to override"""def _define_params(self):self.item_bias = torch.nn.Embedding(self.item_num, 1)def forward(self, feed_dict):""":param feed_dict: batch prepared in Dataset:return: prediction with shape [batch_size, n_candidates]"""i_ids = feed_dict['item_id']prediction = self.item_bias(i_ids)return prediction.view(feed_dict['batch_size'], -1)"""Methods optional to override"""def loss(self, predictions):"""BPR ranking loss with optimization on multiple negative samples@{Recurrent neural networks with top-k gains for session-based recommendations}:param predictions: [batch_size, -1], the first column for positive, the rest for negative:return:"""pos_pred, neg_pred = predictions[:, 0], predictions[:, 1:]neg_softmax = (neg_pred - neg_pred.max()).softmax(dim=1)neg_pred = (neg_pred * neg_softmax).sum(dim=1)loss = F.softplus(-(pos_pred - neg_pred)).mean()# ↑ For numerical stability, we use 'softplus(-x)' instead of '-log_sigmoid(x)'return lossdef customize_parameters(self):# customize optimizer settings for different parametersweight_p, bias_p = [], []for name, p in filter(lambda x: x[1].requires_grad, self.named_parameters()):if 'bias' in name:bias_p.append(p)else:weight_p.append(p)optimize_dict = [{'params': weight_p}, {'params': bias_p, 'weight_decay': 0}]return optimize_dict"""Auxiliary methods"""def save_model(self, model_path=None):if model_path is None:model_path = self.model_pathutils.check_dir(model_path)torch.save(self.state_dict(), model_path)logging.info('Save model to ' + model_path[:50] + '...')def load_model(self, model_path=None):if model_path is None:model_path = self.model_pathself.load_state_dict(torch.load(model_path))logging.info('Load model from ' + model_path)def count_variables(self):total_parameters = sum(p.numel() for p in self.parameters() if p.requires_grad)return total_parametersdef actions_before_train(self):passdef actions_after_train(self):pass"""Define dataset class for the model"""再來看最關鍵basemodel,這涉及到模型具體是怎么實現的。首先用靜態變量的方式指定 reader 和 runner ,指定了它的幫助類是什么,具體用哪個 reader 去讀數據,用哪個 runner 去訓練和評測模型。
這里有一些通用的與模型相關的參數,可以增量的添加。前面是一些與模型相關的參數,包括定義模型里面具體有哪些可學習的參數,prediction 怎么去進行,loss 具體是什么,每個 customize parameters 應該是怎么樣去設置。
后面有一些工具類的函數,再往后是上文提到的把 dataset 的類寫成一個 model 的內部類,目的主要還是希望能在寫模型的過程中,在一個文件里既準備對應的? ?batch,同時定義模型具體在前面怎么forward的。
因為在構建模型時,特別在研究過程中,經常需要變換輸入的信息、輸入的格式,在 forward 中來做相應的這種變換,如果經常需要換文件,或者改動調試,是比較痛苦的,所以考慮把它以內部類的形式呈現。
代碼繼承的 basedataset 其實是 pytorch 中內置的傳給 dataloader 的 dataset ,只是把它改了一個名字,因為這個類本身也想要dataset。如果想用dataset,通過官方方式去使用它的話,一般需要去重寫兩個函數,一個是 len 函數,一個是 getitem 函數。len 函數完成的任務是獲得 basedataset 中存的數據一共有多少個?getitem 是根據給定的 index ,去獲得對應數據中的 index ,要輸入給模型的 batch 。
如何實現這兩個函數?這里面的data是什么?是basereader 讀進來的dataframe ,但是這里為了方便,準備了多線程 batch(dataframe 對于多線程訪問不太友好),所把它轉成一個dict。
class Dataset(BaseDataset):def __init__(self, model, corpus, phase):self.model = modelself.corpus = corpusself.phase = phaseself.data = utils.df_to_dict(corpus.data_df[phase])# ↑ DataFrame is not compatible with multi-thread operationsself.neg_items = None if phase == 'train' else self.data['neg_items']# ↑ Sample negative items before each epoch during trainingself.buffer_dict = dict()self.buffer = self.model.buffer and self.phase != 'train'self._prepare()def __len__(self):for key in self.data:return len(self.data[key])def __getitem__(self, index):return self.buffer_dict[index] if self.buffer else self._get_feed_dict(index)# Prepare model-specific variables and buffer feed dictsdef _prepare(self):if self.buffer:for i in tqdm(range(len(self)), leave=False, ncols=100, mininterval=1,desc=str('Prepare ' + self.phase)):self.buffer_dict[i] = self._get_feed_dict(i)# Key method to construct input data for a single instancedef _get_feed_dict(self, index):target_item = self.data['item_id'][index]neg_items = self.neg_items[index]item_ids = np.concatenate([[target_item], neg_items])feed_dict = {'item_id': item_ids}return feed_dict# Sample negative items for all the instances (called before each epoch)def negative_sampling(self):self.neg_items = np.random.randint(1, self.corpus.n_items, size=(len(self), self.model.num_neg))for i, u in enumerate(self.data['user_id']):user_clicked_set = self.corpus.user_clicked_set[u]for j in range(self.model.num_neg):while self.neg_items[i][j] in user_clicked_set:self.neg_items[i][j] = np.random.randint(1, self.corpus.n_items)# Collate a batch according to the list of feed dictsdef collate_batch(self, feed_dicts):feed_dict = dict()for key in feed_dicts[0]:stack_val = np.array([d[key] for d in feed_dicts])if stack_val.dtype == np.object: # inconsistent length (e.g. history)feed_dict[key] = pad_sequence([torch.from_numpy(x) for x in stack_val], batch_first=True)else:feed_dict[key] = torch.from_numpy(stack_val)feed_dict['batch_size'] = len(feed_dicts)feed_dict['phase'] = self.phasereturn feed_dict上面的代碼是我主要的 data ,確定phase具體在哪個階段,是train 的階段還是在評測 validation 、test 階段。
len 部分直接獲得了data的長度,被很多人吐槽,把data變成了一個dict的形式,它本身是一個data frame,每一列的長度是一樣的,直接返回了第一列的長度。
getitem主要的功能放在 get_feed_dict 里面去完成。這是根據是否需要 buffer 去做選擇。在數據集比較小的時候,如果條件允許的話,對于驗證集跟測試集,完全可以把它所有的 batch 提前準備好放在內存里,這樣訓練、測試就會更快一些。如果不去 buffer 的話,每次現場做準備都要重復工作。
get_feed_dict 主要給index 返回對應的 predict ,也就是輸入到模型的 batch 。base model 本來可能是抽象的,但還是把它寫成可以運行的類。模型之后可以回返回來再看,根據給定的每一個 item ID ,去定義每個 item ID 對應的 bias ,然后直接把輸出的 bias 作為預測的值。所以在這要為它準備 item ID ,target_item ID可以直接從data中item ID的類直接取出即可。
我們會提前準備好負例。訓練集通過函數在每一輪之前進行采樣。可以通過這一步從成員變量中直接獲得對應的負例,并和target一起傳到模型里,相當于返回了每一個index 對應的 free_dict 。而要傳遞模型的一個 batch 相當于一個群組,好多index 組成一個 batch ,也相當于一個 free_dict 的list 來組成一個 batch ,等于把相同的 key 當中的 value 組合到了一起。
注意看代碼部分默認帶有函數,由于后面可能涉及到不同的歷史、長度,這里需要做動態的pad,這部分也重寫了一些。當然,如果檢測到序列的長度不一致,也會進行一個填充的操作,填充到同樣的長度。也可以去添加一些整體上的控制變量。
以上這部分這是 dataset 比較重要的一部分,控制了怎么去給模型輸入,包含了每個 batch 必須要有的內容。
可以再看看整體的模型, item_bias 部分對于我輸入的 item_ID ,可以直接取對應的 bias 作為 prediction ,進而返回對應的 prediction 結果。
到這一部分,其實整個框架已經完成了對使用者的幫助工作。全部的代碼量非常少,所以使用者可以很快上手。
2.3?實例演示
看完以上的引導,還是不知道怎么創建新模型怎么辦?下面繼續手把手教到底,通過一段視頻教大家怎么基于框架在 5 分鐘時間里實現一個 BPR。
看完快速上手視頻,我們對整個框架做完了比較細致的梳理,希望能夠幫助大家更好地上手、更好地使用它。
相關論文方法介紹
下面準備了一些相關具體算法的介紹,也是我們最近一項工作的介紹,可能比較偏模型、偏理論一些。
上圖是我們現在所實現的模型的性能對比,可以看到,基于深度模型的NCF,如果在調參調得不好的情況下,比 BPR 還要差很多。引入了時間信息的 Tensor ,效果會有明顯提升。對于序列的模型來說,因為有序列的信息效果是不錯的。
我們逐一簡單講一講:
1. SASRec 基于 self_attention,如果好好調參,效果確實會非常好。
2. TiSASRec是今年剛提出來的,把時間間隔用embedding的方式去融入到self_attention,也能取得稍微更好一點點的結果,但它的運行時間就會多很多。
3. CFKG 則是一個融入知識圖譜的推薦,效果也是很不錯的。
4. 最后兩個模型,是把知識圖譜、時間相關,還有序列的信息都用進來,也獲得非常好的結果。
接著介紹一下,這里面表現最好的模型,大概是一個什么樣的結構。
這是我們團隊在SIGIR的論文:Make It a Chorus:Knowledge-and Time-aware Item Modeling for Sequential Recommendation。
首先是motivation,做這項工作的目的在于,我們感覺現在的推薦系統有很多問題。舉個例子,我剛買完手機,你認為我會很喜歡電子產品,所以就會推薦很多款手機,但其實我此時已經不需要了。
如果比較智能的算法,可能會去推薦Air Pods,作為配件而言,我對它的需求可能會提升。但是這樣的智能可能還是不夠,如果我已經在其他平臺上買過無線耳機,我現在也就不需要無線耳機,系統可能覺得我需要,但是我實際不需要。我剛開始可能覺得系統挺智能的,但是如果一直去推Air Pods,我會覺得很蠢。
不同的推薦應該會隨著時間有一定的衰減,所以這篇文章所提出來的主要想法,就是每一個item可能在不同的context下,在不同的時間下,扮演不同的角色。
還有一些具體的例子,如果我之前買的是iphone,它對于目標商品Air Pods有沒有互補的關系?它對我購買Air Pods影響應該短期內是正的,但會隨時間慢慢遞減的影響。而如果我之前買的商品是Air Pods同類商品,是替代品Powerbeats,那么短期內應該是有負向的影響,但是隨著時間的增長,可能到該換耳機的時候,反而會得到正向的影響,是分配時間和負向變化正向的這樣的一個過程。
具體怎么去設計這個模型?我們想讓模型在item扮演不同角色的時候,有不一樣的靜態表示,比如在context下扮演互補品、替代品的時候是怎樣的角色。然后根據序列的情況,把這些靜態的表示,與現在有沒有在扮演這個角色進行動態結合,包括之間間隔的時間,每一個扮演的角色有可能有正向、負向影響或者不起作用。例如給AirPods一個基本的表示,還有作為互補品的表示,作為替代品的表示,在不同context下就會都會起到更多的作用,下圖是具體的模型圖。
上圖左邊部分,進行了知識圖譜嵌入,但這其實并不是工作重點,所以我們用了一個比較常見的關系建模,對商品之間的關系進行向量的切入,這些向量也會作為每個商品的基本表示進行數據化。
上圖右半部分是第二個階段,基于左邊的表示,即對每個商品有很基本的表示,我們還希望得到它跟relation相關的表示,通過這樣的translation,在第一個階段里面使用的translation方式,去得到扮演不同角色時的靜態表示,這樣對于每一個商品,都有了基本表示和扮演不同角色時的表示。
這時候,就需要根據 context 去對它們做動態加和,用到叫做 time_aware integration weight 的方法,它是怎么設計的呢?就是去挑選歷史里面跟目標商品有關系的歷史交互,看它們對我的影響到底是什么,這個具體的影響有一個稱為temporal kernel function 設計的函數去控制,是一個疊加的效應。
temporal kernel function 的方式怎么設計?其實會根據先驗知識,或者是希望這個系統展現出來一個什么樣的效果去設計,比如對于互補品設計成遞減,對于替代品則是從負向到正向的變化,這樣能控制扮演不同角色時的靜態 embedding 在整個動態的結合過程中所做的貢獻。
基于這樣動態表示,就等于得到了一個目標商品在目前context下的動態表示,這個表示可以用到很多基于embedding模型里面,比如像BPR、GMF最后會統一去ranking loss。
上圖是大概數據的信息,和剛才所提到的兩種關系。
上圖是實驗結果,大致情況是我們的模型能夠比之前所提到的引入知識、引入時間動態性的模型有比較明顯的提升。
上圖是 relation 的分析,圖中的\R跟\T分別是去掉第一階段的 knowledge graph 和第二階段的 temporal kernel function ,不考慮時間動態變化的影響。可以看到,影響最大的還是商品關系所帶來的,但是有時候商品關系可能處理得不好,這個時候動態結果就起到很大作用,如果不對不同的關系做時間動態變化的結合,\T會帶來非常嚴重的損失,所以時間在所有數據上也有比較一致的提升。
最后,有趣的是,我們看了不同類型商品所求出的 temporal kernel function 方式長什么樣?是否反映該類商品的一些特征?
上圖左邊是互補品求出來的 temporal kernel function ,它相較于可替代商品,下降曲線會更緩一些。這說明什么?說明可能用戶過了一段時間之后,還會對這種可替代商品,比如替換的電池和之前老的智能手機還有興趣,有可能過很長時間才會換。
而對于頭戴式耳機來說,interest 下降就會非常快。就像上文提到的,有可能這個耳機我就不需要了,所以這個分數很快降下來,而不會過多打擾到用戶。
上圖右邊是替代品的 temporal kernel function 方式寫出來的結果。對于像手機殼這一類商品,它的負向影響基本上被削平了,主要是正向的影響,使得它的峰值會不太一樣。這說明了,之前購買手機殼的行為對于購買下一個手機殼,其實沒有很多負向影響。用戶可能因為很多原因去換手機殼,比如摔壞了一個角,或者只是看到外觀就換了,所以負向影響非常少。
而對于充電器、手機,它的負向影響和正向影響都非常明顯。比較奇妙的是,兩者峰值大概都處于一個位置,這其實也說明它倆是有一定依賴關系,因為可能不同的類型的手機配不同類型的充電頭,反映了商品內部的這種關聯。
這個模型在我們現在的框架中表現也是比較突出的。總結來說,這種模型主要提出了對于目標商品的動態表示,能夠比較方便運用到各種基于 embedding 的方法中,并且進一步提升模型的性能等。
總結
最后做個總結,我們介紹 ReChorus 這種 top k 推薦框架,它目前會比較適合兩類人群:作為初學者,可能想要了解一些經典推薦系統相關的算法,可以通過它去快速了解經典算法具體是怎么實現的;對于研究者來說,也可用它來測試一些新的idea,比較模型的性能。
但現在我覺得 ReChorus 還有很多的問題,包括只有一個內置數據集去比較,可能某些實驗設定上還需要進一步提煉,比如最近一篇 ACL best paper 提出的思路,用類似軟工的形式進行NLP的全面評測。不知道之后推薦系統方向是否會有相應的內容。
ReChorus 未來存在很多可以改善的空間,也非常歡迎廣大同行研究者們提交 issue 來完善這個框架,共同構建真正的推薦算法的“合唱團”,不僅僅實現表面上的百花齊放(大量論文的涌現),也去真正推動這個領域一步一個腳印、實打實地進步。
關于數據實戰派
數據實戰派希望用真實數據和行業實戰案例,幫助讀者提升業務能力,共建有趣的大數據社區。
更多閱讀
#投 稿?通 道#
?讓你的論文被更多人看到?
如何才能讓更多的優質內容以更短路徑到達讀者群體,縮短讀者尋找優質內容的成本呢?答案就是:你不認識的人。
總有一些你不認識的人,知道你想知道的東西。PaperWeekly 或許可以成為一座橋梁,促使不同背景、不同方向的學者和學術靈感相互碰撞,迸發出更多的可能性。?
PaperWeekly 鼓勵高校實驗室或個人,在我們的平臺上分享各類優質內容,可以是最新論文解讀,也可以是學習心得或技術干貨。我們的目的只有一個,讓知識真正流動起來。
?????來稿標準:
? 稿件確系個人原創作品,來稿需注明作者個人信息(姓名+學校/工作單位+學歷/職位+研究方向)?
? 如果文章并非首發,請在投稿時提醒并附上所有已發布鏈接?
? PaperWeekly 默認每篇文章都是首發,均會添加“原創”標志
?????投稿郵箱:
? 投稿郵箱:hr@paperweekly.site?
? 所有文章配圖,請單獨在附件中發送?
? 請留下即時聯系方式(微信或手機),以便我們在編輯發布時和作者溝通
????
現在,在「知乎」也能找到我們了
進入知乎首頁搜索「PaperWeekly」
點擊「關注」訂閱我們的專欄吧
關于PaperWeekly
PaperWeekly 是一個推薦、解讀、討論、報道人工智能前沿論文成果的學術平臺。如果你研究或從事 AI 領域,歡迎在公眾號后臺點擊「交流群」,小助手將把你帶入 PaperWeekly 的交流群里。
總結
以上是生活随笔為你收集整理的清华大学王晨阳:轻量级Top-K推荐框架及相关论文介绍的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 农村开什么店比较合适没技术 可以考虑这
- 下一篇: 直播 | 小爱通用理解团队负责人雷宗:小