目录13

文章Matplotlib第 10 章

箱线图

Matplotlib · 第 10 / 43 章

箱线图 boxplot()

箱线图把一组数据压成五个数——下须、Q1、中位数、Q3、上须——再把落在须线外的点单独标出来。它不画分布形状,只画「中间 50% 有多宽、往哪边偏、尾巴有多长」,所以并排比较十几组数据是它最好的场景。

Python28 行
Axes.boxplot(
    x,                      # 一组数据,或「数组的列表」:一组一份
    notch=False,            # 中位数处开缺口,缺口 = 中位数 95% CI
    sym=None,               # 离群点符号的简写,如 "rs";等价 flierprops
    whis=1.5,               # 须线延伸到哪:1.5×IQR,或 (5, 95) 百分位
    positions=None,         # 每组在轴上的位置,默认 1, 2, 3...
    widths=0.5,             # 箱宽
    capwidths=None,         # 端帽宽,默认 = widths / 2
    patch_artist=False,     # ★ 箱子是否变成可填色的 Patch
    bootstrap=None,         # notch 的 CI 改用 bootstrap,如 5000
    usermedians=None,       # 手动指定中位数,跳过计算
    conf_intervals=None,    # 手动指定缺口区间
    meanline=False,         # 均值画成横线(需 showmeans=True)
    showmeans=False,        # 显示均值
    showcaps=True,          # 显示须线端帽
    showbox=True,           # 显示箱体
    showfliers=True,        # 显示离群点
    boxprops=None,          # 箱体属性字典
    tick_labels=None,       # 每组标签(3.9+ 改名自 labels)
    flierprops=None,        # 离群点属性
    medianprops=None,       # 中位线属性
    meanprops=None,         # 均值属性
    capprops=None,          # 端帽属性
    whiskerprops=None,      # 须线属性
    manage_ticks=True,      # 自动打刻度;叠加散点时可能要关
    autorange=False,        # Q1 == Q3 退化时,须线是否拉到 min/max
    orientation="vertical", # 朝向(替代已废弃的 vert)
)
参数 一句话 默认值的影响
x 数据 传一维数组 = 一组;传列表 = 多组
notch 中位数缺口 False;开了才有 CI 信息
whis 须线端点 1.5 倍 IQR;给元组就切百分位
patch_artist 箱子类型 False 时箱子是 Line2D,无法填色
showmeans / meanline 均值 默认不画
showfliers 离群点 默认画
showcaps / showbox 端帽 / 箱体 默认都画
widths / capwidths 箱宽 / 端帽宽 capwidths 默认是 widths 的一半
tick_labels 组标签 旧名 labels 在 3.11 删除
*props 各部件属性 键名同 Line2D / Patch

五数概括:箱子画的到底是哪五个数

Python12 行
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
groups = [rng.normal(0, 1, 300),
          rng.normal(2, 1.5, 300),
          rng.normal(-1, 0.8, 300)]
names = ["A", "B", "C"]

fig, ax = plt.subplots()
ax.boxplot(groups, tick_labels=names)
plt.show()

箱线图最小示例:三组数据各一个箱子

这张图里的每根线都能手算出来:

Python7 行
A = groups[0]
q1, med, q3 = np.percentile(A, [25, 50, 75])
iqr = q3 - q1
lo = max(A.min(), q1 - 1.5 * iqr)      # 下须 = max(最小值, Q1 - 1.5×IQR)
hi = min(A.max(), q3 + 1.5 * iqr)      # 上须 = min(最大值, Q3 + 1.5×IQR)
print(np.round([lo, q1, med, q3, hi], 3))
# 输出: [-2.384 -0.669 -0.087  0.474  2.188]

这五个数依次是:下须底端 → 箱子下沿 → 中位线 → 箱子上沿 → 上须顶端。箱子高度就是 IQR = 1.143,中位线偏向箱子下半部说明右偏(长尾在右)。

