Skip to content

第 25 章 · 选列与选行:loc / iloc ​

这一章要解决什么:精确地取出你要的行和列。 打掉两个坑:loc 和 iloc 的切片语义完全不同;链式赋值 df[条件][列] = 值 不生效。

本章你需要完成的代码:demos/ch25-select-rows-cols.py

25.1 选列:三种写法 ​

python
df["soh"]                  # Series      单列
df[["cell_id", "soh"]]     # DataFrame   多列(两层方括号)
df.soh                     # Series      属性写法

三种写法都能拿到列,但只有前两种推荐。

点开:为什么 df.soh 不推荐?

三个理由:

① 列名带空格就失效。

python
df["resistance mohm"]      # ✅ 方括号能用
df.resistance mohm         # ❌ 语法错误

② 容易和 DataFrame 的方法/属性撞车。

python
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 通常能根据列名提示。

结论:

python
df["soh"]              # ✅ 推荐
df[["cell_id", "soh"]] # ✅ 推荐(多列)
df.soh                 # ⚠️ 能用,但不推荐

少写一层方括号会怎样 ​

python
df["cell_id", "soh"]
# KeyError: ('cell_id', 'soh')

注意报错里那个元组 —— Python 把 "cell_id", "soh" 解析成了一个元组 ("cell_id", "soh"),然后去找"叫这个名字的列"。

报错信息里的括号是关键线索:看到 KeyError: ('a', 'b') 带着元组, 就知道是"少写了一层方括号"。

25.2 loc:按标签 ​

python
df.loc[0]                       # 第 0 行(一个 Series)
df.loc[0, "soh"]                # 第 0 行第 "soh" 列 → 一个标量
df.loc[[0, 1], ["cell_id", "soh"]]    # 指定行 + 指定列

loc 的语法是 df.loc[行选择, 列选择]。

python
print(df.loc[0])
text
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 的索引变成了列名(因为它是"一行", 所以行内的"标签"就是各个列名)。这是一个很有用的转化:

python
row = df.loc[0]
print(row["soh"])            # 0.9999
print(row.to_dict())         # 变成一个字典(第 09 章学的)

⚠ loc 的切片:两端都含 ​

python
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 要"两端都含":

因为它的设计目标是"按标签取",而标签常常是字符串或日期:

python
df.loc["2023-01-01":"2023-01-31", "soh"]

你会希望这个区间包含 1 月 31 日 —— 如果排除右端, 写 "2023-01-31" 却拿不到那天的数据,会非常反直觉。

所以 loc 的规则是"符合人的直觉"的,而 iloc 的规则是"和列表/NumPy 一致"的。

记忆法:

loc 是给人用的(按名字取,两端都含)iloc 是给机器用的(按位置取,左闭右开)

25.3 iloc:按位置 ​

python
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 一致:左闭右开。

python
print(df.iloc[0:3, 0:3])
text
cell_id batch  cycle
   C001     A      1
   C001     A      2
   C001     A      3

iloc 的实用场景:

python
df.iloc[:, -1]          # 最后一列(不管它叫什么名字)
df.iloc[-5:, :]         # 最后 5 行
df.iloc[::100, :]       # 每隔 100 行取一个(快速看大数据的趋势)

⚠ iloc 按位置取有一个隐患:列顺序变了,代码就错了。

python
df.iloc[:, 4]           # "第 4 列" —— 如果上游调整了列顺序,这里就读到别的列了
df["capacity_ah"]       # ✅ "capacity_ah 列" —— 列顺序怎么变都不受影响

纪律:除非你确实要"第几列"(比如要看最后一列),否则用列名。

本书只在两种情况下用 iloc:

python
df.iloc[0]              # 看第一行
df.iloc[-1]             # 看最后一行

25.4 loc vs iloc:一个危险的巧合 ​

这份数据的索引恰好是 0, 1, 2, ...,所以:

python
df.loc[0, "soh"]        # 0.9999
df.iloc[0, 5]           # 0.9999     ← 巧合一样!

这很危险 —— 因为它掩盖了两者的区别。一旦索引变成别的:

python
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 筛选行:布尔条件 ​

python
early = df[df["cycle"] <= 10]
print(len(early))              # 120

df[条件] 就是"选出满足条件的行",返回一个新 DataFrame。

df 本身没被改 —— 这是 Pandas 的重要特性:

python
print(len(df))                 # 13967
print(len(early))              # 120       只是"视图"出了 120 行

链式操作(第 26 章会详细讲):

python
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 一次:

python
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)

这不是"笨办法" —— 排查问题时所有人都会这么做。

验证两种写法一致:

python
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:取最值的几行 ​

python
df.nsmallest(3, "soh")          # SOH 最小的 3 行
df.nlargest(3, "resistance_mohm")   # 内阻最大的 3 行

和 sort_values().head() 的区别:

python
# 两种写法结果一样
df.nsmallest(3, "soh")
df.sort_values("soh").head(3)
nsmallestsort_values().head()
复杂度O(n) 部分选择O(n log n) 全排序
意图"我只要最小的几个""我要排好序的所有"
大数据量快慢

对 1.4 万行,速度差异可以忽略。但用 nsmallest 表达意图更清楚 —— 它明确告诉你"我只关心最小的那几个",而不是"我要排序后取前几个"。

25.7 ★ 链式赋值陷阱 ​

这是本章最重要的坑,也是最容易在真实项目里造成"改了但没生效"的原因。

点开:先看现象,再看原因

现象 ​

python
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 instead

Pandas 3.0(本书使用的版本):默认开启 Copy-on-Write(写时复制), 行为变得更严格也更一致:

场景3.0 的行为
subset["status"] = "新电芯"静默生效在 subset 上,df 不变(没有警告)
df[条件]["status"] = "新电芯"抛 ChainedAssignmentError —— 直接报错!

实测(本书示例 demos/ch27-derived-column.py):

text
② ❌ 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 里的表现 —— 同一个问题的另一种形式。

两种正确的写法 ​

写法一:明确复制(推荐,意图最清楚)

python
subset = df[df["cycle"] <= 10].copy()      # ← .copy() 是关键
subset["status"] = "新电芯"
print("status" in df.columns)              # False  ← df 不受影响(这是你想要的)

写法二:一步到位用 loc(想改原表时用)

python
df.loc[df["cycle"] <= 10, "status"] = "新电芯"
print("status" in df.columns)              # True
print(df["status"].notna().sum())          # 120    ← 只有那 120 行被赋了值

df.loc[条件, 列名] = 值 这个模式要背下来:

按条件改某列的值,永远用 df.loc[条件, 列名] = 值。

它的三个好处:

  1. 一定生效(没有"改的是副本"的问题);
  2. 意图明确(读者一眼看出"给满足条件的行的某一列赋值");
  3. 不产生中间对象(比先筛选再赋值更省内存)。

三条纪律 ​

① 只要你要"改"筛选结果 → 先 .copy()
② 只要你要"按条件改原表" → 用 df.loc[条件, 列] = 值
③ 改完一定要验证:print("列名" in df.columns) / print(df["列名"].notna().sum())

为什么第 ③ 条不能省:Pandas 3.0 的两种行为是

写法行为会报错吗
df[条件]["列"] = 值抛 ChainedAssignmentError✅ 会报错
sub = df[条件] 然后 sub["列"] = 值静默改在副本上,df 不变❌ 不报错

第二种更危险 —— 它不会告诉你出了问题。

一个更隐蔽的版本 ​

python
# 这个也不会报错,但 df 什么都没改
df[df["soh"] < 0.85]["health"] = "待退役"

注意:在 Pandas 2.x 里这行会警告、在 3.0 里可能直接抛错, 但无论如何,你都不能靠"有没有警告/报错"来判断 —— 因为还有一批写法是完全静默的。

要靠纪律:

如果你发现自己在写"两层方括号套着赋值", 先停下来,想想是不是该用 .loc[条件, 列] = 值。

最保险的自检(三行):

python
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 完整示例与输出 ​

