卷 III · 接到眼睛上CH 14深度 14/23

seaborn 不是美化,是图形语法

seaborn 常被介绍成「matplotlib 的美化版」——配色好看,默认值合理。这个说法把它最重要的部分漏掉了。seaborn 真正做的事是换掉了你和图之间的那门语言:你不再说「画一条线、再画一条线、再加个图例」,你说「x 是这一列,y 是这一列,颜色是那一列,分面是另一列」。这一章要说清这个转变值多少钱,以及它对数据形状提出了什么要求。

10 行变 2 行加一个词多 4 张图七个通道

▷ 先猜一下

一张小费数据表:消费额、小费、星期几、抽不抽烟。你要画四张并排的散点图(每个星期一张),每张里用颜色区分抽烟与否,y 轴共享。

问:手写 matplotlib 和用 seaborn,各要几行?

A 差不多,seaborn 省 1 到 2 行 B matplotlib 10 行,seaborn 2 行。相差 5 倍 C seaborn 反而更长,因为参数多 D matplotlib 要 30 行以上

顺手再猜:如果要再加一个维度(抽烟与否也分面,变成 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 提供七个:

参数通道适合什么
xy位置最精确的通道,留给最重要的两个变量
hue颜色类别(3–7 个)或连续量
size大小连续量,但人眼分辨力差
style形状/线型类别,少于 5 个
colrow分面类别;把「叠在一起看不清」变成「并排比」

于是「怎么画一张更复杂的图」这个问题,变成了「我有几个变量,各自适合哪个通道」。这是一个能思考的问题,而不是一个要查文档的问题。

加一个维度,只改一个词

回到第二个猜测。要把 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 级scatterplotlineplotboxplothistplot一个 Axes
Figure 级relplotcatplotdisplotlmplot一个 FacetGrid不能

规律很好记:名字里带 plot 后缀的是 Axes 级,带 relplotcatplotdisplot 这种「短名 + 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 换成 catplotkind="bar""box""violin"),其余参数一个不动。这一点最能说明「语法」的含义:几何对象换了,映射没换。

python3 -c "import seaborn as sns;print(sns.__version__)"

装:pip install seaborn(会带上 matplotlib 和 pandas)。想试自带数据集要联网:sns.load_dataset("tips")

▸ 在现实里
  • Tableau、Power BI 的拖拽界面,就是图形语法的鼠标版。你把字段拖到「列」「行」「颜色」「大小」这些格子里——那些格子正是这一章的通道。理解了 huecol,你也就理解了那些 BI 工具的核心模型。Tableau 的创始人之一 Chris Stolte 的博士论文,引的就是 Wilkinson 那本书。
  • Vega-Lite 把这套语法写成了 JSON。一张图变成一份可以传输、可以生成、可以让大模型直接写出来的声明。声明式的另一个好处在这里显现:它是数据,不是代码,所以能被程序处理。
  • CSS 与手写绘制的关系也是这个。你写 colorfont-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 章那套改细节。两者不是竞争关系,是两层。

◇ 揭晓

正确答案是 Bmatplotlib 10 行(418 字符),seaborn 2 行(95 字符)
加一个维度:seaborn 改一个词(4 个 Axes → 8 个),matplotlib 基本重写。

A 「省 1 到 2 行」——如果只画一张散点图,这个答案是对的:sns.scatterplotax.scatter 差不多长。差距是被分面和配色拉开的——也就是那些「要遍历、要分配、要布局」的部分。越复杂的图,差距越大。 C 「seaborn 更长」——参数确实多,但每个参数替换掉的是好几行手工劳动。而且这些参数是正交的:huecol 互不干扰,随便组合。手写版本里,加一个维度会让另一个维度的代码也得改。 D 「30 行以上」——高估了,10 行就够。但如果再要求「共享颜色映射」「图例只出现一次」「每个子图标题格式统一」「y 轴范围一致」这些 seaborn 免费提供的东西,30 行也不夸张。手写版本的行数会随着「要求变精致」而线性增长,声明式版本不会。
⌗ 交接单
一张长表(第 11 章那个形状)+ 一份「哪一列映到哪个通道」的说明。 一个 FacetGrid(Figure 级)或一个 Axes(Axes 级),里面是第 13 章那棵 Artist 树。 seaborn 会因为列名不存在而报错,但不会因为「你的数据是宽表」而报错——那种情况下你只是填不出参数。另一个常见错误:给 Figure 级函数传 ax=,它没有这个参数。

这一章的一句话

seaborn 换掉的不是配色,是语言:从「怎么画」变成「哪一列映到哪个通道」;收益不在第一次写短了多少,在于改动的成本终于和改动的大小成正比——代价是你的数据必须先站成长表。

下一章是卷 III 的最后一章,也是唯一一章讲「图画对了,人还是会被骗」。三个实测:四个数 98、99、100、101,y 轴从 97.9 起画,最高的柱子看起来是最低的 31 倍,而真实比值是 1.03——放大了 30 倍;Anscombe 的四组数据均值、方差、相关系数、回归线全都相同,图完全不同;十万个散点里,46.2% 被别的点盖住了,你根本看不见