你以为在加两列数,其实先做了一次 join
pandas 常被介绍成「numpy 加了行名列名」。这个说法听起来只是加了点方便,实际上它改掉了运算的语义:numpy 按位置对齐,pandas 按名字对齐。两个各有三个数的 Series 相加,结果可以有四行,其中两行是 NaN——而这既不是 bug,也不是边界情况,它是 pandas 每一次二元运算都在做的事。这一章把这条规则看清楚,卷 II 后面四章的麻烦就都有出处了。
import pandas as pd s1 = pd.Series([1, 2, 3], index=["a", "b", "c"]) s2 = pd.Series([10, 20, 30], index=["b", "c", "d"]) s1 + s2
问:结果是什么?
index 不是「行号」,是一个隐藏的连接键
一个 DataFrame 在概念上是一堆列,外加一根 index。这根 index 看起来只是行标签,实际上它参与了所有二元运算:pandas 在算 s1 + s2 之前,先把两根 index 做一次外连接,再逐个键相加。
所以答案是四行:
a NaN ← s2 里没有 a b 12.0 c 23.0 d NaN ← s1 里没有 d dtype: float64 有值的只有 2 行。
四行里两行是 NaN,不是因为数据缺失,是因为「一边有、另一边没有」。这两件事在 pandas 里用同一个记号表示——第 8 章会讲这个共用带来的麻烦。
作为对照,把名字扔掉再算:
s1.to_numpy() + s2.to_numpy() # array([11, 22, 33])
11、22、33——三个数全都对得整整齐齐,而且全都是错的。它把 s1["a"] 和 s2["b"] 加在了一起,因为它们碰巧都排在第 0 位。
这就是这一章的分界线:
numpy 按位置对齐,pandas 按名字对齐。
numpy 会因为「长度不同」报错,但绝不会因为「顺序不同」报错——它根本不知道有顺序这回事。
pandas 不会因为「长度不同」报错,因为它先做连接;但它会因为「名字对不上」给你 NaN。
两套语义都很合理,危险的是在同一段代码里混用——而 .to_numpy()、.values、以及任何一次「先排序再赋值」,都是在混用。
同一条规则,会静默地害你,也会静默地救你
上面演示了「害」。现在看一个「救」的例子,它更有说服力,因为绝大多数人写出这段代码时并不知道自己被救了。
一张四个人的成绩表,要加一列名次:
df = pd.DataFrame({"name": ["a", "b", "c", "d"],
"score": [50, 90, 70, 60]})
top = df.sort_values("score", ascending=False) # 排序后:b c d a
df["rank"] = top["score"].rank(ascending=False) # 把排好的名次贴回去
注意这里发生了什么:top 的行顺序是 b、c、d、a,和 df 完全不同。按位置贴,四个人的名次会全错。可是:
name score rank a 50 4.0 ✓ 最低分,第 4 b 90 1.0 ✓ 最高分,第 1 c 70 2.0 ✓ d 60 3.0 ✓
全对。因为 top["score"].rank() 是一个带 index 的 Series,赋值给 df["rank"] 时,pandas 按 index 对齐,把每个人的名次送回了他自己那一行。顺序乱了没关系,名字没乱。
而只要加四个字符,它就全错了:
df["rank"] = top["score"].rank(ascending=False).to_numpy()
name score rank a 50 1.0 ✗ 最低分拿了第 1 b 90 2.0 ✗ c 70 3.0 ✗ d 60 4.0 ✗
.to_numpy() 把名字扔了,于是退回按位置贴:排第一位的名次 1 给了排第一行的 a。没有报错,没有警告,四个数全在合法范围内,表格看上去完全正常。
a b c d 四个人,分数 50 90 70 60 按 index 对齐(写对了) 4.0 1.0 2.0 3.0 按位置贴(写错了) 1.0 2.0 3.0 4.0
这两个例子放在一起,就是这一章想给你的判断:
对齐(alignment)这个词在 pandas 文档里到处都是,但它常被当成「格式对齐」那种排版意思。它说的其实是数据库里的连接(join):以 index 为键,把两边的行配对。
s1 + s2 做的是外连接(两边的键全保留,缺的补 NaN)。想要别的连接方式,pandas 提供了方法版本:
s1.add(s2, fill_value=0) # 缺的那边当 0,四行都有值 s1.align(s2, join="inner") # 只保留两边都有的键
运算符版本永远是外连接,没有参数可调。要控制连接方式,必须换成方法名。这是 pandas 里少数几个「运算符和方法不等价」的地方。
什么时候该用 index,什么时候该扔掉它
index 不是白拿的。它带来对齐的安全,也带来三样成本:内存、维护它的时间、以及「两根 index 不一样时静默产生 NaN」。所以有一套朴素的取舍:
| 情况 | 怎么做 | 为什么 |
|---|---|---|
| 要把一列结果贴回原表 | 保留 Series,直接赋值 | 让对齐替你做核对 |
| 两张表要按某个键合并 | 用 merge(第 10 章) | 连接键是显式的,还能 validate |
| 做完一堆过滤和排序,行顺序乱了 | reset_index(drop=True) | 不然 index 会带着旧行号到处跑 |
| 结果交给 sklearn 或画图 | .to_numpy() | 那两层不认 index,见第 17 章 |
用 concat 拼接多张表 | 先确认 index 是否有意义,必要时 ignore_index=True | index 重复时后续 .loc 会返回多行 |
有一个特别值得知道的症状:如果你的 DataFrame 里突然冒出一堆本不该有的 NaN,第一件事是查两边的 index 对不对得上,而不是查数据源。
# 三行诊断 print(s1.index.equals(s2.index)) # 一样吗 print(s1.index.difference(s2.index)) # 左边有右边没有的键 print(s2.index.difference(s1.index)) # 反过来
这一章的两个实验都很短。第一个是那个「四行两个 NaN」:
import pandas as pd s1 = pd.Series([1, 2, 3], index=["a", "b", "c"]) s2 = pd.Series([10, 20, 30], index=["b", "c", "d"]) print(s1 + s2) # 四行,两个 NaN print((s1 + s2).notna().sum()) # 2 print(s1.to_numpy() + s2.to_numpy()) # [11 22 33] ← 全错但很整齐 print(s1.add(s2, fill_value=0)) # 四行都有值
第二个更值得亲手做一次,因为它演示的是「对齐救了你」:
df = pd.DataFrame({"name": list("abcd"), "score": [50, 90, 70, 60]})
top = df.sort_values("score", ascending=False)
print(top["name"].tolist()) # ['b', 'c', 'd', 'a']
df["按名字"] = top["score"].rank(ascending=False) # 对
df["按位置"] = top["score"].rank(ascending=False).to_numpy() # 错
print(df)
把 .to_numpy() 加上、去掉,来回切两次,观察最后一列。这四个字符就是 pandas 和 numpy 的全部区别。
python3 -c "import pandas as pd;a=pd.Series([1,2,3],index=list('abc'));b=pd.Series([10,20,30],index=list('bcd'));print(a+b)"
环境:pip install pandas(本书用的是 pandas 3.0.5)。不想装就用 jupyter.org/try-jupyter。
- 时间序列是 index 对齐最能发挥价值的地方。两只股票的日收盘价,一只在某天停牌。按位置相加,停牌之后的所有日期全部错位;按日期对齐,那一天是
NaN,其余全对。这就是 pandas 最初被造出来的原因——它诞生在一家量化基金(AQR),第一个用户是金融时间序列。 - Excel 的 VLOOKUP 与「直接拖下来」。同一个取舍的手工版:用 VLOOKUP 是按名字对齐,直接复制一列粘到旁边是按位置对齐。财务表里那种「所有数都错一行」的经典事故,和上面
.to_numpy()那个例子一字不差。 - SQL 里没有这个问题,因为它没有「位置」。关系模型里行是无序的,你必须写
JOIN ... ON。pandas 给了你一个默认的连接键(index),方便,但也让「我到底按什么在对齐」变成了不可见的。 - Kotlin 里
zip与associateBy的差别。listA zip listB按位置配对,listA.associateBy { it.id }按键配对。写业务代码时选错了,症状也是「所有数据串了一位」。
「DataFrame 就是带列名的二维数组,index 只是行号,方便看而已。」
index 是参与运算的。每一次 +、-、*、比较、以及每一次把 Series 赋值给某一列,pandas 都会先按 index 做一次外连接。它不是标签,它是键。
这个误解的代价有两个方向,而且方向相反:不知道有对齐,就会被凭空出现的 NaN 搞糊涂;知道有对齐但顺手写了 .to_numpy(),就会静默地把数据串位——后者更贵,因为它连 NaN 这个提示都没有。
判据:每写一次 .to_numpy() 或 .values,问一句「我确定两边的行顺序一致吗?」如果这个问题需要想超过三秒,就别扔掉 index。真要扔,就在扔之前 reset_index(drop=True),把「我打算按位置来」这件事写进代码里。
正确答案是 C:四行,a 和 d 是 NaN,b 是 12,c 是 23。
A 「按位置逐个相加」——这是 numpy 的语义,也是.to_numpy() 之后的行为。它给出 11、22、33,三个数全都错在了「a 配 b」这个假设上。整齐的输出不等于正确的输出,这句话在这本书里还会出现好几次。
B 「报错」——pandas 几乎从不因为形状不一致报错,因为它先做连接。它更倾向于给你一个结果,然后用 NaN 标注哪里没对上。这是设计取向的差别:numpy 认为形状不对是程序员的错,pandas 认为数据不齐是常态。
D 「只保留两边都有的」——这是内连接的行为,需要显式要求:s1.align(s2, join="inner")。运算符默认走外连接,而且没有参数可以改。想换连接方式就得换成方法名(.add、.sub、.mul……),它们都接受 fill_value。
NaN。
没人报错。多出来的 NaN 就是唯一的提示,而且它和「真的缺数据」长得一模一样。自己检查:a.index.equals(b.index)、a.index.difference(b.index)。
这一章的一句话
pandas 的 index 不是行号,是一个参与每一次运算的隐藏连接键;它会替你把顺序不同的数据对回去,也会在名字对不上时静默给你 NaN——而 .to_numpy() 是把这套保护整个关掉的开关。
下一章处理这层名字带来的第二个后果:既然行既有名字又有位置,那「取第 2 行」就有了两种含义。pandas 为此准备了四把不同的刀,而它们的边界特别容易搞混——.loc["x":"y"] 取到 2 行,.iloc[1:2] 只取到 1 行,同一个冒号,一个含右端一个不含。再加上 pandas 3 刚刚换掉的那套复制规则,这一章值得慢慢看。