默认设置里藏着的那些谎
前三章讲的是「怎么把图画出来」。这一章讲一件更要紧的事:图画对了,人还是会被骗。而且大多数时候骗你的不是坏人,是默认设置——一个自动选的 y 轴起点、一条自动画的平滑曲线、一批自动叠在一起的散点。这一章测四种,每一种都给出量化的代价。学完之后你看图的方式会变,包括看别人给你的图。
四个季度的某个指标:98、99、100、101。你画一张柱状图,matplotlib 自动选了 y 轴范围,从 97.9 开始。
问:图上最高的柱子看起来是最矮那根的几倍?
谎言一:截断的 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 方差 | 相关系数 | 回归线 |
|---|---|---|---|---|---|
| I | 9.0 | 7.5 | 11.0 | 0.8164 | y = 0.5x + 3.0 |
| II | 9.0 | 7.5 | 11.0 | 0.8162 | y = 0.5x + 3.0 |
| III | 9.0 | 7.5 | 11.0 | 0.8163 | y = 0.5x + 3.0 |
| IV | 9.0 | 7.5 | 11.0 | 0.8165 | y = 0.5x + 3.0 |
四组数据的均值、方差、相关系数、回归线全都相同(相关系数保留两位全是 0.82,斜率和截距完全一样)。而画出来是四张完全不同的图:
I 一团正常的散点,线性关系 ← 回归有意义 II 一条明显的抛物线 ← 用直线拟合是错的 III 完美的一条直线 + 一个离群点 ← 离群点把线拽歪了 IV x 全是 8,只有一个点是 19 ← 「相关」完全由那一个点决定
第 IV 组最有教育意义:删掉那一个点,相关系数就没有定义了(剩下的 x 全部相同)。一个 0.82 的相关系数,背后可能只有一个点在支撑。
这套数据现在还在流传,因为它证明的事情至今成立:任何一组汇总统计量,都对应着无穷多种可能的数据分布。(2017 年有人把这件事做到了极致,造出一组统计量相同、画出来是恐龙的数据,叫 Datasaurus。)
谎言三:核密度估计会把质量放到不可能的地方
seaborn 的 kdeplot、displot(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_kde 加 np.trapezoid 积一下负半轴的面积。
- 财经新闻里的截断 y 轴。这是最常见的一种,而且往往不是恶意——制图软件的默认设置就会自动截断(因为「让数据填满画布」是一个合理的默认)。看到柱状图,第一件事是看 y 轴从几开始。这一个动作能过滤掉大量误导性的图。
- 疫情期间的对数坐标之争。线性坐标看到的是「爆炸式增长」,对数坐标看到的是「增长率在放缓」。两张图都诚实,回答的是不同的问题。这说明「哪个坐标系」本身就是一个观点,不是中立的技术选择。
- A/B 测试报告里的置信区间。只给一个「转化率提升 3.2%」的数字,等于只给 Anscombe 的均值。没有区间和分布的效果量,是无法判断的。这也是为什么好的实验平台一定会画分布图,而不只给一个数。
- 机器学习论文里的散点图。「预测值 vs 真实值」的图上有几万个点时,看起来总是一团黑。真正该看的是残差图、分位数图,或者干脆 hexbin。一张过度绘制的散点图,能掩盖掉模型在某个区间系统性偏差这种严重问题。
「只要数据是真的、代码没写错,图就是客观的。误导人的是那些故意做手脚的人。」
这一章四个例子里,没有一个动过数据,没有一行代码是错的。y 轴从 97.9 开始是 matplotlib 自动选的;KDE 的带宽是 scipy 用标准规则算的;散点图就是老老实实一个点一个点画的。误导来自默认设置和人眼读法的错位,不是来自恶意。
而且默认设置有它的道理:自动选 y 轴范围是为了「让数据填满画布」,这在折线图上完全正确;KDE 的高斯核在没有边界约束的数据上也完全正确。问题出在「这个默认适合的场景」和「你的场景」不一致,而没有人会来提醒你。
四条自查,画完每张图问一遍:(一)柱状图的 y 轴从 0 起了吗?(二)我除了统计量还看过原始的图吗?(三)这条平滑曲线有没有跑到数据不可能的区域?(四)点会不会互相盖住?——四个问题,十秒钟。
正确答案是 C:31 倍。真实比值 1.0306,谎言因子约 30。
A 「图不会改变数」——图确实没改数,改的是人读到的东西。柱状图用长度编码数值,而长度只有在共同起点下才可比。「数据是真的」和「印象是真的」是两件事,这一章讲的全部是后者。 B 「大约 4 倍」——这正好是 y 轴从 97 起时的答案。有意思的是它演示了这件事的非线性:起点从 97 挪到 97.9,只挪了 0.9,谎言因子却从 4 跳到 31。越接近数据的最小值,放大倍数越夸张,趋于无穷。 D 「取决于图的高度」——不取决于。整张图变高,所有柱子按比例一起变高,比值不变。这正是问题所在:谎言因子是尺度不变的,你没法通过「把图画大一点」来减轻它。只能改 y 轴起点。这一章的一句话
图误导人,通常不是因为有人撒谎,是因为默认设置服务的场景和你的场景不同——而默认设置从不为自己辩护,也从不提醒你。
卷 III 到此结束。列已经接到眼睛上了。下一卷把同一条列接到模型上——而那一层的要求比前面任何一层都硬:两百多个模型,只认一种形状(X 是 (n_samples, n_features),y 是 (n_samples,)),也只会三个动词。形状不对,它当场报错——这是全书唯一一层会替你把关的。