Skip to content

第 08 章 · 差分与斜率:变化量比绝对值更早报警 ​

这一章要解决什么:上一章造的是"窗口内的水平"(均值、标准差)。 这一章造变化:同样是 0.90 的两支设备, 一支每月掉 0.3%、一支每月掉 3%,处置方式完全不同。

最容易踩的坑: ① diff() 忘了 groupby —— C002 第一行减掉 C001 最后一行,造出一个 1429 倍的假信号; ② 只造逐圈差分 —— 这个数据里它被 4 位小数量化吃掉(1.94% 恰好为 0); ③ 窗口太短就拟合斜率 —— 得到一列噪声是信号 30 倍的垃圾(cap_slope)。

本章你需要完成的代码:demos/ch08-diff-slope.py

8.1 一句话:绝对值和变化量在回答两个问题 ​

先看一个"两支设备分不开"的场景:

text
设备甲:SOH = 0.90,最近 60 圈掉了 0.83 个百分点   → 还能用很久
设备乙:SOH = 0.90,最近 60 圈掉了 1.18 个百分点   → 需要提前安排更换
              ↑
      只看 SOH 这一个数,它们一模一样

★ 这就是"变化量"的全部动机:

问的问题该用哪类特征例
现在多健康?绝对值 / 窗口均值soh、res_win_mean
往哪走、多快?差分 / 斜率soh_d1、res_slope20
稳不稳?窗口内 σ / 极差volt_win_std、temp_win_range20

三类特征各管一段,不能互相替代。第 9 章讲第三类,这一章讲第二类。

★ 一个必须建立的本能:

"变化量"是把未来的信息提前"投影"到现在的唯一方式 —— 所以它也是最容易越界的地方(第 10 章会把这条边界讲完)。

8.2 三种差分的语义 ​

真实输出:

text
=== ① 三种差分的语义(C001,每 100 圈取一行)===
 cycle    soh  soh_d1  soh_d100  cap_slope
     1 0.9999     NaN       NaN        NaN
   101 0.9831 -0.0001   -0.0168     0.0103
   201 0.9626 -0.0002   -0.0205     0.0048
   301 0.9404 -0.0003   -0.0222    -0.0118
   401 0.9171 -0.0003   -0.0233    -0.0117
   501 0.8929 -0.0003   -0.0242    -0.0076
   601 0.8680 -0.0002   -0.0249    -0.0172
   701 0.8424 -0.0002   -0.0256     0.0075
   801 0.8162 -0.0003   -0.0262    -0.0004
  三列的量纲:
    soh_d1    = soh(t) - soh(t-1)          → 每 1 圈变化了多少
    soh_d100  = soh(t) - soh(t-100)         → 每 100 圈变化了多少
    cap_slope = Δ容量 / Δ圈数              → Ah / 圈
  ⚠️ 每支设备第一行的差分为 NaN(没有上一行可减)——正常,建模时 dropna。

★ 三列各自的三个特征:

列跨度单位抗噪
soh_d11 圈soh / 圈差(被量化噪声主导)
soh_d100100 圈soh / 100 圈好
cap_slope1 圈Ah / 圈极差(噪声是信号 30 倍)

看数字就能看出来:

text
soh_d1 那一列在 -0.0001 / -0.0002 / -0.0003 三个值之间跳(分辨率不够)
soh_d100 那一列从 -0.0168 单调走到 -0.0262(趋势清清楚楚)
cap_slope 那一列在 +0.0103 和 -0.0118 之间来回翻符号(纯噪声)

★ 一句话:

同样叫"斜率",跨度不同、对象不同,可用性可以差 30 倍。

8.3 差分也必须分组 ​

第 06 章的坑换一个函数又来了 —— 而且这次更夸张。

真实输出:

text
=== ② 不分组差分:C002 第一行会减掉 C001 的最后一行 ===
cell_id  cycle      soh  soh_d1_bad
   C001    862  0.80000    -0.00030
   C002      1  1.00000     0.20000
   C002      2  1.00000     0.00000

  C002 第一行的「变化量」= +0.20000 —— 真实情况是它没有上一行,应该 NaN。
  这个假信号的绝对值 |0.20000| 是正常逐圈变化量(约 0.00014)的 1429 倍。
  → 和 rolling 一样,`diff` 也必须在 `groupby(...)` 里面做。

