
1. 从一次数据清洗的“灵异事件”说起最近在帮一个做电商数据分析的朋友处理一份用户订单数据遇到了一个让我排查了半个下午的“灵异事件”。数据里有个“优惠券使用金额”字段朋友想筛选出所有未使用优惠券的订单也就是金额为0的记录。他写了个简单的过滤条件df[df[‘coupon_amount’] 0]结果跑出来的数据量远少于预期。他一度怀疑是数据库导出出了问题或者业务逻辑有隐藏规则。我接手后第一反应就是检查这个字段里的“空值”。果不其然用df[‘coupon_amount’].isna().sum()一查发现存在大量NaNNot a Number。在 pandas 里NaN 0这个比较会返回False甚至NaN NaN也返回False。这就导致那些真正是NaN代表未使用/未填写的记录没有被 0这个条件捕捉到从而“神秘失踪”了。这个看似简单的问题恰恰是许多 pandas 初学者甚至有一定经验的数据工作者最容易踩的坑之一混淆了NaN与None以及它们与各种运算、比较的微妙关系。NaN和None在 Python 和 pandas 的语境下都常被称作“空值”或“缺失值”但它们从血统、行为到处理方式都截然不同。理解它们的差异不是死记硬背几个知识点而是掌握 pandas 数据处理的底层逻辑之一。这直接关系到数据过滤、聚合计算、合并操作乃至机器学习特征工程的准确性。一个NaN可能让整列数据的平均值变成NaN一次不当的填充可能引入严重的统计偏差。今天我们就来彻底拆解这对“双胞胎”让你在数据战场上不再被它们迷惑。2. 本源探究NaN与None的“出身”与性格要处理好它们首先得知道它们是谁从哪来。2.1 NonePython世界的“空”对象None是 Python 内置的一个单例对象属于NoneType类型。它的核心含义就是“什么都没有”、“空引用”或“未定义”。在纯 Python 的列表或字典中我们常用None来表示缺失。# 纯Python中的None pure_list [1, 2, None, 4] pure_dict {‘name‘: ‘Alice‘, ‘age‘: None}None的行为比较“单纯”类型单一它就是NoneType。布尔值为False在布尔上下文中None等价于False。比较确定None None会返回True。这是一个非常关键的特性因为它意味着None是“自反”的你可以确定地判断一个值是不是None。2.2 NaN数值计算领域的“非数”NaN的出身则带有强烈的科学计算背景。它源自 IEEE 754 浮点数标准全称是“Not a Number”非数。它不是 Python 独有的在 NumPy、pandas 乃至其他科学计算库中都是核心概念。NaN用于表示那些未定义的或无法表示的数字运算结果比如0/0、∞ - ∞或者对负数开平方。在 pandas 中NaN本质上是一个特殊的浮点数float。import pandas as pd import numpy as np # NaN在pandas中默认是float类型 s pd.Series([1.0, np.nan, 3.0]) print(s.dtype) # 输出float64NaN的性格非常“古怪”这也是它难以驾驭的根源类型为浮点数尽管代表“非数”但它的数据类型是float。这意味着一旦一列数据中混入一个NaN整列的数据类型很可能被向上转换为float64即使其他都是整数。布尔值在布尔上下文中试图将NaN作为条件会引发错误。bool(np.nan)会抛出ValueError。比较的传染性与非自反性这是NaN最著名的特性。任何与NaN的比较操作,!,,等几乎都返回False包括np.nan np.nan。它像病毒一样在计算中传播np.nan 1的结果依然是np.nan。判断一个值是否为NaN必须使用专门的函数如pd.isna()或np.isnan()。2.3 pandas中的相遇与强制转换当None进入 pandas 的领地如 DataFrame 或 Series时事情就变得有趣了。pandas 出于对数值计算效率和一致性的考虑会倾向于将None转换为NaN。df pd.DataFrame({‘A‘: [1, 2, None, 4]}) print(df) # A # 0 1.0 # 1 2.0 # 2 NaN # None被自动转换成了NaN # 3 4.0 print(df.dtypes) # A float64 # 数据类型变成了float64这个自动转换是很多混淆的源头。你明明在代码里写了None但查看数据时却看到了NaN。然而这种转换并非绝对。在object类型可以存储任意Python对象的列中None可能会被保留。df[‘B‘] [‘x‘, ‘y‘, None, ‘z‘] # B列是object类型 print(df.loc[2, ‘B‘]) # 输出None所以第一条核心经验来了在涉及数值运算的列中请默认你面对的空值是NaN在纯字符串或混合类型的object列中则需要同时警惕None和NaN。最稳妥的做法是统一使用pd.isna()来检测缺失值它能同时识别NaN和None。3. 实战场景下的行为差异与关键陷阱理解了出身我们来看看它们在具体操作中是如何“捣乱”的。这里有几个高频陷阱。3.1 陷阱一比较运算的“沉默失败”开头的案例就是典型。在筛选数据时s pd.Series([0, 1, np.nan, 3, None]) # 尝试筛选值为0的条目 print(s 0) # 0 True # 1 False # 2 False # np.nan 0 为 False # 3 False # None 0 为 False (在Series中None已转为NaN) # 4 False # dtype: bool # 正确的缺失值筛选方式 print(s.isna()) # 0 False # 1 False # 2 True # 识别出NaN # 3 True # 识别出None (在Series中已转为NaN) # 4 False # dtype: bool教训永远不要用或!来直接判断缺失值。对于筛选应使用df[df[‘col‘].isna()]或df[df[‘col‘].notna()]。对于等于某个具体值如0的筛选如果需要包含缺失值通常需要结合条件df[(df[‘col‘].isna()) | (df[‘col‘] 0)]。3.2 陷阱二聚合计算中的“一票否决”NaN在数学运算中具有传染性。这在进行统计描述时影响巨大。s pd.Series([1, 2, np.nan, 4, 5]) print(s.mean()) # 输出nan print(s.sum()) # 输出nan一次聚合操作因为一个NaN的存在导致整个结果变为NaN。许多聚合方法提供了skipna参数来控制这种行为print(s.mean(skipnaTrue)) # 输出3.0 忽略了NaN计算(1245)/4 print(s.sum(skipnaFalse)) # 输出nan经验在调用mean(),sum(),std()等方法前务必清楚你的数据中是否有NaN并明确skipna参数的设置默认为True。对于None在数值列中它已被转为NaN行为一致。在object列中包含None的聚合可能失败或产生意外结果。3.3 陷阱三分组groupby与数据透视pivot的差异在分组操作中NaN和None通常会被作为一个独立的分组键来处理但有时又会被忽略这取决于具体操作。df pd.DataFrame({ ‘Key‘: [‘A‘, ‘A‘, np.nan, ‘B‘, ‘B‘], ‘Value‘: [10, 20, 30, 40, 50] }) # 按Key分组求和NaN自成一组 grouped_sum df.groupby(‘Key‘, dropnaFalse).sum() # 显式设置dropnaFalse以保留NaN组 print(grouped_sum) # Value # Key # A 30 # B 90 # NaN 30 # 默认情况下dropnaTrueNaN组会被丢弃 grouped_sum_default df.groupby(‘Key‘).sum() print(grouped_sum_default) # Value # Key # A 30 # B 90关键点从 pandas 1.1 版本开始groupby的默认行为是dropnaTrue即忽略以NaN为键的分组。如果你需要保留它们进行分析例如将缺失本身作为一个有意义的类别必须显式设置dropnaFalse。对于pivot_table也有类似的dropna参数需要注意。3.4 陷阱四排序sort与去重drop_duplicates的边界情况排序时NaN默认会被排在末尾无论升序降序。s pd.Series([3, 1, np.nan, 2]) print(s.sort_values()) # 1 1.0 # 3 2.0 # 0 3.0 # 2 NaN # NaN在最后 # dtype: float64去重时一个微妙之处在于NaN是否被视为彼此相等在 pandas 中默认情况下NaN不被认为彼此相等因此它们不会被去重。s pd.Series([1, np.nan, 1, np.nan, 2]) print(s.drop_duplicates()) # 0 1.0 # 1 NaN # 第一个NaN被保留 # 2 1.0 # 重复的1被去除 # 3 NaN # 第二个NaN也被保留因为NaN ! NaN # 4 2.0 # dtype: float64如果你希望将所有NaN视为相同的缺失值并进行去重目前没有直接参数一个常见的做法是先填充一个唯一标记值去重后再换回来或者更简单地在布尔索引中结合使用。4. 系统化处理缺失值的工具箱知道了陷阱我们就要掌握武器。处理NaN/None不是简单粗暴地删除或填充而是一个需要根据业务逻辑进行决策的过程。4.1 检测与定位你的第一道防线isna()/isnull()两者完全等价返回一个布尔掩码指示哪些元素是缺失值NaN或None。notna()/notnull()与上面相反指示非缺失值。info()查看 DataFrame 的概览其中会显示每列的非空值数量快速估算缺失率。describe()对于数值列描述性统计会忽略NaN但观察计数count也能看出缺失情况。df pd.DataFrame({‘a‘: [1, np.nan, 3], ‘b‘: [‘x‘, None, ‘z‘]}) print(df.isna()) # a b # 0 False False # 1 True True # 2 False False print(df.info()) # class ‘pandas.core.frame.DataFrame‘ # RangeIndex: 3 entries, 0 to 2 # Data columns (total 2 columns): # # Column Non-Null Count Dtype # --- ------ -------------- ----- # 0 a 2 non-null float64 # 1 b 2 non-null object # dtypes: float64(1), object(1)4.2 删除缺失值简单直接但可能代价高昂dropna方法用于删除含有缺失值的行或列。# 默认删除任何包含缺失值的行 df_dropped_rows df.dropna() # 删除任何包含缺失值的列 df_dropped_cols df.dropna(axis1) # 只删除在特定列如‘a‘上有缺失值的行 df_dropped_subset df.dropna(subset[‘a‘])决策点删除数据是最简单的方法但可能导致信息大量丢失特别是在缺失率较高或数据集本身不大的情况下。仅当缺失行是随机且数量很少时删除才是相对安全的选择。务必在删除后评估剩余数据量是否仍能满足分析需求。4.3 填充缺失值用智慧弥补空白fillna方法是更常用、也更需要业务知识的方法。固定值填充用某个常数值填充。# 用0填充所有缺失值 df_filled_0 df.fillna(0) # 用字符串‘missing‘填充 df_filled_str df.fillna(‘missing‘)前向填充ffill或后向填充bfill适用于时间序列或有序数据用前一个或后一个有效值填充。df_time pd.DataFrame({‘value‘: [1, np.nan, np.nan, 4, 5]}, indexpd.date_range(‘2023-01-01‘, periods5)) df_ffill df_time.fillna(method‘ffill‘) # 向前填充 # 2023-01-01 1.0 # 2023-01-02 1.0 # 2023-01-03 1.0 # 2023-01-04 4.0 # 2023-01-05 5.0统计值填充用均值、中位数、众数等填充。这是处理数值型数据缺失的常见方法。mean_val df[‘a‘].mean() # 计算a列非缺失值的均值 df[‘a‘].fillna(mean_val, inplaceTrue) # 原地填充 # 对于分类数据可以用众数 mode_val df[‘b‘].mode()[0] # 取第一个众数 df[‘b‘].fillna(mode_val, inplaceTrue)插值法interpolate()方法提供了更复杂的填充方式如线性插值、多项式插值等特别适合时间序列。df_time[‘value‘].interpolate(method‘linear‘) # 线性插值核心建议填充不是魔法它会改变数据的分布。用均值填充会削弱变量的方差用前值填充可能引入自相关性。在填充前务必思考这个值为什么缺失是随机缺失还是系统缺失填充后的数据会如何影响下游的模型或分析结论对于关键指标有时保留缺失状态作为一个特殊类别比盲目填充更有意义。4.4 高级策略预测与多重插补对于复杂场景简单的填充可能不够基于模型的预测填充使用其他完整的特征列通过回归、KNN等模型来预测缺失值。sklearn的SimpleImputer或KNNImputer可以方便地实现。多重插补这是一种更严谨的统计方法它承认填充值的不确定性通过生成多个填充数据集进行分析最后合并结果。Python 的statsmodels或fancyimpute库支持此类方法。这些方法计算成本更高但当缺失数据机制复杂且数据价值高时它们是更优的选择。5. 类型安全与性能考量5.1 数据类型dtype的隐形杀手如前所述NaN是浮点数。这意味着一个原本是整数的列一旦引入一个NaN其类型就会被迫提升为float。这不仅占用更多内存int64变float64也可能导致一些基于整数类型的操作或期望失效。s_int pd.Series([1, 2, 3], dtype‘int32‘) s_int[1] np.nan print(s_int.dtype) # 输出float64解决方案如果可能尽量使用 pandas 提供的可空整数类型Int32、Int64注意首字母大写。它们可以同时存储整数和NaN。s_nullable_int pd.Series([1, 2, 3], dtype‘Int32‘) s_nullable_int[1] pd.NA # 使用pandas的NA见下文 print(s_nullable_int.dtype) # 输出Int32 print(s_nullable_int) # 0 1 # 1 NA # 不是NaN # 2 3 # dtype: Int325.2 性能陷阱对缺失值列进行循环在 pandas 中任何对列的循环操作如apply配合自定义函数都是性能瓶颈。当函数需要处理NaN时必须在函数内部进行判断否则容易出错。# 低效且易错的写法 def risky_calc(x): return x * 2 # 如果x是NaN结果还是NaN但可能不是我们想要的 df[‘new‘] df[‘a‘].apply(risky_calc) # 更稳健的写法 def safe_calc(x): if pd.isna(x): return np.nan # 或某个默认值 return x * 2 df[‘new‘] df[‘a‘].apply(safe_calc)最佳实践优先使用 pandas 或 NumPy 的向量化操作。它们内置了对NaN的处理速度快且安全。# 向量化操作速度快自动处理NaN df[‘new_vectorized‘] df[‘a‘] * 25.3 新选择pd.NA的统一缺失值符号从 pandas 1.0 开始引入了一个新的缺失值标量pd.NA意图为所有数据类型提供一个统一的缺失值表示。它的行为更像NaN传播性但旨在更好地支持可空数据类型。s_new pd.Series([1, pd.NA, 3], dtype‘Int64‘) print(s_new.isna()) # 可以检测到pd.NA目前pd.NA仍在发展中其行为可能与NaN在某些边缘情况有细微差别。在现有代码中pd.isna()可以同时处理NaN、None和pd.NA因此依然是检测缺失值的首选函数。6. 贯穿始终的检查清单与心法处理完数据并不意味着结束。建立一套验证和记录的心法至关重要。导入数据后立即检查缺失使用df.info()和df.isna().sum()快速扫描对数据质量有个整体把握。记录各列的缺失率。理解缺失机制与业务方沟通了解每个字段缺失的可能原因数据未采集、采集失败、不适用等。这直接决定处理策略。处理策略文档化对于每一个有缺失的字段明确记录你采取了哪种处理方式删除、填充、插值以及选择的参数填充值、插值方法。这有助于结果复现和审查。处理前后分布对比对于填充操作务必比较该字段在处理前后的统计分布均值、标准差、直方图。如果分布发生剧烈变化需要重新评估填充方法的合理性。下游操作兼容性检查在处理后运行一遍你计划中的核心分析或模型训练流程确认没有因缺失值处理引入新的错误例如类型错误、意外的NaN传播。回到我朋友的那个案例最终的解决方案是首先用fillna(0)将“优惠券金额”字段的缺失值明确填充为0因为业务上“缺失”就等价于“未使用”即金额为0。然后再进行后续的筛选和分析。这个处理逻辑被记录在了分析文档的开头。数据工作里没有灵异事件只有对细节的疏忽和对工具理解的偏差。把NaN和None这点事搞明白你的数据清洗之路就少了一大块绊脚石。下次当你看到数据中那些空白格时你看到的将不再是一个问题而是一个需要你根据业务上下文做出明智决策的机会。