第 25 章 · 选列与选行:loc / iloc
这一章要解决什么:精确地取出你要的行和列。 打掉两个坑:
loc和iloc的切片语义完全不同;链式赋值df[条件][列] = 值不生效。
本章你需要完成的代码:demos/ch25-select-rows-cols.py
25.1 选列:三种写法
df["soh"] # Series 单列
df[["cell_id", "soh"]] # DataFrame 多列(两层方括号)
df.soh # Series 属性写法三种写法都能拿到列,但只有前两种推荐。
点开:为什么 df.soh 不推荐?
三个理由:
① 列名带空格就失效。
df["resistance mohm"] # ✅ 方括号能用
df.resistance mohm # ❌ 语法错误② 容易和 DataFrame 的方法/属性撞车。
df.mean # 这是"求均值的那个方法",不是名为 mean 的列
df.count
df.size
df.shape如果 CSV 里真有一列叫 mean,df.mean 拿到的是方法而不是数据 —— 而且不报错。
③ IDE 不会提示拼写错误。
df.soh 写错成 df.sohh 会报 AttributeError,但不会有"你是不是想写 soh"的补全提示 (因为属性是动态的)。
df["soh"] 写错成 df["sohh"] 会报 KeyError: 'sohh', 而且 IDE 通常能根据列名提示。
结论:
df["soh"] # ✅ 推荐
df[["cell_id", "soh"]] # ✅ 推荐(多列)
df.soh # ⚠️ 能用,但不推荐少写一层方括号会怎样
df["cell_id", "soh"]
# KeyError: ('cell_id', 'soh')注意报错里那个元组 —— Python 把 "cell_id", "soh" 解析成了一个元组 ("cell_id", "soh"),然后去找"叫这个名字的列"。
报错信息里的括号是关键线索:看到 KeyError: ('a', 'b') 带着元组, 就知道是"少写了一层方括号"。
25.2 loc:按标签
df.loc[0] # 第 0 行(一个 Series)
df.loc[0, "soh"] # 第 0 行第 "soh" 列 → 一个标量
df.loc[[0, 1], ["cell_id", "soh"]] # 指定行 + 指定列loc 的语法是 df.loc[行选择, 列选择]。
print(df.loc[0])cell_id C001
batch A
cycle 1
timestamp 2023-01-01
capacity_ah 2.5092
soh 0.9999
resistance_mohm 40.33
temp_rise_c 6.0
coulombic_eff 0.998
Name: 0, dtype: object注意这个 Series 的索引变成了列名(因为它是"一行", 所以行内的"标签"就是各个列名)。这是一个很有用的转化:
row = df.loc[0]
print(row["soh"]) # 0.9999
print(row.to_dict()) # 变成一个字典(第 09 章学的)⚠ loc 的切片:两端都含
print(df.loc[0:2, "cycle"].tolist()) # [1, 2, 3] ← 含 0、1、2 共 3 行这是 loc 和 iloc 最大的区别,也是本书反复强调的:
| 切片语义 | [0:2] 取到 | |
|---|---|---|
loc(按标签) | 两端都含 | 标签 0、1、2 |
iloc(按位置) | 左闭右开 | 位置 0、1 |
| Python 列表 | 左闭右开 | 下标 0、1 |
为什么 loc 要"两端都含":
因为它的设计目标是"按标签取",而标签常常是字符串或日期:
df.loc["2023-01-01":"2023-01-31", "soh"]你会希望这个区间包含 1 月 31 日 —— 如果排除右端, 写 "2023-01-31" 却拿不到那天的数据,会非常反直觉。
所以 loc 的规则是"符合人的直觉"的,而 iloc 的规则是"和列表/NumPy 一致"的。
记忆法:
loc是给人用的(按名字取,两端都含)iloc是给机器用的(按位置取,左闭右开)
25.3 iloc:按位置
df.iloc[0] # 第 0 行
df.iloc[0, 6] # 第 0 行、第 6 列(= resistance_mohm)
df.iloc[0:3, 0:3] # 前 3 行 × 前 3 列
df.iloc[-1] # 最后一行
df.iloc[:, -1] # 最后一列iloc 的切片和列表/NumPy 一致:左闭右开。
print(df.iloc[0:3, 0:3])cell_id batch cycle
C001 A 1
C001 A 2
C001 A 3iloc 的实用场景:
df.iloc[:, -1] # 最后一列(不管它叫什么名字)
df.iloc[-5:, :] # 最后 5 行
df.iloc[::100, :] # 每隔 100 行取一个(快速看大数据的趋势)⚠ iloc 按位置取有一个隐患:列顺序变了,代码就错了。
df.iloc[:, 4] # "第 4 列" —— 如果上游调整了列顺序,这里就读到别的列了
df["capacity_ah"] # ✅ "capacity_ah 列" —— 列顺序怎么变都不受影响纪律:除非你确实要"第几列"(比如要看最后一列),否则用列名。
本书只在两种情况下用 iloc:
df.iloc[0] # 看第一行
df.iloc[-1] # 看最后一行25.4 loc vs iloc:一个危险的巧合
这份数据的索引恰好是 0, 1, 2, ...,所以:
df.loc[0, "soh"] # 0.9999
df.iloc[0, 5] # 0.9999 ← 巧合一样!这很危险 —— 因为它掩盖了两者的区别。一旦索引变成别的:
td = df[df["cell_id"] == "C001"].copy()
td["timestamp"] = pd.to_datetime(td["timestamp"])
td = td.set_index("timestamp")
td.loc[0] # ❌ KeyError: 0 ← 标签 0 不存在(标签现在是日期)
td.iloc[0] # ✅ 第一行,没问题
td.loc["2023-01-01"] # ✅ 按日期标签取这就是"巧合一样"的危害:你在开发时写 df.loc[0] 一直没问题, 上线后数据换了索引,代码立刻崩 —— 而且只有上线才发现。
纪律:
按位置 → iloc
按标签 → loc
永远不要混用,也不要依赖"索引恰好是 0,1,2"这个巧合25.5 筛选行:布尔条件
early = df[df["cycle"] <= 10]
print(len(early)) # 120df[条件] 就是"选出满足条件的行",返回一个新 DataFrame。
df 本身没被改 —— 这是 Pandas 的重要特性:
print(len(df)) # 13967
print(len(early)) # 120 只是"视图"出了 120 行链式操作(第 26 章会详细讲):
result = df[df["cycle"] <= 10].head(3)[["cell_id", "soh"]]读法(从左到右,每一步都想"现在手上是什么"):
df 一张 13967 行的表
df[df["cycle"] <= 10] 还是表,120 行
.head(3) 还是表,3 行
[["cell_id", "soh"]] 还是表,3 行 2 列建议:链式写法的可读性取决于你的熟练度。 如果一时看不明白,就拆成几步、每步 print 一次:
step1 = df[df["cycle"] <= 10]
print(step1.shape) # (120, 9)
step2 = step1.head(3)
print(step2.shape) # (3, 9)
result = step2[["cell_id", "soh"]]
print(result.shape) # (3, 2)这不是"笨办法" —— 排查问题时所有人都会这么做。
验证两种写法一致:
step_by_step = df[df["cycle"] <= 10].head(3)[["cell_id", "soh"]]
print(result.equals(step_by_step)) # True ← DataFrame 的 equals 比较内容和结构注意 equals:它比 == 更严格(会检查 dtype 和索引),适合做"两种写法等价"的断言。
25.6 nsmallest / nlargest:取最值的几行
df.nsmallest(3, "soh") # SOH 最小的 3 行
df.nlargest(3, "resistance_mohm") # 内阻最大的 3 行和 sort_values().head() 的区别:
# 两种写法结果一样
df.nsmallest(3, "soh")
df.sort_values("soh").head(3)nsmallest | sort_values().head() | |
|---|---|---|
| 复杂度 | O(n) 部分选择 | O(n log n) 全排序 |
| 意图 | "我只要最小的几个" | "我要排好序的所有" |
| 大数据量 | 快 | 慢 |
对 1.4 万行,速度差异可以忽略。但用 nsmallest 表达意图更清楚 —— 它明确告诉你"我只关心最小的那几个",而不是"我要排序后取前几个"。
25.7 ★ 链式赋值陷阱
这是本章最重要的坑,也是最容易在真实项目里造成"改了但没生效"的原因。
点开:先看现象,再看原因
现象
subset = df[df["cycle"] <= 10]
subset["status"] = "新电芯"
print("status" in subset.columns) # True ← subset 里有了
print("status" in df.columns) # False ← df 里没有!没有报错,也没有警告。 subset 看起来改了,但 df 完全没变。
点开:Pandas 3.0 的行为变化(很重要,别照抄旧教程)
Pandas 2.x 及更早:这种情况会打印一条 SettingWithCopyWarning:
SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value insteadPandas 3.0(本书使用的版本):默认开启 Copy-on-Write(写时复制), 行为变得更严格也更一致:
| 场景 | 3.0 的行为 |
|---|---|
subset["status"] = "新电芯" | 静默生效在 subset 上,df 不变(没有警告) |
df[条件]["status"] = "新电芯" | 抛 ChainedAssignmentError —— 直接报错! |
实测(本书示例 demos/ch27-derived-column.py):
② ❌ df[条件]['is_eol'] = True → ChainedAssignmentError
df 被改到了吗?非空 0 行 —— 没有!这是好事:ChainedAssignmentError 把一个"静默失效"的坑 变成了"立刻看得见"的错误。
但也带来了新的危险:第一种写法(先赋给变量再改)不报错, 只是默默改在副本上。所以不能靠"有没有报错"来判断。
唯一可靠的做法是纪律:
① 只要你要"改"筛选结果 → 先 .copy()
② 只要你要"按条件改原表" → 用 df.loc[条件, 列] = 值
③ 想确认改没改到 → print("列名" in df.columns)为什么 Pandas 要做这个改变:因为老版本的行为根本无法预期 —— df[条件] 有时返回视图、有时返回副本(取决于内存布局), 所以"改了没生效"这个 bug 会随机出现,极难排查。
Copy-on-Write 让规则变得明确:任何"派生"出来的对象都是独立的, 修改不会影响原表,除非你明确用 loc 在原点改。
原因
df[df["cycle"] <= 10] 返回的是什么?在 Pandas 3.0 之前,这取决于内部实现:
- 有时它返回一个视图(指向原数据的一部分)
- 有时它返回一个副本
Pandas 无法保证是哪一个,所以在 3.0 里干脆规定: 派生对象一律是独立的副本(Copy-on-Write), 并且禁止在"链式"表达式上赋值(抛 ChainedAssignmentError)。
这就是第 19 章"视图 vs 拷贝"在 Pandas 里的表现 —— 同一个问题的另一种形式。
两种正确的写法
写法一:明确复制(推荐,意图最清楚)
subset = df[df["cycle"] <= 10].copy() # ← .copy() 是关键
subset["status"] = "新电芯"
print("status" in df.columns) # False ← df 不受影响(这是你想要的)写法二:一步到位用 loc(想改原表时用)
df.loc[df["cycle"] <= 10, "status"] = "新电芯"
print("status" in df.columns) # True
print(df["status"].notna().sum()) # 120 ← 只有那 120 行被赋了值df.loc[条件, 列名] = 值 这个模式要背下来:
按条件改某列的值,永远用
df.loc[条件, 列名] = 值。
它的三个好处:
- 一定生效(没有"改的是副本"的问题);
- 意图明确(读者一眼看出"给满足条件的行的某一列赋值");
- 不产生中间对象(比先筛选再赋值更省内存)。
三条纪律
① 只要你要"改"筛选结果 → 先 .copy()
② 只要你要"按条件改原表" → 用 df.loc[条件, 列] = 值
③ 改完一定要验证:print("列名" in df.columns) / print(df["列名"].notna().sum())为什么第 ③ 条不能省:Pandas 3.0 的两种行为是
| 写法 | 行为 | 会报错吗 |
|---|---|---|
df[条件]["列"] = 值 | 抛 ChainedAssignmentError | ✅ 会报错 |
sub = df[条件] 然后 sub["列"] = 值 | 静默改在副本上,df 不变 | ❌ 不报错 |
第二种更危险 —— 它不会告诉你出了问题。
一个更隐蔽的版本
# 这个也不会报错,但 df 什么都没改
df[df["soh"] < 0.85]["health"] = "待退役"注意:在 Pandas 2.x 里这行会警告、在 3.0 里可能直接抛错, 但无论如何,你都不能靠"有没有警告/报错"来判断 —— 因为还有一批写法是完全静默的。
要靠纪律:
如果你发现自己在写"两层方括号套着赋值", 先停下来,想想是不是该用
.loc[条件, 列] = 值。
最保险的自检(三行):
before = df["health"].notna().sum() if "health" in df.columns else 0
df.loc[df["soh"] < 0.85, "health"] = "待退役"
after = df["health"].notna().sum()
print(f"改前 {before} 行有值,改后 {after} 行 —— 差 {after - before}")25.8 完整示例与输出
cd code
python demos/ch25-select-rows-cols.py真实输出(节选):
=== ① 选列 ===
单列 df['soh'] → Series
多列 df[['cell_id','soh']] → DataFrame
⚠ 少写一层方括号 df['cell_id','soh'] → KeyError: ('cell_id', 'soh')
=== ② loc:按标签 ===
df.loc[0, 'soh'] = 0.9999 ← 行标签 + 列名 = 一个值
loc 的切片是「两端都含」:
df.loc[0:2, 'cycle'].tolist() = [1, 2, 3] ← 含 0、1、2 共 3 行
=== ③ iloc:按位置(整数下标) ===
df.iloc[0, 6] = 40.33 ← 第 0 行、第 6 列 = resistance_mohm
iloc 的切片是「左闭右开」,和列表/NumPy 一致。
=== ④ loc vs iloc:一句话记法 ===
loc → 按「标签」(列名、索引值) → 切片两端都含
iloc → 按「位置」(0,1,2...) → 切片左闭右开本章练习
练习 1:用 loc / iloc 选行选列
完成四件事,每步打印 .shape 验证:
- 筛出
cycle <= 10的记录,打印行数; - 取前 3 行,只保留
cell_id和soh两列; - 用
loc取第 0 行;用iloc取第 0 行; - 比较
df.loc[0:2, ["cell_id", "soh"]]和df.iloc[0:3, 0:2]的行数。
参考答案
from pathlib import Path
import pandas as pd
pd.set_option("display.width", 160)
CODE_DIR = Path(__file__).resolve().parent.parent.parent
df = pd.read_csv(CODE_DIR / "data" / "battery" / "cycle_raw.csv")
# ① 筛选
early = df[df["cycle"] <= 10]
print(len(early)) # 120 = 12 支 × 10 圈
# ② 前 3 行 + 两列
result = early.head(3)[["cell_id", "soh"]]
print(result)
# 验证:拆成三步结果一样
assert result.equals(df[df["cycle"] <= 10].head(3)[["cell_id", "soh"]])
# ③ loc 与 iloc 取单行
print(df.loc[0, "soh"]) # 0.9999
print(df.iloc[0, 5]) # 0.9999 ← 第 5 列也是 soh
print(df.loc[0].to_dict()) # 整行变成一个字典
# ④ 切片语义的差别
a = df.loc[0:2, ["cell_id", "soh"]] # loc:两端都含 → 3 行
b = df.iloc[0:3, 0:2] # iloc:左闭右开 → 3 行
print(len(a), b.shape) # 3 (3, 2)
# 都是 3 行,但原因不同:loc 含右端标签 2,iloc 不含右端位置 3关键理解:
| 写法 | 语义 | 取到 |
|---|---|---|
df.loc[0:2] | 标签 0 到标签 2,两端都含 | 3 行 |
df.iloc[0:3] | 位置 0 到位置 3,左闭右开 | 3 行 |
两者都是 3 行,但一个写 2 一个写 3。
这就是最容易混的地方 —— 记住这个对照:
df.loc[a:b] # 含 b
df.iloc[a:b] # 不含 b完整练习:exercises/ex25-01-select-rows-cols
练习 2:为什么这个赋值没生效?
import pandas as pd
df = pd.read_csv("data/battery/cycle_raw.csv")
# 想给"前 10 圈"的记录加一个标记
early = df[df["cycle"] <= 10]
early["is_early"] = True
print("is_early" in df.columns) # ?
print(early["is_early"].sum()) # ?要求:预测两个输出,运行验证,并说明怎么改才能让 df 也有这一列。
参考答案
False ← df 里没有 is_early 列
120 ← 但 early 里有同时你会看到 SettingWithCopyWarning 警告。
原因:df[df["cycle"] <= 10] 返回的可能是视图、也可能是副本 —— Pandas 不保证。所以对它赋值的结果不可预期(这里恰好改到了副本)。
你想让 df 也有这一列,两种改法:
改法一:明确复制,只操作子集(如果本来就不想改 df)
early = df[df["cycle"] <= 10].copy() # ← 加 .copy()
early["is_early"] = True
print("is_early" in df.columns) # False (df 干净,符合预期)改法二:用 loc 一步到位(如果就是要改 df)
df.loc[df["cycle"] <= 10, "is_early"] = True
print("is_early" in df.columns) # True
print(df["is_early"].notna().sum()) # 120注意改法二的效果:只有满足条件的 120 行被赋了 True, 其余 13847 行是 NaN(不是 False)。
print(df["is_early"].value_counts(dropna=False))
# True 120
# NaN 13847为什么会是 NaN 而不是 False:因为这一列是"赋值时才创建的", 没被赋值的行就是"没有值"(NaN)。
如果你希望是 False:
df["is_early"] = False # ① 先给全部行一个默认值
df.loc[df["cycle"] <= 10, "is_early"] = True # ② 再改满足条件的
print(df["is_early"].value_counts())
# False 13847
# True 120★ 记忆这三行模式:
df["新列"] = 默认值 # 先建列
df.loc[条件, "新列"] = 值 # 再按条件改
df["新列"].value_counts() # 最后验证这个模式在数据清洗里会反复用到(比如"先全部标记为正常,再把异常标出来")。
最后强调一次:
看到
SettingWithCopyWarning不要忽略它。 它在告诉你"这个赋值可能没生效" —— 而如果不生效,你后面所有的分析都建立在错误的数据上。
练习 3:为什么"巧合一样"很危险?
df.loc[0, "soh"] # 0.9999
df.iloc[0, 5] # 0.9999要求:解释这两行为什么结果一样、这个巧合会在什么情况下失效、 以及正确的使用纪律。
参考答案
为什么一样:
df 的索引恰好是 0, 1, 2, ..., 13966(RangeIndex), 所以"标签 0"和"位置 0"指向同一行。第 5 列恰好是 soh。
标签 0 ─┐
├─→ 同一行
位置 0 ─┘这个巧合在什么时候失效:
① 索引不是 0,1,2 时(最常见):
td = df[df["cell_id"] == "C001"].copy()
td["timestamp"] = pd.to_datetime(td["timestamp"])
td = td.set_index("timestamp") # 索引变成日期
td.loc[0] # ❌ KeyError: 0 (没有标签叫 0 的行)
td.iloc[0] # ✅ 第一行
td.loc["2023-01-01"] # ✅ 按日期取② 筛选之后索引不连续:
sub = df[df["cycle"] <= 10]
print(sub.index[:15].tolist())
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 862, 863, 864, 865]
# ↑ 注意这里跳到 862!df.loc[11] 会报 KeyError(因为筛选后没有标签 11 的行), 但 sub.iloc[11] 是合法的(第 11 个位置)。
③ 排序之后:
sorted_df = df.sort_values("soh")
sorted_df.loc[0] # 还是标签 0 那一行(但内容变了位置)
sorted_df.iloc[0] # 现在是最差的那一行两者现在完全不同了。
正确的使用纪律:
按标签(列名、索引值)→ loc
按位置(第几行、第几列)→ iloc再加三条辅助规则:
① 筛选后想用位置索引,先 reset_index():
sub = df[df["cycle"] <= 10].reset_index(drop=True)
print(sub.index[:5].tolist()) # [0, 1, 2, 3, 4] ← 连续了drop=True 表示"丢掉原来的索引,不要变成一列"。
② 大多数情况下,根本不需要位置索引:
# ❌ 用位置
df.iloc[:5]
# ✅ 用条件或排名
df.head(5)
df.nsmallest(5, "soh")③ 用 .shape 而不是"我数了数"来确认取到的行数。
一个真实的教训:本书的示例代码里, 凡是需要"按行位置"的地方都用 iloc,凡是"按条件"的地方都用布尔筛选 —— 没有一处在两者之间"差不多"的地方含糊。
这不是教条,而是因为"索引恰好是 0,1,2"这个前提,在任何一次筛选、 排序、set_index、merge 之后都可能被打破。
面向 AI 开发:为什么"列名取值"比"位置取值"重要得多
这一章的 loc/iloc 之辨,在 AI 项目里有一个更严肃的版本:
特征必须按名字取,绝不能按位置取。
一个真实的事故模式:
# 训练时(数据表列顺序:capacity, resistance, temp)
FEATURES = df.columns[4:7] # 拿到 [capacity_ah, soh, resistance_mohm] —— 等等,混进了 soh!
X_train = df[FEATURES].to_numpy()
model.fit(X_train, y)
# 上线时(上游把 soh 列挪到了别的位置)
X_prod = prod_df.iloc[:, 4:7].to_numpy() # 取到的列完全不同了
pred = model.predict(X_prod) # 不报错,但结果全错这个 bug 的两个特征:
- 不报错(形状对了,类型也对)
- 很难发现(模型照样输出数字,只是数字没意义)
正确的做法:
# 把特征列表定义成常量,整个项目都用它
FEATURES = ["capacity_ah", "resistance_mohm", "temp_rise_c"]
TARGET = "soh"
def make_xy(df: pd.DataFrame) -> tuple:
"""按名字取特征和标签 —— 列顺序变了也不受影响。"""
missing = set(FEATURES + [TARGET]) - set(df.columns)
if missing:
raise ValueError(f"缺少必需的列: {missing}")
return df[FEATURES].to_numpy(), df[TARGET].to_numpy()注意那个 missing 检查 —— 它把"列名不对"变成一个明确的报错, 而不是一个静默的错误结果。这是 AI 数据管道最基本的一道防线。
进一步:把列名和 dtype 一起校验
EXPECTED = {
"capacity_ah": "float64",
"resistance_mohm": "float64",
"temp_rise_c": "float64",
}
def validate_schema(df: pd.DataFrame, expected: dict) -> None:
"""校验表的 schema —— 上游改了数据立刻报错。"""
for col, dtype in expected.items():
if col not in df.columns:
raise ValueError(f"缺少列: {col}")
actual = str(df[col].dtype)
if actual != dtype:
raise TypeError(f"{col} 的类型是 {actual},期望 {dtype}")为什么值得写这 10 行:
| 没有 schema 校验 | 有 schema 校验 |
|---|---|
上游改了列名 → 跑到一半 KeyError | 读入时立刻报错,指出缺哪一列 |
| 上游改了 dtype(float → str)→ 训练时崩 | 读入时立刻报错,指出类型不对 |
| 上游改了列顺序 → 静默错误 | 按名字取,顺序无关 |
| 排查时间:几小时 | 排查时间:几秒 |
这条实践在 AI 工程里有正式名字:Schema Validation(模式校验)。 像 Great Expectations、Pandera、Pydantic 这些库就是专门做这件事的。
但即使不用库,用本章的 loc + 列名列表, 再加上几行 assert,就能挡住 90% 的数据管道事故。
本章最实用的一句话:
df.loc[条件, 列名] = 值和"按列名取数据",这两个动作要形成肌肉记忆。 它们一个保证"改一定生效",一个保证"取一定正确"。
下一章:第 26 章 · 筛选与排序