跳到正文

Seaborn统计可视化:分布图与关系图

Matplotlib 能把图画的每个元素精确摆到指定位置,代价是画一张「按物种分三组、每组一个颜色的箱线图」要写十几行。seaborn 把这些统计图形的常见需求封装成了函数,参数直接接受 DataFrame 的列名。

它的定位不是替代 Matplotlib,而是建立在 Matplotlib 之上的一层:画出来的图仍然是 Matplotlib 对象,可以继续用 ax.set_title() 之类的接口修改。

seaborn 的函数签名几乎都是 data=DataFrame 加上若干列名:

import seaborn as sns
from sklearn.datasets import load_iris
iris = load_iris(as_frame=True)
df = iris.frame.copy()
df["species"] = iris.target_names[df["target"]] # 把 0/1/2 换成物种名
sns.scatterplot(data=df, x="petal length (cm)", y="petal width (cm)", hue="species")
df 的形状:(150, 6)
列:sepal length (cm)、sepal width (cm)、petal length (cm)、
petal width (cm)、target、species

后文所有例子都基于这个 df

对比 Matplotlib 的写法 ax.scatter(df["petal length (cm)"], df["petal width (cm)"]),区别不只是少打几个字:传列名意味着 seaborn 知道每个变量叫什么,于是轴标签、图例标题可以自动生成,hue 分组也不用自己写循环。

代价是数据必须整理成长表(long / tidy data):一行一个观测单位,一列一个变量。如果你的测量值分散在 day1day2day3 这样的多列里,seaborn 画不出「按天分组」的图——先用 pd.melt() 把它们摞成两列(一列变量名、一列数值),这一类整形操作见 /python/pandas/cleaning/。R 那边对应的是 tidyr 的 pivot_longer(),思路完全一致。

另外提醒一句:seaborn 文档里的例子大量使用 sns.load_dataset("tips"),这个函数会联网下载示例数据集,实验室的内网机器上会直接报连接错误。本文所有例子都用 sklearn 内置的 iris。

sns.set_theme(style="whitegrid", palette="deep", context="notebook")
  • style 影响背景和网格:whitegrid 适合看数值大小,ticks 更接近期刊风格,darkgrid 是旧默认值。
  • palette 是配色方案,分类变量用 deepmutedcolorblind,连续变量用 viridisrocket
  • context 按输出场景缩放字号和线宽:papernotebooktalkposter

set_theme() 的本质是改 Matplotlib 的 rcParams。这意味着它必须写在所有绘图代码之前,否则已经创建的图不受影响;也意味着后面再手动改 plt.rcParams 会覆盖它。想只改一项就用 sns.set_style("whitegrid")

ax = sns.histplot(data=df, x="petal length (cm)", bins=20)

bins 的取值直接决定读者看到什么。取 5 太粗,分布形状被抹平;取 100 太细,看上去全是噪声。经验做法是先用 Freedman-Diaconis 规则试一下,再手工调到能看出结构为止。

ax = sns.histplot(
data=df,
x="petal length (cm)",
hue="species",
bins=20,
multiple="stack",
kde=True,
)

hue 让三个物种各画一组,multiple 决定重叠方式:layer 半透明叠加(默认)、stack 堆叠、dodge 并排、fill 归一化成比例。kde=True 在直方图上叠一条核密度估计(kernel density estimation,KDE)曲线。

KDE 可以单独画,sns.kdeplot(data=df, x="petal length (cm)", hue="species", fill=True)。它用一堆小波包(通常是高斯核)叠加来估计概率密度,bw_adjust 控制带宽:大于 1 更平滑,小于 1 更贴合数据。KDE 只在样本量足够时才可信——每组只有十几个观测时,曲线上的每一个小峰都可能是两个点造成的,画出来就是在展示不存在的精度。这种情况直接画直方图或者蜂群图(sns.swarmplot)。

print(df.groupby("species")["petal length (cm)"].mean().round(3))
species
setosa 1.462
versicolor 4.260
virginica 5.552
Name: petal length (cm), dtype: float64

三个物种的花瓣长度均值差了将近 3 倍,直方图上会是三个几乎不重叠的峰,这种分布不需要 KDE 也很好读。

关系图:scatterplot、regplot、lmplot

Section titled “关系图:scatterplot、regplot、lmplot”

这三个函数画的东西很像,用途完全不同:

函数 画什么 返回对象 能否分面
scatterplot 只有散点 Axes
regplot 散点 + 回归线 + 置信带 Axes
lmplot 散点 + 回归线,按列分面 FacetGrid

