🏠 总目录📚 本教程 01 · 数据才是那个瓶颈 ← →
📑 本页目录(点开跳转)

01 · 数据才是那个瓶颈

⏱ 52 分钟 | ⭐ 破除「换个模型就好了」


🎯 一句话

模型选型一天就定了,数据能磨三个月 —— 这不是团队水平问题,是这件事本身的结构。 更难受的是:当指标不达标时,几乎所有人的第一反应都是「换个模型试试」, 而在真实项目里,这个反应大部分时候是错的,而且很贵。


🕐 一、一个真实项目的工时账

项目:某电商平台「低质商品识别」模型,从零到上线
团队:3 人(1 算法 + 1 数据 + 0.5 产品 + 0.5 标注管理)
周期:14 周

阶段 人日 占比
问题定义 / 指标对齐 5 7%
找数据 / 申请权限 / 对口径 ⭐ 18 26%
清洗与修脏数据 11 16%
标注体系 + 标注 + 两轮返工 ⭐ 16 23%
建评测集 + 查泄漏 6 9%
特征工程 9 13%
模型选型 + 调参 4 6%
合计 69 100%

⭐ 数据相关 51 人日(74%) vs 模型相关 4 人日(6%)

那 4 天里发生了什么,值得单独说:

结果对照

Day 1 上午:跑了 LightGBM baseline,AUC 0.738
Day 1 下午:试了 LR(0.712)和一个三层 MLP(0.741)
决定用 LightGBM(MLP 只高 0.003,但部署麻烦得多)⭐ 选型结束
Day 2–4 :调参,AUC 0.738→0.746

⭐ 「模型选型一天定了」不是夸张,是常态。 因为在表格类 / 中小规模的工业场景里, 可选的模型其实只有三四个,而且它们的差距通常在 1 个点以内。 真正拉开差距的从来不是这三四个之间怎么选。


📉 二、破除「换个模型就好了」:一组对照数字

同一个业务,同一份原始日志,两条路各走一遍:

做法 AUC 花的时间
起点:LightGBM baseline 0.738 —
换模型路线
↳ 换 CatBoost + 调参 0.746 3 天
↳ 加一个 DNN 做融合 0.751 6 天
↳ 上一个 1.2 亿参数的预训练文本编码器 0.757 11 天
修数据路线(回到 baseline 模型不动)
↳ 统一两个来源的标注口径(见 第 9 章) 0.771 4 天
↳ 删掉 3 个泄漏字段(见 第 14 章) 0.759 ⚠️ 1 天
↳ 修「默认值伪装成真值」(见 第 4 章) 0.784 2 天
↳ 补标 1200 条困难样本(见 第 12 章) 0.803 6 天

信息关系

换模型:11 天→+0.019 (0.738→0.757)
修数据:13 天→+0.065 (0.738→0.803)
⭐ 同样的投入,数据路线的收益是模型路线的 3.4 倍

⚠️ 注意「删掉泄漏字段」那一行是负的(0.771 → 0.759)。 这是整张表最重要的一行 —— 修数据有时会让离线指标下降,因为它把虚高的部分去掉了。 而线上真实效果同时在涨。 一个只按离线 AUC 决策的团队,会主动拒绝掉最有价值的那次修复。


🧱 三、为什么数据慢:它不是一个技术问题

模型调参慢,是因为要等 GPU;数据慢,是因为要等人。

信息关系

一个新字段「商家近 90 天纠纷率」从提出到能用,实际走了 23 天:
D0 你提出需求
D0 发现这个数不在你能访问的库里→找数据同学
D2 数据同学说这在客服团队的系统里→找客服 BP
D5 客服 BP 说要走数据申请流程→提单
D9 审批通过,但只给聚合表,不给明细
D11 拿到聚合表,发现「纠纷」包含了咨询→口径不对 ⭐
D14 和客服确认口径,要重新出一版
D18 新表出来了,但只有近 30 天→训练要 90 天
D21 回溯补数任务排期
D23 能用了
⭐ 23 天里,写代码的时间不到 1 天。
其余全是【找人、等审批、对口径、返工】。

