「實操」結合圖數據庫、圖算法、機器學習、GNN 實現一個推薦系統

語言: CN / TW / HK

本文是一個基於 NebulaGraph 上圖算法、圖數據庫、機器學習、GNN 的推薦系統方法綜述,大部分介紹的方法提供了 Playground 供大家學習。

基本概念

推薦系統誕生的初衷是解決互聯網時代才面臨的信息量過載問題,從最初的 Amazon 圖書推薦、商品推薦,到電影、音樂、視頻、新聞推薦,如今大多數網站、App 中都有至少一個基於推薦系統生成的供用户選擇的物品列表界面。而這些物品的推薦基本都是基於用户喜好、物品的特徵、用户與物品交互歷史和其他相關上下文去做的。

一個推薦系統會包含以下幾個部分:

  • 數據、特徵的處理
  • 從特徵出發,生成推薦列表
  • 過濾、排序推薦列表

這其中,過濾的核心方法主要有兩種:基於內容的過濾 Content-Based Filtering、與協同過濾 Collaborative Filtering,相關論問介紹可參考延伸閲讀 1、2。

基於內容的過濾

內容過濾方法的本質是給用户的偏好做畫像,同時對所有待推薦的物品計算特徵,做用户的畫像與待推薦物品特徵之間的距離運算,過濾得到相近的物品。。

內容過濾的方法的好處有:

  • 清晰的可解釋性,無論是對用户的畫像分析,還是對物品的運算本身天然帶來了排序、過濾的可解釋性;
  • 用户數據輸入的獨立性,對指定的待推薦用户來説,只需要單獨分析他們的畫像和歷史評分就足夠了;
  • 規避新物品冷啟動問題,對於新的添加的物品,即使沒有任何歷史的用户評價,也可以做出推薦;

同時,基於內容過濾的推薦也有以下弊端:

  • 特徵提取難度,比如:照片、視頻等非純文本數據,它們的特徵提取依賴領域專家知識。舉個例子,電影推薦系統需要抽出導演、電影分類等領域知識作為特徵;
  • 難以打破舒適圈,發掘用户的潛在新興趣點;
  • 新用户冷啟動數據缺失問題,新用户個人信息少難以形成用户畫像,進而缺少做物品畫像、特種距離運算的輸入;

基於協同過濾

協同過濾的本質是結合用户與系統之間的交互行為去推薦物品。

協同過濾的方法又分為基於記憶 memory-based 的協同過濾與基於模型 model-based 的協同過濾。

基於記憶的協同過濾主要有物品與物品之間的協同過濾 ItemCF 和用户與用户之間的協同過濾 UserCF。ItemCF 簡單來説是,推薦和用户之前選擇相似的物品,即:根據行為找物品之間的相似性;UserCF 則推薦與用户有共同愛好的人所喜歡的物品,即:根據行為找用户之間的相似性。

基於模型的協同過濾主要根據用户喜好的歷史信息、利用統計與機器學習方法訓練模型,對新用户的偏好進行推理。

協同過濾的方法的好處有:

  • 無需對非結構化物品進行特徵分析,因為協同過濾關注的是用户和物品之間的協同交互,這繞過了對物品領域知識處理的需求;
  • 對用户的個性化定製程度更強、更細,基於行為的分析使得對用户偏好的劃分本質上是連續的(相比來説,對用户做畫像的方法則是離散的),這樣的推薦結果會更加“千人千面”。同時,也會藴含內容過濾、有限的畫像角度之下的“驚喜”推薦。

但,它的缺點有以下方面:

  • 有新用户和新物件上的冷啟動問題,因為它們身上都缺少歷史喜好行為的信息;

我們總結一下,兩種過濾方式各有利弊,也存在互補的地方。比如,新物件的冷啟動上,基於內容的過濾有優勢;對於個性化、推薦驚喜度方面,協同過濾有優勢。所以,在實操中,推薦系統大多演變都比上面的歸類複雜得多,而且常常伴隨着多種方法的融合。

基於圖的個性推薦

圖技術、圖數據庫技術在推薦系統中的應用是多方面的,在本章節中我們會從圖數據庫的出發點上給出多種應用例子。

建立圖譜

在開始之前,我簡單地介紹下本文使用的圖數據集。

為了給出更接近實際情況的例子,我從兩個公開的數據集 OMDBMovieLens 中分別抽取了所需信息,組成了一個既包含電影的卡司(導演、演員)和類型,又包含用户對電影評分記錄的知識圖譜。

Schema 如下:

  • 頂點:
    • user(user_id)
    • movie(name)
    • person(name, birthdate)
    • genre(name)
  • 邊:
    • watched(rate(double))
    • with_genre
    • directed_by
    • acted_by

這個數據的準備、ETL 過程會在另外的文章裏詳細介紹,在進入下一章節之前,我們可以用 Nebula-Up 一鍵搭起一個測試的 NebulaGraph 單機集羣,再參考數據集的 GitHub 倉庫,一鍵導入所需數據,數據集參考延伸閲讀 4。

具體操作步驟:

  1. 用 Nebula-Up 安裝 NebulaGraph;
  2. 克隆 movie-recommendation-dataset;
  3. 導入數據集 NebulaGraph;