A 的最小值是 -2.567、最大值是 2.914,都超出了须线端点,所以它们被画成 fliers——这一组共 4 个离群点。须线不是 min/max,这是箱线图最大的误解来源。

whis:须线画到哪

Python4 行
ax.boxplot([groups[0]], whis=1.5)        # 默认:1.5 倍 IQR
ax.boxplot([groups[0]], whis=3.0)        # 放宽到 3 倍 IQR,离群点更少
ax.boxplot([groups[0]], whis=(5, 95))    # 改用 5/95 百分位当端点
ax.boxplot([groups[0]], whis=(0, 100))   # 拉到 min/max,一个离群点都没有

whis 给浮点数和给元组是两套完全不同的逻辑:

  • 浮点数 → Q1 - whis×IQR 到 Q3 + whis×IQR,仍受 min/max 截断。正态数据下 1.5 倍 IQR 约覆盖 ±2.7σ,所以离群点很少。
  • 元组 → 直接取这两个百分位,跟 IQR 无关。

差别有多大,数一下就知道:

Python5 行
print(len(ax.boxplot([groups[0]], whis=1.5)["fliers"][0].get_ydata()))
# 输出: 4

print(len(ax.boxplot([groups[0]], whis=(5, 95))["fliers"][0].get_ydata()))
# 输出: 30

(5, 95) 听起来比 1.5 更严格,实际相反:它固定砍掉两端各 5%,300 个点里正好 30 个变离群点。要少离群点就调大浮点数,要用固定比例就用元组,别把两者当成同一件事的不同写法。

whis / notch / patch_artist / showmeans / showfliers / widths 六种设置

notch:中位数缺口

Python1 行
ax.boxplot(groups, notch=True, tick_labels=names)

缺口不是装饰,它的上下边界是中位数的 95% 置信区间:

缺口端点 = 中位数 ± 1.57 × IQR / √n

代进 A 组(n = 300)验证:

Python3 行
ci = 1.57 * iqr / np.sqrt(len(A))
print(np.round([med - ci, med + ci], 3))
# 输出: [-0.191  0.017]

这两个数和图里缺口的两个拐点完全重合。用途只有一个:两组的缺口不重叠 ≈ 中位数有显著差异(非严格检验,但足够做第一眼判断)。

bootstrap=5000 会把正态近似换成重采样百分位,缺口因此变得不对称——A 组从 [-0.191, 0.017] 变成 [-0.193, 0.090]。样本量小、分布很偏时用 bootstrap 更可靠。

patch_artist:箱子能不能填色

这是箱线图最容易卡住的地方。默认 patch_artist=False 时,bp["boxes"] 里的对象是 Line2D——它根本没有 set_facecolor 这个方法:

Python3 行
bp = ax.boxplot(groups)
bp["boxes"][0].set_facecolor("red")
# AttributeError: 'Line2D' object has no attribute 'set_facecolor'

打开后才换成 PathPatch,才能填色:

Python6 行
bp = ax.boxplot(groups, patch_artist=True, tick_labels=names)
for i, box in enumerate(bp["boxes"]):
    box.set_facecolor(plt.cm.Set2(i, alpha=0.6))

print(type(bp["boxes"][0]).__name__)
# 输出: PathPatch

注意 patch_artist=True 之后的默认填充色是 C0 蓝,不是白色——想「白底彩边」得自己 set_facecolor("white")。

showmeans / meanline

箱线图默认不画均值,只看中位数。要加均值有两种画法:

Python2 行
ax.boxplot(groups, showmeans=True)                     # 均值 = 实心三角点
ax.boxplot(groups, showmeans=True, meanline=True)      # 均值 = 横穿箱子的线

meanline=True 必须配 showmeans=True,单给 meanline 不会有任何变化——它只决定「已经要画的均值」用哪种形状,不负责打开开关。

均值点和中位线的距离就是偏斜的直接证据:右偏分布里均值点会跑到中位线上方。

showfliers / showcaps / showbox

