Skip to content

第 26 章 · 筛选与排序 ​

这一章要解决什么:从 1.4 万行里精确挑出你要的行,并按需要排序。 打掉最高频的报错:多条件必须用 & / | 并各自加括号,用 and / or 会直接报错。

本章你需要完成的代码:demos/ch26-filter-sort.py

26.1 单条件筛选 ​

python
eol = df[df["soh"] < 0.85]
print(len(eol))          # 3111
print(len(df))           # 13967

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

df 本身没变 —— 这是 Pandas 的重要特性(第 25 章讲过 Copy-on-Write):

python
print(len(df))           # 13967   还是原来的

条件本身是一个布尔 Series:

python
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 多条件:& | 加括号 ​

python
hot = df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]
print(len(hot))          # 832

三个必踩的坑:

python
# ❌ 坑一:用 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 需要判断"左边整体是真还是假":

python
df["temp_rise_c"] > 15        # 这是一个有 13967 个 True/False 的 Series
                              # "整体是真是假"?

没有答案 —— 有 832 个 True、13135 个 False,整体算什么?

所以 Pandas(和 NumPy)都直接报错,不猜。

& / | / ~ 是"逐元素"的运算符:

运算符含义作用于
and / or / not整体逻辑单个布尔值
& / | / ~逐元素逻辑整个 Series/数组

记忆法:

在 Pandas / NumPy 里写多条件,条件两边永远加括号。

不要试图记住优先级(第 22 章给过完整的优先级表),加括号永远是安全的。

~ 是取反(不是 not):

python
df[~(df["soh"] < 0.85)]          # ✅ 不小于 0.85 的
df[not (df["soh"] < 0.85)]       # ❌ 报错

26.3 between 和 isin:两个好用的工具 ​

python
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) 是闭区间 —— 含两端:

python
print(mid["soh"].min())     # 0.85   ← 含下界
print(mid["soh"].max())     # 0.9    ← 含上界

等价写法:

python
df[(df["soh"] >= 0.85) & (df["soh"] <= 0.90)]     # 和 between 等价

用哪个:between 更短、意图更清楚("在这个区间里"), 而且不容易忘记加括号或写反不等号。

.isin([...]) 是"在集合里":

python
# 等价写法(用 | 串起来,但列多了很难看)
df[(df["cell_id"] == "C001") | (df["cell_id"] == "C002") | (df["cell_id"] == "C011")]

isin 的实用场景:

python
# 只看这几支重点电芯
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 ​

python
# 单列升序(默认)
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)

多列排序的语义:先按第一列排,第一列相同时再按第二列排。

python
df.sort_values(["cell_id", "cycle"], ascending=[True, False])
# 先按 cell_id 升序(C001, C001, ..., C002, ...)
# 同一个 cell_id 内按 cycle 降序(862, 861, 860, ...)

⚠ sort_values 默认返回新表:

python
sorted_df = df.sort_values("soh")     # ✅ 用返回值
df.sort_values("soh", inplace=True)   # ⚠️ 不推荐(容易搞不清手上是哪一版)

为什么不推荐 inplace=True:

python
# 危险:忘了 df 已经被改了
df.sort_values("soh", inplace=True)
# ... 50 行之后 ...
print(df.head())          # 咦,怎么顺序变了?

函数式写法(返回新表)让"数据流"更清楚: 每一步的输出是新对象,你可以随时 print 中间结果。

点开:按绝对值排序(第 37 章会用到)

场景:找"与 SOH 相关性最强的特征"。 相关系数有正有负(+0.998 和 -0.996 都很强), 直接 sort_values() 升序会把 -0.996 排到最后。

python
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= 参数是通用的:可以传任何函数。

python
# 按字符串长度排序
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:把条件写成字符串 ​

python
q = df.query("temp_rise_c > 15 and resistance_mohm > 70")
print(len(q))            # 832   ← 和布尔筛选取结果一致

assert len(q) == len(hot)

query 的写法更接近自然语言:

python
# 布尔筛选
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 自己解析。

引用外部变量要用 @:

python
thr = 16
print(len(df.query("temp_rise_c > @thr")))      # 231
点开:query 的三个限制

① 列名有空格或特殊字符时要改写法。

python
df.query("`resistance mohm` > 70")      # 用反引号包起来

② IDE 不会帮你检查列名拼写。

python
df.query("sohh > 0.9")      # 拼错了,IDE 不会有提示
df[df["sohh"] > 0.9]        # ✅ IDE 可能提示"没有这一列"

(运行时会报 UndefinedVariableError,但那已经是运行时了。)

③ 性能不一定更好。

query 内部也是解析成布尔条件,没有魔法加速。 它的价值是可读性,不是性能。

什么时候用 query:

  • 条件比较长、变量比较多时(可读性收益大);
  • 做交互式探索时(改条件方便)。

