☰
NetworkX图可视化实战:节点颜色、大小与边粗细全攻略
2026/10/14 12:23:46 网站建设 项目流程

这个系列写到第三篇,终于要把无向图、有向图、权重映射、点线分类以及节点大小和边粗细这些视觉参数一次性讲透了。前两篇我们把 NetworkX 的基础 API 和常见布局算法过了一遍,今天这篇干脆用案例说话:一份关系数据到手,怎么一步步从建图、布局,到把颜色、形状、大小、线宽全部组织成一张能看懂、能汇报、能指导决策的网络图。无论你是做社交网络分析、系统调用链梳理、知识图谱展示,还是想把杂乱的关系数据变成汇报材料,这篇的代码和思路基本都能直接抄。

1. 先弄明白需求:三种图类型与视觉通道的对应关系

1.1 无向图、有向图、加权图的业务语义

很多人拿数据后的第一反应是直接调nx.draw,画出来发现要么是一团乱麻,要么是张连自己都解释不清楚的图。原因多半不在代码,而在动手前没想清楚三个问题:边有没有方向?边的权重要不要体现?节点和边需不需要分类?

这三个问题的答案,直接决定你用Graph还是DiGraph、要不要读weight字段、以及最终那一堆视觉参数怎么定。我习惯把它们对应到最常见的业务场景里:

图类型典型场景视觉表达重点
无向图好友关系、合著论文、协同合作连通性、社区结构、关键枢纽
有向图关注关系、接口调用、资金流向方向、环路、上下游层级
加权图通话时长、运输距离、交易金额强度差异、瓶颈边、核心链路

这里的核心思路是"数据语义决定图结构,图结构决定视觉参数"。比如好友关系是无向的,你非要用箭头,读者第一反应就是"谁关注了谁",语义就歪了;反过来,接口调用链如果你画成无向图,排查故障时根本分不清调用方向,图就失去了价值。

1.2 环境准备与中文字体坑

案例基于 Python 3.10+,依赖很简单,两个库就够:

pip install networkx matplotlib

NetworkX 现在已经是 3.x,API 和 2.x 相比变化不大,但有个老坑必须提前处理:matplotlib 默认字体不支持中文,画出来的节点标签全是方框。解决办法是全局设置字体:

import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'PingFang SC', 'WenQuanYi Zen Hei'] plt.rcParams['axes.unicode_minus'] = False

axes.unicode_minus这一行是处理负号显示成方块的问题,凡是涉及数值坐标轴的图建议都加上。字体列表按操作系统自动匹配:Windows 通常用 SimHei 或微软雅黑,macOS 用 PingFang SC,Linux 装文泉驿正黑。如果你用的是 Jupyter,记得在 Notebook 里也执行同样的配置,否则重启内核后又恢复原样。

2. 无向图案例:协作关系图从建边到成图

2.1 用 Graph 建模"谁和谁合作过"

先看最简单的无向图。假设我们手头有某个模拟项目的团队协作关系:谁和谁共同完成过任务,关系是对称的,A 认识 B 也就意味着 B 认识 A。这种数据用nx.Graph()再合适不过。

import networkx as nx import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei'] plt.rcParams['axes.unicode_minus'] = False G = nx.Graph() edges = [ ('N1', 'N2'), ('N1', 'N3'), ('N2', 'N3'), ('N2', 'N4'), ('N3', 'N5'), ('N4', 'N5'), ('N5', 'N6') ] G.add_edges_from(edges) pos = nx.spring_layout(G, seed=42, k=1.2, iterations=50) nx.draw_networkx_nodes(G, pos, node_color='#4e9be6', node_size=800) nx.draw_networkx_edges(G, pos, edge_color='#999999', width=1.5) nx.draw_networkx_labels(G, pos, font_size=10, font_color='white') plt.axis('off') plt.show()

这里我特意拆开了draw_networkx_nodes、draw_networkx_edges、draw_networkx_labels三个函数,而不是直接用一把梭的nx.draw。原因很简单:后续要分别控制节点颜色、边宽、标签位置和字体,拆开才能单独传参。nx.draw适合快速预览,正式出图我基本都会拆开写。