这四件事没有一件能靠加班或换框架解决:

慢在哪 本质 唯一解法
找不到数据在哪 没有数据目录 / 血缘 见 第 17 章
拿不到权限 合规是硬约束 见 第 19 章,提前提
口径对不上 ⭐ 同一个词在两个部门意思不同 见 第 3 章
上游随时会变 ⭐ 改动成本不对称 见 第 3 章

⭐ 「改动成本不对称」是这套教程反复出现的一句话: 上游改一行 SQL 花 5 分钟, 下游查清楚「模型为什么变差了」花 3 天。 只要这个不对称存在,上游就永远没有动力通知你 —— 除非有一份会让他的 CI 挂掉的契约。


🔬 四、亲手验一次:标签噪声比换模型更致命

这段代码可以直接跑。它做一件事: 在同一份数据上,比较「换更强的模型」和「修 5% 标签噪声」哪个更划算。

import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

rng = np.random.RandomState(0)
X, y = make_classification(n_samples=8000, n_features=25, n_informative=10,
                           flip_y=0.0, class_sep=0.9, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=0)

def flip(labels, rate, rs):
    """随机翻转 rate 比例的【训练】标签,模拟标注噪声"""
    y2 = labels.copy()
    idx = rs.choice(len(y2), int(len(y2) * rate), replace=False)
    y2[idx] = 1 - y2[idx]                       # ⭐ 只污染训练集,测试集永远是干净的
    return y2

models = {
    "LR":  LogisticRegression(max_iter=2000),
    "RF":  RandomForestClassifier(n_estimators=300, random_state=0),
    "GBDT": GradientBoostingClassifier(random_state=0),
}
for rate in (0.0, 0.05, 0.10):
    ytr_noisy = flip(ytr, rate, np.random.RandomState(42))
    row = []
    for name, m in models.items():
        m.fit(Xtr, ytr_noisy)
        row.append(f"{name}={roc_auc_score(yte, m.predict_proba(Xte)[:, 1]):.4f}")
    print(f"标签噪声 {rate:>4.0%} | " + "  ".join(row))

一次典型输出(你的数字会略有不同,但结构一定一样):

算一算

标签噪声 0% | LR=0.9506 RF=0.9640 GBDT=0.9615

标签噪声 5% | LR=0.9438 RF=0.9560 GBDT=0.9487

标签噪声 10% | LR=0.9358 RF=0.9455 GBDT=0.9330

⭐ 横着读(换模型):同一行内最大差距约 0.013

⭐ 竖着读(修噪声):同一模型从 10% 噪声修到 0%,提升约 0.019~0.029

→ 修掉 10% 的标签噪声,比在三个模型里选最好的那个更值

⚠️ 而且 GBDT 对噪声比 RF 更敏感(0.9615→0.9330,掉 0.0285)

——「更强的模型」在脏数据上反而掉得更多,因为它更愿意去拟合噪声

⭐ 最后那句是本章的核心机制: 模型越强,越会认真学习你的错误。 所以「数据脏就上更大的模型」是个反向操作 —— 它把「学不动噪声」这个原本的保护机制给拆掉了。


🩺 五、接手任何数据的第一件事:先看 30 分钟

在写第一个模型之前,强制自己先跑一遍这个。 它平均能在半小时内发现 2–4 个足以毁掉整个项目的问题。

import numpy as np
import pandas as pd

