Skip to content

第 32 章 · Z 分数异常检测 ​

这一章要解决什么:用一个统计指标找出内阻异常的电芯记录。 打掉一个盲区:Z 分数用的均值和标准差,会被异常值自己带偏 —— 数据越脏,它越不灵。所以还要学一个稳健的替代方案。

本章你需要完成的代码:demos/ch32-zscore.py

32.1 什么是 Z 分数 ​

Z 分数(Z-score,又叫标准分数) 的公式:

z=x−μσ
  • x = 这个值
  • μ = 均值
  • σ = 标准差

含义:这个值离"平均水平"有几个标准差。

z 值含义正态分布下排位
0正好等于均值第 50%
+1比均值高 1 个标准差前 15.9%
+2比均值高 2 个标准差前 2.3%
+3比均值高 3 个标准差前 0.13%
-3低 3 个标准差后 0.13%

正态分布下,|z| > 3 的样本约占 0.27%(大约 370 个里 1 个)。

所以 |z| > 3 天然成为一个异常阈值 —— 它来自统计学,不是拍脑袋定的。

它还有一个身份:Z 分数就是第 21 章讲的标准化的结果。 StandardScaler 输出的就是 z 值 —— 同一个公式,一个用途是"标准化特征",一个用途是"找异常"。

32.2 算一遍 ​

python
d = pd.read_csv(DATA / "dirty.csv")
COL = "resistance_mohm"

x = d[COL]
m, s = x.mean(), x.std()
print(f"均值   = {m:.4f}")          # 56.1841
print(f"标准差 = {s:.4f}")          # 23.1703

d["z"] = (x - m) / s
点开:std() 的 ddof 口径说明(一个容易踩的细节)

标准差有两个版本:

分母Pandas 默认NumPy 默认
总体标准差n✅ ddof=0
样本标准差n - 1✅ ddof=1

实测差异(本书数据):

python
print(x.std())                    # 23.1703   ← Pandas 默认 ddof=1
print(np.std(x.to_numpy()))       # 23.1693   ← NumPy 默认 ddof=0

差别很小(数据量大时 n 和 n-1 几乎一样),但跨库切换时要留意。

本书的做法:统一用 Pandas 的 .std()(ddof=1), 并在需要时说清楚。关键是整个流程保持一致 —— 训练和推理用同一个口径就没问题。

怎么避免混淆:

python
x.std(ddof=1)      # 明确写出来,读者不用猜

Z 分数的分布 ​

python
print(d["z"].describe().round(4))
text
count    13897.0000
mean        -0.0000
std          1.0000
min         -0.7498
25%         -0.5008
50%         -0.1417
75%          0.3183
max         21.8567

注意两点:

  1. std = 1.0000 —— 这是定义性的:Z 分数的标准差必然是 1;
  2. max = 21.86 —— 最大的那个值离均值 21.9 个标准差!远远超过 3。

min 只有 -0.75(没有负向异常)—— 因为注入的异常都是"内阻变大"。

32.3 |z| > 3 抓到什么 ​

python
mask = d["z"].abs() > 3
print(f"|z| > 3 的记录:{int(mask.sum()):,d} 条(占 {mask.mean():.2%})")
text
|z| > 3 的记录:68 条(占 0.49%)
被判异常的样本,内阻范围:219.49 ~ 562.61 mΩ
正常样本的内阻上限  :87.57 mΩ

物理上内阻应该在 38~90 mΩ 之间(数据生成时的设计范围)。

|z| > 3 抓到的 68 条,内阻全在 219 mΩ 以上 —— 显然是异常的。

⚠ 但 0.49% 和理论值 0.27% 不一样:

理论值(正态分布):0.27%
实测值            :0.49%

原因:数据不是正态分布(右偏 + 人工注入的极端值)。

★ 教训:

"|z| > 3 约等于 0.27%" 这个说法依赖"正态分布"这个前提。 真实数据很少正态,所以阈值要用实测比例来校准,不能照抄。

怎么看实测比例是否合理:

python
rate = mask.mean()
if rate < 0.001:
    print("可能阈值太严,漏检了")
