1. 寫在前面
這個系列很久沒有更新了, 主要是前段時間經歷了一波秋招, 后面的方向可能稍微偏資料挖掘和cv多一些,所以向這兩塊又稍微延展了一下,沒來得及看推薦相關的論文,這次借著和如意大佬整理fun-rec專案的機會, 才重新又看起了之前一直想整理的經典模型, 對于推薦, 還是想把之前學習的知識沉淀下來的, 當然,可能后面的整理比較適合像我一樣的初學者了吧,想法還是以經典paper解讀為主, 學習一些新思想,并進行NLP, 推薦,cv, ML和DL等各種知識的串聯,
關于后面的整理, 我也梳理了一個思維框架:

這是我經過了實習和學習推薦相關理論知識后,按照自己的理解對工業推薦系統需要的硬性知識做的一個思維導圖
“熱追"推薦演算法的特色是從實際應用的角度去梳理推薦系統領域目前常用的一些關鍵技術,主要包括召回粗排,精排,重排以及冷啟動,這幾個差不多撐起了工業界推薦系統的流程,召回的目的是根據用戶部分特征,從海量物品庫,快速找到小部分用戶潛在感興趣的物品交給精排,重點強調快,精排主要是融入更多特征,使用復雜模型,來做個性化推薦,強調準, 但有時候,排序環節的速度是跟不上召回的,所以往往也可以在這兩塊直接加一個粗排,用少量用戶和物品特征,對召回結果再先進行一波篩選, 而重排側,主要是結合精排的結果,再加上各種業務策略,比如去重,插入,打散,多樣性保證等,主要是技術產品策略主導或改善用戶體驗的, 所以這四個環節組合起來,以"迅雷不及掩耳漏斗之勢”,組成了推薦系統的整個架構,
這個漏斗架構在YoutubeDNN這篇paper中見到了雛形,借著寒假在家的這段時間, 這個系列想繼續學習,繼續完善,爭取把它沉淀好, 主要包括兩大塊吧:
- 理論知識方面: 按照上面的思維導圖, 繼續整理一些非常重要的推薦模型,重點還是以學習思想為主, 知其然以及所以然, 比如這次整理的YouTubeDNN, 王喆老師都稱其為神文, 還要精排側一些多目標的模型(MMOE, ESSM),以及召回側其他的一些模型,比如經典的雙塔結構, MIND等, 我覺得這些paper里面的精華在于思想,即使后面不做推薦,做資料挖掘甚至cv, 思考問題的方式本質上應該是差不多的
- GitHub模型代碼復現方面: 這塊打算這個假期進行優化, 首先資料集方面采用一個統一的稍微正規的資料集(新聞推薦資料集原資料,原資料8個多G), 這樣可以使得所有的精排模型或者召回模型都可以在這上面做實驗,這樣也能對比各個模型之間的效果, 另外一個是代碼上, 每個模型首先是代碼上重新優化,有伙伴幫我指出了Pytorch版本的一些模型實作上有問題,另外就是盡量給出tf和Pytorch版本的基礎版本,這個版本的目的是更好的看模型結構以及如何作業的,然后再給出基于deepctr的掉包版本,方便實際中使用, 這一塊主要是鍛煉coding能力,
ok, 鋪墊結束, 開始進入今天的正題,

今天整理的這篇文章是YouTubeDNN模型, 這是2016年的一篇文章, 雖然離著現在有些久遠, 但這篇文章無疑是工業界論文的典范, 完全是從工業界的角度去思考如何去做好一個推薦系統,并且處處是YouTube工程師留給我們的寶貴經驗, 由于這兩天用到了這個模型,今天也正好重溫了下這篇文章,所以借著這個機會也整理出來吧, 王喆老師都稱這篇文章是"神文", 可見其不一般處,
今天讀完之后, 給我的最大感覺,首先是從工程的角度去剖析了整個推薦系統,講到了推薦系統中最重要的兩大模塊: 召回和排序, 這篇論文對初學者非常友好,之前的論文模型是看不到這么全面的系統的,總有一種管中規豹的感覺,看不到全域,容易著相, 其次就是這篇文章給出了很多優化推薦系統中的工程性經驗, 不管是召回還是排序上,都有很多的套路或者trick,比如召回方面的"example age", “負采樣”,“非對稱消費,防止泄露”,排序方面的特征工程,加權邏輯回歸等, 這些東西至今也都非常的實用,所以這也是這篇文章厲害的地方,
本篇文章依然是以paper為主線, 先剖析paper里面的每個細節,當然我這里也參考了其他大佬寫的文章,王喆老師的幾篇文章寫的都很好,鏈接我也放在了下面,建議也看看,然后就是如何用YouTubeDNN模型,代碼復現部分,由于時間比較短,自己先不復現了,調deepmatch的包跑起來,然后在新聞推薦資料集上進行了一些實驗, 嘗試了論文里面講述的一些方法,這里主要是把deepmatch的YouTubeDNN模型怎么使用,以及我整個實驗程序的所思所想給整理下, 因為這個模型結構本質上并不是很復雜(三四層的全連接網路),就不自己在實作一遍啦, 一些工程經驗或者思想,我覺得才是這篇文章的精華部分,
主要內容:
- YoutubeDNN? 我們先從一個整體上剖析下推薦系統的漏斗范式
- YouTubeDNN的召回模型理論與細節剖析
- YouTubeDNN的排序模型理論與細節剖析
- YouTubeDNN的deepmatch的使用方法
- YouTubeDNN新聞推薦資料集的實驗記錄
Ok, let’s go!
2. 引言與推薦系統的漏斗范式
2.1 引言部分
本篇論文是工程性論文(之前的DIN也是偏工程實踐的論文), 行文風格上以實際應用為主, 我們知道YouTube是全球性的視頻網站, 所以這篇文章主要講述了YouTube視頻推薦系統的基本架構以及細節,以及各種處理tricks,
在Introduction部分, 作者首先說了在工業上的YouTube視頻推薦系統主要面臨的三大挑戰:
- Scale(規模): 視頻數量非常龐大,大規模資料下需要分布式學習演算法以及高效的線上服務系統,文中體現這一點的是召回模型線下訓練的時候,采用了負采樣的思路,線上服務的時候,采用了hash映射,然后近鄰檢索的方式來滿足實時性的需求, 這個之前我整理過faiss包和annoy包的使用, 感興趣的可以看看, 其實,再拔高一層,我們推薦系統的整體架構呈漏斗范式,也是為了保證能從大規模情景下實時推薦,
- Freshness(新鮮度): YouTube上的視頻是一個動態的, 用戶實時上傳,且實時訪問, 那么這時候, 最新的視頻往往就容易博得用戶的眼球, 用戶一般都比較喜歡看比較新的視頻, 而不管是不是真和用戶相關(這個感覺和新聞比較類似呀), 這時候,就需要模型有建模新上傳內容以及用戶最新發生的行為能力, 為了讓模型學習到用戶對新視頻有偏好, 后面策略里面加了一個"example age"作為體現,我們說的"探索與利用"中的探索,其實也是對新鮮度的把握,
- Noise(噪聲): 由于資料的稀疏和不可見的其他原因, 資料里面的噪聲非常之多,這時候,就需要讓這個推薦系統變得魯棒,怎么魯棒呢? 這個涉及到召回和排序兩塊,召回上需要考慮更多實際因素,比如非對稱消費特性,高活用戶因素,時間因素,序列因素等,并采取了相應的措施, 而排序上做更加細致的特征工程, 盡量的刻畫出用戶興趣以及視頻的特征 優化訓練目標,使用加權的邏輯回歸等,而召回和排序模型上,都采用了深度神經網路,通過特征的相互交叉,有了更強大的建模能力, 相比于之前用的MF(矩陣分解), 建模能力上有了很大的提升, 這些都有助于幫助減少噪聲, 使得推薦結果更加準確,
所以從文章整體邏輯上看, 后面的各個細節,其實都是圍繞著挑戰展開的,找到當前推薦面臨的問題,就得想辦法解決問題,所以這篇文章的行文邏輯也是非常清晰的,
知道了挑戰, 那么下面就看看YouTubeDNN的整體推薦系統架構,
2.2 YouTubeDNN推薦系統架構
整個推薦架構圖如下, 這個算是比較原始的漏斗結構了:

這篇文章之所以寫的好, 是給了我們一個看推薦系統的宏觀視角, 這個系統主要是兩大部分組成: 召回和排序,召回的目的是根據用戶部分特征,從海量物品庫,快速找到小部分用戶潛在感興趣的物品交給精排,重點強調快,精排主要是融入更多特征,使用復雜模型,來做個性化推薦,強調準,
而對于這兩塊的具體描述, 論文里面也給出了解釋, 我這里簡單基于我目前的理解擴展下主流方法:
- 召回側