def first_30_minutes(df: pd.DataFrame, label: str = None, time_col: str = None):
    """接手一份新数据的最小体检。返回一张问题清单。"""
    issues = []
    n = len(df)
    for c in df.columns:
        s = df[c]
        miss = s.isna().mean()
        if miss > 0.3:
            issues.append((c, "高缺失", f"{miss:.1%} —— 先别填,去问为什么缺(第5章)"))
        nun = s.nunique(dropna=True)
        if nun <= 1:
            issues.append((c, "常量列", "整列只有一个值,通常是上游没接通"))
        if nun == n and n > 100:
            issues.append((c, "疑似ID", "取值全不重复,别当特征用"))
        if s.dtype == object:
            top = s.value_counts(dropna=True).head(1)
            if len(top) and top.iloc[0] / max(s.notna().sum(), 1) > 0.9:
                issues.append((c, "单值垄断", f"'{top.index[0]}' 占 {top.iloc[0]/n:.1%}"))
        else:
            vc = s.value_counts().head(1)
            # ⭐ 某个具体数值占比异常高 → 极可能是默认值伪装成真值(第4章)
            if len(vc) and vc.iloc[0] / max(s.notna().sum(), 1) > 0.2:
                issues.append((c, "疑似哨兵值", f"值 {vc.index[0]} 占 {vc.iloc[0]/n:.1%}"))
    dup = df.duplicated().sum()
    if dup:
        issues.append(("<整行>", "精确重复", f"{dup} 行({dup/n:.1%})—— 见第6章"))
    if label is not None and label in df:
        pos = df[label].mean()
        issues.append((label, "正样本率", f"{pos:.3%} —— 低于1%要重新想指标"))
        for c in df.select_dtypes(include=[np.number]).columns:
            if c == label:
                continue
            r = df[[c, label]].corr().iloc[0, 1]
            # ⭐ 单特征与标签相关性过高,几乎总是泄漏,不是你运气好(第14章)
            if abs(r) > 0.8:
                issues.append((c, "疑似泄漏", f"与标签相关系数 {r:.3f}"))
    if time_col is not None and time_col in df:
        t = pd.to_datetime(df[time_col], errors="coerce")
        issues.append((time_col, "时间跨度", f"{t.min()} → {t.max()},坏值 {t.isna().mean():.1%}"))
        by_day = t.dt.date.value_counts().sort_index()
        if len(by_day) > 7:
            ratio = by_day.max() / max(by_day.median(), 1)
            if ratio > 3:
                issues.append((time_col, "日量突刺", f"最大日/中位日 = {ratio:.1f},疑似重跑或补数"))
    return pd.DataFrame(issues, columns=["列", "问题", "说明"])

# 用法:first_30_minutes(df, label="is_bad", time_col="event_time")

⭐ 这个函数里最值钱的是两条: 「疑似哨兵值」 —— 某个具体数值占比 20% 以上,几乎一定是默认值被当成了真值; 「疑似泄漏」 —— 单个特征和标签相关系数 0.8 以上, 不要高兴,那不是好特征,那是你把答案抄进了输入。


🛑 读到这里可以停 —— 已经读了约 28 分钟。 最后一段还有(约 19 分钟):事故复盘:换了四次模型,问题在标注 · 五条可以直接用的判断 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。


💀 六、事故复盘:换了四次模型,问题在标注

发生了什么

对照

系统:某内容平台「低质内容识别」模型

离线:F1 = 0.86(验证集 4.2 万条,人工标注)

上线:人工抽检真实召回率 0.41 ⚠️ 差了一倍多

团队的三周:

周 做了什么 结果
第 1 周 怀疑模型容量不够 → 把文本编码器从 BERT-base 换成 large F1 0.86 → 0.87,线上仍 0.42
第 2 周 怀疑特征不够 → 加了图像分支做多模态 F1 0.88,线上 0.43
第 3 周 怀疑数据量不够 → 训练集从 4 万加到 11 万 F1 0.89,线上 0.40(更差了)💀

第 3 周末,一个新来的实习生问了一句: 「我们的标注是按哪一版审核规则打的?」

根因

对照

训练标注:运营团队在后台按【2023 版低质内容判定规则】打的

线上判定:审核策略在 5 个月前升级到【2024 版】

—— 新增 3 类、废止 2 类、其中「标题党」的判定阈值大幅放宽

⭐ 所以模型学的是【一套已经废弃的标准】,

而线上是用【新标准】评估它。

模型越准,越准地复现了一个过期的规则。

为什么离线指标是好的:验证集也是同一批运营用同一版旧规则标的 —— 训练集和验证集一起错,错得完全一致,所以离线看不出任何异常。

代价

对照

3 人 × 3 周 = 45 人日 白费

