资讯动态

Matplotlib堆积图完全指南:从原理到实战的避坑手册

发布时间:2026/10/9 21:19:44 来源:尧图企业网站定制
1. 为什么堆积图值得单独拎出来讲很多人刚接触 Matplotlib 的时候画折线图、散点图、柱状图都挺顺手唯独一到堆积图就开始犯迷糊数据该准备成什么形状bar和barh到底用哪个bottom参数怎么传才不会错位图例为什么总是对不上颜色我自己在带新人的时候发现堆积图是看起来简单、写起来全是坑的典型代表。它不像折线图那样把两组数据往plot()里一塞就完事堆积图的核心在于层与层之间的叠加关系一旦某一层的顺序、颜色、数据长度对不上整张图就会传达错误的信息。这篇文章就是围绕用 Matplotlib 画堆积图这一件事把从数据准备、方向选择、颜色搭配、标签标注到导出保存的完整链路拆开讲清楚。不管你是刚学完plt.plot()想进阶的初学者还是已经能画图但总在细节上翻车的开发者都能从里面找到可以直接抄走的代码和踩坑经验。我会尽量把每个参数背后的逻辑讲透而不是甩一段代码让你自己猜。堆积图本质上解决的是部分与整体随时间或其他维度变化的表达问题。比如某产品三个渠道的日活占比、某项目四个阶段的工时分配、某地区三类支出的年度构成这些场景用普通柱状图会显得割裂用饼图又只能看单一时点堆积图刚好补上这个空档。理解了它要回答的问题后面所有的参数选择都会变得有据可依。2. 堆积图的核心原理与两种方向2.1 堆积图到底堆的是什么先把这个概念说透。堆积图Stacked Chart是在同一个类别位置上把多个数据系列的值依次累加后绘制出来的图形。以柱状堆积图为例假设类别是第一季度、第二季度系列是A产品、B产品、C产品那么第一根柱子的总高度是三个产品在第一季度的销量之和其中每一段的高度代表单个产品的销量。这里有个关键点容易被忽略堆积图里每一层的起点不是 0而是它下面所有层的累加值。这就是bottom参数存在的意义。很多人第一次画堆积图时直接把三组数据分别bar()三次结果三根柱子完全重叠在一起只看到最后一组——这就是没传bottom的典型症状。用生活化的类比想象你在往一个透明杯子里依次倒蜂蜜、牛奶、果汁蜂蜜在最底下牛奶压在蜂蜜上面果汁又压在牛奶上面。每一层的底部位置取决于它下面已经倒了多少。Matplotlib 的bottom参数就是告诉它这一层从多高的位置开始画。2.2 垂直堆积与水平堆积怎么选Matplotlib 提供了两个函数来画堆积柱状图函数方向适用场景关键参数plt.bar()垂直类别数量少、类别名短bottom控制纵向起点plt.barh()水平类别名长、类别数量多left控制横向起点选择逻辑其实很朴素类别名越长、类别数量越多越应该用水平堆积图。因为垂直柱状图的 x 轴标签一旦变长就得旋转旋转之后阅读体验直线下降而水平堆积图的 y 轴标签天然是横排的长文本也能完整显示。我个人的经验判断线是类别名超过 4 个汉字或者类别数量超过 6 个就优先考虑barh。这不是硬性规定但从可读性角度看这个阈值基本能覆盖大多数实际场景。还有一个容易混淆的点barh的堆积参数叫left而不是bottom。因为水平方向的起点是从左往右算的逻辑上叫left更准确。刚上手的人经常在这里写错参数名报错信息又不够直观白白浪费时间。2.3 数据该准备成什么形状堆积图对数据形状有隐含要求每个系列的数据长度必须一致且顺序要和类别顺序严格对应。假设你有三个渠道、四个季度的数据最稳妥的组织方式是import numpy as np categories [第一季度, 第二季度, 第三季度, 第四季度] channel_a np.array([120, 135, 150, 160]) channel_b np.array([80, 95, 110, 105]) channel_c np.array([60, 70, 85, 90])用np.array而不是普通 list 有个实际好处后面做累加计算比如算bottom时可以直接向量化运算不用写循环。如果你手上是 pandas 的 DataFrame那更省事直接按列取出来就是对齐好的数组但要注意列的顺序决定了堆积的顺序别拿到数据就无脑堆。提示数据里如果有缺失值NaN堆积图会直接画断或者报错。画之前务必用df.isnull().sum()检查一遍该填充填充该剔除剔除。3. 从零画出一张能看的堆积柱状图3.1 最小可运行版本先把最核心的骨架搭起来。下面这段代码是垂直堆积柱状图的最小实现我特意把每一步拆开写方便你对照理解import matplotlib.pyplot as plt import numpy as np # 解决中文显示问题 plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False categories [第一季度, 第二季度, 第三季度, 第四季度] channel_a np.array([120, 135, 150, 160]) channel_b np.array([80, 95, 110, 105]) channel_c np.array([60, 70, 85, 90]) x np.arange(len(categories)) width 0.5 fig, ax plt.subplots(figsize(9, 6)) # 第一层从 0 开始 ax.bar(x, channel_a, width, label渠道A, color#4C72B0) # 第二层从 channel_a 的高度开始 ax.bar(x, channel_b, width, bottomchannel_a, label渠道B, color#DD8452) # 第三层从 channel_a channel_b 的高度开始 ax.bar(x, channel_c, width, bottomchannel_a channel_b, label渠道C, color#55A868) ax.set_xticks(x) ax.set_xticklabels(categories) ax.set_ylabel(销量件) ax.set_title(各渠道季度销量堆积图) ax.legend() plt.tight_layout() plt.show()这段代码里有三个地方值得单独说第一bottom的传参方式。第一层不传bottom默认 0第二层传channel_a第三层传channel_a channel_b。规律很清楚第 n 层的 bottom 等于前 n-1 层数据之和。层数多了以后手写加法容易出错可以用np.cumsum配合切片自动算后面会讲。第二width参数。垂直柱状图的宽度默认是 0.8堆积图我一般调到 0.5 到 0.6 之间。原因很简单堆积图本身信息密度就高柱子太宽会显得笨重留白多一点视觉上更透气。第三中文字体设置。SimHei是 Windows 上常见的黑体Mac 上可能没有需要换成Arial Unicode MS或PingFang SC。如果图里出现方框乱码八成就是字体没配对。axes.unicode_minus False这行是解决负号显示成方块的问题虽然堆积图一般用不到负数但养成习惯没坏处。3.2 用 cumsum 自动计算 bottom三层数据手写加法还行五层、八层就很容易写错。更稳妥的做法是用np.cumsum自动累加data np.array([channel_a, channel_b, channel_c]) colors [#4C72B0, #DD8452, #55A868] labels [渠道A, 渠道B, 渠道C] fig, ax plt.subplots(figsize(9, 6)) # 计算每一层的 bottom第一层为 0之后为前面所有层的累加 cumsum np.cumsum(data, axis0) for i in range(len(data)): bottom cumsum[i - 1] if i 0 else np.zeros_like(data[0]) ax.bar(x, data[i], width, bottombottom, labellabels[i], colorcolors[i])这里np.cumsum(data, axis0)会沿着层的方向累加得到每一层顶部的绝对高度。第 i 层的bottom就是第 i-1 层的累加值第一层特殊处理为全 0。这种写法在层数动态变化时特别有用比如你的数据系列是从配置里读出来的层数不固定用循环就一劳永逸了。注意np.zeros_like(data[0])比np.zeros(len(categories))更稳妥因为它自动匹配数据类型和形状不会因为 dtype 不一致引发隐式转换的坑。3.3 水平堆积图的写法差异把上面的逻辑平移到barh主要改三个地方函数名、bottom换成left、x/y 轴的角色互换。fig, ax plt.subplots(figsize(9, 6)) cumsum np.cumsum(data, axis0) for i in range(len(data)): left cumsum[i - 1] if i 0 else np.zeros_like(data[0]) ax.barh(y, data[i], height, leftleft, labellabels[i], colorcolors[i]) ax.set_yticks(y) ax.set_yticklabels(categories) ax.set_xlabel(销量件)注意barh的宽度参数叫height而不是width这个命名有点反直觉但记住水平柱子的粗细是高度就好理解了。另外set_xticks和set_yticks的角色也互换了类别标签现在挂在 y 轴上。水平堆积图有个额外好处当类别名很长时不需要旋转标签。垂直图里那些斜着 45 度的标签读起来脖子都酸水平图直接横排清爽很多。4. 颜色、标签与细节打磨4.1 配色不是随便挑几个好看的颜色堆积图的配色有个硬性要求相邻层的颜色必须有足够的对比度。因为层与层之间是紧贴的如果两个颜色太接近边界就糊在一起读者根本分不清哪里是第一层结束、第二层开始。我常用的几套配色方案场景推荐色系示例色值商务报告蓝橙绿灰#4C72B0#DD8452#55A868#8172B3数据大屏高饱和对比#E74C3C#3498DB#F1C40F#2ECC71学术论文低饱和柔和#8DA0CB#FC8D62#66C2A5#E78AC3这些色值来自 ColorBrewer 和 Seaborn 的经典调色板经过大量实际项目验证对比度和美观度都过关。如果你懒得记色值可以直接用 Matplotlib 内置的 colormapimport matplotlib.cm as cm colors cm.Set2(np.linspace(0, 1, len(data)))Set2、Paired、tab10这几个 colormap 都适合分类数据取色均匀不会出现两个层颜色撞车的情况。但要注意tab10在层数超过 10 时会循环取色超过 10 层的堆积图本身就不建议画了信息密度太高读者根本消化不了。4.2 数据标签怎么加才不打架堆积图加数据标签是个技术活。垂直堆积图里如果每层都标数值层高不够的时候文字会重叠。我的处理策略是层高足够占比 8%在层中央标注数值层高不足只在图例或表格里体现图上不标总量重要在柱子顶部标注总和for i in range(len(data)): bottom cumsum[i - 1] if i 0 else np.zeros_like(data[0]) bars ax.bar(x, data[i], width, bottombottom, labellabels[i], colorcolors[i]) for j, bar in enumerate(bars): height bar.get_height() if height / cumsum[-1][j] 0.08: # 占比超过 8% 才标注 ax.text(bar.get_x() bar.get_width() / 2, bar.get_y() height / 2, f{int(height)}, hacenter, vacenter, fontsize9, colorwhite)这里bar.get_y()拿到的是这一层的底部位置加上height / 2就是层中央。文字颜色用白色是因为深色填充上白字更清晰但如果你的配色偏浅就得改成深色字。这个判断逻辑可以再细化比如根据颜色的亮度自动选黑字还是白字不过实际项目里手动指定往往更省事。4.3 图例位置与顺序的讲究图例的顺序默认和绘制顺序一致也就是从底层到顶层。这个顺序符合直觉一般不用改。但图例的位置需要根据图形方向调整垂直堆积图图例放右侧或顶部别放底部会和 x 轴标签挤在一起水平堆积图图例放顶部或右侧别放左侧会和 y 轴标签冲突ax.legend(locupper right, bbox_to_anchor(1.15, 1), frameonFalse)bbox_to_anchor可以把图例挪到绘图区域外面避免遮挡数据。frameonFalse去掉图例边框视觉上更干净。如果图例项太多可以用ncol2排成两列节省纵向空间。实操心得图例的标签顺序如果和堆积顺序不一致读者会看得很困惑。我一般会在代码里把labels列表和data的行顺序绑定在一起维护改数据的时候顺手改标签避免两者脱节。5. 进阶技巧与性能优化5.1 百分比堆积图让占比一目了然当各分类的总量差异很大时绝对值的堆积图会让小分类几乎看不见。这时候应该转成百分比堆积图每一根柱子总高度都是 100%各层显示占比。# 归一化每列除以该列总和 data_pct data / data.sum(axis0) * 100 cumsum_pct np.cumsum(data_pct, axis0) fig, ax plt.subplots(figsize(9, 6)) for i in range(len(data_pct)): bottom cumsum_pct[i - 1] if i 0 else np.zeros_like(data_pct[0]) ax.bar(x, data_pct[i], width, bottombottom, labellabels[i], colorcolors[i]) ax.set_ylim(0, 100) ax.set_ylabel(占比%)关键就一行data / data.sum(axis0) * 100。axis0表示沿着层的方向求和得到每个类别的总量然后逐元素相除。这样每列加起来正好是 100。百分比堆积图有个细节要注意浮点数累加可能有微小误差导致最顶层的柱子差那么零点零几个百分点没到 100。视觉上几乎看不出来但如果你的代码里有断言检查记得留个容差。5.2 大数据量下的渲染优化堆积图的数据量一般不会太大但如果类别数上百、层数十几个渲染就会变慢。几个优化方向第一用ax.bar的批量绘制能力。与其循环调用bar不如把同层数据一次性传入。不过堆积图因为每层bottom不同很难完全批量但至少可以避免在循环里做重复的样式计算。第二关闭不必要的抗锯齿。antialiasedFalse在柱子边缘是直线时能省一点渲染时间但视觉上会有轻微锯齿看场景取舍。第三如果只是预览把dpi调低。fig plt.figure(dpi80)比默认的 100 快不少最终导出时再调回 150 或 300。5.3 导出与保存的常见坑保存图片看起来简单但有几个坑我踩过不止一次plt.savefig(stacked_bar.png, dpi300, bbox_inchestight, facecolorwhite)bbox_inchestight会裁掉多余白边但有时候会把图例或标签裁掉一部分需要配合pad_inches微调facecolorwhite确保背景是白色否则某些主题下导出的是透明背景贴到文档里会变黑dpi300适合印刷屏幕展示 150 就够文件大小差好几倍还有一个高频问题保存的图和plt.show()显示的不一样。这通常是因为savefig在show之前调用或者反过来导致画布被清空。正确顺序是先savefig再show或者用面向对象写法在fig对象上操作避免全局状态干扰。提示如果你要保存的是动画比如堆积图随时间变化savefig就不够用了得用matplotlib.animation模块。核心是FuncAnimation配合PillowWriter或FFMpegWriter前者导出 GIF后者导出 MP4。GIF 体积大但兼容性好MP4 体积小但需要额外的编码器支持按发布平台选。6. 常见问题排查速查表堆积图的问题大多集中在显示不对和报错两类。我把实际遇到过的典型情况整理成表方便对照排查现象可能原因解决方向柱子完全重叠只看到一层没传bottom参数每层传入前序累加值中文显示成方框字体未设置或字体缺失设置font.sans-serif为系统已有中文字体负号显示成方块unicode_minus 未关闭设置axes.unicode_minus False图例颜色和柱子对不上绘制顺序与标签顺序不一致检查labels与data行序是否对应数据标签重叠层高太小仍强制标注加占比阈值判断小层不标保存的图有白边未用bbox_inchestight保存时加上该参数保存的图背景透明未指定facecolor加facecolorwhite层数多了颜色重复colormap 取色循环换用层数足够的 colormap 或手动指定百分比加起来不是 100浮点累加误差属正常现象必要时做容差处理barh报参数错误把bottom写成了left之外的名字水平图用left垂直图用bottom除了表里这些还有一个隐蔽的坑数据里混入了字符串。比如从 CSV 读进来的时候某列被识别成 object 类型np.cumsum会直接报错或者产生奇怪的结果。画图前用data.dtype确认一下是float64或int64才放心。另一个经验是别在循环里反复调用plt.legend()。图例只需要在最后调用一次循环里调用会导致图例重复叠加显示出一堆重复项。这个错误在新手里挺常见因为每画一层就想着顺手把图例加上。7. 一个完整的实战案例把前面所有知识点串起来做一个完整的例子某项目四个阶段的工时分配按三个团队统计画成水平百分比堆积图带数据标签和优化后的图例。import matplotlib.pyplot as plt import numpy as np plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False stages [需求分析, 方案设计, 编码实现, 测试验收] team_a np.array([40, 60, 120, 50]) team_b np.array([30, 45, 90, 40]) team_c np.array([20, 35, 70, 30]) data np.array([team_a, team_b, team_c]) labels [团队A, 团队B, 团队C] colors [#4C72B0, #DD8452, #55A868] # 转百分比 data_pct data / data.sum(axis0) * 100 cumsum np.cumsum(data_pct, axis0) y np.arange(len(stages)) height 0.5 fig, ax plt.subplots(figsize(10, 6)) for i in range(len(data_pct)): left cumsum[i - 1] if i 0 else np.zeros_like(data_pct[0]) bars ax.barh(y, data_pct[i], height, leftleft, labellabels[i], colorcolors[i]) for j, bar in enumerate(bars): w bar.get_width() if w 8: # 占比超过 8% 才标注 ax.text(bar.get_x() w / 2, bar.get_y() bar.get_height() / 2, f{w:.1f}%, hacenter, vacenter, fontsize9, colorwhite) ax.set_yticks(y) ax.set_yticklabels(stages) ax.set_xlim(0, 100) ax.set_xlabel(工时占比%) ax.set_title(各团队阶段工时占比堆积图) ax.legend(locupper center, bbox_to_anchor(0.5, -0.12), ncol3, frameonFalse) plt.tight_layout() plt.savefig(stacked_pct.png, dpi200, bbox_inchestight, facecolorwhite) plt.show()这个例子里有几个值得注意的决策用水平方向是因为阶段名是四个汉字横排更舒服转百分比是因为三个团队的总工时不同绝对值对比不公平图例放底部横排是因为顶部要留给标题右侧空间又不够标签阈值设 8% 是经验值低于这个比例的文字会挤在一起。跑出来的图四个阶段各占一行每行被三个团队按占比切分颜色分明标签清晰。这种图放在项目汇报里比一张密密麻麻的表格直观得多。8. 我踩过的几个真实坑最后分享几个文档里不会写、但实际做项目时一定会遇到的坑。第一个是数据顺序的隐性依赖。有次我从数据库查出来的数据字段顺序和我想的不一样结果堆积图里团队A的颜色对应到了团队C的数据上图看起来没报错但结论完全反了。后来我养成了习惯拿到数据先print(data.head())确认列序再动手画图。第二个是图例遮挡数据。默认的locbest会找一个最不碍事的位置但堆积图数据铺满整个绘图区best经常选到柱子上面。我的做法是直接把图例挪到绘图区外面用bbox_to_anchor定位虽然多写一个参数但一劳永逸。第三个是导出分辨率的选择。一开始我图省事所有图都导出 300 dpi结果一个报告里十几张图文件几十兆邮件都发不出去。后来按用途区分屏幕展示 150 dpi印刷 300 dpi网页嵌入 96 dpi 就够。这个习惯帮我省了不少传输时间。第四个是颜色在黑白打印下的区分度。有次报告要打印彩色图变成灰度后相邻两层几乎一个色完全分不清。后来我在需要打印的场景里会额外给每层加不同的纹理hatch参数比如斜线、点状、网格这样即使灰度也能区分。hatch//、hatch..、hatchxx都是可选项代价是渲染稍慢但值得。堆积图这个工具说难不难说简单也不简单。真正拉开差距的不是会不会调bar()而是有没有想清楚这张图要回答什么问题然后让每一个参数选择都服务于这个目标。颜色、方向、标签、图例每一个细节都在影响读者能不能在三秒内看懂你想表达的东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