Python 基础体系 · 第 96/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 数据可视化:Matplotlib、Seaborn、坐标、布局和可解释图表

数据可视化不是“把数据画出来”,而是把数据中的变量、关系、不确定性和比较条件编码成读者可以识别的视觉结构。

一张图是否可靠,至少取决于四个问题:

  1. 数据是否经过了正确的整理和聚合;
  2. 图形是否准确表达了变量之间的关系;
  3. 坐标、比例尺和布局是否改变了读者的判断;
  4. 图表是否明确说明了“图中看到了什么”和“不能从图中推出什么”。

Matplotlib 提供底层而完整的绘图对象模型;Seaborn 建立在 Matplotlib 之上,提供更高层的统计图表接口。Matplotlib 当前文档将 FigureAxesAxis 和各种图形元素统一视为 Artist 体系的一部分;Seaborn 则面向统计图形和结构化数据提供更简洁的接口。(matplotlib.org)

本文代码以 Python 3.14 为语法范围,数据处理使用 NumPy 和 pandas,绘图使用 Matplotlib 与 Seaborn。Python 3.14 本身并不内置这些第三方库,因此实际运行时还必须确认当前环境中的 Matplotlib、Seaborn、NumPy 和 pandas 版本兼容。Python 3.14 的官方文档当前对应 3.14.7。(docs.python.org)


一、先明确:一张图到底在表达什么

一个图表通常包含四类信息:

  • 观测单位:每个点、柱、线段或颜色块代表什么;
  • 变量映射:哪个变量映射到横坐标、纵坐标、颜色、大小或形状;
  • 统计变换:原始观测是否被求和、求均值、分箱或估计区间;
  • 视觉语法:使用点、线、柱、面积、热力图还是其他几何元素。

例如,下面的数据表示三个月中两个产品的销售额:

import pandas as pd

sales = pd.DataFrame(
    {
        "month": ["一月", "一月", "二月", "二月", "三月", "三月"],
        "product": ["A", "B", "A", "B", "A", "B"],
        "revenue": [120, 90, 135, 110, 150, 125],
    }
)

如果绘制:

import matplotlib.pyplot as plt

fig, ax = plt.subplots()
for product, group in sales.groupby("product"):
    ax.plot(group["month"], group["revenue"], marker="o", label=product)

ax.set_xlabel("月份")
ax.set_ylabel("销售额")
ax.set_title("不同产品的月度销售额")
ax.legend(title="产品")
plt.show()

这里的含义是:

  • 一行数据是一种“产品—月份”组合;
  • month 是分类顺序变量,而不是连续数值;
  • revenue 是被比较的数值;
  • 线段暗示月份之间存在顺序关系;
  • marker="o" 显示每个实际观测;
  • labellegend 说明颜色或线型对应的产品。

如果月份数据缺少二月,直接连线会把一月和三月连接起来。这并不一定错误,但它隐含了“中间没有观测,趋势仍然可以连续解释”的假设。因此,线图不是纯粹的装饰选择,而是在表达数据的顺序与连续性。


二、NumPy、pandas 与绘图库的分工

2.1 NumPy:数值数组和形状

NumPy 的核心对象是 ndarray。绘图函数通常接受一维数组作为坐标,接受二维数组作为图像、矩阵或多序列数据。

import numpy as np

x = np.array([0, 1, 2, 3])
y = x**2

print(x.shape)  # (4,)
print(y.shape)  # (4,)

xy 逐元素配对,因此点为:

(0, 0), (1, 1), (2, 4), (3, 9)

NumPy 的广播规则会影响绘图前的数据计算。两个数组从最右侧维度开始比较,维度相等或其中一个为 1 时才兼容;否则会抛出 ValueError。(numpy.org)

x = np.arange(4)[:, None]  # shape: (4, 1)
offset = np.array([10, 20, 30])  # shape: (3,)

result = x + offset
print(result.shape)
print(result)

输出:

(4, 3)
[[10 20 30]
 [11 21 31]
 [12 22 32]
 [13 23 33]]

这里不是把三个偏移量“循环使用”,而是将 (4, 1)(3,) 视为可以扩展成 (4, 3) 的数组。理解形状非常重要,因为绘图中的错误经常不是绘图库问题,而是数据数组的维度已经错了。

2.2 pandas:带标签的数据表