召回側模型的輸入一般是用戶的點擊歷史, 因為我們認為這些歷史能更好的代表用戶的興趣, 另外還有一些人口統計學特征,比如性別,年齡,地域等, 都可以作為召回側模型的輸入, 而最終模型的輸出,就是與該用戶相關的一個候選視頻集合, 量級的話一般是幾百,
召回側, 目前根據我的理解,大致上有兩大類召回方式,一類是策略規則,一類是監督模型+embedding,其中策略規則,往往和真實場景有關,比如熱度,歷史重定向等等,不同的場景會有不同的召回方式,這種屬于"特異性"知識,
后面的模型+embedding思路是一種"普適"方法,我上面圖里面梳理出了目前給用戶和物品打embedding的主流方法, 這些方法大致成幾個系列,比如FM系列(FM,FFM等), 用戶行為序列,基于圖和知識圖譜系列,經典雙塔系列等,這些方法看似很多很復雜,其實本質上還是給用戶或者是物品打embedding而已,只不過考慮的角度方式不同, 這里的YouTubeDNN召回模型,也是這里的一種方式而已, - 精排側

召回那邊對于每個用戶, 給出了幾百個比較相關的候選視頻, 把幾百萬的規模降到了幾百, 當然,召回那邊利用的特征資訊有限,并不能很好的刻畫用戶和視頻特點,所以, 在精排側,主要是想利用更多的用戶,視頻特征,刻畫特點更加準確些,從這幾百個里面選出幾個或者十幾個推薦給用戶, 而涉及到準, 主要的發力點一般有三個:特征工程, 模型設計以及訓練方法, 這三個發力點文章幾乎都有所涉及, 除了模式設計有點審時度勢之外,特征工程以及訓練方法的處理上非常漂亮,具體的后面再整理,
精排側,這一塊的大致發展趨勢,從ctr預估到多目標, 而模型演化上,從人工特征工程到特征工程自動化,主要是三大塊, CTR預估主要分為了傳統的LR,FM大家族,以及后面自動特征交叉的DNN家族,而多目標優化,目前是很多大公司的研究現狀,更是未來的一大發展趨勢,如何能讓模型在各個目標上面的學習都能"游刃有余"是一件非常具有挑戰的事情,畢竟不同的目標可能會互相沖突,互相影響,所以這里的研究熱點又可以拆分成網路結構演化以及loss設計優化等, 而網路結構演化中,又可以再一次細分, 當然這每個模型或者技術幾乎都有對應paper,我們依然可以通過讀paper的方式,把這些關鍵技術學習到,
這兩階段的方法, 就能保證我們從大規模視頻庫中實時推薦, 又能保證個性化,吸參考戶, 當然,隨著時間的發展, 可能資料量非常非常大了, 此時召回結果規模精排依然無法處理,所以現在一般還會在召回和精排之間,加一個粗排進一步篩選作為過渡, 而隨著場景越來越復雜, 精排產生的結果也不是直接給到用戶,而是會再后面加一個重排后處理下,這篇paper里面其實也簡單的提了下這種思想,在排序那塊會整理到, 所以如今的漏斗, 也變得長了些,

論文里面還提到了對模型的評估方面, 線下評估的時候,主要是采用一些常用的評估指標(精確率,召回率, 排序損失或者auc這種), 但是最終看演算法和模型的有效性, 是通過A/B實驗, 在A/B實驗中會觀察用戶真實行為,比如點擊率, 觀看時長, 留存率這種, 這些才是我們終極目標, 而有時候, A/B實驗的結果和線下我們用的這些指標并不總是相關, 這也是推薦系統這個場景的復雜性, 我們往往也會用一些策略,比如修改模型的優化目標,損失函式這種, 讓線下的這個目標盡量的和A/B衡量的這種指標相關性大一些, 當然,這塊又是屬于業務場景問題了,不在整理范疇之中, 但2016年,竟然就提出了這種方式, 所以我覺得,作為小白的我們, 想了解工業上的推薦系統, 這篇paper是不二之選,
OK, 從宏觀的大視角看完了漏斗型的推薦架構,我們就詳細看看YouTube視頻推薦架構里面召回和排序模塊的模型到底長啥樣子? 為啥要設計成這個樣子? 為了應對實際中出現的挑戰,又有哪些策略?
3. YouTubeDNN的召回模型細節剖析
上面說過, 召回模型的目的是在大量YouTube視頻中檢索出數百個和用戶相關的視頻來,
這個問題,我們可以看成一個多分類的問題,即用戶在某一個時刻點擊了某個視頻, 可以建模成輸入一個用戶向量, 從海量視頻中預測出被點擊的那個視頻的概率,
換成比較準確的數學語言描述, 在時刻
t
t
t下, 用戶
U
U
U在背景
C
C
C下對每個視頻
i
i
i的觀看行為建模成下面的公式:
P
(
w
t
=
i
∣
U
,
C
)
=
e
v
i
u
∑
j
∈
V
e
v
j
u
P\left(w_{t}=i \mid U, C\right)=\frac{e^{v_{i} u}}{\sum_{j \in V} e^{v_{j} u}}
P(wt?=i∣U,C)=∑j∈V?evj?uevi?u?
這里的
u
u
u表示用戶向量, 這里的
v
v
v表示視頻向量, 兩者的維度都是
N
N
N, 召回模型的任務,就是通過用戶的歷史點擊和山下文特征, 去學習最終的用戶表示向量
u
u
u以及視頻
i
i
i的表示向量
v
i
v_i
vi?, 不過這倆還有個區別是
v
i
v_i
vi?本身就是模型引數, 而
u
u
u是神經網路的輸出(函式輸出),是輸入與模型引數的計算結果,
解釋下這個公式, 為啥要寫成這個樣子,其實是word2vec那邊借鑒過來的, e ( v i u ) e^{ (v_{i} u)} e(vi?u)表示的是當前用戶向量 u u u與當前視頻 v i v_i vi?的相似程度, e e e只是放大這個相似程度而已, 不用管, 為啥這個就能表示相似程度呢? 因為兩個向量的點積運算的含義就是可以衡量兩個向量的相似程度, 兩個向量越相似, 點積就會越大, 所以這個應該解釋明白了, 再看分母 ∑ j ∈ V e v j u \sum_{j \in V} e^{v_{j} u} ∑j∈V?evj?u, 這個顯然是用戶向量 u u u與所有視頻 v v v的一個相似程度求和, 那么兩者一除, 依然是代表了用戶 u u u與輸出的視頻 v i v_i vi?的相似程度,只不過歸一化到了0-1之間, 畢竟我們知道概率是0-1之間的, 這就是為啥這個概率是右邊形式的原因, 因為右邊公式表示了用戶 u u u與輸出的視頻 v i v_i vi?的相似程度, 并且這個相似程度已經歸一化到了0-1之間, 我們給定 u u u希望輸出 v i v_i vi?的概率越大,因為這樣,當前的視頻 v i v_i vi?和當前用戶 u u u更加相關,正好對應著點擊行為不是嗎?
那么,這個召回模型到底長啥樣子呢?
3.1 召回模型結構
召回模型的結構如下:

這個模型結構呢,相比之前的模型, 比較簡單,就是一個DNN,
它的輸入主要是用戶側的特征,包括用戶觀看的歷史video序列, 用戶搜索的歷史tokens, 然后就是用戶的人文特征,比如地理位置, 性別,年齡這些, 這些特征處理上,和之前那些模型的也比較類似,
-
用戶歷史序列,歷史搜索tokens這種序列性的特征: 一般長這樣
[item_id5, item_id2, item_id3, ...], 這種id特征是高維稀疏,首先會通過一個embedding層,轉成低維稠密的embedding特征,即歷史序列里面的每個id都會對應一個embedding向量, 這樣歷史序列就變成了多個embedding向量的形式, 這些向量一般會進行融合,常見的是average pooling,即每一維求平均得到一個最終向量來表示用戶的歷史興趣或搜索興趣,這里值的一提的是這里的embedding向量得到的方式, 論文中作者這里說是通過word2vec方法計算的, 關于word2vec,這里就不過多解釋,也就是每個item事先通過w2v方式算好了的embedding,直接作為了輸入,然后進行pooling融合,
除了這種算好embedding方式之外,還可以過embedding層,跟上面的DNN一起訓練,這些都是常規操作,之前整理的精排模型里面大都是用這種方式,論文里面使用了用戶最近的50次觀看歷史,用戶最近50次搜索歷史token, embedding維度是256維, 采用的average pooling, 當然,這里還可以把item的類別資訊也隱射到embedding, 與前面的concat起來,
-
用戶人文特征, 這種特征處理方式就是離散型的依然是labelEncoder,然后embedding轉成低維稠密, 而連續型特征,一般是先歸一化操作,然后直接輸入,當然有的也通過分桶,轉成離散特征,這里不過多整理,特征工程做的事情了, 當然,這里還有一波操作值得注意,就是連續型特征除了用了 x x x本身,還用了 x 2 x^2 x2, l o g x logx logx這種, 可以加入更多非線性,增加模型表達能力,
這些特征對新用戶的推薦會比較有幫助,常見的用戶的地理位置, 設備, 性別,年齡等, -
這里一個比較特色的特征是example age,這個特征后面需要單獨整理,
這些特征處理好了之后,拼接起來,就成了一個非常長的向量,然后就是過DNN,這里用了一個三層的DNN, 得到了輸出, 這個輸出也是向量,
Ok,到這里平淡無奇, 前向傳播也大致上快說完了, 還差最后一步, 最后這一步,就是做多分類問題,然后求損失,這就是training那邊做的事情, 但是在詳細說這個之前, 我想先簡單回憶下word2vec里面的skip-gram Model, 這個模型,如果回憶起來,這里理解起來就非常的簡單了,
這里只需要看一張圖即可, 這個來自cs231N公開課PPT, 我之前整理w2v的時候用到的,詳細內容可看我這篇博客, 這里的思想其實也是從w2v那邊過來的,

