ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Matplotlib多子图多曲线绘制:subplots布局与曲线实战指南

Matplotlib多子图多曲线绘制:subplots布局与曲线实战指南

1. 内容整体设计与思路拆解

1.1 为什么需要“一图多子图,一子图多曲线”

做数据可视化的时候,我经常遇到一个很尴尬的场景:手上有好几组实验数据,每一组又有多个对比项,如果全部塞进一张图,最后画出来就是一团乱麻。比如你有三组传感器数据,每组要对比原始值、滤波值和预测值,一次性画在同一张坐标轴里,线条颜色、图例、横纵轴量级全搅在一起,别说看趋势,连哪条线属于哪个实验都拎不清。

这种时候,最合理的方案就是“一张大图,里面放若干个子图,每个子图里再画多条曲线”。用专业一点的话说,这叫“多子图布局”。你不需要打开好几个窗口一个个对比,也不用在想看某一组数据时去翻找对应的图文件,所有信息集中在一张图里,横向或纵向排开,该有的曲线都在各自的位置上。Python里干这件事最顺手的工具就是Matplotlib,它的subplots接口就是为这种场景设计的。

用plt.subplots(nrows, ncols),一行代码就能生成一个包含nrows * ncols个子图的画布,返回一个fig对象和一个axes数组(也可能是多维数组)。然后你只需要按索引去每个子图里画曲线、设标题、调坐标轴,最后统一保存成一张图片。整个过程符合直觉,也方便以后用循环批量处理。

这篇文章要解决的问题很具体:怎么用Python在一张图片里生成多个子图片,并且每个子图片里同时显示多条曲线。我会从最基础的subplots方法讲起,结合具体代码示例逐步拆解,再分享一些我在实际画图过程中踩过的坑和排查经验。无论你是刚接触Matplotlib的新手,还是已经画过一些图但面对“多子图多曲线”需求时还有点发怵的开发者,这篇文章都应该能给你一个可以直接抄作业的模板。

1.2 方案选型:为什么是Matplotlib而不是其他库

Python画图的库很多,比如Plotly、Seaborn、Bokeh、pyqtgraph,甚至Pandas自带的plot接口。但在“多子图、多曲线、静态图片输出”这个场景下,我依然首选Matplotlib,原因非常现实。

第一,Matplotlib是Python生态里最基础、最通用的绘图库。你装了Anaconda或者常见的Python环境,它基本已经在了,不需要额外折腾。Seaborn本质上也是基于Matplotlib构建的,它提供的FacetGrid、relplot等高级功能,底层依然是多子图布局。你把Matplotlib的subplots用熟练了,再去学Seaborn或者Plotly,会轻松很多,反过来则未必。

第二,subplots这个API设计得很直观。你只需告诉它“我要2行3列的格子”,它就给你一个2行3列的坐标轴矩阵,每个坐标轴都是一个独立的绘图区域。你可以灵活地在任意一个区域里画线、打点、设标题、改坐标轴范围。这种“显式控制”的思路,比某些封装过度的库更让人心里有数。尤其是当你需要调试布局、调整间距、精确控制每个子图的元素时,直接操作axes对象比靠库自动推断要可靠得多。

第三,输出格式对大多数工作流足够友好。fig.savefig()可以输出PNG、JPG、PDF、SVG,甚至透明背景。实验中要出图、写报告要插图、网页里要展示,这些格式都够用。

当然,如果你是做交互动图,需要鼠标悬浮、缩放、点击联动,那Plotly、Bokeh更合适;如果你主要画统计图形且需要漂亮默认主题,Seaborn更好。但那就不是“多子图多曲线”这个场景的主要矛盾了。本文所有示例都围绕Matplotlib展开,这也是实际项目中最常见、最稳定、最不依赖环境的一种方案。

2. 核心细节解析与实操要点

2.1 subplots的完整参数:从nrows到constrained_layout

plt.subplots()看起来简单,但里面藏着不少实用参数。我先把最常用的几个列出来,再逐个说明怎么选。