elif rate > 0.05:
    print("异常比例偏高 —— 要么阈值太松,要么数据本身有问题")
else:
    print("比例在常见范围内")

这些异常落在哪些电芯上 ​

python
print(d.loc[mask, "cell_id"].value_counts())

真实输出(节选):

text
C008    12
C007     9
C006     8
...

涉及 12 支电芯 —— 均匀分布在全部电芯上。

这个判断很重要(第 26 章讲过):

均匀分布 → 采集/录入环节的随机错误 → 修数据管道
集中少数 → 那几支真的有问题       → 去查产线

本数据是"均匀" —— 所以结论是"数据采集/注入造成的随机错误", 而不是"某几支电芯有问题"。

32.4 ★ 核心问题:标准差被异常值自己撑大了 ​

python
clean = pd.read_csv(DATA / "cycle_raw.csv")
print(f"脏数据:均值 {m:.4f},标准差 {s:.4f}")           # 56.1841, 23.1703
print(f"干净数据:均值 {clean[COL].mean():.4f},"
      f"标准差 {clean[COL].std():.4f}")                  # 54.8383, 11.6132
print(f"→ 标准差被撑大约 {s / clean[COL].std():.1f} 倍!")  # 2.0 倍

标准差从 11.61 被撑到 23.17 —— 大了 2 倍。

点开:这就是 Z 分数的死穴(附一个受控实验)

逻辑链:

异常值变大
    ↓
均值和标准差都被拉大(异常值也参与计算!)
    ↓
阈值 (mean ± 3σ) 被撑宽
    ↓
本该被抓到的异常,反而"落进了正常范围"
    ↓
漏检

数据越脏,Z 分数越不灵。

受控实验:污染比例越大,Z 分数越失灵 ​

注意:在本数据集上,普通 Z 和稳健 Z 命中完全相同(都是 68 条) —— 因为异常只占 0.49%,污染还不够严重。

所以我们要人为把污染程度调大,才能看清差别:

python
rng_ = np.random.default_rng(7)
base_ok = clean[COL].to_numpy()                    # 干净数据作为基底
for rate in (0.005, 0.01, 0.02, 0.05, 0.10):
    x2 = base_ok.copy()
    k_ = int(len(x2) * rate)
    idx_ = rng_.choice(len(x2), k_, replace=False)
    x2[idx_] = rng_.uniform(200.0, 600.0, k_)      # 注入极端值
    # ...(完整代码见 demos/ch32-zscore.py)

真实输出:

text
  污染比例        均值      标准差   普通 Z 命中      中位数      MAD  稳健 Z 命中
     0.5%      56.57     28.38         69        52.89     9.21        69
     1.0%      58.37     38.90        139        52.99     9.28       139
     2.0%      61.49     50.90        264        53.12     9.39       279
     5.0%      72.14     80.44        512        53.81     9.85       698
    10.0%      88.99    109.32        617        54.93    10.71      1396

读表(每行"应该抓到的个数"= 13967 × 污染比例):

污染比例应该抓到普通 Z稳健 Z结论
0.5%696969✅ 一致
1.0%139139139✅ 一致
2.0%279264279⚠️ 普通 Z 漏 15 个
5.0%698512698❌ 普通 Z 漏 186 个
10.0%13966171396❌ 普通 Z 漏掉一半以上

看标准差那一列就明白了:

污染 0.5% → 10%:
  标准差:28.38 → 109.32   (涨 3.9 倍)
  MAD   : 9.21 →  10.71   (涨 16%)

★ 这就是"稳健(robust)"的含义: 统计量对极端值的敏感程度低。

★ 实践规则:

异常比例建议
< 1%普通 Z 分数就够了
1% ~ 5%换中位数 + MAD
> 5%考虑"这根本不是异常,而是两种分布混在一起" —— 该去查数据来源,而不是继续调阈值

最后一条很重要:如果 10% 的数据都"异常",那它可能不是异常, 而是另一个总体(比如两批不同工艺的电芯混在一起)。

32.5 稳健版本:中位数 + MAD ​

python
med = x.median()
mad = (x - med).abs().median()              # 中位数绝对偏差
robust_z = (x - med) / (1.4826 * mad)       # 1.4826 让它在正态下与标准差等价

