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() 会改到调用方正在画的那张图上,而且不报错。
面向对象写法里,fig 和 ax 都是显式变量:可以当参数传进函数,可以返回,可以放进列表批量设置。Matplotlib 官方文档现在的示例几乎全部是这种写法,Stack Overflow 上的答案也是。从第一天就用 fig, ax = plt.subplots(),能省掉后面重构的时间。
如果你从 R 过来,ggplot2 的思路其实更接近面向对象:ggplot() 产生一个图形对象,可以存进变量、再叠加图层。Matplotlib 的 fig/ax 也是对象,只是要手动指定往哪里加内容,对照 /r/visualization/ggplot2-basics/ 看能更快建立映射关系。
Figure、Axes、Axis
Section titled “Figure、Axes、Axis”| 名称 | 是什么 | 常用操作 |
|---|---|---|
| Figure | 整张画布,包含所有子图和文字 | figsize、dpi、fig.savefig()、fig.suptitle() |
| Axes | 一个坐标系(一张子图) | ax.plot()、ax.set_title()、ax.legend() |
| Axis | 坐标轴对象,管刻度和范围 | ax.xaxis、ax.set_xlim()、ax.tick_params() |
三个词的复数是 Figure → Figures,Axes → Axes,Axis → Axes。axes 单复数同形,所以看到 axes 时要靠上下文判断是一个坐标系还是多个——多子图的代码里 axs 通常是数组,得用 axs[0]、axs[1] 取。
画画的主体是 Axes,不是 Figure。Figure 只负责「多大、多清晰、存到哪」,内容全在 Axes 上。
一个完整的例子
Section titled “一个完整的例子”iris 的四个特征里,花瓣长度和花瓣宽度的相关性最高,先确认一下再画散点图:
import matplotlib.pyplot as pltfrom sklearn.datasets import load_iris
iris = load_iris(as_frame=True)df = iris.framedf["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.963petal 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 分出来的原因。virginica 和 versicolor 在花瓣宽度 1.5 到 1.8 之间有重叠,这也是逻辑回归在 iris 上唯一出错的地方。
常用图形一览
Section titled “常用图形一览”| 目的 | 调用 |
|---|---|
| 折线 | 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"])中文标签显示成方块
Section titled “中文标签显示成方块”这是中文用户遇到的第一个问题,原因是 Matplotlib 默认字体不含中文字形。解决办法是全局指定一个系统里真实存在的中文字体:
import matplotlibfrom 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 SC 或 WenQuanYi Zen Hei,Windows 是 SimHei、Microsoft YaHei,macOS 是 PingFang SC、Heiti SC。名字必须和系统里注册的完全一致,写错了 Matplotlib 只会默默回退到默认字体,图上的中文照样是方块,不会报错。用上面的 font_manager.ttflist 查一遍最保险;Linux 上也可以直接 fc-list :lang=zh 看装了哪些中文字体。
rcParams 的修改只对当前进程有效。写进项目里的做法是放在一个 plot_style.py 里,在需要画图的脚本开头 import 它。
保存高分辨率图片
Section titled “保存高分辨率图片”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 就不再是完整状态了。先 show 再 savefig,拿到的是空白图或者直接报错。Jupyter 里的 inline 后端会把图渲染成静态图片嵌进输出,不阻塞,但顺序仍然建议保持「先存后显」。
几个反复出现的坑
Section titled “几个反复出现的坑”在 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 的分工。