Skip to content

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

这一章要解决什么:用 IQR(四分位距)找异常 —— 它比 Z 分数稳健得多。 学会读"命中全在下侧"这类信息,以及理解"某一列命中 0 条"也是一个结论。

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

33.1 四分位数:把数据四等分 ​

名称又叫含义
Q125% 分位25% 的数据小于它
Q250% 分位 / 中位数一半数据比它小
Q375% 分位75% 的数据小于它
IQR四分位距IQR = Q3 - Q1

IQR 的含义:中间那 50% 数据的跨度 —— 衡量"正常波动范围"。

IQR=Q3−Q1

33.2 箱线图就是这套东西的图像版 ​

              ○  ← 上须外的点 = 异常值
              │
          ┌───┴───┐  ← 上须 = Q3 + 1.5×IQR
          │       │        (或范围内最远的实际数据点)
          ├───────┤  ← 箱体上边 = Q3(75% 分位)
          │       │
          ├───────┤  ← 箱体内横线 = 中位数(Q2)
          │       │
          └───┬───┘  ← 箱体下边 = Q1(25% 分位)
              │
              ○  ← 下须 = Q1 - 1.5×IQR

读法三句话:

  1. 箱子高度 = IQR = 中间 50% 数据的范围 → 离散度;
  2. 箱子里的横线 = 中位数 → 中心位置;
  3. 须外的点 = 异常值 → 这就是本章要找的东西。

Tukey 的 1.5 倍规则(John Tukey,箱线图的发明者):

下界=Q1−1.5×IQR,上界=Q3+1.5×IQR

1.5 这个系数的来历:在正态分布下,Q1 - 1.5×IQR 大约对应 -2.7σ, 越界率约 0.7%。所以它是一个"不松不紧"的经验值。

33.3 对 capacity_ah 算一遍 ​

python
def iqr_bounds(x, k=1.5):
    """返回 (下界, 上界, Q1, Q3, IQR)。"""
    q1, q3 = x.quantile(0.25), x.quantile(0.75)
    iqr = q3 - q1
    return q1 - k * iqr, q3 + k * iqr, q1, q3, iqr
python
x = d["capacity_ah"]
lo, hi, q1, q3, iqr = iqr_bounds(x)

真实输出:

text
Q1 = 2.1387 Ah
Q3 = 2.3997 Ah
IQR = 0.2610 Ah
正常区间 = [1.747, 2.791] Ah

低于下界:70 条
高于上界:0 条
合计    :70 条(0.50%)

计算验证:

下界 = 2.1387 - 1.5 × 0.2610 = 2.1387 - 0.3915 = 1.7472    ✅
上界 = 2.3997 + 1.5 × 0.2610 = 2.3997 + 0.3915 = 2.7912    ✅

33.4 ★ 读结论:"全在下侧"本身就是信息 ​

python
print(f"命中的最小值 = {x[x < lo].min()}")     # -0.5

命中的 70 条全是 -0.5 —— 这正是数据生成时注入的"容量 = -0.5"。

为什么"全在下侧"值得单独说:

分布形状含义例子
全在下侧有一批"不可能的小值"注入的 -0.5、传感器掉线(读数接近 0)
全在上侧有一批"虚高值"传感器漂移、单位错误
两侧都有分布本身很宽(长尾)真实的个体差异

所以"分下界/上界分别计数"这一步不能省:

python
below = int((x < lo).sum())
above = int((x > hi).sum())
print(f"低于下界 {below} 条,高于上界 {above} 条")
# 低于下界 70 条,高于上界 0 条

如果只报"命中 70 条",你丢掉了"方向"这个关键信息。

下界 1.747 已经很小了(容量正常在 2.0~2.5 之间), 而命中的全是 -0.5 —— 这不是"分布的长尾",而是"物理上不可能的值"。

★ 两类异常的区分:

物理上不可能(值域越界)→ 一定是错误 → 可以修/删
统计上偏离(值合法但极端)→ 需要核查 → 可能是真信号

33.5 同一套规则扫多列:命中的是 resistance_mohm,不是 temp_rise_c ​

python
for col in ("temp_rise_c", "resistance_mohm"):
    x = d[col]
    lo, hi, q1, q3, iqr = iqr_bounds(x)
    ...

真实输出:

text
temp_rise_c      Q1=  7.9900 Q3= 13.2000 IQR=  5.2100 界=[  0.175,  21.015] 低于   0 高于   0
resistance_mohm  Q1= 44.5800 Q3= 63.5600 IQR= 18.9800 界=[ 16.110,  92.030] 低于   0 高于  68
点开:为什么 temp_rise_c 命中 0 条也是一个重要结论?

temp_rise_c 区间外 0 条 —— 这不是"没跑出来",而是列干净。

报告里应该这样写:

"温度列未发现统计意义上的异常值(IQR 规则,上下界 [0.175, 21.015]℃)。"

为什么这个结论有价值:

① 它告诉你"这一列不需要额外处理"。

python
if (x < lo).sum() + (x > hi).sum() == 0:
    print(f"{col} 无需清洗")

② 它反过来验证了"脏数据是怎么来的"这个猜测。

dirty.csv 里只有两列被注入了异常:capacity_ah(置成负数)和 resistance_mohm(乘几倍)。扫一遍就能验证这个猜测:

python
# 温度列一条都没命中 → 印证"注入异常时没碰温度列"
for col in ("capacity_ah", "resistance_mohm", "temp_rise_c"):
    lo, hi, *_ = iqr_bounds(d[col])
    n = int(((d[col] < lo) | (d[col] > hi)).sum())
    print(f"{col:<18} 命中 {n} 条")

它确实没有污染 temp_rise_c —— 所以"0 条命中"是正确的。

这正是"异常检测"的另一个用途:验证数据管道。

如果 temp_rise_c 也命中了几十条,那说明:

  • 要么数据生成代码有 bug(没按预期注入);
  • 要么这一列本来就有真实的长尾(那 1.5 倍系数可能太严)。

③ 它的一个前提:IQR 只对"单一总体"有效。

如果温度数据混了两个工况(夏季/冬季),箱体本身就会很宽, 异常反而抓不出来。这时该做的是"分工况看",而不是调系数。

★ 把这个思路一般化:

python
def scan_outliers(df, num_cols, k=1.5):
    """对每一列跑 IQR,输出诊断表。"""
    rows = []
    for col in num_cols:
        x = df[col].dropna()
        lo, hi, q1, q3, iqr = iqr_bounds(x, k)
        below = int((x < lo).sum())
        above = int((x > hi).sum())
        rows.append({
            "column": col, "Q1": round(q1, 4), "Q3": round(q3, 4),
            "IQR": round(iqr, 4), "lower": round(lo, 4), "upper": round(hi, 4),
            "below": below, "above": above, "total": below + above,
        })
    return pd.DataFrame(rows).set_index("column")

一次扫完所有数值列,这是"数据体检"的标准动作。

33.6 ★ IQR 的稳健性:脏数据 vs 干净数据算出的边界几乎一样 ​

python
for col in ("capacity_ah", "resistance_mohm"):
    a = iqr_bounds(d[col])          # 脏数据
    b = iqr_bounds(clean[col])      # 干净数据
    print(f"{col:<16}{a[2]:>10.4f}{b[2]:>10.4f}{a[4]:>10.4f}{b[4]:>11.4f}")

真实输出:

text
列                    Q1(脏)    Q1(干净)    IQR(脏)    IQR(干净)     越界(脏)     越界(干净)
capacity_ah         2.1387    2.1405    0.2610     0.2596        70          0
resistance_mohm    44.5800   44.5200   18.9800    18.9000        68          0
点开:两个关键现象(这就是 IQR 取代 Z 分数的理由)

现象 ①:四分位数几乎不动

capacity_ah    Q1 差 0.0018,IQR 差 0.0014
resistance     Q1 差 0.0600,IQR 差 0.0800

即使数据里被注入了 70 个"容量 -0.5"和 68 个"内阻 200~560", 四分位数只动了一点点。

对比第 32 章:

标准差:脏数据 23.17 vs 干净数据 11.61   → 撑大 2 倍!
四分位数:几乎不变

现象 ②:干净数据"一个越界都没有"

capacity_ah      干净数据越界 0 条
resistance_mohm  干净数据越界 0 条

这说明"边界算得合理,不是硬凑的"。

如果干净数据也有一堆越界,那说明:

  • 系数 1.5 太严(对这份数据);
  • 或者这列本身有多峰/长尾结构。

★ 为什么 IQR 稳健

四分位数是"基于排序的位置统计量" —— 它只关心"排序后第几个位置的值"。

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

Q1(第 25% 位置)= 52
Q3(第 75% 位置)= 61
IQR = 9

把 999 改成 99999:
Q1 还是 52,Q3 还是 61,IQR 还是 9      ← 完全不受影响

对比均值和标准差:

原数据:均值 = 242,标准差 = 379
改后  :均值 = 20042,标准差 = 39928     ← 被彻底带偏

★ 一个结论性的对比表

统计量抗异常值(稳健)极值影响
均值❌大
标准差❌大
min / max❌极大(就是极值本身)
中位数✅小
四分位数 / IQR✅小
MAD✅小

所以:脏数据用中位数/IQR/MAD,干净数据用均值/标准差。

33.7 系数 k 是可调的,但必须写进文档 ​

python
for k in (1.0, 1.5, 2.0, 3.0):
    a, b, *_ = iqr_bounds(z, k)
    n = int(((z < a) | (z > b)).sum())

真实输出(对 resistance_mohm):

text
      k        下界        上界        命中       占比
    1.0     25.60     82.54       219    1.57%
    1.5     16.11     92.03        68    0.49%
    2.0      6.62    101.52        68    0.49%
    3.0    -12.36    120.50        68    0.49%
点开:为什么 k=1.5 之后命中数不再变化?

因为注入的异常值是"内阻 × 5~8 倍",原始内阻 40~88 mΩ → 异常值 200~700 mΩ。

k=1.5 上界 = 92.03    → 68 条 > 92.03(全部异常)
k=2.0 上界 = 101.52   → 68 条 > 101.52(还是全部异常,因为最小的是 200+)
k=3.0 上界 = 120.50   → 68 条 > 120.50(同上)

注入的异常值都远在 200 mΩ 以上,所以只要上界在 200 以下,命中数都是 68。

**这告诉我们:如果异常值和正常值之间的"间隔"很大,阈值就不敏感; 如果两者接近,阈值的选择就变得关键。

怎么判断自己的系数是否合适:

python
# 看命中值的分布:如果它们在边界附近很密集,说明阈值敏感
hits = z[z > hi_k15]
print(hits.describe())
# 如果 min 远大于上界 → 阈值不敏感,k 的选择不太重要
# 如果 min 就在上界附近 → 阈值敏感,需要谨慎定 k

★ 系数选择的规则

k对应正态下的越界率适用
1.0~4.6%宽松:想看"所有偏离的点"
1.5~0.7%Tukey 的标准值(默认)
2.0~0.2%较严
3.0~0.007%只抓"极端异常"

★ 最重要的一条纪律

调 k 等于改异常门槛,属于业务决策: 必须与业务方对齐,并写进文档。

为什么:

如果报告里只写"发现 68 条异常",读者无法判断这个数字的可靠性。
如果写"用 IQR 规则,k=1.5(Tukey 标准),内阻上界 92.03 mΩ,
        命中 68 条(0.49%)",读者就能:
  ① 判断阈值是否合理;
  ② 换成自己的场景时知道怎么调整;
  ③ 复现你的结果。

⚠ 偷偷调 k 让"异常看起来变少",是最常见的数据造假形式之一(哪怕是无意的)。

比如:

python
# 老板说"异常太多了,报告不好看"
for k in (1.5, 2.0, 2.5, 3.0):
    n = int((((z - med) / (1.4826 * mad)).abs() > k).sum())
    print(f"k={k}: {n} 条")
# k=3.0: 68 条 → "报告里写 k=3.0 吧,只有 68 条"

这是错的 —— 因为 k=3.0 抓的是"极端异常", 而 k=1.5 抓的是"一般异常",两个数字回答的不是同一个问题。

正确的做法:同时报告多个 k 下的结果,并说明选了哪个、为什么。

33.8 三个使用注意 ​

点开:IQR 的适用边界(三个"不该用"的情况)

注意 ①:小样本别用 IQR

