跳到正文

Pandas数据筛选与选择:loc、iloc 与布尔索引

筛数据是 pandas 里调用最频繁的操作,也是最容易出静默错误的地方。取错了行不会报错,分析照跑,结论就偏了。三种取数方式要分清楚:[] 加条件做布尔筛选,.loc 按标签取,.iloc 按位置取。区别集中在两点上——边界闭不闭,以及你给的是标签还是位置

sklearn 的 iris 列名里带空格和括号,写条件表达式时要加反引号才能用 query,先改成蛇形命名(snake_case)省事。

import pandas as pd
from sklearn.datasets import load_iris
df = load_iris(as_frame=True).frame
df.columns = ["sepal_len", "sepal_wid", "petal_len", "petal_wid", "species"]
df["species"] = df["species"].map({0: "setosa", 1: "versicolor", 2: "virginica"})
print(df.head(3))
sepal_len sepal_wid petal_len petal_wid species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
2 4.7 3.2 1.3 0.2 setosa

布尔索引:条件表达式本身就是掩码

Section titled “布尔索引:条件表达式本身就是掩码”

df[条件] 里的条件是一个与行数等长的布尔 Series,True 的行留下。这和 R 里 filter() 的思路完全一致,只是 Python 要显式写出列名。

print(df[df["petal_len"] > 6.5])
print(len(df[df["petal_len"] > 6.5]))
sepal_len sepal_wid petal_len petal_wid species
105 7.6 3.0 6.6 2.1 virginica
117 7.7 3.8 6.7 2.2 virginica
118 7.7 2.6 6.9 2.3 virginica
122 7.7 2.8 6.7 2.0 virginica
4

注意结果里的行号是 105、117、118、122,不是 0 到 3。筛选不会重置索引,原始行号被保留下来了。这在追踪异常样本时是好事——看到 118 号样本可以直接回去查原始记录;但要在筛选后的结果上按位置取数就会踩坑,后面 iloc 那节会说。想重新编号就接一句 reset_index(drop=True)

多条件:必须用 & |,且每个条件加括号

Section titled “多条件:必须用 & |,且每个条件加括号”

最常见的报错长这样:

df[(df["petal_len"] > 5.0) and (df["species"] == "virginica")]
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().

原因值得说清楚:and 是 Python 的关键字,它需要把两边都求值成一个布尔值,而 df["petal_len"] > 5.0 是一整个布尔 Series,Python 不知道该取哪一个元素来判断真假。pandas 里要用按位运算符 &(与)、|(或)、~(非),它们逐元素运算。

括号也不能省。& 的优先级比比较运算符高,df["petal_len"] > 5.0 & df["species"] == "x" 会被解释成 df["petal_len"] > (5.0 & df["species"]) == "x",报错或者给出莫名其妙的结果。

out = df[(df["petal_len"] > 5.0) & (df["species"] == "virginica")]
print(out.head(3))
print(len(out))
sepal_len sepal_wid petal_len petal_wid species
100 6.3 3.3 6.0 2.5 virginica
101 5.8 2.7 5.1 1.9 virginica
102 7.1 3.0 5.9 2.1 virginica
41

结论:Python 里写筛选条件,每个条件一对括号,中间用 &|。没有例外。

loc 的第一个参数是行标签,第二个是列名。切片时两端都包含,这点和 Python 的列表切片相反,是从 R 的取数习惯过来的。

print(df.loc[0:2, "sepal_len":"petal_len"])
sepal_len sepal_wid petal_len
0 5.1 3.5 1.4
1 4.9 3.0 1.4
2 4.7 3.2 1.3

0:2 取到了 0、1、2 三行。列名切片 "sepal_len":"petal_len" 按照列在原表中的先后顺序取,也包含两端。

列选择可以传列表,这时候不受顺序限制:

print(df.loc[2:4, ["species", "petal_len"]])
species petal_len
2 setosa 1.3
3 setosa 1.5
4 setosa 1.4

iloc 只认整数位置,规则和 Python 列表切片一致:包含起点,不含终点。上面那个 loc[0:2] 的三行结果,用 iloc 要写 0:3

print(df.iloc[0:2, 0:3])
sepal_len sepal_wid petal_len
0 5.1 3.5 1.4
1 4.9 3.0 1.4

两个都常用,记混的代价是悄悄多一行或者少一行。判断标准很简单:数字是行号还是位置。原始数据带业务编号(比如问卷编号 1001、1002)时,loc[1001:1005] 取五条记录,iloc[1001:1005] 取第 1001 到 1004 条——如果表只有 150 行,后者直接抛 IndexError,反而安全。

筛选之后的行索引是跳号的(105、117、118……),两种取法的差别在这时候最清楚:

long_petal = df[df["petal_len"] > 6.5]
print(long_petal.iloc[0]["petal_len"]) # 位置 0:第一行
print(long_petal.loc[105, "petal_len"]) # 标签 105:同一行
6.6
6.6

两条语句指向同一行:iloc[0] 不关心原始编号,loc[105] 依赖原始编号存在。筛选结果接 reset_index(drop=True) 之后,loc[105] 就取不到了,而 iloc[0] 照常工作。

差一对方括号,返回类型就变了:

print(type(df["sepal_len"]).__name__, type(df[["sepal_len"]]).__name__)
Series DataFrame

单列是 Series(一维),多列是 DataFrame(二维)。差别在这里会咬人:df["species"].str.upper() 能跑,df[["species"]].str.upper() 直接 AttributeError,因为 DataFrame 没有 .str。分组统计也一样,df.groupby("species")["petal_len"].mean() 返回 Series,把中括号改成 [["petal_len"]] 就返回 DataFrame——表结构会一路影响下去,取单列时想清楚后面要做什么:要接着做属性访问就用单括号,要留在二维结构里就用双括号。

isin、between:范围判断的两个快捷方式

Section titled “isin、between:范围判断的两个快捷方式”
print(df[df["species"].isin(["setosa", "virginica"])].shape)
print(len(df[df["petal_len"].between(1.4, 1.5)]))
(100, 5)
26

isin 等价于一串 | 连接的等值比较,取值多的时候写法干净得多。between(1.4, 1.5) 两侧都是闭区间,等价于 (df["petal_len"] >= 1.4) & (df["petal_len"] <= 1.5)

浮点数的等值判断要小心。CSV 里写的 1.4 读进来确实等于字面量 1.4,但经过求和、求均值之后就不是了——0.1 + 0.2 == 0.3 在 Python 里返回 False。凡是拿计算结果去比较,都改用 between 或者 np.isclose 给一个容差,别用 ==

条件多、列名短的时候,query 可读性更好:

print(df.query("petal_len > 6.5 and species == 'virginica'").head(3))
print(df.query("`sepal_len` > 7.5").shape)
sepal_len sepal_wid petal_len petal_wid species
105 7.6 3.0 6.6 2.1 virginica
117 7.7 3.8 6.7 2.2 virginica
118 7.7 2.6 6.9 2.3 virginica
(6, 5)

query 里可以用 andornot,它自己解析字符串,不受前面布尔运算符的限制。列名不合法的(带空格、括号)要用反引号包起来(键盘左上角那个键)。query 支持引用外部变量,写法是在变量名前加 @,例如 df.query("petal_len > @threshold")——循环里换阈值跑筛选时很方便。

代价是条件写在字符串里,编辑器不会帮你检查列名拼写,拼错了要到运行时才报错。列名多、条件复杂时优先用布尔索引,至少 IDE 能提示。

.str 把 Python 的字符串方法逐元素应用到整列上:

print(df["species"].str.startswith("v").sum())
print(df[df["species"].str.contains("color")].shape)
100
(50, 5)

startswith 返回布尔 Series,直接 .sum() 就是计数——True 按 1 算,versicolor 与 virginica 各 50 行,所以是 100。contains 那行取的是名字里带 color 的,只有 versicolor 命中。

两个细节:.str 只在字符串列上有效,列里混了数字时非字符串位置会返回 NaN 而不是报错,用之前先看一眼 dtype;contains 默认把参数当正则表达式,做纯文本查找要显式写 regex=False,否则搜索内容里的 .*( 会被当成模式,匹配结果和预期不同。

筛选之后要给子集写新值,标准写法是用 loc 一次完成:

d = df.copy()
d.loc[d["petal_len"] >= 5.0, "size"] = "long"
d.loc[d["petal_len"] < 5.0, "size"] = "short"
print(d["size"].value_counts())
size
short 104
long 46
Name: count, dtype: int64

链式写法(先取列、再按条件赋值)是错的:

d2 = df.copy()
d2["petal_len"][d2["petal_len"] > 6.5] = 0
print(d2["petal_len"].max())
6.9

pandas 3.0 会打印一条 ChainedAssignmentError 警告,提示改用 .loc;更早的版本里它是彻底静默的——你看到代码跑完了,以为改了,其实改的是中间那份临时副本。判断方法很直接:赋值语句左边出现两次 [,就是链式赋值。改成 d2.loc[d2["petal_len"] > 6.5, "petal_len"] = 0 才对。

筛选和选择解决的是「取哪些行」,下一步通常是把取出来的行按组汇总,见 Pandas分组聚合。如果你习惯 R 的写法,dplyr核心动词 里的 filter()select()slice() 干的是同一件事,对照着看能更快建立对应关系——R 用 filter(df, x > 5),pandas 用 df[df["x"] > 5],语义一致,只是条件写在哪里的区别。