☰
横向堆积柱状图:用Matplotlib清晰呈现峰值分布数据
2026/10/18 20:33:03 网站建设 项目流程

数据可视化这块,有些需求看着简单,真做起来却容易卡壳。前阵子有同事拿了一堆服务监控数据来找我,说要"把系统一周内每天的访问峰值分布讲清楚"。我第一反应是画折线图,把每天最高峰那个点标出来,结果画完发现根本交代不了问题——峰值出现在哪个时段、不同来源流量各自贡献多少、周末和周一的结构差异,一张折线图全糊在一起。最后换成了横向堆积柱状图(peak distribution),信息一下就立住了。今天就把这个做法的完整思路和实操细节拆开聊一聊,算是给自己留个总结。

不管你是做数据分析、运维监控,还是写报表的开发同学,只要手上数据是"某类别在多个区间里的分布构成",这个方案都值得参考。我会把数据预处理、Matplotlib 实现细节、常见坑和进阶玩法都过一遍,尽量让每个步骤都能照着抄。

1. 为什么偏偏是横向堆积柱状图

1.1 同样一张图,横过来到底好在哪

纵向柱状图大家太熟了,但很少有人去想"横过来"这个操作背后的逻辑。横向堆积柱状图的核心优势,是对长文本标签的友好程度实在太高。比如"华东区–移动端–页面浏览"这种标签,放纵向柱状图的坐标轴底下,必然要旋转或者截断,而横向版本可以让它一行放平,阅读成本直线下降。

另一个容易忽略的点在于人对长度的感知精度。我们读图时,对水平方向长短的直觉判断,其实比对垂直方向高度差更稳定。这一点在比较多个条形的总量时特别明显:横向图里的条形并列排开,"谁长谁短"几乎不用注释一眼就能看出来。纵向版本经常要凑近看刻度才能确认差距。

更重要的是堆积结构的识读顺序。堆积图里每一段数据都叠在上一个段上,纵向堆积需要人视线从上往下、再从底往上反复扫描各段的边界;而横向堆积图天然符合我们在屏幕上从左到右的阅读节奏——最底部的类别在最上方,每一列从左侧起点向右延伸,各段之间的分界只要是同一列里对比,识别起来非常舒服。这个细节在实际评审场景里作用很大。

1.2 peak distribution 这类需求,堆积图是不是最优解

先解释下标题里的 peak distribution。我用在这个场景里,指的就是系统每天访问量的峰值分布:哪个时段并发最高、请求集中在哪几个来源、不同来源占比如何。这个需求有两个维度的信息要同时表达——"峰值在哪"和"构成是什么"。堆积柱状图正好能同时承载这两个维度:每个横向条形是一个时间段或类别,条形的总长度反映该时段总量大小,而条形内部的不同颜色段则表示该总量中各来源的贡献比例。

有些场景其实并不适合堆积图。比如只是想看某个点是不是最高,折线图或者散点图就够了;如果只关心占比,根本不看总量,饼图或环形图更直白。拿我的例子说,如果只需要"每天下午两点是全天最高峰"这一个结论,画个最简单的折线图两分钟就完事了。但一旦要追问"这个高峰里有百分之多少来自App、多少来自Web"、或者"周末早高峰和晚高峰的结构差异"时,堆积图就要比折线图或者饼图强大得多。

所以,前提是"总量 + 构成 + 类别"三个信息都要出现在同一张图里,横向堆积柱状图才有真正优势。它适合的人群也很明确:报表开发、数据分析师、站点可靠性工程师,以及所有需要向上汇报"结构变化"的人。如果汇报对象只看总量趋势,那还是老老实实选普通柱状图。

图形类型核心表达能力适合场景不适合场景
普通柱状图单维度数值对比简单总量排名需要看内部结构
折线图趋势变化时间序列峰值定位需要表达构成占比
饼图/环形图占比关系只看比例不看量总量差异大、类别多
横向堆积柱状图总量+构成+多类别峰值的分布结构与时段对比单点对比,不关心结构

2. 数据预处理:先把峰值分布算明白

