AI 互動教室 ‹ MLOps 自動化技術
下載 .py 開啟實戰 notebook ↗ 留言回報
ML TESTING · 補充 H · 13

ML 測試:
用 pytest 幫模型寫行為測試

「這一版 AUC 0.968,比上一版好,可以上線了嗎?」——大多數團隊的模型驗收就停在這一句。 但那個數字測不到:加一點雜訊預測會不會翻掉、哪一群客戶特別不準、把某個特徵推高機率是不是往反方向跑、 少送一欄會炸還是默默算錯。這一課把那些「我相信模型應該怎樣」寫成 10 條會自己跑的斷言。 先挑一個模型,看同一套測試怎麼判它——

要測哪一版
exit 1 不會註冊

每一條的判定、每一句 E   AssertionError 都是 notebook 跑同一份測試檔的實測輸出 (pytest 9.1.1、scikit-learn 1.9);點紅色那條的「看訊息」就是 pytest 印出來的原文。

01 · 為什麼

軟體有單元測試,模型呢?

寫程式的人不會說「這支程式我跑過一次沒噴錯,可以上線了」——他們寫測試。 測試不是為了證明程式對,而是把「我相信它應該怎樣」寫成會自動跑的斷言: 以後任何人改任何一行,這些信念都被重新檢查一次。模型完全一樣,只是「應該怎樣」的內容不同。

軟體單元測試模型行為測試
測什麼函式的輸入 → 輸出模型的輸入 → 預測
誰會讓它變有人改了程式碼有人重訓了模型(換資料也算)
通過的意思這些行為還在這些信念還成立
沒過怎麼辦不准 merge不准註冊、不准晉升 champion

ML 的測試金字塔跟軟體同一個形狀,只是每一層換了主角:底層是資料測試(第 9 課的 pandera 合約, 壞資料進不了管線)、中層是模型行為測試(本課)、上層是管線/整合測試(第 5 課, 訓練→評估→閘門→註冊真的跑得完)。第 5 課那個 quality_gate 是「一個數字的門檻」; 這一課把它擴成一整套 10 條測試。

到 notebook 的 1️⃣ 節:準備 champion 與兩個壞模型
02 · 合約測試

第一組:介面沒變

def test_output_shape_and_range(model, X): p = model.predict_proba(X) assert p.shape == (len(X), 2), f"形狀是 {p.shape},期待 {(len(X), 2)}" assert ((p >= 0) & (p <= 1)).all(), "有機率跑出 [0, 1] 之外" assert np.allclose(p.sum(axis=1), 1.0), "每列兩個機率相加不等於 1" def test_missing_column_raises(model, X): with pytest.raises(ValueError, match="feature names"): # 少一欄要「炸」,不是默默算 model.predict(X.drop(columns="f11"))

最基本、也最常被跳過的一組:形狀與範圍、少一欄要炸、同樣輸入跑兩次要一樣。 第二條特別值得說——吵鬧的失敗永遠好過安靜的錯誤。如果模型少一欄還能算出答案,那答案一定是錯的, 而且沒有人會發現。sklearn 對欄位很嚴格,實測三種破壞都會拋 ValueError, 但訊息不一樣:

少一欄 → ValueError: The feature names should match those that were passed during fit. Feature names seen at fit time, yet now missing: - f11 多一欄 → Feature names unseen at fit time: - extra 順序不同 → Feature names must be in the same order as they were in fit.

所以 pytest.raises 一定要加 match=, 不然任何一種 ValueError(包括你自己寫錯測試造成的)都算它過。

這跟第 2 課的 signature 是什麼關係?

signature(第 2 課)合約測試(本課)
誰寫的MLflow 在 log_model 時自動推論你自己決定要承諾什麼
管什麼欄位名稱與型別任何介面性質:範圍、決定性、要不要炸
什麼時候擋呼叫模型的當下重訓完、還沒註冊之前

一句話:signature 是自動的合約,測試是你額外的承諾。

到 notebook 的 2️⃣ 節:三條合約測試與 conftest.py
03 · 表現測試

第二組:把品質閘寫成一組測試

def test_min_auc(model, X, y): # ① 夠不夠好 auc = roc_auc_score(y, model.predict_proba(X)[:, 1]) assert auc >= 0.95, f"AUC {auc:.4f} 低於上線門檻 0.95" def test_no_regression_vs_baseline(model, X, y): # ② 有沒有退步 prev = json.loads((HERE / "baseline.json").read_text()) acc = accuracy_score(y, model.predict(X)) assert acc >= prev["accuracy"] - 0.02, ... def test_slice_not_much_worse(model, X, y): # ③ 有沒有哪一群特別慘 mask = (X["f3"] > 1).to_numpy() assert accuracy_score(y[mask], model.predict(X[mask])) >= overall - 0.05, ...

