本文是一个基于 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

例子 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 看过电影经由演员、类型扩散出好多新的电影

在得到待推荐电影以及推荐路径后,通过 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 的优势之一:天然具有较好的可解释性

基于记忆的协同过滤

前边我们提过了,协同过滤主要可以分为两种:

  • 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

它们是:

步骤 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 真的很像,只不过关联的关系不是具体的内容,而是行为。

步骤 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

可以看出最推荐的两部电影几乎所有人看过,且是给过中高评分的用户共同看过的电影:

关于”高评分“

这里有个优化点,“高评分”其实是高于 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

步骤 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

我们给它们标记一下颜色:

步骤 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 推荐而得的电影在路径末端:

在可视化中,相似用户的高评分未观看电影被推荐的思想是不是一目了然了呢?

混合的方法

在真实的应用场景里,达到最好效果的方法通常是结合不同的协同方法,这样既可以让不同角度的有用信息得到充分利用,又能弥补单一方法在不同数据量、不同阶段的弱点。

具体的结合方法在工程上千差万别,这里就不做展开。不过,抛砖引玉我们来看一种基于模型的混合方式。

基于模型的方法

上面讲过基于内容的(电影的领域知识、关系)、协同的(用户与用户、用户与电影之间的交互关系)的算法来直接进行相关推荐。但实际上,它们也可以作为机器学习的输入特征,用统计学的方法得到一个模型,用来预测用户可能喜欢的物件(电影),这就是基于模型的方法。

基于模型的方法可以很自然地把以上几种过滤方法作为特征,本质上这也是混合过滤方法的一种实现。

基于模型、机器学习的推荐系统方法有很多,这里着重同图、图数据库相关的方法,讲解其中基于图神经网络 GNN 方法。

GNN 的方法可以将图谱中的内容信息(导演、演员、类型)和协同信息(用户-用户、电影-电影、用户-电影之间的相互关系)以知识的方式嵌入,并且方法中的消息传递方式保有了图中的局部性(locality),这使得它可能成为一个非常新颖、有效的推荐系统模型方法。

GNN + 图数据库的推荐系统

为什么需要图数据库

GNN 的方法中,图数据库只是一个可选项,而我给的 GNN 方法的示例中,图数据库的关键作用是它带来了实时性的可能

一个实时性推荐系统要求在秒级响应下利用 GNN 训练模型从近实时的输入数据中进行推理,这给我们提出的要求是:

  • 输入数据可以实时、近实时获取;
  • 推理运算可以实时完成;

而利用归纳型 Inductive model 的模型从图数据库中实时获取新的数据子图作为推理输入是一个满足这样要求可行的设计方式。

下边是简单的流程图:

  • 左边是模型训练,在图谱的二分图(user 和 item)之上建模

    • 用户和物件(movie)之间的关系除了交互关系之外,还有预先处理的关系,这些关系被查询获得后再写回图数据库中供后续消费使用,关系有:

      • 用户间“相似”关系
      • 物件间不同(共同演员、类型、导演)关系
    • 利用 Nebula-DGL 将图中需要的点、边序列化为图深度学习框架 DGL 可以消费的对象
    • 在 DGL 中分割训练、测试、验证集,利用 PinSAGE 模型训练
    • 导出模型给推荐系统使用
  • 右边是导出的模型作为推理接口的推荐系统
    • 基于图库的实时图谱上一直会有实时的数据更新,节点增减
    • 当给定的用户推荐请求过来的时候,图库中以该用户为起点的子图会被获取(1.)、作为输入发送给推理接口(2.)
    • 推理接口把子图输入给之前训练的模型,获得该用户在子图中关联的新物件中的评分排序(3.)作为推荐结果

由于篇幅关系,这里不做端到端的实例代码展示,后续有机会我会出个 demo。

推荐系统可解释性

在结束本章之前,最后举一个图数据库在推荐系统中的典型应用:推荐理由。

下图是美团、大众点评中的一个常见的搜索、推荐结果。现在推荐系统的复杂度非常高,一方面由实现方式的特性决定,另一方面最终推荐由多个协同系统组合生成最终排名,这使得系统很难对推荐结果进行解释。

