Skip to content

第 04 章 · 特征与标签:把一张表切成 X / y ​

这一章要解决什么:把一张 9 列的表切成"喂给模型的 X"和"要预测的 y"。 听起来是全书最简单的一步,但四个最常见的错误全在这里。

最容易踩的坑:把标签留在特征里 → R² = 1.000000,而且是"正确的"结果 —— 模型只是学会了"把 soh 乘以 1"。

本章你需要完成的代码:demos/ch04-features-labels.py

4.1 两句话把这件事说完 ​

python
X = df[FEATS]        # 特征:用来预测的依据(一张表)
y = df[TARGET]       # 标签:要预测的答案(一列)

就这两行。整本书都是它的变体。

名字别名是什么形状
X特征矩阵、自变量、输入一张表(样本数, 特征数) 二维
y标签、目标、因变量、输出一列(样本数,) 一维

真实输出:

text
  整表 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(一点历史)

这是数学记号的遗留。

在统计学里,线性模型写成:

text
y = Xβ + ε
  • X:自变量矩阵(大写的矩阵)
  • y:因变量向量(小写的列向量)
  • β:系数
  • ε:误差

scikit-learn 沿用了这套记号:

python
model.fit(X, y)          # 学 β
model.predict(X_new)     # 算 X_new · β
model.coef_              # 就是 β

所以你看 sklearn 的文档时会有这些规律:

记号含义
大写 X二维数组(样本 × 特征)
小写 y一维数组(样本)
带下划线结尾的属性(coef_、intercept_)训练后才有,训练前访问会报错

★ 最后一条是新手常踩的坑:

python
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 —— 没有下划线, 因为它是你设定的,不是学出来的。)

这个约定在后面的章节很有用:

python
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 写成一维 ​

python
LinearRegression().fit(df["cycle"], df["soh"])

真实输出:

text
  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 里通用,值得记牢:

python
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" → 就是这个问题。

★ 修复的三种写法:

python
# ① 列表(推荐,最直观)
X = df[["cycle"]]

# ② to_frame()
X = df["cycle"].to_frame()

# ③ reshape(numpy 场景)
X = df["cycle"].to_numpy().reshape(-1, 1)

① 最好 —— 它保留了列名,这在后面很重要:

python
# 有列名时
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 章会看到):

python
pipe.named_steps["model"].coef_     # 一个数组,没有列名
# 但如果特征有列名,可以这样配对:
dict(zip(FEATS, model.coef_))       # {'cycle': -0.000146, ...}   ← 可读多了

4.4 坑二:把标识列当特征 ​

python
LinearRegression().fit(df[["cell_id", "cycle"]], df["soh"])

真实输出:

text
  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

python
print(df.dtypes)
text
cell_id                         str        ← 字符串,可疑!
cycle                         int64       ✅ 数值
mean_charge_voltage_v       float64       ✅
...

凡是 object 或 str 类型的列,都不该直接当特征。

方法二:在代码里显式区分

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

为什么推荐这种写法:它是白名单的反面 —— 黑名单加自动推导。

优点:加新列时会自动进入特征(不容易漏)
缺点:如果不小心加了一列"未来信息",它也会自动进去

更安全的写法(白名单):

python
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c",
         "resistance_mohm", "coulombic_eff",
         "cap_fade_rate_ah_per_100", "temp_std_c"]
优点:明确,加列必须显式声明(不容易误入)
缺点:漏写一列就白丢信息

★ 用哪个?

场景用哪个理由
探索阶段黑名单(自动推导)方便,快速看全貌
正式建模白名单安全,不容易混入脏列
生产代码白名单 + 断言最安全

加一个断言会更稳:

python
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 ★ 坑三:把标签留在特征里(最纯粹的特征泄漏) ​

python
leak = LinearRegression().fit(df[["cycle", "soh"]], df["soh"])

真实输出:

