Skip to content

第 34 章 · 异常处置策略对照 + 落盘 ​

这一章要解决什么:找出可疑样本之后,决定怎么处理它们。 本章没有新语法,但要建立一个重要的认知:发现异常是统计问题,处理异常是业务问题。

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

34.1 先圈出"可疑样本":物理规则 + 统计规则 ​

python
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!)

真实输出:

text
内阻 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 章的受控实验就是讲这个)

如果只用统计规则:

python
# 只用 IQR 检查内阻 → 68 条
# 但「容量为负」这 70 条根本不会被发现(因为它们在 capacity_ah 列,不在内阻列)

所以"物理规则"的另一个作用是:覆盖那些"统计规则不会去看的列"。

| 不是 or ​

python
mask = (d["capacity_ah"] < 0) | (d["resistance_mohm"] > r_hi)      # ✅
mask = (d["capacity_ah"] < 0) or (d["resistance_mohm"] > r_hi)     # ❌ ValueError

or 对 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。

顺便验证一下:

python
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 可疑样本分布在哪些电芯上 ​

python
vc = d.loc[mask, "cell_id"].value_counts()
print(f"涉及 {d.loc[mask, 'cell_id'].nunique()} 支电芯,"
      f"每支 {vc.min()} ~ {vc.max()} 条")

真实输出:

text
涉及 12 支电芯,每支 6 ~ 16 条
点开:这个"分布统计"才是处置策略的依据

两种分布对应两种完全不同的原因:

情况 A:均匀分布(本数据)
  C001 ████████ 12
  C002 ██████ 9
  C003 ██████████ 15
  ...
  → 「采集/录入环节的随机错误」
  → 处理方式:修数据管道(检查传感器、录入流程)

情况 B:集中在少数几支
  C001 ████████████████████████████████████████ 800
  C002 █ 3
  C003 █ 2
  ...
  → 「这几支电芯真的有问题」
  → 处理方式:去查产线、上复检、加人工检查

本数据是情况 A(每支 6~16 条,分布均匀) —— 所以结论是:

"138 条可疑记录均匀分布在全部 12 支电芯上(每支 6~16 条), 符合数据采集/录入环节的随机错误特征, 而非某几支电芯的个体质量缺陷。"

★ 为什么这个判断决定了"处置策略"

原因该做什么
采集错误修数据(清洗、重采、修正管道)
电芯缺陷查产线(工艺、来料、复检)

如果误判:

把"采集错误"当"电芯缺陷" → 白费功夫去查产线
把"电芯缺陷"当"采集错误" → 直接用统计方法修掉,把真问题掩盖了!

后者的后果严重得多 —— 你可能"修好"了一批真有问题的电芯数据, 让它们看起来正常。

★ 怎么判断是哪种:

python
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 四种处置策略,逐一看效果 ​

python
# 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()

真实输出:

text
策略                   行数       内阻均值       内阻标准差       内阻最大
原始脏数据            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(被压到上须)

看起来"两全其美":行数没少,统计量也正常了。

但有三个问题:

① 分布被改变了。

python
# 原来内阻最大 562.61,现在最大 92.03
# 119 个"被夹住"的值全部等于 92.03 —— 形成了一个虚假的尖峰
print(f"等于 92.03 的记录数: {int((B['resistance_mohm'] == r_hi).sum())}")

② "假装它没坏"。

python
# 内阻 562 mΩ 意味着这支电芯可能真的坏了
# 夹到 92.03 之后,它在数据里看起来"只是偏高"
# → 如果用它做寿命预测,会低估风险

③ 对某些列没有合理的截断目标。

python
# 容量 = -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 策略选择:一张决策表 ​

text
┌────────────────┬──────────────────────────┬────────────────────────┐
│ 情况           │ 推荐策略                 │ 理由                   │
├────────────────┼──────────────────────────┼────────────────────────┤
│ 确认是录入错误 │ A 删除(并修数据管道)   │ 值不可能是真的         │
│ 值错但样本宝贵 │ B 截断 或 缺失后插值     │ 保住样本量             │
│ 不确定是不是错 │ C 标记 + D 单独分析      │ 先别动,交人工核查     │
│ 异常本身就是目标│ D 单独分析(甚至保留)  │ 故障检测里异常是正样本  │
└────────────────┴──────────────────────────┴────────────────────────┘

★ 工业项目的默认答案:C + D(主表标记 + 导出核查清单)。

原因很简单:

删数据是不可逆的,而"先标记、后核查"永远有退路。

点开:最后一行"异常本身就是目标"是什么意思?

在"故障检测"这类任务里,异常才是你要找的东西。

普通的预测任务:异常 = 需要清洗的噪声
故障检测任务  :异常 = 正样本(我要预测的目标!)

一个具体的例子:

python
# 任务:预测"哪支电芯会提前失效"
# 那么"内阻异常升高"的记录,恰恰是"即将失效"的信号