什么时候用布尔筛选:

  • 需要 IDE 的列名检查时;
  • 条件里要用复杂的 Python 表达式(query 只支持有限语法)。

两种都掌握,按场景选。

26.6 排序后去重:取"每组第一名" ​

这是一个非常常用的套路:

python
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 支电芯各一行):

text
cell_id batch  cycle  soh
   C012     D    879  0.8
   C008     D   1143  0.8
   C006     B   1392  0.8
   ...

这个套路的通用形式:

python
# 取每组最大的
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"来排名,是区分不出好坏的。

真正能区分的是"用了多少圈才走到那一步":

python
per = df.groupby("cell_id").agg(n_cycles=("cycle", "max"))
print(per.sort_values("n_cycles", ascending=False).head())
# 跑得最久的电芯

这就是第 28 章 groupby 要解决的问题:单靠筛选和排序,回答不了"哪支电芯更好"。

26.7 筛选完先别急着下结论 ​

python
hot = df[(df["temp_rise_c"] > 15) & (df["resistance_mohm"] > 70)]
print(hot["cell_id"].value_counts())

真实输出(节选):

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

bash
cd code
python demos/ch26-filter-sort.py

真实输出(节选):

text
=== ① 单条件筛选 ===
  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:筛选与排序
  1. 筛出"温升 > 15℃ 且 内阻 > 70 mΩ"的记录,打印条数和占比;
  2. 按温升降序打印前 5 行(cell_id / cycle / temp_rise_c / resistance_mohm);
  3. 用 query() 写一遍并验证结果一致;
  4. 统计这些记录落在哪些电芯上,说出你的判断。
参考答案
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")

# ① 多条件:& 且加括号
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:找出这些筛选的问题
python
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)]

② 为什么结果错:表达式被解析成

python
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,这个条件本身也没意义 —— 这是另一个层面的问题。 写筛选条件前先想清楚"这个区间有数据吗"。)

④ 是对的:

python
df[~(df["soh"] < 0.85)]     # 不小于 0.85
# 等价于
df[df["soh"] >= 0.85]

~ 是"逐元素取反",用在这里完全正确。

★ 三条通用纪律:

① 多条件用 & |,不用 and or
② 每个条件都加括号
③ 不要用链式比较(60 < x < 70)—— 用 between 或两个条件 & 起来

最保险的写法是拆成中间变量:

python
cond1 = df["temp_rise_c"] > 15
cond2 = df["resistance_mohm"] > 70
result = df[cond1 & cond2]

为什么这样更好:

  • 不用纠结括号(每个条件独立一行,天然不会有优先级问题);
  • 每个条件都能单独 print 出来检查(print(cond1.sum()) 看命中多少);
  • 出问题时能立刻定位是哪个条件写错了。

这不是"啰嗦",而是"可调试"。

练习 3:按绝对值排序
python
corr = df.select_dtypes("number").corr()
soh_corr = corr["soh"].drop("soh")
print(soh_corr)

真实输出:

text
cycle             -0.9347
capacity_ah        0.9981
resistance_mohm   -0.9397
temp_rise_c       -0.9957
coulombic_eff      0.9849

要求:用两种方式按"相关性强弱"排序,并说出差异。

参考答案

方式一:key=abs(保留符号)

python
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()(丢掉符号)

python
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 最不相关",完全反了。

python
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 —— 两者都是"很强"。

但如果你只看数值大小(不取绝对值),会得出:

python
# 排序后的"最大"值
print(soh_corr.max())        # 0.9981  (capacity_ah)

丢掉了 -0.9957 这个同样强的负相关。

这就是"统计量要配合可视化/绝对值一起看"的一个具体例子 —— 第 37 章的热力图能一眼看出所有强弱关系,就是因为颜色用了 vmin=-1, vmax=1: 深红和深蓝同样显眼。

面向 AI 开发:数据子集是训练集的"特殊样本" ​

筛选在 AI 项目里的用途,不只是"看看某部分数据", 更是构建针对性数据集。

四个实际场景:

① 看模型在哪类样本上表现差。

python
# 找出"高 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)。

python
# 挑出模型预测误差最大的样本,重点分析
df["err"] = (df["pred"] - df["soh"]).abs()
hard = df.nlargest(100, "err")
print(hard.groupby("batch").size())        # 困难样本集中在哪个批次?
print(hard["cycle"].describe())            # 集中在哪个循环阶段?

③ 时间切分训练/测试集(避免数据泄漏)。

python
# ❌ 随机切分:同一支电芯的数据可能同时出现在训练和测试集
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):

python
from sklearn.model_selection import GroupKFold
gkf = GroupKFold(n_splits=4)
for tr, te in gkf.split(df, groups=df["cell_id"]):
    # 保证同一支电芯的数据不会跨训练/测试
    ...

④ 分层采样,保持分布一致。

python
# 按 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 有依据地定阈值, 而不是随手写一个数字。


下一章:第 27 章 · 派生列:现算一列 SOH