pandas 的 SeriesDataFrame 除了保存值,还保存索引和列名。对于可视化而言,列名是解释变量的基础。

import pandas as pd

df = pd.DataFrame(
    {
        "group": ["A", "A", "B", "B"],
        "score": [10, 12, 20, 18],
    }
)

如果直接使用 NumPy 数组:

ax.scatter(df["group"], df["score"])

绘图库只能看到两列向量;如果使用 Seaborn:

import seaborn as sns

sns.scatterplot(data=df, x="group", y="score")

则变量名称可以自然地进入坐标轴、图例和统计接口。Seaborn 的大多数绘图函数以向量为中心,并通常使用 DataFrame 列名指定变量;它同时支持长表和宽表数据。(seaborn.pydata.org)


三、Matplotlib 的对象模型:Figure、Axes、Axis 和 Artist

3.1 Figure:整张画布

Figure 是整张图的容器。它决定:

  • 图像整体尺寸;
  • 输出分辨率;
  • 多个子图的组织;
  • 图级标题、图级图例和整体布局。
fig = plt.figure(figsize=(8, 4), dpi=120)

figsize=(8, 4) 的单位是英寸,不是像素。若 dpi=120,理论像素尺寸约为:

宽度 = 8 × 120 = 960 像素
高度 = 4 × 120 = 480 像素

3.2 Axes:一个实际绘图区

Axes 是最常用的绘图对象。一个 Figure 可以包含多个 Axes,每个 Axes 通常代表一个子图。Matplotlib 官方文档将 Axes 描述为创建可视化的主要入口,它包含数据、刻度、标签、标题、图例等内容。(matplotlib.org)

fig, ax = plt.subplots(figsize=(6, 4))

ax.plot([1, 2, 3], [2, 4, 3])
ax.set_title("一个 Axes")
ax.set_xlabel("x")
ax.set_ylabel("y")

plt.show()

推荐显式保留 figax,而不是依赖当前全局图:

fig, ax = plt.subplots()

这种写法称为面向对象接口。它在多子图、函数封装、测试和批量生成图表时更容易控制。

3.3 Axis:坐标轴系统

Axis 是横轴或纵轴对象,负责:

  • 刻度位置;
  • 刻度标签;
  • 坐标轴标签;
  • 刻度定位器和格式化器;
  • 坐标轴的线性、对数等比例尺。

需要区分:

  • Axes:一个子图区域;
  • AxisAxes 内的 x 轴或 y 轴;
  • ax.xaxis:横向 Axis
  • ax.yaxis:纵向 Axis

3.4 Artist:图上的可绘制元素

线、柱、文字、图例、图像、坐标轴甚至 FigureAxes 都属于 Artist 体系。调用:

line, = ax.plot([1, 2, 3], [2, 4, 3])

会创建一个 Line2D Artist,并将其放入 Axes 中。ax.bar() 会创建矩形,ax.scatter() 会创建集合对象,ax.text() 会创建文本对象。(matplotlib.org)

因此,下面两种修改方式本质相同:

ax.set_title("标题")

和:

title = ax.set_title("标题")
title.set_color("navy")

第一种使用高级辅助方法,第二种直接操作 Artist。


四、状态接口与面向对象接口

Matplotlib 同时提供两种常见写法。

4.1 状态接口:pyplot

import matplotlib.pyplot as plt

plt.plot([1, 2, 3], [2, 4, 3])
plt.title("状态接口")
plt.xlabel("x")
plt.ylabel("y")
plt.show()

pyplot 维护“当前 Figure”和“当前 Axes”。这适合交互式探索,但状态可能在多个单元格之间残留:

plt.plot([1, 2, 3], [2, 4, 3])
plt.plot([1, 2, 3], [1, 3, 2])

第二条线会叠加到当前 Axes,而不是自动创建新图。

4.2 面向对象接口:显式操作对象

fig, ax = plt.subplots()

ax.plot([1, 2, 3], [2, 4, 3], label="序列 A")
ax.plot([1, 2, 3], [1, 3, 2], label="序列 B")

ax.set_title("面向对象接口")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.legend()

plt.show()

在函数中生成图表时,建议返回 Figure 或保存文件:

def plot_series(x, y):
    fig, ax = plt.subplots()
    ax.plot(x, y)
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    return fig, ax