import matplotlib.pyplot as plt fig, axes = plt.subplots(nrows, ncols, figsize=(width, height), sharex=False, sharey=False, constrained_layout=True)
  • nrows、ncols:行数和列数。比如想画4个子图,可以2行2列,也可以1行4列,根据内容排布习惯来定。
  • figsize:整个画布的大小,单位是英寸。这个参数很关键,子图多的时候画布太小,每个子图就会挤成一团;画布太大,线条和文字又显得空洞。我一般先估算每个子图需要的宽度和高度的总和,再乘上子图数量。
  • sharex、sharey:是否共享横轴或纵轴。设为True后,所有子图使用同一套坐标轴范围,并且如果你只显示一个子图的刻度标签,其他子图会自动隐藏,画面会干净很多。后面我会专门讲它的用法和坑。
  • constrained_layout:我给它极高评价。设为True后,Matplotlib会自动调整子图之间的间距、标题和标签的位置,尽量避免重叠。老版本里主要靠tight_layout(),新版本推荐直接在subplots里启用constrained_layout。实测下来,这个参数对新手极其友好。
  • squeeze:控制返回的axes数组形状。默认情况下,如果nrows=1, ncols=1,返回单个Axes对象而不是数组;nrows=1, ncols=3,返回一维数组;nrows=2, ncols=2,返回二维数组。如果不希望有这个“降维”行为,可以设squeeze=False,保证任何情况下都返回二维数组。这个参数在写通用函数时很有用。

举个例子,一个简单的2行2列画布,返回的axes就是2x2的二维数组。你可以用axes[0, 0]访问左上角子图,axes[1, 1]访问右下角子图。如果子图数量少,你也可以直接把axes展开成axes.ravel(),然后按顺序遍历。

2.2 每个子图里画多条曲线:plot与轴管理的底层逻辑

拿到某个子图后,画多条曲线的方式和画单张图没什么区别。以ax = axes[0, 0]为例:

x = [0, 1, 2, 3, 4] y1 = [0, 1, 4, 9, 16] y2 = [0, 0.5, 2, 4.5, 8] ax.plot(x, y1, color='#1f77b4', linewidth=2, label='曲线A') ax.plot(x, y2, color='#ff7f0e', linewidth=2, linestyle='--', label='曲线B') ax.legend()

每次调用ax.plot()就是往这个子图里追加一条曲线。Matplotlib默认会自动给每条线分配一个颜色,但如果线多了,或者需要特定的对比色,我建议手动指定color和linestyle。颜色和线型的选择不是小事,直接关系到读者能不能一眼分清曲线。一般我会遵循几个原则:

  • 同一子图里的多条曲线,尽量优先区分颜色;颜色相近时,用线型辅助区分,比如实线、虚线、点划线。
  • 如果是要突出“参考值”和“预测值”,参考值用浅色虚线,预测值用深色实线。
  • 如果子图数量很多,每个子图里的曲线含义是相似的,那最好让同一种含义的曲线在所有子图里用同一个颜色。比如每个子图都有“原始数据”,那“原始数据”统一用黑色,其他对比项用不同彩色。这样看图的人不需要反复去核对图例。

坐标轴范围可以单独设置,用ax.set_xlim()和ax.set_ylim()。注意,如果你设了sharex=True,那你修改任何一个子图的xlim,其他子图也会跟着变。这是共享轴的行为,有时候很方便,有时候很烦人,需要心里有数。

2.3 视觉装饰项:标题、标签、图例、网格

光有曲线还不够,一张能交付使用的图,必须有足够的上下文信息。每个子图要有标题,横轴要有物理量和单位,纵轴也一样,图例要清楚,最好还有网格线辅助读数。

子图标题用ax.set_title()。如果子图多,且它们的含义是系列编号,我建议标题写成“图1”、图2”或者“样本A”、“样本B”这样的短名。横纵轴标签用ax.set_xlabel()和ax.set_ylabel()。如果所有子图共享横轴且只设置一次,可以把sharex设为True,然后只保留最下面一行子图的xlabel,这样画面更干净。网格线用ax.grid(True, linestyle='--', alpha=0.6),我一般只用虚线和半透明,避免网格喧宾夺主。

