Skip to content

第 04 章 · 信息结构:模型只看到一行 ​

这一章要解决什么:为什么"多凑几列读数"没用,而"历史"有用? 答案不在于列数,而在于模型能看到什么。

最容易踩的坑:以为模型"知道"行的先后顺序。 它不知道 —— 时间信息不编码成列,对模型就等于不存在。

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

4.1 模型的眼睛里只有一行 ​

先看一个直接实验。

真实输出:

text
=== ① 模型把每一行当成独立的一行 ===
  打乱测试集行顺序:逐行预测最大差 = 0.00e+00
  测试 R²  = 0.95879(不变)

★ 打乱测试集的行顺序,逐行预测一个字节都不变。

含义:

每一行都是【独立】送进模型的,谁在谁前面完全不影响它的输出。
    ↓
模型不知道"这一行是这支设备的第几行",也不知道"上一行是什么"
    ↓
【时间信息必须显式编码成列,模型才能利用它。】

★ 这是这一章的第一个核心认知:

表格模型(回归/树/森林/提升)看到的是一个"行的集合",不是一个"序列"。

它和序列模型的区别:

表格模型(本书)序列模型(RNN / Transformer)
输入一堆独立的行有序序列
知道先后吗❌ 不知道✅ 知道
要用时间信息必须手动做成列可以从结构里学

所以"特征工程"在表格数据上不是可选项 —— 它承担了"把序列变成行内信息"的职责。

点开:一个反直觉的细节 —— 打乱【训练集】的顺序,分数居然会变

真实输出:

text
  打乱训练集行顺序:测试 R² = 0.95956  (未打乱 0.95879,差 0.00076)

为什么?按刚才的说法,模型不是不关心行顺序吗?

关键在随机森林的 bootstrap 抽样方式:

python
# 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 索引】

★ 顺带一个"错误对照"的教材

我自己在写这一章时犯过这个错:

python
# ❌ 用原来那组整数下标,去测"行被重新排列过"的表
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 没有历史时,相邻两行几乎无法区分 ​

真实输出:

text
=== ② 没有历史列时,同一支设备的相邻两行几乎无法区分 ===
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 变了,其余几乎完全一样
    ↓
【模型看到两行几乎一模一样的输入,却要给出不同的输出】

★ 这不是个例,而是全表现象

真实输出:

text
  特征                                  相邻行平均差        全表跨度       占比
  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% 之间

★ 一个可以搬走的诊断指标

python
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 加上历史,同一行立刻变得可区分 ​

真实输出:

text
=== ③ 把标签的历史值喂进去,同一行立刻变得可区分 ===
  特征集                                   特征数      测试 R²
  基础 7 列(横截面)                             7    0.95879
  基础 + 窗口/差分/极差(17 列)                    17    0.95930
  基础 + 历史 SOH(11 列)                      11    0.99904
  全部安全特征(21 列)                           21    0.99903

★ 这张表是本书最重要的一张表之一。逐行读:

特征集列数测试 R²关键
基础 7 列70.95879横截面基线
+ 窗口/差分/极差170.95930加 10 列,只涨 0.00051
+ 历史 SOH110.99904只加 4 列,涨 0.03974
全部安全特征210.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 一行数据到底能看到什么 ​

真实输出:

text
=== ④ 一行数据到底能看到什么 ===
  这一行:设备 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 常见坑 ​

text
① 以为模型知道行的先后顺序 → 它把每行当独立样本,时间必须显式编码成列
② 用"多加几列读数"来代替"加历史信息" → 相邻行读数是重合的,加多少都没用
③ 打乱数据后复用原来的行下标 → 测得的是"Δ 样本"不是"Δ 特征"(本章 4.1 节的教材)
④ 比较"差 0.0008"这种量级的分数 → 打乱一次训练集就可能差这么多,那是噪声
⑤ 把"历史特征有用"理解成"标签的历史值有用" → 见下
⑥ 忘了历史特征需要"上线时拿得到" → 第 16 章的账本专门管这件事

★ 关于 ⑤ —— 这一条特别重要

本章的"历史 SOH"用的是【标签的历史值】
   ↓
它的确最有效,但它有一个前提:上线时拿得到历史实测标签
   ↓
如果不满足,就得退而求其次:
   用【其他可观测量的历史】代替
   (历史电压、历史温度、历史内阻…)
      ↓
   它们的有效性会低一些,但原理相同:提供"这支设备自己的基线"

这个取舍在第 16 章会量化(⚠️ 那 4 列就是它)。

4.6 完整示例与输出 ​

bash
cd code
python demos/ch04-information-structure.py

真实输出(节选):

text
可用样本 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:证明模型不感知行顺序

用任意一个模型(线性回归或随机森林),证明"打乱测试集行顺序不影响逐行预测"。

要求:

  1. 训练一个模型;
  2. 把同一批测试行按两种不同顺序送入,比较逐行预测;
  3. 报告最大差,并解释为什么应该是 0。
参考答案
python
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}")

期望输出:

text
测试行数 = 3,995
逐行预测最大差 = 0.00e+00
R²:0.96487  vs  0.96487

★ ③ 为什么必须是 0

预测是【逐行独立】计算的:
   对第 i 行,模型只看 x_i,输出 ŷ_i
      ↓
   它不读 x_{i-1},也不读 x_{i+1}
      ↓
   所以行的排列顺序对 ŷ_i 没有任何影响
      ↓
【最大差必然是 0(浮点上也完全相同,因为计算路径一模一样)】

★ 一个容易写错的地方

python
# ❌ 直接比较 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 个元素对应的是同一行"。

★ 把它推广成一条检查习惯

python
def compare_rowwise(a, b, key_a, key_b):
    """比较两组逐行结果前,先确认行对齐。"""
    assert key_a.equals(key_b), "两边的行不是同一批,无法逐行比较!"
    return np.abs(a - b).max()
练习 2:测出"相邻可区分度"

写一个函数,算每个特征在"相邻行平均差 / 全表跨度"上的比值,并对 7 个基础特征排序。

要求:解释这个比值小于 5% 意味着什么。

参考答案
python
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())}")

期望输出:

text
特征                              可区分度
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%
   ↓
   → 横截面可能够用,先试试不加历史
   ↓
【它是一个"早期决策指标",在动手造特征之前就能算出来】

★ 还可以按设备看

python
# 哪支设备的相邻可区分度最低?(最难预测的设备)
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(内阻)设计一个"相对自身基线"的特征,并验证它是否比原始值有用。

要求:

  1. 构造"相对基线"特征(提示:用这支设备早期的内阻作为基线);
  2. 在同一划分下比较"原始值" vs "相对值"的测试 R²;
  3. 说明构造这个特征时要注意什么(提示:预测时刻可得性)。
参考答案
python
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}")

期望输出:

text
  基础 7 列                      R² = 0.96487
  基础 + 相对内阻(8 列)           R² = 0.95890   Δ = -0.00597
  去掉原始内阻,换成相对内阻(7 列)   R² = 0.95988   Δ = -0.00499

★ ② 结果出乎意料:加了一列,分数反而【降了 0.006】

而且降的原因很值得研究:

text
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²))
    ↓
【不要机械地对每一列都做"相对化" —— 先看干扰占比】

★ ⑤ 构造这个特征时必须注意两件事

第一:基线只能用历史

python
# ❌ 用了全量(包括未来)—— 这就是特征泄漏
v2["res_base_bad"] = g.transform("mean")     # 整支设备的平均内阻

# ✅ 只用第一个窗口(预测时刻一定拿得到)
v2["res_base"] = g.transform("first")

但 "first" 也有问题:

如果预测的是第 300 圈,那么"第 1 圈的内阻"确实是历史
    ↓
但如果预测的是第 1 圈呢?基线就是它自己 → 特征恒等于 1,没有信息
    ↓
更严谨的做法是"截至当前的滚动基线"

第二:分母不能为 0 或极小

python
v2["res_rel"] = v2["resistance_mohm"] / v2["res_base"].clip(lower=1.0)

★ ③ 构造这个特征时必须注意两件事

第一:基线只能用历史

python
# ❌ 用了全量(包括未来)—— 这就是特征泄漏
v2["res_base_bad"] = g.transform("mean")   # 整支设备的平均内阻

# ✅ 只用第一个窗口(预测时刻一定拿得到)
v2["res_base"] = g.transform("first")

但"first"也有问题:

如果预测的是"第 300 圈",那么"第 1 圈的内阻"确实是历史
    ↓
但如果预测的是"第 1 圈"呢?基线就是它自己 → 特征恒等于 1,没有信息
    ↓
更严谨的做法是"截至当前的滚动基线":
    res_base_t = 该设备【前 k 个窗口】的内阻均值(不含当前)

第二:分母不能为 0 或极小

python
# 防护:分母有下限,避免比值爆掉
v2["res_rel"] = v2["resistance_mohm"] / v2["res_base"].clip(lower=1.0)

★★ 一个更好的版本(滚动基线)

python
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 章的窗口特征本质上都是它的变体。

面向工程实践:先问"模型能看到什么",再问"加什么特征" ​

① 一个常见的返工场景

模型效果不好 → 团队开始加特征
    ↓
加了几十列(各种比值、平方、交叉项)
    ↓
分数几乎没变
    ↓
【因为加的全是"同一行的读数变换",而瓶颈在"缺少历史"】

★ 这一章给的判断方法:

python
# 先算"相邻可区分度"(练习 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 一个数就概括了全部"过去"
   ↓
如果数据里有【突变、工况切换、异常事件】
   → "变化率"(差分/斜率)就会变得重要(它能捕捉突变)
   ↓
【特征的"价值"依赖数据的性质,没有普适答案】

★ 这就是为什么这本书反复强调"消融实验" —— 没有任何经验法则能替代"在你的数据上测一遍"。

④ 一句话总结

在加特征之前,先回答一个问题: "我的模型现在能看到什么?它看不到什么?" 看不到的那部分,才是特征工程要补的。


下一章:第 05 章 · 滑动窗口:把一段曲线压成几个数