数据可视化不只是“把图画出来”。一张合格的图需要回答三个问题:比较的对象是什么、变化或差异在哪里、读者应该得出什么结论。本文从 Matplotlib 的对象模型开始,用一组无需外部数据文件的示例,建立从选图到导出的完整工作流。

一、先理解 Figure、Axes 与 Artist

Matplotlib 图形可以用三层结构理解:

  • Figure:整张画布,负责整体尺寸、布局和导出;
  • Axes:画布中的一个绘图区,包含坐标轴、标题和刻度;
  • Artist:图上的具体元素,例如折线、散点、文字和图例。

日常绘图推荐从 plt.subplots() 获得 FigureAxes,再调用 ax.plot()ax.scatter() 等方法。相比把所有状态交给 pyplot,这种对象式写法在多子图和函数封装中更清晰。

二、根据问题选择图表

想回答的问题 推荐图表 常见误区
数值如何随时间或顺序变化 折线图 横轴没有自然顺序却强行连线
两个数值变量是否相关 散点图 点重叠严重但没有透明度
不同类别谁高谁低 柱状图 从非零基线开始夸大差异
连续变量如何分布 直方图 只看一个分箱数就下结论
多组分布和离群值有何差异 箱线图 把离群点直接当作错误数据
整体由少量部分如何构成 饼图 类别过多或数值非常接近

饼图只适合类别少、总和有明确意义的构成关系。需要精确比较时,排序后的条形图通常更易读。

选图实验

先选问题,再选图表

同一组数据可以画成很多图,但图表必须服从你要回答的问题。

三、创建第一张图

导入绘图模块

1
import matplotlib.pyplot as plt

使用对象式接口

1
2
3
4
5
6
7
8
9
10
11
12
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 2 * np.pi, 200)

fig, ax = plt.subplots(figsize=(8, 4), layout="constrained")
ax.plot(x, np.sin(x), label="sin(x)", linewidth=2)
ax.set(title="正弦函数", xlabel="x(弧度)", ylabel="y")
ax.grid(alpha=0.25)
ax.legend()

plt.show()

layout="constrained" 会为标题、坐标标签和图例自动预留空间,适合大多数常规布局。

创建子图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 2 * np.pi, 300)
fig, axes = plt.subplots(
nrows=2,
ncols=1,
figsize=(8, 6),
sharex=True,
layout="constrained",
)

axes[0].plot(x, np.sin(x), color="#4c78a8")
axes[0].set(title="sin(x)", ylabel="振幅")
axes[1].plot(x, np.cos(x), color="#e45756")
axes[1].set(title="cos(x)", xlabel="x(弧度)", ylabel="振幅")

for ax in axes:
ax.grid(alpha=0.2)

plt.show()

避免用 plt.subplot(221) 这类紧凑但不直观的数字写法。显式写出行列数,更容易调整布局。

中文字体与负号

Matplotlib 是否能显示中文取决于系统是否安装对应字体。不要把本机字体文件路径硬编码进可分享的代码,可以提供一组候选字体:

1
2
3
4
5
6
7
8
9
import matplotlib.pyplot as plt

plt.rcParams["font.sans-serif"] = [
"Microsoft YaHei",
"SimHei",
"Noto Sans CJK SC",
"Arial Unicode MS",
]
plt.rcParams["axes.unicode_minus"] = False

四、散点图:观察关系与异常点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
study_hours = rng.uniform(0.5, 8, 80)
score = 52 + 5.5 * study_hours + rng.normal(0, 7, 80)

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")
ax.scatter(
study_hours,
score,
s=45,
alpha=0.7,
color="#4c78a8",
edgecolors="white",
linewidths=0.5,
)
ax.set(title="学习时长与成绩", xlabel="每天学习时长(小时)", ylabel="成绩")
ax.grid(alpha=0.2)

plt.show()

alpha 可以减轻点重叠造成的遮挡。需要表达第三个连续变量时,可以把它映射到点大小或颜色,但要补充图例或颜色条。

五、折线图:表达连续变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import matplotlib.pyplot as plt

hours = list(range(0, 24, 2))
temperature = [15, 14, 14, 16, 20, 24, 27, 29, 28, 25, 21, 18]

fig, ax = plt.subplots(figsize=(8, 4), layout="constrained")
ax.plot(
hours,
temperature,
marker="o",
color="#e45756",
linewidth=2,
label="气温",
)
ax.set(
title="一天中的气温变化",
xlabel="时刻",
ylabel="温度(℃)",
xticks=hours,
)
ax.grid(axis="y", alpha=0.25)
ax.legend()

plt.show()

如果有多条线,应通过颜色之外的线型或标记再增加一种区分方式,避免颜色辨识困难或黑白打印后无法判断。

六、柱状图:比较离散类别

柱状图通常应从零开始,因为读者会通过柱子的长度判断大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import matplotlib.pyplot as plt

languages = ["Python", "C++", "Java", "Go"]
solved = [42, 31, 24, 18]

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")
bars = ax.bar(languages, solved, color="#72b7b2")
ax.bar_label(bars, padding=3)
ax.set(
title="不同语言完成的练习数",
xlabel="语言",
ylabel="题目数",
ylim=(0, 50),
)
ax.spines[["top", "right"]].set_visible(False)

plt.show()

当类别名称很长时,可使用 ax.barh() 绘制水平条形图,并把类别按数值排序。

七、直方图:查看连续变量的分布

直方图把连续数值划入若干区间。分箱太少会隐藏结构,太多则会放大随机波动,因此应尝试几种分箱方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(7)
response_time = rng.lognormal(mean=4.5, sigma=0.35, size=1000)

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")
ax.hist(
response_time,
bins="fd", # Freedman-Diaconis 规则
color="#f2cf5b",
edgecolor="white",
)
ax.set(title="接口响应时间分布", xlabel="响应时间(ms)", ylabel="请求数")

plt.show()

直方图回答“数据落在哪些区间”,而柱状图回答“每个离散类别是多少”。两者外观相似,但含义不同。

八、箱线图:比较多组分布

箱体从第一四分位数延伸到第三四分位数,中线表示中位数;默认“须”覆盖距离箱体不超过 1.5 × IQR 的数据点,更远的点作为离群点单独绘制。离群点提示需要检查,不代表它一定无效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(21)
groups = [
rng.normal(72, 8, 60),
rng.normal(78, 6, 60),
rng.normal(75, 10, 60),
]

fig, ax = plt.subplots(figsize=(7, 4), layout="constrained")
result = ax.boxplot(
groups,
tick_labels=["方案 A", "方案 B", "方案 C"],
patch_artist=True,
showmeans=True,
)

for box, color in zip(result["boxes"], ["#9ecae9", "#a1d99b", "#fdae6b"]):
box.set_facecolor(color)

ax.set(title="三种方案的得分分布", ylabel="得分")
ax.grid(axis="y", alpha=0.2)

plt.show()

Matplotlib 3.9 将箱线图的 labels 参数改名为 tick_labels,并在 3.11 移除了旧名称。新代码应使用 tick_labels

九、导出适合发布的图片

保存图片时应保留 fig 对象,并在 show() 之前调用 savefig()

1
2
3
4
5
6
fig.savefig(
"chart.png",
dpi=180,
bbox_inches="tight",
facecolor="white",
)
  • 网页图片通常用 PNG;
  • 需要无损缩放的线条图可以用 SVG 或 PDF;
  • dpi 主要影响栅格图的像素密度;
  • bbox_inches="tight" 可减少标题或图例被裁切的风险。

如果要在循环中批量生成大量图片,每轮保存后调用 plt.close(fig),避免未关闭的 Figure 持续占用内存。

十、一个可复用的绘图函数

将数据和样式放进函数时,让函数返回 fig, ax,调用方就能继续调整或决定保存位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import matplotlib.pyplot as plt


def plot_series(x, y, *, title, xlabel, ylabel):
fig, ax = plt.subplots(figsize=(8, 4), layout="constrained")
ax.plot(x, y, marker="o", linewidth=2)
ax.set(title=title, xlabel=xlabel, ylabel=ylabel)
ax.grid(alpha=0.25)
return fig, ax


fig, ax = plot_series(
[1, 2, 3, 4],
[3, 5, 4, 8],
title="指标变化",
xlabel="周期",
ylabel="值",
)
fig.savefig("metric.png", dpi=180, bbox_inches="tight")
plt.close(fig)

十一、发布前检查清单

  • 标题是否直接说明图表结论或主题;
  • 坐标轴是否有名称和单位;
  • 图例是否必要,名称是否准确;
  • 颜色是否有足够对比度,是否还用形状或线型辅助区分;
  • 柱状图是否从零开始,截断坐标轴是否明确说明;
  • 随机示例是否固定种子;
  • 导出的文字在手机或文档中是否仍然清晰;
  • 图表能否在不阅读正文的情况下被基本理解。

参考资料