curl -fsSL nebula-up.siwei.io/install.sh | bash

git clone https://github.com/wey-gu/movie-recommendation-dataset.git && cd movie-recommendation-dataset

docker run --rm -ti \
    --network=nebula-net \
    -v ${PWD}:/root/ \
    -v ${PWD}/dbt_project/to_nebulagraph/:/data \
    vesoft/nebula-importer:v3.2.0 \
    --config /root/nebula-importer.yaml

基於內容的過濾

CBF,內容過濾的思想是利用領域知識、歷史記錄、元數據分別對用户和物件做畫像、打標籤,最終根據用户的標籤與待推薦物件之間的距離評分進行排序給出相關推薦。

對用户的畫像不涉及其他用户的信息,但是輸入的特徵可能來源於元數據(生日、國籍、性別、年齡)、歷史記錄(評論、打分、購買、瀏覽)等等,在這些基礎之上對用户進行標籤標註、分類、聚類。

對物件的畫像輸入的特徵可能是基於語言處理(NLP、TF-IDF、LFM)、專家標註、多媒體處理(視覺到文字再 NLP、音頻風格處理、音頻到文字再 NLP)等。

有了用户畫像與物件的畫像特徵、對用户涉及的畫像進行相關畫像物件中新對象的近似度計算,再評分加權,就可以獲得最終的推薦排序了。其中的近似度計算常見的有 KNN、餘弦相似度、Jaccard 等算法。

CBF 的方法中沒有限定具體實現方式。如前邊介紹,可能是基於機器學習、Elasticsearch、圖譜等不同方法。為切合本章的主題,這裏我給出一個基於圖數據庫、圖譜上的 CBF 的例子,做一個電影推薦系統,能讓讀者理解這個方法的思想。同時,也能熟悉圖數據庫、知識圖譜的方法。

實操部分的用户特徵直接利用歷史電影評價記錄,而推薦物件「電影」的畫像則來自於領域中的知識。這些知識有:電影風格、電影的卡司、導演。近似度算法則採用圖譜中基於關係的 Jaccard 相似度算法。

Jaccard Index

Jaccard Index 是一個描述兩個集合距離的定義公式,非常簡單、符合直覺地取兩者的交集與並集測度的比例,它的定義記為:

這裏,我們把交集理解為 A 與 B 共同連接的點(有共同的導演、電影類型、演員),並集理解為這幾種關係下與 A 或者 B 直連的所有點,而測度就直接用數量表示。

CBF 方法在 NebulaGraph 中的實現

CBF 方法分如下四步:

  1. 找出推薦用户評分過的電影;
  2. 從用户評分過的電影,經由導演卡司、電影類型找到新的待推薦電影;
  3. 對看過的電影與新的電影,藉由導演、卡司、電影類型的關係,在圖上做 Jaccard 相似性運算,得出每一對看過的電影和待推薦新電影之間的 Jaccard 係數;
  4. 把用户對看過電影的評分作為加權係數,針對其到每一個新電影之間的 Jaccard 係數加權評分,獲得排序後的推薦電影列表;
// 用户 u_124 看過的電影
MATCH (u:`user`)-[:watched]->(m:`movie`) WHERE id(u) == "u_124"
WITH collect(id(m)) AS watched_movies_id

// 根據電影的標註關係找到備選推薦電影,刨除看過的,把評分、交集關聯鏈路的數量傳下去
MATCH (u:`user`)-[e:watched]->(m:`movie`)-[:directed_by|acted_by|with_genre]->(intersection)<-[:directed_by|acted_by|with_genre]-(recomm:`movie`)
WHERE id(u) == "u_124" AND NOT id(recomm) IN watched_movies_id
WITH (e.rate - 2.5)/2.5 AS rate, m, recomm, size(COLLECT(intersection)) AS intersection_size ORDER BY intersection_size DESC LIMIT 50

// 計算 Jaccard index-------------------------------------------------

// 針對每一對 m 和 recomm:
// 開始計算看過的電影,集合 a 的部分
MATCH (m:`movie`)-[:directed_by|acted_by|with_genre]->(intersection)
WITH rate, id(m) AS m_id, recomm, intersection_size, COLLECT(id(intersection)) AS set_a

// 計算推薦電影,集合 b 的部分
MATCH (recomm:`movie`)-[:directed_by|acted_by|with_genre]->(intersection)
WITH rate, m_id, id(recomm) AS recomm_id, set_a, intersection_size, COLLECT(id(intersection)) AS set_b

// 得到並集數量
WITH rate, m_id, recomm_id, toFloat(intersection_size) AS intersection_size, toSet(set_a + set_b) AS A_U_B

// 得到每一對 m 和 recomm 的 Jaccard index = A_N_B/A_U_B
WITH rate, m_id, recomm_id, intersection_size/size(A_U_B) AS jaccard_index

// 計算 Jaccard index-------------------------------------------------


// 得到每一個被推薦的電影 recomm_id,經由不同看過電影推薦鏈路的相似度 = 評分 * jaccard_index
WITH recomm_id, m_id, (rate * jaccard_index) AS score

// 對每一個 recomm_id 按照 m_id 加權求得相似度的和,為總的推薦程度評分,降序排列
WITH recomm_id, sum(score) AS sim_score ORDER BY sim_score DESC WHERE sim_score > 0
RETURN recomm_id, sim_score LIMIT 50

