图机器学习-全-

图机器学习(全)

原文:annas-archive.org/md5/1143f63a91e2e2932af329b14f80f754

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

图机器学习提供了一套新的工具,用于处理网络数据并利用实体之间关系的力量,这些工具可用于预测、建模和分析任务。

你将从图论和图机器学习的一个简要介绍开始,了解它们的潜力。随着你的深入,你将熟悉图表示学习的主要机器学习模型:它们的目的、工作原理以及如何在广泛的监督和非监督学习应用中实现。然后,你将构建一个完整的机器学习流程,包括数据处理、模型训练和预测,以充分利用图数据的潜力。接下来,你将涵盖现实世界场景,例如使用图从社交网络中提取数据、文本分析和自然语言处理,以及使用图上的金融交易系统。最后,你将学习如何构建和扩展用于图分析的基于数据的驱动应用,以存储、查询和处理网络信息,然后继续探索图上的最新趋势。

在本机器学习书籍结束时,你将学习图论的基本概念以及构建成功的机器学习应用所使用的所有算法和技术。

本书面向的对象

这本书是为想要利用数据点之间连接和关系中的信息、揭示隐藏结构并利用拓扑信息来提高分析和模型性能的数据分析师、图开发者、图分析师和图专业人士而写的。本书对数据科学家和机器学习开发者也很有用,他们想要构建由机器学习驱动的图数据库。需要具备对图数据库和图数据的基础理解。此外,还需要具备中级水平的 Python 编程和机器学习知识,以便充分利用本书。

本书涵盖的内容

第一章开始使用图,使用 NetworkX Python 库介绍了图论的基本概念。

第二章图机器学习,介绍了图机器学习的主要概念和图嵌入技术。

第三章无监督图学习,涵盖了最近的无监督图嵌入方法。

第四章监督图学习,涵盖了最近的监督图嵌入方法。

第五章图上机器学习的问题,介绍了图上最常用的机器学习任务。

第六章社交网络分析,展示了机器学习算法在社会网络数据中的应用。

第七章使用图进行文本分析和自然语言处理,展示了机器学习算法在自然语言处理任务中的应用。

第八章信用卡交易图分析,展示了机器学习算法在信用卡欺诈检测中的应用。

第九章构建数据驱动的图应用,介绍了处理大型图的有用技术和方法。

第十章图机器学习中的新型趋势,介绍了图机器学习中的某些新型趋势(算法和应用)。

要充分利用本书

Jupyter 或 Google Colab 笔记本足以涵盖所有示例。对于某些章节,还需要 Neo4j 和 Gephi。

如果您正在使用本书的数字版,我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将有助于您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Graph-Machine-Learning。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781800204492_ColorImages.pdf

使用的约定

本书中使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将被设置为粗体:

Jupyter==1.0.0
networkx==2.5
matplotlib==3.2.2
node2vec==0.3.3
karateclub==1.0.19
scipy==1.6.2

任何命令行输入或输出都应如下编写:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送电子邮件至 customercare@packtpub.com。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解有关 Packt 的更多信息,请访问packt.com

第一部分 – 图机器学习简介

在本节中,读者将简要了解图机器学习,展示结合适当的机器学习算法的图潜力。此外,还提供了一个关于图理论和 Python 库的概述,以便读者能够处理(即创建、修改和绘制)图数据结构。

本节包括以下章节:

  • 第一章, 开始使用图

  • 第二章, 图机器学习

第一章:图形入门

图是用于描述实体之间关系的数学结构,几乎无处不在都被使用。例如,社交网络是图,用户根据是否一个用户"关注"另一个用户的更新而相互连接。它们可以用来表示地图,城市通过街道相互连接。图可以描述生物结构、网页,甚至神经退行性疾病的进展。

图论,对图的研究,多年来一直受到广泛关注,促使人们开发算法、识别属性和定义数学模型,以更好地理解复杂行为。

本章将回顾图形结构数据背后的某些概念。将介绍理论概念,并辅以示例,帮助您理解一些更普遍的概念并将它们付诸实践。在本章中,我们将介绍并使用一些最广泛使用的库来创建、操作和研究复杂网络的结构动态和功能,特别是关注 Python 的networkx库。

本章将涵盖以下主题:

  • 使用networkx介绍图

  • 绘制图

  • 图的性质

  • 基准和仓库

  • 处理大型图

技术要求

我们将使用带有Python 3.8的 Jupyter Notebooks 进行所有练习。在以下代码片段中,我们展示了使用pip为本章安装的 Python 库列表(例如,在命令行中运行pip install networkx==2.5,等等):

Jupyter==1.0.0
networkx==2.5
snap-stanford==5.0.0
matplotlib==3.2.2
pandas==1.1.3
scipy==1.6.2

在本书中,以下 Python 命令将被提及:

  • import networkx as nx

  • import pandas as pd

  • import numpy as np

对于更复杂的数据可视化任务,还需要 Gephi (gephi.org/)。安装手册在此处可用:gephi.org/users/install/。所有与本章节相关的代码文件均可在github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter01找到。

使用networkx介绍图

在本节中,我们将对图论进行一般介绍。此外,为了将理论概念与其实际应用相结合,我们将通过 Python 代码片段丰富我们的解释,使用networkx

简单无向图(或简称图)G被定义为对G=(V,E)的二元组,其中V={, .., }是一个节点集合(也称为顶点),而E={{, .., {,}}是边的两元素集合(也称为链接)的集合,表示属于V的两个节点之间的连接。

需要强调的是,由于 E 的每个元素都是一个两集合,因此每条边之间没有顺序。为了提供更多细节,{,{, 代表同一条边。

我们现在提供一些基本图和节点属性的定义,如下所示:

  • 图的是其顶点的数量 |V|。图的大小是其边的数量 |E|

  • 顶点的是与它相邻的边的数量。图 G 中顶点 v邻居是由所有与 v 相邻的顶点诱导的顶点子集

  • 图中顶点 v邻域图(也称为自我图)是 G 的一个子图,由与 v 相邻的顶点和连接这些顶点的所有边组成。

图的示例可以在以下屏幕截图中看到:

图 1.1 – 图的示例

图 1.1 – 图的示例

根据这种表示,由于没有方向,从 4(总共有四个顶点和四个边)。到 231。每个节点的邻居如下所示:

  • Paris = {Milan, Dublin}

  • Milan = {Paris, Dublin, Rome}

  • Dublin = {Paris, Milan}

  • Rome = {Milan}

同样的图可以用 networkx 表示,如下所示:

import networkx as nx
G = nx.Graph()
V = {'Dublin', 'Paris', 'Milan', 'Rome'}
E = [('Milan','Dublin'), ('Milan','Paris'), ('Paris','Dublin'), ('Milan','Rome')]
G.add_nodes_from(V)
G.add_edges_from(E)

由于默认情况下,nx.Graph() 命令生成一个无向图,我们不需要指定每条边的两个方向。在 networkx 中,节点可以是任何可哈希的对象:字符串、类,甚至是其他 networkx 图。现在让我们计算之前生成的图的某些属性。

可以通过运行以下代码来获取图的所有节点和边:

print(f"V = {G.nodes}")
print(f"E = {G.edges}")

这是前面命令的输出:

V = ['Rome', 'Dublin', 'Milan', 'Paris']
E = [('Rome', 'Milan'), ('Dublin', 'Milan'), ('Dublin', 'Paris'), ('Milan', 'Paris')]

我们还可以使用以下命令计算图阶、图大小以及每个节点的度数和邻居:

print(f"Graph Order: {G.number_of_nodes()}")
print(f"Graph Size: {G.number_of_edges()}")
print(f"Degree for nodes: { {v: G.degree(v) for v in G.nodes} }")
print(f"Neighbors for nodes: { {v: list(G.neighbors(v)) for v in G.nodes} }") 

结果如下:

Graph Order: 4
Graph Size: 4
Degree for nodes: {'Rome': 1, 'Paris': 2, 'Dublin':2, 'Milan': 3}
Neighbors for nodes: {'Rome': ['Milan'], 'Paris': ['Milan', 'Dublin'], 'Dublin': ['Milan', 'Paris'], 'Milan': ['Dublin', 'Paris', 'Rome']}

最后,我们还可以计算图 G 中特定节点的自我图,如下所示:

ego_graph_milan = nx.ego_graph(G, "Milan")
print(f"Nodes: {ego_graph_milan.nodes}")
print(f"Edges: {ego_graph_milan.edges}")

结果将是以下内容:

Nodes: ['Paris', 'Milan', 'Dublin', 'Rome']
Edges: [('Paris', 'Milan'), ('Paris', 'Dublin'), ('Milan', 'Dublin'), ('Milan', 'Rome')]

可以通过添加新的节点和/或边来修改原始图,如下所示:

#Add new nodes and edges
new_nodes = {'London', 'Madrid'}
new_edges = [('London','Rome'), ('Madrid','Paris')]
G.add_nodes_from(new_nodes)
G.add_edges_from(new_edges)
print(f"V = {G.nodes}")
print(f"E = {G.edges}")

这将输出以下行:

V = ['Rome', 'Dublin', 'Milan', 'Paris', 'London', 'Madrid']
E = [('Rome', 'Milan'), ('Rome', 'London'), ('Dublin', 'Milan'), ('Dublin', 'Paris'), ('Milan', 'Paris'), ('Paris', 'Madrid')]

可以通过运行以下代码来删除节点:

node_remove = {'London', 'Madrid'}
G.remove_nodes_from(node_remove)
print(f"V = {G.nodes}")
print(f"E = {G.edges}")

这是前面命令的结果:

V = ['Rome', 'Dublin', 'Milan', 'Paris']
E = [('Rome', 'Milan'), ('Dublin', 'Milan'), ('Dublin', 'Paris'), ('Milan', 'Paris')]

如预期,所有包含已删除节点的边都会自动从边列表中删除。

此外,可以通过运行以下代码来删除边:

node_edges = [('Milan','Dublin'), ('Milan','Paris')]
G.remove_edges_from(node_edges)
print(f"V = {G.nodes}")
print(f"E = {G.edges}")

最终结果如下:

V = ['Dublin', 'Paris', 'Milan', 'Rome']
E = [('Dublin', 'Paris'), ('Milan', 'Rome')]

networkx 库还允许我们使用以下命令从图 G 中删除单个节点或单个边:G.remove_node('Dublin')G.remove_edge('Dublin', 'Paris')

图的类型

在上一节中,我们描述了如何创建和修改简单的无向图。在这里,我们将展示如何通过引入 有向图有向图)、加权图和多重图来扩展这种基本数据结构,以封装更多信息。

有向图

有向图 G 定义为一个对 G=(V, E),其中 V={, .., } 是节点集合,E={(, .., (,)} 是表示属于 V 的两个节点之间连接的有序对集合。

由于 E 的每个元素都是一个有序对,它强制了连接的方向。边 , 表示 节点 进入 。这与 , 不同,因为它表示 节点 到达 。起始节点 被称为 头节点,而结束节点被称为 尾节点

由于存在边方向,节点度数的定义需要扩展。

入度和出度

对于一个顶点 v,与 v 相邻的头节点数量称为 入度(用 v 表示),而与 v 相邻的尾节点数量称为其 出度(用 表示)。

以下截图展示了有向图的一个示例:

图 1.2 – 有向图的示例

图 1.2 – 有向图的示例

边的方向从箭头中可见——例如,米兰 -> 都柏林表示从 米兰都柏林都柏林 = 2 = 0巴黎 = 0 = 2米兰 = 1 = 2,而 罗马 = 1 = 0

同一个图可以用 networkx 表示,如下所示:

G = nx.DiGraph()
V = {'Dublin', 'Paris', 'Milan', 'Rome'}
E = [('Milan','Dublin'), ('Paris','Milan'), ('Paris','Dublin'), ('Milan','Rome')]
G.add_nodes_from(V)
G.add_edges_from(E)

定义与用于简单无向图的定义相同;唯一的区别在于用于实例化对象的 networkx 类。对于有向图,使用 nx.DiGraph() 类。

可以使用以下命令计算 入度出度

print(f"Indegree for nodes: { {v: G.in_degree(v) for v in G.nodes} }")
print(f"Outdegree for nodes: { {v: G.out_degree(v) for v in G.nodes} }")

结果将如下所示:

Indegree for nodes: {'Rome': 1, 'Paris': 0, 'Dublin': 2, 'Milan': 1}
Outdegree for nodes: {'Rome': 0, 'Paris': 2, 'Dublin': 0, 'Milan': 2}

对于无向图,可以使用 G.add_nodes_from()G.add_edges_from()G.remove_nodes_from()G.remove_edges_from() 函数来修改给定的图 G

多重图

现在我们将介绍多重图对象,它是图定义的推广,允许有多个边具有相同的起始和结束节点对。

多重图 G 定义为 G=(V, E) ,其中 V 是节点集,E 是边的多重集(允许其元素有多个实例的集合)。

如果 E 是有序对的有序多重集,则多重图被称为有向多重图;否则,如果 E 是两个集合的有序多重集,则称为无向多重图

以下截图展示了有向多重图的示例:

![图 1.3 – 多重图的示例图片

图 1.3 – 多重图的示例

在以下代码片段中,我们展示了如何使用networkx来创建有向或无向多重图:

directed_multi_graph = nx.MultiDiGraph()
undirected_multi_graph = nx.MultiGraph()
V = {'Dublin', 'Paris', 'Milan', 'Rome'}
E = [('Milan','Dublin'), ('Milan','Dublin'), ('Paris','Milan'), ('Paris','Dublin'), ('Milan','Rome'), ('Milan','Rome')]
directed_multi_graph.add_nodes_from(V)
undirected_multi_graph.add_nodes_from(V)
directed_multi_graph.add_edges_from(E)
undirected_multi_graph.add_edges_from(E)

有向多重图和无向多重图之间的唯一区别在于前两行,其中创建了两个不同的对象:使用nx.MultiDiGraph()创建有向多重图,而使用nx.MultiGraph()构建无向多重图。添加节点和边所使用的函数对这两个对象都是相同的。

加权图

现在我们将介绍有向、无向和多权重图。

边加权图(或简称为加权图) G 定义为 G=(V, E ,w) ,其中 V 是节点集,E 是边集,而 是将权重函数分配给每个边 的权重,该权重以实数表示。

节点加权图 G 定义为 G=(V, E ,w) ,其中 V 是节点集,E 是边集,而 是将权重函数分配给每个节点 的权重,该权重以实数表示。

请注意以下要点:

  • 如果 E 是有序对的集合,则我们称之为有向加权图

  • 如果 E 是两个集合的集合,则我们称之为无向加权图

  • 如果 E 是多重集,我们将称之为加权多重图有向加权多重图)。

  • 如果E是有序对的有序多重集,则它是一个无向加权多重图

以下截图展示了有向边加权图的示例:

![图 1.4 – 有向边加权图的示例图片

图 1.4 – 有向边加权图的示例

图 1.4 中,很容易看出图上权重的存在如何有助于向数据结构添加有用的信息。确实,我们可以将边权重想象为从一个节点到达另一个节点的“成本”。例如,到达19,而到达11

networkx中,可以通过以下方式生成有向加权图:

G = nx.DiGraph()
V = {'Dublin', 'Paris', 'Milan', 'Rome'}
E = [('Milan','Dublin', 19), ('Paris','Milan', 8), ('Paris','Dublin', 11), ('Milan','Rome', 5)]
G.add_nodes_from(V)
G.add_weighted_edges_from(E)

二部图

我们现在将介绍本节中将使用的一种图形类型:多部分图。二部分图和三分图——以及更一般地,第 k 部分图——是顶点可以被划分为两个、三个或更多 k 个节点集合的图的统称。边只允许在不同集合之间,不允许在同一集合内的节点之间。在大多数情况下,属于不同集合的节点也由特定的节点类型来表征。在第七章,使用图形进行文本分析和自然语言处理第八章信用卡交易图分析中,我们将处理一些基于图形应用的实际例子,你将看到多部分图确实可以在几个场景中产生——例如,在以下场景中:

  • 当处理文档并将文档中出现的实体信息以二部分图的形式结构化时

  • 当处理交易数据时,为了编码买家和商家之间的关系

networkx中,可以使用以下代码轻松创建一个二部分图:

import pandas as pd
import numpy as np
n_nodes = 10
n_edges = 12
bottom_nodes = [ith for ith in range(n_nodes) if ith % 2 ==0]
 top_nodes = [ith for ith in range(n_nodes) if ith % 2 ==1]
iter_edges = zip(
    np.random.choice(bottom_nodes, n_edges),  
    np.random.choice(top_nodes, n_edges))
edges = pd.DataFrame([
    {"source": a, "target": b} for a, b in iter_edges])
B = nx.Graph()
B.add_nodes_from(bottom_nodes, bipartite=0)
 B.add_nodes_from(top_nodes, bipartite=1)
 B.add_edges_from([tuple(x) for x in edges.values])

该网络也可以方便地使用networkxbipartite_layout实用函数绘制,如下面的代码片段所示:

from networkx.drawing.layout import bipartite_layout
pos = bipartite_layout(B, bottom_nodes)
 nx.draw_networkx(B, pos=pos)

bipatite_layout函数生成一个图,如下面的屏幕截图所示:

![Figure 1.5 – 二部分图的示例img/B16069_01_05.jpg

Figure 1.5 – 二部分图的示例

图表示

如前几节所述,使用networkx,我们实际上可以通过使用节点和边对象来定义和操作一个图。在不同的用例中,这种表示可能不太容易处理。在本节中,我们将展示两种执行图数据结构紧凑表示的方法——即邻接矩阵和边列表。

邻接矩阵

图 G=(V,E)的邻接矩阵M 是一个方阵(|V| × |V|),其元素img/Formula_01_041.png在节点 i 到节点 j 之间存在边时为 1,不存在边时为 0。在下面的屏幕截图中,我们展示了不同类型图的邻接矩阵的简单示例:

![Figure 1.6 – 无向图、有向图、多重图和加权图的邻接矩阵img/B16069_01_006.jpg

Figure 1.6 – 无向图、有向图、多重图和加权图的邻接矩阵

很容易看出,无向图的邻接矩阵总是对称的,因为边没有定义方向。由于边方向的约束存在,有向图的邻接矩阵的对称性不能保证。对于多重图,我们可以有大于 1 的值,因为可以使用多条边连接相同的节点对。对于加权图,特定单元格的值等于连接两个节点的边的权重。

networkx 中,给定图的邻接矩阵可以通过两种不同的方式计算。如果 G图 1.6 中的 networkx,我们可以按照以下方式计算其邻接矩阵:

nx.to_pandas_adjacency(G) #adjacency matrix as pd DataFrame
nt.to_numpy_matrix(G) #adjacency matrix as numpy matrix

对于第一行和第二行,我们分别得到以下结果:

          Rome  Dublin  Milan  Paris
Rome     0.0     0.0    0.0    0.0
Dublin   0.0     0.0    0.0    0.0
Milan    1.0     1.0    0.0    0.0
Paris    0.0     1.0    1.0    0.0
[[0\. 0\. 0\. 0.]
 [0\. 0\. 0\. 0.]
 [1\. 1\. 0\. 0.]
 [0\. 1\. 1\. 0.]]

由于 numpy 矩阵不能表示节点的名称,邻接矩阵中元素的位置是 G.nodes 列表中定义的。

边列表

除了邻接矩阵之外,边列表也是表示图的一种紧凑方式。这种格式的思想是将图表示为边的列表。

G=(V,E)边列表 L 是一个大小为 |E| 的矩阵,其元素 是一个表示边 i 的尾节点和终节点的对。每种类型图的边列表的示例可以在以下屏幕截图找到:

图 1.7 – 无向图、有向图、多重图和加权图的边列表

图 1.7 – 无向图、有向图、多重图和加权图的边列表

在以下代码片段中,我们展示了如何在 networkx 中计算 图 1.7 中可用的简单无向图 G 的边列表:

print(nx.to_pandas_edgelist(G))

通过运行前面的命令,我们得到以下结果:

  source  target
0  Milan  Dublin
1  Milan    Rome
2  Paris   Milan
3  Paris  Dublin

networkx 中还有其他一些我们不会详细讨论的表示方法。其中一些例子包括 nx.to_dict_of_dicts(G)nx.to_numpy_array(G) 等。

绘制图形

正如我们在前面的章节中看到的,图是直观的图形数据结构。节点可以绘制为简单的圆圈,而边是连接两个节点的线条。

尽管它们很简单,但当边和节点的数量增加时,可能很难清楚地表示。这种复杂性的来源主要是与在最终绘图中将每个节点分配到位置(空间/笛卡尔坐标)有关。实际上,手动将数百个节点的特定位置分配到最终绘图可能是不可行的。

在本节中,我们将看到如何在不指定每个节点的坐标的情况下绘制图形。我们将利用两种不同的解决方案:networkx 和 Gephi。

networkx

networkx 通过 nx.draw 库提供了一个简单的接口来绘制图形对象。在以下代码片段中,我们展示了如何使用该库来绘制图形:

def draw_graph(G, nodes_position, weight):
      nx.draw(G, pos_ position, with_labels=True, font_size=15, node_size=400, edge_color='gray', arrowsize=30)
             if plot_weight:
             edge_labels=nx.get_edge_attributes(G,'weight')
         nx.draw_networkx_edge_labels(G, pos_ position, edge_labels=edge_labels)

在这里,nodes_position 是一个字典,其键是节点,分配给每个键的值是一个长度为 2 的数组,用于绘制特定节点的笛卡尔坐标。

nx.draw 函数将通过将节点放置在给定的位置来绘制整个图。with_labels 选项将在每个节点上方绘制其名称,并使用特定的 font_size 值。node_sizeedge_color 分别指定表示节点的圆的大小和边的颜色。最后,arrowsize 将定义有向边的箭头大小。当要绘制的图是一个有向图时,将使用此选项。

在以下代码示例中,我们展示了如何使用之前定义的 draw_graph 函数来绘制一个图:

G = nx.Graph()
V = {'Paris', 'Dublin','Milan', 'Rome'}
E = [('Paris','Dublin', 11), ('Paris','Milan', 8),
     ('Milan','Rome', 5), ('Milan','Dublin', 19)]
G.add_nodes_from(V)
G.add_weighted_edges_from(E)
node_position = {"Paris": [0,0], "Dublin": [0,1], "Milan": [1,0], "Rome": [1,1]}
draw_graph(G, node_position, True)

绘图的结果可在以下截图查看:

![Figure 1.8 – 绘图函数的结果]

img/B16069_01_08.jpg

Figure 1.8 – 绘图函数的结果

之前描述的方法简单,但在实际场景中不可行,因为 node_position 的值可能难以决定。为了解决这个问题,networkx 提供了一个不同的函数来自动根据不同的布局计算每个节点的位置。在 图 1.9 中,我们展示了一系列使用 networkx 中可用的不同布局得到的无向图绘制。为了在所提出的函数中使用它们,我们只需将 node_position 分配给我们要使用的布局的结果——例如,node_position = nx.circular_layout(G)。以下截图显示了这些绘制:

![Figure 1.9 – 使用不同布局绘制的相同无向图]

img/B16069_01_009.jpg

Figure 1.9 – 使用不同布局绘制的相同无向图

networkx 是一个易于操作和分析图的优秀工具,但它不提供良好的功能来执行复杂且美观的图绘制。在下一节中,我们将探讨另一个用于执行复杂图可视化的工具:Gephi。

Gephi

在本节中,我们将展示如何使用 Les Miserables.gexf 样本(一个加权无向图),在应用程序启动时可以在 欢迎 窗口中选择。

Gephi 的主界面如 图 1.10 所示。它可以分为四个主要区域,如下所述:

  1. :此部分显示图的最终绘制结果。每次应用过滤器或特定的布局时,图像都会自动更新。

  2. 外观:在此,可以指定节点和边的外观。

  3. networkx) 来调整图中的节点位置。不同的算法,从简单的随机位置生成器到更复杂的 Yifan Hu 算法,都是可用的。

  4. 过滤器 & 统计:在此区域,有两个主要功能可用,概述如下:

    a. 过滤器:在此选项卡中,可以根据使用 统计信息 选项卡计算的一组属性过滤和可视化图形的特定子区域。

    b. 统计信息:此选项卡包含可以在图形上使用 运行 按钮计算的可用图形度量。一旦计算了度量,它们可以用作属性来指定边和节点的外观(例如节点和边的大小和颜色)或用于过滤图形的特定子区域。

    您可以在下面的屏幕截图中看到 Gephi 的主界面:

![Figure 1.10 – Gephi 主窗口img/B16069_01_010.jpg

Figure 1.10 – Gephi 主窗口

我们对 Gephi 的探索从将不同的布局应用于图形开始。如前所述,在 networkx 中,布局允许我们为每个节点在最终绘制中分配一个特定的位置。在 Gephi 1.2 中,有多个布局可供选择。为了应用特定的布局,我们必须从 布局 区域中选择一个可用的布局,然后点击选择后出现的 运行 按钮。

可在 图形 区域中看到的图形表示,将根据布局定义的新坐标自动更新。需要注意的是,某些布局是参数化的,因此最终的图形绘制可以根据所使用的参数显著改变。在下面的屏幕截图中,我们提出了三个不同布局的应用示例:

![Figure 1.11 – 不同布局下的同一图形img/B16069_01_011.jpg

图 1.11 – 不同布局下的同一图形

现在我们将介绍在 图 1.10 中可见的 外观 菜单中的可用选项。在本节中,可以指定要应用于边和节点的样式。要应用的样式可以是静态的,也可以由节点/边的特定属性动态定义。我们可以通过在菜单中选择 节点 选项来更改节点的大小和颜色。

为了更改颜色,我们必须选择调色板图标,并使用特定的按钮决定我们想要分配一个 唯一 的颜色、一个 分区(离散值)或一个 排名(值范围)。对于 分区排名,可以从下拉菜单中选择一个特定的 图形 属性作为颜色范围的参考。只有通过在 统计信息 区域中点击 运行 计算的属性才在下拉菜单中可用。相同的程序可以用来设置节点的大小。通过选择同心圆图标,可以设置所有节点的 唯一 大小或根据特定属性指定大小的一个 排名

对于节点,用户也可以通过在菜单中选择选项来更改边的样式。然后我们可以选择分配唯一颜色、分区(离散值)或排名(值范围)的颜色。对于分区排名,构建颜色刻度的参考值由一个特定的图形属性定义,该属性可以从下拉菜单中选择。

重要的是要记住,为了应用特定的样式到图形,应该点击应用按钮。结果,图形绘制将根据定义的样式更新。在下述屏幕截图中,我们展示了节点颜色由模块化类值给出,每个节点的尺寸由其度数给出,而每条边的颜色由边权重定义的示例:

![图 1.12 – 图形绘制示例,改变节点和边的外观img/B16069_01_012.jpg

图 1.12 – 图形绘制示例,改变节点和边的外观

另一个需要描述的重要部分是过滤器和统计。在此菜单中,可以根据图形度量计算一些统计数据。

最后,我们通过介绍统计菜单中的功能来结束对 Gephi 的讨论,该菜单在图 1.10的右侧面板中可见。通过此菜单,可以计算输入图中不同的统计数据。这些统计数据可以轻松地用于设置最终图表的一些属性,例如节点/边的颜色和大小,或者过滤原始图以仅绘制其特定子集。为了计算特定的统计数据,用户需要明确选择菜单中可用的一个度量标准,然后点击运行按钮(图 1.10,右侧面板)。

此外,用户可以使用统计菜单的过滤器选项卡中的选项选择图形的子区域,该菜单在图 1.10的右侧面板中可见。图 1.13中可以看到过滤图形的示例。为了提供更多细节,我们构建并应用一个度数属性过滤器到图形上。过滤器的结果是原始图形的一个子集,其中只有具有特定度数属性值范围的节点(及其边)是可见的。

这在下述屏幕截图中有说明:

![图 1.13 – 根据度数范围过滤的图形示例img/B16069_01_013.jpg

图 1.13 – 根据度数范围过滤的图形示例

当然,Gephi 允许我们执行更复杂的可视化任务,并包含许多本书无法完全涵盖的功能。一些更好的参考资料以更好地调查 Gephi 中所有可用的功能是官方的 Gephi 指南(gephi.org/users/)或 Packt 出版社的Gephi 食谱书籍。

图形属性

正如我们已经学到的,是一个用于描述实体之间关系的数学模型。然而,每个复杂网络都表现出固有的属性。这些属性可以通过特定的指标来衡量,每个指标可能表征图的一个或多个局部和全局方面。

例如,在 Twitter 这样的社交网络图中,用户(由图的节点表示)相互连接。然而,有些用户比其他用户更连接(影响者)。在 Reddit 社交图中,具有相似特征的用户倾向于组成社区。

我们已经提到了一些图的基本特征,例如图中节点和边的数量,这些构成了图本身的大小。这些属性已经很好地描述了网络的结构。例如,考虑 Facebook 图:它可以由节点和边的数量来描述。这些数字很容易将其与一个远小的网络(例如,办公室的社会结构)区分开来,但无法表征更复杂的动态(例如,节点之间的相似性)。为此,可以考虑更高级的图衍生指标,这些指标可以分为以下四个主要类别,如下概述:

  • 整合度指标:这些指标衡量节点之间相互连接的倾向。

  • 隔离度指标:这些指标量化了网络中存在的一些相互连接的节点群,称为社区或模块。

  • 中心度指标:这些指标评估网络中单个节点的重要性。

  • 弹性度指标:这些指标可以被视为衡量网络在面对故障或其他不利条件时能够维持和适应其操作性能的程度。

这些指标在表达整体网络的度量时被定义为全局的。另一方面,局部指标衡量单个网络元素(节点或边)的值。在加权图中,每个属性可能或可能不考虑到边权重,导致加权和无权重指标

在下一节中,我们将描述一些最常用的衡量全局和局部属性的指标。为了简单起见,除非文本中另有说明,我们将展示该指标的全球无权重版本。在几个案例中,这是通过平均节点的局部无权重属性获得的。

整合度指标

在本节中,我们将描述一些最常用的整合度指标。

距离、路径和最短路径

图中的距离概念通常与从给定的源节点到达目标节点所需跨越的边的数量相关。

尤其是考虑一个源节点和一个目标节点。连接节点到节点的边集被称为路径。在研究复杂网络时,我们通常对在两个节点之间找到最短路径感兴趣。源节点和目标节点之间的最短路径是与所有可能的路径相比具有最少边的路径。网络的直径是所有可能最短路径中最长最短路径中包含的边的数量。

看一下下面的截图。从都柏林东京有不同路径可走。然而,其中有一条是最短的(最短路径上的边被突出显示):

图 1.14 – 两个节点之间的最短路径

图 1.14 – 两个节点之间的最短路径

networkx Python 库的shortest_path函数允许用户快速计算图中两个节点之间的最短路径。考虑以下代码,其中使用networkx创建了一个包含七个节点的图:

G = nx.Graph()
nodes = {1:'Dublin',2:'Paris',3:'Milan',4:'Rome',5:'Naples',
         6:'Moscow',7:'Tokyo'}
G.add_nodes_from(nodes.keys())
G.add_edges_from([(1,2),(1,3),(2,3),(3,4),(4,5),(5,6),(6,7),(7,5)])

可以通过以下方式获得源节点(例如,'Dublin',由键 1 标识)和目标节点(例如,'Tokyo',由键 7 标识)之间的最短路径:

path = nx.shortest_path(G,source=1,target=7)

这应该输出以下内容:

[1,3,4,5,6]

这里,[1,3,4,5,7]'Tokyo''Dublin'之间最短路径中包含的节点。

特征路径长度

特征路径长度定义为所有可能节点对之间最短路径长度的平均值。如果是节点与所有其他节点之间的平均路径长度,则特征路径长度计算如下:

这里,是图中的节点集,代表其阶数。这是衡量信息在网络中传播效率的最常用指标之一。具有较短特征路径长度的网络促进了信息的快速传输并降低了成本。特征路径长度可以通过networkx使用以下函数计算:

nx.average_shortest_path_length(G)

这应该给出以下结果:

2.1904761904761907

然而,这个指标并不总是可以定义的,因为在断开连接的图中无法计算所有节点之间的路径。因此,网络效率也被广泛使用。

全局和局部效率

全局效率是所有节点对之间最短路径长度的倒数平均值。这样的指标可以被视为衡量信息在网络中交换效率的度量。考虑 是节点 和节点 之间的最短路径。网络效率定义为以下:

当图完全连接时,效率达到最大,而对于完全断开的图,效率最小。直观上,路径越短,度量值越低。

使用以下命令的 networkx

nx.global_efficiency(G)

输出应该是以下内容:

0.6111111111111109

平均局部效率在 networkx 中使用以下命令计算:

nx.local_efficiency(G)

输出应该是以下内容:

0.6666666666666667

在以下截图中,展示了两个图的示例。如观察到的,左边的完全连接图比右边的环形图具有更高的效率。在完全连接图中,图中的每个节点都可以从任何其他节点访问,信息在网络中快速交换。然而,在环形图中,需要遍历多个节点才能到达目标节点,这使得其效率较低:

图 1.15 – 完全连接图(左)和环形图(右)的全局效率

图 1.15 – 完全连接图(左)和环形图(右)的全局效率

集成指标很好地描述了节点之间的连接。然而,通过考虑隔离度指标,可以提取更多关于群体存在的信息。

隔离度指标

在本节中,将描述一些最常见的隔离度指标。

聚类系数

使用以下命令的 networkx

nx.average_clustering(G)

这应该输出以下内容:

0.6666666666666667

局部聚类系数在 networkx 中使用以下命令计算:

nx.clustering(G)

这应该输出以下内容:

{1: 1.0,
 2: 1.0,
 3: 0.3333333333333333,
 4: 0,
 5: 0.3333333333333333,
 6: 1.0,
 7: 1.0}

输出是一个 Python 字典,其中包含每个节点(由相应的键标识)的对应值。在 图 1.16 所示的图中,可以轻松地识别出两个节点簇。通过计算每个单独节点的聚类系数,可以观察到 罗马 具有最低的值。东京莫斯科以及巴黎都柏林在其各自群体内连接得非常好(注意每个节点的大小是按每个节点的聚类系数成比例绘制的)。以下截图显示了该图:

图 1.16 – 局部聚类系数表示

图 1.16 – 局部聚类系数表示

传递性

聚类系数的一个常见变体被称为 networkx,如下所示:

nx.transitivity(G)

输出应该是以下内容:

0.5454545454545454

模块度

模块度被设计用来量化网络在高度互连的节点聚合集合中的划分,通常称为 模块社区群体集群。主要思想是,具有高模块度的网络将在模块内显示密集的连接,而在模块之间显示稀疏的连接。

考虑一个像 Reddit 这样的社交网络:与视频游戏相关的社区成员往往与其他社区成员互动更多,谈论最新新闻、最喜欢的游戏机等等。然而,他们可能不会与谈论时尚的用户互动很多。与许多其他图度量不同,模块度通常通过优化算法计算。

networkx 中,使用 networkx.algorithms.community 模块的 modularity 函数计算模块度,如下所示:

import networkx.algorithms.community as nx_comm
nx_comm.modularity(G, communities=[{1,2,3}, {4,5,6,7}])

在这里,第二个参数—communities—是一个集合列表,每个集合代表图的某个划分。输出应如下所示:

0.3671875

分隔度量标准有助于理解群组的存在。然而,图中的每个节点都有其自身的 重要性。为了量化它,我们可以使用中心性度量。

中心性度量

在本节中,将描述一些最常见的中心性度量。

度中心性

最常见且简单的中心性度量之一是 度中心性度量。这与节点的 直接相关,测量某个节点 上的 入边 数量。

直观地说,一个节点与其他节点连接得越多,其度中心性就会越高。请注意,如果图是 有向的,则 networkx 使用以下命令:

nx.degree_centrality(G)

输出应如下所示:

{1: 0.3333333333333333, 2: 0.3333333333333333, 3: 0.5, 4: 0.3333333333333333, 5: 0.5, 6: 0.3333333333333333, 7: 0.3333333333333333}

接近中心性

接近中心性度量试图量化一个节点与其他节点有多接近(连接良好)。更正式地说,它指的是节点 到网络中所有其他节点的平均距离。如果 是节点 和节点 之间的最短路径,则接近中心性定义为以下:

在这里,V 是图中节点的集合。接近中心性可以使用 networkx 中的以下命令计算:

nx.closeness_centrality(G)

输出应如下所示:

{1: 0.4, 2: 0.4, 3: 0.5454545454545454, 4: 0.6, 5: 0.5454545454545454, 6: 0.4, 7: 0.4}

介数中心性

介数中心性度量评估一个节点作为其他节点之间 桥梁 的作用程度。即使连接性较差,一个节点也可以通过战略性地连接,帮助保持整个网络的连接。

如果 是节点 和节点 之间最短路径的总数,而 是通过节点 连接 的最短路径的总数,那么介数中心性定义为以下:

如果我们观察公式,我们可以注意到,通过节点经过的最短路径数量越多,中介中心性的值就越高。中介中心性在networkx中通过以下命令计算:

nx.betweenness_centrality(G)

输出应如下所示:

{1: 0.0, 2: 0.0, 3: 0.5333333333333333, 4: 0.6, 5: 0.5333333333333333, 6: 0.0, 7: 0.0}

图 1.17中,我们展示了度中心性接近中心性中介中心性之间的差异。米兰那不勒斯具有最高的度中心性。罗马具有最高的接近中心性,因为它与其他任何节点距离最近。它还显示出最高的中介中心性,因为其在连接两个可见集群并保持整个网络连通方面发挥着关键作用。

你可以在这里看到差异:

![图 1.17 – 度中心性(左),接近中心性(中),和中介中心性(右)]

图片

图 1.17 – 度中心性(左),接近中心性(中),和中介中心性(右)

中心性度量允许我们衡量网络中节点的相对重要性。最后,我们将提到弹性度量,它使我们能够衡量图的脆弱性。

弹性度量

有几种度量指标可以衡量网络的弹性。配对性是最常用的之一。

配对系数

使用以下命令:

nx.degree_pearson_correlation_coefficient(G)

输出应如下所示:

-0.6

社交网络大多是配对的。然而,所谓的影响者(著名歌手、足球运动员、时尚博主)往往被几个标准用户跟随(入边),同时倾向于相互连接并表现出去配对行为。

重要的一点是指出,之前提出的性质是描述图的所有可能度量指标的一个子集。更广泛的度量指标和算法可以在networkx.org/documentation/stable/reference/algorithms/找到。

基准和存储库

现在我们已经理解了关于图和网络分析的基本概念和概念,现在是时候深入一些实际例子,这些例子将帮助我们开始将我们迄今为止学到的通用概念付诸实践。在本节中,我们将展示一些通常用于研究网络性质、基准性能和网络算法有效性的示例和玩具问题。我们还将提供一些有用的链接,其中可以找到并下载网络数据集,以及一些关于如何解析和处理它们的技巧。

简单图的示例

我们首先查看一些非常简单的网络示例。幸运的是,networkx已经包含了一些已经实现好的图,可以用来使用和玩耍。让我们首先创建一个完全连接的无向图,如下所示:

complete = nx.complete_graph(n=7)

这有条边和一个聚类系数C=1。虽然完全连接的图本身可能不太有趣,但它们代表了一个基本构建块,可能在更大的图中出现。在更大的图中,n个节点的完全连接子图通常被称为大小为n

定义

在无向图中的一个,记为C,定义为它的顶点的一个子集,CV,使得子集中的每两个不同的顶点都是相邻的。这等价于条件,即由C诱导的G的子图是一个完全连接的图。

团是图论中的基本概念之一,也常用于需要编码关系的数学问题中。此外,它们也代表构建更复杂图时的最简单单元。另一方面,在更大的图中找到给定大小n的团的任务(团问题)非常有趣,并且可以证明它是一个在计算机科学中经常研究的非确定性多项式时间完备NP-完备)问题。

以下截图显示了networkx图的简单示例:

图 1.18 – 使用 networkx 的图简单示例。(左)完全连接图;(中)棒棒糖图;(右)哑铃图

图 1.18 – 使用 networkx 的图简单示例:(左)完全连接图;(中)棒棒糖图;(右)哑铃图

图 1.18中,我们展示了一个完整的图以及两个其他包含可以通过networkx轻松生成的团的其他简单示例,如下所述:

  • 由大小为n的团和m个节点的分支组成的棒棒糖图,如下代码片段所示:

    lollipop = nx.lollipop_graph(m=7, n=3)
    
  • 由两个大小为m1m2的团通过节点分支连接而成的哑铃图,这与我们之前用来描述一些全局和局部属性的样本图相似。生成此图的代码如下所示:

    barbell = nx.barbell_graph(m1=7, m2=4)
    

这类简单图是基本构建块,可以通过组合它们来生成更复杂的网络。使用networkx合并子图非常容易,只需几行代码即可完成,如下代码片段所示,其中三个图合并成一个图,并放置了一些随机边来连接它们:

def get_random_node(graph):
    return np.random.choice(graph.nodes)
allGraphs = nx.compose_all([complete, barbell, lollipop])
allGraphs.add_edge(get_random_node(lollipop), get_random_node(lollipop))
allGraphs.add_edge(get_random_node(complete), get_random_node(barbell))

其他非常简单的图(然后可以合并并加以利用)可以在networkx.org/documentation/stable/reference/generators.html#module-networkx.generators.classic找到。

生成图模型

尽管通过创建简单的子图并将它们合并是生成越来越复杂的新图的一种方法,但网络也可以通过概率模型和/或生成模型来生成,这些模型允许图自行增长。这类图通常与真实网络共享有趣的属性,并且长期以来一直被用来创建基准和合成图,尤其是在数据量不像今天这样庞大的时代。在这里,我们展示了随机生成的图的示例,并简要描述了它们背后的模型。

Watts 和 Strogatz(1998)

作者使用此模型研究了小世界网络的行为——也就是说,在某种程度上类似于常见社交网络的网络。首先将n个节点放置在环中,然后连接每个节点与其k个邻居。然后,此类图的每条边都有一个概率p,可能会重新布线到随机选择的节点。通过调整p,Watts 和 Strogatz 模型允许从规则网络(p=0)过渡到完全随机网络(p=1)。在此之间,图表现出小世界特征;也就是说,它们倾向于使此模型更接近社交网络图。这些类型的图可以通过以下命令轻松创建:

graph = nx.watts_strogatz_graph(n=20, k=5, p=0.2)

巴巴什-阿尔伯特(1999)

阿尔伯特和巴巴什提出的模型基于一个生成模型,该模型通过使用优先连接方案允许通过创建随机无标度网络,其中网络是通过逐步添加新节点并将它们连接到已存在的节点来创建的,优先连接到具有更多邻居的节点。从数学上讲,该模型的基本思想是,新节点连接到现有节点i的概率取决于i节点度,根据以下公式:

img/Formula_01_076.jpg

因此,具有大量边(枢纽)的节点倾向于发展更多的边,而具有少量链接的节点则不会发展其他链接(边缘)。由该模型生成的网络在节点之间的连接度(即度)上表现出幂律分布。这种行为也存在于真实网络中(例如,networkx),也允许新边的优先连接或现有边的重新布线。

下面的截图展示了巴巴什-阿尔伯特模型:

Figure 1.19 – 巴巴什-阿尔伯特模型(左)具有 20 个节点(右)的连接度分布,n=100.000 个节点,显示了无标度幂律分布

图 1.19 – 巴巴什-阿尔伯特模型(左)具有 20 个节点(右)的连接度分布,n=100,000 个节点,显示了无标度幂律分布

图 1.19中,我们展示了小网络的 Barabási-Albert 模型的一个示例,您已经可以观察到中心节点的出现(在左侧),以及节点的度数概率分布,它表现出无标度幂律行为(在右侧)。前面的分布可以很容易地在networkx中复制,如下所示:

ba_model = nx.extended_barabasi_albert_graph(n,m=1,p=0,q=0)
degree = dict(nx.degree(ba_model)).values()
bins = np.round(np.logspace(np.log10(min(degree)), np.log10(max(degree)), 10))
cnt = Counter(np.digitize(np.array(list(degree)), bins))

基准测试

数字化深刻地改变了我们的生活,如今,任何活动、个人或过程都会产生数据,提供了大量需要挖掘、分析和用于促进数据驱动决策的信息。几十年前,很难找到可用于开发或测试新算法的数据集。另一方面,现在有大量的存储库为我们提供了数据集,甚至是一些相当大的数据集,可以下载和分析。这些允许人们共享数据集的存储库,还提供了一个基准,算法可以在其中应用、验证和相互比较。

在本节中,我们将简要介绍网络科学中使用的某些主要存储库和文件格式,以便为您提供所有必要的工具来导入不同大小的数据集,以便分析和实验。

在这些存储库中,您将找到来自网络科学的一些常见领域的网络数据集,例如社交网络、生物化学、动态网络、文档、共同作者和引用网络,以及由金融交易产生的网络。在第三部分图机器学习的先进应用中,我们将讨论一些最常见的网络类型(社交网络、处理语料库文档时产生的图和金融网络),并通过应用在第二部分图上的机器学习中描述的技术和算法,对这些网络进行更深入的分析。

此外,networkx已经包含了一些基本(且非常小)的网络,通常用于解释算法和基本度量,这些可以在networkx.org/documentation/stable/reference/generators.html#module-networkx.generators.social找到。然而,这些数据集通常相当小。对于更大的数据集,请参考我们接下来介绍的存储库。

网络数据存储库

网络数据仓库无疑是最大的网络数据仓库之一(networkrepository.com/),拥有数千个不同的网络,包括来自世界各地和顶级学术机构的用户和捐赠。如果一个网络数据集是免费提供的,那么你很可能在那里找到它。数据集被分为大约30 个领域,包括生物学、经济学、引用、社交网络数据、工业应用(能源、道路)等。除了提供数据外,该网站还提供了一种用于交互式可视化、探索和比较数据集的工具,我们建议您查看并探索它。

网络数据仓库中的数据通常以矩阵市场交换格式MTX)文件格式提供。MTX 文件格式基本上是一种通过可读文本文件(美国信息交换标准代码,或ASCII)指定密集或稀疏矩阵、实数或复数的文件格式。有关更多详细信息,请参阅math.nist.gov/MatrixMarket/formats.html#MMformat

使用scipy可以轻松地在 Python 中读取 MTX 格式的文件。我们从网络数据仓库下载的一些文件似乎略有损坏,并在 10.15.2 的 OSX 系统上需要最小的修复。为了修复它们,只需确保文件的标题与格式规范一致;即,行首有一个双%符号且没有空格,如下面的行所示:

%%MatrixMarket matrix coordinate pattern symmetric 

矩阵应以坐标格式表示。在这种情况下,规范也指向一个无权、无向图(由patternsymmetric理解)。一些文件在第一行标题之后有一些注释,这些注释由单个%符号开头。

例如,我们考虑天体物理学ASTRO-PH)合作网络。该图是通过使用从 1993 年 1 月到 2003 年 4 月期间在arXiv存档中发布的天体物理学类别下的所有科学论文生成的。该网络是通过连接(通过无向边)共同撰写出版物的所有作者来构建的,从而形成一个包括给定论文所有作者的团。生成图的代码可以在以下位置查看:

from scipy.io import mmread
adj_matrix = mmread("ca-AstroPh.mtx")
graph = nx.from_scipy_sparse_matrix(adj_matrix)

该数据集有 17,903 个节点,通过 196,072 条边连接。可视化如此多的节点并不容易,即使我们尝试这样做,也可能不太有用,因为如此多的信息下,理解底层结构可能并不容易。然而,我们可以通过查看特定的子图来获得一些见解,正如我们接下来将要做的。

首先,我们可以从计算我们之前描述的一些基本属性开始,并将它们放入 pandas DataFrame中以方便我们稍后使用、排序和分析。完成此操作的代码如下所示:

stats = pd.DataFrame({
    "centrality": nx.centrality.betweenness_centrality(graph), 
    "C_i": nx.clustering(graph), 
    "degree": nx.degree(graph)
})

我们可以很容易地发现具有最大 6933 的节点,它有 503 个邻居(肯定是一位在天体物理学中非常受欢迎和重要的科学家!),如下面的代码片段所示:

neighbors = [n for n in nx.neighbors(graph, 6933)]

当然,也可以绘制其 C_i 值。完成此操作的代码如下所示:

nTop = round(len(neighbors)*sampling)
idx = {
    "random": stats.loc[neighbors].sort_index().index[:nTop], 
    "centrality": stats.loc[neighbors]\
         .sort_values("centrality", ascending=False)\
         .index[:nTop],
    "C_i": stats.loc[neighbors]\
         .sort_values("C_i", ascending=False)\
         .index[:nTop]
}

然后,我们可以定义一个简单的函数来提取和绘制只包含与某些索引相关的节点的子图,如下面的代码片段所示:

def plotSubgraph(graph, indices, center = 6933):
    nx.draw_kamada_kawai(
        nx.subgraph(graph, list(indices) + [center])
    )

使用前面的函数,我们可以绘制出通过使用随机抽样、中心性和我们之前提到的聚类系数这三个不同标准过滤自我网络所获得的不同子图。这里提供了一个示例:

plotSubgraph(graph, idx["random"]) 

图 1.20 中,我们比较了这些结果,其中其他网络是通过将键值更改为 centralityC_i 获得的。随机表示似乎显示出一些分离的社区结构。具有最多中心节点的图显然是一个几乎完全连接的网络,可能由所有全职教授和天体物理学科学界有影响力的人物组成,他们在多个主题上出版并频繁合作。最后,另一种表示方式通过选择具有更高聚类系数的节点来突出显示一些特定的社区,这些社区可能与特定主题相关。这些节点可能没有很大的中心度,但它们很好地代表了特定主题。您可以在以下位置看到自我子图的示例:

图 1.20 – ASTRO-PH 数据集中度最大的节点的自我子图示例。邻居以比率=0.1 进行抽样。(左)随机抽样;(中)具有最大介数中心性的节点;(右)具有最大聚类系数的节点

图 1.20 – ASTRO-PH 数据集中度最大的节点的自我子图示例。邻居以比率=0.1 进行抽样。(左)随机抽样;(中)具有最大介数中心性的节点;(右)具有最大聚类系数的节点

另一种在 networkx 中可视化的选项是使用允许快速过滤和图形可视化的 Gephi 软件。为了做到这一点,我们首先需要将数据导出为 Graph Exchange XML FormatGEXF)(这是一种可以导入 Gephi 的文件格式),如下所示:

nx.write_gext(graph, "ca-AstroPh.gext")

一旦数据被导入 Gephi,通过少量过滤器(如中心性或度)和一些计算(如模块度),你可以轻松地绘制出如图 图 1.21 所示的漂亮图表,其中节点已根据模块度着色,以突出显示聚类。着色还使我们能够轻松地识别连接不同社区并因此具有较大介数的节点。

网络数据存储库中的某些数据集也可能在 networkx 中可用,通过简单地重写其标题即可转换。例如,数字文献和图书馆DBLP)引用网络。

下面的屏幕截图可以看到一个示例图表:

图 1.21 – 使用 Gephi 可视化 ASTRO-PH 数据集的示例。节点通过度中心性进行筛选,并按模块化类别着色;节点大小与度值成比例

图 1.21 – 使用 Gephi 可视化 ASTRO-PH 数据集的示例。节点通过度中心性进行筛选,并按模块化类别着色;节点大小与度值成比例

这是文件标题的代码:

% asym unweighted
% 49743 12591 12591 

这可以通过用以下代码替换这些行来轻松地转换为符合 MTX 文件格式:

%%MatrixMarket matrix coordinate pattern general
12591 12591 49743 

然后,你可以使用之前描述的导入函数。

斯坦福大型网络数据集集合

另一个网络数据集的有价值来源是 斯坦福网络分析平台SNAP)(snap.stanford.edu/index.html) 的网站,这是一个通用网络分析库,旨在处理甚至相当大的图,拥有数亿个节点和数十亿条边。它用 C++ 编写以实现顶级计算性能,但它还提供了与 Python 的接口,以便在原生 Python 应用程序中导入和使用。

虽然 networkx 目前是研究 networkx 的主要库,但 SNAP 或其他库(稍后将有更多介绍)可能比 networkx 快几个数量级,并且可以在需要更高性能的任务中替代 networkx。在 SNAP 网站上,你可以找到一个专门的网页用于 生物医学网络数据集 (snap.stanford.edu/biodata/index.html),除了其他更通用的网络 (snap.stanford.edu/data/index.html),覆盖了与之前描述的网络数据存储库类似的领域和数据集。

数据通常在一行代码中提供 networkx,使用以下命令:

g = nx.read_edgelist("amazon0302.txt")

一些图可能包含关于边之外的信息。这些额外信息包含在数据集的存档中,作为一个单独的文件——例如,提供了节点的某些元数据,并且通过 id 节点与图相关联。

图可以直接使用 SNAP 库及其通过 Python 的接口读取。如果你在本地机器上有一个可工作的 SNAP 版本,你可以轻松地按以下方式读取数据:

from snap import LoadEdgeList, PNGraph
graph = LoadEdgeList(PNGraph, "amazon0302.txt", 0, 1, '\t')

请记住,在此阶段,你将有一个 SNAP 库中的PNGraph对象的实例,并且你不能直接在这个对象上使用networkx的功能。如果你想使用一些networkx函数,你首先需要将PNGraph对象转换为networkx对象。为了使这个过程更简单,在这本书的补充材料(可在github.com/PacktPublishing/Graph-Machine-Learning找到)中,我们编写了一些函数,允许你在networkx和 SNAP 之间无缝切换,如下面的代码片段所示:

networkx_graph = snap2networkx(snap_graph)
snap_graph = networkx2snap(networkx_graph) 

开放图基准

这是最新的图基准更新(日期为 2020 年 5 月),并且预计在未来几年中,这个仓库将获得越来越重要和广泛的支持。开放图基准OGB)的创建是为了解决一个特定问题:与实际应用相比,当前的基准实际上太小,无法为机器学习ML)的进步提供帮助。一方面,一些在小数据集上开发的模型最终无法扩展到大数据集,证明它们在现实世界应用中不适用。另一方面,大数据集也允许我们增加在 ML 任务中使用的模型的容量(复杂性),并探索新的算法解决方案(如神经网络),这些解决方案可以从大量样本中受益,以便有效地训练,从而实现非常高的性能。数据集属于不同的领域,并且它们已经在三个不同的数据集大小(小、中、大)上进行了排名,其中小尺寸图,尽管名称如此,已经拥有超过 10 万个节点和/或超过 100 万个边。另一方面,大型图具有超过 1 亿个节点和超过 10 亿个边的网络,这有助于可扩展模型的开发。

除了数据集之外,OGB 还以Kaggle风格提供了一套端到端的 ML 流程,该流程标准化了数据加载、实验设置和模型评估。OGB 创建了一个平台,用于比较和评估模型之间的性能,发布了一个排行榜,允许跟踪特定任务(节点、边和图属性预测)的性能演变和进步。有关数据集和 OGB 项目的更多详细信息,请参阅arxiv.org/pdf/2005.00687.pdf

处理大型图

当接近一个用例或分析时,了解我们关注的数据的大小或未来将有多大非常重要,因为数据集的维度可能会极大地影响我们使用的技术和我们能够进行的分析。如前所述,一些在小数据集上开发的方法几乎无法扩展到现实世界应用和更大的数据集,这使得它们在实践中无用。

当处理(可能)大型图时,理解我们所使用的工具、技术和/或算法的潜在瓶颈和限制至关重要,评估在增加节点或边数时,我们应用程序/分析的哪一部分可能无法扩展。更重要的是,无论数据驱动应用程序多么简单,或处于早期概念验证POC)阶段,都需要以允许其未来在数据/用户增加时扩展的方式构建,而无需重写整个应用程序。

创建一个依赖于图形表示/建模的数据驱动应用程序是一项具有挑战性的任务,其设计和实现比简单地导入networkx要复杂得多。特别是,将处理图的组件(称为图处理引擎)与允许查询和遍历图的组件(图存储层)解耦通常很有用。我们将在第九章《构建数据驱动应用程序》中进一步讨论这些概念。然而,鉴于本书的重点是机器学习和分析技术,更多地关注图处理引擎而不是图存储层是有意义的。因此,我们认为在当前阶段向您提供一些用于处理大型图的图处理引擎技术是有用的,这对于扩展应用程序至关重要。

在这方面,根据图是否可以适应共享内存机器或需要分布式架构来处理和分析,将图处理引擎分为两类(这会影响要使用的工具/库/算法)是很重要的。

注意,没有关于大型和小型图的绝对定义,这还取决于所选的架构。如今,得益于基础设施的垂直扩展,你可以找到具有大于 1 TB(通常称为胖节点)的随机存取内存RAM)的服务器,以及大多数云服务提供商提供的具有数万个中央处理单元CPUs)用于多线程的基础设施,尽管这些基础设施可能在经济上不可行。即使没有扩展到这种极端的架构,具有数百万个节点和数千万条边的图也可以在具有约 100 GBGB)的 RAM 和约 50 个 CPU 的单个服务器上轻松处理。

虽然networkx是一个非常受欢迎、用户友好且直观的库,但当扩展到这样合理的大型图时,它可能不是最佳选择。networkx是纯 Python 编写的,Python 是一种解释型语言,在性能上可能远不如完全或部分用性能更好的编程语言(如 C++和 Julia)编写的其他图引擎,并且使用了多线程,如下所示:

  • SNAP (snap.stanford.edu/),我们在上一节中已经见过,是斯坦福大学开发的一个图引擎,是用 C++ 编写的,并且提供了 Python 的绑定。

  • igraph (igraph.org/) 是一个 C 库,并且提供了 Python、R 和 Mathematica 的绑定。

  • graph-tool (graph-tool.skewed.de/),尽管是一个 Python 模块,但其核心算法和数据结构是用 C++ 编写的,并使用 OpenMP 并行化以在多核架构上扩展。

  • NetworKit (networkit.github.io/) 也是用 C++ 编写的,其核心功能集集成了 OpenMP 并行化,并集成在 Python 模块中。

  • 在一个性能更优且更健壮的库中实现 networkx 的功能。

所有的上述库在需要提高性能时都是 networkx 的有效替代品。改进可能非常显著,速度提升从 30 到 300 倍不等,最佳性能通常由 LightGraphs 实现。

在接下来的章节中,我们将主要关注 networkx,以提供一致性的展示,并为用户提供网络分析的基本概念。我们希望您知道还有其他选项可用,因为从性能角度来看,这变得极其相关。

摘要

在本章中,我们回顾了诸如图、节点和边等概念。我们回顾了图的 表示 方法,并探讨了如何 可视化 图。我们还定义了用于表征网络或其部分 属性

我们通过一个著名的 Python 图库 networkx 来处理图,并学习了如何将其应用于实践中的理论概念。

我们随后运行了通常用于研究网络特性的示例和玩具问题,以及网络算法的性能和有效性基准测试。我们还为您提供了可以找到和下载网络数据集的存储库的一些有用链接,以及一些解析和处理它们的技巧。

在下一章中,我们将超越在图上定义机器学习(ML)的概念。我们将学习如何通过特定的 ML 算法自动找到更高级和潜在的特性。

第二章:图机器学习

机器学习是人工智能的一个子集,旨在为系统提供从数据中学习和改进的能力。它在许多不同的应用中取得了令人印象深刻的成果,尤其是在难以或无法明确定义规则来解决特定任务的情况下。例如,我们可以训练算法来识别垃圾邮件,将句子翻译成其他语言,识别图像中的物体,等等。

近年来,应用机器学习到图结构数据的兴趣日益增加。在这里,主要目标是自动学习合适的表示,以便进行预测,发现新的模式,并更好地理解相对于“传统”机器学习方法的复杂动态。

本章将首先回顾一些基本的机器学习概念。然后,将提供关于图机器学习的介绍,特别关注表示学习。接着,我们将分析一个实际案例,以指导您理解理论概念。

本章将涵盖以下主题:

  • 机器学习的复习

  • 图上机器学习是什么,为什么它很重要?

  • 在图机器学习算法之间导航的一般分类法

技术要求

我们将使用带有Python 3.8的 Jupyter 笔记本来完成所有练习。以下是需要使用pip安装的 Python 库列表,以便本章使用。例如,在命令行中运行pip install networkx==2.5,等等:

Jupyter==1.0.0
networkx==2.5
matplotlib==3.2.2
node2vec==0.3.3
karateclub==1.0.19
scipy==1.6.2

本章所有相关的代码文件均可在github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter02找到。

在图上理解机器学习

在人工智能的各个分支中,机器学习是近年来最受关注的领域之一。它指的是一类计算机算法,这些算法通过经验自动学习和改进其技能,而不需要明确编程。这种方法从自然界中汲取灵感。想象一下,一位运动员第一次面对一个新颖的动作:他们开始慢慢地、仔细地模仿教练的姿势,尝试、犯错,然后再次尝试。最终,他们会变得越来越好,越来越自信。

现在,这个概念如何转化为机器?这本质上是一个优化问题。目标是找到一个数学模型,能够在特定任务上实现最佳性能。性能可以使用特定的性能指标(也称为 损失函数成本函数)来衡量。在常见的学习任务中,算法被提供数据,可能有很多数据。算法使用这些数据来对特定任务进行迭代决策或预测。在每次迭代中,决策使用损失函数进行评估。产生的 误差 用于以某种方式更新模型参数,希望模型将表现得更好。这个过程通常被称为 训练

更正式地说,让我们考虑一个特定的任务 T 和一个性能指标 P,它允许我们量化算法在 T 上的表现有多好。根据 Mitchell(Mitchell et al., 1997)的说法,如果一个算法在经验 E 上,通过 P 衡量的任务 T 的性能随着经验 E 的增加而提高,那么这个算法就被说成是从经验中学习的。

机器学习的基本原理

机器学习算法分为三大类,称为 监督学习无监督学习半监督学习。这些学习范式取决于数据提供给算法的方式以及性能如何评估。

监督学习是在我们知道问题答案时使用的学习范式。在这种情况下,数据集由形式为 <x,y> 的样本对组成,其中 x 是输入(例如,图像或声音信号),而 y 是相应的期望输出(例如,图像所代表的内容或声音所表达的内容)。输入变量也被称为 特征,而输出通常被称为 标签目标注释。在监督学习设置中,性能通常使用 距离函数 来评估。此函数衡量预测与期望输出之间的差异。根据标签的类型,监督学习可以进一步分为以下几种:

  • 分类:在这里,标签是离散的,指的是输入所属的“类别”。分类的例子包括确定照片中的物体或预测一封电子邮件是否为垃圾邮件。

  • 回归:目标是连续的。回归问题的例子包括预测建筑中的温度或预测任何特定产品的销售价格。

无监督学习与监督学习不同,因为问题答案未知。在这种情况下,我们没有标签,只有输入 。因此,目标是推断结构和模式,试图找到相似性。

发现相似示例的组(聚类)是这类问题之一,以及在高维空间中给出数据的新表示。

半监督学习中,算法使用标记数据和未标记数据的组合进行训练。通常,为了指导未标记输入数据中存在的结构的研究,会使用有限数量的标记数据。

值得注意的是,强化学习用于训练机器学习模型以做出一系列决策。人工智能算法面临类似游戏的情境,根据执行的动作获得惩罚奖励。算法的作用是理解如何行动以最大化奖励并最小化惩罚。

仅在训练数据上最小化错误是不够的。机器学习的关键词是学习。这意味着算法必须能够在未见过的数据上达到相同的性能水平。评估机器学习算法泛化能力的最常见方式是将数据集分为两部分:训练集测试集。模型在训练集上训练,其中计算损失函数并用于更新参数。训练后,模型在测试集上的性能得到评估。此外,当有更多数据可用时,测试集可以进一步分为验证集测试集。验证集通常用于评估训练期间模型的表现。

当训练机器学习算法时,可以观察到三种情况:

  • 在第一种情况下,模型在训练集上的表现水平较低。这种情况通常被称为欠拟合,意味着模型没有足够的能力来处理任务。

  • 在第二种情况下,模型在训练集上达到高水平的表现,但在测试数据的泛化上遇到困难。这种情况被称为过拟合。在这种情况下,模型只是简单地记忆训练数据,而没有真正理解它们之间的真实关系。

  • 最后,理想的情况是模型能够在训练和测试数据上达到(可能)最高的性能水平。

过拟合和欠拟合的例子可以通过图 2.1中显示的风险曲线给出。从图中可以看出,训练集和测试集的性能如何根据模型的复杂性(要拟合的模型参数数量)而变化:

图 2.1 – 描述模型复杂度(模型参数数量)函数上的训练集和测试集预测错误的风险曲线

图 2.1 – 描述模型复杂度(模型参数数量)函数上的训练集和测试集预测错误的风险曲线

过拟合是影响机器学习实践者的主要问题之一。它可能由几个原因引起。以下是一些可能的原因:

  • 数据集可能定义不明确或不足以代表任务。在这种情况下,添加更多数据可以帮助减轻问题。

  • 用于解决问题的数学模型对于任务来说过于强大。在这种情况下,可以向损失函数中添加适当的约束来降低模型的力量。这些约束被称为正则化项。

机器学习在许多领域取得了令人印象深刻的成果,成为计算机视觉、模式识别和自然语言处理等领域中最广泛和最有效的途径之一。

图机器学习的益处

已经开发出多种机器学习算法,每种算法都有其自身的优点和局限性。其中,值得提及的是回归算法(例如,线性回归和逻辑回归)、基于实例的算法(例如,k 近邻或支持向量机)、决策树算法、贝叶斯算法(例如,朴素贝叶斯)、聚类算法(例如,k 均值)和人工神经网络。

但这一切成功的秘诀是什么?

实质上,只有一件事:机器学习可以自动处理人类容易完成的任务。这些任务可能过于复杂,无法用传统的计算机算法描述,在某些情况下,它们甚至显示出比人类更好的能力。这尤其适用于处理图——由于它们的复杂结构,它们可以以比图像或音频信号更多的方式有所不同。通过使用图机器学习,我们可以创建算法来自动检测和解释重复出现的潜在模式。

由于这些原因,对图结构数据的表示学习越来越感兴趣,并且已经开发出许多机器学习算法来处理图。例如,我们可能对确定蛋白质在生物相互作用图中的作用、预测合作网络的演变、向社交网络中的用户推荐新产品等感兴趣(我们将在第十章图的未来)。

由于它们的本质,图可以在不同的粒度级别上进行分析:在节点、边和图级别(整个图),如图图 2.2所示。对于这些级别中的每一个,都可能遇到不同的问题,因此应该使用特定的算法:

![Figure 2.2 – 图的三种不同粒度级别的视觉表示

![img/B16069_02_02.jpg]

图 2.2 – 图的三种不同粒度级别的视觉表示

在以下的项目符号中,我们将给出一些针对这些级别的机器学习问题的例子:

  • 节点级别:给定一个(可能很大的)图,,目标是将每个顶点,,分类到正确的类别。在这种情况下,数据集包括G和一系列成对元素,< vi,yi >,其中vi是图G的一个节点,yi是该节点所属的类别。

  • 边级别:给定一个(可能很大的)图,,目标是将每条边,,分类到正确的类别。在这种情况下,数据集包括G和一系列成对元素,< ei,yi >,其中ei是图G的一条边,yi是该边所属的类别。在这个粒度级别上,另一个典型任务是链接预测,即预测图中两个现有节点之间是否存在链接的问题。

  • 图级别:给定一个包含m个不同图的数据库,任务是构建一个能够将图分类到正确类别的机器学习算法。然后我们可以将这个问题视为一个分类问题,其中数据集由一系列成对元素定义,<Gi,yi**>,其中Gi是一个图,yi*是该图所属的类别。

在本节中,我们讨论了机器学习的一些基本概念。此外,我们通过介绍处理图时的一些常见机器学习问题来丰富了我们的描述。有了这些理论原则作为基础,我们现在将介绍一些与图机器学习相关的更复杂的概念。

广义图嵌入问题

在经典的机器学习应用中,处理输入数据的一种常见方式是通过一组特征进行构建,这个过程称为特征工程,它能够为数据集中每个实例提供一个紧凑且具有意义的表示。

从特征工程步骤获得的数据库将作为机器学习算法的输入。如果这个过程通常对大量问题都有效,那么当我们处理图时,它可能不是最佳解决方案。确实,由于它们的结构定义良好,找到一个能够包含所有有用信息的适当表示可能并不容易。

第一种,也是最直接的方法,是创建能够从图中提取结构信息的特征,即提取某些统计数据。例如,一个图可以通过其度分布、效率和我们在上一章中描述的所有指标来表示。

一个更复杂的程序包括应用特定的核函数,或者在其他情况下,工程特定的特征,这些特征能够将所需属性纳入最终的机器学习模型中。然而,正如你可以想象的那样,这个过程可能非常耗时,并且在某些情况下,模型中使用的特征可能只代表所需信息的子集,以获得最终模型的最佳性能。

在过去十年中,为了定义创建有意义的紧凑图表示的新方法,已经做了大量工作。所有这些方法背后的通用思想是创建能够学习原始数据集良好表示的算法,使得新空间中的几何关系反映了原始图的结构。我们通常将学习给定图的良好表示的过程称为表示学习网络嵌入。我们将在以下内容中提供更正式的定义。

表示学习网络嵌入)的任务是学习从离散图到连续域的映射函数,。函数将能够执行低维向量表示,使得图的属性(局部和全局)得到保留。

一旦学习到映射 ,它就可以应用于图,并且得到的映射可以用作机器学习算法的特征集。这个过程的一个图形示例可见于图 2.3

图 2.3 – 网络嵌入算法的工作流程示例

图 2.3 – 网络嵌入算法的工作流程示例

映射函数也可以应用于学习节点和边的向量表示。正如我们之前提到的,图上的机器学习问题可能发生在不同的粒度级别。因此,已经开发了不同的嵌入算法来学习生成节点向量表示的函数(,也称为节点嵌入)或边的向量表示(,也称为边嵌入)。这些映射函数试图构建一个向量空间,使得新空间中的几何关系反映了原始图、节点或边的结构。因此,我们将看到,在原始空间中相似的结构在新空间中也将是相似的。

换句话说,在嵌入函数生成的空间中,相似的结构将具有较小的欧几里得距离,而不同的结构将具有较大的欧几里得距离。重要的是要强调,尽管大多数嵌入算法在欧几里得向量空间中生成映射,但最近对非欧几里得映射函数产生了兴趣。

现在我们来看一个嵌入空间的实际例子,以及如何在新的空间中看到相似性。在下面的代码块中,我们展示了使用一种称为节点到向量Node2Vec)的特定嵌入算法的示例。我们将在下一章中描述其工作原理。目前,我们只需说该算法将图G中的每个节点映射到一个向量:

import networkx as nx
from node2vec import Node2Vec
import matplotlib.pyplot as plt
G = nx.barbell_graph(m1=7, m2=4)
node2vec = Node2Vec(G, dimensions=2)
model = node2vec.fit(window=10)
fig, ax = plt.subplots()
for x in G.nodes():
    v = model.wv.get_vector(str(x))
    ax.scatter(v[0],v[1], s=1000)
    ax.annotate(str(x), (v[0],v[1]), fontsize=12)

在前面的代码中,我们已经做了以下工作:

  1. 我们生成了一个哑铃图(在上一章中描述)。

  2. 然后使用 Node2Vec 嵌入算法将图中的每个节点映射到二维向量。

  3. 最后,嵌入算法生成的二维向量,代表原始图中的节点,被绘制出来。

结果如图 2.4所示:

![图 2.4 – 将 Node2Vec 算法应用于图(左)以生成其节点的嵌入向量(右)]

](https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/gph-ml/img/B16069_02_04.jpg)

图 2.4 – 将 Node2Vec 算法应用于图(左)以生成其节点的嵌入向量(右)

图 2.4中,很容易看出具有相似结构的节点彼此靠近,并且与具有不同结构的节点相距较远。观察 Node2Vec 如何很好地区分第 1 组和第 3 组也非常有趣。由于该算法使用每个节点的邻接信息来生成表示,因此这两个组之间的清晰区分是可能的。

在同一张图上,可以使用边到向量Edge2Vec)算法进行另一个示例,以便为同一图G生成边的映射:

from node2vec.edges import HadamardEmbedder
edges_embs = HadamardEmbedder(keyed_vectors=model.wv)
fig, ax = plt.subplots()
for x in G.edges():
    v = edges_embs[(str(x[0]), str(x[1]))]
    ax.scatter(v[0],v[1], s=1000)
    ax.annotate(str(x), (v[0],v[1]), fontsize=12)

在前面的代码中,我们已经做了以下工作:

  1. 我们生成了一个哑铃图(在上一章中描述)。

  2. HadamardEmbedder嵌入算法应用于 Node2Vec 算法的结果(keyed_vectors=model.wv),以便将图中的每个边映射到二维向量。

  3. 最后,嵌入算法生成的二维向量,代表原始图中的节点,被绘制出来。

结果如图 2.5所示:

图 2.5 – 将 Hadamard 算法应用于图(左)以生成其边的嵌入向量(右)

图 2.5 – 将 Hadamard 算法应用于图(左)以生成其边的嵌入向量(右)

关于节点嵌入,在图 2.5中,我们报告了边嵌入算法的结果。从图中可以看出,边嵌入算法清楚地识别了相似的边。正如预期的那样,属于第 1、2 和 3 组的边在定义良好且分组清晰的区域内聚集。此外,属于第 4 组的(6,7)边和属于第 5 组的(10,11)边在特定的组内很好地聚集。

最后,我们将提供一个图到向量Grap2Vec)嵌入算法的示例。该算法将单个图映射到向量。至于另一个示例,我们将在下一章中更详细地讨论此算法。在下面的代码块中,我们提供了一个 Python 示例,说明如何使用 Graph2Vec 算法在图集上生成嵌入表示:

import random
import matplotlib.pyplot as plt
from karateclub import Graph2Vec
n_graphs = 20
def generate_random():
    n = random.randint(5, 20)
    k = random.randint(5, n)
    p = random.uniform(0, 1)
    return nx.watts_strogatz_graph(n,k,p)
Gs = [generate_random() for x in range(n_graphs)]
model = Graph2Vec(dimensions=2)
model.fit(Gs)
embeddings = model.get_embedding()
fig, ax = plt.subplots(figsize=(10,10))
for i,vec in enumerate(embeddings):
    ax.scatter(vec[0],vec[1], s=1000)
    ax.annotate(str(i), (vec[0],vec[1]), fontsize=16)

在这个例子中,已经做了以下工作:

  1. 20 个 Watts-Strogatz 图(在上一章中描述)已使用随机参数生成。

  2. 我们已经执行了图嵌入算法,以生成每个图的二维向量表示。

  3. 最后,生成的向量被绘制在其欧几里得空间中。

本例的结果在图 2.6中显示:

图 2.6 – 使用 Graph2Vec 算法应用于 20 个随机生成的 Watts-Strogatz 图生成的两个嵌入向量的图(左)。展示了两个具有较大欧几里得距离的图(图 12 和图 8 在右上角)以及两个具有较低欧几里得距离的图(图 14 和图 4 在右下角)

图 2.6 – 使用 Graph2Vec 算法应用于 20 个随机生成的 Watts-Strogatz 图生成的两个嵌入向量的图(左)。展示了两个具有较大欧几里得距离的图(图 12 和图 8 在右上角)以及两个具有较低欧几里得距离的图(图 14 和图 4 在右下角)

图 2.6所示,具有较大欧几里得距离的图,例如图 12 和图 8,具有不同的结构。前者使用nx.watts_strogatz_graph(20,20,0.2857)参数生成,后者使用nx.watts_strogatz_graph(13,6,0.8621)参数生成。相比之下,具有较低欧几里得距离的图,例如图 14 和图 8,具有相似的结构。图 14 使用nx.watts_strogatz_graph(9,9,0.5091)命令生成,而图 4 使用nx.watts_strogatz_graph(10,5,0.5659)生成。

在科学文献中,已经开发出大量的嵌入方法。我们将在本书的下一节中详细描述并使用其中的一些方法。这些方法通常分为两大类:归纳演绎,这取决于在添加新样本时函数的更新过程。如果提供了新节点,归纳方法会更新模型(例如,重新训练)以推断有关节点的信息,而在归纳方法中,模型预期可以推广到训练期间未观察到的新的节点、边或图。

图嵌入机器学习算法的分类

已经开发出多种方法来为图表示生成紧凑的空间。近年来,研究人员和机器学习从业者趋向于统一符号,以提供一个共同的定义来描述此类算法。在本节中,我们将介绍论文《图上的机器学习:一个模型和综合分类》中定义的分类的简化版本(arxiv.org/abs/2005.03675)。

在这种形式表示中,每个图、节点或边嵌入方法都可以用两个基本组件来描述,称为编码器和解码器。编码器ENC)将输入映射到嵌入空间,而解码器DEC)从学习到的嵌入中解码关于图的结构信息(图 2.7)。

论文中描述的框架遵循一个直观的想法:如果我们能够将一个图编码,使得解码器能够检索所有必要的信息,那么嵌入必须包含所有这些信息的压缩版本,并且可以用于下游机器学习任务:

图 2.7 – 嵌入算法的通用编码器(ENC)和解码器(DEC)架构

图 2.7 – 嵌入算法的通用编码器(ENC)和解码器(DEC)架构

在许多基于图的机器学习算法中,用于表示学习,解码器通常被设计为将节点嵌入对映射到实值,通常表示原始图中节点的邻近度(距离)。例如,可以实现解码器,给定两个节点的嵌入表示,如果输入图中存在连接两个节点的边,。在实践中,更有效的邻近度函数可以用来衡量节点之间的相似性。

嵌入算法的分类

受到图 2.7中描述的通用框架的启发,我们现在将对各种嵌入算法进行分类,分为四大主要类别。此外,为了帮助您更好地理解这种分类,我们将提供简单的伪代码代码片段。在我们的伪代码形式中,我们用G表示一个通用的networkx图,graphs_list表示networkx图的列表,model表示一个通用的嵌入算法:

  • graphs_list(第 2 行)。无监督和监督浅层嵌入方法将分别在第三章,“无监督图学习”和第四章,“监督图学习”中描述。

  • graphs_list(第 1 行)。一旦模型在输入训练集上拟合,就可以用它来生成一个未见过的图G的嵌入向量。图自动编码方法将在第三章,“无监督图学习”中描述。

  • 邻域聚合方法:这些算法可以用于在图级别提取嵌入,其中节点被标记为某些属性。此外,对于图自动编码方法,属于此类别的算法能够学习一个通用的映射函数,,也能够为未见实例生成嵌入向量。

    这些算法的一个良好特性是能够构建一个嵌入空间,不仅考虑了图的内部结构,还考虑了一些外部信息,定义为节点属性。例如,使用这种方法,我们可以有一个能够同时识别具有相似结构和节点上不同属性的图的嵌入空间。无监督和监督的邻域聚合方法将分别在第三章 无监督图学习第四章 监督图学习中描述。

  • 图正则化方法:基于图正则化的方法与前面列出的方法略有不同。在这里,我们没有图作为输入。相反,目标是通过对特征集的“交互”来学习,从而正则化过程。更详细地说,可以通过考虑特征相似性从特征构建一个图。主要思想基于这样的假设:图中相邻的节点很可能具有相同的标签。因此,损失函数被设计成约束标签与图结构的一致性。例如,正则化可能约束相邻节点在 L2 范数距离上共享相似的嵌入。因此,编码器只使用X节点特征作为输入。

    属于这个家族的算法学习一个函数,,它将一组特定的特征()映射到一个嵌入向量。至于图自动编码和邻域聚合方法,这个算法也能够将学习到的函数应用于新的、未见过的特征。图正则化方法将在第四章 监督图学习中描述。

对于属于浅层嵌入方法和邻域聚合方法的算法,可以定义一个无监督监督版本。属于图自动编码方法的算法适用于无监督任务,而属于图正则化方法的算法用于半监督/监督设置。

对于无监督算法,特定数据集的嵌入仅使用输入数据集中包含的信息执行,例如节点、边或图。对于监督设置,使用外部信息来指导嵌入过程。这些信息通常被归类为标签,例如一对<Gi,yi>),将每个图分配给一个特定的类别。这个过程比无监督过程更复杂,因为模型试图找到最佳的向量表示,以便找到对实例的最佳标签分配。为了阐明这个概念,我们可以以图像分类中的卷积神经网络为例。在它们的训练过程中,神经网络试图通过同时拟合各种卷积滤波器来将每个图像分类到正确的类别。这些卷积滤波器的目标是找到输入数据的紧凑表示,以最大化预测性能。同样的概念也适用于监督图嵌入,其中算法试图找到最佳的图表示,以最大化类别分配任务的性能。

从更数学的角度来看,所有这些模型都是通过适当的损失函数进行训练的。这个函数可以使用两个术语进行泛化:

  • 第一个用于监督设置中,以最小化预测值与目标值之间的差异。

  • 第二个用于评估输入图与经过 ENC + DEC 步骤后重建的图之间的相似性(即结构重建误差)。

正式来说,它可以定义为以下内容:

公式图片

在这里,公式图片是监督设置中的损失函数。模型被优化以最小化每个实例中正确值(公式图片)与预测类别(公式图片)之间的误差。公式图片是表示输入图(公式图片)与经过 ENC + DEC 过程后获得的图(公式图片)之间重建误差的损失函数。对于无监督设置,我们有相同的损失函数,但公式图片,因为我们没有目标变量可以使用。

当我们试图在图上解决机器学习问题时,这些算法扮演着非常重要的角色。它们可以被被动地使用,以便将图转换为适合经典机器学习算法或数据可视化任务的特性向量。但它们也可以在学习过程中主动地使用,此时机器学习算法会找到一个紧凑且具有意义的解决方案来解决特定问题。

摘要

在本章中,我们刷新了一些基本的机器学习概念,并探讨了它们如何应用于图。我们定义了基本的图机器学习术语,特别关注图表示学习。为了阐明多年来开发的各个解决方案的不同之处,我们提出了主要图机器学习算法的分类。最后,提供了实际例子,以开始理解理论如何应用于实际问题。

在下一章中,我们将回顾主要的基于图的机器学习算法。我们将分析它们的行为,并了解它们在实际中的应用方式。

第二部分 – 图上的机器学习

在本节中,读者将了解现有的主要图表示学习机器学习模型:它们的目的、工作原理以及如何实现。

本节包括以下章节:

  • 第三章无监督图学习

  • 第四章监督图学习

  • 第五章图上机器学习的问题

第三章:无监督图学习

无监督机器学习指的是机器学习算法的一个子集,在训练过程中不利用任何目标信息。相反,它们独立工作以找到聚类、发现模式、检测异常,并解决许多其他问题,对于这些问题没有教师和事先已知的正确答案。

正如许多其他机器学习算法一样,无监督模型在图表示学习领域得到了广泛的应用。事实上,它们是解决各种下游任务(如节点分类和社区检测等)的极其有用的工具。

在本章中,将提供最近无监督图嵌入方法的概述。给定一个图,这些技术的目标是自动学习其潜在表示,其中关键的结构组件以某种方式得到保留。

本章将涵盖以下主题:

  • 无监督图嵌入路线图

  • 浅层嵌入方法

  • 自动编码器

  • 图神经网络

技术要求

我们将使用 Python 3.9 的 Jupyter 笔记本来进行所有练习。以下是需要使用 pip 安装此章节所需的 Python 库的列表。例如,在命令行中运行 pip install networkx==2.5,等等:

Jupyter==1.0.0
networkx==2.5
matplotlib==3.2.2
karateclub==1.0.19
node2vec==0.3.3
tensorflow==2.4.0
scikit-learn==0.24.0
git+https://github.com/palash1992/GEM.git
git+https://github.com/stellargraph/stellargraph.git

在本书的其余部分,除非明确说明,否则我们将引用 Python 命令 import networkx 作为 nx

所有与本章相关的代码文件均可在github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter03找到。

无监督图嵌入路线图

图是非欧几里得空间中定义的复杂数学结构。粗略地说,这意味着并不总是容易定义什么是接近什么;甚至可能很难说“接近”这个词究竟是什么意思。想象一下社交网络图:两个用户可能分别连接,但共享非常不同的特征——一个可能对时尚和服装感兴趣,而另一个可能对体育和电子游戏感兴趣。我们能认为他们是“接近”的吗?

因此,无监督机器学习算法在图分析中得到了广泛的应用。无监督机器学习是机器学习算法的一个类别,可以在没有手动标注数据的情况下进行训练。这些模型中的大多数实际上只使用邻接矩阵和节点特征中的信息,而不了解下游机器学习任务。

这怎么可能呢?最常用的解决方案之一是学习保留图结构的嵌入。学习到的表示通常被优化,以便它可以用来重建成对节点相似度,例如,邻接矩阵。这些技术带来一个重要特性:学习到的表示可以编码节点或图之间的潜在关系,使我们能够发现隐藏和复杂的新的模式。

与无监督图机器学习技术相关联的许多算法已经被开发出来。然而,如不同科学论文先前所报告的(arxiv.org/abs/2005.03675),这些算法可以被分为几个宏观组:浅层嵌入方法、自动编码器和图神经网络GNNs),如下面的图表所示:

图 3.1 – 本书所述的不同无监督嵌入算法的层次结构

图 3.1 – 本书所述的不同无监督嵌入算法的层次结构

在接下来的章节中,你将了解每组算法背后的主要原理。我们将尝试提供该领域最著名算法背后的想法以及它们如何用于解决实际问题。

浅层嵌入方法

如已在第二章中介绍,图机器学习,使用浅层嵌入方法,我们识别出一组能够学习和仅返回学习输入数据的嵌入值的算法。

在本节中,我们将详细解释其中的一些算法。此外,我们将通过提供几个如何在 Python 中使用这些算法的示例来丰富描述。本节中描述的所有算法,我们将使用以下库中提供的实现:图嵌入方法GEM)、节点到向量Node2Vec)和 Karate Club。

矩阵分解

矩阵分解是一种在多个领域广泛使用的通用分解技术。许多图嵌入算法使用这种技术来计算图的节点嵌入。

我们将首先提供一个关于矩阵分解问题的通用介绍。在介绍基本原理之后,我们将描述两个算法,即图分解GF)和高阶邻近保持嵌入HOPE),它们使用矩阵分解来构建图的节点嵌入。

为输入数据。矩阵分解将分解为,分别称为丰度矩阵,而是生成的嵌入空间的维度数。矩阵分解算法通过最小化一个损失函数来学习VH矩阵,该损失函数可以根据我们想要解决的问题的具体情况而变化。在其一般公式中,损失函数通过计算使用 Frobenius 范数()的重构误差来定义。

一般而言,所有基于矩阵分解的无监督嵌入算法都使用相同的原则。它们都将表示为矩阵的输入图分解为不同的组件。每种方法之间的主要区别在于优化过程中使用的损失函数。确实,不同的损失函数允许创建一个嵌入空间,该空间强调输入图的具体属性。

图分解

GF 算法是第一个在执行给定图的节点嵌入时达到良好计算性能的模型之一。通过遵循我们之前描述的矩阵分解原理,GF 算法分解给定图的邻接矩阵。

形式上,设为我们想要计算节点嵌入的图,设为其邻接矩阵。在此矩阵分解问题中使用的损失函数(L)如下:

图片

在前面的方程中,代表G中的一条边,而是包含d-维嵌入的矩阵。矩阵的每一行代表一个给定节点的嵌入。此外,嵌入矩阵的正则化项()用于确保即使在缺乏足够数据的情况下,问题仍然是有良好定义的。

在此方法中使用的损失函数主要是为了提高 GF 的性能和可扩展性。实际上,此方法生成的解决方案可能会有噪声。此外,需要注意的是,通过观察其矩阵分解公式,GF 执行了强对称分解。这一特性特别适合无向图,其中邻接矩阵是对称的,但可能对无向图构成潜在的限制。

在下面的代码中,我们将展示如何使用 Python 和 GEM 库对给定的networkx图执行节点嵌入:

import networkx as nx
from gem.embedding.gf import GraphFactorization
G = nx.barbell_graph(m1=10, m2=4)
gf = GraphFactorization(d=2, data_set=None, max_iter=10000, eta=1*10**-4, regu=1.0)
gf.learn_embedding(G)
embeddings = gf.get_embedding()

在前面的例子中,以下操作已经完成:

  1. networkx用于生成一个双环图G),作为 GF 分解算法的输入。

  2. 使用GraphFactorization类生成一个d=2维度的嵌入空间。

  3. 使用 gf.learn_embedding(G) 执行输入图的节点嵌入计算。

  4. 通过调用gf.get_embedding()方法提取计算出的嵌入。

上一段代码的结果显示在以下图中:

图 3.2 – 将 GF 算法应用于图(左)以生成其节点嵌入向量(右)

图 3.2 – 将 GF 算法应用于图(左)以生成其节点嵌入向量(右)

图 3.2 中,我们可以看到属于组 1 和 3 的节点如何在同一空间区域中映射在一起。这些点被属于组 2 的节点所分隔。这种映射使我们能够很好地将组 1 和 3 与组 2 分离。不幸的是,组 1 和 3 之间没有明显的分离。

高阶邻近保持嵌入

HOPE 是另一种基于矩阵分解原理的图嵌入技术。这种方法允许保持高阶邻近,并且不强制其嵌入具有任何对称性质。在开始描述该方法之前,让我们了解一阶邻近和高阶邻近的含义:

  • 一阶邻近:给定一个图 img/B16069_03_013.png,其中边具有权重 img/B16069_03_014.png,对于每个顶点对 img/B16069_03_015.png,如果边 img/B16069_03_017.png,则我们说它们具有等于 img/B16069_03_016.png 的一阶邻近。否则,两个节点之间的一阶邻近为 0。

  • 二阶及高阶邻近:使用二阶邻近,我们可以捕捉每对顶点之间的两步关系。对于每个顶点对 img/B16069_03_018.png,我们可以将二阶邻近视为从 img/B16069_03_019.pngimg/B16069_03_020.png 的两步转换。高阶邻近泛化了这个概念,并允许我们捕捉更全局的结构。因此,高阶邻近可以被视为从 img/B16069_03_021.pngimg/B16069_03_022.png 的 k 步(k ≥ 3)转换。

给定邻近的定义,我们现在可以描述 HOPE 方法。形式上,设 img/B16069_03_023.png 为我们想要计算嵌入的图,设 img/B16069_03_024.png 为其邻接矩阵。此问题使用的损失函数(L)如下:

img/B16069_03_025.jpg

在前面的方程中,img/B16069_03_026.png 是由图 img/B16069_03_027.pngimg/B16069_03_028.png 以及 img/B16069_03_029.png 生成的相似性矩阵。更详细地说,img/B16069_03_030.png 代表源嵌入,而 img/B16069_03_031.png 代表目标嵌入。

HOPE 使用这两个矩阵来捕捉有向网络中的非对称邻近性,其中存在从源节点到目标节点的方向。最终的嵌入矩阵,,通过简单地按列连接矩阵获得。由于这个操作,由 HOPE 生成的最终嵌入空间将具有维。

正如我们之前所述,矩阵是从原始图G中获得的相似度矩阵。的目标是获得高阶邻近信息。形式上,它被计算为,其中都是矩阵的多项式。

在其原始公式中,HOPE 的作者建议了不同的方法来计算。在这里,我们报告了一种常见且简单的方法来计算这些矩阵,Adamic-AdarAA)。在这个公式中,(单位矩阵)而,其中是一个对角矩阵,其计算方式为。计算的其他公式包括Katz 指数根 PageRankRPR)和共同邻居CN)。

在以下代码中,我们将展示如何使用 Python 和 GEM 库对给定的networkx图执行节点嵌入:

import networkx as nx
from gem.embedding.hope import HOPE
G = nx.barbell_graph(m1=10, m2=4)
gf = HOPE(d=4, beta=0.01)
gf.learn_embedding(G)
embeddings = gf.get_embedding()

上述代码与用于 GF 的代码类似。唯一的区别在于类初始化,因为在这里我们使用HOPE。根据 GEM 提供的实现,d参数,表示嵌入空间的维度,将定义最终嵌入矩阵的列数,该矩阵是在按列连接之后获得的。

因此,的列数由d分配的值的整数除法(Python 中的//运算符)定义。代码的结果在以下图表中显示:

图 3.3 – 将 HOPE 算法应用于图(左)以生成其节点的嵌入向量(右)

图 3.3 – 将 HOPE 算法应用于图(左)以生成其节点的嵌入向量(右)

在这种情况下,图是无向的,因此源节点和目标节点之间没有区别。图 3.3显示了表示embeddings矩阵的前两个维度。可以看到,HOPE 生成的嵌入空间在这种情况下提供了不同节点之间更好的分离。

带有全局结构信息的图表示

具有全局结构信息的图表示(GraphRep),如 HOPE,允许我们保留高阶邻近度,而不强迫其嵌入具有对称属性。形式上,设为我们想要计算节点嵌入的图,设为其邻接矩阵。此问题使用的损失函数(L)如下:

在前一个方程中,是从图G生成的矩阵,用于获取节点之间的k阶邻近度。

是两个嵌入矩阵,分别表示源节点和目标节点的k阶邻近度的d-维嵌入空间。

根据以下方程计算矩阵:。在这里,是使用以下方程计算出的称为度矩阵的对角矩阵:

图 3.4 – 将 GraphRep 算法应用于图(顶部)以生成其节点嵌入向量(底部)的不同 k 值

代表(一步)概率转移矩阵,其中是在一步内从到顶点的转移概率。一般来说,对于通用的k值,代表在k步内从到顶点的转移概率。

对于每个邻近度阶数k,拟合一个独立的优化问题。然后,所有生成的k嵌入矩阵按列连接,以获得最终的源嵌入矩阵。

在以下代码中,我们将展示如何使用 Python 和karateclub库对给定的networkx图执行节点嵌入:

import networkx as nx
from karateclub.node_embedding.neighbourhood.grarep import GraRep
G = nx.barbell_graph(m1=10, m2=4)
gr = GraRep(dimensions=2, order=3)
gr.fit(G)
embeddings = gr.get_embedding()

我们从karateclub库初始化GraRep类。在这个实现中,dimension参数表示嵌入空间的维度,而order参数定义了节点之间最大邻近度阶数。最终嵌入矩阵的列数(例如,在示例中存储在embeddings变量中)是dimension*order,因为,正如我们所说的,对于每个邻近度阶数,都会计算并连接到最终的嵌入矩阵中。

具体来说,由于示例中计算了两个维度,embeddings[:,:2]表示对于k=1 获得的嵌入,embeddings[:,2:4]表示k=2,而embeddings[:,4:]表示k=3。代码的结果如下所示:

.f

图 3.4 – 将 GraphRep 算法应用于图(顶部)以生成其节点嵌入向量(底部)的不同 k 值

图 3.4 – 将 GraphRep 算法应用于图(顶部)以生成其节点嵌入向量(底部)的不同 k 值

从前面的图中,很容易看出不同的邻近顺序如何使我们得到不同的嵌入。由于输入图相当简单,在这种情况下,即使k=1,也能获得一个很好地分离的嵌入空间。具体来说,所有邻近顺序中属于第 1 组和第 3 组的节点具有相同的嵌入值(它们在散点图中是重叠的)。

在本节中,我们描述了一些无监督图嵌入的矩阵分解方法。在下一节中,我们将介绍使用跳字图模型进行无监督图嵌入的不同方法。

跳字图

在本节中,我们将简要描述跳字图模型。由于它在不同的嵌入算法中广泛使用,因此需要一个高级描述来更好地理解不同的方法。在深入详细描述之前,我们首先给出一个简要概述。

跳字图模型是一个简单的神经网络,包含一个隐藏层,用于在输入词存在时预测给定词出现的概率。该神经网络通过使用文本语料库作为参考来构建训练数据来训练。这个过程在以下图表中描述:

图 3.5 – 从给定语料库生成训练数据的例子。在填充的框中是目标词。在虚线框中,是长度为 2 的窗口大小识别出的上下文词

图 3.5 – 从给定语料库生成训练数据的例子。在填充的框中是目标词。在虚线框中,是长度为 2 的窗口大小识别出的上下文词

图 3.5中描述的例子展示了生成训练数据的算法是如何工作的。选择一个目标词,并围绕该词构建一个固定大小的滚动窗口w。滚动窗口内的词被称为上下文词。然后根据滚动窗口内的词构建多个(目标词,上下文词)对。

一旦从整个语料库中生成了训练数据,跳字图模型就被训练来预测给定目标词的上下文词的概率。在其训练过程中,神经网络学习输入词的紧凑表示。这就是为什么跳字图模型也被称为词到向量Word2Vec)。

跳字图模型表示的神经网络结构在以下图表中描述:

图 3.6 – 跳字图模型的神经网络结构。隐藏层中 d 神经元的数量表示嵌入空间的最终大小

图 3.6 – 跳字图模型的神经网络结构。隐藏层中 d 神经元的数量表示嵌入空间的最终大小

神经网络的输入是一个大小为 m 的二进制向量。向量的每个元素代表我们想要嵌入的单词语言字典中的一个单词。当在训练过程中给出一个 (目标词,上下文词) 对时,输入数组在其所有条目中都将为 0,除了表示“目标”词的条目,它将等于 1。隐藏层有 d 个神经元。隐藏层将学习每个单词的嵌入表示,创建一个 d-维嵌入空间。

最后,神经网络的输出层是一个包含 m 个神经元(与输入向量大小相同)的密集层,并使用 softmax 激活函数。每个神经元代表字典中的一个单词。神经元分配的值对应于该单词与输入单词“相关”的概率。由于当 m 的大小增加时 softmax 可能难以计算,因此通常使用层次 softmax方法。

skip-gram 模型最终的目标不是真正学习我们之前描述的任务,而是构建输入单词的紧凑 d-维表示。得益于这种表示,可以很容易地使用隐藏层的权重提取单词的嵌入空间。另一种常见的创建 skip-gram 模型的方法(这里将不描述),是基于连续词袋CBOW)。

在介绍了 skip-gram 模型背后的基本概念之后,我们可以开始描述一系列基于此模型构建的无监督图嵌入算法。一般来说,所有基于 skip-gram 模型的无监督嵌入算法都使用相同的原理。

从一个输入图开始,他们从中提取出一组路径。这些路径可以看作是一个文本语料库,其中每个节点代表一个单词。在路径中通过边连接的两个单词(代表节点)在文本中彼此靠近。每种方法之间的主要区别在于计算这些路径的方式。实际上,正如我们将看到的,不同的路径生成算法可以强调图的特殊局部或全局结构。

DeepWalk

DeepWalk 算法使用 skip-gram 模型生成给定图的节点嵌入。为了更好地解释这个模型,我们需要介绍随机游走的概念。

形式上,设为一个图,设为一个选定的起始点。我们随机选择的一个邻居并向其移动。从这个点开始,我们随机选择另一个点进行移动。这个过程重复次。以这种方式选出的个顶点的随机序列是一个长度为的随机游走。值得一提的是,用于生成随机游走的算法不对它们的构建方式施加任何约束。因此,不能保证节点的局部邻域得到很好的保留。

使用随机游走的概念,DeepWalk 算法为每个节点生成一个最大长度为t的随机游走。这些随机游走将被作为 skip-gram 模型的输入。使用 skip-gram 生成的嵌入将被用作最终的节点嵌入。在下面的图(图 3.7)中,我们可以看到算法的逐步图形表示:

图 3.7 – DeepWalk 算法生成给定图节点嵌入的所有步骤

图 3.7 – DeepWalk 算法生成给定图节点嵌入的所有步骤

下面是对前面图表中图形描述的算法的逐步解释:

  1. 随机游走生成:对于输入图G的每个节点,计算一组具有固定最大长度(t)的随机游走。需要注意的是,长度t是一个上限。没有约束强制所有路径具有相同的长度。

  2. Skip-Gram 训练:使用之前步骤中生成的所有随机游走,训练一个 skip-gram 模型。正如我们之前所描述的,skip-gram 模型在单词和句子上工作。当将一个图作为 skip-gram 模型的输入时,如图 3.7所示,一个图可以看作是一个输入文本语料库,而图中的单个节点可以看作是语料库中的一个单词。

    随机游走可以看作是一系列单词(一个句子)。然后使用随机游走中节点生成的“假”句子来训练 skip-gram。在此步骤中使用了之前描述的 skip-gram 模型的参数(窗口大小,w,和嵌入大小,d)。

  3. 嵌入生成:使用训练好的 skip-gram 模型的隐藏层中的信息来提取每个节点的嵌入。

在下面的代码中,我们将展示如何使用 Python 和karateclub库对给定的networkx图执行节点嵌入:

import networkx as nx
from karateclub.node_embedding.neighbourhood.deepwalk import DeepWalk
G = nx.barbell_graph(m1=10, m2=4)
dw = DeepWalk(dimensions=2)
dw.fit(G)
embeddings = dw.get_embedding()

代码相当简单。我们从karateclub库初始化DeepWalk类。在这个实现中,dimensions参数表示嵌入空间的维度。DeepWalk类接受的其它值得注意的参数如下:

  • walk_number:为每个节点生成随机游走的数量

  • walk_length:生成的随机游走的长度

  • window_size:跳字模型的窗口大小参数

最后,使用dw.fit(G)在图G上拟合模型,并使用dw.get_embedding()提取嵌入。

以下图显示了代码的结果:

图 3.8 – 将 DeepWalk 算法应用于图(左)以生成其节点的嵌入向量(右)

图 3.8 – 将 DeepWalk 算法应用于图(左)以生成其节点的嵌入向量(右)

从前面的图中,我们可以看到 DeepWalk 如何将区域 1 和区域 3 分开。这两个组受到属于区域 2 的节点的影响。确实,对于这些节点,在嵌入空间中无法看到明显的区分。

Node2Vec

Node2Vec算法可以看作是 DeepWalk 的扩展。确实,与 DeepWalk 一样,Node2Vec 也生成一组随机游走,作为跳字模型的输入。一旦训练完成,跳字模型的隐藏层被用来生成图中节点的嵌入。这两个算法之间的主要区别在于随机游走的生成方式。

事实上,如果 DeepWalk 在不使用任何偏差的情况下生成随机游走,那么在 Node2Vec 中引入了一种生成有偏随机游走的新技术。生成随机游走的算法通过合并广度优先搜索BFS)和深度优先搜索DFS)来进行图探索。这两种算法在随机游走生成中的结合方式由两个参数进行正则化,定义了随机游走返回前一个节点的概率,而定义了随机游走通过之前未见过的图部分的概率。

由于这种组合,Node2Vec 可以通过保留图中的局部结构和全局社区结构来保留高阶邻近性。这种新的随机游走生成方法允许解决 DeepWalk 保留节点局部邻域属性的限制。

在以下代码中,我们将展示如何使用 Python 和node2vec库对给定的networkx图进行节点嵌入:

import networkx as nx
from node2vec import Node2Vec
G = nx.barbell_graph(m1=10, m2=4)
draw_graph(G)
node2vec = Node2Vec(G, dimensions=2)
model = node2vec.fit(window=10)
embeddings = model.wv

此外,对于 Node2Vec,代码非常直接。我们从node2vec库中初始化Node2Vec类。在这个实现中,dimensions参数表示嵌入空间的维度。然后使用node2vec.fit(window=10)进行模型拟合。最后,使用model.wv获取嵌入。

应注意,model.wvWord2VecKeyedVectors 类的对象。为了获取具有 nodeid 作为 ID 的特定节点的嵌入向量,我们可以使用训练好的模型,如下所示:model.wv[str(nodeId)]Node2Vec 类接受的其它参数也值得提及,如下:

  • num_walks: 为每个节点生成的随机游走的数量

  • walk_length: 生成的随机游走的长度

  • p, q: 随机游走生成算法的 pq 参数

代码的结果显示在 图 3.9 中:

图 3.9 – 将 Node2Vec 算法应用于图(左)以生成其节点的嵌入向量(右)

图 3.9 – 将 Node2Vec 算法应用于图(左)以生成其节点的嵌入向量(右)

图 3.9 所示,Node2Vec 允许我们在嵌入空间中获得比 DeepWalk 更好的节点分离。具体来说,区域 1 和 3 在空间中的两个区域中很好地聚集。而区域 2 则位于两组中间,没有任何重叠。

Edge2Vec

与其他嵌入函数不同,边到向量Edge2Vec)算法在边而不是节点上生成嵌入空间。该算法是使用 Node2Vec 生成的嵌入的简单副作用。主要思想是使用两个相邻节点的节点嵌入来执行一些基本的数学运算,以提取连接它们的边的嵌入。

形式上,令 为两个相邻节点,令 为它们使用 Node2Vec 计算的嵌入。表 3.1 中描述的算子可以用来计算它们的边的嵌入:

表 3.1 – Node2Vec 库中的边嵌入算子及其方程和类名

表 3.1 – Node2Vec 库中的边嵌入算子及其方程和类名

在以下代码中,我们将展示如何使用 Python 和 Node2Vec 库对给定的 networkx 图执行节点嵌入:

from node2vec.edges import HadamardEmbedder
embedding = HadamardEmbedder(keyed_vectors=model.wv)

代码相当简单。HadamardEmbedder 类仅使用 keyed_vectors 参数实例化。此参数的值是 Node2Vec 生成的嵌入模型。为了使用其他技术生成边嵌入,我们只需更改类并从 表 3.1 中选择一个即可。以下图示展示了该算法的应用示例:

图 3.11 – 将 Edge2Vec 算法应用于图(顶部)以生成其节点的嵌入向量(底部)的不同方法

图 3.11 – 将 Edge2Vec 算法应用于图(顶部)以生成其节点的嵌入向量(底部)的不同方法

从[图 3.11]中,我们可以看到不同的嵌入方法如何生成完全不同的嵌入空间。在本例中,AverageEmbedderHadamardEmbedder为区域 1、2 和 3 生成了良好的分离嵌入。

然而,对于WeightedL1EmbedderWeightedL2Embedder,由于边嵌入集中在单个区域而没有显示出清晰的聚类,因此嵌入空间没有很好地分离。

Graph2Vec

我们之前描述的方法为给定图上的每个节点或边生成了嵌入空间。图到向量Graph2Vec)泛化了这个概念,并为整个图生成嵌入。

具体来说,给定一组图,Graph2Vec 算法生成一个嵌入空间,其中每个点代表一个图。该算法使用 Word2Vec skip-gram 模型的演变来生成其嵌入,这种演变被称为文档到向量Doc2Vec)。我们可以在[图 3.12]中直观地看到该模型的简化:

图 3.12 – Doc2Vec skip-gram 模型的简化图形表示。隐藏层中 d 神经元的数量表示嵌入空间的最终大小

图 3.12 – Doc2Vec skip-gram 模型的简化图形表示。隐藏层中 d 神经元的数量表示嵌入空间的最终大小

与简单的 Word2Vec 相比,Doc2Vec 还接受表示包含输入单词的文档的另一个二进制数组。给定一个“目标”文档和一个“目标”单词,该模型随后尝试预测与输入“目标”单词和文档相关的最可能的“上下文”单词。

随着 Doc2Vec 模型的引入,我们现在可以描述 Graph2Vec 算法。这种方法背后的主要思想是将整个图视为一个文档,并将每个节点生成的作为 ego 图(参见第一章Graphs 入门)的每个子图,视为构成文档的单词。

换句话说,图由子图组成,就像文档由句子组成一样。根据这种描述,算法可以总结为以下步骤:

  1. 子图生成:围绕每个节点生成一组根子图。

  2. Doc2Vec 训练:使用前一步骤生成的子图对 Doc2Vec skip-gram 进行训练。

  3. 嵌入生成:使用训练好的 Doc2Vec 模型的隐藏层中的信息来提取每个节点的嵌入。

在以下代码中,正如我们在第二章Graph Machine Learning中所做的那样,我们将展示如何使用 Python 和karateclub库对一组networkx图执行节点嵌入:

import matplotlib.pyplot as plt
from karateclub import Graph2Vec
n_graphs = 20
def generate_random():
    n = random.randint(5, 20)
    k = random.randint(5, n)
    p = random.uniform(0, 1)
    return nx.watts_strogatz_graph(n,k,p)
Gs = [generate_random() for x in range(n_graphs)]
model = Graph2Vec(dimensions=2)
model.fit(Gs)
embeddings = model.get_embedding()

在本例中,以下工作已经完成:

  1. 已生成 20 个具有随机参数的 Watts-Strogatz 图。

  2. 然后,我们使用 karateclub 库中的 Graph2Vec 类初始化两个维度。在这个实现中,dimensions 参数表示嵌入空间的维度。

  3. 然后使用 model.fit(Gs) 在输入数据上拟合模型。

  4. 使用 model.get_embedding() 提取包含嵌入的向量。

    代码的结果显示在下图中:

图 3.13 – 将 Graph2Vec 算法应用于图(左)以生成其节点的嵌入向量(右)的不同方法

图 3.13 – 将 Graph2Vec 算法应用于图(左)以生成其节点的嵌入向量(右)的不同方法

图 3.13 中,可以看到为不同图生成的嵌入空间。

在本节中,我们描述了基于矩阵分解和 skip-gram 模型的不同浅层嵌入方法。然而,在科学文献中,存在许多无监督嵌入算法,如拉普拉斯方法。我们建议对那些感兴趣探索这些方法的读者查阅可用的论文 Machine Learning on Graphs: A Model and Comprehensive Taxonomy,链接为 arxiv.org/pdf/2005.03675.pdf

我们将在下一节继续描述无监督图嵌入方法。我们将描述基于自动编码器的更复杂的图嵌入算法。

自动编码器

自动编码器是一种极其强大的工具,可以有效帮助数据科学家处理高维数据集。尽管它最初是在大约 30 年前提出的,但在最近几年,随着基于神经网络的算法的普遍兴起,自动编码器变得越来越普及。除了允许我们进行紧凑的稀疏表示外,它们还可以作为生成模型的基础,代表着著名的生成对抗网络GAN)的首次出现,正如杰弗里·辛顿所说:

"过去 10 年中最有趣的机器学习思想"

自动编码器是一种神经网络,其中输入和输出基本上是相同的,但特征在于隐藏层中的单元数量很少。简单来说,它是一种经过训练以使用显著较少的变量和/或自由度来重建其输入的神经网络。

由于自动编码器不需要标记的数据集,它可以被视为无监督学习的一个例子和降维技术。然而,与主成分分析PCA)和矩阵分解等其他技术不同,自动编码器可以通过其神经元的非线性激活函数学习非线性变换:

图 3.14 – 自动编码器结构图。输入层和输出层中的颜色表示值应该尽可能相似。实际上,网络的训练是为了匹配这些值并最小化重建误差

图 3.14 – 自动编码器结构图。输入层和输出层中的颜色表示值应该尽可能相似。实际上,网络的训练是为了匹配这些值并最小化重建误差

图 3.14展示了自动编码器的一个简单示例。您可以看到自动编码器通常可以看作由两部分组成:

  • 一个编码器网络,它通过一个或多个单元处理输入,并将其映射到一个编码表示,该表示减少了输入的维度(欠完备自动编码器)和/或约束其稀疏性(过完备正则化自动编码器)

  • 一个解码器网络,它从中间层的编码表示中重建输入信号

然后训练编码器-解码器结构以最小化整个网络重建输入的能力。为了完全指定一个自动编码器,我们需要一个损失函数。输入和输出之间的误差可以使用不同的度量标准来计算,并且确实,选择“重建”误差的正确形式是构建自动编码器时的一个关键点。

用于衡量重建误差的一些常见损失函数选择是均方误差平均绝对误差交叉熵KL 散度

在接下来的章节中,我们将向您展示如何从一些基本概念开始构建自动编码器,然后将这些概念应用于图结构。但在深入之前,我们感到有必要给您一个非常简短的介绍,这些框架将使我们能够做到这一点:TensorFlow 和 Keras。

TensorFlow 和 Keras – 强力组合

Google 于 2017 年开源的 TensorFlow 现在已成为标准、事实上的框架,允许符号计算和微分编程。它基本上允许您构建一个符号结构,描述输入如何组合以产生输出,定义通常称为计算图有状态数据流图的内容。在这个图中,节点是变量(标量、数组、张量),边代表连接输入(边源)到单个操作的输出(边目标)的操作。

在 TensorFlow 中,这样的图是静态的(这确实是与这个环境中另一个非常流行的框架相比的一个主要区别:torch),可以通过向其中输入数据作为输入,清除之前提到的“数据流”属性来执行。

通过抽象计算,TensorFlow 是一个非常通用的工具,可以在多个后端上运行:在由 CPU、GPU 或甚至专门设计的处理单元(如 TPU)驱动的机器上。此外,由 TensorFlow 驱动的应用程序也可以部署在不同的设备上,从单机和分布式服务器到移动设备。

除了抽象计算之外,TensorFlow 还允许你针对其任何变量符号化地微分你的计算图,从而生成一个新的计算图,该图也可以微分以产生高阶导数。这种方法通常被称为符号到符号的导数,它确实非常强大,尤其是在通用损失函数优化的背景下,这需要梯度估计(例如梯度下降技术)。

如你所知,相对于许多参数优化损失函数的问题,是任何神经网络通过反向传播训练的核心。这无疑是 TensorFlow 在过去几年中变得非常流行的主要原因,也是它最初由谷歌设计和生产的原因。

深入探讨 TensorFlow 的使用超出了本书的范围,实际上你可以通过在专门的书籍中的描述了解更多信息。在接下来的章节中,我们将使用其一些主要功能,并为你提供构建神经网络的基本工具。

自从其上一个主要版本 2.x 以来,使用 TensorFlow 构建模型的标准方式是使用 Keras API。Keras 最初是 TensorFlow 的一个外部项目,旨在提供一个通用且简单的 API 来使用几个微分编程框架,例如 TensorFlow、Teano 和 CNTK,以实现神经网络模型。它通常抽象了计算图的底层实现,并为你提供了构建神经网络时最常用的层(尽管也可以轻松实现自定义层),如下所示:

  • 卷积层

  • 循环层

  • 正则化层

  • 损失函数

Keras 还公开了与 scikit-learn 非常相似的 API,scikit-learn 是 Python 生态系统中最受欢迎的机器学习库,这使得数据科学家在他们的应用程序中构建、训练和集成基于神经网络的模型变得非常容易。

在下一节中,我们将向您展示如何使用 Keras 构建和训练自动编码器。我们将开始将这些技术应用于图像,以便逐步将关键概念应用于图结构。

我们的第一种自动编码器

我们将从实现最简单的自动编码器开始,即一个简单的前馈网络,该网络被训练以重建其输入。我们将将其应用于 Fashion-MNIST 数据集,这是一个类似于著名 MNIST 数据集的数据集,它包含黑白图像上的手写数字。

MNIST 有 10 个类别,由 60k + 10k(训练数据集 + 测试数据集)28x28 像素的灰度图像组成,代表一件服装(T-shirtTrouserPulloverDressCoatSandalShirtSneakerBagAnkle boot)。Fashion-MNIST 数据集比原始 MNIST 数据集更具挑战性,通常用于算法的基准测试。

数据集已经集成在 Keras 库中,可以使用以下代码轻松导入:

from tensorflow.keras.datasets import fashion_mnist
(x_train, y_train), (x_test, y_test) = fashion_mnist.load_data() 

通常,将输入按数量级约 1(对于激活函数来说效率最高)进行缩放,并确保数值数据以单精度(32 位)而不是双精度(64 位)的形式存在。这是因为当训练神经网络时,通常更希望提高速度而不是精度,这是一个计算密集型的过程。在某些情况下,精度甚至可以降低到半精度(16 位)。我们使用以下方式转换输入:

x_train = x_train.astype('float32') / 255.
x_test = x_test.astype('float32') / 255.

我们可以通过使用以下代码绘制训练集中的某些样本来了解我们正在处理哪种类型的输入:

n = 10
plt.figure(figsize=(20, 4))
for i in range(n):
    ax = plt.subplot(1, n, i + 1)
    plt.imshow(x_train[i])
    plt.title(classes[y_train[i]])
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()

在前面的代码中,classes 表示整数和类名之间的映射,例如,T-shirtTrouserPulloverDressCoatSandalShirtSneakerBagAnkle boot

图 3.15 – 从 Fashion-MNIST 数据集的训练集中取出的某些样本

图 3.15 – 从 Fashion-MNIST 数据集的训练集中取出的某些样本

现在我们已经导入了输入,我们可以通过创建编码器和解码器来构建我们的自动编码器网络。我们将使用 Keras 功能 API 来完成这项工作,与所谓的顺序 API 相比,它提供了更多的通用性和灵活性。我们首先定义编码器网络:

from tensorflow.keras.layers import Conv2D, Dropout, MaxPooling2D, UpSampling2D, Input
input_img = Input(shape=(28, 28, 1))
x = Conv2D(16, (3, 3), activation='relu', padding='same')(input_img)
x = MaxPooling2D((2, 2), padding='same')(x)
x = Conv2D(8, (3, 3), activation='relu', padding='same')(x)
x = MaxPooling2D((2, 2), padding='same')(x)
x = Conv2D(8, (3, 3), activation='relu', padding='same')(x)
encoded = MaxPooling2D((2, 2), padding='same')(x)

我们的网络由三个相同模式的堆叠组成,每个模式由相同的两层构建块组成:

  • Conv2D 是一个二维卷积核,应用于输入,并且实际上对应于在整个输入神经元之间共享权重。在应用卷积核后,使用 ReLU 激活函数转换输出。这种结构在 n 个隐藏平面中重复,其中 n 在第一个堆叠层中为 16,在第二个和第三个堆叠层中为 8。

  • MaxPooling2D 通过在指定的窗口(在这种情况下为 2x2)上取最大值来对输入进行下采样。

使用 Keras API,我们还可以使用 Model 类来概述层如何转换输入,该类将张量转换为用户友好的模型,以便使用和探索:

Model(input_img, encoded).summary()

这提供了 图 3.16 中可见的编码器网络的概览:

![图 3.16 – 编码器网络概览图片

图 3.16 – 编码器网络概览

如所示,在编码阶段结束时,我们得到了一个(4, 4, 8)的张量,这比我们原始的初始输入(28x28)小六倍以上。我们现在可以构建解码器网络。请注意,编码器和解码器不需要具有相同的结构以及/或共享权重:

x = Conv2D(8, (3, 3), activation='relu', padding='same')(encoded)
x = UpSampling2D((2, 2))(x)
x = Conv2D(8, (3, 3), activation='relu', padding='same')(x)
x = UpSampling2D((2, 2))(x)
x = Conv2D(16, (3, 3), activation='relu')(x)
x = UpSampling2D((2, 2))(x)
decoded = Conv2D(1, (3, 3), activation='sigmoid', padding='same')(x) 

在这种情况下,解码器网络类似于编码器结构,其中使用MaxPooling2D层实现的输入下采样已被UpSampling2D层替换,该层基本上在指定的窗口(在这种情况下为 2x2)上重复输入,从而在每个方向上有效地将张量加倍。

我们现在已经完全定义了网络结构,包括编码器和解码器层。为了完全指定我们的自动编码器,我们还需要指定一个损失函数。此外,为了构建计算图,Keras 还需要知道应该使用哪些算法来优化网络权重。这两项信息,即损失函数和要使用的优化器,通常在编译模型时提供给 Keras:

autoencoder = Model(input_img, decoded)
autoencoder.compile(optimizer='adam', loss='binary_crossentropy')

我们现在可以最终训练我们的自动编码器。Keras Model类提供了与 scikit-learn 类似的 API,使用fit方法来训练神经网络。请注意,由于自动编码器的性质,我们正在使用与网络输入和输出相同的信息:

autoencoder.fit(x_train, x_train,
                epochs=50,
                batch_size=128,
                shuffle=True,
                validation_data=(x_test, x_test))

一旦训练完成,我们可以通过比较输入图像与其重建版本来检验网络重建输入的能力。这可以通过使用 Keras Model类的predict方法轻松计算,如下所示:

decoded_imgs = autoencoder.predict(x_test)

图 3.17中,我们展示了重建的图像。如您所见,网络在重建未见过的图像方面相当出色,尤其是在考虑大规模特征时。在压缩过程中可能会丢失细节(例如,T 恤上的标志),但网络确实捕捉到了整体的相关信息:

![Figure 3.17 – 训练的自动编码器在测试集上完成的重建示例img/B16069_03_17.jpg

Figure 3.17 – 训练的自动编码器在测试集上完成的重建示例

使用 T-SNE 在二维平面上表示图像的编码版本也非常有趣:

from tensorflow.keras.layers import Flatten
embed_layer = Flatten()(encoded)
embeddings = Model(input_img, embed_layer).predict(x_test)
tsne = TSNE(n_components=2)
emb2d = tsne.fit_transform(embeddings)
x, y = np.squeeze(emb2d[:, 0]), np.squeeze(emb2d[:, 1])

T-SNE 提供的坐标显示在图 3.18中,根据样本所属的类别进行着色。不同服装的聚类可以清楚地看到,尤其是对于一些与其他类别非常分离的类别:

Figure 3.18 – 从测试集中提取的嵌入的 T-SNE 转换,根据样本所属的类别着色

图 3.18 – 从测试集中提取的嵌入的 T-SNE 转换,根据样本所属的类别着色

然而,自动编码器很容易过拟合,因为它们倾向于精确地重新创建训练图像,而不是很好地泛化。在下一小节中,我们将看到如何防止过拟合,以构建更稳健和可靠的密集表示。

去噪自动编码器

除了允许我们将稀疏表示压缩到更密集的向量中,自动编码器还被广泛用于处理信号,以过滤噪声并提取仅相关的(特征)信号。这在许多应用中非常有用,尤其是在识别异常值和离群值时。

去噪自动编码器是对之前实现的小幅修改。如前所述,基本自动编码器使用相同的图像作为输入和输出进行训练。去噪自动编码器使用各种强度的噪声来损坏输入,同时保持相同的无噪声目标。这可以通过简单地向输入添加一些高斯噪声来实现:

noise_factor = 0.1
x_train_noisy = x_train + noise_factor * np.random.normal(loc=0.0, scale=1.0, size=x_train.shape) 
x_test_noisy = x_test + noise_factor * np.random.normal(loc=0.0, scale=1.0, size=x_test.shape) 
x_train_noisy = np.clip(x_train_noisy, 0., 1.)
x_test_noisy = np.clip(x_test_noisy, 0., 1.)

网络可以使用损坏的输入进行训练,而对于输出,则使用无噪声图像:

noisy_autoencoder.fit(x_train_noisy, x_train,
                epochs=50,
                batch_size=128,
                shuffle=True,
                validation_data=(x_test_noisy, x_test))

当数据集较大且过拟合噪声的风险相对较小时,这种方法通常是有效的。当数据集较小时,为了避免网络“学习”噪声(从而学习从静态噪声图像到其无噪声版本的映射),可以通过添加一个 GaussianNoise 层来使用训练随机噪声作为替代。

注意,这样,噪声可能在各个时期之间变化,从而防止网络学习叠加到我们的训练集上的静态损坏。为了做到这一点,我们以下列方式更改我们网络的顶层:

input_img = Input(shape=(28, 28, 1))
noisy_input = GaussianNoise(0.1)(input_img)
x = Conv2D(16, (3, 3), activation='relu', padding='same')(noisy_input)

差别在于,不是静态损坏的样本(随时间不变),噪声输入现在在各个时期之间不断变化,从而避免网络学习噪声。

GaussianNoise 层是正则化层的一个例子,即一个通过在网络中插入随机部分来帮助减少神经网络过拟合的层。GaussianNoise 层使模型更稳健,能够更好地泛化,避免自动编码器学习恒等函数。

另一个常见的正则化层示例是 dropout 层,它有效地将某些输入(以概率随机)设置为 0,并通过一个 因子重新缩放其他输入,以(统计上)保持所有单元的总和恒定,无论是带有还是不带 dropout。

Dropout 对应于随机杀死层之间的一些连接,以减少输出对特定神经元的依赖。你需要记住,正则化层仅在训练时活跃,而在测试时它们仅仅对应于恒等层。

图 3.19中,我们比较了先前未正则化训练的网络和具有GaussianNoise层的网络的噪声输入(输入)的网络重建。如图所示(例如,比较裤子图像),具有正则化的模型倾向于发展更强的鲁棒性,并重建无噪声的输出:

图 3.19 – 与噪声样本重建的比较。第一行:噪声输入;第二行:使用普通自编码器重建的输出;第三行:使用降噪自编码器重建的输出

图 3.19 – 与噪声样本重建的比较。第一行:噪声输入;第二行:使用普通自编码器重建的输出;第三行:使用降噪自编码器重建的输出

当处理容易过拟合的深度神经网络,并且能够为自编码器学习身份函数时,通常会使用正则化层。通常,会引入 dropout 或GaussianNoise层,重复一个由正则化和可学习层组成的类似模式,我们通常将其称为堆叠降噪层

图自编码器

一旦理解了自编码器的基本概念,我们现在可以转向将这个框架应用于图结构。一方面,网络结构,分解为一个编码器-解码器结构,其中间有一个低维表示,仍然适用,但在处理网络时,需要优化损失函数的定义时需要小心。首先,我们需要将重建误差适应到一个有意义的公式,使其能够适应图结构的特殊性。但要做到这一点,让我们首先介绍一阶和更高阶邻近度的概念。

当将自编码器应用于图结构时,网络的输入和输出应该是一个图表示,例如邻接矩阵。然后,重建损失可以定义为输入和输出矩阵之间的 Frobenius 范数的差。然而,当将自编码器应用于此类图结构和邻接矩阵时,会出现两个关键问题:

  • 虽然链接的存在表明两个顶点之间存在关系或相似性,但它们的缺失通常并不表明顶点之间存在不相似性。

  • 邻接矩阵非常稀疏,因此模型自然会倾向于预测 0 而不是正值。

为了解决图结构的这种特殊性,在定义重建损失时,我们需要对非零元素所做的错误进行惩罚,而不是对零元素进行惩罚。这可以通过以下损失函数来实现:

在这里,哈达玛逐元素乘积是哈达玛逐元素乘积,其中存在边如果节点节点 1节点 2之间存在边,否则为 0。前面的损失保证了共享邻域(即它们的邻接向量相似)的顶点在嵌入空间中也会很接近。因此,前面的公式将自然地保留重建图的二阶邻近性。

另一方面,你还可以在重建图中促进第一阶邻近性,从而强制连接的节点在嵌入空间中靠近。可以通过以下损失来实现这一条件:

损失函数

在这里,节点表示 1节点表示 2是嵌入空间中节点 1节点 2的两个表示。这个损失函数强制相邻节点在嵌入空间中靠近。实际上,如果两个节点紧密连接,连接强度将很大。因此,它们在嵌入空间中的差异差异值,应该受到限制(表示两个节点在嵌入空间中靠近),以保持损失函数较小。这两个损失也可以组合成一个单一的损失函数,其中,为了防止过拟合,可以添加一个正比于权重系数范数的正则化损失:

图片

在前面的方程中,W代表网络中使用的所有权重。前面的公式由 Wang 等人于 2016 年提出,现在被称为结构深度网络嵌入SDNE)。

尽管前面的损失也可以直接使用 TensorFlow 和 Keras 实现,但你已经可以在我们之前提到的 GEM 包中找到这个网络集成。和之前一样,提取节点嵌入可以通过几行代码完成,如下所示:

G=nx.karate_club_graph()
sdne=SDNE(d=2, beta=5, alpha=1e-5, nu1=1e-6, nu2=1e-6,
          K=3,n_units=[50, 15,], rho=0.3, n_iter=10, 
          xeta=0.01,n_batch=100,
          modelfile=['enc_model.json','dec_model.json'],
          weightfile=['enc_weights.hdf5','dec_weights.hdf5'])
sdne.learn_embedding(G)
embeddings = m1.get_embedding()

尽管非常强大,但这些图自动编码器在处理大型图时遇到一些问题。对于这些情况,我们的自动编码器的输入是邻接矩阵的一行,其元素数量与网络中的节点数量相同。在大网络中,这个大小可以很容易地达到数百万或数千万。

在下一节中,我们描述了一种不同的编码网络信息策略,在某些情况下,它可能仅迭代地聚合局部邻域的嵌入,使其可扩展到大型图。

图神经网络

GNNs是针对图结构数据工作的深度学习方法。这个方法族也被称为几何深度学习,在包括社交网络分析和计算机图形学在内的各种应用中越来越受到关注。

根据在第二章中定义的分类法,图机器学习,编码器部分将图结构和节点特征作为输入。这些算法可以带监督或不带监督地进行训练。在本章中,我们将关注无监督训练,而监督设置将在第四章监督图学习中探讨。

如果你熟悉卷积神经网络CNN)的概念,你可能已经知道,当处理规则欧几里得空间(如文本(一维)、图像(二维)和视频(三维))时,它们能够取得令人印象深刻的成果。一个经典的 CNN 由一系列层组成,每一层提取多尺度局部化的空间特征。这些特征被深层层利用,以构建更复杂和高度表达性的表示。

近年来,观察到多层和局部性等概念也适用于处理图结构数据。然而,图是在一个非欧几里得空间上定义的,正如图 3.20中所述,找到一个适用于图的 CNN 泛化并不简单:

图 3.20 – 欧几里得和非欧几里得邻域之间的视觉差异

图 3.20 – 欧几里得和非欧几里得邻域之间的视觉差异

GNN 的原始公式由 Scarselli 等人于 2009 年提出。它依赖于每个节点可以通过其特征和其邻域来描述的事实。来自邻域的信息(在图域中代表局部性概念)可以被聚合并用于计算更复杂和高级的特征。让我们更详细地了解它是如何实现的。

在开始时,每个节点,,都与一个状态相关联。让我们从一个随机的嵌入开始,(为了简单起见,忽略节点属性)。在算法的每次迭代中,节点通过一个简单的神经网络层从其邻居那里积累输入:

在这里,是可训练的参数(其中d是嵌入的维度),是一个非线性函数,t代表算法的第t次迭代。该方程递归应用,直到达到特定的目标。请注意,在每次迭代中,前一个状态(前一次迭代计算的状态)被利用,以便通过循环神经网络计算新的状态是否发生。

GNN 的变体

从这个最初的想法出发,近年来已经尝试了多种方法来重新审视从图数据中学习的问题。特别是,提出了之前描述的 GNN 的变体,目的是提高其表示学习能力。其中一些是专门设计来处理特定类型的图(直接、间接、加权、无权、静态、动态等)。

此外,还提出了几种对传播步骤(卷积、门机制、注意力机制和跳跃连接等)的修改,目的是在不同层次上提高表示能力。同时,还提出了不同的训练方法来提高学习效果。

在处理无监督表示学习时,最常见的方法之一是使用编码器将图嵌入(编码器被表述为 GNN 的一种变体)然后使用一个简单的解码器来重建邻接矩阵。损失函数通常被表述为原始邻接矩阵与重建矩阵之间的相似度。形式上,它可以定义为以下:

图图

在这里,图是邻接矩阵表示,节点属性矩阵是节点属性矩阵。这种方法的一种常见变体,尤其是在处理图分类/表示学习时,是对目标距离进行训练。其思路是同时嵌入两对图以获得一个组合表示。然后,模型被训练以使这种表示与距离相匹配。在处理节点分类/表示学习时,也可以采用类似的策略,通过使用节点相似度函数。

基于图卷积神经网络GCN)的编码器是用于无监督学习中最广泛使用的 GNN 变体之一。GCN 是受到许多 CNN 基本思想启发的 GNN 模型。滤波参数通常在图的所有位置共享,并且通过连接多层形成一个深度网络。

对于图数据,本质上存在两种类型的卷积操作,即光谱方法非光谱空间)方法。第一种,正如其名所示,在频谱域中定义卷积(即将图分解为更简单的元素组合)。空间卷积将卷积表述为从邻居聚合特征信息。

光谱图卷积

频谱方法与频谱图论相关,频谱图论是研究图的特征与关联矩阵的特征多项式、特征值和特征向量之间的关系。卷积操作定义为信号(节点特征)与核的乘积。更详细地说,它在傅里叶域中通过确定图的拉普拉斯算子的特征分解(将图拉普拉斯算子视为以特殊方式归一化的邻接矩阵)来定义。

虽然频谱卷积的定义有很强的数学基础,但该操作在计算上很昂贵。因此,已经进行了许多工作来以有效的方式近似它。例如,Defferrard 等人提出的 ChebNet 是频谱图卷积的最早开创性工作之一。在这里,操作通过使用 K 阶切比雪夫多项式的概念来近似(一种用于有效近似函数的特殊多项式)。

在这里,K 是一个非常有用的参数,因为它决定了滤波器的局部性。直观地说,对于 K=1,只有节点特征被输入到网络中。对于 K=2,我们平均两个跳邻居(邻居的邻居)等等。

为节点特征矩阵。在经典神经网络处理中,这个信号将由以下形式的层组成:

图片

在这里, 是层权重, 代表某种非线性激活函数。这个操作的缺点是它独立处理每个节点信号,而没有考虑到节点之间的连接。为了克服这个局限性,可以进行以下简单(但有效)的修改:

图片

通过引入邻接矩阵 ,在每个节点及其对应邻居之间添加了一个新的线性组合。这样,信息只依赖于邻域,参数同时应用于所有节点。

值得注意的是,这个操作可以连续重复多次,从而创建一个深度网络。在每一层,节点描述符 X 将被替换为前一层的输出,

然而,前面提出的方程有一些局限性,不能直接应用。第一个局限性是,通过乘以 A,我们考虑了节点的所有邻居,但没有考虑节点本身。这个问题可以通过在图中添加自环(即添加 单位矩阵)来轻松克服。

第二个限制与邻接矩阵本身有关。由于它通常没有归一化,我们将在高度节点的特征表示中观察到较大的值,在低度节点的特征表示中观察到较小的值。这将在训练期间导致几个问题,因为优化算法通常对特征尺度敏感。已经提出了几种用于归一化 A 的方法。

例如,在 Kipf 和 Welling,2017 年(一个著名的 GCN 模型)中,归一化是通过将 A 乘以 对角节点度矩阵 D 来实现的,使得所有行的和为 1:img/B16069_03_120.png。更具体地说,他们使用了对称归一化 img/B16069_03_121.png,使得提出的传播规则如下:

img/B16069_03_122.jpg

这里,img/B16069_03_123.pngimg/B16069_03_124.png 的对角节点度矩阵。

在下面的例子中,我们将创建一个如 Kipf 和 Welling 所定义的 GCN,并将应用此传播规则来嵌入一个著名的网络:Zachary 的空手道俱乐部图:

  1. 首先,有必要导入所有 Python 模块。我们将使用 networkx 来加载 barbell 图

    import networkx as nx
    import numpy as np
    G = nx.barbell_graph(m1=10,m2=4)
    
  2. 要实现 GC 传播规则,我们需要一个表示 G 的邻接矩阵。由于这个网络没有节点特征,我们将使用 img/B16069_03_125.png 单位矩阵作为节点描述符:

    A = nx.to_numpy_matrix(G)
     I = np.eye(G.number_of_nodes())
    
  3. 我们现在添加自环并准备对角节点度矩阵:

    from scipy.linalg import sqrtm
    A_hat = A + I
    D_hat = np.array(np.sum(A_hat, axis=0))[0]
     D_hat = np.array(np.diag(D_hat))
     D_hat = np.linalg.inv(sqrtm(D_hat))
     A_norm = D_hat @ A_hat @ D_hat
    
  4. 我们的 GCN 将由两层组成。让我们定义层的权重和传播规则。层权重,W,将使用 Glorot 均匀初始化 来初始化(即使也可以使用其他初始化方法,例如,从高斯或均匀分布中采样):

    def glorot_init(nin, nout):
         sd = np.sqrt(6.0 / (nin + nout))
         return np.random.uniform(-sd, sd, size=(nin, nout))
    class GCNLayer():
      def __init__(self, n_inputs, n_outputs):
          self.n_inputs = n_inputs
          self.n_outputs = n_outputs
          self.W = glorot_init(self.n_outputs, self.n_inputs)
          self.activation = np.tanh
      def forward(self, A, X):
          self._X = (A @ X).T
          H = self.W @ self._X 
          H = self.activation(H)
          return H.T # (n_outputs, N)
    
  5. 最后,让我们创建我们的网络并计算前向传递,即通过网络传播信号:

    gcn1 = GCNLayer(G.number_of_nodes(), 8)
     gcn2 = GCNLayer(8, 4)
     gcn3 = GCNLayer(4, 2)
    H1 = gcn1.forward(A_norm, I)
     H2 = gcn2.forward(A_norm, H1)
    H3 = gcn3.forward(A_norm, H2)
    

H3 现在包含了使用 GCN 传播规则计算出的嵌入。注意,我们选择了 2 作为输出数量,这意味着嵌入是二维的,可以很容易地可视化。在 图 3.21 中,你可以看到输出:

Figure 3.21 – 应用图卷积层到图(左)以生成其节点的嵌入向量(右)

图 3.21 – 应用图卷积层到图(左)以生成其节点的嵌入向量(右)

你可以观察到存在两个相当分离的社区。考虑到我们还没有训练网络,这是一个很好的结果!

谱图卷积方法在许多领域都取得了显著成果。然而,它们也有一些缺点。例如,考虑一个非常大的图,包含数十亿个节点:谱方法需要同时处理整个图,这在计算上可能是不切实际的。

此外,频谱卷积通常假设一个固定的图,导致在新、不同的图上泛化能力较差。为了克服这些问题,空间图卷积代表了一个有趣的替代方案。

空间图卷积

空间图卷积网络通过直接在图上操作,并聚合空间上接近的邻居的信息来执行操作。空间卷积有许多优点:权重可以轻松地在图的不同位置共享,从而在不同图上具有良好的泛化能力。此外,可以通过考虑节点子集而不是整个图来进行计算,这可能会提高计算效率。

GraphSAGE 是实现空间卷积的算法之一。其主要特点之一是它能够扩展到各种类型的网络。我们可以将 GraphSAGE 视为由三个步骤组成:

  1. 邻域采样:对于图中的每个节点,第一步是找到其 k-邻域,其中k由用户定义,以确定要考虑多少跳(邻居的邻居)。

  2. 聚合:第二步是为每个节点聚合描述相应邻域的节点特征。可以执行各种类型的聚合,包括平均、池化(例如,根据某些标准选择最佳特征)或更复杂的操作,例如使用循环单元(如 LSTM)。

  3. 预测:每个节点都配备了一个简单的神经网络,该网络学习如何根据邻居的聚合特征进行预测。

GraphSAGE 通常用于监督学习场景,正如我们将在第四章“监督图学习”中看到的。然而,通过采用诸如使用相似度函数作为目标距离等策略,它也可以在没有明确监督任务的情况下进行嵌入学习。

实际中的图卷积

在实践中,GNNs 已经在许多机器学习和深度学习框架中得到实现,包括 TensorFlow、Keras 和 PyTorch。在下一个例子中,我们将使用 StellarGraph,这是一个用于图上机器学习的 Python 库。

在以下示例中,我们将以无监督的方式了解嵌入向量,没有目标变量。该方法受 Bai 等人 2019 年的启发,并基于图对的同步嵌入。这种嵌入应匹配图之间的真实距离:

  1. 首先,让我们加载所需的 Python 模块:

    import numpy as np
    import stellargraph as sg
    from stellargraph.mapper import FullBatchNodeGenerator
    from stellargraph.layer import GCN
    import tensorflow as tf
    from tensorflow.keras import layers, optimizers, losses, metrics, Model
    
  2. 我们将使用PROTEINS数据集进行此示例,该数据集在 StellarGraph 中可用,包含 1,114 个图,每个图平均有 39 个节点和 73 条边。每个节点由四个属性描述,并属于两个类别之一:

    dataset = sg.datasets.PROTEINS()
    graphs, graph_labels = dataset.load()
    
  3. 下一步是创建模型。它将由两个具有 64 和 32 个输出维度的 GC 层组成,随后是 ReLU 激活,分别。输出将被计算为两个嵌入之间的欧几里得距离:

    generator = sg.mapper.PaddedGraphGenerator(graphs)
    
    # define a GCN model containing 2 layers of size 64 and 32, respectively. 
    # ReLU activation function is used to add non-linearity between layers
    gc_model = sg.layer.GCNSupervisedGraphClassification(
     [64, 32], ["relu", "relu"], generator, pool_all_layers=True)
    # retrieve the input and the output tensor of the GC layer such that they can be connected to the next layer
    inp1, out1 = gc_model.in_out_tensors()
    inp2, out2 = gc_model.in_out_tensors()
    vec_distance = tf.norm(out1 - out2, axis=1)
    
    # create the model. It is also useful to create a specular model in order to easily retrieve the embeddings
    pair_model = Model(inp1 + inp2, vec_distance)
     embedding_model = Model(inp1, out1)
    
  4. 现在,是时候准备用于训练的数据集了。对于每一对输入图,我们将分配一个相似度分数。请注意,在这种情况下可以使用任何图相似度的概念,包括图编辑距离。为了简单起见,我们将使用图的拉普拉斯谱之间的距离:

    def graph_distance(graph1, graph2):
       spec1 = nx.laplacian_spectrum(graph1.to_networkx(feature_attr=None))
       spec2 = nx.laplacian_spectrum(graph2.to_networkx(feature_attr=None))
       k = min(len(spec1), len(spec2))
       return np.linalg.norm(spec1[:k] - spec2[:k])
    graph_idx = np.random.RandomState(0).randint(len(graphs), size=(100, 2))
    targets = [graph_distance(graphs[left], graphs[right]) for left, right in graph_idx]
    train_gen = generator.flow(graph_idx, batch_size=10, targets=targets)
    
  5. 最后,让我们编译和训练模型。我们将使用自适应矩估计优化器(Adam),学习率参数设置为1e-2。我们将使用的损失函数定义为预测与先前计算的真实距离之间的最小平方误差。模型将训练 500 个周期:

    pair_model.compile(optimizers.Adam(1e-2), loss="mse")
    pair_model.fit(train_gen, epochs=500, verbose=0)
    
  6. 训练完成后,我们现在可以检查和可视化学习到的表示。由于输出是 32 维的,我们需要一种方法来定性评估嵌入,例如,通过在二维空间中绘制它们。我们将为此目的使用 T-SNE:

    # retrieve the embeddings
    embeddings = embedding_model.predict(generator.flow(graphs))
    # TSNE is used for dimensionality reduction
    from sklearn.manifold import TSNE
    tsne = TSNE(2)
     two_d = tsne.fit_transform(embeddings)
    

让我们绘制嵌入。在图中,每个点(嵌入图)的颜色根据相应的标签(蓝色=0,红色=1)进行着色。结果在图 3.22中可见:

![图 3.22 – 使用 GCNs 的 PROTEINS 数据集嵌入]

img/B16069_03_22.jpg

图 3.22 – 使用 GCNs 的 PROTEINS 数据集嵌入

这只是学习图嵌入的可能方法之一。可以尝试更高级的解决方案,以更好地适应感兴趣的问题。

摘要

在本章中,我们学习了如何将无监督机器学习有效地应用于图来解决实际问题,例如节点和图表示学习。

尤其是首先分析了浅层嵌入方法,这是一组能够学习并仅返回学习输入数据的嵌入值的算法。

然后,我们学习了如何使用自动编码器算法通过在低维空间中保留重要信息来编码输入。我们还看到了如何通过学习允许我们重建成对节点/图相似度的嵌入来将这个想法应用于图。

最后,我们介绍了 GNN 背后的主要概念。我们看到了如何将诸如卷积等众所周知的概念应用于图。

在下一章中,我们将以监督设置修订这些概念。在那里,将提供一个目标标签,目标是学习输入和输出之间的映射。

第四章:监督图学习

监督学习(Supervised learning)(SL)很可能是大多数实际机器学习(ML)任务的代表。多亏了越来越活跃和有效的数据收集活动,如今处理带标签的数据集是非常常见的。

这也适用于图数据,其中标签可以分配给节点、社区,甚至整个结构。那么,任务就是学习一个从输入到标签的映射函数(也称为目标或注释)。

例如,给定一个表示社交网络的图,我们可能会被要求猜测哪个用户(节点)会关闭他们的账户。我们可以通过在历史数据上训练图机器学习来学习这个预测函数,其中每个用户根据他们在几个月后是否关闭账户被标记为“忠诚”或“退出”。

在本章中,我们将探讨监督学习(SL)的概念以及它如何在图上应用。因此,我们还将提供主要监督图嵌入方法的概述。以下主题将涵盖:

  • 监督图嵌入路线图

  • 基于特征的方法

  • 浅层嵌入方法

  • 图正则化方法

  • 卷积神经网络(CNNs

技术要求

我们将使用带有 Python 3.8 的 Jupyter 笔记本来进行所有练习。在下面的代码块中,你可以看到使用 pip 安装本章所需库的列表(例如,在命令行中运行 pip install networkx==2.5):

Jupyter==1.0.0
networkx==2.5
matplotlib==3.2.2
node2vec==0.3.3
karateclub==1.0.19
scikit-learn==0.24.0
pandas==1.1.3
numpy==1.19.2
tensorflow==2.4.1
neural-structured-learning==1.3.1
stellargraph==1.2.1

在本书的其余部分,除非明确说明,否则我们将把 nx 作为 import networkx as nx Python 命令的结果来引用。

本章相关的所有代码文件均可在github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter04找到。

监督图嵌入路线图

在监督学习(SL)中,训练集由一系列有序对 (x, y) 组成,其中 x 是一组输入特征(通常是定义在图上的信号),而 y 是分配给它的输出标签。因此,机器学习模型的目标是学习将每个 x 值映射到每个 y 值的函数。常见的监督任务包括预测大型社交网络中的用户属性或预测分子的属性,其中每个分子都是一个图。

有时,然而,并非所有实例都能提供标签。在这种情况下,一个典型的数据集由一小部分带标签的实例和一大部分未带标签的实例组成。对于这种情况,提出了半监督学习(semi-SL)(也称为SSL),其中算法旨在利用可用标签信息反映的标签依赖信息,以便学习对未标记样本的预测函数。

关于监督图机器学习技术,已经开发了许多算法。然而,正如不同科学论文之前所报告的(arxiv.org/abs/2005.03675),它们可以被分组为如基于特征的方法浅层嵌入方法正则化方法图神经网络GNNs)等宏观组,如下面的图中所示:

图 4.1 – 本书中描述的不同监督嵌入算法的层次结构

图 4.1 – 本书中描述的不同监督嵌入算法的层次结构

在接下来的章节中,你将学习到每组算法背后的主要原理。我们还将尝试对领域内最著名的算法提供洞察,因为这些算法可以用来解决现实世界的问题。

基于特征的方法

在图上应用机器学习的一个非常简单(但强大)的方法是将编码函数视为一个简单的嵌入查找。在处理监督任务时,实现这一点的简单方法之一是利用图属性。在第一章《开始使用图》中,我们学习了如何通过结构属性来描述图(或图中的节点),每个“编码”都从图中本身提取了重要信息。

让我们暂时忘记图上的机器学习:在经典监督机器学习中,任务是找到一个函数,将实例的(描述性)特征集合映射到特定的输出。这些特征应该精心设计,以便它们足够具有代表性,可以学习该概念。因此,当花瓣数量和萼片长度可能是一个花的良好描述符时,在描述图时,我们可能依赖于其平均度、全局效率和其特征路径长度。

这种浅层方法分为两个步骤,具体如下:

  1. 选择一组良好的描述性图属性。

  2. 使用这些属性作为传统机器学习算法的输入。

不幸的是,没有关于良好描述性属性的通用定义,它们的选择严格依赖于要解决的问题的具体性。然而,你仍然可以计算大量的图属性,然后进行特征选择来选择最有信息量的那些。特征选择是机器学习中的一个广泛研究的话题,但提供关于各种方法的详细信息超出了本书的范围。不过,我们建议你参考 Packt Publishing 出版的《机器学习算法——第二版》(subscription.packtpub.com/book/big_data_and_business_intelligence/9781789347999),以进一步了解这个主题。

现在让我们看看如何将这样一个基本方法应用到实际例子中。我们将通过使用PROTEINS数据集来执行一个监督图分类任务。PROTEINS数据集包含表示蛋白质结构的几个图。每个图都有标签,定义蛋白质是否是酶。我们将遵循以下步骤:

  1. 首先,让我们通过stellargraph Python 库加载数据集,如下所示:

    from stellargraph import datasets
    from IPython.display import display, HTML
    dataset = datasets.PROTEINS()
    graphs, graph_labels = dataset.load()
    
  2. 为了计算图属性,我们将使用networkx,如第一章中所述,开始使用图。为此,我们需要将图从stellargraph格式转换为networkx格式。这可以通过两个步骤完成:首先,将图从stellargraph表示转换为numpy邻接矩阵。然后,使用邻接矩阵检索networkx表示。此外,我们还将标签(存储为pandas Series)转换为numpy数组,这样评价函数可以更好地利用它,正如我们将在下一步中看到的。代码如下所示:

    # convert from StellarGraph format to numpy adj matrices
    adjs = [graph.to_adjacency_matrix().A for graph in graphs]
    # convert labels from Pandas.Series to numpy array
    labels = graph_labels.to_numpy(dtype=int)
    
  3. 然后,对于每个图,我们计算全局指标来描述它。在这个例子中,我们选择了边的数量、平均聚类系数和全局效率。然而,我们建议您计算其他您可能认为值得探索的属性。我们可以使用networkx提取图指标,如下所示:

    import numpy as np
    import networkx as nx
    metrics = []
    for adj in adjs:
      G = nx.from_numpy_matrix(adj)
      # basic properties
      num_edges = G.number_of_edges()
      # clustering measures
      cc = nx.average_clustering(G)
      # measure of efficiency
      eff = nx.global_efficiency(G)
      metrics.append([num_edges, cc, eff]) 
    
  4. 现在,我们可以利用scikit-learn工具来创建训练集和测试集。在我们的实验中,我们将使用 70%的数据集作为训练集,其余的作为测试集。我们可以通过使用scikit-learn提供的train_test_split函数来实现这一点,如下所示:

    from sklearn.model_selection import train_test_split
    X_train, X_test, y_train, y_test = train_test_split(metrics, labels, test_size=0.3, random_state=42)
    
  5. 现在是时候训练一个合适的机器学习算法了。我们选择了scikit-learnSVC模块,如下所示:

    from sklearn import svm
    from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
    clf = svm.SVC()
    clf.fit(X_train, y_train)
     y_pred = clf.predict(X_test)
    print('Accuracy', accuracy_score(y_test,y_pred))
     print('Precision', precision_score(y_test,y_pred))
     print('Recall', recall_score(y_test,y_pred))
     print('F1-score', f1_score(y_test,y_pred))
    

    这应该是之前代码片段的输出:

    Accuracy 0.7455
    Precision 0.7709
    Recall 0.8413
    F1-score 0.8045
    

我们使用AccuracyPrecisionRecallF1-score来评估算法在测试集上的表现效果。我们达到了大约 80%的 F1 分数,对于这样一个简单任务来说已经相当不错了。

浅层嵌入方法

正如我们在第三章中描述的,无监督图学习,浅层嵌入方法是图嵌入方法的一个子集,它只为有限的数据集学习节点、边或图表示。它们不能应用于与训练模型所用的实例不同的其他实例。在我们开始讨论之前,定义监督和无监督浅层嵌入算法之间的区别是很重要的。

无监督嵌入方法和监督嵌入方法之间的主要区别本质上在于它们试图解决的任务。确实,如果无监督浅层嵌入算法试图学习一个好的图、节点或边表示以构建定义良好的聚类,那么监督算法则试图找到预测任务(如节点、标签或图分类)的最佳解决方案。

在本节中,我们将详细解释一些这些监督浅层嵌入算法。此外,我们将通过提供如何在 Python 中使用这些算法的几个示例来丰富我们的描述。在本节中描述的所有算法,我们将使用 scikit-learn 库中可用的基类提供一个自定义实现。

标签传播算法

标签传播算法是一种广为人知的半监督算法,在数据科学中得到广泛应用,用于解决节点分类任务。更确切地说,该算法会将给定节点的标签传播到其邻居或具有从该节点到达的高概率的节点。

这种方法背后的基本思想相当简单:给定一个具有一组标记和未标记节点的图,标记节点会将它们的标签传播到具有最高到达概率的节点。在下面的图中,我们可以看到一个具有标记和未标记节点的图的示例:

图 4.2 – 具有两个标记节点(红色表示类别 0,绿色表示类别 1)和六个未标记节点的图示例

图 4.2 – 具有两个标记节点(红色表示类别 0,绿色表示类别 1)和六个未标记节点的图示例

根据 图 4.2,使用标记节点的信息(节点 06),算法将计算移动到另一个未标记节点的概率。从标记节点具有最高概率的节点将获得该节点的标签。

形式上,设 为一个图,设 为一组标签。由于该算法是半监督的,只有一部分节点会被分配标签。此外,设 为输入图 G 的邻接矩阵,设 为对角度矩阵,其中每个元素 定义如下:

换句话说,度矩阵中唯一的非零元素是对角元素,其值由表示该行的节点的度数给出。在下面的图中,我们可以看到 图 4.2 中表示的图的对角度矩阵:

图 4.3 – 图 4.2 中图的对角度矩阵

图 4.3 – 图 4.2 中图的对角度矩阵

图 4.3中,我们可以看到矩阵的对角线元素包含非零值,这些值代表特定节点的度数。我们还需要引入转移矩阵。这个矩阵定义了从另一个节点到达节点的概率。更确切地说,是从节点到达节点的概率。以下图显示了图 4.2中描述的图的转移矩阵

![图 4.4 – 图 4.2 中图的转移矩阵图片

图 4.4 – 图 4.2 中图的转移矩阵

图 4.4中,矩阵显示了给定起始节点达到终止节点的概率。例如,从矩阵的第一行,我们可以看到从节点 0 出发,以 0.5 的概率仅能到达节点 1 和 2。如果我们用定义初始标签分配,使用矩阵获得的每个节点的标签分配概率可以计算为图 4.2中图的矩阵在以下图中显示:

![图 4.5 – 使用图 4.2 中图的矩阵得到的解图片

图 4.5 – 使用图 4.2 中图的矩阵得到的解

图 4.5中,我们可以看到,使用转移矩阵,节点 1 和节点 2 被分配到标签的概率分别为 0.5 和 0.33,而节点 5 和节点 6 被分配到标签的概率分别为 0.33 和 0.5。

此外,如果我们更好地分析图 4.5,我们可以看到两个主要问题,如下所示:

  • 使用这个解决方案,可以将概率仅分配给节点[1 2]和[5 7]与一个标签相关联。

  • 节点 0 和 6 的初始标签与中定义的不同。

为了解决第一个问题,算法将执行次不同的迭代;在每次迭代中,算法将计算该迭代的解,如下所示:

图片

算法在满足一定条件时停止迭代。第二个问题通过在给定迭代的解中强制标签节点具有初始类值来解决。例如,在计算图 4.5中可见的结果后,算法将强制结果矩阵的第一行是,矩阵的第七行是

在这里,我们提出了scikit-learn库中LabelPropagation类的修改版。选择这个方案的主要原因是LabelPropagation类接受一个表示数据集的矩阵作为输入。矩阵的每一行代表一个样本,每一列代表一个特征。

在执行fit操作之前,LabelPropagation类内部执行_build_graph函数。此函数将使用参数化核(_get_kernel函数)构建一个描述输入数据集的图。因此,原始数据集被转换为一个图(其邻接矩阵表示),其中每个节点是一个样本(输入数据集的行),每条边是样本之间的交互

在我们的特定情况下,输入数据集已经是一个图,因此我们需要定义一个新的类,能够处理networkx图并在原始图上执行计算操作。通过创建一个新的类——即GraphLabelPropagation,通过扩展ClassifierMixinBaseEstimatorABCMeta基类来实现这一目标。这里提出的算法主要用于帮助您理解算法背后的概念。整个算法在本书 GitHub 仓库的04_supervised_graph_machine_learning/02_Shallow_embeddings.ipynb笔记本中提供。为了描述算法,我们将仅使用fit(X,y)函数作为参考。代码如下所示:

class GraphLabelPropagation(ClassifierMixin, BaseEstimator, metaclass=ABCMeta):

     def fit(self, X, y):
        X, y = self._validate_data(X, y)
        self.X_ = X
        check_classification_targets(y)
        D = [X.degree(n) for n in X.nodes()]
        D = np.diag(D)
        # label construction
        # construct a categorical distribution for classification only
       unlabeled_index = np.where(y==-1)[0]
       labeled_index = np.where(y!=-1)[0]
       unique_classes = np.unique(y[labeled_index])
       self.classes_ = unique_classes
       Y0 = np.array([self.build_label(y[x], len(unique_classes)) if x in labeled_index else np.zeros(len(unique_classes)) for x in range(len(y))])

       A = inv(D)*nx.to_numpy_matrix(G)
       Y_prev = Y0
       it = 0
       c_tool = 10
       while it < self.max_iter & c_tool > self.tol:
           Y = A*Y_prev
           #force labeled nodes
           Y[labeled_index] = Y0[labeled_index]
           it +=1
           c_tol = np.sum(np.abs(Y-Y_prev))
           Y_prev = Y
       self.label_distributions_ = Y
       return self

fit(X,y)函数接受一个networkximg/B16069__04_025.png和一个表示分配给每个节点的标签的数组img/B16069__04_026.png。没有标签的节点应有一个代表值-1。while循环执行实际计算。更确切地说,它在每次迭代中计算img/B16069__04_027.png的值,并强制将标记节点在解中的值等于其原始输入值。算法执行计算直到满足两个停止条件。在此实现中,使用了以下两个标准:

  • 迭代次数:算法运行计算直到完成指定的迭代次数。

  • 解的容差误差:算法运行计算直到连续两次迭代获得的解的绝对差值,img/B16069__04_028.pngimg/B16069__04_029.png,低于给定的阈值值。

该算法可以使用以下代码应用于图 4.2所示的示例图:

glp = GraphLabelPropagation()
y = np.array([-1 for x in range(len(G.nodes()))])
y[0] = 0
y[6] = 1
glp.fit(G,y)
 glp.predict_proba(G)

算法得到的结果如下所示:

图 4.6 – 标签传播算法在图 4.2 上的结果:左侧为最终标记的图;右侧为最终概率分配矩阵

图 4.6 – 标签传播算法在图 4.2 上的结果:左侧为最终标记图;右侧为最终概率分配矩阵

图 4.6 中,我们可以看到算法应用于 图 4.2 中示例的结果。从最终的概率分配矩阵中,我们可以看到由于算法的约束,初始标记节点的概率为 1,以及“靠近”标记节点的节点如何获得它们的标签。

标签传播算法

标签传播算法是另一种半监督浅嵌入算法。它是为了克服标签传播方法的一个大限制:初始标记。确实,根据标签传播算法,初始标签在训练过程中不能修改,并且在每次迭代中,它们被迫等于其原始值。这种约束可能会在初始标记受到错误或噪声影响时产生错误的结果。因此,错误将传播到输入图的所有节点。

为了解决这一限制,标签传播算法试图放松原始标记数据的约束,允许标记输入节点在训练过程中改变其标签。

形式上,设 为一个图,设 为一组标签(由于算法是半监督的,只有一部分节点会被分配标签),设 分别为图 G 的邻接矩阵对角度矩阵。标签传播算法不是计算概率转移矩阵,而是使用以下定义的归一化图拉普拉斯矩阵:

与标签传播一样,这个矩阵可以看作是整个图中定义的连接的某种紧凑的低维表示。这个矩阵可以使用以下代码通过 networkx 容易地计算:

from scipy.linalg import fractional_matrix_power
D_inv = fractional_matrix_power(D, -0.5)
 L = D_inv*nx.to_numpy_matrix(G)*D_inv

结果如下:

图 4.7 – 归一化图拉普拉斯矩阵

图 4.7 – 归一化图拉普拉斯矩阵

标签传播算法与标签传播算法之间最重要的区别与用于提取标签的函数有关。如果我们用 定义初始标签分配,使用 矩阵获得的每个节点的标签分配概率可以按以下方式计算:

与标签传播一样,标签传播有一个迭代过程来计算最终解。算法将执行 次不同的迭代;在每次迭代 中,算法将计算该迭代的解,如下所示:

算法在满足一定条件时停止迭代。重要的是要强调方程中的术语img/B16069__04_041.png。实际上,正如我们所说,标签传播并不强制解的标记元素等于其原始值。相反,算法使用正则化参数img/B16069__04_042.png在每个迭代中对原始解的影响进行加权。这允许我们明确地强制原始解的“质量”及其在最终解中的影响。

与标签传播算法一样,在以下代码片段中,我们提出了由于我们在上一节中提到的动机而修改的LabelSpreading类,该类可在scikit-learn库中找到。我们通过扩展我们的GraphLabelPropagation类提出了GraphLabelSpreading类,因为唯一的区别将在于类的fit()方法。整个算法在本书 GitHub 仓库中的04_supervised_graph_machine_learning/02_Shallow_embeddings.ipynb笔记本中提供:

class GraphLabelSpreading(GraphLabelPropagation):
    def fit(self, X, y):
        X, y = self._validate_data(X, y)
        self.X_ = X
        check_classification_targets(y)
        D = [X.degree(n) for n in X.nodes()]
        D = np.diag(D)
        D_inv = np.matrix(fractional_matrix_power(D,-0.5))
        L = D_inv*nx.to_numpy_matrix(G)*D_inv
        # label construction
        # construct a categorical distribution for classification only
        labeled_index = np.where(y!=-1)[0]
        unique_classes = np.unique(y[labeled_index])
        self.classes_ = unique_classes
         Y0 = np.array([self.build_label(y[x], len(unique_classes)) if x in labeled_index else np.zeros(len(unique_classes)) for x in range(len(y))])

        Y_prev = Y0
        it = 0
        c_tool = 10
        while it < self.max_iter & c_tool > self.tol:
           Y = (self.alpha*(L*Y_prev))+((1-self.alpha)*Y0)
            it +=1
            c_tol = np.sum(np.abs(Y-Y_prev))
            Y_prev = Y
        self.label_distributions_ = Y
        return self

在这个类中,fit()函数是焦点。该函数接受一个networkximg/B16069__04_043.png和一个表示每个节点分配的标签的数组img/B16069__04_044.png。没有标签的节点应有一个代表值-1。while循环在每个迭代中计算img/B16069__04_045.png值,通过参数img/B16069__04_046.png加权初始标记的影响。此外,对于这个算法,迭代次数和连续两个解之间的差异被用作停止标准。

该算法可以使用图 4.2中描述的示例图,以下代码实现:

gls = GraphLabelSpreading()
y = np.array([-1 for x in range(len(G.nodes()))])
y[0] = 0
y[6] = 1
gls.fit(G,y)
 gls.predict_proba(G)

在以下图中,展示了算法得到的结果:

图 4.8 – 标签传播算法在图 4.2 中的结果:左侧为最终标记的图;右侧为最终概率分配矩阵

图 4.8 – 标签传播算法在图 4.2 中的结果:左侧为最终标记的图;右侧为最终概率分配矩阵

图 4.8中显示的结果看起来与使用标签传播算法获得的结果相似。主要区别与标签分配的概率有关。实际上,在这种情况下,我们可以看到节点 0 和 6(具有初始标签的节点)的概率为 0.5,这比使用标签传播算法获得的概率 1 显著低。这种行为是预期的,因为初始标签分配的影响是通过正则化参数img/B16069__04_047.png加权的。

在下一节中,我们将继续描述监督图嵌入方法。我们将描述基于网络的信息如何帮助正则化训练并创建更鲁棒的模型。

图正则化方法

在上一节中描述的浅层嵌入方法展示了如何将拓扑信息和数据点之间的关系编码并利用,以构建更鲁棒的分类器并解决半监督任务。一般来说,网络信息在约束模型和确保输出在相邻节点内平滑时可以非常有用。正如我们在前面的章节中已经看到的,这个想法可以有效地用于半监督任务,在传播邻居无标记节点的信息时。

另一方面,这也可以用来正则化学习阶段,以创建更鲁棒且倾向于更好地泛化到未见示例的模型。我们之前看到的标签传播和标签扩散算法可以作为成本函数实现,当添加一个额外的正则化项时进行最小化。通常,在监督任务中,我们可以将最小化的成本函数写成以下形式:

在这里, 分别代表标记和无标记的样本,第二个项作为一个正则化项,它依赖于图 的拓扑信息。

在本节中,我们将进一步描述这样一个想法,并看看它如何非常强大,尤其是在正则化神经网络训练时,如您所知,神经网络自然倾向于过拟合,并且/或者需要大量的数据才能有效地进行训练。

流形正则化和半监督嵌入

流形正则化(Belkin 等人,2006 年)通过在再生核希尔伯特空间RKHS)中对模型函数进行参数化,并使用均方误差MSE)或折损损失作为监督损失函数(前一个方程中的第一个项),扩展了标签传播框架。换句话说,当训练支持向量机或最小二乘拟合时,它们会基于拉普拉斯矩阵 L 应用图正则化项,如下所示:

因此,这些方法通常被标记为拉普拉斯正则化,这样的公式导致了拉普拉斯正则化最小二乘法LapRLS)和LapSVM分类。标签传播和标签扩散可以看作是流形正则化的一个特例。此外,这些算法也可以在没有标记数据的情况下使用(方程中的第一个项消失),这会简化为拉普拉斯特征映射

另一方面,它们也可以用于完全标记的数据集的情况,在这种情况下,前面的术语将约束训练阶段以正则化训练并实现更鲁棒的模型。此外,由于模型是在 RKHS 中参数化的分类器,因此它可以用于未观察到的样本,并且不需要测试样本属于输入图。从这个意义上说,它因此是一个归纳模型。

流形学习仍然代表一种浅层学习形式,其中参数化的函数不利用任何形式的中间嵌入。半监督嵌入(Weston 等人,2012 年)通过在神经网络的中间层上施加函数的约束和光滑性,将图正则化的概念扩展到更深的架构。让我们将 定义为第 k 个隐藏层的中间输出。半监督嵌入框架中提出的正则化项如下所示:

图片 B16069__04_054.jpg

根据正则化施加的位置,可以实现三种不同的配置(如图 4.9 所示),如下所示:

  • 正则化应用于网络的最终输出。这对应于将流形学习技术泛化到多层神经网络。

  • 正则化应用于网络的中间层,从而正则化嵌入表示。

  • 正则化应用于共享前 k-1 层的辅助网络。这基本上对应于在同时训练监督网络的同时训练无监督嵌入网络。这种技术基本上对受无监督网络约束的前 k-1 层施加了派生的正则化,并同时促进了网络节点的嵌入。

以下图表展示了使用半监督嵌入框架可以实现的三个不同配置的示意图——它们的相似之处和不同之处:

图 4.9 – 半监督嵌入正则化配置:用交叉表示的图正则化可以应用于输出(左)、中间层(中)或辅助网络(右)

图 4.9 – 半监督嵌入正则化配置:用交叉表示的图正则化可以应用于输出(左)、中间层(中)或辅助网络(右)

在其原始公式中,用于嵌入的损失函数是从 Siamese 网络公式推导出来的,如下所示:

图片 B16069__04_055.jpg

从这个方程可以看出,损失函数确保相邻节点的嵌入保持接近。另一方面,非相邻节点被拉远到由阈值!指定的距离(至少)。与基于拉普拉斯算子!的正则化(尽管对于相邻点,惩罚因子实际上得到了恢复)相比,这里展示的通常更容易通过梯度下降进行优化。

图 4.9中展示的三种配置中,最佳选择在很大程度上受到可用数据以及特定用例的影响——也就是说,您是否需要一个正则化模型输出或学习高级数据表示。然而,您应该始终记住,当使用 softmax 层(通常在输出层进行)时,基于 hinge 损失的正则化可能不太合适或适合对数概率。在这种情况下,应该在中间层引入正则化嵌入和相对损失。然而,请注意,位于深层层的嵌入通常更难训练,需要仔细调整学习率和边界以使用。

神经图学习

神经图学习NGL)基本上推广了之前的公式,并且正如我们将看到的,使得将图正则化无缝应用于任何形式的神经网络成为可能,包括 CNN 和循环神经网络RNN)。特别是,存在一个名为神经结构学习NSL)的极其强大的框架,它允许我们通过非常少的代码行将 TensorFlow 中实现的神经网络扩展到图正则化。网络可以是任何类型:自然或合成。

当是合成的,图可以通过不同的方式生成,例如使用无监督方式学习的嵌入和/或使用样本特征之间的相似性/距离度量。您还可以使用对抗性示例生成合成图。对抗性示例是通过以某种方式扰动实际(真实)示例而人工生成的样本,以混淆网络,试图强制预测错误。这些精心设计的样本(通过在梯度下降方向上扰动给定样本以最大化错误而获得)可以与其相关样本连接,从而生成图。然后可以使用这些连接来训练网络的图正则化版本,使我们能够获得对对抗性生成的示例更具鲁棒性的模型。

NGL 通过增强神经网络中图正则化的调整参数来扩展正则化,分别使用三个参数!、!和!分解标签-标签、标签-未标记和未标记-未标记关系的贡献,如下所示:

img/B16069__04_061.png

函数img/B16069__04_062.png代表两个向量之间的通用距离——例如,L2 范数img/B16069__04_063.png。通过改变系数和img/B16069__04_064.png的定义,我们可以得到之前作为极限行为的不同算法,如下所示:

  • img/B16069_04_065a.pngimg/B16069_04_065b.png时,我们检索到神经网络的非正则化版本。

  • 当只有img/B16069__04_066.png时,我们恢复了一个完全监督的公式,其中节点之间的关系起到正则化训练的作用。

  • 当我们将由一组α系数参数化的img/B16069__04_067.png(要学习的值img/B16069__04_068.png映射到每个样本的实例类别时),我们恢复了标签传播公式。

通俗地说,NGL 公式可以看作是标签传播和标签扩散算法的非线性版本,或者是一种图正则化神经网络,其中可以获取流形学习或半监督嵌入。

现在,我们将 NGL 应用于一个实际示例,你将学习如何在神经网络中应用图正则化。为此,我们将使用 NLS 框架(github.com/tensorflow/neural-structured-learning),这是一个建立在 TensorFlow 之上的库,它使得在标准神经网络上仅用几行代码即可实现图正则化。

对于我们的示例,我们将使用Cora数据集,这是一个包含 2,708 篇计算机科学论文的有标签数据集,这些论文被分为七个类别。每篇论文代表一个节点,该节点根据引用与其他节点相连。网络中总共有 5,429 个链接。

此外,每个节点还由一个 1,433 个二进制值(0 或 1)的向量进一步描述,这些值代表一个二分Cora数据集,可以直接从stellargraph库中用几行代码下载,如下所示:

from stellargraph import datasets
dataset = datasets.Cora()
dataset.download()
G, labels = dataset.load()

这返回两个输出,如下所述:

  • G是包含网络节点、边和描述 BOW 表示的特征的引用网络。

  • labels是一个pandas Series,它提供了论文 ID 与一个类别之间的映射,如下所示:

    ['Neural_Networks', 'Rule_Learning', 'Reinforcement_Learning', 
    'Probabilistic_Methods', 'Theory', 'Genetic_Algorithms', 'Case_Based']
    

从这些信息开始,我们创建一个训练集和一个验证集。在训练样本中,我们将包括与邻居相关的信息(这些邻居可能属于也可能不属于训练集,因此可能有标签),这将用于正则化训练。

另一方面,验证样本将没有邻域信息,预测标签将仅取决于节点特征——即词袋(BOW)表示。因此,我们将利用标记和无标记样本(半监督任务)来生成一个可以用于未观察样本的归纳模型。

首先,我们将节点特征方便地结构化为一个 DataFrame,而将图存储为邻接矩阵,如下所示:

adjMatrix = pd.DataFrame.sparse.from_spmatrix(
        G.to_adjacency_matrix(), 
        index=G.nodes(), columns=G.nodes()
)
features = pd.DataFrame(G.node_features(), index=G.nodes())

使用 adjMatrix,我们实现了一个辅助函数,能够检索节点的最接近的 topn 邻居,返回节点 ID 和边权重,如下面的代码片段所示:

def getNeighbors(idx, adjMatrix, topn=5):
    weights = adjMatrix.loc[idx]
    neighbors = weights[weights>0]\
         .sort_values(ascending=False)\
         .head(topn)
    return [(k, v) for k, v in neighbors.iteritems()]

使用前面的信息和辅助函数,我们可以将信息合并到一个单独的 DataFrame 中,如下所示:

dataset = {
    index: {
        "id": index,
        "words": [float(x) 
                  for x in features.loc[index].values], 
        "label": label_index[label],
        "neighbors": getNeighbors(index, adjMatrix, topn)
    }
    for index, label in labels.items()
}
df = pd.DataFrame.from_dict(dataset, orient="index")

这个 DataFrame 代表了以节点为中心的特征空间。如果我们使用一个不利用节点间关系信息的常规分类器,这将足够了。然而,为了允许计算图正则化项,我们需要将前面的 DataFrame 与与每个节点的邻域相关的信息连接起来。然后我们定义一个函数,能够检索并连接邻域信息,如下所示:

def getFeatureOrDefault(ith, row):
    try:
        nodeId, value = row["neighbors"][ith]
        return {
            f"{GRAPH_PREFIX}_{ith}_weight": value,
            f"{GRAPH_PREFIX}_{ith}_words": df.loc[nodeId]["words"]
        } 
     except:
        return {
            f"{GRAPH_PREFIX}_{ith}_weight": 0.0,
            f"{GRAPH_PREFIX}_{ith}_words": [float(x) for x in np.zeros(1433)]
        } 
def neighborsFeatures(row):
    featureList = [getFeatureOrDefault(ith, row) for ith in range(topn)]
    return pd.Series(
        {k: v 
         for feat in featureList for k, v in feat.items()}
    )

如前面的代码片段所示,当邻居数量少于 topn 时,我们将权重和单词的 one-hot 编码设置为 0GRAPH_PREFIX 常量是一个前缀,它将被添加到所有将后来由 nsl 库用于正则化的特征之前。尽管它可以更改,但在下面的代码片段中,我们将保持其默认值:"NL_nbr"

这个函数可以应用于 DataFrame,以计算完整的特征空间,如下所示:

neighbors = df.apply(neighborsFeatures, axis=1)
allFeatures = pd.concat([df, neighbors], axis=1)

现在,allFeatures 中包含了我们实现图正则化模型所需的所有成分。

我们首先将数据集分为训练集和验证集,如下所示:

n = int(np.round(len(labels)*ratio))  
labelled, unlabelled = model_selection.train_test_split(
    allFeatures, train_size=n, test_size=None, stratify=labels
)

通过改变比率,我们可以改变标记数据点与无标记数据点的数量。随着比率的降低,我们预计标准非正则化分类器的性能会降低。然而,这种降低可以通过利用无标记数据提供的网络信息来补偿。因此,我们预计图正则化神经网络将提供更好的性能,因为它们利用了增强的信息。对于下面的代码片段,我们将假设 ratio 值等于 0.2

在将此数据输入到我们的神经网络之前,我们将 DataFrame 转换为 TensorFlow 张量和数据集,这是一个方便的表示,将允许模型在其输入层中引用特征名称。

由于输入特征具有不同的数据类型,最好分别处理 weightswordslabels 值的数据集创建,如下所示:

train_base = {
    "words": tf.constant([
         tuple(x) for x in labelled["words"].values
    ]),
    "label": tf.constant([
         x for x in labelled["label"].values
    ])
 }
train_neighbor_words = {
    k: tf.constant([tuple(x) for x in labelled[k].values])
    for k in neighbors if "words" in k
}
train_neighbor_weights = {
^    k: tf.constant([tuple([x]) for x in labelled[k].values])
    for k in neighbors if "weight" in k
} 

现在我们有了张量,我们可以将所有这些信息合并到一个 TensorFlow 数据集中,如下所示:

trainSet = tf.data.Dataset.from_tensor_slices({
    k: v
    for feature in [train_base, train_neighbor_words,
                    train_neighbor_weights]
    for k, v in feature.items()
})

我们可以类似地创建一个验证集。如前所述,由于我们想要设计一个归纳算法,验证数据集不需要任何邻域信息。代码如下所示:

validSet = tf.data.Dataset.from_tensor_slices({
    "words": tf.constant([
       tuple(x) for x in unlabelled["words"].values
    ]),
    "label": tf.constant([
       x for x in unlabelled["label"].values
    ])
 })

在将数据集输入模型之前,我们需要将特征与标签分开,如下所示:

def split(features):
    labels=features.pop("label")
    return features, labels
trainSet = trainSet.map(f)
 validSet = validSet.map(f)

就这样!我们已经生成了我们模型的输入。我们还可以通过打印特征和标签的值来检查我们数据集的一个样本批次,如下面的代码块所示:

for features, labels in trainSet.batch(2).take(1):
    print(features)
    print(labels)

现在是时候创建我们的第一个模型了。为此,我们从简单的架构开始,该架构以单热表示作为输入,并有两个隐藏层,每个隐藏层由一个Dense层和一个具有 50 个单位的Dropout层组成,如下所示:

inputs = tf.keras.Input(
    shape=(vocabularySize,), dtype='float32', name='words'
)
cur_layer = inputs
for num_units in [50, 50]:
    cur_layer = tf.keras.layers.Dense(
        num_units, activation='relu'
    )(cur_layer)
    cur_layer = tf.keras.layers.Dropout(0.8)(cur_layer)
outputs = tf.keras.layers.Dense(
    len(label_index), activation='softmax',
    name="label"
)(cur_layer)
model = tf.keras.Model(inputs, outputs=outputs)

事实上,我们也可以通过简单地编译模型以创建计算图来训练这个模型而不使用图正则化,如下所示:

model.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',
    metrics=['accuracy']
)

然后,我们可以像往常一样运行它,同时允许将历史文件写入磁盘,以便使用TensorBoard进行监控,如下面的代码片段所示:

from tensorflow.keras.callbacks import TensorBoard
model.fit(
    trainSet.batch(128), epochs=200, verbose=1,
    validation_data=validSet.batch(128),
    callbacks=[TensorBoard(log_dir='/tmp/base)]
)

在处理过程的最后,我们应该得到以下类似的输出:

Epoch 200/200
loss: 0.7798 – accuracy: 06795 – val_loss: 1.5948 – val_accuracy: 0.5873

在准确率大约为 0.6 的顶级性能下,我们现在需要创建前面模型的图正则化版本。首先,我们需要从头开始重新创建我们的模型。这在比较结果时很重要。如果我们使用之前模型中已经初始化并使用的层,则层权重将不会是随机的,而是会使用之前运行中已经优化的权重。一旦创建了一个新的模型,我们只需几行代码就可以在训练时添加图正则化技术,如下所示:

import neural_structured_learning as nsl
graph_reg_config = nsl.configs.make_graph_reg_config(
    max_neighbors=2,
    multiplier=0.1,
    distance_type=nsl.configs.DistanceType.L2,
    sum_over_axis=-1)
graph_reg= nsl.keras.GraphRegularization(
     model, graph_reg_config)

让我们分析正则化的不同超参数,如下所示:

  • max_neighbors 调整用于计算每个节点正则化损失的邻居数量。

  • multiplier 对应于调整正则化损失重要性的系数。由于我们只考虑有标签-有标签和有标签-无标签,这实际上对应于 img/B16069__04_069.pngimg/B16069__04_070.png

  • distance_type 表示要使用的成对距离 img/B16069__04_071.png

  • sum_over_axis 设置是否应该根据特征(当设置为None时)或样本(当设置为-1 时)计算加权平均和。

图正则化模型可以使用以下命令以与之前相同的方式进行编译和运行:

graph_reg.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',    metrics=['accuracy']
)
model.fit(
    trainSet.batch(128), epochs=200, verbose=1,
    validation_data=validSet.batch(128),
    callbacks=[TensorBoard(log_dir='/tmp/nsl)]
)

注意,现在的损失函数现在也考虑了之前定义的图正则化项。因此,我们现在还引入了来自相邻节点的信息,以正则化我们神经网络的训练。前面的代码在大约 200 次迭代后提供了以下输出:

Epoch 200/200
loss: 0.9136 – accuracy: 06405 – scaled_graph_loss: 0.0328 - val_loss: 1.2526 – val_accuracy: 0.6320

如你所见,与原始版本相比,图正则化使我们能够在准确率方面提升大约 5%。这绝对不错!

你可以进行多个实验,改变标记/未标记样本的比例、要使用的邻居数量、正则化系数、距离等。我们鼓励你使用本书提供的笔记本进行探索,以自己研究不同参数的影响。

在以下截图的右侧面板中,我们展示了随着监督比率的增加,通过准确率测量的性能依赖关系。正如预期的那样,随着比率的增加,性能也会提高。在左侧面板中,我们展示了不同邻居配置和监督比率在验证集上的准确率提升。

:

图 4.10 – (左) 邻居数为 2 和各种监督比率的图正则化神经网络在验证集上的准确率;(右) 与原始版本相比,图正则化神经网络在验证集上的准确率提升

图 4.10 – (左) 邻居数为 2 和各种监督比率的图正则化神经网络在验证集上的准确率;(右) 与原始版本相比,图正则化神经网络在验证集上的准确率提升

图 4.10 所示,几乎所有图正则化版本都优于原始模型。唯一的例外是邻居数为 2 和比率为 0.5 的配置,这两个模型的表现非常相似。然而,曲线有明显的上升趋势,我们有理由期待图正则化版本在更多轮次中优于原始模型。

注意,在笔记本中,我们还使用了 TensorFlow 的另一个有趣特性来创建数据集。与之前使用 pandas DataFrame 不同,我们将使用 TensorFlow 的 ExampleFeaturesFeature 类来创建数据集,这些类不仅提供了样本的高级描述,还允许我们使用 protobuf 序列化输入数据,使其在不同平台和编程语言之间兼容。

如果你对进一步使用 TensorFlow 进行模型原型设计和通过数据驱动应用(可能用其他语言编写)将其部署到生产环境中感兴趣,我们强烈建议你深入研究这些概念。

Planetoid

到目前为止讨论的方法提供了基于拉普拉斯矩阵的图正则化。如我们在前面的章节中看到的,基于 的约束确保了第一阶邻近性的保留。Yang 等人(2016)提出了一种扩展图正则化的方法,以便也考虑高阶邻近性。他们的方法,他们命名为 Planetoid(代表 从数据中通过归纳或演绎预测标签和邻居),扩展了用于计算节点嵌入的 skip-gram 方法,以包含节点标签信息。

如我们在上一章所见,skip-gram 方法基于在图中生成随机游走,然后使用生成的序列通过 skip-gram 模型学习嵌入。以下图表展示了如何修改无监督版本以考虑监督损失:

图 4.11 – Planetoid 架构草图:虚线表示一个参数化函数,允许方法从归纳扩展到演绎

图 4.11 – Planetoid 架构草图:虚线表示一个参数化函数,允许方法从归纳扩展到演绎

图 4.11 所示,嵌入被输入到以下两个部分:

  • 一个 softmax 层来预测采样随机游走序列的图上下文

  • 一组隐藏层,它们与从节点特征中导出的隐藏层结合在一起,以预测类别标签

训练组合网络的成本函数由监督损失和无监督损失组成——分别表示为 。无监督损失类似于与负采样一起使用的 skip-gram,而监督损失最小化条件概率,可以表示如下:

前面的公式是 归纳 的,因为它要求样本属于图才能应用。在半监督任务中,这种方法可以有效地用于预测未标记样本的标签。然而,它不能用于未观察到的样本。如图 4.11 中的虚线所示,通过将嵌入参数化为节点特征的函数,通过专用连接层,可以获得 Planetoid 算法的归纳版本。

图卷积网络(Graph CNNs)

第三章 无监督图学习 中,我们学习了 GNN 和 图卷积网络GCNs)背后的主要概念。我们还学习了频谱图卷积和空间图卷积之间的区别。更确切地说,我们进一步看到 GCN 层可以在无监督设置下使用,通过学习如何保留图属性(如节点相似性)来编码图或节点。

在本章中,我们将探讨在监督设置下的此类方法。这次,我们的目标是学习能够准确预测节点或图标签的图或节点表示。确实值得指出的是,编码函数保持不变。将发生变化的是目标函数!

使用 GCN 进行图分类

让我们再次考虑我们的PROTEINS数据集。让我们按照以下方式加载数据集:

import pandas as pd
from stellargraph import datasets
dataset = datasets.PROTEINS()
graphs, graph_labels = dataset.load()
# necessary for converting default string labels to int
labels = pd.get_dummies(graph_labels, drop_first=True)

在以下示例中,我们将使用(并比较)最广泛使用的 GCN 算法之一进行图分类:Kipf 和 Welling 的GCN

  1. 我们使用的stellargraph在构建模型时使用tf.Keras作为后端。根据其特定标准,我们需要一个数据生成器来为模型提供数据。更确切地说,由于我们正在解决一个监督图分类问题,我们可以使用stellar``graphPaddedGraphGenerator类的实例,它通过填充自动解决节点数量差异。以下是这一步骤所需的代码:

    from stellargraph.mapper import PaddedGraphGenerator
    generator = PaddedGraphGenerator(graphs=graphs)
    
  2. 我们现在准备好实际创建我们的第一个模型了。我们将通过stellargraphutility函数创建并堆叠四个 GCN 层,如下所示:

    from stellargraph.layer import DeepGraphCNN
    from tensorflow.keras import Model
    from tensorflow.keras.optimizers import Adam
    from tensorflow.keras.layers import Dense, Conv1D, MaxPool1D, Dropout, Flatten
    from tensorflow.keras.losses import binary_crossentropy
    import tensorflow as tf
    nrows = 35  # the number of rows for the output tensor
    layer_dims = [32, 32, 32, 1]
    # backbone part of the model (Encoder)
     dgcnn_model = DeepGraphCNN(
        layer_sizes=layer_dims,
        activations=["tanh", "tanh", "tanh", "tanh"],
        k=nrows,
        bias=False,
        generator=generator,
    )
    
  3. 这个骨干将被连接到tf.Keras,如下所示:

    # necessary for connecting the backbone to the head
    gnn_inp, gnn_out = dgcnn_model.in_out_tensors()
    # head part of the model (classification)
     x_out = Conv1D(filters=16, kernel_size=sum(layer_dims), strides=sum(layer_dims))(gnn_out)
    x_out = MaxPool1D(pool_size=2)(x_out)
     x_out = Conv1D(filters=32, kernel_size=5, strides=1)(x_out)
    x_out = Flatten()(x_out)
     x_out = Dense(units=128, activation="relu")(x_out)
     x_out = Dropout(rate=0.5)(x_out)
    predictions = Dense(units=1, activation="sigmoid")(x_out)
    
  4. 让我们使用tf.Keras工具创建和编译一个模型。我们将使用binary_crossentropy损失函数(用于衡量预测标签和真实标签之间的差异)以及Adam优化器和 0.0001 的学习率来训练模型。我们还将监控训练过程中的准确率指标。以下代码片段展示了这一过程:

    model = Model(inputs=gnn_inp, outputs=predictions)
    model.compile(optimizer=Adam(lr=0.0001), loss=binary_crossentropy, metrics=["acc"])
    
  5. 我们现在可以利用scikit-learn工具创建训练集和测试集。在我们的实验中,我们将使用数据集的 70%作为训练集,其余部分作为测试集。此外,我们需要使用生成器的flow方法将它们提供给模型。以下代码片段展示了如何实现这一点:

    from sklearn.model_selection import train_test_split
    train_graphs, test_graphs = train_test_split(
    graph_labels, test_size=.3, stratify=labels,)
    gen = PaddedGraphGenerator(graphs=graphs)
    train_gen = gen.flow(
        list(train_graphs.index - 1),
        targets=train_graphs.values,
        symmetric_normalization=False,
        batch_size=50,
    )
    test_gen = gen.flow(
        list(test_graphs.index - 1),
        targets=test_graphs.values,
        symmetric_normalization=False,
        batch_size=1,
    )
    
  6. 现在是进行训练的时候了。我们将模型训练 100 个 epoch。然而,你可以随意调整超参数以获得更好的性能。以下是相应的代码:

    epochs = 100
    history = model.fit(train_gen, epochs=epochs, verbose=1,
     validation_data=test_gen, shuffle=True,)
    

    经过 100 个 epoch 后,应该得到以下输出:

    Epoch 100/100
    loss: 0.5121 – acc: 0.7636 – val_loss: 0.5636 – val_acc: 0.7305
    

在这里,我们在训练集上达到了大约 76%的准确率,在测试集上达到了大约 73%的准确率。

使用 GraphSAGE 进行节点分类

在下一个示例中,我们将训练GraphSAGE以对Cora数据集的节点进行分类。

首先,让我们使用stellargraph工具来加载数据集,如下所示:

dataset = datasets.Cora()
G, nodes = dataset.load()

按照以下步骤来训练GraphSAGE以对Cora数据集的节点进行分类:

  1. 如前一个示例,第一步是分割数据集。我们将使用数据集的 90%作为训练集,其余部分用于测试。以下是这一步骤的代码:

    train_nodes, test_nodes = train_test_split(nodes, train_size=0.1,test_size=None, stratify=nodes)
    
  2. 这次,我们将使用c表示可能的靶标数量(在Cora数据集中为七个),每个标签将被转换为一个大小为c的向量,其中所有元素都是0,除了对应于目标类别的那个元素。代码在下面的代码片段中展示:

    from sklearn import preprocessing
    label_encoding = preprocessing.LabelBinarizer()
    train_labels = label_encoding.fit_transform(train_nodes)
     test_labels = label_encoding.transform(test_nodes)
    
  3. 让我们创建一个生成器来将数据输入到模型中。我们将使用stellargraphGraphSAGENodeGenerator类的实例。我们将使用flow方法将训练集和测试集输入到模型中,如下所示:

    from stellargraph.mapper import GraphSAGENodeGenerator
    batchsize = 50
    n_samples = [10, 5, 7]
     generator = GraphSAGENodeGenerator(G, batchsize, n_samples)
    train_gen = generator.flow(train_nodes.index, train_labels, shuffle=True)
     test_gen = generator.flow(test_labels.index, test_labels)
    
  4. 最后,让我们创建模型并编译它。在这个练习中,我们将使用一个具有 32、32 和 16 维度的三层GraphSAGE编码器。编码器随后将连接到一个具有softmax激活函数的密集层以执行分类。我们将使用学习率为 0.03 的Adam优化器,并将categorical_crossentropy作为损失函数。代码在下面的代码片段中展示:

    from stellargraph.layer import GraphSAGE
    from tensorflow.keras.losses import categorical_crossentropy
    graphsage_model = GraphSAGE(layer_sizes=[32, 32, 16], generator=generator, bias=True, dropout=0.6,)
    gnn_inp, gnn_out = graphsage_model.in_out_tensors()
    outputs = Dense(units=train_labels.shape[1], activation="softmax")(gnn_out)
    # create the model and compile
    model = Model(inputs=gnn_inp, outputs=outputs)
    model.compile(optimizer=Adam(lr=0.003), loss=categorical_crossentropy, metrics=["acc"],)
    
  5. 现在是时候训练模型了。我们将训练模型 20 个周期,如下所示:

    model.fit(train_gen, epochs=20, validation_data=test_gen, verbose=2, shuffle=False)
    
  6. 这应该是输出:

    Epoch 20/20
    loss: 0.8252 – acc: 0.8889 – val_loss: 0.9070 – val_acc: 0.8011
    

我们在训练集上达到了大约 89%的准确率,在测试集上达到了大约 80%的准确率。

摘要

在本章中,我们学习了如何有效地将监督机器学习应用于图来解决节点和图分类等实际问题。

尤其是首先,我们分析了如何直接使用图和节点属性作为特征来训练经典的机器学习算法。我们已经看到了针对有限输入数据集的节点、边或图表示的浅层方法和简单学习方法。

我们还学习了如何在学习阶段使用正则化技术来创建更鲁棒且泛化能力更强的模型。

最后,我们已经看到了图神经网络(GNNs)如何应用于解决图上的监督机器学习问题。

但这些算法有什么用途呢?在下一章中,我们将探讨需要通过机器学习技术解决的问题的常见图问题。

第五章:图上机器学习的问题

机器学习ML)方法可以用于广泛的任务,其应用范围从药物设计到社交网络中的推荐系统。此外,鉴于这些方法的设计是通用的(这意味着它们不是针对特定问题定制的),相同的算法可以用来解决不同的问题。

有一些常见问题可以使用基于图的学习技术来解决。在本章中,我们将通过提供关于如何使用我们已经在第三章“无监督图学习”和第四章“监督图学习”中已经学习到的特定算法来解决一个任务的细节,来介绍这些问题中最被广泛研究的一些。阅读本章后,你将了解在处理图时可能遇到的一些常见问题的形式化定义。此外,你还将学习到有用的机器学习管道,你可以在未来处理的真实世界问题中重复使用。

更精确地说,本章将涵盖以下主题:

  • 预测图中的缺失链接

  • 检测有意义的结构,如社区

  • 检测图相似性和图匹配

技术要求

我们将使用 Python 3.8 的Jupyter笔记本来完成所有练习。在下面的代码块中,你可以看到使用pip为本章安装的 Python 库列表(例如,在命令行中运行pip install networkx==2.5):

Jupyter==1.0.0
networkx==2.5
karateclub==1.0.19
scikit-learn==0.24.0
pandas==1.1.3
node2vec==0.3.3
numpy==1.19.2
tensorflow==2.4.1
stellargraph==1.2.1
communities==2.2.0
git+https://github.com/palash1992/GEM.git 

本章相关的所有代码文件均可在github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter05找到。

预测图中的缺失链接

链接预测,也称为图补全,是处理图时遇到的一个常见问题。更确切地说,从一个部分观察到的图——在这种图中,对于某些节点对,无法确切知道它们之间是否存在(或不存在)边——我们想要预测未知状态节点对之间是否存在边,如图图 5.1所示。形式上,设为一个图,其中是其节点集,是其边集。边集被称为观测链接,而边集被称为未知链接。链接预测问题的目标是利用的信息来估计。当处理时间图数据时,这个问题也很常见。在这种情况下,设为在给定时间点观察到的图,我们想要预测该图在给定时间点的边。部分观察到的图如下所示:

图 5.1 – 具有观测链接(实线)和未知链接(虚线)的部分观察图

图 5.1 – 具有观测链接(实线)和未知链接(虚线)的部分观察图

链接预测问题在多个领域得到广泛应用,例如在推荐系统中提出社交网络中的友谊或电子商务网站上的购买项目。它还用于犯罪网络调查,以找到犯罪集群之间的隐藏联系,以及在生物信息学中用于蛋白质-蛋白质相互作用的分析。在接下来的几节中,我们将讨论解决链接预测问题的两种方法族——即基于相似度基于嵌入的方法。

基于相似度的方法

在本子节中,我们展示了几个解决标签预测问题的简单算法。所有这些算法背后的主要共同思想是在图中的每对节点之间估计一个相似度函数。如果根据该函数,节点看起来相似,它们将有很高的概率通过边连接。我们将将这些算法分为两个子类:networkx库中的networkx.algorithms.link_prediction包。

基于索引的方法

在本节中,我们将展示networkx中可用于计算两个不连接节点之间边概率的一些算法。这些算法基于通过分析两个不连接节点的邻居获得的信息来计算一个简单的指标。

资源分配指标

资源分配索引方法通过估计所有节点对根据以下公式计算的资源分配索引来估计两个节点之间连接的概率:

在给定的公式中,函数计算节点的邻居,正如公式中所示,是同时是邻居的节点。这个索引可以使用以下代码在networkx中计算:

import networkx as nx
edges = [[1,3],[2,3],[2,4],[4,5],[5,6],[5,7]]
 G = nx.from_edgelist(edges)
 preds = nx.resource_allocation_index(G,[(1,2),(2,5),(3,4)])

resource_allocation_index函数的第一个参数是输入图,第二个参数是可能边的列表。我们想要计算连接的概率。因此,我们得到以下输出:

[(1, 2, 0.5), (2, 5, 0.5), (3, 4, 0.5)]

输出是一个包含节点对如(1,2)(2,5)(3,4)的列表,这些节点对构成了资源分配索引。根据这个输出,这些节点对之间存在边的概率是0.5

Jaccard 系数

该算法根据Jaccard 系数计算两个节点之间的连接概率,计算方法如下:

这里,用于计算节点的邻居。该函数可以使用以下代码在networkx中使用:

import networkx as nx
edges = [[1,3],[2,3],[2,4],[4,5],[5,6],[5,7]]
 G = nx.from_edgelist(edges)
 preds = nx.resource_allocation_index(G,[(1,2),(2,5),(3,4)])

resource_allocation_index函数与之前的函数具有相同的参数。代码的结果如下:

[(1, 2, 0.5), (2, 5, 0.25), (3, 4, 0.3333333333333333)]

根据这个输出,节点(1,2)之间存在边的概率是0.5,而节点(2,5)之间是0.25,节点(3,4)之间是0.333

networkx中,其他基于相似度分数计算两个节点之间连接概率的方法是nx.adamic_adar_indexnx.preferential_attachment,分别基于Adamic/Adar 指数优先连接指数的计算。这些函数具有与其他函数相同的参数,并接受一个图和一个节点对的列表,其中我们想要计算分数。在下一节中,我们将展示另一系列基于社区检测的算法。

基于社区的方法

与基于索引的方法一样,属于这个家族的算法也计算一个表示断开节点连接概率的索引。基于索引和基于社区的方法之间的主要区别在于它们的逻辑。实际上,基于社区的方法在生成索引之前,需要计算那些节点所属社区的信息。在本小节中,我们将展示一些常见的基于社区的方法,并提供几个示例。

社区常见邻居

为了估计两个节点连接的概率,此算法计算共同邻居的数量,并将属于同一社区的共同邻居数量添加到这个值中。形式上,对于两个节点 img/B16069_05_025.pngimg/B16069_05_026.png,社区共同邻居值的计算如下:

img/B16069_05_027.jpg

在这个公式中,img/B16069_05_028.png 用于计算节点 img/B16069_05_029.png 的邻居,而 img/B16069_05_030.png 如果 img/B16069_05_031.png 属于 img/B16069_05_032.pngimg/B16069_05_033.png 的同一社区,否则,这个值为 0。该函数可以使用以下代码在 networkx 中计算:

import networkx as nx
edges = [[1,3],[2,3],[2,4],[4,5],[5,6],[5,7]]
 G = nx.from_edgelist(edges)

G.nodes[1]["community"] = 0
G.nodes[2]["community"] = 0
G.nodes[3]["community"] = 0
G.nodes[4]["community"] = 1
G.nodes[5]["community"] = 1
G.nodes[6]["community"] = 1
G.nodes[7]["community"] = 1
preds = nx.cn_soundarajan_hopcroft(G,[(1,2),(2,5),(3,4)])

从前面的代码片段中,我们可以看到我们需要如何将 community 属性分配给图中的每个节点。这个属性用于在计算之前方程中定义的函数 img/B16069_05_034.png 时识别属于同一社区的两个节点。正如我们将在下一节中看到的,社区值也可以使用特定的算法自动计算。正如我们已经看到的,cn_soundarajan_hopcroft 函数接受输入图和一对我们想要计算分数的节点。因此,我们得到以下输出:

[(1, 2, 2), (2, 5, 1), (3, 4, 1)]

与之前函数的主要区别在于索引值。实际上,我们可以很容易地看到输出不在 (0,1) 范围内。

社区资源分配

与之前的方法一样,社区资源分配算法将来自节点邻居和社区的信息合并,如下公式所示:

img/B16069_05_035.jpg

在这里,img/B16069_05_036.png 用于计算节点 img/B16069_05_037.png 的邻居,而 img/B16069_05_038.png 如果 img/B16069_05_039.png 属于 img/B16069_05_040.pngimg/B16069_05_041.png 的同一社区,否则,这个值为 0。该函数可以使用以下代码在 networkx 中计算:

import networkx as nx
edges = [[1,3],[2,3],[2,4],[4,5],[5,6],[5,7]]
 G = nx.from_edgelist(edges)

G.nodes[1]["community"] = 0
G.nodes[2]["community"] = 0
G.nodes[3]["community"] = 0
G.nodes[4]["community"] = 1
G.nodes[5]["community"] = 1
G.nodes[6]["community"] = 1
G.nodes[7]["community"] = 1
preds = nx. ra_index_soundarajan_hopcroft(G,[(1,2),(2,5),(3,4)])

从前面的代码片段中,我们可以看到我们需要如何将 community 属性分配给图中的每个节点。这个属性用于在计算之前方程中定义的函数 img/B16069_05_042.png 时识别属于同一社区的两个节点。正如我们将在下一节中看到的,社区值也可以使用特定的算法自动计算。正如我们已经看到的,ra_index_soundarajan_hopcroft 函数接受输入图和一对我们想要计算分数的节点。因此,我们得到以下输出:

[(1, 2, 0.5), (2, 5, 0), (3, 4, 0)]

从前面的输出中,我们可以看到社区在索引计算中的影响。由于节点 12 属于同一社区,它们在索引中的值更高。相反,边 (2,5)(3,4) 由于它们属于不同的社区,其值为 0。

networkx中,有两种基于节点相似度分数合并社区信息来计算两个节点之间连接概率的方法,分别是nx.a.within_inter_clusternx.common_neighbor_centrality

在下一节中,我们将描述一种基于机器学习和边缘嵌入的更复杂的技术,用于预测未知边。

基于嵌入的方法

在本节中,我们描述了一种更高级的进行链接预测的方法。这种方法背后的思想是将链接预测问题作为一个监督分类任务来解决。更确切地说,对于给定的图,每对节点用一个特征向量()表示,并为每对节点分配一个类别标签()。形式上,设为一个图,对于每对节点,我们构建以下公式:

这里,是表示节点对特征向量,而是它们的标签的值定义为:如果图G中存在连接节点的边,则为;否则为。使用特征向量和标签,我们可以训练一个机器学习算法,以预测给定的节点对是否构成给定图的合理边。

如果为每对节点构建标签向量很容易,那么构建特征空间就不那么直接了。为了为每对节点生成特征向量,我们将使用一些嵌入技术,例如已在第三章“无监督图学习”中讨论过的node2vecedge2vec。使用这些嵌入算法,特征空间的生成将大大简化。实际上,整个过程可以总结为以下两个主要步骤,概述如下:

  1. 对于图G中的每个节点,其嵌入向量是通过使用node2vec算法计算得到的。

  2. 对于图中所有可能的节点对,嵌入是通过使用edge2vec算法计算得到的。

我们现在可以将通用的机器学习算法应用于生成的特征向量,以解决分类问题。

为了给您提供一个关于此过程的实际解释,我们将在下面的代码片段中提供一个示例。更确切地说,我们将使用networkxstellargraphnode2vec库来描述整个流程(从图到链接预测)。我们将整个过程分成不同的步骤,以便简化我们对不同部分的理解。链接预测问题被应用于在第一章“Python 中的图入门”中描述的引用网络数据集,可通过以下链接获取:linqs-data.soe.ucsc.edu/public/lbc/cora.tgz

作为第一步,我们将使用引用数据集构建一个networkx图,如下所示:

import networkx as nx
import pandas as pd
edgelist = pd.read_csv("cora.cites", sep='\t', header=None, names=["target", "source"])
G = nx.from_pandas_edgelist(edgelist)

由于数据集以边列表的形式表示(参见第一章“Python 中的图入门”),我们使用了from_pandas_edgelist函数来构建图。

作为第二步,我们需要从图G创建训练集和测试集。更确切地说,我们的训练集和测试集应包含图G的真实边的子集,以及不表示G中真实边的节点对。代表真实边的节点对将是正实例(类别标签 1),而那些不表示真实边的节点对将是负实例(类别标签 0)。这个过程可以很容易地执行,如下所示:

from stellargraph.data import EdgeSplitter
edgeSplitter = EdgeSplitter(G)
 graph_test, samples_test, labels_test = edgeSplitter.train_test_split(p=0.1, method="global")

我们使用了stellargraph中可用的EdgeSplitter类。EdgeSplitter类的主要构造参数是我们想要用于执行分割的图(G)。实际的分割是通过train_test_split函数执行的,该函数将生成以下输出:

  • graph_test是原始图的一个子集 img/B16069_05_055.png,包含所有节点,但只包含选定的边子集。

  • samples_test是一个向量,在每个位置包含一对节点。这个向量将包含代表真实边(正实例)的节点对,但也会包含不表示真实边的节点对(负实例)。

  • labels_test是一个与samples_test长度相同的向量,它只包含 0 或 1。0 的值出现在代表samples_test向量中负实例的位置,而 1 的值出现在代表samples_test中正实例的位置。

通过遵循用于生成测试集的相同程序,可以生成训练集,如下面的代码片段所示:

edgeSplitter = EdgeSplitter(graph_test, G)
 graph_train, samples_train, labels_train = edgeSplitter.train_test_split(p=0.1, method="global")

这部分代码的主要区别与EdgeSplitter的初始化有关。在这种情况下,我们还提供了graph_test,以避免为测试集重复生成正负实例。

到目前为止,我们有了包含正负实例的训练和测试数据集。对于这些实例中的每一个,我们现在需要生成它们的特征向量。在这个例子中,我们使用了node2vec库来生成节点嵌入。一般来说,任何节点嵌入算法都可以用来执行这项任务。因此,对于训练集,我们可以使用以下代码生成特征向量:

from node2vec import Node2Vec
from node2vec.edges import HadamardEmbedder
node2vec = Node2Vec(graph_train)
 model = node2vec.fit()
edges_embs = HadamardEmbedder(keyed_vectors=model.wv)
 train_embeddings = [edges_embs[str(x[0]),str(x[1])] for x in samples_train]

从前面的代码片段中,我们可以看到以下内容:

  • 我们使用node2vec库为训练图中的每个节点生成嵌入。

  • 我们使用HadamardEmbedder类来生成训练集中包含的每对节点的嵌入。这些值将被用作特征向量以执行我们模型的训练。

在这个例子中,我们使用了HadamardEmbedder算法,但一般来说,可以使用其他嵌入算法,例如在第三章中描述的,无监督图学习

上一步也需要对测试集执行,以下代码所示:

edges_embs = HadamardEmbedder(keyed_vectors=model.wv)
 test_embeddings = [edges_embs[str(x[0]),str(x[1])] for x in samples_test]

这里的唯一区别是由用于计算边嵌入的samples_test数组给出的。实际上,在这种情况下,我们使用了为测试集生成的数据。此外,应注意node2vec算法并未为测试集重新计算。确实,鉴于node2vec的随机性,无法保证两次学习到的嵌入是“可比较”的,因此node2vec嵌入会在运行之间发生变化。

现在一切已设置完毕。我们最终可以使用train_embeddings特征空间和train_labels标签分配来训练一个机器学习算法以解决标签预测问题,如下所示:

from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(n_estimators=1000)
 rf.fit(train_embeddings, labels_train);

在这个例子中,我们使用了一个简单的RandomForestClassifier类,但任何机器学习算法都可以用来解决这个任务。然后我们可以将训练好的模型应用于test_embeddings特征空间,以量化分类的质量,如下所示:

from sklearn import metrics
y_pred = rf.predict(test_embeddings)
 print('Precision:', metrics.precision_score(labels_test, y_pred))
 print('Recall:', metrics.recall_score(labels_test, y_pred))
 print('F1-Score:', metrics.f1_score(labels_test, y_pred))

因此,我们得到以下输出:

Precision: 0.8557114228456913
Recall: 0.8102466793168881
F1-Score: 0.8323586744639375

正如我们之前提到的,我们刚才描述的方法只是一个通用方案;管道中的每一部分——如训练/测试分割、节点/边嵌入和机器学习算法——都可以根据我们面临的具体问题进行更改。

这种方法在处理时间图中的链接预测时特别有用。在这种情况下,用于训练模型的时间点img/B16069_05_056.png获得的信息可以用来预测时间点img/B16069_05_057.png的边。

在本节中,我们介绍了标签预测问题。我们通过提供描述和几个示例,丰富了我们的解释,展示了用于找到链接预测问题解决方案的不同技术。我们展示了处理问题的不同方法,从简单的基于索引的技术到更复杂的基于嵌入的技术。然而,科学文献中充满了用于解决链接预测任务的算法,并且存在不同的算法来解决此问题。在论文《关于学习与提取链接预测图特征的综述》(arxiv.org/pdf/1901.03425.pdf)中,可以找到解决链接预测问题所使用不同技术的良好概述。在下一节中,我们将研究社区检测问题。

检测有意义的结构,如社区

数据科学家在处理网络时面临的一个常见问题是如何在图中识别集群和社区。这种情况通常出现在从社交网络和已知存在社区中导出的图中。然而,这些底层算法和方法也可以在其他环境中使用,代表了一种执行聚类和分割的另一种选择。例如,这些方法可以有效地用于文本挖掘,以识别新兴主题和将引用单个事件/主题的文档进行聚类。社区检测任务包括将图划分为几个部分,使得属于同一社区的节点之间紧密相连,而与其他社区的节点之间则弱连接。存在几种识别社区的策略。一般来说,我们可以将它们定义为属于以下两个类别之一,如下所述:

  • 非重叠社区检测算法,提供节点与社区之间的一对一关联,因此社区之间没有重叠节点

  • 重叠社区检测算法,允许一个节点被包含在多个社区中——例如,反映社交网络自然发展重叠社区的趋势(例如,来自同一所学校的朋友、邻居、玩伴、在同一足球队的人等等),或者在生物学中,一个单一的蛋白质可以参与多个过程和生物反应

在以下部分,我们将回顾社区检测中最常用的技术。

基于嵌入的社区检测

一类允许我们将节点划分为社区的方法可以通过在节点嵌入上应用标准浅层聚类技术简单获得,这些嵌入是通过第三章无监督图学习中描述的方法计算的。实际上,嵌入方法允许我们将节点投影到向量空间中,在该空间中可以定义表示节点之间相似性的距离度量。正如我们在第三章无监督图学习中所示,嵌入算法在分离具有相似邻域和/或连接属性节点方面非常有效。然后,可以使用标准聚类技术,例如基于距离的聚类(K-means)、基于连接的聚类(层次聚类)、基于分布的聚类(高斯混合)和基于密度的聚类(networkx实用函数,如下所示:

import networkx as nx 
G = nx.barbell_graph(m1=10, m2=4) 

然后,我们可以首先使用我们之前看到的嵌入算法之一(例如,HOPE)获取减少的密集节点表示,如下所示:

from gem.embedding.hope import HOPE 
gf = HOPE(d=4, beta=0.01) 
gf.learn_embedding(G) 
 embeddings = gf.get_embedding() 

我们最终可以在节点嵌入提供的向量表示上运行聚类算法,如下所示:

from sklearn.mixture import GaussianMixture
gm = GaussianMixture(n_components=3, random_state=0)
 labels = gm.fit_predict(embeddings)

我们可以用不同的颜色突出显示计算出的社区,并绘制出网络图,如下所示:

colors = ["blue", "green", "red"]
nx.draw_spring(G, node_color=[colors[label] for label in labels])

通过这样做,你应该会获得以下屏幕截图所示的输出:

图 5.2 – 使用基于嵌入的方法应用社区检测算法的哑铃图

图 5.2 – 使用基于嵌入的方法应用社区检测算法的哑铃图

这两个簇以及连接的节点已经被正确地分成了三个不同的社区,反映了图的内部结构。

谱方法和矩阵分解

另一种实现图划分的方法是处理表示图连接属性邻接矩阵或拉普拉斯矩阵。例如,可以通过在拉普拉斯矩阵的特征向量上应用标准聚类算法来获得谱聚类。在某种意义上,谱聚类也可以被视为一种基于嵌入的社区检测算法的特殊情况,其中嵌入技术被称为谱嵌入,通过考虑拉普拉斯矩阵的前 k 个特征向量获得。通过考虑拉普拉斯的不同定义以及不同的相似性矩阵,可以得到该方法的不同变体。此方法的方便实现可以在communities Python 库中找到,并且可以轻松地应用于从networkx图中获得的邻接矩阵表示,如下面的代码片段所示:

from communities.algorithms import spectral_clustering
adj=np.array(nx.adjacency_matrix(G).todense())
communities = spectral_clustering(adj, k=2)

此外,邻接矩阵(或拉普拉斯矩阵)也可以使用除了奇异值分解SVD)技术之外的矩阵分解技术进行分解——例如非负矩阵分解NMF)——允许类似的描述,如下面的代码片段所示:

from sklearn.decomposition import NMF
nmf = NMF(n_components=2)
 score = nmf.fit_transform(adj)
communities = [set(np.where(score [:,ith]>0)[0])
               for ith in range(2)]

在这个例子中,社区归属的阈值被设置为 0,尽管也可以使用其他值来仅保留社区核心。请注意,这些方法是重叠的社区检测算法,节点可能属于多个社区。

概率模型

社区检测方法也可以通过拟合生成概率图模型的参数来推导。例如,在第一章“Python 中图形入门”中已经描述了生成模型。然而,它们并没有假设存在任何潜在的社区,与所谓的随机块模型SBM)不同。事实上,这个模型基于这样的假设:节点可以被划分为K个不相交的社区,并且每个社区都有一个定义的概率,可以连接到另一个社区。对于一个包含n个节点和K个社区的图,生成模型因此由以下参数来定义:

  • 隶属矩阵M,这是一个n x K矩阵,表示给定节点属于某个特定类别k的概率

  • 概率矩阵B,这是一个K x K矩阵,表示属于社区i的节点与属于社区j的节点之间的边概率

邻接矩阵随后通过以下公式生成:

在这里,代表社区,它们可以通过从概率的多项分布中进行抽样来获得。

在 SBM 中,我们可以基本上逆转公式,将社区检测问题简化为从矩阵A中通过最大似然估计对隶属矩阵M的后验估计。最近,这种方法与随机谱聚类一起使用,以在非常大的图中进行社区检测。请注意,在恒定概率矩阵的极限下(即,),SBM 模型对应于 Erdős-Rényi 模型。这些模型的优势在于它们还描述了社区之间的关系,确定了社区间的联系。

成本函数最小化

在图中检测社区的另一种可能方法是优化一个表示图结构的给定成本函数,并惩罚社区间的边相对于社区内的边。这基本上包括构建一个衡量社区质量(如我们很快将看到的,其模块度)的度量,然后优化节点到社区的关联,以最大化整体划分的质量。

在二元关联社区结构的背景下,社区关联可以通过一个二分变量 来描述,其值为 -1 或 1,具体取决于节点是否属于两个社区中的任何一个。在这种情况下,我们可以定义以下数量,它确实可以用来有效地表示两个不同社区节点之间链接的成本:

事实上,当两个相连的节点 属于不同的社区 时,边的贡献是正的。另一方面,当两个节点没有连接 () 或者两个相连的节点属于同一社区 () 时,贡献为 0。因此,问题是要找到最佳的社区分配 () 以最小化前面的函数。然而,这种方法仅适用于二元社区检测,因此在应用上相当有限。

属于这一类别的另一个非常流行的算法是 Louvain 方法,该算法的名字来源于它被发明的那所大学。这个算法的目标是最大化模块度,其定义如下:

在这里, 表示边的数量, 分别表示第 i 个和第 j 个节点的度,而 是克罗内克δ函数,当 有相同的值时为 1,否则为 0。模块度基本上代表了一个与随机重新布线节点相比,社区识别性能提升的度量。因此,它创建了一个具有相同数量边和度分布的随机网络。

为了有效地最大化这个模块度,Louvain 方法迭代地计算以下步骤:

  1. 模块度优化:节点被迭代地移动,并且对于每个节点,我们计算如果该节点被分配给其邻居的每个社区,模块度Q会有多大的变化。一旦计算了所有值,节点就会被分配到提供最大增加的社区。如果将节点放置在任何其他社区中都不会获得增加,则节点将保留在其原始社区中。此优化过程会一直持续到不再引起变化。

  2. 节点聚合:在第二步中,我们通过将同一社区中的所有节点分组并使用连接两个社区的所有边的总和形成的边来构建一个新的网络。社区内的边也通过自环来计算,这些自环的权重是社区中所有边权重总和的结果。

communities库中可以找到 Louvain 实现的示例,如下代码片段所示:

from communities.algorithms import louvain_method
communities = louvain_method(adj) 

另一种最大化模块度的方法是 Girvan-Newman 算法,该算法基于迭代移除具有最高中介中心性的边(因此连接两个分离的节点簇)以创建连通组件社区。以下是相关的代码:

from communities.algorithms import girvan_newman
communities = girvan_newman(adj, n=2)

注意

后者算法需要计算所有边的介数中心性以移除边。在大型图中,此类计算可能非常昂贵。实际上,Girvan-Newman 算法的扩展性为,其中是边的数量,是节点的数量,并且当处理大型数据集时不应使用。

检测图相似性和图匹配

学习图中相似性的量化度量被认为是关键问题。事实上,这是网络分析的关键步骤,也可以促进许多机器学习问题,例如分类、聚类和排序。例如,许多聚类算法使用相似性概念来确定一个对象是否应该是某个组的成员。

在图域中,找到一个有效的相似度度量对于许多应用来说是一个关键问题。例如,考虑图内一个节点的角色。这个节点可能对于在网络中传播信息或保证网络鲁棒性非常重要:例如,它可能是星形图的中心,或者它可能是团的一部分。在这种情况下,有一个强大的方法来根据节点的角色比较节点将非常有用。例如,你可能对寻找表现出相似角色或呈现类似异常行为的个人感兴趣。你也可以用它来搜索相似的子图或确定网络的兼容性以进行知识迁移。例如,如果你找到一个提高网络鲁棒性的方法,并且你知道这样的网络与另一个网络非常相似,你可以直接将适用于第一个网络的同一种解决方案应用于第二个网络:

图 5.3 – 两个图之间差异的示例

图 5.3 – 两个图之间差异的示例

可以使用几种度量来衡量两个对象之间的相似度(距离)。一些例子包括欧几里得距离曼哈顿距离余弦相似度等等。然而,这些度量可能无法捕捉到正在研究的数据的特定特征,尤其是在非欧几里得结构如图上。看看图 5.3G1G2看起来有多“远”?它们看起来相当相似。但是,如果G2中红色社区缺失的连接导致严重的信息损失,情况会怎样?它们仍然看起来相似吗?

已经提出了几种基于数学概念如图同构编辑距离公共子图的算法方法和启发式方法(我们建议阅读link.springer.com/article/10.1007/s10044-012-0284-8以获取详细综述)。许多这些方法目前在实际应用中被使用,即使它们通常需要指数级高的计算时间来解决一般NP 完全问题(其中NP代表非确定性多项式时间)。因此,找到或学习一个用于测量特定任务中涉及的数据点相似度的度量标准是至关重要的。正是在这里,机器学习(ML)为我们提供了帮助。

在我们之前已经看到的第三章,“无监督图学习”,和第四章,“监督图学习”中的许多算法可能对学习有效的相似度度量很有用。根据它们的使用方式,可以定义一个精确的分类法。在这里,我们提供了一个关于图相似性技术的简单概述。更全面的列表可以在论文《深度图相似性学习:综述》(arxiv.org/pdf/1912.11615.pdf)中找到。它们基本上可以分为三个主要类别,尽管也可以开发出复杂的组合。基于图嵌入的方法使用嵌入技术来获取图的嵌入表示,并利用这种表示来学习相似度函数;基于图核的方法通过测量构成子结构的相似度来定义图之间的相似度;基于图神经网络的方法使用图神经网络GNNs)来联合学习嵌入表示和相似度函数。让我们更详细地看看它们。

基于图嵌入的方法

这些技术试图将图嵌入技术应用于获取节点级或图级表示,并进一步使用这些表示进行相似度学习。例如,DeepWalkNode2Vec可以用来提取有意义的嵌入,然后可以用来定义相似度函数或预测相似度分数。例如,在 Tixier 等人(2015)的研究中,node2vec被用来编码节点嵌入。然后,从这些节点嵌入中获得的二维2D)直方图被传递到一个为图像设计的经典二维卷积神经网络CNN)架构。这种简单而强大的方法使得从许多基准数据集中得出良好的结果成为可能。

基于图核的方法

基于图核的方法在捕捉图之间的相似度方面引起了很大兴趣。这些方法将两个图之间的相似度计算为它们某些子结构相似度的函数。根据它们使用的子结构,存在不同的图核,包括随机游走、最短路径和子图。例如,一种称为深度图核DGK)的方法(Yanardag 等人,2015)将图分解为被视为“单词”的子结构。然后,使用自然语言处理NLP)方法,如连续词袋CBOW)和跳字模型skip-gram)来学习子结构的潜在表示。这样,两个图之间的核基于子结构空间的相似度来定义。

GNN-based methods

随着深度学习(DL)技术的出现,图神经网络(GNNs)已成为学习图上表示的强大新工具。这些强大的模型可以轻松地适应各种任务,包括图相似性学习。此外,它们相对于其他传统图嵌入方法具有关键优势。确实,后者通常在独立阶段学习表示,而在这种类型的方法中,表示学习和目标学习任务是一起进行的。因此,GNN 深度模型可以更好地利用图特征来执行特定的学习任务。我们已经在第三章的“无监督图学习”中看到了使用 GNNs 进行相似性学习的例子,其中训练了一个双分支网络来估计两个图之间的邻近距离。

应用

在许多领域,图上的相似性学习已经取得了有希望的结果。在化学和生物信息学中可能找到重要的应用,例如,用于寻找与查询化合物最相似的化学物质,如图中左侧所示。在神经科学中,相似性学习方法已经开始应用于测量多个受试者之间脑网络的相似性,从而允许进行新颖的临床研究,以研究脑部疾病。

图 5.4 – 图如何有助于表示各种对象的示例:(a)两种化学物质的差异;(b)两种人类姿态的差异

图 5.4 – 图如何有助于表示各种对象的示例:(a)两种化学物质的差异;(b)两种人类姿态的差异

图相似性学习在计算机安全领域也得到了探索,其中已经提出了新颖的方法来检测软件系统中的漏洞以及硬件安全问题。最近,观察到一种趋势,即应用此类解决方案来解决计算机视觉问题。一旦将图像转换为图数据的挑战性问题得到解决,确实可以提出一些有趣的方法来解决视频序列中的人类动作识别和场景中的物体匹配等问题(如图 5.4 的右侧所示)。

摘要

在本章中,我们学习了如何使用基于图的机器学习技术来解决许多不同的问题。

尤其是我们可以看到,相同的算法(或其略微修改的版本)可以适应解决看似非常不同的任务,例如链接预测、社区检测和图相似性学习。我们也看到,每个问题都有其独特的特点,研究人员已经利用这些特点来设计更复杂的解决方案。

在下一章中,我们将探讨使用机器学习解决的问题的实际问题。

第三部分 – 图机器学习的高级应用

在本节中,读者将通过将这些方法应用于实际案例来获得对前几章中概述的方法的更实际的知识,并学习如何将方法扩展到结构化和非结构化数据集。

本节包括以下章节:

  • 第六章, 社交网络图

  • 第七章, 使用图进行文本分析和自然语言处理

  • 第八章, 信用卡交易图分析

  • 第九章, 构建数据驱动的图增强应用程序

  • 第十章, 图上的新趋势

第六章:社交网络图

社交网络网站的增长一直是数字媒体中最活跃的趋势之一。自 20 世纪 90 年代末第一批社交应用发布以来,它们吸引了数十亿活跃用户,其中许多人已经将数字社交互动融入他们的日常生活中。Facebook、Twitter、Instagram 等社交网络正在推动新的沟通方式。用户可以在社交网络上分享想法、发布更新和反馈,或在活动中参与并分享他们的更广泛兴趣。

此外,社交网络构成了研究用户行为、解释人与人之间的互动以及预测他们兴趣的巨大信息来源。将它们结构化为图,其中顶点对应于一个人,边代表他们之间的连接,这使得一个强大的工具来提取有用的知识。

然而,由于变量参数众多,理解驱动社交网络演变的动态是一个复杂的问题。

在本章中,我们将讨论如何使用图论分析 Facebook 社交网络,以及如何使用机器学习解决诸如链接预测和社区检测等有用的问题。

本章将涵盖以下主题:

  • 数据集概述

  • 网络拓扑和社区检测

  • 用于监督和无监督任务的嵌入

技术要求

我们将使用带有Python 3.8 的Jupyter笔记本进行所有练习。以下是需要使用pip安装的 Python 库列表,以便本章使用:例如,在命令行中运行pip install networkx==2.5

Jupyter==1.0.0
networkx==2.5
scikit-learn==0.24.0 
numpy==1.19.2 
node2vec==0.3.3 
tensorflow==2.4.1 
stellargraph==1.2.1
communities==2.2.0 
git+https://github.com/palash1992/GEM.git

在本章的其余部分,除非明确说明,否则我们将使用以下 Python 命令的结果来指代nxpdnpimport networkx as nximport pandas as pdimport numpy as np

所有与本章相关的代码文件均可在github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter06找到。

数据集概述

我们将使用社交圈子 SNAP Facebook 公共数据集,来自斯坦福大学(snap.stanford.edu/data/ego-Facebook.html)。

数据集是通过从调查参与者收集 Facebook 用户信息创建的。从 10 个用户中创建了自我网络。每个用户被要求识别所有他们朋友所属的圈子(朋友列表)。平均而言,每个用户在他们的自我网络中识别了 19 个圈子,其中每个圈子平均有 22 个朋友。

对于每个用户,收集了以下信息:

  • :如果两个用户在 Facebook 上是朋友,则存在边。

  • 如果用户在其个人资料中具有此属性则为1,否则为0。由于特征名称会泄露私人数据,因此已对特征进行匿名化。

然后将 10 个 ego 网络统一到一个我们将要研究的单个图中。

数据集下载

可以使用以下 URL 检索数据集:snap.stanford.edu/data/ego-Facebook.html。特别是,可以下载三个文件:facebook.tar.gzfacebook_combined.txt.gzreadme-Ego.txt。让我们分别检查每个文件:

  • facebook.tar.gz:这是一个包含四个文件的存档,每个nodeId.extension对应一个文件,其中nodeId是 ego 用户的节点 ID,extension可以是edgescirclesfeategofeatfeatnames。以下提供更多详细信息:

    a. nodeId.edges:这包含nodeId节点网络的边列表。

    b. nodeId.circles:这包含多个行(每个圈一行)。每行包含一个名称(圈名称)后跟一系列节点 ID。

    c. nodeId.feat:这包含 ego 网络中每个节点的特征(如果nodeId具有该特征则为0,否则为1)。

    d. nodeId.egofeat:这包含 ego 用户的特征。

    e. nodeId.featname:这包含特征名称。

  • facebook_combined.txt.gz:这是一个包含单个文件facebook_combined.txt的存档,它是所有 ego 网络合并后的边列表。

  • readme-Ego.txt:这包含之前提到的文件的描述。

请自行查看这些文件。强烈建议在开始任何机器学习任务之前探索并尽可能熟悉数据集。

使用 networkx 加载数据集

我们分析的第一步将是使用networkx加载聚合 ego 网络。正如我们在前面的章节中所见,networkx在图分析方面非常强大,并且鉴于数据集的大小,它将是我们在本章中进行分析的完美工具。然而,对于具有数十亿节点和边的更大社交网络图,可能需要更具体的工具来加载和处理它们。我们将在第九章中介绍用于扩展分析的工具和技术,构建数据驱动的图应用

正如我们所见,组合 ego 网络表示为边列表。我们可以使用networkx从边列表创建一个无向图,如下所示:

G = nx.read_edgelist("facebook_combined.txt", create_using=nx.Graph(), nodetype=int)

让我们打印一些关于图的基本信息:

print(nx.info(G))

输出应该如下所示:

Name: 
Type: Graph
Number of nodes: 4039
Number of edges: 88234
Average degree:  43.6910

如我们所见,聚合网络包含4039个节点和88234条边。这是一个相当紧密连接的网络,边的数量比节点多 20 多倍。实际上,聚合网络中应该存在多个簇(可能是每个 ego 用户的小世界)。

绘制网络也将帮助我们更好地理解我们将要分析的内容。我们可以使用 networkx 如下绘制图:

nx.draw_networkx(G, pos=spring_pos, with_labels=False, node_size=35)

输出结果应如下所示:

![图 6.1 – Facebook 自我网络聚合图片 B16069_06_01.jpg

图 6.1 – Facebook 自我网络聚合

我们可以观察到高度互联的中心节点。从社会网络分析的角度来看,这很有趣,因为它们可能是可以进一步研究以更好地理解个人与其世界关系结构的潜在社会机制的结果。

在继续我们的分析之前,让我们保存网络中自我用户节点的 ID。我们可以从包含在 facebook.tar.gz 存档中的文件中检索它们。

首先,解压存档。提取的文件夹将被命名为 facebook。让我们运行以下 Python 代码,通过取每个文件名的第一部分来检索 ID:

ego_nodes = set([int(name.split('.')[0]) for name in os.listdir("facebook/")])

我们现在已准备好分析图。特别是,在下一节中,我们将通过检查其属性来更好地理解图的结构。这将帮助我们更清晰地了解其拓扑及其相关特征。

网络拓扑和社区检测

理解网络的拓扑以及其节点的角色是分析社交网络的关键步骤。重要的是要记住,在这种情况下,节点实际上是用户,每个用户都有自己的兴趣、习惯和行为。这种知识在执行预测和/或寻找洞察时将非常有用。

我们将使用 networkx 来计算我们在 第一章图形入门 中看到的大部分有用度量。我们将尝试对它们进行解释,以收集关于图的信息。让我们像往常一样开始,导入所需的库并定义一些我们将贯穿整个代码的变量:

import os
import math
import numpy as np
import networkx as nx
import matplotlib.pyplot as plt
default_edge_color = 'gray'
default_node_color = '#407cc9'
enhanced_node_color = '#f5b042'
enhanced_edge_color = '#cc2f04'

我们现在可以继续分析。

拓扑概述

正如我们之前所见,我们的组合网络有 4,039 个节点和超过 80,000 条边。我们将计算的下一个度量是配对性。它将揭示用户倾向于与具有相似度数用户连接的趋势。我们可以这样做:

assortativity = nx.degree_pearson_correlation_coefficient(G)

输出结果应如下所示:

0.06357722918564912

在这里,我们可以观察到正配对性,这很可能表明联系紧密的个人会与其他联系紧密的个人联系(正如我们在 第一章图形入门 中所见)。这是预期的,因为每个圈内的用户可能倾向于彼此高度连接。

传递性也有助于更好地理解个人是如何连接的。回想一下,传递性表示两个有共同朋友的人成为朋友的平均概率:

t = nx.transitivity(G)

输出结果应如下所示:

0.5191742775433075

这里我们有大约 50%的概率,两个朋友可以或不可以有共同的朋友。

通过计算平均聚类系数,这一观察也得到了证实。确实,它可以被认为是传递性的另一种定义:

aC = nx.average_clustering(G)

输出应该是这样的:

0.6055467186200876

注意到聚类系数往往高于传递性。实际上,根据定义,它对度数低的顶点赋予更多的权重,因为它们有有限的可能邻居对(局部聚类系数的分母)。

节点中心性

一旦我们对整体拓扑有了更清晰的认识,我们就可以通过调查网络中每个个体的重要性来继续。正如我们在第一章,“开始使用图”中看到的,重要性的第一个定义可以通过介数中心性指标给出。它衡量有多少最短路径穿过一个给定的节点,从而给出一个关于该节点在信息在网络中传播中的中心性的印象。我们可以使用以下方法来计算它:

bC = nx.betweenness_centrality(G)
 np.mean(list(bC.values()))

输出应该是这样的:

0.0006669573568730229

平均介数中心性相当低,考虑到网络中大量非桥接节点,这是可以理解的。然而,通过图形的视觉检查,我们可以收集更好的见解。特别是,我们将通过增强具有最高介数中心性的节点来绘制组合自我网络。让我们为此定义一个适当的函数:

def draw_metric(G, dct, spring_pos):
  top = 10
  max_nodes =  sorted(dct.items(), key=lambda v: -v[1])[:top]
  max_keys = [key for key,_ in max_nodes]
  max_vals = [val*300 for _, val in max_nodes]
  plt.axis("off")
  nx.draw_networkx(G,
                   pos=spring_pos,
                   cmap='Blues',
                   edge_color=default_edge_color,
                   node_color=default_node_color,
                   node_size=3,
                   alpha=0.4,
                   with_labels=False)
  nx.draw_networkx_nodes(G,
                         pos=spring_pos,
                         nodelist=max_keys,
                         node_color=enhanced_edge_color,
                         node_size=max_vals)

现在我们按照以下方式调用它:

draw_metric(G,bC,spring_pos)

输出应该是这样的:

![图 6.2 – 介数中心性img/B16069_06_02.jpg

图 6.2 – 介数中心性

让我们也检查每个节点的度中心性。由于这个指标与节点的邻居数量相关,我们将更清楚地了解节点之间是如何相互连接的:

deg_C = nx.degree_centrality(G)
 np.mean(list(deg_C.values()))
draw_metric(G,deg_C,spring_pos)

输出应该是这样的:

0.010819963503439287

这里是度中心性的表示:

![图 6.3 – 度中心性img/B16069_06_03.jpg

图 6.3 – 度中心性

最后,让我们也看看亲近中心性。这将帮助我们理解节点之间在最短路径意义上的接近程度:

clos_C = nx.closeness_centrality(G)
 np.mean(list(clos_C.values()))
draw_metric(G,clos_C,spring_pos)

输出应该是这样的:

0.2761677635668376

这里是亲近中心性的表示:

![图 6.4 – 亲近中心性img/B16069_06_04.jpg

图 6.4 – 亲近中心性

从中心性分析中,有趣的是观察到每个中心节点似乎都是某种社区的一部分(这是合理的,因为中心节点可能对应于网络的自我节点)。同样有趣的是注意到存在大量高度相互连接的节点(特别是从亲近中心性分析中)。因此,让我们在分析的下个部分中识别这些社区。

社区检测

由于我们正在进行社会网络分析,探索社交网络中最有趣的图结构之一:社区,是值得的。如果你使用 Facebook,你的朋友很可能反映了你生活的不同方面:来自教育环境的朋友(高中、大学等),来自你每周足球比赛的朋友,你在派对上遇到的朋友等等。

社会网络分析的一个有趣方面是自动识别这样的群体。这可以通过自动推断其拓扑属性或半自动地利用一些先验洞察来完成。

一个好的标准是尽量减少社区内边缘(连接不同社区成员的边缘)的数量,同时最大化社区间边缘(连接同一社区成员的边缘)的数量。

我们可以在networkx中这样做:

import community
parts = community.best_partition(G)
 values = [parts.get(node) for node in G.nodes()]
n_sizes = [5]*len(G.nodes())
plt.axis("off")
nx.draw_networkx(G, pos=spring_pos, cmap=plt.get_cmap("Blues"), edge_color=default_edge_color, node_color=values, node_size=n_sizes, with_labels=False)

输出应该是这样的:

图 6.5 – 使用 networkx 检测到的社区

图 6.5 – 使用 networkx 检测到的社区

在这个背景下,研究 ego 用户是否在检测到的社区中占据某些角色也是有趣的。让我们按照以下方式增强 ego 用户节点的大小和颜色:

for node in ego_nodes:
   n_sizes[node] = 250
nodes = nx.draw_networkx_nodes(G,spring_pos,ego_nodes,node_color=[parts.get(node) for node in ego_nodes])
 nodes.set_edgecolor(enhanced_node_color)

输出应该是这样的:

图 6.6 – 使用 networkx 检测到的社区,增强 ego 用户节点大小

图 6.6 – 使用 networkx 检测到的社区,增强 ego 用户节点大小

有趣的是注意到一些 ego 用户属于同一个社区。可能的情况是 ego 用户是 Facebook 上的实际朋友,因此他们的 ego 网络部分是共享的。

我们现在已经完成了对图结构的基本理解。我们知道网络中可以识别出一些重要的节点。我们也看到了这些节点所属的明确社区的存在。在进行下一部分分析时,即应用机器学习方法进行监督和非监督任务时,请记住这些观察结果。

监督和非监督任务的嵌入

现在,社交媒体是信息来源中最有趣和最丰富的之一。每天都有成千上万的新连接出现,新用户加入社区,数十亿条帖子被分享。图从数学上表示了所有这些互动,有助于对这种自发和无结构的流量进行排序。

当处理社交图时,有许多有趣的问题可以使用机器学习来解决。在正确的设置下,可以从这些大量数据中提取有用的见解,以改进您的营销策略,识别具有危险行为的用户(例如,恐怖分子网络),以及预测用户阅读您的新帖子的可能性。

具体来说,链接预测是这个领域中最有趣和最重要的研究课题之一。根据你在社交图中的“连接”代表什么,通过预测未来的边,你将能够预测你的下一个建议的朋友、下一个建议的电影以及你可能会购买的产品。

如我们已在第五章中看到的,“图上机器学习的问题”,链接预测任务旨在预测两个节点之间未来连接的可能性,并且可以使用多种机器学习算法来解决。

在接下来的示例中,我们将应用监督和无监督的机器学习图嵌入算法来预测 SNAP Facebook 社交图上的未来连接。此外,我们还将评估节点特征在预测任务中的贡献。

任务准备

为了执行链接预测任务,有必要准备我们的数据集。这个问题将被视为一个监督任务。节点对将被提供给每个算法作为输入,而目标将是二进制,即如果两个节点在网络中实际上连接,则为连接,否则为不连接

由于我们旨在将此问题作为监督学习任务,我们需要创建一个训练和测试数据集。因此,我们将创建两个具有相同节点数但边数不同的新子图(因为一些边将被移除并作为训练/测试算法的正样本处理)。

stellargraph库提供了一个有用的工具来分割数据并创建训练和测试简化的子图。这个过程与我们已在第五章中看到的类似,“图上机器学习的问题”:

from sklearn.model_selection import train_test_split
from stellargraph.data import EdgeSplitter
from stellargraph import StellarGraph
edgeSplitter = EdgeSplitter(G) 
graph_test, samples_test, labels_test = edgeSplitter.train_test_split(p=0.1, method="global", seed=24)
edgeSplitter = EdgeSplitter(graph_test, G)
 graph_train, samples_train, labels_train = edgeSplitter.train_test_split(p=0.1, method="global", seed=24)

我们使用EdgeSplitter类从G中提取所有边的部分(p=10%),以及相同数量的负边,以获得一个简化的图graph_testtrain_test_split方法还会返回一个节点对列表samples_test(其中每个对对应于图中存在或不存在的边),以及与samples_test列表长度相同的二进制目标列表labels_test。然后,从这样一个简化的图中,我们重复操作以获得另一个简化的图graph_train,以及相应的samples_trainlabels_train列表。

我们将比较三种预测缺失边的不同方法:

  • 方法 1:将使用 node2vec 来学习一个无监督的节点嵌入。学到的嵌入将被用作监督分类算法的输入,以确定输入对是否实际上连接。

  • 方法 2:将使用基于图神经网络的算法 GraphSAGE 来联合学习嵌入并执行分类任务。

  • 方法 3:将从图中提取手工特征,并将其与节点 ID 一起用作监督分类器的输入。

让我们更详细地分析它们。

基于 node2vec 的链接预测

这里提出的方法分为几个步骤:

  1. 我们使用 node2vec 从训练图中生成节点嵌入,而不需要监督。这可以通过node2vec Python 实现来完成,正如我们在第五章**, 图上机器学习的问题中已经看到的:

    from node2vec import Node2Vec
    node2vec = Node2Vec(graph_train) 
    model = node2vec.fit()
    
  2. 然后,我们使用HadamardEmbedder为每个嵌入节点的对生成嵌入。这些特征向量将被用作训练分类器的输入:

    from node2vec.edges import HadamardEmbedder
    edges_embs = HadamardEmbedder(keyed_vectors=model.wv)
     train_embeddings = [edges_embs[str(x[0]),str(x[1])] for x in samples_train]
    
  3. 是时候训练我们的监督分类器了。我们将使用 RandomForest 分类器,这是一种基于决策树的强大集成算法:

    from sklearn.ensemble import RandomForestClassifier 
    from sklearn import metrics 
    rf = RandomForestClassifier(n_estimators=10)
     rf.fit(train_embeddings, labels_train);
    
  4. 最后,让我们应用训练好的模型来创建测试集的嵌入:

    edges_embs = HadamardEmbedder(keyed_vectors=model.wv) test_embeddings = [edges_embs[str(x[0]),str(x[1])] for x in samples_test]
    
  5. 现在,我们已经准备好使用训练好的模型在测试集上进行预测:

    y_pred = rf.predict(test_embeddings) 
    print('Precision:', metrics.precision_score(labels_test, y_pred)) 
    print('Recall:', metrics.recall_score(labels_test, y_pred)) 
    print('F1-Score:', metrics.f1_score(labels_test, y_pred)) 
    
  6. 输出应该是这样的:

    Precision: 0.9701333333333333
    Recall: 0.9162573983125551
    F1-Score: 0.9424260086781945
    

完全不错!我们可以观察到,基于 node2vec 的嵌入已经为在结合的 Facebook ego 网络上预测链接提供了一个强大的表示。

基于 GraphSAGE 的链接预测

接下来,我们将使用 GraphSAGE 来学习节点嵌入并分类边。我们将构建一个两层 GraphSAGE 架构,它给定标记的节点对,输出一对节点嵌入。然后,将使用全连接神经网络来处理这些嵌入并产生链接预测。请注意,GraphSAGE 模型和全连接网络将连接并端到端训练,以便嵌入学习阶段受到预测的影响。

无特征方法

在开始之前,我们可以回顾一下第四章,监督图学习第五章图上机器学习的问题,其中 GraphSAGE 需要节点描述符(特征)。这些特征可能在你的数据集中可用,也可能不可用。让我们从不考虑可用的节点特征开始我们的分析。在这种情况下,一个常见的方法是为图中的每个节点分配一个长度为|V|(图中节点的数量)的 one-hot 特征向量,其中只有对应给定节点的单元格为 1,而其余单元格为 0。

这可以在 Python 和networkx中完成如下:

eye = np.eye(graph_train.number_of_nodes())
fake_features = {n:eye[n] for n in G.nodes()}
nx.set_node_attributes(graph_train, fake_features, "fake")
eye = np.eye(graph_test.number_of_nodes())
fake_features = {n:eye[n] for n in G.nodes()}
nx.set_node_attributes(graph_test, fake_features, "fake")

在前面的代码片段中,我们做了以下操作:

  1. 我们创建了一个大小为|V|的标识矩阵。矩阵的每一行是我们为图中每个节点所需的 one-hot 向量。

  2. 然后,我们创建了一个 Python 字典,其中对于每个nodeID(用作键),我们分配之前创建的标识矩阵的对应行。

  3. 最后,将字典传递给networkxset_node_attributes函数,以将“假”特征分配给networkx图中的每个节点。

注意,这个过程在训练图和测试图上都会重复。

下一步将是定义用于向模型提供数据的生成器。我们将使用 stellargraphGraphSAGELinkGenerator 来完成这项工作,它本质上向模型提供节点对作为输入:

from stellargraph.mapper import GraphSAGELinkGenerator
batch_size = 64
num_samples = [4, 4]
# convert graph_train and graph_test for stellargraph
sg_graph_train = StellarGraph.from_networkx(graph_train, node_features="fake")
sg_graph_test = StellarGraph.from_networkx(graph_test, node_features="fake")
train_gen = GraphSAGELinkGenerator(sg_graph_train, batch_size, num_samples)
 train_flow = train_gen.flow(samples_train, labels_train, shuffle=True, seed=24)
test_gen = GraphSAGELinkGenerator(sg_graph_test, batch_size, num_samples)
 test_flow = test_gen.flow(samples_test, labels_test, seed=24)

注意,我们还需要定义 batch_size(每个 minibatch 的输入数量)以及 GraphSAGE 应考虑的第一跳和第二跳邻居样本的数量。

最后,我们准备好创建模型:

from stellargraph.layer import GraphSAGE, link_classification
from tensorflow import keras
layer_sizes = [20, 20]
graphsage = GraphSAGE(layer_sizes=layer_sizes, generator=train_gen, bias=True, dropout=0.3)
x_inp, x_out = graphsage.in_out_tensors()
# define the link classifier
prediction = link_classification(output_dim=1, output_act="sigmoid", edge_embedding_method="ip")(x_out)
model = keras.Model(inputs=x_inp, outputs=prediction)
model.compile(
    optimizer=keras.optimizers.Adam(lr=1e-3),
    loss=keras.losses.mse,
    metrics=["acc"],
)

在前面的代码片段中,我们创建了一个具有两个大小为 20 的隐藏层(每个层都有一个偏置项和用于减少过拟合的 dropout 层)的 GraphSAGE 模型。然后,模块中 GraphSAGE 部分的输出与一个 link_classification 层连接,该层使用节点嵌入对(GraphSAGE 的输出)使用二元运算符(内积;在我们的例子中是 ip)来产生边嵌入,并最终通过一个全连接神经网络进行分类。

该模型通过 Adam 优化器(学习率 = 1e-3)使用均方误差作为损失函数进行优化。

让我们训练模型 10 个 epoch:

epochs = 10
history = model.fit(train_flow, epochs=epochs, validation_data=test_flow)

输出应该如下所示:

Epoch 18/20
loss: 0.4921 - acc: 0.8476 - val_loss: 0.5251 - val_acc: 0.7884
Epoch 19/20
loss: 0.4935 - acc: 0.8446 - val_loss: 0.5247 - val_acc: 0.7922
Epoch 20/20
loss: 0.4922 - acc: 0.8476 - val_loss: 0.5242 - val_acc: 0.7913

训练完成后,让我们在测试集上计算性能指标:

from sklearn import metrics 
y_pred = np.round(model.predict(train_flow)).flatten()
print('Precision:', metrics.precision_score(labels_train, y_pred)) 
print('Recall:', metrics.recall_score(labels_train, y_pred))  print('F1-Score:', metrics.f1_score(labels_train, y_pred)) 

输出应该如下所示:

Precision: 0.7156476303969199
Recall: 0.983125550938169
F1-Score: 0.8283289124668435

如我们所观察到的,性能低于基于 node2vec 的方法。然而,我们还没有考虑真实的节点特征,这可能会是一个巨大的信息来源。让我们在接下来的测试中这样做。

引入节点特征

提取组合自我网络的节点特征的过程相当冗长。这是因为,正如我们在本章的第一部分所解释的,每个自我网络都是使用几个文件以及所有特征名称和值来描述的。我们已经编写了有用的函数来解析所有自我网络以提取节点特征。你可以在 GitHub 仓库提供的 Python 笔记本中找到它们的实现。在这里,我们只需简要总结一下它们是如何工作的:

  • load_features 函数解析每个自我网络并创建两个字典:

    a. feature_index,将数值索引映射到特征名称

    b. inverted_feature_indexes,将名称映射到数值索引

  • parse_nodes 函数接收组合自我网络 G 和自我节点 ID。然后,网络中的每个自我节点都被分配了之前使用 load_features 函数加载的相应特征。

让我们按顺序调用它们来加载组合自我网络中每个节点的特征向量:

load_features()
parse_nodes(G, ego_nodes)

我们可以通过打印网络中一个节点的信息(例如,ID 为0的节点)来轻松检查结果:

print(G.nodes[0])

输出应该如下所示:

{'features': array([1., 1., 1., ..., 0., 0., 0.])}

如我们所观察到的,节点包含一个包含名为 features 的键的字典。相应的值是分配给该节点的特征向量。

现在我们准备重复之前用于训练 GraphSAGE 模型的相同步骤,这次在将networkx图转换为StellarGraph格式时使用features作为键:

sg_graph_train = StellarGraph.from_networkx(graph_train, node_features="features")
sg_graph_test = StellarGraph.from_networkx(graph_test, node_features="features")

最后,就像我们之前做的那样,我们创建生成器,编译模型,并对其进行 10 个 epoch 的训练:

train_gen = GraphSAGELinkGenerator(sg_graph_train, batch_size, num_samples)
train_flow = train_gen.flow(samples_train, labels_train, shuffle=True, seed=24)
test_gen = GraphSAGELinkGenerator(sg_graph_test, batch_size, num_samples)
test_flow = test_gen.flow(samples_test, labels_test, seed=24)
layer_sizes = [20, 20]
graphsage = GraphSAGE(layer_sizes=layer_sizes, generator=train_gen, bias=True, dropout=0.3)
x_inp, x_out = graphsage.in_out_tensors()
prediction = link_classification(output_dim=1, output_act="sigmoid", edge_embedding_method="ip")(x_out)
model = keras.Model(inputs=x_inp, outputs=prediction)
model.compile(
    optimizer=keras.optimizers.Adam(lr=1e-3),
    loss=keras.losses.mse,
    metrics=["acc"],
)
epochs = 10
history = model.fit(train_flow, epochs=epochs, validation_data=test_flow)

注意,我们正在使用相同的超参数(包括层数、批量大小和学习率)以及随机种子,以确保模型之间公平的比较。

输出应该如下所示:

Epoch 18/20
loss: 0.1337 - acc: 0.9564 - val_loss: 0.1872 - val_acc: 0.9387
Epoch 19/20
loss: 0.1324 - acc: 0.9560 - val_loss: 0.1880 - val_acc: 0.9340
Epoch 20/20
loss: 0.1310 - acc: 0.9585 - val_loss: 0.1869 - val_acc: 0.9365

让我们评估模型性能:

from sklearn import metrics 
y_pred = np.round(model.predict(train_flow)).flatten()
print('Precision:', metrics.precision_score(labels_train, y_pred)) 
print('Recall:', metrics.recall_score(labels_train, y_pred)) 
print('F1-Score:', metrics.f1_score(labels_train, y_pred))

我们可以检查输出:

Precision: 0.7895418326693228
Recall: 0.9982369978592117
F1-Score: 0.8817084700517213

如我们所见,引入真实节点特征带来了良好的改进,即使最佳性能仍然是使用 node2vec 方法实现的。

最后,我们将评估一个浅层嵌入方法,其中将使用手工特征来训练一个监督分类器。

用于链接预测的手工特征

正如我们在第四章,“监督图学习”中已经看到的,浅层嵌入方法代表了一种简单而强大的处理监督任务的方法。基本上,对于每个输入边,我们将计算一组指标,这些指标将被作为输入提供给分类器。

在这个例子中,对于每个表示为节点对 (u,v) 的输入边,将考虑四个指标,即以下指标:

  • 如果 u 无法从 v 达到,则使用0

  • Jaccard 系数:给定一对节点 (u,v),定义为节点 uv 的邻居集合的交集与并集的比值。形式上,设 为节点 u 的邻居集合, 为节点 v 的邻居集合:

  • u 中心性:为节点 v 计算的度中心性。

  • v 中心性:为节点 u 计算的度中心性。

  • u 社区:使用 Louvain 启发式算法分配给节点 u 的社区 ID。

  • v 社区:使用 Louvain 启发式算法分配给节点 v 的社区 ID。

我们已经编写了一个有用的函数,使用 Python 和networkx来计算这些指标。你可以在 GitHub 仓库提供的 Python 笔记本中找到实现。

让我们计算训练集和测试集中每个边的特征:

feat_train = get_hc_features(graph_train, samples_train, labels_train)
feat_test = get_hc_features(graph_test, samples_test, labels_test)

在提出的浅层方法中,这些特征将直接用作Random Forest分类器的输入。我们将使用其scikit-learn实现如下:

from sklearn.ensemble import RandomForestClassifier 
from sklearn import metrics 
rf = RandomForestClassifier(n_estimators=10) 
rf.fit(feat_train, labels_train); 

前面的行自动实例化和训练了一个使用我们之前计算过的边缘特征的 RandomForest 分类器。我们现在可以按照以下方式计算性能:

y_pred = rf.predict(feat_test)
print('Precision:', metrics.precision_score(labels_test, y_pred))
 print('Recall:', metrics.recall_score(labels_test, y_pred)) print('F1-Score:', metrics.f1_score(labels_test, y_pred)) 

输出将如下所示:

Precision: 0.9636952636282395
Recall: 0.9777853337866939
F1-Score: 0.9706891701828411

意想不到的是,基于手工特征的浅层方法比其他方法表现更好。

结果总结

在前面的例子中,我们在有监督和无监督的情况下,对学习算法进行了训练,用于链接预测的有用嵌入。在下表中,我们总结了结果:

表 6.1 – 链接预测任务所取得的结果总结

表 6.1 – 链接预测任务所取得的结果总结

表 6.1所示,基于 node2vec 的方法已经能够在没有监督和每个节点信息的情况下实现高水平的表现。这样的高结果可能与组合自我网络的特定结构有关。由于网络的高次模量(因为它由多个自我网络组成),预测两个用户是否会连接可能高度相关于两个候选节点在网络上连接的方式。例如,可能存在一种系统性的情况,其中两个用户,都连接到同一自我网络中的几个用户,有很高的可能性也会连接。另一方面,属于不同自我网络或非常遥远的用户不太可能连接,这使得预测任务变得更容易。这也得到了使用浅层方法获得的高结果的支持。

这种情况可能会令人困惑,相反,对于像 GraphSAGE 这样的更复杂算法,尤其是当涉及到节点特征时。例如,两个用户可能拥有相似的兴趣,使他们非常相似。然而,他们可能属于不同的自我网络,其中相应的自我用户生活在世界上的两个非常不同的部分。因此,在原则上应该连接的相似用户并没有连接。然而,也有可能这样的算法正在预测未来的某些东西。回想一下,组合自我网络是特定时间段内特定情况的标记。谁知道它现在可能如何演变呢!

解释机器学习算法可能是机器学习本身最有趣的挑战。因此,我们应该始终谨慎地解释结果。我们的建议是始终深入数据集,并尝试解释你的结果。

最后,重要的是要指出,每个算法都不是为了这次演示而调整的。通过适当调整每个超参数,可以获得不同的结果,我们强烈建议你尝试这样做。

总结

在本章中,我们看到了机器学习如何有助于解决社交网络图上的实际机器学习任务。此外,我们还看到了如何在 SNAP Facebook 组合自我网络上预测未来的连接。

我们回顾了图分析概念,并使用图衍生指标来收集关于社交图的洞察。然后,我们在链接预测任务上对几个机器学习算法进行了基准测试,评估了它们的性能,并试图对它们进行解释。

在下一章中,我们将关注如何使用类似的方法通过文本分析和自然语言处理来分析文档集合。

第七章:使用图进行文本分析和自然语言处理

现在,大量的信息以自然书面语言的形式以文本的形式存在。你现在正在阅读的这本书就是一个例子。你每天早上阅读的新闻,你之前发送/阅读的推文或 Facebook 帖子,你为学校作业撰写的报告,我们持续撰写的电子邮件——这些都是我们通过书面文档和文本交换信息的例子。这无疑是间接互动中最常见的方式,与直接互动如谈话或手势相反。因此,能够利用这类信息并从文档和文本中提取见解至关重要。

现今以这种形式存在的海量信息决定了自然语言处理(NLP)领域的大发展和近期进步。

在本章中,我们将向您展示如何处理自然语言文本,并回顾一些基本模型,这些模型使我们能够结构化文本信息。使用从文档语料库中提取的信息,我们将向您展示如何创建可以使用我们在前几章中看到的一些技术进行分析的网络。特别是,使用标记语料库,我们将向您展示如何开发监督(用于将文档分类到预定的主题的分类模型)和无监督(社区检测以发现新主题)算法。

本章涵盖以下主题:

  • 快速概述数据集

  • 理解 NLP 中使用的主要概念和工具

  • 从文档语料库创建图

  • 构建文档主题分类器

技术要求

我们将在所有练习中使用Python 3.8。以下是在本章中必须使用 pip 安装的 Python 库列表。例如,在命令行上运行pip install networkx==2.4等。

networkx==2.4 
scikit-learn==0.24.0
stellargraph==1.2.1
spacy==3.0.3
pandas==1.1.3
numpy==1.19.2
node2vec==0.3.3
Keras==2.0.2
tensorflow==2.4.1
communities==2.2.0
gensim==3.8.3
matplotlib==3.3.4
nltk==3.5
fasttext==0.9.2

与本章相关的所有代码文件均可在github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter07找到。

快速概述数据集

为了向您展示如何处理文档语料库以提取相关信息,我们将使用一个来自 NLP 领域知名基准的数据集:所谓的路透社-21578。原始数据集包括一组在 1987 年发布的 21,578 篇金融路透社新闻稿,这些新闻稿被汇编并按类别索引。原始数据集具有非常倾斜的分布,一些类别仅在训练集或测试集中出现。因此,我们将使用一个修改后的版本,称为ApteMod,也称为路透社-21578 分布 1.0,它具有较小的倾斜分布,并且在训练集和测试数据集之间具有一致的标签。

尽管这些文章有些过时,但该数据集已被用于大量关于 NLP 的论文中,并且仍然代表了一个常用于算法基准的数据集。

事实上,Reuters-21578 包含足够多的文档,可以进行有趣的后期处理和洞察。如今可以轻易找到包含更多文档的语料库(例如,查看github.com/niderhoff/nlp-datasets以了解最常见的语料库概述),但它们可能需要更大的存储和计算能力,以便进行处理。在第九章**,构建数据驱动、图增强应用程序中,我们将向您展示一些可以用来扩展您的应用程序和分析的工具和库。

Reuters-21578 数据集中的每个文档都提供了一组标签,代表其内容。这使得它成为测试监督和无监督算法的完美基准。可以使用nltk库轻松下载 Reuters-21578 数据集(这是一个非常有用的库,用于文档的后期处理):

from nltk.corpus import reuters
corpus = pd.DataFrame([
    {"id": _id,
     "text": reuters.raw(_id).replace("\n", ""), 
     "label": reuters.categories(_id)}
    for _id in reuters.fileids()
])

如您从检查corpus数据框中可以看到的,ID 的格式为training/{ID}test/{ID},这使得很清楚哪些文档应该用于训练和测试。首先,让我们列出所有主题,并使用以下代码查看每个主题有多少文档:

from collections import Counter
Counter([label for document_labels in corpus["label"] for label in document_labels]).most_common()

Reuters-21578 数据集包含 90 个不同的主题,类别之间存在着显著的不平衡,其中最常见类别中有近 37%的文档,而在五个最少见类别中每个类别只有 0.01%。正如您从检查文本中可以看到的,一些文档中嵌入了一些换行符,这些换行符在第一次文本清理阶段可以很容易地被移除:

corpus["clean_text"] = corpus["text"].apply(
    lambda x: x.replace("\n", "")
)

现在我们已经将数据加载到内存中,我们可以开始分析它了。在下一小节中,我们将向您展示一些可以用来处理非结构化文本数据的主要工具。它们将帮助您提取结构化信息,以便可以轻松使用。

理解 NLP 中使用的主要概念和工具

在处理文档时,第一个分析步骤无疑是推断文档语言。实际上,用于自然语言处理任务的大多数分析引擎都是在特定语言的文档上训练的,并且应该仅用于这种语言。最近,一些构建跨语言模型的尝试(例如,多语言嵌入fasttext.cc/docs/en/aligned-vectors.htmlgithub.com/google-research/bert/blob/master/multilingual.md)越来越受欢迎,尽管它们在自然语言处理模型中仍然只占一小部分。因此,首先推断语言以便使用正确的下游分析自然语言处理管道是非常常见的。

您可以使用不同的方法来推断语言。一种非常简单但有效的方法是查找语言中最常见的单词(所谓的停用词,如theandbetoof等)并根据它们的频率构建一个分数。然而,它的精确度往往局限于短文本,并且没有利用单词的位置和上下文。另一方面,Python 有许多使用更复杂逻辑的库,允许我们以更精确的方式推断语言。这些库中的一些是fasttextpolyglotlangdetect,仅举几个例子。

例如,在下面的代码中,我们将使用fasttext,它可以通过很少的代码行进行集成,并支持超过 150 种语言。可以使用以下代码片段推断所有文档的语言:

from langdetect import detect
import numpy as np
def getLanguage(text: str):
    try:
        return langdetect.detect(text)
    except:
        return np.nan
corpus["language"] = corpus["text"].apply(langdetect.detect)

正如您将在输出中看到的那样,似乎有一些非英语语言的文档。事实上,这些文档通常要么非常短,要么结构奇特,这意味着它们不是真正的新闻文章。当文档代表人类阅读并标记为新闻的文本时,模型通常非常精确和准确。

现在我们已经推断出了语言,我们可以继续分析管道的语言相关步骤。对于以下任务,我们将使用spaCy,这是一个极其强大的库,它允许我们用很少的代码行嵌入最先进的自然语言处理模型。在用pip install spaCy安装库之后,可以通过使用spaCy下载工具简单地安装它们来集成特定语言模型。例如,以下命令可以用来下载和安装英语模型:

python -m spacy download en_core_web_sm

现在,我们应该已经准备好了可以使用的英语语言模型。让我们看看它能够提供哪些信息。使用 spaCy 非常简单,只需一行代码就可以将计算嵌入为一个非常丰富的信息集。让我们首先将模型应用于路透社语料库中的一个文档:

SUBROTO 表示印度尼西亚支持锡协议延期

能源和矿业部长 Subroto 确认印度尼西亚支持第六次国际锡协定ITA)的延长,但表示新的协议并非必要。应路透社要求,Subroto 在周一澄清了他的声明,称该协议应被允许失效,他表示印度尼西亚准备支持 ITA 的延长。“我们可以支持第六次协议的延长,”他说。“但我们认为第七次协议是不必要的。”除非三分之二的大多数成员投票支持延长,否则第六次 ITA 将于六月底到期。

只需加载模型并将其应用于文本,spacy 就可以轻松应用。

nlp = spacy.load('en_core_web_md')
parsed = nlp(text)

由于许多模型被组合到一个单独的管道中,parsed 对象返回时具有几个字段。这些提供了不同级别的文本结构化。让我们逐一检查它们:

  • spacy 通常工作得相当好。然而,请注意,根据上下文,可能需要进行一些模型调整或规则修改。例如,当您处理包含俚语、表情符号、链接和标签的短文本时,对于文本分割和标记化,更好的选择可能是 TweetTokenizer,它包含在 nltk 库中。根据上下文,我们鼓励您探索其他可能的分割方式。

    spacy 返回的文档中,句子分割可以在 parsed 对象的 sents 属性中找到。可以通过以下代码简单地遍历每个句子的标记:

    for sent in parsed.sents:
        for token in sent:
            print(token)
    

    每个标记都是一个 spaCy Span 对象,它具有指定标记类型和由其他模型引入的进一步特征化的属性。

  • (DET) 通常后面跟着一个名词,等等。当使用 spaCy 时,关于词性标注的信息通常存储在 Span 对象的 label_ 属性中。可用的标签类型可以在 spacy.io/models/en 找到。相反,您可以使用 spacy.explain 函数为给定类型获取一个可读的值。

  • parsed 对象的 ents 属性。spaCy 还提供了一些通过 displacy 模块可视化文本中实体的实用工具:

    displacy.render(parsed, style='ent', jupyter=True)
    

    这导致了以下输出:

图 7.1 – spaCy NER 引擎输出的示例

图 7.1 – spaCy NER 引擎输出的示例

  • spacy 可以用来构建一个可以导航以识别标记之间关系的句法树。正如我们很快就会看到的,当构建知识图谱时,这些信息可能是至关重要的:

图 7.2 – spaCy 提供的句法依存树示例

图 7.2 – spaCy 提供的句法依存树示例

  • 通过 lemma_ 属性访问 Span 对象。

    如前图所示,spaCy 管道可以轻松集成以处理整个语料库并将结果存储在我们的 corpus DataFrame 中:

    nlp = spacy.load('en_core_web_md')
    sample_corpus["parsed"] = sample_corpus["clean_text"]\
        .apply(nlp)
    

这个数据框代表了文档的结构化信息。这将是所有后续分析的基础。在下一节中,我们将展示如何使用此类信息构建图。

从文档语料库中创建图

在本节中,我们将使用上一节中通过不同的文本引擎提取的信息来构建关联不同信息的网络。特别是,我们将关注两种类型的图:

  • 基于知识的图,我们将使用句子的语义意义来推断不同实体之间的关系。

  • 二分图,我们将连接文档和文本中出现的实体。然后,我们将二分图投影到一个同质图中,该图只由文档或实体节点组成。

知识图谱

知识图谱非常有趣,因为它们不仅关联实体,还提供了关系的方向和意义。例如,让我们看看以下关系:

我 (->) 买 (->) 一本书

这与以下关系有显著不同:

我 (->) 卖 (->) 一本书

除了关系类型(购买或销售)之外,还有一个方向也很重要,即主语和宾语不是对称处理的,而是存在执行动作的人和动作的目标之间的差异。

因此,要创建一个知识图谱,我们需要一个能够识别每个句子的主语-谓语-宾语SVO)三元组的函数。这个函数可以应用于语料库中的所有句子;然后,所有三元组可以汇总生成相应的图。

SVO 提取器可以建立在 spaCy 模型提供的丰富之上。确实,依赖树解析器提供的标记对于区分主句及其从句以及识别 SOV 三元组非常有帮助。业务逻辑可能需要考虑一些特殊情况(如并列连词、否定和介词处理),但这可以通过一组规则来编码。此外,这些规则也可能根据具体用例而变化,用户可以根据需要进行微调。此类规则的基础实现可以在github.com/NSchrading/intro-spacy-nlp/blob/master/subject_object_extraction.py找到。这些规则已经稍作修改,并包含在这本书提供的 GitHub 仓库中。使用这个辅助函数,我们可以计算语料库中的所有三元组并将它们存储在我们的corpus数据框中:

from subject_object_extraction import findSVOs
corpus["triplets"] = corpus["parsed"].apply(
    lambda x: findSVOs(x, output="obj")
)
edge_list = pd.DataFrame([
    {
        "id": _id,
        "source": source.lemma_.lower(),
        "target": target.lemma_.lower(),
        "edge": edge.lemma_.lower()
    }
    for _id, triplets in corpus["triplets"].iteritems()
    for (source, (edge, neg), target) in triplets
])

连接的类型(由句子的主要谓语决定)存储在edge列中。可以使用以下命令显示前 10 个最常见的关联关系:

edges["edge"].value_counts().head(10)

最常见的边类型对应于非常基本的谓词。确实,除了非常一般的动词(如 be、have、tell 和 give)之外,我们还可以找到更多与金融背景相关的谓词(如 buy、sell 和 make)。使用所有这些边,我们现在可以使用networkx实用函数创建我们的基于知识的图:

G = nx.from_pandas_edgelist(
    edges, "source", "target", 
    edge_attr=True, create_using=nx.MultiDiGraph()
)

通过过滤边数据框并使用此信息创建子网络,我们可以分析特定关系类型,例如lend边:

G=nx.from_pandas_edgelist(
    edges[edges["edge"]=="lend"], "source", "target",
    edge_attr=True, create_using=nx.MultiDiGraph()
)

下面的图显示了基于贷款关系的子图。正如我们所见,它已经提供了有趣的经济洞察,例如国家之间的经济关系,如委内瑞拉-厄瓜多尔和美苏关系:

![图 7.3 – 与贷款关系相关的知识图的一部分示例]

![图片 B16069_07_03.jpg]

图 7.3 – 与贷款关系相关的知识图的一部分示例

您可以通过根据其他关系过滤图来玩弄前面的代码。我们确实鼓励您这样做,以便从我们刚刚创建的知识图中揭示更多有趣的见解。在下一节中,我们将向您展示另一种方法,允许我们将从文本中提取的信息编码到图结构中。这样做时,我们还将利用我们在第一章**, 二分图中介绍的一种特定类型的图。

文档/实体二分图

知识图可以揭示和查询实体上的聚合信息。然而,其他图表示也是可能的,并且在其他情况下可能很有用。例如,当您想要在语义上对文档进行聚类时,知识图可能不是最佳的数据结构来使用和分析。知识图在寻找间接关系方面也不是非常有效,例如识别竞争对手、类似产品等,这些关系通常不会出现在同一句话中,但经常出现在同一文档中。

为了解决这些限制,我们将以二分图的形式对文档中存在的信息进行编码。对于每个文档,我们将提取最相关的实体,并将代表文档的节点与该文档中代表相关实体的所有节点连接起来。每个节点可能有多个关系:根据定义,每个文档连接多个实体。相反,一个实体可以在多个文档中被引用。正如我们将看到的,交叉引用可以用来创建实体和文档之间的相似度度量。这种相似度也可以用来将二分图投影到特定的节点集——要么是文档节点,要么是实体节点。

为了达到这个目的,构建我们的二分图,我们需要提取文档的相关实体。术语“相关实体”显然是模糊和广泛的。在当前上下文中,我们将相关实体视为命名实体(如由 NER 引擎识别的组织、人或地点)或关键词;即一个词(或词的组合),它可以识别并通常描述文档及其内容。例如,这本书的合适关键词可能是“图”、“网络”、“机器学习”、“监督模型”和“无监督模型”。存在许多从文档中提取关键词的算法。一种非常简单的方法是基于所谓的 TF-IDF 分数,它为每个标记(或标记组,通常称为“词组”)构建一个分数,该分数与文档中的词频(词频,或 TF)成正比,并与该词在给定语料库中的频率的倒数(逆文档频率,或 IDF)成正比:

这里, 代表文档 中词 的计数, 代表语料库中的文档数量,而 是出现词 的文档。因此,TF-IDF 分数促进了在文档中多次重复的词,惩罚了常见且可能不是文档非常有代表性的词。也存在更复杂的算法。

在本书的上下文中,确实有一种非常强大且值得提及的方法是 gensim,它可以以直接的方式使用:

from gensim.summarization import keywords
text = corpus["clean_text"][0]
 keywords(text, words=10, split=True, scores=True,
         pos_filter=('NN', 'JJ'), lemmatize=True)

这会产生以下输出:

[('trading', 0.4615130639538529),
 ('said', 0.3159855693494515),
 ('export', 0.2691553824958079),
 ('import', 0.17462010006456888),
 ('japanese electronics', 0.1360932626379031),
 ('industry', 0.1286043740379779),
 ('minister', 0.12229815662000462),
 ('japan', 0.11434500812642447),
 ('year', 0.10483992409352465)]

在这里,分数代表中心性,它代表一个给定标记的重要性。正如你所看到的,一些复合标记也可能出现,例如“日本电子产品”。可以通过实现关键词提取来计算整个语料库的关键词,从而将信息存储在我们的 corpus DataFrame 中:

corpus["keywords"] = corpus["clean_text"].apply(
    lambda text: keywords(
       text, words=10, split=True, scores=True,
       pos_filter=('NN', 'JJ'), lemmatize=True)
)

除了关键词之外,为了构建二分图,我们还需要解析由 NER 引擎提取的命名实体,然后将信息编码成与关键词使用类似的数据格式。这可以通过使用几个实用函数来完成:

def extractEntities(ents, minValue=1, 
                    typeFilters=["GPE", "ORG", "PERSON"]):
    entities = pd.DataFrame([
       {
          "lemma": e.lemma_, 
          "lower": e.lemma_.lower(),
          "type": e.label_
       } for e in ents if hasattr(e, "label_")
    ])
    if len(entities)==0:
        return pd.DataFrame()
    g = entities.groupby(["type", "lower"])
    summary = pd.concat({
        "alias": g.apply(lambda x: x["lemma"].unique()),
        "count": g["lower"].count()
    }, axis=1)
    return summary[summary["count"]>1]\
             .loc[pd.IndexSlice[typeFilters, :, :]]

def getOrEmpty(parsed, _type):
    try:  
        return list(parsed.loc[_type]["count"]\
           .sort_values(ascending=False).to_dict().items())
    except:
        return []
def toField(ents):
    typeFilters=["GPE", "ORG", "PERSON"]
    parsed = extractEntities(ents, 1, typeFilters)
    return pd.Series({_type: getOrEmpty(parsed, _type)
                      for _type in typeFilters})

使用这些函数,可以通过以下代码解析 spacy 标签:

entities = corpus["parsed"].apply(lambda x: toField(x.ents))

可以使用 pd.concat 函数轻松地将 entities DataFrame 与 corpus DataFrame 合并,从而将所有信息放置在单一的数据结构中:

merged = pd.concat([corpus, entities], axis=1)

现在我们已经拥有了构建二分图的所有成分,我们可以通过遍历所有文档-实体或文档-关键词对来创建边列表:

edges = pd.DataFrame([
    {"source": _id, "target": keyword, "weight": score, "type": _type}
    for _id, row in merged.iterrows()
    for _type in ["keywords", "GPE", "ORG", "PERSON"] 
    for (keyword, score) in row[_type]
])

一旦创建了边列表,我们就可以使用 networkx API 生成二分图:

G = nx.Graph()
G.add_nodes_from(edges["source"].unique(), bipartite=0)
 G.add_nodes_from(edges["target"].unique(), bipartite=1)
 G.add_edges_from([
    (row["source"], row["target"])
    for _, row in edges.iterrows()
])

现在,我们可以使用 nx.info 来查看我们图的概述:

Type: Graph
Number of nodes: 25752
Number of edges: 100311
Average degree:   7.7905

在下一小节中,我们将对两个节点集之一(实体或文档)中的二分图进行投影。这将使我们能够探索两个图之间的差异,并使用在第第四章**,监督图学习中描述的无监督技术对术语和文档进行聚类。然后,我们将回到二分图,展示监督分类的一个例子,我们将通过利用二分图的网络信息来完成这项工作。

实体-实体图

我们将首先将我们的图投影到实体节点集。networkx提供了一个专门用于处理二分图的子模块,networkx.algorithms.bipartite,其中已经实现了许多算法。特别是,networkx.algorithms.bipartite.projection子模块提供了一些实用函数,用于将二分图投影到节点子集。在执行投影之前,我们必须使用我们在生成图时创建的“二分”属性提取与特定集合(文档或实体)相关的节点:

document_nodes = {n 
                  for n, d in G.nodes(data=True)
                  if d["bipartite"] == 0}
entity_nodes = {n 
                for n, d in G.nodes(data=True)
                if d["bipartite"] == 1}

图投影基本上是通过选择节点集创建一个新的图。边是节点之间的位置,基于两个节点是否有共同的邻居。基本的projected_graph函数创建了一个无权边的网络。然而,基于共同邻居数量的边权重通常更有信息量。projection模块提供了基于权重计算方式的不同函数。在下一节中,我们将使用overlap_weighted_projected_graph,其中边权重是通过基于共同邻居的 Jaccard 相似度来计算的。然而,我们也鼓励您探索其他选项,这些选项根据您的用例和上下文,可能最适合您的目标。

注意维度 - 过滤图

在处理投影时,你应该注意的另一个问题是投影图的维度。在某些情况下,例如我们在这里考虑的情况,投影可能会创建极其大量的边,这使得图难以分析。在我们的用例中,根据我们创建网络的逻辑,一个文档节点至少连接到 10 个关键词,以及一些实体。在生成的实体-实体图中,所有这些实体都会因为至少有一个共同邻居(包含它们的文档)而相互连接。因此,对于一篇文档,我们只会生成大约![img/B16067_07_008.png]条边。如果我们把这个数字乘以文档的数量,![img/B16067_07_009.png],最终会得到大量边,尽管用例规模较小,但这些边已经几乎无法处理,因为有几百万条边。尽管这无疑是一个保守的上限(因为实体之间的共现可能在许多文档中是共同的,因此不会重复),但它提供了一个可能预期的复杂性的量级。因此,我们鼓励你在投影你的二分图之前谨慎行事,这取决于底层网络的拓扑结构和你的图的大小。减少这种复杂性和使投影可行的一个技巧是只考虑具有一定度的实体节点。大部分复杂性来自于只出现一次或几次的实体,但它们在图中仍然生成完全图。这样的实体对于捕捉模式和提供洞察力并不很有信息量。此外,它们可能受到统计变异性的强烈影响。另一方面,我们应该关注由较大出现频率支持的强相关性,并提供更可靠的统计结果。

因此,我们只会考虑具有一定度的实体节点。为此,我们将生成过滤后的二分子图,排除度值较低的节点,即小于 5 的节点:

nodes_with_low_degree = {n 
    for n, d in nx.degree(G, nbunch=entity_nodes) if d<5}
subGraph = G.subgraph(set(G.nodes) - nodes_with_low_degree)

现在可以投影这个子图,而不会生成一个包含过多边的图:

entityGraph = overlap_weighted_projected_graph(
    subGraph,
    {n for n in subGraph.nodes() if n in entity_nodes}
)

我们可以使用networkxnx.info函数来检查图的维度:

Number of nodes: 2386
Number of edges: 120198
Average degree: 100.7527

尽管我们已应用了过滤器,边的数量和平均节点度数仍然相当大。以下图表显示了度分布和边权重的分布,我们可以观察到在较低的度值分布中有一个峰值,并向大度值方向有较宽的尾部。同样,边权重也表现出类似的行为,在较低值处有一个峰值,并且右侧尾部较宽。这些分布表明存在几个小型社区,即完全图,它们通过一些中心节点相互连接:

![图 7.4 – 实体-实体网络的度分布和权重分布]

![img/B16069_07_04(Merged).jpg]

图 7.4 – 实体-实体网络的度和权重分布

边权重的分布还表明可以应用第二个过滤器。我们在二分图上之前应用的实体度过滤器允许我们过滤掉仅出现在少数文档中的罕见实体。然而,得到的图也可能受到相反问题的困扰:流行的实体可能只是因为它们倾向于经常出现在文档中而连接在一起,即使它们之间没有有趣的因果联系。考虑美国和微软。它们几乎肯定连接在一起,因为它们同时出现在至少一份或几份文档中的可能性极高。然而,如果它们之间没有强大且直接的因果联系,Jaccard 相似度很大是不太可能的。仅考虑具有最大权重的边可以使你专注于最相关且可能稳定的联系。前面图表中显示的边权重分布表明,合适的阈值可能是0.05

filteredEntityGraph = entityGraph.edge_subgraph(
    [edge 
     for edge in entityGraph.edges
     if entityGraph.edges[edge]["weight"]>0.05])

这样的阈值可以显著减少边的数量,使得分析网络成为可能:

Number of nodes: 2265
Number of edges: 8082
Average degree:   7.1364   

图 7.5 – 经过基于边权重的过滤后得到的图的度分布(左)和边权重分布(右)

图 7.5 – 经过基于边权重的过滤后得到的图的度分布(左)和边权重分布(右)

前面的图表显示了过滤图的节点度和边权重的分布。边权重的分布对应于图 7.4中显示的分布的右尾。度分布与图 7.4的关系不太明显,它显示了度数约为 10 的节点的峰值,而图 7.4中观察到的峰值在低范围内,约为 100。

分析图

使用 Gephi,我们可以提供整体网络的概述,如图图 7.6所示。

图如下:

图 7.6 – 突出显示存在多个小型子社区的实体-实体网络

图 7.6 – 突出显示存在多个小型子社区的实体-实体网络

为了对网络的拓扑结构有更深入的了解,我们还将计算一些全局度量,例如平均最短路径、聚类系数和全局效率。尽管该图有五个不同的连通组件,但最大的一个几乎完全占用了整个图,包括 2,254 个节点中的 2,265 个:

components = nx.connected_components(filteredEntityGraph)
 pd.Series([len(c) for c in components])

使用以下代码可以找到最大组件的全局属性:

comp = components[0] 
global_metrics = pd.Series({
    "shortest_path": nx.average_shortest_path_length(comp),
    "clustering_coefficient": nx.average_clustering(comp),
    "global_efficiency": nx.global_efficiency(comp)
 })

最短路径和全局效率可能需要几分钟的计算时间。这导致以下输出:

{
    'shortest_path': 4.715073779178782,
    'clustering_coefficient': 0.21156314975836915,
    'global_efficiency': 0.22735551077454275
}

基于这些指标的大小(最短路径约为 5,聚类系数约为 0.2),结合之前显示的度分布,我们可以看出该网络具有多个有限大小的社区。以下图表显示了其他有趣的局部属性,如度、页面排名和介数中心性分布,展示了所有这些指标如何相互关联和连接:

图 7.7 – 度、页面排名和介数中心性度量之间的关系和分布

图 7.7 – 度、页面排名和介数中心性度量之间的关系和分布

在提供局部/全局度量描述以及网络的一般可视化之后,我们将应用之前章节中看到的一些技术来识别网络中的见解和信息。我们将使用第4 章**,监督图学习中描述的无监督技术来完成这项工作。

我们将首先使用 Louvain 社区检测算法,通过优化其模块度,旨在识别节点在不相交社区中的最佳分区:

import community
communities = community.best_partition(filteredEntityGraph)

注意,由于随机种子,结果可能在不同运行之间有所不同。然而,应该会出现一个类似的分区,其聚类成员的分布类似于以下图表中所示。我们通常观察到大约 30 个社区,其中较大的社区包含大约 130-150 个文档。

图 7.8 – 检测到的社区大小的分布

图 7.8 – 检测到的社区大小的分布

图 7.9显示了其中一个社区的一个局部放大图,我们可以识别出一个特定的主题/论点。在左侧,实体节点旁边,我们还可以看到文档节点,从而揭示相关二分图的结构:

图 7.9 – 我们已识别的一个社区的一个局部放大图

图 7.9 – 我们已识别的一个社区的一个局部放大图

第四章中所示,监督图学习,我们可以通过使用节点嵌入来提取有关实体拓扑和相似性的有见解的信息。特别是,我们可以使用 Node2Vec,通过向 skip-gram 模型提供随机生成的随机游走,可以将节点投影到向量空间中,其中相邻节点被映射到附近的点:

from node2vec import Node2Vec
node2vec = Node2Vec(filteredEntityGraph, dimensions=5) 
model = node2vec.fit(window=10) 
embeddings = model.wv

在嵌入空间的向量空间中,我们可以应用传统的聚类算法,如高斯混合K 均值DB-scan。正如我们在前面的章节中所做的那样,我们还可以使用 t-SNE 将嵌入投影到二维平面上,以可视化集群和社区。除了给我们提供另一种在图中识别集群/社区的方法之外,Node2Vec 还可以用来提供词语之间的相似性,就像传统上由turkey所做的那样,它提供语义上相似的词语:

[('turkish', 0.9975333213806152),
 ('lira', 0.9903393983840942),
 ('rubber', 0.9884852170944214),
 ('statoil', 0.9871745109558105),
 ('greek', 0.9846569299697876),
 ('xuto', 0.9830175042152405),
 ('stanley', 0.9809650182723999),
 ('conference', 0.9799597263336182),
 ('released', 0.9793018102645874),
 ('inra', 0.9775203466415405)]

虽然这两种方法,Node2Vec 和 Word2Vec,在方法论上有些相似之处,但两种嵌入方案来自不同类型的信息:Word2Vec 直接从文本中构建,并在句子级别包含关系,而 Node2Vec 编码的描述在文档级别上起作用,因为它来自双边实体-文档图。

文档-文档图

现在,让我们将双边图投影到文档节点集合中,以创建一个我们可以分析的文档-文档网络。以我们创建实体-实体网络的方式,我们将使用overlap_weighted_projected_graph函数来获取一个加权图,可以过滤以减少显著边的数量。实际上,网络的拓扑结构和构建双边图所使用的业务逻辑并不利于团的形成,正如我们在实体-实体图中看到的那样:只有当两个节点至少共享一个关键词、组织、地点或人物时,它们才会连接起来。这当然是有可能的,但在 10-15 个节点的组内,这种情况并不极端可能,正如我们观察到的实体那样。

正如我们之前所做的那样,我们可以轻松地使用以下几行代码构建我们的网络:

documentGraph = overlap_weighted_projected_graph(
    G,
    document_nodes
)

以下图表显示了度数和边权重的分布。这可以帮助我们决定用于过滤边的阈值值。有趣的是,与观察到的实体-实体图中的度数分布相比,节点度数分布显示出向大值方向的明显峰值。这表明存在一些超级节点(即具有相当大度数的节点)高度连接。此外,边权重分布显示了 Jaccard 指数趋向于接近 1 的值,这些值远大于我们在实体-实体图中观察到的值。这两个观察结果突出了两个网络之间的深刻差异:而实体-实体图以许多紧密连接的社区(即团)为特征,文档-文档图则以具有大度数的节点(构成核心)之间相对紧密的连接为特征,而外围则是弱连接或未连接的节点:

图 7.10 – 双边图投影到文档-文档网络中的度数分布和边权重分布

图 7.10 – 双边图投影到文档-文档网络中的度分布和边权重分布

将所有边存储在 DataFrame 中可能很方便,这样我们就可以绘制它们,然后使用它们进行过滤,从而创建子图:

allEdgesWeights = pd.Series({
    (d[0], d[1]): d[2]["weight"] 
    for d in documentGraph.edges(data=True)
})

通过查看前面的图表,设置边权重阈值为0.6似乎是合理的,这样我们可以使用networkxedge_subgraph函数生成一个更易于处理的网络:

filteredDocumentGraph = documentGraph.edge_subgraph(
    allEdgesWeights[(allEdgesWeights>0.6)].index.tolist()
)

下面的图显示了缩减图的度分布和边权重的分布:

图 7.11 – 文档-文档过滤网络的度分布和边权重分布

图 7.11 – 文档-文档过滤网络的度分布和边权重分布

文档-文档图与实体-实体图在拓扑结构上的显著差异也可以在以下图表中清楚地看到,该图表显示了完整的网络可视化。正如分布所预期的,文档-文档网络以核心网络和几个不经常连接的卫星为特征。这些卫星代表所有没有或只有少数关键词或实体共同出现的文档。不连通文档的数量相当大,占总数的近 50%:

图 7.12 – (左) 文档-文档过滤网络的表示,突出显示核心和外围的存在。(右) 核心的特写,其中嵌入了一些子社区。节点大小与节点度成正比

img/B16069_07_12(Merged).jpg

图 7.12 – (左) 文档-文档过滤网络的表示,突出显示核心和外围的存在。(右) 核心的特写,其中嵌入了一些子社区。节点大小与节点度成正比

使用以下命令提取此网络的连通组件可能是值得的:

components = pd.Series({
    ith: component 
    for ith, component in enumerate(
        nx.connected_components(filteredDocumentGraph)
    )
})

在下面的图中,我们可以看到连通组件大小的分布。在这里,我们可以清楚地看到存在一些非常大的簇(核心),以及大量不连通或非常小的组件(外围或卫星)。这种结构与我们所观察到的实体-实体图的结构截然不同,在实体-实体图中,所有节点都是由一个非常大的、连通的簇生成的:

图 7.13 – 连通组件大小的分布,突出显示了许多小型社区(代表外围)和少数大型社区(代表核心)

img/B16069_07_12(Merged).jpg

图 7.13 – 连通组件大小的分布,突出显示了许多小型社区(代表外围)和少数大型社区(代表核心)

进一步研究核心组件的结构可能很有趣。我们可以使用以下代码从完整图中提取由网络的最大组件组成的子图:

coreDocumentGraph = nx.subgraph(
    filteredDocumentGraph,
    [node 
     for nodes in components[components.apply(len)>8].values
     for node in nodes]
)

我们可以使用 nx.info 检查核心网络的属性:

Type: Graph
Number of nodes: 1050
Number of edges: 7112
Average degree:  13.5467

图 7.12 的左侧面板显示了核心的 Gephi 可视化。正如我们所见,核心由几个社区组成,以及一些相互之间有相当大度数且强连接的节点。

正如我们在实体-实体网络中所做的那样,我们可以处理网络以识别图中嵌入的社区。然而,与之前所做不同的是,文档-文档图现在提供了一个使用文档标签来判断聚类的手段。确实,我们期望属于同一主题的文档彼此靠近并相互连接。此外,正如我们很快将看到的,这还将使我们能够识别主题之间的相似性。

首先,让我们先提取候选社区:

import community
communities = pd.Series(
    community.best_partition(filteredDocumentGraph)
)

然后,我们将提取每个社区内的主题混合,以查看是否存在同质性(所有文档都属于同一类别)或主题之间的一些相关性:

from collections import Counter
def getTopicRatio(df):
    return Counter([label 
                    for labels in df["label"] 
                    for label in labels])

communityTopics = pd.DataFrame.from_dict({
    cid: getTopicRatio(corpus.loc[comm.index])
    for cid, comm in communities.groupby(communities)
 }, orient="index")
normalizedCommunityTopics = (
    communityTopics.T / communityTopics.sum(axis=1)
).T

normalizedCommunityTopics 是一个 DataFrame,对于 DataFrame 中的每个社区(行),它提供了不同主题(沿列轴)的主题混合(百分比)。为了量化集群/社区内主题混合的异质性,我们必须计算每个社区的 Shannon 散度:

图片

这里,图片 代表集群的熵,图片,而 图片 对应于主题 图片 在社区 图片 中的百分比。我们必须计算所有社区的实证 Shannon 散度:

normalizedCommunityTopics.apply(
    lambda x: np.sum(-np.log(x)), axis=1)

下面的图显示了所有社区中的熵分布。大多数社区具有零或非常低的熵,这表明属于同一类别(标签)的文档往往聚集在一起:

![图 7.14 – 每个社区主题混合的熵分布图片

图 7.14 – 每个社区主题混合的熵分布

即使大多数社区在主题周围表现出零或低变异性,研究社区表现出一些异质性时主题之间的关系也是有趣的。具体来说,我们计算主题分布之间的相关性:

topicsCorrelation = normalizedCommunityTopics.corr().fillna(0)

然后,我们可以使用主题-主题网络来表示和可视化这些属性:

topicsCorrelation[topicsCorrelation<0.8]=0
topicsGraph = nx.from_pandas_adjacency(topicsCorrelation)

以下图的左侧显示了主题网络的完整图表示。正如在文档-文档网络中观察到的,主题-主题图显示了一个由不连接的节点外围和一个强连接的核心组织起来的结构。以下图的右侧显示了核心网络的特写。这表明了一个由语义意义支持的关联,与商品相关的主题紧密相连:

图 7.15 – (左) 主题-主题相关性图,采用外围-核心结构组织。(右) 网络核心的特写

图 7.15 – (左) 主题-主题相关性图,采用外围-核心结构组织。(右) 网络核心的特写

在本节中,我们分析了在分析文档以及更一般性的文本来源时出现的不同类型的网络。为此,我们使用了全局和局部属性来统计描述网络,以及一些无监督算法,这些算法使我们能够揭示图中的某些结构。在下一节中,我们将向您展示如何利用这些图结构来构建机器学习模型。

构建文档主题分类器

为了向您展示如何利用图结构,我们将专注于使用二分实体-文档图提供的拓扑信息和实体之间的连接来训练多标签分类器。这将帮助我们预测文档主题。为此,我们将分析两种不同的方法:

  • 一种浅层机器学习方法,我们将使用从二分网络中提取的嵌入来训练传统分类器,例如 RandomForest 分类器。

  • 一种更集成和可微分的基于使用图形神经网络的方法,该方法已应用于异构图(如二分图)。

让我们考虑前 10 个主题,我们对这些主题有足够的文档来训练和评估我们的模型:

from collections import Counter
topics = Counter(
    [label 
     for document_labels in corpus["label"] 
     for label in document_labels]
).most_common(10)

以下代码块生成了以下输出。这显示了主题的名称,在以下分析中,我们都会关注这些主题:

[('earn', 3964), ('acq', 2369), ('money-fx', 717), 
('grain', 582), ('crude', 578), ('trade', 485), 
('interest', 478), ('ship', 286), ('wheat', 283), 
('corn', 237)]

在训练主题分类器时,我们必须将我们的重点限制在仅属于此类标签的文档上。可以通过以下代码块轻松获得过滤后的语料库:

topicsList = [topic[0] for topic in topics]
 topicsSet = set(topicsList)
dataset = corpus[corpus["label"].apply(
    lambda x: len(topicsSet.intersection(x))>0
)]

现在我们已经提取并结构化了数据集,我们准备开始训练我们的主题模型并评估其性能。在下一节中,我们将首先创建一个简单的模型,使用浅层学习方法,这样我们就可以通过使用图神经网络来增加模型的复杂性。

浅层学习方法

我们将首先通过利用网络信息来实现一个浅层方法,用于主题分类任务。我们将向您展示如何做到这一点,以便您可以根据您的用例进一步定制:

  1. 首先,我们将使用Node2Vec在二分图上计算嵌入。过滤后的文档-文档网络具有许多未连接的节点,因此它们不会从拓扑信息中受益。另一方面,未过滤的文档-文档网络将具有许多边,这使得方法的可扩展性成为一个问题。因此,使用二分图对于有效地利用拓扑信息和实体与文档之间的连接至关重要:

    from node2vec import Node2Vec
    node2vec = Node2Vec(G, dimensions=10) 
    model = node2vec.fit(window=20) 
    embeddings = model.wv 
    

    在这里,dimension嵌入以及用于生成游走的window都是必须通过交叉验证进行优化的超参数。

  2. 为了提高计算效率,可以事先计算一组嵌入,将其保存到磁盘,然后在优化过程中使用。这基于我们处于一个半监督设置或一个归纳任务,在训练时间我们有关于整个数据集的连接信息,除了它们的标签。在本章的后面部分,我们将概述另一种基于图神经网络的方法,它为在训练分类器时集成拓扑提供了一个归纳框架。让我们将嵌入存储在文件中:

    pd.DataFrame(embeddings.vectors,
                 index=embeddings.index2word
    ).to_pickle(f"graphEmbeddings_{dimension}_{window}.p")
    

    在这里,我们可以选择并循环不同的dimensionwindow值。对于这两个变量,一些可能的选择是 10、20 和 30。

  3. 这些嵌入可以集成到 scikit-learn 的transformer中,以便在网格搜索交叉验证过程中使用:

    from sklearn.base import BaseEstimator
    class EmbeddingsTransformer(BaseEstimator):
        def __init__(self, embeddings_file):
            self.embeddings_file = embeddings_file        
        def fit(self, *args, **kwargs):
            self.embeddings = pd.read_pickle(
                self.embeddings_file)
            return self        
        def transform(self, X):
            return self.embeddings.loc[X.index]    
        def fit_transform(self, X, y):
            return self.fit().transform(X)
    
  4. 为了构建建模训练管道,我们将我们的语料库分为训练集和测试集:

    def train_test_split(corpus):
        indices = [index for index in corpus.index]
        train_idx = [idx 
                     for idx in indices 
                     if "training/" in idx]
        test_idx = [idx 
                    for idx in indices 
                    if "test/" in idx]
        return corpus.loc[train_idx], corpus.loc[test_idx]
    train, test = train_test_split(dataset)
    

    我们还将构建函数以方便地提取特征和标签:

    def get_features(corpus):
        return corpus["parsed"]
    def get_labels(corpus, topicsList=topicsList):
        return corpus["label"].apply(
            lambda labels: pd.Series(
               {label: 1 for label in labels}
            ).reindex(topicsList).fillna(0)
        )[topicsList]
    def get_features_and_labels(corpus):
        return get_features(corpus), get_labels(corpus)
    features, labels = get_features_and_labels(train)
    
  5. 现在,我们可以实例化建模管道:

    from sklearn.pipeline import Pipeline
    from sklearn.ensemble import RandomForestClassifier 
    from sklearn.multioutput import MultiOutputClassifier
    pipeline = Pipeline([
        ("embeddings", EmbeddingsTransformer(
            "my-place-holder")
        ),
        ("model", MultiOutputClassifier(
            RandomForestClassifier())
        )
    ])
    
  6. 让我们定义交叉验证网格搜索的参数空间以及配置:

    from glob import glob
    param_grid = {
        "embeddings__embeddings_file": glob("graphEmbeddings_*"),
        "model__estimator__n_estimators": [50, 100],
        "model__estimator__max_features": [0.2,0.3, "auto"], 
    }
    grid_search = GridSearchCV(
        pipeline, param_grid=param_grid, cv=5, n_jobs=-1)
    
  7. 最后,让我们使用 sklearn API 的fit方法来训练我们的主题模型:

    model = grid_search.fit(features, labels)
    

太好了!你刚刚创建了一个利用图信息的话题模型。一旦确定了最佳模型,我们就可以使用这个模型在测试数据集上评估其性能。为此,我们必须定义以下辅助函数,它允许我们获得一组预测:

def get_predictions(model, features):
    return pd.DataFrame(
        model.predict(features),
        columns=topicsList, index=features.index)
preds = get_predictions(model, get_features(test))
 labels = get_labels(test)

使用sklearn功能,我们可以迅速查看训练分类器的性能:

from sklearn.metrics import classification_report
print(classification_report(labels, preds))

这提供了以下输出,显示了通过 F1 分数获得的总体性能指标。这大约在 0.6 – 0.8 之间,具体取决于如何处理不平衡的类别:

              precision    recall  f1-score   support
           0       0.97      0.94      0.95      1087
           1       0.93      0.74      0.83       719
           2       0.79      0.45      0.57       179
           3       0.96      0.64      0.77       149
           4       0.95      0.59      0.73       189
           5       0.95      0.45      0.61       117
           6       0.87      0.41      0.56       131
           7       0.83      0.21      0.34        89
           8       0.69      0.34      0.45        71
           9       0.61      0.25      0.35        56
   micro avg       0.94      0.72      0.81      2787
   macro avg       0.85      0.50      0.62      2787
weighted avg       0.92      0.72      0.79      2787
 samples avg       0.76      0.75      0.75      2787

您可以尝试不同的分析管道类型和超参数,改变模型,并在编码嵌入时尝试不同的值。正如我们之前提到的,前面的方法显然是归纳的,因为它使用了一个在整个数据集上训练的嵌入。这在半监督任务中是一个常见的情况,其中标记信息仅存在于一小部分点上,任务是从所有未知样本中推断标签。在下一个小节中,我们将概述如何使用图神经网络构建一个归纳分类器。这些可以在测试样本在训练时未知的情况下使用。

图神经网络

现在,让我们描述一种基于神经网络的、原生集成并利用图结构的方法。图神经网络在第三章中介绍,无监督图学习,以及第四章中介绍,监督图学习。然而,在这里,我们将向您展示如何将此框架应用于异构图;即,存在多种节点类型的图。每种节点类型可能有一组不同的特征,训练可能只针对一种特定的节点类型而不是其他。

我们在这里将要展示的方法将利用stellargraphGraphSAGE算法,这些算法我们在之前已经描述过。这些方法也支持为每个节点使用特征,而不仅仅是依赖于图的拓扑结构。如果你没有任何节点特征,可以使用一热节点表示法来代替,如第六章中所示,社交网络图。然而,在这里,为了使事情更加通用,我们将基于每个实体和关键词的 TF-IDF 分数(我们之前已经看到过)生成一组节点特征。在这里,我们将向您展示一个逐步指南,这将帮助您基于图神经网络训练和评估一个模型,用于预测文档主题分类:

  1. 让我们先计算每个文档的 TF-IDF 分数。sklearn已经提供了一些功能,允许我们轻松地从文档语料库中计算 TF-IDF 分数。TfidfVectorizer sklearn类已经内置了一个tokenizer。然而,由于我们已经有了一个使用spacy提取的标记化和词元化的版本,我们也可以提供一个自定义的tokenizer实现,该实现利用 spaCy 处理:

    def my_spacy_tokenizer(pos_filter=["NOUN", "VERB", "PROPN"]):
        def tokenizer(doc):
            return [token.lemma_ 
                    for token in doc 
                    if (pos_filter is None) or 
                       (token.pos_ in pos_filter)] 
        return tokenizer 
    

    这可以在TfidfVectorizer中使用:

    cntVectorizer = TfidfVectorizer(
        analyzer=my_spacy_tokenizer(),
        max_df = 0.25, min_df = 2, max_features = 10000
    )
    

    为了使这种方法真正具有归纳性,我们只对训练集进行 TF-IDF 训练。这仅适用于测试集:

    trainFeatures, trainLabels = get_features_and_labels(train)
    testFeatures, testLabels = get_features_and_labels(test)
    trainedIDF = cntVectorizer.fit_transform(trainFeatures)
    testIDF = cntVectorizer.transform(testFeatures)
    

    为了方便起见,现在可以将两个 TF-IDF 表示(训练集和测试集)堆叠成一个单一的数据结构,表示整个图中文档节点的特征:

    documentFeatures = pd.concat([trainedIDF, testIDF])
    
  2. 除了文档节点的特征信息外,我们还将为实体构建一个简单的特征向量,基于实体类型的 one-hot 编码表示:

    entityTypes = {
        entity: ith 
        for ith, entity in enumerate(edges["type"].unique())
    }
    entities = edges\
        .groupby(["target", "type"])["source"]\
        .count()\
        .groupby(level=0).apply(
            lambda s: s.droplevel(0)\
                       .reindex(entityTypes.keys())\
                       .fillna(0))\
        .unstack(level=1)
    entityFeatures = (entities.T / entities.sum(axis=1))
    
  3. 我们现在拥有了创建StellarGraph实例所需的所有信息。我们将通过合并节点特征的信息,包括文档和实体的信息,以及由edges数据框提供的连接来完成此操作。我们应该仅过滤掉一些边/节点,以便只包括属于目标主题的文档:

    from stellargraph import StellarGraph
    _edges = edges[edges["source"].isin(documentFeatures.index)]
    nodes = {«entity»: entityFeatures, 
             «document»: documentFeatures}
    stellarGraph = StellarGraph(
        nodes, _edges,
        target_column=»target», edge_type_column=»type»
    )
    

    这样,我们就创建了我们的StellarGraph。我们可以使用以下命令检查网络,类似于我们对networkx所做的那样:

    print(stellarGraph.info())
    

    这产生了以下概述:

    StellarGraph: Undirected multigraph
     Nodes: 23998, Edges: 86849
    Node types:
      entity: [14964]
        Features: float32 vector, length 6
        Edge types: entity-GPE->document, entity-ORG->document, entity-PERSON->document, entity-keywords->document
      document: [9034]
        Features: float32 vector, length 10000
        Edge types: document-GPE->entity, document-ORG->entity,
     document-PERSON->entity, document-keywords->entity
    Edge types:
        document-keywords->entity: [78838]
            Weights: range=[0.0827011, 1], mean=0.258464,
     std=0.0898612
            Features: none
        document-ORG->entity: [4129]
            Weights: range=[2, 22], mean=3.24122, std=2.30508
            Features: none
        document-GPE->entity: [2943]
            Weights: range=[2, 25], mean=3.25926, std=2.07008
            Features: none
        document-PERSON->entity: [939]
            Weights: range=[2, 14], mean=2.97444, std=1.65956
            Features: none
    

    StellarGraph的描述实际上非常详尽。此外,StellarGraph还原生支持不同类型的节点和边,并为每种节点/边类型提供即插即用的分段统计数据。

  4. 你可能已经注意到我们刚刚创建的图既包含训练数据又包含测试数据。为了真正测试归纳方法的性能并避免训练集和测试集之间的信息链接,我们需要创建一个仅包含训练时可用数据的子图:

    targets = labels.reindex(documentFeatures.index).fillna(0)
     sampled, hold_out = train_test_split(targets)
    allNeighbors = np.unique([n 
        for node in sampled.index 
        for n in stellarGraph.neighbors(node)
    ])
    subgraph = stellarGraph.subgraph(
        set(sampled.index).union(allNeighbors)
    )
    

    考虑的子图包含 16,927 个节点和 62,454 条边,与整个图中的 23,998 个节点和 86,849 条边相比。

  5. 现在我们只有训练时可用数据和网络,我们可以在其上构建我们的机器学习模型。为此,我们将数据分为训练、验证和测试数据。对于训练,我们只使用 10%的数据,这类似于半监督任务:

    from sklearn.model_selection import train_test_split
    train, leftOut = train_test_split(
        sampled,
        train_size=0.1,
        test_size=None,
        random_state=42
    )
    validation, test = train_test_split(
        leftOut, train_size=0.2, test_size=None, random_state=100,
    ) 
    
  6. 现在,我们可以开始使用stellargraphkeras API 构建我们的图神经网络模型。首先,我们将创建一个生成器,能够生成将输入神经网络的样本。请注意,由于我们处理的是一个异构图,我们需要一个生成器,它将只从属于特定类的节点中采样示例。在这里,我们将使用HinSAGENodeGenerator类,它将用于同构图中的节点生成器推广到异构图,允许我们指定我们想要的目标节点类型:

    from stellargraph.mapper import HinSAGENodeGenerator
    batch_size = 50
    num_samples = [10, 5]
    generator = HinSAGENodeGenerator(
        subgraph, batch_size, num_samples,
        head_node_type="document"
    )
    

    使用此对象,我们可以为训练和验证数据集创建生成器:

    train_gen = generator.flow(train.index, train, shuffle=True)
     val_gen = generator.flow(validation.index, validation)
    
  7. 现在,我们可以创建我们的 GraphSAGE 模型。像生成器一样,我们需要使用一个可以处理异构图的模式。在这里,我们将使用HinSAGE代替GraphSAGE

    from stellargraph.layer import HinSAGE
    from tensorflow.keras import layers
    graphsage_model = HinSAGE(
        layer_sizes=[32, 32], generator=generator,
        bias=True, dropout=0.5
    )
    x_inp, x_out = graphsage_model.in_out_tensors()
    prediction = layers.Dense(
        units=train.shape[1], activation="sigmoid"
    )(x_out)
    

    注意,在最终的密集层中,我们使用的是sigmoid激活函数而不是softmax激活函数,因为当前的问题是一个多类、多标签任务。因此,一个文档可能属于多个类别,在这种情况下,sigmoid 激活函数似乎是一个更合理的选择。像往常一样,我们将编译我们的 Keras 模型:

    from tensorflow.keras import optimizers, losses, Model
    model = Model(inputs=x_inp, outputs=prediction)
    model.compile(
        optimizer=optimizers.Adam(lr=0.005),
        loss=losses.binary_crossentropy,
        metrics=["acc"]
    )
    
  8. 最后,我们将训练神经网络模型:

    history = model.fit(
        train_gen, epochs=50, validation_data=val_gen,
        verbose=1, shuffle=False
    )
    

    这将产生以下输出:

    图.7.16 – (顶部)训练和验证准确率与训练轮数的关系。(底部)训练和验证数据集的二进制交叉熵损失与训练轮数的关系

    图.7.16 – (顶部)训练和验证准确率与训练轮数的关系。(底部)训练和验证数据集的二进制交叉熵损失与训练轮数的关系

    上一张图显示了训练和验证损失以及准确率随训练轮数变化的曲线。如图所示,训练和验证准确率持续上升,直到大约 30 轮。在这里,验证集的准确率达到了一个平台期,而训练准确率仍在上升,表明有过度拟合的趋势。因此,在约 50 轮时停止训练似乎是一个相当合理的选择。

  9. 一旦模型训练完成,我们就可以在测试集上测试其性能:

    test_gen = generator.flow(test.index, test)
     test_metrics = model.evaluate(test_gen)
    

    这应该提供以下值:

    loss: 0.0933
    accuracy: 0.8795
    

    注意,由于标签分布不平衡,准确率可能不是评估性能的最佳选择。此外,通常使用 0.5 作为阈值,因此在不平衡设置中提供标签分配也可能不是最优的。

  10. 为了确定用于分类文档的最佳阈值,我们将对所有测试样本进行预测:

    test_predictions = pd.DataFrame(
        model.predict(test_gen), index=test.index,
        columns=test.columns)
    test_results = pd.concat({
        "target": test,
        "preds": test_predictions
    }, axis=1)
    

    然后,我们将计算不同阈值选择的宏平均 F1 分数:

    thresholds = [0.01,0.05,0.1,0.2,0.3,0.4,0.5] 
    f1s = {}
    for th in thresholds:
        y_true = test_results["target"]
        y_pred = 1.0*(test_results["preds"]>th)
        f1s[th] = f1_score(y_true, y_pred, average="macro")    
    pd.Series(f1s).plot()
    

    如以下图表所示,0.2 的阈值似乎是最好的选择,因为它实现了最佳的性能:

    图 7.17 – 使用标签的阈值与宏平均 F1 分数的关系

    图 7.17 – 使用标签的阈值与宏平均 F1 分数的关系

  11. 使用 0.2 的阈值,我们可以提取测试集的分类报告:

    print(classification_report(
        test_results["target"], 1.0*(test_results["preds"]>0.2))
    )
    

    这给出了以下输出:

                  precision    recall  f1-score   support
               0       0.92      0.97      0.94      2075
               1       0.85      0.96      0.90      1200
               2       0.65      0.90      0.75       364
               3       0.83      0.95      0.89       305
               4       0.86      0.68      0.76       296
               5       0.74      0.56      0.63       269
               6       0.60      0.80      0.69       245
               7       0.62      0.10      0.17       150
               8       0.49      0.95      0.65       149
               9       0.44      0.88      0.58       129
       micro avg       0.80      0.89      0.84      5182
       macro avg       0.70      0.78      0.70      5182
    weighted avg       0.82      0.89      0.84      5182
     samples avg       0.83      0.90      0.85      5182
    
  12. 到目前为止,我们已经训练了一个图神经网络模型并评估了其性能。现在,让我们将此模型应用于一组未观察到的数据——我们在一开始就留下的数据——并在归纳设置中表示真实的测试数据。为此,我们需要实例化一个新的生成器:

    generator = HinSAGENodeGenerator(
        stellarGraph, batch_size, num_samples,
        head_node_type="document")
    

    注意,我们从HinSAGENodeGenerator获取的图形现在是一个完整的图形(代替我们之前使用的过滤图形),它包含训练和测试文档。使用这个类,我们可以创建一个生成器,它只从测试节点中采样,过滤掉不属于我们主要选择主题之一的节点:

    hold_out = hold_out[hold_out.sum(axis=1) > 0]
    hold_out_gen = generator.flow(hold_out.index, hold_out)
    
  13. 该模型可以在这些样本上评估,并使用我们之前确定的阈值来预测标签;即,0.2:

    hold_out_predictions = model.predict(hold_out_gen)
    preds = pd.DataFrame(1.0*(hold_out_predictions > 0.2),
                         index = hold_out.index,
                         columns = hold_out.columns)
    results = pd.concat(
        {"target": hold_out,"preds": preds}, axis=1
    )
    

    最后,我们可以提取归纳测试数据集的性能:

    print(classification_report(
        results["target"], results["preds"])
    )
    

    这会产生以下表格:

                  precision    recall  f1-score   support
               0       0.93      0.99      0.96      1087
               1       0.90      0.97      0.93       719
               2       0.64      0.92      0.76       179
               3       0.82      0.95      0.88       149
               4       0.85      0.62      0.72       189
               5       0.74      0.50      0.59       117
               6       0.60      0.79      0.68       131
               7       0.43      0.03      0.06        89
               8       0.50      0.96      0.66        71
               9       0.39      0.86      0.54        56
       micro avg       0.82      0.89      0.85      2787
       macro avg       0.68      0.76      0.68      2787
    weighted avg       0.83      0.89      0.84      2787
    samples avg       0.84      0.90      0.86      2787
    

与浅层学习方法相比,我们可以看到我们在性能上取得了显著的提升,介于 5-10%之间。

摘要

在本章中,您学习了如何处理非结构化信息,以及如何使用图来表示此类信息。从众所周知的基准数据集,即路透社-21578 数据集开始,我们应用了标准的 NLP 引擎来标记和结构化文本信息。然后,我们使用这些高级特征来创建不同类型的网络:基于知识的网络、二分网络、以及节点子集的投影,以及与数据集主题相关的网络。这些不同的图也使我们能够使用我们在前几章中介绍的工具从网络表示中提取见解。

我们使用局部和全局属性向您展示了这些数量如何表示和描述结构上不同的网络类型。然后,我们使用无监督技术来识别语义社区并对属于相似主题/主题的文档进行聚类。最后,我们使用数据集中提供的标记信息来训练监督多类多标签分类器,这些分类器也利用了网络的拓扑结构。

然后,我们将监督技术应用于一个异构图,其中存在两种不同的节点类型:文档和实体。在这个设置中,我们向您展示了如何通过使用浅层学习和图神经网络,分别实现归纳和演绎方法。

在下一章中,我们将探讨另一个领域,其中图分析可以有效地用于提取见解和/或创建利用网络拓扑的机器学习模型:交易数据。下一个用例也将允许您将本章中引入的二分图概念推广到另一个层次:三分图。

第八章:信用卡交易图分析

财务数据分析是大数据和数据分析中最常见和最重要的领域之一。确实,由于移动设备的数量不断增加以及在线支付标准的引入,银行产生的交易数据量呈指数级增长。

因此,需要新的工具和技术来充分利用这些大量信息,以便更好地理解客户行为并支持业务流程中的数据驱动决策。数据还可以用来构建更好的机制,以改善在线支付过程中的安全性。确实,由于电子商务平台的普及,在线支付系统越来越受欢迎,同时,欺诈案件也在增加。一个欺诈交易的例子是使用被盗信用卡进行的交易。确实,在这种情况下,欺诈交易将与信用卡原始持卡人进行的交易不同。

然而,由于涉及大量变量,构建自动检测欺诈交易的程序可能是一个复杂问题。

在本章中,我们将描述如何将信用卡交易数据表示为图,以便使用机器学习算法自动检测欺诈交易。我们将通过应用之前章节中描述的一些技术和算法来处理数据集,从而构建一个欺诈检测算法。

本章将涵盖以下主题:

  • 从信用卡交易生成图

  • 从图中提取属性和社区

  • 将监督和无监督机器学习算法应用于欺诈分类

技术要求

我们将使用带有 Python 3.8 的Jupyter笔记本进行所有练习。以下是本章将使用pip安装的 Python 库列表。例如,在命令行中运行pip install networkx==2.5

Jupyter==1.0.0
networkx==2.5
scikit-learn==0.24.0
pandas==1.1.3
node2vec==0.3.3
numpy==1.19.2
communities==2.2.0

在本书的其余部分,除非明确指出相反,我们将把nx称为 Python import networkx as nx命令的结果。

本章相关的所有代码文件可在以下网址找到:github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter08

数据集概述

本章使用的数据集是可在Kaggle上找到的Credit Card Transactions Fraud Detection Dataset,以下为 URL:www.kaggle.com/kartik2112/fraud-detection?select=fraudTrain.csv

该数据集由 2019 年 1 月 1 日至 2020 年 12 月 31 日期间的合法和欺诈性信用卡交易组成。它包括 1,000 名客户与 800 家商户的交易池进行的交易。该数据集使用Sparkov 数据生成生成。有关生成算法的更多信息,请访问以下网址:github.com/namebrandon/Sparkov_Data_Generation

对于每笔交易,数据集包含 23 个不同的特征。在以下表中,我们将仅展示本章将使用的信息:

![表 8.1 – 数据集中使用的变量列表图片 B16069_08_01.jpg

表 8.1 – 数据集中使用的变量列表

为了我们的分析目的,我们将使用fraudTrain.csv文件。正如之前所建议的,请自己查看数据集。强烈建议在开始任何机器学习任务之前,探索并尽可能熟悉数据集。我们还建议您调查本章未涵盖的两个其他数据集。第一个是捷克银行金融分析数据集,可在 https://github.com/Kusainov/czech-banking-fin-analysis 找到。该数据集来自 1999 年的一家实际捷克银行,涵盖 1993 年至 1998 年的时期。关于客户及其账户的数据包括有向关系。不幸的是,交易上没有标签,这使得无法使用机器学习技术训练欺诈检测引擎。第二个数据集是 paysim1 数据集,可在www.kaggle.com/ntnu-testimon/paysim1找到。该数据集基于从非洲国家实施的一个移动货币服务的一个月财务日志中提取的真实交易样本模拟的移动货币交易。原始日志由一家跨国公司提供,该公司是移动金融服务提供商,目前在全球 14 多个国家运营。该数据集还包含欺诈/真实交易的标签。

使用 networkx 加载数据集和构建图

我们分析的第一步将是加载数据集并构建一个图。由于数据集代表的是一个简单的交易列表,我们需要执行几个操作来构建最终的信用卡交易图。数据集是一个简单的 CSV 文件;我们可以使用pandas如下加载数据:

import pandas as pd
df = df[df["is_fraud"]==0].sample(frac=0.20, random_state=42).append(df[df["is_fraud"] == 1])

为了帮助读者处理数据集,我们选择了 20%的真实交易和所有的欺诈交易。因此,从总共 1,296,675 笔交易中,我们只将使用 265,342 笔交易。此外,我们还可以调查数据集中欺诈和真实交易的数量如下:

df["is_fraud"].value_counts()

作为结果,我们得到以下内容:

0    257834
1      7506

换句话说,从总共 265,342 笔交易中,只有 7506 笔(2.83%)是欺诈交易,其余都是真实的。

使用 networkx 库可以将数据集表示为图。在开始技术描述之前,我们将首先指定如何从数据构建图。我们使用了两种不同的方法来构建图,即二分法和三分法,如论文 APATE:使用基于网络的扩展的自动信用卡交易欺诈检测的新方法 所述,该论文可在 https://www.scinapse.io/papers/614715210 找到。

对于二分法,我们构建了一个加权二分图 ![img/B16069_08_001.png],其中 ![img/B16069_08_002.png],其中每个节点 ![img/B16069_08_003.png] 代表一个客户,每个节点 ![img/B16069_08_004.png] 代表一个商家。如果存在从客户 ![img/B16069_08_006.png],到商家 ![img/B16069_08_007.png] 的交易,则创建一条边 ![img/B16069_08_005.png]。最后,我们为图中的每条边分配一个(始终为正的)权重,表示交易的金额(以美元计)。在我们的形式化中,我们允许使用有向和无向图。

由于数据集表示时间序列交易,客户和商家之间可能发生多次交互。在我们的两种形式化中,我们都决定将所有这些信息合并到一个图中。换句话说,如果客户和商家之间存在多笔交易,我们将在这两个节点之间构建一条单边,其权重为所有交易金额的总和。直接二分图的图形表示可见于 图 8.1

![图 8.1 – 从输入数据集生成的二分图

![img/B16069_08_011.jpg]

图 8.1 – 从输入数据集生成的二分图

我们定义的二分图可以使用以下代码构建:

def build_graph_bipartite(df_input, graph_type=nx.Graph()):
    df = df_input.copy()
    mapping = {x:node_id for node_id,x in enumerate(set(df["cc_num"].values.tolist() + df["merchant"].values.tolist()))}
    df["from"] = df["cc_num"].apply(lambda x: mapping[x])
    df["to"] = df["merchant"].apply(lambda x: mapping[x])
    df = df[['from', 'to', "amt", "is_fraud"]].groupby(['from', 'to']).agg({"is_fraud": "sum", "amt": "sum"}).reset_index()
    df["is_fraud"] = df["is_fraud"].apply(lambda x: 1 if x>0 else 0)
    G = nx.from_edgelist(df[["from", "to"]].values, create_using=graph_type)
    nx.set_edge_attributes(G, {(int(x["from"]), int(x["to"])):x["is_fraud"] for idx, x in df[["from","to","is_fraud"]].iterrows()}, "label")
    nx.set_edge_attributes(G,{(int(x["from"]), int(x["to"])):x["amt"] for idx, x in df[["from","to","amt"]].iterrows()}, "weight")
    return G

代码相当简单。为了构建二分信用卡交易图,我们使用了不同的 networkx 函数。要深入了解,我们在代码中执行的操作如下:

  1. 我们构建了一个映射,为每个商家或客户分配一个 node_id

  2. 多笔交易被汇总为单笔交易。

  3. 使用 networkx 函数 nx.from_edgelist 来构建 networkx 图。

  4. 每条边分配了两个属性,即 weightlabel。前者表示两个节点之间的交易总数,而后者表示交易是真实的还是欺诈的。

如代码所示,我们可以选择是否构建有向或无向图。我们可以通过调用以下函数来构建无向图:

G_bu = build_graph_bipartite(df, nx.Graph(name="Bipartite Undirect"))))

我们也可以通过调用以下函数来构建直接图:

G_bd = build_graph_bipartite(df, nx.DiGraph(name="Bipartite Direct"))))

唯一的区别在于我们传递给构造函数的第二个参数。

三分法是之前方法的扩展,也允许将交易表示为顶点。一方面,这种方法大大增加了网络复杂性,另一方面,它允许为商家和持卡人以及每笔交易构建额外的节点嵌入。正式来说,对于这种方法,我们构建一个加权三分图,,其中,其中每个节点代表一个客户,每个节点代表一个商家,每个节点代表一笔交易。对于每笔交易,创建两个边,从客户到商家

最后,我们为图中的每条边分配一个(始终为正的)权重,表示交易金额(以美元计)。由于在这种情况下,我们为每笔交易创建一个节点,因此我们不需要从客户到商家的多笔交易的聚合。此外,与其他方法相比,在我们的形式化中,我们允许使用有向和无向图。直接二分图的图形表示可见于图 8.2

![Figure 8.2 – 从输入数据集生成的三分图]

![img/B16069_08_02.jpg]

Figure 8.2 – 从输入数据集生成的三分图

我们定义的三分图可以使用以下代码构建:

def build_graph_tripartite(df_input, graph_type=nx.Graph()):
    df = df_input.copy()
    mapping = {x:node_id for node_id,x in enumerate(set(df.index.values.tolist() + df["cc_num"].values.tolist() + df["merchant"].values.tolist()))}
    df["in_node"] = df["cc_num"].apply(lambda x: mapping[x])
    df["out_node"] = df["merchant"].apply(lambda x: mapping[x])
    G = nx.from_edgelist([(x["in_node"], mapping[idx]) for idx, x in df.iterrows()] + [(x["out_node"], mapping[idx]) for idx, x in df.iterrows()], create_using=graph_type)
    nx.set_edge_attributes(G,{(x["in_node"], mapping[idx]):x["is_fraud"] for idx, x in df.iterrows()}, "label")
    nx.set_edge_attributes(G,{(x["out_node"], mapping[idx]):x["is_fraud"] for idx, x in df.iterrows()}, "label")
    nx.set_edge_attributes(G,{(x["in_node"], mapping[idx]):x["amt"] for idx, x in df.iterrows()}, "weight")
    nx.set_edge_attributes(G,{(x["out_node"], mapping[idx]):x["amt"] for idx, x in df.iterrows()}, "weight")
    return G

代码相当简单。为了构建三分信用卡交易图,我们使用不同的networkx函数。要深入了解,我们在代码中执行的操作如下:

  1. 我们构建了一个映射,为每个商家、客户和交易分配一个node_id

  2. networkx函数nx.from_edgelist用于构建 networkx 图,

  3. 每条边分配了两个属性,即weightlabel。前者表示两个节点之间的总交易次数,而后者表示交易是否为真实或欺诈。

如我们从代码中也可以看到,我们可以选择是否要构建有向图或无向图。我们可以通过调用以下函数来构建一个无向图:

G_tu = build_graph_tripartite(df, nx.Graph(name="Tripartite Undirect"))

我们可以通过调用以下函数来构建一个直接图:

G_td = build_graph_tripartite(df, nx.DiGraph(name="Tripartite Direct"))

唯一的区别在于我们传递给构造函数的第二个参数。

在我们引入的形式化图表示中,实际交易被表示为边。根据这种结构,对于二分图和三分图,欺诈/真实交易的分类被描述为边分类任务。在这个任务中,目标是给定的边分配一个标签(0表示真实,1表示欺诈),描述该边所代表的交易是欺诈还是真实。

在本章的其余部分,我们使用二部图和三部图的无向图进行分析,分别用 Python 变量G_buG_tu表示。我们将把本章提出的分析扩展到有向图的练习留给你们。

我们首先通过以下行进行简单检查,以验证我们的图是否为真正的二部图:

from networkx.algorithms import bipartite
all([bipartite.is_bipartite(G) for G in [G_bu,G_tu]]

结果,我们得到True。这个检查让我们确信这两个图实际上是二部图/三部图。

此外,使用以下命令,我们可以得到一些基本统计信息:

for G in [G_bu, G_tu]:
 print(nx.info(G))

通过一个结果,我们得到以下:

Name: Bipartite Undirect
Type: Graph
Number of nodes: 1676
Number of edges: 201725
Average degree: 240.7220
Name: Tripartite Undirect
Type: Graph
Number of nodes: 267016
Number of edges: 530680
Average degree:   3.9749

如我们所见,两个图在节点数量和边数量上都有所不同。二部图无向图有 1,676 个节点,等于客户数量加上拥有大量边(201,725)的商家数量。三部图无向图有 267,016 个节点,等于客户数量加上商家数量加上所有交易。

在这个图中,正如预期的那样,节点的数量(530,680)比二部图要高。在这个比较中,有趣的不同之处在于两个图的平均度。确实,正如预期的那样,二部图的平均度比三部图要高。实际上,由于在三部图中,交易节点的存在将连接“分割”开来,因此平均度较低。

在下一节中,我们将描述如何现在可以使用生成的交易图进行更完整的统计分析。

网络拓扑和社区检测

在本节中,我们将分析一些图度量子,以便对图的一般结构有一个清晰的了解。我们将使用networkx来计算我们在第一章,“开始使用图”中看到的大部分有用度量子。我们将尝试解释这些度量子,以获得对图的洞察。

网络拓扑

我们分析的一个好的起点是提取简单的图度量子,以便对二部图和三部交易图的主要属性有一个一般性的了解。

我们首先通过以下代码查看二部图和三部图的度分布:

for G in [G_bu, G_tu]:
  plt.figure(figsize=(10,10))
  degrees = pd.Series({k: v for k, v in nx.degree(G)})
  degrees.plot.hist()
  plt.yscale("log")

通过一个结果,我们得到了以下图表:

图 8.3 – 二部图(左)和三部图(右)的度分布

图 8.3 – 二部图(左)和三部图(右)的度分布

图 8.3中,我们可以看到节点分布如何反映了我们之前看到的平均度。更详细地说,二部图有一个更多样化的分布,峰值约为 300。对于三部图,分布有一个 2 度的峰值,而三部图度分布的其他部分与二部图分布相似。这些分布完全反映了两个图定义方式的不同。实际上,如果二部图是通过客户到商家的连接来构建的,那么在三部图中,所有连接都通过交易节点。这些节点是图中的大多数,它们都具有 2 度(一个来自客户的边和一个指向商家的边)。因此,代表 2 度频率的箱子的频率等于交易节点的数量。

我们将继续通过分析边权重分布来继续我们的研究:

  1. 我们首先计算分位数分布:

    for G in [G_bu, G_tu]:
      allEdgesWeights = pd.Series({(d[0], d[1]): d[2]["weight"] for d in G.edges(data=True)})
      np.quantile(allEdgesWeights.values,[0.10,0.50,0.70,0.9])
    
  2. 作为结果,我们得到以下:

    array([  5.03 ,  58.25 ,  98.44 , 215.656])
     array([  4.21,  48.51,  76.4 , 147.1 ])
    
  3. 使用之前的相同命令,我们也可以绘制(对数尺度)边权重的分布,切割到 90 百分位数。结果在以下图表中可见:图 8.4 – 二部图(左侧)和三部图(右侧)的边权重分布

    图 8.4 – 二部图(左侧)和三部图(右侧)的边权重分布

    我们可以看到,由于具有相同客户和商家的交易聚合,与没有计算边权重、聚合多个交易的三部图相比,二部图的分布向右(高值)偏移。

  4. 我们现在将研究介数中心性指标。它衡量有多少最短路径通过一个给定的节点,从而给出了该节点在信息在网络内部传播中的中心性。我们可以通过以下命令计算节点中心性的分布:

    for G in [G_bu, G_tu]:
      plt.figure(figsize=(10,10))
      bc_distr = pd.Series(nx.betweenness_centrality(G))
      bc_distr.plot.hist()
      plt.yscale("log")
    
  5. 作为结果,我们得到以下分布:图 8.5 – 二部图(左侧)和三部图(右侧)的介数中心性分布

    图 8.5 – 二部图(左侧)和三部图(右侧)的介数中心性分布

    如预期,对于两个图,中间中心性都较低。这可以通过网络内部大量非桥接节点来理解。与我们所看到的度分布类似,中间中心性值的分布在这两个图中是不同的。实际上,如果二分图有一个更分散的分布,平均值为 0.00072,那么在三分图中,交易节点是主要移动分布值并降低平均值到 1.38e-05 的节点。在这种情况下,我们还可以看到三分图的分布有一个大峰值,代表交易节点,其余的分布与二分图分布相当相似。

  6. 我们终于可以使用以下代码来计算两个图的相似性:

    for G in [G_bu, G_tu]:
       print(nx.degree_pearson_correlation_coefficient(G)) 
    
  7. 通过这种方式,我们得到以下结果:

    -0.1377432041049189
    -0.8079472914876812
    

在这里,我们可以观察到两个图都具有负相似性,这很可能表明联系紧密的人与联系较差的人联系在一起。对于二分图,由于低度数的客户仅与高度数的商家相连,因为交易数量众多,所以值较低(-0.14)。对于三分图,相似性甚至更低(-0.81)。由于存在交易节点,这种表现是可以预期的。实际上,这些节点总是具有 2 度,并且与代表高度连接节点的客户和商家相连。

社区检测

另一个我们可以进行的有趣分析是社区检测。这种分析有助于识别特定的欺诈模式:

  1. 执行社区提取的代码如下:

    import community
    for G in [G_bu, G_tu]:
       parts = community.best_partition(G, random_state=42, weight='weight')
       communities = pd.Series(parts)   print(communities.value_counts().sort_values(ascending=False))
    

    在此代码中,我们简单地使用community库从输入图中提取社区。然后我们按包含的节点数量对算法检测到的社区进行排序并打印出来。

  2. 对于二分图,我们得到以下输出:

    5     546
    0     335
    7     139
    2     136
    4     123
    3     111
    8      83
    9      59
    10     57
    6      48
    11     26
    1      13
    
  3. 对于三分图,我们得到以下输出:

    11     4828
    3      4493
    26     4313
    94     4115
    8      4036
        ... 47     1160
    103    1132
    95      954
    85      845
    102     561
    
  4. 由于三分图中有大量节点,我们发现了 106 个社区(我们只报告了其中的一部分),而对于二分图,只发现了 12 个社区。因此,为了有一个清晰的图像,对于三分图,最好使用以下命令绘制不同社区中包含的节点分布:

    communities.value_counts().plot.hist(bins=20)
    
  5. 通过这种方式,我们得到以下结果:![图 8.6 – 社区节点大小的分布 图片

    图 8.6 – 社区节点大小的分布

    从图中可以看到,峰值在约 2,500 处。这意味着有 30 多个大型社区拥有超过 2,000 个节点。从图中还可以看到,一些社区拥有少于 1,000 个节点和超过 3,000 个节点。

  6. 对于算法检测到的每一组社区,我们可以计算欺诈交易的百分比。分析的目标是识别欺诈交易高度集中的特定子图:

    graphs = []
    d = {}
    for x in communities.unique():
        tmp = nx.subgraph(G, communities[communities==x].index)
        fraud_edges = sum(nx.get_edge_attributes(tmp, "label").values())
        ratio = 0 if fraud_edges == 0 else (fraud_edges/tmp.number_of_edges())*100
        d[x] = ratio
        graphs += [tmp]
    print(pd.Series(d).sort_values(ascending=False))
    
  7. 代码简单地通过使用特定社区中的节点生成节点诱导子图。该图用于计算欺诈交易的百分比,即欺诈边数与图中所有边数的比率。我们还可以使用以下代码绘制社区检测算法检测到的节点诱导子图:

    gId = 10
    spring_pos = nx.spring_layout(graphs[gId])
     edge_colors = ["r" if x == 1 else "g" for x in nx.get_edge_attributes(graphs[gId], 'label').values()]
    nx.draw_networkx(graphs[gId], pos=spring_pos, node_color=default_node_color, edge_color=edge_colors, with_labels=False, node_size=15)
    

    给定特定的社区索引gId,代码提取包含在gId社区索引中的节点,并绘制得到的图。

  8. 在二分图上运行两个算法,我们将得到以下结果:

    9     26.905830
    10    25.482625
    6     22.751323
    2     21.993834
    11    21.333333
    3     20.470263
    8     18.072289
    4     16.218905
    7      6.588580
    0      4.963345
    5      1.304983
    1      0.000000
    
  9. 对于每个社区,我们都有其欺诈边的百分比。为了更好地描述子图,我们可以通过执行上一行代码并使用gId=10来绘制社区 10。结果如下:![图 8.7 – 二分图中社区 10 的诱导子图 图片

    图 8.7 – 二分图中社区 10 的诱导子图

  10. 诱导子图的图像使我们能够更好地理解数据中是否存在特定模式。在三分图上运行相同的算法,我们得到以下输出:

    6      6.857728
    94     6.551151
    8      5.966981
    1      5.870918
    89     5.760271
          ...   
    102    0.889680
    72     0.836013
    85     0.708383
    60     0.503461
    46     0.205170
    
  11. 由于社区数量众多,我们可以使用以下命令绘制欺诈与真实比率分布图:

    pd.Series(d).plot.hist(bins=20)
    
  12. 结果如下:![图 8.8 – 社区欺诈/真实边比率分布 图片

    图 8.8 – 社区欺诈/真实边比率分布

    从图中,我们可以观察到大部分分布集中在比率为 2 到 4 之间的社区。有几个社区比率较低(<1)和比率较高(>5)。

  13. 此外,对于三分图,我们可以通过执行上一行代码并使用gId=6来绘制由 1,935 个节点组成的社区 6(比率为 6.86):

![图 8.9 – 三分图中社区 6 的诱导子图图片

图 8.9 – 三分图中社区 6 的诱导子图

对于二分图用例,在这张图像中,我们可以看到一个有趣的模式,可以用来深入探索一些重要的图子区域。

在本节中,我们执行一些探索性任务,以更好地理解图及其属性。我们还提供了一个示例,说明如何使用社区检测算法来发现数据中的模式。在下一节中,我们将描述如何使用机器学习自动检测欺诈交易。

监督和无监督欺诈检测嵌入

在本节中,我们将描述如何使用之前描述的二分图和三分图,通过图机器学习算法利用监督和无监督方法构建自动的欺诈检测程序。正如我们在本章开头所讨论的,交易由边表示,我们接下来想要将每个边分类到正确的类别:欺诈或真实。

我们将用于执行分类任务的流程如下:

  • 不平衡任务的采样程序

  • 使用无监督嵌入算法为每个边缘创建特征向量

  • 将监督和无监督机器学习算法应用于前一点定义的特征空间

监督方法用于欺诈交易识别

由于我们的数据集高度不平衡,欺诈交易占总交易的 2.83%,我们需要应用一些技术来处理不平衡数据。在这个用例中,我们将应用简单的随机欠采样策略。更深入地说,我们将从多数类(真实交易)中抽取子样本以匹配少数类(欺诈交易)的样本数量。这只是文献中许多技术中的一种。也有可能使用异常检测算法,如隔离森林,将欺诈交易检测为数据中的异常。我们将这个任务留给你作为练习,使用其他技术来处理不平衡数据,例如随机过采样或使用成本敏感分类器进行分类任务。可以直接应用于图的节点和边缘采样的特定技术将在第十章中描述,图的新趋势

  1. 我们用于随机欠采样的代码如下:

    from sklearn.utils import resample
    df_majority = df[df.is_fraud==0]
     df_minority = df[df.is_fraud==1]
    df_maj_dowsampled = resample(df_majority, n_samples=len(df_minority), random_state=42)
    df_downsampled = pd.concat([df_minority, df_maj_dowsampled])
     G_down = build_graph_bipartite(df_downsampled, nx.Graph())
    
  2. 代码很简单。我们应用了 sklearn 包中的 resample 函数来过滤原始数据框中的 downsample 函数。然后,我们使用本章开头定义的函数构建一个图。为了创建三分图,应使用 build_graph_tripartite 函数。作为下一步,我们将数据集分为训练集和验证集,比例为 80/20:

    from sklearn.model_selection import train_test_split
    train_edges, val_edges, train_labels, val_labels = train_test_split(list(range(len(G_down.edges))), list(nx.get_edge_attributes(G_down, "label").values()), test_size=0.20, random_state=42)
     edgs = list(G_down.edges)
    train_graph = G_down.edge_subgraph([edgs[x] for x in train_edges]).copy()
    train_graph.add_nodes_from(list(set(G_down.nodes) - set(train_graph.nodes)))
    

    与之前一样,在这种情况下,代码也很简单,因为我们只是应用了 sklearn 包中的 train_test_split 函数。

  3. 我们现在可以使用 Node2Vec 算法如下构建特征空间:

    from node2vec import Node2Vec
    node2vec = Node2Vec(train_graph, weight_key='weight')
     model = node2vec_train.fit(window=10)
    

    第三章中所述,使用 node2vec 结果构建边缘嵌入,这将生成分类器使用的最终特征空间,该嵌入属于无监督图学习

  4. 执行此任务的代码如下:

    from sklearn import metrics
    from sklearn.ensemble import RandomForestClassifier 
    from node2vec.edges import HadamardEmbedder, AverageEmbedder, WeightedL1Embedder, WeightedL2Embedder
    classes = [HadamardEmbedder, AverageEmbedder, WeightedL1Embedder, WeightedL2Embedder]
    for cl in classes:
        embeddings = cl(keyed_vectors=model.wv)
        train_embeddings = [embeddings[str(edgs[x][0]), str(edgs[x][1])] for x in train_edges]
        val_embeddings = [embeddings[str(edgs[x][0]), str(edgs[x][1])] for x in val_edges]
        rf = RandomForestClassifier(n_estimators=1000, random_state=42)
        rf.fit(train_embeddings, train_labels)
        y_pred = rf.predict(val_embeddings)
        print(cl)
        print('Precision:', metrics.precision_score(val_labels, y_pred))
        print('Recall:', metrics.recall_score(val_labels, y_pred))
        print('F1-Score:', metrics.f1_score(val_labels, y_pred))
    

与之前的代码相比,执行了不同的步骤:

  1. 对于每个 Edge2Vec 算法,都使用先前计算出的 Node2Vec 算法来生成特征空间。

  2. 在上一步生成的特征集上,使用sklearn Python 库中的RandomForestClassifier进行训练。

  3. 在验证测试上计算了不同的性能指标,即精确度、召回率和 F1 分数。

我们可以将之前描述的代码应用于二分图和三分图来解决欺诈检测任务。在下面的表中,我们报告了二分图的性能:

表 8.2 – 二分图的监督欺诈边缘分类性能

表 8.2 – 二分图的监督欺诈边缘分类性能

在下面的表中,我们报告了三分图的性能:

表 8.3 – 三分图的监督欺诈边缘分类性能

表 8.3 – 三分图的监督欺诈边缘分类性能

表 8.2表 8.3中,我们报告了使用二分图和三分图获得的分类性能。从结果中可以看出,这两种方法在 F1 分数、精确度和召回率方面存在显著差异。由于对于两种图类型,Hadamard 和平均边嵌入算法给出了最有趣的结果,我们将重点关注这两个算法。更详细地说,三分图的精确度比二分图更好(三分图的精确度为 0.89 和 0.74,而二分图的精确度为 0.73 和 0.71)。

相比之下,二分图的召回率比三分图更好(二分图的召回率为 0.76 和 0.79,而三分图的召回率为 0.29 和 0.45)。因此,我们可以得出结论,在这种情况下,使用二分图可能是一个更好的选择,因为它在 F1 分数方面实现了较高的性能,并且与三分图相比,图的大小(节点和边)更小。

无监督欺诈交易识别方法

同样的方法也可以应用于使用 k-means 的无监督任务。主要区别在于生成的特征空间将不会经历训练-验证分割。实际上,在下面的代码中,我们将对按照下采样过程生成的整个图上的Node2Vec算法进行计算:

nod2vec_unsup = Node2Vec(G_down, weight_key='weight')
 unsup_vals = nod2vec_unsup.fit(window=10)

如前所述,在构建节点特征向量时,我们可以使用不同的Egde2Vec算法来运行 k-means 算法,如下所示:

from sklearn.cluster import KMeans
classes = [HadamardEmbedder, AverageEmbedder, WeightedL1Embedder, WeightedL2Embedder]
 true_labels = [x for x in nx.get_edge_attributes(G_down, "label").values()]
for cl in classes:
    embedding_edge = cl(keyed_vectors=unsup_vals.wv)
    embedding = [embedding_edge[str(x[0]), str(x[1])] for x in G_down.edges()]
    kmeans = KMeans(2, random_state=42).fit(embedding)
    nmi = metrics.adjusted_mutual_info_score(true_labels, kmeans.labels_)
    ho = metrics.homogeneity_score(true_labels, kmeans.labels_)
    co = metrics.completeness_score(true_labels, kmeans.labels_
    vmeasure = metrics.v_measure_score(true_labels, kmeans.labels_)
    print(cl)
    print('NMI:', nmi)
    print('Homogeneity:', ho)
    print('Completeness:', co)
    print('V-Measure:', vmeasure)

在前面的代码中执行了不同的步骤:

  1. 对于每个Edge2Vec算法,使用之前在训练和验证集上计算的Node2Vec算法来生成特征空间。

  2. 在上一步生成的特征集上,使用sklearn Python 库中的KMeans聚类算法进行拟合。

  3. 不同的性能指标,即调整后的互信息MNI)、同质性、完整性和 v-measure 分数。

我们可以将之前描述的代码应用于二分图和三分图,以使用无监督算法解决欺诈检测任务。在下表中,我们报告了二分图的性能:

**表 8.4** – 二分图的无监督欺诈边缘分类性能

表 8.4 – 二分图的无监督欺诈边缘分类性能

在下表中,我们报告了三分图的性能:

**表 8.5** – 三分图的无监督欺诈边缘分类性能

表 8.5 – 三分图的无监督欺诈边缘分类性能

表 8.4表 8.5中,我们报告了使用二分图和三分图以及无监督算法获得的分类性能。从结果中我们可以看出,两种方法显示出显著差异。也值得注意,在这种情况下,使用 Hadamard 嵌入算法获得的表现明显优于所有其他方法。

表 8.4表 8.5所示,对于这个任务,使用三分图获得的表现优于使用二分图获得的表现。在无监督的情况下,我们可以看到引入交易节点如何提高整体性能。我们可以断言,在无监督设置中,对于这个特定用例,并以表 8.4表 8.5中获得的结果为参考,使用三分图可能是一个更好的选择,因为它能够实现比二分图更优越的性能。

摘要

在本章中,我们描述了如何将经典的欺诈检测任务描述为图问题,以及如何使用前一章中描述的技术来解决这个问题。更详细地说,我们介绍了我们使用的数据集,并描述了将交易数据转换为两种类型的图的步骤,即二分图和三分图无向图。然后,我们计算了两个图的局部(及其分布)和全局指标,并比较了结果。

此外,为了发现并绘制交易图中欺诈交易密度高于其他社区的特定区域,我们对图应用了社区检测算法。

最后,我们使用监督和无监督算法解决了欺诈检测问题,比较了二分图和三分图的性能。首先,由于问题是不平衡的,真实交易的存在更高,我们进行了简单的下采样。然后,对于监督任务,我们将不同的 Edge2Vec 算法与随机森林结合使用,对于无监督任务,使用 k-means,实现了良好的分类性能。

本章总结了用于展示图机器学习算法如何应用于不同领域问题的示例系列,例如社交网络分析、文本分析和信用卡交易分析。

在下一章中,我们将描述一些图数据库和图处理引擎的实际应用,这些工具对于将分析扩展到大型图非常有用。

第九章:构建数据驱动的图驱动应用

到目前为止,我们已经向您提供了理论和实践上的想法,以便您设计并实现利用图结构进行机器学习模型的构建。除了设计算法之外,将建模/分析流程嵌入到一个强大且可靠的全端到端应用中通常也非常重要。这在工业应用中尤其如此,因为最终目标通常是设计和实现支持数据驱动决策和/或为用户提供及时信息的生产系统。然而,创建一个依赖图表示/建模的数据驱动应用确实是一项具有挑战性的任务,它需要适当的设计,这比简单地导入networkx要复杂得多。本章旨在向您提供构建基于图、可扩展、数据驱动的应用时所使用的核心概念和框架的一般概述。

我们将首先概述所谓的Lambda 架构,它提供了一个框架来构建需要大规模处理和实时更新的可扩展应用。然后,我们将继续在图驱动应用的背景下应用这个框架,即利用本书中描述的技术等利用图结构的应用。我们将描述其两个主要分析组件:图处理引擎图查询引擎。我们将介绍一些在共享内存机器和分布式内存机器上使用的技术,概述相似之处和不同之处。本章将涵盖以下主题:

  • Lambda 架构概述

  • 用于图驱动应用的 Lambda 架构

  • 图处理引擎的技术和示例

  • 图查询引擎和图数据库

技术要求

我们将在所有练习中使用 Python 3.8。在下面的代码块中,您可以使用pip找到本章需要安装的 Python 库列表。例如,在命令行中运行pip install networkx==2.5,等等:

networkx==2.5 
neo4j==4.2.0 
gremlinpython==3.4.6

所有与本章相关的代码文件可在github.com/PacktPublishing/Graph-Machine-Learning/tree/main/Chapter09找到。

Lambda 架构概述

近年来,人们高度重视设计可扩展的架构,一方面可以处理大量数据,另一方面可以实时提供答案/警报/操作,使用最新可用的信息。

此外,这些系统还需要能够通过水平扩展(添加更多服务器)或垂直扩展(使用更强大的服务器)无缝地扩展到更多用户或更多数据。Lambda 架构是一种特定的数据处理架构,旨在以非常高效的方式处理大量数据,并确保高吞吐量,同时保持低延迟并确保容错性和可忽略的错误。

Lambda 架构由三个不同的层组成:

  • 批量层:这一层位于(可能分布式和可扩展的)存储系统之上,可以处理和存储所有历史数据,并在整个数据集上执行在线分析处理OLAP)计算。新数据会持续被摄取和存储,就像在传统数据仓库系统中那样。大规模处理通常通过大规模并行作业实现,旨在生成相关信息的汇总、结构和计算。在机器学习的背景下,依赖于历史信息的模型训练通常在这一层进行,从而产生一个用于批量预测作业或实时执行的训练模型。

  • 速度层:这是一个低延迟层,允许实时处理信息,以提供及时更新和信息。它通常由流处理过程提供数据,通常涉及快速计算,不需要长时间的计算时间或负载。它产生的输出与批量层生成的数据(在(近)实时中)集成,为在线事务处理OLTP)操作提供支持。速度层也可能很好地使用 OLAP 计算的一些输出,例如训练模型。通常,使用实时机器学习建模的应用程序(例如,在信用卡交易中使用的欺诈检测引擎)在其速度层中嵌入训练模型,以提供及时的预测并触发潜在的欺诈实时警报。库可以在事件级别(如 Apache Storm)或微型批次(如 Spark Streaming)上运行,根据用例,对延迟、容错性和计算速度有不同的要求。

  • flaskfastapiturbogear),它们通过专门设计的端点提供数据:

![图 9.1 – 基于 Lambda 架构的应用功能图

![img/B16069_09_01.jpg]

图 9.1 – 基于 Lambda 架构的应用功能图

Lambda 架构具有几个优点,这些优点推动了其使用,尤其是在大数据应用场景中。以下是一些 Lambda 架构的主要优点:

  • 无需服务器管理:因为 Lambda 架构设计模式通常抽象了功能层,不需要安装、维护或管理任何软件/基础设施

  • 灵活扩展:因为应用程序可以是自动扩展的,也可以通过控制批量层(例如计算节点)和/或在速度层(例如 Kafka 代理)中使用的处理单元的数量来扩展

  • 自动高可用性:由于它代表了一种无服务器设计,我们已经有内置的可用性和容错性

  • 业务敏捷性:实时响应不断变化的业务/市场场景

尽管非常强大和灵活,但 Lambda 架构也存在一些限制,这主要归因于存在两个相互关联的处理流程:批量层速度层。这可能要求开发人员为批量处理和流处理构建和维护单独的代码库,从而导致更多的复杂性和代码开销,这可能导致更困难的调试、可能的偏差和错误升级。

在这里,我们提供了一个 Lambda 架构及其基本构建块的简要概述。有关如何设计可扩展架构和最常用的架构模式的更多详细信息,请参阅 Tomcy John 和 Pankaj Misra 于 2017 年出版的书籍《企业数据湖》。

在下一节中,我们将向您展示如何实现图驱动应用程序的 Lambda 架构。特别是,我们将描述主要组件并回顾最常用的技术。

图驱动应用程序的 Lambda 架构

当处理可扩展的、基于图的数据驱动应用程序时,Lambda 架构的设计也体现在分析管道两个关键组件之间的功能分离上,如图9.2所示:

  • 图处理引擎在图结构上执行计算以提取特征(例如嵌入),计算统计数据(例如度分布、边的数量和团),计算指标和关键性能指标KPIs)(例如中心性度量聚类系数),并识别通常需要 OLAP 的相关子图(例如社区)。

  • 图查询引擎使我们能够持久化网络数据(通常通过图数据库完成)并提供了快速的信息检索和高效的查询以及图遍历(通常通过图查询语言完成)。所有信息都已持久化在某些数据存储中(可能或可能不在内存中),除了(可能)一些最终的聚合结果之外,不需要进一步的计算,对于这些结果,索引对于实现高性能和低延迟至关重要:

图 9.2 – 基于图的架构,主要组件也反映在 Lambda 架构模式中

图 9.2 – 基于图的架构,主要组件也反映在 Lambda 架构模式中

图处理引擎位于批处理层之上,并生成可能存储和索引在适当图数据库中的输出。这些数据库是图查询引擎的后端,允许相关信息的轻松快速检索,代表服务层使用的操作视图。根据用例和/或图的大小,通常在相同的基础设施上同时运行图处理引擎和图查询引擎是有意义的。

与在低级存储层(例如,文件系统、HDFS 或 S3)上存储图不同,有一些图数据库选项可以同时支持 OLAP 和 OLTP。这些提供,同时,一个后端持久化层,其中存储了批处理层处理的历史信息,以及来自速度层的实时更新,并且信息可以被服务层高效查询。

与其他用例相比,这种条件对于基于图、数据驱动的应用确实相当独特。历史数据通常提供了一种拓扑结构,新的实时更新和 OLAP 输出(KPI、数据聚合、嵌入、社区等)可以存储在其上。这种数据结构还代表了服务层随后查询的丰富图中的信息。

图处理引擎

要选择合适的图处理引擎技术,估计网络在内存中的大小与目标架构的容量相比至关重要。你可以从使用更简单的框架开始,这些框架在项目的早期阶段允许快速原型设计,当时的目标是快速构建最小可行产品MVP)。

这些框架可以在性能和可扩展性变得更为关键时,由更先进的工具所取代。微服务模块化方法和这些组件的正确结构将允许独立于应用程序的其他部分切换技术/库,以针对特定问题,这也会指导后端堆栈的选择。

图处理引擎需要快速访问整个图的信息,即所有图都在内存中,并且根据上下文,你可能需要或不需要分布式架构。正如我们在第一章,“开始使用图”中看到的,networkx是一个在处理相对较小的数据集时构建图处理引擎的优秀库示例。当数据集变大,但仍可以适应单个服务器或共享内存机器时,其他库可能有助于减少计算时间。正如在第一章,“开始使用图”中看到的,使用除了networkx之外的其他库,其中图算法是用性能更好的语言(如 C++或 Julia)实现的,可能会将计算速度提高两个数量级以上。

然而,在某些情况下,数据集增长如此之大,以至于使用不断增加容量的共享内存机器(胖节点)在技术上或经济上不再可行。在这种情况下,将数据分布到由数十或数百个计算节点组成的集群上,实现水平扩展,变得更为必要。在这些情况下,可以支持图处理引擎的两个最流行的框架如下:

  • Apache Spark GraphX,这是 Spark 库中处理图结构的模块(spark.apache.org/graphx)。它涉及使用弹性分布式数据集RDDs)对顶点和边进行分布式表示。图在整个计算节点上的重新分区可以通过边切割策略来完成,这在逻辑上相当于将节点分配到多台机器上,或者通过顶点切割策略,这在逻辑上相当于将边分配到不同的机器上,并允许顶点跨越多台机器。尽管是用 Scala 编写的,但 GraphX 提供了 R 和 Python 的包装器。GraphX 已经内置了一些算法实现,例如PageRank连通分量三角形计数。还有其他库可以在 GraphX 之上使用,用于其他算法,例如SparklingGraph,它实现了更多的中心性度量。

  • Apache Giraph,这是一个为高可扩展性而构建的迭代图处理系统(giraph.apache.org/)。它由 Facebook 开发和目前使用,用于分析由用户及其连接形成的社会图,并建立在 Hadoop 生态系统之上,以释放大规模结构化数据集的潜力。Giraph 是用 Java 原生编写的,类似于 GraphX,也提供了一些基本图算法的可扩展实现,例如PageRank最短路径

当我们考虑扩展到分布式生态系统时,我们应该始终牢记,可用的算法选择比在共享机器环境中要少得多。这通常有两个原因:

  • 首先,由于节点之间的通信,以分布式方式实现算法比在共享机器上要复杂得多,这也降低了整体效率。

  • 其次,更重要的是,大数据分析的一个基本信条是,只有那些(几乎)与数据点数量成线性关系的算法才应该被实现,以确保解决方案的水平可扩展性,即随着数据集的增加而增加计算节点。

在这方面,Giraph 和 GraphX 都允许你使用基于Pregel的标准接口定义可扩展的、以顶点为中心的迭代算法,这可以被视为图(实际上,应用于三元组节点-边-节点实例的迭代 map-reduce 操作)的某种迭代 map-reduce 操作的等价物。Pregel 计算由一系列迭代组成,每个迭代称为超级步,每个迭代都涉及一个节点及其邻居。

在超级步 S 期间,对每个顶点 V 应用一个用户定义的函数。这个函数将超级步 S-1 中发送到 V 的消息作为输入,并修改 V 及其出度边的状态。这个函数代表映射阶段,可以很容易地进行并行化。除了计算 V 的新状态外,该函数还将消息发送到与 V 连接的其他顶点,这些顶点将在超级步 S+1 接收到这些信息。消息通常沿着出度边发送,但可以向任何已知标识符的顶点发送消息。在图 9.3 中,我们展示了 Pregel 算法在计算网络上的最大值时的草图。有关此算法的更多详细信息,请参阅 Malewicz 等人于 2010 年撰写的原始论文《Pregel:一个用于大规模图处理的系统》:

![图 9.3 – 使用 Pregel 计算节点属性最大值的示例]

![img/B16069_09_03.jpg]

图 9.3 – 使用 Pregel 计算节点属性最大值的示例

通过使用 Pregel,你可以轻松地以非常高效和通用的方式实现其他算法,例如PageRank连通分量,甚至可以并行实现节点嵌入的变体(例如,请参阅 Riazi 和 Norris 于 2020 年撰写的《用于大规模图的分布式内存顶点中心网络嵌入》,Riazi 和 Norris,2020)。

图查询层

在过去十年中,由于非结构化数据的大量扩散,NoSQL 数据库开始获得相当大的关注和重要性。其中,图数据库确实非常强大,可以基于实体之间的关系存储信息。实际上,在许多应用中,数据可以自然地被视为实体,通过具有属性的边连接,这些边也具有进一步描述实体之间关系的属性。

图数据库的例子包括 Neo4j、OrientDB、ArangoDB、Amazon Neptune、Cassandra 和 JanusGraph(之前称为 TitanDB)。在接下来的章节中,我们将简要介绍其中的一些,以及允许我们查询和遍历底层图的语言,这些语言被称为图查询语言

Neo4j

在撰写本文时,Neo4J (neo4j.com/)无疑是周围最常见的图数据库,拥有一个支持其使用和采用的庞大社区。它有两个版本:

  • 社区版,在 GPL v3 许可下发布,允许用户/开发者公开将 Neo4j 包含在其应用程序中

  • 企业版,专为需要规模和可用性的商业部署设计

Neo4j 可以通过分片扩展到相当大的数据集,即通过将数据分布到多个节点并在数据库的多个实例上并行查询和聚合来并行化查询。此外,Neo4j 联邦还允许查询较小的分离图(有时甚至具有不同的模式),就像它们是一个大图一样。

Neo4j 的一些优点是其灵活性(允许模式演变)和用户友好性。特别是,Neo4j 中的许多操作都可以通过其查询语言完成,该语言非常直观且易于学习:Cypher。Cypher 可以被视为图数据库的 SQL 对应物。

测试 Neo4j 和 Cypher 非常容易。您可以通过 Docker 安装社区版(见下一节)或尝试在线沙盒版本(neo4j.com/sandbox/)。

通过使用后者,您可以导入一些内置的数据集,例如电影数据集,并开始使用 Cypher 查询语言对其进行查询。电影数据集由 38 部电影和 133 位参与表演、导演、编写、评论和制作这些电影的人组成。无论是在场版本还是在线版本,都配备了用户友好的 UI,允许用户查询和可视化数据(见图 9.4)。我们首先列出电影数据集中的 10 位演员,只需查询以下内容:

MATCH (p: Person) RETURN p LIMIT 10

但现在让我们利用数据点之间关系的信息。我们看到数据库中出现的一个演员是基努·里维斯。我们可能会想知道他在列出的电影中与哪些演员合作过。此信息可以通过以下查询轻松检索:

MATCH (k: Person {name:"Keanu Reeves"})-[:ACTED_IN]-(m: Movie)-[:ACTED_IN]-(a: Person) RETURN k, m, a

如以下图所示,查询通过声明我们感兴趣的路径,直观地以语法形式指示如何遍历图:

图 9.4 – Neo4j UI 的示例,使用 Cypher 查询检索电影数据集中基努·里维斯的合作演员

图 9.4 – Neo4j UI 的示例,使用 Cypher 查询检索电影数据集中基努·里维斯的合作演员

除了 Cypher,数据也可以使用 Gremlin 进行查询。这将在描述图数据库的通用接口时简要介绍。

Neo4j 还提供了与多种编程语言的绑定,例如 Python、JavaScript、Java、Go、Spring 和.NET。特别是对于 Python,有几个库实现了与 Neo4j 的连接,如neo4jpy2neoneomodel,其中neo4j是官方和支持的,通过二进制协议直接连接到数据库。创建数据库连接并运行查询只需几行代码:

from neo4j import GraphDatabase
driver = GraphDatabase("bolt://localhost:7687", "my-user", "my-password")
def run_query(tx, query):
    return tx.run(query)
with driver.session() as session:
    session.write_transaction(run_query, query)

查询可以是任何 Cypher 查询,例如,之前编写的用于检索基努·里维斯合作演员的查询。

JanusGraph – 一个可扩展的图数据库,可扩展到非常大的数据集

Neo4j 是一款非常出色的软件,当你想要快速完成任务时,凭借其直观的界面和查询语言,无与伦比。Neo4j 确实是一个适合生产的图数据库,但在敏捷性至关重要的 MVP 中尤其出色。然而,随着数据量的增加,基于分片和将大型图分解成较小子图的扩展性可能不是最佳选择。

当数据量显著增加时,你可能需要开始考虑其他图数据库选项。再次强调,这应该只在用例需求开始触及 Neo4j 的可扩展性限制时进行,因为需求从 MVP 的初始要求演变而来。

在这种情况下,有几种选择。其中一些是商业产品,例如 Amazon Neptune 或 Cassandra。然而,开源选项也是可用的。其中,我们认为值得提及的是JanusGraph (janusgraph.org/),这是一款特别有趣的软件。JanusGraph 是之前名为TitanDB的开放源代码项目的演变,现在是 Linux 基金会的官方项目,同时也得到了技术领域顶级玩家的支持,如 IBM、Google、Hortonworks、Amazon、Expero 和 Grakn Labs。

JanusGraph 是一个可扩展的图数据库,专为存储和查询跨多机集群分布的图而设计,具有数百亿个顶点和边。实际上,JanusGraph 本身没有存储层,而是一个组件,用 Java 编写,位于其他数据存储层之上,如下所示:

  • Google Cloud Bigtable (cloud.google.com/bigtable),这是基于 Google 文件系统构建的专有数据存储系统的云版本,旨在扩展数据中心间分布的大量数据(*《Bigtable: A Distributed Storage System for Structured Data》,Fay Chang 等人,2006 年)。

  • Apache HBase (hbase.apache.org/),这是一个非关系型数据库,它基于 Hadoop 和 HDFS 提供了 Bigtable 功能,从而确保了类似的可扩展性和容错性。

  • Apache Cassandra (cassandra.apache.org/),这是一个开源的分布式 NoSQL 数据库,允许处理大量数据,跨越多个数据中心。

  • ScyllaDB (www.scylladb.com/),这是一种专门为实时应用设计的数据库,与 Apache Cassandra 兼容,同时实现了显著更高的吞吐量和更低的延迟。

因此,JanusGraph 继承了可扩展解决方案的所有良好特性,如可扩展性、高可用性和容错性,并在其之上抽象出一个图视图。

通过与 ScyllaDB 的集成,JanusGraph 可以处理极快、可扩展和高吞吐量的应用。此外,JanusGraph 还集成了基于 Apache Lucene、Apache Solr 和 Elasticsearch 的索引层,以便在图中实现更快的信息检索和搜索功能。

与索引层一起使用高度分布的后端,允许 JanusGraph 扩展到巨大的图,拥有数百亿个节点和边,有效地处理所谓的超节点——换句话说,具有极端大度数的节点,这些节点通常出现在现实世界应用中(记住,一个非常著名的真实网络模型是Barabasi-Albert模型,基于优先连接,这使得中心节点自然地出现在图中)。

在大型图中,超节点往往是应用的潜在瓶颈,尤其是在业务逻辑需要通过它们遍历图时。具有在图遍历期间快速过滤相关边的属性可以帮助显著加快处理过程并提高性能。

JanusGraph 通过Apache TinkerPop库(tinkerpop.apache.org/)提供了一个标准的 API 来查询和遍历图,这是一个开源的、供应商无关的图计算框架。TinkerPop 提供了一个标准接口,用于使用Gremlin图遍历语言查询和分析底层图。因此,所有 TinkerPop 兼容的图数据库系统都可以无缝地相互集成。因此,TinkerPop 允许你构建“标准”的服务层,这些服务层不依赖于后端技术,从而给你自由选择/更改适合你应用的实际需要的图技术。实际上,大多数图数据库(甚至包括我们之前提到的 Neo4j)现在都具备与 TinkerPop 的集成功能,这使得在后台图数据库之间切换变得无缝,并避免了任何供应商锁定。

除了 Java 连接器之外,Gremlin 还通过gremlinpython库提供了直接的 Python 绑定,这使得 Python 应用能够连接和遍历图。为了查询图结构,我们首先需要连接到数据库,使用以下方法:

from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection
connection = DriverRemoteConnection(
    'ws://localhost:8182/gremlin', 'g'
)

一旦建立了连接,我们就可以实例化GraphTraversalSource,这是所有 Gremlin 遍历的基础,并将其绑定到我们刚刚创建的连接:

from gremlin_python.structure.graph import Graph
from gremlin_python.process.graph_traversal import __ 
graph = Graph()
g = graph.traversal().withRemote(connection)

一旦实例化了GraphTraversalSource,我们就可以在整个应用中重用它来查询图数据库。想象一下,我们将之前描述的 Movie 图数据库导入到 JanusGraph 中;我们可以重新编写之前用来查找基努·里维斯所有合演者的 Cypher 查询,使用 Gremlin:

co_actors = g.V().has('Person', 'name', 'Keanu Reeves').out("ACTED_IN").in("ACTED_IN").values("name")

如前述代码行所示,Gremlin 是一种函数式语言,其中操作符被分组在一起形成类似路径的表达式。

选择 Neo4j 和 GraphX

Neo4j 或 GraphX?这是一个经常被问到的问题。然而,正如我们简要描述的,这两款软件并不是真正的竞争对手,而是针对不同的需求。Neo4j 允许我们以图结构存储信息并查询数据,而 GraphX 使得分析图(特别是对于大型图维度)成为可能。虽然你也可以将 Neo4j 用作处理引擎(实际上 Neo4j 生态系统包含一个图数据科学库,它实际上是一个处理引擎),GraphX 也可以用作内存存储的图,但应避免这种做法。

图处理引擎通常计算 KPI,这些 KPI 存储在图数据库层(可能被索引,以便查询和排序变得高效)以供以后使用。因此,像 GraphX 这样的技术并不与像 Neo4j 这样的图数据库竞争,它们可以在同一应用程序中很好地共存,以服务于不同的目的。正如我们在引言中所强调的,即使在 MVP 和早期阶段,最好是将两个组件——图处理引擎和图查询引擎——分开,并为每个组件使用适当的技术。

在这两种情况下,都存在简单易用的库和工具,我们强烈建议你明智地使用它们,以构建一个坚实可靠的应用程序,它可以无缝扩展。

摘要

在本节中,我们向您提供了如何设计、实现和部署依赖图建模并利用图结构的数据驱动应用程序的基本概念。我们强调了模块化方法的重要性,这通常是任何数据驱动用例从早期 MVP 到可以处理大量数据和大量计算性能的生产系统的无缝扩展的关键。

我们概述了主要的架构模式,这应该在你设计数据驱动应用程序的主干结构时为你提供指导。然后我们继续描述了构成基于图的应用程序基础的主要组件:图处理引擎图数据库图查询语言。对于每个组件,我们都提供了最常见工具和库的概述,以及实用的示例,这将帮助你构建和实施你的解决方案。因此,你现在应该对目前的主要技术和它们的应用有了很好的了解。

在下一章中,我们将转向一些最近的发展和最新研究,这些研究将机器学习的趋势应用于图。特别是,我们将描述科学文献中可用的最新技术(如生成型神经网络)和应用程序(如神经科学中应用的图论),并提供一些实用示例和可能的应用。

第十章:图上的新颖趋势

在前面的章节中,我们描述了不同类型的监督和非监督算法,这些算法可以用于涉及图数据结构的广泛问题。然而,关于图机器学习的科学文献浩如烟海,并且不断演变,每个月都有新的算法发表。在本章中,我们将提供一些关于图机器学习的新技术和应用的高级描述。

本章将分为两个主要部分——高级算法和应用。第一部分主要致力于描述图机器学习领域的一些有趣的新技术。你将了解基于随机游走和生成神经网络的图数据采样和数据增强技术。然后,你将了解拓扑数据分析,这是一种相对新颖的工具,用于分析高维数据。在第二部分,我们将向你提供图机器学习在不同领域的一些有趣应用,从生物学到几何分析。阅读本章后,你将意识到如何观察数据之间的关系为新颖迷人的解决方案打开了大门。

具体来说,本章将涵盖以下主题:

  • 学习关于图数据增强

  • 学习关于拓扑数据分析

  • 在新领域应用图论

在我们开始之前,让我们确保我们已经具备了以下章节中提到的先决条件。

技术要求

我们将使用 Python 3.6.9 进行所有练习。以下是你必须使用pip安装的 Python 库列表,以便本章使用:例如,你可以在命令行上运行pip install networkx==2.5,等等:

networkx==2.5 
littleballoffur==2.1.8

与本章相关的所有代码文件都可在待定 URL找到。

学习关于图数据增强

在第八章《信用卡交易图分析》中,我们描述了如何使用图机器学习来研究和自动检测欺诈信用卡交易。在描述用例时,我们遇到了两个主要障碍:

  • 原始数据集中节点太多,难以处理。因此,计算成本过高,无法进行计算。这就是我们只选择数据集的 20%的原因。

  • 从原始数据集中,我们发现不到 1%的数据被标记为欺诈交易,而数据集的其余 99%包含真实交易。这就是为什么在边缘分类任务中,我们随机对数据集进行子采样。

我们用来解决这两个障碍的技术,总的来说,并不是最优的。对于图数据,需要更复杂和创新的技术来解决任务。此外,当数据集高度不平衡时,正如我们在第八章中提到的,信用卡交易图分析,我们可以使用异常检测算法来解决这个问题。

在本节中,我们将描述一些技术和算法,我们可以使用它们来解决上述问题。我们将从描述图采样问题开始,然后描述一些图数据增强技术。我们将分享一些有用的参考文献和 Python 库,用于这两个方面。

样本策略

第八章中,信用卡交易图分析,为了执行边分类任务,我们最初只对整个数据集的 20%进行了采样。不幸的是,这种策略,总的来说,并不是一个最优的方案。实际上,使用这种简单策略选择的节点子集可能会生成一个子图,这个子图并不能代表整个图的拓扑结构。因此,我们需要定义一个策略,通过采样正确的节点来构建给定图的子图。从给定的大图中通过最小化拓扑信息损失来构建(小)子图的过程被称为图采样

为了让我们对图采样算法有一个全面的了解,可以在论文《Little Ball of Fur: A Python Library for Graph Sampling》中找到好的起点,该论文可以从以下 URL 下载:arxiv.org/pdf/2006.04311.pdf。他们使用networkx库的 Python 实现可以在以下 URL 找到:github.com/benedekrozemberczki/littleballoffur。这个库中可用的算法可以分为节点采样算法和边采样算法。这些算法分别对图中的节点和边进行采样。结果,我们从原始图中得到一个节点或边诱导的子图。我们将让您使用littleballoffur Python 包中可用的不同图采样策略来执行第八章中提出的分析,信用卡交易图分析

探索数据增强技术

数据增强是在处理不平衡数据时的一种常见技术。在不平衡问题中,我们通常有两个或更多类的标记数据。数据集中只有一个或多个类的样本很少。包含少量样本的类也称为少数类,而包含大量样本的类称为多数类。例如,在第八章中描述的用例,“信用卡交易图分析”中,我们有一个不平衡数据集的明显例子。在输入数据集中,只有 1% 的所有交易被标记为欺诈(少数类),而其他 99% 是真实交易(多数类)。在处理经典数据集时,问题通常通过随机下采样或上采样或使用如SMOTE之类的数据生成算法来解决。然而,对于图数据,这个过程可能并不那么简单,因为生成新节点或图不是一个简单的过程。这是由于复杂的拓扑关系存在。在过去十年中,已经开发出大量数据增强图算法。在这里,我们将介绍两种最新可用的算法,即 GAugGRAN

GAug 算法是一种基于节点的数据增强算法。它在论文《用于图神经网络的图数据增强》中进行了描述,该论文可在以下网址找到:arxiv.org/pdf/2006.06830.pdf。该库的 Python 代码可在以下网址找到:github.com/zhao-tong/GAug。此算法在需要边缘或节点分类的场景中可能很有用,例如在第八章中提供的用例,“信用卡交易图分析”,其中可以使用该算法对属于少数类的节点进行增强。作为一个练习,你可以扩展我们在第八章中提出的分析,“信用卡交易图分析”,使用 GAug 算法。

GRAN 算法是一种基于图的图数据增强算法。它在论文《使用图循环注意力网络的高效图生成》中进行了描述,该论文可在以下网址找到:arxiv.org/pdf/1910.00760.pdf。该库的 Python 代码可在以下网址找到:github.com/lrjconan/GRAN。此算法在处理图分类/聚类问题时生成新图时很有用。例如,如果我们处理一个不平衡的图分类问题,使用 GRAN 算法为数据集创建平衡步骤然后执行分类任务可能很有用。

了解拓扑数据分析

拓扑数据分析TDA)是一种相当新颖的技术,用于提取量化数据“形状”的特征。这种方法的想法是,通过观察数据点在某个空间中的组织方式,我们可以揭示有关生成该数据的过程的一些重要信息。

应用 TDA 的主要工具是持久同调。这种方法背后的数学相当高级,所以让我们通过一个例子来介绍这个概念。假设你有一组分布在空间中的数据点,让我们假设你正在“观察”它们随时间的变化。点保持静止(它们不会在空间中移动);因此,你将永远观察这些独立点。然而,让我们想象我们可以通过一些定义良好的规则将这些数据点连接起来,从而在这些数据点之间建立关联。特别是,让我们想象一个从这些点随时间扩展的球体。每个点都将有一个自己的扩展球体,一旦两个球体碰撞,这两个点就可以放置一个“边”。这可以用以下图例来表示:

![图 10.1 – 点之间关系创建的示例img/B16069_10_01.jpg

图 10.1 – 点之间关系创建的示例

碰撞的球体越多,创建的关联就越多,放置的边也就越多。这发生在多个球体与更复杂的几何结构(如三角形、四面体等)相交时:

![图 10.2 – 点之间连接生成几何结构的示例img/B16069_10_02.jpg

图 10.2 – 点之间连接生成几何结构的示例

当一个新的几何结构出现时,我们可以记录其“诞生”时间。另一方面,当现有的几何结构消失(例如,它成为更复杂几何结构的一部分)时,我们可以记录其“死亡”时间。在模拟过程中观察到的每个几何结构的生存时间(从诞生到死亡的时间)可以用作分析原始数据集的新特征。

我们还可以通过将每个结构的对应对(诞生,死亡)放置在二维坐标系中来定义所谓的持久图。靠近对角线的点通常反映噪声,而远离对角线的点代表持久特征。以下是一个持久图的示例。请注意,我们通过使用扩展的“球体”作为示例来描述整个过程。在实践中,我们可以改变这个扩展形状的维度(例如,使用二维圆),从而为每个维度生成一组特征(通常用字母H表示):

![图 10.3 – 2D 点云(右)及其对应持久图(左)的示例img/B16069_10_03.jpg

图 10.3 – 2D 点云(右)及其对应持久图(左)的示例

一个用于执行拓扑数据分析的优秀的 Python 库是giotto-tda,可在以下网址找到:github.com/giotto-ai/giotto-tda。使用 giotto-tda 库,可以轻松构建单纯复形及其相关的持久图,如图像所示。

拓扑机器学习

现在我们已经了解了 TDA 背后的基础知识,让我们看看它如何被用于机器学习。通过向机器学习算法提供拓扑数据(例如持久特征),我们可以捕捉到其他传统方法可能错过的模式。

在上一节中,我们看到了持久图在描述数据方面的有用性。然而,使用它们来喂养机器学习算法(如RandomForest)并不是一个好的选择。例如,不同的持久图可能有不同数量的点,基本代数运算可能没有很好地定义。

一种克服这种限制的常见方法是将图表转换为更合适的表示。可以使用嵌入或核方法来获得图表的“向量化”表示。此外,如“持久图像”,“持久景观”和“贝蒂曲线”等高级表示方法已被证明在实用应用中非常有用。例如,“持久图像”(图 10.4)是持久图的二维表示,可以轻松地输入到卷积神经网络中。

从这个理论中产生了几个可能性,并且发现与深度学习之间仍然存在联系。正在提出一些新想法,使这个主题既热门又迷人:

图 10.4 – 持久图像的示例

图 10.4 – 持久图像的示例

拓扑数据分析是一个快速发展的领域,尤其是在它可以与机器学习技术相结合的情况下。每年都会发表关于这个主题的几篇科学论文,我们预计在不久的将来会有新颖而令人兴奋的应用。

在新领域中应用图论

近年来,由于对图机器学习有了更坚实的理论理解,以及可用存储空间和计算能力的增加,我们可以确定许多领域,在这些领域中,这种学习理论正在传播。只要稍加想象,你就可以开始将周围的世界看作是一组“节点”和“链接”。我们的工作或学习场所,我们每天使用的科技设备,甚至我们的大脑都可以表示为网络。在本节中,我们将探讨一些图论(以及图机器学习)如何被应用于看似无关的领域的例子。

图机器学习和神经科学

使用图论研究大脑是一个繁荣且不断发展的领域。已经研究了多种表示大脑作为网络的方法,目的是了解大脑的不同部分(节点)是如何相互功能上结构上连接的。

通过使用如磁共振成像MRI)等医疗技术,可以获得大脑的三维表示。这样的图像可以通过不同的算法进行处理,以获得大脑的不同分区(分区)。

我们可以根据是否对分析这些区域的功能性或结构性连通性感兴趣,以不同的方式定义这些区域之间的连接:

  • 功能性磁共振成像fMRI)是一种测量大脑某个部分是否“活跃”的技术。具体来说,它测量每个区域的血氧水平依赖性BOLD)信号(表示在特定时间内血液和氧气水平的变化的信号)。然后,可以计算两个感兴趣大脑区域的 BOLD 序列之间的皮尔逊相关系数。高相关性意味着这两个部分“功能上连接”,可以在它们之间放置一条边。一篇关于图形分析 fMRI 数据的有趣论文是基于图的静息态功能性 MRI 网络分析,可在www.frontiersin.org/articles/10.3389/fnsys.2010.00016/full找到。

  • 另一方面,通过使用如扩散张量成像DTI)等高级 MRI 技术,我们还可以测量两个感兴趣大脑区域之间物理连接的白质纤维束的强度。因此,我们可以获得表示大脑结构连通性的图表。一篇将神经网络与从 DTI 数据生成的图表结合使用的论文称为通过图卷积神经网络的多发性硬化症临床特征,可在www.frontiersin.org/articles/10.3389/fnins.2019.00594/full找到。

  • 功能性和结构连通性可以使用图论进行分析。有几项研究增强了与神经退行性疾病相关的此类网络的显著变化,例如阿尔茨海默病、多发性硬化症和帕金森病等。

最终结果是描述不同大脑区域之间连接的图表,如图所示:

![图 10.5 – 作为图表的大脑区域连接img/B16069_10_05.jpg

图 10.5 – 作为图表的大脑区域连接

在这里,我们可以看到如何将不同的大脑区域视为图的节点,而那些区域之间的连接则是边。

图机器学习已被证明对于这种分析非常有用。不同的研究已经进行,基于大脑网络自动诊断特定的病理,从而预测网络的演变(例如,识别未来可能受到病理影响的潜在脆弱区域)。

网络神经科学是一个有希望的领域,在未来,我们将从这些网络中收集越来越多的见解,以便我们能够理解病理改变并预测疾病的演变。

图论和化学与生物学

图机器学习可以应用于化学。例如,通过将原子视为图的节点,将键视为它们的连接,图提供了一种自然的方法来描述分子结构。这种方法已被用于研究化学系统的不同方面,包括表示反应、学习化学指纹(指示化学特征或子结构的存在或不存在)等。

在生物学中也可以找到一些应用,其中许多不同的元素可以表示为图。例如,蛋白质-蛋白质相互作用PPI)是研究最广泛的主题之一。在这里,构建了一个图,其中节点代表蛋白质,边代表它们的相互作用。这种方法使我们能够利用 PPI 网络的结构信息,这在 PPI 预测中已被证明是有信息的。

图机器学习和计算机视觉

深度学习的兴起,特别是卷积神经网络CNN)技术,在计算机视觉研究中取得了惊人的成果。对于广泛的任务,如图像分类、目标检测和语义分割,CNNs 可以被认为是当前最先进的。然而,最近,计算机视觉中的核心挑战已经开始使用图机器学习技术来解决——特别是几何深度学习。正如我们在本书中学到的,图像表示的二维欧几里得域与更复杂对象(如三维形状和点云)之间存在基本差异。从二维和三维视觉数据中恢复世界的三维几何形状、场景理解、立体匹配和深度估计只是可以做到的几个例子。

图像分类和场景理解

图像分类,作为计算机视觉中最广泛研究的一项任务,如今主要由基于 CNN 的算法主导,已经开始从不同的角度被处理。图神经网络模型已经显示出吸引人的结果,尤其是在大量标记数据不可用的情况下。特别是,有一种趋势是将这些模型与零样本和少样本学习技术相结合。在这里,目标是分类模型在训练期间从未见过的类别。例如,这可以通过利用未见对象与已见对象“语义”相关性的知识来实现。

类似的方法也已被用于场景理解。使用场景中检测到的对象之间的关系图提供了图像的可解释结构化表示。这可以用于支持各种任务的高级推理,包括标题生成和视觉问答等。

形状分析

与二维像素网格表示的图像不同,有几种方法可以表示 3D 形状,例如多视图图像深度图体素点云网格隐式表面等。然而,在应用机器和深度学习算法时,这些表示可以被利用来学习特定的几何特征,这对于设计更好的分析是有用的。

在这个背景下,几何深度学习技术已经显示出有希望的结果。例如,GNN 技术已被成功应用于寻找可变形形状之间的对应关系,这是一个经典问题,导致了许多应用,包括纹理动画和映射,以及场景理解。对于那些对此感兴趣的人,一些有助于理解图机器学习应用的资源可以在arxiv.org/pdf/1611.08097.pdfgeometricdeeplearning.com/找到。

推荐系统

图机器学习的另一个有趣应用是在推荐系统中,我们可以用它来预测用户会对一个项目分配的“评分”或“偏好”。在第六章 社交网络图中,我们提供了一个例子,说明了如何使用链接预测来构建为特定用户和/或客户提供推荐的自动算法。在可从arxiv.org/pdf/2011.02260.pdf获取的论文推荐系统中的图神经网络:综述中,作者提供了一份关于图机器学习如何应用于构建推荐系统的广泛调查。更具体地说,作者描述了不同的图机器学习算法及其应用。

总结

在本章中,我们提供了关于一些新兴图机器学习算法及其在新领域应用的高级概述。在本章开头,我们以第八章中提供的示例“信用卡交易图分析”为例,描述了一些图数据的采样和增强算法。我们还提供了一些可以用于处理图采样和图数据增强任务的 Python 库。

我们继续通过提供拓扑数据分析的一般描述以及这项技术最近在不同领域中的应用情况。

最后,我们提供了关于新应用领域的几个描述,例如神经科学、化学和生物学。我们还描述了机器学习算法如何被用来解决其他任务,例如图像分类、形状分析和推荐系统。

就到这里!在这本书中,我们概述了最重要的图机器学习技术和算法。你现在应该能够处理图数据并构建机器学习算法。我们希望你现在工具箱中拥有更多工具,并且你会使用它们来开发令人兴奋的应用。我们还邀请你检查本书中提供的参考文献,并应对我们在不同章节中提出的挑战。

图机器学习的世界令人着迷且发展迅速。每天都有新的研究论文发表,其中包含令人难以置信的发现。像往常一样,持续回顾科学文献是发现新算法的最佳方式,而 arXiv (arxiv.org/) 是搜索免费科学论文的最佳场所。

posted @ 2025-09-04 14:14  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报