🏠 总目录📚 本教程 06 · 重复与实体解析
📑 本页目录(点开跳转)

06 · 重复与实体解析

66 分钟 | ⭐⭐ 同一条数据的两个副本,分别落进了训练集和验证集


🎯 一句话

重复数据最贵的后果不是「样本多算了一遍」,是「你的验证集提前看过了答案」。 一条记录被复制成两份,一份进训练、一份进验证 —— 你的模型只要能把它背下来,验证指标就会好看。 而这个"好看",一分钱都换不回线上的效果。


🧬 一、三个层次,难度差一个数量级

   ① 精确重复      两行的每一个字节都一样
      来源:任务重跑、回填、多次导出、上游 at-least-once 投递
      检测:哈希,一行代码,O(n)                          ⭐ 简单

   ② 近重复        同一条内容的不同版本
      文本:改了标点 / 加了后缀 / 换了排版
      图片:改了尺寸 / 压缩过一次 / 加了水印
      数值:浮点尾数差一点、时间戳差 1 秒
      检测:SimHash / MinHash / 感知哈希,O(n) ~ O(n log n)  ⚠️ 要定阈值

   ③ 同一实体多条记录   "张三" 和 "张 三" 和 "ZHANG SAN"
      来源:拼写差异、别名、ID 变更、多系统合并、用户重新注册
      检测:阻塞 + 相似度 + 判定 + 传递闭包                 💀 最难
      —— 这一层没有"正确答案",只有"你定义的正确答案"

三层的关键区别不是技术难度,是「有没有客观标准」① 和 ② 有客观标准(字节相同 / 相似度超过阈值)。 ③ 没有 —— 「同一家公司的两个分支机构算不算一个实体」, 取决于你要用它做什么。做风控算一个,做配送算两个。 先定义,再解析。顺序反了,你会做出一个谁都不满意的结果。


🚰 二、重复是从哪儿来的