调用者可以继续设置标题、坐标范围或保存格式,而不依赖全局状态。


五、坐标:数据坐标不等于屏幕坐标

“坐标”至少有四个层次:

  1. 数据坐标:例如 x=10, y=20
  2. Axes 坐标:子图左下角为 (0, 0),右上角为 (1, 1)
  3. Figure 坐标:整张图左下角为 (0, 0),右上角为 (1, 1)
  4. 显示坐标:最终窗口或图片中的像素坐标。

Matplotlib 使用变换系统将这些坐标转换为显示坐标。ax.transData 将数据坐标转换为显示坐标,ax.transAxes 将 Axes 坐标转换为显示坐标,fig.transFigure 则处理 Figure 坐标。(matplotlib.org)

5.1 在数据坐标中标注

fig, ax = plt.subplots()

x = np.arange(5)
y = x**2

ax.plot(x, y, marker="o")
ax.annotate(
    "最大值",
    xy=(4, 16),
    xytext=(2.8, 12),
    arrowprops={"arrowstyle": "->"},
)

plt.show()

xy=(4, 16)xytext=(2.8, 12) 默认使用数据坐标。因此,当坐标范围改变时,标注会随数据移动。

5.2 在 Axes 坐标中放置固定说明

fig, ax = plt.subplots()

ax.plot([1, 2, 3], [2, 4, 3])
ax.text(
    0.02,
    0.95,
    "数据范围:2026 年第一季度",
    transform=ax.transAxes,
    ha="left",
    va="top",
)

plt.show()

这里的 (0.02, 0.95) 表示“子图左上附近”,而不是某个具体数据值。即使数据范围改变,文本仍然保持在子图的相对位置。

这也是坐标变换的实际价值:数据标注使用 transData,版面说明使用 transAxestransFigure。如果把两者混用,文本可能在缩放后跑出图外,或者随着数据移动而不再位于预期位置。


六、比例尺、坐标范围与视觉判断

6.1 线性比例尺

在线性坐标中,数据值到屏幕距离近似满足:

p=ax+bp = a x + b

其中:

  • x 是数据值;
  • p 是屏幕上的位置;
  • a 是缩放因子;
  • b 是平移量。

数据差值越大,视觉距离越大。

6.2 对数比例尺

对数坐标先计算:

u=logb(x)u = \log_b(x)

再把 u 映射到屏幕。这里:

  • x 必须为正数;
  • b 是对数底;
  • 相同的倍数变化会产生相同的视觉距离。

例如,1 → 1010 → 100 都是十倍增长,在以 10 为底的对数轴上距离相同。

fig, ax = plt.subplots()

x = np.arange(1, 101)
ax.plot(x, x**2)
ax.set_yscale("log")

ax.set_xlabel("x")
ax.set_ylabel("y:对数比例尺")
ax.set_title("对数坐标适合观察数量级差异")

plt.show()

对数坐标不能用于包含零或负数的数据:

values = np.array([-1, 0, 1, 10])

如果直接设置对数轴,负数和零无法正常映射。此时应先确认业务含义:

  • 零是否表示“没有发生”;
  • 负数是否表示方向或亏损;
  • 是否应该使用对称对数尺度;
  • 是否需要拆成正负两个图。

不能为了“让图画出来”而直接丢弃零值或负值,因为这会改变数据定义。

6.3 截断坐标与柱状图

折线图可以使用截断的 y 轴来放大局部差异,但柱状图通常不应随意截断基线。

categories = ["A", "B", "C"]
values = [98, 99, 100]

fig, ax = plt.subplots()
ax.bar(categories, values)
ax.set_ylim(0, 105)
plt.show()

柱子的高度通过从零开始的长度表达数量。如果把范围改为:

ax.set_ylim(97, 101)

视觉上会把很小的差异放大成巨大差异。数值本身没有被改变,但视觉编码已经改变了比例关系。


七、常见图表与适用条件

7.1 折线图:顺序和趋势

折线图适用于横轴具有自然顺序,且相邻观测之间的连接有意义的情况:

  • 时间序列;
  • 实验阶段;
  • 连续参数;
  • 有序等级。

不适合把无序类别强行连接成线。例如“北京、上海、广州、深圳”若只是四个独立类别,折线会暗示它们存在连续顺序。

