1. 为什么“检查 NaN”这件事,远比你想象的更危险、更值得深挖

在真实的数据清洗现场,我见过太多人把 df.isna().sum() 一跑,看到几行 True 就心安理得地敲下 .dropna() —— 结果模型上线三天后业务方打电话来问:“为什么上个月的销售额预测全崩了?”查了一整天,最后发现是某张表里一个本该是整数的字段,混进了字符串 'N/A' ,而 isna() 对它完全免疫。它不是 NaN,它只是“看起来像缺失”,但 Python 根本不认识它。

这就是我要说的第一件事: NaN 不是缺失值的全部,它只是 IEEE 754 浮点标准下的一个特定幽灵 。它只存在于 float 类型的世界里,对 str int bool 、甚至 pd.Categorical 都是隐形的。而现实中的脏数据,90% 的“缺失”根本不是 NaN —— 它们是空字符串、是 'NULL' 、是 '-' 、是 'TBD' 、是 None (注意,是 Python 原生的 None ,不是 np.nan ),甚至是带空格的 ' ' 。如果你只盯着 np.isnan() pd.isna() 看,等于在雾中开车,只开了前照灯,却关掉了后视镜和雷达。

所以这篇笔记不叫“4 种检查 NaN 的方法”,它的真实名字是: 《在 Python 数据流中,如何系统性地识别、归因、并最终驯服所有形态的‘逻辑缺失’》 。关键词里的 None 不是配角,它是主角之一 —— 因为 None np.nan 在 Pandas 里被统一处理,但在底层行为、内存表现、序列化兼容性上,它们是两个物种。我用三年时间踩过所有坑才明白: isna() 是个好用的锤子,但你得先确认眼前的问题是不是钉子。

这篇文章适合三类人:刚学 Pandas 的新手(别再死记 isna isnull 的区别了,它们本质一样);每天和 Excel、CSV、API 返回数据搏斗的业务分析师(你会看到为什么 read_csv(dtype=str) 能救你一命);还有正在写数据校验模块的工程师(最后一节的“混合缺失检测协议”直接能抄进你的 data_quality.py )。下面进入正题,我们不讲概念,只讲我在生产环境里反复验证过的实操逻辑。

2. 四种方法的本质拆解:不是“怎么用”,而是“为什么必须这样用”

2.1 np.isnan() :浮点世界的原生哨兵,但有致命盲区

np.isnan() 是 NumPy 底层 C 实现的函数,它直接调用 CPU 的 x87 指令集判断一个 float64 值是否符合 IEEE 754 的 NaN 编码规范。它的速度极快,单次判断耗时约 30 纳秒,比 Python 层任何判断都快一个数量级。但它的设计哲学非常纯粹: 只服务于数值计算,且只认 float 类型。

来看一组残酷的对比实验:

import numpy as np

# 这些都会返回 True —— 它们的二进制编码就是 NaN
print(np.isnan(np.nan))           # True
print(np.isnan(float('nan')))     # True
print(np.isnan(np.float64('nan')))# True

# 这些全会报错!因为类型不匹配
try:
    print(np.isnan(None))         # TypeError: ufunc 'isnan' not supported for the input types
except Exception as e:
    print(f"None -> {type(e).__name__}")

try:
    print(np.isnan(''))           # TypeError: ufunc 'isnan' not supported...
except Exception as e:
    print(f"'' -> {type(e).__name__}")

try:
    print(np.isnan(0))            # False(正确),但注意:0 是 int,会被隐式转为 float
    print(np.isnan(np.int64(0)))  # False(正确)
except Exception as e:
    print(f"int -> {type(e).__name__}")

关键点来了: np.isnan() int str None list 等非浮点类型直接抛出 TypeError 。这意味着,如果你在一个混合类型的 Pandas Series 里强行用 np.isnan(series) ,结果不是 False ,而是整个程序崩溃。我曾经在某个 ETL 任务里,因为上游数据突然多了一列全是字符串的 ID 字段, np.isnan() 直接让调度器卡死,日志里只有一行 TypeError ,排查了六小时。

提示: np.isnan() 的唯一安全使用场景,是当你 100% 确认输入是 np.ndarray dtype float32/64 ,或单个 float 值。对 Pandas 对象,请永远优先用 pd.isna()