GPU(large + 多模态训练) ≈ 3.1 万元

期间线上漏放的低质内容 ≈ 1.9 万条(按事后回捞)

最终真正的修复 = 重标 6000 条 + 重训一次 = 9 人日

⭐ 45 人日的错误方向 vs 9 人日的正确方向

⭐ 而且那 45 人日里,每一次尝试离线指标都在【涨】——

涨的指标提供了「我们在进步」的错觉,让错误方向持续了整整三周

该补什么

缺失 补上之后
标注规则没有版本号 数据集元数据里必须记录 rule_version,见 第 9 章、第 17 章
没人对比过「标注口径」和「线上口径」 每次训练前跑一次 diff:拿 500 条线上样本让当前审核规则重判,和训练标签比一致率 ⭐
离线涨了就以为在进步 任何离线提升,必须有一次小流量线上验证,见 模型上线之后 02
没有「先怀疑数据」的排查顺序 线上离线差距 > 0.1 时,规定前 3 天不许碰模型 ⭐

💀 这个事故里最贵的不是钱,是那条「离线在涨」的曲线。 它每周都在给团队正反馈, 而它衡量的是「对一个错误标准的拟合程度」。 这就是为什么本套教程反复说:指标可信,比指标高,重要得多。


🧠 七、五条可以直接用的判断

流程图

① 线上和离线差 0.1 以上→先查数据,别碰模型 ⭐
② 某个特征"好得不像话"→先当泄漏处理,再去证明它无辜 ⭐
③ 换模型的收益在 1 个点量级;修一个系统性数据错误
的收益在 3~8 个点量级 —— 先做后者
④ 修数据可能让离线指标【下降】,那往往是修对了 ⚠️
⑤ 数据脏的时候,用更强的模型会更糟,不会更好 ⭐

💡 第 ③ 条有个便宜的自查方式: 问自己「如果我现在把训练集随机删掉一半,指标会掉多少?」 如果掉得很少(<0.005),说明你不缺数据量,你缺的是数据质量 —— 这时候再标 10 万条也没用,该去查口径和噪声。


🔗 这一章连到哪里

去哪 为什么
Kaggle 01 数据策略与特征工程 竞赛里数据是给定的,所以那边的重点是「榨」;本章说的是「造」的那一段,是它的上游
ML 基础 16 特征工程基础 特征工程从「有一张干净表」开始;本章讲那张表怎么来的、要花多久
模型上线之后 01 训练完才是开始 姊妹篇的同位章。那边:训练只占 20%(往后看);这边:模型选型只占 6%(往前看)
模型上线之后 02 离线好不等于线上好 本章事故的直接对应:离线涨了三周,线上一动不动

✅ 检查点

  1. 那个 14 周项目里,数据相关和模型相关各占多少人日和比例?模型选型具体是怎么在一天内定下来的?
  2. 「换模型路线」和「修数据路线」各花多少天、各提升多少 AUC?倍数是多少?
  3. 那张表里「删掉 3 个泄漏字段」那一行为什么是负的?它说明了什么决策陷阱?
  4. 新字段「商家近 90 天纠纷率」走了多少天?其中写代码的时间大概多少?慢在哪四件事上?
  5. 「改动成本不对称」具体指什么?为什么它导致上游永远不会主动通知你?
  6. 标签噪声实验里,横着读和竖着读分别看到什么?为什么 GBDT 掉得比 RF 更多?由此得到哪条反直觉结论?
  7. first_30_minutes 里最值钱的两个检查是什么?各自的阈值是多少、指向哪一章?
  8. 低质内容那个事故的根因是什么?为什么离线指标看不出来?代价是多少人日和多少钱?最贵的东西其实是什么?
  9. 五条判断里,怎么快速自查「我到底缺数据量还是缺数据质量」?
