Matplotlib绘图进阶




2021-12-20

blog_main_img

很多人用 `matplotlib`,停留在“能画图”这一层:`plot` 一下、`bar` 一下、`savefig` 一下,任务就算结束。 但一旦你开始做报告图、科研图、监控面板、批量导图,或者需要把风格、布局、导出过程统一起来,`matplotlib` 的真正价值才会慢慢露出来。

先把脑内模型摆正:FigureAxesArtist

官方文档里对 Artist 的定义非常关键:几乎所有你在图上接触到的对象,都是 Artist
Figure 是整张画布,Axes 是真正承载坐标系和数据的绘图区,而线条、文字、图例、矩形块、注释、刻度,统统都属于 Artist

这套模型的好处是:你不需要把每张图都写成一串“即时命令”,而是可以先拿到对象,再慢慢调。

Artist 模型

来看一段非常典型的对象式写法:

import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 2 * np.pi, 300)
y = np.sin(x)

fig, ax = plt.subplots(figsize=(8, 4), constrained_layout=True)
line, = ax.plot(x, y, color="#f97316", linewidth=2.5, label="sin(x)")
title = ax.set_title("Object-Oriented Matplotlib")
text = ax.text(0.02, 0.95, "peak-ready", transform=ax.transAxes, va="top")

ax.legend()
ax.grid(alpha=0.25)

# 后续可以继续精修 Artist
line.set_linestyle("--")
title.set_color("#7c2d12")
text.set_fontsize(11)

plt.show()

这段代码里:

  • fig 是整张图的容器
  • ax 是那块绘图区域
  • linetitletext 都是后续可继续修改的 Artist

很多“高阶感”,其实就来自这件事:你不再是“调用一个函数就结束”,而是在管理一组可编辑对象。

布局别再硬拼:GridSpecsubplot_mosaic

当图只剩一个坐标轴时,plt.subplots() 已经足够顺手。
但如果你要做主图 + 辅图、主图 + 残差图、左大右小、上宽下窄这类结构,仅靠普通二维数组布局就会开始别扭。

这时更推荐两套工具:

  • GridSpec:控制力强,适合精确切分
  • subplot_mosaic:语义清晰,适合用“布局草图”来搭图
import numpy as np
import matplotlib.pyplot as plt

fig = plt.figure(figsize=(10, 6), layout="constrained")
gs = fig.add_gridspec(2, 3)

ax_main = fig.add_subplot(gs[:, :2])
ax_top = fig.add_subplot(gs[0, 2])
ax_bottom = fig.add_subplot(gs[1, 2])

x = np.linspace(0, 10, 200)
ax_main.plot(x, np.sin(x), color="#ea580c")
ax_top.hist(np.random.randn(800), bins=30, color="#fdba74")
ax_bottom.scatter(np.random.randn(80), np.random.randn(80), s=18, alpha=0.7)

plt.show()

如果你更喜欢“看着结构写代码”,subplot_mosaic 会很舒服:

import matplotlib.pyplot as plt

fig, axd = plt.subplot_mosaic(
    [
        ["main", "stat"],
        ["main", "mini"],
    ],
    figsize=(10, 6),
    layout="constrained",
    width_ratios=[2.2, 1],
)

axd["main"].set_title("Main View")
axd["stat"].set_title("Distribution")
axd["mini"].set_title("Zoom")

plt.show()

这类写法的优势,不只是“更优雅”,而是后面维护时脑子更清楚。
当一张图开始承担解释任务时,布局本身就是信息结构的一部分。

真正能拉开差距的,是 transforms

很多人第一次看到 transform=ax.transAxes,会觉得它像个辅助参数。
其实这套坐标变换系统,是 matplotlib 最值得认真吃透的一部分。

常见的几种坐标语义可以简单理解成:

  • 数据坐标:跟着数据值走
  • 轴坐标:左下角是 (0, 0),右上角是 (1, 1)
  • 图坐标:相对于整张 Figure
  • 显示坐标:最终落到屏幕或输出设备上的像素/点位

布局与变换

最常见的高阶用法,是把“横向依赖数据、纵向固定在轴区域”的元素绑在一起。比如强调一个时间区间,不希望阴影跟着 y 值范围变化。

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.transforms as mtransforms