2.2 pd.isna() :Pandas 的智能翻译官,统一抽象层的胜利

pd.isna() 是 Pandas 为解决 np.isnan() 的狭隘性而设计的“通用缺失探测器”。它的源码逻辑是:先尝试用 np.isnan() 判断,如果失败(比如遇到 None 或字符串),就退回到 Python 层的类型检查 —— 对 None 返回 True ,对字符串则检查是否为空或匹配常见缺失标识(如 'NULL' , 'N/A' 等,但默认不启用,需手动配置)。

我们用真实数据验证它的鲁棒性:

import pandas as pd
import numpy as np

# 构建一个典型的“脏数据”Series
dirty_series = pd.Series([
    1.0,                    # float,正常
    np.nan,                 # 标准 NaN
    None,                   # Python None
    '',                     # 空字符串
    'N/A',                  # 文本型缺失
    float('nan'),           # 手动构造 NaN
    0,                      # 整数 0(不是缺失!)
])

print("原始数据:", dirty_series.tolist())
print("pd.isna() 结果:", pd.isna(dirty_series).tolist())
# 输出: [False, True, True, False, False, True, False]

看到没? pd.isna() 正确识别了 np.nan None ,但对 '' 'N/A' 返回 False —— 这不是 bug,是 design choice。Pandas 认为,空字符串和 'N/A' 是有效字符串值,除非你明确告诉它“这些也算缺失”。这个设计背后是数据语义的严谨性:一个电商订单的 shipping_address 字段如果是空字符串,可能表示“地址待填”,而 'N/A' 可能表示“无需配送”,它们的业务含义和 np.nan (数据未采集)完全不同。

注意: pd.isna() pd.isnull() 是完全等价的,源码里 isnull 就是 isna 的别名。官方文档建议用 isna() ,因为语义更清晰(“is not available” vs “is null”)。

2.3 DataFrame 的 .isna() 方法:向量化操作的威力与陷阱

pd.isna() 作用于 DataFrame 时,它返回一个同形状的布尔 DataFrame,每个单元格是 True/False 。这是向量化操作的典范,但新手常犯两个致命错误:

错误一:用 df.isna().sum() 后直接 .dropna() ,忽略数据类型分布

import pandas as pd
import numpy as np

df = pd.DataFrame({
    'sales': [100.0, 200.0, np.nan],
    'region': ['North', 'South', None],  # 注意:region 是 object 类型
    'date': pd.to_datetime(['2023-01-01', '2023-01-02', None])
})

print("各列缺失统计:")
print(df.isna().sum())
# sales    1
# region   1
# date     1

# 看似三列各缺一个,但 dropna() 行为完全不同:
print("\n默认 dropna():")
print(df.dropna().shape)  # (2, 3) —— 删除了第2行和第3行?不对,是删除了所有含缺失的行!

# 正确做法:按列策略处理
print("\n按列处理示例:")
df_clean = df.copy()
df_clean['sales'] = df_clean['sales'].fillna(0)          # 数值列用均值/0填充
df_clean['region'] = df_clean['region'].fillna('Unknown') # 分类列用占位符
df_clean['date'] = df_clean['date'].fillna(pd.Timestamp('1970-01-01')) # 时间列特殊处理

错误二:对 object 类型列误用 np.isnan() 导致静默失败

# 危险操作!
# df['region'].apply(np.isnan)  # 会报错,因为 np.isnan 无法处理字符串

# 正确操作:
df['region'].apply(lambda x: pd.isna(x))  # 安全,返回布尔 Series
# 或更高效:
pd.isna(df['region'])  # 向量化,无需 apply

DataFrame 的 .isna() 方法真正的价值,在于它能和 any() / all() 配合做行/列级决策:

# 找出“整行都是缺失”的记录(极端脏数据)
full_missing_rows = df[df.isna().all(axis=1)]
print("全空行索引:", full_missing_rows.index.tolist())

# 找出“缺失率 > 50%”的列,考虑删除
missing_ratio = df.isna().mean()
high_missing_cols = missing_ratio[missing_ratio > 0.5].index.tolist()
print("高缺失率列:", high_missing_cols)

