首页 / 资讯中心 / 文章详情

Matplotlib堆积柱状图实战:从数据组织到动画导出

Matplotlib堆积柱状图实战:从数据组织到动画导出 ★ FEATURED ARTICLE
1. 为什么我劝你别再手写堆积柱状图堆积图这个东西乍一看特别简单——不就是把几组数据摞起来画个柱子吗我刚开始用 Matplotlib 那会儿也是这么想的结果第一次给一个运营数据做周报画出来的图被同事吐槽“像被压扁的彩虹糖”。后来我才意识到堆积图真正的难点根本不在“画出来”而在于数据怎么组织、颜色怎么分配、标签怎么放、比例怎么算。这四个问题任何一个没处理好图就废了。这篇文章我想跟你聊的是我自己在实际项目里反复踩坑之后总结出来的一整套堆积图绘制方法。从最基础的plt.bar()堆叠逻辑到多子图共享图例、百分比归一化、动画导出 GIF再到颜色搭配和保存时的分辨率陷阱我都会掰开揉碎讲清楚。不管你是刚学 Matplotlib 的新手还是已经能画折线图想进阶到复合图表的老手这篇内容应该都能让你少走一些弯路。我默认你已经会基本的import matplotlib.pyplot as plt和plt.show()如果你连折线图都还没画过建议先找个简单的折线图练练手再回来看这篇。下面所有代码我都实测跑过环境是 Python 3.10 Matplotlib 3.7不同版本可能有细微差异我会在关键位置标注出来。2. 堆积图的核心逻辑与数据组织方式2.1 堆积图的本质bottom 参数的接力赛很多人以为堆积图是 Matplotlib 内置的一种图表类型其实不是。Matplotlib 里压根没有plt.stackbar()这种函数堆积效果完全靠plt.bar()的bottom参数一层一层叠出来。理解这一点非常关键因为它决定了你后面所有的操作逻辑。打个比方bottom参数就像接力赛里的接力棒。第一组数据从 0 开始画画完之后它的高度就是第二组数据的起点第二组画完的高度又成为第三组的起点以此类推。用代码表示就是import matplotlib.pyplot as plt import numpy as np # 假设有三组数据分别代表三个渠道的周访问量 weeks [第1周, 第2周, 第3周, 第4周] channel_a np.array([120, 135, 150, 140]) channel_b np.array([80, 95, 110, 105]) channel_c np.array([60, 70, 85, 90]) fig, ax plt.subplots(figsize(8, 5)) # 第一层从 0 开始 ax.bar(weeks, channel_a, label渠道A, color#4C72B0) # 第二层bottom 设为第一层的高度 ax.bar(weeks, channel_b, bottomchannel_a, label渠道B, color#DD8452) # 第三层bottom 设为前两层之和 ax.bar(weeks, channel_c, bottomchannel_a channel_b, label渠道C, color#55A868) ax.set_ylabel(访问量) ax.set_title(各渠道周访问量堆积图) ax.legend() plt.tight_layout() plt.show()这段代码里最关键的就是bottomchannel_a和bottomchannel_a channel_b这两行。你可以看到每一层的起点都是前面所有层累加的结果。如果层数很多手动累加会非常痛苦所以实际项目里我一般会用一个循环来处理data np.array([channel_a, channel_b, channel_c]) colors [#4C72B0, #DD8452, #55A868] labels [渠道A, 渠道B, 渠道C] fig, ax plt.subplots(figsize(8, 5)) bottom np.zeros(len(weeks)) for i, (row, color, label) in enumerate(zip(data, colors, labels)): ax.bar(weeks, row, bottombottom, labellabel, colorcolor) bottom row ax.legend() plt.show()这个循环写法是我强烈推荐的因为不管你后面要加多少层数据只需要往data里加一行就行不用改任何绘图逻辑。bottom初始化为全零数组每画完一层就累加一次逻辑非常干净。2.2 横向堆积与纵向堆积的选择默认的ax.bar()是纵向柱状图如果你想要横向的堆积条比如做甘特图风格的任务分布需要用ax.barh()对应的参数从bottom变成leftfig, ax plt.subplots(figsize(8, 5)) left np.zeros(len(weeks)) for row, color, label in zip(data, colors, labels): ax.barh(weeks, row, leftleft, labellabel, colorcolor) left row ax.set_xlabel(访问量) ax.legend() plt.show()横向堆积图在展示“任务时长分布”“预算分配”这类场景时特别直观因为文字标签可以水平放置不用旋转阅读体验比纵向图好很多。我个人的经验是如果类别名称比较长超过4个汉字优先考虑横向堆积否则纵向图的 x 轴标签会挤成一团。2.3 数据归一化百分比堆积图的正确打开方式很多时候我们关心的不是绝对数值而是各部分的占比。比如“各渠道访问量占比变化趋势”这时候就需要把数据归一化成百分比再堆积。这里有个坑不能先画图再改坐标轴标签那样柱子的高度还是原始数值只是标签骗人而已。正确做法是先对数据做归一化# 按列归一化每一周的总和变成 1 data_normalized data / data.sum(axis0) fig, ax plt.subplots(figsize(8, 5)) bottom np.zeros(len(weeks)) for row, color, label in zip(data_normalized, colors, labels): ax.bar(weeks, row, bottombottom, labellabel, colorcolor) bottom row # 关键把 y 轴刻度格式化成百分比 ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f{y:.0%})) ax.set_ylabel(占比) ax.legend(locupper center, bbox_to_anchor(0.5, -0.1), ncol3) plt.tight_layout() plt.show()data.sum(axis0)是按列求和得到每一周的总量然后广播除法就完成了归一化。FuncFormatter那行是把 0.25 这种小数显示成 25%比手动乘 100 再改标签要优雅得多。注意axis0和axis1的区别搞反了归一化方向就全错了这是新手最容易犯的错误之一。3. 颜色、标签与图例的实战处理技巧3.1 颜色搭配别再用默认色环了Matplotlib 默认的颜色循环tab10在堆积图里其实不太够用因为堆积图通常需要 4 到 8 种颜色而且相邻颜色要有足够的对比度否则堆在一起根本分不清边界。我试过很多方案最后固定下来两套配色思路第一套是同色系渐变适合表达“同一事物的不同阶段”或“从高到低的层级关系”。比如用plt.cm.Blues生成一组蓝色渐变import matplotlib.cm as cm n_layers len(data) colors [cm.Blues(0.3 0.6 * i / (n_layers - 1)) for i in range(n_layers)]第二套是对比色系适合表达“互相独立的类别”。我常用的是Set2或Paired这类定性色板colors plt.cm.Set2(np.linspace(0, 1, n_layers))这里有个细节plt.cm.Set2返回的是一个 colormap 对象直接传数字进去会报错必须用np.linspace生成 0 到 1 之间的采样点。另外如果你要发到印刷品或者对色盲友好有要求建议避开红绿搭配用蓝橙组合会更安全。提示颜色数量超过 6 种时人眼区分能力会急剧下降。如果层数确实很多考虑把占比最小的几层合并成“其他”或者改用分组柱状图而不是堆积图。3.2 数据标签的放置策略堆积图最让人头疼的就是数据标签。放在柱子内部吧层太薄的时候文字会溢出放在柱子外面吧又不知道指向哪一层。我的做法是分情况处理如果层高足够占总量 15% 以上标签直接放在柱子内部居中for i, (row, color, label) in enumerate(zip(data, colors, labels)): bars ax.bar(weeks, row, bottombottom, labellabel, colorcolor) # 在每根柱子内部标注数值 for bar, val in zip(bars, row): height bar.get_height() if height / data[:, weeks.index(bar.get_x())].sum() 0.15: ax.text(bar.get_x() bar.get_width()/2, bar.get_y() height/2, f{val}, hacenter, vacenter, fontsize9) bottom row如果层太薄就干脆不标数值靠图例和鼠标悬停交互模式下来识别。硬塞标签只会让图变得乱七八糟这一点我在给非技术同事做图的时候体会特别深——他们根本不看那些挤在一起的小数字反而会问“这个图怎么这么乱”。3.3 图例位置共享图例的两种方案堆积图的图例通常放在图外因为柱子本身已经占满了绘图区。最常用的位置是底部水平排列ax.legend(locupper center, bbox_to_anchor(0.5, -0.08), ncollen(labels), frameonFalse)bbox_to_anchor的坐标是相对于 axes 的归一化坐标(0.5, -0.08) 表示水平居中、垂直方向在图下方 8% 的位置。ncol控制列数一般设成和层数一样这样图例就是一行排开。frameonFalse去掉图例边框视觉上更干净。如果你有多个子图需要共享同一个图例不要在每个子图里都调legend()那样会重复显示。正确做法是只在最后一个子图调用或者用fig.legend()在画布级别添加fig, axes plt.subplots(1, 2, figsize(12, 5)) # ... 分别绘制两个子图都不调 legend ... handles, labels axes[0].get_legend_handles_labels() fig.legend(handles, labels, loclower center, ncol3, frameonFalse) plt.tight_layout(rect[0, 0.05, 1, 1]) # 给底部图例留空间tight_layout(rect...)里的 rect 参数很容易被忽略但如果不设图例会和 x 轴标签重叠。rect 的四个值分别是左、下、右、上的边距比例这里把底部留出 5% 给图例。4. 完整实操从原始数据到可发布图表4.1 场景设定与数据准备假设我在做一个模拟项目需要展示某产品四个季度里三个功能模块的使用量分布。原始数据是一张 CSV 表我用 pandas 读进来之后长这样季度模块A模块B模块CQ132018095Q2410220130Q3380260175Q4450310210读取和转换的代码import pandas as pd import matplotlib.pyplot as plt import numpy as np df pd.read_csv(module_usage.csv) quarters df[季度].tolist() modules [模块A, 模块B, 模块C] data df[modules].values.T # 转置成 (模块数, 季度数)这里values.T的转置操作很关键。pandas 读进来默认是 (行季度, 列模块)而我们的循环需要按模块遍历所以转置成 (行模块, 列季度)。如果你忘了转置画出来的图会把季度当成层叠完全错位。4.2 绘图参数逐项配置接下来是完整的绘图配置我把每个参数为什么这么设都写在注释里fig, ax plt.subplots(figsize(9, 5.5), dpi120) # 配色用 Set2 定性色板适合独立类别 colors plt.cm.Set2(np.linspace(0, 1, len(modules))) bottom np.zeros(len(quarters)) bars_list [] for module, color in zip(modules, colors): row df[module].values bars ax.bar(quarters, row, bottombottom, labelmodule, colorcolor, edgecolorwhite, linewidth0.8) # 白色描边分隔各层 bars_list.append(bars) bottom row # y 轴从 0 开始避免视觉误导 ax.set_ylim(0, bottom.max() * 1.15) # 网格线只保留横向放在柱子后面 ax.yaxis.grid(True, linestyle--, alpha0.4) ax.set_axisbelow(True) # 去掉上边和右边的边框 ax.spines[top].set_visible(False) ax.spines[right].set_visible(False) ax.set_ylabel(使用量, fontsize11) ax.set_title(各模块季度使用量堆积图, fontsize13, pad12) # 图例放底部 ax.legend(locupper center, bbox_to_anchor(0.5, -0.08), ncol3, frameonFalse, fontsize10) plt.tight_layout() plt.savefig(stacked_bar.png, dpi200, bbox_inchestight) plt.show()几个值得展开说的点edgecolorwhite给每根柱子加了白色描边这样相邻两层之间会有一条细白线分隔视觉上清晰很多。linewidth0.8是我试出来的比较合适的值太粗会显得柱子被切碎太细又看不出来。ax.set_axisbelow(True)让网格线画在柱子下面否则网格线会盖在柱子上看起来像柱子被划了几刀。bbox_inchestight在保存时自动裁掉多余的空白边距这个参数我几乎每次保存都会加不然导出的图四周会有一大圈白边放到文档里很难看。4.3 保存动画 GIF 的完整流程热搜词里提到了“matplotlib保存动画gif”这其实是堆积图的一个进阶玩法——展示数据随时间变化的动态堆积过程。Matplotlib 的animation模块配合pillow写入器可以导出 GIF。先确保装了 pillowpip install pillow然后构造动画。核心思路是每一帧只显示到当前时间点为止的数据from matplotlib.animation import FuncAnimation, PillowWriter fig, ax plt.subplots(figsize(8, 5)) colors plt.cm.Set2(np.linspace(0, 1, len(modules))) def update(frame): ax.clear() bottom np.zeros(frame 1) for module, color in zip(modules, colors): row df[module].values[:frame 1] ax.bar(quarters[:frame 1], row, bottombottom, labelmodule, colorcolor, edgecolorwhite) bottom row ax.set_ylim(0, df[modules].values.sum(axis1).max() * 1.15) ax.set_ylabel(使用量) ax.set_title(f各模块使用量堆积图截至{quarters[frame]}) ax.legend(locupper left, frameonFalse) anim FuncAnimation(fig, update, frameslen(quarters), interval800) anim.save(stacked_animation.gif, writerPillowWriter(fps1.5))frameslen(quarters)表示一共 4 帧interval800是每帧间隔 800 毫秒。PillowWriter(fps1.5)控制导出 GIF 的播放速度fps 越低播放越慢。这里有个坑ax.clear()必须在每帧开头调用否则上一帧的柱子会残留画面会越来越乱。另外FuncAnimation对象必须赋值给一个变量这里是anim如果直接写FuncAnimation(...)不赋值动画对象会被垃圾回收保存出来是空文件。这个坑我踩过不止一次。5. 常见问题排查与避坑清单5.1 中文显示方块问题Matplotlib 默认字体不支持中文标题和标签里的汉字会变成方块。解决办法是设置字体plt.rcParams[font.sans-serif] [SimHei] # Windows 用黑体 plt.rcParams[axes.unicode_minus] False # 解决负号显示问题Mac 用户把SimHei换成Arial Unicode MS或PingFang SC。Linux 服务器上如果没有中文字体需要先安装字体文件再指定路径。这个配置我一般写在脚本最开头全局生效省得每个图都设一遍。5.2 柱子顺序错乱如果你发现堆积的顺序和图例对不上八成是因为bottom累加的顺序和label的顺序不一致。检查你的循环里bottom row是不是在ax.bar()之后执行的。另外如果用了 pandas 的df.plot(kindbar, stackedTrue)它会自动处理堆积但顺序由列顺序决定想调整顺序得先对 DataFrame 的列重新排序。5.3 保存的图片模糊或尺寸不对plt.savefig()的dpi参数决定分辨率默认是 100放到 PPT 或文档里会偏糊。我一般设 200 或 300。但 dpi 调高之后图片像素尺寸也会变大如果文档有尺寸限制需要同时调整figsize。比如想要 1200x800 像素的图可以设figsize(6, 4), dpi200或者figsize(12, 8), dpi100两者像素一样但后者字号会显得小。我的习惯是figsize按英寸设成实际需要的物理尺寸dpi设 200 保证清晰度。5.4 常见问题速查表问题现象可能原因解决方法中文显示为方块未设置中文字体设置rcParams[font.sans-serif]柱子层叠顺序错乱bottom 累加顺序错误检查循环中累加与绘制的先后图例与 x 轴标签重叠图例位置太靠下调整bbox_to_anchor或tight_layout(rect...)保存的 GIF 是空文件动画对象未赋值给变量anim FuncAnimation(...)百分比堆积图数值不对归一化方向搞反确认sum(axis0)还是axis1柱子之间有缝隙未设 edgecolor 或 linewidth加edgecolorwhite, linewidth0.8网格线盖住柱子未设 axisbelowax.set_axisbelow(True)5.5 几个我踩过的坑第一个坑是在循环里反复调ax.legend()。我一开始图省事在画每一层的时候都调一次 legend结果图例里同一个标签出现了好几次。正确做法是循环结束后统一调一次。第二个坑是用plt.bar()而不是ax.bar()。在单图场景下两者没区别但一旦涉及子图plt.bar()会画到当前激活的 axes 上很容易画错位置。养成用ax.bar()的习惯代码可维护性会好很多。第三个坑是忘记plt.close()。如果你在脚本里批量生成几十张图不关闭 figure 会导致内存持续增长跑到后面可能卡死。每张图保存完之后加一句plt.close(fig)释放内存。第四个坑是数据里有 NaN。堆积图遇到 NaN 会直接报错或者画出断裂的柱子而且报错信息很不直观。画图之前先用df.isnull().sum()检查一遍有缺失值就填充或删除。6. 进阶玩法与性能优化6.1 大数据量下的渲染优化当柱子数量超过几百根时Matplotlib 的渲染会明显变慢。我做过一个测试1000 根柱子的堆积图渲染要 3 秒多。优化手段有几个一是降低dpi二是用rasterizedTrue把柱子栅格化矢量图里柱子变成位图但文字保持矢量三是考虑用bar的width参数把柱子加宽减少数量。如果数据量真的很大其实堆积图本身就不是合适的可视化方式了应该换成堆叠面积图或者热力图。6.2 交互式堆积图静态图有个天然缺陷看不到具体数值。用mplcursors库可以给柱子加悬停提示import mplcursors cursor mplcursors.cursor(bars_list, hoverTrue) cursor.connect(add, lambda sel: sel.annotation.set_text( f{sel.artist.get_label()}: {sel.target[1]:.0f}))这样鼠标悬停在柱子上时会弹出数值做数据探索的时候特别方便。不过导出静态图时交互功能会丢失所以交互图适合自己分析用发布用的还是静态图。6.3 堆积图 vs 分组柱状图的选择最后聊一个经常被问到的问题什么时候用堆积图什么时候用分组柱状图我的判断标准很简单——如果你关心的是总量和构成用堆积图如果你关心的是各类别之间的直接对比用分组柱状图。堆积图的劣势在于中间层的柱子起点不在同一水平线上人眼很难准确比较中间层的高度。所以如果模块 B 的对比是重点堆积图反而会误导读者。这个取舍在做图之前就要想清楚不然画完了再改很费时间。我在实际项目里通常是两种图都出一版让需求方自己选。多数情况下如果层数不超过 4 层且总量对比是重点堆积图的效果会更好层数多或者需要精确对比某一层时分组柱状图更靠谱。
阅读完成 · 觉得有帮助?
咨询建站