2.1 多子图绘制 本节摘要:多子图就是把一张画布(Figure)切成多个绘图区域(Axes),让几个相关的图并排或交错地放在一起,方便横向比较。核心工具是 做规整网格、 做不规整布局,再配合共享坐标轴、间距、总标题这些细节。本节还会讲清两个关键选择:什么时候该规整、什么时候该不规整,以及共享坐标轴为什么能让比较更可靠。学完你能把原来要开三个窗口对比的三张图,收进一张画布里。 本节地图 阅读完本节,你应当能够: 说清 Figure 和 Axes 的关系,以及为什么多子图就是"一个 Figure 装多个 Axes" 用 创建任意行数、列数的规整子图网格 用 创建跨行跨列的不规整布局 用 、 让子图共享坐标轴,减少重复、方便对比 给子图和整张画布分别加标题,并处理间距和重叠
本节摘要:多子图就是把一张画布(Figure)切成多个绘图区域(Axes),让几个相关的图并排或交错地放在一起,方便横向比较。核心工具是
plt.subplots做规整网格、GridSpec做不规整布局,再配合共享坐标轴、间距、总标题这些细节。本节还会讲清两个关键选择:什么时候该规整、什么时候该不规整,以及共享坐标轴为什么能让比较更可靠。学完你能把原来要开三个窗口对比的三张图,收进一张画布里。
阅读完本节,你应当能够:
plt.subplots 创建任意行数、列数的规整子图网格GridSpec 创建跨行跨列的不规整布局sharex、sharey 让子图共享坐标轴,减少重复、方便对比我们先把动机说清楚。很多时候,数据是成组出现的:一家公司四个季度的营收、三支股票在同一时间段的走势、同一个实验在不同参数下的曲线。你可以为它们各画一张图,再在报告里并排贴四张;但更省事的做法,是让它们在一张画布上并列出现。
并列的好处不只是省版面。人眼对"并排的差异"比"翻页后的差异"敏感得多:两张图肩并肩,纵轴刻度对齐,谁的峰值更高、谁先见顶,一眼就能比出来。这就是多子图存在的根本理由——它把"比较"这件事变得可视化。
要理解多子图,先得回到第 1 章的对象模型。我们把 Figure 想象成一张画纸,Axes 是画纸上被切出来的一个个区域。过去你习惯用 plt.plot 直接画,其实是 Matplotlib 偷偷帮你建了一个 Figure 和一个 Axes。到了多子图,我们不再让库代劳,而是显式地"领一张画纸、切几个区域、逐个画"。
别急着背参数。先看一段能直接跑的最小代码,感受一下"一次领多个区"的手感。
import matplotlib.pyplot as plt import numpy as np x = np.linspace(0, 10, 100) fig, axes = plt.subplots(2, 2, figsize=(8, 6)) # 2 行 2 列,一次领 4 个 Axes axes[0, 0].plot(x, np.sin(x)) axes[0, 1].plot(x, np.cos(x)) axes[1, 0].plot(x, x ** 2) axes[1, 1].plot(x, np.log(x + 1)) fig.suptitle('四张函数图') # 整张画布的总标题 plt.tight_layout() plt.show()
plt.subplots(2, 2) 返回两个东西:fig 是那张画纸,axes 是一个二维数组,axes[0, 0] 到 axes[1, 1] 就是四个绘图区。之后你想在哪块画,就调用哪个 axes 的方法。这个"领区再画"的动作,是多子图与单图最大的区别。
这里有个容易踩的点:axes 的形状会随行列数变。一行三列时它是一维的,用 axes[0];两行两列时是二维的,用 axes[0, 0]。如果你希望无论几行几列都统一用二维下标,就把 squeeze 设为 False。我自己的习惯是:写循环批量画图时统一用 squeeze=False,这样 axes 永远是二维数组,遍历起来不会一会儿一维一会儿二维。
subplots 好用,但它只能切出"整齐的豆腐块"——每行每列一样宽高。现实里我们常常想要不规整的布局:上面一条宽宽的总览图,下面并排两个细节图;或者左边一个大图,右边摞两个小图。这时候就要请出 GridSpec。
GridSpec 的思路像装修时的"格子纸":先在纸上画一个 3 行 3 列的网格,然后告诉工人"第 1 行整行都归我""第 2 行前两列归我""最后一列竖着打通"。每个格子是逻辑位置,实际占多少面积由你说了算。
import matplotlib.gridspec as gridspec fig = plt.figure(figsize=(9, 7)) gs = gridspec.GridSpec(3, 3) # 先画一张 3x3 的格子纸 ax1 = fig.add_subplot(gs[0, :]) # 第 1 行整行 ax2 = fig.add_subplot(gs[1, :-1]) # 第 2 行,去掉最后一列 ax3 = fig.add_subplot(gs[1:, -1]) # 最后一列,从第 2 行往下 ax4 = fig.add_subplot(gs[2, 0]) ax5 = fig.add_subplot(gs[2, 1])
gs[0, :] 这种切片写法,和 Python 列表切片是同一个直觉:冒号表示"整段",:-1 表示"到倒数第二个为止",1: 表示"从第二个开始到最后"。一旦你习惯了这个语法,不规整布局就从"要查文档的难题"降级成"写几行切片的事"。