真实输出:

text
中位数       = 52.9000
MAD          = 9.2200
稳健 Z 命中数 = 68 条(占 0.49%)
普通 Z 命中数 = 68 条
两者都命中    = 68 条
只有普通 Z 命中 = 0 条
只有稳健 Z 命中 = 0 条

在本数据集上两者命中完全相同 —— 因为这里异常只占 0.49%(见上面的受控实验)。

点开:MAD 和那个 1.4826 是什么?

MAD(Median Absolute Deviation,中位数绝对偏差):

MAD = median(|x - median(x)|)

三步:

  1. 算中位数;
  2. 算每个值到中位数的距离;
  3. 取这些距离的中位数。

为什么它稳健:

x = [45, 52, 53, 61, 999]

中位数 = 53
距离   = [8, 1, 0, 8, 946]
距离的中位数(MAD)= 8        ← 那个 946 被排到了最后,不影响中位数

对比标准差:
  均值 = 242,标准差 = 379      ← 被 999 彻底带偏

那个 1.4826 是什么:

它是"让 MAD 在正态分布下等于标准差"的校正系数。

推导:正态分布下,MAD ≈ 0.6745σ,所以 σ ≈ MAD / 0.6745 = 1.4826 × MAD。

用途:让"稳健 Z 分数"和"普通 Z 分数"在干净的正态数据上给出相同的尺度, 这样阈值 3 在两个方法里含义一致。

验证(本数据):

python
print(f"标准差      = {s:.4f}")                    # 23.1703(脏数据,被撑大)
print(f"1.4826×MAD  = {1.4826 * mad:.4f}")          # 13.6696(稳健估计)
print(f"干净数据 σ  = {clean[COL].std():.4f}")       # 11.6132(真值)

注意:1.4826 × MAD = 13.67 比干净数据的标准差 11.61 大一些 —— 说明这份数据的分布不是正态(实际是右偏的), 所以这个校正系数在这里只是近似。

★ 教训:MAD 方法也依赖"分布形状"的假设。 任何统计方法都有前提 —— 知道前提,才知道什么时候不该用。

Python 里的实现:

python
# 手写
med = x.median()
mad = (x - med).abs().median()
robust_z = (x - med) / (1.4826 * mad)

# 或者用 scipy(需要安装)
from scipy.stats import median_abs_deviation
mad = median_abs_deviation(x, scale="normal")      # scale="normal" 自动乘 1.4826

本书手写(不引入额外依赖),但知道有现成的库也不错。

32.6 ⚠ 发现异常只是第一步 ​

这一节是第 34 章的引子。

python
print("""
  发现异常只是第一步,怎么处理是业务决定:
    · 容量为负   → 物理上不可能,一定是录入错误 → 可以删
    · 内阻 560   → 可能是传感器故障,也可能是真有问题的电芯 → 必须人工核查
""")

★ 两类完全不同的"异常"

类型例子特征处理
物理上不可能容量 = -0.5 Ah值域越界,不需要统计可以直接删/修
统计上偏离内阻 = 560 mΩ值合法但极端必须核查

第二类为什么必须核查:

  • 可能是传感器故障(读数错了)→ 数据要修
  • 可能是真有问题的电芯(内阻真的高)→ 这是最有价值的样本,不能删!

这就是"删数据要慎重"的根本原因 —— 你删掉的可能正是你要找的东西。

★ 物理规则应该排在统计规则前面

python
# ① 物理规则:不需要统计,直接拦
rule_phys = d["capacity_ah"] < 0        # 容量不可能是负数

# ② 统计规则:值合法但偏离
rule_stat = d["resistance_mohm"] > r_hi  # 超过 IQR 上须

# 两条取并集
mask = rule_phys | rule_stat

为什么顺序重要:均值和标准差会被异常值带偏(本节讲的就是这件事), 而物理规则不依赖分布 —— 它永远可靠。

第 34 章会完整实现"物理规则 + 统计规则"的双保险方案。

NaN 不会被判成异常(但要单独处理) ​

python
print(f"本列还有 {int(d[COL].isna().sum())} 个 NaN")

