ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python 绘制桑基图全面解析

Python 绘制桑基图全面解析 前言桑基图Sankey diagram是一种「流量图」用粗细不等的流带表示不同去向的数量流带的宽度与数值成正比并且总体守恒——流进某个节点的总量等于从它流出的总量。它最适合表达「一份总量被拆成几路、又汇到哪里」这类结构比如企业利润分配、能量损耗、用户流转路径。初学者最常见的误解是以为matplotlib能像新闻里那种多层嵌套的桑基图一样随手画出几十个节点互相连接的大图。实际情况是matplotlib.sankey的模型相当朴素——它一次只画一个层级的「一进多出」多层要靠prior和connect把两个系统接起来。真正画复杂多层桑基图通常会用plotly之类的第三方库或专门的桑基图工具。本文只讲标准可核实的部分matplotlib.sankey模块的类与参数。我们以官方文档和官方示例为准把Sankey的构造参数、add()的每个参数含义、以及「流量不守恒会怎样」讲清楚最后给出两个可直接套用的例子。matplotlib是第三方库需要先pip install matplotlib若你用的是别的桑基图库其 API 请以该库官方文档为准。一、桑基图的数据模型只有三类数在写代码前先把「一张桑基图 什么数据」说清楚。matplotlib.sankey的模型里只有一个核心概念流flow。每个流是一个浮点数符号表示方向——按官方约定流入为正流出为负。一次add()调用描述一个「系统」可以想成一个节点或一个层级。这个系统接收若干正数输入分出若干负数输出。如果输入之和与输出之和不相等即所有流加起来不为 0多出来或缺少的那部分会在系统的「主干trunk」上下边缘显示成一条三次贝塞尔曲线——这就是图上那种「鼓出来」的怪异形状的来源。官方文档对此的原话是这个差值会表现为主干上下边缘的贝塞尔曲线官方示例里也指出流总和不为零时「主干的宽度不均匀」。也就是说桑基图的守恒是画图的前提不是画图的自动结果。你喂进去的数据不守恒图就会画得别扭而这个提示默认只在 DEBUG 级别日志里出现很容易被忽略。概念在代码里对应说明一个系统 / 节点一次sankey.add(...)同级流都在这一次调用里描述一条流flows列表里的一个浮点数正数输入负数输出流的方向orientations列表里的 -1 / 0 / 10 为水平1 为朝上-1 为朝下流上标什么字labels列表每一项可为字符串或None节点中心写什么patchlabel放在图中央的文字数字怎么格式化format/unit构造Sankey时传入二、Sankey 类的构造与 add() 的参数matplotlib.sankey.Sankey的构造函数签名以官方文档为准是class matplotlib.sankey.Sankey(axNone, scale1.0, unit, format%G,gap0.25, radius0.1, shoulder0.03, offset0.15,head_angle100, margin0.4, tolerance1e-06,**kwargs)构造时传入的**kwargs会被原样转发给add()。所以官方示例里可以这样一行画完# 适用于 Python 3.8需先安装pip install matplotlibimport matplotlib.pyplot as pltfrom matplotlib.sankey import Sankey# 构造函数的额外关键字参数会被转发给 add()# 因此这里等价于「构造 add(...) finish()」Sankey(flows[0.25, 0.15, 0.60, -0.20, -0.15, -0.05, -0.50, -0.10],labels[, , , First, Second, Third, Fourth, Fifth],orientations[-1, 1, 0, 1, 1, 1, 0, -1],).finish()plt.title(默认参数画出的桑基图)plt.show()add()的完整签名是add(patchlabel, flowsNone, orientationsNone, labels,trunklength1.0, pathlengths0.25, priorNone, connect(0, 0),rotation0, **kwargs)逐个参数说明全部来自官方文档flows浮点数列表。正数为输入、负数为输出。orientations列表取值只能是-1、0、1。0表示「从左侧进、向右侧出」1表示「从上方进、向上方出」-1表示「从下方进、向下方出」。也可以只给一个值表示所有流都用同一个方向。labels每个流的标签列表。每一项可以是字符串或None。如果给的是哪怕是空字符串该流对应的数值会显示在标签下方但如果主图的unit是None则数值永远不显示不管labels怎么写。trunklength输入组与输出组基部之间的距离数据空间单位。pathlengths每条竖直箭头在「转折前 / 转折后」的长度列表。只给一个值时它作用于上下两侧最靠内的第一条路径其余箭头的长度按此对齐推算水平方向的输入输出不受pathlengths影响。prior要连接到的「之前那个图」的索引。connect一个(prior, this)二元组分别指 Previous 图的第几条流、当前图的第几条流把它们接起来。如果是第一个图或prior is Noneconnect被忽略。rotation整个图的旋转角度度。orientations 的语义会跟着旋转解释例如rotation90时orientations里的1表示朝左。当该图与已有图连接用了prior/connect时rotation被忽略。patchlabel放在图中央的标签。注意label没有 patch是另一回事——它是传给PathPatch的图例条目名。**kwargs会被设置成matplotlib.patches.PathPatch的属性例如fillFalse、labelA legend entry。add()的返回值就是当前Sankey实例所以可以连续.add().add()最后调用finish()返回一个Diagram对象列表diagrams[0].texts、diagrams[0].patch等可以拿到图元再微调。三、第一个完整例子把默认值改得像样默认参数画出的图往往太挤加上scale、offset、head_angle之后会舒服很多。下面这个例子对应官方示例的第二张图# 适用于 Python 3.8需先安装pip install matplotlibimport matplotlib.pyplot as pltfrom matplotlib.sankey import Sankeyfig plt.figure()ax fig.add_subplot(1, 1, 1, xticks[], yticks[],titleFlow Diagram of a Widget)sankey Sankey(axax,scale0.01, # 数据已经被 scale 归一化流的总量要乘这个比例offset0.2, # 路径末端与标签之间的偏移head_angle180, # 箭头张角format%.0f, # 数值格式unit%, # 单位设为 None 则一律不显示数值)sankey.add(flows[25, 0, 60, -10, -20, -5, -15, -10, -40],labels[, , , First, Second, Third, Fourth,Fifth, Hurray!],orientations[-1, 1, 0, 1, 1, 1, -1, -1, 0],pathlengths[0.25, 0.25, 0.25, 0.25, 0.25, 0.6, 0.25, 0.25, 0.25],patchlabelWidget \n A,)diagrams sankey.finish()diagrams[0].texts[-1].set_color(r) # 把最后一个标签染红diagrams[0].text.set_fontweight(bold) # 图中央的文字加粗plt.show()这个例子有两个值得注意的地方。第一flows里有一项是0值为 0 的流不会显示出来这一点官方文档在示例说明里明确指出并且它同样只在 DEBUG 日志里提示。第二这个例子中流的总和并不为零25060-10-20-5-15-10-40 -15所以你能看到主干宽度不均匀——这不是 bug而是数据不守恒的直接体现。四、第二个例子把两个系统连起来桑基图真正的表达力在「连接」。add()的prior和connect让后一个系统挂到前一个系统的某条流上# 适用于 Python 3.8需先安装pip install matplotlibimport matplotlib.pyplot as pltfrom matplotlib.sankey import Sankeyfig plt.figure()ax fig.add_subplot(1, 1, 1, xticks[], yticks[], titleTwo Systems)flows [0.25, 0.15, 0.60, -0.10, -0.05, -0.25, -0.15, -0.10, -0.35]sankey Sankey(axax, unitNone) # unitNone一律不显示数值sankey.add(flowsflows,labelone,orientations[-1, 1, 0, 1, 1, 1, -1, -1, 0],)sankey.add(flows[-0.25, 0.15, 0.1],labeltwo,orientations[-1, -1, -1],prior0, # 连接到第 0 个图connect(0, 0), # 第 0 个图的第 0 条流接当前图的第 0 条流)diagrams sankey.finish()diagrams[-1].patch.set_hatch(/) # 给最后一个系统加斜线纹理plt.legend()plt.show()官方示例对这段的说明很有意思这里只显式指定了一个连接但两个系统看起来构成了回路。原因是「路径长度会自行对齐」并且「流的朝向和顺序是镜像的」。所以connect不是逐条连而是利用镜像关系让两个系统自然拼合。常见坑点流不守恒却以为图坏了。❌flows[10, -3, -4]光看数字就往里塞画出来主干上下鼓出一块还去查是不是版本问题。 ✅ 让输入之和等于输出之和整体加起来约为 0不守恒时图上会出现贝塞尔曲线补偿这是数据问题。orientations里填了角度值。❌ 写orientations[0, 90, 180]以为和旋转一样用度数。 ✅orientations只接受-1、0、1三个值意义是「朝下 / 水平 / 朝上」。以为label和labels是一回事。❌ 想标注每条流却给add()传了labelfoo结果只多了一个图例项。 ✅ 每条流的标签用labels复数列表图中央的标题用patchlabellabel是传给PathPatch的图例条目名。unitNone时死等数值不出现。❌labels写了字符串却发现数值怎么都不显示。 ✅ 官方说明主图unit为None时数值永远不显示想显示就把unit设成非None如%。连接时prior/connect索引搞错。❌connect(1, 2)却以为连的是「第 1 个系统」。 ✅connect是(prior, this)指的是流的索引prior才是「连到第几个图」。连接后还想用rotation旋转。❌ 用了prior/connect之后又传rotation45发现没反应。 ✅ 官方明确与已有图连接时rotation被忽略。指望一次add()画出多层桑基图。❌ 拿一张十几个节点、层层展开的复杂桑基图试图用一次add()复刻。 ✅matplotlib.sankey一次只画同一层级的流多层要用多次add()配合prior/connect或者换用plotly之类专门库其 API 以其官方文档为准。把scale当成「自动归一化」。❌ 数据没归一化就画塞进scale0.01指望它把图缩放到合适大小。 ✅scale只是把流量值乘上一个比例换算成图形尺寸数据本身要先归一化官方示例里正是「因为数据已归一化所以scale可省」。总结需求参数 / 做法关键点描述一个节点一次sankey.add(...)只画同一层级的流输入 / 输出flows正数 / 负数总和应约为 0流的方向orientations取 -1/0/1不是角度流上的文字labelsunitNone时数值不显示节点中央文字patchlabel与label不同连接两个系统priorconnect(prior, this)连接后rotation失效拿到图元再改finish()返回的Diagram列表diagrams[0].texts/.patch记住三条结论桑基图的本质是「一堆加起来为零的数」matplotlib.sankey的定位是「单层流量图 有限的系统连接」不是万能的多层画布所有具体参数名以 matplotlib 官方文档为准第三方库版本间还会有调整。把数据先算守恒、把方向先想清楚剩下的就只是调参数。
返回列表