第 26 章 · 筛选与排序
这一章要解决什么:从 1.4 万行里精确挑出你要的行,并按需要排序。 打掉最高频的报错:多条件必须用
&/|并各自加括号,用and/or会直接报错。
本章你需要完成的代码:demos/ch26-filter-sort.py
26.1 单条件筛选
eol = df[df["soh"] < 0.85]
print(len(eol)) # 3111
print(len(df)) # 13967df[条件] 就是"选出满足条件的行",返回一个新 DataFrame。
df 本身没变 —— 这是 Pandas 的重要特性(第 25 章讲过 Copy-on-Write):
print(len(df)) # 13967 还是原来的条件本身是一个布尔 Series:
mask = df["soh"] < 0.85
print(type(mask)) # Series
print(mask.dtype) # bool
print(mask.sum()) # 3111 ← True 的数量(第 19 章的"魔法")
print(mask.mean()) # 0.2227 ← 占比 22.3%26.2 多条件:& | 加括号
hot = df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]
print(len(hot)) # 832三个必踩的坑:
# ❌ 坑一:用 and
df[(df["temp_rise_c"] > 15) and (df["resistance_mohm"] > 70)]
# ValueError: The truth value of a Series is ambiguous.
# Use a.empty, a.bool(), a.item(), a.any() or a.all().
# ❌ 坑二:不加括号
df[df["temp_rise_c"] > 15 & df["resistance_mohm"] > 70]
# & 优先级高于比较,被解析错
# ✅ 正确
df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]点开:为什么 and 会报这个错?(和 NumPy 是同一个原因)
Python 的 and 需要判断"左边整体是真还是假":
df["temp_rise_c"] > 15 # 这是一个有 13967 个 True/False 的 Series
# "整体是真是假"?没有答案 —— 有 832 个 True、13135 个 False,整体算什么?
所以 Pandas(和 NumPy)都直接报错,不猜。
& / | / ~ 是"逐元素"的运算符:
| 运算符 | 含义 | 作用于 |
|---|---|---|
and / or / not | 整体逻辑 | 单个布尔值 |
& / | / ~ | 逐元素逻辑 | 整个 Series/数组 |
记忆法:
在 Pandas / NumPy 里写多条件,条件两边永远加括号。
不要试图记住优先级(第 22 章给过完整的优先级表),加括号永远是安全的。
~ 是取反(不是 not):
df[~(df["soh"] < 0.85)] # ✅ 不小于 0.85 的
df[not (df["soh"] < 0.85)] # ❌ 报错26.3 between 和 isin:两个好用的工具
mid = df[df["soh"].between(0.85, 0.90)]
print(len(mid)) # 3222
some = df[df["cell_id"].isin(["C001", "C002", "C011"])]
print(len(some)) # 3234.between(a, b) 是闭区间 —— 含两端:
print(mid["soh"].min()) # 0.85 ← 含下界
print(mid["soh"].max()) # 0.9 ← 含上界等价写法:
df[(df["soh"] >= 0.85) & (df["soh"] <= 0.90)] # 和 between 等价用哪个:between 更短、意图更清楚("在这个区间里"), 而且不容易忘记加括号或写反不等号。
.isin([...]) 是"在集合里":
# 等价写法(用 | 串起来,但列多了很难看)
df[(df["cell_id"] == "C001") | (df["cell_id"] == "C002") | (df["cell_id"] == "C011")]isin 的实用场景:
# 只看这几支重点电芯
focus = ["C001", "C011", "C007"]
print(df[df["cell_id"].isin(focus)].groupby("cell_id")["soh"].mean())
# 排除某几个批次
print(df[~df["batch"].isin(["D"])].shape)
# 筛出取值在某个列表里的行(比如从另一张表拿到的 ID 列表)
valid_ids = other_df["cell_id"].unique()
print(df[df["cell_id"].isin(valid_ids)].shape)最后一个用法在做数据校验时很有用:检查"主表里的外键是否都在维表里"。
26.4 排序:sort_values
# 单列升序(默认)
df.sort_values("soh").head(3)
# 单列降序
df.sort_values("soh", ascending=False).head(3)
# 多列 + 每列不同方向
df.sort_values(["cell_id", "cycle"], ascending=[True, False]).head(4)多列排序的语义:先按第一列排,第一列相同时再按第二列排。
df.sort_values(["cell_id", "cycle"], ascending=[True, False])
# 先按 cell_id 升序(C001, C001, ..., C002, ...)
# 同一个 cell_id 内按 cycle 降序(862, 861, 860, ...)⚠ sort_values 默认返回新表:
sorted_df = df.sort_values("soh") # ✅ 用返回值
df.sort_values("soh", inplace=True) # ⚠️ 不推荐(容易搞不清手上是哪一版)为什么不推荐 inplace=True:
# 危险:忘了 df 已经被改了
df.sort_values("soh", inplace=True)
# ... 50 行之后 ...
print(df.head()) # 咦,怎么顺序变了?函数式写法(返回新表)让"数据流"更清楚: 每一步的输出是新对象,你可以随时 print 中间结果。
点开:按绝对值排序(第 37 章会用到)
场景:找"与 SOH 相关性最强的特征"。 相关系数有正有负(+0.998 和 -0.996 都很强), 直接 sort_values() 升序会把 -0.996 排到最后。
corr = df.select_dtypes("number").corr()
soh_corr = corr["soh"].drop("soh")
# ❌ 直接升序:强负相关被排到末尾
print(soh_corr.sort_values().head(3))
# resistance_mohm -0.9397
# cycle -0.9347
# ...
# temp_rise_c -0.9957 ← 排在很后面,看起来像"最不相关"
# ✅ 按绝对值排(保留符号)
print(soh_corr.sort_values(key=abs, ascending=False))
# capacity_ah 0.9981
# temp_rise_c -0.9957
# coulombic_eff 0.9849
# resistance_mohm -0.9397
# cycle -0.9347
# ✅ 另一种写法(丢掉符号,只剩大小)
print(soh_corr.abs().sort_values(ascending=False))key=abs 和 .abs() 的区别:
| 写法 | 结果保留符号吗 |
|---|---|
sort_values(key=abs, ...) | ✅ 保留(值还是 +0.998 / -0.996) |
.abs().sort_values(...) | ❌ 丢掉了(都变成正数) |
要保留符号就用 key=abs —— 因为你通常还想知道"是正相关还是负相关"。
key= 参数是通用的:可以传任何函数。
# 按字符串长度排序
df.sort_values("cell_id", key=lambda s: s.str.len())
# 按自定义规则排序(比如"健康 > 预警 > 待退役")
order = {"健康": 0, "预警": 1, "待退役": 2}
df.sort_values("health", key=lambda s: s.map(order))26.5 query:把条件写成字符串
q = df.query("temp_rise_c > 15 and resistance_mohm > 70")
print(len(q)) # 832 ← 和布尔筛选取结果一致
assert len(q) == len(hot)query 的写法更接近自然语言:
# 布尔筛选
df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]
# query
df.query("temp_rise_c > 15 and resistance_mohm > 70")注意 query 里用的是 and / or(不是 & / |)—— 因为它是字符串表达式,Pandas 自己解析。
引用外部变量要用 @:
thr = 16
print(len(df.query("temp_rise_c > @thr"))) # 231点开:query 的三个限制
① 列名有空格或特殊字符时要改写法。
df.query("`resistance mohm` > 70") # 用反引号包起来② IDE 不会帮你检查列名拼写。
df.query("sohh > 0.9") # 拼错了,IDE 不会有提示
df[df["sohh"] > 0.9] # ✅ IDE 可能提示"没有这一列"(运行时会报 UndefinedVariableError,但那已经是运行时了。)
③ 性能不一定更好。
query 内部也是解析成布尔条件,没有魔法加速。 它的价值是可读性,不是性能。
什么时候用 query:
- 条件比较长、变量比较多时(可读性收益大);
- 做交互式探索时(改条件方便)。
什么时候用布尔筛选:
- 需要 IDE 的列名检查时;
- 条件里要用复杂的 Python 表达式(
query只支持有限语法)。
两种都掌握,按场景选。
26.6 排序后去重:取"每组第一名"
这是一个非常常用的套路:
worst = (df.sort_values("soh")
.drop_duplicates("cell_id", keep="first")
.sort_values("soh"))三步的意思:
sort_values("soh") 按 SOH 升序(最差在最前)
.drop_duplicates("cell_id", keep="first") 每支电芯只保留第一行(也就是最差的那行)
.sort_values("soh") 最后再整体排一次(好看)真实输出(12 支电芯各一行):
cell_id batch cycle soh
C012 D 879 0.8
C008 D 1143 0.8
C006 B 1392 0.8
...这个套路的通用形式:
# 取每组最大的
df.sort_values("指标", ascending=False).drop_duplicates("分组键", keep="first")
# 取每组最小的
df.sort_values("指标").drop_duplicates("分组键", keep="first")
# 取每组最后一个
df.drop_duplicates("分组键", keep="last")⚠ 一个反直觉的结果:这份数据里,12 支电芯的"最差时刻 SOH"都等于 0.8000。
为什么:因为数据生成规则是"SOH 降到 0.80 就停止采样"。 所以每支电芯的最后一个采样点都是 0.80。
这告诉我们一件事:用"最差 SOH"来排名,是区分不出好坏的。
真正能区分的是"用了多少圈才走到那一步":
per = df.groupby("cell_id").agg(n_cycles=("cycle", "max"))
print(per.sort_values("n_cycles", ascending=False).head())
# 跑得最久的电芯这就是第 28 章 groupby 要解决的问题:单靠筛选和排序,回答不了"哪支电芯更好"。
26.7 筛选完先别急着下结论
hot = df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]
print(hot["cell_id"].value_counts())真实输出(节选):
cell_id
C006 145
C005 138
...分布是均匀的 —— 12 支电芯的命中数都在几十到一百多之间。
点开:这个"均匀分布"说明了什么?(本章最重要的思考)
两种可能的分布,含义完全不同:
情况 A:均匀分布
C001 ████████ 68
C002 █████████ 75
C003 ██████████ 82
...(各支差不多)
→ 这是「老化的普遍症状」,每支电芯循环到后期都会这样
情况 B:集中在少数几支
C001 ████████████████████████████████ 800
C002 █ 12
C003 █ 8
...
→ 这几支电芯「真的有问题」,其他都正常本数据是情况 A —— 所以结论应该是:
"832 条『温升 >15℃ 且 内阻 >70mΩ』的记录均匀分布在全部 12 支电芯上, 属于电池老化的正常伴随现象,而不是某几支电芯的个体问题。"
如果误判成情况 B,你可能会去"重点排查那几支电芯" —— 白费功夫。
★ 但这里还有一个更深的陷阱
"温升高"和"内阻高"确实高度相关(r = 0.936),但这不是因果关系。
循环次数 ↑
├──→ SOH ↓ (老化)
├──→ 温升 ↑ (老化时发热变多)
└──→ 内阻 ↑ (老化时内阻上升)两者都是"老化"这个共同原因的结果,所以它们"看起来"高度相关。
这就叫「共同趋势造成的假相关」。
第 36 章会用分段相关系数拆穿它: 整体 r = 0.93,但把 cycle 分成 5 段后,段内 r 只有 0.47~0.79。
本章先埋下这个伏笔:
相关 ≠ 因果。 看到两个变量高度相关时, 先问一句"它们是不是都被第三个东西推着走?"
这个思维习惯,比任何代码技巧都重要。
26.8 完整示例与输出
cd code
python demos/ch26-filter-sort.py真实输出(节选):
=== ① 单条件筛选 ===
df[df['soh'] < 0.85] → 3,111 行(占 22.3%)
=== ② 多条件:& | 且必须加括号 ===
温升 > 15℃ 且 内阻 > 70 mΩ → 832 条(6.0%)
cell_id cycle temp_rise_c resistance_mohm
C010 1422 17.20 71.55
C008 1128 17.16 71.94
三个必踩的坑:
❌ 用 and → ValueError: The truth value of a Series is ambiguous
=== ④ 两个好用的区间/集合工具 ===
df['soh'].between(0.85, 0.90) → 3,222 条(闭区间,含两端)
df['cell_id'].isin(['C001','C002','C011']) → 3,234 条
=== ⑥ query:把条件写成字符串 ===
df.query('temp_rise_c > 15 and resistance_mohm > 70') → 832 条
query 里的变量要用 @ 引入:df.query('temp_rise_c > @thr') → 231 条
=== ⑦ 排序后去重:「每支电芯最差的那一条」 ===
12 支电芯的「最差时刻」SOH 都等于 0.8000 —— 因为数据生成时到 0.80 就停采样
真正能区分好坏的,是「用了多少圈才走到那一步」(第 28 章用 groupby 算)本章练习
练习 1:筛选与排序
- 筛出"温升 > 15℃ 且 内阻 > 70 mΩ"的记录,打印条数和占比;
- 按温升降序打印前 5 行(
cell_id/cycle/temp_rise_c/resistance_mohm); - 用
query()写一遍并验证结果一致; - 统计这些记录落在哪些电芯上,说出你的判断。
参考答案
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")
# ① 多条件:& 且加括号
hot = df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]
print(f"{len(hot)} 条({len(hot) / len(df):.1%})") # 832 条(6.0%)
# ② 排序 + 选列
cols = ["cell_id", "cycle", "temp_rise_c", "resistance_mohm"]
print(hot.sort_values("temp_rise_c", ascending=False).head(5)[cols].to_string(index=False))
# ③ query 验证
q = df.query("temp_rise_c > 15 and resistance_mohm > 70")
assert len(q) == len(hot)
print("query 写法一致 ✅")
# ④ 分布
vc = hot["cell_id"].value_counts()
print(vc.to_string())
print(f"涉及 {hot['cell_id'].nunique()} 支电芯(共 {df['cell_id'].nunique()} 支)")④ 的判断:
12 支电芯的命中数在几十到一百多之间,分布均匀。
结论:这 832 条记录均匀分布在全部 12 支电芯上, 属于「电池老化的普遍伴随现象」,而不是某几支电芯的个体问题。
如果分布是"某几支占绝大部分",那才是"这几支有问题"的信号。
★ 但要注意一个更深的陷阱:
temp_rise_c 和 resistance_mohm 的相关系数是 0.936, 看起来"强相关" —— 但它们不是因果关系。
两者都随"循环次数/老化"单调上升,所以呈现出共同趋势造成的假相关。
第 36 章会用分段相关拆穿它(整体 r=0.93,段内只有 0.47~0.79)。
完整练习:exercises/ex26-01-filter-sort
练习 2:找出这些筛选的问题
import pandas as pd
df = pd.read_csv("data/battery/cycle_raw.csv")
# ①
a = df[df["temp_rise_c"] > 15 and df["resistance_mohm"] > 70]
# ②
b = df[df["temp_rise_c"] > 15 & df["resistance_mohm"] > 70]
# ③
c = df[60 < df["soh"] < 70]
# ④
d = df[~(df["soh"] < 0.85)]要求:说出每一条能不能跑、结果对不对。
参考答案
| # | 能跑吗 | 结果对吗 | 问题 |
|---|---|---|---|
| ① | ❌ 报错 | — | 用了 and |
| ② | ⚠️ 能跑 | ❌ 错 | & 优先级高于 >,被解析错 |
| ③ | ❌ 报错 | — | 链式比较对 Series 无效 |
| ④ | ✅ 能跑 | ✅ 对 | 正确(~ 取反) |
① 的报错:
ValueError: The truth value of a Series is ambiguous.
Use a.empty, a.bool(), a.item(), a.any() or a.all().修复:df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]
② 为什么结果错:表达式被解析成
df["temp_rise_c"] > (15 & df["resistance_mohm"]) > 70
^^^^^^^^^^^^^^^^^^^^^^^^^^
先算位与,得到一个整数 Series实际行为:15 & df["resistance_mohm"] 做的是按位与(把数字当二进制处理), 结果是一个毫无意义的 Series。然后整个表达式变成一个奇怪的比较。
它可能不报错(因为都是数值之间的比较),但结果肯定是错的 —— 这就是最危险的情况。
修复:df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]
③ 的错误:60 < df["soh"] < 70 是 Python 的链式比较, 等价于 (60 < df["soh"]) and (df["soh"] < 70) —— 又是 and 作用于 Series,报错。
修复:df[(df["soh"] > 60) & (df["soh"] < 70)]
(顺便:SOH 不会大于 1,这个条件本身也没意义 —— 这是另一个层面的问题。 写筛选条件前先想清楚"这个区间有数据吗"。)
④ 是对的:
df[~(df["soh"] < 0.85)] # 不小于 0.85
# 等价于
df[df["soh"] >= 0.85]~ 是"逐元素取反",用在这里完全正确。
★ 三条通用纪律:
① 多条件用 & |,不用 and or
② 每个条件都加括号
③ 不要用链式比较(60 < x < 70)—— 用 between 或两个条件 & 起来最保险的写法是拆成中间变量:
cond1 = df["temp_rise_c"] > 15
cond2 = df["resistance_mohm"] > 70
result = df[cond1 & cond2]为什么这样更好:
- 不用纠结括号(每个条件独立一行,天然不会有优先级问题);
- 每个条件都能单独
print出来检查(print(cond1.sum())看命中多少); - 出问题时能立刻定位是哪个条件写错了。
这不是"啰嗦",而是"可调试"。
练习 3:按绝对值排序
corr = df.select_dtypes("number").corr()
soh_corr = corr["soh"].drop("soh")
print(soh_corr)真实输出:
cycle -0.9347
capacity_ah 0.9981
resistance_mohm -0.9397
temp_rise_c -0.9957
coulombic_eff 0.9849要求:用两种方式按"相关性强弱"排序,并说出差异。
参考答案
方式一:key=abs(保留符号)
print(soh_corr.sort_values(key=abs, ascending=False))
# capacity_ah 0.9981
# temp_rise_c -0.9957
# coulombic_eff 0.9849
# resistance_mohm -0.9397
# cycle -0.9347方式二:.abs()(丢掉符号)
print(soh_corr.abs().sort_values(ascending=False))
# capacity_ah 0.9981
# temp_rise_c 0.9957
# coulombic_eff 0.9849
# resistance_mohm 0.9397
# cycle 0.9347区别:方式一保留了正负号,方式二全都变成正数。
该用哪个:
| 目的 | 用哪个 |
|---|---|
| 只想知道"哪个最强" | 都行 |
| 还想知道"是正相关还是负相关" | 方式一(key=abs) |
| 要展示给读者看 | 方式一(-0.9957 比 0.9957 信息更多) |
为什么需要"按绝对值":
直接 sort_values() 升序会把强负相关(-0.9957)排到最后 —— 看结果的人会以为"temp_rise_c 最不相关",完全反了。
print(soh_corr.sort_values().head(2))
# temp_rise_c -0.9957 ← 最强的负相关排在了最前面(升序)
print(soh_corr.sort_values().tail(2))
# capacity_ah 0.9981 ← 最强的正相关排在最后所以"按相关性强弱排序"必须显式处理绝对值。
★ 一个延伸教训:
temp_rise_c 的 r = -0.9957,coulombic_eff 的 r = 0.9849 —— 两者都是"很强"。
但如果你只看数值大小(不取绝对值),会得出:
# 排序后的"最大"值
print(soh_corr.max()) # 0.9981 (capacity_ah)丢掉了 -0.9957 这个同样强的负相关。
这就是"统计量要配合可视化/绝对值一起看"的一个具体例子 —— 第 37 章的热力图能一眼看出所有强弱关系,就是因为颜色用了 vmin=-1, vmax=1: 深红和深蓝同样显眼。
面向 AI 开发:数据子集是训练集的"特殊样本"
筛选在 AI 项目里的用途,不只是"看看某部分数据", 更是构建针对性数据集。
四个实际场景:
① 看模型在哪类样本上表现差。
# 找出"高 SOC 区间"的样本,单独评估
hi_soc = df[df["soh"] > 0.95]
print(f"高 SOH 样本 {len(hi_soc)} 条,模型 MAE = {mae(pred[hi_soc.index], y[hi_soc.index]):.4f}")
lo_soc = df[df["soh"] < 0.85]
print(f"低 SOH 样本 {len(lo_soc)} 条,模型 MAE = {mae(pred[lo_soc.index], y[lo_soc.index]):.4f}")如果低 SOH 段的误差明显更大,说明模型在"老化后期"不准 —— 而那正是最需要预测准确的区间。这个发现比总体 MAE 有用得多。
② 构建困难样本集(hard negative mining)。
# 挑出模型预测误差最大的样本,重点分析
df["err"] = (df["pred"] - df["soh"]).abs()
hard = df.nlargest(100, "err")
print(hard.groupby("batch").size()) # 困难样本集中在哪个批次?
print(hard["cycle"].describe()) # 集中在哪个循环阶段?③ 时间切分训练/测试集(避免数据泄漏)。
# ❌ 随机切分:同一支电芯的数据可能同时出现在训练和测试集
train = df.sample(frac=0.8, random_state=42)
# ✅ 按时间切分(模拟"用过去预测未来")
cut = df["timestamp"] < "2024-06-01"
train, test = df[cut], df[~cut]为什么这件事很重要:同一支电芯相邻两圈的 SOH 几乎一样 (第 37 章的 r = 0.998 就说明了这点)。 随便切分的话,测试集里会有大量"和训练集几乎相同"的样本, 评估结果会虚高。
更严格的切分是"按电芯切分"(GroupKFold):
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=4)
for tr, te in gkf.split(df, groups=df["cell_id"]):
# 保证同一支电芯的数据不会跨训练/测试
...④ 分层采样,保持分布一致。
# 按 SOH 分档,每档都按比例采样
df["soh_bin"] = pd.cut(df["soh"], bins=[0.8, 0.85, 0.9, 0.95, 1.0])
train = df.groupby("soh_bin", observed=True).sample(frac=0.8, random_state=42)为什么需要分层:如果随机切分后,测试集里 90% 都是高 SOH 样本, 那评估出来的是"模型在简单样本上的表现",不能代表真实能力。
★ 一条通用纪律
筛选条件本身,就是对"数据是什么"的一个假设。 写
df[df["temp_rise_c"] > 15]时,你其实在断言 "15℃ 以上算异常" —— 这个假设需要业务依据,不能拍脑袋。
这条纪律在筛选"异常值"时尤其重要。 第 32、33 章会讲怎么用 Z 分数和 IQR 有依据地定阈值, 而不是随手写一个数字。