7.2 散点图:两个数值变量的关系

fig, ax = plt.subplots()

ax.scatter(
    [1, 2, 3, 4, 5],
    [2, 2.5, 3.8, 4.1, 5.2],
    alpha=0.8,
)

ax.set_xlabel("训练时长")
ax.set_ylabel("测试得分")
plt.show()

散点图可以帮助观察:

  • 相关方向;
  • 非线性关系;
  • 离群点;
  • 聚类;
  • 异方差。

但散点图中的相关性不等于因果关系。若两个变量共同受第三个变量影响,图中出现的趋势不能直接解释为“x 导致 y”。

7.3 直方图:连续变量的分布

直方图先将数值轴划分为区间,再统计每个区间的观测数量:

hi=#{xjbixj<bi+1}h_i = \#\{x_j \mid b_i \leq x_j < b_{i+1}\}

其中 b_ib_{i+1} 是第 i 个箱子的边界,h_i 是落入该区间的样本数。

rng = np.random.default_rng(42)
values = rng.normal(loc=70, scale=8, size=500)

fig, ax = plt.subplots()
ax.hist(values, bins=20, edgecolor="white")
ax.set_xlabel("分数")
ax.set_ylabel("样本数")
plt.show()

bins 越少,分布被过度平滑;bins 越多,随机波动越明显。因此,直方图的形状部分取决于分箱规则,不能把某一个分箱方案当成唯一事实。

7.4 箱线图:分位数和离群点

箱线图通常使用:

  • 中位数;
  • 第一四分位数 Q1
  • 第三四分位数 Q3
  • 四分位距 IQR = Q3 - Q1
  • Q1 - 1.5IQRQ3 + 1.5IQR 为基础识别的潜在离群点。

箱线图适合比较多个组的分布位置和离散程度,但它隐藏了样本量和多峰结构。样本量很小或分布明显多峰时,应结合原始点或小提琴图使用。

7.5 柱状图:类别聚合后的比较

柱状图通常展示类别对应的聚合值,而不是“每一行原始数据”。

summary = (
    sales.groupby("product", as_index=False)["revenue"]
    .sum()
    .sort_values("revenue", ascending=False)
)

fig, ax = plt.subplots()
ax.bar(summary["product"], summary["revenue"])

ax.set_xlabel("产品")
ax.set_ylabel("累计销售额")
ax.set_title("各产品累计销售额")
plt.show()

这里必须明确统计量是“累计销售额”。如果改成:

summary = (
    sales.groupby("product", as_index=False)["revenue"]
    .mean()
)

图表含义就变成“平均每月销售额”。相同的柱状图外观,背后可能对应完全不同的统计问题。


八、Seaborn:在 Matplotlib 之上的统计接口

Seaborn 的基本接口通常以:

sns.some_plot(data=data, x="列名", y="列名", hue="分组列")

为中心。它的优势不是替代 Matplotlib,而是将常见的数据映射、分组、调色和统计估计组合起来。

import seaborn as sns

tips = sns.load_dataset("tips")

fig, ax = plt.subplots(figsize=(7, 4))
sns.scatterplot(
    data=tips,
    x="total_bill",
    y="tip",
    hue="time",
    style="sex",
    size="size",
    ax=ax,
)

ax.set_title("账单金额与小费")
plt.show()

这里:

  • xy 映射两个数值变量;
  • hue 使用颜色编码时间段;
  • style 使用点形编码性别;
  • size 使用点大小编码用餐人数;
  • ax=ax 让 Seaborn 绘制到指定的 Matplotlib Axes

8.1 Seaborn 的统计变换

fig, ax = plt.subplots(figsize=(7, 4))

sns.barplot(
    data=tips,
    x="day",
    y="tip",
    errorbar=("ci", 95),
    ax=ax,
)

ax.set_title("不同星期的小费均值及区间")
plt.show()

这里的柱高不是每一行数据的高度,而是 Seaborn 根据 day 分组后对 tip 进行统计汇总的结果。误差线还表达了某种不确定性估计。

因此,使用 Seaborn 的统计图时必须回答:

  • 聚合函数是什么;
  • 误差线表示什么;
  • 样本是否独立;
  • 分组是否足够大;
  • 缺失值是否被排除;
  • 置信区间是否适合当前数据生成过程。