2.1 原始日志长什么样

绘图之前,数据基本逃不出两种形态。一类是明细日志,也就是一条一条的原始访问记录,字段通常包括时间戳、来源渠道、请求耗时这些维度。另一类已经是聚合后的结果,形如"某小时某渠道的请求量"。如果你拿到的是一堆原始日志,千万别直接丢进绘图函数,那是给自己挖坑。第一步一定是按维度做聚合。

我当时手里的数据类似这样:一行记录代表一次请求,包含三个待聚合字段——小时(00点到23点)、来源渠道(Web端、App端、API调用)、请求量。要画"峰值分布",就需要先按小时分组,再按来源渠道分组求和。这个聚合看似简单,但容易漏掉空时段——某个小时可能没有任何请求,这一行在聚合后直接就消失了,如果不做填充,画出来的图会少一个格子,对比时产生误导。所以聚合之后我习惯用reindex把缺失的时间轴补齐。

import pandas as pd # 模拟:一张明细日志 raw = pd.DataFrame({ 'hour': [8, 8, 9, 9, 9, 10, 14, 14, 15], 'source': ['Web', 'App', 'Web', 'App', 'API', 'Web', 'App', 'API', 'App'], 'requests': [120, 80, 200, 160, 50, 90, 240, 70, 180] }) # 聚合 + 透视 pivot = raw.pivot_table( index='hour', columns='source', values='requests', aggfunc='sum' ).fillna(0) # 补全缺失小时,0-23点都必须有 pivot = pivot.reindex(range(0, 24), fill_value=0)

这一步做完,得到的pivot就是绘图前最干净的数据结构:行是小时,列是来源渠道,每个单元格是请求量。后面所有绘图逻辑都围绕这个表格展开。这里必须强调一个经常翻车的细节:不要让某个来源列为空列,比如某一天压根没有 API 调用,那这一列全是0,画图时会留出一条本不该出现的空隙,最好在填数阶段就删掉所有列和不为全零的列。

2.2 分箱处理:把连续分布切成段

如果是时长这类连续数值字段,比如每条请求的耗时,就不能简单按小时聚合了,需要先分箱。分箱的意思是把整个数值范围切成长度相等的区间,比如 0~100 毫秒、100~500 毫秒、500~1000 毫秒这样。我用的是pd.cut,它的好处是返回的每个箱还有标签,可以直接作为柱子顺序使用。

下面是实际项目中用过的分箱片段。我习惯把耗时数值先定好边界,再把这些边界交给pd.cut处理。需要注意的是边界定义有一定讲究:如果边界间隔太均等,数据密集的区间会全部扎进同一个箱,分布特征反而被抹掉;如果间隔太随意,又会影响可读性。折中的做法是先跑一遍describe()看分位数,按业务上关心的阈值来切。

import pandas as pd bins = [0, 100, 500, 1000, 5000, 20000] labels = ['0-100ms', '100-500ms', '500-1s', '1s-5s', '5s-20s'] df['bucket'] = pd.cut( df['耗时'], bins=bins, labels=labels, right=False ) bucket_counts = df.groupby(['bucket', '来源'], observed=False)['请求量'].sum().unstack()

分箱的粒度直接决定了后续堆积图的信息密度。切得太细,柱子数量暴增,横向空间不够用;切得太粗,又体现不出"峰值分布"的层次感。我的经验是:优先让业务方定义边界,其次自己看数据分布找拐点,比如"p50 在150毫秒、p99 在两秒左右"就按这个思路去切。代码里那个right=False的意义是让区间左闭右开,避免同一个值被算进两个箱。

2.3 保留总量视角:数值型堆积和百分比型堆积

堆积柱状图有个容易被新手忽略的分叉点:画的是绝对值的堆积,还是每行归一化到 100% 的堆积。这两种图画出来结构一样,表达含义却完全不同。绝对值堆积重在展示总量差异——哪个时段请求量最大,直观看柱子的总长度;百分比堆积重在展示构成比例变化——比如同样是 1000 条请求,早高峰和晚高峰的内部来源比例到底差在哪。