text
  用 [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 这一列?"

第 ④ 条是要害:

标签的定义就是"要预测的东西"。你在预测时刻【不可能】知道它。 所以任何"用了标签"的特征,都是作弊。

点开:三种特征泄漏(按隐蔽程度排序)

① 标签本身在特征里(最明显)

python
FEATS = ["cycle", "soh"]                 # ❌ 标签在里面

识别:R² = 1.000000 或极接近 1。

② 标签的计算来源在特征里(较隐蔽)

python
# 假设表里同时有"实测容量"和"健康度"
# 而 健康度 = 实测容量 / 额定容量
FEATS = ["capacity_ah", "cycle"]          # ❌ capacity_ah 算出 soh

识别:某个特征与标签的相关系数 |r| > 0.99。

这一类比第一类隐蔽得多 —— 因为特征名字不叫 soh, 你看列名不会觉得有问题,要知道它的物理含义才能发现。

验证方法:

python
corr = df.select_dtypes("number").corr()["soh"].drop("soh")
print(corr.sort_values(key=abs, ascending=False).round(4))
text
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 → 留意,但通常是真的强信号

③ 时间穿越式泄漏(最隐蔽)

python
# 用"整个时间段的表现"预测"某个时刻的状态"
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 数组 ​

python
model = LinearRegression().fit(df[FEATS], df[TARGET])
model.predict(np.array([[500.0]]))

真实输出:

text
  UserWarning: X does not have valid feature names, but LinearRegression was fitted with feature names…
  → 结果对,但列名丢了。
    改用 pd.DataFrame({'cycle': [500.0]}) 就没有警告。

这只是一个警告,结果是对的。 但值得处理,因为:

① 警告会淹没真正重要的警告(噪声)
② 列名丢失后,你无法察觉"特征顺序错了"

★ 第 ② 条才是真正的风险:

python
FEATS = ["cycle", "resistance_mohm"]        # 训练时的顺序
model.fit(df[FEATS], y)

# 预测时手滑写反了顺序
model.predict(np.array([[50.0, 500.0]]))    # ❌ 把 cycle 填成了 resistance 的位置
                                            #    只给一个警告,不会报错!

如果传 DataFrame:

python
model.predict(pd.DataFrame({"resistance_mohm": [50.0], "cycle": [500.0]}))
# → 如果模型训练时没有 "resistance_mohm" 这一列... 不会报错,
#   但如果列名齐全,sklearn 会【按列名对齐】,顺序无关 ✅

这是 DataFrame 最实用的一个好处:按名字对齐,而不是按位置。

点开:画预测曲线时的正确写法(这一段后面每章都要用)

场景:画一条"预测 vs 循环次数"的曲线,需要造一批网格点。

❌ 错误写法:

python
grid_x = np.linspace(0, 1500, 300)
model.predict(grid_x)                      # ❌ 一维,直接报错
model.predict(grid_x.reshape(-1, 1))       # ⚠️ 能跑,但有警告

✅ 正确写法:

python
grid = pd.DataFrame({"cycle": np.linspace(0, 1500, 300)})
y_grid = model.predict(grid)

为什么 pd.DataFrame({"cycle": ...}) 是标准写法:

① 列名对上了训练时的列名 → 没有警告
② 特征多的时候,能按名字对齐 → 不怕顺序错
③ 一眼看出"这是 cycle 轴上的预测" → 可读

★ 多特征时的网格构造

如果要在一个二维平面上画预测(比如 cycle × temperature), 可以用 itertools.product:

python
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 会按名字对齐, 但保持一致更不容易出错)。

★ 这个写法的一个额外好处:能发现"特征名写错"

python
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 常见坑 ​

text
① X 用一维 Series       → ValueError: Expected 2D array,改用 df[["col"]]
② cell_id 当特征        → 字符串报错;如果是数字会更糟(不报错但结果错)
③ 标签留在 X 里         → R² = 1.000000,模型学会了"把 soh 乘 1"
④ predict 传 numpy 数组 → 只是警告,但列名丢失会掩盖"特征顺序错"
⑤ 用事后聚合的特征      → 时间穿越泄漏,最隐蔽的一类

★ 这五个坑的共同点

