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

Polars Pivot / Unpivot:長寬表轉換、重複鍵與 Round-trip 契約

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

一. 前言:轉回去了,真的等於沒改過嗎?
#

嗨,我是拍拍君。今天拿到一份量測表:每列是一個樣本,兩個指標各占一欄。 畫圖想用長表,交接又要寬表,於是 unpivot()、pivot() 各跑一次。 欄位回來了,大家就放心收工?先等等。

如果原本的 Null 被刪掉、重複資料被 first 蓋掉, 或整數偷偷變成浮點數,表格長得相似,也不代表資料有完整回來。 今天的目標不是「做出交叉表」,而是證明轉換沒有遺失約定中的資訊。

本篇延伸 Polars 基礎,專注長寬表的可逆性。 Pandas GroupBy 報表 處理的是彙總, 會依業務規則把多筆交易聚合;這裡恰好相反:一格只能對應一筆,不准偷算。

二. 安裝與檔案安排
#

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

uv init --python 3.12 pypy-reshape
cd pypy-reshape
uv add 'polars==2.0.0'
uv add --dev pytest

第三節存成 fixtures.py,第六節存成 reshape.py。 第四、五、七至九節的 Python 區塊依序放入 demo.py,第十節另存測試檔。 先把三個檔案的內容備齊再執行,因為 fixture 會匯入第六節的常數。

這是小型 eager 教學,沒有宣稱適合任意大小的矩陣。 假設資料已是同單位的 Float64 量測;不是替所有欄位自動找共同型別的工具。

三. 先決定一列與一格代表什麼
#

本例用 (sample, run) 識別一列,metric 識別量測欄位。 名字只是合成測試標籤,沒有連接任何真實資料來源。

import polars as pl
from reshape import WIDE_SCHEMA

wide = pl.DataFrame({
    "sample": ["拍拍君", "拍拍君", "拍拍醬"],
    "run": [1, 2, 1],
    "m_a": [21.5, None, 0.0],
    "m_b": [None, 19.0, None],
}, schema=WIDE_SCHEMA)

拍拍君/1 和 拍拍君/2 不是重複樣本,而是兩次不同量測。 單用 sample 當鍵,才會把它們擠進同一格。

對象 本篇契約
寬表一列 一個唯一且非空的 (sample, run)
指標欄 固定順序 m_a、m_b,兩欄皆 Float64
長表一列 一個唯一的 (sample, run, metric) 儲存格
明確 Null 有這個儲存格,但值未知
缺少組合 來源根本沒提供這個儲存格

寬表本身是矩形:每一列都包含每個指標位置,即使值是 Null。 但外部收到的長表可能是稀疏的;這兩種起點不能混為一談。

四. Unpivot:欄名變成值,Null 也要留下
#

按明確的指標清單展開,不用「除了 ID 以外通通拿來」:

import polars as pl
from polars.testing import assert_frame_equal
from fixtures import wide
from reshape import IDS, METRICS, KEYS, validate_long, pack, unpack

long = wide.unpivot(
    on=METRICS, index=IDS,
    variable_name="metric", value_name="value",
)
assert long.height == wide.height * len(METRICS) == 6
assert long["value"].null_count() == 3
validate_long(long)

本次實測中,原本三個 Null 都變成長表裡真正存在的列。 因此不要順手接 drop_nulls("value"):那會把「有量測欄、數值未知」改成「沒有紀錄」。 零也不是缺值;0.0 必須原樣保留,不能用 truthy 判斷刪掉。

輸出列序不當成身分。我們稍後按 KEYS 排序再比較, 而不是依賴某次執行剛好先列出哪一個指標。

還有一個容易忽略的邊界:長表只有一個 value dtype。 若把 Int64、Float64、字串和日期一起展開,原始欄位型別未必能直接還原。 本篇入口就拒絕混合 dtype;別把超過精確表示範圍的整數硬轉 Float64。 真的需要異質資料,可以按型別分表,或另外設計帶型別的編碼協定。