2.4 math.isnan() :纯 Python 的轻量级方案,但仅限单值

math.isnan() 是 Python 标准库函数,它和 np.isnan() 一样,只接受 float 类型参数。但它有一个关键优势: 不依赖 NumPy,启动快,内存占用极小 。在嵌入式环境、CLI 工具或需要最小依赖的脚本中,它是首选。

import math

# 安全检查:先确保是 float
def safe_math_isnan(x):
    if isinstance(x, float):
        return math.isnan(x)
    elif isinstance(x, (int, str)):
        try:
            return math.isnan(float(x))
        except (ValueError, TypeError):
            return False
    else:
        return False

# 测试
print(safe_math_isnan(np.nan))      # True
print(safe_math_isnan('nan'))       # True(因为 float('nan') 成功)
print(safe_math_isnan('hello'))     # False(捕获 ValueError)
print(safe_math_isnan(None))        # False(isinstance 失败)

但请注意: math.isnan() np.float64 会报错,因为它严格要求 float 类型(Python 内置类型),而 np.float64 是 NumPy 自定义类型:

x = np.float64('nan')
print(type(x))           # <class 'numpy.float64'>
print(math.isnan(x))     # TypeError: must be real number, not numpy.float64
print(np.isnan(x))       # True(正确)

所以 math.isnan() 的适用场景非常明确: 你在写一个不引入 NumPy 的纯 Python 脚本,且输入确定是 float 或可安全转为 float 的字符串。 其他情况,一律用 pd.isna()

3. 实操全流程:从读取数据到生成缺失报告,一套可复用的工业级方案

3.1 第一步:数据加载阶段的缺失预埋(防患于未然)

90% 的后续清洗痛苦,源于加载时的草率。 pandas.read_csv() 有四个关键参数,能让你在源头就控制缺失语义:

import pandas as pd

# 示例:一份真实的销售数据 CSV,包含多种缺失形态
# sales_data.csv 内容:
# product_id,sales,region,notes
# P001,100.0,North,"Good"
# P002,,South,"N/A"
# P003,200.0,, 
# P004,,"",Out of stock

# ❌ 危险加载(默认行为)
df_bad = pd.read_csv('sales_data.csv')
print("默认加载 - region 列类型:", df_bad['region'].dtype)  # object
print("默认加载 - sales 列缺失:", df_bad['sales'].isna().sum())  # 2(P002 和 P004 的空格被转为 NaN)

# ✅ 推荐加载(显式声明缺失标识)
df_good = pd.read_csv(
    'sales_data.csv',
    na_values=['', 'N/A', 'NULL', 'Out of stock'],  # 显式声明哪些字符串算缺失
    keep_default_na=True,  # 保留默认的 ['', '#N/A', 'NULL'...],与自定义合并
    dtype={'product_id': 'string'}  # 强制 string 类型,避免数字 ID 被转为 float
)

print("\n优化加载 - region 列缺失:", df_good['region'].isna().sum())  # 1(P003 的空字符串被识别)
print("优化加载 - notes 列缺失:", df_good['notes'].isna().sum())      # 1('Out of stock' 被识别)
print("优化加载 - sales 列缺失:", df_good['sales'].isna().sum())      # 2(同上,但更可控)

关键参数解析:

  • na_values : 接收列表或字典( {'column_name': ['val1', 'val2']} ),定义业务语义上的缺失值。
  • keep_default_na : 设为 False 可禁用所有默认缺失标识,完全由你控制。
  • dtype : 对 object 列指定 string 类型(Pandas 1.3+),避免混合类型导致的 object 列无法向量化操作。
  • converters : 对特定列用函数预处理,例如 {'notes': lambda x: x.strip().upper() if isinstance(x, str) else x}

实操心得:我维护的每个 ETL 项目, read_csv() 调用都封装在一个 load_data() 函数里, na_values 参数从 YAML 配置文件读取,确保不同数据源的缺失规则可配置、可审计。

3.2 第二步:缺失探查与可视化(用代码代替肉眼)

光看 df.isna().sum() 是低效的。我们需要结构化报告:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

def generate_missing_report(df, title="Missing Data Report"):
    """生成完整的缺失数据探查报告"""
    
    # 1. 基础统计
    total_cells = np.product(df.shape)
    total_missing = df.isna().sum().sum()
    missing_percentage = (total_missing / total_cells) * 100
    
    print(f"=== {title} ===")
    print(f"数据形状: {df.shape}")
    print(f"总单元格数: {total_cells}")
    print(f"缺失单元格数: {total_missing} ({missing_percentage:.2f}%)")
    
    # 2. 按列详细统计
    missing_stats = pd.DataFrame({
        'missing_count': df.isna().sum(),
        'missing_pct': df.isna().mean() * 100,
        'dtype': df.dtypes,
        'unique_count': df.nunique(dropna=False),  # 包含 NaN 的去重计数
        'sample_values': df.apply(lambda x: x.dropna().head(3).tolist() if len(x.dropna()) > 0 else ['<all missing>'])
    }).sort_values('missing_pct', ascending=False)
    
    print("\n--- 按列缺失详情(降序)---")
    print(missing_stats.round(2))
    
    # 3. 可视化:缺失矩阵图(热力图)
    plt.figure(figsize=(12, 6))
    sns.heatmap(df.isna(), cbar=False, yticklabels=False, cmap='viridis_r')
    plt.title(f'{title}: Missing Value Matrix')
    plt.xlabel('Columns')
    plt.ylabel('Rows')
    plt.tight_layout()
    plt.show()
    
    # 4. 关联分析:缺失是否集中在某些行?
    rows_with_missing = df.isna().any(axis=1).sum()
    print(f"\n--- 行级分析 ---")
    print(f"含缺失的行数: {rows_with_missing} ({rows_with_missing/len(df)*100:.2f}%)")
    
    # 5. 组合缺失模式(找规律)
    if len(df.columns) <= 10:  # 列数太多时跳过,避免组合爆炸
        from itertools import combinations
        patterns = {}
        for r in range(2, min(4, len(df.columns)+1)):  # 检查 2-3 列组合
            for combo in combinations(df.columns, r):
                mask = df[list(combo)].isna().all(axis=1)
                if mask.sum() > 0:
                    patterns[str(combo)] = mask.sum()
        
        if patterns:
            print("\n--- 常见缺失组合模式(出现频次 > 0)---")
            for pattern, count in sorted(patterns.items(), key=lambda x: x[1], reverse=True)[:5]:
                print(f"{pattern}: {count} 行")

# 使用示例
df_sample = pd.DataFrame({
    'A': [1, 2, np.nan, 4],
    'B': [np.nan, 2, np.nan, 4],
    'C': [1, np.nan, 3, np.nan],
    'D': ['x', 'y', 'z', 'w']
})

generate_missing_report(df_sample, "Sample Dataset")

这个报告函数输出五层信息:全局概览、列级明细、可视化热力图、行级覆盖、组合模式。其中“组合模式”能发现业务逻辑漏洞 —— 比如 customer_id order_date 总是一起缺失,说明可能是测试数据或爬虫注入。

3.3 第三步:缺失归因与分类(决定如何处理)

不是所有缺失都该被填充或删除。我按业务影响将缺失分为四类:

类型 特征 处理建议 实操代码示例
随机缺失 (MCAR) 缺失与任何变量无关,纯随机 可安全删除或均值填充 df.dropna(thresh=len(df)*0.8) (保留至少80%非空的行)
机制缺失 (MAR) 缺失与观测到的其他变量相关(如:高收入用户更不愿填年龄) 用回归/插补模型预测填充 from sklearn.impute import KNNImputer; imputer = KNNImputer(n_neighbors=5)
本质缺失 (MNAR) 缺失与自身值相关(如:病情越重,越不愿填症状) 必须建模为新特征(如: age_missing_flag = df['age'].isna().astype(int) df['sales_missing_flag'] = df['sales'].isna().astype(int)
语义缺失 业务上明确的占位符(如: status='pending' completion_date 必为空) 用业务规则填充,而非统计方法 df.loc[df['status']=='pending', 'completion_date'] = pd.NaT
# 示例:自动归因缺失类型(简化版)
def diagnose_missing_type(df, target_col, observed_cols=None):
    """
    简易缺失机制诊断
    target_col: 待诊断的列名
    observed_cols: 用于判断 MAR 的其他列(默认为除 target_col 外的所有列)
    """
    if observed_cols is None:
        observed_cols = [c for c in df.columns if c != target_col]
    
    # 1. 检查是否 MCAR:缺失是否与任何观测列无关?
    missing_mask = df[target_col].isna()
    if len(observed_cols) == 0:
        return "MCAR (no observed variables)"
    
    # 计算每列与缺失掩码的相关性(分类列用卡方,数值列用 point-biserial)
    correlations = {}
    for col in observed_cols:
        if pd.api.types.is_numeric_dtype(df[col]):
            # 数值列:计算 point-biserial 相关系数
            from scipy.stats import pointbiserialr
            corr, _ = pointbiserialr(missing_mask.astype(int), df[col].dropna())
            correlations[col] = abs(corr)
        else:
            # 分类列:计算卡方检验 p 值(越小越相关)
            from scipy.stats import chi2_contingency
            contingency = pd.crosstab(missing_mask, df[col])
            _, p, _, _ = chi2_contingency(contingency)
            correlations[col] = 1 - p  # 转换为“相关强度”,便于排序
    
    # 找出最强相关列
    if correlations:
        strongest_col = max(correlations, key=correlations.get)
        if correlations[strongest_col] > 0.3:
            return f"Likely MAR (driven by {strongest_col})"
    
    return "MCAR or MNAR (requires domain knowledge)"

# 使用
print(diagnose_missing_type(df_sample, 'A'))

3.4 第四步:工业级缺失处理流水线(可部署代码)

以下是一个生产环境使用的缺失处理类,已通过 pytest 覆盖:

import pandas as pd
import numpy as np
from typing import Dict, List, Optional, Union, Callable

class MissingDataHandler:
    """工业级缺失数据处理器"""
    
    def __init__(self, strategy_config: Dict[str, Dict]):
        """
        初始化处理器
        strategy_config 示例:
        {
            'sales': {'method': 'mean', 'groupby': ['region']},
            'region': {'method': 'mode', 'fill_value': 'Unknown'},
            'notes': {'method': 'constant', 'fill_value': 'No notes'}
        }
        """
        self.config = strategy_config
    
    def fit(self, df: pd.DataFrame) -> 'MissingDataHandler':
        """拟合填充参数(如均值、众数)"""
        self.stats_ = {}
        for col, config in self.config.items():
            if col not in df.columns:
                continue
                
            if config['method'] == 'mean':
                if config.get('groupby'):
                    grouped = df.groupby(config['groupby'])[col]
                    self.stats_[col] = grouped.mean()
                else:
                    self.stats_[col] = df[col].mean()
                    
            elif config['method'] == 'mode':
                if config.get('groupby'):
                    grouped = df.groupby(config['groupby'])[col]
                    self.stats_[col] = grouped.apply(lambda x: x.mode().iloc[0] if not x.mode().empty else None)
                else:
                    mode_val = df[col].mode()
                    self.stats_[col] = mode_val.iloc[0] if not mode_val.empty else None
                    
        return self
    
    def transform(self, df: pd.DataFrame) -> pd.DataFrame:
        """应用填充"""
        df_filled = df.copy()
        
        for col, config in self.config.items():
            if col not in df_filled.columns:
                continue
                
            mask = df_filled[col].isna()
            if not mask.any():
                continue
                
            if config['method'] == 'mean':
                if config.get('groupby'):
                    # 按组填充
                    for idx, row in df_filled[mask].iterrows():
                        group_key = tuple(row[config['groupby']])
                        fill_val = self.stats_.get(col, {}).get(group_key, np.nan)
                        df_filled.loc[idx, col] = fill_val if pd.notna(fill_val) else 0
                else:
                    df_filled.loc[mask, col] = self.stats_.get(col, 0)
                    
            elif config['method'] == 'mode':
                fill_val = self.stats_.get(col, 'Unknown')
                df_filled.loc[mask, col] = fill_val
                
            elif config['method'] == 'constant':
                df_filled.loc[mask, col] = config.get('fill_value', 'MISSING')
                
        return df_filled
    
    def fit_transform(self, df: pd.DataFrame) -> pd.DataFrame:
        return self.fit(df).transform(df)

# 使用示例
df_real = pd.DataFrame({
    'sales': [100, 200, np.nan, 150, np.nan, 300],
    'region': ['North', 'North', 'South', 'South', 'North', 'South'],
    'category': ['A', 'B', np.nan, 'A', 'B', 'A']
})

handler = MissingDataHandler({
    'sales': {'method': 'mean', 'groupby': ['region']},
    'category': {'method': 'mode'}
})

df_clean = handler.fit_transform(df_real)
print(df_clean)

这个类的核心价值在于: 分离了“学习填充规则”和“应用规则”两个阶段 ,完美适配训练/预测分离的机器学习 pipeline。 fit() 在训练集上学习均值/众数, transform() 在测试集或新数据上应用,避免数据泄露。

4. 深度避坑指南:那些文档不会写的血泪教训

4.1 None vs np.nan :内存、序列化、比较的三重幻觉

这是最常被误解的概念。表面上 pd.isna(None) pd.isna(np.nan) 都返回 True ,但它们在底层是截然不同的存在:

import pandas as pd
import numpy as np
import json

# 1. 内存表现
none_val = None
nan_val = np.nan

print("None 的内存地址:", id(none_val))      # 固定,所有 None 共享同一内存
print("np.nan 的内存地址:", id(nan_val))    # 每次创建新对象

# 2. JSON 序列化
try:
    json.dumps({'val': none_val})   # ✅ 成功,转为 null
except Exception as e:
    print("None JSON:", e)

try:
    json.dumps({'val': nan_val})    # ❌ 失败!JSON 不支持 NaN
except Exception as e:
    print("np.nan JSON:", type(e).__name__)  # TypeError

# 3. 比较行为(最危险!)
print("None == None:", None == None)           # True
print("np.nan == np.nan:", np.nan == np.nan)   # False!IEEE 754 规定
print("pd.isna(np.nan):", pd.isna(np.nan))     # True(正确方式)
print("np.nan is np.nan:", np.nan is np.nan)   # True(同一对象)

致命陷阱 :用 == 比较 NaN 会永远返回 False ,导致条件判断失效:

# ❌ 危险代码
if my_value == np.nan:  # 永远不成立!
    handle_missing()

# ✅ 正确代码
if pd.isna(my_value):   # 安全,覆盖 None 和 np.nan
    handle_missing()

4.2 pd.isna() 的隐藏参数: pd.options.mode.use_inf_as_na

Pandas 有一个全局开关,能改变 isna() 的行为:

import pandas as pd
import numpy as np

# 默认行为:inf 不被视为缺失
print(pd.isna(np.inf))  # False

# 开启后:inf 和 -inf 也被视为缺失
pd.set_option('mode.use_inf_as_na', True)
print(pd.isna(np.inf))  # True

# ⚠️ 警告:此选项会影响所有后续 isna() 调用,包括第三方库!
# 生产环境强烈建议显式控制,而非全局修改
pd.reset_option('mode.use_inf_as_na')  # 恢复

这个选项在金融风控场景很有用(无穷大收益率明显异常),但会破坏与其他库的兼容性。我的建议是:在特定函数内临时设置:

def check_financial_anomaly(series):
    original_setting = pd.get_option('mode.use_inf_as_na')
    try:
        pd.set_option('mode.use_inf_as_na', True)
        return series.isna().sum() > 0
    finally:
        pd.set_option('mode.use_inf_as_na', original_setting)

4.3 混合缺失检测协议:应对真实世界的混沌

最后分享一个我压箱底的工具函数,它能一次性检测所有常见缺失形态:

import re
import pandas as pd
import numpy as np

def comprehensive_isna(x, custom_na_strings: List[str] = None) -> bool:
    """
    综合缺失检测协议
    检测:np.nan, None, '', ' ', 'N/A', 'NULL', 'NaN', 'nan', 'None', 'null'
    """
    if custom_na_strings is None:
        custom_na_strings = ['N/A', 'NULL', 'NaN', 'nan', 'None', 'null', 'nil', 'tbd', 'TBD']
    
    # 1. 原生检测
    if pd.isna(x):
        return True
    
    # 2. 字符串标准化检测
    if isinstance(x, str):
        stripped = x.strip()
        if stripped == '':
            return True
        if stripped.lower() in [s.lower() for s in custom_na_strings]:
            return True
    
    # 3. 数值边界检测(可选:极小/极大值视为异常缺失)
    if isinstance(x, (int, float, np.number)):
        if np.isinf(x):
            return True
        if abs(x) < 1e-15 and x != 0:  # 接近零的浮点数(有时是计算误差)
            return True
    
    return False

# 向量化版本(用于 Series)
def vectorized_comprehensive_isna(series: pd.Series, custom_na_strings=None) -> pd.Series:
    return series.apply(lambda x: comprehensive_isna(x, custom_na_strings))

# 测试
test_values = [np.nan, None, '', '   ', 'N/A', '123', 0, 1e-20, np.inf]
for v in test_values:
    print(f"{repr(v):>12} -> {comprehensive_isna(v)}")

这个函数是我处理政府开放数据、医疗问卷、电商评论时的标配。它把 pd.isna() 作为第一道防线,再用字符串规则兜底,最后加一层数值异常检测。你可以根据业务需求调整 custom_na_strings 列表。

5. 常见问题速查表与终极建议

问题现象 根本原因 解决方案 我的实操备注
df.isna().sum() 显示某列有缺失,但 df[col].unique() 里看不到 nan 该列是 object 类型,缺失被存储为字符串 'nan' 'None' ,而非 np.nan df[col] = df[col].replace({'nan': np.nan, 'None': np.nan}) 清洗,或加载时用 na_values 我在所有数据接入点加了 preprocess_object_columns() 钩子函数
pd.isna() 对某列返回全 False ,但直觉觉得有缺失 该列是 int 类型,Pandas 用 pd.NA (pandas 1.0+)或 None (旧版)表示缺失,但 int 列不能存 None ,所以被转为 float 并用 np.nan 存储 —— 但你的列可能被强制设为 Int64 (nullable integer) 检查 df[col].dtype ,若为 Int64 ,用 df[col].isna() ;若为 int64 ,缺失值不可能存在(除非你用了 pd.NA Int64 是 Pandas 的杀手级特性,我所有新项目默认用 dtype={'id': 'Int64'}
groupby().agg() 中,含缺失的组被自动丢弃 agg() 默认 skipna=True ,但某些函数(如 first() )对缺失行为不一致 显式指定 skipna 参数,或用 groupby(..., dropna=False) 保留缺失组 dropna=False 是救命参数,尤其在做分组统计时
json.dumps(df.to_dict()) 报错 Object of type float32 is not JSON serializable np.float32 不能被 JSON 序列化,而 df.isna() 返回的布尔数组是 np.bool_ ,某些旧版 Pandas 会触发此错误 在序列化前,用 df.replace({np.nan: None}).to_dict() ,或用 df.astype(object).where(pd.notna(df), None).to_dict() 我的 api_response() 函数第一行永远是 df = df.replace({np.nan: None})
pd.isna() 在大型 DataFrame 上很慢 isna() object 列需逐元素 Python 层判断,无法向量化 object 列转换为 string 类型(Pandas 1.3+),或对高频列预计算 isna_mask = df[col].isna() 缓存 df.astype({'col': 'string'}) 是性能优化核弹

最后分享一个小技巧: 永远在 Jupyter Notebook 的第一个 cell 里运行这段代码

# 全局缺失检测快捷键
def show_missing(df, top_n=10):
    """快速显示缺失最多的 top_n 列"""
    missing = df.isna().sum().sort_values(ascending=False)
    display(missing.head(top_n))
    return missing

# 绑定到变量
_ = show_missing  # 这样输入 `_` 就能快速调用

这个习惯让我在每次打开新数据集时,3 秒内就能掌握缺失全景。数据清洗不是苦力活,而是侦探工作 —— 你得先看清敌人长什么样,才能制定战术。而 isna() ,只是你手中的第一把匕首,不是万能钥匙。

我在实际使用中发现,真正决定数据质量的,从来不是用了多少高级插补算法,而是**在数据加载那一刻,你有没有勇气写下那行 na_values=['', 'N/A', 'NULL']

更多推荐