第 04 章 · 特征与标签:把一张表切成 X / y
这一章要解决什么:把一张 9 列的表切成"喂给模型的
X"和"要预测的y"。 听起来是全书最简单的一步,但四个最常见的错误全在这里。最容易踩的坑:把标签留在特征里 → R² = 1.000000,而且是"正确的"结果 —— 模型只是学会了"把 soh 乘以 1"。
本章你需要完成的代码:demos/ch04-features-labels.py
4.1 两句话把这件事说完
X = df[FEATS] # 特征:用来预测的依据(一张表)
y = df[TARGET] # 标签:要预测的答案(一列)就这两行。整本书都是它的变体。
| 名字 | 别名 | 是什么 | 形状 |
|---|---|---|---|
| X | 特征矩阵、自变量、输入 | 一张表 | (样本数, 特征数) 二维 |
| y | 标签、目标、因变量、输出 | 一列 | (样本数,) 一维 |
真实输出:
整表 shape : (21239, 9)
X.shape = (21239, 1) ← 二维表(行 × 特征)
y.shape = (21239,) ← 一维序列
X 的行数 == y 的长度 : True★ 记住这两行形状:
X.shape = (21239, 1) ← 两个数字:行数 × 特征数
y.shape = (21239,) ← 一个数字:只有长度
X 的行数必须 == y 的长度,否则模型不知道该拿哪个答案去对哪个样本点开:为什么 X 和 y 的命名是 X 和 y(一点历史)
这是数学记号的遗留。
在统计学里,线性模型写成:
y = Xβ + εX:自变量矩阵(大写的矩阵)y:因变量向量(小写的列向量)β:系数ε:误差
scikit-learn 沿用了这套记号:
model.fit(X, y) # 学 β
model.predict(X_new) # 算 X_new · β
model.coef_ # 就是 β所以你看 sklearn 的文档时会有这些规律:
| 记号 | 含义 |
|---|---|
大写 X | 二维数组(样本 × 特征) |
小写 y | 一维数组(样本) |
带下划线结尾的属性(coef_、intercept_) | 训练后才有,训练前访问会报错 |
★ 最后一条是新手常踩的坑:
model = LinearRegression()
print(model.coef_) # ❌ NotFittedError: This LinearRegression instance is not fitted yet
model.fit(X, y)
print(model.coef_) # ✅ 现在有了规律:sklearn 里凡是以 _ 结尾的属性,都是"学出来的",必须先 fit。
(例外是那些你手动传进去的参数,比如 model.n_estimators —— 没有下划线, 因为它是你设定的,不是学出来的。)
这个约定在后面的章节很有用:
pipe.named_steps["scaler"].mean_ # 标准化时学到的均值
pipe.named_steps["model"].coef_ # 模型学到的系数
tree.feature_importances_ # 树学到的重要性所有"模型从数据里学到的东西",都带下划线。
4.2 哪一列不能进 X
这张表有 9 列。哪些能当特征?
| 列 | 能当特征吗 | 为什么 |
|---|---|---|
cell_id | ❌ 绝对不能 | 它是身份,不是物理量(见 4.3) |
cycle | ✅ | 物理量:循环次数 |
mean_charge_voltage_v | ✅ | 物理量:电压 |
mean_temp_rise_c | ✅ | 物理量:温升 |
resistance_mohm | ✅ | 物理量:内阻 |
coulombic_eff | ✅ | 物理量:库仑效率 |
cap_fade_rate_ah_per_100 | ✅ | 物理量:衰减速率 |
temp_std_c | ✅ | 物理量:温度稳定性 |
soh | ❌ 绝对不能 | 它是标签(见 4.5) |
所以:7 列可以当特征,1 列是身份,1 列是答案。
点开:判断"这一列能不能当特征"的三个问题
按顺序问三个问题:
问题一:它是不是"身份"?
cell_id = "C001"
↕ 这告诉你什么物理状态?什么也没有。
它只是在说"这是第 1 支"。身份类列的特征:
- 编号(
cell_id、user_id、order_no) - 名字(产品名、门店名)
- 时间戳的绝对部分(2023-01-01 这个"日期"本身)
为什么不能当特征:
模型会学到:"cell_id = C007 时,soh 平均是 0.906"
↑
这不是规律,这是【记忆】后果:在训练集上很准(它见过 C007), 换一支新设备就完全失效(cell_id = C061 它从没见过)。
★ 一个例外:如果 cell_id 是类别特征并且你确定"身份有意义" (比如预测"哪个用户会续费",用户的历史等级就可能有意义), 那需要做类别编码(one-hot 等),而不是把字符串直接塞进去。
但这本书的场景不是 —— 我们要预测"没见过的设备",身份必须排除。
问题二:它是不是"标签的计算来源"?
这是第 4.5 节的主题。 先给判据:
问:"如果我把这一列拿掉,标签还能算出来吗?"
如果不能(因为标签就是它算的)→ 泄漏,不能当特征问题三:它在"预测那一刻"真的能拿到吗?
预测"第 500 圈的 soh"时:
· cycle = 500 ✅ 能拿到
· resistance_mohm(第500圈) ✅ 能拿到
· 全周期平均 soh ❌ 用了第 500 圈之后的数据
· 设备的最终寿命 ❌ 这是要预测的东西这一类叫"时间穿越式泄漏",比标签泄漏更隐蔽。
★ 三个问题的速查表
| 问题 | 如果答案是"是" |
|---|---|
| 它是身份吗? | 排除(或做类别编码) |
| 它是标签的计算来源吗? | 必须排除 |
| 它在预测时刻拿不到吗? | 必须排除 |
★ 一个真实的教训
任务:预测"用户会不会流失"
特征里有一列:"客服工单数量"
↓
离线 AUC = 0.95,非常好
↓
上线后 AUC = 0.62
↓
排查发现:用户打电话来投诉(产生工单)之后,才会流失。
所以"工单数量"是【流失的结果】,不是原因。
↓
模型学的是"有工单的人更容易流失" —— 这在离线数据上永远成立,
但上线时你用它去【提前预测】,等于在问"这个人打过电话吗"问法:"如果我只知道这一列,我能提前多久知道答案?"
4.3 坑一:X 写成一维
LinearRegression().fit(df["cycle"], df["soh"])真实输出:
ValueError: Expected a 2-dimensional container but got <class 'pandas.Series'> instead…为什么:
df["cycle"] → Series,形状 (21239,)
df[["cycle"]] → DataFrame,形状 (21239, 1)
↑↑
多一层方括号★ 记忆法:
df["cycle"] 方括号里是【字符串】 → 取一列 → 一维
df[["cycle"]] 方括号里是【列表】 → 取一张表 → 二维这条规则在 pandas 里通用,值得记牢:
df["a"] # Series
df[["a"]] # DataFrame(1 列)
df[["a", "b"]] # DataFrame(2 列)★ 为什么 sklearn 要这么严格:
因为它必须知道"哪一维是样本、哪一维是特征"。
(21239, 1) → 21239 个样本,每个 1 个特征
(1, 21239) → 1 个样本,每个 21239 个特征 ← 完全不同的意思!一维数组 (21239,) 就有歧义 —— 是 21239 个样本,还是 1 个样本的 21239 个特征?
sklearn 选择直接报错,而不是猜。这是好事。
点开:常见的报错措辞(认出它)
不同 sklearn 版本的报错文本略有不同,但意思一样:
Expected 2D array, got 1D array instead
Expected a 2-dimensional container but got <class 'pandas.Series'> instead
Found array with dim 1. Estimator expected <= 2看到 "2D"、"2-dimensional"、"dim 1" → 就是这个问题。
★ 修复的三种写法:
# ① 列表(推荐,最直观)
X = df[["cycle"]]
# ② to_frame()
X = df["cycle"].to_frame()
# ③ reshape(numpy 场景)
X = df["cycle"].to_numpy().reshape(-1, 1)① 最好 —— 它保留了列名,这在后面很重要:
# 有列名时
model.fit(df[["cycle"]], y)
model.predict(pd.DataFrame({"cycle": [500.0]})) # ✅ 没有警告
# 没列名时
model.fit(df["cycle"].to_numpy().reshape(-1, 1), y)
model.predict(np.array([[500.0]])) # ⚠️ 警告:no valid feature names列名还有别的好处(第 15 章会看到):
pipe.named_steps["model"].coef_ # 一个数组,没有列名
# 但如果特征有列名,可以这样配对:
dict(zip(FEATS, model.coef_)) # {'cycle': -0.000146, ...} ← 可读多了4.4 坑二:把标识列当特征
LinearRegression().fit(df[["cell_id", "cycle"]], df["soh"])真实输出:
cell_id 的 dtype = str,样例 ['C001', 'C001', 'C001']
ValueError: could not convert string to float: 'C001'…这次报错的原因和前一个不同:
上一次:形状不对(1D vs 2D)
这一次:类型不对(字符串 vs 数值)sklearn 的模型做的是数值运算 —— 它要算 w × x + b, 而 w × "C001" 没有定义。
★ 但真正的问题不是"报错",而是"报错救了你的命"
如果 cell_id 是数字(比如 1、2、3...60)会怎样?
↓
不会报错!模型会照常训练
↓
而且 R² 会【偏高】 —— 因为模型学会了"编号 7 的设备平均 soh 是 0.906"
↓
这个模型上线后,遇到 61 号设备就完全失效
↓
而你在离线评估里看不到任何异常★ 这是一个重要的认知:
报错是好事。最危险的错误是"不报错但结果错了"。
这本书里有很多这样的例子 —— 我们会在每一处都停下来看它一眼。
点开:怎么系统地检查"有没有把标识列混进去"
方法一:看 dtype
print(df.dtypes)cell_id str ← 字符串,可疑!
cycle int64 ✅ 数值
mean_charge_voltage_v float64 ✅
...凡是 object 或 str 类型的列,都不该直接当特征。
方法二:在代码里显式区分
ID_COLS = ["cell_id"] # 身份列
TARGET = "soh" # 标签
FEATS = [c for c in df.columns
if c not in ID_COLS + [TARGET]] # 剩下的才是特征
print(f"可用特征({len(FEATS)}): {FEATS}")为什么推荐这种写法:它是白名单的反面 —— 黑名单加自动推导。
优点:加新列时会自动进入特征(不容易漏)
缺点:如果不小心加了一列"未来信息",它也会自动进去更安全的写法(白名单):
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c",
"resistance_mohm", "coulombic_eff",
"cap_fade_rate_ah_per_100", "temp_std_c"]优点:明确,加列必须显式声明(不容易误入)
缺点:漏写一列就白丢信息★ 用哪个?
| 场景 | 用哪个 | 理由 |
|---|---|---|
| 探索阶段 | 黑名单(自动推导) | 方便,快速看全貌 |
| 正式建模 | 白名单 | 安全,不容易混入脏列 |
| 生产代码 | 白名单 + 断言 | 最安全 |
加一个断言会更稳:
assert len(FEATS) == len(set(FEATS)), "特征列表有重复"
assert not set(FEATS) & set(ID_COLS + [TARGET]), "特征里混进了身份列或标签!"
assert all(df[c].dtype != object for c in FEATS), "特征里还有非数值列!"这三行在你的项目里会救你很多次,尤其是数据表被别人改过之后。
4.5 ★ 坑三:把标签留在特征里(最纯粹的特征泄漏)
leak = LinearRegression().fit(df[["cycle", "soh"]], df["soh"])真实输出:
用 [cycle, soh] 预测 soh → 整表 R² = 1.000000
coef_ = [-0. 1.] intercept_ = 0.0000
→ R² = 1.000000。模型发现最简单的事就是【把 soh 乘以 1】。
这是最纯粹的特征泄漏:答案在试卷里。★ 这一节是全书"泄漏"主题的开场。
看懂这三行输出:
R² = 1.000000 → 完美预测,误差为 0
coef_ = [-0., 1.] → cycle 的权重是 0,soh 的权重是 1
intercept_ = 0.0000 → 截距是 0
翻译:预测值 = 0 × cycle + 1 × soh + 0 = soh
↑ ↑
完全没用上 cycle 直接把答案抄了一遍模型做了最聪明的事:既然 soh 就在特征里,那预测它就是复制它。
★ 为什么这个错误特别值得警惕
① 它不报错
② 它给出的 R² 是 1.000000 —— 看起来"完美"
③ 它会让你非常高兴("我的模型太准了!")
④ 而你如果去汇报,会显得很厉害……
直到有人问:"那你上线时怎么拿到 soh 这一列?"第 ④ 条是要害:
标签的定义就是"要预测的东西"。你在预测时刻【不可能】知道它。 所以任何"用了标签"的特征,都是作弊。
点开:三种特征泄漏(按隐蔽程度排序)
① 标签本身在特征里(最明显)
FEATS = ["cycle", "soh"] # ❌ 标签在里面识别:R² = 1.000000 或极接近 1。
② 标签的计算来源在特征里(较隐蔽)
# 假设表里同时有"实测容量"和"健康度"
# 而 健康度 = 实测容量 / 额定容量
FEATS = ["capacity_ah", "cycle"] # ❌ capacity_ah 算出 soh识别:某个特征与标签的相关系数 |r| > 0.99。
这一类比第一类隐蔽得多 —— 因为特征名字不叫 soh, 你看列名不会觉得有问题,要知道它的物理含义才能发现。
验证方法:
corr = df.select_dtypes("number").corr()["soh"].drop("soh")
print(corr.sort_values(key=abs, ascending=False).round(4))coulombic_eff 0.9683
cycle -0.9137
resistance_mohm -0.8784
mean_charge_voltage_v -0.8003
temp_std_c -0.7868
mean_temp_rise_c -0.7491
cap_fade_rate_ah_per_100 -0.4257注意:这份数据里最高的只有 0.9683,没有 0.99+。
所以这份数据【没有】第二类泄漏(我们用的是测量特征,不含容量的直接换算)。
⚠️ 但要学会怎么看:
|r| > 0.99 → 高度可疑,去查这个特征的物理定义
|r| 在 0.9~0.99 → 留意,但通常是真的强信号③ 时间穿越式泄漏(最隐蔽)
# 用"整个时间段的表现"预测"某个时刻的状态"
df["soh_full_mean"] = df.groupby("cell_id")["soh"].transform("mean")
FEATS = ["cycle", "soh_full_mean"] # ❌ 用了未来的数据识别:
- 特征的构造里出现了
groupby(...).transform("mean")、max()、count()这类聚合; - 特征名里有
total、full、final、lifetime; - 你使用的信息"跨越了预测时刻"。
★ 一个统一的判据(记住这一条就够)
假想你现在站在【预测时刻】,只能看到那一刻之前的信息。 这个特征,你当时拿得到吗?
预测"第 500 圈的 soh":
模型部署在第 500 圈结束的时候
├─ cycle = 500 ✅ 拿得到
├─ resistance_mohm(第 500 圈测的) ✅ 拿得到
├─ 第 300~500 圈的均值 ✅ 拿得到(都在过去)
├─ 全周期平均(含第 501~1400 圈) ❌ 拿不到
└─ 设备最终寿命 ❌ 拿不到★ 三种泄漏的对比
| 隐蔽度 | 识别信号 | 检查方法 | |
|---|---|---|---|
| 标签在特征里 | 低 | R² = 1.000000 | 看自己有没有写 TARGET |
| 标签的计算来源 | 中 | 某特征 |r| > 0.99 | 算相关性 + 查物理含义 |
| 时间穿越 | 高 | 分数好得不合理 | 逐个特征问"预测时刻拿得到吗" |
★ 最后一条最实用:
每次做完特征工程(构造新特征),都要重新过一遍这个检查。 因为泄漏大多是"你新造出来的特征"带来的。
4.6 坑四:predict 传 numpy 数组
model = LinearRegression().fit(df[FEATS], df[TARGET])
model.predict(np.array([[500.0]]))真实输出:
UserWarning: X does not have valid feature names, but LinearRegression was fitted with feature names…
→ 结果对,但列名丢了。
改用 pd.DataFrame({'cycle': [500.0]}) 就没有警告。这只是一个警告,结果是对的。 但值得处理,因为:
① 警告会淹没真正重要的警告(噪声)
② 列名丢失后,你无法察觉"特征顺序错了"★ 第 ② 条才是真正的风险:
FEATS = ["cycle", "resistance_mohm"] # 训练时的顺序
model.fit(df[FEATS], y)
# 预测时手滑写反了顺序
model.predict(np.array([[50.0, 500.0]])) # ❌ 把 cycle 填成了 resistance 的位置
# 只给一个警告,不会报错!如果传 DataFrame:
model.predict(pd.DataFrame({"resistance_mohm": [50.0], "cycle": [500.0]}))
# → 如果模型训练时没有 "resistance_mohm" 这一列... 不会报错,
# 但如果列名齐全,sklearn 会【按列名对齐】,顺序无关 ✅这是 DataFrame 最实用的一个好处:按名字对齐,而不是按位置。
点开:画预测曲线时的正确写法(这一段后面每章都要用)
场景:画一条"预测 vs 循环次数"的曲线,需要造一批网格点。
❌ 错误写法:
grid_x = np.linspace(0, 1500, 300)
model.predict(grid_x) # ❌ 一维,直接报错
model.predict(grid_x.reshape(-1, 1)) # ⚠️ 能跑,但有警告✅ 正确写法:
grid = pd.DataFrame({"cycle": np.linspace(0, 1500, 300)})
y_grid = model.predict(grid)为什么 pd.DataFrame({"cycle": ...}) 是标准写法:
① 列名对上了训练时的列名 → 没有警告
② 特征多的时候,能按名字对齐 → 不怕顺序错
③ 一眼看出"这是 cycle 轴上的预测" → 可读★ 多特征时的网格构造
如果要在一个二维平面上画预测(比如 cycle × temperature), 可以用 itertools.product:
import itertools
grid = pd.DataFrame(
itertools.product(
np.linspace(0, 1500, 100), # cycle 的取值
[20.0, 30.0, 40.0], # temperature 的几个固定值
),
columns=["cycle", "mean_temp_rise_c"],
)
y_grid = model.predict(grid)注意列名的顺序要和 FEATS 一致(scikit-learn 会按名字对齐, 但保持一致更不容易出错)。
★ 这个写法的一个额外好处:能发现"特征名写错"
FEATS = ["cycle", "resistance_mohm"]
grid = pd.DataFrame({"cycle": [500.0], "resistence_mohm": [50.0]}) # 拼错了
model.predict(grid)
# ValueError: The feature names should match those that were passed during fit.
# Feature names unseen at fit time: - resistence_mohm
# Feature names seen at fit time, yet now missing: - resistance_mohm报错直接告诉你哪个名字错了。
如果用 numpy 数组,这个拼写错误会变成"静默地算错" —— 因为 numpy 数组没有名字,sklearn 只能按位置对上,算出一个错的结果。
★ 一条纪律
训练之后,凡是 predict,都用 DataFrame(或至少保证列名一致)。
好处是"把静默错误变成报错" —— 这一条在真实项目里很值钱。
4.7 常见坑
① X 用一维 Series → ValueError: Expected 2D array,改用 df[["col"]]
② cell_id 当特征 → 字符串报错;如果是数字会更糟(不报错但结果错)
③ 标签留在 X 里 → R² = 1.000000,模型学会了"把 soh 乘 1"
④ predict 传 numpy 数组 → 只是警告,但列名丢失会掩盖"特征顺序错"
⑤ 用事后聚合的特征 → 时间穿越泄漏,最隐蔽的一类★ 这五个坑的共同点
①②④ → 技术性错误,通常会报错或警告
③⑤ → 【逻辑性错误,往往不报错,而且分数很漂亮】真正危险的是后两个。 第 09~10 章会专门讲怎么系统性地发现它们。
4.8 完整示例与输出
cd code
python demos/ch04-features-labels.py真实输出(节选):
=== ① 正确的切法 ===
整表 shape : (21239, 9)
X.shape = (21239, 1) ← 二维表(行 × 特征)
y.shape = (21239,) ← 一维序列
y 大于 1.00 的行数:11 ← 标签是测出来的,不是真值
=== ② 坑一:X 写成一维 Series ===
ValueError: Expected a 2-dimensional container but got <class 'pandas.Series'> instead
=== ③ 坑二:把标识列当特征 ===
cell_id 的 dtype = str,样例 ['C001', 'C001', 'C001']
ValueError: could not convert string to float: 'C001'
=== ④ 坑三:把标签留在 X 里(泄漏)===
用 [cycle, soh] 预测 soh → 整表 R² = 1.000000
coef_ = [-0. 1.] intercept_ = 0.0000
=== ⑤ 坑四:predict 传 numpy 数组 ===
UserWarning: X does not have valid feature names, but LinearRegression was fitted with feature names本章练习
练习 1:亲手制造标签泄漏,并解释它
- 用
[cycle, soh]当作特征拟合一个线性回归,打印 R² 和coef_; - 把
soh从特征里去掉,重新拟合,打印 R²; - 解释
coef_里的两个数分别意味着什么。
参考答案
import pandas as pd
from sklearn.linear_model import LinearRegression
df = pd.read_csv("data/battery/soh_features.csv")
y = df["soh"]
# ① 泄漏版本
leak = LinearRegression().fit(df[["cycle", "soh"]], y)
print(f"[cycle, soh] → R² = {leak.score(df[['cycle', 'soh']], y):.6f}")
print(f"coef_ = {leak.coef_.round(6)} intercept_ = {leak.intercept_:.6f}")
# ② 正确版本
ok = LinearRegression().fit(df[["cycle"]], y)
print(f"\n[cycle] → R² = {ok.score(df[['cycle']], y):.6f}")
print(f"coef_ = {ok.coef_.round(8)} intercept_ = {ok.intercept_:.6f}")期望输出:
[cycle, soh] → R² = 1.000000
coef_ = [-0. 1.] intercept_ = 0.000000
[cycle] → R² = 0.834759
coef_ = [-0.0001462] intercept_ = 0.996891③ 解释
泄漏版本的 coef_ = [-0., 1.]:
预测值 = (-0.0) × cycle + 1.0 × soh + 0.0
= soh
含义:模型发现"最好的策略是把 soh 原样复制过去"。
cycle 的权重是 -0(负零,本质是 0),
说明 cycle 被完全无视了 —— 有 soh 在,cycle 毫无用处。正确版本的 coef_ = [-0.0001462]:
预测值 = -0.0001462 × cycle + 0.996891
含义:每多循环 1 圈,soh 平均下降 0.0001462
→ 每 100 圈下降 1.462 个百分点★ 三个关键观察
观察一:R² 从 1.000000 掉到 0.834759,差了 0.165。
观察二:-0. 这个负零很说明问题
-0. 表示"一个非常小的负数四舍五入成 0,且符号是负的"
↓
说明模型确实给 cycle 分配了权重,但优化后接近 0观察三:泄漏版本的 R² 高,但模型毫无用处
R² = 1.000000 看起来很完美
↓
但上线时你【拿不到 soh 这一列】—— 它就是要预测的东西
↓
所以这个模型在真实场景里的可用性 = 0★ 一句话总结
R² = 1.000000 不是"模型很强",是"我犯了个错"。
这个反应(看到 1.0 就警觉)要练成本能。
练习 2:用白名单切 X / y,并加三道断言
写一个函数 make_xy(df, feats, target),返回 (X, y),并加三道断言:
feats里没有重复;feats里没有混进target或身份列;feats里的列都是数值类型。
参考答案
import pandas as pd
ID_COLS = ["cell_id"]
TARGET = "soh"
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c",
"resistance_mohm", "coulombic_eff",
"cap_fade_rate_ah_per_100", "temp_std_c"]
def make_xy(df, feats, target, id_cols=ID_COLS):
"""切出 X / y,并用断言挡住三类常见错误。"""
# 断言一:没有重复
assert len(feats) == len(set(feats)), f"特征列表有重复:{feats}"
# 断言二:没有混进标签或身份列
bad = set(feats) & (set(id_cols) | {target})
assert not bad, f"特征里混进了身份列或标签:{bad}"
# 断言三:都是数值列
non_num = [c for c in feats if not pd.api.types.is_numeric_dtype(df[c])]
assert not non_num, f"特征里有非数值列:{non_num}"
X, y = df[feats], df[target]
assert len(X) == len(y), f"X 和 y 长度不一致:{len(X)} vs {len(y)}"
return X, y
df = pd.read_csv("data/battery/soh_features.csv")
X, y = make_xy(df, FEATS, TARGET)
print(f"X.shape = {X.shape} y.shape = {y.shape}")
print(f"特征({len(FEATS)} 个):{FEATS}")期望输出:
X.shape = (21239, 7) y.shape = (21239,)
特征(7 个):['cycle', 'mean_charge_voltage_v', 'mean_temp_rise_c', 'resistance_mohm', 'coulombic_eff', 'cap_fade_rate_ah_per_100', 'temp_std_c']★ 为什么"断言"值得写
看这三个断言分别能挡住什么:
# 断言一挡住:手抄特征列表时写重了
FEATS = ["cycle", "cycle", "resistance_mohm"] # → AssertionError
# 断言二挡住:这是最值钱的一个
FEATS = ["cycle", "soh"] # → AssertionError
FEATS = ["cell_id", "cycle"] # → AssertionError
# 断言三挡住:表里混了字符串列
FEATS = ["cycle", "timestamp"] # → AssertionError★ 断言和报错的差别
报错(第 4.3 节的 ValueError):
你写错了,sklearn 告诉你 —— 但你要【跑起来才知道】
断言:
你写错了,【在你自己的代码里立刻】告诉你 —— 而且信息更清楚对比两种信息的可读性:
# sklearn 的报错(不知道你为什么要传这一列)
ValueError: could not convert string to float: 'C001'
# 你的断言(直接指出问题所在)
AssertionError: 特征里混进了身份列或标签:{'soh'}★ 一条工程经验
断言不是"防御性编程的洁癖",它是"把调试信息提前"。
在数据管道里,越早发现错误,排查成本越低:
在特征构造处发现 → 1 分钟
在训练时报错 → 10 分钟
在评估时发现分数异常 → 半天
在上线后才发现 → 几天,而且可能已经造成损失所以: 关键假设就写成断言。** 这一条会在这本书后面的章节反复出现。
练习 3:找出这份数据里的"可疑特征"
计算所有数值列与 soh 的相关系数,按绝对值排序,然后回答:
- 有没有
|r| > 0.99的列?如果有,它可疑吗? - 最高的是哪一列,它的物理含义是什么?
- 这份数据里没有标签泄漏,你能从哪个数字上看出来?
参考答案
import pandas as pd
df = pd.read_csv("data/battery/soh_features.csv")
corr = df.select_dtypes("number").corr()["soh"].drop("soh")
print(corr.sort_values(key=abs, ascending=False).round(4).to_string())
print(f"\n最大 |r| = {corr.abs().max():.4f}")
print(f"有 |r| > 0.99 的列吗:{bool((corr.abs() > 0.99).any())}")期望输出:
coulombic_eff 0.9683
cycle -0.9137
resistance_mohm -0.8784
mean_charge_voltage_v -0.8003
temp_std_c -0.7868
mean_temp_rise_c -0.7491
cap_fade_rate_ah_per_100 -0.4257
最大 |r| = 0.9683
有 |r| > 0.99 的列吗:False① 没有 |r| > 0.99 的列。
② 最高的是 coulombic_eff(库仑效率),r = 0.9683。
物理含义:库仑效率 = 放电容量 / 充电容量。设备老化时, 副反应增多、效率下降,所以它和健康度强相关。
⚠️ 但要留意第 20 章会讲它的问题:
coulombic_eff 的 r = 0.9683,特征重要性 0.93
↓
看起来是"最有用的特征"
↓
但它的取值只有 **47 个**不同值(本来就被限制在很窄的区间里)
↓
它更像是"当前窗口状态的一个省事的代理量",
而不是"能指导改进的物理量"③ 能从"最大 |r| = 0.9683 < 0.99"看出来这份数据没有标签泄漏。
为什么 0.99 是个有用的阈值:
标签泄漏时,泄漏列与标签的关系是【函数关系】
例:soh = capacity_ah / 2.5
→ 相关系数会是 0.998 以上
真实强信号:
r 通常落在 0.7 ~ 0.98
(因为测量有噪声、关系不是完美线性)★ 但阈值只是"提示去查",不是"判定"
if corr.abs().max() > 0.99:
print("⚠️ 有列 |r| > 0.99 → 逐个检查它的物理定义")
else:
print("✅ 没有明显可疑的高相关列")真正的判定要靠"看物理含义" —— 一个 r = 0.998 的特征,如果它的定义里含标签,那就是泄漏; 如果它是独立测量的另一个物理量,那就是强信号。
★ 一个补充练习(想深入可以做)
# 验证"最大 |r| 只有 0.9683"这件事和"R² 上不了 0.999"是同一件事的两面
print(f"7 特征线性回归的整表 R² 实测 = 0.94321")
print(f"→ 单列最高 |r|=0.97,多列组合也只能到 0.94~0.96")
print(f"→ 因为数据里有【每行独立】的测量噪声,任何模型都消不掉")这个数字(0.96 附近)会在第 23 章的四路横评里再次出现 —— 那时你会知道它是数据的上限,不是模型的问题。
面向工程实践:把"切 X / y"变成一个可复用的函数
这一章的动作看起来很简单(两行代码),但它是数据管道里最容易被草率对待的一步。
① 为什么值得写成一个函数
❌ 反面:每个脚本都手写一遍
notebook1.py: X = df[["cycle","resistance"]]; y = df["soh"]
notebook2.py: X = df[["cycle","resistance","temp"]]; y = df["soh"]
train.py: X = df[["cycle","resistance"]]; y = df["SOH"] ← 大小写错了!
↓
三个地方三套写法 → 无法保证一致 → 结果对不上✅ 正面:一个函数,一处定义
from src.data import make_xy
X, y = make_xy(df, FEATS, TARGET) # 所有脚本都调它
↓
改特征只改一处② 真实项目里的 make_xy 会做更多事
def make_xy(df, feats, target, id_cols=("cell_id",), drop_na=True):
"""生产级的 X/y 切分。"""
# ① 校验(就是练习 2 的三道断言)
assert len(feats) == len(set(feats))
assert not set(feats) & (set(id_cols) | {target})
# ② 缺数据的处理策略要明确
if drop_na:
before = len(df)
df = df.dropna(subset=list(feats) + [target])
print(f"[make_xy] dropna 丢掉 {before - len(df)} 行")
# ③ 返回时把分组列也带上(第 09 章要用!)
groups = df[id_cols[0]] if id_cols else None
return df[list(feats)], df[target], groups★ 第 ③ 点是这一章埋给第 09 章的伏笔:
做分组划分需要 groups(哪一行属于哪支设备)
↓
如果 make_xy 只返回 X 和 y,你就得回去重新对齐索引
↓
如果一开始就返回 groups,后面省很多事③ 一个"数据契约"的思路
在生产项目里,通常会显式写一份"数据契约":
# src/schema.py
ID_COLS = ("cell_id",)
TARGET = "soh"
FEATS = (
"cycle",
"mean_charge_voltage_v",
"mean_temp_rise_c",
"resistance_mohm",
"coulombic_eff",
"cap_fade_rate_ah_per_100",
"temp_std_c",
)
FEATURE_RANGES = { # 用于数据校验
"cycle": (50, 1600),
"resistance_mohm": (30, 200),
"coulombic_eff": (0.9, 1.0),
}好处:
① 特征列表是【唯一来源】→ 训练和推理不会用不同的特征
② 有了 RANGE,可以在数据入库时校验(越界就报警)
③ 文档和代码合一(不用维护两份)★ 这一章教的东西,实际是"数据契约的第一步"。
想清楚"哪些列是特征、哪一列是标签、哪些列是身份", 是任何建模项目的第一个决定 —— 而且很难改。
改特征要重训模型、重跑评估、重写报告。 所以在项目早期把这份契约定清楚,收益极大。