👀 答案
  1. 数据相关 51 人日(74%)、模型相关 4 人日(6%),共 69 人日。选型过程:Day 1 上午 LightGBM baseline AUC 0.738,下午试 LR(0.712)和 MLP(0.741),因为 MLP 只高 0.003 但部署麻烦得多,当天定用 LightGBM;Day 2–4 只是调参(0.738→0.746)。
  2. 换模型 11 天 +0.019(0.738→0.757),修数据 13 天 +0.065(0.738→0.803),数据路线收益是模型路线的 3.4 倍。
  3. 因为删掉泄漏字段是把虚高的部分去掉了(0.771→0.759),而线上真实效果同时在涨。陷阱是:一个只按离线 AUC 决策的团队会主动拒绝掉最有价值的那次修复。
  4. 23 天,其中写代码不到 1 天。慢在:找不到数据在哪、拿不到权限、口径对不上、上游随时会变 —— 全是找人/等审批/对口径/返工。
  5. 上游改一行 SQL 花 5 分钟,下游查清「模型为什么变差」花 3 天。因为代价不落在上游身上,他就永远没有动力通知你 —— 除非有一份会让他 CI 挂掉的契约(第 3 章)。
  6. 横着读(换模型)同一行内最大差距约 0.013;竖着读(修噪声)同一模型从 10% 噪声修到 0% 提升约 0.019~0.029 —— 修噪声比选最好的模型更值。GBDT 从 0.9615 掉到 0.9330(-0.0285)比 RF 掉得多,因为更强的模型更愿意去拟合噪声。结论:模型越强,越会认真学习你的错误;「数据脏就上更大的模型」是反向操作。
  7. 「疑似哨兵值」:某个具体数值占比 > 20%,几乎一定是默认值被当成真值(第 4 章);「疑似泄漏」:单特征与标签相关系数 |r| > 0.8,那不是好特征,是你把答案抄进了输入(第 14 章)。
  8. 根因:训练标注是运营按 2023 版规则打的,而线上审核策略 5 个月前已升级到 2024 版(新增 3 类、废止 2 类、标题党阈值放宽),模型精确复现了一个过期标准。离线看不出来是因为验证集和训练集是同一批人用同一版旧规则标的,一起错、错得一致。代价:45 人日白费 + 约 3.1 万元 GPU + 漏放约 1.9 万条低质内容,而真正的修复只要 9 人日(重标 6000 条 + 重训一次)。最贵的是那条一直在涨的离线曲线 —— 它衡量的是「对错误标准的拟合程度」,却每周提供「我们在进步」的正反馈。
  9. 问「把训练集随机删掉一半,指标会掉多少」。掉得很少(<0.005)说明不缺数据量、缺数据质量 —— 这时再标 10 万条也没用,该去查口径和噪声。

🛑 可以停在这里

⚡ 走神救援

⭐ 核心结论:模型选型一天就定了,数据能磨三个月。 那个项目的工时账里数据相关占七成半,模型选型只占 6%,而且选型在第一天上午就基本定了。

⭐ 对照实验才是关键:换模型路线十几天只涨 0.019,修数据路线同样时长涨 0.065——收益是三倍多。⚠️ 而且「删掉泄漏字段」那一行离线是负的:修数据有时会让离线指标下降,因为它把虚高的去掉了,而线上真实效果同时在涨——⭐ 只按离线 AUC 决策的团队会主动拒绝最有价值的那次修复。

⭐ 数据慢不是技术问题,是要等人:一个新字段从提出到可用走了三周多,写代码不到一天。

⭐ 标签噪声实验的读法:横着读(换模型)同行差距很小,竖着读(修噪声)提升大得多;而且 GBDT 掉得比 RF 更狠——模型越强越会认真学习你的错误,所以「数据脏就上更大的模型」是反向操作。

💀 事故的形状:离线一路涨、线上一路掉。根因是训练标注按旧版规则打,而线上审核早已换版;⭐ 离线看不出来,是因为验证集和训练集一起错、错得完全一致——那条一直在涨的曲线衡量的是对一个错误标准的拟合程度。

五条判断里最好用的两条:特征「好得不像话」先当泄漏、换模型的收益量级和修系统性数据错误的收益量级差一个数量级。💡 自查缺什么:把训练集随机删一半,指标掉得很少就说明你不缺量、缺质量。

下一节 👉 02-一份数据集是怎么来的.md

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