五. Pivot 前先問:同一格是不是出現兩次?
#

先製造一筆重複的儲存格。即使數值相同,本篇仍拒絕:

dirty = pl.concat([long, long.head(1)])
duplicates = dirty.group_by(KEYS).len().filter(pl.col("len") > 1)
assert duplicates.height == 1
assert duplicates["len"].item() == 2

同一格兩筆,可能是重送,也可能是量測鍵漏了時間或版本。 aggregate_function="first" 會選掉一筆,sum 則把兩筆變成一個新數字。 兩者都不是「可逆重塑」,更不能因為暫時不報錯就當成修好了。

若業務真的需要平均值,請另外產生一張摘要表, 明確標成聚合結果,保留來源明細;不要對它承諾能還原所有輸入列。 這與 Join 的基數契約 相呼應, 但這次限制的是一個儲存格的來源數,不是兩張表的配對關係。

六. 核心函式:值矩陣加上存在矩陣
#

把以下完整存成 reshape.py。 兩張矩陣使用同一組 ID 和指標欄:values 保存數值,present 保存來源是否有列。 正式入口用例外拒收,不靠可被 python -O 關掉的 assert。

import polars as pl

IDS = ["sample", "run"]
METRICS = ["m_a", "m_b"]
KEYS = IDS + ["metric"]
ID_SCHEMA = {"sample": pl.String, "run": pl.Int64}
WIDE_SCHEMA = {**ID_SCHEMA, **dict.fromkeys(METRICS, pl.Float64)}
LONG_SCHEMA = {**ID_SCHEMA, "metric": pl.String, "value": pl.Float64}
MASK_SCHEMA = {**ID_SCHEMA, **dict.fromkeys(METRICS, pl.Boolean)}


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


def check(frame, schema, keys):
    require(list(frame.schema.items()) == list(schema.items()), "schema")
    require(frame.height > 0, "empty_input")
    require(frame.select(pl.any_horizontal(
        pl.col(k).is_null() for k in keys
    ).any()).item() is False, "null_key")
    require(frame.select(keys).is_duplicated().any() is False, "duplicate_key")


def validate_long(frame):
    check(frame, LONG_SCHEMA, KEYS)
    require(frame.filter(~pl.col("metric").is_in(METRICS)).is_empty(),
            "unknown_metric")


def pack(frame):
    validate_long(frame)
    values = frame.pivot(
        "metric", on_columns=METRICS, index=IDS, values="value",
        aggregate_function=None,
    ).select(*IDS, *METRICS).sort(IDS)
    present = frame.with_columns(pl.lit(True).alias("_present")).pivot(
        "metric", on_columns=METRICS, index=IDS, values="_present",
        aggregate_function=None,
    ).select(*IDS, *METRICS).with_columns(
        pl.col(METRICS).fill_null(False)
    ).sort(IDS)
    return values, present


def unpack(values, present):
    check(values, WIDE_SCHEMA, IDS)
    check(present, MASK_SCHEMA, IDS)
    require(values.select(IDS).sort(IDS).equals(
        present.select(IDS).sort(IDS)), "mask_ids")
    require(all(present[m].null_count() == 0 for m in METRICS), "null_mask")
    require(present.select(pl.any_horizontal(
        pl.col(METRICS)
    ).all()).item() is True, "empty_row")
    cells = values.unpivot(on=METRICS, index=IDS,
        variable_name="metric", value_name="value")
    flags = present.unpivot(on=METRICS, index=IDS,
        variable_name="metric", value_name="present")
    paired = cells.join(flags, on=KEYS, how="left", validate="1:1")
    require(paired.filter(
        ~pl.col("present") & pl.col("value").is_not_null()
    ).is_empty(), "hidden_value")
    result = paired.filter(pl.col("present")).select(*KEYS, "value").sort(KEYS)
    validate_long(result)
    return result

