↓快轉到主要內容
  1. 教學文章/

Polars Join 資料契約:Key 唯一性、Cardinality 與 Null 對帳

·7 分鐘· loading · loading · ·
Python Polars Join Cardinality Data-Contracts Validation
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 145: 本文

一. 前言:Join 沒報錯,營收卻變多了
#

嗨,我是拍拍君。今天的任務很單純:替訂單補上客戶名稱。 五筆訂單進去,七筆資料出來,程式還笑咪咪地正常結束。 不是業績突然變好,是右表同一個客戶出現兩次。

Join 的成功條件,不是「有結果」,而是結果仍符合資料關係。 這次不列一整頁 Join 類型,而是替一條補資料管線訂契約: 一列仍是一筆訂單、金額不變、客戶最多配一筆、漏配原因要能對帳。

如果剛接觸 expression,先看 Polars 基礎。 本篇比 Pandas 資料清理 的簡短 merge 範例更深入, 重點是複合鍵、配對基數與來源辨識,不是再做一次清洗流程。

二. 安裝:固定版本,也固定「一列代表什麼」
#

以下小型範例實測 Python 3.12、Polars 2.0.0:

uv init pypy-join-contracts
cd pypy-join-contracts
uv add 'polars==2.0.0'
uv add --dev pytest

第三節存成 fixtures.py;第八節存成 join_contract.py。 第四至七節、九節的 Python 片段依序放入 demo.py,最後再一起執行。 先寫契約,才知道要檢查哪一邊:

對象 粒度/規則
訂單 facts 一列一個 order_id,ID 不可空且唯一
客戶 dimension 一列一組 (shop, customer_id),鍵不可空且唯一
補完資料 每筆訂單恰好保留一次,原始欄位不可改
缺少客戶鍵 保留訂單,標成 null_key,不能亂補客戶
鍵存在但查不到 保留訂單,標成 orphan_key

後兩種資料可以留在診斷輸出,但預設不能通過正式報表 gate。 這是本篇選擇的政策;你的業務若允許訪客訂單,應明確修改規則。

三. 測試資料:同一個 c1,不一定是同一個人
#

客戶 ID 只在各店內唯一,因此不能單用 customer_id。 把以下存成 fixtures.py;schema 常數定義在第八節:

import polars as pl
from join_contract import FACT_SCHEMA, DIM_SCHEMA

orders = pl.DataFrame({
    "order_id": [101, 102, 103, 104, 105],
    "shop": ["tw", "tw", "jp", "tw", "tw"],
    "customer_id": ["c1", "c1", "c1", None, "c404"],
    "amount_cents": [100, 200, 300, 400, 500],
}, schema=FACT_SCHEMA)
customers = pl.DataFrame({
    "shop": ["tw", "jp", "tw"],
    "customer_id": ["c1", "c1", "c2"],
    "customer_name": ["拍拍醬", None, "chatPTT"],
}, schema=DIM_SCHEMA)

金額是同一幣別的整數分,只用來核對是否重算,不做匯率或捨入。 jp/c1 有客戶紀錄,但名稱尚未補齊;tw/c2 目前沒有訂單。 104 缺少客戶 ID,105 則帶著查不到的 c404。 這些不是同一種錯誤,別通通叫「有 null」。

四. 先抓重複鍵:不是只找整列一模一樣
#

先製造一個名稱不同、但鍵相同的客戶版本:

import polars as pl
from fixtures import orders, customers
from join_contract import KEYS, duplicate_keys, null_key

dirty = pl.concat([
    customers,
    pl.DataFrame({"shop": ["tw"], "customer_id": ["c1"],
                  "customer_name": ["拍拍君"]}, schema=customers.schema),
])
assert duplicate_keys(dirty).to_dicts() == [
    {"shop": "tw", "customer_id": "c1", "key_count": 2}
]
unsafe = orders.join(dirty, on=KEYS, how="left")
assert unsafe.height == 7
assert unsafe["amount_cents"].sum() == 1800  # 原本只有 1500

