第 27 章 · 派生列:现算一列 SOH
这一章要解决什么:从已有列算出新列(特征工程的第一步)。 打掉两个坑:
np.select的条件顺序写反不报错只给错结果;在筛选结果上赋值不生效。
本章你需要完成的代码:demos/ch27-derived-column.py
27.1 最基础的派生:整列一起算
df["cap_mah"] = df["capacity_ah"] * 1000.0 # Ah → mAh
df["soh_pct"] = df["soh"] * 100.0 # 小数 → 百分比
df["fade_rate"] = 1.0 - df["soh"] # 衰减率加了三列,写法和读法都只有一行。
背后是 NumPy 的向量化(第 20 章):
type(df["soh"]) # Series
type(df["soh"].to_numpy()) # numpy.ndarraydf["soh"] * 100.0 之所以能一行算完 13967 个值, 是因为 Pandas 底层调用了 NumPy 的向量化运算。
真实输出:
cell_id cycle capacity_ah cap_mah soh soh_pct fade_rate
C001 1 2.5092 2509.2 0.9999 99.99 0.0001
C001 2 2.4800 2480.0 0.9998 99.98 0.0002
C001 3 2.4862 2486.2 0.9997 99.97 0.0003点开:派生列的两个纪律
纪律一:新列名不能和已有列重复。
df["soh"] = df["capacity_ah"] / 2.5 # ⚠️ 覆盖了真值列!这会永久覆盖原始 SOH —— 如果你的计算有偏差(比如忘了额定容量不是 2.5), 原始数据就丢了,而且不报错。
防御做法:新列名带后缀表示来源或口径。
df["soh_calc"] = df["capacity_ah"] / 2.5 # ✅ 明确是"算出来的"
df["soh_pct"] = df["soh"] * 100 # ✅ 明确是百分比形式纪律二:算完立刻验证。
# 算完 ratio 后,及时验证
diff = (df["soh"] - df["soh_calc"]).abs()
print(f"最大差异 {diff.max():.6f},平均差异 {diff.mean():.6f}")为什么:派生列的错误往往很隐蔽。 比如"忘了除额定容量"会得到一个恒等于 2.5 的列 —— 它不报错,但那个列毫无信息量。
第 37 章的相关性分析会发现它(和其他列都不相关), 但那是很久以后的事了。当场验证只要两行代码。
27.2 由别的列反推:口径问题
df["soh_calc"] = df["capacity_ah"] / 2.5 # 由容量反推 SOH
diff = (df["soh"] - df["soh_calc"]).abs()
print(f"最大差异 = {diff.max():.6f}")
print(f"平均差异 = {diff.mean():.6f}")真实输出:
soh(真值列) 前 3 个:[0.9999, 0.9998, 0.9997]
soh_calc(算出来) 前 3 个:[1.0037, 0.992, 0.9945]
最大差异 = 0.013500,平均差异 = 0.002910两者不相等!(差异最大 0.0135,相对误差 1.35%)
点开:为什么"算出来"和"真值"不一样?这引出一个重要教训
原因:数据生成时,容量上叠加了 ±0.4% 的测量噪声:
cap = RATED_CAP_AH * soh * (1.0 + rng.normal(0.0, 0.004, n_cycles))
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# 噪声:标准差 0.4%所以:
soh是公式算出来的真值(干净、精确)capacity_ah是实测值(带噪声)soh_calc = capacity_ah / 2.5把噪声带进来了
这引出一个重要教训:
能从原始测量算出来的量,别去用"别人算好的", 除非你知道它的口径 —— 反过来也一样。
具体到这个例子:
soh(真值列) | soh_calc(算出来) | |
|---|---|---|
| 来源 | 生成公式 | 实测容量 / 额定 |
| 精度 | 精确 | 带 ±0.4% 噪声 |
| 用在哪 | 作为标签(预测目标) | 模拟"只能用容量推算"的场景 |
在真实项目里:
- 如果
soh是从容量算出来的(这是 SOH 的标准定义), 那capacity_ah就不能当特征 —— 这是特征泄漏(第 37 章会坐实)。 - 如果
soh来自独立的测量(比如专门的 SOH 测试仪), 那capacity_ah可能可以用 —— 但要小心两者的相关性。
判断方法:
print((df["soh"] - df["soh_calc"]).abs().describe())
# 如果差异很小(< 1e-6),说明两者是同一个量 → 不能同时用
# 如果差异明显,说明它们携带不同信息顺便:这个例子也解释了为什么第 37 章的相关性是 0.9981 而不是 1.0000 —— 噪声让它们不是完全线性相关。
27.3 分档:np.where 与 np.select
df["health"] = np.where(df["soh"] >= 0.90, "健康",
np.where(df["soh"] >= 0.85, "预警", "待退役"))这就是第 20 章学过的 np.where,用在 Pandas 的列上完全一样。
真实分布:
health
健康 7642
预警 3214
待退役 3111
占比:{'健康': '54.7%', '预警': '23.0%', '待退役': '22.3%'}层数多了用 np.select:
df["health2"] = np.select(
[df["soh"] < 0.85, df["soh"] < 0.90], # 条件列表
["待退役", "预警"], # 对应的值
default="健康",
)
assert (df["health"] == df["health2"]).all()⚠ 条件顺序写反:不报错,只给错结果
# ❌ 先判 "< 0.90",那么 0.82 也满足它,永远轮不到"待退役"
wrong = np.select([df["soh"] < 0.90, df["soh"] < 0.85],
["预警", "待退役"], default="健康")
bad = df.loc[wrong != df["health"], "soh"]
print(f"先判 <0.90 的话,{len(bad):,d} 条本该「待退役」的被划进了「预警」")
# 3,111 条
print(f"受影响区间:SOH {bad.min():.4f} ~ {bad.max():.4f}")
# 0.8000 ~ 0.8499这就是 if/elif 的顺序坑在 Pandas 里的版本(第 08 章讲过)。
np.select 的规则:取第一个为 True 的条件对应值。 所以条件必须从严格到宽松。
和 if/elif 完全一致:
np.select([cond1, cond2], [val1, val2], default=val3)
# 等价于
if cond1: val1
elif cond2: val2
else: val3验证方法:拿几个边界值试一下。
test = pd.Series([0.78, 0.84, 0.87, 0.92])
print(np.select([test < 0.85, test < 0.90], ["待退役", "预警"], default="健康").tolist())
# ['待退役', '待退役', '预警', '健康'] ✅27.4 ⚠ 在筛选结果上赋值:不生效
这是第 25 章讲过的坑,这里用真实代码再演示一遍。
df["is_eol"] = False
df[df["soh"] < 0.80]["is_eol"] = True # ❌ 改的是临时副本
print(df["is_eol"].sum()) # 0 —— 什么都没改Pandas 3.0 会直接抛 ChainedAssignmentError:
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series
through chained assignment. Such chained assignment never works to update the
original DataFrame or Series.这是好事 —— 把一个静默失效的坑变成了明确的错误 (第 25 章 25.7 节详细讲了 Copy-on-Write)。
正确写法只有一个:
df.loc[df["soh"] < 0.80, "is_eol"] = True # ✅ 一步到位⚠ 注意一个容易困惑的点:本例数据里 SOH 最小就是 0.80, 所以 df["soh"] < 0.80 命中 0 条 —— 这不是代码写错了, 而是数据生成规则"降到 0.80 就停采样"的必然结果。
print(df["soh"].min()) # 0.8
print((df["soh"] < 0.80).sum()) # 0
print((df["soh"] <= 0.80).sum()) # 12 ← 用 <= 就有 12 条这个细节值得注意:写筛选条件时要用 print(条件.sum()) 确认命中数。 如果以为是 0 条 bug 而去"修代码",就白费功夫了。
★ 三行模式:按条件打标签
df["新列"] = 默认值 # ① 先建列(给所有行一个默认值)
df.loc[条件, "新列"] = 值 # ② 再按条件改
df["新列"].value_counts() # ③ 最后验证分布合理为什么第 ① 步重要:
# 不先建列:没被赋值的行是 NaN(不是 False)
df.loc[df["soh"] < 0.80, "is_eol"] = True
print(df["is_eol"].value_counts(dropna=False))
# NaN 13967 ← 全是 NaN(因为 0 条命中)
# 先建列:没被赋值的行是 False
df["is_eol"] = False
df.loc[df["soh"] <= 0.80, "is_eol"] = True
print(df["is_eol"].value_counts())
# False 13955
# True 12NaN 和 False 在布尔运算里行为不同:
# 都是 NaN 的话,这一列没法用于 & | 运算(结果是 NaN 而不是布尔值)所以"先建列再改"是标准模式。
27.5 量纲变了,排序不变
# soh_pct 最小的 5 行(也就是最差的状态)
print(df.nsmallest(5, "soh_pct")[["cell_id", "cycle", "soh", "soh_pct"]])cell_id cycle soh soh_pct
C001 862 0.8 80.0
C002 1342 0.8 80.0
...print(df["soh_pct"].min()) # 80.080.0 正好是 EOL 退役线(0.80 × 100)—— 因为数据的设计就是"SOH 降到 0.80 就停止采样" —— 每条记录都是走到退役线才收工。
验证"乘 100 不改变排序":
a = df.nsmallest(5, "soh_pct")["cell_id"].tolist()
b = df.nsmallest(5, "soh")["cell_id"].tolist()
print(a == b) # True这类 assert 是很好的自检手段:
线性缩放(乘除一个正常数)不改变相对顺序。 如果排序变了,说明计算过程有问题(比如除零、溢出)。
同理:
# 单位换算不改变排序
assert df.nlargest(5, "capacity_ah")["cell_id"].tolist() == \
df.nlargest(5, "cap_mah")["cell_id"].tolist()27.6 派生"有业务含义"的特征
这才是"特征工程"的真正内容 —— 不是随便乘来乘去,而是造出有物理意义的量。
# ① 组内归一化:每支电芯自己的"寿命进度"(0 → 1)
df["cycle_frac"] = df["cycle"] / df.groupby("cell_id")["cycle"].transform("max")
# ② 上升速率:内阻 / 圈数
df["r_per_cycle"] = df["resistance_mohm"] / df["cycle"]
# ③ 组合特征:两个症状的乘积
df["temp_x_r"] = df["temp_rise_c"] * df["resistance_mohm"] / 100真实输出:
cell_id cycle cycle_frac r_per_cycle temp_x_r
C001 1 0.0012 40.3300 2.4198
C001 2 0.0023 19.7350 2.3721
C001 3 0.0035 13.2833 2.3950点开:逐个分析这三个特征的价值
① cycle_frac:组内归一化 —— 最有价值的特征
df["cycle_frac"] = df["cycle"] / df.groupby("cell_id")["cycle"].transform("max")它回答的问题:"这支电芯已经走完了自己寿命的百分之几?"
为什么比 cycle 更有用:
C001:862 圈退役 cycle=500 → 走完了 58%
C002:1342 圈退役 cycle=500 → 走完了 37%同样 cycle=500,两支电芯的状态完全不同。 用 cycle 当特征,模型学不到这个区别;用 cycle_frac 就能。
groupby(...).transform("max") 的作用(第 28 章详讲): 把"每支电芯的寿命"这个组级统计量贴回原表的每一行。
df.groupby("cell_id")["cycle"].transform("max")
# 每一行都得到"它所属电芯的总寿命"
# C001 的 862 行都得到 862,C002 的 1342 行都得到 1342这就是 transform 相对 agg 的区别:agg 让表变短(每组一行), transform 让表等长(结果广播回每一行)。
② r_per_cycle:速率类特征
df["r_per_cycle"] = df["resistance_mohm"] / df["cycle"]它想表达"内阻上升得多快"。但注意这个近似很粗糙:
cycle=1: 40.33 / 1 = 40.33 ← 第一圈时分母太小,值巨高
cycle=2: 39.47 / 2 = 19.74
cycle=3: 39.85 / 3 = 13.28
...
cycle=862: 83.81 / 862 = 0.097 ← 到后期又变得很小这个特征的问题:它不是"速率",而是"总量 / 圈数", 随 cycle 增大而单调下降。它主要在编码"cycle 的倒数"这个信息,而不是速率。
更好的写法(如果有足够数据):
# 用最近 N 圈的斜率表示速率
df["r_slope"] = df.groupby("cell_id")["resistance_mohm"].diff(50) / 50★ 教训:造特征要用物理/业务逻辑验证,而不是"看起来有道理"。r_per_cycle 看起来很合理,实际上是个有偏的代理。
③ temp_x_r:组合特征
df["temp_x_r"] = df["temp_rise_c"] * df["resistance_mohm"] / 100这类"相乘"特征的风险:
temp_rise_c 和 resistance_mohm 的相关系数是 0.936(第 37 章会看到)
→ 它们的乘积不会带来新信息,反而:
① 放大了两者的共同趋势(噪声也被相乘放大)
② 加剧多重共线性什么时候组合特征有用:两个量各自对目标有影响,但交互才是关键。
# 有意义的例子:温度 × 电流 才是"热负荷"
heat_load = temperature * current★ 教训:造特征要看业务含义,不要盲目乘来乘去。 每一个新特征都应该能回答"它代表了什么物理量/业务概念"。
27.7 pd.cut:把连续量切成几档
df["cycle_stage"] = pd.cut(
df["cycle"],
bins=[0, 300, 600, 900, 1200, 1500],
labels=["0-300", "300-600", "600-900", "900-1200", "1200+"],
)真实输出:
n soh_mean r_mean
cycle_stage
0-300 3600 0.9790 42.0518
300-600 3600 0.9300 49.5642
600-900 3500 0.8768 59.7554
900-1200 2372 0.8429 66.6904
1200+ 895 0.8154 76.8442一眼看出:圈数越多,SOH 越低、内阻越高,而且后期变化更快(膝点效应)。
pd.cut vs pd.qcut:
pd.cut | pd.qcut | |
|---|---|---|
| 分箱依据 | 等宽(按数值区间) | 等频(每箱行数相同) |
| 每箱行数 | 不等 | 相等 |
| 适合 | 有物理意义的区间(0~300 圈) | 需要均衡分组时(评分、分位) |
pd.qcut 的例子:
df["soh_quartile"] = pd.qcut(df["soh"], q=4, labels=["Q1 最差", "Q2", "Q3", "Q4 最好"])
print(df["soh_quartile"].value_counts())
# 每档行数相同(各约 3492 行)为什么分箱有用:
- 交叉分析(把连续量变成类别,方便
groupby); - 处理非线性(有些模型对分箱后的类别变量更好用);
- 可解释性("600-900 圈"比 "cycle=723" 更容易讲给别人听)。
⚠ pd.cut 的注意点:
# ① 默认左开右闭 (a, b]:300 会落到「300-600」而不是「0-300」
pd.cut([300], bins=[0, 300, 600]) # → (0, 300] ← 落在第一档
# 因为默认 right=True,区间是 (0, 300]、(300, 600]
# ② 加 include_lowest=True 才能包含最小值
pd.cut([0, 100], bins=[0, 300, 600], include_lowest=True)
# ③ groupby 分箱列时要加 observed=True(否则会带出空组)
df.groupby("cycle_stage", observed=True).size()第 ③ 点值得记住:Pandas 2.0 起,groupby 对分类型(Categorical)的默认行为变了, 不加 observed=True 会打印所有可能的分箱(包括没有数据的空箱)。
27.8 完整示例与输出
cd code
python demos/ch27-derived-column.py真实输出(节选):
=== ② 由别的列反推:soh 的定义就是 capacity / rated ===
最大差异 = 0.013500,平均差异 = 0.002910
差异不为 0 的原因:生成数据时容量上叠加了 ±0.4% 的测量噪声。
=== ③ 分档:np.where 与 np.select ===
health
健康 7642
预警 3214
待退役 3111
np.select 版结果一致 ✅(条件顺序同样必须「从严格到宽松」)
⚠ 顺序写反会怎样:3,111 条本该「待退役」的被划进了「预警」
受影响区间:SOH 0.8000 ~ 0.8499
=== ④ 一个零基础的经典错误:在筛选结果上赋值 ===
② ❌ df[条件]['is_eol'] = True → ChainedAssignmentError
✅ df.loc[条件, 'is_eol'] = True → 生效
=== ⑦ pd.cut:把连续量切成几档 ===
n soh_mean r_mean
0-300 3600 0.9790 42.0518
1200+ 895 0.8154 76.8442
→ 一眼看出:圈数越多,SOH 越低、内阻越高,而且后期变化更快(膝点效应)。本章练习
练习 1:派生出有业务含义的新列
新增三列:
cap_mah(Ah → mAh);soh_pct(SOH × 100);health(三档:< 0.85待退役、< 0.90预警、其余健康,用np.where或np.select)。
然后:
- 打印三档的计数与占比;
- 打印
soh_pct最小的 5 行; - 验证"乘 100 不改变排序"。
参考答案
import numpy as np
import pandas as pd
from pathlib import Path
pd.set_option("display.width", 170)
CODE_DIR = Path(__file__).resolve().parent.parent.parent
df = pd.read_csv(CODE_DIR / "data" / "battery" / "cycle_raw.csv")
# ① ② 整列运算
df["cap_mah"] = df["capacity_ah"] * 1000.0
df["soh_pct"] = df["soh"] * 100.0
print(f"列数: 9 → {df.shape[1]}")
# ③ 三档:条件从严格到宽松
df["health"] = np.where(df["soh"] < 0.85, "待退役",
np.where(df["soh"] < 0.90, "预警", "健康"))
# ④ 分布
counts = df["health"].value_counts()
print(counts)
print({k: f"{v / len(df):.1%}" for k, v in counts.items()})
# 健康 7642 (54.7%) / 预警 3214 (23.0%) / 待退役 3111 (22.3%)
# ⑤ 最差的 5 行
print(df.nsmallest(5, "soh_pct")[["cell_id", "cycle", "soh", "soh_pct"]]
.to_string(index=False))
print(f"soh_pct 最小值 = {df['soh_pct'].min()}") # 80.0
# ⑥ 排序不变
a = df.nsmallest(5, "soh_pct")["cell_id"].tolist()
b = df.nsmallest(5, "soh")["cell_id"].tolist()
print(f"排序一致? {a == b}") # True三个要点:
soh_pct最小值是 80.0 —— 正好是 EOL 退役线 ×100, 因为数据生成规则是"到 0.80 就停采样"。- 三档分布不均衡(54.7% / 23% / 22.3%) —— 这符合"电池大部分时间处于健康状态"的物理直觉。
- 验证顺序不变:线性缩放不改变相对大小。这类
assert是很好的自检。
完整练习:exercises/ex27-01-derived-column
练习 2:为什么这段代码的"待退役"永远是 0 条?
df["health"] = np.select(
[df["soh"] < 0.90, df["soh"] < 0.85],
["预警", "待退役"],
default="健康",
)
print(df["health"].value_counts())要求:说出输出、原因,以及怎么改。
参考答案
print(df["health"].value_counts())
# 健康 7642
# 预警 6325 ← 3214 + 3111
# (待退役永远不出现)原因:条件顺序写反了。
np.select 的规则是取第一个为 True 的条件:
对于 soh = 0.82:
条件 1:0.82 < 0.90 → True ← 命中!取 "预警"
条件 2:(不再判断)所以 soh < 0.85 这条永远轮不到 —— 和 if/elif 的顺序坑完全一样。
修复:从严格到宽松
df["health"] = np.select(
[df["soh"] < 0.85, df["soh"] < 0.90], # 严格 → 宽松
["待退役", "预警"],
default="健康",
)验证修复效果:
print(df["health"].value_counts())
# 健康 7642
# 预警 3214
# 待退役 3111 ← 出现了★ 一个量化的诊断方法:
# 对比正确和错误的分类结果,看差多少
right = np.select([df["soh"] < 0.85, df["soh"] < 0.90], ["待退役", "预警"], default="健康")
wrong = np.select([df["soh"] < 0.90, df["soh"] < 0.85], ["预警", "待退役"], default="健康")
diff = df.loc[right != wrong, "soh"]
print(f"{len(diff)} 条被误分类,SOH 范围 {diff.min():.4f} ~ {diff.max():.4f}")
# 3111 条被误分类,SOH 范围 0.8000 ~ 0.8499"被误分类的区间正好是 [0.80, 0.85)" —— 这个诊断结果直接指出了 bug 的位置。
★ 一条纪律:
写任何"分档"逻辑(
if/elif、np.select、np.where嵌套), 都用边界值试一遍。
test = pd.Series([0.78, 0.84, 0.87, 0.92])
print(np.select([test < 0.85, test < 0.90], ["待退役", "预警"], default="健康").tolist())
# 期望:['待退役', '待退役', '预警', '健康']四个值覆盖了三个档位和两个边界,两秒钟就能验证顺序对不对。
练习 3:为什么"在筛选结果上赋值"不生效?
import pandas as pd
df = pd.read_csv("data/battery/cycle_raw.csv")
df["is_eol"] = False
df[df["soh"] < 0.85]["is_eol"] = True
print(df["is_eol"].sum()) # ?要求:说出输出、原因、两种修法,以及为什么"先建列"这一步不能省。
参考答案
输出:在 Pandas 3.0 里这行会抛 ChainedAssignmentError:
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series
through chained assignment. Such chained assignment never works to update the
original DataFrame or Series, because the intermediate object on which we are
setting values always behaves as a copy (due to Copy-on-Write).如果它没报错(比如在更宽松的配置下),df["is_eol"].sum() 会是 0 —— 因为赋值改的是临时副本。
原因:df[df["soh"] < 0.85] 返回的是一个派生对象。 Pandas 3.0 的 Copy-on-Write 规定"派生对象一律独立",所以改它不影响 df。
修法一:用 loc 一步到位
df.loc[df["soh"] < 0.85, "is_eol"] = True
print(df["is_eol"].sum()) # 3111修法二:先明确复制(如果只想改子集)
sub = df[df["soh"] < 0.85].copy() # ← .copy() 是关键
sub["is_eol"] = True
print("is_eol" in df.columns) # False (df 不受影响,这是你的本意)★ "先建列"这一步为什么不能省
# 不先建列
df.loc[df["soh"] < 0.85, "is_eol"] = True
print(df["is_eol"].value_counts(dropna=False))
# NaN 10856 ← 没被赋值的行是 NaN,不是 False
# True 3111问题:NaN 在布尔运算里的行为是"传染的":
mask = df["is_eol"] & (df["soh"] < 0.90) # NaN & False → False;NaN & True → NaN
print(mask.dtype) # object(不是 bool!)这会破坏后续的筛选逻辑,而且不报错。
正确的三行模式:
df["is_eol"] = False # ① 先给所有行一个默认值
df.loc[df["soh"] < 0.85, "is_eol"] = True # ② 再按条件改
print(df["is_eol"].value_counts()) # ③ 验证
# False 10856
# True 3111
print(df["is_eol"].dtype) # bool ← 干净的类型★ 一个实用的自检:
def add_flag(df, name, mask, default=False):
"""按条件打标记列 —— 保证 dtype 是 bool 而不是 object。"""
df[name] = default
df.loc[mask, name] = not default
assert df[name].dtype == bool, f"{name} 的 dtype 是 {df[name].dtype}"
return df这个 assert 抓住了最关键的问题:NaN 会让 dtype 悄悄变成 object, 而 object 类型的布尔列在后续运算里会出各种奇怪的问题。
面向 AI 开发:特征工程是"把业务知识翻译成列"
这一章讲的"派生列",在 AI 里的正式名字是特征工程(Feature Engineering)。 它的地位可以说得再重一点:
在表格数据上,好的特征比好的模型重要得多。
一个直观的对比(典型表格数据竞赛的经验):
| 投入 | 效果提升 |
|---|---|
| 换更强的模型(XGBoost → 神经网络) | +1~3% |
| 加一个好的领域特征 | +5~20% |
为什么:模型只能学到"数据里已有的模式"。 如果关键信息没有被表达成某一列,再强的模型也学不到。
四个"从业务知识到列"的例子(用本章的数据):
# ① 领域知识:电池老化看的是「寿命进度」,不是绝对圈数
df["cycle_frac"] = df["cycle"] / df["cycle"].groupby(df["cell_id"]).transform("max")
# ② 领域知识:内阻上升的「加速度」比绝对值更能预示即将跳水
df["r_growth"] = df["resistance_mohm"] / df["resistance_mohm"].groupby(df["cell_id"]).transform("first")
# ③ 领域知识:温升和库仑效率一起下降才是真衰退(单一指标可能波动)
df["eff_drop"] = df["coulombic_eff"].groupby(df["cell_id"]).transform("first") - df["coulombic_eff"]
# ④ 领域知识:早期行为能预测后期(前 100 圈的平均衰减速率)
early = df[df["cycle"] <= 100].groupby("cell_id")["soh"].agg(["first", "last"])
early["early_fade_rate"] = (early["first"] - early["last"]) / 100
df = df.merge(early[["early_fade_rate"]], left_on="cell_id", right_index=True)第 ④ 个例子最值得注意:它用了"早期的信息"来预测"后期的结果" —— 这在业务上完全合理(电池的早期表现确实能预示寿命), 而且没有数据泄漏(用的都是"预测时刻之前"的数据)。
★ 特征工程的三条纪律
纪律一:每个特征都要能回答"它代表什么物理量/业务概念"。
# ✅ 好:能说清含义
df["days_since_start"] # 距首次使用多少天 → 老化程度
df["temp_x_current"] # 温度 × 电流 → 热负荷
# ❌ 坏:说不出含义
df["capacity_ah_x_coulombic_eff"] # 为什么乘?代表什么?
df["soh_squared"] # 平方有什么物理意义?纪律二:新特征要验证它真的带来信息。
# 算一下新特征和目标的相关性
print(df[["cycle_frac", "cycle"]].corrwith(df["soh"]))
# cycle_frac -0.94
# cycle -0.93
# → 两者接近,说明 cycle_frac 没有明显更好的信息量(在这个数据集里)
# 但如果换成"预测每支电芯的剩余寿命",cycle_frac 的优势就出来了纪律三:警惕"用未来信息造特征"。
# ❌ 泄漏:用了整支电芯的全周期统计量
df["soh_mean_of_cell"] = df.groupby("cell_id")["soh"].transform("mean")
# 预测"第 100 圈的 SOH"时,这个特征用到了第 800 圈的数据 → 作弊
# ✅ 安全:只用"当前及之前"的数据
df["soh_mean_so_far"] = (df.groupby("cell_id")["soh"]
.transform(lambda s: s.expanding().mean()))这个区别在时序预测里极其重要,也是 AI 工程最常见的坑之一。
★ 一个实用的特征工程工作流
① 先问业务:什么因素会影响这个目标?
(电池 SOH ← 循环次数、温度、内阻、充放电倍率……)
② 看已有列:哪些能直接表达这些因素?
(cycle、temp_rise_c、resistance_mohm 都在表里)
③ 造派生列:把"业务概念"翻译成列
(寿命进度 = cycle / 总寿命;热负荷 = 温度 × 电流)
④ 验证信息量:和目标的相关系数、单特征模型效果
(用 corrwith 或简单的线性回归看)
⑤ 剔除冗余:和已有特征高度相关的删掉
(第 37 章的热力图干这件事)本章的 demos/ch27-derived-column.py 就是这个流程的第一次实践。