不要把误差线自动解释为“数据的最大值和最小值”。它可能表示置信区间、标准误、标准差或其他统计范围,具体取决于参数和函数。

8.2 Figure-level 与 Axes-level

Axes-level 函数通常绘制到一个指定的 Axes

fig, ax = plt.subplots()
sns.histplot(data=tips, x="total_bill", ax=ax)

Figure-level 函数则可能自行创建并管理整张图,例如:

g = sns.displot(data=tips, x="total_bill", col="time")

当需要精细控制单个子图、与 Matplotlib Artist 混合或嵌入既有布局时,优先使用 Axes-level 函数,并显式传入 ax

8.3 Seaborn Objects 接口

Seaborn 还提供声明式的 seaborn.objects 接口:

import seaborn.objects as so

(
    so.Plot(tips, x="total_bill", y="tip", color="time")
    .add(so.Dots())
)

这个接口将数据映射、Mark、统计变换、分面和比例尺分开描述。Plot 方法通常返回新的规格对象,真正的处理一般发生在显示或保存时;当前文档仍将该接口描述为实验性且不完整,因此生产代码应确认所使用版本的 API 行为。(seaborn.pydata.org)


九、布局:为什么图画出来了却不可读

9.1 子图布局

fig, axes = plt.subplots(
    2,
    2,
    figsize=(10, 7),
    sharex=True,
    layout="constrained",
)

axes[0, 0].plot([1, 2, 3], [1, 4, 2])
axes[0, 0].set_title("趋势")

axes[0, 1].scatter([1, 2, 3], [2, 3, 1])
axes[0, 1].set_title("关系")

axes[1, 0].hist([1, 1, 2, 2, 2, 3], bins=3)
axes[1, 0].set_title("分布")

axes[1, 1].bar(["A", "B"], [3, 5])
axes[1, 1].set_title("比较")

plt.show()

sharex=True 会让子图共享 x 轴范围或刻度关系。这样便于比较,但如果不同子图本应使用不同范围,共享坐标会隐藏局部结构。

Matplotlib 提供 tight_layoutconstrained_layout 等布局机制;当前文档将 constrained_layout 描述为较新的布局引擎,通常比早期的 tight_layout 处理复杂布局更好。(matplotlib.org)

9.2 tight_layoutconstrained_layout

fig, axes = plt.subplots(1, 2, figsize=(9, 4), layout="constrained")

axes[0].set_ylabel("一个较长的纵轴标签")
axes[0].set_title("左图")
axes[1].set_title("右图")

plt.show()

布局引擎会根据标题、标签、刻度和图例等 Artist 调整子图位置。但它不是语义判断器,不能知道:

  • 哪个标题应该放在图外;
  • 哪些标签必须完整显示;
  • 两个子图是否应该共享比例;
  • 图例是否遮挡数据。

布局自动化解决的是几何空间问题,不解决图表设计问题。

9.3 GridSpec:不规则布局

当一个图需要一个大图和两个小图时,可以使用 GridSpec

import matplotlib.gridspec as gridspec

fig = plt.figure(figsize=(9, 6), layout="constrained")
grid = gridspec.GridSpec(2, 2, figure=fig)

ax_main = fig.add_subplot(grid[:, 0])
ax_top = fig.add_subplot(grid[0, 1])
ax_bottom = fig.add_subplot(grid[1, 1])

ax_main.set_title("主图")
ax_top.set_title("上方辅助图")
ax_bottom.set_title("下方辅助图")

plt.show()

grid[:, 0] 表示主图占据第一列的两行,右侧两个 Axes 各占一格。


十、颜色、图例和可解释编码

10.1 颜色不是越多越好

颜色可以编码类别,也可以编码连续数值:

  • 类别变量适合使用离散调色板;
  • 连续变量适合使用连续色图;
  • 有方向的偏差适合使用发散色图。
fig, ax = plt.subplots()

scatter = ax.scatter(
    [1, 2, 3, 4],
    [4, 3, 5, 2],
    c=[-2, -1, 1, 2],
    cmap="coolwarm",
)

fig.colorbar(scatter, ax=ax, label="偏差")
plt.show()

颜色条必须说明颜色对应的变量和单位。否则读者只能看到颜色变化,却不知道颜色代表类别、数量还是误差。

10.2 图例必须对应真实映射