NaN > 3 的结果是 False(第 22 章讲过),所以 NaN 既不在 mask 里、 也不在 ~mask 的"正常"里 —— 是个盲区。

python
print(f"总数: {len(d)}")
print(f"异常: {int(mask.sum())}")
print(f"正常: {int((~mask).sum())}")
print(f"异常 + 正常 = {int(mask.sum()) + int((~mask).sum())}   ← 差了 NaN 的个数")

处理方式:

python
# 分开处理两类问题
missing = d[COL].isna()                  # 缺失 → 第 30 章的流程
outlier = d["z"].abs() > 3               # 异常 → 本章 + 第 34 章的流程
normal = ~(missing | outlier)            # 真正"正常"的

print(f"缺失 {missing.sum()}, 异常 {outlier.sum()}, 正常 {normal.sum()}")

★ 这个"三类划分"(缺失/异常/正常)是数据清洗的标准视角。

32.7 别在训练集上算阈值然后套到测试集 ​

python
train = d.sample(frac=0.7, random_state=42)
test = d.drop(train.index)

mu_tr, sd_tr = train[COL].mean(), train[COL].std()
print(f"用训练集阈值判测试集:{int((((test[COL] - mu_tr) / sd_tr).abs() > 3).sum())} 条")
print(f"用测试集自己的阈值  :"
      f"{int((((test[COL] - test[COL].mean()) / test[COL].std()).abs() > 3).sum())} 条")

真实输出:

text
用训练集的阈值判测试集:|z_test| > 3 命中 19 条
用测试集自己的阈值        :|z_test| > 3 命中 19 条

(本数据上两者恰好相同,但这不是重点。)

点开:为什么统计量属于"模型的一部分"?

一个核心认知:

均值、标准差、IQR 边界、min/max 缩放系数 —— 这些"从数据里学出来的数字",都是模型的一部分。

为什么:

它们决定了"什么样的值算异常"。如果训练时用的是训练集的均值和标准差, 那上线时也必须用同一套 —— 否则"异常"的定义就变了。

一个具体的例子:

python
# 训练时:某列均值 50、标准差 10,阈值 = 50 ± 30 = [20, 80]

# 上线后数据整体漂移了:均值变成 70
# 如果用线上数据重算:阈值 = [40, 100] → 原本 85 不算异常了
# 如果用训练时的阈值:[20, 80] → 85 会被标为异常(这才是对的!)

用训练集的统计量,能检测出"数据漂移"; 用线上自己算的统计量,会"自动适应"漂移 —— 从而失去监控能力。

正确做法:

python
# 训练阶段:算好并保存
scaler_stats = {"mean": train[COL].mean(), "std": train[COL].std()}
pd.Series(scaler_stats).to_csv("outputs/scaler_stats.csv")

# 推理阶段:加载训练时的统计量
stats = pd.read_csv("outputs/scaler_stats.csv", index_col=0)["0"]
z_prod = (prod_df[COL] - stats["mean"]) / stats["std"]

在 sklearn 里这是自动的:

python
scaler = StandardScaler()
scaler.fit(X_train)                # 记住 mean_ 和 scale_
X_test_s = scaler.transform(X_test)  # 用训练集的统计量

但很多人会写成:

python
X_test_s = StandardScaler().fit_transform(X_test)   # ❌ 测试集自己算了一套

后者叫"数据泄漏" —— 测试集的信息泄漏到了预处理里, 评估结果会偏乐观。

★ 检查清单

步骤训练时推理时
算均值/标准差✅ fit❌ 不要重算
应用标准化✅ transform✅ transform(用训练的值)
算异常阈值✅ 在训练集上❌ 不要重算
填充缺失(中位数)✅ 训练集中位数✅ 用训练集的中位数
分箱边界✅ 训练集分位数✅ 用训练集的边界

一句话:训练阶段"学"参数,推理阶段"用"参数。

第 39 章的体检报告是"离线分析"(不是预测任务), 所以可以用全量数据的统计量 —— 但要说清楚这一点。

32.8 完整示例与输出 ​

bash
cd code
python demos/ch32-zscore.py

真实输出(节选):