bash
cd code
python demos/ch25-select-rows-cols.py

真实输出(节选):

text
=== ① 选列 ===
  单列  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 验证:

  1. 筛出 cycle <= 10 的记录,打印行数;
  2. 取前 3 行,只保留 cell_id 和 soh 两列;
  3. 用 loc 取第 0 行;用 iloc 取第 0 行;
  4. 比较 df.loc[0:2, ["cell_id", "soh"]] 和 df.iloc[0:3, 0:2] 的行数。
参考答案
python
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。

这就是最容易混的地方 —— 记住这个对照:

python
df.loc[a:b]      # 含 b
df.iloc[a:b]     # 不含 b

完整练习:exercises/ex25-01-select-rows-cols

练习 2:为什么这个赋值没生效?
python
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)

python
early = df[df["cycle"] <= 10].copy()     # ← 加 .copy()
early["is_early"] = True
print("is_early" in df.columns)           # False  (df 干净,符合预期)

改法二:用 loc 一步到位(如果就是要改 df)

python
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)。

python
print(df["is_early"].value_counts(dropna=False))
# True     120
# NaN    13847

为什么会是 NaN 而不是 False:因为这一列是"赋值时才创建的", 没被赋值的行就是"没有值"(NaN)。

如果你希望是 False:

python
df["is_early"] = False                     # ① 先给全部行一个默认值
df.loc[df["cycle"] <= 10, "is_early"] = True   # ② 再改满足条件的
print(df["is_early"].value_counts())
# False    13847
# True       120

★ 记忆这三行模式:

python
df["新列"] = 默认值                          # 先建列
df.loc[条件, "新列"] = 值                    # 再按条件改
df["新列"].value_counts()                   # 最后验证

这个模式在数据清洗里会反复用到(比如"先全部标记为正常,再把异常标出来")。

最后强调一次:

看到 SettingWithCopyWarning 不要忽略它。 它在告诉你"这个赋值可能没生效" —— 而如果不生效,你后面所有的分析都建立在错误的数据上。

练习 3:为什么"巧合一样"很危险?
python
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 时(最常见):

python
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"] # ✅ 按日期取

② 筛选之后索引不连续:

python
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 个位置)。

③ 排序之后:

python
sorted_df = df.sort_values("soh")
sorted_df.loc[0]        # 还是标签 0 那一行(但内容变了位置)
sorted_df.iloc[0]       # 现在是最差的那一行

两者现在完全不同了。

正确的使用纪律:

按标签(列名、索引值)→ loc
按位置(第几行、第几列)→ iloc

再加三条辅助规则:

① 筛选后想用位置索引,先 reset_index():

python
sub = df[df["cycle"] <= 10].reset_index(drop=True)
print(sub.index[:5].tolist())    # [0, 1, 2, 3, 4]  ← 连续了

drop=True 表示"丢掉原来的索引,不要变成一列"。

② 大多数情况下,根本不需要位置索引:

python
# ❌ 用位置
df.iloc[:5]

# ✅ 用条件或排名
df.head(5)
df.nsmallest(5, "soh")

③ 用 .shape 而不是"我数了数"来确认取到的行数。

一个真实的教训:本书的示例代码里, 凡是需要"按行位置"的地方都用 iloc,凡是"按条件"的地方都用布尔筛选 —— 没有一处在两者之间"差不多"的地方含糊。

这不是教条,而是因为"索引恰好是 0,1,2"这个前提,在任何一次筛选、 排序、set_index、merge 之后都可能被打破。

面向 AI 开发:为什么"列名取值"比"位置取值"重要得多 ​

这一章的 loc/iloc 之辨,在 AI 项目里有一个更严肃的版本:

特征必须按名字取,绝不能按位置取。

一个真实的事故模式:

python
# 训练时(数据表列顺序: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 的两个特征:

  • 不报错(形状对了,类型也对)
  • 很难发现(模型照样输出数字,只是数字没意义)

正确的做法:

python
# 把特征列表定义成常量,整个项目都用它
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 一起校验

python
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 章 · 筛选与排序