一個字元的 bug 讓 775 列準備金全錯:chainladder CapeCod.predict 拆解
我做了十年財務顧問,精算是隔壁那間房。chainladder-python 是美國產險精算學會(Casualty Actuarial Society)維護的損失準備金函式庫,精算師拿它算「已發生但尚未報案」的賠款要提多少準備金。2026 年 9 月 5 日,我在裡面修的第二筆 PR 被合併,改動是一個字元。
這篇把那一個字元前後的事情全部攤開:這個方法在算什麼、那一行為什麼錯、為什麼既有測試沒抓到、以及我怎麼跟維護者談定修法。
Cape Cod 方法在算什麼
損失準備金的經典做法是鏈梯法(chain ladder):看過去各年賠款隨時間發展的比例,推估今年最終會賠多少。問題是最近的年度資料很少,發展比例乘上去誤差很大。
Cape Cod 方法的做法是先估一個「預期損失率」,也就是 apriori:用所有年度已經發展到的賠款除以對應的已賺保費,得到一個整體的損失率,再用它去補最近年度還沒發展出來的部分。這個 apriori 是整個方法的核心,估錯它,後面每一列準備金都跟著錯。
在 chainladder-python 裡,CapeCod().fit(triangle, sample_weight=premium) 擬合出 apriori_,predict(new_triangle, sample_weight=new_premium) 用擬合好的 apriori 對新資料做預測。
粒度:模型在哪一層擬合,就要在哪一層預測
精算資料有「粒度」的問題。同一份資料可以按業務線(LOB)看,也可以按公司(GRNAME)看,或兩者都分。clrd 這個公開樣本就是這樣:每一列是一家公司在一條業務線上的三角形。
常見的做法是在粗的粒度擬合、在細的粒度預測。例如按業務線擬合 apriori(把同業務線所有公司加總),然後對每一家公司分別預測,讓每家公司用它所屬業務線的 apriori:
import chainladder as cl
clrd = cl.load_sample("clrd")
tri, prem = clrd["CumPaidLoss"], clrd["EarnedPremDIR"].latest_diagonal
model = cl.CapeCod().fit(tri.groupby("LOB").sum(),
sample_weight=prem.groupby("LOB").sum())
model.predict(tri, sample_weight=prem)
predict() 得先判斷:預測資料的粒度比模型細嗎?細的話要先聚合回模型的粒度,用擬合好的 apriori;不細的話直接用。判斷方式是數預測資料的 sample_weight 比 apriori_ 多出幾層索引。
那一行
if len(set(sample_weight.key_labels) - set(self.apriori_.key_labels)) > 1:
條件寫的是「多出的索引層數大於一」。上面的例子,模型在 LOB 擬合,預測資料有 LOB 和 GRNAME 兩層,多出的是一層:GRNAME。一不大於一,落到 else 分支,用預測資料重新算 apriori,擬合好的那個被丟掉。
實測 comauto 業務線:擬合出的 apriori 是 0.5689995797,predict() 回傳的是 1.2516635774。clrd 總共 775 列,775 列全部跟自己業務線的擬合值不同。修正後每一列帶的都是擬合值,expectation_ 在 775 列上都剛好等於保費乘以 apriori。
修法:> 1 改成 > 0。多出任何一層索引,就聚合。
為什麼既有測試沒抓到
repo 裡有 test_capecod_predict2,明明覆蓋這條路徑。我去看它用的資料集:prism。prism 的三角形比擬合模型多五層索引。五大於一,永遠走聚合的分支,永遠不會碰到「剛好多一層」。
這是測試覆蓋率報表不會告訴你的事:那一行被執行了,覆蓋率是綠的,但它只在一個永遠不會觸發 bug 的輸入上被執行。測試覆蓋了分支,沒有覆蓋邊界。 新的回歸測試用 clrd,剛好多一層,並且明確斷言多出來的層就是 {"GRNAME"},斷言預測的 apriori 跟擬合的 apriori 相等,斷言兩邊 ultimate 加總差距在 1e-6 內。這個測試在未修的程式碼上失敗,在修好的程式碼上通過,我兩邊都跑過。
修法是先談定的,程式碼是後寫的
這筆 PR 的來源是 issue #1265。維護者 henrydingliu 在那個 issue 裡就已經指出 > 0 是對的修法,並請人開 PR。我做的是重現、量化影響、寫出能抓到這個邊界的測試,以及確認文件範例不受影響:predict() 的 docstring 範例用 ukmotor,擬合和預測在同一粒度,層數差是空的,兩個分支都不會變,它記錄的輸出仍然逐字重現。
issue 裡順帶冒出的 API 設計問題,我拆成另一個 issue #1274,不塞進這筆 PR。
合併前維護者只要求一件事:順手把 ruff 的 lint 修一修。我修了:capecod.py 有一個沒用到的 numpy import,測試檔裡有幾處空白,並且把這兩個檔案從 per-file-ignores 清單拿掉,因為表格的註解說檔案清乾淨就要移除,兩個都乾淨了。ruff format 我沒動,它會改測試檔 107 行裡的 40 行,那不是這筆 PR 的事。
AI 使用揭露
casact 有 AI 使用政策,要求貢獻者揭露。我在 PR 裡寫的是:
我用了 Claude Code。它重現了 bug、跑了 clrd 上的前後比較、起草了回歸測試。那一個字元的修法是我和 henrydingliu 在 #1265 裡談定的,在任何程式碼寫出來之前。diff 和測試我自己審過,測試套件在我的機器上跑:1130 passed, 7 skipped。
維護者合併時對這段沒有任何意見。我的看法是:揭露的重點不是「有沒有用 AI」,是「哪些判斷是人做的」。修法的決定、範圍的界定、不動 ruff format 的取捨,這些是人的事。重現和起草測試,工具做得比我快,沒有理由不用。
三個帶走的東西
- 一個字元可以讓整份報表全錯,而且不會報錯。 準備金算出來是一個看起來合理的數字,沒有 traceback。這種 bug 只有拿擬合值和預測值對照才看得到。
- 覆蓋率綠不代表邊界有被測。 問自己:這個測試用的輸入,能不能觸發我要防的那個條件?不能的話,它在保護的是別的東西。
- 在 issue 裡先談定,PR 就會很短。 這筆 PR 從開到合併約 57 小時(2 天 9 小時),因為維護者要審的只有「你證明了什麼」,不是「你想做什麼」。
PR 原頁:casact/chainladder-python#1275。同一個 repo 三星期前的另一筆,#1205,我在描述裡犯了一個維護者當場抓出來的錯,那段寫在七筆 PR 的總整理裡。