text
=== ② 算一遍 ===
  均值   = 56.1841 mΩ
  标准差 = 23.1703
  |z| > 3 的记录:68 条(占 0.49%)
  ⚠ 这个占比和理论值 0.27% 不一样 —— 因为数据不是正态分布

=== ④ ★ 核心问题:标准差被异常值自己撑大了 ===
  脏数据:均值 56.1841,标准差 23.1703
  干净数据:均值 54.8383,标准差 11.6132
  → 标准差被撑大约 2.0 倍!

=== ⑤b 受控实验:污染比例越大,Z 分数越失灵 ===
  污染比例        均值      标准差   普通 Z 命中  稳健 Z 命中
     0.5%      56.57     28.38         69          69
     5.0%      72.14     80.44        512         698
    10.0%      88.99    109.32        617        1396
  看标准差那一列:污染从 0.5% 涨到 10%,标准差涨 3.9 倍,MAD 只涨 16%

本章练习 ​

练习 1:用 Z 分数找内阻异常

对 dirty.csv 的 resistance_mohm:

  1. 算均值、标准差,构造 z 列;
  2. 数出 |z| > 3 的条数与占比;
  3. 打印异常样本的内阻范围、正常样本的上限;
  4. 对比干净数据的标准差,解释"为什么 Z 分数会被削弱"。
参考答案
python
from pathlib import Path
import pandas as pd

pd.set_option("display.width", 170)
CODE_DIR = Path(__file__).resolve().parent.parent.parent
DATA = CODE_DIR / "data" / "battery"
d = pd.read_csv(DATA / "dirty.csv")
clean = pd.read_csv(DATA / "cycle_raw.csv")
COL = "resistance_mohm"

# ①② Z 分数
x = d[COL]
m, s = x.mean(), x.std()
d["z"] = (x - m) / s
mask = d["z"].abs() > 3
print(f"均值 {m:.4f},标准差 {s:.4f}")
print(f"|z| > 3:{int(mask.sum())} 条({mask.mean():.2%})")     # 68 条,0.49%

# ③ 范围
print(f"异常样本 {d.loc[mask, COL].min():.2f} ~ {d.loc[mask, COL].max():.2f} mΩ")
print(f"正常样本上限 {d.loc[~mask, COL].max():.2f} mΩ")
# 异常样本 219.49 ~ 562.61 mΩ
# 正常样本上限 87.57 mΩ

# ④ 对比
print(f"干净数据标准差 {clean[COL].std():.4f}")                   # 11.6132
print(f"→ 标准差被撑大约 {s / clean[COL].std():.1f} 倍")           # 2.0 倍

四个要点:

  1. 实测 0.49% ≠ 理论 0.27% —— 因为数据不是正态分布。 阈值要用实测比例校准。
  2. 异常样本内阻 219~563 mΩ,远高于正常上限 87.57 —— 抓得很准。
  3. 标准差被撑大 2 倍 —— 这就是 Z 分数的死穴(见受控实验)。
  4. 异常均匀分布在 12 支电芯上 —— 说明是采集环节的随机错误。

完整练习:exercises/ex32-01-zscore

练习 2:为什么数据越脏,Z 分数越不灵?

用受控实验验证:在干净数据上注入不同比例的极端值, 看"普通 Z"和"稳健 Z"各能抓到多少。

参考答案
python
import numpy as np
import pandas as pd

clean = pd.read_csv(DATA / "cycle_raw.csv")
COL = "resistance_mohm"
base = clean[COL].to_numpy()
rng = np.random.default_rng(7)

print(f"{'污染':<8}{'应抓':>7}{'标准差':>10}{'普通 Z':>9}{'MAD':>8}{'稳健 Z':>9}")
for rate in (0.005, 0.01, 0.02, 0.05, 0.10):
    x = base.copy()
    k = int(len(x) * rate)
    idx = rng.choice(len(x), k, replace=False)
    x[idx] = rng.uniform(200.0, 600.0, k)     # 注入极端值
    s = pd.Series(x)
    m, sd = s.mean(), s.std()
    med = s.median()
    mad = (s - med).abs().median()
    n_z = int((((s - m) / sd).abs() > 3).sum())
    n_rz = int((((s - med) / (1.4826 * mad)).abs() > 3).sum())
    print(f"{rate:>7.1%}{k:>7d}{sd:>10.2f}{n_z:>9d}{mad:>8.2f}{n_rz:>9d}")