上邊查詢的執行結果截取出來是:

recomm_id sim_score
1891 0.2705882352941177
1892 0.22278481012658227
1894 0.15555555555555556
808 0.144
1895 0.13999999999999999
85 0.12631578947368421
348 0.12413793103448277
18746 0.11666666666666668
628 0.11636363636363636
3005 0.10566037735849057

可視化分析

我們把上面過程中的部分步驟查詢修改一下為 p=xxx 的方式,並渲染出來,會更加方便理解。

例子 1,用户 u_124 看過的、評分過的電影:

// 用户 u_124 看過的電影
MATCH p=(u:`user`)-[:watched]->(m:`movie`) WHERE id(u) == "u_124"
RETURN p

CBF_step0

例子 2,找到用户 u_124 看過的那些電影在相同的演員、導演、電影類型的關係圖譜上關聯的所有其他電影:

// 用户 u_124 看過的電影
MATCH (u:`user`)-[:watched]->(m:`movie`) WHERE id(u) == "u_124"
WITH collect(id(m)) AS watched_movies_id
  
// 根據電影的標註關係找到備選推薦電影,刨除看過的,把評分、交集關聯鏈路的數量傳下去
MATCH p=(u:`user`)-[e:watched]->(m:`movie`)-[:directed_by|acted_by|with_genre]->(intersection)<-[:directed_by|acted_by|with_genre]-(recomm:`movie`)
WHERE id(u) == "u_124" AND NOT id(recomm) IN watched_movies_id
RETURN p, size(COLLECT(intersection)) AS intersection_size ORDER BY intersection_size DESC LIMIT 500

可以看到用户 u_124 看過電影經由演員、類型擴散出好多新的電影

CBF_step1

在得到待推薦電影以及推薦路徑後,通過 Jaccard 係數與用户路徑第一條邊的評分綜合評定之後,得到了最終的結果。

這裏,我們把結果再可視化一下:取得它們和用户之間的路徑並渲染出來。  

// 用户 u_124 看過的電影
MATCH (u:`user`)-[:watched]->(m:`movie`) WHERE id(u) == "u_124"
WITH collect(id(m)) AS watched_movies_id

// 根據電影的標註關係找到備選推薦電影,刨除看過的,把評分、交集關聯鏈路的數量傳下去
MATCH (u:`user`)-[e:watched]->(m:`movie`)-[:directed_by|acted_by|with_genre]->(intersection)<-[:directed_by|acted_by|with_genre]-(recomm:`movie`)
WHERE id(u) == "u_124" AND NOT id(recomm) IN watched_movies_id
WITH (e.rate - 2.5)/2.5 AS rate, m, recomm, size(COLLECT(intersection)) AS intersection_size ORDER BY intersection_size DESC LIMIT 50

// 計算 Jaccard index-------------------------------------------------

// 針對每一對 m 和 recomm:
// 開始計算看過的電影,集合 a 的部分
MATCH (m:`movie`)-[:directed_by|acted_by|with_genre]->(intersection)
WITH rate, id(m) AS m_id, recomm, intersection_size, COLLECT(id(intersection)) AS set_a

// 計算推薦電影,集合 b 的部分
MATCH (recomm:`movie`)-[:directed_by|acted_by|with_genre]->(intersection)
WITH rate, m_id, id(recomm) AS recomm_id, set_a, intersection_size, COLLECT(id(intersection)) AS set_b

// 得到並集數量
WITH rate, m_id, recomm_id, toFloat(intersection_size) AS intersection_size, toSet(set_a + set_b) AS A_U_B

// 得到每一對 m 和 recomm 的 Jaccard index = A_N_B/A_U_B
WITH rate, m_id, recomm_id, intersection_size/size(A_U_B) AS jaccard_index

// 計算 Jaccard index-------------------------------------------------


// 得到每一個被推薦的電影 recomm_id,經由不同看過電影推薦鏈路的相似度 = 評分 * jaccard_index
WITH recomm_id, m_id, (rate * jaccard_index) AS score

// 對每一個 recomm_id 按照 m_id 加權求得相似度的和,為總的推薦程度評分,降序排列
WITH recomm_id, sum(score) AS sim_score ORDER BY sim_score DESC WHERE sim_score > 0
WITH recomm_id, sim_score LIMIT 10
WITH COLLECT(recomm_id) AS recomm_ids
MATCH p = (u:`user`)-[e:watched]->(m:`movie`)-[:directed_by|acted_by|with_genre]->(intersection)<-[:directed_by|acted_by|with_genre]-(recomm:`movie`)
WHERE id(u) == "u_124" AND id(recomm) in recomm_ids
RETURN p

哇,我們可以很清晰地看到推薦的理由路徑:喜歡星戰的用户通過多條共同的類型、演員、導演的邊引導出未觀看的幾部星戰電影。這其實就是 CBF 的優勢之一:天然具有較好的可解釋性

CBF_step2

基於記憶的協同過濾

