Stories

Detail Return Return

Pandas 缺失值最佳實踐:用 pd.NA 解決缺失值的老大難問題 - Stories Detail

做數據處理的都知道,一個 NaN 就能讓整個數據清洗流程崩盤。過濾條件失效、join 結果錯亂、列類型莫名其妙變成 object——這些坑踩過的人應該都有所體會。而Pandas 引入的可空數據類型(nullable dtypes)就是來幫我們填這個坑的。

現在整數列終於能表示缺失了,布爾列不會再退化成 object,字符串列的行為也更可控,這樣我們代碼的邏輯可以變得更清晰。

NumPy 整數類型的歷史遺留問題

早期NumPy 的 int 類型壓根就不支持缺失值,只能選一個不太優雅的方案:要麼轉成 float1, 2, NaN 變成 1.0, 2.0, NaN,要麼直接用 object 類型塞 Python 的 None 進去。

前免得方法會帶來浮點精度的麻煩和類型語義的混亂,而且還會站更多的內存,而後者直接把向量化計算的性能優勢給廢了。

Pandas 後來搞的可空數據類型(extension dtypes)用了另一套思路:單獨維護一個 mask 來標記缺失位置。具體包括這幾種:

  • Int64Int32UInt8 等:真正支持 pd.NA 的整數類型
  • boolean:三值布爾,可以是 TrueFalsepd.NA
  • string:行為一致的文本類型,不會退化成 object
  • Float64(nullable):用 pd.NA 替代 np.nan 的浮點類型

這些類型統一用 pd.NA 表示缺失,不像以前 Nonenp.nan 混用,誰想怎麼用就怎麼用,沒準自己都用不同的方法來表示缺失。

pd.NA 的三值邏輯

pd.NA 遵循類似 SQL 的三值邏輯規則:

  • True & pd.NA 結果是 pd.NA
  • False | pd.NA 結果還是 pd.NA
  • 任何值和 pd.NA 做相等判斷都返回 pd.NA,不是 True 也不是 False

這樣設計的好處是把"未知"這個語義明確表達出來了。如果確實需要一個純布爾 mask,用 fillna 轉一下就行:

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, <NA>, False]  

# When you must force a pure boolean array (e.g., .loc):
final_mask = mask.fillna(False)

所以我們現在儘量用 pd.NA,別再把 Nonenp.nan 混着用了,因為後者很容易讓列類型退化成 object

類型轉換的基本操作

轉成可空類型很簡單,逐列指定就可以:

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 類型也簡單,不過要注意缺失值的處理邏輯會變:

# Back to NumPy dtypes (careful: NA handling changes)
df["user_id_np"] = df["user_id"].astype("float64")  # NA -> NaN

實際場景:用户行為事件表

假設有個 Web 埋點數據,session ID 和購買標記都可能缺失,這種稀疏數據用可空類型處理起來就清爽多了:

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

# 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) 把規則寫清楚。

分組計算

# Average order amount per user, ignoring unknowns
order_stats = (events  
    .groupby("user_id", dropna=False)["amount"]  
    .mean())  # skipna=True by default

dropna=False 會保留 user_id = <NA> 的分組,排查數據質量問題時挺有用。

Join 的語義和 SQL 一致:NA 不等於 NA

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<NA> 的行不會匹配到任何記錄。如果需要把缺失鍵當作特殊分組處理,merge 之前先 fillna 成哨兵值:

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.nanNone
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 的三值語義

三值邏輯更貼合實際數據流程,尤其適合表示可選的布爾標記。

maybe = pd.Series([True, pd.NA, False], dtype="boolean")  
(maybe.fillna(False) & True).sum()  # treat unknown as False

IO 操作和 Arrow 後端

讀取時可以直接指定可空類型:

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 後端。它的字符串存儲更緊湊,某些操作也更快:

# 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 的完整性:

df.to_parquet("events.parquet", index=False)

性能和內存開銷

可空整數和布爾類型保持了向量化特性,所以性能不會差。雖然達不到純 NumPy 的極限速度,但分析場景下完全夠用。

每個可空列會額外維護一個 mask,每個值佔 1 bit。這點開銷換來的正確性和可讀性,這是很值得的。並且Arrow 後端的字符串類型在大文本列上通常更省內存,速度也更穩定。

幾個常見的坑

1. 類型靜默退化成 object

同一列裏混用 Nonenp.nan 和實際值會導致類型變成 object,用 astype 轉一下:

df["col"] = df["col"].astype("Int64")  # or boolean/string

2. 布爾索引中的 NA

比較操作會產生 NA,記得明確處理:

df[condition.fillna(False)]

3. 缺失鍵的 join

NA != NA,如果要把缺失值當一個分組,merge 前先填充:

events["user_id"].fillna(-1)

4. 別用浮點數存缺失的整數

直接用 Int64 + pd.NA,別再搞什麼 float 轉換了。

5. CSV 往返類型變化

讀 CSV 時一定要指定 dtypedtype_backend,並且規範化 na_values

用 Parquet 保持 schema 一致性;文本列多的話測試下 Arrow 後端

總結

Pandas1.0引入的可空類型不只是修邊角的細節優化,它把"缺失"這個語義明確編碼進了類型系統。整數保持整數,布爾值該表示"未知"就表示"未知",字符串就是字符串。過濾和 join 的邏輯變得更清楚,也更不容易出錯。

https://avoid.overfit.cn/post/d595b7b6ff9148bc8adb8b8c133763b4

作者:Codastra

user avatar didiaodekaishuiping Avatar hsr2022 Avatar dadetouyingyi Avatar xingxingshangdeliushu Avatar chengshudeyuechi_ewr3r2 Avatar
Favorites 5 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.