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

一次 melt,决定你能不能一句话画出图

卷 III 要把列接到眼睛上。但在碰任何画图函数之前,有一步必须先做完——因为它决定了后面四章你是在写代码还是在描述数据。同样 12 个数字,摆成 4 × 4 的表你只能一根一根手画,摆成 12 × 3 的表一行就能画出带分面、带配色的图。这一步没有任何视觉美感可言,它纯粹是形状问题。

4×4 变 12×3一行一个观测加一个城市改几处

▷ 先猜一下

一张四个月、三个城市的气温表,共 12 个数。它现在是 4 行 × 4 列(第一列是月份,后三列是三个城市)。

现在业务上加了第四个城市。

问:宽表和长表,各自要改几处?

A 都是一处:加一列 / 加四行。差不多 B 宽表要改画图代码,长表不用改任何代码 C 长表更麻烦,因为要多加四行数据 D 都要改画图代码,只是改法不同

两张表,同样 12 个数

左边这张叫宽表(wide form)。它读起来很舒服,一眼能看完,Excel 里绝大多数表都长这样。它的问题在于:「城市」这个变量,藏在列名里。

右边这张叫长表(long form),或者按 Hadley Wickham 那篇论文的说法叫整洁数据(tidy data)。它的规则只有两条:

◆ 主线

一行一个观测,一列一个变量。

「北京三月的气温是 25 度」是一个观测——它涉及三个变量:月份、城市、气温。所以长表里它就是一行三列。

宽表违反了第二条:「北京」不是一个变量,它是「城市」这个变量的一个取值。把取值提升成列名,这个变量就消失了——你没法对一个不存在的列做任何事。

转换只要一行:

long = wide.melt(id_vars="月份", var_name="城市", value_name="温度")
# (4, 4)  →  (12, 3)

back = long.pivot(index="月份", columns="城市", values="温度")
# (12, 3) →  (4, 3)     ← 转得回去,一个数不差

meltpivot 是一对逆运算。数据量一点没变(12 个数还是 12 个数),变的只是「哪些东西是列」。

顺手记一个细节:pivot 转回去的时候,行和列都会被重新排序——按标签的字典序,不是按原来的顺序。本机上四个月份变成了这个顺序:

原来的月份顺序      一月  二月  三月  四月
pivot 之后          一月  三月  二月  四月       ← 按 Unicode 码位排的
pivot 之后的列      上海  北京  广州             ← 城市也重排了

数一个不差,顺序全变了。所以「转回去和原表一模一样」这句话要加个前提:先 reindex 回原来的顺序才成立。这条在处理月份、星期、评分档位这类有内在顺序但字典序不对的标签时会反复咬人——正规解法是把它们声明成有序分类:pd.Categorical(月份, categories=[...], ordered=True)

为什么这一步值得单独占一章

回到开头那个问题。加一个城市:

宽表长表
数据本身加一列(5 列)加四行(16 行)
画图代码要改:循环里的城市列表、图例、颜色一个字都不用改
groupby 汇总要改:得手写四列的名字groupby("城市"),不变
加筛选条件没法写「只看南方城市」df[df.城市.isin(...)]
列的名字随数据变永远是 月份/城市/温度

关键在最后一行:长表的列名是稳定的,宽表的列名跟着数据走。而代码是写给列名的。列名会变,代码就得跟着变;列名不变,代码就能一直用。

这不是画图专属的道理。往前看,第 9 章的 groupby("城市") 需要「城市」是一列;往后看,第 14 章的 hue="城市" 也需要「城市」是一列,第 17 章 sklearn 的 OneHotEncoder 同样需要。整个下游生态,都建立在「一个变量是一列」这个前提上。

那宽表就没用了吗

不是。两种形状各有各的场合,判据很清楚:

用宽表用长表
给人看:报表、周报、屏幕上的表格给代码看:画图、分组、建模
做矩阵运算:相关矩阵、透视表存储:加一个类别不用改表结构
行列都是同一种东西(比如混淆矩阵)每列的含义不同(月份、城市、温度)

所以典型的流程是:长表存、长表算、宽表看。数据库里存长表(加一个城市不用 ALTER TABLE),分析时用长表,最后一步 pivot 成宽表给人看。

顺带认一下 pandas 里这一族的四个方法,它们经常被搞混:

melt        宽 → 长      指定 id_vars,其余列被「融化」成两列
pivot       长 → 宽      要求 (index, columns) 组合唯一,否则报错
pivot_table 长 → 宽      允许重复,用 aggfunc 汇总(Excel 数据透视表)
stack /
unstack     宽 ↔ 长      在 index 和列名之间搬东西(多层 index 时用)

pivotpivot_table 的差别值得记:pivot 在遇到重复时报错,pivot_table 默认取平均。如果你不确定组合是否唯一,pivot 的报错反而是好事——它逼你先想清楚「重复了该怎么办」。

✎ 术语正名

整洁数据(tidy data)来自 Hadley Wickham 2014 年的同名论文。原文给了三条:每个变量一列、每个观测一行、每类观测单位一张表。第三条常被忽略,但它就是数据库范式里的「一张表只讲一件事」。

注意这个词很容易被误解成「干净的数据」。它跟数据质量无关——一张全是错误值的表也可以是 tidy 的,一张完全正确的宽表也可以是不 tidy 的。它说的纯粹是形状

