原文:
towardsdatascience.com/graph-visualization-7-steps-from-easy-to-advanced-4f5d24e18056
Davis 的南方俱乐部图,图片由作者提供
一些数据类型,如社交网络或知识图谱,可以“原生”地以图形形式表示。这种数据的可视化可能具有挑战性,而且没有通用的配方。在这篇文章中,我将展示使用开源NetworkX库进行图形可视化的几个步骤。
让我们开始吧!
基本示例
如果我们想在 Python 中使用图表,NetworkX 可能是最受欢迎的选择。它是一个用于网络分析的开放源代码 Python 包,包括不同的算法和强大的功能。正如我们所知,每个图都包含节点(顶点)和它们之间的关系;我们可以在 NetworkX 中轻松创建一个简单的图:
import networkx as nx
G = nx.Graph()
G.add_node("A")
G.add_node("B")
G.add_edge("A", "B")
...
然而,以这种方式创建一个大型图可能会很累人,在这篇文章中,我将使用 NetworkX 库中包含的“Davis 的南方俱乐部女性”图(3-clause BSD 许可)。这些数据由 A. Davis 等人于 20 世纪 30 年代收集(A. Davis,1941 年,《深南》,芝加哥:芝加哥大学出版社)。它代表了 18 位南方女性参加 14 个社交活动的观察结果。让我们加载这个图并绘制它:
import networkx as nx
import matplotlib.pyplot as plt
G = nx.davis_southern_women_graph()
fig1 = plt.figure(figsize=(12, 8))
nx.draw(G, with_labels=True)
plt.show()
结果看起来像这样:
Davis 的南方俱乐部女性图,图片由作者提供
它是可行的,但这张图片肯定可以改进。让我们看看不同的方法来实现它。
1. 布局
根据定义,一个图本身只包含节点和它们之间的关系;它没有任何坐标。同一个图可以用许多不同的方式显示,NetworkX 中也有不同的布局可供选择。没有一种通用的解决方案适合所有情况,视觉印象也可能具有主观性。最好的方法是尝试不同的选项,找到更适合特定数据集的图像。
螺旋布局这种布局可以通过使用spiral_layout方法生成:
pos = nx.spiral_layout(G)
print(pos)
#> {'Evelyn Jefferson': array([-0.51048124, 0.00953613]),
# 'Laura Mandeville': array([-0.59223481, -0.08317364]), ... }
nx.draw(G, pos=pos, with_labels=True)
如我们从print输出中可以看到,布局本身只是一个包含坐标的字典。这个布局可以作为draw方法的可选参数指定。结果看起来像这样:
螺旋布局,图片由作者提供
对于这种类型的图,这并不是最好的选择;让我们尝试其他方法。
圆形布局在这里,代码逻辑是相同的。首先,我们创建一个布局,然后我们在代码中使用它:
pos = nx.circular_layout(G)
nx.draw(G, pos=pos, with_labels=True)
结果:
循环布局,图片由作者提供
就像上一个例子一样,圆形布局对于这个图表来说并不是最好的。
Kamada-Kawai 布局此方法使用 Kamada-Kawai 路径长度成本函数:
pos = nx.kamada_kawai_layout(G)
nx.draw(G, pos=pos, with_labels=True)
结果看起来更好:
Kamada-Kawai 布局,图片由作者提供
弹簧布局此方法使用 Fruchterman-Reingold 力导向算法,它作为一种“反重力力”,将节点彼此拉远,除非系统达到平衡。
pos = nx.spring_layout(G, seed=42)
nx.draw(G, pos=pos, with_labels=True)
主观上,结果看起来最好:
弹簧布局,图片由作者提供
这里的seed参数在我们要得到相同结果时很有用,否则,每次重绘都会产生另一个看起来不同的图表。
其他图形布局类型在 NetworkX 中可用;读者可以自己尝试它们。
2. 节点颜色
作为提醒,我们的图表代表了 18 位参与 14 个社交活动的女性。图表中的所有事件都有“Exx”名称;让我们改变它们的颜色以获得更好的视觉效果。
首先,我将创建一个辅助方法来检测节点是否是事件:
def is_event_node(node: str) -> bool:
""" Check if events starts with Exx """
return re.match("^Ed", node) is not None
在这里,我使用正则表达式来确定节点模式(我的第一次尝试是使用node.startswith("E")方法,但一些女性的名字也可以以"E"开头)。现在,我们可以轻松地为每个节点创建一个颜色数组,并使用它来绘制图表:
def get_node_color(node: str) -> str:
""" Get color of the individual node """
return "#00AA00" if is_event_node(node) else "#00AAEE"
node_colors = [get_node_color(node) for node in G.nodes()]
nx.draw(G, pos=pos, node_color=node_colors, with_labels=True)
结果看起来像这样:
节点颜色,图片由作者提供
3. 节点大小
就像颜色一样,我们可以指定每个节点的大小。让我们使“事件”节点更大;节点大小也可以与它的连接数成比例:
edges = {node:len(G.edges(node)) for node in G.nodes()}
def node_size(node: str) -> int:
""" Get size of the individual node """
k = 4 if is_event_node(node) else 1
return 100*k + 100 + 50*edges[node]
node_sizes = [node_size(node) for node in G.nodes()]
nx.draw(G, pos=pos, node_color=node_colors, node_size=node_sizes,
with_labels=True)
在这里,我将每个节点的边数保存到一个单独的字典中。我还使用了之前相同的is_event_node方法来使“事件”节点更大。
结果看起来像这样:
不同的节点大小,图片由作者提供
4. 边颜色
我们不仅可以指定节点颜色,还可以指定边颜色。作为一个例子,让我们突出显示 Theresa Anderson 访问的所有事件。为此,我需要三个辅助方法:
highlighted_node = "Theresa Anderson"
def get_node_color(node: str) -> str:
""" Get color of the individual node """
if is_event_node(node):
if G.has_edge(node, highlighted_node):
return "#00AA00"
elif node == highlighted_node:
return "#00AAEE"
return "#AAAAAA"
def edge_color(node1: str, node2: str) -> str:
""" Get color of the individual edge """
if node1 == highlighted_node or node2 == highlighted_node:
return "#992222"
return "#999999"
def edge_weight(node1: str, node2: str) -> str:
""" Get width of the individual edge """
if node1 == highlighted_node or node2 == highlighted_node:
return 3
return 1
在这里,我为 Theresa Anderson 的节点使用了单独的颜色。我还改变了她访问的所有事件的颜色;has_edge方法是一个简单的方法来查找两个节点是否有共同边。我还改变了边的权重。
现在,我们可以绘制图表:
edge_colors = [edge_color(n1, n2) for n1, n2 in G.edges()]
edge_weights = [edge_weight(n1, n2) for n1, n2 in G.edges()]
node_colors = [get_node_color(node) for node in G.nodes()]
nx.draw(G, pos=pos, node_color=node_colors, node_size=node_sizes,
edge_color=edge_colors, width=edge_weights, with_labels=True)
结果看起来像这样:
突出显示节点和边的图表,图片由作者提供
5. 节点标签
当我们有一个具有不同节点类型的图时,我们可以为不同的节点使用不同的字体。然而,令我惊讶的是,在 NetworkX 中,没有简单的方法来指定字体,就像我们为颜色所做的那样。为了绘制“事件”和“人物”节点,我们可以将图分成子图并分别绘制:
node_events = [node for node in G.nodes() if is_event_node(node)]
node_people = [node for node in G.nodes() if not is_event_node(node)]
nx.draw(G, pos=pos, node_color=node_colors, node_size=node_sizes,
with_labels=False)
nx.draw_networkx_labels(G.subgraph(node_events),
pos=pos, font_weight="bold")
nx.draw_networkx_labels(G.subgraph(node_people),
pos=pos, font_weight="normal", font_size=11)
在这里,我首先像以前一样绘制节点,但将with_labels参数设置为 False。然后我使用了两次draw_networkx_labels方法,并设置了不同的字体设置。
输出看起来像这样:
具有不同标签的图,图片由作者提供
6. 节点属性
我们能够使用辅助 Python 方法设置节点颜色和大小。然而,使用节点属性,我们也可以将此信息保存到图中:
colors_dict = {node: get_node_color(node) for node in G.nodes()}
nx.set_node_attributes(G, colors_dict, "color")
我们也可以为一些节点手动指定属性:
custom_colors_dict = {
"Frances Anderson": "orange",
"Theresa Anderson": "orange",
"E3": "darkgreen",
"E5": "darkgreen",
"E6": "darkgreen"
}
nx.set_node_attributes(G, custom_colors_dict, "color")
然后,我们可以将图保存到文件中,所有信息都将被保留:
nx.write_gml(G, "davis_southern_women.gml")
然后,我们可以加载图并从节点属性中提取所有颜色;我们不再需要任何辅助方法:
attributes = nx.get_node_attributes(G, "color")
node_color_attrs = [attributes[node] for node in G.nodes()]
nx.draw(G, pos=pos, node_color=node_color_attrs, node_size=node_sizes, with_labels=False)
nx.draw_networkx_labels(G.subgraph(node_events), pos=pos, font_weight="bold")
nx.draw_networkx_labels(G.subgraph(node_people), pos=pos, font_weight="normal", font_size=11)
结果看起来像这样:
具有节点属性的图,图片由作者提供
这里,我们看到与之前相同的颜色和四个具有自定义颜色的节点;所有信息都保存到了 GML 文件中。
7. 奖励:使用 D3.JS 绘制图
当我们绘制图时,NetworkX 在底层使用 Matplotlib。这对于像这样的小图来说是可以的,但如果图中包含 1000+个节点,Matplotlib 就会变得非常慢。使用D3.JS可以获得更好的结果。D3.JS 是一个开源的 JavaScript 库,可以更有效地进行图可视化。D3 是一个成熟的数据可视化项目(第一个版本于 2011 年发布),它不仅适用于图;在示例画廊中可以找到许多美丽的图像。
我没有找到将 NetworkX 图“原生”导出到 D3 的方法;然而,我们可以用几行代码做到这一点:
def convert_to_d3(graph: nx.Graph) -> dict:
""" Convert nx.Graph to D3 data """
nodes, edges = [], []
for node_name in graph.nodes:
nodes.append({"id": node_name,
"color": get_node_color(node_name),
"radius": 0.01*node_size(node_name)})
for node1, node2 in graph.edges:
edges.append({"source": node1, "target": node2})
return {"nodes": nodes, "links": edges}
# Save in D3 format
d3 = convert_to_d3(G)
with open('d3_graph.json', 'w', encoding='utf-8') as f_out:
json.dump(d3, f_out, ensure_ascii=False, indent=2)
之后,我们可以将 JSON 数据加载到一个 JavaScript 页面中:
<script type="module">
// Specify the dimensions of the chart.
const width = window.innerWidth;
const height = window.innerHeight;
// Specify the color scale.
const color = d3.scaleOrdinal(d3.schemeTableau10);
const data = await d3.json("./d3_graph.json");
const links = data.links.map(d => ({...d}));
const nodes = data.nodes.map(d => ({...d}));
// Create the SVG container.
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
...
// Create a simulation with several forces.
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(width / 2, height / 2))
.on("tick", ticked);
// Append the SVG element.
container.append(svg.node());
</script>
本文不专注于 JavaScript 本身;在 D3.JS 中绘制图的代码示例很容易在网上找到(这个可以作为一个好的开始;完整源代码的链接也位于本页末尾)。
最后,我们可以运行本地服务器并在浏览器中打开一个页面。作为 JavaScript 的一个优点,我们可以使图交互式,甚至可以通过拖放移动节点:
图可视化,图片由作者提供
作为缺点,几乎每次在 D3 渲染中的更改都需要深入到 JavaScript、CSS 和 HTML 样式。我不是前端网页开发者,即使是微小的调整也需要花费太多时间(然而,学习新事物总是令人愉快的:)。例如,我示例中的默认图形大小太小,我没有找到一种简单的方法来设置它的默认“缩放”值。然而,对于复杂的图形,别无选择,因为 Matplotlib 渲染太慢。我使用了 D3 库来可视化现代艺术家的图形,结果很好:
结论
在这篇文章中,我展示了使用 NetworkX 制作图形可视化的不同方法。正如我们所见,这个过程主要是直截了当的,我们可以轻松调整许多参数,如节点大小或颜色。更复杂的图形可以导出为 JSON,并与 JavaScript 一起使用。之后,我们可以使用强大的 D3.JS 库——在网页浏览器中的渲染过程可能是硬件加速的,并且速度更快。
在我之前的文章Python 数据分析:我们了解现代艺术家什么?中,我使用了 NetworkX 库来分析从维基百科收集的现代艺术家的数据。对社交数据分析感兴趣的人也可以阅读其他帖子:
如果你喜欢这个故事,请随意订阅Medium,你将在我新文章发布时收到通知,以及访问成千上万其他作者故事的完全权限。你还可以通过LinkedIn与我建立联系。如果你想获取这篇文章和其他文章的完整源代码,请随意访问我的Patreon 页面。
感谢阅读。

被折叠的 条评论
为什么被折叠?