scatterplot 是纯粹的散点图,huesizestyle 三个参数可以同时按三个变量分组。regplot 在散点基础上拟合一条回归线,默认是线性最小二乘,配一条 95% 置信带。这条带子是用 bootstrap 重采样算出来的(n_boot 默认 1000 次),所以它反映的是拟合的不确定性,不是数据的离散程度;点少的时候带子会很宽,别把它读成「预测区间」。

需要按某个分类变量分面时用 lmplot,它接受 col=row= 参数;regplot 没有这个能力,得用 plt.subplots() 自己拼。拟合形状可以通过 order=2 改成二次曲线,或者 lowess=True 用局部加权回归(适合关系明显非线性的探索阶段)。

完整例子:

import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
from sklearn.datasets import load_iris
sns.set_theme(style="whitegrid")
iris = load_iris(as_frame=True)
df = iris.frame.copy()
df["species"] = iris.target_names[df["target"]]
fig, ax = plt.subplots(figsize=(6, 4.5))
sns.scatterplot(
data=df,
x="petal length (cm)",
y="petal width (cm)",
hue="species",
s=35,
alpha=0.85,
ax=ax,
)
sns.regplot(
data=df,
x="petal length (cm)",
y="petal width (cm)",
scatter=False,
ci=95,
color="grey",
ax=ax,
)
ax.set_title("Petal width vs petal length")
slope, intercept = np.polyfit(df["petal length (cm)"], df["petal width (cm)"], 1)
print(f"三个物种合并拟合:y = {slope:.3f}x {intercept:+.3f}")
三个物种合并拟合:y = 0.416x -0.363

这条线的斜率 0.416 是三个物种混在一起的结果:setosa 在左下角单独成团,把整体斜率拉低了。分开拟合的话,versicolorvirginica 各自的斜率都比它大——看到分组结构明显的数据,先分组再拟合,这是比调参数更重要的一步。

ax = sns.boxplot(data=df, x="species", y="petal width (cm)", hue="species", legend=False)
ax = sns.violinplot(data=df, x="species", y="petal width (cm)", inner="quart")

箱线图(boxplot)的五要素要背下来才能正确解读:箱子上下边是上下四分位数(Q1、Q3),箱内横线是中位数,须(whisker)延伸到 1.5 倍四分位距(IQR = Q3 − Q1)以内的最远数据点,超出的点单独画成离群点。它默认不显示均值和样本量,一组 5 个点和一组 500 个点的箱线图长得一样宽。

小提琴图(violinplot)在箱线图外面套了一层 KDE,能看出双峰这类箱线图藏起来的结构。inner="quart" 在内部画四分位线,也可以用 inner="box"inner=None。它的短板和 KDE 一样:样本量小的时候,小提琴的「腹部」形状基本是核函数画出来的,和数据没关系。每组样本少于 20 个时,宁可画 sns.stripplot(抖动散点)或 sns.swarmplot(不重叠散点),让读者直接看到有几个点。

新版 seaborn 对 huex 用同一个变量会给出冗余警告,加上 legend=False 就安静了——同一个信息没必要在轴标签和图例里各出现一次。

规则只有一条:看函数返回什么

axes-level 函数(histplotscatterplotregplotboxplotkdeplot 等)返回一个 Axes,后面可以照常接 Matplotlib 的调用:

ax = sns.boxplot(data=df, x="species", y="petal width (cm)", hue="species", legend=False)
ax.axhline(1.0, color="red", linestyle=":", linewidth=1)
ax.set_ylabel("Petal width (cm)")
ax.set_title("Petal width by species")

反过来,想把 seaborn 画在已经存在(或者已经用 Matplotlib 布置好)的坐标系上,传 ax=ax 参数即可,前面的完整例子里就是这么做的。

figure-level 函数(displotlmplotcatplotrelplotjointplotpairplot)返回的是 FacetGridJointGrid,它们自己管理整个画布,不接受 ax= 参数。要调整就得用它们自己的接口:g.set_axis_labels("x", "y")g.set_titles("{col_name}")g.figure.suptitle("...")。混用两者的接口是最常见的报错来源,遇到 AttributeError: 'FacetGrid' object has no attribute 'set_title' 时,回头看看是不是用了 figure-level 函数。

ggplot2 里这些图形是 geom_point()geom_smooth()geom_boxplot() 一个个图层叠加的结果,seaborn 把它们各自封装成了一个函数,参数更多、写法更短。R 语言里每种几何对象的用法见 /r/visualization/geometries/,对照着看能理解「图层」和「函数」两种组织方式的取舍。

一张图上叠多个坐标系、多张图排成版式,是画图里另一类问题。/python/visualization/subplots/ 讲子图布局、共享坐标轴和双坐标轴。