图例麻烦一点。如果需要每个子图都有完整图例,直接在每个子图里调用ax.legend();如果每个子图里的曲线含义都一样,可以在整个画布上统一放一个图例,节省空间。统一图例的方法后面会专门讲。

还有一类是子图之间共享的标题,也就是整个画布的大标题。用fig.suptitle()设置,它显示在所有子图的上方。常见的用法是把“实验总名”放在全面,子图标题只写“批次1”、“批次2”。

2.4 中文字体与负号显示:先解决乱码再干活

这个问题几乎每个用Matplotlib画中文的人都会遇到。默认字体是不支持中文的,直接写plt.title("温度变化"),出来的是一个个方框。解决方法是手动指定中文字体,最常见的是配置rcParams。

import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'PingFang SC', 'Microsoft YaHei'] plt.rcParams['axes.unicode_minus'] = False

font.sans-serif里填系统支持的字体名称。Windows上常见的是SimHei(黑体)、Microsoft YaHei(微软雅黑);macOS上常见的是PingFang SC、Hiragino Sans GB;Linux上可能需要先用fc-list :lang=zh命令查看系统装了哪些中文字体。axes.unicode_minus设为False是为了让坐标轴上的负号能正常显示,否则负号会变成一个类似“横杠”的方块。

这个配置我建议放在画图脚本的最开头。如果你做的工作长期涉及绘图,还可以把它写进一份公共配置模块,每次画图直接导入。字体的选择也要考虑清晰度,正文类的小字号用黑体或雅黑比较稳,宋体在图上小字号时笔画太细,容易糊。

3. 实操过程与核心环节实现

3.1 一个可直接运行的完整代码示例

下面我用一个实际可以运行的数据生成场景,把整个流程串起来。假设我们有4个实验组,每组需要画3条曲线:原始数据、平滑数据、拟合数据。我们目标是一张2行2列的图,每个子图展示一个实验组的3条曲线。

import matplotlib.pyplot as plt import numpy as np # 全局中文字体配置 plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei'] plt.rcParams['axes.unicode_minus'] = False # 生成模拟数据 np.random.seed(42) x = np.linspace(0, 10, 100) groups = { '实验组A': {'base': 2.0, 'offset': 0.5}, '实验组B': {'base': 3.0, 'offset': 0.8}, '实验组C': {'base': 1.5, 'offset': 1.2}, '实验组D': {'base': 2.5, 'offset': 0.3}, } # 创建2行2列的画布,约束布局,共享横轴,不共享纵轴 fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharex=True, sharey=False, constrained_layout=True) # 将axes展平,方便按顺序遍历 ax_list = axes.ravel() for i, (name, params) in enumerate(groups.items()): ax = ax_list[i] # 生成三条曲线 y_raw = params['base'] * np.sin(x) + params['offset'] * np.random.randn(len(x)) y_smooth = np.convolve(y_raw, np.ones(5)/5, mode='same') # 简单滑动平均 y_fit = params['base'] * np.sin(x) # 理想拟合线 # 画三条曲线 ax.plot(x, y_raw, color='#d3d3d3', linewidth=1.2, label='原始数据') ax.plot(x, y_smooth, color='#1f77b4', linewidth=2, label='平滑数据') ax.plot(x, y_fit, color='#ff7f0e', linewidth=2, linestyle='--', label='拟合曲线') # 装饰 ax.set_title(f'{name} 数据对比') ax.grid(True, linestyle='--', alpha=0.5) # 只在最下面一行设置X轴标签 for ax in axes[-1, :]: ax.set_xlabel('时间 (s)') # 为所有子图统一设置Y轴标签 for ax in axes[:, 0]: ax.set_ylabel('数值') # 整个画布总标题 fig.suptitle('四组实验数据对比总览', fontsize=16) # 保存图片 fig.savefig('multi_subplots_multi_curves.png', dpi=150) plt.show()

这个代码运行后,会生成一张2行2列的大图,每个子图里有三条曲线,彼此之间互不干扰。你可以把groups替换成自己的真实数据集,其他结构基本不用动。

