Python 基础体系 · 第 96/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 数据可视化:Matplotlib、Seaborn、坐标、布局和可解释图表
数据可视化不是“把数据画出来”,而是把数据中的变量、关系、不确定性和比较条件编码成读者可以识别的视觉结构。
一张图是否可靠,至少取决于四个问题:
- 数据是否经过了正确的整理和聚合;
- 图形是否准确表达了变量之间的关系;
- 坐标、比例尺和布局是否改变了读者的判断;
- 图表是否明确说明了“图中看到了什么”和“不能从图中推出什么”。
Matplotlib 提供底层而完整的绘图对象模型;Seaborn 建立在 Matplotlib 之上,提供更高层的统计图表接口。Matplotlib 当前文档将 Figure、Axes、Axis 和各种图形元素统一视为 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"显示每个实际观测;label和legend说明颜色或线型对应的产品。
如果月份数据缺少二月,直接连线会把一月和三月连接起来。这并不一定错误,但它隐含了“中间没有观测,趋势仍然可以连续解释”的假设。因此,线图不是纯粹的装饰选择,而是在表达数据的顺序与连续性。
二、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,)
x 和 y 逐元素配对,因此点为:
(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 的 Series 和 DataFrame 除了保存值,还保存索引和列名。对于可视化而言,列名是解释变量的基础。
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()
推荐显式保留 fig 和 ax,而不是依赖当前全局图:
fig, ax = plt.subplots()
这种写法称为面向对象接口。它在多子图、函数封装、测试和批量生成图表时更容易控制。
3.3 Axis:坐标轴系统
Axis 是横轴或纵轴对象,负责:
- 刻度位置;
- 刻度标签;
- 坐标轴标签;
- 刻度定位器和格式化器;
- 坐标轴的线性、对数等比例尺。
需要区分:
Axes:一个子图区域;Axis:Axes内的 x 轴或 y 轴;ax.xaxis:横向Axis;ax.yaxis:纵向Axis。
3.4 Artist:图上的可绘制元素
线、柱、文字、图例、图像、坐标轴甚至 Figure 和 Axes 都属于 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
调用者可以继续设置标题、坐标范围或保存格式,而不依赖全局状态。
五、坐标:数据坐标不等于屏幕坐标
“坐标”至少有四个层次:
- 数据坐标:例如
x=10, y=20; - Axes 坐标:子图左下角为
(0, 0),右上角为(1, 1); - Figure 坐标:整张图左下角为
(0, 0),右上角为(1, 1); - 显示坐标:最终窗口或图片中的像素坐标。
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,版面说明使用 transAxes 或 transFigure。如果把两者混用,文本可能在缩放后跑出图外,或者随着数据移动而不再位于预期位置。
六、比例尺、坐标范围与视觉判断
6.1 线性比例尺
在线性坐标中,数据值到屏幕距离近似满足:
其中:
x是数据值;p是屏幕上的位置;a是缩放因子;b是平移量。
数据差值越大,视觉距离越大。
6.2 对数比例尺
对数坐标先计算:
再把 u 映射到屏幕。这里:
x必须为正数;b是对数底;- 相同的倍数变化会产生相同的视觉距离。
例如,1 → 10 与 10 → 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 直方图:连续变量的分布
直方图先将数值轴划分为区间,再统计每个区间的观测数量:
其中 b_i 和 b_{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.5IQR和Q3 + 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()
这里:
x和y映射两个数值变量;hue使用颜色编码时间段;style使用点形编码性别;size使用点大小编码用餐人数;ax=ax让 Seaborn 绘制到指定的 MatplotlibAxes。
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_layout 和 constrained_layout 等布局机制;当前文档将 constrained_layout 描述为较新的布局引擎,通常比早期的 tight_layout 处理复杂布局更好。(matplotlib.org)
9.2 tight_layout 与 constrained_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()
这个例子可以逐步解释为:
- 每组每天生成 8 个观测,因此原始表中一行代表一个观测;
groupby(["group", "day"])将数据聚合到“组—天”粒度;mean是均值;std是样本标准差;sem = std / sqrt(n)是均值标准误;mean ± 1.96 × sem构造了近似区间;- 实线表达均值趋势;
- 半透明区域表达区间,而不是额外的一组观测;
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 之前调用。若程序已经初始化了后端,再切换可能失败或行为不一致。
诊断时应依次确认:
- 数据数组长度是否一致;
- 是否包含
NaN、无穷值或非法对数值; - 当前 Figure 和 Axes 是否是预期对象;
- 标签、图例和注释是否超出边界;
- 当前运行环境是否支持所需后端;
- 输出文件是否确实生成并可被目标程序打开。
十六、如何选择 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
这个结构包含几个重要边界:
- 输入列不存在时立即失败;
- 缺失值不被静默转换;
- 聚合逻辑可单独测试;
- 绘图函数只负责视觉表达;
- 输出路径由调用者传入;
- 返回
Figure和Axes,便于测试和进一步定制; - 保存使用当前函数创建的
fig,不会依赖全局当前图。
可视化代码的质量,不仅表现为“能生成图片”,还表现为数据粒度、统计变换、视觉编码和输出结果之间没有未说明的跳跃。
当读者能够从原始数据追踪到聚合表,再追踪到坐标、图形元素和最终结论时,这张图才真正具备可解释性。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:pandas 完整基础:Series、DataFrame、索引、缺失值、分组和连接
- 下一篇:scikit-learn 工程基础:Pipeline、预处理、训练、评测和持久化
- 延伸:Jupyter 工程化:Kernel、Notebook 状态、复现、参数化和安全
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论