🏠 总目录📚 本教程 08 · 质量怎么量 ← →
📑 本页目录(点开跳转)

08 · 数据质量怎么量

⏱ 70 分钟 | ⭐ 「感觉数据挺脏」不是一个能交接的判断,断言才是


🎯 一句话

数据质量不是一种感觉,是一组能跑、能进 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 天窗口」, 每过一天就旧一天。对刚发生消费行为的新申请人,模型看到的是一片空白。

为什么没被发现(三条,每条都很典型)

操作步骤

  1. 六个维度只实现了四个 —— 时效性一条都没有
  2. 团队认为"分区存在性检查"就是时效性检查
  3. 💀 分区存在只证明【任务跑完了】,不证明【数据是新的】
  4. 这两件事之间隔着整个事故
  5. 行数波动断言设的是 ±20%
  6. 而"复制昨天"这种故障,行数波动恰好是 0.00%
  7. 💀 它是所有断言里【最绿】的那一条 ⭐
  8. 异常检测的盲区不是"偏差太大",是"偏差为零"
  9. 模型侧的漂移监控(PSI)纹丝不动:PSI = 0.02
  10. 因为特征分布确实没变 —— 那就是昨天的分布
  11. ⭐ 漂移监控问的是"分布变没变",而陈旧数据的分布恰恰【没变】
  12. 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)
SQL 查询这一关 10 拿 SQL 审问一份数据 ⭐ 本章六个维度的 SQL 版:那一章把能自动查的前五个(完整性/唯一性/有效性/一致性/时效性)写成纯 SQL 断言 —— 数据还在库里时,没必要先拉进内存再用 pandas 查

✅ 检查点

  1. 数据质量的六个维度是哪些?哪一个不能靠数据自己证明?为什么这决定了它的成本模型完全不同?
  2. 「city 缺失率 0.4%,很健康」这句话可能藏着什么问题?断言该怎么改?
  3. BLOCK / WARN / INFO 的分级判据一句话是什么?为什么「和昨天不一样」不该设成阻断?
  4. 「所有断言都设成 BLOCK」会发生什么?衡量门禁真实健康度的那个诚实指标是什么?
  5. 模拟里,静态阈值 >1% 在 90 天中报了多少次、真事件前误报几次?换成历史 P99×1.5(=2.80%)之后呢?说明了什么?
  6. 为什么纯动态阈值(中位数+3.5×MAD)在第 74 天就不响了?这叫什么问题,怎么补?
  7. 质量报告里最值钱的字段是哪个?为什么?
  8. 那个 430 万的事故里,38 条断言为什么全是绿的?行数波动断言为什么反而是最绿的一条?
  9. 为什么模型侧的 PSI 漂移监控对这个事故完全免疫?
  10. 「跨天指纹」断言是什么?为什么这么便宜的一条值得单独强调?
  11. 从 0 搭门禁时,为什么 Day 2 一条 BLOCK 都不要设、要等到 Day 3-7 之后?
