数据可视化不只是“把图画出来”。一张合格的图需要回答三个问题:比较的对象是什么、变化或差异在哪里、读者应该得出什么结论。本文从 Matplotlib 的对象模型开始,用一组无需外部数据文件的示例,建立从选图到导出的完整工作流。
Matplotlib 图形可以用三层结构理解:
- Figure:整张画布,负责整体尺寸、布局和导出;
- Axes:画布中的一个绘图区,包含坐标轴、标题和刻度;
- Artist:图上的具体元素,例如折线、散点、文字和图例。
日常绘图推荐从 plt.subplots() 获得 Figure 和 Axes,再调用 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", 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)
|
十一、发布前检查清单
- 标题是否直接说明图表结论或主题;
- 坐标轴是否有名称和单位;
- 图例是否必要,名称是否准确;
- 颜色是否有足够对比度,是否还用形状或线型辅助区分;
- 柱状图是否从零开始,截断坐标轴是否明确说明;
- 随机示例是否固定种子;
- 导出的文字在手机或文档中是否仍然清晰;
- 图表能否在不阅读正文的情况下被基本理解。
参考资料