两个思路之间切换非常快。按行归一化只需要一行代码:

# 绝对值堆积 absolute = pivot # 百分比堆积 percent = pivot.div(pivot.sum(axis=1), axis=0) * 100

在实际的 peak distribution 项目里,我经常两张图都出。第一张给管理层看峰值和总量,第二张给技术团队看结构变化。比例视图对数据量级不敏感,如果一个时段只有 10 条请求、另一个时段有 1 万条,百分比一归一化,两者的构成对比就很可疑,看图时需要特别提防。这正是我后来在总结中反复提醒自己的:百分比堆积图永远需要搭配一个总量信息,否则容易被误读成"两个时段情况差不多"。

3. 用 Matplotlib 实现横向堆积柱状图

3.1 核心思路:barh加left参数

Matplotlib 里画横向柱状图的函数是ax.barh。名字里的 h 就是 horizontal。它和纵向的ax.bar在参数上有个对应关系:bar用height控制柱高,barh用height控制柱子厚度。横向柱子的长度用width,而这个width对应的是纵向柱子的height。语法上的对应关系容易让人犯迷糊,我统一按"物理方向"来记:横向柱子的粗细用height,长短用width。

横向堆积的实现关键就是left参数。每一段的起点不再是零点,而是前面所有段的累计值。比如第一段从 0 开始、宽度 300;第二段就从 300 开始、宽度 500;第三段从 800 开始、宽度 200。这样视觉上三个颜色段首尾相接,正好组成一个完整的条形。

3.2 完整代码走一遍

下面这段代码是把前面的pivot表画成横向堆积图。数据行是小时,列是来源,逐列循环画出堆积段。

import matplotlib.pyplot as plt import numpy as np # 假设 pivot 已准备好:index=小时(0-23),columns=来源 fig, ax = plt.subplots(figsize=(10, 7)) categories = pivot.index.tolist() left = np.zeros(len(pivot)) # 每一行的起始位置,一开始全部为0 for source in pivot.columns: ax.barh( y=categories, width=pivot[source], left=left, label=source, height=0.65 ) left = left + pivot[source].values # 累加,作为下一段的起始点 ax.set_xlabel('请求量(次)') ax.set_ylabel('小时') ax.set_title('全天请求量峰值分布(按来源堆积)') ax.legend(loc='lower right') ax.grid(axis='x', linestyle='--', alpha=0.4) plt.tight_layout() plt.show()

这里left的更新方式非常直白,但有几个隐性坑要提醒大家。第一,left = left + pivot[source].values是在原地更新数组,如果在 Jupyter 里反复执行这一段,left会被持续累加,导致图形越堆越长。稳妥做法是用一个独立的局部数组名,或每次画图前重新赋零。第二,循环画的时候pivot.columns的顺序就是图例顺序,后面讲图例时还会展开。第三,ax.barh的y参数传的是类别列表,Matplotlib 默认会把列表的第一项画在最上面,如果希望第一个类别在最顶,反而要把这个顺序反转,这算是个经典反直觉点。

3.3 颜色、图例和顺序的细节

颜色这块,Matplotlib 默认的颜色循环在类别少于十列时勉强可用,类别一多就必须换colormap。我通常用plt.cm.tab10这种感知上比较统一的调色板,或者直接给每个来源指定稳定颜色。注意堆积图里不同来源段的颜色必须是区分度极高的,因为人眼要在相邻堆段间快速切换,颜色近似的话图就废了。

图例顺序有个关键讲究。堆积柱的视觉顺序是从左到右:第一列是最底层,第二列叠加在上面,以此类推。而图例如果是默认的legend(),顺序是按画入顺序来的——正好也是第一列、第二列的顺序。但人的阅读习惯是先看顶部或第一个图例,这会导致脑海里建立的对应关系容易错位。我的做法是手动反转图例的顺序,让第一个图例对应最底层的堆段。具体只要handles, labels = ax.get_legend_handles_labels(),然后反向传入ax.legend(handles[::-1], labels[::-1])。

