第 04 章 · 信息结构:模型只看到一行
这一章要解决什么:为什么"多凑几列读数"没用,而"历史"有用? 答案不在于列数,而在于模型能看到什么。
最容易踩的坑:以为模型"知道"行的先后顺序。 它不知道 —— 时间信息不编码成列,对模型就等于不存在。
本章你需要完成的代码:demos/ch04-information-structure.py
4.1 模型的眼睛里只有一行
先看一个直接实验。
真实输出:
=== ① 模型把每一行当成独立的一行 ===
打乱测试集行顺序:逐行预测最大差 = 0.00e+00
测试 R² = 0.95879(不变)★ 打乱测试集的行顺序,逐行预测一个字节都不变。
含义:
每一行都是【独立】送进模型的,谁在谁前面完全不影响它的输出。
↓
模型不知道"这一行是这支设备的第几行",也不知道"上一行是什么"
↓
【时间信息必须显式编码成列,模型才能利用它。】★ 这是这一章的第一个核心认知:
表格模型(回归/树/森林/提升)看到的是一个"行的集合",不是一个"序列"。
它和序列模型的区别:
| 表格模型(本书) | 序列模型(RNN / Transformer) | |
|---|---|---|
| 输入 | 一堆独立的行 | 有序序列 |
| 知道先后吗 | ❌ 不知道 | ✅ 知道 |
| 要用时间信息 | 必须手动做成列 | 可以从结构里学 |
所以"特征工程"在表格数据上不是可选项 —— 它承担了"把序列变成行内信息"的职责。
点开:一个反直觉的细节 —— 打乱【训练集】的顺序,分数居然会变
真实输出:
打乱训练集行顺序:测试 R² = 0.95956 (未打乱 0.95879,差 0.00076)为什么?按刚才的说法,模型不是不关心行顺序吗?
关键在随机森林的 bootstrap 抽样方式:
# sklearn 的随机森林:每棵树有放回地抽 n 个样本
# random_state 决定"抽【第几行】",不是"抽【哪一行】"
indices = rng.randint(0, n_samples, n_samples)所以:
行顺序变了 → 同一组位置下标指向的样本变了 → 每棵树拿到的样本组合变了
↓
模型变了 → 分数变一点点★ 准确的表述是:
| 打乱什么 | 逐行预测 | 说明 |
|---|---|---|
| 测试集行顺序 | 完全相同(0.00e+00) | 预测是逐行独立的 |
| 训练集行顺序 | 略有不同(差 0.00076) | bootstrap 按位置抽样,样本组合变了 |
这个差别很重要:
前者说明"模型不感知顺序"(本章的论点)
后者说明"模型对训练样本的组合敏感"(这是随机性,不是顺序信息)
↓
【两件事不能混为一谈】★ 一个实用的推论
既然"打乱训练集"就会让分数变 0.0008,那么:
两份实验如果只差"数据读入的顺序",分数就可能差千分之几
↓
所以"两个配置分数差 0.0008"这种量级的比较【没有意义】
↓
要比较,必须固定 random_state,并且用同一份数据、同一顺序
↓
【这也解释了为什么本书所有对照实验都复用同一组 tr/te 索引】★ 顺带一个"错误对照"的教材
我自己在写这一章时犯过这个错:
# ❌ 用原来那组整数下标,去测"行被重新排列过"的表
bad = r2_score(sub_shuffled["soh"].iloc[te], model.predict(sub_shuffled[FEATS].iloc[te]))
# 得到 0.99072 —— 而正确做法是 0.95879,差了 0.032模型的代码一个字没改,分数却差了 0.032 —— 因为打乱之后,同一组整数下标指向的行已经不同了。
这正是第 07 章的纪律:
做对照实验时,两边比的必须是同一批行, 否则 Δ 分数里混进的是"Δ 样本",不是"Δ 特征"。
4.2 没有历史时,相邻两行几乎无法区分
真实输出:
=== ② 没有历史列时,同一支设备的相邻两行几乎无法区分 ===
cell_id cycle soh mean_charge_voltage_v mean_temp_rise_c resistance_mohm
C001 114 0.9800 3.7586 8.0985 45.0266
C001 116 0.9798 3.7525 8.1202 45.4253
C001 120 0.9787 3.7620 7.6922 45.4291看第 2、3 行:
cycle 差 4;soh 差 0.0011
↓
7 个基础特征里只有 cycle 变了,其余几乎完全一样
↓
【模型看到两行几乎一模一样的输入,却要给出不同的输出】★ 这不是个例,而是全表现象
真实输出:
特征 相邻行平均差 全表跨度 占比
cycle 2.998745 1326.0000 0.23%
mean_charge_voltage_v 0.008921 0.4497 1.98%
mean_temp_rise_c 0.507495 23.3180 2.18%
resistance_mohm 1.142040 53.0424 2.15%
coulombic_eff 0.000170 0.0043 3.96%
cap_fade_rate_ah_per_100 0.006241 0.0953 6.55%
temp_std_c 0.007895 0.7486 1.05%★ 相邻两行在每一个基础特征上都只差"全表跨度的千分之几到百分之几"。
这就是"信息不足"的最直接证据:
同一个设备的相邻两行,输入几乎重合
↓
模型没有任何可用来区分它们的输入
↓
它只能给出接近的输出
↓
但如果标签(soh)在相邻两行确实不同,模型就必然犯错★ 换个角度看这张表:
"相邻行平均差 / 全表跨度" 这个比值,
衡量的是"相邻样本之间的可区分度"
↓
比值越小 → 相邻样本越像 → 模型越难区分 → 需要历史信息的程度越高
↓
本数据这个比值在 0.23% ~ 6.55% 之间★ 一个可以搬走的诊断指标
def adjacent_separability(df, feats, entity_col, time_col):
"""相邻样本的可区分度:越小越说明"需要历史信息"。"""
d = df.sort_values([entity_col, time_col])
adj = d.groupby(entity_col)[feats].diff().abs().mean()
span = d[feats].max() - d[feats].min()
return (adj / span).sort_values()
# 判读:
# < 5% → 相邻样本高度相似,横截面特征很可能不够用
# > 20% → 相邻样本差异明显,横截面特征可能够用在本数据上跑这个函数,7 列全部 < 7% —— 所以横截面特征必然不够。
4.3 加上历史,同一行立刻变得可区分
真实输出:
=== ③ 把标签的历史值喂进去,同一行立刻变得可区分 ===
特征集 特征数 测试 R²
基础 7 列(横截面) 7 0.95879
基础 + 窗口/差分/极差(17 列) 17 0.95930
基础 + 历史 SOH(11 列) 11 0.99904
全部安全特征(21 列) 21 0.99903★ 这张表是本书最重要的一张表之一。逐行读:
| 特征集 | 列数 | 测试 R² | 关键 |
|---|---|---|---|
| 基础 7 列 | 7 | 0.95879 | 横截面基线 |
| + 窗口/差分/极差 | 17 | 0.95930 | 加 10 列,只涨 0.00051 |
| + 历史 SOH | 11 | 0.99904 | 只加 4 列,涨 0.03974 |
| 全部安全特征 | 21 | 0.99903 | 再加 10 列窗口特征,反而略降 0.00001 |
★ 最刺眼的一行是第三行:
"基础 + 历史 SOH" 只有 11 列,却拿到了 0.99904
"全部安全特征" 有 21 列,是 0.99903
↓
【多出来的那 10 列窗口/差分特征,一列都没用】为什么历史 SOH 这么有用?
相邻两行在基础特征上几乎重合(第 4.2 节)
↓
但它们的【历史】明确不同:
一行是"从 0.9987 走到 0.9800"
另一行是"从 0.9800 走到 0.9798"
↓
历史值给了模型一个【锚点】:它知道这支设备之前在哪
↓
【信息结构改变了:从"看一个点"变成"看一条轨迹"】★ 这个改变的准确描述
横截面建模: f(这个窗口的读数) → soh
轨迹建模: f(这个窗口的读数, 这支设备的历史 soh) → soh
↑
新增的那一项才是关键为什么它天然有用?
第 01 章讲过数据的两层误差:
① 每支设备的固定偏置(传感器零点偏移)
② 每一行的随机噪声
↓
横截面特征里【含偏置】,但模型无法知道偏置是多少
↓
而"这支设备自己的历史 SOH"已经【吸收了它的偏置】
↓
拿它当特征,等价于告诉模型:"这支设备的基线在这一带"
↓
【偏置被抵消了,剩下的差异才是真正的退化信号】这也解释了第 10 章那条判据的由来:
预测这一刻拿得到的、属于"这支设备自己"的历史信息,才是最有价值的信息。
4.4 一行数据到底能看到什么
真实输出:
=== ④ 一行数据到底能看到什么 ===
这一行:设备 C001,第 114 圈,soh = 0.9800
横截面特征(描述【现在】):
cycle 114.0000
mean_charge_voltage_v 3.7586
mean_temp_rise_c 8.0985
resistance_mohm 45.0266
历史特征(描述【过去】):
soh_lag1 0.9814
soh_lag5 0.9841
soh_win_mean5 0.9823
soh_win_std5 0.0021★ 两句话概括两类特征
横截面特征回答:"这个读数说明它现在多健康?"
历史特征回答 :"从它走过的地方看,现在应该在哪?"★ 这两类特征在"能不能外推"上差别很大
| 横截面特征 | 历史特征 | |
|---|---|---|
| 依据 | 这一行的读数 | 这支设备自己的过去 |
| 个体偏置 | 含在读数里,无法分离 | 已被历史值吸收 |
| 换个设备 | 读数分布可能不同 → 失效 | 自适应(每支设备用自己的历史) |
| 换一批工况 | 读数名义值会变 → 可能失效 | 相对变化,鲁棒性更好 |
★ 一个重要的推论
历史特征之所以"抗个体差异",是因为它做了一次【隐含的归一化】:
不是"电压是多少",而是"电压相对它自己的历史变了多少"
↓
这就是第 15 章"相对自身基线"那类特征的原理
↓
【把"绝对值"换成"相对值",是特征工程最常用也最有效的一招】4.5 常见坑
① 以为模型知道行的先后顺序 → 它把每行当独立样本,时间必须显式编码成列
② 用"多加几列读数"来代替"加历史信息" → 相邻行读数是重合的,加多少都没用
③ 打乱数据后复用原来的行下标 → 测得的是"Δ 样本"不是"Δ 特征"(本章 4.1 节的教材)
④ 比较"差 0.0008"这种量级的分数 → 打乱一次训练集就可能差这么多,那是噪声
⑤ 把"历史特征有用"理解成"标签的历史值有用" → 见下
⑥ 忘了历史特征需要"上线时拿得到" → 第 16 章的账本专门管这件事★ 关于 ⑤ —— 这一条特别重要
本章的"历史 SOH"用的是【标签的历史值】
↓
它的确最有效,但它有一个前提:上线时拿得到历史实测标签
↓
如果不满足,就得退而求其次:
用【其他可观测量的历史】代替
(历史电压、历史温度、历史内阻…)
↓
它们的有效性会低一些,但原理相同:提供"这支设备自己的基线"这个取舍在第 16 章会量化(⚠️ 那 4 列就是它)。
4.6 完整示例与输出
cd code
python demos/ch04-information-structure.py真实输出(节选):
可用样本 19,979 行 / 60 支设备
=== ① 模型把每一行当成独立的一行 ===
打乱测试集行顺序:逐行预测最大差 = 0.00e+00
测试 R² = 0.95879(不变)
打乱训练集行顺序:测试 R² = 0.95956 (未打乱 0.95879,差 0.00076)
=== ③ 把标签的历史值喂进去,同一行立刻变得可区分 ===
基础 7 列(横截面) 7 0.95879
基础 + 窗口/差分/极差(17 列) 17 0.95930
基础 + 历史 SOH(11 列) 11 0.99904
全部安全特征(21 列) 21 0.99903本章练习
练习 1:证明模型不感知行顺序
用任意一个模型(线性回归或随机森林),证明"打乱测试集行顺序不影响逐行预测"。
要求:
- 训练一个模型;
- 把同一批测试行按两种不同顺序送入,比较逐行预测;
- 报告最大差,并解释为什么应该是 0。
参考答案
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
v2 = pd.read_csv("data/battery/soh_features_v2.csv")
BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(v2[BASE], v2["soh"], groups=v2["cell_id"]))
m = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=1)
m.fit(v2[BASE].iloc[tr], v2["soh"].iloc[tr])
Xte, yte = v2[BASE].iloc[te], v2["soh"].iloc[te]
pred_a = m.predict(Xte)
rng = np.random.default_rng(0)
perm = rng.permutation(len(Xte))
pred_b = m.predict(Xte.iloc[perm])
# 关键:要把 pred_b 还原回原来的行顺序才能逐行比较
pred_b_back = np.empty_like(pred_b)
pred_b_back[perm] = pred_b
print(f"测试行数 = {len(Xte):,d}")
print(f"逐行预测最大差 = {np.abs(pred_a - pred_b_back).max():.2e}")
print(f"R²:{r2_score(yte, pred_a):.5f} vs {r2_score(yte, pred_b):.5f}")期望输出:
测试行数 = 3,995
逐行预测最大差 = 0.00e+00
R²:0.96487 vs 0.96487★ ③ 为什么必须是 0
预测是【逐行独立】计算的:
对第 i 行,模型只看 x_i,输出 ŷ_i
↓
它不读 x_{i-1},也不读 x_{i+1}
↓
所以行的排列顺序对 ŷ_i 没有任何影响
↓
【最大差必然是 0(浮点上也完全相同,因为计算路径一模一样)】★ 一个容易写错的地方
# ❌ 直接比较 pred_a 和 pred_b —— 它们对应不同的行!
np.abs(pred_a - pred_b).max() # 会得到一个很大的假差异
# ❌ 直接对 pred_b 算 R² —— 行与标签错位了,会算出负 R²(比如 -0.967)
r2_score(yte, pred_b)
# ✅ 先把 pred_b 按 perm 还原回原顺序
pred_b_back = np.empty_like(pred_b)
pred_b_back[perm] = pred_b
np.abs(pred_a - pred_b_back).max() # 这才是 0
r2_score(yte, pred_b_back) # 这才是 0.96487★ 那个 R² = -0.967 特别值得看一眼:
把打乱顺序的预测直接和原来的标签算 R² → 得到一个很大的负数
↓
模型没有任何问题,是【对齐】错了
↓
同样的坑还有一个更隐蔽的形式:**
把预测和标签各自排序后再算 R² → 会得到一个虚高的分数
(因为排序抹掉了"哪一个预测对应哪一个标签"这个信息)
↓
【算任何指标之前,先确认"行对齐"】—— 这和第 07 章的纪律是同一条这个错误和本章 4.1 节那个"错误对照"是同一类 —— 比较两份结果时,必须先确认"第 i 个元素对应的是同一行"。
★ 把它推广成一条检查习惯
def compare_rowwise(a, b, key_a, key_b):
"""比较两组逐行结果前,先确认行对齐。"""
assert key_a.equals(key_b), "两边的行不是同一批,无法逐行比较!"
return np.abs(a - b).max()练习 2:测出"相邻可区分度"
写一个函数,算每个特征在"相邻行平均差 / 全表跨度"上的比值,并对 7 个基础特征排序。
要求:解释这个比值小于 5% 意味着什么。
参考答案
import pandas as pd
v2 = pd.read_csv("data/battery/soh_features_v2.csv")
BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
def adjacent_separability(df, feats, entity="cell_id", time="cycle"):
"""相邻样本的可区分度 = 相邻行平均差 / 全表跨度。"""
d = df.sort_values([entity, time])
adj = d.groupby(entity)[feats].diff().abs().mean()
span = d[feats].max() - d[feats].min()
return (adj / span).sort_values()
ratio = adjacent_separability(v2, BASE)
print(f"{'特征':<28}{'可区分度':>10}")
for c, v in ratio.items():
print(f"{c:<28}{v:>10.4f}{' ⚠️ <5%' if v < 0.05 else ''}")
print(f"\n全部 < 5% 吗? {bool((ratio < 0.05).all())}")期望输出:
特征 可区分度
cycle 0.0022 ⚠️ <5%
temp_std_c 0.0104 ⚠️ <5%
mean_charge_voltage_v 0.0193 ⚠️ <5%
resistance_mohm 0.0212 ⚠️ <5%
mean_temp_rise_c 0.0217 ⚠️ <5%
coulombic_eff 0.0370 ⚠️ <5%
cap_fade_rate_ah_per_100 0.0616
全部 < 5% 吗? False★ 判读:比值小于 5% 意味着什么
它意味着:同一支设备的相邻两行,在这个特征上只差【全表跨度的 5% 以下】
↓
也就是"几乎无法区分"
↓
如果标签在相邻两行确实不同,模型没有输入可以用来分辨
↓
【这个特征无法单独支撑"逐点预测"】★ 但要注意:一个特征的可区分度低,不代表它没用
7 个特征里 6 个都 < 5%
↓
但"基础 7 列"合起来仍有 0.95879 的 R² ——
它们【联合起来】还是有信息的
↓
【可区分度低说明的是"单个特征分辨力弱",
不是"这一列该删"】★★ 这个指标的真正用途:判断"要不要做历史特征"
如果一个数据集里,所有特征的可区分度都 < 5%
↓
→ 强烈提示"横截面信息不足,需要历史特征"
↓
如果有一批特征的可区分度 > 20%
↓
→ 横截面可能够用,先试试不加历史
↓
【它是一个"早期决策指标",在动手造特征之前就能算出来】★ 还可以按设备看
# 哪支设备的相邻可区分度最低?(最难预测的设备)
per_device = v2.groupby("cell_id")[BASE].apply(
lambda d: (d.diff().abs().mean() / (v2[BASE].max() - v2[BASE].min())).mean())
print(per_device.sort_values().head(3))这个名单和第 12 章"最差的那支设备"(R² 0.816)通常高度重合 —— 因为"相邻样本最难区分"的设备,就是模型最难学的那一支。
练习 3:设计一个"相对自身基线"的特征
本章 4.4 节说:历史特征之所以抗个体差异,是因为它做了一次隐含的归一化。
请为 resistance_mohm(内阻)设计一个"相对自身基线"的特征,并验证它是否比原始值有用。
要求:
- 构造"相对基线"特征(提示:用这支设备早期的内阻作为基线);
- 在同一划分下比较"原始值" vs "相对值"的测试 R²;
- 说明构造这个特征时要注意什么(提示:预测时刻可得性)。
参考答案
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
v2 = pd.read_csv("data/battery/soh_features_v2.csv").sort_values(["cell_id", "cycle"])
BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
# ① 构造"相对自身基线"的内阻
# 基线 = 这支设备【第一个窗口】的内阻(只用历史!)
g = v2.groupby("cell_id")["resistance_mohm"]
v2["res_base"] = g.transform("first") # 该设备第一个窗口的值
v2["res_rel"] = v2["resistance_mohm"] / v2["res_base"]
sub = v2[BASE + ["res_rel"]].notna().all(axis=1)
sub = v2[sub].reset_index(drop=True)
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
.split(sub[BASE], sub["soh"], groups=sub["cell_id"]))
def score(feats):
m = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=1)
m.fit(sub[feats].iloc[tr], sub["soh"].iloc[tr])
return r2_score(sub["soh"].iloc[te], m.predict(sub[feats].iloc[te]))
raw = score(BASE)
with_rel = score(BASE + ["res_rel"])
no_raw = score([c for c in BASE if c != "resistance_mohm"] + ["res_rel"])
print(f" 基础 7 列 R² = {raw:.5f}")
print(f" 基础 + 相对内阻(8 列) R² = {with_rel:.5f} Δ = {with_rel - raw:+.5f}")
print(f" 去掉原始内阻,换成相对内阻(7 列) R² = {no_raw:.5f} Δ = {no_raw - raw:+.5f}")期望输出:
基础 7 列 R² = 0.96487
基础 + 相对内阻(8 列) R² = 0.95890 Δ = -0.00597
去掉原始内阻,换成相对内阻(7 列) R² = 0.95988 Δ = -0.00499★ ② 结果出乎意料:加了一列,分数反而【降了 0.006】
而且降的原因很值得研究:
r(resistance_mohm, soh) = -0.8784 ← 原始内阻
r(res_rel_first, soh) = -0.9080 ← 相对内阻【相关性更高】!
r(resistance_mohm, res_rel_first) = +0.9412 ← 但两者高度冗余
结果:加进模型 → 0.96487 降到 0.95890★★ 这是本章最有价值的观察:
单列相关性【更高】的特征,加进模型后可能让分数【下降】。
为什么?
相对内阻 = 原始内阻 ÷ 常数(每支设备一个常数)
↓
它和原始内阻【几乎是同一个信号】(r = 0.9412)
↓
模型本来已经把信号用原始内阻表达出来了
↓
现在多了一列"几乎相同但略有不同"的特征
↓
对随机森林来说:多了一个【分裂候选】,
而它的排序和原始内阻不完全一致
↓
于是有些树选了这一列、有些选了那一列 → 平均之后反而不如只用一列
↓
【冗余特征不会增加信息,只会增加"选择的随机性"】★ 这与第 15 章的消融结论是同一件事的两面
第 15 章:删掉冗余列,分数【不掉】(Δ = +0.00000)
本章 :加上冗余列,分数【下降】(Δ = -0.00597)
↓
两个方向都说明:【冗余列没有价值,而且有害】★ ④ 那什么时候"相对化"才有用?
它有效的前提是:【你要消掉的那个干扰,在变异里占比够大】
↓
· 历史 SOH 有效(+0.0397):因为"设备间差异"是主要干扰
· 相对内阻无效(-0.0060):因为内阻的"真实退化"本身就是主要变异
(第 01 章讲过:内阻从 34 涨到 88,而设备间偏置只有 N(0, 2.5²))
↓
【不要机械地对每一列都做"相对化" —— 先看干扰占比】★ ⑤ 构造这个特征时必须注意两件事
第一:基线只能用历史
# ❌ 用了全量(包括未来)—— 这就是特征泄漏
v2["res_base_bad"] = g.transform("mean") # 整支设备的平均内阻
# ✅ 只用第一个窗口(预测时刻一定拿得到)
v2["res_base"] = g.transform("first")但 "first" 也有问题:
如果预测的是第 300 圈,那么"第 1 圈的内阻"确实是历史
↓
但如果预测的是第 1 圈呢?基线就是它自己 → 特征恒等于 1,没有信息
↓
更严谨的做法是"截至当前的滚动基线"第二:分母不能为 0 或极小
v2["res_rel"] = v2["resistance_mohm"] / v2["res_base"].clip(lower=1.0)★ ③ 构造这个特征时必须注意两件事
第一:基线只能用历史
# ❌ 用了全量(包括未来)—— 这就是特征泄漏
v2["res_base_bad"] = g.transform("mean") # 整支设备的平均内阻
# ✅ 只用第一个窗口(预测时刻一定拿得到)
v2["res_base"] = g.transform("first")但"first"也有问题:
如果预测的是"第 300 圈",那么"第 1 圈的内阻"确实是历史
↓
但如果预测的是"第 1 圈"呢?基线就是它自己 → 特征恒等于 1,没有信息
↓
更严谨的做法是"截至当前的滚动基线":
res_base_t = 该设备【前 k 个窗口】的内阻均值(不含当前)第二:分母不能为 0 或极小
# 防护:分母有下限,避免比值爆掉
v2["res_rel"] = v2["resistance_mohm"] / v2["res_base"].clip(lower=1.0)★★ 一个更好的版本(滚动基线)
v2["res_base_roll"] = (g.transform(lambda s: s.shift(1).rolling(20, min_periods=5).mean()))
v2["res_rel_roll"] = v2["resistance_mohm"] / v2["res_base_roll"]它同时满足两个要求:
① 只用历史(shift(1) + rolling)
② 随着数据积累自适应(不是一直用第 1 圈)★ 这个"滚动基线 + 相对值"模式,是时序特征工程里最通用的模板之一 —— 第 05~09 章的窗口特征本质上都是它的变体。
面向工程实践:先问"模型能看到什么",再问"加什么特征"
① 一个常见的返工场景
模型效果不好 → 团队开始加特征
↓
加了几十列(各种比值、平方、交叉项)
↓
分数几乎没变
↓
【因为加的全是"同一行的读数变换",而瓶颈在"缺少历史"】★ 这一章给的判断方法:
# 先算"相邻可区分度"(练习 2 的函数)
ratio = adjacent_separability(df, feats, "entity_id", "time")
# 如果绝大多数特征的可区分度 < 5%
if (ratio < 0.05).mean() > 0.8:
print("⚠️ 横截面特征大概率不够 —— 优先做历史特征,不要做读数变换")
else:
print("✅ 横行面信息可能够用,可以试试特征变换/交叉项")★ 半天就能算出来,避免两周的无效工作。
② 三类特征的正确优先级
第一优先:历史标签(如果上线拿得到)
→ 收益最大(本数据 +0.0397)
→ 前提:第 16 章账本里的 ⚠️ 要确认
第二优先:其他可观测量的历史(历史电压/温度/内阻)
→ 收益次之,但几乎没有前提限制
→ 本数据对应"窗口统计/差分斜率"那 10 列
第三优先:当前读数的变换(比值、平方、交叉项)
→ 收益最小
→ 本数据的实测:+0.00051(10 列合起来)★ 这个优先级的依据就是"信息结构":
历史信息 > 当前读数的变换
↓
因为历史提供了"这支设备自己的基线",
而读数变换只是在同一个点上兜圈子③ 一个反直觉但重要的结论
本数据里,"加 10 列窗口/差分特征"只值 +0.00051
↓
但那 10 列在别的场景可能很值钱
↓
为什么本数据不值钱?
因为本数据的退化【单调、平滑、无突变】
→ 历史 SOH 一个数就概括了全部"过去"
↓
如果数据里有【突变、工况切换、异常事件】
→ "变化率"(差分/斜率)就会变得重要(它能捕捉突变)
↓
【特征的"价值"依赖数据的性质,没有普适答案】★ 这就是为什么这本书反复强调"消融实验" —— 没有任何经验法则能替代"在你的数据上测一遍"。
④ 一句话总结
在加特征之前,先回答一个问题: "我的模型现在能看到什么?它看不到什么?" 看不到的那部分,才是特征工程要补的。