skip-gram的原理咱這里就不整理了, 這里就只看這張圖,這其實就是w2v訓練的一種方式,當然是最原始的, word2vec的核心思想呢? 就是共現頻率高的詞相關性越大,所以skip-gram采用中心詞預測背景關系詞的方式去訓練詞向量,模型的輸入是中心詞,做樣本采用滑動視窗的形式,和這里序列其實差不多,視窗滑動一次就能得到一個序列[word1, word2, …wordn], 而這個序列里面呢? 就會有中心詞(比如中間那個), 兩邊向量的是背景關系詞, 如果我們輸入中心詞之后,模型能預測背景關系詞的概率大,那說明這個模型就能解決詞相關性問題了,
一開始, 我們的中心單詞 w t w_t wt?就是one-hot的表示形式,也就是在詞典中的位置,這里的形狀是 V × 1 V \times1 V×1, V V V表示詞庫里面有 V V V個單詞, 這里的 W W W長上面那樣, 是一個 d × V d\times V d×V的矩陣, d d d表示的是詞嵌入的維度, 那么用 W ? w t W*w_t W?wt?(矩陣乘法)就會得到中心詞的詞向量表示 v c v_c vc?, 大小是 d × 1 d\times1 d×1,這個就是中心詞的embedding向量, 其實就是中心詞過了一個embedding層得到了它的embedding向量,
然后就是 v c v_c vc?和背景關系矩陣 W ′ W' W′相乘, 這里的 W ′ W' W′是 V × d V\times d V×d的一個矩陣, 每一行代表每個單詞作為背景關系的時候的詞向量表示, 也就是 u w u_w uw?, 每一列是詞嵌入的維度, 這樣通過 W ′ ? v c W'*v_c W′?vc?就會得到一個 V × 1 V\times 1 V×1的向量,這個表示的就是中心單詞 w t w_t wt?與每個單詞的相似程度,
最后,我們通過softmax操作把這個相似程度轉成概率, 選擇概率最大的index輸出,
這就是這個模型的前向傳播程序,
有了這個程序, 再理解YouTubeDNN頂部就非常容易了, 我單獨截出來:

只看這里的這個程序, 其實就是上面skip-gram程序, 不一樣的是右邊這個中心詞向量 v c v_c vc?是直接過了一個embedding層得到的,而左邊這個用戶向量 u u u是用戶的各種特征先拼接成一個大的向量,然后過了一個DNN降維, 訓練方式上,這兩個也是一模一樣的,無非就是左邊的召回模型,多了幾層全連接而已,
這樣,也就很容易的理解,模型訓練好了之后,用戶向量和item向量到底在哪里取了吧,
- 用戶向量,其實就是全連接的DNN網路的輸出向量,其實即使沒有全連接,原始的用戶各個特征拼接起來的那個長向量也能用,不過維度可能太大了,所以DNN在這里的作用一個是特征交叉,另一個還有降維的功效,
- item向量: 這個其實和skip-gram那個一樣,每個item其實是用兩個embedding向量的,比如skip-gram那里就有一個作為中心詞時候的embedding矩陣 W W W和作為背景關系詞時候的embedding矩陣 W ′ W' W′, 一般取的時候會取前面那個 W W W作為每個詞的詞向量, 這里其實一個道理,只不過這里最前面那個item向量矩陣,是通過了w2v的方式訓練好了直接作為的輸入,如果不事先計算好,對應的是embedding層得到的那個矩陣, 后面的item向量矩陣,就是這里得到用戶向量之后,后面進行softmax之前的這個矩陣, YouTubeDNN最終是從這個矩陣里面拿item向量,
這就是知識串聯的魅力,其實熟悉了word2vec, 這個召回模型理解非常簡單,
這其實就是這個模型訓練階段最原始的剖析,實際訓練的時候,依然是采用了優化方法, 這個和word2vec也是一樣,采用了負采樣的方式(當然實作細節上有區別),因為視頻的數量太大,每次做多分類,最終那個概率分母上的加和就非常可怕了,所以就把多分類問題轉成了多個二分類的問題, 也就是不用全部的視頻,而是隨機選擇出了一些沒點的視頻, 標記為0, 點了的視頻標記為1, 這樣就成了二分類的問題, 關于負樣本采樣原理, 我之前也整理了一篇博客
負類基于樣本分布抽取而來,負采樣是針對類別數很多情況下的常用方法,當然,負樣本的選擇也是有講究的,詳細的看這篇文章, 我后面實驗主要用了下面兩種
- 展示資料隨機選擇負例
- 隨機負例與熱門打壓
這樣整個召回模型訓練部分的"基本操作"就基本整理完了,關于細節部分,后面代碼里面會描述下, 但是在訓練召回模型程序中,還有一些經驗性的知識也非常重要, 下面重點整理一下,
3.2 訓練資料的選取和生成
模型訓練的時候, 為了計算更加高效,采用了負采樣的方法, 但正負樣本的選取,以及訓練樣本的來源, 還有一些注意事項,
首先,訓練樣本來源于全部的YouTube觀看記錄,而不僅僅是被推薦的觀看記錄

否則對于新視頻會難以被曝光,會使最終推薦結果有偏;同時系統也會采集用戶從其他渠道觀看的視頻,從而可以快速應用到協同過濾中;
其次, 是訓練資料來源于用戶的隱式資料, 且用戶看完了的視頻作為正樣本, 注意這里是看完了, 有一定的時長限制, 而不是僅僅曝光點擊,有可能有誤點的, 而負樣本,是從視頻庫里面隨機選取,或者在曝光過的里面隨機選取用戶沒看過的作為負樣本,
這里的一個經驗是訓練資料中對于每個用戶選取相同的樣本數, 保證用戶在損失函式等權重, 因為這樣可以減少高度活躍用戶對于loss的影響,可以改進線上A/B測驗的效果,

這里的另一個經驗是避免讓模型知道不該知道的資訊

這里作者舉了一個例子是如果模型知道用戶最后的行為是搜索了"Taylor Swift", 那么模型可能會傾向于推薦搜索頁面搜"Taylor Swift"時搜索的視頻, 這個不是推薦模型期望的行為, 解法方法是扔掉時序資訊, 歷史搜索tokens隨機打亂, 使用無序的搜索tokens來表示搜索queryies(average pooling),
基于這個例子就把時序資訊扔掉理由挺勉強的,解決這種特殊場景的資訊泄露會有更針對性的方法,比如把搜索query與搜索結果行為系結讓它們不可分, 感覺時序資訊還是挺重要的, 有專門針對時序資訊建模的研究,
在生成樣本的時候, 如果我們的用戶比較少,行為比較少, 是不足以訓練一個較好的召回模型,此時一個用戶的歷史觀看序列,可以采用滑動視窗的形式生成多個訓練樣本, 比如一個用戶的歷史觀看記錄是"abcdef", 那么采用滑動視窗, 可以是abc預測d, bcd預測e, cde預測f,這樣一個用戶就能生成3條訓練樣本, 后面實驗里面也是這么做的, 但這時候一定要注意一點,就是資訊泄露,這個也是和word2vec的cbow不一樣的地方,
論文中上面這種滑動制作樣本的方式依據是用戶的"asymmetric co-watch probabilities(非對稱觀看概率)",即一般情況下,用戶開始瀏覽范圍較廣, 之后瀏覽范圍逐漸變窄,
下圖中的
w
t
N
w_{tN}
wtN?表示當前樣本, 原來的做法是它前后的用戶行為都可以用來產生特征行為輸入(word2vec的CBOW做樣本的方法), 而作者擔心這一點會導致資訊泄露, 模型不該知道的資訊是未來的用戶行為, 所以作者的做法是只使用更早時間的用戶行為來產生特征, 這個也是目前通用的做法, 兩種方法的對比如下:

(a)是許多協同過濾會采取的方法,利用全域的觀看資訊作為輸入(包括時間節點N前,N后的觀看),這種方法忽略了觀看序列的不對稱性,而本文中采取(b)所示的方法,只把歷史資訊當作輸入,用歷史來預測未來

