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

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_keysuser_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)

✅ 检查点

  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、能拦流水线的断言。 六个维度:完整性(值缺不缺 + 行缺不缺)、唯一性(主键)、有效性(类型/范围/枚举/正则/外键)、一致性(跨字段逻辑 + 明细汇总对账)、时效性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

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