做數據處理的都知道,一個 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 轉一下就行:
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,別再把 None 和 np.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.nan或None
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
同一列裏混用 None、np.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 時一定要指定 dtype 或 dtype_backend,並且規範化 na_values。
用 Parquet 保持 schema 一致性;文本列多的話測試下 Arrow 後端
總結
Pandas1.0引入的可空類型不只是修邊角的細節優化,它把"缺失"這個語義明確編碼進了類型系統。整數保持整數,布爾值該表示"未知"就表示"未知",字符串就是字符串。過濾和 join 的邏輯變得更清楚,也更不容易出錯。
https://avoid.overfit.cn/post/d595b7b6ff9148bc8adb8b8c133763b4
作者:Codastra