📑 本页目录(点开跳转)
08 · 数据质量怎么量
⏱ 78 分钟 | ⭐ 「感觉数据挺脏」不是一个能交接的判断,断言才是
🎯 一句话
数据质量不是一种感觉,是一组能跑、能进 CI、能把流水线拦下来的断言。 前面四章(04 脏数据形态、05 缺失、06 重复、07 异常值)教你认出各种毛病; 这一章把它们全部翻译成代码里的一行 assert, 并且回答三个真正难的问题:阈值定多少才不会天天报警、哪些该阻断流水线、哪些只该记一笔。
📐 一、六个维度:把「脏」拆开
「数据挺脏」这句话没法交接,因为它可能指六件完全不同的事:
| 维度 | 它在问什么 | 典型断言 | 能自动查吗 |
|---|---|---|---|
| 完整性 Completeness | 该有的值和该有的行在不在? | 关键字段非空率 ≥ 99.5%;昨天的分区行数在合理区间 | ✅ 能 |
| 唯一性 Uniqueness | 一个实体是不是只有一行? | 主键无重复;同一 order_id 只出现一次 |
✅ 能 |
| 有效性 Validity | 值本身合不合法? | 类型对、范围对、枚举在白名单里、手机号符合正则、外键能对上 | ✅ 能 |
| 一致性 Consistency | 字段之间 / 表之间自洽吗? | pay_time ≥ create_time;明细 SUM 等于汇总表 |
✅ 能 |
| 时效性 Timeliness | 这份数据是什么时候的? | now − max(event_time) < 3h;到达延迟 P99 < 30min |
✅ 能(但最常被漏掉) |
| 准确性 Accuracy | 它和真实世界一致吗? | 抽样人工复核;和权威源对账 | ❌ 不能 |
前五个维度:数据可以自己证明自己
─────────────────────────────────────────────
「金额是数字吗」「主键重复吗」「时间戳新吗」
→ 只看这份数据本身就能判定,全部可自动化
第六个维度:数据永远不能自己证明自己 ⭐⭐
─────────────────────────────────────────────
「这个用户的年龄真的是 34 岁吗」
→ 表里写着 34,格式合法、范围合法、和其他字段自洽
但他可能今年 51 岁
→ 需要外部参照:人工抽检 / 对账 / 回访 / 另一个系统
⭐ 这是六个维度里唯一需要单独记住的结构性事实: 准确性是唯一一个不能靠数据自己证明的维度。 所有「一键数据质量扫描」工具能给你的,都只是前五个。 第六个必须花人力,所以它的成本模型完全不同 —— 它是抽样问题,不是全量扫描问题。 抽多少条、怎么抽、什么置信度,第 9 章质检抽样那一节会给公式。
一个立刻能用的分工:前五个维度做成全量断言 + 每批次跑; 准确性做成每周抽 N 条人工复核 + 记录错误率趋势。 两者输出到同一份质量报告里,但走两条完全不同的流程。
🧪 二、六段可跑的检查代码
每段独立可跑,最后合成一个统一框架。
① 完整性 —— 别只查「值缺不缺」,还要查「行缺不缺」
def check_completeness(df, required, min_rows, max_rows, group_col=None):
"""required: {列名: 允许的最大缺失率}"""
out = []
n = len(df)
out.append(("行数在区间内", min_rows <= n <= max_rows, {"rows": n}))
for col, max_null in required.items():
r = float(df[col].isna().mean())
out.append((f"{col} 缺失率<={max_null}", r <= max_null, {"null_rate": round(r, 5)}))
# ⭐ 分组看:整体 0.4% 可能是某个渠道 100% 缺 + 其他渠道 0%
if group_col is not None:
g = df.groupby(group_col)[col].apply(lambda s: s.isna().mean())
worst = g.idxmax(), round(float(g.max()), 5)
out.append((f"{col} 分组最差缺失率<={max_null*3}",
g.max() <= max_null * 3, {"worst_group": worst}))
return out
⚠️ 整体缺失率是最会骗人的一个指标。 「
city缺失率 0.4%」听起来很健康 —— 直到你发现是某个新接入的渠道 100% 缺失, 而那个渠道刚好只占 0.4% 的流量。任何比率型断言都要有一个分组版本。
② 唯一性
def check_uniqueness(df, pk_cols, sample=5):
d = df.duplicated(subset=pk_cols, keep=False)
bad = df.loc[d, pk_cols].drop_duplicates().head(sample)
return [("主键唯一", not d.any(),
{"dup_rows": int(d.sum()),
"dup_rate": round(float(d.mean()), 6),
# ⭐ 报告里必须带失败样例的主键,否则没人能去查
"samples": bad.to_dict("records")})]
③ 有效性 —— 范围 / 枚举 / 正则 / 引用完整性
import re
import pandas as pd
def check_validity(df, ranges=None, enums=None, regexes=None, fk=None):
"""fk: {列名: 允许取值的集合} —— 引用完整性"""
out = []
for col, (lo, hi) in (ranges or {}).items():
s = pd.to_numeric(df[col], errors="coerce")
bad = ((s < lo) | (s > hi)).sum()
out.append((f"{col} in [{lo},{hi}]", bad == 0, {"bad": int(bad)}))
for col, allowed in (enums or {}).items():
bad = (~df[col].isin(allowed)) & df[col].notna()
# ⭐ 一定要把"新出现的枚举值"打出来:多半是上游加了新类型没通知你
unseen = sorted(set(df.loc[bad, col].astype(str)))[:10]
out.append((f"{col} 枚举合法", not bad.any(),
{"bad": int(bad.sum()), "unseen": unseen}))
for col, pat in (regexes or {}).items():
c = re.compile(pat)
bad = df[col].dropna().astype(str).map(lambda v: c.fullmatch(v) is None).sum()
out.append((f"{col} 格式合法", bad == 0, {"bad": int(bad)}))
for col, allowed in (fk or {}).items():
bad = (~df[col].isin(allowed)).sum()
out.append((f"{col} 外键可对上", bad == 0, {"orphan": int(bad)}))
return out
④ 一致性 —— 跨字段逻辑 + 跨表对账
def check_consistency(df, rules, recon=None, tol=1e-6):
"""rules: {规则名: 返回布尔 Series 的函数}
recon: (明细聚合后的 Series, 汇总表的 Series) —— 对账"""
out = []
for name, fn in rules.items():
ok = fn(df)
out.append((name, bool(ok.all()), {"violations": int((~ok).sum())}))
if recon is not None:
a, b = recon
diff = (a.reindex(b.index).fillna(0) - b).abs()
# ⭐ 对账是唯一能发现"口径悄悄变了"的检查
out.append(("明细汇总对账", bool((diff <= tol).all()),
{"max_diff": float(diff.max()), "bad_keys": list(diff[diff > tol].index[:5])}))
return out
RULES = {
"支付时间不早于下单": lambda d: d["pay_time"].isna() | (d["pay_time"] >= d["create_time"]),
"已支付必须有支付时间": lambda d: (d["status"] != "paid") | d["pay_time"].notna(),
"实付不超过应付": lambda d: d["paid_amt"] <= d["total_amt"] + 0.01,
}
⑤ 时效性 —— 本章事故的主角
import hashlib
import pandas as pd
def check_timeliness(df, ts_col, partition_ts, max_lag_h=3, prev_fingerprint=None):
ts = pd.to_datetime(df[ts_col])
lag_h = (partition_ts - ts.max()).total_seconds() / 3600
out = [("数据新鲜度", lag_h <= max_lag_h, {"lag_hours": round(float(lag_h), 2)}),
("到达延迟P99可接受",
float((partition_ts - ts).dt.total_seconds().quantile(0.99)) <= max_lag_h * 3600 * 2,
{})]
# ⭐⭐ 一行防「今天的分区是昨天的拷贝」:内容指纹不能和上一个分区相同
fp = hashlib.md5(pd.util.hash_pandas_object(df, index=False).values).hexdigest()
if prev_fingerprint is not None:
out.append(("与上一分区内容不同", fp != prev_fingerprint, {"fingerprint": fp[:12]}))
return out
⑥ 准确性 —— 只能抽样,只能靠外部
import numpy as np
def sample_for_review(df, n=200, strata=None, seed=0):
"""分层抽样出待人工复核的样本;⭐ 别用简单随机抽,稀有类会抽不到"""
if strata is None:
return df.sample(min(n, len(df)), random_state=seed)
g = df.groupby(strata, group_keys=False)
per = max(1, n // max(1, g.ngroups))
return g.apply(lambda s: s.sample(min(per, len(s)), random_state=seed))
def accuracy_from_review(n_checked, n_wrong, conf=0.95):
"""人工复核完之后:错误率点估计 + 单侧上限(全对时也别写 0%)"""
p = n_wrong / n_checked
upper = 1 - (1 - conf) ** (1 / n_checked) if n_wrong == 0 else p + 1.96 * np.sqrt(p * (1 - p) / n_checked)
return {"error_rate": round(p, 4), "upper_bound": round(float(upper), 4)}
# ⭐ 抽 200 条全对 → 只能说"95% 置信下错误率 < 1.49%",不能说"错误率 0%"
把六项合起来的最小框架(真实项目里可以换成 Great Expectations / Soda / dbt tests,逻辑一样):
from dataclasses import dataclass, field
from typing import Callable, Any
@dataclass
class Check:
id: str
dim: str # completeness / uniqueness / validity / consistency / timeliness
level: str # BLOCK / WARN / INFO ⭐ 级别是断言的一部分,不是事后决定的
fn: Callable[[Any], list]
def run_suite(df, checks, ctx=None):
report, blocked = [], False
for c in checks:
for name, ok, detail in c.fn(df):
report.append({"check_id": c.id, "dim": c.dim, "level": c.level,
"name": name, "passed": bool(ok), "detail": detail,
"n_rows": len(df)})
if not ok and c.level == "BLOCK":
blocked = True # ⭐ 只有 BLOCK 级失败才拦流水线
return {"blocked": blocked, "results": report}
🛑 读到这里可以停 —— 前半章讲完了(约 32 分钟)。 后半章还有:哪些阻断流水线,哪些只记一笔 · 阈值怎么定才不会天天报警 · 质量报告怎么进 CI · 事故复盘:数据全都「合法」,只是旧了六天 · 从 0 到有门禁:一周的顺序 回来的时候不用重读,直接从下一节接着看就行。
🚦 三、哪些阻断流水线,哪些只记一笔
这是整套门禁能不能活过三个月的关键。全设成阻断的门禁,两周内就会被绕过。
| 级别 | 什么时候用 | 例子 | 触发后 |
|---|---|---|---|
| 🔴 BLOCK | 数据本身能 100% 证明这是错的,且下游一定会被污染 | 主键重复;关键字段类型变了;行数只有平时的 3%;now − max(event_time) > 24h |
停止发布,保留上一个可用版本,叫人 |
| 🟡 WARN | 「看起来不对」,但可能是真实业务变化 | 缺失率从 0.6% 升到 2%;新枚举值出现;某渠道量翻倍 | 照常发布,报告里高亮,进当日待办 |
| ⚪ INFO | 只是想留个趋势 | 各字段分布分位数、类别占比、行数 | 只落库,画趋势图 |
⭐ 分级判据一句话: 能靠数据自己 100% 判定是错的 → BLOCK;只是「和昨天不一样」→ WARN。 「和昨天不一样」永远可能是业务真的变了 —— 大促、新渠道上线、口径调整。 把它设成阻断,你就是在用数据管道惩罚业务增长。
三个必须避开的反模式:
❌ 反模式一:所有断言都是 BLOCK
→ 第一次大促当天全线飘红 → 有人加了个"强制通过"按钮
→ 三周后所有人默认先点强制通过再看报告
💀 门禁就此死亡,但报表上它还是"100% 覆盖"
❌ 反模式二:BLOCK 了但没有回退目标
→ 拦住了脏数据,下游拿到的是"没有数据"
→ 有些场景比脏数据更糟(特征服务直接返回默认值)
⭐ BLOCK 的正确语义是"回退到上一个已知良好版本",不是"什么都不给"
❌ 反模式三:断言写在数据加工代码里
→ 改一次口径要动两处,很快就不同步
⭐ 断言应该和数据契约(第 3 章)放在一起,独立于加工逻辑
💀 一个能直接拿去用的健康度指标: 统计「强制通过」按钮被点击的次数。 这个数字才是门禁真实健康度的唯一诚实指标 —— 它每周被点 3 次以上,说明你的 BLOCK 级断言定错了,不是人不守规矩。
🔔 四、阈值怎么定才不会天天报警
拍脑袋的整数阈值(1%、5%、99.9%)是告警疲劳的头号来源。 这一节做了一个 90 天的模拟:某字段的日缺失率日常在 0.61% ± 0.29% 波动, 中间有两天是自愈的临时抖动(2.1% / 1.7%),第 60 天上游真的改坏了,缺失率跳到 3.96% 并一直保持。
| 阈值策略 | 90 天报警次数 | 真事件前误报 | 真事件后命中 |
|---|---|---|---|
静态 > 1%(拍脑袋的整数) |
32 次 | 2 次(都是自愈抖动) | 30 / 30 |
| 中位数 + 3.5×MAD(28 天滚动窗) | 15 次 | 1 次 | 14 / 30 💀 |
静态 > 2.80%(历史 P99 × 1.5) ⭐ |
30 次 | 0 次 | 30 / 30 |
两个结论都很反直觉:
⭐ 结论一:阈值该从历史分布里算出来,不该是个好看的整数。 同一份数据,
1%让你在真事件之前先吃两次误报; 而 历史 P99 × 1.5 = 2.80% 这个"难看"的数字,误报 0 次,真事件 30/30 全中。 拍整数之所以危险,是因为 1% 这个数和这份数据的实际波动幅度(σ ≈ 0.29%)毫无关系 —— 它落在了日常波动的尾巴上,而不是尾巴外面。💀 结论二:纯动态阈值会把「持续变坏」学成新常态。 MAD 动态阈值在第 60~73 天连报 14 天,然后从第 74 天起彻底静默 —— 数据依然是坏的(缺失率 3.96%,是正常水平的 6.5 倍), 但滚动窗口已经被坏数据填满,4% 成了它眼里的"正常"。 动态阈值只报「变化」,不报「坏」。
所以正确答案是两条线一起用:
- 怎么算:中位数 + k×MAD,滚动窗排除已知异常日
- 作用:抓突变,抓"今天和过去不一样"
- ⚠️ 会随时间漂移,不能作为唯一防线
- 怎么定:从业务上"绝对不可接受"倒推,写死不随窗口变
- 例:缺失率 > 5% 就是不能进模型,不管趋势如何
- 作用:兜住"温水煮青蛙"式的持续劣化
再加三个能立刻砍掉一半噪音的技巧:
| 技巧 | 怎么做 | 效果 |
|---|---|---|
| 持续时间条件 | 连续 2 个批次都超阈值才升级为告警 | 干掉全部一次性抖动(本例那 2 次误报直接归零) |
| 分层阈值 | 主表/主渠道用严阈值,长尾维度用松阈值 | 避免被低流量分片的天然高方差刷屏 |
| 告警自带上下文 | 附上失败样例主键、上次同类告警的处理记录、该找谁 | 决定了这条告警是被处理还是被划掉 ⭐ |
⚠️ 这一节的所有内容,在《模型上线之后》里有一整章的展开。 阈值定错导致的不是"多几条消息",而是整个告警体系失去可信度: 一旦告警的准确率低于某个线,人对它的默认反应就变成"先划掉"。 详见 上线之后 08 · 告警为什么没人看 —— 数据质量告警和模型监控告警死于完全相同的机制。
📄 五、质量报告怎么进 CI
报告格式:一份数据,两种渲染。
机器可读(JSON,落库) 人可读(Markdown / HTML)
──────────────────────── ──────────────────────────
check_id / dim / level ✅ 32 通过 🟡 3 警告 🔴 0 阻断
passed / n_rows / fail_count 每条失败:名称 + 失败率 + 5 个样例主键
fail_rate / sample_keys ⭐ 和上一批次的对比箭头
run_id / commit_sha / 数据版本 趋势小图(近 30 批次)
⭐ 报告里最值钱的一个字段是
sample_keys。 「user_id有 412 条枚举越界」没人能行动; 「这 5 条 id 是 8823/9104/9331/9502/9877,都来自渠道ch_ad_07」十分钟就能定位。 一条不带样例主键的失败断言,等于没写。
三个挂点,各管一件事:
| 挂点 | 跑什么 | 失败了怎样 |
|---|---|---|
| PR 阶段 | 数据契约(第 3 章)变更时,用样本数据跑全套断言 | 阻断合并 —— 最便宜的一道 |
| 批次产出后 | 全量跑,BLOCK 级决定要不要发布 | 阻断发布,回退到上一版本 |
| 每日趋势 | 把所有指标落库,看曲线不看单点 | 不阻断,但生成日报 |
⭐ 第三个挂点最容易被砍掉,但它才是抓慢性病的那个。 断言只回答「今天过不过」,趋势回答「我们是不是在慢慢变坏」。 通过率连续 30 天 100%、缺失率从 0.6% 一路爬到 0.9% —— 每一天都合格,一个月后你的模型已经在另一份数据上跑了。
💀 六、事故复盘:数据全都「合法」,只是旧了六天
发生了什么
系统:消费金融的申请评分卡(A 卡)
规模:日均申请 4.2 万笔,46 个特征来自离线数仓的用户行为宽表
门禁:已有 38 条质量断言,覆盖完整性 / 唯一性 / 有效性 / 一致性
⚠️ 时效性:0 条 准确性:0 条
3 月 11 日,调度平台升级。某个上游任务的分区参数从 ${bizdate} 被改成了硬编码,
重跑逻辑把新分区写成了 3 月 10 日数据的完整拷贝。之后连续六天,每天的分区都是同一份数据。
那六天里,38 条断言的运行结果:
行数 200,143 行 ← 和昨天完全一样,波动 0.00% ✅
主键唯一 0 重复 ✅
非空率 99.71% ← 和昨天完全一样 ✅
枚举合法 全部命中白名单 ✅
跨字段一致性 0 违规 ✅
分区存在性 dt=2024-03-16 存在 ✅
⭐ 38/38 全绿。而 max(event_time) 从 3 月 11 日起就再没动过。
「近 30 天消费金额」这类特征因此变成了「截止 3 月 10 日的 30 天窗口」, 每过一天就旧一天。对刚发生消费行为的新申请人,模型看到的是一片空白。
为什么没被发现(三条,每条都很典型)
① 六个维度只实现了四个 —— 时效性一条都没有
团队认为"分区存在性检查"就是时效性检查
💀 分区存在只证明【任务跑完了】,不证明【数据是新的】
这两件事之间隔着整个事故
② 行数波动断言设的是 ±20%
而"复制昨天"这种故障,行数波动恰好是 0.00%
💀 它是所有断言里【最绿】的那一条 ⭐
异常检测的盲区不是"偏差太大",是"偏差为零"
③ 模型侧的漂移监控(PSI)纹丝不动:PSI = 0.02
因为特征分布确实没变 —— 那就是昨天的分布
⭐ 漂移监控问的是"分布变没变",而陈旧数据的分布恰恰【没变】
PSI 对"数据停止更新"这类故障完全免疫
代价
3 月 17 日风控日报看出通过率异常,此时已连续 6 天
通过率 31.4% → 38.9% (+7.5 pp)
多通过 ≈ 1.89 万笔(4.2万 × 6天 × 7.5%)
实际放款 1.14 万笔,7,760 万元
首逾(M1)率 这批 8.7% vs 正常批次 2.9% (+5.8 pp)
⭐ 最终多核销 约 430 万元
排查耗时也是代价的一部分:
定位用了 2 天 —— 因为质量报告 38/38 全绿,
所有人的第一反应是查模型和策略,没人怀疑数据 💀
回溯重跑 6 天分区 + 重训模型,又用了 11 天
💀 这个事故的本质,值得抄在门禁代码的注释里: 38 条断言全都在问「这份数据对不对」,没有一条在问「这份数据是不是今天的」。 一份六天前的数据,在前四个维度上是完美的。
该补什么
| 缺失 | 补上之后 |
|---|---|
| 时效性维度整个缺失 | 每张进模型的表都要有 now − max(event_time) < SLA 断言,级别 BLOCK ⭐⭐ |
| 把「分区存在」当新鲜度 | 新鲜度分两层:分区级(分区在不在)+ 记录级(里面的数据新不新),后者才是真的 |
| 没有跨批次内容比对 | 加一条跨天指纹断言:相邻两个分区的内容哈希不能相同 —— 一行代码,能单独拦住整个事故 ⭐⭐ |
| 特征侧没有时间戳 | 特征值随身带 feature_as_of_time,打分时校验它和请求时间的差,超了就降级 |
| 监控只有「变化」指标 | 补「绝对水平」指标(数据年龄、最新事件时间),PSI 这类相对指标对停更故障免疫 ⭐ |
⭐ 那条跨天指纹断言便宜到离谱:
md5(内容) != md5(上一批内容)。 它拦不住绝大多数数据问题,但它拦住的这一类 —— 「上游悄悄停更 / 重复写入同一份数据」—— 是所有质量事故里最难被察觉的一种, 因为它在每一个传统维度上都表现得完美无瑕。
🧭 七、从 0 到有门禁:一周的顺序
Day 1 列出【进模型的前 20 个字段】,只管这 20 个
⚠️ 不要从"给所有表加断言"开始,那个项目永远做不完
Day 2 每个字段写 3 条:非空率、范围/枚举、时效性
全部先设成 WARN,一条 BLOCK 都不要有 ⭐
Day 3-7 只跑不拦,收集 7 天的实际分布
⭐ 阈值从这 7 天的 P99 × 1.5 算出来,不要在 Day 2 就拍数字
第 2 周 把其中"确定性错误"那几条升级为 BLOCK
(主键重复、类型变更、数据年龄超 24h)
其余永远留在 WARN
之后 每次线上事故复盘,问一句:
"哪一条断言本可以拦住它?" → 加上去
⭐ 断言集应该由事故驱动生长,不是由想象力驱动
⭐ Day 3-7 那一步是最多人跳过、也最不能跳过的一步。 没有 7 天的实际分布,你写的每一个阈值都是猜的; 而第四节已经证明了:猜出来的整数阈值会让你在真事件之前先吃两次误报, 而误报的代价不是噪音,是下次真的响的时候没人看。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 第 3 章 · 数据契约 | 断言应该和契约放在一起、独立于加工逻辑;契约变更触发 PR 阶段的检查 |
| 第 7 章 · 异常值是错还是真 | 「极端值比例」「删除率」就是这里的 WARN 级指标;清洗动作必须留痕进质量报告 |
| 第 14 章 · 数据泄漏的七种来源 | 时效性断言的另一面:特征的时间戳晚于标签时间就是泄漏 |
| 上线之后 08 · 告警为什么没人看 | ⚠️ 阈值定错→误报→没人看,数据质量告警和模型告警死于同一个机制 |
| 上线之后 05 · 该监控什么 | 质量指标进监控大盘的那一层;哪些指标该有 SLO |
| 上线之后 06 · 数据漂移与概念漂移 | PSI 的三档阈值和它的两个坑(分桶必须按训练集切、样本 <1000 不稳),以及它更大的盲区:只看 P(x),对概念漂移完全失明。⭐ 而「数据停止更新」这一类它同样看不见 —— 本章第六节的事故就是(实测 PSI=0.02) |
✅ 检查点
- 数据质量的六个维度是哪些?哪一个不能靠数据自己证明?为什么这决定了它的成本模型完全不同?
- 「
city缺失率 0.4%,很健康」这句话可能藏着什么问题?断言该怎么改? - BLOCK / WARN / INFO 的分级判据一句话是什么?为什么「和昨天不一样」不该设成阻断?
- 「所有断言都设成 BLOCK」会发生什么?衡量门禁真实健康度的那个诚实指标是什么?
- 模拟里,静态阈值
>1%在 90 天中报了多少次、真事件前误报几次?换成历史 P99×1.5(=2.80%)之后呢?说明了什么? - 为什么纯动态阈值(中位数+3.5×MAD)在第 74 天就不响了?这叫什么问题,怎么补?
- 质量报告里最值钱的字段是哪个?为什么?
- 那个 430 万的事故里,38 条断言为什么全是绿的?行数波动断言为什么反而是最绿的一条?
- 为什么模型侧的 PSI 漂移监控对这个事故完全免疫?
- 「跨天指纹」断言是什么?为什么这么便宜的一条值得单独强调?
- 从 0 搭门禁时,为什么 Day 2 一条 BLOCK 都不要设、要等到 Day 3-7 之后?
👀 答案
- 完整性 / 唯一性 / 有效性 / 一致性 / 时效性 / 准确性。准确性不能靠数据自己证明 —— 表里写着年龄 34,格式合法、范围合法、和其他字段自洽,但他可能真是 51 岁,必须有外部参照(人工抽检 / 对账 / 回访)。所以它是抽样问题而不是全量扫描问题,成本模型完全不同:前五个做全量断言每批跑,它做每周抽 N 条人工复核 + 错误率趋势。
- 可能是某个新接入的渠道 100% 缺失,而那个渠道刚好只占 0.4% 流量。整体比率会把局部灾难摊平。改法:任何比率型断言都要有一个分组版本(按渠道/来源/地区分组看最差的那一组)。
- 能靠数据自己 100% 判定是错的 → BLOCK;只是「和昨天不一样」→ WARN。 因为「和昨天不一样」永远可能是业务真变了(大促、新渠道、口径调整),把它设成阻断等于用数据管道惩罚业务增长。
- 第一次大促就全线飘红 → 有人加「强制通过」按钮 → 三周后所有人默认先点强制通过。门禁死亡但覆盖率报表还是 100%。诚实指标:「强制通过」按钮被点击的次数 —— 每周超过 3 次说明 BLOCK 级断言定错了,不是人不守规矩。
- 静态
>1%:90 天报 32 次,真事件前误报 2 次(都是自愈抖动),命中 30/30。换成 历史 P99×1.5 = 2.80%:误报 0 次,命中 30/30。说明阈值该从历史分布里算,不该是好看的整数 —— 1% 和这份数据的实际波动(0.61% ± 0.29%)毫无关系,它落在日常波动的尾巴上而不是尾巴外。 - 因为 28 天滚动窗被坏数据填满了,4% 成了它眼里的"正常" —— 数据依然坏着(3.96%,正常水平的 6.5 倍)但它静默了。本质是动态阈值只报「变化」,不报「坏」。补法:再加一条静态硬底线(从业务上"绝对不可接受"倒推,写死不随窗口变),两条线一起用。
sample_keys(失败样例的主键)。「412 条枚举越界」没人能行动;「这 5 条 id 是 8823/9104/…,都来自渠道 ch_ad_07」十分钟能定位。不带样例主键的失败断言等于没写。- 因为 38 条断言全都在问「这份数据对不对」,没有一条在问「这份数据是不是今天的」 —— 六天前的数据在完整性/唯一性/有效性/一致性四个维度上是完美的。行数波动断言设的 ±20%,而"复制昨天"的行数波动恰好是 0.00%,所以它是最绿的一条:异常检测的盲区不是偏差太大,是偏差为零。
- 因为 PSI 问的是「分布变没变」,而陈旧数据的分布恰恰没变(那就是昨天的分布),实测 PSI = 0.02。所有相对/变化型指标对「数据停止更新」这类故障免疫,必须补绝对水平指标(数据年龄、最新事件时间)。
md5(本批内容) != md5(上一批内容),一行代码。值钱是因为它专治「上游悄悄停更 / 重复写入同一份数据」 —— 这类故障在每一个传统维度上都表现完美,是所有质量事故里最难察觉的一种。事故里它能单独拦住整件事。- 因为没有 7 天的实际分布,写的每个阈值都是猜的;而猜出来的整数阈值会让你在真事件之前先吃误报,误报的代价不是噪音而是下次真响时没人看。正确顺序:Day 2 全设 WARN 只跑不拦 → Day 3-7 收集分布 → 阈值取 P99×1.5 → 第 2 周只把「确定性错误」升级为 BLOCK。
🛑 可以停在这里
⚡ 走神救援
⭐⭐数据质量不是感觉,是一组能跑、能进 CI、能拦流水线的断言。 六个维度:完整性(值缺不缺 + 行缺不缺)、唯一性(主键)、有效性(类型/范围/枚举/正则/外键)、一致性(跨字段逻辑 + 明细汇总对账)、时效性(
now − max(event_time))、准确性。⭐前五个数据能自己证明自己,第六个永远不能 —— 表里写 34 岁,格式范围全对,人可能真是 51 岁;所以准确性是抽样问题(每周抽 N 条人工复核),不是全量扫描问题。⚠️ 写断言的第一个坑:整体比率最会骗人,「city 缺失率 0.4%」可能是某个新渠道 100% 缺失,任何比率断言都要有分组版本。分级三档:能靠数据 100% 判定是错的 → BLOCK;只是和昨天不一样 → WARN;只想留趋势 → INFO;💀 全设 BLOCK 的门禁两周内就会长出一个"强制通过"按钮然后死掉,统计这个按钮被点的次数才是门禁健康度的唯一诚实指标。阈值这一节有实测:某字段日缺失率日常 0.61%±0.29%,第 60 天上游改坏跳到 3.96% —— 拍脑袋的静态>1%在 90 天里报 32 次、真事件前误报 2 次;而从历史算出来的 P99×1.5 = 2.80%,误报 0 次、命中 30/30 ⭐阈值该从历史分布算,不该是好看的整数(1% 落在日常波动的尾巴上而不是尾巴外)。💀 但纯动态阈值(中位数+3.5MAD,28天窗)更阴险:第 60–73 天连报 14 天,第 74 天起彻底静默 —— 数据还坏着(6.5 倍于正常),滚动窗却把 4% 学成了新常态,动态阈值只报「变化」不报「坏」,所以必须动态线 + 静态硬底线两条一起用。再加持续时间条件(连报 2 批才升级)、分层阈值、告警自带样例主键和处理记录。⭐报告里最值钱的字段是sample_keys:「412 条越界」没人能行动,「这 5 条 id 都来自 ch_ad_07」十分钟定位。💀事故:消费金融 A 卡,调度升级后上游连续六天把新分区写成 3 月 10 日的完整拷贝 —— 行数波动 0.00%、主键无重复、非空率一模一样、枚举全对、分区存在,38 条断言 38 绿,而max(event_time)六天没动。通过率 31.4%→38.9%,多批 1.89 万笔、放款 7,760 万,首逾率 8.7% vs 正常 2.9%,多核销约 430 万;定位还多花 2 天,因为报告全绿没人怀疑数据。三个盲区:时效性一条断言都没有(把"分区存在"当成了新鲜度检查,可它只证明任务跑完了)、行数波动 ±20% 的断言遇到"复制昨天"恰好波动为零,反而是最绿的一条、PSI = 0.02 纹丝不动,因为陈旧数据的分布恰恰没变,漂移监控对停更故障完全免疫。补法里最便宜的一条:⭐⭐跨天指纹断言 —— 相邻分区内容哈希不能相同,一行代码单独拦住整个事故。落地顺序:只管进模型的前 20 个字段 → 每字段 3 条断言全设 WARN → 跑满 7 天收集真实分布再定阈值 → 第 2 周只把确定性错误升级为 BLOCK → 之后每次事故复盘问「哪条断言本可以拦住它」,断言集由事故驱动生长,不由想象力驱动。
下一节 👉 09-标注体系怎么设计.md