fig, ax = plt.subplots()

ax.plot([1, 2, 3], [2, 4, 3], label="实验组")
ax.plot([1, 2, 3], [1, 3, 2], label="对照组")
ax.legend(title="组别")

如果没有设置有效的 label,调用图例可能得到空图例或警告。Matplotlib 对以下划线开头的默认标签通常不自动加入图例。(matplotlib.org)

图例的作用是解释编码,不是重复标题。因此:

  • 标题说明图表要回答的问题;
  • 坐标轴说明变量和单位;
  • 图例说明颜色、线型或点形;
  • 注释说明重要异常或事件。

十一、缺失值、重复值与连接错误会直接改变图

可视化前的 pandas 处理不是附属步骤,而是图表语义的一部分。

11.1 缺失值

df = pd.DataFrame(
    {
        "month": ["一月", "二月", "三月", "四月"],
        "revenue": [100, None, 140, 150],
    }
)

对缺失值直接插值:

df["revenue_interpolated"] = df["revenue"].interpolate()

意味着假设二月和一月、三月之间存在可插值的连续过程。对于温度这样的物理量,这个假设可能合理;对于一次性订单金额,则可能没有意义。

因此,绘图前需要区分:

  • 缺失表示未采集;
  • 缺失表示没有发生;
  • 缺失表示数据错误;
  • 缺失是否允许被估计。

11.2 重复记录

如果同一订单在连接后出现两次,柱状图的总和会被放大:

result = orders.merge(customers, on="customer_id")

当连接键在右表中不唯一时,一对一关系可能变成一对多关系。连接前应检查键的基数:

customers["customer_id"].is_unique

如果结果为 False,就不能直接假定每个客户只有一行。

11.3 聚合粒度

假设原始数据是一行一次交易:

日期、门店、商品、交易金额

按“门店”聚合后绘图,表达的是门店层面的统计;按“日期、门店”聚合后绘图,表达的是门店日层面的统计。两者不能混用。

daily = (
    sales.groupby(["month", "product"], as_index=False)
    .agg(
        revenue=("revenue", "sum"),
        orders=("revenue", "size"),
    )
)

如果图表要表达经营规模,可以使用 revenue;如果要表达交易频率,可以使用 orders。变量名称和聚合函数必须与问题一致。


十二、把图表做成可解释的证据

可解释图表不是在图上添加大量文字,而是让视觉编码、统计过程和结论之间的链条可追溯。

12.1 一个完整算例:比较两组趋势

下面构造两组有噪声的时间序列,并绘制均值与不确定性范围。

from __future__ import annotations

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(2026)

records: list[dict[str, object]] = []

for group, base in [("实验组", 10.0), ("对照组", 8.0)]:
    for day in range(1, 11):
        observations = base + 0.4 * day + rng.normal(0, 0.8, size=8)
        for value in observations:
            records.append(
                {
                    "group": group,
                    "day": day,
                    "value": value,
                }
            )

raw = pd.DataFrame(records)

summary = (
    raw.groupby(["group", "day"], as_index=False)
    .agg(
        mean=("value", "mean"),
        std=("value", "std"),
        n=("value", "size"),
    )
)

summary["sem"] = summary["std"] / np.sqrt(summary["n"])
summary["lower"] = summary["mean"] - 1.96 * summary["sem"]
summary["upper"] = summary["mean"] + 1.96 * summary["sem"]

fig, ax = plt.subplots(figsize=(8, 5), layout="constrained")

for group, part in summary.groupby("group", sort=False):
    x = part["day"].to_numpy()
    mean = part["mean"].to_numpy()
    lower = part["lower"].to_numpy()
    upper = part["upper"].to_numpy()

    ax.plot(x, mean, marker="o", label=group)
    ax.fill_between(x, lower, upper, alpha=0.18)

ax.set_xlabel("实验天数")
ax.set_ylabel("指标均值")
ax.set_title("两组指标随实验天数的变化")
ax.legend(title="组别")
ax.grid(axis="y", alpha=0.25)

fig.savefig("group_trend.png", dpi=160, bbox_inches="tight")
plt.show()