前邊我們提過了,協同過濾主要可以分為兩種:

  • User-User CF 基於多個用户對物件的歷史行為,判定用户之間的相似性,再根據相似用户的選擇推薦新的物件;
  • Item-Item CF 判斷物件之間的相似性,給用户推薦他喜歡的物品相似的物品。

這裏,ItemCF 看起來和前邊的 CBF 有些類似,核心區別在於 CBF 找到相似物件的方式是基於物件的“內容”本身,是領域知識的畫像,而 ItemCF 的協同則是考慮用户對物件的歷史行為

ItemCF

這個方法分如下四步:

  1. 找出推薦用户評分過的電影;
  2. 經由用户的高評分電影,找到其他給出高評分用户所看過的新的高評分電影;
  3. 通過用户的評分對看過的電影與新的電影在圖上做 Jaccard 相似性運算,得出每一對看過的電影和待推薦新電影之間的 Jaccard 係數;
  4. 把用户對看過電影的評分作為加權係數,針對其到每一個新電影之間的 Jaccard 係數加權評分,獲得排序後的推薦電影列表。
// 用户 u_124 看過的並給出高評分的電影
MATCH (u:`user`)-[e:watched]->(m:`movie`) WHERE id(u) == "u_124" AND e.rate > 3
WITH collect(id(m)) AS watched_movies_id

// 根據同樣也看過這些電影,並給出高評分的用户,得出待推薦的電影
MATCH (u:`user`)-[e:watched]->(m:`movie`)<-[e0:watched]-(intersection:`user`)-[e1:watched]->(recomm:`movie`)
WHERE id(u) == "u_124" AND NOT id(recomm) IN watched_movies_id AND e0.rate >3 AND e1.rate > 3
WITH (e.rate - 2.5)/2.5 AS rate, m, recomm, size(COLLECT(intersection)) AS intersection_size ORDER BY intersection_size DESC LIMIT 50

// 計算 Jaccard index-------------------------------------------------

// 針對每一對 m 和 recomm:
// 開始計算看過的電影,集合 a 的部分
MATCH (m:`movie`)<-[e0:watched]-(intersection:`user`)
WHERE e0.rate >3
WITH rate, id(m) AS m_id, recomm, intersection_size, COLLECT(id(intersection)) AS set_a

// 計算推薦電影,集合 b 的部分
MATCH (recomm:`movie`)<-[e1:watched]-(intersection:`user`)
WHERE e1.rate >3
WITH rate, m_id, id(recomm) AS recomm_id, set_a, intersection_size, COLLECT(id(intersection)) AS set_b

// 得到並集數量
WITH rate, m_id, recomm_id, toFloat(intersection_size) AS intersection_size, toSet(set_a + set_b) AS A_U_B

// 得到每一對 m 和 recomm 的 Jaccard index = A_N_B/A_U_B
WITH rate, m_id, recomm_id, intersection_size/size(A_U_B) AS jaccard_index

// 計算 Jaccard index-------------------------------------------------


// 得到每一個被推薦的電影 recomm_id,經由不同看過電影推薦鏈路的相似度 = 評分 * jaccard_index
WITH recomm_id, m_id, (rate * jaccard_index) AS score

// 對每一個 recomm_id 按照 m_id 加權求得相似度的和,為總的推薦程度評分,降序排列,只取正值
WITH recomm_id, sum(score) AS sim_score ORDER BY sim_score DESC WHERE sim_score > 0
RETURN recomm_id, sim_score LIMIT 50

結果:

recomm_id sim_score
832 0.8428369424692955
114707 0.7913842214590154
64957 0.6924673321504288
120880 0.5775219768736295
807 0.497532028328161
473 0.4748322300870322
52797 0.2311965559170528
12768 0.19642857142857142
167058 0.19642857142857142

可視化分析 ItemCF

同樣,我們把整個過程中的部分步驟查詢修改一下為 p=xxx 的方式,並渲染出來,看看有什麼有意思的的洞察。

步驟 1 的例子,找出推薦用户評分過的電影:

// 用户 u_124 看過的並給出高評分的電影
MATCH p=(u:`user`)-[e:watched]->(m:`movie`) WHERE id(u) == "u_124" AND e.rate > 3
RETURN p

它們是:

ItemCF_step0

步驟 2 的例子,經由用户的高評分電影,找到其他給出高評分用户所看過的新的高評分電影,修改結果為路徑:

// 用户 u_124 看過的並給出高評分的電影
MATCH (u:`user`)-[e:watched]->(m:`movie`) WHERE id(u) == "u_124" AND e.rate > 3
WITH collect(id(m)) AS watched_movies_id

// 根據同樣也看過這些電影,並給出高評分的用户,得出待推薦的電影
MATCH p=(u:`user`)-[e:watched]->(m:`movie`)<-[e0:watched]-(intersection:`user`)-[e1:watched]->(recomm:`movie`)
WHERE id(u) == "u_124" AND NOT id(recomm) IN watched_movies_id AND e0.rate >3 AND e1.rate > 3
WITH p, size(COLLECT(intersection)) AS intersection_size ORDER BY intersection_size DESC LIMIT 200

可以看到待推薦的電影在路徑的右邊末端,中間連接着的都是其他用户的推薦記錄,它的模式和 CBF 真的很像,只不過關聯的關係不是具體的內容,而是行為。

ItemCF_step1