第二條比第一條重要得多。絕對門檻只保證「不會爛到不能用」, 但真正常見的事故是「這一版比上一版差一點,卻因為還在門檻之上而被放行」—— 連續三次各退 1%,半年後你換了一個明顯更差的模型,而且每一次都合規。

第三條最容易被忽略、也最容易上新聞。整體 accuracy 是平均數,平均數會把某一群人的災難藏起來。 門檻要先實測再寫:把測試集切成幾群,看誰跟整體差最多。這份資料實測——

切片客戶數champion 的 accuracy跟整體 0.9160 的落差
f1 > 12580.8837低 0.0323(最慘的一群)
f0 < -12700.9000低 0.0160
f3 > 12080.9038低 0.0122
f0 > 1640.9844高 0.0684(最好的一群)

切片要挑「你會被追究責任」的那些群:不同地區、不同方案、新客戶 vs 老客戶、樣本最少的那一群。 門檻通常比整體寬鬆(子群樣本少、波動大),但必須有——沒有它,你永遠不知道自己的平均數是誰在扛。

到 notebook 的 3️⃣ 節:三條表現測試與切片實測
04 · 行為測試

第三組: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)總變化判定
champion0.141 → 0.197 → 0.553 → 0.716 → 0.751+0.610單調上升、幅度夠
shallow0.414 → 0.414 → 0.530 → 0.537 → 0.537+0.122方向對,但幾乎沒反應
shuffled0.484 → 0.490 → 0.478 → 0.490 → 0.501+0.017上上下下,中途反轉
@pytest.mark.parametrize("col,sign", [("f2", +1), ("f3", -1)]) def test_directional(model, X, col, sign): grid = json.loads((HERE / "quantiles.json").read_text())[col] curve = np.array([model.predict_proba(X.assign(**{col: q})[X.columns])[:, 1].mean() for q in grid]) monotone = bool((np.diff(curve) * sign >= 0).all()) assert monotone, f"{col} 往預期方向動時,機率中途反轉:{curve.round(3)}" # 抓 shuffled move = float((curve[-1] - curve[0]) * sign) assert move >= 0.20, f"{col} 從 P05 拉到 P95,流失機率只動了 {move:.3f}" # 抓 shallow

兩個斷言缺一不可:只檢查方向,抓不到「方向沒錯但根本沒反應」的 shallow; 只檢查幅度,抓不到「總量有動但中途亂走」的 shuffled。

黃金樣本:用領域規則挑,不要用模型挑

最低功能測試最像人工驗收:挑幾筆「一定要對」的樣本,每次重訓都問一次。挑法決定了它有沒有用—— 如果拿「模型最有把握的那幾筆」當黃金樣本,那測試只是讓模型同意自己,永遠會過。 這一課用領域規則挑:真的流失了,而且兩個主訊號都站在同一邊f2 在 P80 以上、 f3 在 P20 以下)——實測挑出 14 位典型流失客戶、11 位典型續約客戶。 門檻不是「分類對就好」:機率不得低於 0.70,因為一個把所有人都猜 0.51 的模型分類全對,但它其實什麼都不知道。

到 notebook 的 4️⃣ 節:部分依賴曲線圖與三條行為測試
05 · 讓它紅

一套從來沒紅過的測試,你不知道它會不會紅

綠燈很好看,但沒有意義——除非你看過它紅。把 MODEL_UNDER_TEST 換成兩個故意做壞的模型: shallow(深度 1 的樹,有點笨但正常,AUC 0.8903)與 shuffled(訓練前把標籤打亂, 看起來正常但完全沒學到東西,AUC 0.4637)。兩個都是 6 條紅、4 條綠——但紅的不是同一組

測試shallowshuffled為什麼
三條合約測試通過通過合約測試抓不到爛模型——介面對不代表答案對
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 條全綠,再換兩個壞模型撞一次
06 · 放進流程

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 -q -k "directional or golden" # 名字比對,只跑幾條 pytest -q -m "not slow" # 依 mark 過濾,跳過慢的(要在 pytest.ini 註冊 slow) pytest -q --collect-only # 只列出會跑哪些,不執行 pytest -q --tb=line # 每個失敗只印一行(CI log 最好讀)

慢的測試(例如「用完整資料重訓一次再比較」)掛 @pytest.mark.slow, 平常 PR 只跑快的、每晚跑全部。mark 沒在 pytest.ini 註冊時, 打錯字只會得到一行 PytestUnknownMarkWarning: Unknown pytest.mark.slwo - is this a typo? ——而且測試照跑、照過,你不會發現自己少跑了東西。