这个例子可以逐步解释为:

  1. 每组每天生成 8 个观测,因此原始表中一行代表一个观测;
  2. groupby(["group", "day"]) 将数据聚合到“组—天”粒度;
  3. mean 是均值;
  4. std 是样本标准差;
  5. sem = std / sqrt(n) 是均值标准误;
  6. mean ± 1.96 × sem 构造了近似区间;
  7. 实线表达均值趋势;
  8. 半透明区域表达区间,而不是额外的一组观测;
  9. fill_between 的上下边界必须与 x 轴长度一致,否则会产生维度错误。

这里的 1.96 依赖近似正态和较大样本等条件。对于每组每天只有 8 个观测,严格推断时可以考虑 t 分布、Bootstrap 或层级模型。图中的阴影不能被无条件解释成“真实值有 95% 概率落在其中”,因为置信区间和预测区间的含义不同。

12.2 反例:双 y 轴制造虚假关联

fig, ax1 = plt.subplots()

ax2 = ax1.twinx()

ax1.plot([1, 2, 3], [100, 110, 120], color="tab:blue")
ax2.plot([1, 2, 3], [1, 2, 3], color="tab:red")

ax1.set_ylabel("指标 A")
ax2.set_ylabel("指标 B")

两个变量可以通过不同的 y 轴被视觉上“对齐”,从而看起来同步增长。但由于两个轴的尺度独立,线条之间的相对位置没有稳定的比较意义。

双 y 轴并非绝对错误,但必须:

  • 明确标注两个单位;
  • 说明两条线不是同一尺度;
  • 避免让颜色和位置暗示不存在的数量关系;
  • 必要时改用上下两个共享 x 轴的子图。

十三、布局和输出文件:显示成功不等于保存正确

绘图窗口中能看到内容,不代表导出的图片一定完整。常见问题包括:

  • 图例被裁剪;
  • 中文字体缺失;
  • 负号显示异常;
  • 保存时使用了错误的当前 Figure;
  • 输出分辨率不适合印刷;
  • 透明背景导致文字在目标环境中不可见。

推荐显式使用 Figure 保存:

fig.savefig(
    "report.svg",
    format="svg",
    bbox_inches="tight",
)

savefig 支持 PNG、PDF、SVG 等格式;dpi 控制栅格输出分辨率,bbox_inches="tight" 会尝试根据图中元素裁剪输出边界。(matplotlib.org)

格式选择取决于用途:

  • PNG:适合网页、文档和栅格图像;
  • SVG:适合需要缩放和后期编辑的二维矢量图;
  • PDF:适合报告和打印;
  • 高 DPI 不能修复错误的字体、比例或坐标范围。

中文环境中还需要确认字体:

import matplotlib.pyplot as plt

plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC"]
plt.rcParams["axes.unicode_minus"] = False

字体名称必须是当前系统实际安装的字体。设置不存在的字体不会保证中文正常显示,Matplotlib 可能回退到其他字体并产生缺字警告。


十四、Jupyter 中的状态问题

Jupyter Notebook 会保留变量、Figure 和导入状态。下面的执行顺序会产生不同结果:

plt.plot([1, 2, 3], [1, 4, 9])

再次执行:

plt.plot([1, 2, 3], [2, 3, 4])

如果没有创建新 Figure,第二条线通常会叠加在当前图上。更可靠的写法是:

fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 4, 9])
plt.show()

批量绘图后应关闭 Figure:

for i in range(100):
    fig, ax = plt.subplots()
    ax.plot([1, 2, 3], [i, i + 1, i + 2])
    fig.savefig(f"plot-{i}.png")
    plt.close(fig)

否则大量 Figure 可能持续占用内存。Notebook 中还应避免依赖“前面某个单元格已经执行过”的隐含状态;数据读取、清洗、绘图和保存最好组织成可以从头运行的流程。


十五、交互式后端、脚本后端与故障路径

Matplotlib 的绘制过程可以抽象为:

数据数组
  ↓
Axes 方法创建 Artist
  ↓
Figure 组织布局
  ↓
后端 Canvas 渲染
  ↓
窗口显示或文件输出

交互式环境需要 GUI 后端;服务器、CI 或无桌面环境通常使用非交互式后端。若在无显示环境中调用:

plt.show()

可能出现后端初始化错误或没有窗口。批处理脚本通常只需要保存文件,并可在导入 pyplot 前选择非交互式后端:

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt

matplotlib.use() 应在创建 Figure 之前调用。若程序已经初始化了后端,再切换可能失败或行为不一致。