柱子的粗细由height参数控制。类别的数量少,柱子可以画粗一些,比如 0.7;类别非常多,就要缩到 0.3 以下避免重叠。但要注意,如果类别标签比较长,太粗的柱子反而会让标签区域拥挤,所以要结合figsize和标签长度一起调。我在 24 小时这种场景下,height用 0.65,标签一字排开,效果还不错。

# 手动控制图例顺序,让最底层段显示在第一位 handles, labels = ax.get_legend_handles_labels() ax.legend(handles[::-1], labels[::-1], loc='lower right', frameon=False)

4. 常见问题与排查技巧实录

4.1 标签一长就被截断:三步解决

横向堆积图最常翻车的场景之一是类别标签太宽,比如"华东区–移动端–页面浏览"这种长字符串。Matplotlib 不会自动把坐标轴区域让给标签,结果就是标签前后被吞掉。我实际项目里处理过这类问题,根本原因其实就是画布空间没有分配好。

第一步,调figsize,把画布加宽;第二步,用plt.tight_layout()让 Matplotlib 自动收紧子图边界;第三步,如果标签依然被截断,直接用fig.subplots_adjust(left=0.3)这类手动命令给左侧坐标轴区域腾出空间。多数长标签问题到这基本就解决了。

另外还有个细节:y 轴标签如果实在长,可以考虑在传入数据时就把它们拆成两行,比如用"华东区\n移动端"代替"华东区-移动端"。这样既保住了纵向空间的紧凑性,也不会触发截断。反过来,如果你发现 y 轴标签太过拥挤,每根柱子上的标签相互碰到,那就用ax.set_yticks(ticks[::2])做疏密控制,只显示一半的刻度,宁缺毋滥。

4.2 堆积顺序和颜色对应不上:我踩过的坑

有一次我处理四类来源的数据,颜色代码写好后怎么都跟图例对不上。看堆积图第一个色块以为是 A,一查数据底层其实是 B,排查了半天才发现问题在pivot.columns的排序上。原始数据在聚合时可能输出的是字母序,也可能是字典序,而我自己心里默认的是业务排序,两者不一致时整张图的解读就全乱了。

这个问题的解法很简单,却容易一直忽略:画图前显式给列排序。不管是按总量从大到小排,还是按业务优先级排,总之要有一个确定性顺序,不能把排序交给哈希表。我现在写图之前都会加一行pivot = pivot[custom_order_list],其中custom_order_list是手工指定的来源列表。这行代码看似多余,实则是防止隐性 Bug 最有效的护身符。

还有一个和顺序相关的坑:某一行数据里某种来源的数值正好是 0,那么这个段画出来是个零宽度的"隐形条",但图例里仍然会包含它。在堆积图里这种零值段会导致相邻颜色块之间的边界出现细微的错位,视觉上像多了一条缝。通常的处理方法是画完图后检查每列的sum(),如果某列全为零就直接从pivot.columns中剔除。

4.3 量级悬殊:一条柱子盖住所有细节

堆积图最怕的是数据量级差太大。比如 API 调用一天有 3 万次,内部接口某类只有 10 次,小数值的那一段几乎看不见,跟不存在似的。这种问题不是画图技巧能补救的,是数据本身该换表达方式了。

我见过一种常见处理套路,把小数值的类别单独画一张图,或者用"断裂"的方式把大柱子截断——但断裂轴的可信度比较低,评审时不推荐。我更推荐的方案是:如果不同来源的量级天然不在一个水平,那么考虑把这几类来源拆分,改成"分组堆积图"或者双面板,把量级大的类别和量级小的类别分开展示。另一个思路是把大类别换成对数轴。注意对数轴在堆积图上会让最底层段的起点变成 NaN 或负值,Matplotlib 会直接报错,所以对数轴这里并不建议使用,除非你能保证所有值都是正数且完整。

实际项目里,我最后选择的是百分占比视图 + 绝对总量标注。百分比的堆积消除了量级差异的基本盘,总量信息用每个时段标签后面的(总计: xxx)标注出来。这样既照顾了小类别的可见性,又不丢掉总量概念。