步驟 3 的例子,在得出每一對看過的電影和待推薦新電影之間的 Jaccard 係數之後,把用户對看過電影的評分作為加權係數,針對其到每一個新電影之間的 Jaccard 係數加權評分,獲得排序後的推薦電影列表。這裏改造下最終的查詢為路徑,並渲染前 500 條路徑:

// 用户 u_124 看過的並給出高評分的電影
MATCH (u:`user`)-[e:watched]->(m:`movie`) WHERE id(u) == "u_124" AND e.rate > 3
WITH collect(id(m)) AS watched_movies_id

// 根據同樣也看過這些電影,並給出高評分的用户,得出待推薦的電影
MATCH (u:`user`)-[e:watched]->(m:`movie`)<-[e0:watched]-(intersection:`user`)-[e1:watched]->(recomm:`movie`)
WHERE id(u) == "u_124" AND NOT id(recomm) IN watched_movies_id AND e0.rate >3 AND e1.rate > 3
WITH (e.rate - 2.5)/2.5 AS rate, m, recomm, size(COLLECT(intersection)) AS intersection_size ORDER BY intersection_size DESC LIMIT 50

// 計算 Jaccard index-------------------------------------------------

// 針對每一對 m 和 recomm:
// 開始計算看過的電影,集合 a 的部分
MATCH (m:`movie`)<-[e0:watched]-(intersection:`user`)
WHERE e0.rate >3
WITH rate, id(m) AS m_id, recomm, intersection_size, COLLECT(id(intersection)) AS set_a

// 計算推薦電影,集合 b 的部分
MATCH (recomm:`movie`)<-[e1:watched]-(intersection:`user`)
WHERE e1.rate >3
WITH rate, m_id, id(recomm) AS recomm_id, set_a, intersection_size, COLLECT(id(intersection)) AS set_b

// 得到並集數量
WITH rate, m_id, recomm_id, toFloat(intersection_size) AS intersection_size, toSet(set_a + set_b) AS A_U_B

// 得到每一對 m 和 recomm 的 Jaccard index = A_N_B/A_U_B
WITH rate, m_id, recomm_id, intersection_size/size(A_U_B) AS jaccard_index

// 計算 Jaccard index-------------------------------------------------


// 得到每一個被推薦的電影 recomm_id,經由不同看過電影推薦鏈路的相似度 = 評分 * jaccard_index
WITH recomm_id, m_id, (rate * jaccard_index) AS score

// 對每一個 recomm_id 按照 m_id 加權求得相似度的和,為總的推薦程度評分,降序排列,只取正值
WITH recomm_id, sum(score) AS sim_score ORDER BY sim_score DESC WHERE sim_score > 0
WITH recomm_id LIMIT 10
WITH COLLECT(recomm_id) AS recomm_ids
MATCH p = (u:`user`)-[e:watched]->(m:`movie`)<-[e0:watched]-(intersection:`user`)-[e1:watched]->(recomm:`movie`)
WHERE id(u) == "u_124" AND id(recomm) in recomm_ids
RETURN p limit 500

可以看出最推薦的兩部電影幾乎所有人看過,且是給過中高評分的用户共同看過的電影:

ItemCF_step2

關於”高評分“

這裏有個優化點,“高評分”其實是高於 3 的評分。這樣的設定會有失公允,更合理的方式是針對每一個用户,取得這個用户所有評分的平均值,再取得與平均值相差的比例或者絕對值判定高低。此外,在通過 Jaccard 相似性判斷每一部看過的電影和對應推薦電影的相似性時,並沒有考慮這層關聯關係:

(m:`movie`)<-[e0:watched]-(intersection:`user`)-[e1:watched]->(recomm:`movie`)

e0 與 e1 的評分數值的作用因素只過濾了低評分的關係,可以做進一步優化。

Pearson Correlation Coefficient

皮爾遜積矩相關係數 Pearson Correlation Coefficient,就是考慮了關係中的數值進行相似性運算的方法。

Pearson Correlation Coefficient,縮寫 PCC。其定義為:

相比 Jaccard Index,它把對象之間關係中的數值與自身和所有對象的數值的平均值的差進行累加運算,在考慮了數值比重的同時考慮了數值基於對象自身的相對差異。

UserCF

下面,我們就利用 Pearson Correlation Coefficient 來舉例 UserCF 方法。

基於用户的協同過濾方法分如下四步:

  1. 找出和推薦用户同樣給出評分過的電影的用户;
  2. 運算 Pearson Correlation Coefficient 得到和推薦用户興趣接近的用户;
  3. 通過興趣接近用户得到高評分未觀看電影;
  4. 根據觀看用户的 Pearson Correlation Coefficient 加權,排序得推薦電影列表;
// 找出和用户 u_2 看過相同電影的用户, 得電影評分
MATCH (u:`user`)-[e0:watched]->(m:`movie`)<-[e1:watched]-(u_sim:`user`)
WHERE id(u) == "u_2" AND id(u_sim) != "u_2"
WITH u, u_sim, collect(id(m)) AS watched_movies_id, COLLECT({e0: e0, e1: e1}) AS e

// 計算 u_2 和這些用户的 pearson_cc
MATCH (u:`user`)-[e0:watched]->(m:`movie`)
WITH u_sim, watched_movies_id, avg(e0.rate) AS u_mean, e