得益于被推荐用户和物件、以及他们的各种各样画像最终形成的知识图谱,我们只需要在图谱中对推荐结果进行“路径查找”就可以获得很有意义的解释,像是如下截图的“在北京喜欢北京菜的山东老乡都说这家店很赞”就是这样获得的解释。

图片来源:美团案例分享

可解释性的例子

回到咱们电影推荐的图谱上,前边的算法中我们获得过用户 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

我们在结果可视化中可以很容易看出这个推荐的结果可以是:

  • 曾经喜欢的星战电影的大部分演职人员都也参与了这部和同样是“奥斯卡获奖”且“经典”的电影。

总结

图数据库可作为推荐系统中信息的最终形式,尽管在很多推荐实现中,图库不一定是最终落地系统方案的选择,但图数据库所带来的可视化洞察的潜力还是非常大的。

同时,构建的综合知识图谱上的解释、推理能力与一些实时要求高的图方法中,比如:GNN的基于模型方法上能起到带来独一无二的作用。

延伸阅读


谢谢你读完本文 (///▽///)

要来近距离体验一把图数据库吗?现在可以用用 NebulaGraph Cloud 来搭建自己的图数据系统哟,快来节省大量的部署安装时间来搞定业务吧~ NebulaGraph 阿里云计算巢现 30 天免费使用中,点击链接来用用图数据库吧~

想看源码的小伙伴可以前往 GitHub 阅读、使用、(з)-☆ star 它 -> GitHub;和其他的 NebulaGraph 用户一起交流图数据库技术和应用技能,留下「你的名片」一起玩耍呢~

「实操」结合图数据库、图算法、机器学习、GNN 实现一个推荐系统的更多相关文章

  1. R数据分析:孟德尔随机化实操

    好多同学询问孟德尔随机化的问题,我再来尝试着梳理一遍,希望对大家有所帮助,首先看下图1分钟,盯着看将下图印在脑海中: 上图是工具变量(不知道工具变量请翻之前的文章)的模式图,明确一个点:我们做孟德尔的 ...

  2. Neo4j图数据库简介和底层原理

    现实中很多数据都是用图来表达的,比如社交网络中人与人的关系.地图数据.或是基因信息等等.RDBMS并不适合表达这类数据,而且由于海量数据的存在,让其显得捉襟见肘.NoSQL数据库的兴起,很好地解决了海 ...

  3. 基于 Node.js 的轻量「持续集成」工具 CIZE

    CIZE 是什么? CIZE 是一个「持续集成」工具,希望能让开发人员更快捷的搭建一个完整.可靠.便捷的 CI 服务. 甚至可以像 Gulp 或 Grunt 一样,仅仅通过一个 cizefile.js ...

  4. neo4j(图数据库)是什么?

    不多说,直接上干货! 作为一款强健的,可伸缩的高性能数据库,Neo4j最适合完整的企业部署或者用于一个轻量级项目中完整服务器的一个子集存在. 它包括如下几个显著特点: 完整的ACID支持 高可用性 轻 ...

  5. Neo4j:图数据库GraphDB(三)创建删除及高级操作

    本片继续前几篇介绍图数据库的创建,有疑问可以我的看看前两篇文章:http://www.cnblogs.com/rongyux/p/5537206.html 四 图数据库的创建 1 创建一个节点   P ...

  6. 「UNR#2」黎明前的巧克力

    「UNR#2」黎明前的巧克力 解题思路 考虑一个子集 \(S\) 的异或和如果为 \(0\) 那么贡献为 \(2^{|S|}\) ,不难列出生产函数的式子,这里的卷积是异或卷积. \[ [x^0]\p ...

  7. 「状压DP」「暴力搜索」排列perm

    「状压DP」「暴力搜索」排列 题目描述: 题目描述 给一个数字串 s 和正整数 d, 统计 sss 有多少种不同的排列能被 d 整除(可以有前导 0).例如 123434 有 90 种排列能被 2 整 ...

  8. 【LOJ121】「离线可过」动态图连通性

    [LOJ121]「离线可过」动态图连通性 题面 LOJ 题解 线段树分治的经典应用 可以发现每个边出现的时间是一个区间 而我们每个询问是一个点 所以我们将所有边的区间打到一颗线段树上面去 询问每个叶子 ...

  9. [Django框架 - 静态文件配置、request对象方法初识、 pycharm链接数据库、ORM实操增删改查、django请求生命周期]

    [Django框架 - 静态文件配置.request对象方法初识. pycharm链接数据库.ORM实操增删改查.django请求生命周期] 我们将html文件默认都放在templates文件夹下 将 ...

  10. 实时分布式低延迟OLAP数据库Apache Pinot探索实操

    @ 目录 概述 定义 特性 何时使用 部署 Local安装 快速启动 手动设置集群 Docker安装 快速启动 手动启动集群 Docker Compose 实操 批导入数据 流式导入数据 概述 定义 ...

随机推荐

  1. overflow的所有值,overlay不占位

    visible: 默认值.内容不会被修剪,会呈现在元素框之外. hidden: 内容会被修剪,并且其余内容是不可见的. scroll: 内容会被修剪,总是显示滚动条. auto: 内容被修剪,超出浏览 ...

  2. golang: 模仿 VictoriaMetrics 中的做法,通过把局部变量放在自定义 Context 对象中来做到hot path 的 0 alloc

    作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢! cnblogs博客 zhihu Github 公众号:一本正经的瞎扯 使用 benchmark 压测过程中通常会出现这样的信息: ...

  3. 搜索推荐DeepFM算法详解:算法原理、代码实现、比赛实战

    搜索推荐DeepFM算法详解:算法原理.代码实现.比赛实战 可以说,DeepFM 是目前最受欢迎的 CTR 预估模型之一,不仅是在交流群中被大家提及最多的,同时也是在面试中最多被提及的: 1.Deep ...

  4. 【深度学习项目一】全连接神经网络实现mnist数字识别

    相关文章: [深度学习项目一]全连接神经网络实现mnist数字识别 [深度学习项目二]卷积神经网络LeNet实现minst数字识别 [深度学习项目三]ResNet50多分类任务[十二生肖分类] 『深度 ...

  5. Linux 配置Quota磁盘配额

    由于Linux是一个多用户管理的操作系统,而Linux默认情况下并不限制每个用户使用磁盘空间的大小,假如某个用户疏忽或者恶意占满磁盘空间,将导致系统磁盘无法写入甚至崩溃,为了保证系统磁盘的有足够的剩余 ...

  6. python排序之快速排序

    快速排序 快速排序是比较常用的一种排序方式,通过递归的方法进行排序 首先使用递归方式我们先要解决两个问题:1找到基准条件 2找到递归条件 基线条件为数组为空或只包含一个元素.在这种情况下,只需原样返回 ...

  7. 发布三个月跳水超1000!苹果M2版Mac mini降到3478元了

    快科技4月18日消息,苹果在今年1月17日晚,在官网上架了新款的Mac mini,搭载了M2芯片,起售价4499元. 当时该产品配合上教育优惠,一度被认为是"香爆了"的产品,如今距 ...

  8. 练习(java):关于自增运算的练习

    //练习3: byte bb1 = 127; bb1++; System.out.println("bb1 = " + bb1);//-128 bb1--; System.out. ...

  9. 1.变量和简单的数据类型--《Python编程:从入门到实践》

    1.1 变量 在Python中使用变量时,需要遵守一些规则和指南. 变量名只能包含字母.数字和下划线.变量名可以字母或下划线打头,但不能以数字打 头. 变量名不能包含空格,但可使用下划线来分隔其中的单 ...

  10. .NET 云原生架构师训练营(模块二 基础巩固 EF Core 关系)--学习笔记

    2.4.4 EF Core -- 关系 一对多 一对一 多对多 示例 关系:https://docs.microsoft.com/zh-cn/ef/core/modeling/relationship ...