3.2 逐段拆解:数据生成、布局选择、循环绘制

先看数据生成部分。np.linspace(0, 10, 100)生成了100个等间距的横轴点。np.random.randn(len(x))生成了标准正态分布的随机噪声,乘上offset就得到每个实验组的噪声强度。这里刻意让每个实验组的base和offset不一样,这样四个子图画出来才有差异感,方便你观察曲线对比。

np.convolve(y_raw, np.ones(5)/5, mode='same')是滑动平均,用来模拟平滑数据。mode='same'保证输出长度和输入一致。这里np.ones(5)/5是长度为5的窗口,每个权重为0.2,相当于取前后相邻点加自身的平均值。滑动平均是数据平滑里最简单的方法,画图时能直观看到原始噪声被削弱的趋势。

然后是布局部分。plt.subplots(2, 2, figsize=(12, 8), sharex=True, sharey=False, constrained_layout=True),2行2列,画布宽12英寸、高8英寸。sharex=True表示四个子图共享横轴范围,之后再单独设置xlabel时,只需要在最后一行做,上面三行不显示刻度标签,标题下方区域不会被刻度数字占掉过多空间。sharey=False是因为四个实验组的数值范围可能差别较大,强制共享纵轴会让某几条曲线被压扁。

constrained_layout=True是这里最重要的布局参数。它会在绘制完成后,自动调整子图的间距和子图标题的位置。以前没有这个参数时,我常常需要手动调整fig.subplots_adjust(left=0.1, bottom=0.1, right=0.9, top=0.9, wspace=0.3, hspace=0.3),参数数值全靠试。用了constrained_layout以后,绝大多数情况下不用再手动调间距。

循环绘制部分注意一点:axes.ravel()把2x2数组展开成一维数组,顺序是从上到下、从左到右,也就是[axes[0,0], axes[0,1], axes[1,0], axes[1,1]]。如果你希望按列优先遍历,可以用axes.T.ravel()。groups的字典顺序在Python 3.7以后是插入顺序,所以遍历时不用担心顺序错乱。

3.3 输出图片前的最后设置:dpi、透明通道、尺寸权衡

fig.savefig()的参数比你想的更讲究。常用的几个:

  • dpi:每英寸点数。做电子版报告用dpi=150足够,要打印或者投放到大屏幕上建议dpi=300。注意dpi只影响输出文件的分辨率,不影响figsize所定义的物理尺寸。
  • bbox_inches='tight':可以裁掉图片周围多余的空白。但要注意,它与constrained_layout=True同时使用时,可能造成布局重新计算的效果,导致你原本希望保留的间距被压缩。
  • transparent=True:输出透明背景的PNG,用于网页叠加背景时很方便。
  • facecolor='white':如果不设置,默认背景颜色是白色,但在某些环境下可能变成透明或浅灰,建议显式指定。

保存格式方面,PNG适合大部分场景,PDF适合论文,SVG适合网页。如果保存时发现图片内容被截断,先检查bbox_inches和constrained_layout,不要一上来就疯狂扩大figsize。

3.4 循环里使用subplots的通用封装套路

如果你以后要经常画“多子图、多曲线”,我建议你写一个可复用的函数,把布局逻辑封装起来。这样每次只需要传数据列表和曲线配置,函数内部自动决定子图行列数。

def plot_multi_subplots(data_list, curve_specs, nrows=None, ncols=None): num = len(data_list) if nrows is None and ncols is None: nrows = int(np.ceil(np.sqrt(num))) ncols = int(np.ceil(num / nrows)) elif nrows is None: nrows = int(np.ceil(num / ncols)) elif ncols is None: ncols = int(np.ceil(num / nrows)) fig, axes = plt.subplots(nrows, ncols, figsize=(4*ncols, 3*nrows), constrained_layout=True) axes = np.array(axes).reshape(-1) # 统一转成一维 for i, data in enumerate(data_list): if i >= num: axes[i].axis('off') continue ax = axes[i] for spec in curve_specs: # spec = {'x':..., 'y':..., 'color':..., 'label':...} ax.plot(spec['x'][i], spec['y'][i], color=spec.get('color', None), linestyle=spec.get('linestyle', '-'), label=spec.get('label', '')) ax.legend() for i in range(num, len(axes)): axes[i].axis('off') return fig, axes