x = np.linspace(0, 20, 400)
y = np.sin(x) + 0.15 * np.random.randn(len(x))

fig, ax = plt.subplots(figsize=(9, 4), constrained_layout=True)
ax.plot(x, y, color="#f97316", linewidth=2)

mask = (x >= 6) & (x <= 10)
trans = mtransforms.blended_transform_factory(ax.transData, ax.transAxes)
ax.fill_between(x, 0, 1, where=mask, transform=trans, color="#fb923c", alpha=0.18)

ax.text(0.02, 0.95, "用轴坐标固定说明文字", transform=ax.transAxes, va="top")
ax.text(8, 0.92, "重点区间", transform=trans, ha="center", va="top")

plt.show()

这类技巧特别适合:

  • 做峰值区间标注
  • 给策略图加交易窗口
  • 在监控图里强调异常片段
  • 做带解释层的可视化封装组件

一旦明白不同坐标系分别负责什么,你就不容易写出“缩放一下,注释全跑偏”的图。

风格系统不是调色板,而是批量控制器

如果你常常写这种代码:

plt.plot(...)
plt.xticks(fontsize=12)
plt.yticks(fontsize=12)
plt.title(..., fontsize=18)
plt.grid(alpha=0.2)

那说明你正在手工重复“样式定义”。
更稳的方式,是把样式收进 rcParamsstyle.use()style.context() 里。

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt

custom = {
    "axes.facecolor": "#fff7ed",
    "figure.facecolor": "#fffbf5",
    "axes.edgecolor": "#9a3412",
    "axes.labelcolor": "#7c2d12",
    "xtick.color": "#7c2d12",
    "ytick.color": "#7c2d12",
    "grid.color": "#fdba74",
    "grid.alpha": 0.25,
    "axes.grid": True,
    "font.size": 11,
}

with mpl.rc_context(custom):
    x = np.linspace(0, 6, 200)
    fig, ax = plt.subplots(figsize=(8, 4))
    ax.plot(x, np.cos(x), color="#ea580c", linewidth=2.5)
    ax.set_title("Scoped style context")
    plt.show()

这个思路很适合做:

  • 团队统一图表风格
  • 一份代码导出多套主题
  • 论文图、报告图、后台图分别切风格
  • 在函数内部局部套用样式,不污染全局环境

如果你还想让文字压在复杂背景上更清楚,可以再加一点 path effects

import matplotlib.patheffects as pe

label = ax.text(0.5, 0.5, "signal", transform=ax.transAxes,
                ha="center", va="center", color="white", fontsize=16)
label.set_path_effects([
    pe.withStroke(linewidth=3, foreground="#7c2d12")
])

别小看这一步。很多“图看着精致”的原因,并不是数据更复杂,而是样式控制足够一致。

双轴、辅助轴、局部放大,用在刀刃上

高阶图里很容易出现一个误区:功能一多,就开始乱用双轴。
其实更稳的策略是:

  • 同量纲对比,优先同轴
  • 单位换算,用 secondary_xaxis / secondary_yaxis
  • 需要局部观察,用 inset 或单独小图

下面这个例子就比“强行 twinx”更克制一些:

import numpy as np
import matplotlib.pyplot as plt

def c_to_f(x):
    return x * 9 / 5 + 32

def f_to_c(x):
    return (x - 32) * 5 / 9

x = np.arange(0, 12)
y = np.linspace(18, 30, len(x))

fig, ax = plt.subplots(figsize=(8, 4), constrained_layout=True)
ax.plot(x, y, color="#f97316", linewidth=2.5)
ax.set_ylabel("temperature (C)")

secax = ax.secondary_yaxis("right", functions=(c_to_f, f_to_c))
secax.set_ylabel("temperature (F)")

plt.show()

这种写法很适合“只是换算单位,并不是另一套业务指标”的场景。

动画不是花活,它是可视化解释器

matplotlib.animation 经常被当成锦上添花,但它在很多场景下都很实用:

  • 展示训练过程
  • 展示时序数据滚动
  • 生成策略回放
  • 导出 GIF / MP4 给文档或页面使用

