ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

matplotlib箱线图自定义填充颜色:从原理到实战

matplotlib箱线图自定义填充颜色:从原理到实战

做数据分析这一行,箱线图是逃不掉的基础图。我最早用它的时候,matplotlib 默认画出来的箱体就是个白色带黑边的矩形,放在报告里总觉得单调,尤其是要对比三五个业务分组时,光靠位置和形状去区分,眼睛看久了容易花。后来开始研究怎么给箱线图自定义填充颜色,才发现这里面的门道比我预想的多:默认情况下 matplotlib 根本没打算让你直接填色,需要先打开一个叫patch_artist的开关,再配合boxprops参数才能控制箱体的facecolor。如果你已经在用 Python 做数据分析和可视化,想画一张既专业又有辨识度的彩色箱线图,这篇文章就是围绕这个目标展开的。我会从箱线图的基本原理讲起,再用可复现的代码一步步演示如何实现自定义填充颜色、按分组上色、结合 seaborn 快速配色,最后把我在实际项目中踩过的坑和排查方法一并整理出来。

1. 箱线图到底在展示什么

想给箱线图换颜色,先得搞清楚你画的这个图是什么。箱线图不是简单的“一个盒子加两根须”,它把一组连续型数值数据的分布情况浓缩成了几个关键统计量。理解这些统计量,你才能在上色之后依然读出图表里的信息。

1.1 五个统计量构成

一份标准的箱线图,会呈现五个关键位置,通常称为五数概括。箱体上边缘和下边缘分别是上四分位数(Q3)和下四分位数(Q1),箱体中间那条线是中位数(Q2),从箱体延伸出去的两条须线则指向非异常范围内的最大值和最小值,须线之外单独画的点就是被识别为离群值的数据点。

我用一个具体数字举例。假设某门店一周的日销售额是 80、85、88、90、92、95、200 这 7 个数据,排序后中位数是 90,Q1 是 85,Q3 是 95,IQR 等于 10。按照常见的 1.5 倍 IQR 规则,下边界是 85 - 15 = 70,上边界是 95 + 15 = 110,那么 200 就会被标成异常点,须线最右端只画到 95。箱体的高度,也就是 Q1 到 Q3 这段区间,集中了中间 50% 的数据,这比单纯报一个平均值更能反映数据是否稳定。

实际工作中,我经常用箱线图做三件事:第一,快速判断一组数据是左偏还是右偏;第二,比较不同分组之间的中位数和离散程度;第三,找出异常值并跟进原因。颜色本身不改变这些统计结果,但合理的配色能让图表更快被读懂,尤其是当你要在汇报时同时展示七八个分组时。

1.2 为什么需要给箱线上色

很多人一开始只画单组箱线图,用默认颜色没问题。但一旦涉及多组对比,比如不同地区、不同产品线、不同月份,光靠黑白线条就很难一眼看出哪个组的波动更大。给箱体填充不同的颜色,本质上是把“分组”这个额外维度用视觉通道表达出来,观众不用花时间对比每根须线的位置,直接看颜色就能锁定关注对象。

还有个容易被忽视的使用场景:品牌一致性。给外部客户做交付报告时,如果整份报告的主色是某一种品牌蓝,那么图表却还是 matplotlib 默认的白底黑边,视觉上会非常割裂。自定义填充颜色后,箱体颜色能和报告主视觉保持一致,整体专业感立刻提升。

另外,颜色还能用于突出“异常组”。我在一次质量分析中,把合格率偏低的产线标成红色,其余产线用灰色,汇报时所有人的注意力第一时间就集中到了红色箱体上。当然,使用颜色也要克制,如果每组的颜色都特别鲜艳,图就会变得刺眼,信息层次反而被削弱。后面我会专门讲配色经验,这里先明确一个结论:上色不是随便挑几个好看的颜色,而是为了让数据对比更清晰、报告更统一、重点更突出。

2. 环境准备与数据样例