2.2 布局选型:为什么默认先试 spring_layout

布局决定了图的"颜值"和可读性。上面代码里用了spring_layout,也就是力导向布局:节点之间互相排斥,边像弹簧一样把两端拉在一起,迭代若干次后达到平衡。它的优势是不需要任何先验信息,纯粹从图结构出发,就能把相邻节点聚在一起、把不相关的节点推开,非常符合人对"关系远近"的直觉。

其他布局各有用武之地:

  • circular_layout:所有节点均匀排成一个圆,适合展示顺序或环形结构,比如事件循环。
  • kamada_kawai_layout:基于最短路径距离的力导向算法,对加权图效果往往比 spring 更好。
  • shell_layout:多层同心圆,适合按层级或分组建模的数据。

值得强调的是seed=42这个参数。力导向布局带随机初始化,不固定 seed 的话,每次运行节点位置都变。哪怕只是一个给同事看的临时图,也建议固定 seed,否则你截图汇报完,第二天想微调颜色重新跑一遍,位置全变了,图例全都对不上。

3. 有向图案例:调用链关系与箭头细节

3.1 DiGraph 与 Graph 的核心差别

有向图的建模用nx.DiGraph()。不建模的同学可能觉得无非是多个箭头,但底层差别很大:DiGraph的边有起点和终点之分,G[u][v]和G[v][u]是两个不同的边对象,邻居、度数、最短路径的计算逻辑全都不一样。比如degree会拆成in_degree(入度)和out_degree(出度),这在无向图里是没有的概念。

举一个系统调用链的例子:

DG = nx.DiGraph() call_edges = [ ('API网关', '用户服务'), ('用户服务', '订单服务'), ('用户服务', '库存服务'), ('订单服务', '库存服务'), ('订单服务', '支付服务'), ('支付服务', '用户服务') ] DG.add_edges_from(call_edges) pos = nx.circular_layout(DG) nx.draw_networkx_nodes(DG, pos, node_color='#f39c12', node_size=1200) nx.draw_networkx_edges( DG, pos, edge_color='#7f8c8d', arrows=True, arrowstyle='->', arrowsize=20, connectionstyle='arc3, rad=0.15', width=2 ) nx.draw_networkx_labels(DG, pos, font_size=9, font_color='#2c3e50') plt.axis('off') plt.show()

注意这里我用了circular_layout,因为调用链的场景里,环形布局能直观暴露循环依赖——比如这个例子里 支付服务 → 用户服务 形成了一条回路,项目经理看到图的第一反应就应该是"这里可能存在循环调用"。

3.2 双向边的重叠问题

有向图最容易翻车的是双向边。如果 A 调用 B,B 也调用 A,两条边在默认画法下会完全重叠,箭头根本看不出来。解决方法是给边加曲率:

