什么时候用它
桑基图只回答一类问题:一个量在流转,我想知道它从哪来、到哪去、路上漏了多少。
判断标准不是"数据有没有层级"——那是树图和 treemap 的活。判断标准是同一批东西在不同阶段之间移动 。钱从收入走到各项开支、病人从筛选走到最终分析、能量从输入端走到各处损耗,这三件事是同一种结构。
具体到三条:
数值可加。 两条流并作一条,就等于把两个数加起来,这件事得说得通。相关系数、pH 值、五星评分加起来没有意义,也就画不了桑基图。
至少两个阶段。 只有一层拆分(总量 → 若干类)用桑基是杀鸡用牛刀,堆叠条形图更清楚也更省版面。
关心的是分配,不是排名。 想知道"谁最大"就画条形图;想知道"这一块是怎么散出去的"才画桑基。
不该用的场合 同样明确。想精确比较两个数量:带子的宽度没有共同基线,两条带子分处画面上下两端时,差个百分之几根本看不出来,条形图在这件事上永远更好。数据里有环:A → B → A 这种结构常规桑基图排不出层级,SankeyMATIC 也不支持——真需要表达循环,得找支持 circular Sankey 的实现,或者干脆换网络图。
怎么读
先看清起点、终点和总量。 从哪儿进来、能到哪几个地方去、一共多少。这一步定住了后面所有比例的分母。
再找最粗的那条带。 它是主路径,通常解释了大部分总量,其余都是它的旁支。
在分叉点用宽度看大致比例,具体数看标签。 这两件事不矛盾:宽度告诉你"大概一半对一半"还是"九比一",这是桑基图真正擅长的;而两条带子宽度接近时,眼睛分不出 48% 和 52%,那就必须去看数字。
找中途停下的节点。 不再往右延伸的那些,就是"漏掉的"——流失、损耗、排除。它们往往是这张图最该被看见的部分:在表格里,它们不过是几行不起眼的小数字。
别拿垂直位置当描述。 节点的上下顺序是布局算法排的,换个工具、甚至同一个工具改个参数,位置就全变了。今天写下的"最上面那条",三个月后指的已经不是同一条。
常见陷阱
分两类:一类让数据错了但你看不出来 ,一类让图画对了却读不出来 。前一类更凶险,因为它们全都没有症状。
数据出错,而图看不出来
流入流出不平衡,工具不会报错。 这是桑基图最危险的一个坑,因为它没有任何症状。假设你写下 符合性评估 [1200] 随机分组,可下游的 随机分组 一共只流出 450 + 450 = 900,中间凭空少了 300。SankeyMATIC 既不报错也不警告,它把 随机分组 按 900 画出来,图挑不出一点毛病。图是自洽的,数据是错的。
第二张图的原始数据里就藏着这个错,我是逐行对账时才发现的,不是看图看出来的——看图永远看不出来 ,这正是它可怕的地方。meta listimbalances Y 会在输入框下面把不平衡的节点列出来,但那要等图渲染完才出现,所以粘贴之后第一件事是去看那一行,别急着先看图好不好看。
节点名就是主键,同名即同一个节点。 「完成随访」在干预组和对照组各有一个。如果两条都写成 完成随访,工具会把它们合并成一个节点 ——图看上去照样连得通、总数照样对得上,只是两个组在这里悄悄混成了一锅。所以必须写成 完成随访(干预) 和 完成随访(对照)。这是这类图最容易犯、也最难自查的错,因为错误的版本看起来完全正常。
[*] 会悄悄吞掉误差。 Budget [*] Savings 的意思是"剩下的都归这里"。写起来方便,但如果上游某个数敲错了,差额会一声不响地进到这个节点——本该暴露成不平衡的错误,被它吸收成了一个看上去很正常的数字。定稿前把 [*] 换成算好的实数 ,让上面那个不平衡检查真的有东西可查。
图没问题,但读不出来
颜色不表示任何东西的时候,它就只是噪音。 默认的分类配色给每个节点分一个不同的颜色,看着热闹,可你从颜色里读不出任何信息。要么让它表示点什么——属于哪个组、去了哪一类,要么干脆全图统一成一个颜色。见下一节。
桑基不是流程图。 CONSORT 的官方模板是方框加箭头,因为它要交代的是步骤 ;桑基交代的是数量 。把 CONSORT 画成桑基是一笔有得有失的交易:得到的是"300 人被排除到底占多大比例"这种一眼就有的直观,失去的是方框里那些逐条写清楚的说明文字——桑基的标签只放得下一个名字和一个数。所以它替代不了正式的 CONSORT 流程图 :写试验报告时该以 CONSORT 图为主,桑基更适合当补充,专门用来强调各阶段的数量比例和流失规模。
占比不到 1% 的流是看不见的。 这一页第一张图里 其他支出净额 $171 只有营业利润的 0.6%,画出来就是一根发丝,标签飘在旁边一片空白里,像是没连着任何东西。这不是能调参数解决的 ——把它人为画粗就破坏了比例,而比例正是桑基图唯一的本事。所以判断标准很直接:如果那条小流对你要讲的事情重要,桑基图就不是对的图 ,得另配一张表把数字列清楚。反过来,如果它只是"为了让账平必须在那儿",那它现在这个样子就正合适——小到看不见,本身就是它该有的分量。
桑基图里没有阶段标题。 读者只能从水平位置推断"这一列是筛选、那一列是随访"。加一排"筛选/随机/分配/随访/分析"的栏目标题确实会好读很多,但 SankeyMATIC 没有这个功能,也没有标题和副标题——要加只能导出 SVG 之后在矢量编辑器里手工画上去,而那一步不可复现 :源码就不再完整决定输出了,这一页的整个保证也就没了。要阶段标题就得换工具(ggalluvial 可以用 scale_x_discrete() 加),或者接受它、把阶段写在图注里。
配色
用 walter_white2 这套。五个色低饱和、彼此分得开,这正是桑基图需要的——色块面积极大 ,鲜艳色铺满半张图会盖过数据本身。
关键原则是:颜色该说明的是"这条路径是谁",不是"这是第几个节点"。 两张图各有一套说法:
收支图 ——冷色是还留在公司里的钱,暖色是花出去的,绿色是最后剩下的。于是从左看到右,颜色本身就在讲一件事:一条 #5AB5BF 的青色主干(收入、毛利、营业利润、税前利润)在每个环节被 #BF8888(成本、税)和 #F29E6D(研发、销管)削掉一块,走到头剩下 #808C56 的净利润。
队列图 ——#D1BE9E 是尚未分组的主干,#5AB5BF 干预臂、#808C56 对照臂,#BF8888 是任何形式的流失 (未接受、失访、中止、排除分析)。两条臂的颜色一路保持到各自的分析终点,于是"哪些人掉了"是一个统一的视觉信号,而"这些人属于哪个组"从头到尾没断过。
带子的颜色继承哪一头
带子的颜色不单独写,它从两端里挑一头继承。这个选择比看上去重要——挑错了,图会自己跟自己打架 。
SankeyMATIC 的默认值是 flow inheritfrom outside-in:靠左的带子取来源的颜色,靠右的取去向的颜色。这个规则的问题在于,"靠左"和"靠右"是按水平位置 算的。于是收支图上就出现了这一幕:净销售额 → 销售成本 位置偏左,带子取了来源的青色,可它的终点 销售成本 是粉的;而 毛利 → 研发费用 位置偏右,带子取了去向的橙色。同样是花出去的钱,两条流一条青一条橙 ——青的那条看上去甚至还像是留下来的。
两张图都改成了 flow inheritfrom target:每条带子一律取去向的颜色 。因为这两张图问的其实是同一个问题——这笔钱去了哪、这些人最后到了哪。一条规则管全图:主干的下一站还是主干,所以主干的颜色不断;分出去的成本、费用、税、流失,自动变成各自终点的颜色。
配方
一、源码的三段
SankeyMATIC 的输入是一份纯文本,数据和样式全在里面 ,三段依次是:
段
长什么样
管什么
流
源 [数值] 目标
数据。一行一条流,这是你唯一经常改的部分
节点
:节点名 #RRGGBB
给单个节点上色。不写就走 node theme 的自动配色
样式
// === Settings === 之后的几十行
画布、节点宽度间距、连线透明度、字体、数值格式
// 开头的整行都是注释。所以下面两块源码第一行的 // fig: <名字> 只是这一页拿来给图命名用的,SankeyMATIC 会直接忽略它——整块复制走就行,不用先删掉那一行 。
三段里只有第一段是每张图独有的。样式段可以整块在图之间复制 :下面两张图的样式段只差三个数——画布高度、节点间距、字号。想换风格的时候,在网页上把滑块调到满意,点 Save my work,存下来的文件里就是一份完整的样式段。这是把手调出来的样式变成一段可提交文本的唯一办法,也是这套流程能复现的根本原因:滑块的位置留不住,文本留得住。
标签太长可以用 \n 断行,配合样式段里的 labels linespacing 控制行距。
二、出图
把下面某一块整个复制,粘进 sankeymatic.com/build 左边的输入框(替换掉原有内容)。
先看输入框下方有没有不平衡提示 (meta listimbalances Y 打开的就是它)。有提示先回去改数据,不要急着导图。
右上角 Save as a .PNG image,倍率选到导出宽度 2400 px 以上——画布设的是 1200 宽,2 倍就够。存下来的是底片,用的时候再缩。
没有"重新运行"这一步 ,这是它和 R 配方最大的不同。好处很实在:整张图——数据、配色、样式——就是一份纯文本,能提交、能 diff、几个月后一眼看得出改动过哪一行。比起"图在仓库里、样式在某人浏览器的滑块上",这是天壤之别。
但别把它当成长期可复现的保证 。SankeyMATIC 是一个活的网页应用,布局算法、默认值、字体都可能变,而它连版本号都没有——R 配方那边至少还能锁包版本。纯文本的优势是可提交、可比较、可审计 ,不是逐像素重现。
所以正确的做法是三样一起存:源码、导出的图,以及一个把两者绑在一起的哈希 。哈希这一步不能省,因为图是从浏览器里手动导出来的,没有任何东西拦着你改完源码忘了重导——那和手工导图是同一种病:页面上照样是一张好看的图,只是它和上面那段源码已经对不上了,而且不会有任何症状 。这一页的办法是把每个源码块算一个哈希记下来,构建时重算一遍去核对,对不上就直接报错。
注意它保证的是这张图确实由这段源码产出 ,不是"将来还能一模一样地重画出来"。这是两件事,别混。
三、要矢量图的话
投稿要 PDF 的话,走 Download .SVG 再转:
inkscape sankey.svg --export-type=pdf --export-text-to-path \
--export-filename=sankey.pdf
--export-text-to-path 把文字转成路径。不转的话 PDF 里存的只是字体名字,编辑部的排版系统里如果没有装这套中文字体,标签就会掉字或者变成方框——而这件事你在自己电脑上永远看不出来。
图 1 · 收支拆解
Apple 2025 财年第三季度。这是桑基图最经典的形态:总量守恒,先汇聚再逐级分叉 。左边四条产品线加上服务,汇成净销售额,然后在四个环节被一层层削掉,剩下净利润。
注意它不是单纯的"从左往右分叉"——左半是收敛 (产品线 → 产品 → 净销售额),右半才是发散。桑基图能同时表达这两种方向,这是它比堆叠条形图强的地方。
// fig: revenue
// Apple 2025 财年第三季度(截至 2025-06-28),单位:百万美元
// 数据来源:Apple Inc. Form 10-Q
// ── ① 产品线汇聚成净销售额 ────────────────────────────
iPhone [44582] 产品
Mac [8046] 产品
iPad [6581] 产品
可穿戴及配件 [7404] 产品
产品 [66613] 净销售额
服务 [27423] 净销售额
// ── ② 成本与毛利 ──────────────────────────────────
净销售额 [50318] 销售成本
净销售额 [43718] 毛利
// ── ③ 运营费用 ────────────────────────────────────
毛利 [8866] 研发费用
毛利 [6650] 销售及管理费用
毛利 [28202] 营业利润
// ── ④ 税前 → 净利 ─────────────────────────────────
营业利润 [171] 其他支出净额
营业利润 [28031] 税前利润
税前利润 [4597] 所得税
税前利润 [23434] 净利润
// ── 节点配色 · walter_white2 ──────────────────────
// 冷色 = 钱还在体内,暖色 = 流出去了,绿色 = 最终留下的
:iPhone #5AB5BF
:Mac #5AB5BF
:iPad #5AB5BF
:可穿戴及配件 #5AB5BF
:服务 #5AB5BF
:产品 #5AB5BF
:净销售额 #5AB5BF
:毛利 #5AB5BF
:营业利润 #5AB5BF
:税前利润 #5AB5BF
:净利润 #808C56
:销售成本 #BF8888
:所得税 #BF8888
:其他支出净额 #BF8888
:研发费用 #F29E6D
:销售及管理费用 #F29E6D
// === Settings ===
size w 1200
h 700
margin l 14
r 80
t 18
b 20
bg color #FFFFFF
transparent N
node w 14
h 50
spacing 70
border 0
theme a
color #888888
opacity 1
flow curvature 0.5
inheritfrom target
color #999999
opacity 0.55
layout order automatic
justifyorigins Y
justifyends N
reversegraph N
attachincompletesto nearest
labels color #000000
hide N
highlight 0.5
fontface sans-serif
linespacing 0.2
relativesize 110
magnify 100
labelname appears Y
size 18
weight 400
labelvalue appears Y
fullprecision Y
position below
weight 400
labelposition autoalign 0
scheme auto
first before
breakpoint 4
value format ',.'
prefix '$'
suffix ''
themeoffset a 6
b 0
c 0
d 0
meta mentionsankeymatic Y
listimbalances Y sankey-revenue
两处样式值得说一句:
layout justifyorigins Y 把所有起点节点拉到同一列。不加的话 服务 会掉到第二列去——它直接流向净销售额,中间比产品线少一层,算出来的深度就不一样——于是它和 iPhone、Mac 这些同样是收入来源的节点错开一列,标签也跟着挤到 产品 旁边。可服务本来就和 iPhone 是并列关系 ,对齐之后左边那一列读起来才是完整的"五个收入来源"。
value prefix '$' 给数字加上货币符号。没有加 suffix 'M' :十六个标签每个都拖一个 M 太重,右侧支出节点本来就密。单位是百万美元,说一次就够,不必每个标签重复。
图 2 · 队列流失
模拟随机对照试验,1200 人进入筛选。这是另一种形态:总量递减 。没有汇聚,只有一路分叉,而每次分叉都掉下去一部分人——那些断头的节点就是这张图真正要说的事。
节点名里的「(干预)」「(对照)」不是为了好看:两个臂在每个阶段都有同名的状态,不加区分的话它们会在图里合并成一个节点。
两条臂到分析这一步也没有合并。 让两个 完成随访 都流进同一个「纳入分析 738」,图上会收出一个漂亮的尾巴,但随机分组的身份就在这里断了——读者看不出每组各分析了多少人,而分臂报告正是 CONSORT 要求的。视觉上也有代价:那个汇合节点会成为全图最大的一块色,把两条主线压下去,终点不该比路径更抢眼 。分开画之后,青和橄榄各自走到底,738 和 7 这两个总数交给正文说,一个数都没少。真要把两组并起来做 pooled analysis 才该合流,而且得在标签上写明是合并分析。
这张图画的是完整病例分析(complete-case) :只有结局资料完整的人进入分析集。真实试验里更常用的是 ITT——随机之后的所有人都要分析,未接受方案的、中止的、失访的都算在内。
ITT 一样能画成桑基图 ,别被这张图误导。它不需要任何回头的流,需要的是换一套节点逻辑 :分析集沿着随机分组走,未接受方案 和 中止方案 不再是死路,而是继续汇进本臂的 纳入分析;至于失访者的缺失结局怎么处理——插补、还是按最差情况算——那是图外的事,得单独交代。真正画不成的从来不是 ITT,而是"只有完成随访者才进分析"这套节点逻辑跟 ITT 摆在一起。
所以这里真正要说的是:一张流程图会隐含一个统计假设 。这件事必须在图注里写明,否则读者很容易以为"中止干预就必然被排除在分析之外"——而那是这张图的画法带出来的错觉,不是试验设计的规定。
// fig: consort
// CONSORT 随机对照试验流程(模拟数据,1200 人筛选)
// 分析集:完整病例分析(complete-case)—— 只有完成随访者进入分析。
// 真实试验多用 ITT,随机后的所有人都要分析,那种结构画不成单向的桑基图。
// ── ① 筛选与随机 ──────────────────────────────────
符合性评估 [900] 随机分组
符合性评估 [300] 排除
排除 [180] 不符合纳入标准
排除 [90] 拒绝参与
排除 [30] 其他原因
// ── ② 分组与实际接受 ──────────────────────────────
随机分组 [450] 干预组
随机分组 [450] 对照组
干预组 [430] 接受干预方案
干预组 [20] 未接受干预方案
对照组 [425] 接受对照方案
对照组 [25] 未接受对照方案
// ── ③ 随访期流失 ──────────────────────────────────
接受干预方案 [375] 完成随访(干预)
接受干预方案 [35] 失访(干预)
接受干预方案 [20] 中止方案(干预)
接受对照方案 [370] 完成随访(对照)
接受对照方案 [40] 失访(对照)
接受对照方案 [15] 中止方案(对照)
// ── ④ 分析纳入 · 两臂分别报告,不合并 ──────────────
完成随访(干预) [372] 纳入分析(干预)
完成随访(干预) [3] 排除分析(干预)
完成随访(对照) [366] 纳入分析(对照)
完成随访(对照) [4] 排除分析(对照)
// ── 节点配色 · walter_white2 ──────────────────────
// 米色 = 尚未分组的主干,青 = 干预臂,绿 = 对照臂,
// 粉 = 任何形式的流失。臂色一路保持到分析终点。
:符合性评估 #D1BE9E
:随机分组 #D1BE9E
:干预组 #5AB5BF
:接受干预方案 #5AB5BF
:完成随访(干预) #5AB5BF
:纳入分析(干预) #5AB5BF
:对照组 #808C56
:接受对照方案 #808C56
:完成随访(对照) #808C56
:纳入分析(对照) #808C56
:排除 #BF8888
:不符合纳入标准 #BF8888
:拒绝参与 #BF8888
:其他原因 #BF8888
:未接受干预方案 #BF8888
:未接受对照方案 #BF8888
:失访(干预) #BF8888
:失访(对照) #BF8888
:中止方案(干预) #BF8888
:中止方案(对照) #BF8888
:排除分析(干预) #BF8888
:排除分析(对照) #BF8888
// === Settings ===
size w 1200
h 900
margin l 14
r 80
t 18
b 20
bg color #FFFFFF
transparent N
node w 14
h 50
spacing 55
border 0
theme a
color #888888
opacity 1
flow curvature 0.5
inheritfrom target
color #999999
opacity 0.5
layout order automatic
justifyorigins N
justifyends N
reversegraph N
attachincompletesto nearest
labels color #000000
hide N
highlight 0.5
fontface sans-serif
linespacing 0.2
relativesize 110
magnify 100
labelname appears Y
size 16
weight 400
labelvalue appears Y
fullprecision Y
position below
weight 400
labelposition autoalign 0
scheme auto
first before
breakpoint 4
value format ',.'
prefix ''
suffix ''
themeoffset a 6
b 0
c 0
d 0
meta mentionsankeymatic Y
listimbalances Y sankey-consort
layout attachincompletesto nearest 是这张图的关键一行。失访、中止这些节点在流程中途就结束了,默认设置会把它们全推到画布最右边,于是"第二阶段掉的人"和"最后一阶段掉的人"排在同一列,读者完全看不出它们发生在什么时候。nearest 让它们停在离来源最近的地方,时间顺序才对得上。
三种画法怎么选
要一张排版讲究的静态图 → SankeyMATIC。 它对标签位置、节点间距、颜色的控制力比另外两个都强,而且源码是纯文本,可提交、可 diff。两个代价:出图那一步在浏览器里手动完成,没法进批处理;画布上只有图,没有标题、副标题和阶段栏 ,那些东西得靠图注补。另外它是个没有版本号的网页应用,别指望三年后还能逐像素重现。
图要进 R 流程,或者要标题和阶段栏 → ggalluvial。 数据本来就在 R 里、要循环出十张图、要和别的 ggplot 拼版,或者需要上面那些 SankeyMATIC 给不了的图外元素时,用它——它就是个 ggplot 图层,labs() 和 scale_x_discrete() 照常用。默认样式比 SankeyMATIC 素,标签重叠要自己调。
要交互 → networkD3。 悬停看数值、拖动节点重排,这两件事静态图给不了(想要"高亮整条路径"那种效果得自己补 JavaScript,不是 sankeyNetwork() 现成的)。但它输出的是网页,进不了论文。
流多到几十条的时候,很自然会想把边表放进 Excel、再写个脚本生成源码文本。这条路可行,但配色千万别按行号对齐 :一列颜色对着一列流,中间插进一条新流,整套颜色就往下错一格;而且当颜色的条数和流的条数恰好相等时,脚本连警告都不会给。要做就按节点名 关联——这也是上面两张图的节点色都写成 :名字 #色值 而不是一列裸色值的原因。