Pandas 缺失值最佳实践:用 pd.NA 解决缺失值的老大难问题

2026-07-17 15:45:22

点击上方“Deephub Imba”,关注公众号,好文章不错过 !

做数据处理的都知道,一个 NaN 就能让整个数据清洗流程崩盘。过滤条件失效、join 结果错乱、列类型莫名其妙变成 object——这些坑踩过的人应该都有所体会。而Pandas 引入的可空数据类型(nullable dtypes)就是来帮我们填这个坑的。

现在整数列终于能表示缺失了,布尔列不会再退化成 object,字符串列的行为也更可控,这样我们代码的逻辑可以变得更清晰。

NumPy 整数类型的历史遗留问题早期NumPy 的 int 类型压根就不支持缺失值,只能选一个不太优雅的方案:要么转成 float 让 1, 2, NaN 变成 1.0, 2.0, NaN,要么直接用 object 类型塞 Python 的 None 进去。

前免得方法会带来浮点精度的麻烦和类型语义的混乱,而且还会站更多的内存,而后者直接把向量化计算的性能优势给废了。

Pandas 后来搞的可空数据类型(extension dtypes)用了另一套思路:单独维护一个 mask 来标记缺失位置。具体包括这几种:

Int64、Int32、UInt8 等:真正支持 pd.NA 的整数类型boolean:三值布尔,可以是 True、False 或 pd.NAstring:行为一致的文本类型,不会退化成 objectFloat64(nullable):用 pd.NA 替代 np.nan 的浮点类型这些类型统一用 pd.NA 表示缺失,不像以前 None、np.nan 混用,谁想怎么用就怎么用,没准自己都用不同的方法来表示缺失。

pd.NA 的三值逻辑pd.NA 遵循类似 SQL 的三值逻辑规则:

True & pd.NA 结果是 pd.NAFalse | pd.NA 结果还是 pd.NA任何值和 pd.NA 做相等判断都返回 pd.NA,不是 True 也不是 False这样设计的好处是把"未知"这个语义明确表达出来了。如果确实需要一个纯布尔 mask,用 fillna 转一下就行:

代码语言:javascript复制 import pandas as pd

s = pd.Series([True, pd.NA, False], dtype="boolean")

# mask is boolean + NA; many ops accept this.

mask = s & True # -> [True, , False]

# When you must force a pure boolean array (e.g., .loc):

final_mask = mask.fillna(False)所以我们现在尽量用 pd.NA,别再把 None 和 np.nan 混着用了,因为后者很容易让列类型退化成 object。

类型转换的基本操作转成可空类型很简单,逐列指定就可以:

代码语言:javascript复制 df = pd.DataFrame({

"user_id": [101, 102, None, 104],

"active": [1, None, 0, 1],

"email": ["a@x", None, "c@x", "d@x"]

})

df = df.astype({

"user_id": "Int64", # 不是 int64

"active": "boolean", # 不是 bool

"email": "string" # 不是 object

})转回 NumPy 类型也简单,不过要注意缺失值的处理逻辑会变:

代码语言:javascript复制 # Back to NumPy dtypes (careful: NA handling changes)

df["user_id_np"] = df["user_id"].astype("float64") # NA -> NaN实际场景:用户行为事件表假设有个 Web 埋点数据,session ID 和购买标记都可能缺失,这种稀疏数据用可空类型处理起来就清爽多了:

代码语言:javascript复制 events = pd.DataFrame({

"session_id": [123, 124, None, 126, 127, None],

"user_id": [10, 10, 11, 11, None, 13],

"purchased": [1, None, 0, 1, None, None],

"amount": [49, None, None, 99, None, None]

}).astype({

"session_id": "Int64",

"user_id": "Int64",

"purchased": "boolean",

"amount": "Int64"

})

# How many known sessions and confirmed purchases?

events.agg({

"session_id": "count", # ignores NA by default

"purchased": lambda s: s.fillna(False).sum()

})不需要将整数转为浮点数,也不需要拖累性能的 object 列,"NA"和"False"的区别也很明确。

过滤、分组和 join 的变化三值逻辑下,比较操作产生的 mask 里会包含 NA:

代码语言:javascript复制 # Three-valued logic: comparisons with NA yield NA in the mask

mask = (events["amount"] > 50) & events["purchased"] # -> boolean + NA

# Resolve NA explicitly for indexing:

filtered = events[mask.fillna(False)]关键是要明确业务语义,用 fillna(False) 或 fillna(True) 把规则写清楚。

分组计算

代码语言:javascript复制 # Average order amount per user, ignoring unknowns

order_stats = (events

.groupby("user_id", dropna=False)["amount"]

.mean()) # skipna=True by defaultdropna=False 会保留 user_id = 的分组,排查数据质量问题时挺有用。

Join 的语义和 SQL 一致:NA 不等于 NA。

代码语言:javascript复制 users = pd.DataFrame({

"user_id": [10, 11, 12],

"tier": ["gold", "silver", "bronze"]

}).astype({"user_id": "Int64", "tier": "string"})

joined = events.merge(users, on="user_id", how="left")user_id 是 的行不会匹配到任何记录。如果需要把缺失键当作特殊分组处理,merge 之前先 fillna 成哨兵值:

代码语言:javascript复制 E = events.assign(user_id=events["user_id"].fillna(-1))

U = users.assign(user_id=users["user_id"].fillna(-1))

joined_special = E.merge(U, on="user_id", how="left")string 和 boolean 类型的实用价值string 比 object 靠谱

类型一致,不会混进各种 Python 对象向量化的字符串方法行为更可预测缺失值统一用 pd.NA,不会是 np.nan 或 None代码语言:javascript复制 emails = events["user_id"].astype("string") # demo only

# Realistic:

customers = pd.Series(["a@x", None, "c@x"], dtype="string")

customers.str.contains("@").fillna(False)boolean 的三值语义三值逻辑更贴合实际数据流程,尤其适合表示可选的布尔标记。

代码语言:javascript复制 maybe = pd.Series([True, pd.NA, False], dtype="boolean")

(maybe.fillna(False) & True).sum() # treat unknown as FalseIO 操作和 Arrow 后端读取时可以直接指定可空类型:

代码语言:javascript复制 df = pd.read_csv(

"data.csv",

dtype={"user_id": "Int64", "active": "boolean", "email": "string"},

na_values=["", "NA", "null"] # map vendor missings to real NA

)文本数据量大或者对吞吐有要求的话,可以考虑 Arrow 后端。它的字符串存储更紧凑,某些操作也更快:

代码语言:javascript复制 # Example: opt-in when reading (availability depends on your pandas build)

df_arrow = pd.read_csv(

"data.csv",

dtype_backend="pyarrow" # uses Arrow dtypes where possible

)写 Parquet 时用可空类型能保持 schema 的完整性:

代码语言:javascript复制 df.to_parquet("events.parquet", index=False)性能和内存开销可空整数和布尔类型保持了向量化特性,所以性能不会差。虽然达不到纯 NumPy 的极限速度,但分析场景下完全够用。

每个可空列会额外维护一个 mask,每个值占 1 bit。这点开销换来的正确性和可读性,这是很值得的。并且Arrow 后端的字符串类型在大文本列上通常更省内存,速度也更稳定。

几个常见的坑1. 类型静默退化成 object

同一列里混用 None、np.nan 和实际值会导致类型变成 object,用 astype 转一下:

代码语言:javascript复制 df["col"] =df["col"].astype("Int64") # or boolean/string2. 布尔索引中的 NA

比较操作会产生 NA,记得明确处理:

代码语言:javascript复制 df[condition.fillna(False)]3. 缺失键的 join

NA != NA,如果要把缺失值当一个分组,merge 前先填充:

代码语言:javascript复制 events["user_id"].fillna(-1)4. 别用浮点数存缺失的整数

直接用 Int64 + pd.NA,别再搞什么 float 转换了。

5. CSV 往返类型变化

读 CSV 时一定要指定 dtype 或 dtype_backend,并且规范化 na_values。

用 Parquet 保持 schema 一致性;文本列多的话测试下 Arrow 后端

总结Pandas1.0引入的可空类型不只是修边角的细节优化,它把"缺失"这个语义明确编码进了类型系统。整数保持整数,布尔值该表示"未知"就表示"未知",字符串就是字符串。过滤和 join 的逻辑变得更清楚,也更不容易出错。

作者:Codastra

喜欢就关注一下吧:

点个 在看 你最好看!

视博士:你知道眼镜的保质期吗?(一)
墨西哥世界杯名单公布:希门尼斯身价1800万欧领衔