Python3 行
ax.boxplot(groups, showfliers=False)   # 藏起离群点,只留五数
ax.boxplot(groups, showcaps=False)     # 须线顶端没有小横杠
ax.boxplot(groups, showbox=False)      # 只剩须线 + 离群点,退化成像误差棒

showfliers=False 在意的是「离群点太多把坐标轴拉得太长」。showbox=False 则是另一个用途:当多组数据的箱体互相压住时,只留须线能看清范围。

widths / capwidths / positions

Python3 行
bp = ax.boxplot(groups, widths=0.6, capwidths=0.2)
print(bp["caps"][0].get_xdata())
# 输出: [0.9 1.1]

capwidths 是绝对宽度(数据坐标),不是比例:0.2 就是端帽占 0.2 宽,箱体仍占 0.6。默认 capwidths = widths / 2,所以 widths=0.6 时端帽是 [0.85, 1.15]。

positions 用来错开位置,配合 widths 做窄箱并排:

Python1 行
ax.boxplot(groups, positions=[1, 3, 5], widths=0.6, tick_labels=names)

传了 positions 就要自己 set_xticks,否则刻度仍打在 1/2/3 上,标签会错位。

tick_labels:3.9 改名了

Python4 行
ax.boxplot(groups, tick_labels=names)     # ✅ 3.9+
ax.boxplot(groups, labels=names)          # ⚠️ 旧名
# MatplotlibDeprecationWarning: The 'labels' parameter of boxplot() has been renamed
# 'tick_labels' since Matplotlib 3.9; support for the old name will be dropped in 3.11.

旧名还能跑,但 3.11 起会直接报 TypeError。网上大量教程仍是 labels=。

六个 *props:逐部件改样式

字典 管的部件 键名来自
boxprops 箱体 Patch(facecolor 需 patch_artist=True)
whiskerprops 须线 Line2D
capprops 端帽 Line2D
medianprops 中位线 Line2D
meanprops 均值点/线 Line2D
flierprops 离群点 Line2D
Python10 行
ax.boxplot(
    groups, patch_artist=True, tick_labels=names, showmeans=True,
    boxprops=dict(facecolor="#AEC7E8", edgecolor="#1f77b4", linewidth=1.2),
    whiskerprops=dict(color="#1f77b4", linestyle="--", linewidth=1),
    capprops=dict(color="#1f77b4", linewidth=1.2),
    medianprops=dict(color="#C44E52", linewidth=2.5),
    meanprops=dict(marker="D", markerfacecolor="white", markeredgecolor="black"),
    flierprops=dict(marker="o", markersize=3, markerfacecolor="none",
                    markeredgecolor="gray", alpha=0.5),
)

一次写完六个字典很啰嗦,但比画完再去 bp["medians"][0].set_color(...) 逐条改要稳——props 字典在创建时就生效,不会漏掉某一组。

autorange:只处理 Q1 == Q3 的退化

autorange 不是「反转坐标轴」,它只管一种病态情况:数据里大量重复值导致 Q1 和 Q3 相等,IQR = 0,须线长度变成 0,箱子塌成一条线。

Python7 行
degenerate = [0, 1, 1, 1, 1, 2]        # Q1 == Q3 == 1

print(np.asarray(ax.boxplot([degenerate])["fliers"][0].get_ydata()).astype(int))
# 输出: [0 2]

print(np.asarray(ax.boxplot([degenerate], autorange=True)["fliers"][0].get_ydata()).astype(int))
# 输出: []

打开后 whis 被强制当作 (0, 100),须线落到 min/max,全部点回到箱内。要反转方向请用 ax.invert_yaxis()。

返回对象
Python7 行
bp = ax.boxplot(groups, tick_labels=names)

print(sorted(bp.keys()))
# 输出: ['boxes', 'caps', 'fliers', 'means', 'medians', 'whiskers']