python
small = pd.Series([45, 52, 61, 70, 85])       # 只有 5 个值
lo, hi, q1, q3, iqr = iqr_bounds(small)
print(f"Q1={q1} Q3={q3} IQR={iqr}")
# Q1=52.0 Q3=70.0 IQR=18.0

问题:5 个值算出的四分位数本身就是不稳的。 少一个值、多一个值,Q1/Q3 可能差很多。

经验规则:

每列至少几百行,再谈 IQR

本书数据每列 13967 行,完全够用(这也是为什么前面说"数据量小的时候不要谈异常检测")。

注意 ②:NaN 的处理,pandas 和 numpy 不一样

python
arr = z.to_numpy()                        # 含 NaN
print(z.quantile(0.25))                    # 44.58    ← pandas 自动跳过 NaN
print(np.percentile(arr, 25))              # nan      ← numpy 不跳过,结果是 nan!
print(np.nanpercentile(arr, 25))           # 44.58    ← 要用 nan 版本

规律:

工具对 NaN 的默认行为
pd.Series.quantile()自动跳过
np.percentile()不跳过 → 结果 nan
np.nanpercentile()跳过

这条差异在第 22 章(mean vs nanmean)出现过一次, 这里是它的第二个表现。

注意 ③:IQR 只负责"标出可疑",不负责"决定删不删"

这是本章最容易误解的一点:

python
# ❌ 把 IQR 当成"必须删除的规则"
d_clean = d[~((x < lo) | (x > hi))]

# ✅ IQR 的输出是"可疑清单",交给下一步决策
cases = d[(x < lo) | (x > hi)]
cases.to_csv("outputs/outlier_cases.csv", index=False)

为什么不能自动删(第 32 章讲过,这里再强调):

内阻 560 mΩ 的记录:
  · 可能是传感器故障 → 数据要修
  · 可能是真有问题的电芯 → 这是最有价值的样本!

删掉它,你就删掉了"你要找的东西"。

第 34 章会完整讲四种处置策略的取舍。

33.9 整合成一个可复用的函数 ​

python
def find_outliers(frame, col, k=1.5):
    """返回疑似异常的样本(含方向与边界),不修改原表。"""
    lo, hi, *_ = iqr_bounds(frame[col], k)
    mask = (frame[col] < lo) | (frame[col] > hi)
    out = frame.loc[mask, ["cell_id", "batch", "cycle", col]].copy()
    out["direction"] = np.where(out[col] < lo, "低于下界", "高于上界")
    return out

真实输出:

text
find_outliers(d, 'capacity_ah') → 70 条
cell_id batch  cycle  capacity_ah direction
   C001     A    622         -0.5 低于下界
   ...

方向分布:{'低于下界': 70}

这个函数的三个特点:

  1. 不修改原表(只返回可疑样本)—— 这是安全的设计;
  2. 带 direction 列("低于下界" / "高于上界")—— 保留方向信息;
  3. 可复用(换列名、换 k 都行)。

它会在第 34 章被继续使用 —— 用来生成"核查清单"。

33.10 完整示例与输出 ​

bash
cd code
python demos/ch33-iqr.py

真实输出(节选):

text
=== ② 对 capacity_ah 算一遍 ===
  Q1  = 2.1387 Ah
  Q3  = 2.3997 Ah
  IQR = 0.2610 Ah
  正常区间 = [1.747, 2.791] Ah
  低于下界:70 条
  高于上界:0 条

=== ③ ★ 读结论:「全在下侧」本身就是信息 ===
  而命中的 70 条全是 -0.5 —— 这正是生成数据时注入的「容量 = -0.5」

=== ⑤ 稳健性对比:脏数据 vs 干净数据算出的边界几乎一样 ===
  列                    Q1(脏)    Q1(干净)    IQR(脏)    IQR(干净)     越界(脏)     越界(干净)
  capacity_ah         2.1387    2.1405    0.2610     0.2596        70          0
  resistance_mohm    44.5800   44.5200   18.9800    18.9000        68          0
    ① 四分位数几乎不动(Q1 差 < 0.01)→ IQR 对异常值稳健
    ② 干净数据「一个越界都没有」→ 说明边界算得合理,不是硬凑的

本章练习 ​

练习 1:用 IQR 检查容量与温度
  1. 对 capacity_ah 算 Q1/Q3/IQR、正常区间、低于/高于下界各多少条;
  2. 解释"命中为什么全在下侧";
  3. 用同一套规则检查 temp_rise_c,解释 0 条命中;
  4. 对比干净数据算出的边界,验证 IQR 的稳健性。
参考答案
python
import numpy as np
import pandas as pd
from pathlib import Path

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


def iqr_bounds(x, k=1.5):
    """返回 (下界, 上界, Q1, Q3, IQR)。"""
    q1, q3 = x.quantile(0.25), x.quantile(0.75)
    iqr = q3 - q1
    return q1 - k * iqr, q3 + k * iqr, q1, q3, iqr


# ① capacity_ah
x = d["capacity_ah"]
lo, hi, q1, q3, iqr = iqr_bounds(x)
print(f"Q1={q1:.4f} Q3={q3:.4f} IQR={iqr:.4f}")
print(f"正常区间 = [{lo:.3f}, {hi:.3f}]")
below, above = int((x < lo).sum()), int((x > hi).sum())
print(f"低于下界 {below} 条,高于上界 {above} 条")      # 70, 0
print(f"命中的值 = {sorted(x[x < lo].unique())}")       # [-0.5]

# ② 全在下侧 = 物理上不可能的值,不是分布长尾
# ③ temp_rise_c
y = d["temp_rise_c"]
lo2, hi2, _, _, _ = iqr_bounds(y)
n2 = int(((y < lo2) | (y > hi2)).sum())
print(f"temp_rise_c 区间外 {n2} 条")                    # 0
print("→ 这一列干净,无需处理(这也是一个结论)")

# ④ 稳健性对比
for col in ("capacity_ah", "resistance_mohm"):
    a, b = iqr_bounds(d[col]), iqr_bounds(clean[col])
    out_clean = int(((clean[col] < b[0]) | (clean[col] > b[1])).sum())
    print(f"{col}: Q1 差 {abs(a[2] - b[2]):.4f},"
          f"IQR 差 {abs(a[4] - b[4]):.4f},干净数据越界 {out_clean} 条")

真实输出:

text
Q1=2.1387 Q3=2.3997 IQR=0.2610
正常区间 = [1.747, 2.791]
低于下界 70 条,高于上界 0 条
命中的值 = [-0.5]
temp_rise_c 区间外 0 条
capacity_ah: Q1 差 0.0018,IQR 差 0.0014,干净数据越界 0 条
resistance_mohm: Q1 差 0.0600,IQR 差 0.0800,干净数据越界 0 条

四个要点:

  1. "全在下侧 + 值全是 -0.5" → 这是注入的错误值,不是真实的分布长尾。 分方向计数才能看出这一点。
  2. temp_rise_c 命中 0 条是结论 —— 这一列干净,无需清洗。 它反过来验证了"注入异常时确实没碰温度列"。
  3. 四分位数几乎不动(Q1 差 0.0018 / 0.0600)—— 对比第 32 章"标准差被撑大 2 倍",这就是 IQR 稳健性的实证。
  4. 干净数据一个越界都没有 —— 边界算得合理。

完整练习:exercises/ex33-01-iqr

练习 2:k 该取多少?

对 resistance_mohm 分别用 k = 1.0 / 1.5 / 2.0 / 3.0 算命中数, 说说你会选哪个、为什么、以及要在报告里写什么。

参考答案
python
z = d["resistance_mohm"]
print(f"{'k':>5}{'下界':>10}{'上界':>10}{'命中':>10}{'占比':>9}")
for k in (1.0, 1.5, 2.0, 3.0):
    a, b, *_ = iqr_bounds(z, k)
    n = int(((z < a) | (z > b)).sum())
    print(f"{k:>5.1f}{a:>10.2f}{b:>10.2f}{n:>10,d}{n / len(d):>9.2%}")

真实输出:

text
    k        下界        上界        命中       占比
  1.0     25.60     82.54       219    1.57%
  1.5     16.11     92.03        68    0.49%
  2.0      6.62    101.52        68    0.49%
  3.0    -12.36    120.50        68    0.49%

我的选择:k = 1.5(Tukey 标准值)。

