🏠 总目录📚 本教程 04 · 脏数据十种形态
📑 本页目录(点开跳转)

04 · 脏数据的十种形态

66 分钟 | ⭐ 会报错的脏数据不可怕,可怕的是能跑通的那种


🎯 一句话

脏数据只分两类:一类让你的任务挂掉,一类让你的任务成功地算出错答案。 下面十种形态里,只有前两种偶尔属于第一类。 剩下八种全部能顺利跑完、不报任何错、产出一份看起来完全正常的结果。


🧨 一、先按「会不会叫」把十种排个序

   会报错(好脏数据)              静默通过(坏脏数据)💀
   ─────────────────              ────────────────────
   ① 类型不一致   (有时会)        ③ 时区
   ② 字符编码     (有时会)        ④ 单位
                                   ⑤ 字段截断
                                   ⑥ 默认值伪装成真值
                                   ⑦ 枚举漂移
                                   ⑧ 前后空格与全半角
                                   ⑨ 嵌套 JSON 结构变了
                                   ⑩ 同名不同义

   ⭐ 大多数人的时间分配是反的:
      90% 的排查精力花在 ①②(因为它们会叫、会红、会有堆栈)
      而 90% 的实际损失来自 ③–⑩(因为它们不叫)

🔑 判断一种脏数据危不危险,只看一件事:它会不会让程序停下来。 会停的,代价是几十分钟;不会停的,代价是几周乘以每天的业务量。 这一章后面所有的「怎么发现」,本质上都是给不会叫的东西装一个铃铛


🔬 二、十种形态:症状 → 怎么发现 → 怎么修

① 类型不一致

同一列里 "123"123123.0"123" 混着来。常见于 JSON 日志、Excel 导出、多来源合并。

内容
症状 join 之后行数莫名变少("1001" 匹配不上 1001)⭐ 这是最典型的一个
groupby 出现两个看起来一样的分组;读 CSV 时列 dtype 变成 object
怎么发现 对每列统计实际 Python 类型的分布,而不是看声明的 dtype
检查「能否全部转成数值」的比例:0% 和 100% 都正常,中间态最可疑 ⚠️
怎么修 入口统一:读取时显式指定 dtype,join key 全部先 astype(str).str.strip()
⚠️ 不要靠 CAST 兜底 —— 它会把 "abc" 变成 NULL,静默丢数据

② 字符编码

GBK 当成 UTF-8 读 → ����;UTF-8 当成 GBK 读 → 鎴戜滑;带 BOM 的 CSV 让第一列列名变成 id

内容
症状 中文变问号或乱码;第一列名字后面莫名多一个不可见字符df["id"] 报 KeyError ⭐
怎么发现 统计非 ASCII 字符里 (替换符)的占比;检查列名里的 (零宽空格)⚠️
抽 1000 行分别按 UTF-8 / GBK 解码,看哪个「可打印中文字符比例」高
怎么修 全链路强制 UTF-8,读文件时 encoding="utf-8-sig"(自动吃掉 BOM)⭐
已经乱码的尽量回溯上游重取s.encode("gbk").decode("utf-8") 这类反解只在乱码是单次误解码时才有效

③ 时区 💀

本章事故的主角。 三个独立的坑:存的是 UTC 还是本地?分区按哪个时区切?夏令时怎么办?

内容
症状 每天的数据量在某个整点前后「搬家」;跨天的会话被切成两段;夜间行为消失
⭐ 两份本该对齐的表,日汇总总是差那么一点,而且差的量随星期几变化
怎么发现 ⭐⭐ 画按小时的分布直方图,和历史做循环互相关。时区错位在按天的任何图上都不可见,在按小时的图上是整体平移 N 格
怎么修 存储一律用 UTC epoch 毫秒,展示层再转;契约里必须写 epoch_unittz
⚠️ 分区键的时区要单独声明 —— dt 按 UTC 切还是按 Asia/Shanghai 切,是两份不同的数据

④ 单位

元 / 分,米 / 千米,秒 / 毫秒,摄氏 / 华氏,GB / GiB。类型完全不变,数值差 100 倍或 1024 倍。

