ML 測試:
用 pytest 幫模型寫行為測試
「這一版 AUC 0.968,比上一版好,可以上線了嗎?」——大多數團隊的模型驗收就停在這一句。 但那個數字測不到:加一點雜訊預測會不會翻掉、哪一群客戶特別不準、把某個特徵推高機率是不是往反方向跑、 少送一欄會炸還是默默算錯。這一課把那些「我相信模型應該怎樣」寫成 10 條會自己跑的斷言。 先挑一個模型,看同一套測試怎麼判它——
每一條的判定、每一句 E AssertionError 都是 notebook 跑同一份測試檔的實測輸出 (pytest 9.1.1、scikit-learn 1.9);點紅色那條的「看訊息」就是 pytest 印出來的原文。
軟體有單元測試,模型呢?
寫程式的人不會說「這支程式我跑過一次沒噴錯,可以上線了」——他們寫測試。 測試不是為了證明程式對,而是把「我相信它應該怎樣」寫成會自動跑的斷言: 以後任何人改任何一行,這些信念都被重新檢查一次。模型完全一樣,只是「應該怎樣」的內容不同。
| 軟體單元測試 | 模型行為測試 | |
|---|---|---|
| 測什麼 | 函式的輸入 → 輸出 | 模型的輸入 → 預測 |
| 誰會讓它變 | 有人改了程式碼 | 有人重訓了模型(換資料也算) |
| 通過的意思 | 這些行為還在 | 這些信念還成立 |
| 沒過怎麼辦 | 不准 merge | 不准註冊、不准晉升 champion |
ML 的測試金字塔跟軟體同一個形狀,只是每一層換了主角:底層是資料測試(第 9 課的 pandera 合約, 壞資料進不了管線)、中層是模型行為測試(本課)、上層是管線/整合測試(第 5 課, 訓練→評估→閘門→註冊真的跑得完)。第 5 課那個 quality_gate 是「一個數字的門檻」; 這一課把它擴成一整套 10 條測試。
到 notebook 的 1️⃣ 節:準備 champion 與兩個壞模型第一組:介面沒變
最基本、也最常被跳過的一組:形狀與範圍、少一欄要炸、同樣輸入跑兩次要一樣。 第二條特別值得說——吵鬧的失敗永遠好過安靜的錯誤。如果模型少一欄還能算出答案,那答案一定是錯的, 而且沒有人會發現。sklearn 對欄位很嚴格,實測三種破壞都會拋 ValueError, 但訊息不一樣:
所以 pytest.raises 一定要加 match=, 不然任何一種 ValueError(包括你自己寫錯測試造成的)都算它過。
這跟第 2 課的 signature 是什麼關係?
| signature(第 2 課) | 合約測試(本課) | |
|---|---|---|
| 誰寫的 | MLflow 在 log_model 時自動推論 | 你自己決定要承諾什麼 |
| 管什麼 | 欄位名稱與型別 | 任何介面性質:範圍、決定性、要不要炸 |
| 什麼時候擋 | 呼叫模型的當下 | 重訓完、還沒註冊之前 |
一句話:signature 是自動的合約,測試是你額外的承諾。
到 notebook 的 2️⃣ 節:三條合約測試與 conftest.py第二組:把品質閘寫成一組測試
第二條比第一條重要得多。絕對門檻只保證「不會爛到不能用」, 但真正常見的事故是「這一版比上一版差一點,卻因為還在門檻之上而被放行」—— 連續三次各退 1%,半年後你換了一個明顯更差的模型,而且每一次都合規。
第三條最容易被忽略、也最容易上新聞。整體 accuracy 是平均數,平均數會把某一群人的災難藏起來。 門檻要先實測再寫:把測試集切成幾群,看誰跟整體差最多。這份資料實測——
| 切片 | 客戶數 | champion 的 accuracy | 跟整體 0.9160 的落差 |
|---|---|---|---|
| f1 > 1 | 258 | 0.8837 | 低 0.0323(最慘的一群) |
| f0 < -1 | 270 | 0.9000 | 低 0.0160 |
| f3 > 1 | 208 | 0.9038 | 低 0.0122 |
| f0 > 1 | 64 | 0.9844 | 高 0.0684(最好的一群) |
切片要挑「你會被追究責任」的那些群:不同地區、不同方案、新客戶 vs 老客戶、樣本最少的那一群。 門檻通常比整體寬鬆(子群樣本少、波動大),但必須有——沒有它,你永遠不知道自己的平均數是誰在扛。
到 notebook 的 3️⃣ 節:三條表現測試與切片實測第三組:CheckList 的三件事
2020 年 Ribeiro 等人的論文《Beyond Accuracy: Behavioral Testing of NLP Models with CheckList》 提出一組到今天還在用的分類。原本講 NLP,換個主角完全成立:
| 類型 | 白話 | 這份資料的寫法 |
|---|---|---|
| 不變性 invariance | 改了不該影響結果的東西,預測就不該變 | 每欄加 sigma=0.01 的雜訊,98% 的預測要不變(champion 實測 0.9980) |
| 方向性 directional | 改了該往某方向影響的東西,預測要往那個方向動 | f2 調高 → 流失機率上升;f3 調高 → 下降 |
| 最低功能 minimum functionality | 幾筆「連新人都不會答錯」的樣本,一定要對 | 14 位教科書等級的流失客戶,機率不得低於 0.70 |
方向性:先看曲線,再寫斷言
「f2 調高,流失機率應該上升」這句話從哪來?不能用猜的。 做法是畫部分依賴:把整欄換成訓練分佈的 P05/P25/P50/P75/P95,看平均預測機率怎麼走。實測:
| 模型 | f2 的曲線(P05 → P95) | 總變化 | 判定 |
|---|---|---|---|
| champion | 0.141 → 0.197 → 0.553 → 0.716 → 0.751 | +0.610 | 單調上升、幅度夠 |
| shallow | 0.414 → 0.414 → 0.530 → 0.537 → 0.537 | +0.122 | 方向對,但幾乎沒反應 |
| shuffled | 0.484 → 0.490 → 0.478 → 0.490 → 0.501 | +0.017 | 上上下下,中途反轉 |
兩個斷言缺一不可:只檢查方向,抓不到「方向沒錯但根本沒反應」的 shallow; 只檢查幅度,抓不到「總量有動但中途亂走」的 shuffled。
黃金樣本:用領域規則挑,不要用模型挑
最低功能測試最像人工驗收:挑幾筆「一定要對」的樣本,每次重訓都問一次。挑法決定了它有沒有用—— 如果拿「模型最有把握的那幾筆」當黃金樣本,那測試只是讓模型同意自己,永遠會過。 這一課用領域規則挑:真的流失了,而且兩個主訊號都站在同一邊(f2 在 P80 以上、 f3 在 P20 以下)——實測挑出 14 位典型流失客戶、11 位典型續約客戶。 門檻不是「分類對就好」:機率不得低於 0.70,因為一個把所有人都猜 0.51 的模型分類全對,但它其實什麼都不知道。
到 notebook 的 4️⃣ 節:部分依賴曲線圖與三條行為測試一套從來沒紅過的測試,你不知道它會不會紅
綠燈很好看,但沒有意義——除非你看過它紅。把 MODEL_UNDER_TEST 換成兩個故意做壞的模型: shallow(深度 1 的樹,有點笨但正常,AUC 0.8903)與 shuffled(訓練前把標籤打亂, 看起來正常但完全沒學到東西,AUC 0.4637)。兩個都是 6 條紅、4 條綠——但紅的不是同一組:
| 測試 | shallow | shuffled | 為什麼 |
|---|---|---|---|
| 三條合約測試 | 通過 | 通過 | 合約測試抓不到爛模型——介面對不代表答案對 |
| test_min_auc | 失敗 | 失敗 | 0.8903 / 0.4637 都低於 0.95 |
| test_no_regression | 失敗 | 失敗 | 比上一版 logreg 的 0.8820 退步太多 |
| test_slice_not_much_worse | 失敗 | 通過 | shuffled 對每一群都一樣爛,切片跟整體沒有落差 |
| test_invariance_to_noise | 通過 | 失敗 | shallow 幾乎不隨輸入變(一致率 1.0000),shuffled 的決策邊界是噪音(0.9740) |
| test_directional ×2 | 失敗(幅度不足) | 失敗(中途反轉) | 同一條測試,兩種不同的失敗訊息 |
| test_golden_samples | 失敗 | 失敗 | 兩個都對「教科書客戶」沒有把握(0.4569 / 0.4005) |
這張表就是本課最重要的一句話:沒有任何一種測試能單獨守住模型。 合約測試放過了兩個垃圾模型;切片測試放過了 shuffled;不變性測試放過了 shallow,還給了它滿分 ——一個什麼都不學的模型,本來就最「穩定」。要靠一整組彼此互補的測試, 才會在不同的故障模式下各自亮紅燈。
換個說法:測試就是把 code review 的直覺自動化。資深同事看模型時腦子裡跑的就是這幾條—— 「這數字比上一版好嗎」「哪一群比較差」「把這個特徵推高會怎樣」「我隨手挑幾個案例看看」。 寫成 pytest 之後,這些直覺就不再依賴那位同事今天有沒有空。
到 notebook 的 5️⃣–6️⃣ 節:10 條全綠,再換兩個壞模型撞一次exit code 就是那道閘門
測試寫完只是一半,它要擋得住東西才算上線。pytest 的 exit code 實測有四種你會遇到:
| code | 意思 | 什麼時候出現 |
|---|---|---|
| 0 | 全部通過 | 正常 |
| 1 | 有測試失敗 | 你希望它擋下來的那種 |
| 2 | 收集階段就出錯、直接中斷 | parametrize 的參數對不上、import 失敗 |
| 5 | 一條測試都沒跑到 | 檔名沒有 test_ 前綴、-k 打錯字 |
5 是最危險的一個。CI 腳本如果寫「失敗=回傳 1」來判斷,那「一條都沒跑」會被當成成功放行—— 判準一律寫「不等於 0」。實測掃一個只有 checks_model.py 的資料夾, pytest 回的是 no tests ran in 0.00s 加 exit code 5; -k 打錯字則是 2 deselected,一樣 exit 5。
選測試:-k 與 mark
慢的測試(例如「用完整資料重訓一次再比較」)掛 @pytest.mark.slow, 平常 PR 只跑快的、每晚跑全部。mark 沒在 pytest.ini 註冊時, 打錯字只會得到一行 PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? ——而且測試照跑、照過,你不會發現自己少跑了東西。
接進 CI:測試綠了才准註冊
GitHub Actions 的預設行為就是「前一步非 0 就中斷」,所以不用自己寫判斷—— pytest 的 exit code 直接變成閘門。這跟第 5 課 Dagster 的 @asset_check(blocking=True) 是同一件事的兩種寫法: 一個在管線裡擋、一個在 CI 裡擋,通常兩個都要有(管線擋排程重訓,CI 擋人為改動)。
測試結果要跟著 run 走
通過與否跟 AUC 一樣,是這一版模型的性質。寫進 run 之後,半年後有人問 「上線那一版當時測試過了嗎、哪幾條沒過」,答案在 Registry 裡,不在誰的記憶裡。
到 notebook 的 7️⃣–8️⃣ 節:exit code、-k、MLflow,與可以自己按的測試面板補充系列走完了
這是 MLOps 補充系列的最後一堂。主線五課(第 1–5 課)把訓練變成有紀錄、有版本、會自動跑、有品質閘的東西; 補充八課各補上一塊:
| 課 | 一句話 | |
|---|---|---|
| 補充 A | 模型上線 | 批次評分、自包 FastAPI、mlflow models serve:模型要被「用」才叫上線 |
| 補充 B | 模型監控 | PSI/KS 看資料漂移、預測漂移是最省事的早期警報 |
| 補充 C | Optuna 調參 | 超參數搜尋自己跑,價值在「掃不完的空間」 |
| 補充 D | 資料驗證 | pandera 合約:把安靜的錯誤變成吵鬧的錯誤 |
| 補充 E | MLflow Tracing | LLM 應用的每一步都留下 span,出事看得到中間 |
| 補充 F | Feast 特徵倉 | 訓練與上線用同一份特徵,point-in-time join 防穿越 |
| 補充 G | DVC 資料版控 | 資料與模型也要有 git:內容定址、repro 只重跑改過的 |
| 補充 H | ML 測試(本課) | 把「模型應該怎樣」寫成 10 條 pytest,exit code 就是閘門 |
八堂課合起來是同一句話:讓「這個模型可以上線」變成一件有人能檢查、機器能重跑的事。
換你動手
加一條校準測試:平均預測機率與實際正例比率不得差超過 0.05。寫完先對 champion 跑(實測差 0.0210),再對另外兩個模型跑跑看——想一想,這條測試分辨得出好壞模型嗎?
把切片測試改成 parametrize 三個切片(f1>1、f0<-1、f3>1),讓報告一眼看出是哪一群客戶出事。champion 應該三條全綠,shallow 只有一條紅。
用 hypothesis 寫一條屬性測試:在訓練資料的值域內隨機生成幾百筆客戶,每一筆的機率都要合法。200 個例子應該 2 秒內跑完——但先別急著讓它綠,看看它第一個抓到的問題是什麼。
卡住了?每一題在 notebook 末節都有折疊解答與實測數字——先自己做,再打開對照。
情境測驗
離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。
Q1 情境題
新版模型整體 accuracy 從 0.912 升到 0.916,順利通過「AUC ≥ 0.95」的閘門上線。兩週後客服反映:某個方案的客戶被大量誤判。你要在測試裡加什麼,讓下次不會再發生?
症狀是「整體變好、某一群變壞」——這是平均數把子群災難藏起來的典型樣子,只有切片測試看得到。實測這份資料就有這種落差:整體 0.9160,但 f1>1 那 258 位客戶只有 0.8837(低 0.0323),而 f0>1 那群反而有 0.9844。切片門檻通常比整體寬鬆(子群樣本少、波動大),重點是要有,而且用 parametrize 讓測試名字帶上群名,報告才會直接說出是哪一群出事。A 把門檻拉高只是讓整體平均更難過關,子群落差照樣看不見(一個 AUC 0.98 但對某群特別差的模型仍然過關);B 不變性測的是「對雜訊穩不穩」,跟「某群特別不準」是兩件事——實測連什麼都沒學到的模型都能拿到 0.974 的一致率;D 是好測試但也只比整體數字,這一版整體確實變好了,它會是綠的。
Q2 錯誤診斷
同事把模型測試接上 CI,之後每次都是綠燈、非常快。你手動跑了一次,看到下面的輸出。發生了什麼、怎麼修?
no tests ran + exit code 5 是 pytest 專門用來說「我一條都沒收集到」的組合。最常見的原因是命名:掃資料夾時 pytest 只收 test_*.py,一個叫 checks_model.py 的檔案會被完全忽略(實測就是這個輸出);函式名沒有 test_ 前綴也一樣,而且那種更陰險——檔案裡其他測試照跑照過,你只是少跑了幾條。但真正要修的是 CI 的判準:很多腳本寫「exit code 是 1 就算失敗」,於是 5 被當成成功,綠燈連續好幾個月都是假的。判準一律寫「不等於 0」。A 若真被 skip,輸出會是 3 skipped 而不是 no tests ran;C 的 fixture 出錯會是 ERROR at setup + exit 1,例如 fixture 'modle' not found;D 沒有 pytest.ini 也照跑,它只影響 rootdir 與 mark 註冊。
Q3 情境題
你的模型測試只有一組不變性測試(加雜訊後預測不變)。這次重訓後它拿到 1.0000 的完美一致率,比上一版的 0.9980 還高。團隊想直接上線。你的判斷是?
這是本課實測過的陷阱:深度 1 的 shallow 模型一致率正好是 1.0000,比正常的 champion(0.9980)還「穩」——因為它幾乎對所有人都給同一個答案。一個什麼都不學的模型,不變性必然滿分。所以不變性不能單獨當驗收;它只有跟表現測試(AUC 0.8903,低於門檻)與方向性測試(f2 從 P05 拉到 P95 只動了 0.122)放在一起,才會顯示出這一版其實退化了。A 把門檻拉高只會讓「越呆越容易過」;B 這個數字是真的,不是測試壞掉——真相比 bug 更糟;C 加大雜訊是有用的補充實驗(champion 在 sigma=0.3 是 0.9540),但它仍然只在同一個軸上量,答不出「這個模型還會不會分辨客戶」。本課的核心結論就是這句:沒有任何一種測試能單獨守住模型。
Q4 錯誤診斷
你在 notebook 裡用 pytest.main() 反覆跑測試。剛剛把 test_model.py 裡的門檻從 0.90 改成 0.99(應該要失敗才對),重跑卻還是 1 passed、exit code 0。最可能的原因是?
Python 匯入過的模組會留在 sys.modules,而 pytest.main() 是在同一個行程裡跑的——所以第二次執行時,pytest 拿到的是上一次匯入的那份舊程式碼。實測非常明確:把一個必過的測試改成必敗的測試,不清快取重跑仍然是 1 passed、exit 0;把模組從 sys.modules 刪掉之後再跑,立刻變成 1 failed。修法就是在每次 pytest.main() 之前掃一遍 sys.modules,把 __file__ 落在測試資料夾裡的模組(包括 conftest.py)刪掉。B 的 .pytest_cache 只存「上次哪些失敗」給 --lf 用,不會改變測試結果(不過 -p no:cacheprovider 仍值得加,避免在工作目錄留垃圾);C 用 write_text() 寫檔會自己關檔,磁碟上的內容是新的——這正是最迷惑的地方,你打開檔案看是新的,跑起來卻是舊的;D exit code 完全準確,它只是誠實地回報了那份舊程式碼的結果。
Q5 情境題
你要幫流失模型建一組「黃金樣本」測試(幾筆一定要答對的客戶)。手上有訓練集、測試集與現在的 champion。最好的挑法是?
黃金樣本的意義是「連新人都不會答錯的案例」,所以挑選標準必須獨立於模型——用真實標籤加上領域知識(本課實測:真的流失了,且 f2 在 P80 以上、f3 在 P20 以下,挑出 14 位典型流失客戶)。門檻也不只是「分類對」而是「要有把握」(機率 ≥ 0.70):一個把所有人都猜 0.51 的模型分類全對,卻什麼都不知道——實測 shallow 對這 14 位的最低機率只有 0.4569,就這樣被抓出來。A 是最常見的錯誤:用模型自己最有把握的樣本當標準,等於讓模型同意自己,換一版只要行為相近就過,測不到任何東西。C 隨機抽會混進本來就模稜兩可的邊界案例,那些案例答錯很正常,測試會變得又脆弱又沒說服力(然後大家開始習慣性忽略它)。D 方向相反:模型答錯的通常正是最難的案例,把它們變成硬性門檻,會逼著下一版去過擬合這幾筆。
實作在 molab 跑(免費)
molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。
- 登入 molab(GitHub / Google)
- 開啟課程 notebook,Fork 成自己的副本即可編輯
- 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;測試檔都寫在暫存資料夾裡跑,不連任何伺服器
不想用 molab?下載 ml-testing_ext.py 後在自己電腦
uvx marimo edit --sandbox ml-testing_ext.py,依賴會自動安裝。
molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。