真实输出:

text
污染        应抓      标准差   普通 Z     MAD   稳健 Z
  0.5%       69     28.38      69    9.21      69
  1.0%      139     38.90     139    9.28     139
  2.0%      279     50.90     264    9.39     279
  5.0%      698     80.44     512    9.85     698
 10.0%     1396    109.32     617   10.71    1396

结论:

污染比例普通 Z 的漏检稳健 Z
≤ 1%00
2%150
5%1860
10%779(漏掉一半以上)0

原因:

标准差被异常值抬高 → 阈值 mean ± 3σ 变宽 → 漏检
  标准差:28.38 → 109.32(涨 3.9 倍)

MAD 几乎不动 → 阈值稳定
  MAD:9.21 → 10.71(涨 16%)

★ 实践规则

异常比例用什么
< 1%普通 Z 分数
1% ~ 5%中位数 + MAD
> 5%停下来想:这可能是"两种分布混在一起",不是异常

最后一条最值得记住:如果 10% 的数据都"异常", 那更可能的解释是数据本身来自两个不同的总体 (比如两批工艺不同的电芯混在一起)。

这时该做的事是"去查数据来源",而不是"继续调异常阈值"。

练习 3:为什么阈值不能在测试集上算?
python
train = d.sample(frac=0.7, random_state=42)
test = d.drop(train.index)

# 做法一:用训练集的统计量
mu_tr, sd_tr = train[COL].mean(), train[COL].std()
n1 = ((((test[COL] - mu_tr) / sd_tr).abs() > 3)).sum()

# 做法二:用测试集自己的统计量
n2 = ((((test[COL] - test[COL].mean()) / test[COL].std()).abs() > 3)).sum()

print(n1, n2)

要求:说出"为什么做法二有问题",并举一个它造成实际错误的场景。

参考答案

两个数字在本数据上恰好相同(19 和 19),但这不是重点。

做法二的问题:它让测试集"自己定义什么是异常"。

一个会造成实际错误的场景:

python
# 训练时:某传感器内阻均值 50 mΩ、标准差 10 mΩ
# 阈值 = 50 + 3×10 = 80 mΩ

# 上线后:传感器开始漂移,读数整体上移 +20 mΩ
# 真实情况:所有电芯的内阻读数都虚高了 20 mΩ

# ❌ 做法二(用线上数据重算):
#   线上均值变成 70、标准差还是 10
#   新阈值 = 70 + 3×10 = 100
#   → 真实内阻 85 的电芯(其实已异常)被判为"正常"
#   → 传感器漂移被"自动适应"掉了,监控完全失效!

# ✅ 做法一(用训练时的阈值):
#   用 80 mΩ 的阈值去判线上数据
#   → 大量样本超过 80 → 立刻报警"数据分布漂移"
#   → 这正是我们想要的监控能力

★ 核心认知

训练时"学"参数(均值、标准差、阈值、分箱边界), 推理时"用"这些参数,绝不重算。

为什么:

用训练集的参数用线上的参数
检测数据漂移✅ 能❌ 不能(自动适应)
评估结果真实虚高
与训练时的一致性✅ 保证❌ 破坏

在 sklearn 里的正确写法:

python
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)      # fit + transform
X_test_s = scaler.transform(X_test)             # 只 transform!

❌ 常见错误:

python
X_test_s = StandardScaler().fit_transform(X_test)
#           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#           测试集自己 fit 了一套统计量 → 数据泄漏

★ 一个"泄漏自检"清单

步骤检查
标准化fit 只在训练集上调用过吗?
填充缺失中位数/均值是训练集的吗?
异常阈值在训练集上算的吗?
分箱边界训练集的分位数吗?
特征选择用训练集选的还是全量?
目标编码训练集的均值吗?

第 39 章的体检报告是"离线分析"(不是预测), 所以用全量统计量是对的 —— 但要在方法学说明里写清楚:

"本报告为离线数据分析,统计量基于全量数据计算。 若用于在线监控,需将统计量固化为基线(baseline), 用基线阈值检测线上漂移。"

面向 AI 开发:异常检测是"数据质量监控"的核心 ​

在 AI 项目里,异常检测有三个层次的应用:

层次目标时机
数据质量找出"不可能的值"数据入库时
数据漂移发现"分布变了"每天/每小时
业务异常找出"需要关注的对象"分析时

① 数据质量:入库时拦截

python
def validate_row(row, rules):
    """按物理规则校验一行 —— 不需要统计,最快。"""
    errors = []
    for col, (lo, hi) in rules.items():
        v = row.get(col)
        if v is None or pd.isna(v):
            continue
        if not (lo <= v <= hi):
            errors.append(f"{col}={v} 超出 [{lo}, {hi}]")
    return errors

RULES = {
    "capacity_ah": (0.0, 3.0),
    "soh": (0.0, 1.05),
    "resistance_mohm": (10.0, 200.0),
    "temp_rise_c": (0.0, 60.0),
    "coulombic_eff": (0.9, 1.0),
}

为什么物理规则优先:它不需要历史数据、不依赖分布、永远可靠。

② 数据漂移:Z 分数是基础工具

python
def drift_report(df_new, baseline):
    """对比线上数据与基线,报告漂移情况。"""
    rows = []
    for col in baseline:
        m0, s0 = baseline[col]["mean"], baseline[col]["std"]
        m1 = df_new[col].mean()
        z = (m1 - m0) / s0               # 均值偏移了几个标准差
        rows.append({
            "column": col,
            "baseline_mean": round(m0, 3),
            "current_mean": round(m1, 3),
            "z_shift": round(z, 2),
            "alert": "⚠️" if abs(z) > 2 else "",
        })
    return pd.DataFrame(rows)

z_shift 的含义:当前均值偏离基线多少个标准差。

z_shift判断
< 1正常波动
1 ~ 2留意
> 2显著漂移,检查上游

③ 业务异常:需要"标记 + 核查"而不是"自动删除"

本章的 68 条异常,处理方式不应该是"删掉":

python
# ❌ 直接删
d_clean = d[d["z"].abs() <= 3]

# ✅ 标记 + 导出核查清单
d["is_outlier"] = d["z"].abs() > 3
d[d["is_outlier"]].to_csv("outputs/outlier_cases.csv", index=False)

为什么:这 68 条里可能有"真的有问题"的电芯 —— 那才是最值得深入分析的样本(第 34 章详讲)。

★ 三个层次的对比

数据质量数据漂移业务异常
方法物理规则Z 分数(对基线)Z 分数 / IQR
阈值来源行业标准训练时的统计量业务决策
动作拒收/修正报警/排查标记 + 人工核查
删除数据?可以(值不可能)不绝不

★ 一个完整的监控思路

python
def monitor(df_new, baseline_path="outputs/baseline.csv"):
    """一个最小可用的数据监控:物理规则 + 漂移检测。"""
    baseline = pd.read_csv(baseline_path, index_col=0)

    # ① 物理规则:立刻拦截
    bad = 0
    for col, (lo, hi) in RULES.items():
        if col in df_new.columns:
            n = int((~df_new[col].between(lo, hi)).sum())
            if n:
                print(f"[物理规则] {col}: {n} 个值越界")
                bad += n

    # ② 漂移检测:对比基线
    for col in baseline.index:
        if col not in df_new.columns:
            continue
        m0, s0 = baseline.loc[col, "mean"], baseline.loc[col, "std"]
        z = (df_new[col].mean() - m0) / s0
        if abs(z) > 2:
            print(f"[漂移告警] {col}: 均值偏移 {z:+.2f} 个标准差")

    return bad

这 20 行代码是"数据监控"的最小实现 —— 它能在数据出问题的当天发现问题,而不是等到模型效果下降几周后。

★ 一句话总结本章的价值

Z 分数是"发现异常"的工具,不是"处理异常"的规则。发现之后怎么做,取决于业务,不取决于统计。


下一章:第 33 章 · IQR 异常检测(箱线图规则)