同一個非空鍵,左邊有 L 筆、右邊有 R 筆,就會產生 L × R 個配對。 這裡 tw/c1 的兩筆訂單各複製一次,所以多出兩列、多算 300 分。 其他漏配訂單仍保留一列,不能拿「左表五筆」推論 left join 永遠五筆。

is_duplicated() 檢查整列時,看不出兩個不同名稱其實搶同一個鍵。 應按預定的 Join key 分組計數,再回查那些版本的來源。 若只按 customer_id 分組,又會把不同店的合法 c1 誤判成重複。

不要直接 unique(keep="first") 把錯誤壓掉。 同一個客戶有多個歷史版本時,需要有效期間或已核准的版本選擇規則; 任選第一列,不等於解決主資料衝突。

五. Cardinality:把預期關係交給 validate
#

本例左表可以重複客戶,右表不可以,所以是 m:1:

safe = orders.join(
    customers, on=KEYS, how="left", validate="m:1",
    nulls_equal=False, maintain_order="left",
)
assert safe.height == 5
try:
    orders.join(dirty, on=KEYS, how="left", validate="m:1")
except pl.exceptions.ComputeError:
    pass  # 預期失敗:右表的複合鍵不唯一
else:
    raise AssertionError("重複客戶鍵竟然通過")

依 官方 Join API, validate 的左右,是依本次呼叫的左表與右表,不是你心中的主表:

validate 檢查哪邊的 Join key 唯一
1:1 兩邊
1:m 左邊
m:1 右邊
m:m 不做唯一性檢查,也是預設值

這組訂單若改用 1:1 會失敗,因為 101、102 都屬於 tw/c1。 訂單 ID 唯一,不代表訂單上的客戶鍵也唯一。 validate="m:1" 更不保證每筆訂單都有客戶,它只限制配對的基數。

六. Null:不能拿「未知」當成同一個客戶
#

本篇顯式選 nulls_equal=False,不讓 Null key 彼此匹配。 先用另一組最小資料觀察差異,不修改正式客戶表:

left = pl.DataFrame({"k": [None, None]}, schema={"k": pl.String})
right = pl.DataFrame({"k": [None, None], "v": ["A", "B"]},
                     schema={"k": pl.String, "v": pl.String})
assert left.join(right, on="k", how="left", nulls_equal=False).height == 2
assert left.join(right, on="k", how="left", nulls_equal=True).height == 4

允許 Null 相等會讓兩筆「不知道是誰」配到兩筆「也不知道是誰」。 不是所有缺值都代表同一個實體,因此不能把這個開關當作修復缺漏。 複合鍵只要任一欄是 null,本篇就判為缺鍵;右表則直接拒絕缺鍵。

注意:validate 不是 NOT NULL 約束。 實測 nulls_equal=False 時,右表的重複 Null 不一定觸發 m:1 錯誤; 所以不可空政策要獨立檢查,而不是等 Join 替你報錯。 空字串也不是 null;是否允許空白、是否大小寫合併,應在入口明確決定。 正規化必須在唯一性檢查前完成,並保留原始值以便診斷碰撞。

七. Anti Join:漏的是訂單,還是沒被使用的客戶?
#

只看結果的 customer_name.is_null() 不可靠: 103 其實配對成功,只是客戶名稱為 null。 先把缺鍵和有鍵但不存在的訂單拆開:

missing_key = orders.filter(null_key())
present_key = orders.filter(~null_key())
orphans = present_key.join(customers, on=KEYS, how="anti", nulls_equal=False)
matched = present_key.join(customers, on=KEYS, how="semi", nulls_equal=False)
unused = customers.join(present_key, on=KEYS, how="anti", nulls_equal=False)
assert missing_key["order_id"].to_list() == [104]
assert orphans["order_id"].to_list() == [105]
assert matched.sort("order_id")["order_id"].to_list() == [101, 102, 103]
assert unused.select(KEYS).rows() == [("tw", "c2")]
assert orders.height == missing_key.height + orphans.height + matched.height

Anti join 保留左側沒有配對的列,不會把右側欄位補過來。 換左右之後,問題也跟著改變:未匹配訂單是參照完整性問題; 沒有被訂單使用的客戶,則可能完全合法,不必自動刪掉。