MATCH (u_sim:`user`)-[e1:watched]->(recomm:`movie`)
WITH u_sim, watched_movies_id, u_mean, avg(e1.rate) AS u_sim_mean, e AS e_

UNWIND e_ AS e
WITH sum((e.e0.rate - u_mean) * (e.e1.rate - u_sim_mean) ) AS numerator,
     sqrt(sum(exp2(e.e0.rate - u_mean)) * sum(exp2(e.e1.rate - u_sim_mean))) AS denominator,
     u_sim, watched_movies_id WHERE denominator != 0

// 取 pearson_cc 最大的 50 個相似用户
WITH u_sim, watched_movies_id, numerator/denominator AS pearson_cc ORDER BY pearson_cc DESC LIMIT 50

// 取相似用户給出高評分的新電影,根據相似用户個數對用户相似程度 pearson_cc 加權,獲得推薦列表
MATCH (u_sim:`user`)-[e1:watched]->(recomm:`movie`)
WHERE NOT id(recomm) IN watched_movies_id AND e1.rate > 3
RETURN recomm, sum(pearson_cc) AS sim_score ORDER BY sim_score DESC LIMIT 50

結果:

recomm sim_score
("120880" :movie{name: "I The Movie"}) 33.018868012270396
("167058" :movie{name: "We"}) 22.38531462958867
("12768" :movie{name: "We"}) 22.38531462958867
("55207" :movie{name: "Silence"}) 22.342886447570585
("170339" :movie{name: "Silence"}) 22.342886447570585
("114707" :movie{name: "Raid"}) 21.384280909249796
("10" :movie{name: "Star Wars"}) 19.51546960750133
("11" :movie{name: "Star Wars"}) 19.515469607501327
("64957" :movie{name: "Mat"}) 18.142639694676603
("187689" :movie{name: "Sin"}) 18.078111338733557

可視化分析 UserCF

再看看 UserCF 的可視化結果吧:

步驟 1 的例子,找出和推薦用户同樣給出評分過的電影的用户:

// 找出和用户 u_2 看過相同電影的用户
MATCH p=(u:`user`)-[e0:watched]->(m:`movie`)<-[e1:watched]-(u_sim:`user`)
WHERE id(u) == "u_2" AND id(u_sim) != "u_2"
RETURN p

UserCF_step0

步驟 2 的例子,運算 Pearson Correlation Coefficient 得到和推薦用户興趣接近的用户,輸出這些接近的用户:

// 找出和用户 u_2 看過相同電影的用户, 得電影評分
MATCH (u:`user`)-[e0:watched]->(m:`movie`)<-[e1:watched]-(u_sim:`user`)
WHERE id(u) == "u_2" AND id(u_sim) != "u_2"
WITH u, u_sim, collect(id(m)) AS watched_movies_id, COLLECT({e0: e0, e1: e1}) AS e

// 計算 u_2 和這些用户的 pearson_cc
MATCH (u:`user`)-[e0:watched]->(m:`movie`)
WITH u_sim, watched_movies_id, avg(e0.rate) AS u_mean, e

MATCH (u_sim:`user`)-[e1:watched]->(recomm:`movie`)
WITH u_sim, watched_movies_id, u_mean, avg(e1.rate) AS u_sim_mean, e AS e_

UNWIND e_ AS e
WITH sum((e.e0.rate - u_mean) * (e.e1.rate - u_sim_mean) ) AS numerator,
 sqrt(sum(exp2(e.e0.rate - u_mean)) * sum(exp2(e.e1.rate - u_sim_mean))) AS denominator,
 u_sim, watched_movies_id WHERE denominator != 0

// 取 pearson_cc 最大的 50 個相似用户
WITH u_sim, watched_movies_id, numerator/denominator AS pearson_cc ORDER BY pearson_cc DESC LIMIT 50

RETURN u_sim

我們給它們標記一下顏色:

UserCF_step1

步驟 3 的例子,通過興趣接近用户得到高評分未觀看電影,根據觀看用户的 Pearson Correlation Coefficient 加權,排序得推薦電影列表。我們把結果輸出為這些相似用户的高評分電影路徑:

// 找出和用户 u_2 看過相同電影的用户, 得電影評分
MATCH (u:`user`)-[e0:watched]->(m:`movie`)<-[e1:watched]-(u_sim:`user`)
WHERE id(u) == "u_2" AND id(u_sim) != "u_2"
WITH u, u_sim, collect(id(m)) AS watched_movies_id, COLLECT({e0: e0, e1: e1}) AS e

// 計算 u_2 和這些用户的 pearson_cc
MATCH (u:`user`)-[e0:watched]->(m:`movie`)
WITH u_sim, watched_movies_id, avg(e0.rate) AS u_mean, e

MATCH (u_sim:`user`)-[e1:watched]->(recomm:`movie`)
WITH u_sim, watched_movies_id, u_mean, avg(e1.rate) AS u_sim_mean, e AS e_

UNWIND e_ AS e
WITH sum((e.e0.rate - u_mean) * (e.e1.rate - u_sim_mean) ) AS numerator,
 sqrt(sum(exp2(e.e0.rate - u_mean)) * sum(exp2(e.e1.rate - u_sim_mean))) AS denominator,
 u_sim, watched_movies_id WHERE denominator != 0

