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

默认设置里藏着的那些谎

前三章讲的是「怎么把图画出来」。这一章讲一件更要紧的事:图画对了,人还是会被骗。而且大多数时候骗你的不是坏人,是默认设置——一个自动选的 y 轴起点、一条自动画的平滑曲线、一批自动叠在一起的散点。这一章测四种,每一种都给出量化的代价。学完之后你看图的方式会变,包括看别人给你的图。

放大 30 倍四组数统计量全同46.2% 看不见

▷ 先猜一下

四个季度的某个指标:98、99、100、101。你画一张柱状图,matplotlib 自动选了 y 轴范围,从 97.9 开始。

问:图上最高的柱子看起来是最矮那根的几倍?

A 1.03 倍。数就是这么点差别,图不会改变它 B 大约 4 倍。截断确实会放大,但没那么夸张 C 31 倍。柱子高度是「值减去 97.9」,差别被放大了 30 倍 D 无法确定,取决于图的高度

谎言一:截断的 y 轴

柱状图的语义是「高度正比于数值」。这个语义只在 y 轴从 0 开始时成立。一旦从 97.9 开始,柱子的高度变成了「数值 − 97.9」,于是:

四个数     98    99    100   101
真实比值   101 / 98  =  1.0306

y 轴从 0 起      高度比 1.0306        ✓ 诚实
y 轴从 97 起     高度比 4.0
y 轴从 97.9 起   高度比 31.0          ★ 放大了 30 倍

注意这里没有任何人在撒谎:数据是真的,标签是真的,y 轴上的数字也是真的。被改掉的只是「看一眼得到的印象」,而看一眼恰恰是绝大多数人看图的方式。

规则很简单,值得当成纪律:

◆ 主线

柱状图(面积编码)的 y 轴必须从 0 开始;折线图(位置编码)不必。

区别在于人眼读的是什么:柱子读长度,长度必须有共同起点才有意义;折线读斜率和形状,起点无所谓。所以「股价走势图从 0 画起」反而是浪费画布。

ax.bar(x, y)
ax.set_ylim(0, None)        # ← 柱状图记得加这一行

谎言二:只看统计量,不看图

这是反过来的一个方向:不是图骗人,是不看图会被数字骗

1973 年统计学家 Francis Anscombe 造了四组数据,各 11 个点。本机把它们的统计量全算了一遍:

数据组x 均值y 均值x 方差相关系数回归线
I9.07.511.00.8164y = 0.5x + 3.0
II9.07.511.00.8162y = 0.5x + 3.0
III9.07.511.00.8163y = 0.5x + 3.0
IV9.07.511.00.8165y = 0.5x + 3.0

四组数据的均值、方差、相关系数、回归线全都相同(相关系数保留两位全是 0.82,斜率和截距完全一样)。而画出来是四张完全不同的图:

I     一团正常的散点,线性关系                ← 回归有意义
II    一条明显的抛物线                          ← 用直线拟合是错的
III   完美的一条直线 + 一个离群点              ← 离群点把线拽歪了
IV    x 全是 8,只有一个点是 19                ← 「相关」完全由那一个点决定

第 IV 组最有教育意义:删掉那一个点,相关系数就没有定义了(剩下的 x 全部相同)。一个 0.82 的相关系数,背后可能只有一个点在支撑。

这套数据现在还在流传,因为它证明的事情至今成立:任何一组汇总统计量,都对应着无穷多种可能的数据分布。(2017 年有人把这件事做到了极致,造出一组统计量相同、画出来是恐龙的数据,叫 Datasaurus。)

谎言三:核密度估计会把质量放到不可能的地方

seaborn 的 kdeplotdisplot(kind="kde")、以及 histplot(kde=True) 都会给你一条漂亮的平滑曲线。它比直方图好看得多,而且没有「分箱宽度」这个烦人的参数。

代价是它会把概率抹到数据不可能出现的地方。本机拿 2000 个指数分布的样本(全部大于 0)测了一下:

2000 个样本,最小值 0.001(全部为正)
高斯核密度估计(scipy,带宽 0.2187)

  曲线在 x < 0 区域下的面积     7.2%      ★ 7.2% 的概率质量落在了负数上

为什么?因为 KDE 的做法是在每个数据点上放一个高斯钟形,再全部加起来。高斯钟形往两边无限延伸,所以靠近 0 的那些点,会把一部分「概率」洒到 0 的左边去。