这个封装把两个重复劳动解决了:一是自动计算行列数,二是当数据数量小于子图数量时自动隐藏多余的子图。axis('off')会隐藏坐标轴、刻度、边框,只留下空白区域,比直接删除子图要简单。实际项目里遇到“数据只有7个,但子图是3行3列共9个”这种场景,多余的两个子图就能用这个办法处理掉。

4. 常见问题与排查技巧实录

4.1 子图之间重叠,标题被切掉一半

这是新手最容易遇到的事。症状是子图的标题和上一个子图的刻度标签挤在一起,或者最上方的子图标题冲出了画布边界。出现这个问题,先看几个地方:

  • 是否设置了constrained_layout=True。在plt.subplots()里加这个参数,能解决80%的重叠问题。
  • 是否在savefig时设了bbox_inches='tight',但没给constrained_layout留缓冲空间。bbox_inches='tight'会裁剪空白,有时反而让子图边缘紧贴图片边界,看起来像被切了。把bbox_inches去掉试试。
  • 是否手动调整了fig.subplots_adjust,并且数值不合理。比如hspace=0会让上下子图贴合,一般至少设0.2到0.5。

还有一个隐蔽情况:当子图标题太长时,即使布局自动调整,也可能会被裁剪。解决办法是在set_title时缩短文字,或者使用set_title(..., fontsize=10)让字体小一点。别想着靠布局参数硬撑,标题太冗长本身就不利于阅读。

4.2 坐标轴标签只显示一部分子图,但其他子图也需要

如果你设了sharex=True,Matplotlib默认只会把xlabel显示在画布最底部的子图上,上面的子图不显示刻度标签。如果你希望每个子图都显示完整的xlabel,有两个方案。

第一个方案是plt.subplots时不要用sharex,改成sharex=False,然后每个子图单独ax.set_xlabel()。这样每个子图都有完整刻度标签,缺点是占空间。

第二个方案是保留共享轴,但设置fig.supxlabel()。这个方法是整个画布的横轴标签,显示在所有子图的下方居中位置,并且不会在每个子图里重复出现。如果你只是想让整个图表示同一个横轴含义,用fig.supxlabel()会比每个子图都写一遍更专业。

实际项目里我偏爱sharex=True+fig.supxlabel()的组合,因为刻度标签只在底部出现,画布空间利用效率最高,同时不影响阅读。

4.3 图例重复显示,或者图例位置互相遮挡

当每个子图画的曲线具有相同标签时,如果你在每个子图里都调用ax.legend(),图例会重复出现。如果子图宽高比较小,图例还可能盖住曲线。解决办法有很多,我分别说一下。

如果想让每个子图都有自己的图例,但又要限制它的大小和位置,可以这样:

ax.legend(loc='upper right', fontsize=8, frameon=False)

小图例,不要边框,放在右上角,一般不会挡曲线主体。loc还可以选best、upper left、lower center等。

如果希望所有子图共享一个图例,放在整张大图下方或右侧,做法是先隐藏各子图的label,再在fig上统一添加。简单版本是全部子图绘制完成后,用fig.legend():

handles, labels = [], [] for ax in ax_list: h, l = ax.get_legend_handles_labels() handles.extend(h) labels.extend(l) # 去除重复项 unique = dict(zip(labels, handles)) fig.legend(unique.values(), unique.keys(), loc='lower center', ncol=3, fontsize=10)

这里用dict(zip(labels, handles))去重是常用技巧。因为多个子图里的曲线标签如果相同,图例会收集到重复的handles和labels,用字典重建后,每个标签只剩一个图例项。然后loc='lower center'把整个图例放在图片底部中央,ncol=3表示横向排3列。如果你把它放在savefig之前,布局还是由constrained_layout自动处理,一般不会冲突。

4.4 保存的图片和plt.show()显示的效果不一样