开始画图之前,先把运行环境准备好。这里我用的是最常见的 Python 数据科学组合:matplotlib 负责绘图,numpy 负责生成模拟数据,seaborn 作为可选的美化工具。如果你用的是 Anaconda 或者 VS Code 配好了 Python 环境,下面这几行命令基本不会遇到问题。

2.1 环境准备

推荐使用 Python 3.8 以上版本,matplotlib 用 3.5 以上版本,因为低版本在boxplot的参数细节上有些小差异,比如某些返回对象结构不一样。安装很简单:

pip install matplotlib numpy seaborn

如果网络环境比较慢,也可以指定国内镜像源安装。装好之后,在代码开头统一导入:

import matplotlib.pyplot as plt import numpy as np import seaborn as sns

seaborn 这个库其实不是必须的,它是在 matplotlib 之上做了一层封装,让我能用更少的代码画出样式更精致的图。但你如果只为了实现“自定义填充颜色”,只用 matplotlib 也可以,后面我会两种方式都演示。

2.2 构造演示数据

直接拿真实数据不方便复现,所以我用 numpy 生成三组模拟数据,模拟三个门店的日销售额情况。为了每次运行结果一致,我设置了随机种子。

rng = np.random.default_rng(42) store_a = rng.normal(loc=90, scale=8, size=120) store_b = rng.normal(loc=85, scale=15, size=120) store_c = rng.normal(loc=95, scale=5, size=120) # 手动加入几个离群点,模拟真实业务中的突发情况 store_b = np.append(store_b, [150, 160]) store_c = np.append(store_c, [60]) data = [store_a, store_b, store_c] labels = ["门店A", "门店B", "门店C"]

这三组数据的设计思路是:门店A 平均水平中等、波动适中;门店B 平均水平略低但波动很大,还带两个高值异常点;门店C 平均水平最高、最稳定,但我也加了一个偏低异常值。这样画出来之后,每组箱体之间能明显看出差异,颜色对比的效果也更直观。

3. 用 matplotlib 实现自定义填充颜色

matplotlib 的boxplot函数是绘制箱线图的核心入口。这一节我会从最朴素的画法开始,一步步把颜色加进去,同时解释每个参数背后的原理。

3.1 最基础的箱线图

先画一个完全默认的箱线图,看看 matplotlib 默认给了我们什么。

plt.figure(figsize=(8, 6)) bp = plt.boxplot(data, tick_labels=labels, patch_artist=False) plt.title("默认样式的箱线图") plt.ylabel("销售额") plt.grid(axis="y", linestyle="--", alpha=0.6) plt.show()

运行这段代码,你会看到三个白底黑边的箱体。很多人第一次看到patch_artist=False会很疑惑:为什么默认是 False?这就涉及 matplotlib 的内部绘图机制了。在默认情况下,boxplot里的箱体是用线条对象(Line2D)画出来的,它只有边框颜色,没有真正意义上的填充区域,所以无论你怎么设置facecolor都不会生效。只有把patch_artist改成True,箱体才会被创建成可填充的Patch对象,这时候你才有资格去设置填充颜色。

我最初就是没搞懂这一点,直接传了boxprops={'facecolor': 'red'},结果运行完什么变化都没有,还以为是自己参数写错了。后来翻源码才发现,patch_artist这个开关是前提条件。你记住一句话就行:不给patch_artist=True,一切facecolor都是白给。

3.2 用 patch_artist 打开填充开关

把patch_artist打开之后,箱体从线条对象变成了可填充图形对象,boxprops里的facecolor就能真正控制箱体内部的颜色了。最简单的写法如下:

plt.figure(figsize=(8, 6)) bp = plt.boxplot( data, tick_labels=labels, patch_artist=True, # 关键开关 boxprops=dict( facecolor="#4C72B0", # 箱体填充颜色 edgecolor="black", # 边框颜色 linewidth=1.5 ) ) plt.grid(axis="y", linestyle="--", alpha=0.6) plt.show()

这次三个箱体都变成了蓝色,边框是黑色。这里颜色我用了十六进制字符串#4C72B0,它是 seaborn 常用配色中的一种蓝色,偏沉稳,适合商业场景。你也可以用内置颜色名称,比如"red"、"steelblue"、"skyblue",或者 RGB 三元组(0.3, 0.45, 0.69)。

有一点要提醒:如果你在同一个boxprops里既设置了facecolor,又设置了color,color会覆盖边框、须线等多个元素的颜色,有时候会出现边框也跟着变蓝的情况。所以更稳妥的做法是分开控制:facecolor管填充,edgecolor管边框。

3.3 boxprops 参数详解

想要画出好看且信息完整的箱线图,只调facecolor是不够的。boxprops作为一个字典,可以接收大量线条和填充相关属性,我按实际使用频率整理了一张表格:

参数作用常用取值示例
facecolor箱体内部填充色"#4C72B0",(0.2, 0.4, 0.6)
edgecolor箱体边框颜色"black","#333333"
linewidth边框线粗细1.2,2
linestyle边框线型"-","--"
alpha整体透明度,0 到 10.6,0.85
hatch填充纹理,打印时防颜色丢失"/","o","xx"
zorder图层层级,控制谁盖住谁2,3

hatch这个参数很多人不知道,但我强烈建议你做打印材料时考虑它。因为有些打印机默认是黑白模式,或者对方会把报告打印出来看,这时候仅靠颜色区分分组就没用了。加上hatch="/"后,箱体内部会有斜线纹理,黑白打印也能清晰看出是不同分组。

透明度alpha也是一个需要谨慎设置的参数。理论上透明色能减轻视觉压迫感,但如果alpha太低,比如 0.2,箱体颜色会变得很浅,叠加在格子背景上可能看不太清。我自己的习惯是商业正式图表降到 0.7 左右,既保留颜色质感,又不至于太刺眼。

4. 按分组给不同箱体上色

上面演示的是所有箱体统一用一种颜色。但实际分析中,更多情况是每个分组需要各自的颜色,这样可以在同一张图里清晰地对照多个门店、多个时段。这一节我会介绍两种方式:手动指定颜色列表,以及用 colormap 自动映射。

4.1 手动指定多组颜色

当分组数量不多时(3 到 6 组),手动指定颜色是最直接可控的。核心思路是先把boxplot返回的结果保存下来,然后遍历每个箱体,逐个设置set_facecolor。boxplot的返回值是一个字典,里面的boxes键对应所有箱体对象的列表。

colors = ["#4C72B0", "#DD8452", "#55A868"] plt.figure(figsize=(8, 6)) bp = plt.boxplot( data, tick_labels=labels, patch_artist=True, medianprops=dict(color="black", linewidth=2) ) for patch, color in zip(bp["boxes"], colors): patch.set_facecolor(color) patch.set_edgecolor("#333333") patch.set_linewidth(1.2) plt.title("每个分组使用不同填充颜色") plt.ylabel("销售额") plt.grid(axis="y", linestyle="--", alpha=0.6) plt.show()

这里我用zip把箱体对象和颜色列表一一对应,顺序不会错。为什么不用索引for i in range(len(bp['boxes']))?因为一旦你后面增删了分组数据,索引写法很容易越界或者错位,而zip会安全地按最短列表停止,至少不会报错,这个习惯我在很多循环场景里都保持下来了。

颜色列表的选择上,要注意组与组之间的亮度差。比如三组分别用深蓝、橙、绿,对比度足够;但如果你选了浅蓝、浅灰、浅粉,它们之间的界限会比较模糊,图例一多就分不清了。

4.2 使用 colormap 自动映射

如果分组数量很多,比如 8 个、10 个甚至更多,手动写颜色列表就很痛苦,也容易让颜色之间缺乏递进感。这时候可以用 matplotlib 自带的 colormap 来生成一组颜色。colormap 是一系列连续变化的颜色,常见的有viridis、plasma、coolwarm等。

import matplotlib.colors as mcolors n_groups = len(data) cmap = plt.get_cmap("viridis") norm = mcolors.Normalize(vmin=0, vmax=n_groups - 1) colors = [cmap(norm(i)) for i in range(n_groups)] plt.figure(figsize=(8, 6)) bp = plt.boxplot( data, tick_labels=labels, patch_artist=True, medianprops=dict(color="white", linewidth=2) ) for patch, color in zip(bp["boxes"], colors): patch.set_facecolor(color) patch.set_edgecolor("#333333") plt.title("使用 viridis 自动映射颜色") plt.ylabel("销售额") plt.grid(axis="y", linestyle="--", alpha=0.6) plt.show()

这段代码的思路是:先用Normalize把组序号 0 到 2 映射到 0 到 1 的范围,再用cmap(norm(i))取出对应颜色。这样就算你有 20 组数据,也不需要手写 20 个颜色值。

用 colormap 时有个细节:viridis这类感知均匀的色图,颜色从深紫到明黄过渡,虽然好看,但它偏向表达“从小到大”的数值等级。如果你的分组本身没有大小顺序,只是并列关系,用连续的 colormap 反而可能让读者误以为组 A 比组 C 数值更大。遇到这种并列分组,我建议还是回到 4.1 的离散颜色方案,或者选择色相跨度大一些的定性色图,比如tab10。

4.3 同时自定义中位数线、异常点颜色

箱线图的信息不只是箱体,中位数线、异常点、须线都在传递信息。给箱体上色以后,千万注意线条颜色和箱体填充色之间的关系。如果中位数线和箱体颜色同为深色,中位数可能就看不清了,整个箱体的高度信息会丢失。

我常用的做法是把中位数线设置成与箱体对比明显的颜色。颜色较浅的箱体配黑色中位数线,颜色较深的箱体配白色中位数线。同时,异常点一般用醒目的颜色标出,因为异常值往往是业务上需要重点关注的数据。

下面这段代码是一次性调整所有元素的示例:

plt.figure(figsize=(9, 6)) bp = plt.boxplot( data, tick_labels=labels, patch_artist=True, boxprops=dict(facecolor="#4C72B0", edgecolor="#2A3F5F", linewidth=1.2), medianprops=dict(color="white", linewidth=2.5), whiskerprops=dict(color="#2A3F5F", linewidth=1.5, linestyle="-"), capprops=dict(color="#2A3F5F", linewidth=1.5), flierprops=dict(marker="o", markerfacecolor="#C44E52", markeredgecolor="#C44E52", markersize=6) ) plt.grid(axis="y", linestyle="--", alpha=0.6) plt.show()

各参数的作用如下:

  • medianprops:中位数线的样式,我建议加大线宽,让中位数位置更突出。
  • whiskerprops:须线的样式,通常和箱体边框保持一致颜色。
  • capprops:须线末端的横线,用来标记非异常范围内的最小值与最大值。
  • flierprops:异常点的样式,包括点形状、填充色、边框色、尺寸。

这一套设置下来,图表的层次就非常清楚了:白色中位数线在蓝色箱体上很明显,红色异常点不会被误认为正常数据。颜色之间的对比度不仅仅是审美问题,更是信息传递问题。

5. 用 seaborn 一行实现带填充色的箱线图

如果你不想花太多时间调 matplotlib 的参数,seaborn 是一个很好的替代方案。它上层封装了boxplot,对输入数据的格式更友好,尤其是当你手上已经是一个 DataFrame 时,一行代码就能画出带填充颜色的箱线图。

5.1 palette 参数介绍

假设数据已经整理成了长格式的 DataFrame,包含一个分组列和一个数值列。下面构造一份:

import pandas as pd df = pd.DataFrame({ "门店": np.repeat(labels, 120), "销售额": np.concatenate([store_a[:120], store_b[:120], store_c[:120]]) }) sns.boxplot(data=df, x="门店", y="销售额", palette="Set2") plt.title("seaborn 默认 palette 配色箱线图") plt.show()

palette参数可以直接传字符串,比如"Set2"、"husl"、"pastel";也可以传一个颜色列表,顺序对应分组顺序;最灵活的是传一个字典,键是分组名称,值是颜色。

color_map = { "门店A": "#4C72B0", "门店B": "#DD8452", "门店C": "#55A868" } sns.boxplot(data=df, x="门店", y="销售额", palette=color_map) plt.title("seaborn 使用颜色字典精确控制每个分组") plt.show()

用字典最大的好处是:即使 DataFrame 里分组的顺序发生变化,颜色和分组之间的绑定关系也不会错乱。这在数据源字段顺序不稳定时非常有用。

seaborn 默认的箱体边框是白色,这在深色背景图或者白色背景上都很干净,但如果你觉得白色边框太轻,可以在boxplot中加上linewidth=2让边框更明显。

5.2 与 matplotlib 混排时的回填

seaborn 虽然方便,但它返回的是一个 matplotlib 的Axes对象,所以你在它之后依然可以用 plt 系列函数去微调标题、坐标轴、网格线。也可以利用返回的Axes拿到花了几分钟才调好的图形细节。

有一个常见需求是:先用 seaborn 画箱线图,再往同一张图上叠加其他统计信息,比如把每个组的均值点画上去。代码可以这样写:

ax = sns.boxplot(data=df, x="门店", y="销售额", palette="Set2") means = df.groupby("门店")["销售额"].mean() for idx, (store, mean_val) in enumerate(means.items()): ax.scatter(idx, mean_val, marker="D", s=60, color="black", zorder=3) plt.title("箱线图上叠加均值点") plt.show()

这里的核心思路是:seaborn 和 matplotlib 本来就共用底层对象,不需要在两种绘图风格之间做切换,你可以把ax.scatter看成是对同一个坐标画布追加元素。我个人偏爱这种“先用 seaborn 快速出图,再用 matplotlib 精细微调”的组合方式,效率高,也保留了足够的灵活性。

6. 常见问题与排查实录

这部分是实打实的经验复盘。我见过身边不少同事在给箱线图上色时踩坑,有些问题排查起来很费时间,但其实原因就那么几个。我按常见程度从高到低列出来,并附上排查思路。

6.1 设置了 facecolor 却不变色

这是最经典的问题,我在前面已经提到过。现象是:boxprops={'facecolor': 'red'}写进去了,图还是白底的。原因几乎都是没有设置patch_artist=True。

排查步骤很简单:第一,确认boxplot调用里有没有patch_artist=True;第二,确认颜色是写在boxprops里,而不是直接写在boxplot的最外层;第三,检查颜色值本身是否合法,比如十六进制字符串有没有写错,RGB 三元组是否在 0 到 1 范围内。

如果你用bp["boxes"][0].get_facecolor()拿到的是白色或透明色的 RGBA 值,那说明问题还是出在patch_artist上。这个函数是我调试时很常用的“体检工具”。

6.2 填充色盖住了中位数线

设置成深色填充后,中位数线也设置成深色,比如黑色,结果中位数线和箱体边缘融为一体,看不出中位数在哪。严格说不是盖住,而是对比度不够。

解决方式有两种:一是把中位数线改成白色或浅色,线宽加大到 2.5,这适合深色箱体;二是降低箱体填充色的透明度,比如facecolor=(0.2, 0.4, 0.6, 0.5),让深色背景上的深色线条透出来。前一种方式更干净,我推荐优先调整medianprops。

还有一个容易被忽略的地方:当你用patch.set_facecolor(color)循环上色时,medianprops必须在boxplot初始化时就传好,因为中位数线的对象已经生成,之后你依然可以通过bp["medians"]来改,但直接在初始化时设置最省事。

6.3 颜色列表与分组数量不匹配

手动传颜色列表时,最常见的问题是颜色数量少于分组数量。用zip(bp['boxes'], colors)时不会报错,但最后几组箱体会保持默认颜色或上次循环留下的颜色,很容易看不出问题。

如果颜色数量多于分组数量,多出来的颜色静静躺在列表里没人用,同样不报错。这两种情况都属于“灾难性失败的很安静”的类型,让人防不胜防。

我的排查习惯是:在对每个箱体上色后,马上用print(len(bp['boxes']), len(colors))确认两边数量一致。另外,尽量不要在图例里手写颜色,而是从修改后的图形对象中提取真实的颜色来生成图例句柄,这样图例和实际图形永远对得上。

6.4 保存图片后颜色发灰

有时候屏幕显示颜色很鲜艳,保存成 PNG 后却发灰变暗,问题通常出在配色方案上。某些颜色在纯白背景下显示正常,但系统会自动进行色彩管理,加上字体渲染宽度不同,整体观感会变化。

这类问题我一般从两个角度解决。第一,保存时提高分辨率,用dpi=200或 300,能让颜色边缘更平滑,整体画面更细腻。第二,如果想保持透明背景,用plt.savefig("boxplot.png", transparent=True, dpi=300),但要注意透明背景经过某些看图软件展示时会反白,慎用于正式交付场景。保存矢量图用plt.savefig("boxplot.svg"),颜色精度极高,放到 PPT 或设计软件里也很稳。

还有一个容易被忽略的问题是屏幕色域差异。同一份红色在你的显示器上看着发暗,在别人笔记本上可能偏亮。所以正式报告里,我通常会在颜色旁边加文本标签或图例文字,保证即使色彩有轻微偏差,分组信息也不会丢。

7. 几个实际项目里的配色经验

技术细节聊完了,最后分享一些我真实项目里积累的配色原则。这些经验不一定出现在官方文档里,但对最终成图质量影响很大。

先看“品牌色优先”。如果这份图要进公司汇报 PPT,我建议直接从公司 VI 规范里取主色和辅助色,而不是临时去调色板里选。这样整份报告的颜色系统是统一的,领导看完不会觉得图表是“外包风格”。具体操作时,我会把品牌色整理成颜色列表,专门放在一个配置模块里,所有项目统一调用。

再说“颜色数量控制”。一张箱线图里,颜色数量我尽量控制在 3 到 6 个。超过 7 个时,即使颜色有差异,人眼也很难快速区分,不如改用其他视觉通道,比如箱体填充纹理、边框线型。分组特别多时,我更倾向于拆成小图,或者先用序号排序,再用颜色强调其中一组。

“颜色要有语义”。如果你在画的是业务监控数据,尽量让颜色本身包含含义:红色代表异常,绿色代表正常,蓝色代表中性。这样读者不需要看图例,单凭生活常识就能快速判断。比如我在分析门店销售额时,把异常波动的门店用红色箱体标出,其余用灰色,信息传达效率非常高。

还要记得“打印与色盲友好”。前面提到的hatch参数,我在做纸质汇报材料时一定会加,因为黑白打印无法显示色彩区别。对于色盲人群,红绿搭配是最容易被混淆的,所以我会改用蓝橙搭配,或者同时用纹理区分。一次给客户做交付,对方明确指出小组内部有红绿色盲同事,从那以后我的默认配色方案就从红绿改成了蓝橙。

最后一个建议:给箱线图加图例时,不要直接拿颜色名称当分组名称。我通常会把图例和颜色关系用一个小表格放在图表下方,或者直接在箱体上方标注文字。颜色作为视觉辅助非常好用,但只有配合清晰的文字说明,图表才能真正自解释。

我个人在实际操作中的体会是:自定义填充颜色的价值,不在于炫技,而在于让数据图表更快地说明问题。当你面对一张多组对比的箱线图,颜色能让观众在 10 秒内锁定哪组波动最大、哪组中位数最高,这就是这项技能最大的回报。下次你再画箱线图,不妨先把默认的白色箱体换成有语义的颜色,再看一眼图表是否立刻变得更容易读了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进