这在实际工作里会造成真实的误读:一张「响应时间分布」的 KDE 图会显示存在负的响应时间;一张「年龄分布」的 KDE 图会显示有 −3 岁的人。看图的人不会怀疑,因为曲线太光滑了,光滑本身就在传达「这是经过分析的、可靠的」。

三个对策:

sns.histplot(x)                       # 1. 直方图不会造假:每根柱子是真的计数
sns.kdeplot(x, cut=0)                 # 2. 不让曲线超出数据范围
sns.kdeplot(np.log(x))                # 3. 有下界的量,先取对数再估
sns.ecdfplot(x)                       # 4. 经验累积分布:完全不需要任何参数

最后那个 ecdfplot 值得推荐——它没有带宽、没有分箱,是数据本身的无损展示,代价是需要一点时间习惯怎么读。

谎言四:过度绘制

十万个点画在一张 640 × 480 的图上会发生什么?本机数了一下有多少个像素格子被占用:

100000 个点,画布 640 × 480

  实际占用的像素格    53807
  被别的点盖住的      46.2%          ★ 将近一半的点你根本看不见

后果不只是「看不清」,而是密度信息被彻底破坏:一个格子里有 1 个点还是有 50 个点,画出来一模一样。于是「中心区域」和「边缘区域」看起来一样黑,你完全无法判断数据的分布。

四个对策,按数据量从小到大:

点数做法说明
几百照常 scatter没问题
几千alpha=0.2 + 小 s叠加的地方自然变深,密度就回来了
几万ax.hexbin(...)sns.histplot(x, y)把平面分格,画每格的计数——密度是出来的
十万以上先抽样,或用 datashader 一类的工具要么减少点,要么换渲染方式
✎ 术语正名

lie factor(谎言因子):Edward Tufte 在《The Visual Display of Quantitative Information》(1983) 里提出的指标——图上效果的大小 ÷ 数据中效果的大小。理想值是 1。这一章第一个例子的谎言因子是 31.0 / 1.0306 ≈ 30

Tufte 那本书还提出了另一个概念:data-ink ratio(数据墨水比)——图上真正编码数据的墨水,占总墨水的比例。它是「去掉网格线、去掉边框、去掉 3D 效果」这类建议的理论依据。第 13 章那两行 spines[...].set_visible(False),就是在提高这个比值。

注意区分「图错了」和「图误导」:这一章的四个例子,图全都是正确的——每个像素都忠实反映了数据。它们误导,是因为人眼的读法和图的编码方式不一致。所以这不是技术问题,是「你用什么视觉通道编码什么信息」的问题——回到第 14 章那张通道表。

⌨ 自己跑一遍

第一个和第二个不需要画图,算一下就够:

import numpy as np

vals = np.array([98., 99., 100., 101.])
for base in [0.0, 97.0, 97.9]:
    h = vals - base
    print("y 轴从 %5.1f 起,高度比 %.2f" % (base, h.max() / h.min()))
# 0.0 → 1.03    97.0 → 4.00    97.9 → 31.00

# Anscombe 四组
ans = {
 "I":  ([10,8,13,9,11,14,6,4,12,7,5],[8.04,6.95,7.58,8.81,8.33,9.96,7.24,4.26,10.84,4.82,5.68]),
 "II": ([10,8,13,9,11,14,6,4,12,7,5],[9.14,8.14,8.74,8.77,9.26,8.10,6.13,3.10,9.13,7.26,4.74]),
 "III":([10,8,13,9,11,14,6,4,12,7,5],[7.46,6.77,12.74,7.11,7.81,8.84,6.08,5.39,8.15,6.42,5.73]),
 "IV": ([8,8,8,8,8,8,8,19,8,8,8],[6.58,5.76,7.71,8.84,8.47,7.04,5.25,12.50,5.56,7.91,6.89]),
}
for k, (x, y) in ans.items():
    x, y = np.array(x, float), np.array(y, float)
    a, b = np.polyfit(x, y, 1)
    print(k, "均值 %.1f %.1f  r %.2f  y = %.2fx + %.2f"
          % (x.mean(), y.mean(), np.corrcoef(x, y)[0, 1], a, b))
# 四行输出几乎完全相同 —— 然后把它们画出来看看

过度绘制那个,也是纯算术:

rng = np.random.default_rng(151)
px, py = rng.normal(0, 1, 100_000), rng.normal(0, 1, 100_000)
gx = np.clip(((px + 4) / 8 * 640).astype(int), 0, 639)
gy = np.clip(((py + 4) / 8 * 480).astype(int), 0, 479)
occupied = len(set(zip(gx.tolist(), gy.tolist())))
print(occupied, "%.1f%% 被盖住" % ((1 - occupied / 100_000) * 100))   # 53807  46.2%

python3 -c "v=[98,99,100,101];print([round(max(x-b for x in v)/min(x-b for x in v),2) for b in (0,97,97.9)])"

KDE 那个需要 scipy:pip install scipy,然后 gaussian_kdenp.trapezoid 积一下负半轴的面积。

▸ 在现实里
  • 财经新闻里的截断 y 轴。这是最常见的一种,而且往往不是恶意——制图软件的默认设置就会自动截断(因为「让数据填满画布」是一个合理的默认)。看到柱状图,第一件事是看 y 轴从几开始。这一个动作能过滤掉大量误导性的图。
  • 疫情期间的对数坐标之争。线性坐标看到的是「爆炸式增长」,对数坐标看到的是「增长率在放缓」。两张图都诚实,回答的是不同的问题。这说明「哪个坐标系」本身就是一个观点,不是中立的技术选择。
  • A/B 测试报告里的置信区间。只给一个「转化率提升 3.2%」的数字,等于只给 Anscombe 的均值。没有区间和分布的效果量,是无法判断的。这也是为什么好的实验平台一定会画分布图,而不只给一个数。
  • 机器学习论文里的散点图。「预测值 vs 真实值」的图上有几万个点时,看起来总是一团黑。真正该看的是残差图、分位数图,或者干脆 hexbin。一张过度绘制的散点图,能掩盖掉模型在某个区间系统性偏差这种严重问题。
✗ 这个直觉是错的

「只要数据是真的、代码没写错,图就是客观的。误导人的是那些故意做手脚的人。」

这一章四个例子里,没有一个动过数据,没有一行代码是错的。y 轴从 97.9 开始是 matplotlib 自动选的;KDE 的带宽是 scipy 用标准规则算的;散点图就是老老实实一个点一个点画的。误导来自默认设置和人眼读法的错位,不是来自恶意。

而且默认设置有它的道理:自动选 y 轴范围是为了「让数据填满画布」,这在折线图上完全正确;KDE 的高斯核在没有边界约束的数据上也完全正确。问题出在「这个默认适合的场景」和「你的场景」不一致,而没有人会来提醒你。

四条自查,画完每张图问一遍:(一)柱状图的 y 轴从 0 起了吗?(二)我除了统计量还看过原始的图吗?(三)这条平滑曲线有没有跑到数据不可能的区域?(四)点会不会互相盖住?——四个问题,十秒钟。

◇ 揭晓

正确答案是 C31 倍。真实比值 1.0306,谎言因子约 30

A 「图不会改变数」——图确实没改数,改的是人读到的东西。柱状图用长度编码数值,而长度只有在共同起点下才可比。「数据是真的」和「印象是真的」是两件事,这一章讲的全部是后者。 B 「大约 4 倍」——这正好是 y 轴从 97 起时的答案。有意思的是它演示了这件事的非线性:起点从 97 挪到 97.9,只挪了 0.9,谎言因子却从 4 跳到 31。越接近数据的最小值,放大倍数越夸张,趋于无穷。 D 「取决于图的高度」——不取决于。整张图变高,所有柱子按比例一起变高,比值不变。这正是问题所在:谎言因子是尺度不变的,你没法通过「把图画大一点」来减轻它。只能改 y 轴起点。
⌗ 交接单
一份正确的数据 + 一段没有 bug 的画图代码。 一张技术上正确、印象上可能失真的图。 没有任何程序会检查这一层——matplotlib 不知道你的柱状图该从 0 起,scipy 不知道你的数据有下界。这一章是全书唯一一处「检查者只能是你自己」的交接。

这一章的一句话

图误导人,通常不是因为有人撒谎,是因为默认设置服务的场景和你的场景不同——而默认设置从不为自己辩护,也从不提醒你。

卷 III 到此结束。列已经接到眼睛上了。下一卷把同一条列接到模型上——而那一层的要求比前面任何一层都硬:两百多个模型,只认一种形状X(n_samples, n_features)y(n_samples,)),也只会三个动词。形状不对,它当场报错——这是全书唯一一层会替你把关的。