理由:

  1. 它是行业惯例 —— 箱线图默认用的就是 1.5,别人一看就懂;
  2. 对应正态分布下约 0.7% 的越界率 —— 合理的"异常比例";
  3. k=1.0 太松(1.57%)—— 会把一些正常的边界值也抓进来;
  4. k=2.0/3.0 在这里没区别(都是 68)—— 因为注入的异常值都远在 200 mΩ 以上, 上界在 92~121 之间都拦得住。但换个数据就不一定了。

报告里必须写清楚:

"使用 IQR 规则(Tukey 1.5 倍), 内阻的 Q1=44.58、Q3=63.56、IQR=18.98 mΩ, 上界 = Q3 + 1.5×IQR = 92.03 mΩ。 超过上界的记录 68 条(占 0.49%)。"

★ 为什么"写清楚 k"这么重要

如果只写"发现 68 条内阻异常":

  • 读者不知道阈值是多少;
  • 无法判断"68"是多还是少;
  • 换成自己的数据时不知道该用什么阈值。

如果写清楚了:

  • 读者能验证:63.56 + 1.5 × 18.98 = 92.03 ✅
  • 读者能判断:0.49% 的异常比例是合理的;
  • 读者能迁移:在(比如)温度数据上用同样的规则。

★ 一条纪律

任何"阈值"出现在报告里,都必须同时给出: ① 用什么统计量算的;② 系数是多少;③ 命中多少条、占比多少。

这三条合起来,才是"可复现的异常检测"。

⚠ 一个反面案例:

python
# 老板:"68 条太多了,报告不好看"
# 你:"那我改成 k=3.0"  → 还是 68 条(因为数据本身的性质)
# 你:"那我改成 k=5.0"  → 可能变成 0 条
a, b, *_ = iqr_bounds(z, 5.0)
print(f"k=5.0 上界 = {b:.2f},命中 {int((z > b).sum())} 条")

如果为了"数字好看"而调大 k,那就是在掩盖问题。正确的做法是:报告真实数字,同时说明"这些异常经核查后是 XX 性质"。

练习 3:IQR 还是 Z 分数?

对下面三种情况,选择 IQR 或 Z 分数,并说明理由:

  1. 内阻数据,异常比例约 0.5%,分布近似正态;
  2. 容量数据,异常比例约 8%,分布明显右偏;
  3. 只有 50 行的小样本数据。
参考答案
情况选择理由
1两者都行(任选,建议 Z)异常少、近似正态 → Z 分数工作良好
2IQR(或中位数+MAD)异常比例高 + 右偏 → Z 分数的均值和标准差会被严重带偏
3都不太合适小样本下四分位数和标准差都不稳定 → 先用物理规则,或增加数据

情况 1 的代码:

python
m, s = x.mean(), x.std()
z = (x - m) / s
mask = z.abs() > 3

情况 2 的关键:第 32 章的受控实验已经证明——

污染 5%  → 普通 Z 应抓 698,实抓 512(漏 186)
污染 10% → 普通 Z 应抓 1396,实抓 617(漏掉一半以上)

而稳健方法(IQR / 中位数+MAD)在同等污染下依然全部抓到。

情况 3 的代码:

python
small = pd.Series([45, 52, 61, 70, 85])
print(f"n={len(small)}, Q1={small.quantile(.25)}, Q3={small.quantile(.75)}")
# Q1/Q3 只由排序后的第 1~2 个值决定,极不稳定

小样本该怎么办:

python
# ① 优先用物理规则(不依赖统计)
mask = (x < 10) | (x > 200)

# ② 如果要用统计方法,用更宽的阈值并明确声明
lo, hi = x.quantile(0.05), x.quantile(0.95)

# ③ 或者干脆不做异常检测,先解决"数据太少"这个更大的问题

★ 一个决策流程图

异常比例知道吗?
  ├─ 不知道 → 先跑 Z 分数和 IQR,看两者命中是否一致
  │            一致 → 用任一(分布没有严重污染)
  │            不一致 → 用 IQR(说明有污染)
  └─ 已知
      ├─ < 1%  → Z 分数
      ├─ 1~5%  → IQR 或中位数+MAD
      └─ > 5%  → 停下来想:这可能是"两个总体混在一起"
                   该去查数据来源,而不是继续调阈值