// 取 pearson_cc 最大的 50 個相似用户
WITH u_sim, watched_movies_id, numerator/denominator AS pearson_cc ORDER BY pearson_cc DESC LIMIT 50

// 取相似用户給出高評分的新電影,根據相似用户個數對用户相似程度 pearson_cc 加權,獲得推薦列表
MATCH p=(u_sim:`user`)-[e1:watched]->(recomm:`movie`)
WHERE NOT id(recomm) IN watched_movies_id AND e1.rate > 3
WITH p, recomm, sum(pearson_cc) AS sim_score ORDER BY sim_score DESC LIMIT 50
RETURN p

得到的結果,我們增量渲染到畫布上可以得到:這些 UserCF 推薦而得的電影在路徑末端:

UserCF_step2

在可視化中,相似用户的高評分未觀看電影被推薦的思想是不是一目瞭然了呢?

混合的方法

在真實的應用場景裏,達到最好效果的方法通常是結合不同的協同方法,這樣既可以讓不同角度的有用信息得到充分利用,又能彌補單一方法在不同數據量、不同階段的弱點。

具體的結合方法在工程上千差萬別,這裏就不做展開。不過,拋磚引玉我們來看一種基於模型的混合方式。

基於模型的方法

上面講過基於內容的(電影的領域知識、關係)、協同的(用户與用户、用户與電影之間的交互關係)的算法來直接進行相關推薦。但實際上,它們也可以作為機器學習的輸入特徵,用統計學的方法得到一個模型,用來預測用户可能喜歡的物件(電影),這就是基於模型的方法。

基於模型的方法可以很自然地把以上幾種過濾方法作為特徵,本質上這也是混合過濾方法的一種實現。

基於模型、機器學習的推薦系統方法有很多,這裏着重同圖、圖數據庫相關的方法,講解其中基於圖神經網絡 GNN 方法。

GNN 的方法可以將圖譜中的內容信息(導演、演員、類型)和協同信息(用户-用户、電影-電影、用户-電影之間的相互關係)以知識的方式嵌入,並且方法中的消息傳遞方式保有了圖中的局部性(locality),這使得它可能成為一個非常新穎、有效的推薦系統模型方法。

GNN + 圖數據庫的推薦系統

為什麼需要圖數據庫

GNN 的方法中,圖數據庫只是一個可選項,而我給的 GNN 方法的示例中,圖數據庫的關鍵作用是它帶來了實時性的可能

一個實時性推薦系統要求在秒級響應下利用 GNN 訓練模型從近實時的輸入數據中進行推理,這給我們提出的要求是:

  • 輸入數據可以實時、近實時獲取;
  • 推理運算可以實時完成;

而利用歸納型 Inductive model 的模型從圖數據庫中實時獲取新的數據子圖作為推理輸入是一個滿足這樣要求可行的設計方式。

下邊是簡單的流程圖:

  • 左邊是模型訓練,在圖譜的二分圖(user 和 item)之上建模
    • 用户和物件(movie)之間的關係除了交互關係之外,還有預先處理的關係,這些關係被查詢獲得後再寫回圖數據庫中供後續消費使用,關係有:
      • 用户間“相似”關係
      • 物件間不同(共同演員、類型、導演)關係
    • 利用 Nebula-DGL 將圖中需要的點、邊序列化為圖深度學習框架 DGL 可以消費的對象
    • 在 DGL 中分割訓練、測試、驗證集,利用 PinSAGE 模型訓練
    • 導出模型給推薦系統使用
  • 右邊是導出的模型作為推理接口的推薦系統
    • 基於圖庫的實時圖譜上一直會有實時的數據更新,節點增減
    • 當給定的用户推薦請求過來的時候,圖庫中以該用户為起點的子圖會被獲取(1.)、作為輸入發送給推理接口(2.)
    • 推理接口把子圖輸入給之前訓練的模型,獲得該用户在子圖中關聯的新物件中的評分排序(3.)作為推薦結果

PinSAGE_RecommendationSystem

由於篇幅關係,這裏不做端到端的實例代碼展示,後續有機會我會出個 demo。

推薦系統可解釋性

在結束本章之前,最後舉一個圖數據庫在推薦系統中的典型應用:推薦理由。

下圖是美團、大眾點評中的一個常見的搜索、推薦結果。現在推薦系統的複雜度非常高,一方面由實現方式的特性決定,另一方面最終推薦由多個協同系統組合生成最終排名,這使得系統很難對推薦結果進行解釋。

得益於被推薦用户和物件、以及他們的各種各樣畫像最終形成的知識圖譜,我們只需要在圖譜中對推薦結果進行“路徑查找”就可以獲得很有意義的解釋,像是如下截圖的“在北京喜歡北京菜的山東老鄉都説這家店很贊”就是這樣獲得的解釋。

reasoning

圖片來源:美團案例分享

可解釋性的例子

回到咱們電影推薦的圖譜上,前邊的算法中我們獲得過用户 u_124 的推薦電影 1891(星球大戰),那麼我們可以通過這一個查詢獲得它的推薦解釋:

FIND NOLOOP PATH FROM "u_124" TO "1891" over * BIDIRECT UPTO 4 STEPS yield path as `p` | LIMIT 20

我們可以很快獲得 20 條路徑:

(root@nebula) [moviegraph]> FIND NOLOOP PATH FROM "u_124" TO "1891" over * BIDIRECT UPTO 4 STEPS yield path as `p` | LIMIT 20
+-----------------------------------------------------------------------------------------------------+
| p                                                                                                   |
+-----------------------------------------------------------------------------------------------------+
| <("u_124")-[:watched@0 {}]->("11")-[:with_genre@0 {}]->("g_49")<-[:with_genre@0 {}]-("1891")>       |
| <("u_124")-[:watched@0 {}]->("11")-[:with_genre@0 {}]->("g_17")<-[:with_genre@0 {}]-("1891")>       |
| <("u_124")-[:watched@0 {}]->("11")-[:with_genre@0 {}]->("g_10281")<-[:with_genre@0 {}]-("1891")>    |
| <("u_124")-[:watched@0 {}]->("11")-[:acted_by@0 {}]->("p_4")<-[:acted_by@0 {}]-("1891")>            |
| <("u_124")-[:watched@0 {}]->("11")-[:acted_by@0 {}]->("p_3")<-[:acted_by@0 {}]-("1891")>            |
| <("u_124")-[:watched@0 {}]->("11")-[:acted_by@0 {}]->("p_24342")<-[:acted_by@0 {}]-("1891")>        |
| <("u_124")-[:watched@0 {}]->("11")-[:acted_by@0 {}]->("p_2")<-[:acted_by@0 {}]-("1891")>            |
| <("u_124")-[:watched@0 {}]->("832")-[:with_genre@0 {}]->("g_1110")<-[:with_genre@0 {}]-("1891")>    |
| <("u_124")-[:watched@0 {}]->("11")-[:with_genre@0 {}]->("g_1110")<-[:with_genre@0 {}]-("1891")>     |
| <("u_124")-[:watched@0 {}]->("11")-[:acted_by@0 {}]->("p_13463")<-[:acted_by@0 {}]-("1891")>        |
| <("u_124")-[:watched@0 {}]->("11")-[:acted_by@0 {}]->("p_12248")<-[:acted_by@0 {}]-("1891")>        |
| <("u_124")-[:watched@0 {}]->("47981")-[:with_genre@0 {}]->("g_10219")<-[:with_genre@0 {}]-("1891")> |
| <("u_124")-[:watched@0 {}]->("11")-[:acted_by@0 {}]->("p_6")<-[:acted_by@0 {}]-("1891")>            |
| <("u_124")-[:watched@0 {}]->("497")-[:with_genre@0 {}]->("g_104")<-[:with_genre@0 {}]-("1891")>     |
| <("u_124")-[:watched@0 {}]->("120880")-[:with_genre@0 {}]->("g_104")<-[:with_genre@0 {}]-("1891")>  |
| <("u_124")-[:watched@0 {}]->("11")-[:with_genre@0 {}]->("g_104")<-[:with_genre@0 {}]-("1891")>      |
| <("u_124")-[:watched@0 {}]->("11")-[:acted_by@0 {}]->("p_130")<-[:acted_by@0 {}]-("1891")>          |
| <("u_124")-[:watched@0 {}]->("497")-[:with_genre@0 {}]->("g_50")<-[:with_genre@0 {}]-("1891")>      |
| <("u_124")-[:watched@0 {}]->("11635")-[:with_genre@0 {}]->("g_50")<-[:with_genre@0 {}]-("1891")>    |
| <("u_124")-[:watched@0 {}]->("11")-[:with_genre@0 {}]->("g_50")<-[:with_genre@0 {}]-("1891")>       |
+-----------------------------------------------------------------------------------------------------+
Got 20 rows (time spent 267151/278139 us)

Wed, 09 Nov 2022 19:05:56 CST

我們在結果可視化中可以很容易看出這個推薦的結果可以是:

  • 曾經喜歡的星戰電影的大部分演職人員都也參與了這部和同樣是“奧斯卡獲獎”且“經典”的電影。

reasoning_movie

總結

圖數據庫可作為推薦系統中信息的最終形式,儘管在很多推薦實現中,圖庫不一定是最終落地系統方案的選擇,但圖數據庫所帶來的可視化洞察的潛力還是非常大的。

同時,構建的綜合知識圖譜上的解釋、推理能力與一些實時要求高的圖方法中,比如:GNN的基於模型方法上能起到帶來獨一無二的作用。

延伸閲讀


謝謝你讀完本文 (///▽///)

要來近距離體驗一把圖數據庫嗎?現在可以用用 NebulaGraph Cloud 來搭建自己的圖數據系統喲,快來節省大量的部署安裝時間來搞定業務吧~ NebulaGraph 阿里雲計算巢現 30 天免費使用中,點擊鏈接來用用圖數據庫吧~

想看源碼的小夥伴可以前往 GitHub 閲讀、使用、(^з^)-☆ star 它 -> GitHub;和其他的 NebulaGraph 用户一起交流圖數據庫技術和應用技能,留下「你的名片」一起玩耍呢~