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

模型監控:資料漂移、預測漂移,
與什麼時候該重訓

模型上線後最危險的事不是壞掉——壞掉會噴 500、警報會響、有人會被叫起來。危險的是它還在回答,只是答得越來越爛: 沒有例外、儀表板一片綠,準確率卻從 0.916 滑到 0.864,你要等季末業務抱怨才知道。 先玩玩看:把兩個特徵推歪,看監控指標怎麼反應——

每一根柱子都是 notebook 真的算出來的 PSI(12 個特徵 × 81 種漂移組合全部實跑過), accuracy 那一格在真實世界要等標籤回來才看得到——這堂課大半的功夫,就花在「還看不到它」的那段時間裡。

01 · 三種漂移

症狀看得到,病因要等

漂移變的是什麼什麼時候看得到
資料漂移
data / covariate drift
輸入 X 的分佈變了馬上——有輸入就算得出來
預測漂移
prediction drift
模型輸出的機率分佈變了馬上——模型有在跑就算得出來
概念漂移
concept drift
輸入與標籤的關係變了要等標籤——可能好幾週

前兩種是症狀(免費、即時、不需要標籤),第三種是病因(要等,而且常常等很久)。 監控的全部藝術就在這句話裡:用看得到的症狀,去猜看不到的病因,而且要在損失擴大之前決定要不要重訓。

這堂課的前導課用模擬曲線給你看過那條下滑的準確率(為什麼需要 MLOps), 而且做過一個很重要的對照:只有資料漂移時準確率 0.945 幾乎不掉,概念漂移時卻掉到 0.660—— 輸入變了不一定有事,輸入沒變也不一定沒事。這一課是它的工具版:同一件事,換成正式環境真的會用的指標與自動化。

素材沿用整個系列:2000 筆客戶流失資料、12 個特徵、RandomForest champion(accuracy 0.916、AUC 0.9684)。 我們刻意造一份漂移過的生產資料:f0 整欄平移 +1.5、f3 整欄放大 2 倍。 真實世界的漂移長這樣:上游把公分換成公尺、行銷把客群從 25 歲拉到 45 歲、某個欄位的預設值從 0 改成 NaN。 列數一樣、欄位一樣、沒有缺值,程式一個錯都不會報。

到 notebook 的 0️⃣ 節:champion 模型與那批怪怪的生產資料
02 · PSI 與 KS

先自己算,才知道工具在算什麼

def psi(ref, cur, bins=10): edges = np.quantile(ref, np.linspace(0, 1, bins + 1)) edges[0], edges[-1] = -np.inf, np.inf # 兩端開放,平移出去的資料才不會掉光 r = np.histogram(ref, edges)[0] / len(ref) + 1e-6 # 1e-6:空箱時不要 log(0) c = np.histogram(cur, edges)[0] / len(cur) + 1e-6 return float(np.sum((c - r) * np.log(c / r))) # PSI = Σ (c−r) × ln(c/r)

PSI(Population Stability Index)只有三步:用參考視窗的十分位數分箱、算兩邊落在每箱的比例、 把「比例差 × 比例的倍數變化」加總。兩個細節是血淚換來的:兩端要開放, 否則平移過的資料落不進任何箱子;比例要加一個極小值,否則只要有一箱是空的, log(0) 會讓整個 PSI 變成 inf(實測:f0 平移 8 不加 1e-6 就是 inf,加了是 12.434)。

最重要的一步:跑一組對照