模型的測驗集, 往往也是用戶最近一次觀看行為, 后面的實驗中,把用戶最后一次點擊放到了測驗集里面去,這樣可以防止資訊穿越,
資料集的細節和tricks基本上說完, 更細的東西,就得通過代碼去解釋了, 接下來, 再聊聊作者加入的非常有意思的一個特征,叫做example age,
3.2 "Example Age"特征
這個特征我想單獨拿出來說,是因為這個是和場景比較相關的特征,也是作者的經驗傳授, 我們知道,視頻有明顯的生命周期,例如剛上傳的視頻比之后更受歡迎,也就是用戶往往喜歡看最新的東西,而不管它是不是和用戶相關,所以視頻的流行度隨著時間的分布是高度非穩態變化的(下面圖中的綠色曲線)

但是我們模型訓練的時候,是基于歷史資料訓練的(歷史觀看記錄的平均),所以模型對播放某個視頻預測值的期望會傾向于其在訓練資料時間內的平均播放概率(平均熱度), 上圖中藍色線,但如上面綠色線,實際上該視頻在訓練資料時間視窗內熱度很可能不均勻, 用戶本身就喜歡新上傳的內容, 所以,為了讓模型學習到用戶這種對新穎內容的bias, 作者引入了"example age"這個特征來捕捉視頻的生命周期,
"example age"定義為 t m a x ? t t_{max}-t tmax??t, 其中 t m a x t_{max} tmax?是訓練資料中所有樣本的時間最大值(有的文章說是當前時間,但我總覺得還是選取的訓練資料所在時間段的右端點時間比較合適,就比如我用的資料集, 最晚時間是2021年7月的,總不能用現在的時間吧), 而 t t t為當前樣本的時間,線上預測時, 直接把example age全部設為0或一個小的負值,這樣就不依賴于各個視頻的上傳時間了,
其實這個操作, 現在常用的是位置上的除偏, 比如商品推薦的時候, 用戶往往喜歡點擊最上面位置的商品或廣告, 但這個bias模型依然是不知道, 為了讓模型學習到這個東西, 也可以把商品或者廣告的位置資訊做成一個feature, 訓練的時候告訴模型, 而線上推理的那些商品, 這個feature也都用一樣的, 異曲同工的意思有沒有,
那么這樣的操作為啥會work呢? example age這個我理解,是有了這個特征, 就可以把某視頻的熱度分布資訊傳遞給模型了, 比如某個example age時間段該視頻播放較多, 而另外的時間段播放較少, 這樣模型就能發現用戶的這種新穎偏好, 消除熱度偏見,
這個地方看了一些文章寫說, 這樣做有利于讓模型推新熱內容, 總感覺不是很通, 我這里理解是類似讓模型消除位置偏見那樣, 這里消除一種熱度偏見,
我理解是這樣,假設沒有這樣一個example age特征表示視頻新穎資訊,或者一個位置特征表示商品的位置資訊,那模型訓練的樣本,可能是用戶點擊了這個item,就是正樣本, 但此時有可能是用戶真的喜歡這個item, 也有可能是因為一些bias, 比如用戶本身喜歡新穎, 用戶本身喜歡點擊上面位置的item等, 但模型推理的時候,都會誤認為是用戶真的喜歡這個item, 所以,為了讓模型了解到可能是存在后面這種bias, 我們就把item的新穎資訊, item的位置資訊等做成特征, 在模型訓練的時候就告訴模型,用戶點了這個東西可能是它比較新或者位置比較靠上面等,這樣模型在訓練的時候, 就了解到了這些bias,等到模型在線推理的時候呢, 我們把這些bias特征都弄成一樣的,這樣每個樣品在模型看來,就沒有了新穎資訊和位置資訊bias(一視同仁了),只能靠著相關性去推理, 這樣才能推到用戶真正感興趣的東西吧,
而有些文章記錄的, 能夠推薦更熱門的視頻啥的, 我很大一個疑問就是推理的時候,不是把example age用0表示嗎? 模型應該不知道這些視頻哪個新不新吧, 當然,這是我自己的看法,感興趣的可以幫我解答下呀,
example age這個特征到這里還沒完, 原來加入這種時間bias的傳統方法是使用video age, 即一個video上傳到樣本生成的這段時間跨度, 這么說可能有些懵, 看個圖吧, 原來這是兩個東西:

王喆老師那篇文章里面也談到了這兩種理解, 對于某個視頻的不同樣本,其實這兩種定義是等價的,因為他們的和是一個常數,
t
video age
+
t
example age
=
Const
t_{\text {video age }}+t_{\text {example age }}=\text { Const }
tvideo age ?+texample age ?= Const
詳細證明可以看參考的第三篇文章,但example age的定義有下面兩點好處:
- 線上預測時
example age是常數值, 所有item可以設定成統一的, 但如果是video age的話,這個根每個視頻的上傳時間有關, 那這樣在計算用戶向量的時候,就依賴每個候選item了, 而統一的這個好處就是用戶向量只需要計算一次, - 對不同的視頻,對應的
example age所在范圍一致, 只依賴訓練資料選取的時間跨度,便于歸一化操作,
3.3 實驗結果
這里就簡單過下就好, 作者這里主要驗證了下DNN的結構對推薦效果的影響,對于DNN的層級,作者嘗試了0~4層, 實驗結果是層數越多越好, 但4層之后提升很有限, 層數越多訓練越困難

作者這里還啟發了一個事情, 從"雙塔"的角度再看YouTubeDNN召回模型, 這里的DNN個結構,其實就是一個用戶塔, 輸入用戶的特征,最終通過DNN,編碼出了用戶的embedding向量,
而得到用戶embedding向量到后面做softmax那塊,不是說了會經過一個item embedding矩陣嗎? 其實這個矩陣也可以用一個item塔來實作, 和用戶embedding計算的方式類似, 首先各個item通過一個物品塔(輸入是item 特征, 輸出是item embedding),這樣其實也能得到每個item的embedding,然后做多分類或者是二分類等, 所以YouTubeDNN召回模型本質上還是雙塔結構, 只不過上面圖里面值體現了用戶塔, 我看deepmatch包里面實作的時候, 用戶特征和item特征分開輸入的, 感覺應該就是實作了個雙塔,原始碼倒是沒看, 等看了之后再確認,
3.4 線上服務
線上服務的時候, YouTube采用了一種最近鄰搜索的方法去完成topK推薦,這其實是工程與學術trade-off的結果, model serving程序中對幾百萬個候選集一一跑模型顯然不現實, 所以通過召回模型得到用戶和video的embedding之后, 用最近鄰搜索的效率會快很多,
我們甚至不用把任何model inference的程序搬上服務器,只需要把user embedding和video embedding存到redis或者記憶體中就好了,like this:

在線上,可以根據用戶興趣Embedding,采用類似Faiss等高效Embedding檢索工具,快速找出和用戶興趣匹配的物品, 高效embedding檢索工具, 我目前接觸到了兩個,一個是Faiss, 一個是annoy, 關于這兩個工具的使用, 我也整理了兩篇文章:
- annoy(快速近鄰向量搜索包)學習小記
- Faiss(Facebook開源的高效相似搜索庫)學習小記
之前寫新聞推薦比賽的時候用過Faiss, 這次實驗中使用的是annoy工具包,
另外多整理一點:
我們做線上召回的時候, 其實可以有兩種:
- item_2_item: 因為我們有了所有item的embedding了, 那么就可以進行物品與物品之間相似度計算,每個物品得到近似的K個, 這時候,就和協同過濾原理一樣, 之間通過用戶觀看過的歷史item,就能進行相似召回了, 工程實作上,一般會每個item建立一個相似度倒排表
- user_2_item: 將item用faiss或者annoy組織成index,然后用user embedding去查相近item
召回環節到這里差不多,接下來整理排序模塊,
4. YouTubeDNN排序模型細節剖析
排序模型面對的只是來自檢索的數百候選視頻,所以可以使用更多精細的特征, 重點是精準預測,

檢索出的數百候選視頻可能來自于不同的檢索模型,它們的檢索得分無法直接對比,排序模型的另一個作用是融合這些來自不同檢索模型的候選視頻,
感覺這篇paper在排序模型這塊的特色,第一個是特征工程,第二個是優化目標, 后面也會詳細剖析這兩大塊, 但在這之前,還是先看模型結構,
4.1 模型結構
排序模型的結構類似檢索模型,如下圖,

