清华大学王晨阳:轻量级Top-K推荐框架及相关论文介绍
本文內(nèi)容整理自 PaperWeekly 和 biendata 在 B 站組織的直播回顧,點(diǎn)擊文末閱讀原文即可跳轉(zhuǎn)至 B 站收看本次分享完整視頻錄像,如需嘉賓課件,請(qǐng)?jiān)?PaperWeekly 公眾號(hào)回復(fù)關(guān)鍵詞課件下載獲取下載鏈接。
構(gòu)建一個(gè)公平的推薦算法“合唱團(tuán)”,這也是框架名稱 ReChorus 的由來(lái) 圖片出處:pixabay
作者簡(jiǎn)介:王晨陽(yáng),清華大學(xué)計(jì)算機(jī)系人智所信息檢索課題組二年級(jí)博士生,研究方向?yàn)橥扑]系統(tǒng)中用戶的動(dòng)態(tài)需求,主要包括序列推薦、引入知識(shí)及時(shí)間動(dòng)態(tài)性的意圖理解等,在WWW、SIGIR等會(huì)議發(fā)表多篇論文。
推薦系統(tǒng)中基于深度學(xué)習(xí)的方法近幾年來(lái)層出不窮,然而不同工作之間實(shí)驗(yàn)設(shè)定和實(shí)現(xiàn)細(xì)節(jié)的差異使得我們很難直接比較不同論文的相對(duì)效果。有論文針對(duì)推薦領(lǐng)域中實(shí)驗(yàn)的可復(fù)現(xiàn)性提出了質(zhì)疑,認(rèn)為百花齊放的表象背后的實(shí)際情況是推薦系統(tǒng)領(lǐng)域長(zhǎng)時(shí)間的停滯不前。
為此,我們基于最近發(fā)表在 SIGIR’20 工作的代碼,整理出了一個(gè)輕量級(jí)的 Top-K推薦框架 ReChorus,旨在分離模型間共同的實(shí)驗(yàn)設(shè)定和不同的模型設(shè)計(jì),使得各個(gè)模型能夠在一個(gè)公平的 benchmark 上進(jìn)行對(duì)比。ReChorus 足夠簡(jiǎn)單易上手,既適合初學(xué)者了解推薦領(lǐng)域的經(jīng)典模型,也適合研究者快速實(shí)現(xiàn)自己的想法;同時(shí) ReChorus 足夠靈活,可以輕松適配個(gè)性化的數(shù)據(jù)格式和評(píng)測(cè)流程。
本文還會(huì)介紹目前 ReChorus 中表現(xiàn)最好的模型——引入商品關(guān)系和時(shí)間動(dòng)態(tài)性的商品表示。這個(gè)工作顯式建模了目標(biāo)商品和近期交互商品之間的關(guān)系,以及不同關(guān)系所產(chǎn)生的影響如何隨時(shí)間變化。實(shí)驗(yàn)表明該方法得到的商品表示可以靈活地應(yīng)用于各種推薦算法并取得顯著的效果提升。
推薦系統(tǒng)領(lǐng)域的劣幣驅(qū)逐良幣
在開(kāi)始介紹 ReChorus 前,讓我們先思考幾個(gè)問(wèn)題。
第一個(gè)問(wèn)題,如上圖所示,簡(jiǎn)單回憶的話,推薦系統(tǒng)領(lǐng)域所用的 baseline 是不是往往就那么幾個(gè)?可能研究者們不再像過(guò)去那樣傾向于同某個(gè)領(lǐng)域或問(wèn)題設(shè)定下最好的?baseline?做比較,轉(zhuǎn)而和比較流行的 baseline 做比較。只做到了比某個(gè)流行的baseline 表現(xiàn)好一點(diǎn),研究者就宣稱自己達(dá)到了?state-of-the-art(SOTA) 的性能。如果你去找那些非常強(qiáng)的 SOTA?的模型做比較,提升就會(huì)相對(duì)變小,論文相對(duì)更難發(fā),而這樣的狀況很可能導(dǎo)致劣幣驅(qū)逐良幣的趨勢(shì)。
近期就有這樣的一批論文發(fā)表,研究者們都自稱達(dá)到了 SOTA 的性能。可以預(yù)見(jiàn)的是后續(xù)的研究者撰寫新一批論文,選擇?baseline?進(jìn)行比較時(shí)會(huì)更傾向于選這批論文中表現(xiàn)比較差的,由此對(duì)比出他們的論文算法有了比較大的提升。雖然這樣會(huì)讓整體的論文發(fā)表呈現(xiàn)百家爭(zhēng)鳴、百花齊放的表象。但是這樣的機(jī)制會(huì)使真正高質(zhì)量?baseline?在浪潮中被淹沒(méi)。
在推薦領(lǐng)域,即使是比較資深的專家,也很難指出在某個(gè)任務(wù)設(shè)定下 SOTA 模型到底是哪一個(gè)。或許你會(huì)覺(jué)得這有什么難的,直接將新論文同一個(gè) baseline 相對(duì)提升的幅度做比較不就可以了?
這就引出來(lái)了第二個(gè)問(wèn)題,比較后會(huì)發(fā)現(xiàn)一個(gè)詭異的現(xiàn)象,同一組的?baseline?在不同的論文中相對(duì)的優(yōu)劣是不一樣的。雖然有時(shí)候?qū)徃迦藭?huì)指出這些問(wèn)題,但是在很多已發(fā)表的論文中也能觀察到這樣的現(xiàn)象,雖然數(shù)據(jù)集在其中造成了一定的影響,但是我覺(jué)得很大程度上還是因?yàn)闆](méi)有把參數(shù)調(diào)好。
現(xiàn)在很多研發(fā)者不會(huì)下功夫?qū)?baseline 做調(diào)整,調(diào)出一個(gè)差不多的結(jié)果后就做罷了。但這樣的后果是,如果想比較兩個(gè)自身達(dá)到了?SOTA?性能的模型,其相對(duì)提升就不會(huì)有特別明顯的可比性,可能其中一個(gè) baseline?調(diào)的非常好,另外一個(gè)則沒(méi)有。這時(shí)就很難比較到底哪個(gè)模型在整個(gè)領(lǐng)域上達(dá)到了一個(gè)更優(yōu)的效果。
那么,將在同一個(gè)數(shù)據(jù)集上進(jìn)行過(guò)實(shí)驗(yàn)的模型拿來(lái)比較效果不就行了嗎?這就引出了第三個(gè)問(wèn)題:很多論文即使是在同一個(gè)數(shù)據(jù)集上,實(shí)驗(yàn)結(jié)果也不太有可比性。其中的原因有很多,我們來(lái)看幾個(gè)比較有代表性的例子:
1. 推薦領(lǐng)域比較常見(jiàn)的數(shù)據(jù)預(yù)處理,是否去除了那些交互數(shù)量比較少的用戶item(常見(jiàn)去掉小于等于5的)?
2. 是否去掉了比較 popular 的 item ?
3. 在數(shù)據(jù)劃分時(shí),直接用 leave-one-out 的方法把每個(gè)用戶的最后一個(gè)序列作為測(cè)試集,還是為了防止時(shí)間泄露,設(shè)置一個(gè)時(shí)間來(lái)做一刀切來(lái)做數(shù)據(jù)集?
4. 在負(fù)例選取上是直接選用戶沒(méi)有交互過(guò)的作為負(fù)例,還是選擇一個(gè)按照 item popular 的程度進(jìn)行加權(quán)的負(fù)例采樣?
上述例子看起來(lái)都是一些細(xì)節(jié)的設(shè)定,可能并不會(huì)非常明確的在論文中體現(xiàn),但它們對(duì)模型的效果卻有很大的影響。在同一個(gè)數(shù)據(jù)集上,兩篇論文的實(shí)驗(yàn)結(jié)果可能會(huì)因?yàn)檫@些細(xì)節(jié)設(shè)定的差別存在跨數(shù)量級(jí)的差別。
最近?RecSys?的一篇論文 Are we really making much progress? A worrying analysis of recent neural recommendation approaches?也講了這個(gè)問(wèn)題。在推薦系統(tǒng)中,實(shí)驗(yàn)設(shè)定有很多具有分歧的地方,在這篇論文中就總結(jié)了多達(dá)8個(gè)分歧點(diǎn),看起來(lái)不多,但假如說(shuō)每一個(gè)分歧點(diǎn)至少有兩種選擇,2的8次方就是256種實(shí)驗(yàn)組合。
雖然實(shí)際中可能并不會(huì)這么多,常見(jiàn)的情況可能接近10種。這依然意味著很難保證想比較的兩篇論文采取了完全一樣的實(shí)驗(yàn)設(shè)定,也就導(dǎo)致即使兩篇在同一個(gè)數(shù)據(jù)集上進(jìn)行實(shí)驗(yàn)的論文,它們的結(jié)果也無(wú)法做比較科學(xué)的比較。
可能你會(huì)產(chǎn)生新的疑問(wèn),現(xiàn)在代碼不都開(kāi)源嗎?我直接將代碼在我的實(shí)驗(yàn)設(shè)定下跑一跑就可以了。我們來(lái)看看會(huì)發(fā)生什么:假設(shè)你準(zhǔn)備下周一跟導(dǎo)師開(kāi)組會(huì)匯報(bào),你和導(dǎo)師說(shuō)這周要把一個(gè)開(kāi)源的?baseline?在自己的實(shí)驗(yàn)設(shè)定下跑一跑結(jié)果。周二你下載了代碼,但發(fā)現(xiàn)把它改到能在自己的實(shí)驗(yàn)設(shè)定下去跑是非常困難的。
這其實(shí)是現(xiàn)在比較常見(jiàn)的一個(gè)現(xiàn)象,按說(shuō)代碼開(kāi)源要滿足的最低要求是可以復(fù)現(xiàn)論文的結(jié)果,先不說(shuō)有些開(kāi)源代碼連這個(gè)最低的要求都沒(méi)有實(shí)現(xiàn),即使是達(dá)到最低要求的代碼可能也很難匹配你的實(shí)驗(yàn)設(shè)定。我遇到過(guò)一個(gè)最極端的例子,當(dāng)時(shí)要去補(bǔ)充一個(gè)帶商品關(guān)系的 baseline(具體的名字就不說(shuō)了)。
拿到代碼后首先發(fā)現(xiàn)這篇論文有兩個(gè)數(shù)據(jù)集,但奇怪的是代碼載入時(shí)好像只有一個(gè)數(shù)據(jù)集的相關(guān)的內(nèi)容。再看模型就更奇怪了,這份商品關(guān)系的baseline帶有一個(gè)知識(shí)圖譜,其中所有關(guān)于商品關(guān)系的代碼,都按商品關(guān)系的數(shù)量去寫了多份,比如數(shù)據(jù)其實(shí)有兩種關(guān)系,就需要把同樣的代碼段寫兩份,把變量名做一個(gè)像X1、X2這樣的區(qū)分。
我當(dāng)時(shí)非常震驚,第二個(gè)數(shù)據(jù)集怎么辦?果然有其他人問(wèn)了論文作者同樣的問(wèn)題,作者怎么回應(yīng)呢?給了一個(gè)百度網(wǎng)盤的鏈接用來(lái)下載第二個(gè)數(shù)據(jù)集的代碼,我下載后發(fā)現(xiàn)第二個(gè)數(shù)據(jù)集有3種關(guān)系,類似第一個(gè)數(shù)據(jù)集,作者又把所有跟這種商品關(guān)系相關(guān)的代碼段寫了3遍,所有的變量用X1、X2、X3來(lái)替代,這讓我非常崩潰,我的數(shù)據(jù)集有十幾種關(guān)系,如果像他這樣寫,就要寫上一個(gè)幾千行且bug非常多的程序。
其實(shí)整個(gè)開(kāi)源領(lǐng)域的代碼質(zhì)量非常參差不齊。回到上文假設(shè)的場(chǎng)景,整整一周你都沒(méi)有把開(kāi)源的?baseline 在自己的實(shí)驗(yàn)設(shè)定下真正跑起來(lái)。但和跟老師匯報(bào)時(shí),老師可能會(huì)質(zhì)疑你這一周都干了啥?既然是開(kāi)源代碼,為什么一周連一個(gè)實(shí)驗(yàn)的結(jié)果都沒(méi)跑出來(lái),你到底有沒(méi)有在做實(shí)驗(yàn)?你只能一肚子的委屈。
上面的這些問(wèn)題,也是一、兩年間我們?cè)谧鐾扑]領(lǐng)域研究中觀察到的。上圖右邊,去年 RecSys 上的 best paper 也討論了這些問(wèn)題,在推薦系統(tǒng)領(lǐng)域,我們是否在真正的 making progress ?作者選擇了18篇推薦領(lǐng)域的論文,但他只成功復(fù)現(xiàn)了其中的7篇,這7篇中效果能超過(guò)優(yōu)質(zhì)傳統(tǒng)模型的又少之又少。
在目前看來(lái),推薦系統(tǒng)論文百花齊放的表象下,確實(shí)很有可能隱藏著一個(gè)長(zhǎng)期的停滯不前,或至少是一個(gè)比較緩慢的前行實(shí)際情況。
ReChorus推薦框架介紹
2.1 ReChorus推薦框架介紹
如何改善這樣的狀況呢?我們認(rèn)為關(guān)鍵點(diǎn)在于是提供一個(gè)比較公平的 benchmark 評(píng)測(cè)平臺(tái),讓不同的模型在同樣的設(shè)定下進(jìn)行評(píng)測(cè)。研究者可以直觀、清晰地看到不同的模型之間的優(yōu)劣關(guān)系。好比看家用顯卡天梯圖一樣:我基于現(xiàn)在的預(yù)算,就知道我該選什么樣的顯卡。
類似的,有了評(píng)測(cè)平臺(tái)提供的模型天梯圖,研究者就能知道基于自己的實(shí)驗(yàn)設(shè)定,應(yīng)該選擇哪個(gè) baseline 去作為我對(duì)比的目標(biāo)、超越的對(duì)象,同時(shí)也可以幫助初學(xué)者更快地了解常見(jiàn)推薦算法。
我們基于上述的想法,同時(shí)在整理這次 SIGIR 一篇論文的代碼時(shí),就思考如何做成更通用的操作,可以推薦框架,促進(jìn)領(lǐng)域中的模型來(lái)做公平的對(duì)比,從而構(gòu)建一個(gè)真正的推薦算法“合唱團(tuán)”,這也是框架名稱 ReChorus 的由來(lái)。
在設(shè)計(jì)框架時(shí),最主要的核心思想是如何分離模型間共同的實(shí)驗(yàn)設(shè)定到共享的類中,突出不同的模型的細(xì)節(jié),從而讓不同的模型可以在完全相同的實(shí)驗(yàn)設(shè)定下進(jìn)行對(duì)比。另外我們希望框架具有以下四個(gè)特點(diǎn):
輕量:易上手,代碼self-contain;
高效:盡可能加速通用的訓(xùn)練和評(píng)測(cè)過(guò)程;
靈活:適配不同的數(shù)據(jù)輸入格式和實(shí)驗(yàn)設(shè)定;
專注:實(shí)現(xiàn)新模型時(shí)只需要關(guān)注一個(gè)文件。
針對(duì)第4點(diǎn)再補(bǔ)充說(shuō)明幾句,為什么很多開(kāi)源出來(lái)不是特別好的代碼,都是一個(gè)文件把一個(gè)模型寫完?因?yàn)檫@樣的好處非常明顯,使得研究者在調(diào)試時(shí)非常方便,只關(guān)注這一個(gè)文件,哪里有問(wèn)題直接翻到那去找。
而一些框架會(huì)分很多很多類,非常面向?qū)ο蟆Q芯空呖赡軐懸粋€(gè)模型代碼,在數(shù)據(jù)準(zhǔn)備時(shí)要翻到前面去,看看所用的類如何適配自己的模型,這需要翻很多其他的文件,甚至還要對(duì)文件做改動(dòng),牽一發(fā)而動(dòng)全身,研究者又要顧及改動(dòng)會(huì)不會(huì)影響自己構(gòu)建的其他模型,構(gòu)建模型的思路就會(huì)被打亂。
我們希望實(shí)現(xiàn)把模型間不同的部分盡可能都集中到統(tǒng)一文件中去。
上圖顯示了已有的模型?目前實(shí)現(xiàn)的模型主要是基于 SIGIR 那篇論文的 baseline ,添加了一些常見(jiàn)的模型,還在繼續(xù)擴(kuò)充當(dāng)中,上圖的右面的二維碼指向GitHub的鏈接,歡迎查看。
可以看到目前實(shí)現(xiàn)的模型包括從2009年比較經(jīng)典的BPR,到后續(xù)的2016年、2017年、2018年、2019年、2020年的算法,既包括傳統(tǒng)的模型,也有序列的模型,同時(shí)結(jié)合知識(shí)圖譜、結(jié)合時(shí)間信息的也有了一些實(shí)踐,性能的對(duì)比、各自的特點(diǎn)和運(yùn)行時(shí)間列在了上圖右側(cè)。后文還會(huì)再講這個(gè)結(jié)果,這里先不做詳細(xì)分析。
2.2?框架主體
如上圖,首先把框架分成了兩個(gè)類型的類,核心的模型類和幫助類。核心的模型類以 model 結(jié)尾,主要用來(lái)定義模型的細(xì)節(jié),也就是體現(xiàn)模型之間差異化的內(nèi)容,以及如何構(gòu)建輸入的batch,這些都放在同一個(gè)文件里面,而且并不長(zhǎng)。
幫助類分reader 和 runner 。reader 從硬盤中讀取文件、數(shù)據(jù)集放到內(nèi)存里,然后進(jìn)行統(tǒng)一的預(yù)處理。runner 控制模型訓(xùn)練和評(píng)測(cè)的過(guò)程,會(huì)和所用到的深度學(xué)習(xí)框架訓(xùn)練和評(píng)測(cè)的代碼有關(guān),這里是基于pytorch的一個(gè)實(shí)現(xiàn)。
從上圖可以看出,模型可以共享幫助類。雖然目前幫助類只實(shí)現(xiàn)了兩個(gè)(base reader 和 base runner),如果我們的實(shí)驗(yàn)有變化,比如數(shù)據(jù)集的格式有變化,我們可以實(shí)現(xiàn)新的 reader,也可以用其他的 runner 來(lái)實(shí)現(xiàn)不同的評(píng)測(cè)的機(jī)制。這些 reader 和 runner 幫助類都是可以指定給每個(gè)模型,有點(diǎn)像 OOP 里面的設(shè)計(jì)模式,這些就像它的“廚師”,可以把它指定給每一個(gè)模型,實(shí)現(xiàn)靈活的適配。
接下來(lái)帶大家從代碼的層面梳理一遍 ReChorus 框架。
文件夾層面大概分data、log和src,log包含輸出的log文件,src包含主要的模型代碼。下面快速看一下data中的內(nèi)容,數(shù)據(jù)集大概長(zhǎng)什么樣。
上面的代碼非常簡(jiǎn)單,包含四個(gè)文件,其中train、test和dev 這三個(gè)文件比較重要,關(guān)鍵數(shù)值是user ID、item ID,還有每個(gè)的時(shí)間戳。
對(duì)于測(cè)試集和驗(yàn)證集來(lái)說(shuō),測(cè)試的時(shí)候我們一般會(huì) sample 一些負(fù)例,和正例組成 candidate set,然后把正例和負(fù)例一起做排序,看正例到底排在第幾位,所以train、test和dev這三個(gè)文件是必須的。后面選擇性的提供 item 的、特征知識(shí)圖譜的一些信息。r_complement 部分代表第一個(gè)item跟這一類item有互補(bǔ)的關(guān)系。
r_complement 部分代表第一個(gè)item跟這一類item有互補(bǔ)的關(guān)系。到這個(gè) src 中的代碼層面。主要分為三部分,一個(gè)是前文說(shuō)的幫助類,實(shí)現(xiàn)了 base reader 和 base runner,第二部分 models 層面除了 base model 是一個(gè)基本的類以外,可以理解為一個(gè)抽象類,后面對(duì)于每個(gè)模型實(shí)現(xiàn)一個(gè)類,繼承這個(gè) base model 來(lái)實(shí)現(xiàn)它具體的功能。第三部分 util 層面是一些工具性的函數(shù)。函數(shù)主要入口在main,從main開(kāi)始來(lái)看一下完整的框架。
# -*- 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的參數(shù),主要控制整體的,比如 manual_seed的問(wèn)題。主函數(shù)部分還包含一些比較通用的設(shè)置,像隨機(jī)數(shù)參數(shù)(隨機(jī)數(shù)的種子)、具體用哪一個(gè)GPU。我調(diào)用 reade r這個(gè)類去進(jìn)行 corpus 的構(gòu)建。
有一些預(yù)處理會(huì)比較花費(fèi)時(shí)間,所以默認(rèn)把讀入數(shù)據(jù)進(jìn)行存儲(chǔ),也可以修改比如 regenerate 這樣的參數(shù),讓它實(shí)現(xiàn)每一次都進(jìn)行一個(gè)重復(fù)的預(yù)處理。還可以定義 model,根據(jù)所定義的model的內(nèi)容,來(lái)做初始化參數(shù)的操作以及決定是否輸入到顯卡中。
之后調(diào)用 runner 類對(duì)模型進(jìn)行評(píng)測(cè)和訓(xùn)練。還定義了每個(gè)的 dataset ,也就是pytorch 面內(nèi)置的 dataset 一個(gè)集成的類,可以看到我把 dataset 寫到了 model 中作為一個(gè)內(nèi)部類。
為什么不把準(zhǔn)備batch寫到reader里面去?基于前文說(shuō)過(guò)的設(shè)計(jì)框架指導(dǎo)原則,就是要把模型間不同的地方都集中到一個(gè)文件里,其實(shí)準(zhǔn)備batch不同模型往往非常不一樣,所以我就把它集成到了模型這類里面去。runner 通過(guò) runner.train 這行代碼控制整個(gè)訓(xùn)練的過(guò)程,看一下訓(xùn)練結(jié)果這部分就結(jié)束了。以上,main主要就是把所有的部分串聯(lián)起來(lái)。
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 的代碼,先看如何把數(shù)據(jù)集加載到內(nèi)存里,其中兩個(gè)函數(shù) read_data 和 append_info。read_data 把數(shù)據(jù)讀到內(nèi)存中,轉(zhuǎn)成 dataframe 的形式,可能會(huì)去根據(jù) item_meta_data 構(gòu)建三元組的形式,也會(huì)做整個(gè)數(shù)據(jù)集的統(tǒng)計(jì)特征。
appen_info 主要做統(tǒng)一的、之后模型可能都會(huì)用到的預(yù)處理。具體工作主要包括把item交互的歷史拼到對(duì)應(yīng)的dataframe里面去,tradeoff 整個(gè)的訓(xùn)練過(guò)程非常快,不過(guò)可能比較占內(nèi)存,對(duì)于更大一點(diǎn)數(shù)據(jù)集可以考慮把它放到 dataset 里面在多線程準(zhǔn)備的時(shí)動(dòng)態(tài)的找對(duì)應(yīng)的這個(gè)歷史。
補(bǔ)充一點(diǎn)說(shuō)明,需要把數(shù)據(jù)一次性讀到內(nèi)存里面去嗎?確實(shí)是,推薦領(lǐng)域中,至少在研究中很少很少像 CV 領(lǐng)域,可能因?yàn)閳D片都比較大,無(wú)法一次全部裝載到內(nèi)存里。在推薦領(lǐng)域,如上文展示的那種數(shù)據(jù)集的格式,主要是 ID 和一些特征,直接講 CSV 裝載到內(nèi)存還是比較方便的。
如果整個(gè)數(shù)據(jù)集比較大,無(wú)法預(yù)先的把歷史和一些特征先準(zhǔn)備好的話,可以之后寫在 batch 里做動(dòng)態(tài)的準(zhǔn)備,犧牲一點(diǎn)時(shí)間來(lái)減少內(nèi)存的使用。?
總結(jié)base reader 這部分就是講數(shù)據(jù)讀到 dataframe 里,做一個(gè)統(tǒng)一的預(yù)處理。
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 部分,主要控制整個(gè)訓(xùn)練的流程和評(píng)測(cè)。通過(guò)設(shè)置參數(shù)控制整個(gè)訓(xùn)練的流程,如check time是一些工具性的函數(shù),通過(guò)build_optimizer 去構(gòu)建具體的優(yōu)化器。
訓(xùn)練方面,可以看到主要調(diào)用的是 train 函數(shù),去調(diào)用后面的fit的函數(shù),對(duì)訓(xùn)練集做參數(shù)的更新,它主要解決驗(yàn)證集上的結(jié)果,在測(cè)試集上的結(jié)果進(jìn)行一個(gè)輸出,看是否在驗(yàn)證集上達(dá)到最好。達(dá)到最好的話需要 save model,是否滿足 early_stop 的條件,如果滿足就 break ,這里其實(shí)也檢測(cè)了手動(dòng)的 Ctrl C break ,可能訓(xùn)練到中間的某一個(gè)輪次覺(jué)得這個(gè)明顯不會(huì)好,所以就先去掉。
去掉了之后,它會(huì)問(wèn)你是否要真正退出,如果最后想要再評(píng)測(cè)一下看最后效果、最后的指標(biāo),可以不退出,如果連最后指標(biāo)都不想看,可以完全退出。
最后訓(xùn)練完,我會(huì)找到在驗(yàn)證集上最優(yōu)的一輪,去做模型的load ,方便后續(xù)進(jìn)行測(cè)試。fit 這部分是剛才 train 中去調(diào)用的,代碼都是 pytorch 用戶非常熟悉的。
每一個(gè) batch 參數(shù)的更新,前面是準(zhǔn)備性工作,包括訓(xùn)練的時(shí)候因?yàn)槭?top_k 的訓(xùn)練,應(yīng)是一個(gè) ranking loss ,會(huì)采樣一些負(fù)例。這里還會(huì)定義 dataloader,dataloader 是 pytorch 內(nèi)置的類,是 dataset 相應(yīng)的那個(gè)類,它會(huì)返回一個(gè)迭代器,當(dāng)你每次去迭代它的時(shí)候,它會(huì)多線程從 data 中去準(zhǔn)備相應(yīng)的 batch。
這個(gè) batch 具體是什么樣是要靠你在 dataset 類里面去設(shè)定的。它會(huì)根據(jù)參數(shù)的不同,是否 shuffle ,每次返回對(duì)應(yīng)的batch,這就相當(dāng)于是 for dataloader 得到對(duì)應(yīng)的batch之后,讓model 得到 batch prediction 的結(jié)果,進(jìn)行參數(shù)的更新,以上就是fit 這部分代碼所做的工作。
def eval_termination 開(kāi)始這部分是之前調(diào)用的、判斷是否 early_stop 的標(biāo)準(zhǔn),evaluate 這部分這些比較簡(jiǎn)單,直接調(diào)用 prediction ,得到 predictions 之后,用到工具類中寫到的評(píng)測(cè)函數(shù)去進(jìn)行評(píng)測(cè)。這個(gè)評(píng)測(cè)其實(shí)也針對(duì)目前的 topk 實(shí)驗(yàn)設(shè)定進(jìn)行了相應(yīng)優(yōu)化,會(huì)讓整個(gè)算 NDCG、算 HR 都會(huì)非常快。
predict 與 fit 比較像,不需要進(jìn)行參數(shù)的更新,也是定義相應(yīng)的 dataloader ,每一個(gè) batch 得到預(yù)測(cè)的結(jié)果即可,最后規(guī)范輸出的 string 格式。
整個(gè) baserunner 大概不到200行,整個(gè)核心框架不到800行,所以說(shuō)這是一個(gè)非常容易上手的框架。上述很多是比較細(xì)節(jié)的信息,希望幫助新手能更快上手。
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"""再來(lái)看最關(guān)鍵basemodel,這涉及到模型具體是怎么實(shí)現(xiàn)的。首先用靜態(tài)變量的方式指定 reader 和 runner ,指定了它的幫助類是什么,具體用哪個(gè) reader 去讀數(shù)據(jù),用哪個(gè) runner 去訓(xùn)練和評(píng)測(cè)模型。
這里有一些通用的與模型相關(guān)的參數(shù),可以增量的添加。前面是一些與模型相關(guān)的參數(shù),包括定義模型里面具體有哪些可學(xué)習(xí)的參數(shù),prediction 怎么去進(jìn)行,loss 具體是什么,每個(gè) customize parameters 應(yīng)該是怎么樣去設(shè)置。
后面有一些工具類的函數(shù),再往后是上文提到的把 dataset 的類寫成一個(gè) model 的內(nèi)部類,目的主要還是希望能在寫模型的過(guò)程中,在一個(gè)文件里既準(zhǔn)備對(duì)應(yīng)的? ?batch,同時(shí)定義模型具體在前面怎么forward的。
因?yàn)樵跇?gòu)建模型時(shí),特別在研究過(guò)程中,經(jīng)常需要變換輸入的信息、輸入的格式,在 forward 中來(lái)做相應(yīng)的這種變換,如果經(jīng)常需要換文件,或者改動(dòng)調(diào)試,是比較痛苦的,所以考慮把它以內(nèi)部類的形式呈現(xiàn)。
代碼繼承的 basedataset 其實(shí)是 pytorch 中內(nèi)置的傳給 dataloader 的 dataset ,只是把它改了一個(gè)名字,因?yàn)檫@個(gè)類本身也想要dataset。如果想用dataset,通過(guò)官方方式去使用它的話,一般需要去重寫兩個(gè)函數(shù),一個(gè)是 len 函數(shù),一個(gè)是 getitem 函數(shù)。len 函數(shù)完成的任務(wù)是獲得 basedataset 中存的數(shù)據(jù)一共有多少個(gè)?getitem 是根據(jù)給定的 index ,去獲得對(duì)應(yīng)數(shù)據(jù)中的 index ,要輸入給模型的 batch 。
如何實(shí)現(xiàn)這兩個(gè)函數(shù)?這里面的data是什么?是basereader 讀進(jìn)來(lái)的dataframe ,但是這里為了方便,準(zhǔn)備了多線程 batch(dataframe 對(duì)于多線程訪問(wèn)不太友好),所把它轉(zhuǎn)成一個(gè)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具體在哪個(gè)階段,是train 的階段還是在評(píng)測(cè) validation 、test 階段。
len 部分直接獲得了data的長(zhǎng)度,被很多人吐槽,把data變成了一個(gè)dict的形式,它本身是一個(gè)data frame,每一列的長(zhǎng)度是一樣的,直接返回了第一列的長(zhǎng)度。
getitem主要的功能放在 get_feed_dict 里面去完成。這是根據(jù)是否需要 buffer 去做選擇。在數(shù)據(jù)集比較小的時(shí)候,如果條件允許的話,對(duì)于驗(yàn)證集跟測(cè)試集,完全可以把它所有的 batch 提前準(zhǔn)備好放在內(nèi)存里,這樣訓(xùn)練、測(cè)試就會(huì)更快一些。如果不去 buffer 的話,每次現(xiàn)場(chǎng)做準(zhǔn)備都要重復(fù)工作。
get_feed_dict 主要給index 返回對(duì)應(yīng)的 predict ,也就是輸入到模型的 batch 。base model 本來(lái)可能是抽象的,但還是把它寫成可以運(yùn)行的類。模型之后可以回返回來(lái)再看,根據(jù)給定的每一個(gè) item ID ,去定義每個(gè) item ID 對(duì)應(yīng)的 bias ,然后直接把輸出的 bias 作為預(yù)測(cè)的值。所以在這要為它準(zhǔn)備 item ID ,target_item ID可以直接從data中item ID的類直接取出即可。
我們會(huì)提前準(zhǔn)備好負(fù)例。訓(xùn)練集通過(guò)函數(shù)在每一輪之前進(jìn)行采樣。可以通過(guò)這一步從成員變量中直接獲得對(duì)應(yīng)的負(fù)例,并和target一起傳到模型里,相當(dāng)于返回了每一個(gè)index 對(duì)應(yīng)的 free_dict 。而要傳遞模型的一個(gè) batch 相當(dāng)于一個(gè)群組,好多index 組成一個(gè) batch ,也相當(dāng)于一個(gè) free_dict 的list 來(lái)組成一個(gè) batch ,等于把相同的 key 當(dāng)中的 value 組合到了一起。
注意看代碼部分默認(rèn)帶有函數(shù),由于后面可能涉及到不同的歷史、長(zhǎng)度,這里需要做動(dòng)態(tài)的pad,這部分也重寫了一些。當(dāng)然,如果檢測(cè)到序列的長(zhǎng)度不一致,也會(huì)進(jìn)行一個(gè)填充的操作,填充到同樣的長(zhǎng)度。也可以去添加一些整體上的控制變量。
以上這部分這是 dataset 比較重要的一部分,控制了怎么去給模型輸入,包含了每個(gè) batch 必須要有的內(nèi)容。
可以再看看整體的模型, item_bias 部分對(duì)于我輸入的 item_ID ,可以直接取對(duì)應(yīng)的 bias 作為 prediction ,進(jìn)而返回對(duì)應(yīng)的 prediction 結(jié)果。
到這一部分,其實(shí)整個(gè)框架已經(jīng)完成了對(duì)使用者的幫助工作。全部的代碼量非常少,所以使用者可以很快上手。
2.3?實(shí)例演示
看完以上的引導(dǎo),還是不知道怎么創(chuàng)建新模型怎么辦?下面繼續(xù)手把手教到底,通過(guò)一段視頻教大家怎么基于框架在 5 分鐘時(shí)間里實(shí)現(xiàn)一個(gè) BPR。
看完快速上手視頻,我們對(duì)整個(gè)框架做完了比較細(xì)致的梳理,希望能夠幫助大家更好地上手、更好地使用它。
相關(guān)論文方法介紹
下面準(zhǔn)備了一些相關(guān)具體算法的介紹,也是我們最近一項(xiàng)工作的介紹,可能比較偏模型、偏理論一些。
上圖是我們現(xiàn)在所實(shí)現(xiàn)的模型的性能對(duì)比,可以看到,基于深度模型的NCF,如果在調(diào)參調(diào)得不好的情況下,比 BPR 還要差很多。引入了時(shí)間信息的 Tensor ,效果會(huì)有明顯提升。對(duì)于序列的模型來(lái)說(shuō),因?yàn)橛行蛄械男畔⑿Ч遣诲e(cuò)的。
我們逐一簡(jiǎn)單講一講:
1. SASRec 基于 self_attention,如果好好調(diào)參,效果確實(shí)會(huì)非常好。
2. TiSASRec是今年剛提出來(lái)的,把時(shí)間間隔用embedding的方式去融入到self_attention,也能取得稍微更好一點(diǎn)點(diǎn)的結(jié)果,但它的運(yùn)行時(shí)間就會(huì)多很多。
3. CFKG 則是一個(gè)融入知識(shí)圖譜的推薦,效果也是很不錯(cuò)的。
4. 最后兩個(gè)模型,是把知識(shí)圖譜、時(shí)間相關(guān),還有序列的信息都用進(jìn)來(lái),也獲得非常好的結(jié)果。
接著介紹一下,這里面表現(xiàn)最好的模型,大概是一個(gè)什么樣的結(jié)構(gòu)。
這是我們團(tuán)隊(duì)在SIGIR的論文:Make It a Chorus:Knowledge-and Time-aware Item Modeling for Sequential Recommendation。
首先是motivation,做這項(xiàng)工作的目的在于,我們感覺(jué)現(xiàn)在的推薦系統(tǒng)有很多問(wèn)題。舉個(gè)例子,我剛買完手機(jī),你認(rèn)為我會(huì)很喜歡電子產(chǎn)品,所以就會(huì)推薦很多款手機(jī),但其實(shí)我此時(shí)已經(jīng)不需要了。
如果比較智能的算法,可能會(huì)去推薦Air Pods,作為配件而言,我對(duì)它的需求可能會(huì)提升。但是這樣的智能可能還是不夠,如果我已經(jīng)在其他平臺(tái)上買過(guò)無(wú)線耳機(jī),我現(xiàn)在也就不需要無(wú)線耳機(jī),系統(tǒng)可能覺(jué)得我需要,但是我實(shí)際不需要。我剛開(kāi)始可能覺(jué)得系統(tǒng)挺智能的,但是如果一直去推Air Pods,我會(huì)覺(jué)得很蠢。
不同的推薦應(yīng)該會(huì)隨著時(shí)間有一定的衰減,所以這篇文章所提出來(lái)的主要想法,就是每一個(gè)item可能在不同的context下,在不同的時(shí)間下,扮演不同的角色。
還有一些具體的例子,如果我之前買的是iphone,它對(duì)于目標(biāo)商品Air Pods有沒(méi)有互補(bǔ)的關(guān)系?它對(duì)我購(gòu)買Air Pods影響應(yīng)該短期內(nèi)是正的,但會(huì)隨時(shí)間慢慢遞減的影響。而如果我之前買的商品是Air Pods同類商品,是替代品Powerbeats,那么短期內(nèi)應(yīng)該是有負(fù)向的影響,但是隨著時(shí)間的增長(zhǎng),可能到該換耳機(jī)的時(shí)候,反而會(huì)得到正向的影響,是分配時(shí)間和負(fù)向變化正向的這樣的一個(gè)過(guò)程。
具體怎么去設(shè)計(jì)這個(gè)模型?我們想讓模型在item扮演不同角色的時(shí)候,有不一樣的靜態(tài)表示,比如在context下扮演互補(bǔ)品、替代品的時(shí)候是怎樣的角色。然后根據(jù)序列的情況,把這些靜態(tài)的表示,與現(xiàn)在有沒(méi)有在扮演這個(gè)角色進(jìn)行動(dòng)態(tài)結(jié)合,包括之間間隔的時(shí)間,每一個(gè)扮演的角色有可能有正向、負(fù)向影響或者不起作用。例如給AirPods一個(gè)基本的表示,還有作為互補(bǔ)品的表示,作為替代品的表示,在不同context下就會(huì)都會(huì)起到更多的作用,下圖是具體的模型圖。
上圖左邊部分,進(jìn)行了知識(shí)圖譜嵌入,但這其實(shí)并不是工作重點(diǎn),所以我們用了一個(gè)比較常見(jiàn)的關(guān)系建模,對(duì)商品之間的關(guān)系進(jìn)行向量的切入,這些向量也會(huì)作為每個(gè)商品的基本表示進(jìn)行數(shù)據(jù)化。
上圖右半部分是第二個(gè)階段,基于左邊的表示,即對(duì)每個(gè)商品有很基本的表示,我們還希望得到它跟relation相關(guān)的表示,通過(guò)這樣的translation,在第一個(gè)階段里面使用的translation方式,去得到扮演不同角色時(shí)的靜態(tài)表示,這樣對(duì)于每一個(gè)商品,都有了基本表示和扮演不同角色時(shí)的表示。
這時(shí)候,就需要根據(jù) context 去對(duì)它們做動(dòng)態(tài)加和,用到叫做 time_aware integration weight 的方法,它是怎么設(shè)計(jì)的呢?就是去挑選歷史里面跟目標(biāo)商品有關(guān)系的歷史交互,看它們對(duì)我的影響到底是什么,這個(gè)具體的影響有一個(gè)稱為temporal kernel function 設(shè)計(jì)的函數(shù)去控制,是一個(gè)疊加的效應(yīng)。
temporal kernel function 的方式怎么設(shè)計(jì)?其實(shí)會(huì)根據(jù)先驗(yàn)知識(shí),或者是希望這個(gè)系統(tǒng)展現(xiàn)出來(lái)一個(gè)什么樣的效果去設(shè)計(jì),比如對(duì)于互補(bǔ)品設(shè)計(jì)成遞減,對(duì)于替代品則是從負(fù)向到正向的變化,這樣能控制扮演不同角色時(shí)的靜態(tài) embedding 在整個(gè)動(dòng)態(tài)的結(jié)合過(guò)程中所做的貢獻(xiàn)。
基于這樣動(dòng)態(tài)表示,就等于得到了一個(gè)目標(biāo)商品在目前context下的動(dòng)態(tài)表示,這個(gè)表示可以用到很多基于embedding模型里面,比如像BPR、GMF最后會(huì)統(tǒng)一去ranking loss。
上圖是大概數(shù)據(jù)的信息,和剛才所提到的兩種關(guān)系。
上圖是實(shí)驗(yàn)結(jié)果,大致情況是我們的模型能夠比之前所提到的引入知識(shí)、引入時(shí)間動(dòng)態(tài)性的模型有比較明顯的提升。
上圖是 relation 的分析,圖中的\R跟\T分別是去掉第一階段的 knowledge graph 和第二階段的 temporal kernel function ,不考慮時(shí)間動(dòng)態(tài)變化的影響。可以看到,影響最大的還是商品關(guān)系所帶來(lái)的,但是有時(shí)候商品關(guān)系可能處理得不好,這個(gè)時(shí)候動(dòng)態(tài)結(jié)果就起到很大作用,如果不對(duì)不同的關(guān)系做時(shí)間動(dòng)態(tài)變化的結(jié)合,\T會(huì)帶來(lái)非常嚴(yán)重的損失,所以時(shí)間在所有數(shù)據(jù)上也有比較一致的提升。
最后,有趣的是,我們看了不同類型商品所求出的 temporal kernel function 方式長(zhǎng)什么樣?是否反映該類商品的一些特征?
上圖左邊是互補(bǔ)品求出來(lái)的 temporal kernel function ,它相較于可替代商品,下降曲線會(huì)更緩一些。這說(shuō)明什么?說(shuō)明可能用戶過(guò)了一段時(shí)間之后,還會(huì)對(duì)這種可替代商品,比如替換的電池和之前老的智能手機(jī)還有興趣,有可能過(guò)很長(zhǎng)時(shí)間才會(huì)換。
而對(duì)于頭戴式耳機(jī)來(lái)說(shuō),interest 下降就會(huì)非常快。就像上文提到的,有可能這個(gè)耳機(jī)我就不需要了,所以這個(gè)分?jǐn)?shù)很快降下來(lái),而不會(huì)過(guò)多打擾到用戶。
上圖右邊是替代品的 temporal kernel function 方式寫出來(lái)的結(jié)果。對(duì)于像手機(jī)殼這一類商品,它的負(fù)向影響基本上被削平了,主要是正向的影響,使得它的峰值會(huì)不太一樣。這說(shuō)明了,之前購(gòu)買手機(jī)殼的行為對(duì)于購(gòu)買下一個(gè)手機(jī)殼,其實(shí)沒(méi)有很多負(fù)向影響。用戶可能因?yàn)楹芏嘣蛉Q手機(jī)殼,比如摔壞了一個(gè)角,或者只是看到外觀就換了,所以負(fù)向影響非常少。
而對(duì)于充電器、手機(jī),它的負(fù)向影響和正向影響都非常明顯。比較奇妙的是,兩者峰值大概都處于一個(gè)位置,這其實(shí)也說(shuō)明它倆是有一定依賴關(guān)系,因?yàn)榭赡懿煌念愋偷氖謾C(jī)配不同類型的充電頭,反映了商品內(nèi)部的這種關(guān)聯(lián)。
這個(gè)模型在我們現(xiàn)在的框架中表現(xiàn)也是比較突出的。總結(jié)來(lái)說(shuō),這種模型主要提出了對(duì)于目標(biāo)商品的動(dòng)態(tài)表示,能夠比較方便運(yùn)用到各種基于 embedding 的方法中,并且進(jìn)一步提升模型的性能等。
總結(jié)
最后做個(gè)總結(jié),我們介紹 ReChorus 這種 top k 推薦框架,它目前會(huì)比較適合兩類人群:作為初學(xué)者,可能想要了解一些經(jīng)典推薦系統(tǒng)相關(guān)的算法,可以通過(guò)它去快速了解經(jīng)典算法具體是怎么實(shí)現(xiàn)的;對(duì)于研究者來(lái)說(shuō),也可用它來(lái)測(cè)試一些新的idea,比較模型的性能。
但現(xiàn)在我覺(jué)得 ReChorus 還有很多的問(wèn)題,包括只有一個(gè)內(nèi)置數(shù)據(jù)集去比較,可能某些實(shí)驗(yàn)設(shè)定上還需要進(jìn)一步提煉,比如最近一篇 ACL best paper 提出的思路,用類似軟工的形式進(jìn)行NLP的全面評(píng)測(cè)。不知道之后推薦系統(tǒng)方向是否會(huì)有相應(yīng)的內(nèi)容。
ReChorus 未來(lái)存在很多可以改善的空間,也非常歡迎廣大同行研究者們提交 issue 來(lái)完善這個(gè)框架,共同構(gòu)建真正的推薦算法的“合唱團(tuán)”,不僅僅實(shí)現(xiàn)表面上的百花齊放(大量論文的涌現(xiàn)),也去真正推動(dòng)這個(gè)領(lǐng)域一步一個(gè)腳印、實(shí)打?qū)嵉剡M(jìn)步。
關(guān)于數(shù)據(jù)實(shí)戰(zhàn)派
數(shù)據(jù)實(shí)戰(zhàn)派希望用真實(shí)數(shù)據(jù)和行業(yè)實(shí)戰(zhàn)案例,幫助讀者提升業(yè)務(wù)能力,共建有趣的大數(shù)據(jù)社區(qū)。
更多閱讀
#投 稿?通 道#
?讓你的論文被更多人看到?
如何才能讓更多的優(yōu)質(zhì)內(nèi)容以更短路徑到達(dá)讀者群體,縮短讀者尋找優(yōu)質(zhì)內(nèi)容的成本呢?答案就是:你不認(rèn)識(shí)的人。
總有一些你不認(rèn)識(shí)的人,知道你想知道的東西。PaperWeekly 或許可以成為一座橋梁,促使不同背景、不同方向的學(xué)者和學(xué)術(shù)靈感相互碰撞,迸發(fā)出更多的可能性。?
PaperWeekly 鼓勵(lì)高校實(shí)驗(yàn)室或個(gè)人,在我們的平臺(tái)上分享各類優(yōu)質(zhì)內(nèi)容,可以是最新論文解讀,也可以是學(xué)習(xí)心得或技術(shù)干貨。我們的目的只有一個(gè),讓知識(shí)真正流動(dòng)起來(lái)。
?????來(lái)稿標(biāo)準(zhǔn):
? 稿件確系個(gè)人原創(chuàng)作品,來(lái)稿需注明作者個(gè)人信息(姓名+學(xué)校/工作單位+學(xué)歷/職位+研究方向)?
? 如果文章并非首發(fā),請(qǐng)?jiān)谕陡鍟r(shí)提醒并附上所有已發(fā)布鏈接?
? PaperWeekly 默認(rèn)每篇文章都是首發(fā),均會(huì)添加“原創(chuàng)”標(biāo)志
?????投稿郵箱:
? 投稿郵箱:hr@paperweekly.site?
? 所有文章配圖,請(qǐng)單獨(dú)在附件中發(fā)送?
? 請(qǐng)留下即時(shí)聯(lián)系方式(微信或手機(jī)),以便我們?cè)诰庉嫲l(fā)布時(shí)和作者溝通
????
現(xiàn)在,在「知乎」也能找到我們了
進(jìn)入知乎首頁(yè)搜索「PaperWeekly」
點(diǎn)擊「關(guān)注」訂閱我們的專欄吧
關(guān)于PaperWeekly
PaperWeekly 是一個(gè)推薦、解讀、討論、報(bào)道人工智能前沿論文成果的學(xué)術(shù)平臺(tái)。如果你研究或從事 AI 領(lǐng)域,歡迎在公眾號(hào)后臺(tái)點(diǎn)擊「交流群」,小助手將把你帶入 PaperWeekly 的交流群里。
總結(jié)
以上是生活随笔為你收集整理的清华大学王晨阳:轻量级Top-K推荐框架及相关论文介绍的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 农村开什么店比较合适没技术 可以考虑这
- 下一篇: 直播 | 小爱通用理解团队负责人雷宗:小