From e9c2823a650384ee4f0754d93704a75fd57489e2 Mon Sep 17 00:00:00 2001 From: ppcvote Date: Sun, 6 Sep 2026 11:13:42 +0800 Subject: [PATCH] [FEAT] warn when CapeCod.predict infers the grain rather than regrouping silently predict() re-estimates the apriori at the fitted grain whenever sample_weight carries index levels the fitted apriori_ does not, and returns apriori_ at that grain rather than the caller's. A 775 row triangle goes in and a 6 row apriori_ comes back with nothing said about it. The warning is scoped to self.groupby is None. CapeCod(groupby=...) reaches the same branch, but there the grain was asked for rather than inferred, and warning someone who was explicit is the fastest way to get a warning suppressed. Co-Authored-By: Claude Opus 5 (1M context) --- chainladder/methods/capecod.py | 14 ++++- chainladder/methods/tests/test_capecod.py | 67 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/chainladder/methods/capecod.py b/chainladder/methods/capecod.py index c748b3a9..f656379a 100644 --- a/chainladder/methods/capecod.py +++ b/chainladder/methods/capecod.py @@ -320,8 +320,20 @@ def predict(self, X, sample_weight=None): raise ValueError("sample_weight is required.") X_new = X.copy() _, X_new.ldf_ = self.intersection(X_new, self.ldf_) + inferred_levels = set(sample_weight.key_labels) - set(self.apriori_.key_labels) # 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: + if inferred_levels: + if self.groupby is None: + # The grain was worked out from the data rather than asked for, and the + # apriori comes back at it, so say so instead of regrouping silently. + warnings.warn( + "sample_weight has index levels the fitted apriori does not (" + + ", ".join(sorted(inferred_levels)) + + "), so the apriori is re-estimated at the fitted grain " + + str(self.apriori_.key_labels) + + ". apriori_ is returned at that grain, not the one passed in. " + "Pass groupby to CapeCod to state this explicitly." + ) 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 80f2c03f..a89e18d9 100644 --- a/chainladder/methods/tests/test_capecod.py +++ b/chainladder/methods/tests/test_capecod.py @@ -1,5 +1,8 @@ +import warnings + import chainladder as cl import numpy as np +import pytest def test_struhuss(): @@ -133,3 +136,67 @@ def test_capecod_predict_one_extra_index_level(clrd): 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 + + +def _capecod_grain_warnings(fn): + """Only the grain warning, so numpy's RuntimeWarnings do not count.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + fn() + return [ + w + for w in caught + if "the apriori is re-estimated at the fitted grain" in str(w.message) + ] + + +def test_capecod_predict_warns_when_the_grain_is_inferred(clrd): + """github issue #1274 + + Fitting on pre-aggregated data and predicting on the granular triangle makes + predict() work the grain out from key_labels. apriori_ then comes back at the + fitted grain rather than the caller's, which used to happen silently. + """ + tri = clrd["CumPaidLoss"] + sample_weight = clrd["EarnedPremDIR"].latest_diagonal + + model = cl.CapeCod().fit( + tri.groupby("LOB").sum(), sample_weight=sample_weight.groupby("LOB").sum() + ) + with pytest.warns( + UserWarning, match="the apriori is re-estimated at the fitted grain" + ): + pred = model.predict(tri, sample_weight=sample_weight) + + assert pred.apriori_.shape[0] == model.apriori_.shape[0] + assert pred.apriori_.shape[0] != tri.shape[0] + + +def test_capecod_predict_does_not_warn_when_groupby_is_explicit(clrd): + """github issue #1274 + + groupby reaches the same branch, but the grain was asked for rather than + inferred, so there is nothing to point out. + """ + tri = clrd["CumPaidLoss"] + sample_weight = clrd["EarnedPremDIR"].latest_diagonal + + model = cl.CapeCod(groupby="LOB").fit(tri, sample_weight=sample_weight) + assert set(sample_weight.key_labels) - set(model.apriori_.key_labels) + + assert ( + _capecod_grain_warnings(lambda: model.predict(tri, sample_weight=sample_weight)) + == [] + ) + + +def test_capecod_predict_does_not_warn_at_a_matching_grain(clrd): + """github issue #1274""" + tri = clrd["CumPaidLoss"] + sample_weight = clrd["EarnedPremDIR"].latest_diagonal + + model = cl.CapeCod().fit(tri, sample_weight=sample_weight) + assert ( + _capecod_grain_warnings(lambda: model.predict(tri, sample_weight=sample_weight)) + == [] + )