内容
症状 某天开始,某个数值列的均值突然是原来的 100 倍或 1/100,分布形状完全不变
怎么发现 监控分位数而不是均值(均值会被异常值带偏,分位数不会)
⭐ 断言取值上限:amount <= 1e7。一个上限断言能抓住绝大多数「元改成分」
怎么修 字段名里带单位:amount_centsdwell_msdist_m ⭐⭐ 这是成本最低、收益最大的一条规范
契约里写 unit,变更时必须 bump 版本号(第 3 章

⑤ 字段截断

varchar(64) 装不下 128 字节的 URL;Excel 把 18 位身份证的后 3 位变成 000;按字节截断 UTF-8 中文,切出半个汉字。

内容
症状 ⭐⭐ 一批不同的实体挤成了同一个值 —— 这是最贵的后果,直接制造假重复(第 6 章
长文本末尾整齐地断在同一个位置;字符串末尾出现
怎么发现 统计每个字符串列的长度直方图。⭐ 如果在某个长度上出现尖峰(比如 64、255),几乎一定是截断
数值列:看有没有值恰好等于 32767 / 65535 / 2147483647
怎么修 扩字段长度并回溯重取;截断已经发生的数据是不可逆的,只能重取 ⚠️
长 ID 一律当字符串传,永远不要让它经过 Excel / float64

⑥ 默认值伪装成真值 💀

0-1-9991970-01-011900-01-01"未知""""其他"。 它们不是 NULL,所以所有缺失率监控都看不到它们

内容
症状 某个值的占比高得不合理(年龄 0 占 8%、首单时间 1970-01-01 占 3%)⭐
均值被拽向哨兵值;模型把「年龄 0」当成真实的婴儿用户
怎么发现 ⭐⭐ 算每列的「最高频单值占比」。真实的连续变量不该有任何一个值占到 1% 以上
对照哨兵值清单扫一遍;把 epoch=0 附近和「未来时间」的时间戳单独统计
怎么修 在入口把哨兵值显式转成 NULL,然后按第 5 章处理,并保留一个缺失指示列
⚠️ 不要直接删 —— 「这个字段是默认值」本身往往是有信息的

⑦ 枚举漂移

上游悄悄加了取值、改了拼写(iOSios)、合并了两个取值。第 3 章那个 216 万的事故就是它。

内容
症状 映射表的 default 分支占比缓慢上涨;某个类别的量突然归零,同时冒出一个新类别
怎么发现 契约里列枚举全集,出现未知值就告警;⭐ 同时监控「未知值占比」这条曲线本身
怎么修 禁止 dict.get(x, default) 静默兜底,改成显式记录 + 计数 + 超阈值 raise ⭐
大小写、空格先归一化,再比对枚举

⑧ 前后空格与全半角

"北京" vs "北京 " vs "北京"(全角空格)vs "BEIJING"(全角字母)。 中文数据里还有:全角括号、中文逗号、不同的破折号、零宽空格

内容
症状 groupby 出现两个「看起来一模一样」的分组 ⭐ 肉眼永远看不出来
去重去不干净;join 丢行;类别数比预期多几十个
怎么发现 对每个字符串列做 strip() + 全角转半角 + NFKC 归一化,看去重后类别数掉了多少
掉幅超过 1% 就说明这一列一直在带病工作 ⚠️
怎么修 入口统一做 unicodedata.normalize("NFKC", s).strip() ⭐ 这一行能解决八成问题
归一化必须在训练和推理两侧用同一份代码模型上线之后 04)⚠️

⑨ 嵌套 JSON 结构变了

ext 字段里 {"price": 12.5} 变成 {"price": {"amount": 12.5, "cur": "CNY"}}; 数组有时是 []、有时是 null、有时是 "[]"(字符串)。

内容
症状 解析出来的列突然全是 NULL,而 JSON 本身完全合法 —— 所以什么都不会报错 💀
怎么发现 ⭐ 采样解析 JSON,把所有路径 + 叶子类型收集成集合,逐天对比。新增/消失的路径就是变更
怎么修 把常用字段在上游拍平成物理列,写进契约;嵌套字段视为「随时会变」的非契约区
解析失败不要 try/except: pass,要计数(第 8 章)⚠️

⑩ 同名不同义 💀

两个系统都有 user_id:一个是账号 ID,一个是设备 ID。两张表都有 amount:一个含税,一个不含税。 这是十种里唯一一种代码完全无能为力的。

内容
症状 join 之后行数不对(一对多或者匹配率极低);两个口径的报表永远对不上
⭐ 匹配率是 60% 这种「不高不低」的数字 —— 全匹配和全不匹配都容易发现,半匹配最致命
怎么发现 join 前先算基数和交集率|A∩B| / min(|A|,|B|)。低于 95% 就必须人工确认 ⭐⭐
对同名列做分布对比:位数不同、前缀不同、取值范围不同,都是不同义的信号
怎么修 只能靠第 3 章语义定义。字段名前面加系统前缀:crm_user_idapp_device_id

⭐⭐ 十种里,⑤ 字段截断和 ⑩ 同名不同义是被低估最多的两种: 它们都不会产生任何「异常值」—— 截断产生的是完全合法的短字符串,同名不同义产生的是完全合法的 join 结果。 你的所有取值域校验、缺失率监控、异常检测,对这两种是全盲的。


🛑 读到这里可以停 —— 前半章讲完了(约 19 分钟)。 后半章还有:一段可以直接跑的体检代码 · 事故复盘:8 小时,把「次日留存」凭空抬高了 4.1 个点 · 一次 30 分钟的体检该按什么顺序做 回来的时候不用重读,直接从下一节接着看就行。


🧑‍💻 三、一段可以直接跑的体检代码

import unicodedata
import pandas as pd

SENT_NUM = {0, -1, -99, -999, -9999, 9999, 99999, 999999, 2147483647, 32767, 65535}
SENT_STR = {"", "-", "null", "none", "nan", "n/a", "未知", "无", "其他", "默认", "test"}
SENT_DAY = {"1970-01-01", "1900-01-01", "0000-00-00", "2099-12-31", "9999-12-31"}
TRUNC_LEN = {16, 32, 50, 64, 100, 128, 200, 255, 256, 512, 1000, 1024}


def scan_dirty(df: pd.DataFrame, enum_spec=None, top_ratio=0.01):
    """十种形态里能被代码发现的那几种,一次扫完。返回 (级别, 列, 形态, 详情)。"""
    v = []
    for c in df.columns:
        s = df[c].dropna()
        if len(s) == 0:
            v.append(("P1", c, "⑥ 整列为空", "0 行非空")); continue

        # ⑥ 默认值伪装成真值:最高频单值占比 ⭐ 一条规则抓住绝大多数哨兵值
        vc = s.value_counts()
        top, share = vc.index[0], vc.iloc[0] / len(s)
        looks_sent = (top in SENT_NUM) or (str(top).strip().lower() in SENT_STR) \
                     or (str(top)[:10] in SENT_DAY)
        if share > 0.30 or (looks_sent and share > top_ratio):
            v.append(("P1" if looks_sent else "P2", c, "⑥ 疑似默认值/哨兵值",
                      f"{top!r}{share:.2%}"))

        if s.map(type).nunique() > 1:      # ① 类型不一致
            kinds = s.map(lambda x: type(x).__name__).value_counts().to_dict()
            v.append(("P1", c, "① 类型不一致", str(kinds)))

        if s.dtype == object:
            t = s.astype(str)
            # ② 编码:替换符 / BOM / 零宽空格
            bad = t.str.contains("�||​", regex=True).mean()
            if bad > 0:
                v.append(("P0", c, "② 编码损坏或不可见字符", f"{bad:.2%} 的行"))
            # ⑧ 空格与全半角:归一化后类别数掉了多少 ⭐
            norm = t.map(lambda x: unicodedata.normalize("NFKC", x).strip())
            if norm.nunique() < t.nunique():
                v.append(("P1", c, "⑧ 空格/全半角未归一",
                          f"类别数 {t.nunique()}{norm.nunique()}"))
            # ⑤ 截断:长度直方图在整数边界上出现尖峰
            L = t.str.len()
            peak = L.value_counts(normalize=True)
            for cand in TRUNC_LEN:
                if peak.get(cand, 0) > 0.005 and cand >= L.median():
                    v.append(("P0", c, "⑤ 疑似字段截断",
                              f"长度恰好={cand} 的行占 {peak[cand]:.2%}"))
            # ① 半数能转数值 = 混合列
            num_ok = pd.to_numeric(t, errors="coerce").notna().mean()
            if 0.02 < num_ok < 0.98:
                v.append(("P2", c, "① 数值/文本混合列", f"可转数值 {num_ok:.1%}"))
            # ⑦ 枚举漂移
            if enum_spec and c in enum_spec:
                unknown = set(norm.unique()) - set(enum_spec[c])
                if unknown:
                    v.append(("P1", c, "⑦ 未知枚举值",
                              f"{sorted(unknown)[:5]}{norm.isin(unknown).mean():.2%}"))
        elif pd.api.types.is_numeric_dtype(s):
            # ④ 单位:分位数比均值稳,作为跨天对比的基准量
            q = s.quantile([0.5, 0.99]).to_dict()
            v.append(("P3", c, "④ 单位基准(存起来跨天比)", f"p50={q[0.5]:.4g} p99={q[0.99]:.4g}"))
    return pd.DataFrame(v, columns=["级别", "列", "形态", "详情"])

# 用法:report = scan_dirty(df, enum_spec={"channel": ["app_home", "h5"]})
#      把今天的 P3 基准行存下来,明天和今天比 —— 单位跳变就是这么抓的 ⭐

③ 时区单独给一段,因为它是唯一一种「必须按小时看」的:

import numpy as np

def hour_shift(hours_now, hours_ref):
    """按小时直方图找整点位移。hours_*: 0-23 的整数数组(事件发生的小时)。
    返回 (最可能的位移小时数, 该位移下的相关系数, PSI)。"""
    a = np.bincount(np.asarray(hours_ref) % 24, minlength=24).astype(float)
    b = np.bincount(np.asarray(hours_now) % 24, minlength=24).astype(float)
    a, b = a / a.sum() + 1e-9, b / b.sum() + 1e-9
    psi = float(np.sum((b - a) * np.log(b / a)))          # ⭐ PSI > 0.25 = 重大偏移
    cor = [float(np.corrcoef(a, np.roll(b, -s))[0, 1]) for s in range(24)]
    k = int(np.argmax(cor))
    return k, cor[k], psi

# 实跑(200 万条事件,人为把时间戳整体 +8 小时):
#   按【天】看:总量一模一样,日活曲线毫无异常
#   按【小时】看:PSI = 1.457,位移 0 的相关系数 = -0.465,
#                位移 8 的相关系数 = 1.000   ⭐⭐ 一眼就指出「差了 8 小时」

⭐⭐ 上面这组数字是本章最该被记住的东西同一份被搞坏的数据,在按天的视角下 PSI 约等于 0,在按小时的视角下 PSI = 1.457。 不是监控不够灵敏,是聚合的粒度把证据抹掉了。 一般规律:你的聚合粒度必须比你想抓的错误的周期更细。


💀 四、事故复盘:8 小时,把「次日留存」凭空抬高了 4.1 个点

发生了什么

   系统:某内容 App 的埋点主表 dwd_event_di,下游 40+ 个消费者
   字段:event_ts   bigint   not null   (契约里就写了这三样)

   埋点 SDK v4.2 上线,作者做了一个"体验优化":
       把 event_ts 从【UTC 毫秒】改成【本地时间毫秒】(东八区,+8h)
       理由是"这样在日志文件里肉眼可读,排查方便"
   字段名没变、类型没变、非空率没变、数量级没变  ✅✅✅✅

   ⚠️ 而且它是【灰度】上线的:3 周内从 5% 放量到 100%

为什么没被发现

   ✅ 契约校验全绿:bigint、not null、行数在区间内
   ✅ 时间戳范围检查也全绿 —— 规则是"必须落在过去 30 天内",
      差 8 小时在这个规则里根本不算什么 💀
   ✅ 日活曲线正常、总事件量正常、缺失率 0

   💀 灰度把断崖变成了缓坡:
      3 周从 5% 到 100%,指标是【每天挪一点点】
      拐点检测按天跑,形状是"缓慢上升" → 被判定成【漂移】而不是【故障】
      于是走了"重训模型"的流程,而不是"排查上游"的流程  ⚠️

代价

   受影响的是"跨午夜"那一段:22:00–23:59 的事件占全天的 11.3%,
   它们被整体划进了第二天

   · 「次日留存」从 31.2% 虚高到 35.3%   (+4.1pp)
   · 运营据此把 push 时间从 20:00 挪到 22:30 —— 因为"数据显示晚间用户更活跃"
     实际结果:7 日留存掉了 0.9pp,两个月后才回滚
   · 流失预测模型的标签(次日是否回访)有 6.8% 直接翻转
     线上 recall 从 0.62 掉到 0.51

   发现延迟:5 周
   发现方式:财务对账时发现按小时的 GMV 曲线有【两个午夜峰】⭐
             —— 因为新旧 SDK 版本在灰度期同时在跑
   修复:回溯 38 天日志重算分区 + 重训 = 11 人日 + 4.2 万核时的全量回刷

该补什么

缺失 补上之后
契约里没有 epoch_unittz 时间字段必须三件套:epoch_unittzis_localized
只有按天的监控 加一条按小时直方图的 PSI:这次事故的 PSI = 1.457,会在第一天就红 ⭐⭐
灰度期没按 SDK 版本分组看 灰度期间「版本号」必须是强制的分组维度(模型上线之后 11
没有客户端/服务端时间差的分布 server_ts - event_ts 应集中在秒级;一旦出现 8 小时的峰,一目了然 ⭐
缓降被自动归类成"漂移" 判定漂移之前先问一句:同期有没有任何东西在灰度? ⚠️

💀 这个事故的两个通用教训① 灰度会把故障伪装成漂移。 断崖是「有人改了东西」,缓坡是「世界慢慢变了」—— 而灰度发布恰好把前者变成后者的形状。 看到缓降先查灰度,再谈重训。 ② 时区错误在任何按天的视图里都是不可见的。 同一份数据,按天看 PSI≈0,按小时看 PSI=1.457 —— 证据一直都在,是聚合把它抹掉了。


🧭 五、一次 30 分钟的体检该按什么顺序做

   ① 先跑 scan_dirty,只看 P0/P1                       5 分钟
      —— 截断、编码、类型混合、哨兵值,这几类几乎一定有

   ② 每个字符串列打印【长度直方图】和【Top-20 取值】     10 分钟 ⭐
      —— 肉眼扫一遍。这一步的产出率高得离谱,
         因为人对"这个值出现 8% 不对劲"的直觉比任何规则都准

   ③ 每个数值列打印 p1 / p50 / p99 和最高频单值          5 分钟
      —— 单位问题和哨兵值都在这三个数字里

   ④ 时间列:画按小时的分布 + 和上周做循环互相关          5 分钟 ⭐⭐

   ⑤ 每个 join:先算交集率,低于 95% 就停下来问人         5 分钟
      —— ⑩ 同名不同义只能在这一步被抓到

第 ② 步是全章性价比最高的一步,也是最容易被跳过的一步。 因为它「不够自动化」,看起来像手工劳动。 但十种形态里有六种,是靠人扫一眼 Top-20 取值发现的,而不是靠规则。 规则只能查你已经想到的东西;Top-20 能让你看见你没想到的东西。


🔗 这一章连到哪里

去哪 为什么
第 3 章 数据契约 unit / tz / enum 写进契约,这十种里有六种能在上游被拦住
第 5 章 缺失不是一种东西 哨兵值转成 NULL 之后,接下来该怎么办
第 6 章 重复与实体解析 字段截断制造的假重复,和空格/全半角制造的假不重复
模型上线之后 04 训练推理一致性 归一化代码必须两侧同一份,否则清洗本身成为新的脏源
模型上线之后 11 从告警到根因 「缓降 = 漂移」这个默认判断,正是被灰度骗到的地方
AI 基础设施 22 数据管线与存储 编码、分区时区、回溯重刷在工程上怎么落

✅ 检查点

  1. 脏数据分成哪两类?为什么说「危不危险只看它会不会让程序停下来」?大多数人的时间分配错在哪?
  2. 类型不一致最典型的症状是什么?为什么说「能转成数值的比例在 0% 和 100% 之间」最可疑?为什么不能靠 CAST 兜底?
  3. 字段截断最贵的后果是什么?怎么用一张图发现它?为什么说它「不可逆」?
  4. 「默认值伪装成真值」为什么所有缺失率监控都看不到?用哪一条规则能一次抓住绝大多数哨兵值?
  5. 十种里哪两种被低估最多?它们的共同特点是什么?
  6. 时区问题为什么必须按小时看?实跑里按天和按小时的 PSI 分别是多少?「聚合粒度」的一般规律是什么?
  7. 那个 8 小时的事故:改了什么、为什么四项契约校验全绿、灰度起了什么作用、留存虚高多少、push 决策造成了什么后果、发现延迟多久、靠什么发现的?
  8. 「灰度会把故障伪装成漂移」是什么意思?看到缓降时该先问哪一句?
  9. 30 分钟体检的五步里,哪一步性价比最高?为什么它容易被跳过?规则和「人扫一眼 Top-20」各自能发现什么?
👀 答案
  1. 一类让任务挂掉,一类让任务成功地算出错答案。会停的代价是几十分钟(你立刻就知道了),不会停的代价是几周 × 每天的业务量。大多数人把 90% 的排查精力花在会报错的 ①② 上,而 90% 的实际损失来自不报错的 ③–⑩
  2. 最典型的是 join 之后行数莫名变少"1001" 匹配不上 1001),以及 groupby 出现两个看起来一样的分组。0% 和 100% 都正常(说明这是纯文本列或纯数值列),中间态说明一列里混了两种东西CAST 会把 "abc" 静默变成 NULL —— 它不是修复,是把类型问题转换成缺失问题,而且不留痕迹。
  3. 最贵的后果是⭐⭐一批不同的实体挤成了同一个值,直接制造假重复(第 6 章)。发现方法:画字符串长度直方图在 64 / 255 这种整数边界上出现尖峰几乎一定是截断。不可逆是因为被切掉的字节根本没有被写下来过,只能回上游重取。
  4. 因为它们不是 NULL,非空率是 100%,所有缺失率监控全绿。一条规则:⭐⭐算每列的「最高频单值占比」 —— 真实的连续变量不该有任何一个值占到 1% 以上,一旦有,那个值几乎一定是默认值。
  5. ⑤ 字段截断⑩ 同名不同义。共同点:它们不产生任何异常值 —— 截断产生的是完全合法的短字符串,同名不同义产生的是完全合法的 join 结果。取值域校验、缺失率监控、异常检测对这两种全盲
  6. 因为时区错位在按天的视图里表现为「总量完全一样」,只有在按小时的直方图上才表现为整体平移 N 格。实跑:按天 PSI ≈ 0(总量一模一样),按小时 PSI = 1.457;位移 0 的相关系数 -0.465,位移 8 的相关系数 1.000。一般规律:⭐聚合粒度必须比你想抓的错误的周期更细,否则聚合会把证据抹掉。
  7. 埋点 SDK v4.2 把 event_tsUTC 毫秒改成本地时间毫秒(+8h),理由是「日志里肉眼可读」。四项全绿是因为字段名、类型、非空率、数量级全都没变,而时间戳范围规则只查「过去 30 天内」,差 8 小时完全在范围内。灰度 3 周从 5% 到 100%,把断崖变成了缓坡,拐点检测按天跑判成了「漂移」,于是走了重训流程而不是排查上游。22:00–23:59 的事件占全天 11.3%,被划到第二天 → 次日留存从 31.2% 虚高到 35.3%(+4.1pp);运营据此把 push 从 20:00 挪到 22:30,7 日留存反而掉 0.9pp;模型标签 6.8% 翻转,线上 recall 从 0.62 掉到 0.51发现延迟 5 周,靠财务对账时看到按小时的 GMV 曲线出现两个午夜峰(新旧 SDK 灰度期并存);修复 11 人日 + 4.2 万核时回刷。
  8. 断崖的形状意味着「有人改了东西」,缓坡的形状意味着「世界慢慢变了」——而灰度发布恰好把前者变成后者的形状。看到缓降先问:⚠️同期有没有任何东西在灰度? 确认没有,再去谈漂移和重训。
  9. 第 ② 步(每个字符串列打印长度直方图和 Top-20 取值)性价比最高。它容易被跳过是因为「不够自动化」、看起来像手工劳动。但十种形态里有六种是靠人扫一眼 Top-20 发现的。区别在于:规则只能查你已经想到的东西,Top-20 能让你看见你没想到的东西。

🛑 可以停在这里

走神救援

脏数据只分两类:让任务挂掉的,和让任务成功算出错答案的。 十种形态里只有①类型不一致②字符编码偶尔会报错,③时区④单位⑤字段截断⑥默认值伪装成真值⑦枚举漂移⑧空格与全半角⑨嵌套JSON结构变了⑩同名不同义全部静默通过。⚠️大多数人的时间分配是反的:90% 的排查精力花在会叫的①②,90% 的损失来自不叫的③–⑩。 每种的抓手: join 后行数莫名变少是最典型症状,「可转数值比例」在 0% 和 100% 之间最可疑,⚠️别用 CAST 兜底——它把 "abc" 静默变成 NULL//零宽空格,读文件用 utf-8-sig③时区⭐⭐必须按小时看④单位——字段名里带单位(amount_centsdwell_ms)是成本最低收益最大的规范,一个取值上限断言能抓住绝大多数「元改成分」⑤截断最贵的后果是⭐⭐一批不同实体挤成同一个值(直接制造假重复→第6章),发现靠长度直方图在 64/255 这种边界上的尖峰,而且不可逆,只能回上游重取⑥默认值⭐⭐所有缺失率监控都看不到它们,因为它们不是 NULL——一条规则通杀:算每列「最高频单值占比」,真实连续变量不该有任何一个值占到 1% 以上⑦枚举漂移禁止 dict.get(x, default) 静默兜底,监控「未知值占比」这条曲线本身⑧空格全半角入口统一 unicodedata.normalize("NFKC", s).strip(),一行解决八成,⚠️归一化代码训练和推理必须是同一份⑨嵌套 JSON 收集「路径+叶子类型」集合逐天对比,解析失败要计数不要 except: pass⑩同名不同义是唯一代码完全无能为力的一种,join 前先算交集率,低于 95% 必须问人——⚠️匹配率 60% 这种「不高不低」最致命。⭐⭐被低估最多的是⑤和⑩,因为它们不产生任何异常值:截断产出的是合法短字符串,同名不同义产出的是合法 join 结果,取值域校验和异常检测对它们全盲。💀事故:埋点 SDK v4.2 把 event_ts 从 UTC 毫秒改成本地毫秒(+8h),理由是「日志里好读」;字段名/类型/非空率/数量级全没变,时间戳范围规则只查「过去 30 天内」,差 8 小时完全在范围内。而且它是灰度 3 周从 5% 到 100%——⭐⭐灰度把断崖变成了缓坡,拐点检测按天看判成「漂移」,于是走了重训流程而不是排查上游。22:00–23:59 的事件占全天 11.3% 被划到第二天:次日留存从 31.2% 虚高到 35.3%(+4.1pp),运营据此把 push 从 20:00 挪到 22:30,7 日留存反而掉 0.9pp;模型标签 6.8% 翻转,线上 recall 0.62→0.51发现延迟 5 周,靠财务对账看到按小时的 GMV 曲线有两个午夜峰;修复 11 人日 + 4.2 万核时回刷。⭐⭐最该记住的数字:同一份坏数据,按天看 PSI≈0,按小时看 PSI=1.457(位移 0 相关 -0.465,位移 8 相关 1.000)——不是监控不灵敏,是聚合粒度把证据抹掉了;聚合粒度必须比你想抓的错误周期更细。两条通用教训:灰度会把故障伪装成漂移(看到缓降先问「同期有没有东西在灰度」);时区错误在任何按天视图里都不可见。30 分钟体检五步:scan_dirty 只看 P0/P1 → ⭐每个字符串列打印长度直方图和 Top-20 取值 → 数值列 p1/p50/p99 + 最高频单值 → 时间列按小时循环互相关 → 每个 join 先算交集率。第②步性价比最高也最容易被跳过,因为它看起来像手工劳动,但十种里有六种是靠人扫一眼 Top-20 发现的——规则只能查你已经想到的,Top-20 能让你看见你没想到的。

下一节 👉 05-缺失不是一种东西.md

打卡记录保存在你的浏览器里,首页能看到总进度