動態

詳情 返回 返回

12 種 Pandas 測試技巧,讓數據處理少踩坑 - 動態 詳情

12 種 Pandas 測試技巧,讓數據處理少踩坑

image.png

12 種測試實踐 —— fixtures、schemas、property-based tests、snapshots、performance guards —— 每週能省不少排查問題的時間

Pandas 的 bug 有個特點,就是不會在控制枱裏大喊大叫,而是悄悄藏在 dtype 轉換、索引操作、時區處理的某個角落,或者那種跑十萬次才能復現一次的邊界條件。所以如果你想找到和定位這種隱藏的BUG就需要一套相對簡潔的測試手段能把大部分坑提前暴露出來。

下面這 12 個策略是實際項目裏反覆使用的測試方法,能讓數據處理代碼變得比較靠譜。

1) 用 Pytest Fixtures 做 DataFrame 工廠

弄幾個小巧的 fixture "工廠"來生成樣例數據,這樣setup 代碼會少寫很多,測試邏輯反而能寫更充分。

# conftest.py  
import pandas as pd  
import numpy as np  
import pytest  
  
@pytest.fixture  
def sales_df():  
    return pd.DataFrame({  
        "order_id": [1, 2, 3],  
        "country": ["IN", "US", "IN"],  
        "amount": [99.0, 149.5, np.nan],  
        "ts": pd.to_datetime(["2025-09-01", "2025-09-02", "2025-09-02"])  
    })

然後在哪裏都能直接用:

def test_revenue_total(sales_df):  
    assert sales_df["amount"].sum(skipna=True) == 248.5

一個標準樣本反覆使用,減少重複代碼,測試的樣例也更容易讀懂。

2) Schema 層來約束數據

Dtype 會漂,列也可能會丟。所以加個 schema 檢查,這樣違規的數據在邊界就會被暴露出來。用 pandera 這類工具也行,或者自己寫個輕量檢查:

def assert_schema(df, expected):  
    # expected: dict[column] -> dtype string, e.g. {"order_id": "int64", ...}  
    assert set(df.columns) == set(expected), "Columns mismatch"  
    for c, dt in expected.items():  
        assert str(df[c].dtype) == dt, f"{c} dtype mismatch: {df[c].dtype} != {dt}"  
  
def test_schema(sales_df):  
    assert_schema(sales_df, {  
        "order_id": "int64",  
        "country": "object",  
        "amount": "float64",  
        "ts": "datetime64[ns]"  
    })

數據結構變化能第一時間發現,不會傳到轉換邏輯深處才暴露。

3) Property-Based Testing 檢查不變量

有些規則應該對任意輸入都成立,比如歸一化之後總和還是 1。所以可以用 Hypothesis 自動生成各種輸入來驗證:

from hypothesis import given, strategies as st  
import pandas as pd  
import numpy as np  
  
@given(st.lists(st.floats(allow_nan=False, width=32), min_size=1, max_size=50))  
def test_normalize_preserves_sum(xs):  
    s = pd.Series(xs, dtype="float32")  
    total = float(s.sum())  
    if total == 0:  # define behavior on zero-sum  
        return  
    normalized = s / total  
    assert np.isclose(float(normalized.sum()), 1.0, atol=1e-6)

一個測試用例能自動覆蓋幾十種形狀、數值範圍和邊界情況。

4) 參數化測試把邊緣 case 都列出來

有一些經典的麻煩場景:空 DataFrame、單行數據、重複索引、全 null 列、混時區。

import pytest  
import pandas as pd  
import numpy as np  
  
@pytest.mark.parametrize("df", [  
    pd.DataFrame(columns=["a","b"]),  
    pd.DataFrame({"a":[1], "b":[np.nan]}),  
    pd.DataFrame({"a":[1,1], "b":[2,2]}).set_index("a"),  
])  
def test_transform_handles_edges(df):  
    out = df.assign(b=df.get("b", pd.Series(dtype=float)).fillna(0.0))  
    assert "b" in out

一次性把健壯性鎖定,以後就不用反覆調同樣的邊界問題了。

5) Golden Snapshot + 校驗和來固定輸出

複雜輸出可以存個"黃金樣本",CI 裏對比校驗和。如果輸出變了會直接報錯。

import pandas as pd  
import hashlib  
  
def df_digest(df: pd.DataFrame) -> str:  
    b = df.sort_index(axis=1).to_csv(index=False).encode()  
    return hashlib.md5(b).hexdigest()  
  
def test_output_snapshot(tmp_path, sales_df):  
    out = (sales_df  
           .assign(day=sales_df["ts"].dt.date)  
           .groupby(["country","day"], as_index=False)["amount"].sum())  
    expected = pd.read_parquet("tests/golden/agg.parquet")  
    assert df_digest(out) == df_digest(expected)

6) 固定隨機數和時間

如果轉換依賴隨機或者當前時間,得把種子釘死。