另外,「宽」和「长」在不同工具里叫法不一:R 的 tidyrpivot_longerpivot_wider,SQL 里叫 UNPIVOTPIVOT,Excel 叫「逆透视」/「透视」。四个生态,同一个操作,四套名字。

⌨ 自己跑一遍

整个转换六行:

import pandas as pd

wide = pd.DataFrame({"月份": ["一月", "二月", "三月", "四月"],
                     "北京": [12, 18, 25, 31],
                     "上海": [15, 20, 24, 28],
                     "广州": [22, 26, 29, 32]})

long = wide.melt(id_vars="月份", var_name="城市", value_name="温度")
print(wide.shape, "→", long.shape)          # (4, 4) → (12, 3)
print(long.head(3))

back = long.pivot(index="月份", columns="城市", values="温度")
print(back)                                  # 转得回去,一个数不差

然后亲手做一次「加一个城市」,感受那个差别:

wide2 = wide.copy()
wide2["深圳"] = [24, 27, 30, 33]
long2 = wide2.melt(id_vars="月份", var_name="城市", value_name="温度")

print(len(wide2.columns), len(long2))                    # 5 列 / 16 行
print(list(long.columns) == list(long2.columns))          # True ← 列名没变

# 长表上这些全都不用改:
long2.groupby("城市")["温度"].mean()
long2[long2["城市"].isin(["广州", "深圳"])]

python3 -c "import pandas as pd;w=pd.DataFrame({'m':[1,2],'a':[1,2],'b':[3,4]});print(w.shape,'->',w.melt(id_vars='m').shape)"

画图部分从下一章开始。这一章不需要 matplotlib,只要 pandas。

▸ 在现实里
  • 数据库设计里的「加一列还是加一行」。用户属性表设计成宽表(每个属性一列),加一个属性要 ALTER TABLE;设计成长表(user_id, key, value,也就是 EAV 模型),加属性只是插一行。取舍和这一章完全一样:宽表查询快、类型明确,长表灵活但每次查询都要 pivot 一次。
  • 时序数据库天生是长表。Prometheus、InfluxDB 存的都是 (时间, 指标名, 标签, 值) ——完美的长表。新增一个监控指标不需要改任何 schema,这正是长表灵活性的价值。
  • Excel 的「逆透视」按钮。Power Query 里那个按钮做的就是 melt。会用它的人和不会用的人,在 Excel 里的生产力差一个量级——因为数据透视表要求的输入正是长表。
  • 为什么日志要写成结构化的键值对。level=error service=auth latency=230 是长表;一行自由文本是没有形状的数据。日志系统的全部查询能力,都建立在「每个字段是一列」这个前提上。
✗ 这个直觉是错的

「数据整理是个人习惯问题,怎么摆都行,反正数是一样的。」

数确实一样,但能力不一样。宽表里「城市」不是一列,所以你没法:按城市分组、按城市配色、按城市分面、按城市过滤、把城市喂给 OneHotEncoder这些不是「写起来麻烦一点」,是「根本没有这个东西可以指」。

代价是可以量的:加一个城市,长表要改 0 行代码,宽表要改画图循环、图例、颜色映射,还有每一处硬编码了城市名的地方。而且这类改动不会报错——你新加的城市只是不出现在图上而已。

判据一句话:看着你的列名,问「这些列名里有没有藏着一个变量的取值」。如果三列叫「北京/上海/广州」,那「城市」就藏在里面。如果三列叫「月份/城市/温度」,那才是三个变量。

◇ 揭晓

正确答案是 B宽表要改画图代码,长表不用改任何代码

A 「都是一处,差不多」——只看数据的话确实差不多(加一列 vs 加四行)。差别全在代码那边:长表的列名是 月份/城市/温度,加多少个城市都不变;宽表的列名就是城市名,数据一变,所有引用列名的代码都得跟着变。 C 「长表更麻烦,要多加四行」——行数确实多了,但行是数据,列是接口。多几行数据没有任何维护成本;多一列意味着所有下游代码的接口变了。这正是「一行一个观测」这条规则的价值:让变化落在行上,不落在列上。 D 「都要改」——长表真的不用改。这一点值得亲手验一次:上面那段代码里,long2.groupby("城市")sns.relplot(hue="城市") 在加了深圳之后一个字都不用动,新城市自动出现在结果里。
⌗ 交接单
一张宽表:某个变量的取值被提升成了列名。 一张长表:一行一个观测,一列一个变量,列名稳定 seaborn。它的 x=hue=col= 参数收的是列名,宽表里那个变量不是列,所以你根本填不进去。这不是报错,是「没有东西可填」——第 14 章会看到这道关卡的样子。

这一章的一句话

整理数据不是审美问题,是形状问题:只有当一个变量真的是一列时,下游那些「按它分组、按它配色、按它分面」的能力才存在——而宽表把变量藏进了列名,那个东西就不存在了。

下一章开始碰画图本身,第一件事是解释一个让所有人困惑过的现象:为什么 plt.plot() 有时候会画到另一张图上。本机实测,连着 plt.figure() 两次再 plt.plot(),那条线画在了第二张图上,第一张是空的——而这不是 bug,是一个 1990 年代的设计决定留下的影子。