資料驗證:
用 pandera 幫資料寫合約
管線最貴的故障,通常沒有錯誤訊息:上游把金額的單位從元改成分、把 amount 改名成 total、多送一個沒見過的國家代碼——你的程式照樣跑完,報表照樣寄出,只是全錯。 資料驗證就是在入口簽一份合約:這批資料應該長什麼樣,寫成程式碼,每一批都對一次。 下面這台檢查機就是一份合約——點格子把值弄壞,看它怎麼回應:
點任何一格,把值換成「上游可能送來的意外」(再點一次換下一種、繞回乾淨值); 點欄名 amount 可以模擬上游把它改名成 total。
| order_id | customer | country |
|---|
| schema_context | column | check | failure_case | index |
|---|
表格是 notebook 那份訂單資料的前 6 列;check 欄位的字串 (greater_than(0)、isin(['TW', 'JP', 'US'])、 not_nullable、field_uniqueness…) 全部是 notebook 的實測輸出(pandera 0.33)——你在自己的管線上看到的就是這些字。
不會拋例外的那種壞掉
上一課的模型監控處理「模型上線之後」——用統計量看輸入分佈有沒有慢慢飄走。這一課處理更前面的一步: 資料剛進管線的那一刻。因為大部分的資料事故,在監控看到之前就已經污染了下游。
| 上游做了什麼 | 你的程式會怎樣 | 你什麼時候會知道 |
|---|---|---|
欄位改名(amount → total) | KeyError,會炸 | 馬上(這是最幸運的一種) |
| 單位變了(元 → 分) | 照算,答案全錯 | 有人覺得數字怪怪的時候 |
多了一批 NaN | 平均值悄悄偏移,或某些列被靜靜丟掉 | 可能永遠不會 |
| 類別欄多一個新值 | one-hot 多出沒見過的欄,或被當成未知值 | 模型準確率慢慢掉 |
| 主鍵重複(抓了兩次) | 每個客戶被算兩次,指標整體膨脹 | 季報對不起來的時候 |
第一列以外,全部都不會拋例外。管線很開心地把錯的答案算完、存好、發出去——這才是最貴的失敗, 因為它會一路傳到報表、模型與決策,而且很久以後才被發現。
資料驗證換掉的就是這件事:把安靜的錯誤變成吵鬧的錯誤。做法不是統計,是合約—— 把「這份資料應該長什麼樣」寫成程式碼,每一批進來都對一次;不合約就擋下來, 而且說清楚是哪一列、哪一欄、違反哪一條。pandera 就是做這件事的套件: 語法像 pydantic,但主角是 DataFrame。
到 notebook 的 1️⃣ 節:500 筆假訂單與它的合約一本字典:欄位名 → 這一欄的規矩
每一欄用 pa.Column 宣告:第一個參數是型別,後面接檢查 (ge/gt/between/isin)、 unique=True(主鍵不可重複)、nullable=False(不可以有空值)。 最後那個 checks=(複數、放在整張表那一層)拿到的是整個 DataFrame—— 這裡放的是最實用的一條:至少要有 400 列。上游只回傳一半資料是非常常見的故障, 而且單看每一欄都完全正常。
通過的話 validate() 把資料原樣回傳(內容一模一樣,只是新物件)。 這個設計讓驗證變成資料流的一站,而不是額外一句 if:df = schema.validate(load_orders())。 實測 500 列全部通過。
順帶一提第一行的 import pandera.pandas as pa:網路上很多範例寫的是 import pandera as pa——實測還能用,但會噴 FutureWarning: Importing pandas-specific classes and functions from the top-level pandera module will be removed in a future version of pandera. 新程式一律用子模組那條路徑。
到 notebook 的 1️⃣ 節:把腦袋裡的規矩寫成程式碼SchemaError 與 SchemaErrors:差一個 s,差很多
合約寫得對不對,看它「通過」是看不出來的——要看它擋不擋得住壞資料。
把四筆資料弄壞(第 0 列金額 −50、第 1 列國家 XX、第 2 列客戶 99、第 3 列金額 NaN)之後,
直接 validate() 的結果是:
只講了一個。另外三個問題還在資料裡,但你得先修好這一個、再跑一次才會看到下一個—— 四個問題就是四輪。開發時這樣很方便(訊息短),生產環境則是災難:每一輪都要重跑一次上游、 重等一次 ETL。
e.failure_cases 是這一課最該記住的東西——一張 DataFrame,每一列是一筆違約: column(哪一欄)、check(違反哪一條)、 failure_case(壞掉的那個值本身)、index(哪一列, 可以直接拿去 df.loc[...] 撈出來看)。 有了它,你能做三件單一錯誤訊息做不到的事:一次修完、把表寄給上游(對方不用問「你說的壞資料是哪一筆」)、 以及第 8 節要做的——把它掛在管線的檢查結果上。
生產環境一律 lazy=True。這是本課最實用的一條規則。
| 上游做了什麼 | check | failure_case | index |
|---|---|---|---|
| 型別變了(int → float) | dtype('int64') | float64 | None |
| 主鍵重複(抓了兩次) | field_uniqueness | 0 | 0 與 10(兩列都報) |
| 多了缺值 | not_nullable | NaN | 5 |
| 類別多一個新值 | isin(['TW', 'JP', 'US']) | KR | 7 |
欄位改名 amount → total | column_in_dataframe | amount | None |
| 只回傳 100 列 | at least 400 rows | False | None |
看最後兩列:它們的 schema_context 是 DataFrameSchema 而不是 Column,index 是空的——因為問題不在某一列的值, 是整張表的形狀。順帶一提,欄位改名是唯一一種「不驗證也會炸」的變化, 但合約讓它在入口就炸、而且指名道姓,而不是在第 40 行的 df["amount"] * 1.05 才炸。
到 notebook 的 2️⃣–3️⃣ 節:六種變化,一次撞完一欄看不出來的規矩:跨欄、分組、格式
真實的資料規則常常跨欄:退款不可以超過訂單金額、每個國家至少要有 100 筆(不然這批八成只抓到一部分)、
訂單編號要符合 國碼-四位數、日期不可以是未來。這些都靠
pa.Check 加一個你自己寫的函式,放在欄裡(拿到那一欄的 Series)或放在表那一層(拿到整張表)。
這裡有一個決定「錯誤訊息有多好用」的細節:你的函式回傳什麼,決定 pandera 能不能告訴你「哪一列壞掉」。
| 函式回傳 | pandera 怎麼判 | 出事時你知道什麼 |
|---|---|---|
| 布林 Series(每列一個) | 逐列判定 | 哪一列、那一列長什麼樣 |
單一 bool(.all()、比大小) | 整批判定 | 只知道「這批不合格」 |
第一段訊息把壞掉那一列的每一個欄位值都印出來了(實測 8 欄的表就是 8 個值, failure_cases 也會是 8 列、index 全是 0——講的是同一列, 看 index 不要看筆數);第二列只有一句「這批不合格」。兩種都對, 但能寫成 Series 就寫成 Series:出事時你會很感謝當初多想了那三秒。
還有一個 element_wise=True:讓你的函式一次只拿到一個值,寫起來最直覺, 代價是它是 Python 迴圈、比向量化慢。留給真的沒辦法向量化的邏輯就好。 另外,每一條自訂檢查都要給 error=——不然訊息裡只會有一個 <lambda> 和它在清單裡的編號。
到 notebook 的 4️⃣ 節:五種跨欄規則各弄壞一次DataFrameModel、coerce、strict
同一份合約的第二種寫法:用型別註記宣告欄位,用過 pydantic 就會覺得很熟。實測驗同一份髒資料, 結果與字典版一模一樣(4 筆違約、同樣的 check 名稱)—— 它們是同一個東西的兩張臉,Orders.to_schema() 隨時能轉回字典版。
DataFrameSchema(字典) | DataFrameModel(class) | |
|---|---|---|
| 形狀 | 一個物件,可以在執行時組出來 | 一個類別,寫死在程式碼裡 |
| 適合 | 欄位是動態的(設定檔、依日期生成) | 欄位固定,全公司共用同一份定義 |
| 好處 | 可以塞進 dict、迴圈、to_yaml() | 型別註記能當函式簽名 DataFrame[Orders],編輯器會補全 |
團隊裡選一種寫,別兩種混著用。至於 Config 那兩個開關,它們解決的是完全不同的問題:
| 開關 | 擋掉什麼 |
|---|---|
coerce = True | CSV 讀進來整欄是字串——先轉再驗,轉不動才報錯,而且指名是哪個值 |
| (沒開 coerce) | 字串欄對上 int 宣告直接被拒 |
strict = True | 上游偷偷多送一欄(多半沒事,直到有人寫 get_dummies 或 to_sql) |
strict = "filter" | 更務實的第三條路:把不在合約裡的欄位直接砍掉(不報錯,回傳只剩宣告過的欄位) |
一個很多人搞錯的地方:strict 只管一個方向——「資料多送了合約沒有的欄位」。 「資料少了合約要求的欄位」不管你開不開 strict,本來就會被 column_in_dataframe 擋下來。
到 notebook 的 5️⃣ 節:class 版與兩個開關的實測最容易寫錯的一節
型別是合約裡最基本的一條,也是最常寫不對的一條。三個一定會撞到的坑,實測結果都在這裡:
| 資料是 | 合約寫 | 結果 |
|---|---|---|
| 沒有時區的時間 | pa.Column(pa.DateTime) | ✅ 通過 |
| 帶時區的時間(資料庫撈出來的) | pa.Column(pa.DateTime) | ❌ 型別不符 |
| 帶時區的時間 | pa.Column("datetime64[ns, UTC]") | ❌ 還是不過——單位也要對 |
| 帶時區的時間 | pa.Column("datetime64[ns, UTC]", coerce=True) | ✅ 通過 |
| 字串日期(CSV 讀進來的) | pa.Column(pa.DateTime) | ❌ 型別不符 |
| 字串日期 | pa.Column(pa.DateTime, coerce=True) | ✅ 通過 |
category 欄(省記憶體) | pa.Column(str) | ❌ 型別不符 |
category 欄 | pa.Column(pa.Category, pa.Check.isin([...])) | ✅ 通過(同時管住型別與允許值) |
看第 3 列與第 4 列:同樣的資料、同樣的宣告,差別只有 coerce=True。
這就是本節的結論——型別宣告是給人看的意圖,coerce 才是務實的執行策略。
把宣告寫死成某個精確 dtype,等於把合約綁死在某個 pandas 版本上;開 coerce 之後,
轉不動的值("2026-13-45"、"N/A")反而會在入口就被指名,
而不是變成 NaT 之後靜靜影響統計。
先讓資料寫草稿,再把合約存成設定檔
一張三十欄的表要一欄一欄想「型別是什麼、範圍多少」,會寫到放棄。 pa.infer_schema(df) 看一眼資料就生一份草稿——但它產出的東西不能直接用:
它把「這批資料剛好的樣子」寫成了規矩:明天第 501 筆訂單進來就違約了。 正確用法是用它省下打字的力氣,然後人工把每一條改成業務上真正的規矩—— 無意義的界線刪掉,真正的界線寫進去(金額必須大於 0、國家只有那三個)。
改完之後,schema.to_yaml() 把合約變成一份設定檔:PR 上看得到
「這次把 country 多加了一個 KR」、資料工程與後端可以共用同一份定義、
不同環境可以載不同的合約。from_yaml() 載回來驗證行為一模一樣——
但有一半沒被存進去:
| 拿什麼去驗 | 原本的 schema | YAML 載回來的 |
|---|---|---|
| 四筆壞資料的 500 列(全是欄位規則) | 4 筆違約 | 4 筆——完全一致 |
| 乾淨、但只回傳 100 列(只違反表級規則) | 1 筆違約 | 0 筆——直接放行 |
原因不難理解:表級的 checks= 是 Python lambda,沒辦法用 YAML 表達 (欄位層的內建檢查——連 str_matches 的正規表達式——都存得下來)。 所以真實專案的合約通常是兩層:欄位規則走 YAML,跨欄與整批的規則留在程式碼裡。 知道哪一半沒被存進去,比記住這個限制更重要。
到 notebook 的 7️⃣ 節:YAML 來回一趟,親手比對違約數合約變成擋得住下游的閘門
到這裡合約已經很完整了,但它還只是「你手動跑的一個函式」。最後一步是接進管線, 讓它在每一批資料進來時自動執行,而且——不合格的資料,下游根本不准開始跑。 第 3 課的 Dagster @asset_check(blocking=True) 就是為這件事存在的:
同一條管線(raw_orders → 檢查 → customer_summary)跑兩次,實測結果:
| 這一批 | run.success | 檢查 | violations | 實體化的資產 |
|---|---|---|---|---|
| 乾淨資料 | True | ✅ 通過 | 0 | raw_orders, customer_summary |
| 四筆壞資料 | False | ❌ 沒過 | 4 | raw_orders 只有它 |
customer_summary 不在清單裡——閘門關上了,下游一步都沒跑。
這就是 blocking=True 的全部意義:壞資料不會變成壞報表、壞模型、壞決策。
改成 blocking=False 的話,檢查照樣變紅、但下游照跑——那叫警告,不叫閘門。
另外兩個細節:這裡一定要 lazy=True(維運的人要的是「這批有哪些問題」),
而 MetadataValue.md 讓半夜看板的人不用開 notebook 就知道哪一欄壞了。
擋下來之後呢?管線停在那裡沒人管,就只是換一種方式壞掉。三種收尾,選一種寫進你的管線:
| 做法 | 怎麼做 | 適合 |
|---|---|---|
| 擋住+通知 | blocking=True,檢查失敗觸發通知,人工判斷 | 資料錯了會出人命(金流、醫療) |
| 丟掉壞的列 | DataFrameSchema(..., drop_invalid_rows=True) + lazy=True(實測本課資料丟 1 列剩 499 列) | 壞資料比例低、少幾列不影響結論 |
| 隔離區 | 檢查改 blocking=False,壞的列寫進另一張表,好的列繼續走 | 每天都有一點髒資料,但不能停線 |
沒有標準答案,但一定要選一個——最糟的是沒想過,然後在半夜臨時決定。 而這三種都建立在同一件事上:你知道確切是哪幾列、違反哪一條。這就是前面花那麼多篇幅講 failure_cases 的原因。
到 notebook 的 8️⃣–9️⃣ 節:閘門實跑兩次+自己挑一種破壞方式換你動手
在表級 checks= 再加一條「退貨率不可超過 15%」(本課資料實測 8.4%,乾淨資料要能通過),再把 returned 整欄設成 True,確認它擋得下來。
用 DataFrameModel 把 8 欄的完整合約整份重寫,Config 加上 strict = True,然後拿只有 5 欄的那份資料去驗——先猜猜看,會是「多欄位」還是「少欄位」的錯?
把合約存成 YAML、在另一個 cell from_yaml 載回來驗同一份資料,比對兩邊的 failure_cases。要比兩次:一次只違反欄位規則、一次只違反表級規則——兩次的結論不一樣才算做完。
卡住了?每一題在 notebook 末節都有折疊解答——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
每天凌晨從交易系統撈訂單、清資料、算特徵、重訓模型。上個月上游改版把金額單位從元換成分,模型連續三天算錯才被發現——當時管線全綠、零錯誤。要怎麼做才不會再發生?
關鍵在「管線全綠、零錯誤」——這種故障不會拋例外,所以任何靠 try/except 的方案都接不到它(A 的問題)。B 的方向是對的(它其實就是一種驗證),但位置太後面:錯的資料已經走過清洗、特徵、訓練,模型已經被污染了,而且「比昨天多 100 倍」這種手寫規則只擋得住這一種變化。D 是組織流程,值得做,但你擋不住別的團隊改自己的系統,而且忘記通知一次就破功。C 把規矩寫在資料的入口:金額範圍一條就擋住單位變更,欄位與型別擋住改名,isin 擋住新類別;lazy=True 讓你一次看到全部問題,blocking=True 讓下游一步都不跑。合約寫一次,之後每一批資料都受保護。
Q2 錯誤診斷
同事回報:早上那批資料驗不過,他照著訊息修好 customer、重跑一次,又冒出 amount 的問題;修完再跑,又冒出 country。每一輪都要重等 40 分鐘的 ETL。最直接的修法是?
預設的 validate()(lazy=False)遇到第一個問題就拋 SchemaError——所以才會「修一個、炸一個」。加上 lazy=True,pandera 會把所有欄位都驗完再一起報 SchemaErrors(複數,差一個 s),e.failure_cases 是一張 DataFrame:哪一欄、違反哪一條、壞掉的值、哪一列。本課實測同一份資料,不 lazy 只看到 customer 那一筆,lazy 一次拿到 4 筆——一輪就修完。A 能勉強做到,但要自己重寫 pandera 已經提供的東西,而且拿不到表級檢查的結果;C 是把錯誤降級成警告,資料照樣進下游,等於沒驗;D 更糟:dropna 會把該被指名的問題悄悄刪掉,正好回到「安靜的錯誤」。
Q3 錯誤診斷
合約在資料庫來源上跑得好好的,換成讀每日 CSV 之後第一欄就掛了。資料本身沒問題,打開 CSV 看到的就是 1,2,3。最合適的修法是?
訊息說得很直白:合約要 int64,拿到的是 str。coerce=True 就是為這種「來源格式不同、語意相同」的情況設計的:pandera 先把欄位轉成宣告的型別再驗,轉不動才報錯,而且告訴你是哪個值(實測把某格改成 "abc" 會得到 Error while coercing 'order_id' to type int64 ... failure_case 0 1 abc)——這比自己轉型多了一層保護。B 是為了讓驗證通過而放棄合約:order_id 變成字串之後,ge(0) 這類數值檢查就不再成立,等於把規矩改鬆來配合資料。C 能動,但 astype(int) 遇到壞值直接拋 ValueError,錯誤訊息裡沒有欄名、沒有列號,也不會出現在 failure_cases 裡——你把驗證搬到了合約外面。D 沒有解決任何事,pa.Column(int) 本來就對應 int64。
Q4 情境題
團隊決定把合約 to_yaml() 存進 repo,讓後端與分析同事也能讀。有人提議「既然 YAML 是正本,程式碼裡就只留 from_yaml,其他刪掉」。你該說什麼?
實測:同一份合約存成 YAML 再載回來,拿「四筆欄位違約」的資料去驗,兩邊都抓到 4 筆、完全一致;但拿「乾淨、只是列數不足」的資料去驗,原本的 schema 擋得下來(1 筆違約:at least 400 rows),YAML 版 0 筆直接放行——因為表級 checks= 是 Python 函式,YAML 表達不了,存出來的最後一行就是 checks: null。這種「刪掉之後測試還是綠的、但保護少了一半」的改動最危險。B 說錯了方向:欄位層的檢查(isin、in_range、unique,連 str_matches 的正規表達式)都存得下來。C 是編造的行為。實務做法是兩層並存:欄位規則走 YAML 好 review 好共用,跨欄與整批規則留在程式碼裡,並在文件裡寫清楚哪一半在哪裡。
Q5 情境題
使用者行為日誌每天有 0.1%–0.3% 的列因為前端埋點問題而缺欄,這個比例三個月沒變過。目前 blocking=True 的合約每天早上擋住整條管線,值班的人手動放行已經變成例行公事。最合適的調整是?
「每天都會有一點髒資料、比例穩定、但不能停線」正是丟掉壞的列那一格的場景:drop_invalid_rows=True 搭 lazy=True 會把違約的列直接不要(實測 500 列丟 1 列剩 499 列),下游拿到的是一份乾淨資料。關鍵是 B 的後半句——把丟掉的列數記成 metadata:合約還在、資料品質仍然被量化,比例從 0.3% 跳到 8% 的那天你會知道。A 是把規矩改鬆去配合現況,之後真正的缺值故障也一起被放行了,而且那幾欄的下游計算並沒有因此變得能處理空值。C 一次拿掉全部閘門,連「金額變成負的」這種真的該停線的問題也不擋了。D 最糟:它保留了警報的成本卻拿掉了警報的意義,而且訓練所有人忽略紅燈——真正的事故發生那天,沒有人會多看一眼。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;全部在記憶體裡跑,不連任何伺服器
不想用 molab?下載 data-validation_ext.py 後在自己電腦
uvx marimo edit --sandbox data-validation_ext.py,依賴會自動安裝。
molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。