这是老版Matplotlib常见的坑。plt.show()打开的是一个带交互界面的窗口,渲染引擎和savefig不完全一致。最常见的是字体、坐标轴刻度密度、子图边距存在差异。解决方法有两个:

  • 在savefig之后再调用plt.show(),两者都做一遍,以savefig的输出为准。
  • 如果要让两者尽量一致,可以把plt.show()放前面,然后紧跟着fig.savefig(),并保证调用的是同一个fig对象。

这里要提醒一句:如果你在脚本里用了plt.show(),它默认会清空当前fig状态。如果之后还要保存,要么先保存再show,要么保存时使用捕获的fig对象而不是再调plt.savefig。我习惯的做法是:

fig, axes = plt.subplots(...) # 画图... fig.savefig('result.png') plt.show()

先savefig,再show,这样保存的结果不会被交互窗口影响。

4.5 坐标轴范围乱了,某几条曲线看不到

如果不同子图的数据量级差异很大,而你又设了sharey=True,就会出现“A子图曲线正常,B子图曲线被压成一条直线”的情况。要么关掉sharey,要么手动设置每个子图的ylim。手动设置可以这样:

ax.set_ylim(min_value, max_value)

如果想让范围自动适配,但给一点上下余量,可以先获取数据范围再扩展:

y_min, y_max = min(y), max(y) margin = 0.1 * (y_max - y_min) ax.set_ylim(y_min - margin, y_max + margin)

这个扩展余量尤其适合有多条曲线叠加的场景。比如正弦波基线在0附近,但某条曲线的尖峰特别大,自动范围会从尖峰到尖峰,中间的细节就看不清了,适当给余量反而能兼顾趋势和细节。

4.6 中文字体乱码,负号和特殊字符显示异常

前面提过字体配置。这里再补充一个常见坑:如果你在rcParams里设置字体后仍然显示方框,可能是该字体并没有被Matplotlib正确加载。这时先用下面的代码查看当前可用的字体:

import matplotlib.font_manager as fm font_names = [f.name for f in fm.fontManager.ttflist] print([name for name in font_names if 'Hei' in name or 'YaHei' in name or 'PingFang' in name])

如果列表里没有你想要的字体,说明系统没装,或者字体文件不在Matplotlib搜索路径里。解决方法要么安装字体,要么手动注册字体文件路径:

fm.fontManager.addfont('/path/to/your_font.ttf') plt.rcParams['font.family'] = 'your_font_name'

负号问题相对简单,axes.unicode_minus=False基本能解决。如果你的图里用了希腊字母、上下标,尽量用LaTeX语法,比如$\\alpha$表示α。记得在字符串前面加r,写成r'$\alpha$',避免反斜杠被转义。

5. 进阶玩法:从“能用”到“好用”

5.1 子图大小不等宽:使用gridspec_kw

标准subplots生成的子图都是等宽等高。但实际内容可能要求某些子图更宽或更高。比如左侧放一个长条形频谱图,右侧放两个上下排列的小图。这时可以用gridspec_kw参数,或者直接用GridSpec。

fig, axes = plt.subplots(2, 2, figsize=(12, 8), gridspec_kw={'width_ratios': [2, 1], 'height_ratios': [1, 1]})

width_ratios=[2, 1]表示第一列宽度是第二列的2倍。height_ratios同理。这样整体布局会更有针对性,而不是所有子图都平均分配。

如果你要更复杂的跨行跨列布局,GridSpec提供了subplot套路,但入门阶段先用width_ratios和height_ratios基本够用。

5.2 共享横轴的两个子图,横轴对齐但纵轴独立

这种图在信号处理里非常常见:上边是原始波形,下边是功率谱,上下两个子图共享横轴频率,但纵轴含义不同。使用sharex=True后,所有子图的横轴范围会联动。想要更精细地控制,可以用plt.subplots(2, 1, sharex=True),然后下方子图设置xlabel,上方子图隐藏刻度标签。这样一个“对齐”的效果就出来了。