print({k: len(v) for k, v in bp.items()})
# 输出: {'whiskers': 6, 'caps': 6, 'boxes': 3, 'medians': 3, 'fliers': 3, 'means': 0}

每个值都是 list,长度规律是:boxes / medians / fliers = 组数,whiskers / caps = 组数 × 2,means 在 showmeans=False 时是空列表。

Python18 行
bp = ax.boxplot(groups, tick_labels=names)

# 不开 patch_artist 时箱子是 Line2D:只有 set_color,
# 没有 set_facecolor / set_edgecolor
for i, box in enumerate(bp["boxes"]):
    box.set_color(plt.cm.Set2(i))
    box.set_linewidth(1.8)

# 填充色必须 patch_artist=True
bp_filled = ax.boxplot(groups, patch_artist=True, tick_labels=names)
for i, box in enumerate(bp_filled["boxes"]):
    box.set_facecolor(plt.cm.Set2(i, alpha=0.45))

# 中位线加粗
for med_line in bp["medians"]:
    med_line.set_linewidth(2.5)
    med_line.set_color("#C44E52")
plt.show()

注意事项

  • patch_artist=False 时箱子是 Line2D:set_facecolor 和 set_edgecolor 都会 AttributeError,改颜色只能用 set_color。
  • 须线不等于 min/max,超出 Q1 - 1.5×IQR / Q3 + 1.5×IQR 的点都在 fliers 里。
  • whis=(5, 95) 比 whis=1.5 砍掉的点更多,不是更严格。
  • meanline=True 单独给没有任何效果,必须配 showmeans=True。
  • tick_labels 是 3.9+ 的名字,labels 会在 3.11 删除。
  • vert 已是 PendingDeprecationWarning,改用 orientation。
  • 箱线图看不见双峰。 两组完全不同的分布可以画出形状相近的箱子,要判断分布形状请叠加散点或改小提琴图。
  • manage_ticks=False 只在手动设了 positions 又要自己控制刻度时才需要。

综合示例:默认 / 填色 + 缺口 / 叠加散点

Python41 行
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False

rng = np.random.default_rng(42)
groups = [rng.normal(0, 1, 300),
          rng.normal(2, 1.5, 300),
          rng.lognormal(0, 0.5, 300)]
names = ["A", "B", "C"]

fig, axes = plt.subplots(1, 3, figsize=(15, 4.6))

# ---- (a) 默认三组 ----
axes[0].boxplot(groups, tick_labels=names)
axes[0].set_title("(a) 默认:空心箱 + 离群点")

# ---- (b) 填色 + 缺口 ----
bp = axes[1].boxplot(groups, notch=True, patch_artist=True, widths=0.55,
                     tick_labels=names,
                     medianprops=dict(color="white", linewidth=2),
                     flierprops=dict(marker="o", markersize=3,
                                     markerfacecolor="none", alpha=0.5))
for i, box in enumerate(bp["boxes"]):
    box.set_facecolor(plt.cm.Set2(i, alpha=0.85))
    box.set_edgecolor("none")
axes[1].set_title("(b) notch=True + patch_artist=True")

# ---- (c) 箱线图 + 抖动散点:既看统计量又看原始分布 ----
axes[2].boxplot(groups, widths=0.5, showfliers=False,
                medianprops=dict(color="#C44E52", linewidth=2),
                boxprops=dict(color="gray"), whiskerprops=dict(color="gray"),
                capprops=dict(color="gray"), tick_labels=names)
for i, g in enumerate(groups, start=1):
    jitter = rng.normal(0, 0.045, len(g))          # ★ 横向抖动,别让点连成一条线
    axes[2].scatter(np.full(len(g), i) + jitter, g, s=4, alpha=0.25, color="#333333")
axes[2].set_title("(c) 叠加抖动散点(看得出双峰)")

plt.tight_layout()
plt.show()

(c) 那组 C 数据换成了对数正态——它的箱子和正态分布长得差不多,但散点一叠就能看见长尾,这正是箱线图单独看不出来的东西。