第四章 数据质量检查与缺失值统计:COUNT、IS NULL 实战
数据没到你手里的时候,总觉得它干干净净。等真正打开表一看——嗯,空的、重复的、格式错乱的,什么妖魔鬼怪都有。我在实际项目中遇到过不止一次,跑完模型发现 AUC 低了 0.05,回头一查,原来是某个关键字段 40% 都是 NULL,模型直接把它当废纸扔了。
所以说,数据质量检查不是走形式,它决定你后面所有工作的地基稳不稳。这一章咱们就聊清楚:怎么用 COUNT 和 IS NULL 来给数据「体检」。
核心原则:衍生变量之前,必须知道每个字段的缺失比例、记录总数、有效记录数。这三件事没搞清楚,后续的分箱、聚合全是闭着眼开车。
4.1 为什么先统计缺失值?
我个人的习惯是——拿到源表第一件事,先跑一遍元数据概览。不是看数据长什么样,而是看它「缺什么」。举个例子:某个客户年龄字段,缺失率如果超过 30%,在后续建模时要么做单独分箱「未知」,要么做插补。但你要是不统计,直接拿来分箱,会把「缺失」和「0岁」混在一起,后果你想想看。
缺失值的处理策略完全取决于缺失率的分布。所以我从来不看单表总行数,而是看「每个字段的非空计数 + 总行数」,然后算出缺失率。这个习惯帮我避了很多坑。
我曾经犯过一个低级错误:直接 SELECT COUNT(*) FROM table,以为是全表总行数,结果那个表是个视图,默认带了 WHERE 过滤条件。后来做缺失率统计时,分母是错的,分子也是错的,整个数据质量报告都是废的。所以记住:统计缺失率,一定要用原始表的 COUNT(*),或者在同一个查询里先确认总行数。
4.2 核心 SQL 语句:COUNT 与 IS NULL 的三种用法
先来看最简单的表结构,假设我们有一张客户信息表 cust_info:
| 字段名 | 数据类型 | 说明 |
|---|---|---|
| cust_id | STRING | 客户ID(主键) |
| gender | STRING | 性别 |
| age | INT | 年龄 |
| income | DECIMAL | 年收入(元) |
| last_txn_date | DATE | 最近交易日期 |
咱们一个一个来。
用法一:统计总记录数
SELECT COUNT(*) AS total_rows
FROM cust_info;
这是最基础的。注意 COUNT(*) 会包含所有行,不管某个字段是不是 NULL。我建议把这个值单独拿出来,因为后面每个字段的非空计数都要除以它。
用法二:统计每个字段的非空计数
SELECT
COUNT(cust_id) AS cnt_cust_id,
COUNT(gender) AS cnt_gender,
COUNT(age) AS cnt_age,
COUNT(income) AS cnt_income,
COUNT(last_txn_date) AS cnt_last_txn_date
FROM cust_info;
很多人以为 COUNT(字段) 就是数这个字段有多少行——错。COUNT(字段) 自动忽略 NULL。所以结果就是该字段里非空的记录数。你看,一个字段少了一堆,缺失率就直接出来了。
用法三:用 IS NULL 找缺失明细
SELECT
SUM(CASE WHEN age IS NULL THEN 1 ELSE 0 END) AS null_age,
SUM(CASE WHEN income IS NULL THEN 1 ELSE 0 END) AS null_income
FROM cust_info;
或者更直接写:
SELECT
COUNT(*) - COUNT(age) AS null_age,
COUNT(*) - COUNT(income) AS null_income
FROM cust_info;
这两种写法等价。我个人更推荐第二种,因为不用写 CASE WHEN,一页 SQL 里扫一眼就能看出来逻辑。当然,如果你需要同时判断多个条件(比如 IS NULL OR 空字符串),那还是 CASE WHEN 更灵活。
一个小技巧:如果某个字段除了 NULL,还有空字符串 '' 或空格,记得用 TRIM(field) = '' OR field IS NULL 来捕获。我遇到过某个上游系统把缺失值的性别写成了空格,COUNT(gender) 居然没把它当成缺失,后来导致分箱时「空字符串」被单独分了一箱,闹了笑话。
4.3 一次查出全貌:批量缺失率统计
上面的写法一次只能统计固定字段。如果表有 50 个字段,那 SQL 就变成了一堵墙。有没有更聪明的方法?有。用元数据表 + 动态 SQL,或者直接用 Hive / Spark SQL 里的 stack 函数。下面给一个 **可移植、可读性好** 的解决方案:
WITH total AS (
SELECT COUNT(*) AS total_rows FROM cust_info
)
SELECT
'cust_id' AS field_name,
COUNT(cust_id) AS non_null_cnt,
total.total_rows - COUNT(cust_id) AS null_cnt,
ROUND((total.total_rows - COUNT(cust_id)) * 100.0 / total.total_rows, 2) AS null_pct
FROM cust_info, total
UNION ALL
SELECT
'gender',
COUNT(gender),
total.total_rows - COUNT(gender),
ROUND((total.total_rows - COUNT(gender)) * 100.0 / total.total_rows, 2)
FROM cust_info, total
UNION ALL
SELECT
'age',
COUNT(age),
total.total_rows - COUNT(age),
ROUND((total.total_rows - COUNT(age)) * 100.0 / total.total_rows, 2)
FROM cust_info, total
UNION ALL
SELECT
'income',
COUNT(income),
total.total_rows - COUNT(income),
ROUND((total.total_rows - COUNT(income)) * 100.0 / total.total_rows, 2)
FROM cust_info, total
UNION ALL
SELECT
'last_txn_date',
COUNT(last_txn_date),
total.total_rows - COUNT(last_txn_date),
ROUND((total.total_rows - COUNT(last_txn_date)) * 100.0 / total.total_rows, 2)
FROM cust_info, total;
结果长这样:
| field_name | non_null_cnt | null_cnt | null_pct |
|---|---|---|---|
| cust_id | 50000 | 0 | 0.00 |
| gender | 48500 | 1500 | 3.00 |
| age | 35200 | 14800 | 29.60 |
| income | 27800 | 22200 | 44.40 |
| last_txn_date | 50100 | –100 | –0.20 |
最后一行那个负值显然不对,说明什么?说明 last_txn_date 非空计数比总行数还多,大概率有重复数据。这就引出另一个灵魂拷问:统计完缺失,还得检查重复。
4.4 进阶:同时输出缺失率 + 样本异常
仅仅知道某个字段缺了 30%,还不够。你还需要知道:
- 缺失值是否集中出现在某些分组中(比如新用户群体更缺年龄)?
- 是否存在「逻辑缺失」——比如男性客户却有「怀孕次数」字段的填充?
- 缺失值是否与目标变量(Y)有相关性?
不过这些属于更深层的数据审计,咱们在第 7 章讲变量探索时会细聊。先回到本章的核心——用 COUNT 和 IS NULL 把缺失字典做出来。
4.5 知识结构图
下面这张图展示了从原始数据到缺失率报告的完整流程:
4.6 关于「空值」的两种定义
很多数据分析新手会把 NULL 和空字符串当成一回事。其实在 SQL 里,NULL 是「未知」,空字符串是「已知为空」。两者在 COUNT、WHERE 条件、JOIN 时的行为都不同。举个例子:
SELECT COUNT(*)
FROM cust_info
WHERE gender = ''; -- 只抓空字符串,不抓 NULL
SELECT COUNT(*)
FROM cust_info
WHERE gender IS NULL; -- 只抓 NULL,不抓空字符串
如果你要做统一的数据清洗,我建议把两者都视为缺失。用下面这个表达式统一排除:
SUM(CASE WHEN gender IS NULL OR TRIM(gender) = '' THEN 1 ELSE 0 END)
这种做法在金融风控场景里尤其常见,因为很多遗留系统会把空值写成空格或者 NULL 混用。我在做过的一个反欺诈项目中,发现用户邮箱字段 12% 是 NULL,7% 是空字符串,还有 3% 是纯数字。如果不统一清洗,分箱的时候三种异常会变成三个箱,完全没意义。
4.7 总结一下
这一章我们学了三个核心操作:
- COUNT(*) —— 拿到总行数,作为缺失率的分母。
- COUNT(字段) —— 干掉 NULL,拿到非空记录数。
- IS NULL 配合 CASE WHEN —— 精准定位每一条缺失记录。
然后我们把这三个操作组合起来,造了一张缺失率汇总表。这张表就是后续所有衍生工作的「健康诊断报告」。记住一个原则:数据质量不过关,变量衍生就是瞎忙活。
个人经验:我一般在做任何宽表之前,先把每个源表的缺失率统计做成脚本模板,每次只要改表名和字段列表。这个习惯至少节省了我一半的排查时间。你也不妨试试。