nx.draw_networkx_edges( DG, pos, connectionstyle='arc3, rad=0.2', # 正值会让边向一个方向弯曲 arrowstyle='-|>', # 实心三角箭头,更醒目 arrowsize=18 )

rad控制弯曲弧度,0.2 表示在两点连线的垂直方向上偏移 20%。一对双向边只要一条用rad=0.2、另一条用rad=-0.2,就能完美错开。arrowstyle的参数里,'->'是细箭头,'-|>'是三角箭头,后者在密集图中辨识度更高。

4. 权重映射:从数据字段到视觉通道

4.1 权重驱动边粗细的核心写法

加权图的关键在于"权重是数据属性,粗细是视觉属性",两者不会自动关联,必须手动建立映射。NetworkX 不会因为你给边加了weight=8就自动把这条边画粗,它只负责把数据存起来,画图时全靠你自己读取。

WG = nx.Graph() weighted_edges = [ ('A', 'B', 5), ('A', 'C', 3), ('B', 'C', 2), ('C', 'D', 8), ('D', 'A', 4), ('B', 'E', 1) ] WG.add_weighted_edges_from(weighted_edges) pos = nx.spring_layout(WG, seed=7, k=1.1) weights = [WG[u][v]['weight'] for u, v in WG.edges] max_w = max(weights) widths = [1 + (w / max_w) * 5 for w in weights] nx.draw_networkx_edges(WG, pos, width=widths, edge_color='#95a5a6', alpha=0.8) nx.draw_networkx_nodes(WG, pos, node_color='#2c3e50', node_size=700) nx.draw_networkx_labels(WG, pos, font_size=10)

映射公式我习惯写成min_width + (w / max_w) * range,也就是1 + (w / max_w) * 5。含义是:最细的边也有 1 磅保证可见,最粗的边按权重最大值缩放后达到 6 磅。分子用w而不是w - min_w,是为了保证权重为 0 的边不被算成负宽度。

4.2 边标签格式化与阈值控制

边标签用draw_networkx_edge_labels,核心是构造一个(边: 标签)字典:

edge_labels = {(u, v): f'{d["weight"]:.1f}' for u, v, d in WG.edges(data=True)} nx.draw_networkx_edge_labels(WG, pos, edge_labels=edge_labels, font_size=9, font_color='#c0392b')

WG.edges(data=True)返回每条边及其属性字典,所以d['weight']能直接取到权重。f'{d["weight"]:.1f}'保留一位小数,避免 5.0 和 4.999999999 这种浮点误差把标签搞得很乱。

但边一多,全标权重就是灾难。我一个比较实用的习惯是设置阈值,只显示权重达到一定级别的边:

edge_labels = { (u, v): f'{d["weight"]:.1f}' for u, v, d in WG.edges(data=True) if d['weight'] >= 2 }

这样弱边保留粗细变化但不显示数字,强边突出数字,图面干净得多。如果需要更高级的映射,还可以把权重同时映射到颜色深浅上:

edge_colors = [WG[u][v]['weight'] for u, v in WG.edges] nx.draw_networkx_edges(WG, pos, edge_color=edge_colors, edge_cmap=plt.cm.Reds, width=widths)

edge_color传数值序列,再用edge_cmap指定一个颜色映射,权重低的浅红、权重高的深红。粗细和颜色双重编码,信息密度一下就上来了。

5. 点线分类:颜色、形状、线型的语义化设计

5.1 节点按类别着色和选形

实际项目里节点很少有完全同质的,通常分成几类角色。给每个节点塞一个属性,再映射成颜色:

dept_color = { '研发': '#e74c3c', '产品': '#3498db', '运营': '#2ecc71', '数据': '#9b59b6' } WG.nodes['A']['dept'] = '研发' WG.nodes['B']['dept'] = '研发' WG.nodes['C']['dept'] = '产品' WG.nodes['D']['dept'] = '运营' WG.nodes['E']['dept'] = '数据' node_colors = [dept_color[WG.nodes[n]['dept']] for n in WG.nodes]

批量赋值用nx.set_node_attributes更省事:

dept_dict = {'A': '研发', 'B': '研发', 'C': '产品', 'D': '运营', 'E': '数据'} nx.set_node_attributes(WG, dept_dict, name='dept')

颜色编码适合区分类别,但有个隐患:红绿色盲读者会分不清某些组合。所以我在颜色之外还会加一层形状编码。matplotlib 的node_shape支持多种 marker,NetworkX 里每个draw_networkx_nodes调用只能指定一种形状,所以要按形状分组循环绘制:

shape_map = {'研发': 'o', '产品': 's', '运营': '^', '数据': 'D'} for dept in shape_map: subset = [n for n in WG.nodes if WG.nodes[n]['dept'] == dept] nx.draw_networkx_nodes( WG, pos, nodelist=subset, node_color=[dept_color[dept]] * len(subset), node_shape=shape_map[dept], node_size=700 )

常用的 marker 形状和语义可以这样对应:

marker形状建议语义
'o'圆形普通节点/默认角色
's'正方形核心服务/关键节点
'^'正三角外部依赖/入口
'v'倒三角出口/边界节点
'D'菱形数据源/存储

5.2 边按关系类型设置线型与透明度

边的分类用线型表达最直观。solid表示稳定关系,dashed表示弱关联或异步调用,dotted表示潜在或待确认关系。给每条边一个relation属性,再映射成线型列表:

rel_style = {'合作': 'solid', '推荐': 'dashed', '潜在': 'dotted'} edge_styles = [rel_style[WG[u][v]['relation']] for u, v in WG.edges] nx.draw_networkx_edges( WG, pos, width=widths, style=edge_styles, edge_color='#555555', alpha=0.7 )

NetworkX 3.x 里draw_networkx_edges的style参数支持传入字符串列表,所以可以直接把每条边的线型算出来传进去。低版本如果报错,就改成循环分组绘制,原理和形状分组一样。透明度alpha=0.7是处理重叠边的好帮手,多条边叠在一起时颜色会自然地变深,读者能隐约感知到"这里连接密集"。

线型的语义建议:

线型语义倾向
'solid'实体关系、强关联、同步调用
'dashed'弱关联、异步、推荐链路
'dotted'潜在关系、待确认、低频
'dashdot'主备关系、容灾链路

6. 大小与粗细的进阶调参:让图承担更多信息

6.1 节点大小承载度数与层级信息

节点大小是最容易被浪费的视觉通道。很多人不管三七二十一全部node_size=500,图是整齐了,但信息量也丢了。节点大小的经典做法是映射到度数——度数越大的节点在图中的地位越关键:

degrees = dict(WG.degree()) node_sizes = [400 + degrees[n] * 600 for n in WG.nodes]

如果想知道"谁是不可替代的枢纽",可以把度数换成介数中心性:

centrality = nx.betweenness_centrality(WG) node_sizes = [600 + centrality[n] * 4000 for n in WG.nodes]

介数中心性衡量的是"有多少条最短路径经过这个节点",比度数更能反映结构性关键程度。这里我必须提醒一个特别容易踩的坑:node_size的单位不是像素,也不是直径,而是"面积"。matplotlib 里节点大小实际是包围正方形面积的平方点(points²),所以视觉上想让节点半径翻倍,node_size要乘以 4 而不是 2。300 和 600 放在一起,直径只差了约 1.4 倍,视觉差异远没有数值差异大。理解了这点之后,你在调参拉开大小差距时会心里有数得多。

6.2 边粗细的比例缩放与视觉平衡

边粗细的映射同样有讲究。权重数据经常是长尾分布:少数几条边权重特别高,其余全是小值。如果直接线性映射,弱边全部细到看不见,图面就废了。我常用的处理是开方或对数压缩:

import math weights = [WG[u][v]['weight'] for u, v in WG.edges] max_w = max(weights) widths_sqrt = [1 + math.sqrt(w / max_w) * 5 for w in weights] widths_log = [1 + math.log2(w + 1) / math.log2(max_w + 1) * 5 for w in weights]

sqrt压缩让中等权重的边不至于太细,log压缩更激进,适合数据跨好几个数量级的场景(比如权重从 1 到 1000)。没有绝对正确的公式,核心原则是让图的视觉分布符合你要讲的故事。如果故事是"这条主链路远超其他",那线性映射更好;如果故事是"整体结构均匀但有强弱梯度",压缩映射更好。

还有个视觉平衡问题:边宽增大的同时,节点大小也必须跟着增大,否则粗边会把小节点整个盖住。我一般保证node_size最小值在 500 以上,而最大边宽不超过 6 磅,这两个数值搭配出来观感比较稳。

7. 综合案例:一张图集成所有技巧

7.1 完整可运行的整合代码

把上面的技巧拼成一个完整案例,模拟一个多部门协作网络:节点有部门属性,核心节点放大;边有合作、推荐、潜在三类关系,线型区分;权重同时映射到边粗细和边标签。全部代码可以直接跑:

import math import networkx as nx import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei'] plt.rcParams['axes.unicode_minus'] = False G = nx.Graph() nodes = [ ('A', {'dept': '研发', 'level': '核心'}), ('B', {'dept': '研发', 'level': '普通'}), ('C', {'dept': '产品', 'level': '核心'}), ('D', {'dept': '运营', 'level': '普通'}), ('E', {'dept': '数据', 'level': '核心'}), ('F', {'dept': '数据', 'level': '普通'}) ] G.add_nodes_from(nodes) edges = [ ('A', 'B', {'weight': 5, 'relation': '合作'}), ('A', 'C', {'weight': 3, 'relation': '合作'}), ('B', 'C', {'weight': 1, 'relation': '潜在'}), ('C', 'D', {'weight': 4, 'relation': '推荐'}), ('D', 'E', {'weight': 2, 'relation': '合作'}), ('E', 'F', {'weight': 6, 'relation': '合作'}), ('A', 'E', {'weight': 4, 'relation': '推荐'}) ] G.add_edges_from(edges) pos = nx.spring_layout(G, seed=7, k=1.1) dept_color = {'研发': '#e74c3c', '产品': '#3498db', '运营': '#2ecc71', '数据': '#9b59b6'} shape_map = {'研发': 'o', '产品': 's', '运营': '^', '数据': 'D'} rel_style = {'合作': 'solid', '推荐': 'dashed', '潜在': 'dotted'} node_colors = [dept_color[G.nodes[n]['dept']] for n in G.nodes] node_sizes = [1200 if G.nodes[n]['level'] == '核心' else 600 for n in G.nodes] for dept in shape_map: subset = [n for n in G.nodes if G.nodes[n]['dept'] == dept] colors = [dept_color[dept] for _ in subset] sizes = [1200 if G.nodes[n]['level'] == '核心' else 600 for n in subset] nx.draw_networkx_nodes( G, pos, nodelist=subset, node_color=colors, node_size=sizes, node_shape=shape_map[dept], alpha=0.9 ) weights = [G[u][v]['weight'] for u, v in G.edges] max_w = max(weights) widths = [1 + math.sqrt(w / max_w) * 5 for w in weights] edge_styles = [rel_style[G[u][v]['relation']] for u, v in G.edges] nx.draw_networkx_edges( G, pos, width=widths, style=edge_styles, edge_color='#555555', alpha=0.7 ) nx.draw_networkx_labels(G, pos, font_size=10) edge_labels = {(u, v): f'{G[u][v]["weight"]}' for u, v in G.edges if G[u][v]['weight'] > 1} nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=8, font_color='#c0392b') plt.axis('off') plt.tight_layout() plt.show()

运行结果里,你能一眼看出研发和数据是两大核心部门(红圆和紫菱偏大),A 和 E 是核心枢纽,C 通过一条虚线连接到 D 表示推荐关系,A 到 E 的每条边粗细则反映了合作强度。这就是"点线分类 + 大小粗细"组合拳达成的最佳效果。

7.2 实际项目中踩过的坑

最后集中说几个我真实遇到的坑,都是文档里不会直接写的。

坑一:只画边不画节点,孤立节点消失。如果数据里有孤立节点(没有任何边的节点),draw_networkx_edges不会画它,而draw_networkx_nodes如果不传nodelist默认会画所有节点。我早期直接复制网上的nx.draw_networkx_edges代码,孤立节点全部消失在图上,关键信息就这么丢了。所以建图后第一件事,G.nodes()扫一遍,确认孤立节点要不要保留。

坑二:布局计算是一次性的。很多人每次画节点、画边、画标签都重新算一次spring_layout,结果节点位置每次都不同,标签和边对不上,图面飘忽不定。正确做法是pos只算一次,后面所有绘制函数都复用同一个pos对象。

坑三:标签会有偏移。draw_networkx_labels的标签默认贴着节点中心,如果用node_size把节点放很大,标签会被节点色块压住。解决办法是手动偏移:

labels_pos = {n: (x, y + 0.05) for n, (x, y) in pos.items()} nx.draw_networkx_labels(G, labels_pos, font_size=10)

坑四:数据量大时别提标签和边标签。节点超过一两百个,再画标签就是一团黑。这时候只画节点和边,用颜色和大小承载信息,交互式探索交给其他工具,静态图里的密集标签没有意义。

我个人做这类图的一点体会是:调参永远是为了回答某个具体问题,不是为了好看而好看。你先把"这张图要让读者看出什么结论"写下来,再去选颜色、定线宽、调大小,每一步视觉参数就都有了依据。这套案例代码我已经在不同项目里复用多次,从几十个节点的协作网络到上千节点的实体关系图,核心思路都是一样的,只是细节取舍不同。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询