你的 predict 到底在做什麼:chainladder CapeCod 回傳的第三個數字
目錄
先講結論:chainladder-python 的 CapeCod.predict 不是把你存下的模型套到新資料上。它每次都用傳入的新資料重新估計 apriori,但沿用擬合當時的發展型態。在 ukmotor 樣本上,同一個模型給出三個不同的 apriori:前一條對角線擬合出 0.6572660657,predict 到新對角線回傳 0.6894525318,在新資料上乾淨重新擬合是 0.7062253126。第三個數字不是你存的模型,也不是你今天會建的模型。我們開討論串時寫的是「No bug here」;CapeCod 該不該重新估計,維護者已經交給社群決定,截至 2026 年 9 月 26 日還沒定案。
能帶走的教訓只有一句:呼叫任何 predict 之前,先分清哪些量是存下來的、哪些是每次重算的。
先揭露角色。chainladder-python 放在美國產險精算學會(CAS)的官方 GitHub 組織 casact 底下。討論串 #1274 是我們(GitHub 帳號 ppcvote)開的,但最早在串裡點出「CapeCod 每次推論都重新估計」的是維護者 henrydingliu(9 月 6 日 17:58 UTC)。我們一小時後的留言做的是把它量化成上面三個數字。下面 ukmotor 的數字都在目前的 main(commit 08ded050)與 PyPI chainladder==0.10.1 上重跑過,另外三個歷史 commit 的結果也逐位相同;手算拆解、trend=0.05 對照與 #1385 的範例則在 08ded050 上跑。
Cape Cod 的 apriori 從哪裡來
BornhuetterFerguson(以下簡稱 BF)法的預期損失率 apriori 由你指定。Cape Cod 法不讓你指定,而是從資料裡估:各年度已報賠款的加總,除以「已經用掉的曝險」的加總,也就是每個年度的曝險除以它的累積發展因子(CDF)。在 trend=0、decay=1、沒有費率調整(on-level)的設定下:
apriori = sum(最新對角線) / sum(曝險 / CDF)
官方使用者指南推導 CapeCod 隱含的 apriori 時,用的也是這個式子(docs/user_guide/methods.ipynb 第 29 格)。
式子裡有三樣東西:最新對角線、曝險、CDF。predict 被呼叫時,這三樣各自來自哪一期,就是整篇要回答的問題。
三個數字
ukmotor 是 chainladder 內建的樣本,7×7 年度累積三角形,最新評價日 2013-12-31。它沒有保費欄位,所以曝險是合成的:每個年度固定 20000,照抄 CapeCod.predict docstring 範例的寫法。
import chainladder as cl
tr = cl.load_sample("ukmotor")
tr_prior = tr[tr.valuation < tr.valuation_date] # 拿掉最新一條對角線
exp_prior = cl.Chainladder().fit(tr_prior).ultimate_ * 0 + 20000
exp_now = cl.Chainladder().fit(tr).ultimate_ * 0 + 20000
cc = cl.CapeCod().fit(tr_prior, sample_weight=exp_prior)
pred = cc.predict(tr, sample_weight=exp_now)
refit = cl.CapeCod().fit(tr, sample_weight=exp_now)
| 情境 | apriori_ | |
|---|---|---|
| A | 在 2012 年底的三角形擬合(6 個年度) | 0.6572660657 |
| B | 拿 A 的模型 predict 2013 年底的三角形(7 個年度) | 0.6894525318 |
| C | 在 2013 年底的三角形乾淨重新擬合 | 0.7062253126 |
我們 9 月 6 日的留言是這樣寫的:"predict returns a third number, neither the fitted apriori nor a clean refit."
CapeCod.predict 有一條「推論擬合粒度」的分支,上一篇寫的一字元 bug 就在那裡。這個例子兩邊的索引層都是 ['Total'],分支條件算出來是空集合,執行時也沒有任何警告,所以三個數字的差異不是從那條路來的。
另外,docstring 範例用的是 trend=0.05,上面是預設的 trend=0。換成 0.05,三個數字變成 0.7605768615、0.8162389074、0.8360961065,仍然是三個不同的值。
拆開 0.6894525318
我們照上面的式子逐年手算,三個結果都和 chainladder 的輸出一致到小數點後 10 位。
| 最新對角線加總 | 曝險 / CDF 加總 | 用的 CDF | apriori | |
|---|---|---|---|---|
| A 擬合 | 58994 | 89756.6497 | 擬合時 | 0.6572660657 |
| B predict | 75672 | 109756.6497 | 擬合時 | 0.6894525318 |
| C 重新擬合 | 75672 | 107149.9402 | 新的 | 0.7062253126 |
兩兩比對:
- B 和 C 用同樣的損失(75672)、同樣的曝險,差別只在 CDF。
- A 和 B 用同樣的 CDF,差別只在資料:多了一條新對角線,也多了 2013 年度。
所以 0.6894525318 不是另外兩個數的平均或折衷。它是 CapeCod 的公式,吃進今天的損失和曝險,配上一期的發展型態。在這個例子裡它剛好落在兩者之間,但我們只測了 trend 0 與 0.05 兩種設定,不能推論它永遠落在中間。
最具體的一格是 2008 年度,它在 2013 年底發展到 72 個月。擬合用的 2012 年底三角形從沒觀察過 72 到 84 個月的發展,擬合時的型態在這一段是 1.000000,B 就用 1.000000;重新擬合的三角形看得到這一段,C 用的是 1.027530。
對應的原始碼在 CapeCod.predict 裡,以下是 commit 08ded050 的原文(if 在第 329 行,PyPI 0.10.1 是第 324 行):
X_new = X.copy()
_, X_new.ldf_ = self.intersection(X_new, self.ldf_)
# If model was fit at a higher grain, then need to aggregate predicted aprioris too
if len(set(sample_weight.key_labels) - set(self.apriori_.key_labels)) > 0:
apriori_, detrended_apriori_ = self._get_capecod_aprioris(
X_new.groupby(self.apriori_.key_labels).sum(),
sample_weight.groupby(self.apriori_.key_labels).sum(),
)
else:
apriori_, detrended_apriori_ = self._get_capecod_aprioris(
X_new, sample_weight
)
先執行的是 intersection 那一行:把擬合時的 ldf_ 接到新資料 X_new 上。接著 if 的兩個分支都呼叫 _get_capecod_aprioris,用這個 X_new 與傳入的 sample_weight 重算 apriori。沒有任何一條路徑會直接沿用擬合時存下的 apriori_。
「重算時用的是擬合時的 CDF」這一點,我們用兩個實跑結果確認:一是上面 B 列的手算,代入擬合時的 CDF,與 chainladder 的輸出一致到小數點後 10 位;二是 pred.ldf_ == cc.ldf_ 印出 True。
沿用擬合時的 ldf_ 本身不稀奇,我們 9 月 4 日在 #1274 的留言提過,Chainladder 與 BornhuetterFerguson 的 predict 也是這樣。CapeCod 不同的地方在於它另外重估了 apriori,於是回傳的結果混了兩個時期的東西。
維護者怎麼看
#1274 原本問的不是這件事。它的標題是 "CapeCod.predict infers the fitted grain from key_labels: should that inference exist?",問的是粒度推論該不該存在,內文第一行寫明 "No bug here"。討論進行到 9 月 6 日,henrydingliu(專案 CODEOWNERS 之一,也是合併我們 #1275 的人)寫下:
the current implementation of capecod re-estimates
apriori_on every inference. there is essentially no model persistence. as in, i can't save the capdecod from last quarter and reapply it this quarter.
(capdecod 是原文拼法。)意思是:目前的 CapeCod 每次推論都會重新估計 apriori_,基本上沒有模型持久化,上一季存下的 CapeCod 不能拿來套這一季。
同一天稍晚,他補了實務面的觀察與套件現況:
in practice, the a priori for a BF method is often determined based on some historical chainladder result.
this package currently doesn't support putting this entire analysis into a pipeline. something we'll have to revisit in the future
同一則留言裡,他提出兩條路讓社群選:
if consensus is 'as is', then we make explicit disclaimer in the docstring around the re-estimation behavior - if consensus is 'no re-estimation', then a bigger refactor effort would be needed.
共識是維持現狀,就在 docstring 明寫會重新估計;共識是不重新估計,就需要較大的重構。至於要不要每次呼叫都發警告,雙方都同意寫進 docstring 就好。我們當時的理由是:每次呼叫都跳的警告,使用者只會把它關掉。
9 月 17 日他開了 #1385,標題是「[BUG/BRK] Is CapeCod a property or an estimator?」,核心問題這樣寫:
what is the capecod method at its core? is it just a way of calculating a bf apriori? do we expect that apriori to update? if we do expect that apriori to change, capecod ultimate actually becomes a property, rather than an estimated result.
Cape Cod 的本質是什麼?只是算 BF apriori 的一種方法嗎?我們預期 apriori 會更新嗎?如果會,CapeCod 的 ultimate 就成了一個「屬性」,而不是估計出來的結果。
他在 #1385 附了一個小例子,我們在 08ded050 上原樣重跑,輸出與 issue 裡印的一致:模型在第一個狀態擬合出 apriori 0.625,predict 到只有兩個年度的新狀態,回傳 0.416667。同一個新狀態下,Chainladder 的 predict 得 3750 與 2500,BF 得 4500 與 4500,CapeCod 得 3500 與 3166.67。這個例子是他的,不是我們的。
凍結 apriori 就是 BF,但只在樣本內
我們 9 月 6 日的留言還寫了這句:
Persistence and re-estimation are the same fork, and taking one means opting out of the thing CapeCod does.
持久化與重新估計是同一個岔路,選了其中一邊,就等於放棄 CapeCod 在做的那件事。這是我們的說法,維護者沒有引用,也沒有表態同意。
它背後的等式不是新發現。官方使用者指南本來就寫了預設的 CapeCod "can be emulated by" BornhuetterFerguson。原始碼裡,CapeCod.fit 就是直接組出 BF 的期望值再交給 BF:
self.expectation_ = sample_weight * self.detrended_apriori_
所以把 detrended_apriori_ 乘上曝險餵給 BF,在同一份資料上兩邊的 ultimate 合計都是 78871.9279,逐格差距 0.0。這是結構上必然成立的,我們 9 月 13 日自己也補寫了 "That is close to tautological"。
要看的是樣本外。把存下的 detrended_apriori_ 帶到下一條對角線(BF 的 sample_weight 設為新曝險乘上存下的值),輸出如下:
| 項目 | 結果 |
|---|---|
存下的 detrended_apriori_ 涵蓋的年度 |
2007 到 2012 |
2013 年度的 sample_weight |
nan |
2013 年度 ultimate_ |
6283.00,等於已報賠款 |
2013 年度 ibnr_ |
NaN |
| 前六個年度 ultimate 合計 | 凍結版 80242.93,CapeCod.predict 80774.45,差約 0.66% |
最新的年度沒有提列任何 IBNR,而 ultimate_ 裡沒有任何 NaN,單看 ultimate_ 看不出少了什麼。這和維護者 9 月 6 日的更正對得上:"detrended_apriori_ is origin-specific because the trend and olf vectors are origin-specific." 存下的值綁在舊的年度上,新年度沒有對應。
0.66% 這個數字的限定條件要一起寫:一個公開小三角形,配上固定 20000 的合成曝險。它說明機制,不能當成真實帳本的誤差大小。
呼叫 predict 前該問的三個問題
這個例子可以套到任何「擬合一次、之後反覆 predict」的模型,不限精算:
- 哪些量是存下來的,哪些是每次重算的? CapeCod 存下的是
ldf_,重算的是apriori_。文件沒寫清楚的話,把擬合與 predict 的關鍵屬性並排印出來比。 - predict 和乾淨重新擬合,差在哪一個量? 用同一份新資料各跑一次,逐項對照。本例的差異全部來自 CDF,損失和曝險完全相同。
- 你要持久化的東西,對新資料有沒有定義? 以年度為索引的參數,遇到新年度就沒有值。本例的後果是最新年度零 IBNR,而且不報錯。以業務線為索引的發展型態也一樣:預測資料帶著模型沒見過的業務線時,Chainladder 的 predict 會把那些列丟掉、不報錯。
在社群對 #1385 有結論之前,如果你的流程是「上一季的 CapeCod 套到這一季」,至少要知道 predict 回來的 apriori_ 已經不是上一季那個。
目前狀態(2026-09-26)
- #1274:開著,14 則留言。最後一則是維護者 9 月 17 日寫的:"new issue raised at #1385. thanks for all the help. we'll be able to finally close this out soon after some community input."
- #1385:開著,標籤 Triage Pending,零回覆。
- #1306:我們另開的 PR,處理的是粒度推論時的警告,不是重新估計。目前未合併,等待審查,且與 main 有衝突。
方向還沒有決定。
資料來源
- casact/chainladder-python issue #1274(我們開的)。引用的留言:維護者 2026-09-06 17:58 UTC(comment 5561081469)、我們 2026-09-06 18:58 UTC(comment 5561426764)、維護者 2026-09-06 20:55 UTC(comment 5562110562)、我們 2026-09-13(comment 5652358664)、維護者 2026-09-17(comment 5720823903)。2026-09-26 查核時,14 則留言發佈後都沒有編輯過。
- issue #1385(henrydingliu 開的,2026-09-17)。
- 官方使用者指南 Methods,對應原始檔
docs/user_guide/methods.ipynb第 28、29 格。 - 原始碼:
chainladder/methods/capecod.py的CapeCod.fit與CapeCod.predict,commit08ded050188728f155f6ad71b7d7b7586da6eea2(main,2026-09-25)。predict裡的if在這個 commit 是第 329 行,在 PyPI 0.10.1 是第 324 行;#1274 內文引用的capecod.py:325是更早的 commit。 - 重現:資料集
cl.load_sample("ukmotor"),trend=0,曝險固定 20000,程式碼即上文那段。環境為 Python 3.12.4 的獨立 venv(py -3.12 -m venv venv),numpy 2.5.3、pandas 3.0.6。直接pip install chainladder==0.10.1就能重現;main 與另外三個 commit 以PYTHONPATH指向該 commit 的原始碼執行同一段程式:91a942f1(#1275 合併點)、cd24e1d2(9 月 6 日留言當時的 main)、6b51f649(9 月 13 日留言提到的 commit)。五個版本的每個數字都相同。 - 手算機制與 #1385 範例:同一環境、commit
08ded050,2026-09-26 執行。
常見問題
CapeCod.predict 會沿用 fit 時算出的 apriori 嗎?
不會。predict 每次都用傳入的新三角形與新曝險重新計算 apriori,只沿用 fit 時的發展因子 ldf_。在 ukmotor 的例子裡,fit 出來的 apriori 是 0.6572660657,predict 回傳 0.6894525318。
那 predict 的結果等於在新資料上重新 fit 嗎?
也不等於。在新資料上乾淨重新擬合得到 0.7062253126。predict 和重新擬合用的損失(最新對角線加總 75672)與曝險完全相同,差別只在發展型態:predict 用上一期的累積發展因子,重新擬合用新的。例如 2008 年度在 72 個月時,predict 用 1.000000,重新擬合用 1.027530。
這是 chainladder 的 bug 嗎?
我們開 #1274 時,內文第一行寫的是 No bug here,把它當成設計問題提出。維護者 henrydingliu 在 2026-09-17 另開 #1385(標題是 Is CapeCod a property or an estimator?),請社群決定 CapeCod 要維持現狀並在 docstring 寫明會重新估計,還是改成不重新估計(需要較大的重構)。截至 2026-09-26,#1385 還沒有任何回覆。
把 CapeCod 的 apriori 凍結起來重用,就等於 BornhuetterFerguson 嗎?
只在樣本內成立。官方使用者指南本來就寫了預設的 CapeCod 可以用 BornhuetterFerguson 模擬,在同一份資料上兩者的 ultimate 合計都是 78871.9279。但把存下的 detrended_apriori_ 帶到下一條對角線,新的 2013 年度沒有對應值,ultimate 就等於已報賠款 6283.00,沒有提列任何 IBNR。
這個差距在實務上有多大?
我們沒有資料可以回答。唯一量到的是 ukmotor 這個公開小三角形,搭配照抄 docstring 範例的固定 20000 曝險:前六個年度的 ultimate 合計差約 0.66%。它用來說明機制,不代表真實帳本的準備金誤差。