Are you on the latest chainladder version?
Revised after @henrydingliu's note: this issue originally said "grain" where it meant index levels. Reworded throughout, since grain properly refers to origin and development period granularity.
Describe the bug in words
When a CapeCod model is fit on training data that has already been aggregated to fewer index levels, predict() silently discards the fitted apriori and recomputes it at the full index. Pooling sparse segments is the main reason to aggregate before fitting, so the returned apriori_ is not the quantity the caller fitted.
The guard at chainladder/methods/capecod.py:325 reads:
# 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:
That set difference is the number of index levels the prediction carries which the fitted apriori does not, so it is >= 1 exactly when the model was fit on fewer levels. At > 1 the commonest case, one fewer level, falls through to the else and recomputes at the full index.
How can the bug be reproduced?
Using the repository's own clrd sample, whose key_labels are ['GRNAME', 'LOB']:
import chainladder as cl
import numpy as np
clrd = cl.load_sample("clrd")
tri = clrd[clrd["LOB"] == "comauto"]["CumPaidLoss"]
prem = clrd[clrd["LOB"] == "comauto"]["EarnedPremDIR"].latest_diagonal
# aggregate to one fewer index level before fitting: LOB only, dropping GRNAME
cc = cl.CapeCod().fit(tri.groupby("LOB").sum(),
sample_weight=prem.groupby("LOB").sum())
print(len(set(prem.key_labels) - set(cc.apriori_.key_labels))) # 1 -> guard is False
print(float(np.asarray(cc.apriori_).ravel()[0])) # 0.5689995797 (fitted)
pred = cc.predict(tri, sample_weight=prem)
print(np.asarray(pred.apriori_).ravel()[:1]) # 1.2516635774 (recomputed)
The fitted apriori is 0.5689995797; predict() returns 1.2516635774. I checked the fitted value by hand in numpy, sum(latest) / sum(premium / cdf), and it agrees to 1e-9.
The existing predict tests cannot catch this because both fit at ['Line'] on prism, whose key_labels are six long, so the difference is always 5 and they always take the intended branch. clrd has a difference of 1 and is not covered.
What is the expected behavior?
assert np.allclose(np.asarray(pred.apriori_), np.asarray(cc.apriori_))
Changing the threshold to > 0 produces that. I checked the blast radius: with > 0 the diff == 0 case is bit-identical (still 1.2352941176 on the same data) and diff >= 2 is unchanged, so only the broken case moves.
One thing worth deciding rather than assuming: with > 0, a genuinely disjoint index turns today's ValueError: Index broadcasting is ambiguous into a less helpful KeyError. An explicit subset check would read better than a count, and I did not want to pick that shape for you.
Would you be willing to contribute this ticket?
Are you on the latest chainladder version?
mainat 5f34bcd).Describe the bug in words
When a
CapeCodmodel is fit on training data that has already been aggregated to fewer index levels,predict()silently discards the fitted apriori and recomputes it at the full index. Pooling sparse segments is the main reason to aggregate before fitting, so the returnedapriori_is not the quantity the caller fitted.The guard at
chainladder/methods/capecod.py:325reads:That set difference is the number of index levels the prediction carries which the fitted apriori does not, so it is
>= 1exactly when the model was fit on fewer levels. At> 1the commonest case, one fewer level, falls through to theelseand recomputes at the full index.How can the bug be reproduced?
Using the repository's own
clrdsample, whosekey_labelsare['GRNAME', 'LOB']:The fitted apriori is
0.5689995797;predict()returns1.2516635774. I checked the fitted value by hand in numpy,sum(latest) / sum(premium / cdf), and it agrees to 1e-9.The existing
predicttests cannot catch this because both fit at['Line']onprism, whosekey_labelsare six long, so the difference is always 5 and they always take the intended branch.clrdhas a difference of 1 and is not covered.What is the expected behavior?
Changing the threshold to
> 0produces that. I checked the blast radius: with> 0thediff == 0case is bit-identical (still1.2352941176on the same data) anddiff >= 2is unchanged, so only the broken case moves.One thing worth deciding rather than assuming: with
> 0, a genuinely disjoint index turns today'sValueError: Index broadcasting is ambiguousinto a less helpfulKeyError. An explicit subset check would read better than a count, and I did not want to pick that shape for you.Would you be willing to contribute this ticket?