📑 本页目录(点开跳转)
附录A · 速查
⏱ 随时查,不用通读 | ⭐ 出问题时先看第一节
🎯 一句话
数据出问题不会报错 —— 它让你的任务成功地算出一个错答案,然后你满怀信心地把它上线了。
🚨 数据出问题了,从这里开始
① 变好还是变差 ⭐⭐ 变好也要查(分母被换掉会让一切变好看)
② 数据还是模型 离线线上差 > 0.1 时,规定【前 3 天不许碰模型】
③ 换种切法呢 随机切 vs 按时间切,差 > 0.03 就是泄漏 ⭐
④ 拆开看呢 按渠道/城市/新老/覆盖情况分组 —— 整体指标会稀释局部灾难
⑤ 这是今天的吗 38 条断言全绿也可能是昨天的拷贝
⑥ 谁不在数据里 被旧系统筛掉的那批人
| 症状 | 几乎总是 | 去哪 |
|---|---|---|
| AUC >0.95,而这个问题本来很难 ⭐⭐ | 代理变量或时间穿越 | 14 |
| 去掉一个特征 AUC 从 0.97 掉到 0.70 💀 | 代理变量,可以确诊 | 14 |
| 随机 CV 很稳,按时间切掉一大截 | 时间穿越 | 14 |
| CV 折间标准差小得离谱(0.004) 💀 | ⭐ 泄漏的指纹,不是稳定性 | 14 |
| 复杂模型比简单模型好得不成比例 | 重复样本跨集(只有它们记得住) | 06 |
| 对老用户还行、对新用户明显差 | 分组泄漏(模型在"认人") | 14 |
| 线上比离线差,但线上服务查下来是对的 ⭐ | point-in-time 违规 —— 离线错了 | 18 |
| 整体正常,某个渠道彻底坏掉 ⭐⭐ | 枚举漂移/数据源只覆盖一部分人群 | 03、05 |
| 缺失率监控全绿,数据却明显不对 💀 | 填充发生在监控之前 | 05 |
| 断言全过,行数波动 0.00% 💀 | 数据停更/复制了昨天的分区 | 08 |
| 按天看一切正常 | ⭐ 聚合粒度把证据抹掉了(时区) | 04 |
| 缓慢下降被判成"漂移" | ⚠️ 灰度会把故障伪装成漂移 | 04 |
| CTR/正样本率突然变好看 ⭐⭐ | 分母被换掉了(上报口径变更) | 02 |
| 离线 F1 很好,线上召回只有一半 | 标注口径和线上口径不是同一版 | 01、09 |
| 同意率 90%,一致性却很差 | 类别不平衡,Kappa 可能只有 0.20 | 10 |
| 标签里有一批明显标错的 | 用置信学习一类方法定位可疑标签 | 11 |
| 下一批该标什么说不清 | 主动学习(⚠️ 冷启动、采样偏差两个陷阱) | 12 |
| 换个评测集,选型结论就翻转 | 评测集的抽样/样本量/污染有问题 | 13 |
| 推荐排序越来越像同一种人 | 选择偏差 + 反馈回路,会自我加强 | 15 |
| 三个月前的模型重现不出来 | 上游被覆盖,或被静默 backfill | 17 |
| AUC 只差 0.002,个体分数却翻转 ⭐ | 聚合指标掩盖个体决策的不稳定 | 17 |
| 有人说「我们已经匿名了」 | 问:最小等价类是多少人 | 19 |
⭐ 判断脏数据危不危险只看一件事:它会不会让程序停下来。 会停的代价是几十分钟;不会停的代价是几周 × 每天的业务量。
🧹 脏数据十种形态速查
会报错的只有 ①②,其余八种全部静默通过 —— 90% 的排查精力花在会叫的,90% 的损失来自不叫的。
| # | 症状 | 怎么发现 | 怎么修 |
|---|---|---|---|
| ① 类型不一致 | join 后行数莫名变少 | 「可转数值比例」在 0% 和 100% 之间最可疑 | 入口统一 dtype;⚠️ 别用 CAST(它把 "abc" 静默变 NULL) |
| ② 字符编码 | 中文乱码;列名多一个不可见字符 | 查 �/BOM/零宽空格占比 |
全链路 UTF-8,读文件用 utf-8-sig |
| ③ 时区 💀 | 数据量在某个整点"搬家" | ⭐⭐ 按小时画直方图 + 循环互相关(按天完全不可见) | 存 UTC epoch 毫秒;契约写 epoch_unit/tz |
| ④ 单位 | 均值突然 ×100,分布形状不变 | 监控分位数不是均值;断言取值上限 | ⭐ 字段名带单位 amount_cents/dwell_ms |
| ⑤ 字段截断 💀 | ⭐⭐ 一批不同实体挤成同一个值 | 长度直方图在 64/255 这类边界上出现尖峰 | ⚠️ 不可逆,只能回上游重取;长 ID 一律当字符串 |
| ⑥ 默认值伪装成真值 💀 | 年龄 0 占 8%、首单 1970-01-01 占 3% |
⭐⭐ 算每列「最高频单值占比」(连续变量不该有值占到 1%) | 入口显式转 NULL + 保留缺失指示列 |
| ⑦ 枚举漂移 | 映射表 default 分支占比缓慢上涨 |
契约列枚举全集;⭐ 监控「未知值占比」这条曲线本身 | 禁止 dict.get(x, default) 静默兜底 |
| ⑧ 空格与全半角 | groupby 出现两个"一模一样"的分组 |
归一化后类别数掉 >1% 就是带病工作 | ⭐ unicodedata.normalize("NFKC", s).strip() 一行解决八成 |
| ⑨ 嵌套 JSON 变结构 | 解析出的列突然全 NULL,而 JSON 完全合法 |
采样收集「路径 + 叶子类型」逐天对比 | 上游拍平成物理列;解析失败要计数 |
| ⑩ 同名不同义 💀 | ⚠️ 匹配率 60% 这种不高不低最致命 | join 前算交集率,低于 95% 必须问人 | 靠契约的语义定义;加系统前缀 crm_user_id |
⭐⭐ ⑤ 和 ⑩ 被低估最多,因为它们不产生任何异常值 —— 取值域校验、缺失率监控、异常检测对它们全盲。 30 分钟体检:扫 P0/P1 → ⭐ 每个字符串列打印长度直方图 + Top-20 取值(十种里有六种靠人扫一眼发现 —— 规则只能查你想到的,Top-20 能让你看见你没想到的)→ 数值列 p1/p50/p99 → 时间列按小时互相关 → 每个 join 先算交集率。
⬜ 缺失三机制
df.fillna(df.mean()) 是这套教程里最贵的一行代码。
| 缺失取决于 | 能从数据里验证吗 | 正确处理后能无偏吗 | |
|---|---|---|---|
| MCAR | 什么都不取决于 | ✅ 可以证伪 | ✅ |
| MAR | 已观测的其他字段 | ⚠️ 不能证明,只能证伪 MCAR | ✅ |
| MNAR | 缺失的值本身/未观测的东西 | ❌ 原则上不可能 💀 | ❌ 任何填充都有偏 |
⭐⭐ MAR 里的 R 骗了所有人:不是「随机」,是「在你已经看到的东西的条件下随机」。 💀 MAR 和 MNAR 无法用数据区分 —— 判断机制永远是业务判断,数据只能帮你排除 MCAR。
实跑(真值 b1 = 1.500、b2 = −0.800,缺失约 33%):
| 处理方式 | MAR 下 b1 |
偏差 | MNAR 下 b1 |
偏差 |
|---|---|---|---|---|
| 上帝视角 | +1.4994 | −0.0% | — | — |
| 均值填充 💀 | +1.2417 | −17.2% | +1.0560 | −29.6% |
| 均值填充 + 缺失指示 | +1.3318 | −11.2% | +1.2077 | −19.5% |
| 整行删除/回归填充 | +1.4970 | −0.2% | +1.3601 | −9.3% |
| 随机回归填充(只用 x2) ⚠️ | +0.9981 | −33.5% | — | — |
| 随机回归填充(x2 + y) ⭐ | +1.4900 | −0.7% | — | — |
① 偏差会传染给你根本没动过的特征 ⭐⭐
均值填充只碰了 x1,却把 x2 的系数从 −0.80 打到 −0.27(错 66%)
—— 填充改变的是【相关结构】,不只是那一列
② 看起来最"讲究"的方法偏得最厉害
随机回归填充恢复了方差(1.004 vs 原始 1.003),b1 却偏 −33.5%
原因只有一个:填充模型里没有 y。加上 y 立刻回到 −0.7%
⚠️ 但预测建模不能用这招 —— 推理时你没有 y
③ 同一个方法 MAR 下无偏、MNAR 下有偏(−0.2% vs −9.3%)
而你无法从数据里分辨自己处在哪一种 💀
均值填充五条罪状:压缩方差(std 1.003→0.781)/稀释相关(corr 0.599→0.376)/造假尖峰/抹掉「缺失」这条信息/训练推理不一致高发地。⭐ 它唯一的优点是不会报错。
缺失本身是信息(信贷违约实跑,缺失组违约率 23.98% vs 非缺失 13.71%,1.75 倍):均值填充 0.6540 / +缺失指示 0.6825(+0.0285,成本一列 0/1) / 填 -999 + 逻辑回归 0.6292(比什么都不做还差)。
⚠️ 若字段是「标签发生后才被填上」的,_is_missing 就是标签副本 = 泄漏。
| 方法 | 什么时候用 | 什么时候会坏 |
|---|---|---|
| 原生支持缺失的模型 ⭐⭐ | LightGBM/XGBoost,预测任务首选 | 需要可解释系数时 |
| 缺失指示 + 任意填充 | 几乎永远该加 | 缺失「事后才知道」时会泄漏 |
| 整行删除 | MCAR/MAR 且缺失率 <5% | MNAR 下有偏;缺失率高时样本大量流失 |
| 多重填充(MICE) | 要统计推断、要置信区间 | 假设 MAR;预测场景不适用 |
| 均值/中位数填充 | 缺失率 <1% 且确认无所谓 | 其余所有情况 💀 |
| 常数哨兵值(−999) | 只对树模型 | 线性模型/NN 上 0.654 → 0.629 |
五条铁律:统计量只从训练折算并进 pipeline / 训练推理同一份代码同一份统计量 / ⭐⭐ 缺失率必须在填充之前采集 / 缺失率漂移是最早的故障信号 / 缺失率 >1% 自动生成 _is_missing。
⭐ MNAR 只能做三件事:变成 MAR(去采集解释缺失的那个变量)/ delta 敏感性分析(实跑 delta 从 −1.0 到 +0.5,b1 从 0.381 到 1.575)/ 显式建模。⭐ 它的价值不是找到「对的 delta」,而是能说出「只要低不超过 0.25 个标准差,结论就不变」。
👯 重复与实体解析
① 精确重复 每个字节都一样 哈希,一行代码 ⭐ 有客观标准
② 近重复 同一内容的不同版本 MinHash/SimHash/pHash ⚠️ 要定阈值
③ 同一实体多条 阻塞→相似→判定→合并 💀 没有客观标准
「两个分支机构算不算一个实体」——【做风控算一个,做配送算两个】
⭐ 先定义,再解析。反了会做出一个谁都不满意的结果
⚠️ 字段截断制造假重复,空格/全半角掩盖真重复 → 归一化必须在去重之前。
核心实跑(重复率 28.6%):随机切分后验证集 41.5% 的行在训练集里有孪生副本。
| 模型 | 带重复 + 随机切分 | 按实体切分 | 虚高 |
|---|---|---|---|
| 1-NN | 0.7834 | 0.6210 | +0.1624 💀 |
| 随机森林(叶子=1) | 0.9571 | 0.8349 | +0.1222 💀 |
| 逻辑回归 | 0.7601 | 0.7260 | +0.0341 |
⭐⭐ 最干净的证据:按「有没有孪生」把验证集拆开 —— 有孪生的部分 AUC = 1.0000,没孪生的 0.8788。前者不是预测,是检索。
⭐ 虚高幅度和模型记忆力成正比;去重后指标会掉,掉的那部分本来就不属于你。
精确去重必查三个对象:全字段/业务主键/⭐ 去掉时间戳之后(ts 差 1 秒哈希就不同)。⚠️ 浮点先 round;NaN != NaN 要先填占位串。
近重复没有好阈值(7 条商品标题,精确 MD5 找到 0 对):
| 阈值 | 命中 | 对 | 错 | 漏 |
|---|---|---|---|---|
| 0.4 | 10 | 7 | 3 | 0 |
| 0.5 | 6 | 3 | 3 | 4 |
| 0.6 – 0.8 | 3 | 3 | 0 | 4 |
| 0.9 | 1 | 1 | 0 | 6 |
💀 更刺眼的是排序:不同型号的两款手机相似度 0.594,同一个商品只有 0.492 —— ⭐⭐ 字面上更像的业务上可能不同。阈值必须用人工标注的样本对来定,按「漏一对 vs 错合一对」谁代价大来偏。
阻塞(20 万条):全量 2.000e+10 对 → 按城市 5.883e+07(1/339)→ 城市+姓氏首字母 2.262e+06(1/8842)。⚠️ 只降召回不降精确 → ⭐ 多个阻塞键取并集,丢弃超大桶。
⚠️ 传递闭包的相变
| 误判率 | 误连对数 | 簇数(真值 5000) | 最大簇 | = 几个真实体 |
|---|---|---|---|---|
| 0 | 0 | 5000 | 3 | 1 |
| 1e-4 | 300 | 4700 | 15 | 5 |
| 5e-4 | 1500 | 3500 | 69 | 23 |
| 1e-3 | 3000 | 2015 | 4413 | 1471 💀 |
| 2e-3 | 6000 | 526 | 13104 | 4368 |
| 5e-3 | 15000 | 11 | 14970 | 4990 |
💀 误判率只翻一倍,最大簇从 23 个实体炸到 1471 个(64 倍) —— 随机图巨型连通分量的相变,不是线性恶化。 ⭐⭐ 三件事:簇大小设硬上限(超限整簇打回人工)/监控「最大簇大小」曲线(相变最早的信号)/高风险场景禁用传递闭包。 四步顺序:归一化 → 去重 → 切分 → 权重。⭐⭐ 去重必须先于切分,反了等于没做。 ⚠️ 唯一不该去重的场景:重复反映真实业务事件 —— 用权重,不要用删除。
🔬 质量门禁清单
| 维度 | 典型断言 | 能自动查吗 |
|---|---|---|
| 完整性 | 关键字段非空率 ≥99.5%;分区行数在区间内(值缺不缺 + 行缺不缺) | ✅ |
| 唯一性 | 主键无重复 | ✅ |
| 有效性 | 类型/范围/枚举白名单/正则/外键 | ✅ |
| 一致性 | pay_time ≥ create_time;明细 SUM = 汇总表 |
✅ |
| 时效性 | now − max(event_time) < 3h |
✅(最常被漏掉) |
| 准确性 | 抽样人工复核/和权威源对账 | ❌ 不能 |
⭐ 准确性是唯一一个数据不能自己证明自己的维度 —— 它是抽样问题,不是全量扫描问题。
⚠️ 整体比率最会骗人:「city 缺失率 0.4%」可能是某个新渠道 100% 缺失 —— 任何比率型断言都要有分组版本。
| 级别 | 判据 | 例子 | 触发后 |
|---|---|---|---|
| 🔴 BLOCK | 数据自己 100% 能证明这是错的 | 主键重复;类型变了;行数只有平时 3%;数据年龄 >24h | 停发布,回退到上一个已知良好版本 |
| 🟡 WARN | 只是「和昨天不一样」 | 缺失率 0.6%→2%;新枚举值;某渠道量翻倍 | 照常发布,报告高亮 |
| ⚪ INFO | 只想留个趋势 | 分位数、类别占比、行数 | 只落库,画趋势图 |
💀 三个反模式:全设 BLOCK(第一次大促就会长出"强制通过"按钮,然后所有检查一起死)/BLOCK 了却没有回退目标/断言写在加工代码里。 ⭐ 门禁健康度的唯一诚实指标:「强制通过」被点击的次数 —— 每周超 3 次说明是断言定错了,不是人不守规矩。
阈值怎么定(90 天模拟:日缺失率 0.61%±0.29%,第 60 天真坏了跳到 3.96%):
| 阈值策略 | 报警次数 | 真事件前误报 | 真事件后命中 |
|---|---|---|---|
静态 > 1%(拍脑袋的整数) |
32 | 2 次 | 30 / 30 |
| 中位数 + 3.5×MAD(28 天滚动窗) | 15 | 1 次 | 14 / 30 💀 |
静态 > 2.80%(历史 P99 × 1.5) ⭐ |
30 | 0 次 | 30 / 30 |
⭐ 阈值该从历史分布算,不该是好看的整数(1% 落在日常波动的尾巴上而不是尾巴外)。
💀 纯动态阈值会把「持续变坏」学成新常态:MAD 线连报 14 天后彻底静默 —— 它只报「变化」不报「坏」→ 必须动态线 + 静态硬底线两条一起用。
再加三招:连续 2 批都超阈值才升级/分层阈值/⭐ 告警自带 sample_keys(不带样例主键的失败断言等于没写)。
落地一周:只管进模型的前 20 个字段 → 每字段 3 条断言全设 WARN → 跑满 7 天收集真实分布再定阈值 → 第 2 周只把确定性错误升级为 BLOCK。⭐ 断言集由事故驱动生长,不由想象力驱动。
⭐⭐ 一条便宜到离谱的断言:md5(本批内容) != md5(上一批内容) —— 专治「上游悄悄停更/重复写入」,这类故障在每个传统维度上都表现完美。
✍️ 标注
指南六个必需块
| 块 | 关键点 |
|---|---|
| ① 任务一句话 + 下游用途 | 标注员知道用途就能自己推理边界 |
| ② 每个标签的定义 | ⭐ 正面定义 + 排除项 —— 好定义都写了「不是什么」 |
| ③ 正例/反例各 ≥3 | 从真实数据里挑,不许编(编的例子永远太典型) |
| ④ 边界样例库 ⭐⭐ | 这才是指南的正文 |
| ⑤ 优先级/仲裁规则 | 没有它,每个人会自建一套 |
| ⑥ 不确定怎么办 | ⚠️ 必须有合法出口("存疑" + 写一行理由) |
⭐ 只说「是什么」的定义在模糊处提供不了信息 —— 标注员需要思考的样本几乎全是模糊样本;⚠️ 没有第 ⑥ 块,不确定就变成随机猜或者把「其他」变成垃圾桶。 边界样例四字段:原文/正确标签/⭐⭐ 为什么/对照反例 ——「为什么」让标注员能外推到没见过的样本。 ⭐ 按「类别对」组织,不按「类别」(分歧永远发生在两个类之间);边界样例字数应是定义字数的 3 倍以上。💀 指南太长的解法是换载体:定义 1 页封顶,样例做成工具里自动弹出的可检索表格。
试标注收敛
| 轮次 | Fleiss' Kappa | 相比上轮 | 新增争议 |
|---|---|---|---|
| R1 | 0.41 | — | 37 |
| R2 | 0.63 | +0.22 | 12 |
| R3 | 0.71 | +0.08 | 3 |
| R4 | 0.74 | +0.03 | 1 ✅ 收敛 |
⚠️ R3 最容易被误判为收敛 —— 争议只剩 3 条,但 Kappa 还在以每轮 0.08 上涨;还在涨就说明指南还在变清楚。 判据两条必须同时满足:提升 <0.05 且 新增争议 <3 条/轮。⚠️ 每轮必须换新样本。⭐ 分歧会上只准问:「指南里的哪一句话,让你们做出了不同的判断?」 该改指南还是怪人:分歧集中在少数几对类别 → 指南;多数人朝同一方向错 → 指南;⭐⭐ 换一批全新标注员重标、分歧模式一样 → 100% 是指南问题(最硬的判据)。七到八成的低一致性来自指南歧义。
质检抽样量
三种手段不可互替:黄金集(3~5%,⚠️必须伪装)查态度/抽样复核给错误率分布/双标重叠才能测指南歧义。用公式不要拍比例 —— n = ln(1−C) / ln(1−p):
| 想发现的缺陷率 | 90% 置信 | 95% 置信 | 99% 置信 |
|---|---|---|---|
| 10% | 22 | 29 | 44 |
| 5% | 45 | 59 | 90 |
| 2% | 114 | 149 | 228 |
| 1% | 230 | 299 | 459 |
反过来 —— 抽 N 条全对能说多狠的话:50 条 ≤5.82%/100 条 ≤2.95%/200 条 ≤1.49%/500 条 ≤0.60%。 ⭐ 「抽 5%」这种比例式抽样是错的,该定绝对条数,和批次大小几乎无关。💀 抽 200 条全对不是「错误率 0%」—— 12 万条上就是 1,788 条错标。
Kappa 四指标选型
| 指标 | 人数 | 允许缺失 | 标签类型 | 什么时候用 |
|---|---|---|---|---|
| Cohen's Kappa | 恰好 2 人 | ❌ | 名义 | 双标重叠最常见的场景 |
| Fleiss' Kappa | 多人(每条固定 k 个) | ❌ | 名义 | ⭐ 众包:不要求是同一批人 |
| Krippendorff's α | 任意 | ✅ 每条评分数可不同 | 名义/有序/区间 | ⭐⭐ 最通用(真实平台数据总不规整) |
| 加权 Kappa | 2 人 | ❌ | 有序 | 1~5 分、P0~P3、风险等级,必须用 |
⚠️ 多人场景两两算 Cohen 再平均 ≠ Fleiss,人数不齐时会系统性偏高。
⚠️ 为什么不能用同意率
某工单两人独立判「是不是投诉」,1000 条,混淆矩阵 [[883, 50], [50, 17]]:
Po = (883 + 17) / 1000 = 0.900 ← 90%!验收报告写得很好看
Pe = 0.933×0.933 + 0.067×0.067 = 0.874978 ⭐ 闭眼乱按都能有 87.5%
Kappa = (0.900 − 0.874978) / (1 − 0.874978) = 0.2001 💀
那 90% 里 87.5 个百分点是白送的(来自"两人都说不是投诉")
靠水平挣来的只有 2.5 个,最多能挣 12.5 个 —— 2.5/12.5 = 0.20
两人各标了 67 条投诉,共同认定的只有 17 条;正类专属一致率 0.2537 vs 负类 0.9464。 ⭐ 同意率问「你们多常一致」,Kappa 问「你们比按同样比例乱按好了多少」—— 类别越不平衡,同意率越没意义。
| 用途 | Kappa 下限 | 为什么 |
|---|---|---|
| 训练集 | ≥ 0.60 | 低于此,标签噪声开始吃掉模型收益(第 11 章) |
| 评测集 | ≥ 0.80 ⭐⭐ | 评测集的一致性就是你能测出的性能上限(第 13 章) |
| 高风险(医疗/风控/内容安全) | ≥0.80 且必须有仲裁 | 错一条的代价不对称 |
| 主观任务 | 0.5 可能就是天花板 | 改用加权 Kappa 或偏好对比 |
⭐⭐ 人类之间的一致性是模型性能的天花板 —— 先看评测集的一致性,再看模型分数;顺序反了,后面全是幻觉。
| Kappa 的坑 | 表现 | 该怎么办 |
|---|---|---|
| Kappa 悖论 | 同样 Po=0.850、同样 150 条分歧:均衡时 0.700,不均衡时 0.128 | ⭐ 三个一起报:Kappa + 正类专属一致率 + 分歧绝对条数 |
| Bias effect | 两人边际分布差很多时 Kappa 反而虚高 | 同时报两人各自的类别分布 |
| 测「一致」不测「对」 💀 | 两人一致地把广告标成普通评价 → Kappa = 1.00 | 一致性(双标重叠)和正确性(黄金集)是两套质检 |
有序标签必须加权(同一份 400 条 1~5 分):同意率 0.5175/无权 0.3964/线性 0.6747/二次 0.8510。⚠️ 只写「Kappa=0.85」没有信息量,必须写清权重。 定位靠分歧矩阵不靠总分(600 条 4 类,Kappa 0.6477,139 条分歧):投诉↔退款 88 条(63.3%)/咨询↔其他 51 条/其余 4 对 0 条;合并后 Kappa 变 0.8624。 ⭐ 中等的总分几乎总是少数几对类别拖的 —— 修那两对比培训所有人有用一百倍。 ⚠️ 但合并只是诊断不是解药:留着一对标注员分不开的类别,模型也一定分不开。 ⭐ 下一批标什么是主动学习的问题,⚠️ 它有冷启动和采样偏差两个陷阱 —— 第 12 章。⭐ 评测集要单独看五件事:抽样、样本量与置信区间、污染、评分器校准、结果怎么报 —— 第 13 章。
💧 泄漏七种来源
三个问题,问每一个特征、每一条流程:
① 时间:这个值在【预测那一刻】已经产生并且查得到吗?
② 身份:它的计算用到过【这一行的标签】吗?—— 包括间接(编码/填充/采样/筛选)⭐
③ 边界:训练集和测试集之间有没有【同一个东西】跨过去?
| # | 泄漏 | 有→无 | 症状 | 怎么验证 | 怎么修 |
|---|---|---|---|---|---|
| ① | 时间穿越 | 0.810 → 0.682 | 随机 CV 稳、按时间切掉一截 | 两种切法都跑,差 >0.03 就当有 | 按时间前后切;聚合窗口必须跟着样本时间走 |
| ② | 目标编码没做 OOF | 0.854 → 0.707 | 高基数类别排第一,含义说不清 | 把该列打乱重跑,分数没掉多少 = 它本来没信息 | OOF 编码 + 低频平滑,编码器进 Pipeline |
| ③ | 预处理先于切分 | 0.984 → 0.849 | 不平衡数据上假阳假阴同时极低 | 看 train_test_split 在不在所有 fit 之前 |
⭐ 切分永远第一步;测试集绝不重采样 |
| ④ | 分组泄漏 | 0.706 → 0.564 | ⭐ 对老用户还行、对新用户明显差 | len(set(uid_tr) & set(uid_te)),期望 0 |
按业务实体切(GroupKFold),不按行切 |
| ⑤ | 重复样本跨集 | 0.745 → 0.605 | 复杂模型比简单模型好得离谱 | 哈希查重 + 查近似重复 | 去重先于切分;文本 MinHash、图片 pHash |
| ⑥ | 代理变量 💀 | 0.970 → 0.686 | 去掉一两个特征就崩 | ⭐ 单特征 AUC >0.85 全部人工过一遍 | 删掉或查清写入时刻;契约登记 produced_at |
| ⑦ | 外部数据带未来 | 症状同 ① | 加了外部数据离线大涨、线上不涨 | ⭐ 按样本时间分段看提升,泄漏的提升在老样本上更大 | 只用能给 snapshot_date 的源 |
⭐ 七种一起犯:离线 5 折 CV 0.977 → 诚实评估 0.573,落差 0.404(三个种子 0.406/0.403/0.391)。单独一种值 0.13–0.15,七种叠起来是 0.40 —— 互相放大,不是相加;「离线 0.97、线上 0.6」通常不是一个大错,是同时犯了五六个小错。
💀 最毒的代理变量是「人工审核结论」这类字段 —— 几乎就是标签的另一个副本,而名字往往看不出来(status_code、flag_3)。
⭐⭐ 两条元规律:①修泄漏会让线下分数变难看,而且是必然的(OOF 修好后 CV 0.854→0.707,holdout 0.577→0.726)—— 团队按 CV 考核时,这个修复在流程上就不可能发生。这是组织问题,不是技术问题。 ②CV 折间标准差极小是泄漏的指纹 —— 泄漏在每一折里都同样存在,"非常稳定"的意思是"每一折都错得一样多";判断稳定性要看不同切分方式之间的差异。
上线前四条门禁(任何一条不过就拦住):
① 切分体检三个数字全为 0(时间重叠 / 跨集组 / 跨集重复行)
② 单特征 AUC 榜首 < 0.85,否则必须附一份字段说明
③ 随机切分 AUC 与按时间切分 AUC 的差 < 0.03 ⭐⭐ 最有价值
④ 所有有状态变换都在 Pipeline 里
⭐ ③ 最值钱:它不需要你知道泄漏在哪,两次实验就能回答
"我的评测方式本身可不可信"
🎯 偏差
你的训练数据只包含被某个旧系统放行过的样本,而你要预测的是所有人。 ⭐ 弹孔故事的重点不是"反着想",而是返航飞机的数据本身完全正确 —— 它能通过前面十章的全部质量检查。
| 场景 | 你有标签的是谁 | 看不见的那块 |
|---|---|---|
| 招聘 | 入职并干满一年的人 | 被简历筛掉的人本来能不能干好 |
| 风控 | 审批通过并放款的人 | 被拒绝的人本来会不会还钱 |
| 推荐 | 被曝光过的物品 | 从没被推过的物品会不会被点 |
⚠️ 这叫反事实缺失 —— 多攒几个月数据没用,多攒的每一条还是旧系统放行的。
| 情况 | 只用通过样本训练 | 上帝视角 | 缺口 |
|---|---|---|---|
| A:老策略只用可观测量(在全体申请人上) | 0.821 | 0.821 | ⭐ 0 |
| A:同上,但在通过人群上评估 | 0.761 | — | ⚠️ 区间截断,偏低 |
| B:旧策略用了你看不到的东西 | 0.759 | 0.778 | 0.019 |
| C:非线性 + 区间被切断(全体上) | 0.813 | 0.841 | 0.028 |
| C:同上,在被老策略拒掉的那段人群上 💀 | 0.772 | 0.829 | 0.057 |
⚠️ 区间截断:老策略把最好分的人拒掉了,剩下全是边缘案例 —— 「我们模型 AUC 只有 0.76」这句话在有选择的人群上没有意义。 💀 在你最需要它的地方模型差 0.057 —— ⭐ 你部署新模型的全部意义,正好落在它最不可靠的那一段上。
| 情况 | 无随机流量 | 5% 随机流量 | 上帝视角 | 补回缺口 |
|---|---|---|---|---|
| A | 0.821 | 0.821 | 0.821 | —(本来没缺口) |
| B | 0.759 | 0.770 | 0.778 | 58% |
| C(被拒人群上) | 0.772 | 0.819 | 0.829 | 83% ⭐⭐ |
代价:全体好客户 33.1%/通过人群 54.2%/被拒人群只有 19.0% —— 随机放行被拒的 5%,额外坏账敞口约 6.1%。⭐ 压成本:给最低额度/⭐⭐ 在紧邻阈值那一段分层抽样(1% 敞口 ≈ 5% 均匀抽样的信息量)/时间稀释。
IPW:E[ℓ] ≈ (1/N) · Σ s_i / π(x_i) · ℓ_i。💀 死穴是正性假设 —— 硬阈值拒绝的区间里 π 严格等于 0,任何加权都无中生有不出信息。⭐ 随机流量的作用就是把 π=0 变成 π=0.05 —— 它买的不是样本量,是"正性"本身。 ⭐⭐ 权重必须截断并看有效样本量 (Σw)²/Σw²:低于实际样本量 30% 就别信这个结论。
拒绝推断四种:全部当坏客户(❌ 把老策略硬编码进新模型)/软标签(⚠️ 自证预言)/IPW(✅ 卡在正性)/随机流量(✅✅ 唯一产生新信息的,也是唯一有成本的)。💀 前两种会让离线指标变好看 —— 只会复制老策略的模型,在老策略的地盘上永远看起来很准。
⚠️ 选择偏差会自我加强:招聘案例 4 个月学校分布熵 3.1 → 2.2(−29%)。⭐ 评审新增一问:「哪些人不会出现在你的训练数据里?」
🧬 合成数据判断表
⭐ 万能判据:先问「这批数据里的信息是从哪来的」。答不上来,就别让它影响任何决策。 增强来自不变性先验/仿真来自你写的规则/统计生成来自那份真实数据本身(不产生新信息)/大模型生成来自预训练语料(唯一真引入外部信息,也唯一说不清引入了什么)。 ⚠️ 用 1 万条真实数据采出 100 万条,有效信息还是那 1 万条,但置信度会涨 —— 这是过拟合的一种伪装形式。
| 先问这个 | 如果是 | 该做 |
|---|---|---|
| 它会进评测集吗? | 会 | 💀 停。除非它是单独报分的边界样例清单 |
| 信息从哪来,说得清吗? | 说不清 | ⚠️ 只能用在压测,不能进模型 |
| 是模型自己的输出回灌吗? | 是 | ⚠️ 累积不替换、随机抽样不按置信度、上多样性监控 |
| 做了质量过滤/只留高分吗? | 做了 | 💀 坍塌加速器,一代掉 44% 有效模态数 |
| 各分组合成占比 vs 真实占比 | 差 >10% | ⚠️ 生成器在锐化,先修生成器 |
| 合成标注有人核过吗? | 没有 | ⚠️ 抽 200–300 条人核,报人机一致率(<0.75 整类退回) |
| 有退役日期吗? | 没有 | ⭐ 现在就定:真实数据到多少条时降权、清零 |
| 监控多样性吗? | 不监控 | ⭐⭐ 有效簇数 + 尾部占比;只看方差是看不见坍塌的 |
五个有用场景:隐私替代/长尾补充(⭐ 封顶 ≤ 真实样本量 × 3~5)/冷启动(必须带退役日期)/压测造数(最安全:不进模型只进管道,而且要造形态不是造数值)/边界样例(ROI 最高)。 ⭐ 边界样例是唯一可以进评测集的合成数据 —— 它是一份人工维护的必答题清单,必须单独报分,绝不能被平均进总体 F1。
| 模型坍塌实跑 | 数字 |
|---|---|
一维高斯纯自消费,方差每代乘 (n−1)/n,十代后 |
n=1000 剩 0.9904/n=100 剩 0.9012/n=30 剩 0.7073 |
| 方差减半需要多少代 | n=1000 约 693/n=100 约 69/n=30 约 20 代 |
| 均值无方向随机游走(n=100) | 第 0 代 0.101 → 第 10 代 0.325 |
| 八模态长尾,十代后 | 标准差只掉 2.5%,有效模态数 −9.9%、尾部 3 模态 −11.5% |
| 💀 加一步「质量过滤」,一代之内 | 尾部 3 模态占比 9.96% → 0.02%,长尾清零 |
| 同上,十代后 | 标准差 2.625 → 0.268,有效模态数 5.87 → 1.19 |
| 刹车:每代掺 30% 真实数据/累积(真实数据永不出池) | 熵 1.730 / 1.753(十代只掉 1.0%) |
⭐⭐ 坍塌的全部就是两句话:分布宽度单调收缩,中心无方向漂移 —— 一个越来越自信、越来越偏的分布,而它自己看不出来。⚠️ 别被 n=1000 安慰到:真实系统的"每代样本量"是模型在那个子区域见过的独立样本数,长尾常常只有几十。 ⭐ 坍塌先发生在长尾,最后才反映到总体方差 —— 等方差报警时稀有类已经死很久了。 💀 真正的触发条件不是"用了合成数据",是"用合成数据把真实数据换掉了" —— 替换和追加性质完全不同。 ⭐ 质量过滤为什么是加速器:似然分数衡量「这条有多像我已经学会的东西」,按它筛选等于每代主动把分布边缘剪掉一圈 —— 你以为在提质量,实际在做定向的多样性删除。
🗂️ 版本与血缘
模型 = f( 代码 , 参数 , 环境 , 数据 )
✅ ✅ ✅ ❓
commit yaml 镜像 ← 一张每天 INSERT OVERWRITE 的表
💀 这四项是【短板效应】不是加权平均:前三项 100 分,
有一项不可回溯,总分还是 0
manifest 必记项(_MANIFEST.json,和产出物放在同一个目录):
| 必须记 | 为什么 |
|---|---|
| 上游表 + 分区/快照 ID | 最基本的一条 |
| 每个上游的指纹 ⭐⭐ | 💀 只记表名不够 —— 分区名没变但内容变了是最常见的死法 |
| 产出代码的 commit / 参数 | ⭐ 随机种子最常被漏掉 |
| 过滤条件 ⭐ | 写在 SQL 里,但没人会读三个月前的 SQL |
| 数据截止时间(不是任务运行时间)⭐⭐ | 💀 任务重跑三次运行时间不同、截止时间相同 |
| 行数/各列空值率 / 触发方式与执行人 | 指纹对不上时先看这两个 |
⭐ manifest 写在产出物旁边,别写在另一个系统里 —— 💀 血缘系统的存活率比数据文件低得多。
逻辑指纹四个坑:浮点(先 round)/列顺序(按列名排序)/时区(统一 UTC)/行顺序(用 XOR 这类可交换聚合)。⭐⭐ 一定要存 per_column —— 总指纹只说"变了",它说"哪一列变了",这是排查从两天缩到十分钟的差别。
| 方案(200 GB 表、日变化率 5%) | 一年存储 | 主要痛点 |
|---|---|---|
| 全量快照 | ≈ 73 TB 💀 | 贵到通常撑不过三个月就被清理 |
| 时间戳分区 | ≈ 1.1 TB | 💀 回填会静默改写历史分区(多数团队的默认答案) |
| 内容寻址哈希 | ≈ 3.8 TB | 需要一层元数据服务 |
| DVC 类工具 | 同上 | 大文件性能一般;团队得真的会用 git |
⭐ 选型先算日变化率:<2% 内容寻址几乎免费/2–20% 分区 + 每月全量快照/>20% 说明口径不稳定,那不是存储问题。 💀 回填是数据侧最危险的动作,因为它长得像修 bug —— ⭐ 纪律:任何 backfill 都必须写新分区或新版本号,永不原地改写。
| 分级 | 做什么 | 成本 |
|---|---|---|
| L0(1–3 人) | 分区永不覆盖 + 上游分区名打进日志 | 几乎为零 |
| L1(3–10 人)⭐⭐ | 加 _MANIFEST.json:分区名 + 指纹 + 行数 + commit + 参数 |
半天工程量,解决 80% 的问题 |
| L2(生产/受监管) | 正式发版做内容寻址快照;保留期对齐追溯期;季度复现演练 | 存储 + 一条流程 |
| L3(数十个数据集) | 上 DVC / lakeFS / Delta,接血缘平台 | 一个人长期维护 |
⭐⭐ L1 是绝大多数团队应该停下来的地方。 而复现校验不能看聚合指标:AUC 差 0.0023 是"没差别",同一个人的分数差 0.13 是"完全不同的决策" —— 抽 1000 条比对新旧打分,|Δscore| > 0.02 的比例 >1% 就算复现失败。
🏪 特征存储该不该上
⭐⭐ 它解决的是「同一个特征在四个地方被算了四遍」,不是「你的特征不够好」。它是纪律工具,不是能力工具。
每条符合记 1 分:
[ ] 线上同时跑着 ≥ 10 个模型
[ ] 有【在线实时】推理服务(不是只有离线批量打分)
[ ] 特征复用率 > 50%
[ ] 过去半年出过 ≥ 2 次训练/推理不一致的线上事故 ⭐ 权重最高
[ ] 有 ≥ 2 个团队在共享特征
[ ] 需要严格的 point-in-time 回溯(金融、风控、受监管)
[ ] 有至少 1 个人可以长期负责它的运维
[ ] 特征总数 > 300 且还在快速增长
0–2 分 ⭐⭐ 不要上,用三条轻量替代,一周搞定
3–5 分 ⚠️ 先只做 point-in-time join + 特征定义 YAML
6–8 分 ✅ 可以上,但先落实"有人维护"那一条
⭐⭐ 它是为已经发生过的疼痛买的保险。没疼过就买,你买到的只是一个新的运维负担。
⚠️ 「特征复用」最常被当理由也最常落空:<20% 别拿它当理由,>50% 才真省人力,而现实中大多数团队不到 15%。
它不解决:不会让特征变好/⭐⭐ 不会替你定义口径/不会自动发现漂移(存值不判断值)/不解决数据质量。
新增四个问题:多一个故障点(SLA 要求比模型服务更高)/在线存储成本(1000 万实体 × 200 特征 实际约 50 GB Redis)/💀 特征死不掉/调试链路变长。
⚠️ 延迟预算:模型服务 P99 约 100 ms,特征取数必须压在 10–20 ms —— 这是一次取 200 个特征的 P99,没按真实批量压测就上线是最常见的翻车点。
三条轻量替代(约一周,拿八成价值):① point-in-time join 库函数 /② 特征定义 YAML + 两个生成器 /③ 一张 online 表 + 单向同步,绝不双写。💀 Feature Store 是个大工程;point-in-time 正确性是个小函数 —— 别因为买不起前者,就连后者也不做。
⭐ _feature_age_h 判读:中位数≈批周期的一半(日批约 12 小时);出现负数 = 穿越,立刻停;大量 NaN 说明线上会走默认值,训练时也必须走默认值。
🔒 隐私脱敏
| 层 | 是什么 | 例子 |
|---|---|---|
| ① 直接标识符 | 一个字段就能指到人 | 姓名、手机号、身份证、设备 ID |
| ② 准标识符 ⭐ | 单独无害,组合起来是指纹 | 生日、性别、邮编、职业、科室、机型 |
| ③ 敏感属性 | 攻击者最终想要的结果 | 诊断、收入、负债、取向 |
💀 重识别攻击的 join 钥匙永远是第 ②层,这条链根本不经过第 ①层 —— 你在直接标识符上做的删除、哈希、加密,对它毫无影响。
| 保留的准标识符组合(296,000 条实跑) | 唯一率 | k<5 占比 | 最小等价类 |
|---|---|---|---|
| 生日(到天) + 性别 + 邮编5位 | 94.9% 💀 | 100.0% | 1 |
| 出生年 + 性别 + 邮编5位 | 6.6% | 38.0% | 1 |
| 5岁分箱 + 性别 + 邮编前3位 ⭐ | 0.0% | 0.0% | 18 |
| 生日(到天) + 性别(删掉邮编) | 0.2% | 14.7% | 1 |
⭐⭐ 只把生日从「到天」粗化成「到年」,唯一率就从 94.9% 掉到 6.6% —— 一个字段的粒度值 88 个百分点。 ⭐ 风险来自组合,不是某一列 —— 任何只按列打勾的检查表都抓不到它。
| 方法 | 还能 join 吗 | ✅ 防住 | ❌ 防不住 |
|---|---|---|---|
| 删除整列 | ❌ | 基于该列的一切攻击 | 其他列的组合攻击;代价最大 |
掩码 138****5678 |
⚠️ 部分 | 肉眼浏览、截图外泄 | 💀 剩余空间只有 10⁴ |
| 确定性哈希 | ✅ | 肉眼直接读出 | 💀 枚举 + 频率分析 |
| 加盐哈希(全局盐) | ✅ | 外部彩虹表 | ⚠️ 盐泄漏就退化;频率分析照样有效 |
| Tokenization/每行盐 | ❌ 跨表失效 | 频率分析、枚举 | 映射表成了新的高价值资产 |
| 泛化/分箱 ⭐ | ✅ 粗粒度 | 重识别(真正有效) | 敏感属性的同质推断 |
| 加噪 | ✅ | 单条精确读数 | 💀 重复查询取平均能消掉 |
💀 确定性哈希两个死法:① 空间可枚举 —— 纯 Python 单核 1.13 M hash/s,扫完手机号全空间 4.9 小时,一张消费级 GPU:手机号 0.95 秒、身份证 6.1 秒(⭐ 哈希不是加密,它无密钥且公开);② 分布原样保留 —— 哈希前后频率向量逐位完全相同,按频率排序就能映射回去,一个哈希都不用破。
⭐ 这就是 密码学 07 工作模式 那只 ECB 企鹅:确定性脱敏保护的是"值",泄漏的是"结构",而重识别要的从来就是结构。
🧂 盐就是密钥(不能和数据存一起/必须能轮换/全局盐才能跨表 join/写死 "salt" 等于没加)—— 按 密码学 19 密钥管理 那套来。
| 泛化代价(k-匿名,取最小值不是平均值) | 精确年龄 | 5 岁分箱 ⭐⭐ | 10 岁分箱 | 20 岁分箱 | 整列删掉 💀 |
|---|---|---|---|---|---|
| AUC | 0.7345 | 0.7342 | 0.7328 | 0.7275 | 0.5817 |
| 相对损失 | — | −0.0003 | −0.0017 | −0.0070 | −0.1528 |
⭐⭐ 两张表放一起就是全节的结论:5 岁分箱 + 邮编前 3 位让唯一率 94.9% 归零(最小等价类 18 人),AUC 只掉 0.0003;💀 "不确定就删列"掉 0.1528,是分箱代价的 500 倍 —— 脱敏的默认动作应该是「降分辨率」,不是「删列」。
k-匿名三个漏洞:同质攻击(满足 5-匿名的 62.0% 记录里仍有 3.8% 落在"某诊断占 ≥80%"的等价类 —— ⭐ k 保证"认不出是哪个人",不保证"猜不到他的秘密";补丁是 l-多样性/t-接近)/背景知识/💀 多次发布交叉(两份各自 5-匿名的数据交起来 k 可能是 1)。
发布门禁:min k ≥ 10 且 min l ≥ 3,并用一份真实的公开名单跑一次重识别演练。
| ε(Δf=1) | 误差中位数/95 分位 | 分组人数(ε=1) | 相对误差中位数/95 分位 |
|---|---|---|---|
| 0.1 | 6.86/30.03 | 3 | 23.0%/101.2% 💀 |
| 1.0 | 0.70/3.02 | 12 | 5.8%/25.2% |
| 2.0 | 0.35/1.47 | 50 | 1.3%/5.8% |
| 10.0 | 0.07/0.30 | 5000 | 0.0%/0.1% |
⭐ DP 对大盘几乎免费,对小分组毁灭性 —— 而小分组恰恰是罕见病患者、少数族裔、小城市用户。 💀 "我加了随机噪声"不等于 DP:差分攻击实跑(真值 41 vs 排除张三的 40,每次独立加 ε=1 噪声),重复取平均 —— 1 次判对 62.9%、100 次 99.4%、200 次 100.0%。 ⭐⭐ DP 不是"加噪声"这个动作,是"预算"这个制度:①同一个查询必须缓存并返回同一个答案 ②全部 ε 累加记账,账花完就拒绝服务。 留存:原始请求日志 7–30 天/脱敏后行为事件 12–24 个月/训练数据快照 = 模型下线 + 6 个月/标注结果长期但必须能按人删。💀 一条数据的七个藏身处:主库/数仓历史分区快照/特征存储在线+离线表/⚠️ 备份与冷归档(漏得最多)/下游导出/含请求体的日志/向量索引。
| 已训进模型怎么删(SISA 实跑) | AUC | 单次删除的重训成本 |
|---|---|---|
| 单一全量模型 | 0.7168 | 100% |
| SISA,4 分片 ⭐ | 0.7162(−0.0006) | 25% |
| SISA,8 分片 | 0.6982(−0.0185) | 12.5% |
| SISA,16 分片 | 0.6987(−0.0181) | 6.2% |
⭐ 从 4 片到 8 片成本再减半,精度却掉了 30 倍 —— 分片数不是越多越好。⭐⭐ 可遗忘性是一个架构属性,不是一个事后操作 —— 等删除请求来了才想办法,你只剩"全量重训"和"装作没这回事"两个选项。 ⭐ 有人说"我们已经匿名了",就问一句:「最小等价类是多少人?」 答不上来就是没做。
🔨 代码速查
每段自带 import,可以直接复制。
1️⃣ 极端值的正样本富集倍数 | 任何会丢弃数据的地方之前
import numpy as np
def outlier_label_profile(x, y, q=0.999):
"""x: 待检查的数值特征 y: 0/1 标签"""
x, y = np.asarray(x, float), np.asarray(y, int)
hi = x > np.nanquantile(x, q)
base = float(y.mean())
ext = float(y[hi].mean()) if hi.sum() else float("nan")
# ⭐ 富集倍数 > 3 = 极端值就是你的信号,任何"删除"都要重新论证
return {"极端值条数": int(hi.sum()), "总体正样本率": round(base, 5),
"极端值正样本率": round(ext, 5),
"富集倍数": round(ext / base, 2) if base > 0 else float("nan")}
2️⃣ 按小时找整点位移 | 怀疑时区、怀疑上报时机变了
import numpy as np
def hour_shift(hours_now, hours_ref):
"""hours_*: 事件发生的小时(0-23 整数数组)"""
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)))
cor = [float(np.corrcoef(a, np.roll(b, -s))[0, 1]) for s in range(24)]
k = int(np.argmax(cor))
# ⭐ 实跑:整体 +8h 时按天 PSI≈0、按小时 PSI=1.457,位移 8 的相关系数 1.000
return k, round(cor[k], 3), round(psi, 3)
3️⃣ 缺失机制探针 | 决定怎么填之前
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
def missing_probe(X_obs, is_missing, y=None):
"""用已观测特征预测「这一行会不会缺」。⭐ 加上 y 之后 AUC 暴涨 = MNAR 强信号"""
def auc(M):
lr = LogisticRegression(max_iter=2000).fit(M, is_missing)
return round(float(roc_auc_score(is_missing, lr.predict_proba(M)[:, 1])), 3)
a1 = auc(np.asarray(X_obs))
a2 = auc(np.column_stack([X_obs, y])) if y is not None else None
# 实跑:MCAR 0.505/0.506 | MAR 0.829/0.829 | MNAR 0.642/0.837
return a1, a2 # ⚠️ 它只能证伪,不能证明
4️⃣ 单特征 AUC 排序 | 抓代理变量最快的一段
import numpy as np
from sklearn.metrics import roc_auc_score
def single_feature_auc(X, y, names, warn=0.85):
"""⭐ 任何单特征 AUC > 0.85 都要人工过一遍它的【写入时刻】"""
out = []
for j, nm in enumerate(names):
v = np.asarray(X[:, j], dtype=float)
ok = ~np.isnan(v)
if ok.sum() < 50 or len(np.unique(y[ok])) < 2:
continue
a = float(roc_auc_score(y[ok], v[ok]))
a = max(a, 1 - a) # ⭐ 方向无所谓,看的是"分得开吗"
out.append((nm, round(a, 3), "🚨 疑似泄漏" if a > warn else ""))
return sorted(out, key=lambda r: -r[1])
5️⃣ 切分体检 | 三个边界一次查完,都必须是 0
import hashlib
import numpy as np
def split_audit(X_tr, X_te, t_tr=None, t_te=None, g_tr=None, g_te=None):
"""时间重叠 / 组重叠 / 重复样本跨集。⭐ 任何一个 > 0,就先别看指标了"""
rep = {}
if t_tr is not None:
rep["时间重叠"] = float(np.max(t_tr) - np.min(t_te))
if g_tr is not None:
rep["跨集组数"] = len(set(np.asarray(g_tr).tolist())
& set(np.asarray(g_te).tolist()))
h = lambda A: {hashlib.md5(np.ascontiguousarray(r).tobytes()).hexdigest()
for r in np.asarray(A, dtype=float)}
rep["跨集重复行"] = len(h(X_tr) & h(X_te))
return rep
6️⃣ out-of-fold 目标编码 | 高基数类别特征
import numpy as np
from sklearn.model_selection import KFold
def oof_target_encode(cat_tr, y_tr, cat_te, k=20, n_splits=5, seed=0):
"""⭐ 训练集用折外值,测试集用全训练集映射;k 是平滑强度(10~50)"""
cat_tr, y_tr = np.asarray(cat_tr), np.asarray(y_tr, dtype=float)
prior = float(y_tr.mean())
def fit_map(c, yy):
return {v: (yy[c == v].sum() + prior * k) / ((c == v).sum() + k)
for v in np.unique(c)} # ⭐ 低频类别被拉回先验
oof = np.full(len(cat_tr), prior)
for a, b in KFold(n_splits, shuffle=True, random_state=seed).split(cat_tr):
mp = fit_map(cat_tr[a], y_tr[a]) # ⭐⭐ 只用【折外】数据 fit
oof[b] = [mp.get(v, prior) for v in cat_tr[b]]
mp_all = fit_map(cat_tr, y_tr)
# ⚠️ 修好之后 CV 分数一定会掉。掉下去的那部分本来就不是你的
return oof, np.array([mp_all.get(v, prior) for v in np.asarray(cat_te)])
7️⃣ point-in-time join | 生成任何带时间的训练集
import pandas as pd
def point_in_time_join(samples, features, entity="uid",
sample_ts="event_time", feat_ts="available_time"):
"""⭐⭐ features 的时间列必须是【可用时间】,不是【业务时间】"""
out = pd.merge_asof(
samples.sort_values(sample_ts), features.sort_values(feat_ts),
left_on=sample_ts, right_on=feat_ts, by=entity,
direction="backward", # ⭐ 只向过去找,绝不向未来找
allow_exact_matches=False, # ⭐⭐ 同一时刻不算"已可用",宁可保守
)
out["_feature_age_h"] = (out[sample_ts] - out[feat_ts]).dt.total_seconds() / 3600
# ⭐ 有负数 = 穿越,立刻停;中位数应约等于批任务周期的一半
return out
8️⃣ 按实体切分 + 跨集泄漏探针 | 切分之前
import numpy as np
def entity_split(entity_ids, test_size=0.3, seed=0):
"""⭐⭐ 同一实体的所有记录只能落在同一侧;去重必须先于切分"""
ids = np.asarray(entity_ids)
uniq = np.unique(ids)
rng = np.random.default_rng(seed)
rng.shuffle(uniq)
test_ids = set(uniq[:int(len(uniq) * test_size)].tolist())
is_test = np.array([i in test_ids for i in ids])
return ~is_test, is_test
def leak_probe(g_train, g_valid):
"""验证集里有多少行在训练集有同实体副本。⭐ 期望是 0,不是「比较小」"""
return float(np.isin(np.asarray(g_valid), np.unique(g_train)).mean())
9️⃣ Kappa + 正类专属一致率 | 看到「同意率 90%」的时候
import numpy as np
from sklearn.metrics import cohen_kappa_score
def kappa_report(a, b, c, d):
"""a=两人都标负 b=A负B正 c=A正B负 d=两人都标正"""
n = a + b + c + d
po = (a + d) / n
p1, p2 = (a + b) / n, (a + c) / n
pe = p1 * p2 + (1 - p1) * (1 - p2) # ⭐ 偶然一致率
y1 = np.array([0] * a + [0] * b + [1] * c + [1] * d)
y2 = np.array([0] * a + [1] * b + [0] * c + [1] * d)
return {"同意率Po": round(po, 4), "偶然一致Pe": round(pe, 6),
"Kappa": round(float(cohen_kappa_score(y1, y2)), 4),
# ⭐ 不平衡数据必须同时看这一行,它才是真实水平
"正类专属一致率": round(2 * d / (2 * d + b + c), 4)}
print(kappa_report(883, 50, 50, 17)) # Po=0.900,而 Kappa 只有 0.2001
🔟 逻辑指纹 | 每次产出训练集都算一次,写进 manifest
import hashlib
import json
import numpy as np
import pandas as pd
def logical_fingerprint(df, float_dp=6):
"""回答「这两份数据是不是同一份」,而不是「这两个文件是不是同一个文件」"""
parts = []
for c in sorted(df.columns): # ⭐ 列顺序无关
s = df[c].round(float_dp) if pd.api.types.is_float_dtype(df[c]) else df[c]
h = pd.util.hash_pandas_object(s, index=False).to_numpy(dtype=np.uint64)
parts.append((c, int(np.bitwise_xor.reduce(h)))) # ⭐ XOR 聚合 → 行顺序无关
payload = {"n_rows": int(len(df)), "cols": parts}
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
# ⭐⭐ per_column 才是救命的那一半:总指纹只说"变了",它说"哪一列变了"
return {"fingerprint": digest[:16], "n_rows": len(df), "per_column": dict(parts)}
📏 关键数字与阈值
| 主题 | 数字 |
|---|---|
| 时间账 | 14 周项目:数据相关 51 人日 = 74%,模型选型 4 人日 = 6%(第 1 天就定了) |
| 换模型 vs 修数据 | 11 天 +0.019 vs 13 天 +0.065 —— ⭐ 收益 3.4 倍 |
| 标签噪声实验 | 换模型差 0.013;修 10% 噪声 0.019~0.029;GBDT 比 RF 掉得多(−0.0285) |
| 缺量还是缺质 | ⭐ 训练集随机删一半,指标掉 <0.005 = 你不缺量,缺质量 |
| 疑似哨兵值 | 某个具体数值占比 >20%(连续变量则任何值 >1%) |
| 疑似泄漏(单特征) | 与标签相关 |r| >0.8;单特征 AUC >0.85 |
| 时区错位 | 同一份数据:按天 PSI ≈0,按小时 PSI 1.457(位移 8 相关系数 1.000) |
| 3σ 掩蔽效应 | 上界 3,248 → 混入 50 条错误后 21,530(涨 6.6 倍),只抓到 39/50 |
| 重复跨集 | 验证集 41.5% 有孪生;1-NN 虚高 +0.1624;有孪生部分 AUC 1.0000 |
| 传递闭包相变 | 误判率 5e-4 → 1e-3,最大簇从 23 个实体 → 1471 个(64 倍) |
| 阻塞压缩比 | 20 万条:城市 1/339,城市+姓氏首字母 1/8842 |
| 质量阈值 | ⭐ 历史 P99 × 1.5(误报 0 次、命中 30/30);拍脑袋 1% 误报 2 次 |
| 缺失指示 | 一列 0/1 = +0.0285 AUC;-999 在线性模型上 0.654 → 0.629 |
| 缺失率行动线 | >1% 自动生成 _is_missing;填充统计量只从训练折算 |
| Kappa 下限 | 训练集 ≥0.60/评测集 ≥0.80/高风险 ≥0.80 且有仲裁 |
| 同意率陷阱 | Po 0.900 而 Kappa 0.2001(Pe = 0.874978) |
| 有序标签加权 | 无权 0.3964/线性 0.6747/二次 0.8510(同一份数据) |
| 质检抽样量 | n = ln(1−C)/ln(1−p):2% 缺陷 149 条;抽 200 条全对 = ≤1.49% |
| 试标注收敛 | Kappa 提升 <0.05 且新增争议 <3 条/轮,两条同时满足 |
| 泄漏落差 | 单种 0.13–0.15;七种一起 0.977 → 0.573(0.404) |
| 泄漏门禁 | ⭐⭐ 随机切 vs 按时间切,差 <0.03 |
| 随机流量 | 5% 在"区间被切断"时补回 83% 缺口;额外坏账敞口约 6.1% |
| IPW 有效样本量 | <实际样本量的 30% 就别信这个结论 |
| 合成数据封顶 | 稀有类合成量 ≤ 真实样本量 × 3~5 |
| 模型坍塌 | 纯自消费十代掉 9.9% 有效模态数;加质量过滤一代掉 44% |
| 复现校验 | ⭐⭐ 抽 1000 条,|Δscore| >0.02 的比例 >1% 就算复现失败 |
| 日变化率 | <2% 内容寻址/2–20% 分区+月快照/>20% 是口径问题不是存储问题 |
| 特征取数延迟 | P99 10–20 ms(一次取 200 个特征,不是单 key) |
| k-匿名门禁 | min k ≥ 10 且 min l ≥ 3 才允许对外发布 |
| 泛化代价 | 5 岁分箱 AUC 掉 0.0003;整列删掉掉 0.1528(500 倍) |
| 哈希枚举 | 消费级 GPU:手机号 0.95 秒、身份证 6.1 秒 |
| DP 小分组 | ε=1 时 n=3 的相对误差 23.0%,n=5000 是 0.0% |
| SISA 分片 | 4 片:成本 25%、AUC 掉 0.0006;8 片掉 0.0185(30 倍) |
💀 事故一行版
| 章 | 事故 → 教训 |
|---|---|
| 01 | 换了四次模型,根因是标注按 2023 版规则而线上已是 2024 版 → 45 人日白费,正确修复只要 9 人日;最贵的是那条一直在涨的离线曲线 |
| 02 | 曝光改成「停留 ≥1s」,字段类型全没变 → 正样本率 4.1%→6.2%,下游阈值全失效,19 天。⭐ 「指标变好了」从来不是「不用查」的理由 |
| 03 | 新增枚举被 .get(x, 3) 静默映射成 unknown → 该渠道 AUC 0.548、坏账 216 万,而整体只降 0.007。⭐ 整体指标会稀释局部灾难 |
| 04 | event_ts 从 UTC 改成本地时间(+8h),灰度 3 周把断崖变成缓坡 → 次日留存 31.2%→35.3%。⭐ 灰度会把故障伪装成漂移 |
| 05 | 一行 fillna(mean) 把 47% 缺失填成 2180.00 → 非一线 AUC 0.583、坏账 4100 万。⭐ 填充在监控之前,监控的就是填充逻辑 |
| 06 | 只做精确去重(0.4%),近重复 3.1% → 离线 0.871/线上 0.642,17 次实验结论全作废。⭐ 共用污染评测集时 A/B 保护不了你 |
| 07 | 3σ 从「截断」改成「删除整行」,只删 0.31% → 累计多损失 380 万。⭐ 模型按笔数优化,损失按金额结算 |
| 08 | 六天的分区都是同一份拷贝,38 条断言 38 绿(行数波动 0.00%)→ 多核销 430 万。⭐ 盲区不是偏差太大,是偏差为零 |
| 09 | 32 类互斥、0 条边界样例、没做试标注 → 「投诉↔退款」一致率 51%,41 万 + 延期 5 周。⭐ 根因是把正交属性塞进了互斥类别表 |
| 10 | 评测集单人标注("口径最统一")→ Fleiss 只有 0.38,用仲裁金标重测结论完全翻转,62 万 |
| 14 | 「5 折 CV 折间标准差只有 0.004」被当成可靠证据 → 坏账 2900 万。⭐ 一致性恰恰是泄漏的指纹 |
| 15 | 93.2% 没进面试的投递全标成负样本 → 模型学的是「HR 会不会点开简历」,真实 AUC 0.71 而非 0.88 |
| 16 | 同一套 prompt 造训练集和评测集 → 离线 0.94、真实 0.61,兜底率 8%→27%。⭐ 出的题和做的题来自同一支笔 |
| 17 | backfill 原地改写 + 保留期 90 天 < 追溯期 24 个月 → 省下的 5000 元最后花掉 400 多万 |
| 18 | 特征取的是"截至跑数当天" → 离线 0.83/线上 0.68,210 万。⭐ 最反直觉的是线上是对的、离线错了 |
| 19 | 删了姓名手机号身份证就叫"已匿名" → 87.3% 的人被指名道姓对上。⭐ 纪要把「已删除直接标识符」写成了「已匿名」 |
📖 术语对照
| 中文 | 英文 | 一句话 |
|---|---|---|
| 数据契约 | data contract | 把「我们说好了不改」变成「你一改,你的 CI 就红」 |
| 哨兵值 | sentinel value | 0/-999/1970-01-01,不是 NULL,所以缺失率监控看不到 |
| 条件随机缺失 | MAR | 只取决于你已观测到的其他字段 |
| 非随机缺失 | MNAR | 取决于这个值本身,任何填充都有偏 |
| 阻塞 | blocking | 实体解析里先分桶再两两比较;只降召回,不降精确 |
| 传递闭包 | transitive closure | A=B、B=C ⇒ A=C;⚠️ 有相变点,会滚雪球 |
| 一致性(标注) | inter-annotator agreement | 「比按同样比例乱按好了多少」,不是「标得一样的比例」 |
| 普遍性效应 | prevalence effect | 同样的同意率,类别越不平衡 Kappa 越低 |
| 置信学习 | confident learning | 用模型的预测概率反过来定位可疑标签(第 11 章) |
| 主动学习 | active learning | 让模型挑下一批标什么;⚠️ 有冷启动和采样偏差两个陷阱 |
| 评测集污染 | contamination | 评测样本以某种形式已经被模型见过(第 13 章) |
| 数据泄漏 | data leakage | 训练时用了「预测那一刻拿不到」的信息 |
| 代理变量 | target leakage | 一个只有事后才存在的字段,几乎是标签的副本 |
| 区间截断 | range restriction | 最好分的那批人被筛掉了,剩下的当然更难分 |
| 反事实缺失 | counterfactual missing | 「如果当初批了他会怎样」,数据库里永远没有答案 |
| 拒绝推断 | reject inference | 被拒绝的样本永远没有标签,怎么补 |
| 逆倾向加权 | IPW | 按「被选中概率」的倒数加权;💀 死穴是正性假设 |
| 模型坍塌 | model collapse | 自消费下分布宽度单调收缩、中心随机漂移 |
| 回填 | backfill | 💀 长得最像修 bug 的破坏性操作 |
| 时点正确性 | point-in-time correctness | 取该样本事件时间之前最后一次可见的特征值 |
| 可用时间 | available_time | 这个值线上真正能被读到的时刻,不是它描述的时刻 |
| 准标识符 | quasi-identifier | 单独无害,组合起来是指纹;重识别的 join 钥匙 |
| k-匿名 | k-anonymity | 每种准标识符组合至少对应 k 个人;取最小值不是平均值 |
| 差分隐私 | differential privacy | 一个人在与不在,输出分布最多差 e^ε 倍;是制度不是动作 |
🔗 模型上线之后 · 附录A 速查(姊妹篇:训完之后的那一半)/ Kaggle · 23 数据泄露与外部数据(竞赛视角的同一件事)/ ML 基础 · 05 评估与过拟合(切分与交叉验证的基本功)/ 大模型全景导论 · 11 评测体系("评测集继承同样的歧义"在大模型里的重灾区)
👉 回到首页 / 想亲手跑一遍:第 20 章 实战与挑战项目