計算 orphan rate 時要說清楚分母:本例是有鍵訂單的 1/4, 若報全體訂單的未匹配率,則是缺鍵加 orphan 的 2/5。 不要換了分母,卻仍把兩個數字叫成同一項指標。

八. 完整管線:配對標記、粒度不變量與預設拒收
#

把下面存成 join_contract.py。 此函式刻意只接受兩份指定 schema,額外欄位也拒絕,避免 suffix 或內部欄撞名。 右表加一個永遠為 True 的標記,再 Join;這才能區分「配不到」和「屬性空值」。

import polars as pl

KEYS = ["shop", "customer_id"]
FACT_SCHEMA = {"order_id": pl.Int64, "shop": pl.String,
               "customer_id": pl.String, "amount_cents": pl.Int64}
DIM_SCHEMA = {"shop": pl.String, "customer_id": pl.String,
              "customer_name": pl.String}


class ContractError(ValueError):
    pass


def require(ok, code):
    if not ok:
        raise ContractError(code)


def null_key():
    return pl.any_horizontal([pl.col(k).is_null() for k in KEYS])


def duplicate_keys(frame):
    return (frame.group_by(KEYS).len(name="key_count")
            .filter(pl.col("key_count") > 1).sort(KEYS))


def enrich_orders(facts, dimension, *, allow_unmatched=False):
    require(dict(facts.schema) == FACT_SCHEMA, "fact_schema")
    require(dict(dimension.schema) == DIM_SCHEMA, "dimension_schema")
    require(facts["order_id"].null_count() == 0, "null_order_id")
    require(facts["order_id"].n_unique() == facts.height, "duplicate_order_id")
    require(dimension.filter(null_key()).is_empty(), "null_dimension_key")
    require(duplicate_keys(dimension).is_empty(), "duplicate_dimension_key")
    joined = facts.join(
        dimension.with_columns(pl.lit(True).alias("_matched")),
        on=KEYS, how="left", validate="m:1",
        nulls_equal=False, maintain_order="left",
    )
    result = joined.with_columns(
        pl.when(null_key()).then(pl.lit("null_key"))
        .when(pl.col("_matched").fill_null(False)).then(pl.lit("matched"))
        .otherwise(pl.lit("orphan_key")).alias("join_status")
    ).select(*facts.columns, "customer_name", "join_status")
    require(result.height == facts.height, "row_count_changed")
    require(result.select(facts.columns).equals(facts), "fact_columns_changed")
    counts = dict(result.group_by("join_status").len().iter_rows())
    report = {k: counts.get(k, 0) for k in ("matched", "null_key", "orphan_key")}
    require(sum(report.values()) == facts.height, "reconciliation_failed")
    if not allow_unmatched:
        require(report["null_key"] + report["orphan_key"] == 0, "unmatched_key")
    return result, report

require 用例外實作正式 gate;不使用可能被 python -O 移除的 assert。 對 facts 的整份投影比對,比只檢查金額總和更強:列數、ID、順序、值都沒改。 上面是小型 eager enrichment,不是任意資料量都能裝進 RAM 的通用平台。 它也不驗證金額正負、名稱長度或每個店是否合法;那些仍由上游契約負責。

九. 診斷可以保留,正式通過要有政策
#

回到 demo.py,明確允許診斷輸出:

from join_contract import enrich_orders

result, report = enrich_orders(orders, customers, allow_unmatched=True)
assert report == {"matched": 3, "null_key": 1, "orphan_key": 1}
assert result["join_status"].to_list() == [
    "matched", "matched", "matched", "null_key", "orphan_key"
]
assert result["amount_cents"].sum() == orders["amount_cents"].sum() == 1500
assert result.filter(pl.col("order_id") == 103)["customer_name"].item() is None

103 的空名稱沒有被誤認成漏配,104、105 也沒有從結果消失。 正式呼叫省略 allow_unmatched=True,這份 fixture 就會因 unmatched_key 失敗。 先對帳、修來源或明確核准例外,再發布報表;別悄悄改成 inner join 丟掉兩筆。 若需要隔離清單,從 result 按 join_status 輸出,另存 batch ID 與來源版本。 不要在公開 log 印出整份客戶資料。

