輸入驗證靜默失敗測試Python精算開源chainladder

放錯一行的檢查不會報錯,只會變成裝飾:chainladder predict 的四種靜默失敗

· 24 分鐘閱讀
目錄
  1. predict 在做什麼
  2. 四種錯法,都沒有錯誤訊息
  3. 修法的形狀是維護者定的
  4. 教訓一:合法與危險的寬鬆同形,要找系統自己的標記
  5. 教訓二:檢查要放在會改寫輸入的那一行之前
  6. 現況(2026-09-26)
  7. AI 使用揭露
  8. 資料來源

先講結論:寫輸入驗證的人可以帶走兩件事。第一,檢查放在任何會改寫輸入的程式碼之後,不會報錯,而是在它最該看見的那個情況安靜地失明,其他測試照樣全綠。第二,合法的寬鬆和危險的寬鬆在資料上常常長得一模一樣,用形狀分不開,要找系統自己寫下的標記,並把標記會失準的地方寫出來。兩件事都來自精算函式庫 chainladder-python 的同一個 predict,它在四種輸入不匹配時都回傳了看起來正常的結果。

立場揭露:issue #1288 和修正 PR #1310 都是我們(GitHub 帳號 ppcvote)開的。截至 2026 年 9 月 26 日,PR 開著、CI 全綠、沒有任何審查、沒有合併。PyPI 上最新的 0.10.1 和當天的 main 都還是原本的行為。PR 依 casact 的政策揭露了 AI 使用,原文在文末。

predict 在做什麼

chainladder-python 是美國產險精算學會(casact)維護的損失準備金函式庫。fit 從一個三角形學出發展型態 ldf_,predict(X) 把型態套到新的三角形 X,算出最終損失 ultimate_。三角形有索引(公司 GRNAME、業務線 LOB)與欄位(已付 CumPaidLoss、已發生 IncurLoss)。粒度的背景寫在上一篇 CapeCod.predict 的拆解。CapeCod 的 predict 還會用傳入的 X 與曝險重新估計 apriori,只沿用 fit 時的 ldf_,所以回傳的結果混了兩個時期的輸入。

predict 把型態掛到 X 上,再交給 intersection() 對齊兩邊。兩邊對不上時,它的做法是收窄到共同的部分;兩邊都只有一列時,第一行 if len(a) == 1 and len(b) == 1: return a, b 直接原樣放行。它負責對齊,不負責問為什麼對不上。

四種錯法,都沒有錯誤訊息

在 PR 的基底 commit e4e06956 上,用函式庫附的 clrd 樣本(775 列,每列是一家公司在一條業務線上的三角形),方法用 Chainladder:

情境 做法 結果
1. X 有模型沒見過的群組 用 wkcomp 以外 5 條業務線的加總 fit,predict 全部 775 列 ultimate_ 只剩 643 列,wkcomp 的 132 列不見,0 個警告
2. 型態比 X 細 用 775 列逐公司 fit,predict 6 條業務線的加總 6 列的輸入拿到 775 列的 ultimate_,索引多出 GRNAME 一層
3. 兩邊都只有一列 State Farm 的 comauto 型態,predict State Farm 的 othliab 1,638,150.65,標籤是 othliab;othliab 用自己的型態是 2,640,829.49,少 38%,0 個警告
4. 欄位不匹配 業務線加總的已付型態,predict 已發生資料 228,088,946,欄名寫著 IncurLoss;已發生用自己的型態是 150,105,776,高估 52%,0 個警告

第二種跑出 10 個 numpy 執行期警告(開根號與溢位),沒有一個提到粒度不匹配。第一種同樣的輸入交給 BornhuetterFerguson 與 CapeCod,會丟出 operands could not be broadcast together with shapes (775,1,10,1) (643,1,10,1):有報錯,但錯誤來自 numpy 的形狀不合,訊息裡沒有任何索引值。

同一支腳本在 main 08ded050 與 PyPI 的 0.10.1 上跑,輸出與基底相同。第一種與第四種的最短重現:

import chainladder as cl
clrd = cl.load_sample("clrd")
tri = clrd["CumPaidLoss"]

train = tri[tri.index["LOB"] != "wkcomp"].groupby("LOB").sum()
pred = cl.Chainladder().fit(cl.Development().fit_transform(train)).predict(tri)
tri.shape[0], pred.ultimate_.shape[0]            # (775, 643)

paid = clrd["CumPaidLoss"].groupby("LOB").sum()
incurred = clrd["IncurLoss"].groupby("LOB").sum()
model = cl.Chainladder().fit(cl.Development().fit_transform(paid))
model.predict(incurred).ultimate_.sum().sum()    # 228,088,946

我們沒有證據顯示有人的準備金因此算錯。這些數字說明的是:輸入錯了,回傳值看起來仍是一份正常的報表。

修法的形狀是維護者定的

我們在 9 月 4 日開 #1288。維護者 henrydingliu 9 月 5 日回覆:

i think we can simply error out on the prediction if intersection doesn't return the starting index. i believe this is the most consistent with other sklearn estimators when a feature introduces a new level at inference.

(intersection 回傳的索引跟輸入不同,就讓預測報錯;這與 sklearn 其他估計器在推論時遇到新類別的做法一致。)

我們最早提議把檢查寫進 intersection()。維護者提到他考慮過把 intersection 升成三角形的方法(#1037),不傾向在裡面加錯誤處理,改提議從 predict 抽出 validate_ldf,與既有的 validate_X、validate_weight 並列。我們回覆「validate_ldf is better than what I suggested and I withdraw the intersection() version.」PR 照這個形狀寫。

在 PR head bc2563dd 上,前三種都改成報錯,例如 X has index values the model was not fit on: ['wkcomp'],以及 The fitted pattern has index levels that X does not: ['GRNAME']. It cannot be applied to a triangle that does not carry them.。第四種沒有,原因在下一節。

教訓一:合法與危險的寬鬆同形,要找系統自己的標記

最直接的規則是「X 的索引值都必須出現在模型裡」。這條會誤殺既有測試 test_different_backends:它先把資料限縮到 wkcomp,用 clrd["CumPaidLoss"].sum() 這一列 fit,再 predict wkcomp 的 132 家公司。拿群組的加總型態套到群組的每個成員,是正當用法。

危險的那一種:comauto 業務線加總的型態,套到 othliab 業務線加總(PR 的測試 rejects_a_different_single_group)。

兩者在資料上同形:型態都只有一列,它的索引值都不在 X 裡。用列數或索引值比對,都分不開。

分開它們的是 chainladder 自己寫下的標記。沿第 0 軸聚合(sum、mean、median、max、min、prod、var)把一列以上收成一列時,函式庫會把索引值蓋成 "(All)"。validate_ldf 的第一條規則就是認這個標記:

if len(ldf) == 1 and set(ldf.index.values.flatten()) == {"(All)"}:
    return

這個標記有極限,PR 內文照實寫了:sum() 對任何子集都蓋 "(All)",所以「單一業務線的加總」與「全體的加總」一樣被豁免。在 PR head 上重現,wkcomp 加總的型態套到 comauto,回傳 157 列,沒有錯誤。要分開兩者得在三角形上記錄聚合來源,PR 內文寫的是「a larger change than this and your call rather than mine」。

欄位那一側,維護者從另一個系統預設值出發畫線。沒指定索引的三角形,建構時會拿到預設索引 "Total",他 9 月 23 日寫道:

an ibnr predictor trained on an unindexed triangle will work on every other unindex triangle, even if we start to enforce strict index matching.

他把這個先例延伸到欄位,列了一張五列的表,其中 B 列是「single column|single column|works|precedent from A + ML best practice」;多欄 fit、預測欄位不在其中的 E 列則是 raises。我們照表改了 PR(commit f647d352,把欄位檢查限定在 fit 超過一欄時)。結果是第四種在 PR head 上仍回傳 228,088,946、欄名 IncurLoss、沒有錯誤。哪一種寬鬆算合法,是維護者的設計決定;我們在回覆的表裡把這一格的代價寫成數字:paid -> incurred,原本 raised,改後 works,IncurLoss at 228,088,946。

可以重用的做法:寬鬆的輸入要不要放行,先查系統自己有沒有在合法的那一種上留下記號(哨兵值、建構時的預設值),用記號判斷,不用形狀判斷;再把記號會失準的地方寫進文件與程式註解。

教訓二:檢查要放在會改寫輸入的那一行之前

PR head 上 predict 的前段:

X_new = X.val_to_dev()
# Before the line below, which borrows self.X_'s index when both sides
# are a single row and so would erase what the caller actually passed.
self.validate_ldf(X_new, self.ldf_)
X_new = X_new + (self.X_.val_to_dev().iloc[0, 0].sum(2) * 0)
self.validate_weight(X_new, sample_weight)
X_new.ldf_ = self.ldf_
X_new, X_new.ldf_ = self.intersection(X_new, X_new.ldf_)

(中間省略了與本題無關的幾行。)關鍵是那一行乘以 0 的加法。它會經過 _prep_index,其中一段是:

if x.kdims.shape[0] == y.kdims.shape[0] == 1 and x.key_labels != y.key_labels:
    kdims = x.kdims if len(x.key_labels) > len(y.key_labels) else y.kdims
    ...
    x.kdims = y.kdims = kdims

兩邊都只有一列、索引層不同時,兩邊改用索引層較多那一邊的索引,一樣多就用模型那一邊的。呼叫端傳進來的身分,在加法之後可能已經換成模型的。實測:模型 fit 在 Aegis Grp 這家公司的加總(索引層 GRNAME),X 是 othliab 業務線加總(索引層 LOB)。加法前 X 的索引是 othliab,加法後是 Aegis Grp。基底上的 predict 回傳 3,625,080.92,標籤 Aegis Grp。

這條規則我們自己在 PR 裡寫錯過。PR 內文與我們 9 月 20 日在 PR 的留言都寫成:兩邊至少有一個相同的索引層名稱,結果就掛預測那一邊的標籤,完全沒有才借用模型的。重現不支持這個說法:Aegis Grp 的 comauto 型態(索引層 GRNAME、LOB)套到 othliab 業務線加總(索引層 LOB),兩邊共有 LOB,基底上的結果仍是 3,309,931.25、標籤 [['Aegis Grp', 'comauto']]。_prep_index 比的是索引層的數量,不是有沒有重疊。9 月 26 日我們已更正 PR 內文,並在 PR 上留言說明原本的規則寫錯了。

我們用腳本做了兩個錯位版本,一個把檢查放在加法後一行,一個放在 intersection 前一行,結果相同:

  • Aegis Grp 的 comauto 型態(這家公司 comauto 的最新對角線加總是 21)套到 othliab 業務線加總:回傳 3,309,931.25,標籤 [['Aegis Grp', 'comauto']],沒有錯誤。othliab 用自己的型態是 4,862,567.42,這個結果低約 32%。
  • Aegis Grp 公司加總套到 othliab 業務線加總:3,625,080.92,標籤 Aegis Grp,沒有錯誤。
  • State Farm 那一組(兩邊索引層相同)照樣報錯。

錯位的檢查沒有死,它仍擋下其他情況,只在「兩邊都一列、索引層不同」時失明,而那正是它要守的情況。

測試看得出來嗎?在 PR 第一個公開版本 fcbdf4b6 上把檢查往下移一行:六條新測試裡,其他五條照樣全綠,只有 test_predict_checks_before_the_index_is_borrowed 變紅(兩種後端各一,2 failed, 10 passed)。那五條的輸入,不是至少一邊多列,就是兩邊都一列但索引層相同(rejects_a_different_single_group 用的是 comauto 與 othliab 兩個業務線加總,索引層都是 LOB),進不了借用索引的分支。在 PR head 上,兩個錯位版本都是 2 failed, 15 passed,一樣只有順序測試紅;把檢查整個拿掉才是 10 failed。

公開的每個 PR 版本都已經把檢查放在加法之前,錯位版本沒有公開紀錄。唯一的紀錄是 PR 的 AI 揭露:順序問題是在「running the suite against earlier attempts that were wrong in each of those ways」時找到的。上面的數字是我們重現「錯位會發生什麼」,不是重現當時那一版。

可以重用的做法:

  1. 從輸入進來到檢查之間,列出每一行可能改寫你要檢查的東西的程式碼:廣播、對齊、補預設值、型別轉換、借用索引。檢查放在它們全部之前。
  2. 寫一條測試專門走進那個改寫分支,然後把檢查往下挪一行,確認這條測試會變紅。挪了還是全綠,代表你的測試集看不見順序。
  3. 在檢查上方寫明它為什麼必須在這裡。下一個人想把它挪到「看起來更自然」的位置時,註解和那條測試會擋在前面。

現況(2026-09-26)

  • PR #1310:9 月 7 日開立,已請三人審查,0 份審查。head bc2563dd 的 CI 全部通過(ruff、pyright、Python 3.11 到 3.14、pandas3、doctest、codecov、readthedocs),+233/-0,3 個檔案。
  • 我們 9 月 24 日在 PR 回報完整測試 1274 passed, 7 skipped,main 是 1260 與 7;多的 14 條是 7 條新測試乘兩種後端。把 PR 在本機合併到 main 08ded050,test_predict.py 32 passed,重現輸出與 PR head 相同。
  • 多欄 fit、只預測其中一欄時,表中 D 列要求收窄,PR head 仍回傳 fit 時的欄寬。我們在回覆裡說會等 #1310 合併後另開 PR,維護者回「narrows as a new PR sounds good」;截至今天還沒開。
  • 在修正發布之前,用 chainladder 的人可以在 predict 之後自己比對:ultimate_ 的列數與索引是否與輸入相同,model.X_.columns 是不是你以為的那一欄。

AI 使用揭露

PR 內文依 casact 的 AI 使用政策寫的是:

I used Claude Code on this. It ran the reproductions across the estimators and all three directions, and found both the single-row case and the call-site ordering by running the suite against earlier attempts that were wrong in each of those ways. I reviewed the diff and the tests myself, and the suite was run locally on my machine.

這篇的數字都在隔離的虛擬環境重跑過,指令列在下方。

資料來源

  • casact/chainladder-python issue #1288,2026-09-04 開立,開著:https://github.com/casact/chainladder-python/issues/1288
  • casact/chainladder-python PR #1310,2026-09-07 開立,開著,head bc2563dd:https://github.com/casact/chainladder-python/pull/1310 (維護者 9 月 23 日的五列表與我們的回覆都在這裡;PR 內文與我們 2026-09-20 留言中「哪一邊的標籤留下」的規則與重現不符,已於 2026-09-26 更正內文並留言:https://github.com/casact/chainladder-python/pull/1310#issuecomment-5847411009,見教訓二)
  • issue #1037,把 intersection 升為三角形方法的提案,開著
  • 程式碼(commit e4e06956 與 bc2563dd):chainladder/methods/base.py 的 MethodBase.predict、intersection、validate_ldf;chainladder/core/dunders.py 的 _prep_index;chainladder/core/pandas.py 的 "(All)";chainladder/core/triangle.py 的預設 "Total";chainladder/methods/tests/test_benktander.py 的 test_different_backends
  • 重現環境:Python 3.11.6、pandas 3.0.6、numpy 2.4.6;資料集 cl.load_sample("clrd");版本:PR 基底 e4e06956、PR head bc2563dd、第一個公開 head fcbdf4b6、main 08ded050、PyPI chainladder 0.10.1
  • 指令:四種情境與 (All) 豁免用 PYTHONPATH=<checkout> python repro.py(第一種與第四種的內容即上方程式碼);錯位版本的測試用 python -m pytest chainladder/methods/tests/test_predict.py -q -k "predict_rejects or predict_still or predict_checks or predict_columns or misaligned_index"
  • 日期皆為 GitHub 顯示的 UTC 時間

常見問題

chainladder 的 predict 在哪些情況會給出錯的結果卻不報錯?

在 PR 基底 commit e4e06956 上用 clrd 樣本重現到四種:預測資料含模型沒見過的業務線時,775 列進去只回 643 列;型態比資料細時,6 列的輸入拿到 775 列的 ultimate_;兩邊都只有一列時,State Farm 的 comauto 型態套到 othliab,回傳 1,638,150.65,比 othliab 用自己型態的 2,640,829.49 少 38%;已付型態套到已發生資料,回傳 228,088,946,比正確的 150,105,776 高估 52%。都沒有提到不匹配的錯誤或警告。

為什麼不直接要求預測資料的索引值都出現在模型裡?

那條規則會誤殺正當用法。既有測試 test_different_backends 用 wkcomp 的加總 fit,再 predict wkcomp 的 132 家公司,這是拿群組的型態套到群組成員。它跟「拿 comauto 業務線的型態套到 othliab 業務線」在資料上同形:型態都只有一列,索引值都不在預測資料裡。分開它們靠的是 chainladder 聚合時自己蓋上的 (All) 標記。

(All) 標記當判準有什麼限制?

它是字串比對,而且 sum() 對任何子集都會蓋上 (All)。所以單一業務線的加總和全體的加總一樣被豁免:在 PR head 上,wkcomp 加總的型態套到 comauto,回傳 157 列、沒有錯誤。PR 內文照實寫了這個限制,要分開兩者得在三角形上記錄聚合來源。

檢查為什麼放錯一行就失效?

predict 裡有一行乘以 0 的加法,會經過 _prep_index。兩邊都只有一列、索引層又不同時,它讓兩邊共用索引層較多的那一邊的索引,一樣多就用模型的。檢查若放在這行之後,看到的是兩個已經一致的三角形。實測:Aegis Grp 的 comauto 型態套到 othliab 業務線加總,回傳 3,309,931.25,標籤掛著 Aegis Grp 與 comauto,沒有錯誤。其他五條新測試照樣全綠,只有專門釘順序的那條變紅。

這個修正已經能用了嗎?

還沒有。截至 2026 年 9 月 26 日,PR #1310 開著、CI 全綠、沒有任何審查、沒有合併;PyPI 上的 0.10.1 與當天的 main 08ded050 都還是靜默行為。而且依維護者的設計,單欄對單欄的已付套已發生,在 PR 合併後仍會放行。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

想自己動手試試?

UltraProbe 免費、免註冊,掃一次網站就知道 Google 與 AI 找不找得到你。