👀 答案
  1. 完整性 / 唯一性 / 有效性 / 一致性 / 时效性 / 准确性。准确性不能靠数据自己证明 —— 表里写着年龄 34,格式合法、范围合法、和其他字段自洽,但他可能真是 51 岁,必须有外部参照(人工抽检 / 对账 / 回访)。所以它是抽样问题而不是全量扫描问题,成本模型完全不同:前五个做全量断言每批跑,它做每周抽 N 条人工复核 + 错误率趋势。
  2. 可能是某个新接入的渠道 100% 缺失,而那个渠道刚好只占 0.4% 流量。整体比率会把局部灾难摊平。改法:任何比率型断言都要有一个分组版本(按渠道/来源/地区分组看最差的那一组)。
  3. 能靠数据自己 100% 判定是错的 → BLOCK;只是「和昨天不一样」→ WARN。 因为「和昨天不一样」永远可能是业务真变了(大促、新渠道、口径调整),把它设成阻断等于用数据管道惩罚业务增长。
  4. 第一次大促就全线飘红 → 有人加「强制通过」按钮 → 三周后所有人默认先点强制通过。门禁死亡但覆盖率报表还是 100%。诚实指标:「强制通过」按钮被点击的次数 —— 每周超过 3 次说明 BLOCK 级断言定错了,不是人不守规矩。
  5. 静态 >1%:90 天报 32 次,真事件前误报 2 次(都是自愈抖动),命中 30/30。换成 历史 P99×1.5 = 2.80%:误报 0 次,命中 30/30。说明阈值该从历史分布里算,不该是好看的整数 —— 1% 和这份数据的实际波动(0.61% ± 0.29%)毫无关系,它落在日常波动的尾巴上而不是尾巴外。
  6. 因为 28 天滚动窗被坏数据填满了,4% 成了它眼里的"正常" —— 数据依然坏着(3.96%,正常水平的 6.5 倍)但它静默了。本质是动态阈值只报「变化」,不报「坏」。补法:再加一条静态硬底线(从业务上"绝对不可接受"倒推,写死不随窗口变),两条线一起用。
  7. sample_keys(失败样例的主键)。「412 条枚举越界」没人能行动;「这 5 条 id 是 8823/9104/…,都来自渠道 ch_ad_07」十分钟能定位。不带样例主键的失败断言等于没写。
  8. 因为 38 条断言全都在问「这份数据对不对」,没有一条在问「这份数据是不是今天的」 —— 六天前的数据在完整性/唯一性/有效性/一致性四个维度上是完美的。行数波动断言设的 ±20%,而"复制昨天"的行数波动恰好是 0.00%,所以它是最绿的一条:异常检测的盲区不是偏差太大,是偏差为零。
  9. 因为 PSI 问的是「分布变没变」,而陈旧数据的分布恰恰没变(那就是昨天的分布),实测 PSI = 0.02。所有相对/变化型指标对「数据停止更新」这类故障免疫,必须补绝对水平指标(数据年龄、最新事件时间)。
  10. md5(本批内容) != md5(上一批内容),一行代码。值钱是因为它专治「上游悄悄停更 / 重复写入同一份数据」 —— 这类故障在每一个传统维度上都表现完美,是所有质量事故里最难察觉的一种。事故里它能单独拦住整件事。
  11. 因为没有 7 天的实际分布,写的每个阈值都是猜的;而猜出来的整数阈值会让你在真事件之前先吃误报,误报的代价不是噪音而是下次真响时没人看。正确顺序:Day 2 全设 WARN 只跑不拦 → Day 3-7 收集分布 → 阈值取 P99×1.5 → 第 2 周只把「确定性错误」升级为 BLOCK。

🛑 可以停在这里

⚡ 走神救援

⭐ 数据质量不是感觉,是一组能跑、能进 CI、能拦流水线的断言。 六个维度:完整性、唯一性、有效性、一致性、时效性、准确性。

⭐ 前五个数据能自己证明自己,第六个永远不能——表里写 34 岁,格式范围全对,人可能真是 51 岁。所以准确性是抽样问题,不是全量扫描问题。

⚠️ 写断言的第一个坑:整体比率最会骗人——「某字段缺失率 0.4%」可能是某个新渠道 100% 缺失。⭐ 任何比率断言都要有分组版本。

分级三档(能判定是错的才 BLOCK)。💀 全设 BLOCK 的门禁两周内就会长出一个「强制通过」按钮然后死掉——⭐ 统计这个按钮被点的次数,才是门禁健康度的唯一诚实指标。

⭐ 阈值该从历史分布算,不该是好看的整数:实测里拍脑袋的 >1% 在三个月里误报几十次,而按历史分位数算出来的线误报为零、命中全部。

💀 但纯动态阈值更阴险:它连报十几天之后彻底静默——数据还坏着,滚动窗却把异常学成了新常态。⭐ 动态阈值只报「变化」不报「坏」,所以必须动态线加静态硬底线两条一起用。

⭐ 报告里最值钱的字段是样例主键:「四百多条越界」没人能行动,「这 5 条 id 都来自同一个渠道」十分钟定位。

💀 那个事故的形状:上游连续几天把新分区写成同一天的完整拷贝——行数、主键、非空率、枚举全部正常,几十条断言全绿,而唯一能发现它的是「最新事件时间几天没动」。

下一节 👉 09-标注体系怎么设计.md

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