一切都是 Artist,所以一切都能改
画图这件事最让人沮丧的地方,不是画不出来,是差一点点——图例挡住了数据、刻度标签太密、某条线该虚线、边框该去掉两条。然后你开始搜「matplotlib 怎么改 XXX」,抄一段 Stack Overflow,凑合用。这一章想给你的不是又一堆配方,是一个结构:图上的每一样东西都是同一种对象,你能把它们列出来、拿到手、改掉。会了这个,就不用再搜了。
fig, ax = plt.subplots() ax.plot([1, 2, 3], [2, 4, 3]) len(ax.get_children())
问:这张只有一条线的图,里面有几个对象?
三层结构,一个父类
matplotlib 的对象模型只有三层:
关键是那个共同的父类:matplotlib.artist.Artist。「凡是会出现在画布上的东西,都是一个 Artist。」这句话是字面意义上的:
isinstance(fig, Artist) True isinstance(ax, Artist) True isinstance(ax.lines[0], Artist) True
Figure 是 Artist,Axes 是 Artist,那条线也是 Artist——一路到底,没有例外。于是它们共享同一套接口:get_*/set_*、set_visible()、set_zorder()、set_alpha()、remove()。
把开头那张图的孩子数出来,本机结果是 11 个:
ax.get_children() 11 个 Spine × 4 上下左右四条边框 Text × 3 标题 + 两个轴标签(现在是空字符串) Line2D × 1 你画的那条线 Rectangle × 1 坐标区的背景 XAxis × 1 x 轴(刻度和标签在它里面) YAxis × 1
看到这份清单,一批「怎么改 XXX」的问题立刻有了答案:
ax.spines["top"].set_visible(False) # 去掉上边框
ax.spines["right"].set_visible(False) # 去掉右边框
ax.lines[0].set_linestyle("--") # 那条线改成虚线
ax.patch.set_facecolor("#FAFAF7") # 坐标区背景
ax.xaxis.set_major_locator(...) # 刻度密度
「去掉上边框和右边框」是所有数据可视化风格指南的第一条建议,而它在这里只是把两个 Artist 设成不可见。
不用记,问它就行
这一章真正想教的是怎么自己找答案。三个函数:
# 1. 这个 Axes 里有什么
for child in ax.get_children():
print(type(child).__name__, child)
# 2. 这个 Artist 能改什么 ← 最有用的一个
print(ax.lines[0].properties().keys()) # 本机 49 个属性
# 3. 某个属性的合法取值是什么
import matplotlib
print(matplotlib.artist.getp(ax.lines[0])) # 打印当前所有属性和值
本机上一条 Line2D 有 49 个可读属性——color、linewidth、linestyle、marker、markersize、alpha、zorder、label、xdata、ydata……每一个都有对应的 set_。你不需要记住它们,你只需要记住怎么把这个列表打出来。
这也解释了 matplotlib 那套看起来很啰嗦的关键字参数:ax.plot(x, y, color="red", lw=2) 里的每个关键字,其实就是在调 line.set_color("red")、line.set_linewidth(2)。关键字参数只是 setter 的语法糖。
zorder:谁盖住谁
既然什么都是 Artist,那画上去的顺序就得有个规矩。matplotlib 用 zorder(数字越大越靠上):
| zorder | 什么 |
|---|---|
| 0 | 坐标区背景、图片 |
| 1 | 填充区(fill_between) |
| 2 | 网格线、柱子、散点 |
| 2 | 折线 |
| 3 | 文本 |
| 5 | 图例 |
所以「网格线画在了数据上面」这种问题,改法是 ax.set_axisbelow(True),或者直接 ax.grid(zorder=0)。不是玄学,是一个数字。
28 种内置样式,先挑一个再动手改
在逐个改 Artist 之前,通常有更省事的一步:换一套默认值。matplotlib 自带 28 种样式:
print(plt.style.available) # 28 个
plt.style.use("seaborn-v0_8-whitegrid")
# 或者只在一段代码里生效
with plt.style.context("ggplot"):
fig, ax = plt.subplots()
...
样式文件本质上就是一份 rcParams 的覆盖表——上一章那 339 个键的子集。先换样式,再改剩下那两三处不满意的地方,这个顺序比一开始就手调每个 Artist 省很多力气。
Artist 这个词在别的图形库里叫别的名字,但概念是一样的:
- 浏览器里叫 DOM 节点——一棵树,每个节点能改样式、能删除。
- SVG 里叫 元素。
- Android 里叫 View/Drawable。
- 游戏引擎里叫 场景图节点。
它们共享同一套设计:一棵可遍历的树 + 每个节点的属性 + 一个决定遮挡顺序的层级值(CSS 的 z-index、matplotlib 的 zorder、Android 的 elevation)。会了一个,另外几个的心智模型是通用的。
还有两个容易混的:Patch 是「有填充区域的图元」(矩形、圆、多边形),Collection 是「一批同类图元打包在一起」(scatter 返回的是一个 PathCollection,不是几千个点对象——这是性能优化)。所以改散点的颜色要用 collection.set_facecolors(...),一次改一批。
这一章最值得跑的就是「把孩子列出来」这一段:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from collections import Counter
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [2, 4, 3])
kids = ax.get_children()
print(len(kids)) # 11
print(Counter(type(k).__name__ for k in kids))
# Counter({'Spine': 4, 'Text': 3, 'Line2D': 1, 'Rectangle': 1, 'XAxis': 1, 'YAxis': 1})
print(len(ax.lines[0].properties())) # 49
print(sorted(ax.spines.keys())) # bottom left right top
print(len(plt.style.available)) # 28
然后动手改几样,看它们真的生效:
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.lines[0].set_linestyle("--")
ax.lines[0].set_linewidth(3)
ax.set_axisbelow(True); ax.grid(True)
fig.savefig("out.png", dpi=150, bbox_inches="tight")
plt.close(fig)
python3 -c "import matplotlib;matplotlib.use('Agg');import matplotlib.pyplot as p;f,a=p.subplots();a.plot([1,2,3]);print(len(a.get_children()), len(a.lines[0].properties()))"
把 ax.plot 换成 ax.bar、ax.scatter、ax.hist,再数一次孩子。看清「这个函数到底往图上加了什么」,比记住它的参数有用得多。
- 浏览器的开发者工具,就是这一章的图形化版本。右键「检查元素」→ 看见 DOM 树 → 改一个属性 → 页面立刻变。
ax.get_children()是它的命令行版本,只是你得自己print。会用 DevTools 的人,学 matplotlib 的对象模型几乎不需要额外的心智负担。 - 为什么导出的 PDF 里文字可以选中。因为每个 Text 都是一个独立的 Artist,保存成矢量格式时它就是一个真的文本对象,不是像素。所以论文里的图应该存 PDF 或 SVG(矢量、可编辑、无限清晰),不是 PNG。
fig.savefig("x.pdf")。 - 「图例挡住数据」这个万年问题。图例的
zorder是 5,比数据高,所以它一定盖在上面。解法要么挪位置(ax.legend(loc="upper left", bbox_to_anchor=(1, 1))放到图外),要么调透明度(framealpha)。知道它是一个 Artist 之后,这就是一个普通的布局问题。 - 动画和交互的原理。
FuncAnimation每一帧做的事就是拿到那些 Artist、调set_data(...)、重画。之所以能高效,正因为不用重建整棵树,只改属性——和前端框架的「只更新变化的节点」是同一个思路。
「matplotlib 的默认样式很丑,能改的地方有限,想要好看的图只能用别的库。」
默认样式确实旧(它诞生于 2003 年,追求的是「像 MATLAB」),但能改的地方几乎没有限制——你在图上看到的每一个像素,都属于某个 Artist,都有对应的 set_。本机上光是一条线就有 49 个属性。
真正的门槛不是「能不能改」,是「怎么知道该改谁」。而这个问题有一个机械的答案:ax.get_children() 列出来,找到那个对象,.properties() 看它能改什么。这两行取代了绝大多数「matplotlib 怎么改 XXX」的搜索。
顺带说:seaborn、plotnine、pandas 的绘图全部构建在 matplotlib 之上,返回的也是 Axes。所以这一章的能力对它们全部有效——用 seaborn 画完,再拿 ax 去改细节,是最常见的实战组合。
正确答案是 C:11 个。4 个 Spine、3 个 Text、1 个 Line2D、1 个 Rectangle、1 个 XAxis、1 个 YAxis。
A 「就那条线」——低估了图上「你没画但确实存在」的东西:边框、背景、还没写字的标题。它们即使内容为空也是对象,所以随时能改。 B 「线 + 两根轴」——漏掉了四条 Spine。值得注意的是,Spine(边框)和 Axis(坐标轴)是两样东西:Spine 是那条黑线,Axis 管刻度和标签。所以「去掉边框」和「去掉刻度」要改的是不同的对象。 D 「上百个」——单个刻度确实也是对象(Tick),但它们装在 XAxis 和 YAxis 里面,不是 Axes 的直接孩子。get_children() 只列一层。要往下钻就 ax.xaxis.get_children()。这个答案的直觉是对的(树很深),只是数错了层。
get_*/set_*。
没人。改错了对象不会报错,图只是没变——最常见的症状是「我明明设了却没生效」,通常是设在了父节点上,或者被 zorder 更高的东西盖住了。自己检查:ax.get_children()、artist.properties()。
这一章的一句话
图上的每一样东西都是一个 Artist,组成一棵能遍历的树;所以「怎么改 XXX」永远有答案,而找答案的方法只有两行——把孩子列出来,把属性打出来。
下一章换一个方向。前两章讲的是「怎么把图画准」,而实际工作里更常见的需求是「按某一列分面、按另一列配色,一次画出十几张对齐的子图」。手写 matplotlib 要 10 行 418 个字符的循环;seaborn 要 2 行 95 个字符。而且加一个维度时,前者要重写循环,后者只改一个词。这个差距不是 API 好坏,是两种不同的抽象。