🏠 总目录📚 本教程 10 · 拿 SQL 审问一份数据 ← →
📑 本页目录(点开跳转)

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' 白纸黑字写在那里。⭐ 神秘的是另外两件事:

  1. A、B 两区的占比从 33% 涨到了 50%,而没有任何一条 WHERE 提到过 A 或 B。 ⚠️ 你排除一群人,等于放大剩下所有人的权重。三条过滤条件里没有一条写着 「把 A 区的话语权提高 49%」,但它们合起来做到了。
  2. 这三条 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 要改成什么

✅ 检查点

  1. 本章把「审问」分成三层,哪一层会给你明确的 PASS / FAIL,哪一层不会?为什么说贵的错都在不给 PASS / FAIL 的那一层?
  2. 陌生表体检的五问里,MIN/MAX 那一问抓出了什么?为什么说它是最便宜的异常探针?
  3. GROUP BY city 那一步为什么值得单独跑?它在例子里抓出了什么?
  4. 为什么 COUNT(*) - COUNT(city) || ' 行为 NULL' 要加括号?不加会发生什么?
  5. 六个质量维度里,哪一个写不出 SQL 断言?为什么?
  6. 三节里为什么用 SUM(CASE WHEN 坏 THEN 1 ELSE 0 END) 而不是 COUNT(*) ... WHERE 坏?
  7. 「每一个 WHERE 都要有人负责解释」对应哪三个查询动作?各自的输出长什么样?
  8. 留一法为什么不能换成「把每条 WHERE 的影响逐条累加」?用本章的数字说明差多少。
  9. 那三条过滤条件把 region 的占比从 33.4 / 33.7 / 32.9 变成了什么?除了 C 区消失,另一件没人写在注释里的事是什么?
  10. 「留下 / 被挡掉」两份画像里,哪个数最致命?为什么?
  11. 六节那个例子里,行数守恒自检 PASS 了,为什么账还是错的?
  12. 两种写法对拍时,为什么两种写法必须「走不同的路子」?
👀 答案
  1. ① 表本身和② 断言给 PASS / FAIL,③ 口径不给 —— 它的输出是一张对比表,谁看谁负责。 贵的错都在第三层,因为前两层错了会有人来告诉你,第三层错了永远不报错, 要等几个月后以「线上和离线对不上」的形式出现。
  2. 抓出 cents 的范围是 -1.0 ~ 10.2 —— 金额出现负数。 便宜是因为它一个聚合函数就把「最不该出现的那个值」顶到眼前,且完全不需要懂业务。
  3. 因为它让大小写不统一当场现形:输出里 'SH'(2 行)和 'sh'(1 行)分开躺着。 不跑这一步就写 WHERE city = 'SH',那行 'sh' 会被静静扔掉且不报错。
  4. 因为 SQLite 里 || 的优先级高于 -,不加括号算的是 COUNT(*) - (COUNT(city) || '...'), 把字符串当数字用,返回 2 而不是 2 行为 NULL。又一个「不报错的错」—— 数字碰巧一样,你还以为它对了。
  5. 准确性。表里写着 34 岁,格式合法、范围合法、和别的字段自洽,但他可能今年 51 岁。 你能写出的所有断言都只是在让数据自己证明自己,准确性需要外部参照(人工抽检 / 对账)。
  6. 因为 SUM(CASE WHEN ...) 不动 WHERE,所以能和别的断言摞进同一条 UNION ALL, 把 N 条检查压成一次数据库往返、一屏看完;COUNT(*) ... WHERE 做不到。
  7. ① 差集核对(全量 vs 结果,一共挡掉多少)→ 输出一个百分比,本例 81.9%; ② 留一法(每条单独负责多少)→ 每条一个数,本例 35612 / 4517 / 9003; ③ 分桶对比(人群构成变了吗)→ 一张对比表。
  8. 因为条件之间重叠:一个人可能既无点击、又是新用户、还在 C 区,三条各挡一次但只被扣一次。 本章数字:35612 + 4517 + 9003 = 49132,实际挡掉 81881,差 32749。 而且这个差值随数据变,永远对不上。
  9. 变成 49.8 / 50.2 / 0.0。另一件事:A、B 两区的占比从 33% 涨到了 50% —— ⚠️ 排除一群人等于放大剩下所有人的权重,而没有任何一条 WHERE 提到过 A 或 B。
  10. 新用户占比 0.0%(被挡掉那边是 24.3%)。因为 is_new = 0 保证了评估集里 一个新用户都没有,于是它对「新模型对新用户好不好」信息量恰好是零 —— 而新用户往往正是最需要评估的一群。(另外两个数:平均点击 15.52 vs 2.99,重度用户普查; 三刀合计 118940 > 81881,差值就是重叠。)
  11. 因为 oid=1 有两行明细扇出 +1,oid=3 没有明细被内连接丢掉 −1,恰好抵消。 行数 3 进 3 出纹丝不动,SUM 却从 125 变成 205(80 算了两次,30 一分没算)。
  12. 因为只有走不同路子的两种写法才会在「一个订单匹配多条明细」这件事上给出不同结果 (JOIN 会重复计数,EXISTS 不会)。⚠️ 如果两种写法本质是同一件事的换皮, 它们会一起错,对拍退化成一次昂贵的空转。

🛑 可以停在这里

⚡ 走神救援

这一章拿前面的语法反过来审问一份已经交到你手上的数据。⭐ 审问分三层,从便宜到贵:表本身(十分钟、不需要懂业务)、断言(该成立的规则成立吗)、⭐ 口径(这份数据代表谁、谁被排除了)。⚠️ 前两层给明确的通过或失败,第三层不给——它的输出是一张对比表、谁看谁负责,而所有贵的错都在第三层。

第一层是摞成一条的五问:行数、主键唯不唯一、哪列空、取值范围、时间范围。⭐ 回报最高的是顺手按类别分组:大小写不同的两个值分开躺着,不跑这步就写等值过滤会静静扔掉数据且不报错。

第二层把质量六维里能自动查的五个写成断言。⭐ 第六个「准确性」永远写不出来——因为所有断言都只是在让数据自己证明自己。 四个可搬走的做法里最实用的是用条件求和数坏行而不动 WHERE,这样才能摞进同一条查询。⚠️ 白名单断言当心「不在列表里」对空值不成立。

⭐ 第三层是全章核心,三个动作:差集核对(三条看着都合理的过滤条件,合起来挡掉了八成多)、⭐ 留一法(⚠️ 绝不能累加,差额就是条件之间的重叠)、⭐ 分桶对比。⭐⭐ 分桶那一步给出全章最反直觉的结论:某一类整个消失了,而剩下两类的占比从三分之一涨到一半——尽管没有任何条件提过它们。排除一群人,等于放大剩下所有人的权重。

再把被挡掉的人捞回来并排画像,⭐ 最致命的是新用户占比恰好是零——这份评估集对「新模型对新用户好不好」信息量正好为零。

交付前三条自检:行数守恒、两种写法对拍、双向对账。💀 例子里行数守恒通过了、账还是错的——一处扇出多一行、一处丢一行,恰好抵消。

⭐ 整章一句话:别问「这条查询返回了什么」,问「它没返回什么,以及为什么」。

下一节 👉 附录A-速查.md

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