关键点在于:如果你在绘制过程中调用ax.set_xticks()或ax.set_xlim(),共享轴的所有子图都会受影响。想只改某个子图而不影响其他,需要先解除共享关系,或者用make_axes_locatable创建新的轴,但那是更复杂的场景。日常画图时,只要意识到“共享轴就是全局的”这一点,就不会被突变行为吓到。

5.3 双Y轴:同一个子图里画两条量纲不同的曲线

有时一个子图里需要同时显示两个量级的曲线。比如温度曲线(在10到30之间)和湿度曲线(在40到90之间),直接共用Y轴会导致温度曲线被压得非常平缓。这时用双Y轴:

ax1 = axes[0, 0] ax2 = ax1.twinx() ax1.plot(x, temperature, color='red', label='温度') ax2.plot(x, humidity, color='blue', label='湿度') ax1.set_ylabel('温度 (°C)') ax2.set_ylabel('湿度 (%)')

注意twinx()创建的新轴共享X轴,但Y轴独立。图例制作时,两条轴的曲线会混在一起,需要手动合并图例:

lines1, labels1 = ax1.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')

这个技巧在“一个子图里画多条不同量纲曲线”时非常管用,但也要控制使用频率。如果三四种量纲不同,别都堆在同一个子图里,用多个子图各画各的反而更清晰。

5.4 鼠标交互与动画:为演示场景增添一点动态效果

静态图片能承担大部分报告工作,但如果你需要在演示现场切换展示,或者需要强调某一时刻的数据变化,可以给子图加上动画。Matplotlib的FuncAnimation支持多子图同步更新。这也是一个进阶方向,我简单说个思路。

from matplotlib.animation import FuncAnimation fig, axes = plt.subplots(2, 2) def update(frame): for i, ax in enumerate(axes.ravel()): ax.lines.clear() # 清除当前线 ax.plot(x[:frame], y_data[i][:frame]) return axes.ravel() ani = FuncAnimation(fig, update, frames=100, interval=50, blit=True) ani.save('animation.gif', writer='pillow')

动画的核心逻辑是每次更新时重绘每个子图的线条。注意blit=True能提高性能,但要求update函数返回所有需要更新的艺术家对象。这里只给一个骨架,真正常用的视频流或实时数据流,还要结合具体的数据源来设计更新函数。

6. 我的实操体会与最后几条建议

多子图多曲线画图这种需求,谁都会遇到,但不同人做出来的风格差别很大。我自己吃过不少亏,总结下来最值得分享的经验有三条。

第一条,布局参数不是玄学,先让constrained_layout=True帮你托底,再按需微调。很多人在旧教程里看到subplots_adjust的参数然后一顿乱试,其实新版本里大部分手动间距调整都可以被constrained_layout替代。你只需要在确实有特殊需求时,再去调整wspace、hspace。

第二条,颜色和线型要有全局规划,不要依赖默认循环。Matplotlib的默认颜色循环在2x2子图里画2到3条曲线时还能看,一旦超过4条,或者需要跨子图对比同一条曲线,就很容易混淆。我常用的做法是预先定义一个颜色字典,给每种数据含义固定一个颜色,整个项目里所有图的同一含义都用这个颜色。

第三条,画图脚本不要一次性写完。先把原始数据画出来,看坐标轴范围是否合理,再逐步加平滑、加拟合、加图例、加网格、调布局、保存。这个流程可以帮你更快定位问题,到底是数据问题还是绘图参数问题。我曾经一上来就直接写好全套代码,结果发现数据里有几个异常值把坐标轴拉得特别宽,曲线细节全看不见。后面我都是先画一遍散点或原始线,确认数据正常再继续处理。

回到最开始的问题:一张图片显示多个子图片,每个子图片显示多个曲线。Matplotlib的subplots就是为这个需求设计的,它并不神秘,也不需要背大量API。理解fig和axes的分工,掌握subplots的参数含义,再配合plot、legend、grid这些常规操作,绝大多数场景都能很快搞定。我这里给出的代码模板和排查清单,都是从实际项目里沉淀下来的,你直接复用,应该能少踩不少坑。如果后续遇到更复杂的布局需求,比如跨行子图、自由定位子图,可以在GridSpec方向深入学习,但那是另一个话题了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进