模型結構本身并不是很復雜, 也符合之前精排模型的演進歷程, 2016年,那時候DNN剛出來,所以是一個比較簡單版本的DNN,比我之前整理的后續模型要簡單很多, 所以模型結構上不用過多解釋,
這里重點是特征工程,以及目標函式優化上,也就是上圖的首段和尾端,
4.2 特征工程
排序模型用的特征主要分為兩大類, 一類是展示(impression)相關特征, 如視頻屬性等,另一類是query相關屬性,如user/context屬性, 看上面圖里面:
- imporession video ID embedding: 當前要計算的video的embedding
- watched video IDs: 用戶觀看過的最近N個視頻embedding,然后求pooling
- language embedding: 用戶語言embedding和視頻embedding
這三個,就是上面兩大類的代表, 當然,還可以加入更多刻畫視頻和用戶特點的特征, 用戶的年齡,性別, 職業,位置等, 視頻的類別, 上傳時間, 關鍵詞等,
這種特征的處理方式很常規理論, 類別特征需要embedding, 連續特征需要歸一化, 但這里面提到了兩點新的也很有意思:
- 類別特征embedding的維度選擇: 建議選擇與item物料庫大小的對數比例, 即 embed dim ? ∝ log ? ( Vocab Size ) \text { embed } \operatorname{dim} \propto \log (\text { Vocab Size }) embed dim∝log( Vocab Size ), 如果某個類別的取值特別多,可以限定一個值,長尾的值對應的表示為全0的向量(這個也是一個工程和學術的trade-off, 低頻item的embedding學不好, 干脆截斷它)
- 作者發現對連續特征的歸一化處理方式很很影響訓練的收斂性, 但是作者在這里采用了一種累積分布歸一化的方式,對于連續變數
x
x
x ,假設它的取值分布為
f
f
f ,通過以下公式把
x
x
x歸一化為
x
~
∈
[
0
,
1
)
\tilde{x} \in[0,1)
x~∈[0,1):
x
~
=
∫
?
∞
x
d
f
\tilde{x}=\int_{-\infty}^{x} \mathrm{~d} f
x~=∫?∞x? df,就是
x
~
\tilde{x}
x~在
x
x
x整體取值中的百分比排序位置,
具體歸一化時,會使用 x x x 訓練資料中取值分布的四分位值插值得到近似的 x ~ \tilde{x} x~ ,除了歸一化后的 x ~ \tilde{x} x~ , x 2 ~ \tilde{x^2} x2~和 x ~ \sqrt{\tilde{x}} x~ ?也會加入到輸入中,這也是處理連續特征的常見手段,可以增加非線性,
但如果僅僅只有這種特征的話,就太小看這篇文章了,后面的兩個特征才是作者花大功夫做的,
- time since last watch: 自上次觀看同channel視頻的時間
- #previous imporessions: 該視頻已經被曝光給該用戶的次數
這是作者提到的重要的特征, 即能描述用戶歷史上與待評分視頻,或類似視頻已有的互動行為信號的特征,例如用戶與待評分視頻所在頻道的互動歷史:用戶觀看了此頻道的多少視頻?用戶最后一次觀看同主題視頻是什么時候?這些特征的泛發性很好,對待評分視頻的預測很有幫助,

而后面兩個特征制作的依據正是從這個角度出發, time since last watch這個特征來反應用戶看同類視頻的間隔時間,
從用戶的角度想一想,假如我們剛看過“DOTA經典回顧”這個channel的視頻,我們很大概率是會繼續看這個channel的視頻的,那么該特征就很好的捕捉到了這一用戶行為,
另外一點經驗, 把召回模型的資訊以特征形式傳入進排序模型也很有幫助,例如,哪些召回模型召回了這個候選視頻?它們都給出了什么分數?

描述視頻歷史展示頻率的特征對于在推薦中引入 “流失” 也很關鍵(連續的請求不會回傳相同的串列),如果最近給用戶推薦了一個視頻但用戶沒觀看,那么模型會在下一次頁面加載時自動降低這個視頻的展示可能, 這個其實就是#previous impressions干的活,
#previous impressions一定程度上引入exploration的思想,避免同一個視頻持續對同一用戶進行無效曝光,盡量增加用戶沒看過的新視頻的曝光可能性,
4.3 建模目標 - 用戶的觀看時長
這里精排模型訓練的目標函式和我們之前看到的不太一樣:

模型優化的目標是每次展示的平均觀看時間,作者認為按點擊率排序會傾向于把傭訓用戶點擊(用戶未必真感興趣)的視頻排前面,而觀看時間能更好地反映出用戶對視頻的興趣,并增加用戶的watch time也更符合一個視頻網站的長期利益和用戶粘性,
王喆老師: 這個問題看似很小,實則非常重要,objective的設定應該是一個演算法模型的根本性問題,而且是演算法模型部門跟其他部門介面性的作業,從這個角度說,YouTube的推薦模型符合其根本的商業模型,非常好的經驗,
把訓練了看成一個二分類問題, 其中訓練資料中的正樣本是曝光點擊的視頻資料, 負樣本是曝光未點擊的資料, 優化目標是點擊率,這是我們之前排序模型常用的目標, 而這里想把目標變成優化平均觀看時長, 那么如何變化呢?
這里采用了加權的邏輯回歸, 將用戶的watch time作為正樣本的權重, 線上serving的時候時候 e w x + b e^{wx+b} ewx+b做預測,就能直接得到expected watch time的近似
但這么說有點太籠統了, 這究竟是怎么做的呢? 為啥這么做可行呢? 背后的原因是啥呢? 這一下子就來了個靈魂三問(what? why? How?)
首先先解決what的問題,也就是這是怎么做的, 如何把目標變成優化平均觀看時長? 關鍵就在于訓練樣本的權重, paper做的時候,是所有負樣本的權重都是1, 而正樣本的權重是點擊后的視頻觀看時長
T
i
T_i
Ti?, 模型最后計算損失的時候,是使用了加權交叉熵:
Weighted CE Loss
=
?
∑
i
[
T
i
y
i
log
?
p
i
+
(
1
?
y
i
)
log
?
(
1
?
p
i
)
]
\text { Weighted CE Loss }=-\sum_{i}\left[T_{i} y_{i} \log p_{i}+\left(1-y_{i}\right) \log \left(1-p_{i}\right)\right]
Weighted CE Loss =?i∑?[Ti?yi?logpi?+(1?yi?)log(1?pi?)]
這里的
T
i
T_i
Ti?是觀看時長,由于邏輯回歸的運算式:
p
i
=
1
1
+
e
?
f
(
x
i
)
p_{i}=\frac{1}{1+e^{-f\left(\mathbf{x}_{i}\right)}}
pi?=1+e?f(xi?)1?
線上使用的時候, 選取了值
e
f
(
x
i
)
e^{f(x_i)}
ef(xi?)最大的那些視頻進行推薦,
那么為什么優化這樣的加權Loss,就是近似優化平均觀看時長呢? why的問題, 這個建議看王喆老師整理的serving之謎的那篇文章,鏈接在下面,這里只是簡單的解釋下:
首先,我們熟知的大名鼎鼎的sigmoid函式并不是無腦拍出來的, 而是由對數線性模型推導過來的:
log
?
p
1
?
p
=
θ
T
X
?
p
1
?
p
=
e
θ
T
X
?
p
(
1
+
e
θ
T
X
)
=
e
θ
T
X
?
p
=
1
1
+
e
?
θ
T
X
\begin{aligned} &\log \frac{p}{1-p}=\theta^{T} X \Rightarrow \frac{p}{1-p}=e^{\theta^{T} X} \Rightarrow p\left(1+e^{\theta^{T} X}\right)=e^{\theta^{T} X} \\ &\Rightarrow p=\frac{1}{1+e^{-\theta^{T} X}} \end{aligned}
?log1?pp?=θTX?1?pp?=eθTX?p(1+eθTX)=eθTX?p=1+e?θTX1??
前面的
log
?
p
1
?
p
\log \frac{p}{1-p}
log1?pp?,這個東西就是logit函式, 而去掉log符號之后的變數叫做幾率, 即一件事情發生和不發生的比值, 那么這個東西又是怎么來的呢?
這個就涉及到指數族分布和伯努利分布的一些內容, 我們知道,邏輯回歸的先驗分布是伯努利, 而伯努利分布是指數族分布, 這里面經過層層理論化簡推導,最終才出來了sigmoid函式, 關于這塊詳細程序, 我之前整理邏輯回歸的時候整理過,這里就不寫了, 這篇文章
我們這里,就知道原來這個logit函式經過化簡能到sigmoid函式, 而YouTube線上服務預測的 e W x + b e^{Wx+b} eWx+b計算的正是 p 1 ? p \frac{p}{1-p} 1?pp?, 那么下面的邏輯就很簡單,只需要看看為啥預測的用戶觀看時長,成了這樣的一個幾率比了呢?
這個王喆老師解釋的很到位,因為我們訓練的時候,是采用了加權的LR,這個加權LR的特點是正樣本權重 w w w的加入讓這個正樣本發生的幾率變成原來的 w w w倍,
假設原始資料集用有 k k k個正樣本, m m m個負樣本, 總個數是 n n n, 那么正樣本發生的概率 p = k n p=\frac{k}{n} p=nk?,幾率比為
Odds ? ( i ) = p 1 ? p = k m = # p o s # n e g \operatorname{Odds}(i)=\frac{p}{1- p} = \frac{k}{m} = \frac{\#pos}{\#neg} Odds(i)=1?pp?=mk?=#neg#pos?
如果每個正例都用時長進行加權,那么分子上的正例個數可以近似等于
∑
i
T
i
\sum_{i} T_{i}
∑i?Ti?,所以等式右邊的化簡為
#
pos
#
neg
=
∑
i
T
i
n
?
k
=
∑
i
T
i
n
n
n
?
k
=
E
[
T
]
1
1
?
p
?
E
[
T
]
\frac{\# \text { pos }}{\# \text { neg }}=\frac{\sum_{i} T_{i}}{n-k}=\frac{\sum_{i} T_{i}}{n} \frac{n}{n-k}=E[T] \frac{1}{1-p} \simeq E[T]
# neg # pos ?=n?k∑i?Ti??=n∑i?Ti??n?kn?=E[T]1?p1??E[T]
由于視頻推薦場景中,用戶打開一個視頻的概率
p
p
p往往是一個很小的值,所以上面式子其實就近似為用戶觀看視頻的平均時長,而
p
1
?
p
=
e
W
x
+
b
\frac{p}{1-p}=e^{Wx+b}
1?pp?=eWx+b
所以最終
e
W
x
+
b
?
E
[
T
]
e^{Wx+b}\simeq E[T]
eWx+b?E[T]
采用
e
W
x
+
b
e^{Wx+b}
eWx+b這個形式預測就近似用戶觀看視頻時長的期望,用該指標排序后推薦, 符合YouTube推薦場景和以觀看時長為優化目標,
4.4 實驗結果
這里作者定義了一個"weighted, per-user loss",這個詞的意思是
給定一個用戶, 每次選定同頁面展示的一對樣本作為比較物件, 一個為正(曝光點擊), 一個為負(曝光未點擊), 權重來自正樣本觀看時間, 如果負樣本的得分比正樣本高, 就認為正樣本的觀看時間被誤識別了, weight,per-user loss是誤識別的觀看時間占總觀看時間的比例
實驗結果表明, DNN結果增加寬度和深度都能使得結果提高, 但到了3層之后收益就很小了,
- 3層模型時, 如果不使用連續變數的冪次轉換 x ~ 2 \tilde{x}^{2} x~2和 x ~ \sqrt{\tilde{x}} x~ ?, 損失上升 0.2 % 0.2\% 0.2%
- 如果訓練時不對正樣本觀看時間加權, 正負樣本等權重, 損失上升 4.1 % 4.1\% 4.1%

ok,到這里排序模型也差不多了,
5. 基于Deepmatch包YouTubeDNN的使用方法
由于時間原因, 我這里并沒有自己寫代碼復現YouTubeDNN模型,這個結構也比較簡單, 幾層的DNN,自己再寫一遍剖析架構也沒有啥意思, 所以就采用淺夢大佬寫的deepmatch包, 直接用到了自己的資料集上做了實驗, 關于Deepmatch原始碼, 還是看deepmatch專案, 這里主要是整理下YouTubeDNN如何用,
專案里面其實給出了如何使用YouTubeDNN,采用的是movielens資料集, 見這里
我這里就基于我做實驗用的新聞推薦資料集, 把代碼的主要邏輯過一遍,
5.1 資料集
實驗用的資料集是新聞推薦的一個資料集,是做func-rec專案時候一個伙伴分享的,來自于某個推薦比賽,因為這個資料集是來自工業上的真實資料,所以使用起來比之前用的movielens資料集可嘗試的東西多一些,并且原資料有8個多G,總共3個檔案: 用戶畫像,文章畫像, 點擊日志,用戶數量100多萬,6000多萬次點擊, 文章規模是幾百,資料量也比較豐富,所以后面就打算采用這個統一的資料集, 重新做實驗,對比目前GitHub上的各個模型,關于資料集每個檔案詳細描述,后面會更新到GitHub專案,
這里只整理我目前的使用程序, 由于有8個多G的資料,我這邊沒法直接跑,所以對資料進行了采樣, 采樣方法寫成了一個jupyter檔案, 主要包括:
- 分塊讀取資料, 無法一下子讀入記憶體
- 對于每塊資料,基于一些篩選規則進行記錄的洗掉,比如只用了后7天的資料, 洗掉了一些文章不在物料池的資料, 洗掉不合法的點擊記錄(曝光時間大于文章上傳時間), 洗掉沒有歷史點擊的用戶,洗掉觀看時間低于3s的視頻, 洗掉歷史點擊序列太短和太長的用戶記錄
- 洗掉完之后重新保存一份新資料集,大約3個G,然后再從這里面隨機采樣了20000用戶進行了后面實驗
通過上面的一波操作, 我的小本子就能跑起來了,當然可能資料比較少,最終訓練的YouTubeDNN效果并不是很好,詳細看后面GitHub的: 點擊日志資料集初步處理與采樣.ipynb
5.2 簡單資料預處理
這個也是寫成了一個筆記本, 主要是看了下采樣后的資料,序列長度分布等,由于上面做了一些規整化,這里有毛病的資料不是太多,并沒有太多處理, 但是用戶資料里面的年齡,性別源資料是給出了多種可能, 每個可能有概率值,我這里選出了概率最大的那個,然后簡單填充了缺失,
最后把能用到的用戶畫像和文章畫像統一拼接到了點擊日志資料,又保存了一份, 作為YouTubeDNN模型的使用資料, 其他模型我也打算使用這份資料了,
詳見EDA與資料預處理.ipynb
5.3 YouTubeDNN召回
這里就需要解釋下一些代碼了, 首先拿到采樣的資料集,我們先劃分下訓練集和測驗集:
- 測驗集: 每個用戶的最后一次點擊記錄
- 訓練集: 每個用戶除最后一次點擊的所有點擊記錄
這個具體代碼就不在這里寫了,
user_click_hist_df, user_click_last_df = get_hist_and_last_click(click_df)
這么劃分的依據,就是保證不能發生資料穿越,拿最后的測驗,不能讓模型看到,
接下來,就是YouTubeDNN模型的召回,從構造資料集 -> 訓練模型 -> 產生召回結果,我寫到了一個函式里面去,
def youtubednn_recall(data, topk=200, embedding_dim=8, his_seq_maxlen=50, negsample=0,
batch_size=64, epochs=1, verbose=1, validation_split=0.0):
"""通過YouTubeDNN模型,計算用戶向量和文章向量
param: data: 用戶日志資料
topk: 對于每個用戶,召回多少篇文章
"""
user_id_raw = data[['user_id']].drop_duplicates('user_id')
doc_id_raw = data[['article_id']].drop_duplicates('article_id')
# 類別資料編碼
base_features = ['user_id', 'article_id', 'city', 'age', 'gender']
feature_max_idx = {}
for f in base_features:
lbe = LabelEncoder()
data[f] = lbe.fit_transform(data[f])
feature_max_idx[f] = data[f].max() + 1
# 構建用戶id詞典和doc的id詞典,方便從用戶idx找到原始的id
user_id_enc = data[['user_id']].drop_duplicates('user_id')
doc_id_enc = data[['article_id']].drop_duplicates('article_id')
user_idx_2_rawid = dict(zip(user_id_enc['user_id'], user_id_raw['user_id']))
doc_idx_2_rawid = dict(zip(doc_id_enc['article_id'], doc_id_raw['article_id']))
# 保存下每篇文章的被點擊數量, 方便后面高熱文章的打壓
doc_clicked_count_df = data.groupby('article_id')['click'].apply(lambda x: x.count()).reset_index()
doc_clicked_count_dict = dict(zip(doc_clicked_count_df['article_id'], doc_clicked_count_df['click']))
train_set, test_set = gen_data_set(data, doc_clicked_count_dict, negsample, control_users=True)
# 構造youtubeDNN模型的輸入
train_model_input, train_label = gen_model_input(train_set, his_seq_maxlen)
test_model_input, test_label = gen_model_input(test_set, his_seq_maxlen)
# 構建模型并完成訓練
model = train_youtube_model(train_model_input, train_label, embedding_dim, feature_max_idx, his_seq_maxlen, batch_size, epochs, verbose, validation_split)
# 獲得用戶embedding和doc的embedding, 并進行保存
user_embs, doc_embs = get_embeddings(model, test_model_input, user_idx_2_rawid, doc_idx_2_rawid)
# 對每個用戶,拿到召回結果并回傳回來
user_recall_doc_dict = get_youtube_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk)
return user_recall_doc_dict
這里面說一下主要邏輯,主要是下面幾步:
-
用戶id和文章id我們要先建立索引-原始id的字典,因為我們模型里面是要把id轉成embedding,模型的表示形式會是{索引: embedding}的形式, 如果我們想得到原始id,必須先建立起映射來
-
把類別特征進行label Encoder, 模型輸入需要, embedding層需要,這是構建詞典常規操作, 這里要記錄下每個特征特征值的個數,建詞典索引的時候用到,得知道詞典大小
-
保存了下每篇文章被點擊數量, 方便后面對高熱文章實施打壓
-
構建資料集
rain_set, test_set = gen_data_set(data, doc_clicked_count_dict, negsample, control_users=True)這個需要解釋下, 雖然我們上面有了一個訓練集,但是這個東西是不能直接作為模型輸入的, 第一個原因是正樣本太少,樣本數量不足,我們得需要滑動視窗,每個用戶再滑動構造一些,第二個是不滿足deepmatch實作的模型輸入格式,所以gen_data_set這個函式,是用deepmatch YouTubeDNN的第一個范式,基本上得按照這個來,只不過我加了一些策略上的嘗試:
def gen_data_set(click_data, doc_clicked_count_dict, negsample, control_users=False): """構造youtubeDNN的資料集""" # 按照曝光時間排序 click_data.sort_values("expo_time", inplace=True) item_ids = click_data['article_id'].unique() train_set, test_set = [], [] for user_id, hist_click in tqdm(click_data.groupby('user_id')): # 這里按照expo_date分開,每一天用滑動視窗滑,可能相關性更高些,另外,這樣序列不會太長,因為eda發現有點擊1111個的 #for expo_date, hist_click in hist_date_click.groupby('expo_date'): # 用戶當天的點擊歷史id pos_list = hist_click['article_id'].tolist() user_control_flag = True if control_users: user_samples_cou = 0 # 過長的序列截斷 if len(pos_list) > 50: pos_list = pos_list[-50:] if negsample > 0: neg_list = gen_neg_sample_candiate(pos_list, item_ids, doc_clicked_count_dict, negsample, methods='multinomial') # 只有1個的也截斷 去掉,當然我之前做了處理,這里沒有這種情況了 if len(pos_list) < 2: continue else: # 序列至少是2 for i in range(1, len(pos_list)): hist = pos_list[:i] # 這里采用打壓熱門item策略,降低高展item成為正樣本的概率 freq_i = doc_clicked_count_dict[pos_list[i]] / (np.sum(list(doc_clicked_count_dict.values()))) p_posi = (np.sqrt(freq_i/0.001)+1)*(0.001/freq_i) # p_posi=0.3 表示該item_i成為正樣本的概率是0.3, if user_control_flag and i != len(pos_list) - 1: if random.random() > (1-p_posi): row = [user_id, hist[::-1], pos_list[i], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], hist_click.iloc[i]['example_age'], 1, len(hist[::-1])] train_set.append(row) for negi in range(negsample): row = [user_id, hist[::-1], neg_list[i*negsample+negi], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], hist_click.iloc[i]['example_age'], 0, len(hist[::-1])] train_set.append(row) if control_users: user_samples_cou += 1 # 每個用戶序列最長是50, 即每個用戶正樣本個數最多是50個, 如果每個用戶訓練樣本數量到了30個,訓練集不能加這個用戶了 if user_samples_cou > 30: user_samples_cou = False # 整個序列加入到test_set, 注意,這里一定每個用戶只有一個最長序列,相當于測驗集數目等于用戶個數 elif i == len(pos_list) - 1: row = [user_id, hist[::-1], pos_list[i], hist_click.iloc[0]['city'], hist_click.iloc[0]['age'], hist_click.iloc[0]['gender'], 0, 0, len(hist[::-1])] test_set.append(row) random.shuffle(train_set) random.shuffle(test_set) return train_set, test_set關鍵代碼邏輯是首先點擊資料按照時間戳排序,然后按照用戶分組,對于每個用戶的歷史點擊, 采用滑動視窗的形式,邊滑動邊構造樣本, 第一個注意的地方,是每滑動一次生成一條正樣本的時候, 要加入一定比例的負樣本進去, 第二個注意最后一整條序列要放到test_set里面,
我這里面加入的一些策略,負樣本候選集生成我單獨寫成一個函式,因為嘗試了隨機采樣和打壓熱門item采樣兩種方式, 可以通過methods引數選擇, 另外一個就是正樣本里面也按照熱門實作了打壓, 減少高熱item成為正樣本概率,增加高熱item成為負樣本概率, 還加了一個控制用戶樣本數量的引數,去保證每個用戶生成一樣多的樣本數量,打壓下高活用戶, -
構造模型輸入
這個也是調包的定式操作,必須按照這個寫法來:def gen_model_input(train_set, his_seq_max_len): """構造模型的輸入""" # row: [user_id, hist_list, cur_doc_id, city, age, gender, label, hist_len] train_uid = np.array([row[0] for row in train_set]) train_hist_seq = [row[1] for row in train_set] train_iid = np.array([row[2] for row in train_set]) train_u_city = np.array([row[3] for row in train_set]) train_u_age = np.array([row[4] for row in train_set]) train_u_gender = np.array([row[5] for row in train_set]) train_u_example_age = np.array([row[6] for row in train_set]) train_label = np.array([row[7] for row in train_set]) train_hist_len = np.array([row[8] for row in train_set]) train_seq_pad = pad_sequences(train_hist_seq, maxlen=his_seq_max_len, padding='post', truncating='post', value=0) train_model_input = { "user_id": train_uid, "click_doc_id": train_iid, "hist_doc_ids": train_seq_pad, "hist_len": train_hist_len, "u_city": train_u_city, "u_age": train_u_age, "u_gender": train_u_gender, "u_example_age":train_u_example_age } return train_model_input, train_label上面構造資料集的時候,是把每個特征加入到了二維陣列里面去, 這里得告訴模型,每一個維度是啥特征資料,如果相加特征,首先構造資料集的時候,得把資料加入到陣列中, 然后在這個函式里面再指定新加入的特征是啥, 下面的那個詞典, 是為了把資料輸入和模型的Input層給對應起來,通過字典鍵進行標識,
-
訓練YouTubeDNN
這一塊也是定式, 在建模型事情,要把特征封裝起來,告訴模型哪些是離散特征,哪些是連續特征, 模型要為這些特征建立不同的Input層,處理方式是不一樣的def train_youtube_model(train_model_input, train_label, embedding_dim, feature_max_idx, his_seq_maxlen, batch_size, epochs, verbose, validation_split): """構建youtubednn并完成訓練""" # 特征封裝 user_feature_columns = [ SparseFeat('user_id', feature_max_idx['user_id'], embedding_dim), VarLenSparseFeat(SparseFeat('hist_doc_ids', feature_max_idx['article_id'], embedding_dim, embedding_name="click_doc_id"), his_seq_maxlen, 'mean', 'hist_len'), SparseFeat('u_city', feature_max_idx['city'], embedding_dim), SparseFeat('u_age', feature_max_idx['age'], embedding_dim), SparseFeat('u_gender', feature_max_idx['gender'], embedding_dim), DenseFeat('u_example_age', 1,) ] doc_feature_columns = [ SparseFeat('click_doc_id', feature_max_idx['article_id'], embedding_dim) # 這里后面也可以把文章的類別畫像特征加入 ] # 定義模型 model = YoutubeDNN(user_feature_columns, doc_feature_columns, num_sampled=5, user_dnn_hidden_units=(64, embedding_dim)) # 模型編譯 model.compile(optimizer="adam", loss=sampledsoftmaxloss) # 模型訓練,這里可以定義驗證集的比例,如果設定為0的話就是全量資料直接進行訓練 history = model.fit(train_model_input, train_label, batch_size=batch_size, epochs=epochs, verbose=verbose, validation_split=validation_split) return model然后就是建模型,編譯訓練即可,這塊就非常簡單了,當然模型方面有些引數,可以了解下,另外一個注意點,就是這里用戶特征和item特征進行了分開, 這其實和雙塔模式很像, 用戶特征最后編碼成用戶向量, item特征最后編碼成item向量,
-
獲得用戶向量和item向量
模型訓練完之后,就能從模型里面拿用戶向量和item向量, 我這里單獨寫了一個函式:獲取用戶embedding和文章embedding def get_embeddings(model, test_model_input, user_idx_2_rawid, doc_idx_2_rawid, save_path='embedding/'): doc_model_input = {'click_doc_id':np.array(list(doc_idx_2_rawid.keys()))} user_embedding_model = Model(inputs=model.user_input, outputs=model.user_embedding) doc_embedding_model = Model(inputs=model.item_input, outputs=model.item_embedding) # 保存當前的item_embedding 和 user_embedding 排序的時候可能能夠用到,但是需要注意保存的時候需要和原始的id對應 user_embs = user_embedding_model.predict(test_model_input, batch_size=2 ** 12) doc_embs = doc_embedding_model.predict(doc_model_input, batch_size=2 ** 12) # embedding保存之前歸一化一下 user_embs = user_embs / np.linalg.norm(user_embs, axis=1, keepdims=True) doc_embs = doc_embs / np.linalg.norm(doc_embs, axis=1, keepdims=True) # 將Embedding轉換成字典的形式方便查詢 raw_user_id_emb_dict = {user_idx_2_rawid[k]: \ v for k, v in zip(user_idx_2_rawid.keys(), user_embs)} raw_doc_id_emb_dict = {doc_idx_2_rawid[k]: \ v for k, v in zip(doc_idx_2_rawid.keys(), doc_embs)} # 將Embedding保存到本地 pickle.dump(raw_user_id_emb_dict, open(save_path + 'user_youtube_emb.pkl', 'wb')) pickle.dump(raw_doc_id_emb_dict, open(save_path + 'doc_youtube_emb.pkl', 'wb')) # 讀取 #user_embs_dict = pickle.load(open('embedding/user_youtube_emb.pkl', 'rb')) #doc_embs_dict = pickle.load(open('embedding/doc_youtube_emb.pkl', 'rb')) return user_embs, doc_embs獲取embedding的這兩行代碼是固定操作, 下面做了一些歸一化操作,以及把索引轉成了原始id的形式,
-
向量最近鄰檢索,為每個用戶召回相似item
def get_youtube_recall_res(user_embs, doc_embs, user_idx_2_rawid, doc_idx_2_rawid, topk): """近鄰檢索,這里用annoy tree""" # 把doc_embs構建成索引樹 f = user_embs.shape[1] t = AnnoyIndex(f, 'angular') for i, v in enumerate(doc_embs): t.add_item(i, v) t.build(10) # 可以保存該索引樹 t.save('annoy.ann') # 每個用戶向量, 回傳最近的TopK個item user_recall_items_dict = collections.defaultdict(dict) for i, u in enumerate(user_embs): recall_doc_scores = t.get_nns_by_vector(u, topk, include_distances=True) # recall_doc_scores是(([doc_idx], [scores])), 這里需要轉成原始doc的id raw_doc_scores = list(recall_doc_scores) raw_doc_scores[0] = [doc_idx_2_rawid[i] for i in raw_doc_scores[0]] # 轉換成實際用戶id try: user_recall_items_dict[user_idx_2_rawid[i]] = dict(zip(*raw_doc_scores)) except: continue # 默認是分數從小到大排的序, 這里要從大到小 user_recall_items_dict = {k: sorted(v.items(), key=lambda x: x[1], reverse=True) for k, v in user_recall_items_dict.items()} # 保存一份 pickle.dump(user_recall_items_dict, open('youtube_u2i_dict.pkl', 'wb')) return user_recall_items_dict用了用戶embedding和item向量,就可以通過這個函式進行檢索, 這塊主要是annoy包做近鄰檢索的固定格式, 檢索完畢,為用戶生成最相似的200個候選item,
以上,就是使用YouTubeDNN做召回的整個流程, 效果如下:

這個字典長這樣:

接下來就是評估模型的效果,這里我采用了簡單的HR@N計算的, 具體代碼看GitHub吧, 結果如下:

結果不怎么樣啊,唉, 難道是資料量太少了? 總歸是跑起來且能用了,
詳細代碼見尾部GitHub鏈接吧, 硬體設施到位的可以嘗試多用一些資料試試看哈哈,
6. YouTubeDNN新聞推薦資料集的實驗記錄
這塊就比較簡單了,簡單的整理下我用上面代碼做個的實驗,嘗試了論文里面的幾個點,記錄下:
- 負采樣方式上,嘗試了隨機負采樣和打壓高熱item兩種方式, 從我的實驗結果上來看, 帶打壓的效果略好一點點

- 特征上, 嘗試原論文給出的example age的方式,做一個樣本的年齡特征出來
這個年齡樣本,我是用的訓練集的最大時間減去曝光的時間,然后轉成小時間隔算的,而測驗集里面的統一用0表示, 但效果好差, 看好多文章說這個時間單位是個坑,不知道是小時,分鐘,另外這個特征我只做了簡單歸一化,感覺應該需要做歸一化

- 嘗試了控制用戶數量,即每個用戶的樣本數量保持一樣,效果比上面略差

- 開始模型評估,我嘗試用最后一天的,而不是最后一次點擊的, 感覺效果不如最后一次點擊作為測驗集效果好
當然,上面實驗并沒有太大說服力,第一個是我采樣的資料量太少,模型本身訓練的不怎么樣,第二個這些策略相差的并不是很大, 可能有偶然性,
并且我這邊做一次實驗,要花費好長時間,探索就先到這里吧, example age那個確實是個迷, 其他的感覺起來, 打壓高活效果要比不打壓要好,
另外要記錄下學習小tricks:
跑一次這樣的實驗,我這邊一般會花費兩個小時左右的時間, 而這個時間在做實驗之前,一定要做規劃才能好好的利用起來, 比如,我計劃明天上午要開始嘗試各種策略做實驗, 今天晚上的todo里面,就要記錄好, 我會嘗試哪些策略,記錄一個表, 調整策略,跑模型的時候,我這段空檔要干什么事情, todo里面都要記錄好,比如我這段空檔就是解讀這篇paper,寫完這篇博客,基本上是所有實驗做完,我這篇博客也差不多寫完,正好,哈哈
這個空檔利用,一定要提前在todo里面寫好,而不是跑模型的時候再想,這個時候往往啥也干不下去,并且還會時不時的看模型跑,或者盯著進度條發呆,那這段時間就有些浪費了呀,即使這段時間不學習,看個久違的電視劇, 久違的書,或者keep下不香嗎哈哈, 但得提前規劃,
可能每個人習慣不一樣,對于我,是這樣哈,所以記錄下 😉
7. 小總
完結撒花,這篇文章從做實驗到寫完也花了差不多兩天的時間,不過識訓還是蠻大的,對YouTubeDNN又有了新的認識, 更重要的是學習到一些重要的思想和工程上的經驗吧,雖然是好幾年之前的論文, 但每次都能溫故知新,而這次,由于邊動手做實驗邊學習, 知識比之前更加通透了些,所以也趁著熱乎,趕緊整理了下來,
當然,由于時間原因,這次沒來得及嘗試排序模型,以及排序方面的那幾個技巧, 比如特征工程部分, 目標函式那塊,給樣本加權等,如果后面有時間, 我也得學習下給每個樣本加權的方法, 其實這個并不是太會, 那時候再補充召回方面的實驗吧,
由于這篇文章里面的工程經驗太多啦,我前面介紹的時候,可能涉及到知識的一些擴展補充, 把經驗整理的比較凌亂, 這里再統一整理下, 這些也都是工業界常用的一些經驗了:
召回部分:
- 訓練資料的樣本來源應該是全部物料, 而不僅僅是被推薦的物料,否則對于新物料難以曝光
- 訓練資料中對于每個用戶選取相同的樣本數, 保證用戶在損失函式等權重, 這個雖然不一定非得這么做,但考慮打壓高活用戶或者是高活item的影響還是必須的
- 序列無序化: 用戶的最近一次搜索與搜索之后的播放行為有很強關聯,為了避免資訊泄露,將搜索行為順序打亂,
- 訓練資料構造: 預測接下來播放而不是用傳統cbow中的兩側預測中間的考慮是可以防止資訊泄露,并且可以學習到用戶的非對稱視頻消費模式
- 召回模型中,類似word2vec,video 有input embedding和output embedding兩組embedding,并不是共享的, input embedding論文里面是用w2v事先訓練好的, 其實也可以用embedding層聯合訓練
- 召回模型的用戶embedding來自網路輸出, 而video的embedding往往用后面output處的
- 使用
example age特征處理 time bias,這樣線上檢索時可以預先計算好用戶向量
排序部分:
- 特征工程連續特征歸一化,可以按分布進行歸一化
- 連續特征重要的, 還可以人為加入非線性,平方,對數,開根號形式等
- 離散特征embedding維度選擇, 可以結合物料數量, 和
log(#item_nums)有關 - 能描述用戶歷史上與待評分視頻,或類似視頻已有的互動行為信號的特征很重要
- 召回來源和召回分數在排序中很重要
- 排序模型訓練使用基于觀看時長加權的交叉熵,這樣產生的排序可以近似認為是基于期望觀看時長進行的排序,
參考:
- 重讀Youtube深度學習推薦系統論文
- YouTube深度學習推薦系統的十大工程問題
- 你真的讀懂了Youtube DNN推薦論文嗎
- 推薦系統經典論文(二)】YouTube DNN
- 張俊林-推薦技術發展趨勢與召回模型
- 揭開YouTube深度推薦系統模型Serving之謎
- Deep Neural Networks for YouTube Recommendations YouTubeDNN推薦召回與排序
整理這篇文章的同時, 也建立了一個GitHub專案, 準備后面把各種主流的推薦模型用復現一遍,并用通俗易懂的語言進行注釋和邏輯整理, 今天的YouTubeDNN模型代碼已經上傳, 從這篇文章開始,后面的專案開始恢復更新和優化, 主要是資料集上采用統一的資料集,方便做實驗使用, 其次是參考deepctr和deepmatch代碼對模型優化,然后是每個模型盡量給出三版代碼,tf和Pytorch代碼兩版簡易以及掉包使用版, 該GitHub專案只是單純供學習使用, 不作任何商業用途,感興趣的可以看一下 ,star下我會更開心哈哈😉
筋斗云:https://github.com/zhongqiangwu960812/AI-RecommenderSystem
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/421673.html
標籤:AI