★ 这个 0.2 是怎么来的:

text
C002 第一行:soh = 1.0000
减掉"上一行"(其实是 C001 的最后一行):soh = 0.8000
              ↓
soh_d1 = 1.0000 - 0.8000 = +0.2000
              ↓
这个信号的正确含义是:"这台设备的健康度一夜之间上升了 20 个百分点"

而且它的方向还是「错的」:

正常值的符号这个假信号
soh_d1负(健康度只会下降)正 +0.2

★ 一个"正的变化量"本身就是最明显的异常标记:

python
if (df["soh_d1"] > 0).any():
    print("出现了正的健康度变化 —— 先怀疑分组,再怀疑传感器")

这句检查非常便宜,而且非常有效 —— 因为它的物理含义是"设备变好了", 而在这个数据里,非 NaN 的 soh_d100 一次都没有为正(第 8.4 节会给实测)。

8.4 跨度差分比逐圈差分稳 ​

真实输出:

text
=== ④ 跨度差分比逐圈差分稳:噪声的账 ===
  soh_d1  :非空 69,531,其中恰好为 0 的 1,349 行(1.94%)
  → 原始表里 soh 只存到小数点后 4 位,早期每圈变化比 0.00005 还小,差分直接变成 0。
  soh_d1       标准差 = 0.000067
  soh_d100/100  标准差 = 0.000048   ← 同一个「每圈平均变化」,噪声小了 1.41 倍
  soh_d1   为负的比例 = 98.06%
  soh_d100 为负的比例 = 100.00%  ← 跨度差分【没有一次】给出方向相反的信号
  → 隔 100 圈相减,噪声被平均掉,趋势留下来了。

左:逐圈差分(抖)与跨度差分(稳);右:短窗口拟合出的容量斜率全是噪声

★ 这三行数据的完整解读:

指标soh_d1soh_d100 / 100说明
恰好为 0 的比例1.94%0%逐圈差分被 4 位小数量化吃掉了
标准差0.0000670.000048同样表达"每圈平均变化",跨度版更稳
为负的比例98.06%100.00%跨度版从不给反方向信号

★ "1.94% 恰好为 0"是最容易被忽略的一行:

text
原始表里 soh 的存储精度:小数点后 4 位
早期每圈真实变化:约 0.00005
             ↓
0.00005 < 0.0001(一个最低位)
             ↓
相邻两个数被四舍五入到同一个值 → 差分 = 0(假"没有变化")

⚠️ 这带来一个实际的建模后果:

用 soh_d1 当特征,模型会看到"有 1.94% 的样本变化量恰好是 0"
    ↓
那不是"真的没变",是"精度不够"
    ↓
如果这个数据里"没变"本身有含义(比如"设备停了"),
模型会把这两件事混在一起学

★ 两个修法:

python
# ① 用跨度差分代替逐圈差分(本章推荐)
x.diff(100) / 100

# ② 保留逐圈差分但降采样(比如每 10 圈一行)
x.iloc[::10].diff()

★ 一条通用纪律:

差分的跨度必须大于"数据精度能分辨的量"。 否则你得到的不是变化量,是"存储精度的量化栅栏"。

8.5 反面例子:短窗口拟合斜率 = 一列噪声 ​

真实输出:

text
=== ⑤ 反面例子:在 100 圈的短窗口里拟合容量斜率 ===
  cap_slope = Δ容量/Δ圈数(逐圈)
    均值 -0.000432  标准差 0.012911  范围 -0.0648 ~ +0.0548
    为正的比例 48.40% —— 容量明明在单调下降
    相邻两行换符号的比例 0.67(一半以上都在跳)
  真实的容量衰减速率(窗口表口径):0.000424 Ah / 圈
  噪声 / 信号 = 30.5 倍 —— 这一列基本只有噪声。
  验证:容量每圈有约 0.01 Ah 的测量噪声,两个相邻测量相减再除以 1 圈,
        噪声标准差 ≈ 0.0141 Ah/圈,与实测 0.0129 吻合。
  → 特征不是越多越好:这一列喂进去只会让模型多一个机会拟合噪声。