# ❌ 如果按常规流程把它们当噪声删掉
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Ω → 物理上可能(虽然极端)→ 需要核查

所以本章的方案是:

python
# 容量为负 → 确定是错误,按"缺失"处理(第 30 章会插值修复)
# 内阻越界 → 只标记,不删,导出清单

这种"分类处置"才是专业做法 —— 而不是一刀切地"删掉所有异常"。

34.5 工业首选方案的完整实现 ​

python
d2 = d.copy()
d2["is_outlier"] = mask
d2["outlier_reason"] = np.select(
    [rule_phys & rule_stat, rule_phys, rule_stat],
    ["容量为负 + 内阻越界", "容量为负", "内阻越界"],
    default="",
)

真实输出:

text
标记后的样本(前 5 条):
cell_id  cycle  capacity_ah  resistance_mohm  is_outlier  outlier_reason
   C005    890       1.6822           452.13        True         内阻越界
   ...

原因分布:
内阻越界    68
容量为负    70
python
cases_path = OUT / "outlier_cases.csv"
d2.loc[mask].to_csv(cases_path, index=False)
print(f"核查清单已导出:{cases_path.name}({int(mask.sum())} 条)")
text
核查清单已导出: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_outlieroutlier_reason
校验失败is_valid=Falsefail_reason(哪个规则没过)
模型预测低置信low_confidencereason(哪个特征异常)
数据被丢弃droppeddrop_reason

原因列的成本是几行代码,收益是"下游能用起来"。

★ np.select 的条件顺序(第 27 章讲过,这里再用一次):

python
np.select(
    [rule_phys & rule_stat,      # 两个都命中(最具体)
     rule_phys,                   # 只命中物理规则
     rule_stat],                  # 只命中统计规则
    ["容量为负 + 内阻越界", "容量为负", "内阻越界"],
    default="",
)

顺序必须是"从最具体到最一般" —— 否则 rule_phys 会先命中,rule_phys & rule_stat 的情况就永远轮不到。

在本数据上"两条都命中 0 条",所以顺序无关紧要, 但写成正确的顺序是好习惯(换个数据可能就有重叠了)。

34.6 被标记的行要不要参与统计? ​

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

真实输出:

text
剔除可疑样本后:均值 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)。
    本报告后续分析均使用剔除异常后的数据。"

为什么必须写:读者需要知道"你报的这个数字,是从多少数据里算出来的"。

★ 一个实用的做法:同时保留两份数据

python
# 主表:保留全部行 + 标记列(可回溯)
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 留痕:清洗日志是交付物的一部分 ​

python
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",
}

真实输出:

text
输入文件                                                        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 的系数是几?命中多少?
你:……不太清楚。
同事:那这个结论我还不敢用。

"处理了多少、用什么规则、怎么处置"这三件事决定了别人能否信任你的结果。

★ 一份合格的清洗日志应该包含

类别字段
输入文件名、行数、列数
诊断缺失格数、各类异常的个数、占比
规则用的什么规则(物理/统计)、阈值是多少、系数是几
命中命中条数、占比、涉及多少实体
处置每种异常分别怎么处理(删/截/标/单独分析)
输出产出了哪些文件、各自的用途
未做的事明确声明"没有删除数据"这类关键决策

★ 为什么"未做的事"也要写

python
log["未做删除"] = True

因为"我没有删数据"是一个重要的决策,读者需要知道:

  • 如果他看到某个极端值,那是原始数据,不是你没发现;
  • 如果他想要一份"剔除异常后"的数据,需要自己筛选 is_outlier。

同类需要声明的"未做的事":

python
log["未做插值"] = True              # 保留了 NaN,交给下游决定
log["未做标准化"] = True            # 特征保持原始量纲
log["未做样本加权"] = True          # 所有样本权重相等

★ 日志的格式:CSV 还是 Markdown?

格式适合
CSV机器可读、方便对比多次运行
Markdown 表格人可读、直接贴进报告(第 39 章的做法)

本书两个都产出:

python
pd.Series(log).to_csv(OUT / "ch34_cleaning_log.csv", header=["值"])   # 机器读
# 第 39 章会把同样的内容渲染成 Markdown 表格写进报告

★ 最后一条纪律

清洗日志不是"额外的文档工作", 它是数据处理流程的一部分 —— 和代码、数据同等重要。

没有日志的清洗,等于没做 —— 因为没人(包括三个月后的你)能判断这个结果可不可信。

34.8 常见坑 ​

text
① 一上来就 dropna() + 删异常 → 把证据也删了,后面无法复盘、无法向业务解释
② 用 clip 前不看业务含义 → 内阻夹到 90 等于"假装它没坏",做寿命预测会低估风险
③ 在训练集算阈值、套到测试集用 → 阈值也是"从数据学来的",属于模型的一部分(第 32 章)
④ 忘记留痕 → 没写"处理了多少条、依据什么规则",报告等于没做
⑤ 用 or 连接条件 → 对 Series 会报 ValueError,必须用 |
⑥ 只标记不解释 → 只给 True/False,核查的人不知道该从哪查起。加一列原因

