diff --git a/chainladder/methods/base.py b/chainladder/methods/base.py index 7fa30956..924d9ff6 100644 --- a/chainladder/methods/base.py +++ b/chainladder/methods/base.py @@ -102,6 +102,9 @@ def predict(self, X, sample_weight=None): X_new = X.val_to_dev() if sum(X_new.ddims > self.ldf_.ddims.max()) > 0: raise ValueError("X has ages that exceed those available in model.") + # 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) if sample_weight: @@ -155,6 +158,45 @@ def _include_process_variance(self): process_var = None return process_var + @staticmethod + def validate_ldf(X: Triangle, ldf: Triangle) -> None: + """ + Checks that a fitted pattern can be applied to X as it was passed in. + The index and the columns of the two have to line up: values or columns + X carries that the pattern does not cannot be predicted, and index + levels the pattern carries that X does not cannot be applied. + """ + # A pattern whose index is entirely the "(All)" sentinel that Triangle.sum + # sets carries no group identity, so nothing about it constrains what it + # may be applied to. Note the limit of that: sum() stamps "(All)" on + # whatever subset it was called on, so a pattern summed from one line of + # business is exempt here just as a pattern summed from everything is. + # Telling those apart needs aggregation provenance on the Triangle. + if len(ldf) == 1 and set(ldf.index.values.flatten()) == {"(All)"}: + return + shared = sorted(set(X.key_labels) & set(ldf.key_labels)) + if shared: + missing = sorted( + set(X.index.set_index(shared).index) + - set(ldf.index.set_index(shared).index) + ) + if missing: + raise ValueError( + "X has index values the model was not fit on: " + + str(missing[:5]) + + (", and others" if len(missing) > 5 else "") + ) + columns = sorted(set(X.columns) - set(ldf.columns)) + if columns: + raise ValueError("X has columns the model was not fit on: " + str(columns)) + finer = sorted(set(ldf.key_labels) - set(X.key_labels)) + if finer: + raise ValueError( + "The fitted pattern has index levels that X does not: " + + str(finer) + + ". It cannot be applied to a triangle that does not carry them." + ) + @staticmethod def validate_weight( X: Triangle, diff --git a/chainladder/methods/capecod.py b/chainladder/methods/capecod.py index c748b3a9..2dbb4b63 100644 --- a/chainladder/methods/capecod.py +++ b/chainladder/methods/capecod.py @@ -319,6 +319,7 @@ def predict(self, X, sample_weight=None): if sample_weight is None: raise ValueError("sample_weight is required.") X_new = X.copy() + self.validate_ldf(X_new, self.ldf_) _, 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: diff --git a/chainladder/methods/tests/test_predict.py b/chainladder/methods/tests/test_predict.py index 47a08c7b..f33b27dd 100644 --- a/chainladder/methods/tests/test_predict.py +++ b/chainladder/methods/tests/test_predict.py @@ -190,6 +190,168 @@ def test_misaligned_index2(clrd): assert abs(a - b) < 1e-5 +def test_predict_rejects_index_values_the_model_never_saw(clrd): + """github issue #1288 + + intersection() narrows both operands to their shared index, so a group the + model was never fit on used to be dropped from the result instead of + predicted, silently on Chainladder and as a numpy shape error on the others. + """ + tri = clrd["CumPaidLoss"] + sample_weight = clrd["EarnedPremDIR"].latest_diagonal + seen = tri.index["LOB"] != "wkcomp" + train = tri[seen].groupby("LOB").sum() + train_weight = sample_weight[seen].groupby("LOB").sum() + + models = [ + cl.Chainladder().fit(cl.Development().fit_transform(train)), + cl.BornhuetterFerguson(apriori=0.7).fit(train, sample_weight=train_weight), + cl.CapeCod().fit(train, sample_weight=train_weight), + ] + for model in models: + with pytest.raises(ValueError, match="index values the model was not fit on"): + if isinstance(model, cl.Chainladder): + model.predict(tri) + else: + model.predict(tri, sample_weight=sample_weight) + + +def test_predict_rejects_a_different_single_group(clrd): + """github issue #1288 + + Both sides being one row is the case intersection() short circuits on, so + the pattern from one group used to be applied to another silently, with the + result labelled as the group that was predicted on. + """ + lob = clrd["CumPaidLoss"].groupby("LOB").sum() + fitted_on = lob[lob.index["LOB"] == "comauto"] + predicted_on = lob[lob.index["LOB"] == "othliab"] + assert len(fitted_on) == len(predicted_on) == 1 + + weight = clrd["EarnedPremDIR"].latest_diagonal.groupby("LOB").sum() + fitted_weight = weight[weight.index["LOB"] == "comauto"] + predicted_weight = weight[weight.index["LOB"] == "othliab"] + + model = cl.Chainladder().fit(cl.Development().fit_transform(fitted_on)) + with pytest.raises(ValueError, match="index values the model was not fit on"): + model.predict(predicted_on) + # the same single group is still fine + assert model.predict(fitted_on).ultimate_.shape[0] == 1 + + for cls in (cl.BornhuetterFerguson, cl.CapeCod): + exposure_model = cls().fit(fitted_on, sample_weight=fitted_weight) + with pytest.raises(ValueError, match="index values the model was not fit on"): + exposure_model.predict(predicted_on, sample_weight=predicted_weight) + + +def test_predict_rejects_a_pattern_finer_than_the_triangle(clrd): + """github issue #1288 + + The reverse direction: a pattern fit per company cannot be applied to a + triangle that has aggregated companies away. intersection() leaves the ldf_ + at the fitted grain, which used to surface as a 775 row ultimate_ from a + 6 row input. + """ + tri = clrd["CumPaidLoss"] + sample_weight = clrd["EarnedPremDIR"].latest_diagonal + coarse = tri.groupby("LOB").sum() + coarse_weight = sample_weight.groupby("LOB").sum() + + models = [ + cl.Chainladder().fit(cl.Development().fit_transform(tri)), + cl.BornhuetterFerguson(apriori=0.7).fit(tri, sample_weight=sample_weight), + cl.CapeCod().fit(tri, sample_weight=sample_weight), + ] + for model in models: + with pytest.raises(ValueError, match="has index levels that X does not"): + if isinstance(model, cl.Chainladder): + model.predict(coarse) + else: + model.predict(coarse, sample_weight=coarse_weight) + + +def test_predict_rejects_columns_the_model_was_not_fit_on(clrd): + """github issue #1288 + + The same mismatch on the columns axis. A paid pattern applied to an incurred + triangle used to come back labelled incurred, overstating the ultimate by + about half on clrd. + """ + paid = clrd["CumPaidLoss"].groupby("LOB").sum() + incurred = clrd["IncurLoss"].groupby("LOB").sum() + both = clrd[["CumPaidLoss", "IncurLoss"]].groupby("LOB").sum() + + model = cl.Chainladder().fit(cl.Development().fit_transform(paid)) + with pytest.raises(ValueError, match="columns the model was not fit on"): + model.predict(incurred) + with pytest.raises(ValueError, match="columns the model was not fit on"): + model.predict(both) + + # the column it was fit on is still fine + assert model.predict(paid).ultimate_.shape[0] == paid.shape[0] + + +def test_predict_still_allows_an_aggregate_pattern(clrd): + """github issue #1288 + + A pattern fit on a fully aggregated triangle carries the "(All)" sentinel + rather than any group identity, so it may be broadcast to any grain. Both + directions matter: test_different_backends relies on the first. + """ + tri = clrd["CumPaidLoss"] + sample_weight = clrd["EarnedPremDIR"].latest_diagonal + model = cl.BornhuetterFerguson().fit(tri.sum(), sample_weight=sample_weight.sum()) + + per_company = model.predict(tri, sample_weight=sample_weight) + assert per_company.ultimate_.shape[0] == tri.shape[0] + + by_lob = model.predict( + tri.groupby("LOB").sum(), sample_weight=sample_weight.groupby("LOB").sum() + ) + assert by_lob.ultimate_.shape[0] == tri.groupby("LOB").sum().shape[0] + + +def test_predict_checks_before_the_index_is_borrowed(clrd): + """github issue #1288 + + MethodBase.predict adds a zeroed slice of self.X_ to X, and for single row + operands whose key_labels differ that addition takes the model's index. The + check has to run before it, or by the time it looks the caller's identity is + already the model's and the two agree. + """ + tri = clrd["CumPaidLoss"] + by_company = tri.groupby("GRNAME").sum() + by_lob = tri.groupby("LOB").sum() + fitted_on = by_company[by_company.index["GRNAME"] == "Aegis Grp"] + predicted_on = by_lob[by_lob.index["LOB"] == "othliab"] + assert fitted_on.key_labels != predicted_on.key_labels + + model = cl.Chainladder().fit(cl.Development().fit_transform(fitted_on)) + with pytest.raises(ValueError): + model.predict(predicted_on) + + +def test_predict_still_allows_a_pattern_coarser_than_the_triangle(clrd): + """github issue #1288 + + The #400 flow must keep working: a pattern fit at a coarser grain broadcasts + down to the triangle, so validate_ldf has to stay quiet here. + """ + tri = clrd["CumPaidLoss"] + sample_weight = clrd["EarnedPremDIR"].latest_diagonal + train = tri.groupby("LOB").sum() + train_weight = sample_weight.groupby("LOB").sum() + + bcl = cl.Chainladder().fit(cl.Development().fit_transform(train)).predict(tri) + assert bcl.ultimate_.shape[0] == tri.shape[0] + + bcc = cl.CapeCod().fit(train, sample_weight=train_weight) + assert ( + bcc.predict(tri, sample_weight=sample_weight).apriori_.shape[0] + == train.shape[0] + ) + + def test_align_cdfs(raa): ld = raa.latest_diagonal * 0 + 40000 model = cl.BornhuetterFerguson().fit(raa, sample_weight=ld)