十. 測試:壞資料必須真的被擋住
#

以下存成 test_join_contract.py,不只檢查「成功回傳一張表」:

import polars as pl
import pytest
from fixtures import orders, customers
from join_contract import ContractError, enrich_orders


def test_status_and_grain():
    result, report = enrich_orders(orders, customers, allow_unmatched=True)
    assert result.select(orders.columns).equals(orders)
    assert report == {"matched": 3, "null_key": 1, "orphan_key": 1}
    assert result["join_status"][2] == "matched"  # 名稱 null 仍有配對


def test_duplicate_dimension_is_not_silently_deduplicated():
    dirty = pl.concat([customers, customers.head(1)])
    with pytest.raises(ContractError, match="^duplicate_dimension_key$"):
        enrich_orders(orders, dirty, allow_unmatched=True)


def test_publication_gate_rejects_unmatched():
    with pytest.raises(ContractError, match="^unmatched_key$"):
        enrich_orders(orders, customers)
uv run python demo.py
uv run pytest -q

正式 regression suite 再加:左右空表、Null 複合鍵、重複訂單 ID、dtype 漂移, 以及「兩店使用相同客戶 ID」不能串錯人的案例。 Join 的基數驗證、缺值政策、完整性對帳,應該各有獨立的失敗案例。

十一. 換成 Lazy/大型資料時,不要把保證弄丟
#

本篇沒有示範 collect(engine="streaming"),因為重點是契約,不是速度。 目前官方 Join 文件仍註明 validation 的 streaming 支援限制; 不能把引擎選項換掉,就假設同樣的檢查一定被執行。

大型管線可以把唯一鍵檢查、anti join 對帳與正式 enrichment 拆成工作, 但它們必須讀同一份不可變來源快照,否則會發生「檢查完資料卻換了」的競態。 全量 uniqueness 檢查不是抽樣;在不同分區各自唯一,也不代表全域唯一。 輸出先進 staging,通過對帳再提升版本,避免下游讀到一半完成的結果。

同樣別依賴碰巧看到的 Join 順序:本例顯式 maintain_order="left"。 若順序不是契約的一部分,測試可以按穩定 ID 排序後再比; 若輸出刻意改為一筆訂單多個商品,就應改寫粒度與不變量,而不是關掉錯誤。 查詢計畫與資源量測請接著看 LazyFrame 最佳化。

結語:每一次 Join,都要回答「多了誰、少了誰」
#

拍拍君這次最想留下的不是一個 validate 參數,而是一組能對帳的關係: 鍵在哪個範圍唯一、最多可以配幾筆、缺鍵與漏配如何區分,最後一列仍代表什麼。 先診斷重複,再限制 cardinality;用 anti join 看漏配,用來源標記辨識空屬性。 當列數、原始事實與配對狀態都可驗證,漂亮的表格才比較接近可信的答案。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 145: 本文

相關文章

Polars Nested JSON 實戰:Struct、List、NDJSON 與 Schema Drift
·7 分鐘· loading · loading
Python Polars NDJSON Struct List Schema-Drift Data-Engineering
Polars Rolling / Dynamic Windows:時間窗聚合、邊界語意與 QA
·6 分鐘· loading · loading
Python Polars Time-Series Rolling-Window Dynamic-Window Data-Engineering Testing
Polars LazyFrame 最佳化:Pushdown、Streaming 與 Explain
·6 分鐘· loading · loading
Python Polars LazyFrame Query Optimization Parquet Streaming Data-Engineering
Pandas 資料清理實戰:缺失值、型別、重複資料與驗證
·7 分鐘· loading · loading
Python Pandas Data-Cleaning Missing-Data Validation ETL
Streamlit Data Editor 實戰:可編輯表格、上傳驗證與 CSV 匯入匯出
·8 分鐘· loading · loading
Python Streamlit Data-Editor CSV Validation Developer-Tools
Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools