一. 前言:轉回去了,真的等於沒改過嗎? #
嗨,我是拍拍君。今天拿到一份量測表:每列是一個樣本,兩個指標各占一欄。
畫圖想用長表,交接又要寬表,於是 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 不隨手刪,欄序與值用測試守住。 稀疏資料則把「是否存在」一起交接;沒有保存的資訊,轉回來也不會自己長出來。
當寬→長→寬、長→寬→長都有具體契約, 下次別人問「這張表真的沒有少東西嗎」,你就能拿出比截圖更可靠的答案。