第四章 数据质量检查与缺失值统计: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 知识结构图

下面这张图展示了从原始数据到缺失率报告的完整流程:

数据质量检查与缺失值统计流程图 数据质量检查:缺失值统计流程 原始数据表 cust_info COUNT(*) 获取总行数 total_rows COUNT(字段) 逐字段非空计数 自动忽略 NULL,得到每个字段有效记录数 缺失量 = total_rows - COUNT(字段) 也用 SUM(CASE WHEN IS NULL) 等效 缺失率报告(每字段 null_pct) 用于后续分箱、插补决策依据 避坑提示 确认表定义 小心视图过滤 空字符串≠NULL

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 —— 精准定位每一条缺失记录。

然后我们把这三个操作组合起来,造了一张缺失率汇总表。这张表就是后续所有衍生工作的「健康诊断报告」。记住一个原则:数据质量不过关,变量衍生就是瞎忙活。

个人经验:我一般在做任何宽表之前,先把每个源表的缺失率统计做成脚本模板,每次只要改表名和字段列表。这个习惯至少节省了我一半的排查时间。你也不妨试试。