Matplotlib子图布局:多图排列与共享坐标轴
一张图讲一件事,一组图讲一个故事。论文里的组图、答辩用的对照面板,都需要把多张图排进同一张画布。Matplotlib 提供了三种方式:subplots 规则网格、subplot_mosaic 自定义版式、GridSpec 手工精确控制。日常需求前两种就够了。
subplots:规则网格
Section titled “subplots:规则网格”import matplotlib.pyplot as plt
fig, axs = plt.subplots(2, 2, figsize=(9, 6.5), constrained_layout=True)
print(type(axs), axs.shape)<class 'numpy.ndarray'> (2, 2)axs 是一个 numpy 数组(不是 Axes 列表),所以可以用 axs[0, 1] 取第二行第一列,也可以 axs.flat 展平后遍历。配 zip 一次设置多个子图是最常用的写法:
for ax, col in zip(axs.flat, ["a", "b", "c", "d"]): ax.set_title(col)plt.subplots() 的返回值会随网格形状变化,这是新手最容易踩的地方:
| 调用 | 返回的 axs |
|---|---|
plt.subplots(2, 2) |
形状 (2, 2) 的数组 |
plt.subplots(1, 3) |
形状 (3,) 的一维数组,用 axs[0] 取 |
plt.subplots() 或 plt.subplots(1, 1) |
单个 Axes 对象,不是数组 |
最后一行意味着 axs[0] 会报 TypeError: 'Axes' object is not subscriptable。如果一段代码要同时处理「一个子图」和「多个子图」两种情况(比如写通用绘图函数),创建时加 squeeze=False,强制始终返回二维数组:
fig, axs = plt.subplots(1, 1, squeeze=False)print(axs.shape)(1, 1)sharex 与 sharey
Section titled “sharex 与 sharey”sharex=True 让所有子图共用同一条 x 轴:只有最下面一行显示刻度标签,其余子图的标签自动隐藏;缩放或平移任意一个子图,其余子图跟着动。
fig, axs = plt.subplots(3, 1, figsize=(6, 6), sharex=True, constrained_layout=True)什么情况下该共享:子图之间横轴是同一个量——同一批样本的时间序列、同一批处理条件下的剂量梯度。横轴一致,读者才能竖着比较不同子图的高低。反过来,纵轴能不能共享要看量纲:三项指标分别落在 0 到 1、0 到 100、0 到 100000 时,共享 y 轴会把前两张压成贴着底边的直线。这时候只共享 x 就够了。
共享是双向的,也可以只共享一半:sharex="col" 只在同一列的相邻子图之间共享,sharey="row" 按行共享,多行多列的网格里用得上。
如果不想用共享,又想手动藏掉内圈子图的刻度标签:
for ax in axs[:-1]: ax.tick_params(labelbottom=False)labelbottom=False 只藏标签,刻度线还在,比 set_xticklabels([]) 干净。
figsize 与间距
Section titled “figsize 与间距”figsize 的单位是英寸,不是像素。它决定每个子图的实际大小,而字号是按点(point,1/72 英寸)算的绝对值——图变小,字不会跟着变小,于是标签开始互相压。与其反复调字号,不如先把 figsize 放大:2×2 的面板常见尺寸是 (9, 6.5) 或 (10, 8),每个子图大约 4.5×3 英寸。
间距问题交给 constrained_layout=True,它在创建子图时就算好留给标题、轴标签和图例的空间,并在绘制时重新分配。等价的写法是画完之后调用 fig.tight_layout(),区别是后者在子图内容变化后需要重新调用,前者会自动重算。
两个注意事项:不要同时用 constrained_layout 和 tight_layout,两种布局算法会互相覆盖;constrained_layout 与手动添加的 fig.add_axes()(比如自己放 inset 小图)配合时可能算错边距,这种情况下改回手工调 fig.subplots_adjust()。
fig.suptitle() 是画布级标题,用它在整组图上方加一行总标题;constrained_layout 会自动为它留出空间,不用手动 fig.subplots_adjust(top=0.85)。
subplot_mosaic:把版式写成字符串
Section titled “subplot_mosaic:把版式写成字符串”规则网格不够用时(比如想让第二列的图占满整个高度),subplot_mosaic 比手算行列号直观得多:
fig, axd = plt.subplot_mosaic( [ ["A", "A", "B"], ["C", "D", "B"], ], figsize=(9, 5), constrained_layout=True,)
print(type(axd), list(axd))<class 'dict'> ['A', 'B', 'C', 'D']版式直接用字符串描述:同一个标签出现几次就占用几个格子,"B" 在右侧两行都出现,所以整列合并成一张大图。也可以写成多行字符串 "AAB\nCDB",效果一样,版式一眼能看出来。留空的位置用 "." 占位。返回值是字典,用 axd["A"] 取,比 axs[0, 0] 可读得多,改布局时也不容易取错。
需要更自由的控制(子图之间精确的宽高比例、手动指定每个格子占几行几列)就用 GridSpec:
grid = fig.add_gridspec(2, 3, width_ratios=[2, 1, 1], height_ratios=[1, 2])ax_top = fig.add_subplot(grid[0, :])ax_bl = fig.add_subplot(grid[1, 0])width_ratios 和 height_ratios 是 subplot_mosaic 没有的能力。代价是行列号要自己数,改一次布局得重新核对所有索引。
双坐标轴 twinx
Section titled “双坐标轴 twinx”两种量纲差很远的量想画在一起(温度与降水量、浓度与吸光度),可以用 twinx() 生成一个共享 x 轴、独立 y 轴的孪生坐标系:
import numpy as np
days = np.arange(1, 13) # 月份temp = np.array([2.1, 3.4, 8.2, 14.5, 19.8, 24.1, 26.7, 25.9, 21.3, 15.2, 8.4, 3.1])rain = np.array([18, 24, 41, 52, 66, 118, 176, 152, 78, 44, 30, 20])
fig, ax1 = plt.subplots(figsize=(6, 4), constrained_layout=True)
ax1.plot(days, temp, color="tab:red", label="温度")ax1.set_ylabel("Temperature (C)", color="tab:red")ax1.tick_params(axis="y", labelcolor="tab:red")
ax2 = ax1.twinx()ax2.bar(days, rain, color="tab:blue", alpha=0.35, label="降水")ax2.set_ylabel("Precipitation (mm)", color="tab:blue")ax2.tick_params(axis="y", labelcolor="tab:blue")ax2.grid(False) # 两套网格线叠在一起会糊成一片ax1 和 ax2 各自只有一套图例,想合并成一张得手动收集:
h1, l1 = ax1.get_legend_handles_labels()h2, l2 = ax2.get_legend_handles_labels()ax1.legend(h1 + h2, l1 + l2, loc="upper left", frameon=False)把 y 轴刻度染成和曲线相同的颜色,是双轴图的基本礼貌——读者一眼知道哪条线对应哪个刻度。
**不过双轴图要慎用。**两条线的交点完全由两套刻度的缩放方式决定,换个 y 轴范围交点就跑到别处去了,它不代表任何实际意义,但读者几乎必然会去解读它。审稿人要求改成「上下两张共享 x 轴的子图」是常见意见。只有当两个量确实需要同时读数、且量纲关系稳定时才用 twinx;否则老老实实 plt.subplots(2, 1, sharex=True)。
twiny() 是另一半:共享 y 轴、新增 x 轴,用途更少。
把多张图保存成一张
Section titled “把多张图保存成一张”savefig 属于 Figure 对象,调用一次就把整张画布(所有子图、总标题、图例)写成一张图。完整例子,四联面板:
import matplotlib.pyplot as pltfrom sklearn.datasets import load_iris
iris = load_iris(as_frame=True)df = iris.frame.copy()df["species"] = iris.target_names[df["target"]]features = list(iris.feature_names)
fig, axs = plt.subplots(2, 2, figsize=(9, 6.5), sharex=True, constrained_layout=True)
for ax, col in zip(axs.flat, features): for name, sub in df.groupby("species"): ax.scatter(sub["petal length (cm)"], sub[col], s=14, alpha=0.75, label=name) ax.set_title(col, fontsize=10) ax.set_xlabel("petal length (cm)")
axs.flat[0].legend(fontsize=8, frameon=False, loc="upper left")fig.suptitle("iris 各特征与花瓣长度的关系", fontsize=13)fig.savefig("iris-panel.png", dpi=300, bbox_inches="tight")
print(df[features].std().round(3))sepal length (cm) 0.828sepal width (cm) 0.436petal length (cm) 1.765petal width (cm) 0.762dtype: float64四个特征里花瓣长度的标准差最大(1.765),意味着它在三个物种间的区分度最好,面板上那一格的三团点分得最开;花瓣宽度标准差最小(0.762),但它的组间差距同样明显,所以两个花瓣特征才是分类模型真正依赖的变量。四个子图共享 x 轴之后,同一列的点在横向位置上可以直接比较。
Axes 没有 savefig 方法。只想要其中一个子图,最省事的做法是把它单独画进一个新的 figure,而不是想办法从大图里裁剪——裁剪出来往往还带着被切掉一半的标签。
需要按组批量出图时,循环里每次给 savefig 一个不同的文件名:
for name, sub in df.groupby("species"): fig, ax = plt.subplots(figsize=(5, 4)) ax.hist(sub["petal length (cm)"], bins=12) ax.set_title(name) fig.savefig(f"hist-{name}.png", dpi=200, bbox_inches="tight") plt.close(fig)循环末尾的 plt.close(fig) 不要省。脚本里连续创建大量 figure 而不关闭会一直占着内存,画到几十张之后容易变慢甚至被杀掉;在 Jupyter 里则是堆出一屏已经不需要的画布,还拖慢滚动。
分面(facet)这件事,ggplot2 用 facet_wrap() 和 facet_grid() 一行解决,切分变量直接写进函数调用;Matplotlib 需要在 Python 里手写循环。两种做法的适用场景不同——需要精细控制每个子图时,循环反而更灵活。R 语言的 /r/visualization/facets/ 讲的是分面的写法,对照着看能判断哪种更适合手上的任务。
到这里 Python 可视化的基础部分就齐了:Matplotlib 负责精确控制,seaborn 负责统计图形,子图负责组织版面。再往下是数据操作的深水区,/python/pandas/groupby-agg/ 讲分组聚合,那是把原始数据变成图里那几团点之前必须走的一步。