📑 本页目录(点开跳转)
04 · 脏数据的十种形态
⏱ 66 分钟 | ⭐ 会报错的脏数据不可怕,可怕的是能跑通的那种
🎯 一句话
脏数据只分两类:一类让你的任务挂掉,一类让你的任务成功地算出错答案。 下面十种形态里,只有前两种偶尔属于第一类。 剩下八种全部能顺利跑完、不报任何错、产出一份看起来完全正常的结果。
🧨 一、先按「会不会叫」把十种排个序
会报错(好脏数据) 静默通过(坏脏数据)💀
───────────────── ────────────────────
① 类型不一致 (有时会) ③ 时区
② 字符编码 (有时会) ④ 单位
⑤ 字段截断
⑥ 默认值伪装成真值
⑦ 枚举漂移
⑧ 前后空格与全半角
⑨ 嵌套 JSON 结构变了
⑩ 同名不同义
⭐ 大多数人的时间分配是反的:
90% 的排查精力花在 ①②(因为它们会叫、会红、会有堆栈)
而 90% 的实际损失来自 ③–⑩(因为它们不叫)
🔑 判断一种脏数据危不危险,只看一件事:它会不会让程序停下来。 会停的,代价是几十分钟;不会停的,代价是几周乘以每天的业务量。 这一章后面所有的「怎么发现」,本质上都是给不会叫的东西装一个铃铛。
🔬 二、十种形态:症状 → 怎么发现 → 怎么修
① 类型不一致
同一列里 "123"、123、123.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_unit 和 tz |
⚠️ 分区键的时区要单独声明 —— dt 按 UTC 切还是按 Asia/Shanghai 切,是两份不同的数据 |
④ 单位
元 / 分,米 / 千米,秒 / 毫秒,摄氏 / 华氏,GB / GiB。类型完全不变,数值差 100 倍或 1024 倍。
| 内容 | |
|---|---|
| 症状 | 某天开始,某个数值列的均值突然是原来的 100 倍或 1/100,分布形状完全不变 ⭐ |
| 怎么发现 | 监控分位数而不是均值(均值会被异常值带偏,分位数不会) |
⭐ 断言取值上限:amount <= 1e7。一个上限断言能抓住绝大多数「元改成分」 |
|
| 怎么修 | 字段名里带单位:amount_cents、dwell_ms、dist_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、-999、1970-01-01、1900-01-01、"未知"、""、"其他"。
它们不是 NULL,所以所有缺失率监控都看不到它们。
| 内容 | |
|---|---|
| 症状 | 某个值的占比高得不合理(年龄 0 占 8%、首单时间 1970-01-01 占 3%)⭐ |
| 均值被拽向哨兵值;模型把「年龄 0」当成真实的婴儿用户 | |
| 怎么发现 | ⭐⭐ 算每列的「最高频单值占比」。真实的连续变量不该有任何一个值占到 1% 以上 |
对照哨兵值清单扫一遍;把 epoch=0 附近和「未来时间」的时间戳单独统计 |
|
| 怎么修 | 在入口把哨兵值显式转成 NULL,然后按第 5 章处理,并保留一个缺失指示列 |
| ⚠️ 不要直接删 —— 「这个字段是默认值」本身往往是有信息的 |
⑦ 枚举漂移
上游悄悄加了取值、改了拼写(iOS → ios)、合并了两个取值。第 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_id、app_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_unit 和 tz |
时间字段必须三件套:epoch_unit、tz、is_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 数据管线与存储 | 编码、分区时区、回溯重刷在工程上怎么落 |
✅ 检查点
- 脏数据分成哪两类?为什么说「危不危险只看它会不会让程序停下来」?大多数人的时间分配错在哪?
- 类型不一致最典型的症状是什么?为什么说「能转成数值的比例在 0% 和 100% 之间」最可疑?为什么不能靠
CAST兜底? - 字段截断最贵的后果是什么?怎么用一张图发现它?为什么说它「不可逆」?
- 「默认值伪装成真值」为什么所有缺失率监控都看不到?用哪一条规则能一次抓住绝大多数哨兵值?
- 十种里哪两种被低估最多?它们的共同特点是什么?
- 时区问题为什么必须按小时看?实跑里按天和按小时的 PSI 分别是多少?「聚合粒度」的一般规律是什么?
- 那个 8 小时的事故:改了什么、为什么四项契约校验全绿、灰度起了什么作用、留存虚高多少、push 决策造成了什么后果、发现延迟多久、靠什么发现的?
- 「灰度会把故障伪装成漂移」是什么意思?看到缓降时该先问哪一句?
- 30 分钟体检的五步里,哪一步性价比最高?为什么它容易被跳过?规则和「人扫一眼 Top-20」各自能发现什么?
👀 答案
- 一类让任务挂掉,一类让任务成功地算出错答案。会停的代价是几十分钟(你立刻就知道了),不会停的代价是几周 × 每天的业务量。大多数人把 90% 的排查精力花在会报错的 ①② 上,而 90% 的实际损失来自不报错的 ③–⑩。
- 最典型的是 join 之后行数莫名变少(
"1001"匹配不上1001),以及groupby出现两个看起来一样的分组。0% 和 100% 都正常(说明这是纯文本列或纯数值列),中间态说明一列里混了两种东西。CAST会把"abc"静默变成NULL—— 它不是修复,是把类型问题转换成缺失问题,而且不留痕迹。 - 最贵的后果是⭐⭐一批不同的实体挤成了同一个值,直接制造假重复(第 6 章)。发现方法:画字符串长度直方图,在 64 / 255 这种整数边界上出现尖峰几乎一定是截断。不可逆是因为被切掉的字节根本没有被写下来过,只能回上游重取。
- 因为它们不是
NULL,非空率是 100%,所有缺失率监控全绿。一条规则:⭐⭐算每列的「最高频单值占比」 —— 真实的连续变量不该有任何一个值占到 1% 以上,一旦有,那个值几乎一定是默认值。 - ⑤ 字段截断和 ⑩ 同名不同义。共同点:它们不产生任何异常值 —— 截断产生的是完全合法的短字符串,同名不同义产生的是完全合法的 join 结果。取值域校验、缺失率监控、异常检测对这两种全盲。
- 因为时区错位在按天的视图里表现为「总量完全一样」,只有在按小时的直方图上才表现为整体平移 N 格。实跑:按天 PSI ≈ 0(总量一模一样),按小时 PSI = 1.457;位移 0 的相关系数 -0.465,位移 8 的相关系数 1.000。一般规律:⭐聚合粒度必须比你想抓的错误的周期更细,否则聚合会把证据抹掉。
- 埋点 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 曲线出现两个午夜峰(新旧 SDK 灰度期并存);修复 11 人日 + 4.2 万核时回刷。 - 断崖的形状意味着「有人改了东西」,缓坡的形状意味着「世界慢慢变了」——而灰度发布恰好把前者变成后者的形状。看到缓降先问:⚠️同期有没有任何东西在灰度? 确认没有,再去谈漂移和重训。
- ⭐第 ② 步(每个字符串列打印长度直方图和 Top-20 取值)性价比最高。它容易被跳过是因为「不够自动化」、看起来像手工劳动。但十种形态里有六种是靠人扫一眼 Top-20 发现的。区别在于:规则只能查你已经想到的东西,Top-20 能让你看见你没想到的东西。
🛑 可以停在这里
⚡ 走神救援
⭐脏数据只分两类:让任务挂掉的,和让任务成功算出错答案的。 十种形态里只有①类型不一致②字符编码偶尔会报错,③时区④单位⑤字段截断⑥默认值伪装成真值⑦枚举漂移⑧空格与全半角⑨嵌套JSON结构变了⑩同名不同义全部静默通过。⚠️大多数人的时间分配是反的:90% 的排查精力花在会叫的①②,90% 的损失来自不叫的③–⑩。 每种的抓手:① join 后行数莫名变少是最典型症状,「可转数值比例」在 0% 和 100% 之间最可疑,⚠️别用
CAST兜底——它把"abc"静默变成NULL;② 查�//零宽空格,读文件用utf-8-sig;③时区⭐⭐必须按小时看;④单位——字段名里带单位(amount_cents、dwell_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