★ 读这四个数字就够了:

数字值意味着
为正的比例48.40%容量明明只降不升,却有近一半行给出正斜率
相邻两行换符号占比0.67三分之二的相邻行方向都不同 —— 这不是斜率,是抛硬币
真实衰减速率0.000424 Ah/圈信号
cap_slope 标准差0.012911噪声
噪声 / 信号30.5 倍这个特征的"信噪比"

★ 为什么必然如此 —— 一笔可以手算的账:

text
容量的逐圈测量噪声:σ ≈ 0.01 Ah(相对 2.5 Ah 额定,即 0.4%)
逐圈差分 = 两个相邻测量相减再除以 1 圈
             ↓
噪声标准差 ≈ √2 × 0.01 = 0.0141 Ah/圈
             ↓
与实测的 cap_slope 标准差 0.0129 吻合 ✓
             ↓
而真实衰减只有 0.0004 Ah/圈 —— 被噪声盖住了 30 倍

★ 差分的"噪声放大"是一条数学必然:

差分 = 两个独立的测量相减 → 噪声按 √2 放大; 而信号(真实变化量)通常远小于单个测量的噪声。 所以"逐点差分 + 短窗口"= 噪声发生器。

★ 怎么救:

做法效果
拉长跨度(diff(100))信号线性增长、噪声只按 √100 增长 → 信噪比改善 10 倍
先平滑再差分(rolling(20).mean().diff())噪声被平均掉
直接放弃这一列本章的结论:这一列在这份数据上没有可用信息

⚠️ 最后一条要说清楚:

**不是说"容量斜率没用",而是说"在这份数据的分辨率下, 逐圈的容量斜率没用"。

换一份采样精度更高的数据,它可能就有用。

8.6 表里的差分列是什么口径 ​

真实输出:

text
=== ⑥ v2 表里的差分列是什么口径 ===
  volt_slope20   非空 19,979   mean  +0.00065   std  0.00058   min   -0.0017   max   +0.0029
  res_slope20    非空 19,979   mean  +0.09374   std  0.07978   min   -0.2541   max   +0.3760
  temp_slope20   非空 19,979   mean  +0.02598   std  0.03369   min   -0.1349   max   +0.1534
  res_diff1      非空 21,179   mean  +0.09385   std  1.42716   min   -5.2369   max   +6.0536
  volt/res/temp_slope20 = (上一个窗口的值 − 20 个窗口前的值)/ 20
    → 单位是「每窗口变化量」,1 窗口 = 3 圈,所以它约等于过去的 60 圈里变了多少。
  res_diff1 = 相邻两个窗口的一阶差分(mΩ / 窗口)
  现场话术:20 个窗口 ≈ 最近 60 圈 ——
    关心的是「最近一个月掉了多少」,不是「昨天掉了多少」。

★ 两个口径必须分清:

列跨度单位现场怎么读
volt_slope20 等20 个窗口 ≈ 60 圈每窗口变化量"最近两个月电压抬升了多少"
res_diff11 个窗口 = 3 圈mΩ / 窗口"这一周转了多快"

看标准差就能看出差别:

text
res_slope20   std = 0.07978      (跨度 20 窗口,平滑)
res_diff1     std = 1.42716      (跨度 1 窗口,抖)—— 相差 18 倍

★ 这两个列是"同一件事的两个时间尺度",而且它们的关系正好印证第 8.4 节:

跨度大的 → 稳
跨度小的 → 抖

⚠️ 一个容易犯的命名错误:

python
# 危险:名字里没有跨度
df["res_slope"] = ...

# 安全:跨度写在名字里
df["res_slope20"]    # 20 个窗口
df["res_diff1"]      # 1 个窗口

★ 纪律:

窗口类 / 差分类特征的列名里必须带跨度。 因为三个月后没人记得 res_slope 到底是几圈的斜率 —— 而它的取值会因此差 18 倍。

点开:为什么本章把"斜率"和"差分"分开写?

因为在工程上它们的成本完全不同:

差分(diff(k))斜率(polyfit / lstsq)
计算量一行 pandas每个窗口一次最小二乘
抗噪只减一次,噪声 √2取决于窗口长度与拟合方式
可解释"掉了多少""平均掉得多快"
出错方式忘了分组(本章 8.3)窗口太短 → 噪声主导(8.5)