參考視窗 → 當前視窗最大 PSI超過 0.1 的欄數
訓練集 1500 → 沒動過的 test 500(對照組)0.082(f40 / 12
訓練集 1500 → 漂移過的生產資料 5000.558f3)、0.487(f02 / 12

對照組的 PSI 不會是 0——兩份資料本來就是不同抽樣。這個「雜訊底線」(這裡是 0.082) 才是門檻該訂在哪裡的依據。業界慣例的 0.1/0.25 是起點,不是定理: PSI 會隨視窗大小變動(實測同一個小漂移,視窗 10000 筆時 PSI 0.010、縮到 50 筆時衝到 0.243), 所以視窗大小一改,門檻全部要重新校準。

KS 檢定:問錯問題的好工具

stats.ks_2samp(X_train["f3"], prod["f3"]).pvalue # 7.7e-20 ← 漂移過的,方向跟 PSI 一致 stats.ks_2samp(X_train["f3"], X_test["f3"]).pvalue # 0.0400 ← 對照組,什麼都沒發生也 < 0.05

KS 檢定量的是兩條累積分佈曲線的最大垂直距離,p 值回答「假設兩邊來自同一個分佈,看到這麼大差距的機率」。 問題是它回答的不是你想問的問題:它問「有沒有差異」,你想知道的是「差異大不大、要不要管」。 實測對照組裡 f3(p 0.0400)與 f7(p 0.0429)兩欄都低於 0.05—— 照「p < 0.05 就警報」寫,這兩欄今天就會叫你起床。更糟的是樣本越多它越吵: 完全沒漂移的資料,視窗 50 筆時 p = 0.92,10000 筆時 p 已經到 1e-05。

實務結論:用效果量(PSI/Wasserstein)當主判準、統計檢定當輔助,而且兩者一起看—— p 值小而且 PSI 大,才值得處理。

到 notebook 的 1️⃣2️⃣ 節:分箱表、對照組、視窗大小實驗
03 · 預測漂移

沒有標籤,也有東西可以看

p_ref = champion.predict_proba(X_test)[:, 1] # 上線第一週:模型沒看過、表現正常 p_prod = champion.predict_proba(prod)[:, 1] p_ref.mean(), p_prod.mean() # 0.507 → 0.471 平均機率 (p_ref > .5).mean(), (p_prod > .5).mean() # 0.520 → 0.444 判正率 psi(p_ref, p_prod) # 0.166 預測分佈 PSI

輸入有 12 欄,實務上可能有 300 欄,逐欄看很吵。預測漂移直接看模型的輸出:一欄就好、 不需要標籤、而且直接對應到業務影響(判為流失的比例掉了 15%,行銷名單就短了 15%)。 實測模型變得沒那麼有把握:高信心(>0.8)的客戶從 34% 掉到 26%。

⚠️ 參考分佈不能用訓練集的預測。模型看過訓練集,在上面的機率會過度自信。 實測拿訓練集預測當參考、測試集預測當當前,PSI 是 0.183——資料一個字都沒漂移, 分數卻比真的漂移(0.166)還高。參考視窗選錯,整套監控就是白做的。

訊號這次的數字什麼時候拿得到
資料漂移(最大 PSI)0.558(f3立刻
預測漂移(PSI)0.166立刻
accuracy0.916 → 0.864等標籤,可能好幾週

標籤回來之後才看得到的那一列:accuracy 掉了 5.2 個百分點,500 個客戶裡多判錯 26 個。 但注意 AUC 只從 0.9684 掉到 0.9575——排序能力幾乎沒壞,壞的是校準: 模型還是知道誰比較可能流失,只是「多少機率算高」那條線跑掉了。這種情況調門檻常常就能救回大半, 不一定要重訓——也就是為什麼警報之後還要有一個人去看一眼。

到 notebook 的 3️⃣ 節:預測分佈疊圖與 in-sample 陷阱
04 · EVIDENTLY

三行出報告,但預設值不是真理

from evidently import Dataset, DataDefinition, Report from evidently.presets import DataDriftPreset definition = DataDefinition(numerical_columns=FEATURES) # 型別宣告錯,方法就換掉 ref_ds = Dataset.from_pandas(X_train, data_definition=definition) cur_ds = Dataset.from_pandas(prod, data_definition=definition) snapshot = Report([DataDriftPreset()]).run(cur_ds, ref_ds) # 當前在前、參考在後 snapshot.dict()["metrics"] # 每項有 metric_name / config / value

Evidently 是目前最常見的開源漂移監控套件,它幫你做三件事:依欄位型別自動挑方法產出可以拿去開會的 HTML 報告把結果變成可程式判讀的字典。 預設方法是 Wasserstein distance(normed)、門檻 0.1。

被判漂移的欄數最高分第二高
實驗組(真的漂移了)3 / 12f3 0.813f0 0.695
對照組(什麼都沒發生)3 / 12f0 0.114f2 0.111

兩組的「被判漂移欄數」一模一樣。用預設門檻,一份完全沒漂移的資料照樣被判 3 欄漂移。 差別完全在分數的量級(0.813 vs 0.114)。三句話帶走: 「幾欄超標」是資訊量最低的指標,卻是最多人拿來當警報條件的那一個; 任何工具的預設門檻都要用自己的「已知正常」資料校準過對照組不是可選的

Wasserstein 量的是「把一堆土從參考分佈搬成當前分佈平均要搬多遠」,PSI 量的是「分箱後每箱比例變了幾倍」。 整欄平移兩者都抓得到;某一小群客戶忽然消失,PSI 反應大、Wasserstein 幾乎不動。 換方法只要 ValueDrift(column="f3", method="psi")——但實測它算出 0.973, 我們自己算是 0.558(分箱策略不同):門檻永遠綁在某一個實作上,換工具就要重新校準。

那份 HTML 報告實測 4.3 MB(互動圖表全部內嵌,所以打開不用網路)——這個大小塞進 notebook 會讓頁面明顯變重, 所以 notebook 裡是存成檔案、用瀏覽器開

到 notebook 的 4️⃣ 節:Evidently 報告、對照組誤判、換方法
05 · 決策

監控的產出不是數字,是一個動作

def decide(max_psi, streak, watch=0.10, alarm=0.25, need=3): if max_psi >= alarm and streak >= need: return "retrain" # 開工單 → 人確認 → 觸發重訓管線 if max_psi >= watch: return "watch" # 記錄下來、盯著,先不動模型 return "ok"

三個零件缺一不可:

  1. 分數門檻兩級(watch / alarm),而且要用自己的對照組校準過
  2. 連續 N 次——單一視窗超標很可能只是雜訊
  3. 人工確認——retrain 是開工單,不是自動開始重訓

第三點最常被跳過。重訓要算力、要驗證、要重走一次上線流程;而且有些漂移的正確處理方式根本不是重訓—— 是去修上游那個把公分改成公尺的服務。

跑 8 個星期看看

最大 PSI最吵的欄連續超標決策
10.108f40watch
20.130f40watch
30.086f40ok
4–50.156 / 0.150f4 / f30watch
60.352f31watch
70.431f32watch
80.526f33retrain

第 1 週就出現 watch,但那一週一點漂移都還沒注入(最吵的是我們從頭到尾沒碰過的 f4)—— 純粹是重抽樣的雜訊。規則若是「超過 0.1 就重訓」,第一週就白重訓一次。 第 6 週首次越過 0.25,但要到第 8 週連續第 3 次才觸發:代價是晚了兩週動手。 這就是監控的核心取捨——靈敏度 vs 誤報率,沒有免費的午餐。 你能做的是把它寫成明確的參數(alarmneed、視窗大小),而不是留在某個人的直覺裡。

到 notebook 的 5️⃣ 節:decide()、8 週模擬、決策曲線
06 · 接回管線

WARN 提醒、ERROR 擋路、sensor 叫人

@dg.asset_check(asset=drift_report, blocking=False) def psi_watch(drift_report) -> dg.AssetCheckResult: return dg.AssetCheckResult(passed=drift_report["max_psi"] < 0.10, severity=dg.AssetCheckSeverity.WARN) # 預設是 ERROR,要 WARN 得明寫 @dg.asset_check(asset=drift_report, blocking=True) def psi_alarm(drift_report) -> dg.AssetCheckResult: return dg.AssetCheckResult(passed=drift_report["max_psi"] < 0.25, severity=dg.AssetCheckSeverity.ERROR) # ERROR + blocking=擋死下游

監控寫成 notebook 只是分析,寫進管線才是維運。用第 3、4 課的 Dagster,監控就是 production_batch → drift_report → scored_output 三個資產,加上掛在中間那個資產上的兩個檢查。 兩個檢查對應監控的兩種語氣:

psi_watchpsi_alarm
severity / blockingWARN / FalseERROR / True
沒過的時候run 仍然成功,帳本留一筆黃色紀錄run 失敗、下游不執行
用來表達「有點怪,之後查」「這批分數不能用」

實測兩次執行:乾淨資料 → run 成功、三個資產全出(max_psi 0.082);漂移資料 → run 失敗、 scored_output 沒有產出(max_psi 0.558)。這正是你要的行為: 輸入已經不可信時,寧可今天沒有分數,也不要一批錯的分數流進業務系統。錯誤原文是 DagsterAssetCheckFailedError: 1 blocking asset check failed with ERROR severity: drift_report: psi_alarm

@dg.sensor(target=MONITORING, minimum_interval_seconds=3600) def retrain_sensor(context): event = context.instance.get_latest_materialization_event(dg.AssetKey("drift_report")) max_psi = float(event.asset_materialization.metadata["max_psi"].value) streak = int(context.cursor or 0) + 1 if max_psi >= 0.25 else 0 context.update_cursor(str(streak)) # 「連續 N 次」就存在 cursor 裡 if streak >= 3: return dg.RunRequest(run_key=..., tags={"max_psi": str(max_psi)}) return dg.SkipReason(f"max_psi={max_psi},連續超標 {streak}/3——先不重訓")

資產檢查會擋、會叫人,但不會觸發重訓——那是 sensor 的工作。notebook 裡用 evaluate_tick 直接跑五次 tick(不用起 daemon),漂移一次比一次嚴重, 到第 5 次才送出帶著 max_psi 標籤的 RunRequest。 把它的目標換成第 5 課那條訓練管線,就是完整閉環: 監控發現漂移 → 送出重訓請求 → 訓練 → 評估 → 品質閘 → 通過才移動 @champion。 品質閘還在——重訓出來的模型一樣要通過檢查,否則你只是把「模型變差」自動化了。

監控結果也要留下來:每個視窗的分數用 mlflow.log_metric("max_psi", v, step=週) 記成一條時間序列, client.get_metric_history() 讀回來就能問「最近 3 個視窗是不是都超標」—— decide() 要的 streak 不必自己另外存一份狀態。

到 notebook 的 6️⃣ 節:兩個檢查、五次 sensor tick、MLflow 漂移曲線
07 · 線上服務

API 上線之後,儀表板上要有哪四類數字

記什麼為什麼出事時的樣子
延遲 p50 / p95 / p99平均值會騙人平均 7 ms 很漂亮,p99 是 3 秒
錯誤率(依狀態碼)400 跟 500 是不同故障400 暴增=上游格式變了;500 暴增=服務壞了
輸入摘要(每欄筆數/平均/缺值率)原始輸入不能全存存摘要就夠算 PSI,而且不必留客戶原始資料
預測摘要(平均機率、判正率)最省事的早期警報判正率 52% → 44%,不用等標籤就知道有事

前兩類是軟體維運,任何 API 都要有;後兩類是機器學習特有的——模型不會拋例外,它只會安靜地越答越爛。 重點是後兩類要每個視窗存一列:監控要的不是原始請求,是可以跟參考視窗比較的摘要。 一天一列、每列幾十個數字,存一年也只是幾百 KB。

notebook 不起伺服器,直接量模型本身的延遲(這是 API 延遲的下限,真正的 API 還要加上 HTTP 與 schema 驗證): 單筆請求實測 p50 約 7 ms、p99 約 7–12 ms;同樣 500 列改成批次一次算完,每列只要 0.02–0.03 ms—— 差了兩百倍以上。這個比例就是上一課「批次評分 vs 線上 API」那個取捨的來源: 線上 API 買的是即時性,代價是每列成本高得多(你的機器數字會不同,看的是量級)。

到 notebook 的 7️⃣ 節:量延遲、組出一列監控紀錄
08 · 實戰

換你動手

LEVEL 1

換一欄注入漂移:把 f7 乘 0.4(把變異壓扁),重算整張漂移表。f7 會被指出來嗎?accuracy 掉多少?跟動 f0f3 比,哪一欄比較「重要」?

LEVEL 2

把「連續 3 次」換成「最近 4 個視窗裡有 3 次超標」(k-of-n),用 8 週模擬比較兩種規則各在第幾週觸發。在 0.25 門檻下結果一樣,把門檻降到 0.1 再比一次——差別就出來了。

LEVEL 3

換掉 Evidently 的預設:用 ValueDrift(method="psi") 自組一份 12 欄報告、門檻用你從對照組校準出來的數字;或加一個 ClassificationPreset() 產生「有標籤之後」的品質報告。

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

09 · 驗收

情境測驗

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

Q1 情境題

模型上線第一週,你用 Evidently 預設設定跑漂移報告,結果說 12 欄裡有 3 欄漂移。第一件該做的事是?

本課實測:一份完全沒有漂移的資料(訓練集 vs 沒動過的測試集),用 Evidently 預設值照樣被判 3/12 欄漂移(f0 0.114、f2 0.111、f3 0.105)——跟真的漂移時「欄數」一模一樣,差別只在分數量級(真漂移是 0.813/0.695)。所以看到「3 欄漂移」的第一件事永遠是建立對照組、校準門檻。A 是拿一個沒有校準過的警報去花真金白銀;B 方向對但做法反了——調門檻要有依據,隨手調高只是把眼睛蒙起來;D 更糟,KS 在大樣本下更敏感,實測對照組裡就有兩欄 p < 0.05。

Q2 錯誤診斷

同事的監控報告每天都說「12 欄全部漂移」,而且每一欄的分數都一模一樣。程式沒有拋任何錯誤。最可能的原因是?

DriftedColumnsCount(drift_share=0.5) -> {'count': 12.0, 'share': 1.0} ValueDrift(column=f0,method=Jensen-Shannon distance,threshold=0.1) -> 0.8325546111576977 ValueDrift(column=f1,method=Jensen-Shannon distance,threshold=0.1) -> 0.8325546111576977

證據就在方法名稱上:預設的數值欄方法是 Wasserstein distance (normed),這裡卻是 Jensen-Shannon distance——那是類別欄的方法。把連續數值宣告成類別後,每個浮點數都是獨一無二的「類別」,兩份資料的類別集合幾乎不重疊,於是每一欄都得到同一個接近上限的分數(實測 0.8326)。型別宣告錯不會報錯,只會讓報告很有自信地騙你。A 不會產生一模一樣的分數;C 就本課實測而言 PSI/Wasserstein 是對稱的,寫反不會有這種結果(但自己用參考分位數分箱的 PSI 會變:0.558 → 0.837,所以寫反仍然是壞習慣);D 的話分數不會每欄都相同到小數點後十位。

Q3 情境題

客戶是不是真的流失,要 4 週後才會知道。老闆今天問「模型還能用嗎」。你手上有什麼、該怎麼回答?

沒有標籤的期間,你有兩類即時訊號:輸入分佈與輸出分佈。本課實測兩者方向一致(最大 PSI 0.558、預測 PSI 0.166、判正率 0.520 → 0.444),而事後才拿得到的 accuracy 確實從 0.916 掉到 0.864——症狀猜對了病因。A 放棄了兩個免費且即時的訊號;B 是本課特別點名的陷阱,模型看過訓練集、機率過度自信,實測會算出 0.183 的假漂移;C 混淆了兩種故障模式,模型變爛時 API 照樣 200、延遲照樣漂亮——那正是它危險的地方。

Q4 錯誤診斷

自己寫的 PSI 函式平常都正常,某天某一欄回傳 inf,同時噴出這行警告。怎麼修最對?

RuntimeWarning: divide by zero encountered in log return float(np.sum((c - r) * np.log(c / r))) psi(X_train["f0"], window["f0"]) -> inf

divide by zero encountered in log 指的就是 c/r 裡有 0:整欄被推得離參考分佈很遠時,靠近參考低端的那幾個箱子在當前視窗一筆都沒有。實測同一份資料(f0 平移 8),不加極小值得到 inf、加了 1e-6 得到 12.434——一個沒法比較大小、一個可以排序與畫趨勢。A 症狀不符,缺值會讓 np.histogram 少算而不是產生 0 比例的 log;C 方向完全相反,箱子越多每箱越稀疏、空箱只會更多;D 很危險——inf 一旦進了平均、趨勢圖或門檻比較就會污染整條監控管線,而且它掩蓋了「到底漂多遠」這個你真正需要的資訊。

Q5 情境題

監控連續三週警報 → 重訓 → 新模型通過品質閘 → @champion 已移到新版本。接下來最該做、卻最常被忘記的一件事是?

參考視窗代表「模型認識的世界」。模型換了,那個世界也換了——不更新參考視窗,新模型上線第一天就會對你發出「嚴重漂移」的警報,因為它本來就是照著漂移後的資料訓練的。這種每次重訓後都會出現的假警報,是團隊開始無視監控的頭號原因。B 是用調高門檻掩蓋設定錯誤,真的漂移來的時候你也看不到了;C 把回滾的退路砍了——舊版本留著,alias 一行就能指回去;D 等於在最需要把關的時候拆掉煞車,而且下游拿到的會是用未經驗證的新模型算出來的分數。

HANDS-ON · MOLAB

實作在 molab 跑(免費)

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

  1. 登入 molab(GitHub / Google)
  2. 開啟課程 notebook,Fork 成自己的副本即可編輯
  3. 從第一格往下全部執行(首次安裝套件約 1–2 分鐘)——免費 CPU 環境即可,不需要 GPU;整份跑完約 1–2 分鐘

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

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