第 33 章 · IQR 异常检测(箱线图规则)
这一章要解决什么:用 IQR(四分位距)找异常 —— 它比 Z 分数稳健得多。 学会读"命中全在下侧"这类信息,以及理解"某一列命中 0 条"也是一个结论。
本章你需要完成的代码:demos/ch33-iqr.py
33.1 四分位数:把数据四等分
| 名称 | 又叫 | 含义 |
|---|---|---|
| Q1 | 25% 分位 | 25% 的数据小于它 |
| Q2 | 50% 分位 / 中位数 | 一半数据比它小 |
| Q3 | 75% 分位 | 75% 的数据小于它 |
| IQR | 四分位距 | IQR = Q3 - Q1 |
IQR 的含义:中间那 50% 数据的跨度 —— 衡量"正常波动范围"。
33.2 箱线图就是这套东西的图像版
○ ← 上须外的点 = 异常值
│
┌───┴───┐ ← 上须 = Q3 + 1.5×IQR
│ │ (或范围内最远的实际数据点)
├───────┤ ← 箱体上边 = Q3(75% 分位)
│ │
├───────┤ ← 箱体内横线 = 中位数(Q2)
│ │
└───┬───┘ ← 箱体下边 = Q1(25% 分位)
│
○ ← 下须 = Q1 - 1.5×IQR读法三句话:
- 箱子高度 = IQR = 中间 50% 数据的范围 → 离散度;
- 箱子里的横线 = 中位数 → 中心位置;
- 须外的点 = 异常值 → 这就是本章要找的东西。
Tukey 的 1.5 倍规则(John Tukey,箱线图的发明者):
1.5 这个系数的来历:在正态分布下,Q1 - 1.5×IQR 大约对应 -2.7σ, 越界率约 0.7%。所以它是一个"不松不紧"的经验值。
33.3 对 capacity_ah 算一遍
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, iqrx = d["capacity_ah"]
lo, hi, q1, q3, iqr = iqr_bounds(x)真实输出:
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 ★ 读结论:"全在下侧"本身就是信息
print(f"命中的最小值 = {x[x < lo].min()}") # -0.5命中的 70 条全是 -0.5 —— 这正是数据生成时注入的"容量 = -0.5"。
为什么"全在下侧"值得单独说:
| 分布形状 | 含义 | 例子 |
|---|---|---|
| 全在下侧 | 有一批"不可能的小值" | 注入的 -0.5、传感器掉线(读数接近 0) |
| 全在上侧 | 有一批"虚高值" | 传感器漂移、单位错误 |
| 两侧都有 | 分布本身很宽(长尾) | 真实的个体差异 |
所以"分下界/上界分别计数"这一步不能省:
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
for col in ("temp_rise_c", "resistance_mohm"):
x = d[col]
lo, hi, q1, q3, iqr = iqr_bounds(x)
...真实输出:
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]℃)。"
为什么这个结论有价值:
① 它告诉你"这一列不需要额外处理"。
if (x < lo).sum() + (x > hi).sum() == 0:
print(f"{col} 无需清洗")② 它反过来验证了"脏数据是怎么来的"这个猜测。
dirty.csv 里只有两列被注入了异常:capacity_ah(置成负数)和 resistance_mohm(乘几倍)。扫一遍就能验证这个猜测:
# 温度列一条都没命中 → 印证"注入异常时没碰温度列"
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 只对"单一总体"有效。
如果温度数据混了两个工况(夏季/冬季),箱体本身就会很宽, 异常反而抓不出来。这时该做的是"分工况看",而不是调系数。
★ 把这个思路一般化:
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 干净数据算出的边界几乎一样
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}")真实输出:
列 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 是可调的,但必须写进文档
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):
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。
**这告诉我们:如果异常值和正常值之间的"间隔"很大,阈值就不敏感; 如果两者接近,阈值的选择就变得关键。
怎么判断自己的系数是否合适:
# 看命中值的分布:如果它们在边界附近很密集,说明阈值敏感
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 让"异常看起来变少",是最常见的数据造假形式之一(哪怕是无意的)。
比如:
# 老板说"异常太多了,报告不好看"
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
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 不一样
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 只负责"标出可疑",不负责"决定删不删"
这是本章最容易误解的一点:
# ❌ 把 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 整合成一个可复用的函数
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真实输出:
find_outliers(d, 'capacity_ah') → 70 条
cell_id batch cycle capacity_ah direction
C001 A 622 -0.5 低于下界
...
方向分布:{'低于下界': 70}这个函数的三个特点:
- 不修改原表(只返回可疑样本)—— 这是安全的设计;
- 带
direction列("低于下界" / "高于上界")—— 保留方向信息; - 可复用(换列名、换 k 都行)。
它会在第 34 章被继续使用 —— 用来生成"核查清单"。
33.10 完整示例与输出
cd code
python demos/ch33-iqr.py真实输出(节选):
=== ② 对 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 检查容量与温度
- 对
capacity_ah算 Q1/Q3/IQR、正常区间、低于/高于下界各多少条; - 解释"命中为什么全在下侧";
- 用同一套规则检查
temp_rise_c,解释 0 条命中; - 对比干净数据算出的边界,验证 IQR 的稳健性。
参考答案
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} 条")真实输出:
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 条四个要点:
- "全在下侧 + 值全是 -0.5" → 这是注入的错误值,不是真实的分布长尾。 分方向计数才能看出这一点。
temp_rise_c命中 0 条是结论 —— 这一列干净,无需清洗。 它反过来验证了"注入异常时确实没碰温度列"。- 四分位数几乎不动(Q1 差 0.0018 / 0.0600)—— 对比第 32 章"标准差被撑大 2 倍",这就是 IQR 稳健性的实证。
- 干净数据一个越界都没有 —— 边界算得合理。
完整练习:exercises/ex33-01-iqr
练习 2:k 该取多少?
对 resistance_mohm 分别用 k = 1.0 / 1.5 / 2.0 / 3.0 算命中数, 说说你会选哪个、为什么、以及要在报告里写什么。
参考答案
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%}")真实输出:
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.5,别人一看就懂;
- 对应正态分布下约 0.7% 的越界率 —— 合理的"异常比例";
k=1.0太松(1.57%)—— 会把一些正常的边界值也抓进来;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% 的异常比例是合理的;
- 读者能迁移:在(比如)温度数据上用同样的规则。
★ 一条纪律
任何"阈值"出现在报告里,都必须同时给出: ① 用什么统计量算的;② 系数是多少;③ 命中多少条、占比多少。
这三条合起来,才是"可复现的异常检测"。
⚠ 一个反面案例:
# 老板:"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 分数,并说明理由:
- 内阻数据,异常比例约 0.5%,分布近似正态;
- 容量数据,异常比例约 8%,分布明显右偏;
- 只有 50 行的小样本数据。
参考答案
| 情况 | 选择 | 理由 |
|---|---|---|
| 1 | 两者都行(任选,建议 Z) | 异常少、近似正态 → Z 分数工作良好 |
| 2 | IQR(或中位数+MAD) | 异常比例高 + 右偏 → Z 分数的均值和标准差会被严重带偏 |
| 3 | 都不太合适 | 小样本下四分位数和标准差都不稳定 → 先用物理规则,或增加数据 |
情况 1 的代码:
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 的代码:
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 个值决定,极不稳定小样本该怎么办:
# ① 优先用物理规则(不依赖统计)
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 有三个别的统计量替代不了的用途。
① 它是"箱线图"的数学定义 —— 所以图和数永远一致。
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)。
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:
# 数据里有异常值,而你没有(或不想)先清洗
X = df[["resistance_mohm", "temp_rise_c", "capacity_ah"]].to_numpy()
# 如果直接 StandardScaler,那个 562 mΩ 会把内阻列的 σ 撑大 2 倍
# → 标准化后这一列的"有效范围"被压缩,模型学不好
# → 用 RobustScaler,中位数和 IQR 不受影响⚠ 但要注意:
# RobustScaler 不能让"异常值"消失 —— 它只是让缩放不受异常值影响
# 562 mΩ 标准化后仍然是 562 附近 → 仍然是个极端值
# 所以该清洗还是要清洗(第 30、34 章)③ 它是"数据漂移监控"的稳健指标。
第 32 章给的漂移检测用的是"均值偏移几个标准差" —— 但如果线上出现了异常值,那个指标会被污染。
用 IQR 版本更稳:
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 的价值不在于"公式更简单", 而在于"它的结果不依赖数据里有没有异常值"。
这个性质在真实数据上极其重要 —— 因为你永远无法提前知道"数据有多脏"。用稳健的方法,就不用担心这个问题。