★ 一句工程建议:

能用差分表达的,就不要拟合斜率。

"最近 60 圈掉了多少"(diff(60))和"最近 60 圈的斜率"(/60) 是同一个数的两种写法 —— 但前者不用跑最小二乘,也不用担心 拟合出的截距把信息吃掉。

什么时候必须用拟合:

窗口内不是单调的(比如先降后升)
    ↓
"掉了多少"会掩盖形状
    ↓
这时需要斜率 + 二次项,或者干脆用窗口的 max / min / 分位点(第 9 章)

8.7 常见坑 ​

text
① diff() 不分组                 → C002 第一行 +0.2000(正常值的 1429 倍)
② 用逐圈差分当"斜率"             → 1.94% 的值恰好为 0(4 位小数精度不够)
③ 短窗口拟合斜率                 → 噪声是信号的 30.5 倍(48.40% 为正)
④ 列名不带跨度                   → res_slope 与 res_diff1 标准差差 18 倍
⑤ 只看绝对值不管方向             → 正的健康度变化量 = 第一个该排查的信号
⑥ 忘了差分列的 NaN 也是两档      → slope20 缺 21 行,diff1 只缺 1 行(第 07 章)

第 ⑤ 条补一句:

在这个数据里:soh_d100 非空的行【100% 为负】、soh_d1 有 1.94% 为 0
   ↓
所以"出现正值"这件事,几乎一定意味着串线或传感器问题

8.8 完整示例与输出 ​

bash
cd code
python demos/ch08-diff-slope.py

真实输出(节选):

text
=== ① 三种差分的语义(C001,每 100 圈取一行)===
 cycle    soh  soh_d1  soh_d100  cap_slope
   101 0.9831 -0.0001   -0.0168     0.0103
   301 0.9404 -0.0003   -0.0222    -0.0118
   801 0.8162 -0.0003   -0.0262    -0.0004

=== ② 不分组差分:C002 第一行会减掉 C001 的最后一行 ===
   C001    862  0.80000    -0.00030
   C002      1  1.00000     0.20000
  C002 第一行的「变化量」= +0.20000 —— 真实情况是它没有上一行,应该 NaN。
  这个假信号的绝对值 |0.20000| 是正常逐圈变化量(约 0.00014)的 1429 倍。

=== ④ 跨度差分比逐圈差分稳:噪声的账 ===
  soh_d1       标准差 = 0.000067
  soh_d100/100  标准差 = 0.000048
  soh_d100 为负的比例 = 100.00%

=== ⑤ 反面例子:在 100 圈的短窗口里拟合容量斜率 ===
    为正的比例 48.40% —— 容量明明在单调下降
  噪声 / 信号 = 30.5 倍 —— 这一列基本只有噪声。

结论:绝对值说「现在多健康」,变化量说「往哪走、多快」——后期每圈变化是早期的 1.41 倍。
      跨度差分(隔 100 圈)比逐圈差分稳 1.41 倍;
      而窗口太短就拟合斜率,只会得到一列噪声(48% 为正)。

本章练习 ​

练习 1:C003 的三种差分(每 100 圈取一行)

在逐循环表上按设备分组,造三列:soh_d1(逐圈差分)、soh_d100(隔 100 圈差分)、 cap_slope(Δ容量 / Δ圈数)。

打印 C003 每 100 圈取一行的表格(cycle / soh / 三列差分), 并说明第一行为什么三列都是 NaN。

参考答案
python
import os

import pandas as pd

HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
RAW_CSV = os.path.join(CODE, "data", "battery", "cycle_raw.csv")

raw = pd.read_csv(RAW_CSV).sort_values(["cell_id", "cycle"]).reset_index(drop=True)
g = raw.groupby("cell_id")
raw["soh_d1"] = g["soh"].diff()
raw["soh_d100"] = g["soh"].diff(100)
raw["cap_slope"] = g["capacity_ah"].diff() / g["cycle"].diff()

one = raw[raw["cell_id"] == "C003"]
print(one.iloc[::100][["cycle", "soh", "soh_d1", "soh_d100", "cap_slope"]]
      .to_string(index=False, float_format=lambda v: f"{v:>9.5f}"))