接進 CI:測試綠了才准註冊

name: model-tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - name: 跑模型測試(慢的留給夜間排程) env: MODEL_UNDER_TEST: candidate run: uv run pytest tests/ -q --tb=short -m "not slow" - name: 只有全綠才註冊並移動 champion alias run: uv run python scripts/promote.py

GitHub Actions 的預設行為就是「前一步非 0 就中斷」,所以不用自己寫判斷—— pytest 的 exit code 直接變成閘門。這跟第 5 課 Dagster 的 @asset_check(blocking=True) 是同一件事的兩種寫法: 一個在管線裡擋、一個在 CI 裡擋,通常兩個都要有(管線擋排程重訓,CI 擋人為改動)。

測試結果要跟著 run 走

mlflow.log_metric("tests_passed", 4) mlflow.log_metric("tests_failed", 6) mlflow.set_tag("tests_green", "False") mlflow.log_dict({"exit_code": 1, "failed": ["test_min_auc", "test_golden_samples", ...]}, "tests/summary.json")

通過與否跟 AUC 一樣,是這一版模型的性質。寫進 run 之後,半年後有人問 「上線那一版當時測試過了嗎、哪幾條沒過」,答案在 Registry 裡,不在誰的記憶裡。

到 notebook 的 7️⃣–8️⃣ 節:exit code、-k、MLflow,與可以自己按的測試面板
07 · 系列收尾

補充系列走完了

這是 MLOps 補充系列的最後一堂。主線五課(第 1–5 課)把訓練變成有紀錄、有版本、會自動跑、有品質閘的東西; 補充八課各補上一塊:

一句話
補充 A模型上線批次評分、自包 FastAPI、mlflow models serve:模型要被「用」才叫上線
補充 B模型監控PSI/KS 看資料漂移、預測漂移是最省事的早期警報
補充 COptuna 調參超參數搜尋自己跑,價值在「掃不完的空間」
補充 D資料驗證pandera 合約:把安靜的錯誤變成吵鬧的錯誤
補充 EMLflow TracingLLM 應用的每一步都留下 span,出事看得到中間
補充 FFeast 特徵倉訓練與上線用同一份特徵,point-in-time join 防穿越
補充 GDVC 資料版控資料與模型也要有 git:內容定址、repro 只重跑改過的
補充 HML 測試(本課)把「模型應該怎樣」寫成 10 條 pytest,exit code 就是閘門

八堂課合起來是同一句話:讓「這個模型可以上線」變成一件有人能檢查、機器能重跑的事。

08 · 實戰

換你動手

LEVEL 1

加一條校準測試:平均預測機率與實際正例比率不得差超過 0.05。寫完先對 champion 跑(實測差 0.0210),再對另外兩個模型跑跑看——想一想,這條測試分辨得出好壞模型嗎?

LEVEL 2

把切片測試改成 parametrize 三個切片(f1>1f0<-1f3>1),讓報告一眼看出是哪一群客戶出事。champion 應該三條全綠,shallow 只有一條紅。

LEVEL 3

hypothesis 寫一條屬性測試:在訓練資料的值域內隨機生成幾百筆客戶,每一筆的機率都要合法。200 個例子應該 2 秒內跑完——但先別急著讓它綠,看看它第一個抓到的問題是什麼。

卡住了?每一題在 notebook 末節都有折疊解答與實測數字——先自己做,再打開對照。

09 · 驗收

情境測驗

離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。

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,之後每次都是綠燈、非常快。你手動跑了一次,看到下面的輸出。發生了什麼、怎麼修?

$ pytest -q tests/ no tests ran in 0.00s $ echo $? 5

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 方向相反:模型答錯的通常正是最難的案例,把它們變成硬性門檻,會逼著下一版去過擬合這幾筆。

HANDS-ON · MOLAB

實作在 molab 跑(免費)

molab 的登入狀態進不了內嵌框架(瀏覽器的跨站 cookie 保護), 所以 notebook 要在新分頁執行——把它跟本頁並排開,左邊教學照樣對照。

  1. 登入 molab(GitHub / Google)
  2. 開啟課程 notebook,Fork 成自己的副本即可編輯
  3. 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;測試檔都寫在暫存資料夾裡跑,不連任何伺服器

不想用 molab?下載 ml-testing_ext.py 後在自己電腦 uvx marimo edit --sandbox ml-testing_ext.py,依賴會自動安裝。

molab 的線上編輯器在手機上體驗有限——動手這一段建議用電腦進行。