import numpy as np  
import pandas as pd  
from datetime import datetime  
  
def stratified_sample(df, frac, rng):  
    return df.groupby("country", group_keys=False).apply(lambda g: g.sample(frac=frac, random_state=rng))  
  
def test_sample_is_deterministic(sales_df):  
    rng = np.random.default_rng(42)  
    a = stratified_sample(sales_df, 0.5, rng)  
    rng = np.random.default_rng(42)  
    b = stratified_sample(sales_df, 0.5, rng)  
    pd.testing.assert_frame_equal(a.sort_index(), b.sort_index())

這個沒什麼説的,模型訓練的時候也要固定隨機種子

7) 明確測試 NA 的語義

NaNNonepd.NA 在不同操作下行為差異挺大的,這時候需要把預期行為顯式寫出來:

import pandas as pd  
import numpy as np  
  
def test_na_logic():  
    s = pd.Series([1, np.nan, 3])  
    s2 = s.fillna(0)  
    assert s2.isna().sum() == 0  
    assert s2.iloc[1] == 0

NA 相關的 bug 經常藏在 groupby、merge 和數學運算裏,得當成一等公民來測。

8) 索引、排序、唯一性約束

函數如果保證"索引穩定"就測索引,依賴排序就斷言排序狀態。

def is_monotonic(df, column):  
    return df[column].is_monotonic_increasing  
  
def test_index_and_sort(sales_df):  
    out = sales_df.sort_values(["ts","order_id"]).set_index("order_id")  
    assert out.index.is_unique  
    assert is_monotonic(out.reset_index(), "ts")

很多邏輯錯誤其實就是順序錯了或者不小心有重複。

9) 雙實現交叉驗證

聰明的向量化邏輯可以用"慢但一看就懂"的實現來驗證:

import pandas as pd  
  
def vectorized_net(df):  
    return df.assign(net=df["amount"] - df["amount"].mean())  
  
def slow_net(df):  
    mean = df["amount"].mean()  
    df = df.copy()  
    df["net"] = df["amount"].apply(lambda x: x - mean)  
    return df  
  
def test_equivalence(sales_df):  
    a = vectorized_net(sales_df)  
    b = slow_net(sales_df)  
    pd.testing.assert_series_equal(a["net"], b["net"], check_names=False)

防止向量化實現出現細微錯誤,同時保持性能優勢。

10) 性能預算作為冒煙測試

不需要精確的 benchmark,設個大概的護欄就夠了。用小規模代表性數據跑一下,給個時間上限:

import time  
import pandas as pd  
  
def test_runs_fast_enough(sales_df):  
    small = pd.concat([sales_df]*2000, ignore_index=True)  # ~6k rows  
    t0 = time.perf_counter()  
    _ = small.groupby("country", as_index=False)["amount"].sum()  
    dt = time.perf_counter() - t0  
    assert dt < 0.25  # budget for CI environment

11) I/O 往返保證

CSV、Parquet、Arrow 格式往返可能會改類型,測一下關心的部分:

import pandas as pd  
import numpy as np  
  
def test_parquet_round_trip(tmp_path, sales_df):  
    p = tmp_path / "sales.parquet"  
    sales_df.to_parquet(p, index=False)  
    back = pd.read_parquet(p)  
    pd.testing.assert_frame_equal(  
        sales_df.sort_index(axis=1),   
        back.sort_index(axis=1),  
        check_like=True  
    )

"本地跑得好好的,生產環境因為 I/O 就掛了"這種謎之問題可能就出現在這裏

12) Join 操作的基數和覆蓋率檢查

Merge 是數據質量最容易出問題的地方,基數、重複、覆蓋率都得顯式斷言。

import pandas as pd  
  
def test_merge_cardinality():  
    left = pd.DataFrame({"id":[1,2,3], "x":[10,20,30]})  
    right = pd.DataFrame({"id":[1,1,2], "y":[5,6,7]})  
    out = left.merge(right, on="id", how="left")  
    # Expect duplicated rows for id=1 because right has two matches  
    assert (out["id"] == 1).sum() == 2  
    # Coverage: every left id appears at least once  
    assert set(left["id"]).issubset(set(out["id"]))

key 不唯一或者行數意外翻倍的時候能立刻發現。

小結

好的 Pandas 代碼不光要寫得聰明,更重要的是可預測。這 12 個策略能讓正確性變成默認狀態:fixtures 快速啓動、schemas 早期失敗、property-based tests 探索各種古怪情況、簡單的性能預算阻止慢代碼偷偷溜進來。本週先試兩三個,接到 CI 裏,那些神秘的數據 bug 基本就消失了。

https://avoid.overfit.cn/post/6eca2c51cef244849e52ae02b932efa9

作者:Syntal

user avatar qianhedehuangdou 頭像 shumile_5f6954c414184 頭像
點贊 2 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.