期望输出:

text
=== 练习 1:C003 的三种差分(每 100 圈取一行)===
 cycle       soh    soh_d1  soh_d100  cap_slope
     1   0.99990       NaN       NaN        NaN
   101   0.98340  -0.00020  -0.01650    0.01490
   201   0.96300  -0.00020  -0.02040    0.00420
   301   0.94090  -0.00020  -0.02210    0.02490
   401   0.91750  -0.00020  -0.02340   -0.00030
   501   0.89310  -0.00030  -0.02440    0.01740
   601   0.86800  -0.00020  -0.02510    0.01260
   701   0.84210  -0.00020  -0.02590   -0.01980
   801   0.81560  -0.00030  -0.02650    0.01140

★ 第一行为什么是三个 NaN:

soh_d1    → 没有上一行(diff(1) 需要 1 个历史点)
soh_d100  → 没有 100 圈前的行(diff(100) 需要 100 个历史点)
cap_slope → 同 soh_d1,而且容量列也是从这一行开始

★ 顺便看清一件事:cap_slope 在这 8 行里有 6 行是正的 (+0.0149 / +0.0042 / +0.0249 / +0.0174 / +0.0126 / +0.0114), 而容量在这 800 圈里明显在下降 —— 这就是 8.5 节那个反面例子的具体样子。

练习 2:不分组 diff 会造出一个巨大的假信号
  1. 不排序、不分组直接 df["soh"].diff(),打印 C002 / cycle=1 的值;
  2. 打印正常逐圈变化量的绝对值均值,算出这个假信号是它的多少倍;
  3. 分组后再算一次同一个位置(应该是 NaN)。
参考答案
python
import os

import pandas as pd

HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
RAW_CSV = os.path.join(CODE, "data", "battery", "cycle_raw.csv")

bad = pd.read_csv(RAW_CSV)
bad["d_bad"] = bad["soh"].diff()
n1 = int(bad.index[bad["cell_id"] == "C002"][0])

raw = pd.read_csv(RAW_CSV).sort_values(["cell_id", "cycle"]).reset_index(drop=True)
raw["soh_d1"] = raw.groupby("cell_id")["soh"].diff()

v = float(bad["d_bad"].iloc[n1])
normal = float(raw["soh_d1"].abs().mean())
print(f"  不分组:C002 第一行的差分 = {v:+.5f}")
print(f"  正常逐圈变化量的绝对值均值 = {normal:.6f}")
print(f"  假信号是正常值的 {abs(v) / normal:,.0f} 倍")
print(f"  分组后同一个位置 = "
      f"{raw[raw['cell_id'] == 'C002']['soh_d1'].iloc[0]}  ← NaN 才对")

期望输出:

text
=== 练习 2:不分组 diff 会造出一个巨大的假信号 ===
  不分组:C002 第一行的差分 = +0.20000
  正常逐圈变化量的绝对值均值 = 0.000172
  假信号是正常值的 1,160 倍
  分组后同一个位置 = nan  ← NaN 才对

★ 这个数比第 8.3 节的 1429 倍小一点,因为这里的"正常值"用的是 全表(包含后期,后期变化更大)的平均,而 8.3 节用的是早期的一个典型值。

两个数都对,区别只在"拿什么当基准" ——

这也是第 02 章那条纪律的又一次出现: 说倍数的时候必须说清"分母是什么"。

练习 3:变化量后期更大 + 逐圈差分的量化噪声
  1. 算 soh ≥ 0.95 与 soh ≤ 0.85 两个区段的 |soh_d1| 均值与倍数;
  2. 统计 soh_d1 恰好为 0 的行数占比,并解释原因;
  3. 对比 soh_d1 与 soh_d100 中为负的比例,说明哪一个能给方向。
参考答案
python
import os

import pandas as pd

HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
RAW_CSV = os.path.join(CODE, "data", "battery", "cycle_raw.csv")

raw = pd.read_csv(RAW_CSV).sort_values(["cell_id", "cycle"]).reset_index(drop=True)
g = raw.groupby("cell_id")
raw["soh_d1"] = g["soh"].diff()
raw["soh_d100"] = g["soh"].diff(100)