诊断时应依次确认:

  1. 数据数组长度是否一致;
  2. 是否包含 NaN、无穷值或非法对数值;
  3. 当前 Figure 和 Axes 是否是预期对象;
  4. 标签、图例和注释是否超出边界;
  5. 当前运行环境是否支持所需后端;
  6. 输出文件是否确实生成并可被目标程序打开。

十六、如何选择 Matplotlib、Seaborn 或 pandas 绘图接口

可以按问题而不是按库名选择:

使用 Matplotlib 的情况

  • 需要精确控制 Artist;
  • 需要复杂布局;
  • 需要自定义坐标变换;
  • 需要组合多个异构图层;
  • 需要稳定的脚本化输出。

使用 Seaborn 的情况

  • 数据已经是结构化 DataFrame;
  • 需要按类别自动分组;
  • 需要分布、回归、分类或统计估计图;
  • 希望减少颜色、图例和分组的样板代码。

使用 pandas 绘图接口的情况

  • 已经完成简单的 Series 或 DataFrame 探索;
  • 需要快速查看数据;
  • 不需要复杂的图层和布局控制。

但 pandas 绘图最终通常仍然使用 Matplotlib,因此复杂图表可以先用 pandas 快速探索,再取得 Axes 继续定制。


十七、可解释图表的检查方法

在交付一张图之前,可以逐项验证,但每一项都应对应一个具体的语义问题。

数据问题

  • 每个点或柱代表什么;
  • 当前粒度是原始记录、日、周、用户还是产品;
  • 缺失值如何处理;
  • 是否存在重复计数;
  • 聚合函数是否与业务问题一致。

坐标问题

  • x 轴是否有自然顺序;
  • y 轴是否从零开始,是否有截断;
  • 是否使用了对数比例尺;
  • 单位是否写明;
  • 多个子图是否真的应该共享坐标。

视觉编码问题

  • 颜色、大小、形状是否各自只表达一个含义;
  • 图例是否完整;
  • 颜色是否适合色觉差异;
  • 是否存在过度透明、过多类别或严重重叠;
  • 阴影和误差线的统计含义是否明确。

结论问题

  • 图表支持的结论是什么;
  • 图表不能支持的结论是什么;
  • 是否把相关性写成因果性;
  • 是否把置信区间写成个体范围;
  • 是否把离群点当成错误数据而未经调查删除。

十八、一个稳定的绘图函数结构

将数据处理和绘图拆开,可以让统计逻辑更容易验证:

from pathlib import Path

def summarize_sales(data: pd.DataFrame) -> pd.DataFrame:
    required = {"month", "product", "revenue"}
    missing = required - set(data.columns)
    if missing:
        raise ValueError(f"缺少列: {sorted(missing)}")

    if data["revenue"].isna().any():
        raise ValueError("revenue 包含缺失值,请先定义处理策略")

    return (
        data.groupby(["month", "product"], as_index=False)
        .agg(revenue=("revenue", "sum"))
    )


def plot_sales(data: pd.DataFrame, output: str | Path):
    summary = summarize_sales(data)

    fig, ax = plt.subplots(figsize=(8, 4), layout="constrained")

    for product, group in summary.groupby("product", sort=False):
        ax.plot(
            group["month"],
            group["revenue"],
            marker="o",
            label=product,
        )

    ax.set_xlabel("月份")
    ax.set_ylabel("销售额")
    ax.set_title("产品月度销售额")
    ax.legend(title="产品")

    output = Path(output)
    fig.savefig(output, dpi=160, bbox_inches="tight")
    return fig, ax

这个结构包含几个重要边界:

  • 输入列不存在时立即失败;
  • 缺失值不被静默转换;
  • 聚合逻辑可单独测试;
  • 绘图函数只负责视觉表达;
  • 输出路径由调用者传入;
  • 返回 FigureAxes,便于测试和进一步定制;
  • 保存使用当前函数创建的 fig,不会依赖全局当前图。

可视化代码的质量,不仅表现为“能生成图片”,还表现为数据粒度、统计变换、视觉编码和输出结果之间没有未说明的跳跃。

当读者能够从原始数据追踪到聚合表,再追踪到坐标、图形元素和最终结论时,这张图才真正具备可解释性。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。