目录13
文章/Matplotlib/第 10 章
箱线图
Matplotlib · 第 10 / 43 章
箱线图 boxplot()
箱线图把一组数据压成五个数——下须、Q1、中位数、Q3、上须——再把落在须线外的点单独标出来。它不画分布形状,只画「中间 50% 有多宽、往哪边偏、尾巴有多长」,所以并排比较十几组数据是它最好的场景。
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 |
五数概括:箱子画的到底是哪五个数
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()
这张图里的每根线都能手算出来:
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:须线画到哪
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 无关。
差别有多大,数一下就知道:
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 个变离群点。要少离群点就调大浮点数,要用固定比例就用元组,别把两者当成同一件事的不同写法。

notch:中位数缺口
ax.boxplot(groups, notch=True, tick_labels=names)缺口不是装饰,它的上下边界是中位数的 95% 置信区间:
缺口端点 = 中位数 ± 1.57 × IQR / √n代进 A 组(n = 300)验证:
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 这个方法:
bp = ax.boxplot(groups)
bp["boxes"][0].set_facecolor("red")
# AttributeError: 'Line2D' object has no attribute 'set_facecolor'打开后才换成 PathPatch,才能填色:
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
箱线图默认不画均值,只看中位数。要加均值有两种画法:
ax.boxplot(groups, showmeans=True) # 均值 = 实心三角点
ax.boxplot(groups, showmeans=True, meanline=True) # 均值 = 横穿箱子的线meanline=True 必须配 showmeans=True,单给 meanline 不会有任何变化——它只决定「已经要画的均值」用哪种形状,不负责打开开关。
均值点和中位线的距离就是偏斜的直接证据:右偏分布里均值点会跑到中位线上方。
showfliers / showcaps / showbox
ax.boxplot(groups, showfliers=False) # 藏起离群点,只留五数
ax.boxplot(groups, showcaps=False) # 须线顶端没有小横杠
ax.boxplot(groups, showbox=False) # 只剩须线 + 离群点,退化成像误差棒showfliers=False 在意的是「离群点太多把坐标轴拉得太长」。showbox=False 则是另一个用途:当多组数据的箱体互相压住时,只留须线能看清范围。
widths / capwidths / positions
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 做窄箱并排:
ax.boxplot(groups, positions=[1, 3, 5], widths=0.6, tick_labels=names)传了 positions 就要自己 set_xticks,否则刻度仍打在 1/2/3 上,标签会错位。
tick_labels:3.9 改名了
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 |
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,箱子塌成一条线。
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()。
返回对象
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 时是空列表。
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又要自己控制刻度时才需要。
综合示例:默认 / 填色 + 缺口 / 叠加散点
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 数据换成了对数正态——它的箱子和正态分布长得差不多,但散点一叠就能看见长尾,这正是箱线图单独看不出来的东西。