①②④  →  技术性错误,通常会报错或警告
③⑤    →  【逻辑性错误,往往不报错,而且分数很漂亮】

真正危险的是后两个。 第 09~10 章会专门讲怎么系统性地发现它们。

4.8 完整示例与输出 ​

bash
cd code
python demos/ch04-features-labels.py

真实输出(节选):

text
=== ① 正确的切法 ===
  整表 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:亲手制造标签泄漏,并解释它
  1. 用 [cycle, soh] 当作特征拟合一个线性回归,打印 R² 和 coef_;
  2. 把 soh 从特征里去掉,重新拟合,打印 R²;
  3. 解释 coef_ 里的两个数分别意味着什么。
参考答案
python
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}")

期望输出:

text
[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),并加三道断言:

  1. feats 里没有重复;
  2. feats 里没有混进 target 或身份列;
  3. feats 里的列都是数值类型。
参考答案
python
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}")

期望输出:

text
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']

★ 为什么"断言"值得写

看这三个断言分别能挡住什么:

python
# 断言一挡住:手抄特征列表时写重了
FEATS = ["cycle", "cycle", "resistance_mohm"]        # → AssertionError

# 断言二挡住:这是最值钱的一个
FEATS = ["cycle", "soh"]                             # → AssertionError
FEATS = ["cell_id", "cycle"]                         # → AssertionError

# 断言三挡住:表里混了字符串列
FEATS = ["cycle", "timestamp"]                       # → AssertionError

★ 断言和报错的差别

报错(第 4.3 节的 ValueError):
  你写错了,sklearn 告诉你 —— 但你要【跑起来才知道】

断言:
  你写错了,【在你自己的代码里立刻】告诉你 —— 而且信息更清楚

对比两种信息的可读性:

text
# sklearn 的报错(不知道你为什么要传这一列)
ValueError: could not convert string to float: 'C001'

# 你的断言(直接指出问题所在)
AssertionError: 特征里混进了身份列或标签:{'soh'}

★ 一条工程经验

断言不是"防御性编程的洁癖",它是"把调试信息提前"。

在数据管道里,越早发现错误,排查成本越低:

在特征构造处发现    →  1 分钟
在训练时报错        →  10 分钟
在评估时发现分数异常 →  半天
在上线后才发现      →  几天,而且可能已经造成损失

所以: 关键假设就写成断言。** 这一条会在这本书后面的章节反复出现。

练习 3:找出这份数据里的"可疑特征"

计算所有数值列与 soh 的相关系数,按绝对值排序,然后回答:

  1. 有没有 |r| > 0.99 的列?如果有,它可疑吗?
  2. 最高的是哪一列,它的物理含义是什么?
  3. 这份数据里没有标签泄漏,你能从哪个数字上看出来?
参考答案
python
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())}")

期望输出:

text
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
   (因为测量有噪声、关系不是完美线性)

★ 但阈值只是"提示去查",不是"判定"

python
if corr.abs().max() > 0.99:
    print("⚠️ 有列 |r| > 0.99 → 逐个检查它的物理定义")
else:
    print("✅ 没有明显可疑的高相关列")

真正的判定要靠"看物理含义" —— 一个 r = 0.998 的特征,如果它的定义里含标签,那就是泄漏; 如果它是独立测量的另一个物理量,那就是强信号。

★ 一个补充练习(想深入可以做)

python
# 验证"最大 |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 会做更多事

python
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,后面省很多事

③ 一个"数据契约"的思路

在生产项目里,通常会显式写一份"数据契约":

python
# 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,可以在数据入库时校验(越界就报警)
③ 文档和代码合一(不用维护两份)

★ 这一章教的东西,实际是"数据契约的第一步"。

想清楚"哪些列是特征、哪一列是标签、哪些列是身份", 是任何建模项目的第一个决定 —— 而且很难改。

改特征要重训模型、重跑评估、重写报告。 所以在项目早期把这份契约定清楚,收益极大。


下一章:第 05 章 · 表格数据的形状:为什么 X 必须是二维的