
做数据分析这些年我最怕一种需求对方扔过来一张带多级分类的表格说“帮我把这个结构讲清楚”。第一层分类还算好办饼图一圈就画完了可一旦牵扯到第二层、第三层图表就直接变成灾难现场。后来我在项目里把plotly的旭日图Sunburst Chart用起来之后才算是找到了应对复杂数据层次结构的顺手工具——它把多个层级的占比关系同时铺在一个圆里还能点击下钻看每一层的明细配合在线渲染和web嵌入展示效果基本能满足从日常分析到业务汇报的各种场景。这篇文章就围绕“用plotly绘制动态旭日图”这条主线从基础原理、数据准备、参数拆解到交互下钻、在线展示、真实案例和踩坑记录完整走一遍落地过程。内容对新手友好老手也能在细节和避坑部分找到有点价值的东西。1. 为什么复杂层次数据最终选了旭日图1.1 饼图、环形图、Treemap做不到的地方先聊清楚一个问题展示层级结构的方式又不是只有旭日图一种为什么我最后还是选了它饼图的软肋最明显——只能展示一层。硬要往里塞多层就得靠多个饼图拼在一起层层对比全靠肉眼脑补完全谈不上高效。环形图稍微好一点内外环能放两层但实话说两层已经是极限了再往上堆环信息密度和可读性会同时崩溃。Treemap矩形树图是旭日图的主要竞争对手它用嵌套矩形表达层级确实能放很多层。我实际用下来的感受是矩形面积的大小比较其实不如角度和弧长直观尤其是当分类数量一多密密麻麻的小矩形挤在一起很难快速说出“谁是大头”。旭日图本质上是一张多层饼图绕同一个圆心展开每一环代表一层父级的扇区角度在下一环里被继续切分人类对“扇形大小”的感知比对“矩形大小”的感知要自然得多所以读图效率会上来一个台阶。1.2 旭日图的视觉编码逻辑旭日图的信息编码方式其实很简单就三个维度角度弧长编码数值大小数值越大扇区角度越大径向位置从内到外的环编码层次深度最内层是根节点往外依次是一级、二级、三级颜色可以编码另一个业务维度比如销售额高低、利润率区间、线上线下渠道等。我刚才提到“把三个维度塞在同一个图里不打架”这句话的关键在于角度和径向位置是独立通道。角度用于比较同层级之间的相对大小径向位置用于表达层级嵌套关系两者互不干扰。这就是旭日图能在同一张图里讲清楚“哪里大、分几层、占比如何”的原因。我用一个生活化类比来帮助理解旭日图就像把一段圆木横截面切成薄片年轮从内向外代表了生长年份每一圈的弧长代表了那一年各类树木的比例。你一眼看过去既能看出哪些年轮宽也能看出每一年里哪类树种占比最高。1.3 判断该不该用旭日图的三个信号不是说所有层级数据都适合旭日图我在项目里给自己定了三条判断标准满足其中两条以上才会启动这个方案层级至少在3层以上。两层以内用饼图、环形图就够了上旭日图反而显得仪式感过重根节点的汇总值有业务含义。比如“全平台总销售额”“全员总人数”“全站流量”因为旭日图最内圈就是根节点如果这个汇总值没有意义整个圆的信息基础就不成立观者需要下钻看局部明细。旭日图默认支持点击某个扇区放大查看其子层级如果只是看个静态总览PPT截图就够不需要动态交互。另外有一个反向信号如果层级超过五层或者叶子节点数量超过几百个银针一样的细扇区会扎满整个圆这时旭日图也不是最优解建议先聚合低值分类或者换成Treemap。2. 绘制前的数据准备层次结构到底长什么样2.1 两种plotly能识别的数据结构plotly画旭日图主要接收两种数据形式我建议先把它们分清楚。第一种是长格式DataFrame配合plotly express的path参数使用。每一行代表一条从根到叶子的完整路径。比如一条“华东-线上-数码-销售额520万”的记录在DataFrame里就是一行四列region华东channel线上class数码sales520。这种结构在SQL聚合、Excel透视表里非常常见所以我平时90%的场景都直接用这种。第二种是显式的父子节点表配合go.Sunburst使用。每一个节点一行需要给出labels节点名称、parents父节点名称、values节点值。根节点的parents为空字符串。这种结构适合从JSON嵌套接口或者递归算法转换过来的场景。两种结构可以通过一个函数互相转换但日常业务里我强烈建议优先用第一种。因为长格式数据不需要手动维护“每个节点的父节点是谁”plotly会自动根据完整路径生成层级树少写很多容易出错的代码。2.2 一个可以直接复制的示例数据集为了后面讲解方便我准备了一份模拟某电商平台销售数据的DataFrame三个层级分别是区域-渠道-品类指标是销售额。数据是模拟的但结构和真实业务完全一致。import pandas as pd df pd.DataFrame({ region: [华东, 华东, 华东, 华东, 华东, 华东, 华北, 华北, 华北, 华北, 华南, 华南, 华南, 华南], channel: [线上, 线上, 线上, 线下, 线下, 线下, 线上, 线上, 线下, 线下, 线上, 线上, 线下, 线下], class: [数码, 家电, 服饰, 数码, 家电, 服饰, 数码, 家电, 数码, 家电, 数码, 服饰, 家电, 服饰], sales: [520, 380, 420, 260, 310, 280, 450, 350, 240, 290, 410, 370, 260, 220] }) print(df)数据长这样regionchannelclasssales华东线上数码520华东线上家电380华东线上服饰420华东线下数码260华东线下家电310华东线下服饰280华北线上数码450华北线上家电350华北线下数码240华北线下家电290华南线上数码410华南线上服饰370华南线下家电260华南线下服饰220注意这份数据里每条路径都是唯一的region channel class的组合没有重复。这一点非常重要后面我会详细解释为什么。2.3 我处理原始SQL导出时的三个坑数据准备看起来简单实际操作里我踩过的坑一点都不少挑三个最典型的说坑一路径列里有NaN或空字符串。plotly express生成层级树时依赖路径列的完整性。如果某一行region缺失plotly会把这个空值当成根节点的一部分结果就是整张图渲染失败或者中心出现一个奇怪的“空”扇区。我在项目里用fillna(未知)统一填充后再绘图。df df.fillna(未知)坑二重复路径导致节点值被重复计算。如果源表里同一路径出现多行比如“华东-线上-数码”出现了两行plotly默认会保留所有数据而不会自动帮你聚合画出来的扇区面积会偏大更糟糕的是同一路径上可能出现重叠节点。我的习惯是绘图前先做一次分组聚合确保路径唯一。df df.groupby([region, channel, class], as_indexFalse)[sales].sum()坑三数值列有0或负数。数值为0的扇区在旭日图中不显示看起来像少了一块负数会让角度异常产生诡异的交叉重叠。我一般提前清洗将小于等于0的值置为0或过滤掉反正它们展示出来也没有业务意义。3. 首版旭日图px.sunburst参数逐项拆解3.1 一行核心代码跑通基础图数据准备就绪后用plotly express画旭日图的代码非常短import plotly.express as px fig px.sunburst( df, path[region, channel, class], valuessales, title某电商平台区域-渠道-品类销售额分布, ) fig.show()运行之后你会在浏览器或者Jupyter Notebook里看到一个交互式旭日图最内圈是三个区域中间环按比例切分渠道最外环是品类。鼠标悬浮任意扇区会显示完整路径和销售额。这里什么都不用配交互能力就是自带的这也是我推荐plotly的原因——它默认的交互配置已经覆盖了大部分分析场景。path参数是长格式数据的核心它决定层次结构的顺序列表第一个元素是最外层父级后面的元素逐层向下嵌套。这里我把region放在最前面所以区域是第一层如果你想把渠道放到第一层只要调换path列表的顺序即可数据本身不用改这也是长格式数据结构的一个天然优势。3.2branchvalues到底该怎么选total与remainder在所有参数里branchvalues是对图形面积影响最大又最容易被忽略的一个。它只有两个取值total和remainder默认是remainder。我来解释它们的区别。旭日图每个扇区的角度由values决定但对父节点而言它的角度到底怎么算取决于这个参数。branchvaluesremainder父节点的角度由其所有直接子节点的值之和决定。也就是说父节点这一行的values只用于计算文本信息不直接参与面积的额外累加。这种方式适合“上层节点的值就是下层之和”的数据比如销售额汇总。branchvaluestotal每个节点的角度直接使用该行的values。如果父子节点的value相等也会按比例展示。这种方式适合父节点的值不是简单子节点之和的场景比如“父节点是平均单价子节点是各品类平均单价”。我举个例子方便理解。假设根节点“全部”的销售总额是5000但三个子区域加起来是6000数据本身有误差。用remainder时根节点角度由子节点之和6000决定图上看起来是满圆但这6000和根节点记录的5000对不上悬停提示会出现矛盾。用total时根节点角度按5000计算又会和子节点的6000比例冲突图上会留下空隙。遇到这种情况问题不在参数而在数据本身的层级汇总关系不成立绘图前应该先修正数据。如果父子汇总关系正确branchvalues选哪个画出来的图基本一样。为了保险我在处理“汇总值已知”的业务数据时习惯写成fig px.sunburst( df, path[region, channel, class], valuessales, branchvaluestotal, title某电商平台区域-渠道-品类销售额分布, )3.3maxdepth与textinfo控制展示密度默认情况下plotly会把所有层级一次性展开数据一多外圈就会被细碎的小扇区塞满。这时用maxdepth限制初始展示层数让深层数据留在“点击下钻”的动作里。fig px.sunburst( df, path[region, channel, class], valuessales, maxdepth2, )maxdepth2表示只展示两层环加上中心根节点更细的品类层级先收起来需要看某个区域的具体品类时点击对应扇区再展开。这个参数能极大提升图表的初始可读性尤其是在领导“一眼看全局”的汇报场景里。textinfo控制扇区上直接显示的文字信息常用组合是fig.update_traces( textinfolabelpercent root, )label显示名称percent root显示占整体的百分比两者搭配起来扇区上不用悬浮就能看懂大部分信息。如果还要显示数值可以加value但注意外圈小扇区上同时塞名称、数值和百分比文字容易重叠需要根据实际数据规模取舍。我一般遵循一个原则扇区角度小于10度时只显示标签把数值留给悬浮框。4. 让图动起来交互、颜色与在线展示的全套方案4.1 点击下钻和悬停提示的默认玩法旭日图在plotly中最有价值的能力就是点击下钻。用fig.show()打开图表后点击任意扇区图表会以该扇区为中心重新布局展示它的子层级点击圆心位置可以返回上一级。整个操作非常顺滑不需要写任何回调代码。这在汇报时特别好用先给观众看整体结构再根据现场问题点进某个区域一层一层地讲明细。悬停提示默认显示节点名称、数值、占父级百分比和占整体百分比。如果想自定义提示信息可以用hovertemplatefig.update_traces( hovertemplateb%{label}/bbr销售额%{value}br占整体%{percentRoot:.1%}extra/extra )%{label}是节点名%{value}是数值%{percentRoot}是占根节点的比例。最后的extra/extra是plotly的固定写法用来隐藏默认的trace信息框否则悬浮框底部会多一行“sunburst”字样看着比较业余。4.2 用颜色做第二维度编码颜色是旭日图除了角度和层级之外的第三个信息通道。我常用的有两种方案。方案一连续色阶编码数值高低。当你想表达“哪个扇区数值更大”时可以把color参数指向数值列fig px.sunburst( df, path[region, channel, class], valuessales, colorsales, color_continuous_scaleViridis, )这样数值高的扇区颜色亮数值低的颜色暗和扇区面积形成双重编码即使不看数值也能快速定位大头。方案二离散色块编码分类。如果想让不同渠道或不同品类保持固定颜色方便跨图表比较可以这样写fig px.sunburst( df, path[region, channel, class], valuessales, colorchannel, color_discrete_map{线上: #636EFA, 线下: #00CC96}, )从业务视角看离散色更常用。因为连续色阶会把同一父级下的子扇区染成不同深浅读者容易误以为是“同一类别的不同档位”而离散色能明确区分不同渠道、不同品类。具体选哪种取决于你想强调的信息是“数值高低”还是“类别归属”。4.3 在线展示HTML导出、嵌入网页与交互环境标题里写了“在线”两个字这里专门展开讲。plotly的旭日图本质上是基于JavaScript渲染的交互式图表所以“在线”的落地方式很灵活方式一导出独立HTML文件。这是最省事的方法一条命令就能离线生成一个包含全部交互能力的单文件。fig.write_html(sunburst.html)双击打开就是在浏览器里跑的交互图发给同事也能直接看不需要安装Python环境。这个文件自带plotly的js逻辑大小通常几百KB完全可接受。方式二嵌入网页或应用。如果图表要嵌到公司内部系统、个人博客或者数据产品里可以用to_html只输出图表div片段html_div fig.to_html(full_htmlFalse, include_plotlyjscdn)include_plotlyjscdn表示从公共CDN加载plotly.js库不在HTML里重复打包JS大幅减小嵌入代码体积。注意如果内网环境访问不了CDN这个参数要改成True或者include_plotlyjsdirectory让JS一并打包否则图标只显示空白画布。方式三交给Dash或Streamlit托管。在数据产品里旭日图可以作为动态组件跟随筛选条件实时重绘。比如Dash里几行就能接上from dash import dcc dcc.Graph(figurefig)用户在下拉框选择了某个区域回调函数重新生成fig图表就跟着刷新。这算“在线动态绘制”的正统解法适合把图表从“展示工具”升级成“分析工具”。5. 从示例到业务一个完整的区域贡献度分析案例5.1 数据加工与分层指标计算前面的示例数据是“半成品”真实业务里我一般先做这三步加工第一步从源表聚合出region channel class粒度的销售额和毛利率。这里我加一个profit_rate字段目的是后面用旭日图的颜色通道看利润结构。第二步计算每个节点占整体的比例直接挂在DataFrame里方便后续在悬浮框中展示。第三步控制分类数量——如果某个层级下面的分类超过20个我会做Top N截断把其余分类合并成一个“其他”节点避免外圈细碎扇区过多。5.2 完整代码与参数选择import pandas as pd import plotly.express as px # 模拟加工后的数据 df pd.DataFrame({ region: [华东, 华东, 华东, 华东, 华东, 华东, 华北, 华北, 华北, 华北, 华南, 华南, 华南, 华南], channel: [线上, 线上, 线上, 线下, 线下, 线下, 线上, 线上, 线下, 线下, 线上, 线上, 线下, 线下], class: [数码, 家电, 服饰, 数码, 家电, 服饰, 数码, 家电, 数码, 家电, 数码, 服饰, 家电, 服饰], sales: [520, 380, 420, 260, 310, 280, 450, 350, 240, 290, 410, 370, 260, 220], profit_rate: [0.16, 0.11, 0.21, 0.13, 0.09, 0.18, 0.15, 0.12, 0.12, 0.08, 0.14, 0.19, 0.10, 0.17], }) # 计算占整体比例用于悬浮提示 df[sales_ratio] df[sales] / df[sales].sum() # 添加根节点让最内圈显示“全平台” df[platform] 全平台 fig px.sunburst( df, path[platform, region, channel, class], valuessales, colorprofit_rate, color_continuous_scaleRdYlGn, branchvaluestotal, title区域-渠道-品类销售额与利润率分布, ) fig.update_traces( textinfolabel, hovertemplateb%{label}/bbr销售额%{value}br 占整体%{percentRoot:.1%}extra/extra, ) fig.update_layout( fontdict(familyMicrosoft YaHei, SimHei, Arial, size13), margindict(l20, r20, t60, b20), ) fig.show()这里有两个关键选择需要说明一下。一是增加了platform常量列作为根节点。这样最内圈不再是一个无形的“根”而是一个有名字的“全平台”扇区汇报时观众立刻知道整个圆代表什么。二是color用了利润率而不是销售额。因为销售额已经由扇区面积表达了颜色再编码销售额就是信息冗余。用利润率作为第二维度扇区越大说明销售额贡献高颜色越绿说明利润率健康一张图同时回答“哪里卖得多”和“哪里赚得多”两个问题。5.3 图表读出三条业务结论把图渲染出来之后我在实际分析场景中会顺着以下逻辑读图第一层看面积华东三个渠道的总面积明显大于其他区域全平台销售额大头在华东这个结论不用看数值一眼就出来。第二层看颜色分布服饰品类的扇区普遍偏绿利润率整体高于数码和家电这和行业常识一致——服饰毛利空间大数码家电价格透明、利润薄。第三层往下钻点击“华东-线上”扇区可以看到线上渠道里数码和服饰几乎平分秋色再点击“华南-线下”会发现线下没有数码销售只有家电和服饰。这种“实际上没有该组合”的信息在数据表里很容易被忽略但在地图上通过展开后缺扇区一下子就能发现。这三条结论如果只用表格需要反复筛选、汇总、对比才能得出旭日图把整个过程压缩成了几次点击。6. 实战中的翻车记录现象、原因与修复6.1 整图空白或节点错位的排查链路我遇到过最典型的现象是图确实渲染出来了但中心是一小团空白外圈零散挂着几个扇区或者某些扇区莫名其妙多了一块“无主之地”。第一次遇到时我在配置参数上折腾了很久后来才发现问题根本不在plotly。完整排查链路如下先检查原始数据里是否有NaN、None、空字符串。在Jupyter Notebook里直接执行df.isna().sum()一眼定位缺失列确认path里每一列的数据类型是否一致。比如region是字符串但channel列里混入了数值plotly拼接路径时就会产生“意料之外的新节点”确认路径组合是否唯一重复路径会让同一节点被重复计算视觉上就是扇区重叠、角度错乱最后才回头看配置把maxdepth临时调大确认不是展示层数限制导致外圈“看起来少了”。我的经验是八成以上的旭日图怪异表现都出在数据层而不是绘图层。如果图一上来就不对先别去动plotly的几十个参数把数据质量管好问题就解决了一大半。6.2 面积比例看着不对根因还是branchvalues另一个高频问题扇区的实际面积和业务报表里的汇总值对不上。比如报表里“线上渠道”销售额是1700万图里线上渠道的面积却明显超过了总圆的60%。排查时我建议先做一次简单的数据校验把每个父节点下的直接子节点value相加再和父节点自身的value对比。如果两边不相等说明该层级的数据本来就是“父值≠子和”这时就得想清楚业务口径如果父节点是一个独立汇总指标比如“父节点是平均值、子节点是明细值”那么用branchvaluestotal如果父节点只是分组的容器本身没有独立数值所有数值都在叶子层那么用默认的branchvaluesremainder如果父值应该是子和但实际数据不等那要回到上游查数逻辑修数据而不是调图。修复后我通常会再验证一次把根节点的value除以所有叶子节点value的总和如果接近1说明面积计算关系已经回到正轨。6.3 数据量大后卡成PPT三条优化路径旭日图在节点数超过一定量级后交互会明显变卡。这个问题的根源在于plotly默认用SVG渲染每个扇区都是一个DOM元素节点越多浏览器需要维护的DOM节点就越多悬停和下钻时的计算开销也随之增大。我遇到过一个真实场景三万多个叶子节点直接画图页面切换扇区时基本要等两三秒。我总结出三条有效的优化路径第一条预聚合。务必确保传入的是“路径唯一”的数据。如果源表是明细流水先按完整路径求和而不是直接把十万行原始数据丢给plotly。第二条低值合并。对叶子层做Top N截断把排名靠后的低值分类合并成“其他”。比如华南线下渠道有30个品类只保留销量最高的前10个其余品类销售额加总成“其他”。这样叶子节点数能压缩到几十个对业务结论几乎没有影响。top_n 10 top_classes df.groupby(class)[sales].sum().nlargest(top_n).index df[class_merged] df[class].where(df[class].isin(top_classes), 其他)第三条限制初始展开层级。用maxdepth2或maxdepth3延迟展开深层节点这样图表首屏只渲染少数扇区等用户点击下钻时再动态展示局部数据交互流畅度会有明显改善。如果做完这三步还是卡可以考虑减少悬浮框里的自定义字段、关闭阴影和过渡动画但这些属于末梢优化优先从数据层面下手才是正解。6.4 中文字体与静态导出的细节最后补一个容易被忽略的坑中文显示。在浏览器里直接打开fig.show()或者HTML文件时中文一般没问题因为浏览器会自己处理字体。但如果用fig.write_image(sunburst.png)导出静态图片渲染引擎Kaleido在找不到中文字体时会把所有中文显示成方块。解决方法是给layout指明确切的字体族顺序上优先使用通用中文字体fig.update_layout( fontdict(familyMicrosoft YaHei, SimHei, PingFang SC, Noto Sans CJK SC, sans-serif), )如果你是MacPingFang SC会优先命中如果是WindowsMicrosoft YaHei会生效Linux服务器上则要确保装了Noto Sans CJK SC。字体文件缺失时可以在系统层面补装或者把图表字体临时改成英文字体导出后再用PPT补标签我一般不建议用后者维护成本高。还有个更隐蔽的坑fig.write_image()需要单独安装kaleido库。很多环境里plotly装好了导出图片时报错“kaleido not installed”就是这个原因。执行pip install kaleido即可。最后再分享一个我在项目里的个人习惯每画完一张旭日图都会顺手做一次“反常识验证”——故意点进一个平时不太关注的小扇区看看里面能不能钻出预期中的下层结构。这个动作帮我抓住了好几次数据缺失和层级错乱的问题。旭日图这种可视化形式最大的优点不是好看而是它把数据的结构性问题暴露得特别充分图一旦画对了数据十有八九也是对的。如果你的数据层次关系够复杂又需要让观者自己“走进”数据里探索plotly旭日图值得放进你的常规工具箱。