seaborn 不是美化,是图形语法
seaborn 常被介绍成「matplotlib 的美化版」——配色好看,默认值合理。这个说法把它最重要的部分漏掉了。seaborn 真正做的事是换掉了你和图之间的那门语言:你不再说「画一条线、再画一条线、再加个图例」,你说「x 是这一列,y 是这一列,颜色是那一列,分面是另一列」。这一章要说清这个转变值多少钱,以及它对数据形状提出了什么要求。
一张小费数据表:消费额、小费、星期几、抽不抽烟。你要画四张并排的散点图(每个星期一张),每张里用颜色区分抽烟与否,y 轴共享。
问:手写 matplotlib 和用 seaborn,各要几行?
顺手再猜:如果要再加一个维度(抽烟与否也分面,变成 2 × 4 共 8 张图),两边各要改多少?
先把两段代码摆出来
手写版本:
fig, axes = plt.subplots(1, 4, figsize=(16, 4), sharey=True)
for ax, day in zip(axes, ["Thu", "Fri", "Sat", "Sun"]):
sub = tips[tips.day == day]
for smoker, color in [("Yes", "C0"), ("No", "C1")]:
s = sub[sub.smoker == smoker]
ax.scatter(s.total, s.tip, c=color, label=smoker, alpha=0.7)
ax.set_title(day)
ax.set_xlabel("total")
axes[0].set_ylabel("tip")
axes[-1].legend(title="smoker")
seaborn 版本:
sns.relplot(data=tips, x="total", y="tip",
col="day", hue="smoker", kind="scatter")
matplotlib 10 行 418 个字符 seaborn 2 行 95 个字符 两个版本都得到 4 个 Axes
行数只是表象。真正的差别在你写下的是什么:
| 手写版本里你写了 | seaborn 版本里你写了 |
|---|---|
| 创建几个子图、多大、共享哪根轴 | col="day" |
| 遍历星期,每次过滤一次数据 | (同上) |
| 遍历抽烟状态,手工分配颜色 | hue="smoker" |
| 给每个子图设标题、设 x 标签 | (自动,用列名) |
| 只给第一个设 y 标签、只给最后一个加图例 | (自动) |
左边写的是「怎么画」,右边写的是「什么映到什么」。这就是图形语法(grammar of graphics)——一套 1999 年提出、经 R 的 ggplot2 发扬光大的抽象。
核心只有一句话:列 → 通道
「视觉通道」就是一张图上能承载信息的那些维度。seaborn 提供七个:
| 参数 | 通道 | 适合什么 |
|---|---|---|
x/y | 位置 | 最精确的通道,留给最重要的两个变量 |
hue | 颜色 | 类别(3–7 个)或连续量 |
size | 大小 | 连续量,但人眼分辨力差 |
style | 形状/线型 | 类别,少于 5 个 |
col/row | 分面 | 类别;把「叠在一起看不清」变成「并排比」 |
于是「怎么画一张更复杂的图」这个问题,变成了「我有几个变量,各自适合哪个通道」。这是一个能思考的问题,而不是一个要查文档的问题。
加一个维度,只改一个词
回到第二个猜测。要把 4 张图变成 2 × 4 的 8 张:
sns.relplot(data=tips, x="total", y="tip",
col="day", row="smoker", kind="scatter")
# ^^^^^^^^^^^^ 把 hue 换成 row,一个词
改前 4 个 Axes 改后 8 个 Axes
手写版本要做什么?加一层循环、重算 subplots 的行列数、重新安排哪些子图要 x 标签、图例挪到哪、共享轴怎么设。基本是重写。
这就是这一章的判断:
声明式抽象的价值不在于「第一次写起来短」,在于「改起来的成本和改动的大小成正比」。
手写版本里,「加一个分面维度」是一个结构性改动(要动循环、动布局);声明式版本里,它是一个词的改动,因为分面本来就是一个参数。
同一条判断在 SQL 对手写循环、Compose 对命令式 View、React 对 jQuery 上都成立。这本书第 1 章那张跨领域对照表,在这里第二次兑现了。
代价一:它要求长表
col="day" 能写出来,前提是「day」真的是一列。这就是第 11 章那一整章的意义所在。
如果你的数据是宽表(每个星期一列),你根本填不进 col=——不是报错,是没有东西可填。这时只有两条路:melt 成长表,或者退回手写循环。
所以有一条很实用的工作流:看到一张图不知道怎么画,先问「我的数据是不是长表」。很多时候画不出来不是不会画,是形状不对。
代价二:两级 API,返回的东西不一样
seaborn 有一个容易踩的坑:它的函数分两级,返回值不同。
| 级别 | 例子 | 返回 | 能不能收 ax= |
|---|---|---|---|
| Axes 级 | scatterplot、lineplot、boxplot、histplot | 一个 Axes | 能 |
| Figure 级 | relplot、catplot、displot、lmplot | 一个 FacetGrid | 不能 |
规律很好记:名字里带 plot 后缀的是 Axes 级,带 relplot/catplot/displot 这种「短名 + plot」的是 Figure 级。只有 Figure 级支持 col=/row=,因为分面需要控制整张 Figure。
踩坑的样子是这样的:
fig, ax = plt.subplots() sns.relplot(data=tips, x="total", y="tip", ax=ax) # ← 报错,没有 ax 参数 sns.scatterplot(data=tips, x="total", y="tip", ax=ax) # ✓
而 Figure 级函数画完之后,还是能拿到 Artist 去改细节(第 13 章那套能力全部有效):
g = sns.relplot(data=tips, x="total", y="tip", col="day")
g.figure # 拿到 Figure
g.axes.flat # 遍历每个 Axes
g.set_axis_labels("消费额", "小费")
g.set_titles("{col_name}")
图形语法(The Grammar of Graphics)是 Leland Wilkinson 1999 年一本书的名字。核心主张是:一张统计图可以被分解成几个正交的部件——数据、映射(哪一列到哪个通道)、几何对象(点/线/柱)、统计变换(求均值/做核密度)、标度(怎么把数值换成像素和颜色)、分面、坐标系。
Hadley Wickham 2005 年把它实现成了 R 的 ggplot2(gg = grammar of graphics),从此这套思路统治了统计绘图。seaborn 是它在 Python 里的一种实现;plotnine 是更直译的一种;Vega-Lite 和 Observable Plot 是它在 Web 上的实现。
值得知道的是 seaborn 有两套 API:本章讲的是经典的函数式接口(relplot 一族),另外还有一套更接近 ggplot2 的对象式接口(seaborn.objects,2022 年引入,用 so.Plot(...).add(...) 链式调用)。后者更纯粹,但生态还没跟上,日常仍以前者为主。
seaborn 自带示例数据,但要联网下载。下面这段自己造一份,离线可跑:
import numpy as np, pandas as pd, seaborn as sns
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
rng = np.random.default_rng(14)
tips = pd.DataFrame({
"total": rng.gamma(4, 5, 400).round(2),
"day": rng.choice(["Thu", "Fri", "Sat", "Sun"], 400),
"smoker": rng.choice(["Yes", "No"], 400),
})
tips["tip"] = (tips["total"] * rng.uniform(0.10, 0.25, 400)).round(2)
g = sns.relplot(data=tips, x="total", y="tip", col="day", hue="smoker",
kind="scatter", col_order=["Thu", "Fri", "Sat", "Sun"])
print(len(g.figure.axes)) # 4
g.figure.savefig("a.png", dpi=120); plt.close("all")
g2 = sns.relplot(data=tips, x="total", y="tip", col="day", row="smoker")
print(len(g2.figure.axes)) # 8 ← 只改了一个词
g2.figure.savefig("b.png", dpi=120); plt.close("all")
然后把 kind 换成 "line",把 relplot 换成 catplot(kind="bar"/"box"/"violin"),其余参数一个不动。这一点最能说明「语法」的含义:几何对象换了,映射没换。
python3 -c "import seaborn as sns;print(sns.__version__)"
装:pip install seaborn(会带上 matplotlib 和 pandas)。想试自带数据集要联网:sns.load_dataset("tips")。
- Tableau、Power BI 的拖拽界面,就是图形语法的鼠标版。你把字段拖到「列」「行」「颜色」「大小」这些格子里——那些格子正是这一章的通道。理解了
hue和col,你也就理解了那些 BI 工具的核心模型。Tableau 的创始人之一 Chris Stolte 的博士论文,引的就是 Wilkinson 那本书。 - Vega-Lite 把这套语法写成了 JSON。一张图变成一份可以传输、可以生成、可以让大模型直接写出来的声明。声明式的另一个好处在这里显现:它是数据,不是代码,所以能被程序处理。
- CSS 与手写绘制的关系也是这个。你写
color、font-size,浏览器决定怎么画每个像素。差别同样是「说要什么」对「说怎么做」。 - 为什么数据分析岗位的 R 用户看不上 matplotlib。因为
ggplot2从 2007 年起就是纯粹的图形语法,而 Python 这边直到 seaborn 成熟才补上。这也是 Python 数据栈的一个普遍模式:底层强,上层的概念抽象常常从 R 那边学。第 11 章的 tidy data、第 9 章的 split-apply-combine,来源是同一个人。
「seaborn 就是 matplotlib 加了好看的默认配色,学不学都行,反正底层还是 matplotlib。」
配色只是副产品。seaborn 换掉的是你和图之间的语言:从「画一条线、再画一条线」变成「哪一列映到哪个通道」。这个转变的收益不在第一次写(10 行变 2 行),在每一次修改——加一个分面维度,一边是重写循环,一边是改一个词。
而这个转变对你的数据形状提出了硬要求:所有通道参数收的都是列名,所以数据必须是长表。「seaborn 画不出我想要的图」十次里有七次,真正的原因是数据还是宽表。
反过来说,也别指望 seaborn 取代 matplotlib:它负责「说什么」,matplotlib 负责「怎么画」。实战里最顺的组合是 seaborn 出结构、拿到 ax 之后用第 13 章那套改细节。两者不是竞争关系,是两层。
正确答案是 B:matplotlib 10 行(418 字符),seaborn 2 行(95 字符)。
加一个维度:seaborn 改一个词(4 个 Axes → 8 个),matplotlib 基本重写。
sns.scatterplot 和 ax.scatter 差不多长。差距是被分面和配色拉开的——也就是那些「要遍历、要分配、要布局」的部分。越复杂的图,差距越大。
C 「seaborn 更长」——参数确实多,但每个参数替换掉的是好几行手工劳动。而且这些参数是正交的:hue 和 col 互不干扰,随便组合。手写版本里,加一个维度会让另一个维度的代码也得改。
D 「30 行以上」——高估了,10 行就够。但如果再要求「共享颜色映射」「图例只出现一次」「每个子图标题格式统一」「y 轴范围一致」这些 seaborn 免费提供的东西,30 行也不夸张。手写版本的行数会随着「要求变精致」而线性增长,声明式版本不会。
ax=,它没有这个参数。
这一章的一句话
seaborn 换掉的不是配色,是语言:从「怎么画」变成「哪一列映到哪个通道」;收益不在第一次写短了多少,在于改动的成本终于和改动的大小成正比——代价是你的数据必须先站成长表。
下一章是卷 III 的最后一章,也是唯一一章讲「图画对了,人还是会被骗」。三个实测:四个数 98、99、100、101,y 轴从 97.9 起画,最高的柱子看起来是最低的 31 倍,而真实比值是 1.03——放大了 30 倍;Anscombe 的四组数据均值、方差、相关系数、回归线全都相同,图完全不同;十万个散点里,46.2% 被别的点盖住了,你根本看不见。