核心思路并不复杂:

  1. 先画一张初始图
  2. 把返回的 Artist 保存下来
  3. 在更新函数里调用这些对象的 set_* 方法
  4. 交给 FuncAnimation

动画与导出

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

x = np.linspace(0, 4 * np.pi, 300)
y = np.sin(x)

fig, ax = plt.subplots(figsize=(8, 4), constrained_layout=True)
ax.set_xlim(x.min(), x.max())
ax.set_ylim(-1.2, 1.2)
line, = ax.plot([], [], color="#ea580c", linewidth=2.5)

def update(frame):
    line.set_data(x[:frame], y[:frame])
    return (line,)

anim = FuncAnimation(fig, update, frames=len(x), interval=20, blit=True)

# 展示
plt.show()

# 导出时可按需要开启
# anim.save("wave.mp4", fps=30)

这里有个很容易被忽略的小点:动画对象最好保留在变量里,比如上面的 anim
否则你以为自己“已经创建了动画”,实际上它可能还没来得及正确显示或导出。

导出这一步,决定你的图是“能看”还是“能交付”

很多图在 notebook 里看着挺好,一导出就开始出问题:

  • 字被裁掉
  • 背景颜色丢了
  • 位图发虚
  • 矢量图体积偏大
  • 页面嵌入时透明层效果不对

可以先记住这几条:

  • 网页展示优先考虑 SVG 或适中 PNG
  • 打印或继续排版,PDF 很顺手
  • 有大量点云、热力图、复杂阴影时,位图有时比矢量更稳
  • bbox_inches="tight" 往往能减少边缘空白
  • dpi 影响位图清晰度,不影响矢量路径本身
fig.savefig("chart.svg", bbox_inches="tight")
fig.savefig("chart.png", dpi=220, bbox_inches="tight", facecolor=fig.get_facecolor())
fig.savefig("chart.pdf", bbox_inches="tight")

如果你的程序跑在无界面环境,比如任务调度、服务端批处理,记得关注后端问题。
matplotlib 的交互式后端负责屏幕显示,非交互式后端更偏向直接写文件;在纯导图场景里,后者往往更省心。

一段更完整一点的 Python 例子

下面这段小示例,把布局、样式、变换、导出串在一起:

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import matplotlib.transforms as mtransforms

mpl.rcParams.update({
    "figure.facecolor": "#fffaf5",
    "axes.facecolor": "#fff7ed",
    "axes.grid": True,
    "grid.alpha": 0.18,
    "axes.spines.top": False,
    "axes.spines.right": False,
})

rng = np.random.default_rng(7)
x = np.linspace(0, 30, 300)
y = np.sin(x / 2) + rng.normal(0, 0.12, len(x))

fig = plt.figure(figsize=(10, 6), layout="constrained")
axd = fig.subplot_mosaic(
    [
        ["main", "hist"],
        ["main", "mini"],
    ],
    width_ratios=[2.4, 1],
)

ax_main = axd["main"]
ax_hist = axd["hist"]
ax_mini = axd["mini"]

line, = ax_main.plot(x, y, color="#ea580c", linewidth=2.2)
ax_main.set_title("Matplotlib advanced workflow demo")
ax_main.set_xlabel("step")
ax_main.set_ylabel("value")

trans = mtransforms.blended_transform_factory(ax_main.transData, ax_main.transAxes)
focus = (x >= 10) & (x <= 16)
ax_main.fill_between(x, 0, 1, where=focus, transform=trans, color="#fb923c", alpha=0.18)
ax_main.text(13, 0.92, "focus zone", transform=trans, ha="center", va="top")

ax_hist.hist(y, bins=24, orientation="horizontal", color="#fdba74", edgecolor="#9a3412")
ax_hist.set_title("distribution")

ax_mini.plot(x, np.gradient(y), color="#c2410c")
ax_mini.axhline(0, color="#9a3412", linewidth=1, alpha=0.5)
ax_mini.set_title("gradient")

fig.savefig("matplotlib-advanced-demo.svg", bbox_inches="tight")
plt.show()

这类结构很适合封成你自己的绘图模板。
以后换数据,不换骨架,效率会明显提高。