這個小工具刻意採嚴格 schema,連欄位順序也算契約。 讀入不同順序時,先明確 select 正規化;缺欄、多欄或型別錯誤不自動猜。 所有 Null 的指標仍是 Float64,不讓推論把它退成 Null dtype。

on_columns=METRICS 固定輸出指標集合;若某指標整批都缺席,仍有它的位置。 但固定輸出清單不等於驗證來源,所以未知指標必須先被 validate_long 擋下。 關於兩個參數的定義,可對照 官方 Pivot API。

空輸入在此直接拒收,沒有偷偷推導「空批次等於刪除所有樣本」。 若產品需要空表,應額外定義具型別的零列輸出與獨立測試。 空字串、run 是否為正值、NaN/Infinity 是否合格,也應由量測領域另訂政策。

七. 寬 → 長 → 寬:驗證型別、值與欄序
#

從完整寬表展開的 long,每一格都存在,所以 present 應全為 True:

values, present = pack(long)
assert_frame_equal(values, wide.sort(IDS), check_exact=True)
assert values.columns == IDS + METRICS
assert all(present[m].all() for m in METRICS)
assert_frame_equal(unpack(values, present), long.sort(KEYS), check_exact=True)

assert_frame_equal 在這裡也檢查 dtype、列序與欄序,不只看資料列數。 先按穩定鍵排序,是本篇選擇的 canonical order;沒有保存原始輸入的排列順序。 若原始順序也重要,請把顯式列序號加入協定,而不是當成隱藏保證。

我們選 check_exact=True,因為重塑沒有做算術。 測試不該先開一個寬鬆誤差,替意外型別轉換或數值改變找台階下。 若要傳檔,兩張表和契約版本應一起交接,並在接收端重驗 schema/ID。

八. 長 → 寬 → 長:缺一格不等於填 Null
#

現在只刪掉 拍拍君/1/m_b,保留其他明確 Null:

sparse = long.filter(~(
    (pl.col("sample") == "拍拍君") & (pl.col("run") == 1)
    & (pl.col("metric") == "m_b")
))
sparse_values, sparse_present = pack(sparse)
assert_frame_equal(sparse_values, values, check_exact=True)
assert sparse_present.filter(
    (pl.col("sample") == "拍拍君") & (pl.col("run") == 1)
)["m_b"].item() is False
assert sparse_present.filter(pl.col("sample") == "拍拍醬")["m_b"].item() is True
assert_frame_equal(unpack(sparse_values, sparse_present),
                   sparse.sort(KEYS), check_exact=True)
assert unpack(sparse_values, sparse_present).height == 5

重點來了:完整與稀疏來源的 values 完全相同,但存在矩陣不同。 只看數值表,無法知道那個 Null 是明確提供,還是 Pivot 補出的空位。

value present 本篇解讀
0.0 True 已提供,值確實是零
Null True 已提供,但值未知
Null False 沒提供這個組合
非 Null False 不一致,拒絕隱藏數值

若連某個 ID 的所有指標都沒提供,長表裡根本不會有這個 ID。 要保留「零個儲存格的樣本」,就得再交接一張獨立 ID 名冊;本篇未實作這一層。 同理,只留下 values、丟掉 present,便已經放棄稀疏來源的可逆保證。

九. Lazy Pivot:不是一律不能做
#

在本文固定的 Polars 2.0.0,實際可用下列 Lazy API:

lazy_values = long.lazy().pivot(
    "metric", on_columns=METRICS, index=IDS, values="value",
    aggregate_function=None,
).select(*IDS, *METRICS).sort(IDS).collect()
assert_frame_equal(lazy_values, values, check_exact=True)

依 官方 LazyFrame.pivot, 要預先提供 on_columns;這個 API 目前標示 unstable,升版應重跑測試。 所以不能再背「Pivot 永遠只能 eager」這種沒有版本前提的口訣。

這段展示的是 API 和結果一致性,沒有實作完整的 lazy admission gate。 正式處理大檔時,檢查與轉換應讀同一份不可變來源,不能查完鍵卻換了一批資料。 Lazy 也不代表矩陣免費:R 個 ID、M 個指標,寬表仍有約 R × M 個格子。 高基數 metric 未經限制就展欄,可能比保留長表更浪費資源。

十. 測試:讓每種資訊遺失都有警報
#

存成 test_reshape.py。測試抓真正的反例,不只測函式有沒有回傳:

import polars as pl
import pytest
from polars.testing import assert_frame_equal
from fixtures import wide
from reshape import IDS, METRICS, KEYS, check, WIDE_SCHEMA, pack, unpack


def source():
    return wide.unpivot(on=METRICS, index=IDS,
        variable_name="metric", value_name="value")


def test_both_directions():
    long = source()
    values, present = pack(long)
    assert_frame_equal(values, wide.sort(IDS), check_exact=True)
    assert_frame_equal(unpack(values, present), long.sort(KEYS), check_exact=True)


def test_absent_is_not_explicit_null():
    long = source()
    sparse = long.filter(~((pl.col("sample") == "拍拍醬")
        & (pl.col("metric") == "m_b")))
    values, present = pack(sparse)
    assert_frame_equal(values, pack(long)[0], check_exact=True)
    assert_frame_equal(unpack(values, present), sparse.sort(KEYS), check_exact=True)


def test_duplicate_cell_rejected():
    long = source()
    with pytest.raises(ValueError, match="^duplicate_key$"):
        pack(pl.concat([long, long.head(1)]))


def test_mixed_wide_dtype_rejected():
    mixed = wide.with_columns(pl.col("m_a").cast(pl.Int64))
    with pytest.raises(ValueError, match="^schema$"):
        check(mixed, WIDE_SCHEMA, IDS)
uv run python demo.py
uv run pytest -q

至少再補整欄 Null、整個指標缺席、亂序、Null key、未知 metric、空表, 以及 present 的 ID 不一致、Null mask、隱藏數值等破壞案例。 別只比較 sum:[1, 2] 被改成 [0, 3],總和一樣,資料卻已經不同。

結語:重塑不是聚合,可逆也不是看起來一樣
#

拍拍君的原則很簡單:先固定粒度與型別,再談轉換。 重複儲存格預設拒收,指標欄明確列出,Null 不隨手刪,欄序與值用測試守住。 稀疏資料則把「是否存在」一起交接;沒有保存的資訊,轉回來也不會自己長出來。

當寬→長→寬、長→寬→長都有具體契約, 下次別人問「這張表真的沒有少東西嗎」,你就能拿出比截圖更可靠的答案。

延伸閱讀
#

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

相關文章

Polars Enum / Categorical:分類詞彙、未知值與跨批次一致性
·7 分鐘· loading · loading
Python Polars Enum Categorical Data-Contracts Schema
Polars Join 資料契約:Key 唯一性、Cardinality 與 Null 對帳
·7 分鐘· loading · loading
Python Polars Join Cardinality Data-Contracts Validation
Polars Rolling / Dynamic Windows:時間窗聚合、邊界語意與 QA
·6 分鐘· loading · loading
Python Polars Time-Series Rolling-Window Dynamic-Window Data-Engineering Testing
Rich 報表匯出:Record Buffer、HTML/SVG 與可重現分享
·8 分鐘· loading · loading
Python Rich HTML SVG Reports Testing
Streamlit Form 提交生命週期:草稿、已套用狀態與失敗保留
·9 分鐘· loading · loading
Python Streamlit Forms Session State Validation Testing
Textual DataTable 身分契約:Row Key、排序篩選與選取還原
·7 分鐘· loading · loading
Python Textual DataTable RowKey TUI Testing