用Matplotlib绘制环形分组柱状图:论文多模型对比的可视化方案
上周帮师弟改论文配图,他给我看了五种模型对比方案:三张雷达图堆在一起、一张配色炸裂的热力图,还有一个横过来竖过去都放不下的普通分组柱状图。审稿人的原话是“图太丑,看不清重点”。我顺手画了一张环形分组柱状图,跑完数据扔给他,导师看完只留下一句:这张图可以留。
这就是这篇文章的由来。环形分组柱状图,在论文里常被称为“极坐标分组条形图”或者“甜甜圈条形图”,近几年在Nature及其子刊、NeurIPS和CVPR的工作里出现频率越来越高。它解决的问题很具体:当你有6—10个模型、每个模型有4—6个性能指标时,怎么用一张图把这些信息压得下、看得清、还拿得出手。这篇文章我会从设计逻辑、数据组织、Matplotlib逐层实现、参数调优到常见翻车场景,把整个复现过程完整拆开,适合正在写论文或者做技术报告的读者直接参考。
1. 为什么多模型对比偏偏选中这张图
1.1 雷达图、热力图与普通柱状图的痛点
先说雷达图。雷达图的原理是把每个模型绘制成一条闭合的多边形折线,模型的每个指标对应一根轴。模型少、指标少的时候,雷达图确实直观,但当指标超过四个、模型超过五个,多边形线之间就开始交叉缠绕,颜色一多根本分不清谁是谁。师弟最开始就是三个子图拼出五张雷达图,视觉效果乱成一团麻线。
热力图是另一种常用方案,行是模型、列是指标、格子颜色表示数值。热力图的问题不在读取,而在“对比”:你很难一眼看出某个模型A比模型B好多少,因为人眼对颜色梯度的感知远不如对长度差异敏感。论文里如果想让审稿人秒懂某两三个模型之间的性能差距,热力图会让人反复盯着图例换算颜色深浅。
普通分组柱状图的问题更直白:8个模型、5个指标,就要画40根柱子。横过来放,柱子又矮又胖,模型名挤在横轴上;竖过来放,图太宽占版面;如果指标之间量纲差异还很大(比如AP从35到45、FPS从20到250),小数值的柱子会被压成一条地平线。我见过太多人把这样的图放进论文里,最后注释写得比图还长。
1.2 环形分组柱状图到底在表达什么
环形分组柱状图的本质,是把“模型”放在极坐标的角度维度,把“性能数值”放在极坐标的半径维度。每个模型占据一个扇区,扇区内再按指标方向并排画出若干根柱条,柱条沿半径向外延伸,长度对应该指标归一化后的数值。
这样处理有两个直接好处。第一,角度维度天然能容纳更多的类别——一个圆360度,8个模型就是每45度一个扇区,比普通直角坐标系里挤在横轴上的标签要宽松得多。第二,柱条从圆心向外辐射,长度越大越靠外圈,视觉上“向外突出”的程度比“向上长高”更容易被人眼捕捉。这也是为什么Nature系期刊的图表编辑偏爱这种形式:它能在A4纸的一栏宽度里塞下大量信息,同时保持画面整洁,不需要读者来回比划横纵坐标。
如果非要找个生活化类比,环形分组柱状图很像一个圆形的钟表:模型是钟表上的刻度,性能指标是每个刻度旁向外伸出的长短指针,指针越长代表该指标越强。要比较任意两个模型,只要看钟表上对应扇区里的“指针组合”谁更饱满即可。
1.3 什么场景适合、什么场景不适合
必须先把边界说清楚,免得有人拿着它硬套。就我的实际经验,环形分组柱状图最适合的是“中等规模”的对比:模型数量在6—12之间,指标数量在3—6之间。少于6个模型,用普通分组柱状图更简洁;大于12个模型,扇区太窄,每个模型分到的角度只有30度不到,柱条挤压成一圈细线,反而退化成“菊花图”,根本没有可读性。
指标数量也是一个隐形约束。理论上你可以画8个指标,但每个指标都要分配一种颜色,人眼能轻松区分的颜色类别上限就是五六种。所以我在项目里常见的组合是8个模型配4—6个指标。如果你有十几二十个指标,建议先做主成分分析或者人工挑重点,不要指望一张图解决所有维度的展示。
另外,指标之间的量纲差异必须提前消化。环形图对归一化非常敏感,这一点我在第4部分会详细展开。总之,这是一张“强表达、强约束”的图,用好了是论文里最亮眼的配图,用不好就是把40根柱子弯了腰而已。
2. 复现前必须想清楚的三件事
2.1 数据怎么组织:模型行、指标列
动手画图之前,先把数据表整理成标准形态。我习惯的格式是一个二维数组,行是模型,列是指标。举例来说,如果对比8个目标检测模型在COCO上的表现,数据表大概是下面这样:
| 模型 | AP | AP50 | AR | Params(M) | FPS |
|---|---|---|---|---|---|
| YOLOv8n | 37.3 | 53.8 | 53.0 | 3.2 | 255 |
| YOLOv8s | 44.9 | 61.8 | 60.9 | 11.2 | 170 |
| Faster R-CNN | 37.4 | 58.1 | 50.5 | 41.7 | 26 |
| RetinaNet | 36.5 | 55.6 | 49.7 | 37.7 | 45 |
| DETR | 42.0 | 62.4 | 55.8 | 41.0 | 28 |
| FCOS | 41.5 | 60.7 | 54.1 | 32.1 | 60 |
| SSD | 28.0 | 48.7 | 41.2 | 26.3 | 82 |
| Cascade R-CNN | 42.8 | 61.3 | 56.6 | 68.9 | 30 |
这个表里的量纲差异非常典型:AP、AP50、AR都在0—100之间,Params动辄几十兆,FPS更是从26到255横跨一个数量级。如果不做任何处理直接画,FPS这根柱子会撑爆整个半径范围,其他指标全贴在内圈变成一层薄片。所以数据组织的下一步必须是归一化。
我强烈建议逐指标做min-max归一化,也就是对每一列单独映射到0—1区间。这样可以保留该指标内部的相对排名,避免量纲污染。至于归一化导致的“数值绝对值丢失”问题,我一般不担心——这种图的读者更多关注模型之间的相对差异,绝对数值在正文表格里还会给出,图上只要条长关系正确即可。
2.2 工具选型:为什么Matplotlib就够
方案选型上,备选有Python的Matplotlib、Plotly,以及R语言的ggplot2加扩展包。如果要投期刊,尤其是Nature系列,最后的提交格式往往是PDF或者TIFF,必须保证矢量输出和精确排版控制,那么答案基本只有一个:Matplotlib。
Matplotlib的极坐标投影已经封装得非常成熟,用projection='polar'创建一个子图,就能在圆形的坐标系里调用bar、text、grid等常见方法。极坐标下的bar方法接受width参数作为柱条在角度方向占据的宽度,bottom参数作为柱条的起始半径,配合height指定柱条沿径向的长度,完全能覆盖环形分组柱状图的全部需求。
Plotly也可以做,鼠标悬停还能显示数值,做交互式Demo确实好看。但问题在于,Plotly渲染出的静态图导出成矢量格式时,细节控不住,字体、线宽、颜色有时会被默认主题接管,期刊排版时很容易出幺蛾子。所以我的结论是:交互演示用Plotly,正式论文复现用Matplotlib。
2.3 Nature风格不是玄学:配色、字体与留白
很多人一听到“Nature风格”就觉得是某种高深莫测的设计语言,其实拆开看就三样东西:克制的配色、干净的字体、充足的留白。
先说配色。Nature正刊和子刊里的极坐标图,很少用彩虹色或者鲜艳的大红大绿,常见做法是选择一个低饱和的蓝色系作为主色,再用一到两个对比色(比如红、绿、紫)去区分关键变量,整体不超过五六个色相。我复现时常用的是一组经典“期刊色”:深蓝#3B4992、亮红#EE0000、墨绿#008B45、紫色#631879、青绿#1B9E77。这组颜色在彩打和灰度打印下都有区分度,不会出现两种颜色转黑白后糊成一片的情况。
字体方面,Matplotlib默认的DejaVu Sans在论文里显得不够干净。我会在脚本开头统一设置,把字体族切到Helvetica或者Arial,找不到对应字体时退到无衬线体。另外,图上的所有文字元素——模型名、指标图例、数字标签——字号要形成一个清晰的层级,模型名最大,指标图例其次,数值标注最小,这样才能一眼抓住主干信息。
留白也很关键。环形图最怕“填满”——柱条一直顶到画布边缘,标签和柱头挤在一起,图例悬在角落。我的习惯是让最大柱条只占半径的80%左右,剩下的20%留给外圈标签和呼吸空间,图例放在图中绘制顺序不遮挡数据的位置,或者干脆放在Figure层面而不是坐标轴内部。
3. 核心实现:极坐标逐层拆解
3.1 极坐标画布与角度分布
先建画布。用plt.subplots创建极坐标子图,figsize建议直接设成正方形,比如(8, 8)或(10, 10),因为极坐标图天生是圆的,长方形画布会浪费大量空白,还会导致标签偏移。
两个方向约定需要马上处理:让0度位于正上方、角度顺时针增加。这符合大多数论文读者的视觉习惯,也避免模型扇区从右侧开始绕圈导致标签错位。对应代码是ax.set_theta_zero_location('N')和ax.set_theta_direction(-1)。
接下来计算角度分布。假设模型总数是N,每个模型占用的角度步长theta_step = 2 * np.pi / N。为了让相邻模型扇区之间有一道明显的视觉缝隙,我不会让扇区完全贴在一起,而是从theta_step中切出一小块作为扇区间隔。间隔比例我一般取3%——太小了看不出分组,太大了浪费角度空间。
在单个模型扇区内,还要再分配K个指标柱条。柱条之间留一点空隙,让它们看起来是“并排的柱子”而不是“一根粗柱子上的颜色段”。逻辑很简单:每个指标柱条的宽度是(扇区角度 - 扇区间隔 - 柱条间隙总宽) / 指标数。这里的所有“宽度”在极坐标里都是弧度值,width参数接收的就是弧度,不是角度,新手容易在这里翻车。
3.2 在扇区内铺指标柱
角度算完之后,往每个扇区里画柱条。Matplotlib的ax.bar在极坐标下需要四个核心参数:
theta:柱条中心的角度,弧度制;height:柱条沿径向的长度,对应归一化后的指标值;width:柱条在角度方向上的宽度,弧度制;bottom:柱条的起始半径。
我通常把bottom设为一个较小的正数,比如0.05,让圆心附近留出空心圆。这个空心圆既是风格的组成部分,也避免柱条在圆心处交叉形成一团黑色。记住,在极坐标下没有直角坐标系的“x轴”,所有柱子都是从bottom这个半径开始向外长,不是从真正的圆心开始。
径向长度还要做一个缩放。假设最大半径是1,我给柱条预留的径向可用空间大概是0.85,剩下的0.15留给外圈标签。然后把每个模型的归一化值乘以这个可用空间,就是柱条的真实height。这样画出来的图,最长的柱头离外圈标签还有一段缓冲距离,不会“撞车”。
颜色按指标循环分配:同一个指标的所有模型柱条使用同一种颜色,这样读者可以顺着某一圈去找“哪个模型在这个指标上领先”。我在绘制时顺手给每个指标的第一根柱条传入label参数,后续循环用i == 0判断只登记一次图例,避免图例重复生成40次。
3.3 外圈标签、刻度与网格线的取舍
画完柱条,最难的不是柱子本身,而是周围那一圈装饰元素。外圈模型标签是优先要处理的:把每个模型扇区的中心角度算出来,转成度数,用ax.set_thetagrids(angles, labels)一次性设置。
8个模型的标签角度是每45度一个,分布均匀,但像“Faster R-CNN”这种长名字放在某些角度上难免和相邻标签重叠。我的处理办法是设置fontsize稍小一点,再给标签加一个透明的白色bbox背景垫片,文字就不会压到柱条或者彼此叠吃。如果实在太长,就把模型名缩写,或者分两行写。
径向刻度默认会显示一圈数字,这在环形分组柱状图里几乎是多余的,因为每个指标都单独归一化过,数字本身没有统一的物理意义,留着只会让人产生困惑。我直接把y轴刻度标签清空,只保留浅浅的径向网格线,帮助读者判断柱条长短的相对差异。角度方向的网格线则完全关掉,因为每个扇区的角度位置已经由模型标签指明了,再画竖线只会让图面更乱。
边框也处理一下。极坐标子图默认有一个圆形外框,颜色是深灰色,我会把它调成淡灰色或者直接隐藏,让整个图融进论文白纸背景里。
4. 完整代码与参数调优实战
4.1 可直接跑的完整脚本
下面是我整理出来的完整复现脚本。数据直接沿用第2部分那张目标检测对比表,你换成自己的模型指标表就能用。
这段脚本跑出来的图就是标准的环形分组柱状图:8个模型绕成一圈,每个扇区里5根柱子并排向外,颜色对应指标,外圈模型名清晰,中心留白,整体能直接放进论文里做初稿。
4.2 参数调优的关键旋钮
跑通之后,真正花时间的是调参数。我把自己最常用到的几个“旋钮”列在下面,这些参数直接影响成图质量。
第一是figsize。如果你准备把图放进双栏论文的一栏里,figsize=(7, 7)就够;如果是要全页展示或者做汇报PPT,建议figsize=(10, 10)以上。字体大小要跟着画布尺寸走,否则图一放大,标签字就变成蚂蚁大小。
第二是r_min。中心空心半径的大小决定了图的重心。r_min太小(比如0.03),柱条在圆心附近挤成一团;r_min太大(比如0.3),有效展示面积被压缩,柱条长度差异变得不明显。我用下来0.05—0.15之间的视觉反馈最平衡,具体值跟你模型数和指标数有关。
第三是alpha不透明度。论文图一般不搞透明度叠色效果,我用0.85—0.95之间的实色柱条,配合极浅的白色描边。别给edgecolor设置明显的黑色描边,会在小柱条上产生一圈“断线”感,放大后很难看。
第四是bar_gap的比例。柱条间空隙是体现“分组”的关键,空隙太窄,同一模型扇区内的不同指标柱会连成一片;空隙太宽,扇区显得松散。我试过1%—10%的区间,5%是最稳妥的默认值。
4.3 两种合法变体:扇区式与环带式
你可能在期刊上见过另一种“一环接一环”的极坐标柱状图:外层一圈对应指标A,内层一圈对应指标B,每一圈里再按模型划分柱条。这就是我前面说的环带式,也叫“同心圆环分组柱状图”。
扇区式(本文主要实现)把每个模型当做一个扇区,优点是模型之间的对比很直观——你只要盯着某个扇区看,就能同时看到该模型所有指标的高低。缺点是不同模型同一个指标的距离较远,需要顺着颜色大圆环去追。
环带式反过来,把每个指标当做一个径向环带,模型在角方向均匀分布。这样做的好处是指标间数值分布更直观,外圈能一眼看出某个指标下哪些模型突出;坏处是每个模型的“整体画像”被拆散到不同环带里,看单模型要顺着角度逐层扫描。
实现环带式,核心改动在bottom参数:第k个指标的柱条起始半径不再是统一值,而是r_min + k * ring_step,每个环带内柱条高度按该指标归一化后乘以ring_step内部门限。代码整体逻辑不变,只是半径分配公式换成:
选哪种取决于你想强调什么:强调“模型综合能力”用扇区式,强调“单个指标的跨模型排名”用环带式。Nature系论文里两种都很常见,没有绝对优劣,只有表达重点不同。
5. 踩坑实录:我复现时遇到的问题
5.1 典型问题与排查对照表
在帮师弟调图以及自己后续复现过程中,我踩过不少坑。下面这个对照表是我自己整理的排错速查,先直接给结论。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 柱条重叠成一团 | bar_width太大或bar_gap太小 |
增大bar_gap比例,调小bar_width |
| 模型名和柱头相撞 | 径向r_span预留不足 |
把r_span降到0.75—0.85 |
| 某个指标柱子特别短 | 未做归一化,量纲主导 | 逐指标min-max归一化 |
| 外圈标签重叠 | 模型名过长或fontsize过大 |
缩写模型名、分两行或加背景垫片 |
| 圆心处柱根交叉 | r_min太小 |
增大r_min到0.05以上 |
| 图例重复显示几十项 | label在循环里每轮都传 |
用i == 0条件只登记一次 |
| 黑白打印后颜色无法区分 | 选的颜色灰度值接近 | 换用灰度差异大的颜色组 |
| 保存的PNG边缘被截断 | 没设bbox_inches |
保存时加bbox_inches="tight" |
5.2 逐条拆解几个高频翻车位
第一个高频翻车位是忽略归一化。大多数新人第一次跑出来的图,所有柱子看起来都差不多长,或者一根柱特别长,检查数据发现是某个指标数量级太大。我建议拿到任何多指标数据后先画一列describe()看看量纲,再决定归一化策略。逐指标归一化虽然丢掉了指标间可比性,但保住了这张图最核心的“多模型排名”功能。
第二个翻车位是外圈标签和柱头撞车。有次我把r_span设成1.0,模型名标签的锚点也在半径1.0位置,结果柱子长一点的模型,标签被柱头压住半个字。后来我固定了一个原则:柱条最大径向长度永远比标签所在半径小15%以上。也就是说,如果最大半径是1.0,标签角度位于1.0,柱条最多长到0.85。
第三个翻车位是图例。极坐标图的图例位置比直角坐标更挑剔,放在中间会遮挡柱子,放在右下会压在空心上,放在左侧又容易被外圈标签截断。我试过几个位置,最稳的是右上角悬空区,用bbox_to_anchor把图例锚到坐标轴外面一点,配合frameon=False去掉边框,既不影响柱条读数,也显得干净。
第四个隐藏坑是set_thetagrids的标签对齐行为。默认情况下,标签文字是沿着半径方向对齐的,模型名在右上角看起来是斜的。如果希望所有模型名都是水平文字,需要手动遍历返回的文本对象,调整set_rotation(0)和set_ha、set_va,让不同角度的标签各自“贴着”外缘水平放置。这个操作不难,但很多人不知道返回对象还可以继续改。
6. 这张图还能怎么改
6.1 加误差条、加数值标签、做交互
环形分组柱状图不是只能画静态的平均值。如果你在用多个随机种子跑实验、每个指标有均值和方差,完全可以在每根柱条顶端叠一个极坐标误差条。
极坐标下加误差条,我用的不是ax.errorbar(它在这种场景下坐标计算比较绕),而是直接画一根细线加两端短横线:
数值标签也可以加,但我建议克制。40根柱子全部标注“37.3、44.9……”会让画面变成电子表格,我只会在需要强调的某个指标或者某个模型上标注,其他数值留给正文表格。
如果想要交互版本,把数据导出到Plotly,用go.Barpolar或者px.bar_polar,鼠标一悬停就能看到每个模型的原始指标值,做演示和报告特别合适。注意交互版的配色要和静态版保持一致,避免老板觉得你“换了一张图”。
6.2 与雷达图、热力图的适用边界
最后聊一下这类图的边界。我的习惯是:3—5个模型时优先用普通分组柱状图或雷达图,6—12个模型、3—6个指标时用环形分组柱状图,超过12个模型或者指标超过8个,就退回热力图或者做分面小图。
环形分组柱状图确实好看,但它本质上用“极坐标弯曲”换来了屏占比和新鲜感,代价是精确的数字读取不如普通柱状图直观。如果审稿人是个极简主义者,他可能还是会问“为什么不用普通条形图”。所以我在论文里通常把环形分组柱状图作为“整体对比”的主图,数值细节放在附录表格里,二者互补,而不是用一张图解决所有问题。
我个人在实际操作中的体会是:这张图的真正价值不是炫技,而是逼着你先想清楚——要强调模型排名,还是指标分布?想清楚这一点,再决定用扇区式还是环带式,用哪些颜色,标哪些数字,图就会自己“长”出来。别一上来就抄代码,先让数据结构服务于表达,后续所有调参都是顺水推舟的事。
如果你只是需要一张能过审稿人眼的快速对比图,我这个脚本换个数据就能直接用。如果还想继续深挖,可以试试把某一圈的柱子改成堆叠柱,或者在外圈加一圈数据集的次级对比,玩法很多,但每个扩展都要回到同一个问题:读者第一眼看到这张图时,能否在三秒内说出你想表达的核心结论。能做到,这张图就成了。