第 34 章 · 异常处置策略对照 + 落盘
这一章要解决什么:找出可疑样本之后,决定怎么处理它们。 本章没有新语法,但要建立一个重要的认知:发现异常是统计问题,处理异常是业务问题。
本章你需要完成的代码:demos/ch34-policy.py
34.1 先圈出"可疑样本":物理规则 + 统计规则
x = d["resistance_mohm"]
q1, q3 = x.quantile(0.25), x.quantile(0.75)
r_hi = q3 + 1.5 * (q3 - q1)
rule_phys = d["capacity_ah"] < 0 # 物理规则:容量不可能是负数
rule_stat = d["resistance_mohm"] > r_hi # 统计规则:超过 IQR 上须
mask = rule_phys | rule_stat # 并集(| 不是 or!)真实输出:
内阻 IQR 上须 r_hi = 92.030 mΩ
物理规则命中(容量 < 0) :70 条
统计规则命中(内阻 > 上须) :68 条
并集(可疑样本) :138 条(0.99%)
两条都命中的 :0 条点开:为什么要两条规则并用?顺序为什么不能反?
两类规则的分工
| 规则类型 | 判据 | 依赖分布吗 | 可靠性 |
|---|---|---|---|
| 物理规则 | 值域("容量必须 > 0") | ❌ 不依赖 | 最高 |
| 统计规则 | 分布(IQR / Z 分数) | ✅ 依赖 | 依赖数据质量 |
物理规则先跑,因为它不需要看数据长什么样。
一个具体的例子:
容量 = -0.5 Ah
物理规则:-0.5 < 0 → 立刻抓出来,不需要任何统计
统计规则:如果用 Z 分数,-0.5 这个值会参与均值和标准差的计算,
反而把阈值撑宽了(第 32 章的受控实验就是讲这个)如果只用统计规则:
# 只用 IQR 检查内阻 → 68 条
# 但「容量为负」这 70 条根本不会被发现(因为它们在 capacity_ah 列,不在内阻列)所以"物理规则"的另一个作用是:覆盖那些"统计规则不会去看的列"。
| 不是 or
mask = (d["capacity_ah"] < 0) | (d["resistance_mohm"] > r_hi) # ✅
mask = (d["capacity_ah"] < 0) or (d["resistance_mohm"] > r_hi) # ❌ ValueErroror 对 Series 会报:
ValueError: The truth value of a Series is ambiguous.
Use a.empty, a.bool(), a.item(), a.any() or a.all().原因和第 26 章完全一样:or 需要"整体是真还是假", 而 Series 有 13967 个 True/False。
顺便验证一下:
try:
d[rule_phys or rule_stat]
except ValueError as exc:
print(f"用 or → ValueError: {str(exc)[:70]}…")"两条都命中 0 条"是什么意思
并集 138 条 = 物理规则 70 条 + 统计规则 68 条(两者没有重叠)这说明两个问题是"正交"的:
- 容量为负的问题(70 条)都发生在容量列;
- 内阻越界的问题(68 条)都发生在内阻列;
- 没有任何一行同时有两个问题。
这是数据生成代码的结果(make_dirty() 里两批各占一半、索引不重叠), 但它也提示了一个实践要点:多列异常可能重叠也可能不重叠, 要分别统计,不能只报一个总数。
34.2 可疑样本分布在哪些电芯上
vc = d.loc[mask, "cell_id"].value_counts()
print(f"涉及 {d.loc[mask, 'cell_id'].nunique()} 支电芯,"
f"每支 {vc.min()} ~ {vc.max()} 条")真实输出:
涉及 12 支电芯,每支 6 ~ 16 条点开:这个"分布统计"才是处置策略的依据
两种分布对应两种完全不同的原因:
情况 A:均匀分布(本数据)
C001 ████████ 12
C002 ██████ 9
C003 ██████████ 15
...
→ 「采集/录入环节的随机错误」
→ 处理方式:修数据管道(检查传感器、录入流程)
情况 B:集中在少数几支
C001 ████████████████████████████████████████ 800
C002 █ 3
C003 █ 2
...
→ 「这几支电芯真的有问题」
→ 处理方式:去查产线、上复检、加人工检查本数据是情况 A(每支 6~16 条,分布均匀) —— 所以结论是:
"138 条可疑记录均匀分布在全部 12 支电芯上(每支 6~16 条), 符合数据采集/录入环节的随机错误特征, 而非某几支电芯的个体质量缺陷。"
★ 为什么这个判断决定了"处置策略"
| 原因 | 该做什么 |
|---|---|
| 采集错误 | 修数据(清洗、重采、修正管道) |
| 电芯缺陷 | 查产线(工艺、来料、复检) |
如果误判:
把"采集错误"当"电芯缺陷" → 白费功夫去查产线
把"电芯缺陷"当"采集错误" → 直接用统计方法修掉,把真问题掩盖了!后者的后果严重得多 —— 你可能"修好"了一批真有问题的电芯数据, 让它们看起来正常。
★ 怎么判断是哪种:
def diagnose_outlier_pattern(df, mask, group_col="cell_id"):
"""判断异常是"均匀随机"还是"集中在少数" """
vc = df.loc[mask, group_col].value_counts()
n_groups = df[group_col].nunique()
n_hit = len(vc)
total = len(df)
# 期望:如果是均匀随机,每组应该有 total * mean_rate 条
expected = mask.sum() / n_groups
ratio = vc.max() / expected
print(f"命中组数 {n_hit}/{n_groups},每组平均 {expected:.1f} 条,"
f"最多 {vc.max()} 条({ratio:.1f} 倍)")
if n_hit == n_groups and ratio < 3:
print("→ 均匀分布:随机错误(采集/录入环节)")
elif ratio > 5:
print("→ 集中分布:那几支可能真有问题(查产线)")
else:
print("→ 介于两者之间:需要人工判断")
return vc这个判据用了一个简单的统计量:最多的一组 / 平均每组。
- 均匀随机 → 接近 1~3 倍;
- 集中 → 5 倍以上。
经验规则,不是定理 —— 但它能快速提示你该往哪个方向调查。
34.3 四种处置策略,逐一看效果
# A) 删除:把可疑行整行丢掉
A = d.loc[~mask].copy()
# B) 截断:把越界的值夹到边界(样本保留)
B = d.copy()
B.loc[B["capacity_ah"] < 0, "capacity_ah"] = np.nan # 负容量没有合理的夹取目标
B["resistance_mohm"] = B["resistance_mohm"].clip(upper=r_hi)
# C) 标记:主表不动,加一列 is_outlier
C = d.copy()
C["is_outlier"] = mask
# D) 单独分析:主表不动,把可疑样本另存一张清单
D_ = d.copy()真实输出:
策略 行数 内阻均值 内阻标准差 内阻最大
原始脏数据 13,967 56.18 23.17 562.61
A 删除 13,829 54.85 11.62 87.57
B 截断 13,967 55.02 11.87 92.03
C/D 标记 13,967 56.18 23.17 562.61点开:逐行读这张表(四种策略的代价一目了然)
A 删除 → 行数变少,统计量恢复正常
行数:13967 → 13829(丢了 138 行)
内阻均值:56.18 → 54.85(回到干净数据的 54.84 附近 ✅)
内阻标准差:23.17 → 11.62(回到干净数据的 11.61 ✅)
内阻最大:562.61 → 87.57(干净数据也是 87.57 ✅)效果好 —— 统计量完全恢复。
代价:
- 丢样本量(138 行 × 9 个字段 = 1242 个值);
- 可能丢真信号 —— 如果那 68 条高内阻里有一支真的坏了,你就把它删了。
B 截断 → 行数不变,但分布被人为改变
行数:13967(不变)
内阻均值:56.18 → 55.02(接近干净值)
内阻标准差:23.17 → 11.87(接近干净值 ✅)
内阻最大:562.61 → 92.03(被压到上须)看起来"两全其美":行数没少,统计量也正常了。
但有三个问题:
① 分布被改变了。
# 原来内阻最大 562.61,现在最大 92.03
# 119 个"被夹住"的值全部等于 92.03 —— 形成了一个虚假的尖峰
print(f"等于 92.03 的记录数: {int((B['resistance_mohm'] == r_hi).sum())}")② "假装它没坏"。
# 内阻 562 mΩ 意味着这支电芯可能真的坏了
# 夹到 92.03 之后,它在数据里看起来"只是偏高"
# → 如果用它做寿命预测,会低估风险③ 对某些列没有合理的截断目标。
# 容量 = -0.5 Ah → 夹到多少?
B.loc[B["capacity_ah"] < 0, "capacity_ah"] = np.nan # 只能置为缺失
# 因为 0 也是不可能的(容量必须 > 0)⚠ 所以"截断"适合"值轻微越界、且边界有物理意义"的情况, 比如"温度读数 105℃ 明显是错的,夹到 60℃ 更合理"。
不适合"值完全不可能"的情况(负容量、内阻 562)。
C/D 标记 → 统计量完全不变
行数:13967(不变)
内阻均值:56.18(不变)
内阻标准差:23.17(不变)
内阻最大:562.61(不变)因为主表根本没动 —— 只是加了一列标记(C),或者导出了一张清单(D)。
"统计量不变"看起来像是"没处理",但这正是它的价值:
- 信息一点没丢 —— 原始值还在,随时可以回溯;
- 下游可以自己决定怎么用 —— 建模时排除
is_outlier的行, 或者用样本权重降低它们的影响; - 可以人工核查 —— 导出清单交给懂业务的人看。
★ 四种策略的对比总结
| 策略 | 行数 | 信息损失 | 可回溯 | 适合 |
|---|---|---|---|---|
| A 删除 | ↓ | 大 | ❌ 不可逆 | 确认是录入错误 |
| B 截断 | = | 中(值被改) | ❌ 需保留原始副本 | 值轻微越界 |
| C 标记 | = | 无 | ✅ | 不确定时 |
| D 单独分析 | = | 无 | ✅ | 工业首选 |
★ 关键认知:
没有"正确"的策略,只有"与目的匹配"的策略。
34.4 策略选择:一张决策表
┌────────────────┬──────────────────────────┬────────────────────────┐
│ 情况 │ 推荐策略 │ 理由 │
├────────────────┼──────────────────────────┼────────────────────────┤
│ 确认是录入错误 │ A 删除(并修数据管道) │ 值不可能是真的 │
│ 值错但样本宝贵 │ B 截断 或 缺失后插值 │ 保住样本量 │
│ 不确定是不是错 │ C 标记 + D 单独分析 │ 先别动,交人工核查 │
│ 异常本身就是目标│ D 单独分析(甚至保留) │ 故障检测里异常是正样本 │
└────────────────┴──────────────────────────┴────────────────────────┘★ 工业项目的默认答案:C + D(主表标记 + 导出核查清单)。
原因很简单:
删数据是不可逆的,而"先标记、后核查"永远有退路。
点开:最后一行"异常本身就是目标"是什么意思?
在"故障检测"这类任务里,异常才是你要找的东西。
普通的预测任务:异常 = 需要清洗的噪声
故障检测任务 :异常 = 正样本(我要预测的目标!)一个具体的例子:
# 任务:预测"哪支电芯会提前失效"
# 那么"内阻异常升高"的记录,恰恰是"即将失效"的信号
# ❌ 如果按常规流程把它们当噪声删掉
df_clean = df[~outlier_mask]
model.fit(df_clean[FEATURES], df_clean["failed"])
# → 你把所有的正样本都删掉了!模型学不到任何东西
# ✅ 正确做法:保留异常,甚至重点关注
df["is_anomaly"] = outlier_mask
model.fit(df[FEATURES], df["failed"]) # 异常样本是重要信号
print(df.groupby("is_anomaly")["failed"].mean()) # 看异常组的失效率★ 所以在"清洗数据"之前,必须先问一句:
"这些异常值,是噪声还是信号?"
判断方法:
| 问自己 | 如果"是" |
|---|---|
| 这个值和业务目标有关吗? | 它可能是信号,不能删 |
| 它对应一个真实的物理状态吗? | 是 → 保留 |
| 它只可能是测量/录入错误吗? | 是 → 可以修 |
在本课程的数据里:
- 容量 = -0.5 → 物理上不可能 → 确定是错误 → 可以修
- 内阻 562 mΩ → 物理上可能(虽然极端)→ 需要核查
所以本章的方案是:
# 容量为负 → 确定是错误,按"缺失"处理(第 30 章会插值修复)
# 内阻越界 → 只标记,不删,导出清单这种"分类处置"才是专业做法 —— 而不是一刀切地"删掉所有异常"。
34.5 工业首选方案的完整实现
d2 = d.copy()
d2["is_outlier"] = mask
d2["outlier_reason"] = np.select(
[rule_phys & rule_stat, rule_phys, rule_stat],
["容量为负 + 内阻越界", "容量为负", "内阻越界"],
default="",
)真实输出:
标记后的样本(前 5 条):
cell_id cycle capacity_ah resistance_mohm is_outlier outlier_reason
C005 890 1.6822 452.13 True 内阻越界
...
原因分布:
内阻越界 68
容量为负 70cases_path = OUT / "outlier_cases.csv"
d2.loc[mask].to_csv(cases_path, index=False)
print(f"核查清单已导出:{cases_path.name}({int(mask.sum())} 条)")核查清单已导出:outlier_cases.csv(138 条,10 KB)点开:为什么一定要加 outlier_reason 这一列?
只写 is_outlier = True/False 的问题:
核查的人拿到清单,看到一行"容量 = -0.5",他要想:
· 是容量传感器坏了?
· 是录入时写错了?
· 还是别的字段错了我没看出来?写了原因之后:
cell_id capacity_ah resistance_mohm outlier_reason
C001 -0.5000 45.20 容量为负
C005 1.6822 452.13 内阻越界核查的人一眼知道该查什么传感器、该看哪条记录。
这 3 行代码(np.select 生成原因列)的价值, 比后面所有分析加起来还大 —— 因为它决定了"这份清单能不能被用起来"。
★ 一般化这条经验
凡是"标记"类的输出,都要带上"为什么被标记"。
同类例子:
| 输出 | 只有标记 | 加上原因 |
|---|---|---|
| 异常清单 | is_outlier | outlier_reason |
| 校验失败 | is_valid=False | fail_reason(哪个规则没过) |
| 模型预测低置信 | low_confidence | reason(哪个特征异常) |
| 数据被丢弃 | dropped | drop_reason |
原因列的成本是几行代码,收益是"下游能用起来"。
★ np.select 的条件顺序(第 27 章讲过,这里再用一次):
np.select(
[rule_phys & rule_stat, # 两个都命中(最具体)
rule_phys, # 只命中物理规则
rule_stat], # 只命中统计规则
["容量为负 + 内阻越界", "容量为负", "内阻越界"],
default="",
)顺序必须是"从最具体到最一般" —— 否则 rule_phys 会先命中,rule_phys & rule_stat 的情况就永远轮不到。
在本数据上"两条都命中 0 条",所以顺序无关紧要, 但写成正确的顺序是好习惯(换个数据可能就有重叠了)。
34.6 被标记的行要不要参与统计?
main = d2.loc[~mask, "resistance_mohm"]
print(f"剔除可疑样本后:均值 {main.mean():.4f},标准差 {main.std():.4f},最大 {main.max():.2f}")
print(f"含可疑样本 :均值 {x.mean():.4f},标准差 {x.std():.4f},最大 {x.max():.2f}")
print(f"干净数据(真值):均值 {pd.read_csv(DATA / 'cycle_raw.csv')['resistance_mohm'].mean():.4f}")真实输出:
剔除可疑样本后:均值 54.8456,标准差 11.6193,最大 87.57
含可疑样本 :均值 56.1841,标准差 23.1703,最大 562.61
干净数据(真值):均值 54.8383剔除后的均值 54.8456 vs 真值 54.8383 —— 误差只有 0.007, 说明这次标记是准的(几乎把所有被注入的异常都找出来了)。
★ 但报告里必须写清楚用的是哪个口径。
两种表述方式:
❌ 不好的表述:
"内阻均值 54.85 mΩ。"
✅ 好的表述:
"剔除 68 条标记异常后,内阻均值 54.85 mΩ(标准差 11.62);
含异常值时均值为 56.18 mΩ(标准差 23.17)。
本报告后续分析均使用剔除异常后的数据。"为什么必须写:读者需要知道"你报的这个数字,是从多少数据里算出来的"。
★ 一个实用的做法:同时保留两份数据
# 主表:保留全部行 + 标记列(可回溯)
d2.to_csv(OUT / "cycle_clean_dirty.csv", index=False)
# 分析用:剔除可疑行
d2.loc[~mask].to_csv(OUT / "cycle_clean.csv", index=False)
# 核查清单
d2.loc[mask].to_csv(OUT / "outlier_cases.csv", index=False)三个文件的用途:
| 文件 | 用途 |
|---|---|
cycle_clean_dirty.csv | 完整数据 + 标记(可回溯、可重新决策) |
cycle_clean.csv | 分析用的干净数据(下游直接用) |
outlier_cases.csv | 核查清单(交给人看) |
这是"标记 + 单独分析"策略的完整落地。
34.7 留痕:清洗日志是交付物的一部分
log = {
"输入文件": "dirty.csv",
"总行数": len(d),
"检测规则": f"容量<0(物理) 或 内阻>{r_hi:.3f}(IQR 上须)",
"命中条数": int(mask.sum()),
"命中占比": f"{mask.mean():.2%}",
"涉及电芯数": int(d.loc[mask, "cell_id"].nunique()),
"处置策略": "C+D:主表加 is_outlier/outlier_reason 列,可疑样本导出核查清单",
"未做删除": True,
"输出文件": "outlier_cases.csv",
}真实输出:
输入文件 dirty.csv
总行数 13967
检测规则 容量<0(物理) 或 内阻>92.030(IQR 上须)
命中条数 138
命中占比 0.99%
涉及电芯数 12
处置策略 C+D:主表加 is_outlier/outlier_reason 列,可疑样本导出核查清单
未做删除 True
输出文件 outlier_cases.csv点开:为什么"清洗日志"值得单独一节?
一个真实的场景(第 30 章讲过类似的):
你:数据清洗完了,发现 138 条异常。
同事:你怎么处理的?
你:标记了一下。
同事:标记的依据是什么?阈值多少?后面分析用哪些数据?
你:(含糊)……用的是 IQR。
同事:IQR 的系数是几?命中多少?
你:……不太清楚。
同事:那这个结论我还不敢用。"处理了多少、用什么规则、怎么处置"这三件事决定了别人能否信任你的结果。
★ 一份合格的清洗日志应该包含
| 类别 | 字段 |
|---|---|
| 输入 | 文件名、行数、列数 |
| 诊断 | 缺失格数、各类异常的个数、占比 |
| 规则 | 用的什么规则(物理/统计)、阈值是多少、系数是几 |
| 命中 | 命中条数、占比、涉及多少实体 |
| 处置 | 每种异常分别怎么处理(删/截/标/单独分析) |
| 输出 | 产出了哪些文件、各自的用途 |
| 未做的事 | 明确声明"没有删除数据"这类关键决策 |
★ 为什么"未做的事"也要写
log["未做删除"] = True因为"我没有删数据"是一个重要的决策,读者需要知道:
- 如果他看到某个极端值,那是原始数据,不是你没发现;
- 如果他想要一份"剔除异常后"的数据,需要自己筛选
is_outlier。
同类需要声明的"未做的事":
log["未做插值"] = True # 保留了 NaN,交给下游决定
log["未做标准化"] = True # 特征保持原始量纲
log["未做样本加权"] = True # 所有样本权重相等★ 日志的格式:CSV 还是 Markdown?
| 格式 | 适合 |
|---|---|
| CSV | 机器可读、方便对比多次运行 |
| Markdown 表格 | 人可读、直接贴进报告(第 39 章的做法) |
本书两个都产出:
pd.Series(log).to_csv(OUT / "ch34_cleaning_log.csv", header=["值"]) # 机器读
# 第 39 章会把同样的内容渲染成 Markdown 表格写进报告★ 最后一条纪律
清洗日志不是"额外的文档工作", 它是数据处理流程的一部分 —— 和代码、数据同等重要。
没有日志的清洗,等于没做 —— 因为没人(包括三个月后的你)能判断这个结果可不可信。
34.8 常见坑
① 一上来就 dropna() + 删异常 → 把证据也删了,后面无法复盘、无法向业务解释
② 用 clip 前不看业务含义 → 内阻夹到 90 等于"假装它没坏",做寿命预测会低估风险
③ 在训练集算阈值、套到测试集用 → 阈值也是"从数据学来的",属于模型的一部分(第 32 章)
④ 忘记留痕 → 没写"处理了多少条、依据什么规则",报告等于没做
⑤ 用 or 连接条件 → 对 Series 会报 ValueError,必须用 |
⑥ 只标记不解释 → 只给 True/False,核查的人不知道该从哪查起。加一列原因验证 ⑤:
try:
d[rule_phys or rule_stat]
except ValueError as exc:
print(f"用 or → ValueError: {str(exc)[:70]}…")34.9 完整示例与输出
cd code
python demos/ch34-policy.py真实输出(节选):
=== ① 先圈出「可疑样本」:物理规则 + 统计规则 ===
内阻 IQR 上须 r_hi = 92.030 mΩ
并集(可疑样本) :138 条(0.99%)
两条都命中的 :0 条
=== ② 可疑样本分布在哪些电芯上? ===
涉及 12 支电芯,每支 6 ~ 16 条。
★ 均匀分布(像本数据)→ 采集/录入环节的随机错误 → 修数据管道
集中在少数几支 → 那几支真的有问题 → 去查产线 / 上复检
=== ③ 四种处置策略,逐一看效果 ===
策略 行数 内阻均值 内阻标准差 内阻最大
原始脏数据 13,967 56.18 23.17 562.61
A 删除 13,829 54.85 11.62 87.57
B 截断 13,967 55.02 11.87 92.03
C/D 标记 13,967 56.18 23.17 562.61
=== ⑤ 工业首选方案的完整实现 ===
核查清单已导出:outlier_cases.csv(138 条,10 KB)
=== ⑥ 被标记的行要不要参与统计? ===
剔除可疑样本后:均值 54.8456,标准差 11.6193,最大 87.57
干净数据(真值):均值 54.8383本章练习
练习 1:圈出可疑样本、统计分布、导出清单
- 用"物理规则 + 统计规则"构造
mask,统计各自命中数和并集; - 统计可疑样本涉及多少支电芯、最可疑的 3 支;
- 生成
is_outlier+outlier_reason两列,导出核查清单 CSV; - 写一份清洗日志并存成 CSV。
参考答案
from pathlib import Path
import numpy as np
import pandas as pd
pd.set_option("display.width", 170)
CODE_DIR = Path(__file__).resolve().parent.parent.parent
DATA = CODE_DIR / "data" / "battery"
OUT = CODE_DIR / "outputs"
OUT.mkdir(exist_ok=True)
d = pd.read_csv(DATA / "dirty.csv")
# ① 两条规则
x = d["resistance_mohm"]
q1, q3 = x.quantile(0.25), x.quantile(0.75)
r_hi = q3 + 1.5 * (q3 - q1)
rule_phys = d["capacity_ah"] < 0
rule_stat = d["resistance_mohm"] > r_hi
mask = rule_phys | rule_stat # | 不是 or!
print(f"r_hi={r_hi:.3f}")
print(f"物理 {int(rule_phys.sum())} 条,统计 {int(rule_stat.sum())} 条,"
f"并集 {int(mask.sum())} 条({mask.mean():.2%})")
# ② 分布
vc = d.loc[mask, "cell_id"].value_counts()
print(f"涉及 {d.loc[mask, 'cell_id'].nunique()} 支电芯,"
f"每支 {vc.min()} ~ {vc.max()} 条")
print(f"最可疑的 3 支:{vc.head(3).to_dict()}")
# ③ 标记 + 导出
d2 = d.copy()
d2["is_outlier"] = mask
d2["outlier_reason"] = np.select(
[rule_phys & rule_stat, rule_phys, rule_stat],
["容量为负 + 内阻越界", "容量为负", "内阻越界"],
default="",
)
d2.loc[mask].to_csv(OUT / "outlier_cases.csv", index=False)
print(f"核查清单 {int(mask.sum())} 条已导出")
print(d2.loc[mask, "outlier_reason"].value_counts().to_string())
# ④ 清洗日志
log = {
"输入文件": "dirty.csv", "总行数": len(d),
"检测规则": f"容量<0(物理) 或 内阻>{r_hi:.3f}(IQR 上须)",
"命中条数": int(mask.sum()), "命中占比": f"{mask.mean():.2%}",
"涉及电芯数": int(d.loc[mask, "cell_id"].nunique()),
"处置策略": "C+D:主表标记 + 导出核查清单", "未做删除": True,
"输出文件": "outlier_cases.csv",
}
pd.Series(log).to_csv(OUT / "ch34_cleaning_log.csv", header=["值"])
print("清洗日志已保存")真实输出:
r_hi=92.030
物理 70 条,统计 68 条,并集 138 条(0.99%)
涉及 12 支电芯,每支 6 ~ 16 条
最可疑的 3 支:{'C007': 16, 'C004': 15, 'C010': 14}
核查清单 138 条已导出
内阻越界 68
容量为负 70四个要点:
|不是or—— 用or会报ValueError。- "两条都命中 0 条" 说明两个问题正交(分别在不同列)。
- 分布均匀(每支 6~16 条) → 结论是"采集环节随机错误", 而不是"某几支电芯有问题"。
outlier_reason列不能省 —— 核查的人靠它知道该查什么。
完整练习:exercises/ex34-01-policy
练习 2:四种策略该怎么选?
对下面四个场景,选择最合适的处置策略并说明理由:
- 某条记录的容量是
-0.5(物理上不可能); - 某条记录的内阻是 562 mΩ(可能是传感器故障,也可能是真坏了);
- 任务是"预测哪些电芯会提前失效",而异常的正是那些电芯;
- 一批温度读数里有几个
105℃(明显是传感器尖峰,正常范围 20~60℃)。
参考答案
| 场景 | 策略 | 理由 |
|---|---|---|
| 1 | A 删除 / 或按缺失修复 | 物理上不可能 → 确定是错误,可以放心处理 |
| 2 | C 标记 + D 单独分析 | 不确定 → 先别动,交人工核查 |
| 3 | D 单独分析(甚至重点保留) | 异常就是正样本,删了就学不到东西 |
| 4 | B 截断(或置为缺失后插值) | 值明显是尖峰,且边界有物理意义(60℃) |
场景 1 的处理代码:
# 置为缺失,交给插值(比直接删行温和 —— 保留了同一行其他 8 个完好字段)
d.loc[d["capacity_ah"] < 0, "capacity_ah"] = np.nan场景 3 的关键认知:
# ❌ 按常规清洗流程删掉异常
df_clean = df[~outlier_mask]
model.fit(df_clean[FEATURES], df_clean["failed"])
# → 把所有正样本都删了,模型学不到任何东西
# ✅ 保留异常,重点分析
df["is_anomaly"] = outlier_mask
print(df.groupby("is_anomaly")["failed"].mean())
# is_anomaly
# False 0.02 ← 正常电芯失效率 2%
# True 0.65 ← 异常电芯失效率 65%!!
# → 这 138 条异常里藏着最强的预测信号★ 核心判断:这些异常是"噪声"还是"信号"?
问题一:这个值对应一个真实的物理状态吗?
否 → 噪声(场景 1、4)
是 → 可能是信号(场景 2、3)
问题二:这个值和业务目标有关吗?
无关 → 可以清洗掉
有关 → 绝不能删(场景 3)
问题三:我能确定它是错误吗?
能确定 → 可以修
不确定 → 标记 + 核查(场景 2)★ 一个通用的处置流程
def decide_policy(value, col, rules):
"""根据"是否物理可能"和"是否与目标相关"决定处置方式。"""
lo, hi = rules[col]
if value < lo or value > hi:
return "A 删除或置为缺失" # 物理上不可能
if is_related_to_target(col):
return "D 保留并重点分析" # 可能是信号
return "C 标记 + 人工核查" # 不确定最后一条建议:如果不确定,就选 C/D。
因为:
- 删除是不可逆的;
- 标记是可逆的(下游可以自己决定怎么用);
- 只多花几行代码,但保留了全部可能性。
练习 3:为什么清洗日志是"交付物"而不是"文档"?
log = {
"输入文件": "dirty.csv",
"检测规则": "容量<0(物理) 或 内阻>92.030(IQR 上须)",
"命中条数": 138,
"处置策略": "C+D:主表标记 + 导出核查清单",
"未做删除": True,
}要求:解释这份日志里的每一项"解决什么问题", 并说出如果缺了某一项会有什么后果。
参考答案
| 字段 | 解决什么问题 | 缺了会怎样 |
|---|---|---|
输入文件 | 这份结果是从哪来的 | 无法复现(换了个文件结果就不同) |
检测规则 | 阈值怎么定的、依据什么 | 读者无法判断 138 条是多是少、是否合理 |
命中条数 | 处理了多少 | 无法评估处理的影响范围 |
处置策略 | 具体做了什么 | 读者不知道数据被改成什么样了 |
未做删除 | 明确"没有删数据" | 读者看到极端值会以为"你没发现",或以为"已经删过了" |
★ 场景重现
如果没有 检测规则:
同事:138 条异常?怎么算的?
你:就用统计方法。
同事:什么方法?阈值多少?
你:……记不清了。
同事:那我换个数据,该怎么设阈值?
你:……如果有了 检测规则:
规则:内阻 > Q3 + 1.5×IQR = 63.56 + 1.5×18.98 = 92.03 mΩ
同事:哦,Tukey 规则,标准做法。那我这个数据集也这么算。
(他自己算出来 Q3=70、IQR=25,阈值 = 107.5)
行,我可以复现你的方法。★ "未做删除"这类"负面声明"为什么重要
log["未做删除"] = True
log["未做插值"] = True
log["未做标准化"] = True它们回答的是"你没有做什么" —— 而这和"你做了什么"同样重要:
| 声明 | 读者得到的信息 |
|---|---|
未做删除 = True | 数据里的极端值是原始的,不是我没发现 |
未做插值 = True | NaN 还在,需要你自己处理 |
未做标准化 = True | 特征保持原始量纲,别直接喂给要标准化的模型 |
★ 把日志做成"可执行"的
def make_log(df_in, mask, rules_used, strategy, outputs, **extra):
"""生成标准格式的清洗日志。"""
log = {
"输入文件": str(df_in),
"总行数": len(df_in),
"检测规则": rules_used,
"命中条数": int(mask.sum()),
"命中占比": f"{mask.mean():.2%}",
"处置策略": strategy,
"输出文件": ", ".join(outputs),
}
log.update(extra)
return log
# 用法
log = make_log("dirty.csv", mask,
rules_used="容量<0(物理) 或 内阻>92.03(IQR k=1.5)",
strategy="C+D:主表加 is_outlier/outlier_reason,导出核查清单",
outputs=["cycle_clean_dirty.csv", "outlier_cases.csv"],
未做删除=True, 未做插值=True)这个函数的价值:把"日志该有什么"固化成模板, 不会因为赶时间而漏掉字段。
第 39 章会把 make_log 的输出直接渲染进体检报告。
面向 AI 开发:数据处置决策是"工程判断",不是"技术操作"
这一章没有教任何新语法,但它教的可能是全书最有价值的东西: 怎么在"技术正确"和"业务正确"之间做判断。
★ 一个决策框架
发现异常
↓
① 物理上可能吗?
否 → 是错误 → 修正(删除 / 置缺失 / 截断)
是 ↓
② 与业务目标相关吗?
相关 → 是信号 → 保留并重点分析
不相关 ↓
③ 能确定是错误吗?
能 → 修正
不能 → 标记 + 人工核查★ 三种"处置方式"在 AI 项目里的对应
| 处置方式 | 在建模里的对应操作 |
|---|---|
| 删除 | 从训练集排除这些行 |
| 截断/修正 | 用修正后的值训练 |
| 标记 | 作为一列特征(is_outlier)+ 或者用样本权重 |
| 单独分析 | 单独的验证集 / 错误分析对象 |
"标记"在建模里的两种用法:
# 用法一:作为一个特征("是否异常"本身可能有信息)
FEATURES = ["cycle", "resistance_mohm", "is_outlier"]
model.fit(df[FEATURES], df["soh"])
# 用法二:作为样本权重的依据(异常样本权重降低但不为 0)
df["weight"] = np.where(df["is_outlier"], 0.3, 1.0)
model.fit(df[FEATURES], df["soh"], sample_weight=df["weight"])用法二的思路值得学:
不是"要么留要么删",而是"降低它的影响力" —— 这是更细粒度的处理。
★ 工业界的真实做法
在成熟的 AI 团队里,数据清洗决策通常是这样定下来的:
① 数据工程师:跑检测、出清单、给建议
② 业务专家:逐条核查清单,判断"哪些是真问题"
③ 双方一起:定处置规则,写成代码
④ 数据工程师:实现规则 + 日志
⑤ 复核:跑一遍,确认结果符合预期关键:"处置规则"是双方共同决定的,不是数据工程师单方面拍的。
为什么:
- 数据工程师知道"统计上什么是异常";
- 业务专家知道"物理上什么是有意义的";
- 只有两者结合,才能正确区分"噪声"和"信号"。
★ 一个"数据处置决策记录"的模板
## 数据处置决策记录(DR-001)
**日期**:2026-XX-XX
**参与人**:数据工程 + 产线工艺
**问题**:dirty.csv 中发现 138 条可疑记录(占比 0.99%)
- 容量为负(< 0):70 条
- 内阻越界(> 92.03 mΩ):68 条
**分析**:
- 138 条均匀分布在全部 12 支电芯上(每支 6~16 条)
- 容量为负在物理上不可能 → 确定是录入/采集错误
- 内阻 200~562 mΩ 超出传感器量程(设计上限约 200)→ 高度疑似传感器故障
**决策**:
| 异常类型 | 处置 | 理由 |
|---|---|---|
| 容量为负(70 条) | 置为缺失后按电芯插值修复 | 物理不可能,样本其他字段完好吗,不应删整行 |
| 内阻越界(68 条) | 标记 `is_outlier`,导出核查清单,**不删除** | 需产线确认是否传感器故障;若为真实高内阻,则是重要的退化信号 |
**影响**:修复后缺失 0 格;内阻均值从 56.18 降至 54.85(接近干净数据的 54.84)
**待办**:产线核查 68 条内阻越界记录,确认是否传感器问题(负责人:XXX)这份记录的价值:
- 决策有依据(不是"我觉得应该这样");
- 可追溯(半年后有人问"为什么没删这些异常",答案在这里);
- 有后续行动(不只是"处理完了");
- 可复用(下次遇到类似问题,照这个框架走)。
★ 本章最重要的一句话
"发现异常"是技术问题,"处理异常"是业务问题。 数据工程师的职责是把技术问题解决清楚, 然后把业务问题的决策权交给懂业务的人。
这句话说起来简单,但它区分了"执行者"和"专业者"。