验证 ⑤:

python
try:
    d[rule_phys or rule_stat]
except ValueError as exc:
    print(f"用 or → ValueError: {str(exc)[:70]}…")

34.9 完整示例与输出 ​

bash
cd code
python demos/ch34-policy.py

真实输出(节选):

text
=== ① 先圈出「可疑样本」:物理规则 + 统计规则 ===
  内阻 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:圈出可疑样本、统计分布、导出清单
  1. 用"物理规则 + 统计规则"构造 mask,统计各自命中数和并集;
  2. 统计可疑样本涉及多少支电芯、最可疑的 3 支;
  3. 生成 is_outlier + outlier_reason 两列,导出核查清单 CSV;
  4. 写一份清洗日志并存成 CSV。
参考答案
python
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("清洗日志已保存")

真实输出:

text
r_hi=92.030
物理 70 条,统计 68 条,并集 138 条(0.99%)
涉及 12 支电芯,每支 6 ~ 16 条
最可疑的 3 支:{'C007': 16, 'C004': 15, 'C010': 14}
核查清单 138 条已导出
内阻越界    68
容量为负    70

四个要点:

  1. | 不是 or —— 用 or 会报 ValueError。
  2. "两条都命中 0 条" 说明两个问题正交(分别在不同列)。
  3. 分布均匀(每支 6~16 条) → 结论是"采集环节随机错误", 而不是"某几支电芯有问题"。
  4. outlier_reason 列不能省 —— 核查的人靠它知道该查什么。

完整练习:exercises/ex34-01-policy

练习 2:四种策略该怎么选?

对下面四个场景,选择最合适的处置策略并说明理由:

  1. 某条记录的容量是 -0.5(物理上不可能);
  2. 某条记录的内阻是 562 mΩ(可能是传感器故障,也可能是真坏了);
  3. 任务是"预测哪些电芯会提前失效",而异常的正是那些电芯;
  4. 一批温度读数里有几个 105℃(明显是传感器尖峰,正常范围 20~60℃)。
参考答案
场景策略理由
1A 删除 / 或按缺失修复物理上不可能 → 确定是错误,可以放心处理
2C 标记 + D 单独分析不确定 → 先别动,交人工核查
3D 单独分析(甚至重点保留)异常就是正样本,删了就学不到东西
4B 截断(或置为缺失后插值)值明显是尖峰,且边界有物理意义(60℃)

场景 1 的处理代码:

python
# 置为缺失,交给插值(比直接删行温和 —— 保留了同一行其他 8 个完好字段)
d.loc[d["capacity_ah"] < 0, "capacity_ah"] = np.nan

场景 3 的关键认知:

python
# ❌ 按常规清洗流程删掉异常
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)

★ 一个通用的处置流程

python
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:为什么清洗日志是"交付物"而不是"文档"?
python
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)
      行,我可以复现你的方法。

★ "未做删除"这类"负面声明"为什么重要

python
log["未做删除"] = True
log["未做插值"] = True
log["未做标准化"] = True

它们回答的是"你没有做什么" —— 而这和"你做了什么"同样重要:

声明读者得到的信息
未做删除 = True数据里的极端值是原始的,不是我没发现
未做插值 = TrueNaN 还在,需要你自己处理
未做标准化 = True特征保持原始量纲,别直接喂给要标准化的模型

★ 把日志做成"可执行"的

python
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)+ 或者用样本权重
单独分析单独的验证集 / 错误分析对象

"标记"在建模里的两种用法:

python
# 用法一:作为一个特征("是否异常"本身可能有信息)
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 团队里,数据清洗决策通常是这样定下来的:

① 数据工程师:跑检测、出清单、给建议
② 业务专家:逐条核查清单,判断"哪些是真问题"
③ 双方一起:定处置规则,写成代码
④ 数据工程师:实现规则 + 日志
⑤ 复核:跑一遍,确认结果符合预期

关键:"处置规则"是双方共同决定的,不是数据工程师单方面拍的。

为什么:

  • 数据工程师知道"统计上什么是异常";
  • 业务专家知道"物理上什么是有意义的";
  • 只有两者结合,才能正确区分"噪声"和"信号"。

★ 一个"数据处置决策记录"的模板

markdown
## 数据处置决策记录(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)

这份记录的价值:

  1. 决策有依据(不是"我觉得应该这样");
  2. 可追溯(半年后有人问"为什么没删这些异常",答案在这里);
  3. 有后续行动(不只是"处理完了");
  4. 可复用(下次遇到类似问题,照这个框架走)。

★ 本章最重要的一句话

"发现异常"是技术问题,"处理异常"是业务问题。 数据工程师的职责是把技术问题解决清楚, 然后把业务问题的决策权交给懂业务的人。

这句话说起来简单,但它区分了"执行者"和"专业者"。


下一章:第 35 章 · 折线图:容量衰减曲线