跳到正文

Matplotlib绘图基础:Figure 与 Axes 对象模型

Matplotlib 是 Python 可视化的底层库,seaborn、pandas 的 .plot()、很多论文里的图,最终都调用它。它的文档庞大、参数众多,新手最常卡住的地方不在参数,而在概念:Figure、Axes、Axis 三个词长得像,含义完全不同,不搞清楚就会写出「画了半天不知道画到哪去了」的代码。

同一个操作,Matplotlib 提供两套写法:

import matplotlib.pyplot as plt
# 写法一:pyplot 状态机
plt.plot([1, 2, 3, 4], [1, 4, 9, 16])
plt.title("pyplot 接口")
plt.show()
# 写法二:面向对象
fig, ax = plt.subplots()
ax.plot([1, 2, 3, 4], [1, 4, 9, 16])
ax.set_title("面向对象接口")
plt.show()

两者画出同样的图。区别在于 pyplot 维护了一套全局状态——「当前 figure」「当前 axes」,plt.plot() 其实是「在当前 axes 上画一条线」。单张图没问题,一旦开始写函数、循环、多子图,这个隐式的「当前」就变成了 bug 来源:函数内部的 plt.title() 会改到调用方正在画的那张图上,而且不报错。

面向对象写法里,figax 都是显式变量:可以当参数传进函数,可以返回,可以放进列表批量设置。Matplotlib 官方文档现在的示例几乎全部是这种写法,Stack Overflow 上的答案也是。从第一天就用 fig, ax = plt.subplots(),能省掉后面重构的时间。

如果你从 R 过来,ggplot2 的思路其实更接近面向对象:ggplot() 产生一个图形对象,可以存进变量、再叠加图层。Matplotlib 的 fig/ax 也是对象,只是要手动指定往哪里加内容,对照 /r/visualization/ggplot2-basics/ 看能更快建立映射关系。

名称 是什么 常用操作
Figure 整张画布,包含所有子图和文字 figsizedpifig.savefig()fig.suptitle()
Axes 一个坐标系(一张子图) ax.plot()ax.set_title()ax.legend()
Axis 坐标轴对象,管刻度和范围 ax.xaxisax.set_xlim()ax.tick_params()

三个词的复数是 Figure → Figures,Axes → Axes,Axis → Axes。axes 单复数同形,所以看到 axes 时要靠上下文判断是一个坐标系还是多个——多子图的代码里 axs 通常是数组,得用 axs[0]axs[1] 取。

画画的主体是 Axes,不是 Figure。Figure 只负责「多大、多清晰、存到哪」,内容全在 Axes 上。

iris 的四个特征里,花瓣长度和花瓣宽度的相关性最高,先确认一下再画散点图:

import matplotlib.pyplot as plt
from sklearn.datasets import load_iris
iris = load_iris(as_frame=True)
df = iris.frame
df["species"] = iris.target_names[df["target"]]
print(df[["petal length (cm)", "petal width (cm)"]].corr().round(3))
petal length (cm) petal width (cm)
petal length (cm) 1.000 0.963
petal width (cm) 0.963 1.000

相关系数 0.963,而且是同一朵花上量出来的两个量,画散点图合适。按物种分三组上色:

fig, ax = plt.subplots(figsize=(6, 4.5))
for name, sub in df.groupby("species"):
ax.scatter(
sub["petal length (cm)"],
sub["petal width (cm)"],
s=18,
alpha=0.8,
label=name,
)
ax.set_xlabel("Petal length (cm)")
ax.set_ylabel("Petal width (cm)")
ax.set_title("Iris: petal length vs petal width")
ax.legend(title="species", frameon=False)
fig.savefig("iris-scatter.png", dpi=300, bbox_inches="tight")
plt.show()
终端窗口
$ ls -lh iris-scatter.png
-rw-r--r-- 1 user user 184K Sep 22 10:14 iris-scatter.png

这张图上每个点是一朵花,三种颜色对应三个物种,setosa 聚在左下角、和另外两类完全分开——这正是后面所有分类模型都能轻松把 setosa 分出来的原因。virginicaversicolor 在花瓣宽度 1.5 到 1.8 之间有重叠,这也是逻辑回归在 iris 上唯一出错的地方。

目的 调用
折线 ax.plot(x, y, marker="o", linestyle="--")
散点 ax.scatter(x, y, s=大小, c=颜色, alpha=透明度)
柱状 ax.bar(x, height) / 横向用 ax.barh(y, width)
直方图 ax.hist(values, bins=30)
误差棒 ax.errorbar(x, y, yerr=std, capsize=3)
参考线 ax.axhline(y=0, color="grey", linewidth=0.8)
填充区域 ax.fill_between(x, lower, upper, alpha=0.3)
文字标注 ax.annotate("outlier", xy=(x, y), xytext=(x + 1, y))

