📑 本页目录(点开跳转)
02 · 一份数据集是怎么来的
⏱ 46 分钟 | ⭐ 五种来源,五套坑
🎯 一句话
你手里那张表不是「数据」,是某个人为了另一个目的、在某个时刻、按某一版口径写下来的东西。 把它当成「客观记录」,是数据工作里最贵的一个误解 —— 尤其是埋点:它是产品团队写的,口径随时会变,而没人会通知你。
🧭 一、五种来源,一眼看清
| 来源 | 谁产的 | 为什么产 | 你最该担心什么 |
|---|---|---|---|
| 业务日志 | 后端服务 | 对账、排障、审计 | 补写、重放、软删除、状态是最终态不是当时态 ⭐ |
| 埋点 | 产品 + 客户端 | 看报表、算 KPI | 口径随时变,且不通知你 ⭐ |
| 人工标注 | 标注团队 | 就是给你用的 | 指南哪一版、一致性多少、标注员是谁 |
| 外购/外部 | 第三方 | 卖钱 | 口径不透明、覆盖率、里面混着你的答案 ⚠️ |
| 合成 | 你自己 | 补稀缺样本 | 边缘分布像,联合分布和因果结构不像 ⚠️ |
🔑 一条贯穿全章的判断法: 拿到任何一列,先问 「这一列是为谁的 KPI 服务的?」 答案几乎从来不是「为你的模型」。 它服务谁,就会在谁需要的时候被改。
📜 二、业务日志:为对账写的,不是为你写的
业务日志通常是最"可信"的一类 —— 因为钱要对得上。但它的设计目标决定了三个坑。
因果链
怎么发现:
逐项核对
表里有没有 update_time?它和 create_time 差多少?
—— 差得越多,说明这行被改过越多次,越不能当"当时态"用 ⭐
按天统计行数,看最近 3 天是不是明显偏低(= 还在补写)
grep 一遍所有 is_deleted / is_valid / status 类的列,
确认你的 SQL 都过滤了
主键真的唯一吗?(第 6 章)
📡 三、埋点:本章的重点
埋点不是数据工程产的,是产品经理提需求、客户端开发写的。 它的生命周期完全由产品迭代驱动,而你的模型,不在任何人的考虑范围内。
3.1 埋点要穿过三层,每层都能改
操作步骤
3.2 埋点漂移的四种,按恶心程度排序
| 形态 | 例子 | schema 校验能拦住吗 |
|---|---|---|
| 字段删除 / 改名 | item_id → content_id |
✅ 能,任务直接挂 |
| 类型变化 | price 从 string 变 int |
✅ 多半能 |
| 枚举新增值 | channel 多了 mini_program |
⚠️ 不能(第 4、3 章) |
| 上报时机变化 💀 | 「曝光」从进入视口 → 停留 ≥1s | ❌ 完全不能 |
💀 「上报时机变化」是所有数据事故里最难查的一类: 字段名没变、类型没变、非空率没变、行数只是「少了一些」—— 所有自动校验全部通过, 而这一列的含义已经换了一个。
3.3 采样率:一个安静的乘数
信息关系
🧪 四、口径指纹:抓「schema 没变但语义变了」
既然 schema 校验拦不住时机变化和采样变化,就需要另一套东西: 每天给每个关键字段拍一张「指纹」,然后比昨天。
import numpy as np
import pandas as pd
def fingerprint(df: pd.DataFrame, cols, day_col="dt"):
"""给每个 (日期, 字段) 算一组分布指纹。schema 不变但语义变了,会在这里露出来。"""
out = []
for day, g in df.groupby(day_col):
for c in cols:
s = g[c]
rec = {"dt": day, "col": c, "n": len(s), "null_rate": s.isna().mean()}
if pd.api.types.is_numeric_dtype(s):
q = s.quantile([0.05, 0.5, 0.95]).values
rec.update({"p05": q[0], "p50": q[1], "p95": q[2],
"zero_rate": (s == 0).mean()})
else:
vc = s.value_counts(normalize=True)
rec.update({"n_uniq": s.nunique(),
"top1": vc.index[0] if len(vc) else None,
"top1_share": vc.iloc[0] if len(vc) else np.nan})
out.append(rec)
return pd.DataFrame(out)
def diff_fingerprint(fp: pd.DataFrame, ref_day, cur_day, tol=0.15):
"""比较两天的指纹,列出可疑变化。tol 是相对变化阈值。"""
a = fp[fp.dt == ref_day].set_index("col")
b = fp[fp.dt == cur_day].set_index("col")
alerts = []
for c in a.index.intersection(b.index):
# ⭐ 绝对量的变化最能暴露"上报时机变了"——比率类指标会互相抵消,看不出来
if abs(b.loc[c, "n"] - a.loc[c, "n"]) / max(a.loc[c, "n"], 1) > tol:
alerts.append((c, "行数突变", a.loc[c, "n"], b.loc[c, "n"]))
for k in ("null_rate", "p50", "p95", "zero_rate", "top1_share", "n_uniq"):
if k in a.columns and pd.notna(a.loc[c, k]) and pd.notna(b.loc[c, k]):
base = abs(a.loc[c, k]) + 1e-9
if abs(b.loc[c, k] - a.loc[c, k]) / base > tol:
alerts.append((c, k, a.loc[c, k], b.loc[c, k]))
# ⭐ 众数换人 = 枚举漂移或口径重定义,几乎从不是正常波动
if "top1" in a.columns and a.loc[c, "top1"] != b.loc[c, "top1"]:
alerts.append((c, "众数变了", a.loc[c, "top1"], b.loc[c, "top1"]))
return pd.DataFrame(alerts, columns=["col", "signal", "ref", "cur"])
# 用法:fp = fingerprint(df, ["impression", "price", "channel"]); diff_fingerprint(fp, "2026-03-11", "2026-03-12")
⭐
diff_fingerprint里最关键的设计是「看绝对量,不看比率」: 如果只监控 CTR,曝光口径收紧会让 CTR 变好看,你还以为模型进步了。 只有同时盯分子和分母的绝对值,才能看见分母被换掉。 (这条和 模型上线之后 11 的「分母变了」是同一件事的两端。)
✍️ 五、人工标注:唯一为你而生的数据,也是最贵的
操作步骤
- 看到一份标注数据,必须问清六件事:
- 标注指南是哪一版?现在线上用的是哪一版? ⭐(第1章那个事故)
- 每条标了几遍?一致性(Kappa)是多少? (第10章)
- 标注员是谁?外包还是业务专家?流失率多少?
- 样本是怎么抽的?随机还是按某种规则挑的? (第15章)
- 有没有"不确定"这个选项?它被怎么处理了?
- 标注时标注员能看到什么?会不会看到答案? ⚠️ 泄漏
- ⭐ 六个问题里,第①和第②决定了这份数据的【天花板】:
- 标注员之间都对不齐的东西,模型不可能学对。
💰 六、外购数据:三个必问,一个必查
| 必问 | 为什么 |
|---|---|
| 口径与生成方式 | 「行业标签」是人打的还是模型打的?如果是模型打的,你在做知识蒸馏而不是学习真实世界 ⭐ |
| 覆盖率与更新频率 | 覆盖率 60% 意味着 40% 缺失,且缺的那部分通常不是随机的(第 5 章 MNAR) |
| 许可与合规边界 | 能不能用于模型训练、能不能跨境、留存多久(第 19 章) |
一个必查:
因果链
🧬 七、合成数据:边缘像,联合不像
流程图
⚠️ SMOTE 有个几乎人人都踩的坑: 必须在训练集内部做,且在交叉验证的每一折内部做。 先 SMOTE 再切分 = 合成样本的"父样本"在训练集、"子样本"在验证集 → 指标虚高(第 6 章、第 14 章会详细说这个机制)。
(合成数据的完整讨论在 第 16 章。)
💀 八、事故复盘:曝光口径改了一行,模型偏了 19 天
发生了什么
关键信息
为什么没被发现
| 校验项 | 3/11 | 3/12 | 通过? |
|---|---|---|---|
| 字段存在 | ✅ | ✅ | 通过 |
| 类型一致 | int | int | 通过 |
| 非空率 | 100% | 100% | 通过 |
| 主键唯一 | ✅ | ✅ | 通过 |
| 日行数波动阈值 ±40% | 4.21 亿 | 2.78 亿 | 通过(-34%,没超 40%)💀 |
| CTR | 4.1% | 6.2% | 没有人告警 ——因为它变"好"了 💀💀 |
信息关系
怎么发现的
不是监控发现的。 是 3 月 31 日一个运营在群里问: 「为什么最近推荐的东西越来越水?」
因果链
代价
对照
19 天 × 2.6 亿请求 ≈ 49 亿次受影响的曝光
低质内容分发占比从 3.2% 涨到 11.7%
次日留存 -0.9pp(事后用断点回归估的,见《模型上线之后》13)
重建训练集 + 回溯 60 天曝光 + 重训 + 灰度:14 人日
⭐ 真正的修复只用了 14 人日;19 天全花在"没人知道"上
该补什么
| 缺失 | 补上之后 |
|---|---|
| 算法团队不在埋点变更通知链里 | 数据契约里必须有消费者清单,改动前必须通知(第 3 章)⭐ |
| 只监控比率,不监控绝对量 | 曝光数、点击数各自单独告警;比率类指标必须同时展示分子分母 ⭐ |
| 行数阈值 ±40% 太松 | 改成按分位数的动态阈值 + 连续 2 天同向偏移即告警 |
| 「指标变好」不触发任何检查 | CTR 单日跳变 > 20%(无论方向)一律告警(第 8 章)⭐ |
| 模型输出的绝对值没被监控 | 预估分的分位数进监控(模型上线之后 05) |
⭐ 这个事故最该记住的一句话: 「指标变好了」从来不是「不用查」的理由。 在数据这一关,向上的跳变和向下的跳变同样危险 —— 而绝大多数监控体系只对向下的那一半设了告警。
🗺️ 九、一张来源溯源表(建议每个项目都建一份)
先沿一行追踪一个字段的来源,再看「最近变更」是否有待确认项。
| 字段 | 来源层 | 产出方 | 口径定义链接 | 更新频率 | 最近变更 | 我们的用途 |
|---|---|---|---|---|---|---|
impression |
埋点 | 客户端-Feed组 | wiki/xxx | 实时 | 2026-03-12 ⚠️ | 负样本 |
order_amt |
业务 | 交易服务 | wiki/yyy | T+1 | 2025-11-03 | 特征 |
risk_score |
外购 | XX数据 | 合同附件3 | 月更 | 未知 ⚠️ | 特征 |
⭐ 「最近变更」那一列如果写着“未知”,就是一个待办事项。
⭐ 这张表本身就是第 3 章数据契约的雏形,也是第 17 章血缘的人工版。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 推荐算法 03 数据长什么样 | 那边具体展示曝光/点击日志的结构;本章讲这些日志是怎么被改掉的 |
| 模型上线之后 06 数据漂移与概念漂移 | 漂移是「世界变了」,本章的口径变更是「记录世界的方式变了」—— 现象一样,处理完全不同 ⭐ |
| 模型上线之后 11 从告警到根因 | 「分母变了」那一条就是本章事故的排查视角 |
| AI 基础设施 22 数据管线与存储 | 采样、去重、补数这些动作发生在哪一层 |
✅ 检查点
- 五种数据来源分别是什么?各自「为谁的 KPI 服务」?
- 业务日志的三个坑是什么?为什么「状态表」不能直接当特征?
- 埋点要穿过哪三层?每层分别能改什么、会不会通知你?
- 埋点漂移的四种形态里,哪一种 schema 校验完全拦不住?为什么它最难查?
- 采样率变化为什么危险?为什么「按 user_id 哈希采样」会让用户级特征看起来正常?
- 口径指纹为什么要看绝对量而不是比率?
diff_fingerprint里哪两个信号最强? - 拿到一份人工标注要问哪六件事?哪两件决定天花板?
- 外购数据的「一个必查」是什么?怎么查?
- 合成数据的「真假判别器」检验怎么做?AUC 接近 1 说明什么?SMOTE 必须在哪里做?
- 曝光口径那个事故:改了什么、连锁反应是什么、为什么六项校验全过、发现延迟多久、代价多少?最该记住的一句话是什么?
👀 答案
- 业务日志(后端服务,为对账排障)、埋点(产品+客户端,为报表 KPI)、人工标注(标注团队,唯一为你产的)、外购(第三方,为卖钱)、合成(你自己)。判断法:「这一列是为谁的 KPI 服务的」—— 它服务谁,就会在谁需要的时候被改。
- 状态是最终态不是当时态、补写与重放、软删除。状态表存的是最终结果(如
status='已退款'),你要预测的是下单那一刻——直接 join 就是用了未来,属于泄漏。正解是用变更流水表,或记录valid_from/valid_to。 - ①客户端 SDK(客户端开发改上报时机、参数、事件名,不会通知你)、②网关/采集层(数据工程改采样率、去重、丢弃规则,可能通知)、③数仓 ODS→DWD(数仓改重命名、口径重算、补数,偶尔通知)。你看到的表是三层叠加的产物,表结构没变不代表①②没变。
- 上报时机变化(如曝光从「进入视口」改成「停留 ≥1s」)。因为字段名没变、类型没变、非空率没变,行数只是少了一些——所有自动校验全过,而含义已经换了一个。
- 因为它是一个安静的乘数(聚合类特征全部 ÷10 而你不知道),而且采样往往不是随机的:按 user_id 哈希取模只保留 10% 的用户,所以用户级特征分布看起来完全正常,但全站聚合特征全错。更糟的是采样率会在大促临时调整再调回,历史数据里一段 10% 一段 100%。
- 因为只监控比率的话,分母被换掉会让比率变"好看"(曝光口径收紧 → CTR 上升 → 以为模型进步了);只有同时盯分子分母的绝对值才能看见分母被换掉。最强的两个信号:行数突变和众数变了(众数换人几乎从不是正常波动,通常是枚举漂移或口径重定义)。
- ①指南哪一版/线上哪一版 ②标了几遍、Kappa 多少 ③标注员是谁 ④样本怎么抽的 ⑤有没有「不确定」选项 ⑥标注员能不能看到答案。①和②决定天花板——标注员之间都对不齐的东西,模型不可能学对。
- 查外部数据里有没有混进你的目标。查法:把外部特征单独拿出来训一个模型,如果单它一个就能到 0.85+,几乎一定有问题。典型案例:买的「企业风险评分」本身用了「是否已逾期」做输入,AUC 从 0.76 跳到 0.91,上线后崩到 0.68。
- 把真实数据标 1、合成数据标 0 训一个分类器:AUC≈0.5 说明合成得好,AUC≈0.99 说明合成数据有明显指纹,模型会学到「这是合成的」这件事本身。SMOTE 必须在训练集内部、且在交叉验证的每一折内部做 —— 先 SMOTE 再切分会让合成样本的父样本在训练集、子样本在验证集,指标虚高。
- 产品把曝光从「进入视口」改成「停留 ≥1s」,字段名和类型都没改,算法团队不在通知链里。连锁反应:曝光(负样本)-34%,点击几乎不变 → 正样本率 4.1%→6.2%(+51%)→ 预估值整体抬高约 1.5 倍 → 下游按绝对值配的竞价出价和冷启阈值全部失效。六项校验全过是因为字段/类型/非空/主键都没变,日行数 -34% 没超 ±40% 阈值,而 CTR 从 4.1% 涨到 6.2% 没人告警,因为它变"好"了。发现延迟 19 天,靠运营一句「为什么推荐的越来越水」。代价:约 49 亿次曝光受影响、低质分发占比 3.2%→11.7%、次日留存 -0.9pp,而修复只要 14 人日。最该记住的:「指标变好了」从来不是「不用查」的理由——向上的跳变和向下的跳变同样危险。
🛑 可以停在这里
⚡ 走神救援
⭐ 你手里那张表不是「数据」,是某个人为了另一个目的、在某个时刻、按某一版口径写下来的东西。
⭐ 判断法:问「这一列是为谁的 KPI 服务的」——它服务谁,就会在谁需要的时候被改,而那个人从来不是你。
业务日志三坑里最贵的一条:⚠️ 状态表存的是最终态不是当时态——直接拿「已退款」当特征就是用了未来。
⭐ 埋点是本章重点:它穿过客户端 SDK、采集层、数仓三层,而你看到的表是三层叠加的产物——⚠️ 表结构没变,不代表前两层没变。 漂移四形态里,改名和类型 schema 能拦,💀 而「上报时机变化」完全拦不住:字段名没变、类型没变、非空率没变,所有自动校验全过,含义却换了一个。
⚠️ 采样率是安静的乘数:按用户哈希采样会让用户级特征分布正常、全站聚合特征全错。
⭐ 对策是口径指纹:每天给每个字段拍一组分布数字然后 diff——关键是看绝对量不看比率(只看点击率的话,曝光口径收紧反而会让它变好看)。最强的两个信号是行数突变和众数换人。
⚠️ 外购数据必查一条:单独拿它训一个模型,如果单它一个就到很高的 AUC,⭐ 几乎一定是你的目标混进去了。
💀 那个事故:产品把曝光口径从「进入视口」改成「停留一秒」,字段名和类型全没改,而算法团队不在通知群里。
下一节 👉 03-数据契约.md