每列至少几百行吗?
  ├─ 是 → 可以用上述方法
  └─ 否 → 只用物理规则,或先增加数据

★ 最重要的一句话

异常检测方法的选择,取决于"数据的污染程度"和"样本量", 不存在"永远最好"的方法。

所以真正的技能不是"背住 IQR 的公式", 而是"知道什么情况下该用哪个、以及为什么"。

面向 AI 开发:IQR 是"数据体检"里最可靠的那把尺子 ​

在 AI 项目里,IQR 有三个别的统计量替代不了的用途。

① 它是"箱线图"的数学定义 —— 所以图和数永远一致。

python
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.boxplot([df.loc[df["batch"] == b, "resistance_mohm"].to_numpy()
            for b in sorted(df["batch"].unique())])

# 图上箱体的上下边就是 Q1/Q3,须就是 ±1.5×IQR
# 所以"图上的异常点"和"IQR 算出的异常"必然一致 —— 不会出现图数矛盾

这个性质在"报告"里特别重要(第 39 章会用到): 图、数字、结论三者天然一致,不需要额外对齐。

② 它是"稳健缩放"的基础(RobustScaler)。

python
from sklearn.preprocessing import RobustScaler

scaler = RobustScaler()                       # 用中位数和 IQR
X_scaled = scaler.fit_transform(X)

对比三种 Scaler:

Scaler公式抗异常值
StandardScaler(x - μ) / σ❌
MinMaxScaler(x - min) / (max - min)❌ 最差(min/max 就是极值!)
RobustScaler(x - median) / IQR✅

什么时候必须用 RobustScaler:

python
# 数据里有异常值,而你没有(或不想)先清洗
X = df[["resistance_mohm", "temp_rise_c", "capacity_ah"]].to_numpy()
# 如果直接 StandardScaler,那个 562 mΩ 会把内阻列的 σ 撑大 2 倍
# → 标准化后这一列的"有效范围"被压缩,模型学不好
# → 用 RobustScaler,中位数和 IQR 不受影响

⚠ 但要注意:

python
# RobustScaler 不能让"异常值"消失 —— 它只是让缩放不受异常值影响
# 562 mΩ 标准化后仍然是 562 附近 → 仍然是个极端值
# 所以该清洗还是要清洗(第 30、34 章)

③ 它是"数据漂移监控"的稳健指标。

第 32 章给的漂移检测用的是"均值偏移几个标准差" —— 但如果线上出现了异常值,那个指标会被污染。

用 IQR 版本更稳:

python
def robust_drift_report(df_new, baseline):
    """用中位数和 IQR 检测漂移 —— 对异常值不敏感。"""
    rows = []
    for col, stats in baseline.items():
        med0, iqr0 = stats["median"], stats["iqr"]
        med1 = df_new[col].median()
        q1, q3 = df_new[col].quantile(0.25), df_new[col].quantile(0.75)
        iqr1 = q3 - q1
        rows.append({
            "column": col,
            "median_baseline": round(med0, 3),
            "median_current": round(med1, 3),
            "median_shift_iqr": round((med1 - med0) / iqr0, 2),   # 偏移几个 IQR
            "iqr_ratio": round(iqr1 / iqr0, 3),                    # 离散度变化
        })
    return pd.DataFrame(rows)

median_shift_iqr 的含义:当前中位数偏离基线多少个 IQR。

值判断
< 0.2正常
0.2 ~ 0.5留意
> 0.5显著漂移

为什么用 IQR 而不是标准差做单位:因为线上数据可能突然出现一堆异常值, 用标准差做单位会被"稀释",而 IQR 不受影响。

★ 三个统计量的适用场景总结

统计量用途抗异常值
均值 / 标准差干净数据的描述统计、Z 分数❌
中位数 / IQR / MAD脏数据的描述统计、稳健标准化、漂移监控✅
min / max只在"确认数据干净"时用❌❌

★ 一句话总结本章

IQR 的价值不在于"公式更简单", 而在于"它的结果不依赖数据里有没有异常值"。

这个性质在真实数据上极其重要 —— 因为你永远无法提前知道"数据有多脏"。用稳健的方法,就不用担心这个问题。


下一章:第 34 章 · 异常处置策略对照 + 落盘