📑 本页目录(点开跳转)
10 · 拿 SQL 审问一份数据
⏱ 96 分钟 | ⭐ 「每一个 WHERE 都要有人负责解释」—— 这句话怎么真的执行下去
🎯 一句话
审问一份数据,就是把「这份数据代表谁」拆成一串能跑的查询;其中最贵的那一条是:把每一个 WHERE 挡掉的行捞回来,看一眼它们是谁。
前面九章教你把查询写对。这一章反过来 —— 拿同样这套语法去质问一份已经交到你手上的数据, 包括别人写的评估集、祖传报表、上游同事发来的那张表。
🚦 一、这一章和站内那句话的关系
《模型上线之后》02 章第六节复盘过一场事故, 结论里有一条被写进了那一章的教训清单:
离线评估集的构造 SQL,每一个
WHERE都要有人负责解释。
⭐ 那场事故归那一章,这里不复述(想看来龙去脉去那边,一句话概括:一句三年前的过滤条件, 让所有后续实验的结论都失真)。这一章只回答它留下的那个操作性问题:
这句话,用 SQL 怎么真的执行下去?
因为「要有人负责解释」听起来像一条管理制度,但它其实是三个具体的查询动作, 每个都能在两分钟内跑完。跑不跑,是这一章和那一章的分界。
⭐ 本章把「审问」分成三层,从便宜到贵:
| 层 | 在问什么 | 需要懂业务吗 | 大概花多久 |
|---|---|---|---|
| ① 表本身 | 这张表有几行、什么形状、哪里空、哪里重 | ❌ 不需要 | 10 分钟 |
| ② 断言 | 该成立的规则成立吗(非空率、唯一、范围、时序、新鲜度) | 一点点 | 20 分钟 |
| ③ ⭐ 口径 | 这份数据代表谁?谁被排除在外? | ✅ 需要 | 半天,且必须有人负责 |
⚠️ 绝大多数人只做到第 ①、② 层就交付了 —— 因为前两层会给你一个明确的 PASS / FAIL, 第三层不会。第三层的输出是一张对比表,谁看谁负责。而所有贵的错都发生在第三层。
🔍 二、头十分钟:陌生表体检
拿到一张没见过的表,先别写业务查询。先花十分钟问五个和业务无关的问题。
⭐ 好消息是这五问可以合成一条查询(UNION ALL 把互不相干的问句摞成一张表),
一次往返、一屏看完:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("""CREATE TABLE calls(
id INTEGER, user_id TEXT, feature TEXT, city TEXT, cents REAL, ts TEXT)""")
db.executemany("INSERT INTO calls VALUES(?,?,?,?,?,?)", [
(1, 'u1', 'chat', 'BJ', 0.056, '2026-07-01 09:12:00'),
(2, 'u1', 'summary', 'BJ', 2.64, '2026-07-01 10:03:00'),
(3, 'u2', 'summary', None, 10.2, '2026-07-02 22:41:00'),
(4, 'u2', 'chat', 'sh', 0.31, '2026-07-03 08:00:00'),
(5, 'u3', 'chat', 'SH', 0.44, '2026-07-03 08:30:00'),
(5, 'u3', 'chat', 'SH', 0.44, '2026-07-03 08:30:00'),
(7, 'u4', 'rerank', 'GZ', -1.0, '2026-07-04 12:00:00'),
(8, 'u4', 'chat', None, 0.07, '2026-07-04 12:05:00'),
(9, 'u5', 'summary', 'BJ', 3.10, '2026-06-01 00:00:00'),
])
profile = """
SELECT '① 行数' AS 问, CAST(COUNT(*) AS TEXT) AS 答 FROM calls
UNION ALL
SELECT '② id 唯一吗', COUNT(*) || ' 行 / ' || COUNT(DISTINCT id) || ' 个 id' FROM calls
UNION ALL
SELECT '③ city 空多少', (COUNT(*) - COUNT(city)) || ' 行为 NULL' FROM calls -- ⭐ 括号别省
UNION ALL
SELECT '④ cents 范围', MIN(cents) || ' ~ ' || MAX(cents) FROM calls
UNION ALL
SELECT '⑤ ts 范围', MIN(ts) || ' ~ ' || MAX(ts) FROM calls
"""
for r in db.execute(profile):
print("%-14s %s" % r)
print("\n枚举列到底有几个值:")
for r in db.execute(
"SELECT city, COUNT(*) FROM calls GROUP BY city ORDER BY COUNT(*) DESC"):
print(" ", r)
print("\n重复的 id 是哪几个:")
for r in db.execute(
"SELECT id, COUNT(*) AS n FROM calls GROUP BY id HAVING COUNT(*) > 1"):
print(" ", r)
实跑输出:
① 行数 9
② id 唯一吗 9 行 / 8 个 id
③ city 空多少 2 行为 NULL
④ cents 范围 -1.0 ~ 10.2
⑤ ts 范围 2026-06-01 00:00:00 ~ 2026-07-04 12:05:00
枚举列到底有几个值:
('BJ', 3)
('SH', 2)
(None, 2)
('sh', 1)
('GZ', 1)
重复的 id 是哪几个:
(5, 2)
九行数据,五个问题,抓出四个毛病 —— 而且每一个都不需要你知道这张表是干嘛的:
| 问 | 输出 | 它在说什么 |
|---|---|---|
| ② | 9 行 / 8 个 id |
⚠️ 主键不唯一。少一个 id 就意味着有行重复了,HAVING COUNT(*) > 1 立刻指出是 id=5 |
| ③ | 2 行为 NULL |
city 有 22% 是空的 —— 你后面任何按城市的 GROUP BY 都会少这两行 |
| ④ | -1.0 ~ 10.2 |
⚠️ 金额出现负数。MIN/MAX 是最便宜的异常探针,它总是把最不该出现的那个值顶到你眼前 |
| ⑤ | 2026-06-01 ~ 2026-07-04 |
⚠️ 说好是「七月的数据」,却有一行来自 6 月 1 日 —— 一个月前的孤儿行 |
⭐ 枚举列那一段是这五问里回报最高的一个。 GROUP BY city 一跑,
'SH' 和 'sh' 两个值分开躺着 —— 大小写不统一在这里当场现形。
如果你没跑这一步就直接写 WHERE city = 'SH',那一行 'sh' 会被静静扔掉,不报错。
⭐ 为什么这五问必须在业务查询之前跑: 它们回答的都是「这张表的形状」,而不是「这张表说了什么」。 形状错了,说什么都不用听。而形状检查是一次性成本 —— 你只需要在第一次碰这张表时花十分钟。
⚠️ ③ 那对括号不能省。 COUNT(*) - COUNT(city) || ' 行为 NULL' 在 SQLite 里,
|| 的优先级高于 -,于是它算的是 COUNT(*) - (COUNT(city) || ' 行为 NULL'),
把那个字符串当数字用,返回 2 而不是 2 行为 NULL。⭐ 又一个「不报错的错」:
数字碰巧一样,你还以为它对了。拼字符串和算术混在一起时,一律加括号。
⚠️ COUNT(*) 和 COUNT(city) 差在哪,是 03 章的题目;
NULL 为什么不参与比较,是 04 章的题目。这里只是把它们当探针用。
🧪 三、把质量维度写成断言
《数据这一关》08 章把「数据挺脏」这句话拆成了六个维度: 完整性、唯一性、有效性、一致性、时效性、准确性。⭐ 那一章的关键结论是 前五个能靠数据自己证明自己,第六个(准确性)永远不能 —— 它需要外部参照, 只能人工抽检 / 对账,不是查询问题。那边给的检查代码是 pandas 版。
⭐ 这里把能自动查的那五个,写成纯 SQL 断言 —— 因为如果数据在库里, 你根本没必要先把它拉进内存:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("""CREATE TABLE calls(
id INTEGER, user_id TEXT, feature TEXT, city TEXT,
cents REAL, created_at TEXT, paid_at TEXT)""")
db.executemany("INSERT INTO calls VALUES(?,?,?,?,?,?,?)", [
(1, 'u1', 'chat', 'BJ', 0.056, '2026-07-01 09:12:00', '2026-07-01 09:12:30'),
(2, 'u1', 'summary', 'BJ', 2.64, '2026-07-01 10:03:00', '2026-07-01 10:03:10'),
(3, 'u2', 'summary', None, 10.2, '2026-07-02 22:41:00', '2026-07-02 22:41:05'),
(4, 'u2', 'chat', 'sh', 0.31, '2026-07-03 08:00:00', '2026-07-03 07:59:00'),
(5, 'u3', 'chat', 'SH', 0.44, '2026-07-03 08:30:00', '2026-07-03 08:30:02'),
(5, 'u3', 'chat', 'SH', 0.44, '2026-07-03 08:30:00', '2026-07-03 08:30:02'),
(7, 'u4', 'rerank', 'GZ', -1.0, '2026-07-04 12:00:00', '2026-07-04 12:00:01'),
(8, 'u4', 'chat', None, 0.07, '2026-07-04 12:05:00', '2026-07-04 12:05:03'),
])
checks = """
SELECT '完整性 · city 非空率' AS 维度,
ROUND(100.0 * COUNT(city) / COUNT(*), 1) || '%' AS 实测,
'>= 99.5%' AS 期望,
CASE WHEN 100.0*COUNT(city)/COUNT(*) >= 99.5
THEN 'PASS' ELSE 'FAIL' END AS 结论 FROM calls
UNION ALL
SELECT '唯一性 · id 无重复',
CAST(COUNT(*) - COUNT(DISTINCT id) AS TEXT) || ' 个多余行', '= 0',
CASE WHEN COUNT(*) = COUNT(DISTINCT id) THEN 'PASS' ELSE 'FAIL' END FROM calls
UNION ALL
SELECT '有效性 · cents >= 0',
CAST(SUM(CASE WHEN cents < 0 THEN 1 ELSE 0 END) AS TEXT) || ' 行为负', '= 0',
CASE WHEN SUM(CASE WHEN cents < 0 THEN 1 ELSE 0 END) = 0
THEN 'PASS' ELSE 'FAIL' END FROM calls
UNION ALL
SELECT '有效性 · feature 在白名单里',
CAST(SUM(CASE WHEN feature NOT IN ('chat','summary') THEN 1 ELSE 0 END) AS TEXT)
|| ' 行越界', '= 0',
CASE WHEN SUM(CASE WHEN feature NOT IN ('chat','summary') THEN 1 ELSE 0 END) = 0
THEN 'PASS' ELSE 'FAIL' END FROM calls
UNION ALL
SELECT '一致性 · paid_at >= created_at',
CAST(SUM(CASE WHEN paid_at < created_at THEN 1 ELSE 0 END) AS TEXT) || ' 行倒挂', '= 0',
CASE WHEN SUM(CASE WHEN paid_at < created_at THEN 1 ELSE 0 END) = 0
THEN 'PASS' ELSE 'FAIL' END FROM calls
UNION ALL
SELECT '时效性 · 最新一行离 07-05 多久',
CAST(ROUND(julianday('2026-07-05 00:00:00') - julianday(MAX(created_at)), 2) AS TEXT)
|| ' 天', '< 1 天',
CASE WHEN julianday('2026-07-05 00:00:00') - julianday(MAX(created_at)) < 1
THEN 'PASS' ELSE 'FAIL' END FROM calls
"""
for r in db.execute(checks):
print("%-28s %-14s %-10s %s" % r)
实跑输出:
完整性 · city 非空率 75.0% >= 99.5% FAIL
唯一性 · id 无重复 1 个多余行 = 0 FAIL
有效性 · cents >= 0 1 行为负 = 0 FAIL
有效性 · feature 在白名单里 1 行越界 = 0 FAIL
一致性 · paid_at >= created_at 1 行倒挂 = 0 FAIL
时效性 · 最新一行离 07-05 多久 0.5 天 < 1 天 PASS
⭐ 这段代码的形状比它的内容更重要,有四个可以直接搬走的做法:
① 每条断言都是「维度 / 实测 / 期望 / 结论」四列。
只有 FAIL 两个字的报告没法交接 —— 别人看不出离及格差多远。
写上 75.0% 和 >= 99.5%,收报告的人不用问你第二句。
② 用 SUM(CASE WHEN 坏 THEN 1 ELSE 0 END) 数「坏行」,不要用 COUNT(*) ... WHERE 坏。
⭐ 前者能和别的断言摞进同一条 UNION ALL(因为它不动 WHERE),后者不能。
这是把 N 条检查压成一次数据库往返的关键技巧。
③ 时效性那条最容易被漏掉,也最容易骗过所有人。
《数据这一关》08 里那场事故就是这个形状:数据全都合法,只是旧了六天。
⚠️ 前五条断言全 PASS 而时效性没查,等于没查。
④ 白名单断言用 NOT IN ('chat','summary') 抓越界。
它抓出了 'rerank' —— 一个上游新加的、下游没人知道的取值。
⚠️ 这条断言有个 NULL 陷阱:如果 feature 列有 NULL,NULL NOT IN (...) 是 NULL 不是真,
那一行不会被算作越界。要连 NULL 一起抓,得写成
SUM(CASE WHEN feature IS NULL OR feature NOT IN (...) THEN 1 ELSE 0 END)。
为什么,回 04 章。
⚠️ 第六个维度(准确性)没有 SQL 版本,这不是本章偷懒。 表里写着这个用户 34 岁,格式合法、范围合法、和别的字段自洽 —— 但他可能今年 51 岁。 ⭐ 你能写出来的所有断言,都只是在让数据自己证明自己。 一份精心伪造的数据能通过上面全部五条。要判准确性只能找外部参照,那是人力预算问题,不是查询问题。
🛑 读到这里可以停 —— 前半章讲完了(约 34 分钟)。 后半章还有:每一个 WHERE 都要有人负责解释:三个动作 · 把被挡掉的人捞回来看一眼 · 交付之前的两条自检 · 一张按回车之前的清单 回来的时候不用重读,直接从下一节接着看就行。
⭐ 四、每一个 WHERE 都要有人负责解释:三个动作
现在到第三层。假设你手上有一份评估集,它的构造 SQL 长这样(三条 WHERE,看起来都很合理):
SELECT uid FROM traffic
WHERE click_7d > 0 -- 过滤掉无行为用户
AND is_new = 0 -- 排除新用户
AND region <> 'C' -- 去掉 C 区
⭐ 三个动作,按顺序做:
| 动作 | 问什么 | 输出长什么样 |
|---|---|---|
| ① 差集核对 | 全量多少行,结果多少行,一共挡掉了多少 | 一个百分比 |
| ② 留一法 | 每一条 WHERE 单独负责多少 —— 去掉它会多出多少行 |
每条一个数 |
| ③ ⭐ 分桶对比 | 结果集的人群构成,和全量的人群构成一样吗 | 一张对比表 |
十万行的合成数据,跑一遍:
import sqlite3, random
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE traffic (uid INTEGER PRIMARY KEY, click_7d INT, is_new INT, region TEXT)")
random.seed(3) # ⭐ 固定种子,你跑出来的数和这里一样
rows = []
for uid in range(1, 100001):
c = 0 if random.random() < 0.66 else random.randint(1, 30)
rows.append((uid, c, 1 if random.random() < 0.2 else 0, random.choice("ABC")))
db.executemany("INSERT INTO traffic VALUES (?,?,?,?)", rows)
WHERES = [("click_7d > 0", "过滤掉无行为用户"),
("is_new = 0", "排除新用户"),
("region <> 'C'","去掉 C 区")]
full = "SELECT uid FROM traffic WHERE " + " AND ".join(w for w, _ in WHERES)
n_all = db.execute("SELECT COUNT(*) FROM traffic").fetchone()[0]
n_eval = db.execute("SELECT COUNT(*) FROM (%s)" % full).fetchone()[0]
print("① 差集核对:全量 %d,评估集 %d,被这几个 WHERE 挡掉 %d(%.1f%%)"
% (n_all, n_eval, n_all-n_eval, (n_all-n_eval)*100/n_all))
print("② 每个 WHERE 单独负责多少 —— 去掉这一条会多出多少行:")
for w, why in WHERES: # ⭐ 留一法:每次只摘掉一条
rest = [x for x, _ in WHERES if x != w]
sql = "SELECT COUNT(*) FROM traffic" + (" WHERE " + " AND ".join(rest) if rest else "")
n = db.execute(sql).fetchone()[0]
print(" 去掉 `%-14s`(%s) → %6d 行,多出 %6d" % (w, why, n, n - n_eval))
print("③ 按人群分桶对比:评估集里各桶占比 vs 全量各桶占比")
q = """
WITH e AS (%s)
SELECT t.region,
COUNT(*) AS 全量,
SUM(CASE WHEN e.uid IS NOT NULL THEN 1 ELSE 0 END) AS 评估集,
ROUND(100.0*COUNT(*)/(SELECT COUNT(*) FROM traffic), 1) AS 全量占比,
ROUND(100.0*SUM(CASE WHEN e.uid IS NOT NULL THEN 1 ELSE 0 END)
/(SELECT COUNT(*) FROM (%s)), 1) AS 评估占比
FROM traffic t LEFT JOIN e ON e.uid = t.uid GROUP BY t.region ORDER BY t.region
""" % (full, full)
for r in db.execute(q): print(" ", r)
实跑输出:
① 差集核对:全量 100000,评估集 18119,被这几个 WHERE 挡掉 81881(81.9%)
② 每个 WHERE 单独负责多少 —— 去掉这一条会多出多少行:
去掉 `click_7d > 0 `(过滤掉无行为用户) → 53731 行,多出 35612
去掉 `is_new = 0 `(排除新用户) → 22636 行,多出 4517
去掉 `region <> 'C' `(去掉 C 区) → 27122 行,多出 9003
③ 按人群分桶对比:评估集里各桶占比 vs 全量各桶占比
('A', 33424, 9027, 33.4, 49.8)
('B', 33686, 9092, 33.7, 50.2)
('C', 32890, 0, 32.9, 0.0)
🧯 三个动作各自看出了什么
动作①:81.9% 这个数字本身就该触发一次对话。
⭐ 判据很简单:如果一个百分比让你「啊?这么多?」,那它就需要一个书面解释。
81.9% 意味着这份评估集只代表五分之一不到的用户。这句话不一定错 —— 也许业务上就该这样 ——
但它必须有人明确说过一次,而不是从三条各自合理的 WHERE 里悄悄长出来。
动作②:留一法给出的是「边际责任」,不是「总责任」。
⚠️ 这是本节最容易算错的一步:三条 WHERE 的边际值加起来是
35612 + 4517 + 9003 = 49132,而实际挡掉的是 81881。差了 32749。
⭐ 原因是条件之间重叠 —— 一个人可能既无点击、又是新用户、还在 C 区,三条各挡一次,
但他只被扣掉一次。所以:
⭐ 只能用留一法(摘掉一条,看多出多少),不能用「逐条累加」。 累加会得出一个虚高的、根本对不上的数,而且永远对不上 —— 那个差值就是条件之间的交集,它随数据变。
动作③:⭐ 这一行才是这一章存在的理由。
| region | 全量 | 全量占比 | 评估集 | 评估集占比 |
|---|---|---|---|---|
| A | 33424 | 33.4% | 9027 | 49.8% |
| B | 33686 | 33.7% | 9092 | 50.2% |
| C | 32890 | 32.9% | 0 | ⚠️ 0.0% |
全量三个区几乎均分(33.4 / 33.7 / 32.9),评估集里变成 49.8 / 50.2 / 0.0。
C 区整个消失了。
这当然不神秘 —— region <> 'C' 白纸黑字写在那里。⭐ 神秘的是另外两件事:
- A、B 两区的占比从 33% 涨到了 50%,而没有任何一条
WHERE提到过 A 或 B。 ⚠️ 你排除一群人,等于放大剩下所有人的权重。三条过滤条件里没有一条写着 「把 A 区的话语权提高 49%」,但它们合起来做到了。 - 这三条
WHERE是三个不同的人、在三个不同的时间加进去的, 每一条单独看都有它的道理。没有任何一个环节会报错、告警、或者变红。
⭐ 「每一个
WHERE都要有人负责解释」的真正含义: 不是「每条过滤条件要写注释」—— 上面三条都有注释。 是 「每条过滤条件都要有人对着动作③那张表,说出它把人群构成改成了什么样」。 注释解释的是这条WHERE想做什么,动作③给的是这些WHERE合起来实际做了什么。 两者经常不是一回事,而只有后者会影响你的结论。
🔎 五、把被挡掉的人捞回来看一眼
动作③给的是留下的人的画像。⭐ 真正的收尾动作是反过来: 给被挡掉的 81881 人也做一份画像,两份并排放。
import sqlite3, random
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE traffic (uid INTEGER PRIMARY KEY, click_7d INT, is_new INT, region TEXT)")
random.seed(3)
rows = []
for uid in range(1, 100001):
c = 0 if random.random() < 0.66 else random.randint(1, 30)
rows.append((uid, c, 1 if random.random() < 0.2 else 0, random.choice("ABC")))
db.executemany("INSERT INTO traffic VALUES (?,?,?,?)", rows)
KEEP = "click_7d > 0 AND is_new = 0 AND region <> 'C'"
q = """
WITH tagged AS (
SELECT *, CASE WHEN {keep} THEN '留下' ELSE '被挡掉' END AS 分组 FROM traffic
)
SELECT 分组,
COUNT(*) AS 人数,
ROUND(AVG(click_7d), 2) AS 平均点击,
ROUND(100.0*AVG(is_new), 1) AS 新用户占比,
SUM(CASE WHEN region='A' THEN 1 ELSE 0 END) AS A,
SUM(CASE WHEN region='B' THEN 1 ELSE 0 END) AS B,
SUM(CASE WHEN region='C' THEN 1 ELSE 0 END) AS C
FROM tagged GROUP BY 分组 ORDER BY 分组 DESC
""".format(keep=KEEP)
print("留下 vs 被挡掉,两个人群长什么样:")
for r in db.execute(q):
print(" ", r)
q2 = """
SELECT SUM(CASE WHEN click_7d = 0 THEN 1 ELSE 0 END) AS 挨了_无点击,
SUM(CASE WHEN is_new = 1 THEN 1 ELSE 0 END) AS 挨了_新用户,
SUM(CASE WHEN region = 'C' THEN 1 ELSE 0 END) AS 挨了_C区,
COUNT(*) AS 被挡掉合计
FROM traffic WHERE NOT ({keep})
""".format(keep=KEEP)
print("\n被挡掉的人各自挨了哪几刀(会重复计数,一个人可能同时中三刀):")
print(" ", db.execute(q2).fetchone())
print("\n随手抽 5 行被挡掉的原始记录:") # ⭐ 别只看聚合,肉眼看几行
for r in db.execute("SELECT * FROM traffic WHERE NOT (%s) LIMIT 5" % KEEP):
print(" ", r)
实跑输出:
留下 vs 被挡掉,两个人群长什么样:
('被挡掉', 81881, 2.99, 24.3, 24397, 24594, 32890)
('留下', 18119, 15.52, 0.0, 9027, 9092, 0)
被挡掉的人各自挨了哪几刀(会重复计数,一个人可能同时中三刀):
(66113, 19937, 32890, 81881)
随手抽 5 行被挡掉的原始记录:
(1, 0, 0, 'B')
(3, 0, 0, 'B')
(4, 0, 0, 'C')
(5, 0, 0, 'B')
(6, 0, 0, 'A')
⭐ 三个必须停下来看的数:
① 平均点击 15.52 vs 2.99。
留下的人活跃度是被挡掉那群人的五倍。这份评估集不是「全体用户的一个样本」,
它是「重度用户的一个普查」。任何在它上面测出来的指标提升,
默认只对重度用户成立,除非你另外证明。
② 新用户占比 0.0% vs 24.3%。
⭐ 这是三个数里最致命的一个。 评估集里一个新用户都没有 ——
is_new = 0 这条 WHERE 保证了这一点。于是这份评估集对
「新模型对新用户好不好」这个问题,信息量恰好是零。
⚠️ 而新用户往往正是最需要评估的一群人(冷启动最难、流失最快、增长最看重)。
③ 三刀合计 66113 + 19937 + 32890 = 118940,但被挡掉的只有 81881。
差值 37059 就是重叠 —— 印证了上一节那条「不能累加」。
⭐ 这一行的实际用途是排序:click_7d = 0 一条就干掉了 66113 人,
是三条里最狠的。如果这份评估集只能松一条,从它开刀。
④ ⚠️ 别只看聚合,抽几行原始记录肉眼看一遍。
抽出来的五行 (1, 0, 0, 'B')、(3, 0, 0, 'B')…… 全是 click_7d = 0,
和上面那个 66113 对得上 —— 这就是「肉眼看几行」的作用:它是聚合结论的一次廉价交叉验证。
如果抽出来的五行长得和你的聚合结论对不上,那多半是你的查询写错了,不是数据出奇了。
⭐ 被挡掉的人捞回来之后怎么办,超出这套教程的范围(那是统计问题不是查询问题)。 如果你确认样本已经偏了、又没法重造数据, 《数据这一关》15 章那套逆倾向加权(IPW)是标准解法。 ⚠️ 但请先做完这一节 —— 加权之前你得先知道偏在哪个方向、偏了多少, 而那正是上面这两张表在回答的事。
🛑 读到这里可以停 —— 已经读了约 64 分钟。 最后一段还有(约 17 分钟):交付之前的两条自检 · 一张按回车之前的清单 回来的时候不用重读,直接从下一节接着看就行。
📋 六、交付之前的两条自检
前五节审的是别人给你的数据。这一节审你自己刚写完那条查询。
⭐ 两条自检,都只要两行代码,但它们抓的是不同的东西:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders(oid INTEGER, uid TEXT, amount REAL)")
db.execute("CREATE TABLE items (oid INTEGER, sku TEXT, qty INT)")
db.executemany("INSERT INTO orders VALUES(?,?,?)",
[(1, 'u1', 80.0), (2, 'u1', 45.0), (3, 'u2', 30.0)])
db.executemany("INSERT INTO items VALUES(?,?,?)",
[(1, 'a', 1), (1, 'b', 2), (2, 'c', 1)]) # oid=1 有两行;oid=3 一行都没有
# ① 行数守恒:JOIN 之前多少行,JOIN 之后多少行
n_before = db.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
n_after = db.execute(
"SELECT COUNT(*) FROM orders o JOIN items i ON i.oid = o.oid").fetchone()[0]
print("① 行数守恒:orders %d 行 → JOIN 之后 %d 行(%s)"
% (n_before, n_after, "一样" if n_before == n_after else "⚠️ 变了,去查扇出和丢行"))
# ② 同一个数,两种写法对拍
a = db.execute("SELECT SUM(o.amount) FROM orders o JOIN items i ON i.oid = o.oid").fetchone()[0]
b = db.execute("SELECT SUM(amount) FROM orders o "
"WHERE EXISTS (SELECT 1 FROM items i WHERE i.oid = o.oid)").fetchone()[0]
print("② 对拍:JOIN 写法 = %s,EXISTS 写法 = %s → %s"
% (a, b, "一致" if a == b else "⚠️ 不一致,两个口径至少有一个是错的"))
# ③ 双向对账:两边各自有多少行对不上
q = """
SELECT (SELECT COUNT(*) FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM items i WHERE i.oid = o.oid)) AS 订单没明细,
(SELECT COUNT(*) FROM items i
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.oid = i.oid)) AS 明细没订单
"""
print("③ 双向对账:", db.execute(q).fetchone())
实跑输出:
① 行数守恒:orders 3 行 → JOIN 之后 3 行(一样)
② 对拍:JOIN 写法 = 205.0,EXISTS 写法 = 125.0 → ⚠️ 不一致,两个口径至少有一个是错的
③ 双向对账: (1, 0)
💀 这个例子最值钱的地方,是它把「只做一条自检」的后果摆在了台面上:
行数守恒那一条 PASS 了 —— 3 行进,3 行出 —— 而账依然是错的。
原因看第三条:oid=1 有两行明细,扇出成 2 行;oid=3 一行明细都没有,被内连接丢掉 1 行。
⭐ 一个 +1,一个 −1,恰好抵消。 行数看起来纹丝不动,
SUM 却从 125 变成了 205(80 被算了两次,30 一分钱没算)。
| 自检 | 抓什么 | 漏什么 |
|---|---|---|
| ① 行数守恒 | 扇出、丢行 | ⚠️ 扇出和丢行互相抵消的情况 |
| ② 两种写法对拍 | 口径错 | 两种写法犯同一个错的情况(所以两种写法要走不同的路子) |
| ③ 双向对账 | ⭐ 到底是哪边多了 / 少了 | —— 它是①②报警之后的定位工具 |
⭐ 所以这三条要一起做,而且顺序是固定的:①② 任何一条报警 → 跑 ③ 定位 → 定位到
「1 个订单没明细」和「oid=1 有两行」两件事上。⚠️ JOIN 为什么会放大 SUM,
是 02 章的题目,这里只把它当自检项用。
⭐ 对拍那条为什么必须走「不同的路子」:
JOIN版和EXISTS版之所以能互相验证,是因为它们对「一个订单匹配多条明细」的处理根本不同 —— 一个会重复计数,一个不会。 ⚠️ 如果你的两种写法只是把INNER JOIN换成,加WHERE(同一件事的两种写法), 那它们会一起错,对拍就退化成一次昂贵的空转。
🧯 七、一张按回车之前的清单
⭐ 把上面六节压成一页,贴在你写查询的地方:
| # | 问题 | 用什么查 | 什么时候必做 |
|---|---|---|---|
| 1 | 这张表几行、主键唯一吗、哪列空 | 二节那条 UNION ALL 五问 |
第一次碰这张表 |
| 2 | 枚举列到底有几个取值 | GROUP BY 列 ORDER BY COUNT(*) DESC |
你打算 WHERE 列 = '某值' 之前 |
| 3 | 该成立的规则成立吗 | 三节那六条断言 | 数据要进模型 / 进报表之前 |
| 4 | 这些 WHERE 一共挡掉了百分之几 |
动作① 差集核对 | ⭐ 每次写带 WHERE 的取数 |
| 5 | 每条 WHERE 单独负责多少 |
动作② 留一法(⚠️ 不能累加) | 结果比预期少的时候 |
| 6 | 结果集的人群构成变了吗 | 动作③ 分桶占比对比 | ⭐ 数据要用来下结论的时候 |
| 7 | 被挡掉的那群人长什么样 | 五节的 留下 / 被挡掉 并排画像 |
同上 |
| 8 | JOIN 之后行数变了吗 |
六节自检① | 每次写 JOIN |
| 9 | 换个写法还是这个数吗 | 六节自检②(⭐ 两种写法要走不同路子) | 这个数要发出去的时候 |
| 10 | 对不上的行到底在哪边 | 六节自检③ 双向对账 | 8 或 9 报警之后 |
⚠️ 第 4 项和第 6 项是这张清单里最容易被跳过、也最贵的两项 —— 因为它们永远不会报错,跳过它们的代价要等到几个月后才会以「线上效果和离线对不上」的形式出现。
⭐ 整章一句话: 别问「这条查询返回了什么」,问「这条查询没返回什么,以及为什么」。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 模型上线之后 · 02 离线好不等于线上好 | ⭐ 「每一个 WHERE 都要有人负责解释」这句话的出处,以及它是从哪场事故里长出来的。本章只做它的执行方法,事故本身在那边 |
| 数据这一关 · 08 数据质量怎么量 | 本章三节那六条断言的维度来源。那边给的是 pandas 版检查代码 + 阈值怎么定 + 质量报告怎么进 CI,这边只把它们翻成 SQL |
| 数据这一关 · 13 评测集也是数据 | 本章第四节审的是「这个评估集代表谁」,那边回答的是上一层的问题:评测集该抽多大、怎么抽、会不会被污染 |
| 数据这一关 · 15 采样偏差与幸存者偏差 | ⭐ 五节末尾的接力:查出样本偏了之后怎么办。那边有逆倾向加权(IPW)和「什么时候它其实不要紧」的判断表 |
| 03 聚合与分组 | 二节的 COUNT(*) vs COUNT(city) 差在哪、三节的 SUM(CASE WHEN ...) 为什么能替代 COUNT ... WHERE |
| 04 NULL 与三值逻辑 | 三节那条白名单断言的 NULL 陷阱(NULL NOT IN (...) 不是真),以及为什么 city 的空值会静静地从连维表和 <> 过滤里消失(⚠️ 不是 GROUP BY —— 它反而会给 NULL 单开一组) |
| 02 JOIN 的几种语义 | 六节自检①②:扇出把 SUM 放大、内连接把没匹配上的行丢掉,这两件事为什么会互相抵消 |
| 附录A 速查与三库差异对照 | 本章的断言用了 julianday、printf 这类 SQLite 专有函数,换到 Postgres / MySQL 要改成什么 |
✅ 检查点
- 本章把「审问」分成三层,哪一层会给你明确的 PASS / FAIL,哪一层不会?为什么说贵的错都在不给 PASS / FAIL 的那一层?
- 陌生表体检的五问里,
MIN/MAX那一问抓出了什么?为什么说它是最便宜的异常探针? GROUP BY city那一步为什么值得单独跑?它在例子里抓出了什么?- 为什么
COUNT(*) - COUNT(city) || ' 行为 NULL'要加括号?不加会发生什么? - 六个质量维度里,哪一个写不出 SQL 断言?为什么?
- 三节里为什么用
SUM(CASE WHEN 坏 THEN 1 ELSE 0 END)而不是COUNT(*) ... WHERE 坏? - 「每一个
WHERE都要有人负责解释」对应哪三个查询动作?各自的输出长什么样? - 留一法为什么不能换成「把每条
WHERE的影响逐条累加」?用本章的数字说明差多少。 - 那三条过滤条件把 region 的占比从 33.4 / 33.7 / 32.9 变成了什么?除了 C 区消失,另一件没人写在注释里的事是什么?
- 「留下 / 被挡掉」两份画像里,哪个数最致命?为什么?
- 六节那个例子里,行数守恒自检
PASS了,为什么账还是错的? - 两种写法对拍时,为什么两种写法必须「走不同的路子」?
👀 答案
- ① 表本身和② 断言给 PASS / FAIL,③ 口径不给 —— 它的输出是一张对比表,谁看谁负责。 贵的错都在第三层,因为前两层错了会有人来告诉你,第三层错了永远不报错, 要等几个月后以「线上和离线对不上」的形式出现。
- 抓出
cents的范围是-1.0 ~ 10.2—— 金额出现负数。 便宜是因为它一个聚合函数就把「最不该出现的那个值」顶到眼前,且完全不需要懂业务。 - 因为它让大小写不统一当场现形:输出里
'SH'(2 行)和'sh'(1 行)分开躺着。 不跑这一步就写WHERE city = 'SH',那行'sh'会被静静扔掉且不报错。 - 因为 SQLite 里
||的优先级高于-,不加括号算的是COUNT(*) - (COUNT(city) || '...'), 把字符串当数字用,返回2而不是2 行为 NULL。又一个「不报错的错」—— 数字碰巧一样,你还以为它对了。 - 准确性。表里写着 34 岁,格式合法、范围合法、和别的字段自洽,但他可能今年 51 岁。 你能写出的所有断言都只是在让数据自己证明自己,准确性需要外部参照(人工抽检 / 对账)。
- 因为
SUM(CASE WHEN ...)不动WHERE,所以能和别的断言摞进同一条UNION ALL, 把 N 条检查压成一次数据库往返、一屏看完;COUNT(*) ... WHERE做不到。 - ① 差集核对(全量 vs 结果,一共挡掉多少)→ 输出一个百分比,本例 81.9%; ② 留一法(每条单独负责多少)→ 每条一个数,本例 35612 / 4517 / 9003; ③ 分桶对比(人群构成变了吗)→ 一张对比表。
- 因为条件之间重叠:一个人可能既无点击、又是新用户、还在 C 区,三条各挡一次但只被扣一次。
本章数字:
35612 + 4517 + 9003 = 49132,实际挡掉 81881,差 32749。 而且这个差值随数据变,永远对不上。 - 变成 49.8 / 50.2 / 0.0。另一件事:A、B 两区的占比从 33% 涨到了 50% ——
⚠️ 排除一群人等于放大剩下所有人的权重,而没有任何一条
WHERE提到过 A 或 B。 - 新用户占比
0.0%(被挡掉那边是 24.3%)。因为is_new = 0保证了评估集里 一个新用户都没有,于是它对「新模型对新用户好不好」信息量恰好是零 —— 而新用户往往正是最需要评估的一群。(另外两个数:平均点击 15.52 vs 2.99,重度用户普查; 三刀合计 118940 > 81881,差值就是重叠。) - 因为
oid=1有两行明细扇出 +1,oid=3没有明细被内连接丢掉 −1,恰好抵消。 行数 3 进 3 出纹丝不动,SUM却从125变成205(80 算了两次,30 一分没算)。 - 因为只有走不同路子的两种写法才会在「一个订单匹配多条明细」这件事上给出不同结果
(
JOIN会重复计数,EXISTS不会)。⚠️ 如果两种写法本质是同一件事的换皮, 它们会一起错,对拍退化成一次昂贵的空转。
🛑 可以停在这里
⚡ 走神救援
这一章拿前面的语法反过来审问一份已经交到你手上的数据。⭐ 审问分三层,从便宜到贵:表本身(十分钟、不需要懂业务)、断言(该成立的规则成立吗)、⭐ 口径(这份数据代表谁、谁被排除了)。⚠️ 前两层给明确的通过或失败,第三层不给——它的输出是一张对比表、谁看谁负责,而所有贵的错都在第三层。
第一层是摞成一条的五问:行数、主键唯不唯一、哪列空、取值范围、时间范围。⭐ 回报最高的是顺手按类别分组:大小写不同的两个值分开躺着,不跑这步就写等值过滤会静静扔掉数据且不报错。
第二层把质量六维里能自动查的五个写成断言。⭐ 第六个「准确性」永远写不出来——因为所有断言都只是在让数据自己证明自己。 四个可搬走的做法里最实用的是用条件求和数坏行而不动
WHERE,这样才能摞进同一条查询。⚠️ 白名单断言当心「不在列表里」对空值不成立。⭐ 第三层是全章核心,三个动作:差集核对(三条看着都合理的过滤条件,合起来挡掉了八成多)、⭐ 留一法(⚠️ 绝不能累加,差额就是条件之间的重叠)、⭐ 分桶对比。⭐⭐ 分桶那一步给出全章最反直觉的结论:某一类整个消失了,而剩下两类的占比从三分之一涨到一半——尽管没有任何条件提过它们。排除一群人,等于放大剩下所有人的权重。
再把被挡掉的人捞回来并排画像,⭐ 最致命的是新用户占比恰好是零——这份评估集对「新模型对新用户好不好」信息量正好为零。
交付前三条自检:行数守恒、两种写法对拍、双向对账。💀 例子里行数守恒通过了、账还是错的——一处扇出多一行、一处丢一行,恰好抵消。
⭐ 整章一句话:别问「这条查询返回了什么」,问「它没返回什么,以及为什么」。
下一节 👉 附录A-速查.md