early = raw[raw["soh"] >= 0.95]["soh_d1"].abs().mean()
late = raw[raw["soh"] <= 0.85]["soh_d1"].abs().mean()
print(f"  早期(soh ≥ 0.95):{early:.6f} / 圈")
print(f"  后期(soh ≤ 0.85):{late:.6f} / 圈")
print(f"  后期 / 早期 = {late / early:.4f}")

d1 = raw["soh_d1"].dropna()
print(f"  soh_d1 恰好为 0 的行:{int((d1 == 0).sum()):,d} / {len(d1):,d}"
      f"({100 * (d1 == 0).mean():.2f}%)")
print(f"  soh_d1 为负的比例  :{100 * (d1 < 0).mean():.2f}%")
print(f"  soh_d100 为负的比例:{100 * (raw['soh_d100'].dropna() < 0).mean():.2f}%")

期望输出:

text
=== 练习 3:变化量后期更大 + 逐圈差分的量化噪声 ===
  早期(soh ≥ 0.95):0.000139 / 圈
  后期(soh ≤ 0.85):0.000197 / 圈
  后期 / 早期 = 1.4146
  soh_d1 恰好为 0 的行:1,349 / 69,531(1.94%)
  soh_d1 为负的比例  :98.06%
  soh_d100 为负的比例:100.00%
  → 同一份数据,跨度差分一个反方向的信号都没给;逐圈差分被 4 位小数量化吃掉了。

★ 1.4146 这个数在本书里出现了两次:

后期 / 早期(每圈变化量)        = 1.4146
std(soh_d1) / std(soh_d100/100) = 0.000067 / 0.000048 = 1.41

这是巧合,但两个都在说同一件事:

这个数据里的退化是"加速"的(越到后期掉得越快)。 所以"变化量"不是可有可无的补充信息 —— 它承载了"这支设备现在走到哪一段"这个关键信息。

面向工程实践:变化量是"预测性维护"的语言 ​

① 现场真正关心的是"还有多久",不是"现在多少"

只报绝对值:
   "这台设备 SOH = 0.90"  → 现场:"所以呢?"

加上变化量:
   "SOH = 0.90,最近 60 圈掉了 1.18 个百分点"
   → 现场:"照这个速度,掉到 0.80 还要约 500 圈"   ← 这才是一个决策

★ 换算方法(第 30 章会做成完整报告):

python
rate_per_cycle = abs(soh_d100) / 100          # 每圈掉多少
cycles_left = (soh - 0.80) / rate_per_cycle   # 距退役线还有多少圈

② 三类变化量的选择顺序

优先做法什么时候用
1跨度差分 diff(k)默认。k 取"你关心的决策周期"
2平滑后差分 rolling(m).mean().diff(k)测量噪声大时
3窗口内最小二乘斜率窗口内非单调、需要形状信息时

★ k 怎么定:

你的维护决策周期是多久?
   → 一个月(30 圈)?→ k = 30
   → 一个季度?        → k = 100

k 的取值必须 ≥ "数据精度能分辨的最小变化量 / 每圈真实变化"

③ 一个便宜的异常检测

python
def check_diff_direction(df, col="soh_d1", group="cell_id"):
    """变化量的方向检查:健康度类指标不该上升。"""
    bad = df[df[col] > 0]
    if len(bad):
        print(f"⚠️ {len(bad)} 行出现正向变化 → 先怀疑分组,再怀疑传感器")
        print(bad[[group, "cycle", col]].head())

★ 为什么这类检查值得写:

分组错误 → 造出 +0.2 的假信号 → 方向检查立刻报警
传感器漂移 → 造出系统性正斜率 → 方向检查也会报警
                 ↓
一行代码,同时覆盖"代码错误"和"数据错误"两类问题

④ 这一章在整本书里的位置

第 05 章:窗口(把一段压成数)           → 水平
第 06 章:分组与排序(别串线)           → 前提
第 07 章:冷启动 NaN(缺了多少)         → 代价
第 08 章:差分与斜率(变化量)           ← 你在这里
第 09 章:滚动统计与极差(最坏情况)      → 波动
第 10 章:把上面这些收成一张检查表

下一章:第 09 章 · 滚动统计与极差:从"多高"到"多稳"