From efb304e2ca3e10925fd5044cc906d87fc0c467de Mon Sep 17 00:00:00 2001 From: ppcvote Date: Thu, 3 Sep 2026 14:42:45 +0800 Subject: [PATCH 1/3] [FIX] capecod predict drops the fitted apriori when exactly one index level is added predict() regroups the prediction data up to the grain the model was fit at, and decides whether to do so by counting the index levels sample_weight carries that apriori_ does not. The count required more than one, so the ordinary case of a single extra level fell through to the ungrouped branch and recomputed the apriori from the prediction data. The existing #400 regression test uses prism, which has five extra levels, so it exercises the grouped branch and never saw this. Co-Authored-By: Claude Opus 5 (1M context) --- chainladder/methods/capecod.py | 2 +- chainladder/methods/tests/test_capecod.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/chainladder/methods/capecod.py b/chainladder/methods/capecod.py index 4eebf830..982aefc4 100644 --- a/chainladder/methods/capecod.py +++ b/chainladder/methods/capecod.py @@ -322,7 +322,7 @@ def predict(self, X, sample_weight=None): 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)) > 1: + 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()) diff --git a/chainladder/methods/tests/test_capecod.py b/chainladder/methods/tests/test_capecod.py index 55e05f96..bfe268b2 100644 --- a/chainladder/methods/tests/test_capecod.py +++ b/chainladder/methods/tests/test_capecod.py @@ -83,4 +83,25 @@ def test_capecod_predict2(prism): pred1 = pipe1.named_steps.model.ultimate_.sum() pred2 = pipe2.predict(prism['Paid'], sample_weight=prism['reportedCount'].sum('development')).ultimate_.sum() - assert np.nan_to_num(abs(pred1 - pred2).values).sum() <= 1e-6 \ No newline at end of file + assert np.nan_to_num(abs(pred1 - pred2).values).sum() <= 1e-6 + + +def test_capecod_predict_one_extra_index_level(clrd): + """github issue #1265 + + predict() aggregates the prediction data up to the grain the model was fit + at. test_capecod_predict2 covers that path with prism, whose triangle has + five index levels more than the fitted model. This covers the case of a + single extra level, which clrd gives. + """ + tri = clrd["CumPaidLoss"] + sample_weight = clrd["EarnedPremDIR"].latest_diagonal + + model = cl.CapeCod().fit( + tri.groupby("LOB").sum(), sample_weight=sample_weight.groupby("LOB").sum() + ) + pred = model.predict(tri, sample_weight=sample_weight) + + assert set(sample_weight.key_labels) - set(model.apriori_.key_labels) == {"GRNAME"} + assert np.allclose(pred.apriori_.values, model.apriori_.values) + assert abs(pred.ultimate_.sum().sum() - model.ultimate_.sum().sum()) < 1e-6 From b46e378fc230527a2e6ee19dee34d06c47cc36ca Mon Sep 17 00:00:00 2001 From: ppcvote Date: Sat, 5 Sep 2026 20:45:25 +0800 Subject: [PATCH 2/3] style: clear the ruff findings in the two files this PR touches `ruff check --config lint.per-file-ignores={}` now passes on both. The numpy import in capecod.py was unused; the rest is whitespace in existing tests. `ruff format` is left alone: it would rewrite 105 of the 107 lines in test_capecod.py, and six of the eight files in chainladder/methods are unformatted on main, so that looks like a separate decision. Co-Authored-By: Claude Opus 5 (1M context) --- chainladder/methods/capecod.py | 1 - chainladder/methods/tests/test_capecod.py | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/chainladder/methods/capecod.py b/chainladder/methods/capecod.py index 982aefc4..c3b1fb9d 100644 --- a/chainladder/methods/capecod.py +++ b/chainladder/methods/capecod.py @@ -2,7 +2,6 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. import warnings -import numpy as np from chainladder.methods import Benktander diff --git a/chainladder/methods/tests/test_capecod.py b/chainladder/methods/tests/test_capecod.py index bfe268b2..d7744aff 100644 --- a/chainladder/methods/tests/test_capecod.py +++ b/chainladder/methods/tests/test_capecod.py @@ -14,7 +14,7 @@ def test_struhuss(): def test_groupby(clrd): - clrd = clrd[clrd['LOB']=='comauto'] + clrd = clrd[clrd['LOB'] == 'comauto'] # But only the top 10 get their own CapeCod aprioris. Smaller companies get grouped together top_10 = clrd['EarnedPremDIR'].groupby('GRNAME').sum().latest_diagonal top_10 = top_10.loc[..., '1997', :].to_frame(origin_as_datetime=True).nlargest(10) @@ -25,7 +25,7 @@ def test_groupby(clrd): # All companies share the same development factors regardless of size X = cl.Development().fit(clrd['CumPaidLoss'].sum()).transform(clrd['CumPaidLoss']) - sample_weight=clrd['EarnedPremDIR'].latest_diagonal + sample_weight = clrd['EarnedPremDIR'].latest_diagonal a = cl.CapeCod(groupby='Top 10', decay=0.98, trend=0.02).fit(X, sample_weight=sample_weight).ibnr_.groupby('Top 10').sum().sort_index() b = cl.CapeCod(decay=0.98, trend=0.02).fit(X.groupby('Top 10').sum(), sample_weight=sample_weight.groupby('Top 10').sum()).ibnr_.sort_index() xp = a.get_array_module() @@ -35,8 +35,8 @@ def test_groupby(clrd): def test_capecod_zero_tri(raa): premium = raa.latest_diagonal * 0 + 50000 - raa.at['Total','values','1987',48] = 0 - assert cl.CapeCod().fit(raa, sample_weight=premium).ultimate_.loc[:,:,'1987'].sum() > 0 + raa.at['Total', 'values', '1987', 48] = 0 + assert cl.CapeCod().fit(raa, sample_weight=premium).ultimate_.loc[:, :, '1987'].sum() > 0 def test_capecod_predict1(prism): From f036ba7fb6042ada70837ac07ebfee878b08ff83 Mon Sep 17 00:00:00 2001 From: ppcvote Date: Sat, 5 Sep 2026 21:04:54 +0800 Subject: [PATCH 3/3] style: drop the two per-file-ignore entries these files no longer need The table's own comment says to remove entries as files are cleaned up. Both are dead now: ruff check passes on capecod.py and test_capecod.py with the project config as well as with per-file-ignores cleared. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8094fbe7..77985eb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,11 +136,9 @@ select = ["E2", "E4", "E7", "E9", "F", "B018", "UP034", "N802"] "chainladder/development/tests/test_glm.py" = ["E225", "E226", "E231"] "chainladder/development/tests/test_incremental.py" = ["F841"] "chainladder/methods/base.py" = ["E231", "F401", "N802"] -"chainladder/methods/capecod.py" = ["F401"] "chainladder/methods/mack.py" = ["E265", "F401"] "chainladder/methods/tests/rtest_mack.py" = ["E266", "E722", "F401"] "chainladder/methods/tests/test_benktander.py" = ["E231", "E251"] -"chainladder/methods/tests/test_capecod.py" = ["E225", "E231"] "chainladder/methods/tests/test_mack.py" = ["E226", "E231", "E251", "E265"] "chainladder/methods/tests/test_predict.py" = ["E231", "E265"] "chainladder/tails/base.py" = ["E226", "E251"]