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, bottom=channel_a, label='渠道B', color='#DD8452') # 第三层:从 channel_a + channel_b 的高度开始 ax.bar(x, channel_c, width, bottom=channel_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, axis=0) 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, bottom=bottom, label=labels[i], color=colors[i])这里np.cumsum(data, axis=0)会沿着"层"的方向累加,得到每一层顶部的绝对高度。第 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, axis=0) 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, left=left, label=labels[i], color=colors[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 内置的 colormap:
import 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, bottom=bottom, label=labels[i], color=colors[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)}', ha='center', va='center', fontsize=9, color='white')这里bar.get_y()拿到的是这一层的底部位置,加上height / 2就是层中央。文字颜色用白色是因为深色填充上白字更清晰,但如果你的配色偏浅,就得改成深色字。这个判断逻辑可以再细化,比如根据颜色的亮度自动选黑字还是白字,不过实际项目里手动指定往往更省事。
4.3 图例位置与顺序的讲究
图例的顺序默认和绘制顺序一致,也就是从底层到顶层。这个顺序符合直觉,一般不用改。但图例的位置需要根据图形方向调整:
- 垂直堆积图:图例放右侧或顶部,别放底部(会和 x 轴标签挤在一起)
- 水平堆积图:图例放顶部或右侧,别放左侧(会和 y 轴标签冲突)
ax.legend(loc='upper right', bbox_to_anchor=(1.15, 1), frameon=False)bbox_to_anchor可以把图例挪到绘图区域外面,避免遮挡数据。frameon=False去掉图例边框,视觉上更干净。如果图例项太多,可以用ncol=2排成两列,节省纵向空间。
实操心得:图例的标签顺序如果和堆积顺序不一致,读者会看得很困惑。我一般会在代码里把
labels列表和data的行顺序绑定在一起维护,改数据的时候顺手改标签,避免两者脱节。
5. 进阶技巧与性能优化
5.1 百分比堆积图:让占比一目了然
当各分类的总量差异很大时,绝对值的堆积图会让小分类几乎看不见。这时候应该转成百分比堆积图,每一根柱子总高度都是 100%,各层显示占比。
# 归一化:每列除以该列总和 data_pct = data / data.sum(axis=0) * 100 cumsum_pct = np.cumsum(data_pct, axis=0) 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, bottom=bottom, label=labels[i], color=colors[i]) ax.set_ylim(0, 100) ax.set_ylabel('占比(%)')关键就一行data / data.sum(axis=0) * 100。axis=0表示沿着"层"的方向求和,得到每个类别的总量,然后逐元素相除。这样每列加起来正好是 100。
百分比堆积图有个细节要注意:浮点数累加可能有微小误差,导致最顶层的柱子差那么零点零几个百分点没到 100。视觉上几乎看不出来,但如果你的代码里有断言检查,记得留个容差。
5.2 大数据量下的渲染优化
堆积图的数据量一般不会太大,但如果类别数上百、层数十几个,渲染就会变慢。几个优化方向:
第一,用ax.bar的批量绘制能力。与其循环调用bar,不如把同层数据一次性传入。不过堆积图因为每层bottom不同,很难完全批量,但至少可以避免在循环里做重复的样式计算。
第二,关闭不必要的抗锯齿。antialiased=False在柱子边缘是直线时能省一点渲染时间,但视觉上会有轻微锯齿,看场景取舍。
第三,如果只是预览,把dpi调低。fig = plt.figure(dpi=80)比默认的 100 快不少,最终导出时再调回 150 或 300。
5.3 导出与保存的常见坑
保存图片看起来简单,但有几个坑我踩过不止一次:
plt.savefig('stacked_bar.png', dpi=300, bbox_inches='tight', facecolor='white')bbox_inches='tight'会裁掉多余白边,但有时候会把图例或标签裁掉一部分,需要配合pad_inches微调facecolor='white'确保背景是白色,否则某些主题下导出的是透明背景,贴到文档里会变黑dpi=300适合印刷,屏幕展示 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_inches='tight' | 保存时加上该参数 |
| 保存的图背景透明 | 未指定facecolor | 加facecolor='white' |
| 层数多了颜色重复 | 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(axis=0) * 100 cumsum = np.cumsum(data_pct, axis=0) 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, left=left, label=labels[i], color=colors[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}%', ha='center', va='center', fontsize=9, color='white') ax.set_yticks(y) ax.set_yticklabels(stages) ax.set_xlim(0, 100) ax.set_xlabel('工时占比(%)') ax.set_title('各团队阶段工时占比堆积图') ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12), ncol=3, frameon=False) plt.tight_layout() plt.savefig('stacked_pct.png', dpi=200, bbox_inches='tight', facecolor='white') plt.show()这个例子里有几个值得注意的决策:用水平方向是因为阶段名是四个汉字,横排更舒服;转百分比是因为三个团队的总工时不同,绝对值对比不公平;图例放底部横排是因为顶部要留给标题,右侧空间又不够;标签阈值设 8% 是经验值,低于这个比例的文字会挤在一起。
跑出来的图,四个阶段各占一行,每行被三个团队按占比切分,颜色分明,标签清晰。这种图放在项目汇报里,比一张密密麻麻的表格直观得多。
8. 我踩过的几个真实坑
最后分享几个文档里不会写、但实际做项目时一定会遇到的坑。
第一个是数据顺序的隐性依赖。有次我从数据库查出来的数据,字段顺序和我想的不一样,结果堆积图里"团队A"的颜色对应到了"团队C"的数据上,图看起来没报错,但结论完全反了。后来我养成了习惯:拿到数据先print(data.head())确认列序,再动手画图。
第二个是图例遮挡数据。默认的loc='best'会找一个"最不碍事"的位置,但堆积图数据铺满整个绘图区,best经常选到柱子上面。我的做法是直接把图例挪到绘图区外面,用bbox_to_anchor定位,虽然多写一个参数,但一劳永逸。
第三个是导出分辨率的选择。一开始我图省事,所有图都导出 300 dpi,结果一个报告里十几张图,文件几十兆,邮件都发不出去。后来按用途区分:屏幕展示 150 dpi,印刷 300 dpi,网页嵌入 96 dpi 就够。这个习惯帮我省了不少传输时间。
第四个是颜色在黑白打印下的区分度。有次报告要打印,彩色图变成灰度后,相邻两层几乎一个色,完全分不清。后来我在需要打印的场景里,会额外给每层加不同的纹理(hatch参数),比如斜线、点状、网格,这样即使灰度也能区分。hatch='//'、hatch='..'、hatch='xx'都是可选项,代价是渲染稍慢,但值得。
堆积图这个工具,说难不难,说简单也不简单。真正拉开差距的不是会不会调bar(),而是有没有想清楚"这张图要回答什么问题",然后让每一个参数选择都服务于这个目标。颜色、方向、标签、图例,每一个细节都在影响读者能不能在三秒内看懂你想表达的东西。