来源 产生哪一层 特征
任务失败重跑 / 回填 ① 精确 集中在某几个分区,量大且整齐 ⭐ 最容易发现
消息队列 at-least-once ① 精确 稀疏、随机分布,量小但持续
宽表 join 出现一对多 ① 精确 ⭐⭐ 行数突然变成 1.3 倍,最常见也最容易被忽略
爬虫多次抓取同一页 ② 近重复 时间戳/广告位不同,正文相同
用户重复提交 ② 近重复 秒级时间差,其他字段一致
多来源数据合并 ③ 实体 同一个人在两个系统里两条记录
用户注销后重新注册 ③ 实体 ⚠️ 同一个自然人,两个 user_id,行为割裂
字段截断(第 4 章 假的① 💀 不同实体被截成同一个值,看起来像重复

⚠️ 最后一行是一个反向的坑第 4 章的字段截断会制造出根本不存在的重复; 而空格与全半角不一致会掩盖真实存在的重复所以去重之前必须先做归一化 —— 顺序反了,两个方向都会错。


💀 三、最致命的后果:副本跨集

这是本章存在的理由,也是第 14 章的第 ⑤ 种泄漏。

实跑:4000 条原始记录里随机挑 40% 复制一份,特征加 σ=0.01 的抖动(近重复), 总共 5600 行,重复率 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   ⭐⭐ 完美
   验证集里【没有孪生副本】的那部分:AUC = 0.8788

   —— 前者不是"预测",是"检索"。
      模型只是在训练集里找到了那条几乎一模一样的记录,然后把它的标签抄了下来。

⭐⭐ 三个必须记住的结论

① 虚高幅度和模型的记忆力成正比。 1-NN 虚高 0.162,深树 0.122,逻辑回归只有 0.034。 所以有一个非常好用的症状:复杂模型比简单模型好得不成比例 → 先怀疑重复。

② 「有孪生的那部分 AUC = 1.0000」是最干净的证据。 任何时候你怀疑泄漏,都可以这么拆: 按「验证集样本在训练集里有没有近邻」分两组,分别算指标。 差距就是泄漏量。

③ 去重之后指标会掉,掉下去的那部分本来就不属于你。 从 0.957 掉到 0.835 不是"变差了",是第一次看到真实水平


🔍 四、检测:从便宜到贵

4.1 精确重复:哈希

import hashlib
import pandas as pd

def exact_dup_report(df: pd.DataFrame, key_cols=None, round_float=6):
    """精确重复检测。返回 (重复行数, 重复组数, 最大组的大小)。"""
    cols = key_cols or list(df.columns)
    x = df[cols].copy()
    for c in x.columns:                      # ⭐ 浮点必须先定精度,否则 1e-16 的差就不"相同"
        if pd.api.types.is_float_dtype(x[c]):
            x[c] = x[c].round(round_float)
    # ⚠️ NaN != NaN,必须【先】填占位串【再】转字符串,否则全空行永远不算重复
    s = x.fillna("<NA>").astype(str).agg("\x1f".join, axis=1)
    h = s.map(lambda v: hashlib.blake2b(v.encode(), digest_size=16).hexdigest())
    g = h.value_counts()
    dup_groups = g[g > 1]
    return int((dup_groups - 1).sum()), int(len(dup_groups)), int(g.max())

# 三个必查的对象,缺一不可:
#   1) 全字段重复      —— 纯粹的技术性重复
#   2) 业务主键重复    —— (dt, user_id, item_id) 重了 = 上游违约(第 3 章)
#   3) 去掉时间戳之后重复 ⭐ —— 「同一件事被记录了两次,只是时间差 1 秒」

第 3 个最容易被漏掉:全字段哈希会因为一个 ts 差 1 秒就认为两行不同。 真正该问的是「除了时间戳之外,其他都一样吗」。

4.2 近重复:MinHash

对 7 条商品标题实跑(其中真重复 7 对):

   精确 MD5:找到 0 对  💀  —— 连"只多了一个尾部空格"的那对都抓不到

   MinHash(字符 3-gram,128 个排列)的相似度:
     1.000  (0,3)  同一条,只差一个尾空格            真重复
     0.812  (0,1)  "iPhone 15" vs "iPhone15"        真重复
     0.594  (0,4)  256G 原色  vs  512G 蓝色         ⚠️ 不是重复!
     0.492  (0,2)  加了「()」和「全网通」          真重复
     0.445  (5,6)  "小米 14 Ultra" vs "小米14Ultra"  真重复
阈值 命中
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.4 一个不漏,但混进 3 对不同型号的手机;0.6 一对不错,但漏掉 4 对真重复。 更刺眼的是排序本身: (0,4) 是不同的商品,相似度 0.594;(0,2) 是同一个商品,相似度只有 0.492。

⭐⭐ 字面上更像的,业务上可能是不同的东西;字面上差得多的,可能是同一个东西。 任何只靠字符相似度的去重,都会同时犯这两种错。 所以阈值必须用人工标注过的样本对来定,而且要按 「漏掉一对的代价」和「错合并一对的代价」哪个更大来偏。

import hashlib
import numpy as np

def shingles(s, k=3):
    """中文用【字符 n-gram】,不要分词——分词器本身会引入不一致。"""
    s = "".join(s.split()).lower()
    return {s[i:i + k] for i in range(max(1, len(s) - k + 1))}

def minhash(text, num_perm=128, seed=1):
    rng = np.random.default_rng(seed)
    A = rng.integers(1, 2 ** 31 - 1, num_perm)
    B = rng.integers(0, 2 ** 31 - 1, num_perm)
    P = (1 << 31) - 1
    h = np.array([int(hashlib.md5(g.encode()).hexdigest()[:8], 16)
                  for g in shingles(text)])
    return ((A[None, :] * h[:, None] + B[None, :]) % P).min(axis=0)  # ⭐ 每个排列取最小

def jaccard_est(sig_a, sig_b):
    return float((sig_a == sig_b).mean())     # 签名相等的比例 ≈ Jaccard 相似度

# 图片用感知哈希(pHash/dHash):缩到 8x8 灰度 → DCT → 比中位数 → 64 bit
#   汉明距离 <= 5 基本可判近重复;改尺寸、轻度压缩、加水印都扛得住

4.3 实体解析:阻塞 → 相似 → 判定 → 合并

全量两两比较是不可能的。 20 万条记录实跑:

   全量两两            2.000e+10 对     (两百亿次比较,跑不完)
   按【城市】阻塞       5.883e+07 对     降到 1/339
   按【城市+姓氏首字母】 2.262e+06 对     降到 1/8842      ⭐

   ⚠️ 阻塞键的代价:任何一个阻塞键写错的记录,
      永远不会和它的真配对相遇 —— 阻塞是【只降召回,不降精确】的一步
   ⭐ 所以工程上用【多个阻塞键取并集】:城市 / 手机号后4位 / 姓名首字母 / 地址前6字
      任何一个键对上,就进候选池
import collections

class DSU:
    """并查集:把两两判定的结果合并成实体簇。"""
    def __init__(self, n): self.p = list(range(n))
    def find(self, x):
        while self.p[x] != x:
            self.p[x] = self.p[self.p[x]]; x = self.p[x]
        return x
    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a != b: self.p[a] = b

def blocked_pairs(records, key_funcs):
    """多个阻塞键取并集,产出候选对。records: [dict],key_funcs: [callable]"""
    cand = set()
    for kf in key_funcs:
        buckets = collections.defaultdict(list)
        for i, r in enumerate(records):
            k = kf(r)
            if k is not None:
                buckets[k].append(i)
        for idx in buckets.values():
            if len(idx) > 200:      # ⭐ 超大桶直接丢弃:它一定是个无信息的键(比如"未知城市")
                continue
            for a in range(len(idx)):
                for b in range(a + 1, len(idx)):
                    cand.add((idx[a], idx[b]))
    return cand

# 用法:
#   cand = blocked_pairs(recs, [lambda r: r["city"], lambda r: r["phone"][-4:]])
#   dsu = DSU(len(recs))
#   for i, j in cand:
#       if score(recs[i], recs[j]) >= THRESHOLD: dsu.union(i, j)

🛑 读到这里可以停 —— 前半章讲完了(约 26 分钟)。 后半章还有:传递闭包的雪球:一个相变点 · 去重的顺序:这四步不能换 · 事故复盘:3.1% 的重复率,让一个模型在灰度里"赢"了两个月 回来的时候不用重读,直接从下一节接着看就行。


🌨️ 五、传递闭包的雪球:一个相变点

并查集会把 A=BB=C 自动变成 A=C这既是它的价值,也是它的危险。

实跑:5000 个真实体 × 3 条记录 = 15000 条,候选对 300 万,人为注入不同比例的误判:

误判率 误连的对数 得到的簇数(真值 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

💀 误判率从 0.05% 涨到 0.1%(只翻了一倍),最大簇从 23 个实体炸到 1471 个实体(涨了 64 倍)。 这不是线性恶化,是随机图的巨型连通分量,有一个相变点。

⭐⭐ 落地必须做的三件事① 给簇大小设硬上限(比如「一个自然人不可能有 50 条记录」),超限的整簇打回人工。 ② 监控「最大簇大小」这条曲线 —— 它是相变最早的信号,比任何精确率指标都灵敏。 ③ 高风险场景禁用传递闭包,只保留直接判定的成对关系。 因为合并两个不该合并的人,在风控和医疗里是不可接受的错误, 而漏合并只是少了点信息。


🧯 六、去重的顺序:这四步不能换

   ① 归一化    trim / NFKC / 大小写 / 去标点 / 统一编码(第 4 章)
               ⚠️ 不做这步,"北京" 和 "北京 " 永远是两条

   ② 去重      精确 → 近重复 → 实体解析

   ③ 切分      训练 / 验证 / 测试
               ⭐⭐ ②和③的顺序绝对不能反。先切再去重 = 等于没做

   ④ 采样/加权  如果重复反映了真实的业务分布,用【权重】表达,不要用【复制】

⚠️ 第 ④ 步是这一章唯一"不要去重"的场景有些重复是真实的业务事件 —— 同一个人真的买了两次一模一样的东西、 同一条日志真的被触发了两次。这时候删掉一条就是删数据。 判断方法:问「这两行代表两件事,还是同一件事被记了两遍?」 如果是两件事但你不想让它们各算一票,用样本权重,别用删除。

切分必须按实体切,不是按行切

import numpy as np
from sklearn.model_selection import GroupKFold

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。"""
    overlap = np.isin(np.asarray(g_valid), np.unique(g_train))
    return float(overlap.mean())    # ⭐ 实跑里这个数是 41.5%,对应 +0.16 的虚高 AUC

# 交叉验证同理:GroupKFold(n_splits=5).split(X, y, groups=entity_ids)

💀 七、事故复盘:3.1% 的重复率,让一个模型在灰度里"赢"了两个月

发生了什么

   系统:某电商的商品图文违规审核模型(图片 + 标题)
   数据:运营历史处置记录,约 240 万条,人工标注过

   ⚠️ 数据来源有两条:
      · 主动巡检导出        (每周一次全量导出)
      · 用户举报工单        (实时流)
      同一个违规商品,常常两边都有 —— 而且标题被商家反复改过,
      每改一次就是一条新记录:只差一个 emoji、一个空格、一个"正品"后缀

   去重代码只有一行:df.drop_duplicates(subset=["item_id", "title", "img_md5"])
   —— 精确匹配。标题差一个字符、图片重新压缩过一次,就抓不到。

为什么没被发现

   ✅ 精确重复率显示 0.4%,看起来干干净净
   ✅ 数据集大小 240 万,行数监控正常
   ✅ 离线指标一路上涨:新模型 recall@P90 = 0.871,旧模型 0.803
   ✅ A/B 实验也"赢"了 —— 因为实验组和对照组用的是同一份污染的评测集 💀

   💀 真实情况:
      精确重复 0.4%,但【近重复】达到 3.1%(图片 pHash 汉明距离 <= 5
      且标题 MinHash >= 0.75),实体级(同一个商品的多次改标题)达到 11.6%
      随机切分之后,验证集里 9.8% 的样本在训练集有近重复孪生

代价

   模型上线全量之后:
      线上 recall(人工抽检 5000 条复核)  0.871 → 0.642
      而旧模型在同一批抽检上是            0.658
      —— 新模型实际上比旧模型【略差】,却在离线和 A/B 上赢了两个月 ⭐

   期间:
      · 漏放违规商品约 1.9 万件,其中 340 件触发平台处罚,罚款 88 万元
      · 团队按"新模型更好"的结论砍掉了两条规则兜底策略,事后全部回滚
      · 最贵的其实不是钱:两个月里所有基于这份评测集做的 17 次实验结论
        全部作废,需要重跑  💀

   发现方式:一位新同事在看 bad case 时发现,
            某条验证集样本的标题在训练集里出现过 6 次 ⭐
   修复:pHash + MinHash 重建实体 id → 按实体重切 → 重跑全部实验 = 4 周

该补什么

缺失 补上之后
只做精确去重 文本 MinHash + 图片 pHash 进管道;精确重复率 0.4% 和近重复率 3.1% 是两个指标 ⭐
按行随机切分 ⭐⭐ 一律按实体 id 切分(GroupKFold),实体 id 由去重流程产出
没有跨集泄漏探针 上线前必跑 leak_probe期望值是 0,不是"比较小"
评测集和训练集同源同流程 评测集应独立采样、独立去重第 13 章
只信离线 + A/B ⚠️ A/B 用同一份污染评测集时,两边一起虚高,差值反而看不出问题
没有"离线 vs 线上抽检"的对账 每次上线抽检 N 条人工复核,离线与线上差距 > 0.05 就必须停下来查

💀 这个事故最该记住的一句A/B 实验保护不了你,如果两个组共用同一份被污染的评测集。 实验比较的是"两个模型在同一把坏尺子上的读数"—— 尺子坏了,差值可以是对的,绝对值一定是错的。而你要上线的是绝对值。


🔗 这一章连到哪里

去哪 为什么
第 4 章 脏数据的十种形态 截断制造假重复,空格/全半角掩盖真重复;归一化必须在去重之前
第 14 章 数据泄漏的七种来源 ⭐ 本章的「副本跨集」就是第 ⑤ 种泄漏,那里有完整的七种清单
第 13 章 评测集也是数据 评测集为什么要独立采样、独立去重
模型上线之后 02 离线好不等于线上好 「离线赢、线上输」的全部原因清单,重复是其中一条
推荐算法 10 向量检索与ANN 大规模近重复检索在工程上怎么跑
密码学与信息安全 09 哈希函数 密码学哈希(抗碰撞)和局部敏感哈希(要碰撞)是完全相反的目标 ⭐

✅ 检查点

  1. 重复的三个层次是什么?为什么说三层的关键区别是「有没有客观标准」而不是技术难度?
  2. 「同一家公司的两个分支机构算不算一个实体」这个问题说明了什么?正确的顺序是什么?
  3. 字段截断和空格不一致分别对去重造成什么相反的影响?由此得出什么顺序要求?
  4. 实跑里,随机切分后验证集有多少比例的行在训练集有孪生?三种模型的虚高分别是多少?从中得到什么症状规律?
  5. 把验证集按「有没有孪生」拆开,两部分的 AUC 分别是多少?为什么说前者「不是预测,是检索」?
  6. 精确重复必查的三个对象是什么?哪一个最容易被漏掉、为什么?浮点和 NaN 各有什么坑?
  7. 那张 MinHash 阈值表说明了什么?为什么 (0,4) 的相似度反而比 (0,2) 高?由此得出什么结论?
  8. 阻塞把 20 万条记录的比较量压到多少?阻塞的代价是什么?工程上怎么补救?
  9. 传递闭包的相变是什么意思?误判率翻一倍时最大簇发生了什么?落地必须做的三件事是什么?
  10. 什么时候不应该去重?该用什么替代删除?
  11. 那个电商审核的事故:精确重复率和近重复率各是多少、离线和线上 recall 差多少、A/B 为什么没保护住、最贵的代价是什么、发现方式是什么?
👀 答案
  1. 精确重复(每个字节都一样,哈希一行搞定)②近重复(同一内容的不同版本,MinHash/SimHash/pHash,要定阈值)③同一实体多条记录(阻塞 + 相似度 + 判定 + 传递闭包)。关键区别是⭐①②有客观标准,③没有 —— 第三层的"正确答案"是你定义出来的,不是发现出来的。
  2. 说明实体的定义取决于用途做风控算一个,做配送算两个。所以顺序必须是⭐先定义,再解析 —— 反了会做出一个谁都不满意的结果。
  3. 字段截断制造出根本不存在的重复(不同实体被截成同一个值);空格与全半角不一致掩盖真实存在的重复。所以⭐归一化必须在去重之前,顺序反了两个方向都会错。
  4. 41.5%。1-NN 虚高 +0.1624(0.7834 → 0.6210),随机森林 +0.1222(0.9571 → 0.8349),逻辑回归只有 +0.0341。规律:⭐虚高幅度和模型的记忆力成正比 → 复杂模型比简单模型好得不成比例时,先怀疑重复
  5. 有孪生的部分 AUC = 1.0000,没孪生的部分 0.8788。前者是检索不是预测,因为模型只是在训练集里找到那条几乎一模一样的记录,把它的标签抄了下来。这个拆法是通用的泄漏探针:按「验证样本在训练集有没有近邻」分两组算指标,差距就是泄漏量
  6. ①全字段重复 ②业务主键重复 ③⭐去掉时间戳之后的重复。第三个最容易漏,因为全字段哈希会因为 ts 差 1 秒就认为两行不同,而真正该问的是「除了时间戳,其他都一样吗」。浮点必须先 round 到固定精度(否则 1e-16 的差就不算相同);NaN != NaN,必须先填成统一占位串,否则全空行永远不算重复。
  7. 说明⭐⭐没有一个"好阈值":0.4 一对不漏但错合并 3 对不同型号,0.6 一对不错但漏 4 对真重复。(0,4)(256G vs 512G,不同商品)相似度 0.594,高于 (0,2)(同一商品加了括号和"全网通")的 0.492,因为 MinHash 衡量的是字面重合不是语义。结论:⭐⭐字面上更像的业务上可能不同,字面上差得多的可能是同一个 —— 任何只靠字符相似度的去重都会同时犯两种错,阈值必须用人工标注的样本对来定,并按「漏一对 vs 错合一对」哪个代价大来偏。
  8. 全量 2.000e+10 对;按城市阻塞 5.883e+07(1/339);城市+姓氏首字母 2.262e+06(1/8842)。代价:⚠️任何一个阻塞键写错的记录,永远不会和它的真配对相遇 —— 阻塞只降召回,不降精确。补救:⭐多个阻塞键取并集(城市 / 手机号后 4 位 / 姓名首字母 / 地址前 6 字),任一命中即进候选池;同时丢弃超大桶(它一定是个无信息的键)。
  9. 并查集把 A=BB=C 变成 A=C,误判会顺着链条滚雪球。误判率从 5e-4 涨到 1e-3(只翻一倍),最大簇从 69 条记录(23 个真实体)炸到 4413 条(1471 个真实体),涨了 64 倍 —— 这是⭐随机图巨型连通分量的相变,不是线性恶化。三件事:①给簇大小设硬上限,超限整簇打回人工;②⭐监控「最大簇大小」曲线,它是相变最早的信号;③高风险场景禁用传递闭包,只保留直接判定的成对关系 —— 因为错合并两个人在风控和医疗里不可接受,而漏合并只是少了点信息
  10. 当⚠️重复反映的是真实的业务事件时(同一个人真的买了两次一样的东西、同一条日志真的被触发了两次)。判断方法:问「这两行代表两件事,还是同一件事被记了两遍?」如果是两件事但你不想让它们各算一票,⭐用样本权重,不要用删除
  11. 精确重复率 0.4%,近重复率 3.1%,实体级重复 11.6%;随机切分后验证集 9.8% 的样本有近重复孪生。离线 recall@P90 0.871 → 线上抽检 0.642,而旧模型在同一批抽检上是 0.658 —— 新模型实际上略差却赢了两个月。A/B 没保护住是因为💀两个组用的是同一份被污染的评测集,两边一起虚高,差值反而看不出问题 —— 尺子坏了,差值可以是对的,绝对值一定是错的,而你要上线的是绝对值。代价:漏放违规商品约 1.9 万件、340 件触发处罚、罚款 88 万;最贵的不是钱,是两个月里基于这份评测集做的 17 次实验结论全部作废。发现方式:一位新同事看 bad case 时发现某条验证集样本的标题在训练集出现过 6 次

🛑 可以停在这里

走神救援

⭐⭐重复最贵的后果不是「样本多算一遍」,是「验证集提前看过了答案」。 三个层次:①精确重复(字节全同,哈希一行)②近重复(改标点/换尺寸/压缩过,MinHash·SimHash·pHash,要定阈值)③同一实体多条记录(阻塞+相似度+判定+传递闭包);⭐关键区别不是难度,是①②有客观标准而③没有——「两个分支机构算不算一个实体」做风控算一个、做配送算两个,所以先定义再解析。⚠️字段截断制造假重复,空格与全半角掩盖真重复 → 归一化必须在去重之前。💀核心实跑(4000 条挑 40% 复制、σ=0.01 抖动、重复率 28.6%):随机切分后验证集 41.5% 的行在训练集有孪生副本1-NN 0.7834→0.6210(虚高 +0.1624)、随机森林 0.9571→0.8349(+0.1222)、逻辑回归只有 +0.0341——⭐虚高幅度和模型记忆力成正比,所以「复杂模型比简单模型好得不成比例」就该先怀疑重复。⭐⭐最干净的证据:把验证集按「有没有孪生」拆开,有孪生的那部分 AUC = 1.0000,没孪生的 0.8788——前者不是预测是检索,模型只是找到那条几乎一样的记录抄了标签。去重后指标会掉,掉的那部分本来就不属于你(0.957→0.835 不是变差,是第一次看到真实水平)。精确去重必查三个对象:全字段 / 业务主键 / ⭐去掉时间戳之后(最容易漏,因为 ts 差 1 秒哈希就不同);⚠️浮点要先 round,NaN != NaN 必须先填占位串。近重复实跑:精确 MD5 找到 0 对真重复(连只多一个尾空格的都抓不到);阈值表没有一个好阈值:0.4 不漏但错合 3 对不同型号,0.6 不错但漏 4 对;更刺眼的是 (0,4) 256G vs 512G(不同商品)相似度 0.594,高于 (0,2) 同一商品的 0.492——⭐⭐字面上更像的业务上可能不同,字面上差得多的可能是同一个;只靠字符相似度的去重会同时犯两种错,阈值必须用标注过的样本对来定,按「漏一对 vs 错合一对」谁代价大来偏。阻塞:20 万条全量 2.000e+10 对 → 按城市 5.883e+07(1/339)→ 城市+首字母 2.262e+06(1/8842);⚠️阻塞只降召回不降精确,阻塞键写错的记录永远遇不到它的真配对 → ⭐多个阻塞键取并集(城市/手机后4位/姓名首字母/地址前6字),并丢弃超大桶。💀传递闭包有相变点误判率从 5e-4 到 1e-3(翻一倍),最大簇从 69 条(23 个真实体)炸到 4413 条(1471 个真实体),涨 64 倍——这是随机图巨型连通分量,不是线性恶化。⭐⭐三件事:给簇大小设硬上限、监控「最大簇大小」曲线、高风险场景禁用传递闭包(错合并两个人在风控医疗里不可接受,漏合并只是少点信息)。四步顺序不能换:归一化 → 去重 → 切分 → 权重;⭐⭐去重必须先于切分,反了等于没做;⚠️唯一不该去重的场景:重复反映真实业务事件(真买了两次),这时用样本权重不要用删除,判断标准是「这两行是两件事,还是同一件事记了两遍」。切分一律按实体 id(GroupKFold),上线前必跑 leak_probe,期望值是 0 不是「比较小」。💀事故:电商图文违规审核模型,去重只有一行 drop_duplicates(["item_id","title","img_md5"])精确重复率 0.4% 看着干净,近重复 3.1%、实体级(商家反复改标题)11.6%,验证集 9.8% 有近重复孪生。离线 recall@P90 0.871,线上人工抽检只有 0.642,而旧模型在同一批抽检上是 0.658——新模型实际略差,却在离线和 A/B 上赢了两个月。⭐⭐A/B 保护不了你,如果两组共用同一份被污染的评测集:尺子坏了,差值可以是对的,绝对值一定是错的,而你要上线的是绝对值。 代价:漏放违规商品约 1.9 万件、340 件触发处罚、罚款 88 万;最贵的是两个月里基于这份评测集的 17 次实验结论全部作废。发现方式:新同事看 bad case 时发现某条验证集样本的标题在训练集出现过 6 次。修复:pHash+MinHash 重建实体 id → 按实体重切 → 重跑全部实验 = 4 周。

下一节 👉 07-异常值是错还是真.md

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