20 · 实战与挑战项目
⏱ 项目 1–2 天 / 挑战 4–6 天 | ⭐ 这一套必须动手才有用
🎯 一句话
前十九章讲的每一条,都要等你亲手把一份干净数据弄脏、再亲手查出来,才会变成直觉。 所以这四个项目的产出不是模型,而是 —— 一本你自己写的「我注入了什么」的真值台账,和一份「我查出了几种」的成绩单。
🧰 开工前:一个能反复弄脏的沙盘
和《模型上线之后》那套沙盘的区别在于:那边注入的是"上线之后发生的事",这边注入的是"数据本身的毛病"。 共同点是同一条纪律 —— 你必须知道真值,否则你没法给自己判卷。
import numpy as np
rng = np.random.default_rng(0)
def make_clean(n=50_000):
"""一份干净数据。⭐ 关键不是数据本身,是你【知道每一列该长什么样】"""
return {
"user_id": np.arange(1, n + 1),
"age": rng.integers(18, 80, n),
"amount": np.round(rng.lognormal(6, .8, n), 2),
"city": rng.choice(["北京", "上海", "广州", "深圳", "杭州"], n),
"ts": np.datetime64("2026-01-01") +
rng.integers(0, 180 * 86400, n).astype("timedelta64[s]"),
}
def contaminate(d, kinds, rate=0.02, seed=1):
"""污染器:注入若干种脏,同时返回【真值台账】—— 这本账是整个项目的判卷依据"""
r = np.random.default_rng(seed)
d = {k: v.copy() for k, v in d.items()}
n, truth = len(d["user_id"]), {}
pick = lambda: r.choice(n, int(n * rate), replace=False)
if "单位不一致" in kinds: # ⭐ 一部分金额从"元"变成"分"
i = pick(); d["amount"] = d["amount"].astype(float); d["amount"][i] *= 100
truth["单位不一致"] = set(i.tolist())
if "不可能值" in kinds:
i = pick(); d["age"] = d["age"].astype(float); d["age"][i] = 200
truth["不可能值"] = set(i.tolist())
if "空值伪装" in kinds: # ⭐ 不是 None,是字符串"未知"
i = pick(); d["city"] = d["city"].astype(object); d["city"][i] = "未知"
truth["空值伪装"] = set(i.tolist())
return d, truth
💀 这里有一个必须先说的规矩:污染器和检测器不能共用任何常量。 如果检测器直接
import了污染器里那份"未知/NULL/-999"的哨兵字典, 你测的不是检测能力,是复制粘贴能力。 ⭐ 正确做法:两边分开写,检测器那份哨兵字典你要自己从头列一遍 —— 你会立刻发现自己漏了好几个。
🥉 项目一:亲手弄脏一份干净数据,再自己查出来(1–2 天)
目标:把第 4 章那十种形态一个个注入,再写一个检测器,看能查出几种。
- [ ] T1 (60min) 用
make_clean造干净数据,先写好真值台账的格式 - [ ] T2 (3h) 补全污染器:十种形态每种一个函数,每个都往台账里记行号
- [ ] T3 (3h) 另开一个文件写检测器(⚠️ 不许 import 污染器的任何常量)
- [ ] T4 (60min) 算每一种的 召回率 / 精确率,做成一张表
- [ ] T5 (60min) ⭐ 对没查出来的那几种,写清楚为什么查不到、要什么信息才能查到
检测器的骨架(自己往下加):
import numpy as np
SENTINEL = {"", "NULL", "null", "N/A", "-", "未知", "无", "-999", "0000-00-00"}
def detect(d):
hits = {}
a = np.asarray(d["age"], float)
hits["不可能值"] = set(np.where((a < 0) | (a > 120))[0].tolist()) # 业务范围断言
c = np.asarray(d["city"], object)
hits["空值伪装"] = set(np.where(np.isin(c, list(SENTINEL)))[0].tolist())
m = np.asarray(d["amount"], float)
q1, q3 = np.percentile(m, [25, 75])
hits["单位不一致"] = set(np.where(m > q3 + 50 * (q3 - q1))[0].tolist()) # ⭐ 量级跳变
return hits
def score(truth, hits):
for k, t in truth.items(): # ⭐⭐ 召回和精确必须一起报
h = hits.get(k, set()); tp = len(t & h)
print(k, f"召回={tp/len(t):.2f} 精确={tp/max(len(h), 1):.2f}")
📏 参考数字(自己跑出来差太多就回去查)
| 形态 | 靠什么查 | 参考召回 |
|---|---|---|
| 类型漂移(数字存成字符串) | dtype + 可转换率 | 0.99 |
| 编码错乱 / 双重编码 | 字符集白名单 | 0.95 |
| 类别值漂移(冒出新枚举) | 枚举白名单 | 0.95 |
| 截断(字符串卡在 255) | 长度分布的尖峰 | 0.90 |
| 不可能值(年龄 200) | 业务范围断言 | 1.00 |
空值伪装("未知" / -999) |
哨兵字典 + 高频值 | 1.00(字典全的话) |
| 时间格式混用 / 时区 | 解析成功率 + 小时分布 | 0.80 |
| 单位不一致(元 / 分) | 量级跳变、双峰 | 0.71 ⚠️ |
| 近似重复(同一人两条) | MinHash / 分块比对 | 0.60 |
| 跨列关联不一致(省市对不上) | 跨列规则表 | 0.30 💀 |
✅ 通关判据(带数字,可以自己判卷)
| 判据 | 参考值 |
|---|---|
| 十种里查出几种 | ≥ 7 种召回 > 0.5 |
| 误报率 ⭐⭐ | 在没被污染的对照集上跑一遍,被误标的行 < 1% |
| 单位不一致的召回 | 0.6–0.8(到不了 1.0 是正常的,见下) |
| 跨列关联的召回 | < 0.4,并能说清为什么 |
| 台账 | 每一种形态都记了行号,能逐条对账 |
⭐ 这个项目最有价值的是 T5,不是 T3。 "单位不一致"永远查不干净,因为一笔真实的 8,800 元订单和一笔被乘了 100 的 88 元订单, 在数值上完全无法区分 —— 你需要的不是更好的统计量,是契约里的单位字段(第 3 章)。 ⭐⭐ "哪些脏靠看数据查不出来"这份清单,比"我查出了 8 种"这个成绩有用得多。
⚠️ 最容易翻车的地方
💀 「我的检测器召回全是 1.00」
→ 你八成在检测器里 import 了污染器的常量。分开两个文件重写。
💀 「误报率没测」
→ 只在被污染的数据上评估,等于只算召回不算精确。
⭐ 必须留一份【完全没污染】的对照集,跑一遍看误报。
💀 「用 describe() 当检测器」
→ 均值/分位数抓得到数值类的脏,抓不到编码错乱、类别漂移、跨列不一致。
⭐ 十种形态需要至少四类不同的检查手段。
🥈 项目二:建一套质量门禁接进 CI(1–2 天)
目标:把第 8 章那六个维度写成断言,跑在一份真实数据集上,并且故意让它红一次。
六个维度一个都不能少:完整性 / 唯一性 / 有效性 / 一致性 / 时效性 / 分布稳定性。
import sys, numpy as np
CHECKS = [] # ⭐ 断言注册表:每条都带维度和等级
def check(dim, level="hard"):
def deco(fn): CHECKS.append((dim, level, fn.__name__, fn)); return fn
return deco
@check("完整性")
def 关键列非空(d):
bad = int(np.sum([x is None or x == "" for x in d["city"]]))
return bad / len(d["city"]) <= 0.001, f"空值率 {bad/len(d['city']):.4f}"
@check("唯一性")
def 主键唯一(d):
return len(set(d["user_id"].tolist())) == len(d["user_id"]), "主键重复"
@check("分布稳定性", level="warn") # ⭐ 分布类默认只 warn,别一上来就 hard
def 金额PSI(d, base_q=None):
q = np.percentile(d["amount"], [10, 50, 90])
return True, f"分位数 {q.round(1).tolist()}"
def run_gate(d):
hard_fail = 0
for dim, level, name, fn in CHECKS:
ok, msg = fn(d)
if not ok:
print(f"[{'FAIL' if level=='hard' else 'WARN'}] {dim}/{name}: {msg}")
hard_fail += (level == "hard") # ⭐⭐ 只有 hard 才决定退出码
sys.exit(1 if hard_fail else 0) # ⭐ CI 靠这个退出码变红
✅ 通关判据(带数字)
| 判据 | 参考值 |
|---|---|
| 断言总数 | ≥ 18 条,六个维度每维至少 2 条 |
| hard 占比 ⭐⭐ | ≤ 1/3。其余是 warn —— 全 hard 的门禁两周内一定会被加 --skip |
| 运行时间 | 100 万行 < 60 秒(超了就要抽样,但抽样要在报告里写明) |
| 故意注入一次故障,CI 必须红 | 且报告要指出哪一维、哪一列、多少行、样例值 |
| 误报 ⭐ | 拿过去 14 天的真实数据回放,hard fail ≤ 1 次 |
| 阈值来源 | 每个阈值都要能说出是从哪段历史数据的哪个分位数来的 |
⭐ 「hard 占比 ≤ 1/3」这条是整个项目的重点。 一个把所有断言都设成 hard 的门禁,看起来最严格 —— 然后第一次误报之后,就会有人在 CI 里加一个跳过参数,从此再没人删掉它。 ⭐⭐ 门禁的目标不是"拦住一切",是"红灯出现时所有人都相信它"。
⚠️ 最容易翻车的地方
💀 「阈值是拍脑袋定的」
→ 必须用历史数据回测:把过去 14 天逐日跑一遍,
看每条断言的取值分布,阈值放在【历史 p99 之外】而不是"感觉"上。
💀 「门禁只跑在抽样上,线上还是炸了」
→ 唯一性、跨列一致性这两类【不能抽样】——
抽样恰好会把重复和边缘不一致漏掉。 ⭐
💀 「红了以后没人知道该干嘛」
→ 每条断言必须带三样东西:负责人、样例坏行、修复动作。
只报"完整性检查失败"的门禁等于没有。
🥇 项目三:标注一致性实验(1–2 天)
目标:按第 9 章自己写一份标注指南,标 100 条两遍算 Kappa,改完指南再标一遍,看涨多少。
- [ ] T1 (60min) 选一个三分类任务(比如评论的 正面 / 中性 / 负面),写第一版指南
- [ ] T2 (90min) 标 100 条 → 间隔至少 48 小时 + 打乱顺序 → 再标一遍
- [ ] T3 (30min) 算 Cohen's Kappa,同时报 p₀ 和 pₑ
- [ ] T4 (90min) ⭐ 只看前 70 条的分歧修订指南(留 30 条不看,当验证集)
- [ ] T5 (90min) 用新指南重标,算新 Kappa,对比
import numpy as np
def kappa(M):
"""M 是混淆矩阵(两遍标注的交叉表)。⭐ p0 和 pe 必须一起报"""
M = np.asarray(M, float); n = M.sum()
p0 = np.trace(M) / n # 观察一致率
pe = (M.sum(0) * M.sum(1)).sum() / n ** 2 # ⭐ 瞎猜也能达到的一致率
return round(p0, 3), round(pe, 3), round((p0 - pe) / (1 - pe), 3)
print(kappa([[26, 7, 1], [9, 20, 8], [1, 7, 21]])) # 第一版指南 → (0.67, 0.335, 0.504)
print(kappa([[31, 3, 0], [4, 29, 4], [0, 3, 26]])) # 修订后 → (0.86, 0.336, 0.789)
print(kappa([[92, 3], [3, 2]])) # ⭐⭐ 陷阱 → (0.94, 0.905, 0.368)
📏 参考数字(都是上面那段代码实跑出来的)
| 轮次 | p₀(观察一致率) | pₑ(碰运气一致率) | κ |
|---|---|---|---|
| 第一版指南 | 0.670 | 0.335 | 0.504 |
| 修订后 ⭐ | 0.860 | 0.336 | 0.789 |
| 极不均衡的陷阱案例 💀 | 0.940 | 0.905 | 0.368 |
💀 看最后一行:一致率 94%,Kappa 只有 0.368。 因为 95% 的样本都是同一个类别,两个人闭着眼睛全标多数类也能拿到 90.5% 的一致率。 ⭐⭐ 所以"我们标注一致率 94%"这句话,在类别不均衡时没有任何信息量 —— 报 κ 的时候必须把 p₀ 和 pₑ 一起报出来,只报 κ 也不够。
✅ 通关判据(带数字)
| 判据 | 参考值 |
|---|---|
| 第一轮 κ | 0.45–0.60。> 0.85 说明任务太简单或你在背自己的答案 ⭐ |
| 修订后 κ | ≥ 0.75,且提升 ≥ 0.20 |
| 分歧样本占比 | 第一轮 25–35% → 第二轮 ≤ 15% |
| 指南修订条数 | ≥ 8 条,每条对应一个真实分歧,不许写"注意仔细" |
| 留出集 ⭐⭐ | 那 30 条没看过的,κ 提升至少要有前 70 条的一半 |
⭐ 留出集那一条是本项目唯一的科学性保证。 盯着分歧样本改指南,κ 一定会涨 —— 那可能只是你把这 100 条的答案编码进了指南。 只有留出集上也涨,才说明你真的把规则说清楚了。
⚠️ 最容易翻车的地方
💀 「两遍间隔两小时,κ = 0.93」
→ 你在背答案。⭐ 至少隔 48 小时,并且【打乱顺序 + 隐藏第一遍结果】。
💀 「只报一致率不报 Kappa」
→ 见上面那个 0.94 / 0.368 的例子。
💀 「指南越写越长,κ 反而掉了」
→ 常见且真实。⭐ 修订的正确方向是【把模糊的定义换成可判定的规则 + 边界样例】,
不是【加更多描述性形容词】。规则要能让人回答"是/否",而不是"感觉像"。
🏆 挑战项目:复现一次数据泄漏(4–6 天)
难度 ★★★★ | 练第 14 章
目标:做出一个【离线 AUC 0.99、去掉泄漏后掉到 0.72】的完整例子,
并把"怎么发现的"写成一份复盘。
🧰 起步骨架(直接抄)
import numpy as np
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score
rng, N = np.random.default_rng(11), 60_000
tenure = rng.exponential(180, N) # 在网天数
mau = rng.poisson(12, N) # 月活跃天数
tickets = rng.poisson(0.4, N) # 工单数
plan = rng.choice([0, 1, 2], N, p=[.6, .3, .1])
logit = -1.4 - .005 * tenure - .115 * mau + .45 * tickets + .5 * plan
y = (rng.random(N) < 1 / (1 + np.exp(-logit))).astype(int) # 是否流失,约 5.6%
# ⭐ 泄漏一:后果特征 —— 退款工单,只有【已经流失的人】才会开
refund = rng.poisson(np.where(y == 1, 1.4, 0.02))
# ⭐ 泄漏二:时间穿越 —— 账户状态最后更新距今天数,在流失【之后】被系统改写
gap = np.where(y == 1, rng.gamma(2, 3, N), rng.gamma(2, 30, N))
base = np.c_[tenure, mau, tickets, plan]
tr = np.arange(N) < 45_000
def auc(X, name):
m = HistGradientBoostingClassifier(max_iter=150, random_state=0).fit(X[tr], y[tr])
a = roc_auc_score(y[~tr], m.predict_proba(X[~tr])[:, 1])
print(f"{name:16s} AUC={a:.4f}"); return a
auc(np.c_[base, refund, gap], "两个泄漏都在") # 0.9899
auc(np.c_[base, gap], "只有时间穿越") # 0.9789
auc(np.c_[base, refund], "只有后果特征") # 0.9237
auc(base, "清洁版") # 0.7168 ⭐⭐ 这才是真实能力
阶段一:造出泄漏(1 天)
- [ ] T1 跑通上面的骨架,确认拿到 0.99 / 0.72 这对数字
- [ ] T2 再造两种:按 ID 随机切分导致的同人跨集、用全量数据算的统计特征
- [ ] T3 把这四种泄漏都塞进一个"看起来很正常"的特征工程脚本里
阶段二:用四种手段把它找出来(2 天)
- [ ] T4 单特征 AUC 扫描 ⭐ 每个特征单独训一个模型
- [ ] T5 时间切分 vs 随机切分对比
- [ ] T6 逐特征剔除:删掉某个特征后 AUC 掉最多的那个,重点审
- [ ] T7 时点审计 ⭐⭐ 对每个特征问一句:"在做预测的那一刻,这个值真的已经存在了吗?"
阶段三:修掉并复盘(1–2 天)
- [ ] T8 修掉全部泄漏(⚠️ 记得连它的衍生特征一起删)
- [ ] T9 重新报指标,写复盘
✅ 通关判据(带数字)
| 判据 | 参考值 |
|---|---|
| 泄漏版离线 AUC | ≥ 0.98(骨架是 0.9899) |
| 去泄漏后 ⭐⭐ | 0.70–0.75(骨架是 0.7168)—— 这 0.27 就是这个项目的全部内容 |
| 单特征 AUC 扫描 ⭐ | 泄漏特征单独就能到 0.90+;清洁特征最高的那个通常 < 0.68 |
| 时间切分 vs 随机切分 | 清洁版差 < 0.01;有"同人跨集"泄漏时差 > 0.05 |
| 剔除实验 | 删掉泄漏特征,AUC 掉 > 0.15;删掉任一清洁特征,掉 < 0.05 |
| 复盘文档 | 必须逐特征回答 T7 那句话,并且写明每个特征的生成时刻 |
⭐⭐ T4 的单特征 AUC 扫描是性价比最高的探针:一个单列就能到 0.90 的特征, 在真实业务里几乎不存在 —— 如果它出现了,先假设是泄漏,再去证明它不是。 而 T7 那句问话是唯一不依赖任何统计量的手段: 泄漏的本质是"用了预测时刻还不存在的信息",这是一个时间问题,不是一个数值问题。
⚠️ 最容易翻车的地方
💀 「我只用随机切分,指标一直很漂亮」
→ 时间穿越型泄漏在随机切分下【也是高分】。
⭐ 时间切分是发现它的最低成本手段,也是这个项目的第一道检查。
💀 「删了泄漏特征,AUC 还是 0.95」
→ 你漏了它的衍生特征(refund 的对数、gap 的分箱、
以及任何用它算出来的交叉特征)。⭐ 按【血缘】删,不是按列名删。
💀 「用特征重要性找泄漏」
→ 重要性高 ≠ 泄漏,重要性低 ≠ 干净。
⭐⭐ 重要性只能帮你【排查顺序】,判定必须回到"生成时刻"这一条。
💀 「0.72 太难看了,我想把它调上去」
→ ⭐ 这正是这个项目要治的病。0.72 是真的,0.99 是假的。
一个项目组愿不愿意接受这一点,比任何技术手段都重要。
📝 复盘模板
# 数据事故 / 实验:____
## 我注入了什么(真值台账)
## 第一个发现它的信号是什么,来自哪个检查
## 我用了哪几种手段,各花了多久
## 没能发现的部分 —— 为什么?需要什么信息才能发现? ← 最有价值的一节
## 修复后的真实指标 vs 修复前的假指标
## 要加什么,才能让下次在【进模型之前】就被拦住
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 模型上线之后 20 实战与挑战项目 | ⭐⭐ 和这一章成对:那边的沙盘注入"上线之后发生的事"(漂移、管道坏、训练推理不一致),这边注入"数据本身的毛病"。两套连起来就是完整的一条链 |
| Kaggle 23 数据泄露与外部数据 | 挑战项目的竞赛版:那边泄漏是可以"利用"的(榜单奖励它),这边是纯粹的事故 |
| 智能体 19 挑战项目B 评测驱动开发 | ⭐ 「先建评测,再写实现」的同一条纪律 —— 项目一的"先写真值台账"就是它的数据版 |
| 模型上线之后 04 训练推理一致性 | 项目二的门禁往下游延伸一步:同样的断言要在推理侧再跑一遍 |
✅ 检查点
- 这四个项目共同的那条纪律是什么?为什么"真值台账"必须先写?
- 项目一里,为什么污染器和检测器必须分开写?共用常量会导致什么?
- 项目一的参考数字里,哪两种形态的召回最低?"单位不一致"为什么永远查不干净,该靠什么解决?
- 项目一为什么必须留一份没被污染的对照集?
- 项目二里"hard 占比 ≤ 1/3"这条为什么是重点?全 hard 的门禁会怎么死?
- 项目二里哪两类检查不能抽样?为什么?
- 项目三那个"陷阱案例"的 p₀、pₑ、κ 各是多少?它说明了什么?
- 项目三为什么必须留 30 条不看?不留会发生什么?
- 挑战项目里,泄漏版和清洁版的 AUC 各是多少?单特征 AUC 扫描的判读标准是什么?
- 为什么"用特征重要性找泄漏"是错的?唯一不依赖统计量的判定手段是什么?
👀 答案
- 你必须知道真值,否则没法给自己判卷。 真实工作里你永远不知道数据里到底有几处脏、泄漏藏在哪一列;自己造问题是唯一能拿到答案的办法。台账要先写,是因为事后补记的台账一定会漏,而漏掉的那几处正好会被算成"检测器没错"。
- 因为检测器一旦 import 了污染器的常量(比如那份哨兵字典),测的就不是检测能力而是复制粘贴能力 —— 召回会假性地变成 1.00。正确做法是两边分开文件,检测器那份字典自己从头列一遍,你会立刻发现自己漏了好几个。
- 跨列关联不一致(约 0.30)和近似重复(约 0.60),其次是单位不一致(0.71)。单位不一致查不干净是因为一笔真实的 8,800 元订单和一笔被乘了 100 的 88 元订单在数值上完全无法区分 —— 需要的不是更好的统计量,是契约里的单位字段(第 3 章)。⭐ "哪些脏靠看数据查不出来"这份清单比"我查出了 8 种"有用得多。
- 因为只在被污染的数据上评估等于只算召回不算精确。必须在完全没污染的对照集上跑一遍看误报率,通关线是被误标的行 < 1%。
- 因为一个全 hard 的门禁看起来最严格,但第一次误报之后就会有人在 CI 里加跳过参数,从此再没人删掉它。⭐⭐ 门禁的目标不是"拦住一切",是"红灯出现时所有人都相信它" —— 所以还要求"过去 14 天真实数据回放,hard fail ≤ 1 次"。
- 唯一性和跨列一致性。因为抽样恰好会把重复和边缘不一致漏掉 —— 重复的两条可能一条被抽中一条没有,跨列冲突本来就是长尾。
- p₀ = 0.940,pₑ = 0.905,κ = 0.368。 说明类别极不均衡时,两个人闭着眼全标多数类也能拿到 90.5% 的一致率,所以 "我们一致率 94%"这句话没有任何信息量。⭐⭐ 报 κ 时必须把 p₀ 和 pₑ 一起报,只报 κ 也不够。
- 因为盯着分歧样本改指南,κ 一定会涨 —— 那可能只是把这 100 条的答案编码进了指南。留出 30 条不看,只有留出集上也涨(至少有前 70 条一半的提升),才说明规则真的说清楚了。这是本项目唯一的科学性保证。
- 两个泄漏都在 0.9899 / 只有时间穿越 0.9789 / 只有后果特征 0.9237 / 清洁版 0.7168 —— 那 0.27 就是整个项目的内容。单特征扫描判读:泄漏特征单独就能到 0.90+,而清洁特征里最高的通常 < 0.68;一个单列能到 0.90 的特征在真实业务里几乎不存在,先假设是泄漏,再去证明它不是。
- 因为重要性高 ≠ 泄漏,重要性低 ≠ 干净 —— 重要性只能决定排查顺序。唯一不依赖统计量的判定是 T7 那句时点审计:「在做预测的那一刻,这个值真的已经存在了吗?」 ⭐ 泄漏的本质是"用了预测时刻还不存在的信息",这是一个时间问题,不是一个数值问题。
🛑 可以停在这里
⚡ 走神救援
⭐这四个项目共同的那条纪律:你必须知道真值,否则没法给自己判卷。 真实工作里你永远不知道数据里有几处脏、泄漏藏在哪列,自己造问题是唯一能拿到答案的办法;而且台账必须先写,事后补记一定会漏,漏掉的那几处会被算成"检测器没错"。💀开工前的第一条规矩:污染器和检测器不能共用任何常量 —— 检测器一旦 import 了污染器那份哨兵字典,测的就不是检测能力而是复制粘贴能力,召回会假性地变成 1.00。🥉项目一:亲手弄脏一份干净数据再自己查出来。 注入第 4 章那十种形态,另开文件写检测器,召回和精确必须一起报。⭐参考召回:类型漂移 0.99、不可能值 1.00、空值伪装 1.00(字典全的话)、编码错乱 0.95、类别漂移 0.95、截断 0.90、时间格式 0.80、单位不一致 0.71、近似重复 0.60、跨列关联不一致 0.30 💀。通关线:十种里 ≥ 7 种召回 > 0.5,且在没被污染的对照集上误报 < 1%。 ⭐⭐这个项目最有价值的是 T5 而不是 T3:"单位不一致"永远查不干净,因为一笔真实的 8,800 元订单和一笔被乘了 100 的 88 元订单在数值上完全无法区分 —— 需要的不是更好的统计量,是契约里的单位字段。"哪些脏靠看数据查不出来"这份清单,比"我查出了 8 种"有用得多。 🥈项目二:把第 8 章六个维度写成断言接进 CI,并故意让它红一次。 六维一个都不能少:完整性/唯一性/有效性/一致性/时效性/分布稳定性;断言 ≥ 18 条、每维 ≥ 2 条、100 万行 < 60 秒。⭐⭐最重要的一条通关判据是「hard 占比 ≤ 1/3」:全 hard 的门禁看起来最严格,然后第一次误报之后就会有人在 CI 里加跳过参数,从此再没人删掉它 —— 门禁的目标不是"拦住一切",是"红灯出现时所有人都相信它";所以还要求过去 14 天真实数据回放 hard fail ≤ 1 次,每个阈值都要说得出是从哪段历史的哪个分位数来的。💀两类检查不能抽样:唯一性和跨列一致性 —— 抽样恰好会把重复和边缘不一致漏掉。红了以后每条断言必须带负责人、样例坏行、修复动作,只报"完整性检查失败"等于没有。🥇项目三:标注一致性实验。 自己写指南 → 标 100 条 → 隔至少 48 小时并打乱顺序再标一遍 → 算 Cohen's Kappa → 只看前 70 条的分歧修订指南(留 30 条当验证集)→ 重标。⭐实跑数字:第一版 p₀=0.670 / pₑ=0.335 / κ=0.504,修订后 p₀=0.860 / pₑ=0.336 / κ=0.789;💀陷阱案例 p₀=0.940 但 κ 只有 0.368 —— 因为 95% 的样本是同一类,两个人闭着眼全标多数类也能拿 90.5% 的一致率,⭐⭐所以"我们一致率 94%"这句话在类别不均衡时没有任何信息量,报 κ 必须把 p₀ 和 pₑ 一起报。通关线:第一轮 κ 0.45–0.60(>0.85 说明你在背自己的答案)、修订后 ≥ 0.75 且提升 ≥ 0.20、分歧样本从 25–35% 降到 ≤ 15%、指南修订 ≥ 8 条且每条对应一个真实分歧。⭐留出集是本项目唯一的科学性保证:盯着分歧样本改指南 κ 一定会涨,那可能只是把这 100 条的答案编码进了指南,只有那 30 条没看过的也涨(至少有前 70 条一半的提升)才算数。修订的正确方向是把模糊定义换成可判定的规则 + 边界样例,不是加更多形容词。 🏆挑战项目:复现一次数据泄漏(4–6 天)。 骨架实跑:两个泄漏都在 AUC 0.9899、只有时间穿越 0.9789、只有后果特征 0.9237、清洁版 0.7168 —— ⭐⭐那 0.27 就是整个项目的全部内容。两种泄漏分别是后果特征(退款工单,只有已流失的人才会开)和时间穿越(账户状态更新时间,在流失之后被系统改写)。四种发现手段:⭐T4 单特征 AUC 扫描(性价比最高的探针)—— 泄漏特征单列就能到 0.90+,清洁特征最高的通常 < 0.68;一个单列能到 0.90 的特征在真实业务里几乎不存在,先假设是泄漏再去证明它不是;T5 时间切分 vs 随机切分(清洁版差 < 0.01,同人跨集时差 > 0.05);T6 逐特征剔除(删泄漏特征掉 > 0.15,删清洁特征掉 < 0.05);⭐⭐T7 时点审计:对每个特征问一句「在做预测的那一刻,这个值真的已经存在了吗?」 —— 这是唯一不依赖任何统计量的手段,因为泄漏的本质是"用了预测时刻还不存在的信息",这是一个时间问题不是一个数值问题。💀四个翻车点:①只用随机切分 → 时间穿越型泄漏在随机切分下也是高分,时间切分是第一道检查 ②删了泄漏特征 AUC 还是 0.95 → 漏了它的衍生特征,要按血缘删不是按列名删 ③用特征重要性找泄漏 → 重要性高≠泄漏、低≠干净,它只能决定排查顺序 ④「0.72 太难看想调上去」→ ⭐这正是这个项目要治的病:0.72 是真的,0.99 是假的,一个项目组愿不愿意接受这一点,比任何技术手段都重要。
下一节 👉 附录A-速查.md