选哪个,取决于你心里那套"主次"。规整的 2 乘 2 适合四个地位对等的指标;不规整的总览加细节,适合"先给读者一张全景,再让他钻进两个局部"。我见过不少人为了炫技,用 GridSpec 拼出一个谁也看不懂的迷宫,这就本末倒置了——布局是为了服务"比较",不是为复杂而复杂。
把几张图排在一起后,立刻会冒出一个新问题:四张图各有各的纵轴,刻度还不一样,并排看反而费劲。这时候就该共享坐标轴。
subplots 的两个参数 sharex、sharey 就是干这个的。取值有 'all'、'row'、'col'、False。sharex='col' 表示同一列的图共享横轴,只有最下面一行显示横轴刻度;sharey='row' 表示同一行共享纵轴,只有最左边一列显示纵轴刻度。共享之后,图与图之间的刻度线被省掉,画面干净了,而且因为刻度一致,跨图比较才有意义。
fig, axes = plt.subplots(2, 2, figsize=(8, 6), sharex='col', sharey='row') # 只有最底行需要写 x 轴标签,最左列需要写 y 轴标签 axes[1, 0].set_xlabel('时间') axes[0, 0].set_ylabel('数值')
一个反直觉的地方是:共享坐标轴之后,你只需要在"代表格"上设置标签,Matplotlib 会自动同步。别傻乎乎地给四个子图都写一遍 set_xlabel,那样反而可能显示重复。
再谈间距和标题。子图一多,标题、标签就容易打架。plt.tight_layout() 能自动收紧间距,是我的默认动作;还不够就用 gridspec_kw 里的 hspace、wspace 手动调。总标题用 fig.suptitle,注意它常会和第一行子图的标题挤在一起,需要 plt.subplots_adjust(top=0.9) 给顶部留点呼吸空间。
tight_layout 之外,新版 Matplotlib 还提供了 constrained_layout,它比 tight_layout 更智能,能同时处理图例、颜色条这类元素的避让,画布里塞了颜色条或大图例时尤其好用。另一个常被忽略的细节是子图的宽高比例:gridspec_kw 里的 width_ratios 和 height_ratios 能让不同行、不同列占不同的宽度高度,比如让第一列宽一些放主图、第二列窄一些放辅助图。默认的等分网格,很多时候并不是你真正想要的——主图本该占更多版面,等分了反而主次不分。
还要提醒一句调用顺序:tight_layout 或 constrained_layout 要放在所有绘图和标题设置完成之后再调用,因为它需要知道最终的元素尺寸才能计算避让。如果你先调了布局、后面又加标题或图例,新加的元素就可能和已有内容重叠,而布局函数已经"算完了",不会回头再处理。
| 需求 | 手段 | 说明 |
|---|---|---|
| 规整网格 | plt.subplots(行, 列) |
返回 Figure 和 Axes 数组 |
| 不规整布局 | GridSpec 加切片 |
支持跨行、跨列 |
| 同列比趋势 | sharex='col' |
省掉中间横轴刻度 |
| 同行比量级 | sharey='row' |
省掉中间纵轴刻度 |
| 自动收紧间距 | plt.tight_layout() |
多数情况的默认动作 |
| 总标题 | fig.suptitle() |
记得给顶部留空间 |
多子图这个主题,代码不难,难的是"该不该用、怎么排"的判断。给你几条我自己的经验。
先定布局再动手。很多人上来就 subplots(2, 2),画到第三个图才发现"其实我想要上面一宽条加下面三小格"。改布局等于重写,所以动手前先在纸上把格子画出来,几行几列、谁跨谁,想清楚再敲代码。
子图数量别贪多。一张画布塞九个图,每个图小得像邮票,读者一个也看不清。我一般控制在六个以内;再多,就考虑拆成两张画布,或者干脆用动画滚动展示(第 2.5 节会讲)。信息密度高不等于信息传达好。
共享坐标轴要克制。共享能带来可比性,但如果两个图的数据量级差了好几个数量级(比如一个是百分比、一个是几百万),硬共享纵轴会让小量级那条线被压成一条贴地线。这种时候宁可不共享,各自用合适的刻度,再在图里标清楚单位。
关于接口,我倾向全部用面向对象的 axes 写法,而不是混着 plt 的旧式 subplot。面向对象写法里,每个图都明确落在哪个 axes 上,代码长一点,但读起来不会"这行到底画在哪"。多子图这种场景,最怕的就是接口混用导致的"画错区"。
最后补一句反过来的提醒:不是所有多图都要塞进一张画布。当几张图之间没有可比性、甚至量纲都不同,硬把它们并排,反而会让读者误以为它们可以横向比较。多子图服务于"比较",没有比较需求时,分开独立成图更诚实。
⚠️ 常见坑:用旧式
plt.subplot(2, 2, 3)时,索引从 1 开始且按行优先;而subplots返回的axes用二维下标且从 0 开始。两者混着记,最容易把图画错格子。我的建议是:彻底忘掉旧式写法,只用subplots和GridSpec,一套下标逻辑走到底。
💡 关键直觉:把多子图想象成"给一面墙排版画框"。先量好墙(Figure 尺寸),再画好每个画框的位置(布局),最后才往框里挂画(axes.plot)。顺序反了,就得反复拆框重来。
subplot 和 subplots 到底用哪个旧式的 plt.subplot(2, 2, 1) 一次只建一个子图,靠"从 1 开始、按行优先"的索引定位;新式的 plt.subplots(2, 2) 一次把全部子图建好,返回一个 axes 数组。功能上新写法完全覆盖旧写法,还更直观。我的建议是只用 subplots 和 GridSpec,把旧写法彻底放下,少记一套容易出错的规则。
subplots(1, 3) 返回的 axes 默认是一维数组,用 axes[0]、axes[1] 取;subplots(2, 2) 返回二维数组,用 axes[0, 0] 取。如果你嫌这种"一会儿一维一会儿二维"的规则麻烦,就统一加 squeeze=False,让 axes 永远是二维数组,遍历和索引的写法就固定下来了,写循环批量画图时尤其省心。
能。共享坐标轴只是让刻度和范围保持一致,并隐藏了重复的刻度标签,你仍然可以对任意一个 axes 调 set_xlim、set_ylim,只是改动会同步到共享该轴的兄弟子图。如果某个子图确实要"破例"独立,就单独把它建成 sharex=False,别为它影响整组。
三个办法依次试:先调大 figsize 给整张画布更多空间;再用 plt.tight_layout() 自动收紧间距;还不够,就用 gridspec_kw 里的 hspace、wspace 手动拉开。另外要提醒一句,子图超过六个时,先想想是不是该拆成两张画布,别硬塞——信息密度高不等于传达效果好。
这类"总览加细节"正是 GridSpec 的主场。先 GridSpec(2, 3) 画一张两行三列的虚拟格子,上面的大图占 gs[0, :] 整行,下面三个小图分别占 gs[1, 0]、gs[1, 1]、gs[1, 2]。要点是先想清楚虚拟网格的尺寸,再用切片把区域划出来,剩下的交给 tight_layout 收拾间距。
plt.subplots(行, 列) 一次领回 Figure 和 Axes 数组,逐个画即可。sharex、sharey 让刻度一致,比较才有意义。set_title 管子图,suptitle 管整张画布,注意留顶部空间。axes 接口,避免 plt 旧式写法导致"画错区"。布局定好了,下一个问题接踵而至:每个区里的图,颜色该怎么定?这就是第 2.2 节要解决的——颜色系统与色彩映射。