方法命名有个小规律:ax.set_xxx() 是设置属性,ax.xxx() 是往图里加东西。所以设置标题是 set_title,而不存在 ax.title("...");设置坐标轴标签是 set_xlabel,不是 ax.xlabel。搜文档时如果发现某个方法找不到,先想想是不是漏了 set_ 前缀。

ax.set_xticklabels(["A", "B", "C"]) 是个例外中的坑:它只改标签不固定刻度位置,如果此时刻度数量对不上就会错位。稳妥写法是一次写全:

ax.set_xticks([0, 1, 2], labels=["A", "B", "C"])

这是中文用户遇到的第一个问题,原因是 Matplotlib 默认字体不含中文字形。解决办法是全局指定一个系统里真实存在的中文字体:

import matplotlib
from matplotlib import font_manager
print([f.name for f in font_manager.fontManager.ttflist if "CJK" in f.name][:5])
['Noto Sans CJK JP', 'Noto Sans CJK SC', 'Noto Sans CJK TC']
plt.rcParams["font.sans-serif"] = ["Noto Sans CJK SC"]
plt.rcParams["axes.unicode_minus"] = False

第二行是配套的:Matplotlib 的默认数学字体里没有 Unicode 减号(U+2212),关掉 unicode_minus 后负号改用 ASCII 连字符,避免坐标轴上的负数变成方块。

几个平台常见的字体名:Linux 是 Noto Sans CJK SCWenQuanYi Zen Hei,Windows 是 SimHeiMicrosoft YaHei,macOS 是 PingFang SCHeiti SC名字必须和系统里注册的完全一致,写错了 Matplotlib 只会默默回退到默认字体,图上的中文照样是方块,不会报错。用上面的 font_manager.ttflist 查一遍最保险;Linux 上也可以直接 fc-list :lang=zh 看装了哪些中文字体。

rcParams 的修改只对当前进程有效。写进项目里的做法是放在一个 plot_style.py 里,在需要画图的脚本开头 import 它。

fig.savefig("figure.png", dpi=300, bbox_inches="tight")
  • dpi:每英寸点数,默认 rcParams 里是 100。期刊一般要求 300 以上,线图建议更高。文件名后缀决定格式,fig.savefig("figure.pdf") 就是矢量图。
  • bbox_inches="tight":自动裁掉标签、图例之外的空白边距,不用手动调 subplot 参数。代价是不同图的最终尺寸会随内容变化,如果需要多张图尺寸严格一致(拼版、排版),就把它去掉,改用 fig.tight_layout() 调整。
  • 矢量格式(PDF、SVG、EPS)里文字和线条可无限缩放,投稿优先用;PNG 适合网页。给审稿人看的补充材料用 PDF 通常体积更小、更清晰。

保存和显示的顺序不能反:

fig.savefig("figure.png", dpi=300, bbox_inches="tight")
plt.show()

plt.show() 在脚本里会阻塞并把画布交给后端渲染,之后 figure 就不再是完整状态了。先 showsavefig,拿到的是空白图或者直接报错。Jupyter 里的 inline 后端会把图渲染成静态图片嵌进输出,不阻塞,但顺序仍然建议保持「先存后显」。

在 Jupyter 里重复运行同一个 cell,线越画越多。 状态机写法下 plt.plot() 每次都在同一个「当前 axes」上追加,跑十次就有十条线叠在一起,纵轴范围也被拉大。面向对象写法每次 plt.subplots() 都会新建画布,不存在这个问题。

图缩小后文字挤在一起。 字号是按点(point)算的绝对值,figsize 调小时字不会跟着缩,相对就显得大。别用 plt.rcParams["font.size"] = 6 硬压,先想想是不是图本身太挤,该拆成子图。

颜色写死成 "r""b" 三组以内还能看,超过五组就分不清了。用 ax.plot(..., color=plt.get_cmap("tab10")(i)) 或者交给 seaborn 的 hue 参数,配色和色盲友好性它已经处理过。

ax.legend() 不显示内容。 图例只认带 label= 的元素。用 scatter 时把 label 写在 ax.scatter() 里;如果元素是分别画的,也可以手动传:ax.legend(handles=[h1, h2], labels=["A", "B"])

基础图形能画了,但分组着色、分布比较这类统计图,用 seaborn 写会短很多。/python/visualization/seaborn/ 讲它和 Matplotlib 的分工。