5. 进阶:让堆积柱状图会说话

5.1 在柱子里加数值标签

数据可视化的终极目标不是"画出来",而是"让人看懂"。堆积柱状图如果不加标签,读者还得拿尺子对着刻度量距离,这种体验太糟糕。我习惯在每一堆段的中间位置加一个数值标签,标明该段的具体值。

加标签时的位置计算公式很简单:当前段的起点加上它自身宽度的一半,就是这个段的中心横坐标。纵坐标用该行类别索引的数值下标。如果某一段数值太小,标签放里面会挤得不可读,我会加个条件,仅对超过某个阈值的段标注,其他段只在图例里体现。

threshold = 100 # 只标大于100的段 for row_idx, (_, row) in enumerate(pivot.iterrows()): cumulative = 0 for source in pivot.columns: value = row[source] if value > threshold: ax.text( cumulative + value / 2, row_idx, f"{int(value)}", ha='center', va='center', fontsize=9, color='white' ) cumulative += value

这个写法里,row_idx是从数据行顺序来的整数,正好对应ax.barh里 y 轴的类别位置。需要特别提醒的是颜色问题:标签文字颜色必须和所在堆段的背景色形成对比。浅色堆段上用深色字,深色堆段上用白色字。如果你用的调色板里浅色较多,写一个简单的亮度判断来自动决定文字颜色,效果会稳定很多。

5.2 保存出图:清晰度和尺寸别忽略

博文或汇报里用的图,最终都要导出成图片。很多人直接用plt.savefig('xxx.png'),存完才发现精度不够。出图设置里最容易被忽略的两个参数是dpi和bbox_inches。前者控制清晰度,后者保证边缘不被裁掉。

平面展示用 150 dpi 足够,打印或高清投屏至少 300 dpi。bbox_inches='tight'会智能调整留白,把图例和标签都包含进画布,省得手动改subplots_adjust。如果要在深色背景的 PPT 里使用,还可以加transparent=True导出透明底。

fig.savefig('peak_distribution.png', dpi=300, bbox_inches='tight')

5.3 从静态图到交互式:一个快速的换装方案

有时候静态图不够用,尤其需要看某个堆积段具体数值时,管理者会希望鼠标悬停就有提示。如果图本身逻辑没有变化,只是需要交互,没必要重新发明轮子。我一般直接用 Plotly Express,把pivot表转成一列一列的堆积条形,几行代码就能完成移植。

大概逻辑是把pivot用melt转成长表,然后用px.bar指定orientation='h',再把color设置为来源字段,barmode设为'stack'。这套模板基本是通用的。

import plotly.express as px long_df = pivot.reset_index().melt( id_vars='hour', var_name='source', value_name='requests' ) fig = px.bar( long_df, x='requests', y='hour', color='source', orientation='h', barmode='stack', title='全天请求量峰值分布' ) fig.show()

交互式的优势不只是悬停提示,还能在图例上点选隐藏某个来源,方便单独对比某几类流量的分布。当然,静态图在正式报告里的可排版性仍然更强。我的做法通常是分析阶段用交互式来回探索,最终汇报时导出静态图。

尾声:这个方案背后的真实工作流

把横向堆积柱状图(peak distribution)用到顺手,最关键的其实不是记住某个函数参数,而是建立一种"先想清楚要表达什么、再决定怎么画"的工作习惯。我每次拿到这种分布构成类需求,流程都固定成四步:先明确三个信息维度(总量、构成、类别)缺不缺,再用pivot_table或pd.cut把数据整理成规整的行列表,然后根据读者习惯决定画绝对值还是百分比,最后才是调颜色、标签这类视觉细节。

有一次我在一个数据分析项目中踩了跟别人一样的坑:图例顺序没显式排序,导致汇报时把两个来源的数据说反了。虽然当时及时纠正了,但这个教训让我此后每次画堆积图都会加一行强制列序。细节决定专业度,这种话一点不虚。希望这篇小结里提到的那些踩坑记录和代码片段,能让你少消耗几个本该用来喝水的晚上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询