Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions chainladder/methods/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions chainladder/methods/capecod.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
162 changes: 162 additions & 0 deletions chainladder/methods/tests/test_predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading