四把刀:[ ]、.loc、.iloc、布尔
给行贴上名字,就多出一个问题:「取第 2 行」到底是叫 2 的那一行,还是排在第 2 位的那一行?pandas 没有替你选,它给了四种写法,每种回答一个不同的问题。这一章把四把刀的边界划清楚,顺便讲清 pandas 3 里刚刚换掉的那套复制规则——如果你在网上搜到过 SettingWithCopyWarning 的解释,那些解释现在已经过期了。
df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [10, 20, 30, 40]},
index=["w", "x", "y", "z"])
df.loc["x":"y"] # ①
df.iloc[1:2] # ②
问:这两行各返回几行?
四把刀,四个不同的问题
先把全貌摆出来。这四种写法回答的不是同一个问题:
| 写法 | 它在问什么 | 切的是 | 常见误用 |
|---|---|---|---|
df["a"] | 哪一列叫 a | 列 | 以为它取行 |
df.loc["x", "b"] | 叫 x 的行、叫 b 的列 | 行 + 列,按名字 | 忘了它含右端 |
df.iloc[1, 1] | 第 1 位的行、第 1 位的列 | 行 + 列,按位置 | 数据一排序就指错了 |
df[df.a > 2] | 哪些行满足条件 | 行 | 在结果上写值(见后文) |
第一条最反直觉:df["a"] 取的是列,而 df[df.a > 2] 取的是行。同一个方括号,两种含义,靠里面放的东西区分。这是 pandas 为了「常用的写起来短」付出的一致性代价,也是它被批评最多的地方。
那个冒号的两种含义
回到开头的问题。.loc 按名字切,两端都包含;.iloc 按位置切,右端不包含(和 Python 的列表一致):
df.loc["x":"y"] → x, y 2 行 ★ 含右端 df.iloc[1:2] → x 1 行 df.iloc[1:3] → x, y 2 行
为什么 .loc 要含右端?因为按名字切的时候,你通常不知道「下一个名字」是什么。写 df.loc["2026-01-01":"2026-01-31"] 要一月整月,如果右端不含,你得知道二月一号是不是存在——这个要求不合理。按位置可以「下一个」,按名字不能。规则不同不是随意的。
index 是整数的时候,两把刀会给出不同的答案
这是四把刀里最容易出事的一处。index 里的整数是名字,不是位置:
e = pd.DataFrame({"v": [10, 20, 30]}, index=[2, 0, 1])
e.loc[2, "v"] # 10 ← 叫「2」的那行,排在第 0 位
e.iloc[2]["v"] # 30 ← 排在第 2 位的那行,它叫「1」
两行都合法,都不报错,答案差了三倍。而这种 index 太常见了——任何一次 df[df.x > 0] 之后,剩下的行还带着原来的行号,于是 index 就成了一串不连续的整数。
还有一个更隐蔽的:裸方括号加冒号,走的是位置:
e[0:2] → index 是 [2, 0] ← 按位置取了前两行 e.loc[0:2] → 按名字,从「0」到「2」 ← 完全不同的一批行
同一个 [0:2],加不加 .loc 是两回事。所以有一条几乎没有代价的纪律:取行永远写 .loc 或 .iloc,不要用裸方括号切片。裸方括号只留给「取某一列」。
与之配套的是那个到处都在写的 reset_index(drop=True):过滤或排序之后,如果你接下来打算按位置思考,就把 index 重排成 0…n−1;如果打算按名字思考,就别重排。决定权在你,但必须真的做一次决定。
pandas 3 换掉了那套复制规则
如果你搜过 SettingWithCopyWarning,见到的多半是这样一段解释:「pandas 也不确定你拿到的是视图还是副本,所以给你一个警告,你可能改到了原表,也可能没有。」
这个解释在 pandas 3 里已经不成立了。从 3.0 开始,写时复制(Copy-on-Write)是唯一模式,不能关。它的承诺很简单:
任何一次「从 DataFrame 里取出东西」,拿到的都表现得像一份副本;改它永远不会影响原表。
底下还是尽量共享内存(所以取一列仍然不花钱),但只要你往共享的那块写,pandas 会先复制再写。「不知道会不会改到原表」这个问题被删掉了——答案永远是「不会」。
本机验证:
df = pd.DataFrame({"a": [1, 2, 3]})
sub = df["a"] # 取一列
sub.iloc[0] = 99
sub.iloc[0] # 99 ← 改到了 sub
df["a"].iloc[0] # 1 ← 原表纹丝不动
连带着,那个所有人都写过的错误变得更好诊断了:
df[df.a > 2]["b"] = 999 # ← 链式赋值
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy.
关键词是 never works。旧版本说的是「可能有效可能无效」,新版本说的是「一定无效」。df["b"] 的值确实没变,还是 [10, 20, 30, 40]。
正确的写法是一步写完——把行条件和列名交给同一个 .loc:
df.loc[df.a > 2, "b"] = 999 # [10, 20, 999, 999] ✓
这正是第 5 章那句话在 pandas 上的复述:一步写完 = 改原表;先取出来再改 = 改副本。numpy 那边是 a[a > 5] += 1 有效、b = a[a > 5]; b += 1 无效,一模一样的结构。
还有一个变体,pandas 3 里连警告都不给,因为它已经不再有歧义了:
piece = df[df.a > 2] # 先存成一个变量 piece["b"] = 999 # 0 个警告
piece["b"] [999, 999] ← piece 变了 df["b"] [10, 20, 30, 40] ← 原表没变,这是设计好的行为
在 pandas 2 里这一行会给你一个 SettingWithCopyWarning,因为结果不确定。在 pandas 3 里它确定:piece 是一份独立的数据,改它就该只改它。警告消失不是因为 pandas 变宽松了,是因为那个不确定性被消灭了。
链式赋值(chained assignment):连着写两次索引,然后往结果上赋值——df[...][...] = x。要点是「链式」说的是赋值语句里连着两次索引,而不是「连着两次索引」本身。df[df.a > 2]["b"] 用来读是完全合法的,只是有点绕;用来写才是错的。
写时复制(Copy-on-Write)不是 pandas 发明的,是操作系统的老技术:fork() 之后父子进程共享物理页,谁写谁触发复制。Kotlin 的 CopyOnWriteArrayList、Git 的对象存储、文件系统的快照,用的都是同一招——共享是常态,复制只在必要时发生。
四把刀的边界,八行看完:
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [10, 20, 30, 40]},
index=["w", "x", "y", "z"])
print(len(df.loc["x":"y"])) # 2 ← 含右端
print(len(df.iloc[1:2])) # 1 ← 不含
print(len(df.iloc[1:3])) # 2
e = pd.DataFrame({"v": [10, 20, 30]}, index=[2, 0, 1])
print(e.loc[2, "v"], e.iloc[2]["v"], e[0:2].index.tolist())
# 10 30 [2, 0] ← 三种「2」,三个答案
再验一次 pandas 3 的新规矩:
df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [10, 20, 30, 40]})
df[df.a > 2]["b"] = 999 # ChainedAssignmentError,且无效
print(df["b"].tolist()) # [10, 20, 30, 40]
df.loc[df.a > 2, "b"] = 999 # 正确写法
print(df["b"].tolist()) # [10, 20, 999, 999]
python3 -c "import pandas as pd;print(pd.__version__);e=pd.DataFrame({'v':[10,20,30]},index=[2,0,1]);print(e.loc[2,'v'],e.iloc[2]['v'])"
如果你的 pandas 是 2.x,链式赋值会给 SettingWithCopyWarning 而不是 ChainedAssignmentError,而且有时真的会改到原表。先 print(pd.__version__) 确认版本再对照本章。
- SQL 里没有
.iloc。关系模型明确规定行是无序的,所以「第 3 行」这个概念不存在,你必须ORDER BY之后LIMIT/OFFSET。pandas 保留了位置这个概念(因为它服务的是时间序列和数组),代价就是这一章的全部歧义。 - Excel 的 A1 引用与 R1C1 引用。同一件事的两种模式:一个按名字(列字母),一个按位置(行列号)。写复杂公式的人会两种切换着用,出错的地方也一模一样。
- Kotlin 里
list[i]与map[key]的差别。一个按位置,一个按键。pandas 的 DataFrame 同时是 list 和 map,所以它需要两套下标语法——.iloc和.loc就是这两套。 - 为什么
reset_index(drop=True)到处都是。过滤之后 index 变成断续的整数,此时「名字」和「位置」不再重合,后面任何一次.iloc或裸切片都可能指错。那一行代码的真正含义是:「我声明,从这里开始按位置思考。」
「SettingWithCopyWarning 是个恼人的警告,加个 .copy() 或者关掉它就行了。」
这条建议在 pandas 2 时代就很糟(它把「可能改错数据」变成「安静地改错数据」),在 pandas 3 里更是直接失效:那个警告已经不存在了,取而代之的是明确的 ChainedAssignmentError,而且行为是确定的——链式赋值一定无效。
所以真正的修法从来不是消音,是换写法:把两次索引合并成一次 .loc[行条件, 列名]。这不是为了让警告闭嘴,是因为只有一步写完的版本才真的会改到原表。
附带一个更实际的提醒:网上关于 pandas 视图与副本的文章,绝大多数写于 2.x 时代。看到「你可能拿到的是视图」这句话就该警觉——先 print(pd.__version__)。3.0 之后这句话是错的:你拿到的东西,行为上永远是副本。
正确答案是 C:① 2 行,② 1 行。.loc 含右端,.iloc 不含。
.iloc 也遵守。但 .loc 故意破了这条例,理由在正文里:按名字切片时,你往往不知道「右端的下一个名字」是什么,右开就没法用。这是 pandas 里少数几个「故意不一致」的设计之一,而且是有道理的那种。
B 「都是 2 行」——把 .loc 的规则套到了 .iloc 上。要用 .iloc 拿到 2 行得写 iloc[1:3]。
D 「字符串不能切片」——能。只要 index 是有序的(或者至少是唯一的),.loc 就支持用标签切片,日期索引上尤其常用:df.loc["2026-01":"2026-03"]。
顺带一提,四把刀之外还有一把冷门但值得知道的:df.at["x", "b"] 和 df.iat[1, 1],只能取单个格子,但比 .loc/.iloc 快一截。在循环里逐格取值时用得上——不过如果你在循环里逐格取值,第 1 章那条判据在向你招手。
ChainedAssignmentError;但「按位置还是按名字」搞错了没人会拦你——整数 index 下 .loc[2] 和 .iloc[2] 都合法,答案不同。自己检查:过滤之后决定一次,要么 reset_index(drop=True),要么全程只用 .loc。
这一章的一句话
贴上名字之后,「第 2 行」就有了两个含义,pandas 用 .loc 和 .iloc 把它们分开;而 pandas 3 的写时复制把另一个老问题彻底删掉了——你拿到的东西永远表现得像副本,链式赋值永远无效。
下一章处理这层名字带来的第三样东西:NaN。上一章我们已经看到它可以由「对不上」产生,而它还有另一个来源——真的没有数据。这两件事被同一个记号表示,于是产生了一堆麻烦。更要紧的是:每列只缺 5% 的数据,十列一起 dropna,会丢掉 39.3% 的行;而用均值填充,标准差会缩水 2.55%、相关系数会掉 16.0%。没有一种处理是免费的。