Skip to content

[FIX] raise when the fitted pattern cannot be applied to the triangle passed in - #1310

Open
ppcvote wants to merge 1 commit into
casact:mainfrom
ppcvote:feat/validate-ldf
Open

[FIX] raise when the fitted pattern cannot be applied to the triangle passed in#1310
ppcvote wants to merge 1 commit into
casact:mainfrom
ppcvote:feat/validate-ldf

Conversation

@ppcvote

@ppcvote ppcvote commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary of Changes

intersection() narrows two triangles to their shared index. That is the right thing for the grain broadcast #400 asked for, and the wrong thing for a mismatch, and it cannot tell the two apart. Three mismatches were resolving quietly:

  • X carries a group the model was never fit on. intersection drops those rows, so Chainladder returned 643 of the 775 rows it was handed with no warning, while BornhuetterFerguson and CapeCod raised operands could not be broadcast together with shapes (775,1,10,1) (643,1,10,1), which names no index value.
  • ldf_ is at a finer grain than X. intersection narrows neither side, so predict returns an object whose triangle sits at the caller's grain and whose ldf_ sits at the model's, and Chainladder built a 775 row ultimate_ from a 6 row input.
  • Both sides are a single row. intersection short circuits on that at its first line, so a pattern fit on one group was applied to another and the result labelled as the group predicted on. Fitting on one company in comauto and predicting on othliab returns an ultimate of 3,309,931.25 carrying [['Aegis Grp', 'comauto']] as its index.

validate_ldf checks all three, in the shape validate_weight already uses.

Two things about it are worth flagging, because neither is obvious from the diff.

Where the call sits. It runs before X_new = X_new + (self.X_.val_to_dev().iloc[0,0].sum(2) * 0), not next to intersection. That addition goes through _prep_index, which borrows self.X_'s index when both sides are a single row, so by the time control reaches intersection the caller's identity is already gone and the check sees two triangles that agree. Placing the call after that line looks natural and silently does nothing for the single-row case.

The "(All)" exemption. A pattern whose index is entirely the "(All)" sentinel came from Triangle.sum and carries no group identity, so neither its index values nor its key labels constrain what it may be applied to, and it is exempt from both checks. Without that, this rejects test_different_backends, which fits on clrd.sum() and predicts on 132 companies. Row count cannot separate that from the single-group mistake above: both are one row sharing no index value with the target. The limit of that exemption is worth stating plainly, because it is wider than it first looks. Triangle.sum stamps "(All)" on whatever subset it was called on, so a pattern summed from a single line of business is exempt exactly as a pattern summed from the whole book is. Fitting on tri[tri.index["LOB"] == "wkcomp"].sum() and predicting on comauto is allowed and returns 157 rows, which is the third case above reached with one extra .sum(). Closing that means recording aggregation provenance on the Triangle, which is a larger change than this and your call rather than mine. What this PR does is remove the cases where nothing was summed at all.

A pattern coarser than X stays allowed, which is the #400 flow.

Columns, added after review. The same mismatch on the columns axis: fitting on CumPaidLoss and predicting on IncurLoss returned an ultimate of 228,088,946 labelled IncurLoss, where fitting on the incurred triangle gives 150,105,776. paid -> [paid, incurred] was allowed too. The check is one direction, set(X.columns) - set(ldf.columns); the reverse is noted on the thread.

Related GitHub Issue(s)

Fixes #1288. intersection itself is untouched, per your point on that thread about #1037.

Note for merge order: #1306 touches the adjacent lines in CapeCod.predict, so whichever lands second will want a rebase.

Additional Context for Reviewers

  • Seven tests, placed next to test_misaligned_index2. Those two cover predicting on a strict subset of the fitted data, which is the case this check is most likely to reject by mistake, so the boundary sits in one place. Removing validate_ldf from both call sites fails exactly the five rejection tests; the two that assert a flow still works pass either way, which is what they are for. test_predict_checks_before_the_index_is_borrowed fails on its own if the call is moved back after the addition, so the ordering above is held by a test rather than by a comment.
  • pytest chainladder passes: 1156 passed, 7 skipped, against 1142 and 7 on main. The fourteen are the seven new tests across both backend parametrisations; no existing test changes behaviour.
  • ruff check --force-exclude --config lint.per-file-ignores={} reports the same 15 findings on these three files before and after, and ruff format --check reports the same hunks line for line.
  • CapeCod calls validate_ldf as well. For the finer-ldf_ case that call is the only thing between the caller and a raw numpy error inside _get_capecod_aprioris, which runs before MethodBase.predict is reached. For the unseen-group case MethodBase.predict would catch it anyway through super().predict, just after CapeCod has done work it did not need to.
  • One deliberate deviation: validate_weight next door uses a multi-line signature and ''' docstrings, and ruff format wants neither. I wrote validate_ldf the way the formatter wants so it adds nothing to the format diff, rather than matching its neighbour.

Declarations

I am adhering to the standards in the Governing Doc. I am a human contributor, not a bot, and I opened this because @henrydingliu asked for it on #1288.

AI disclosure, per the AI Usage Policy: I used Claude Code on this. It ran the reproductions across the estimators and all three directions, and found both the single-row case and the call-site ordering by running the suite against earlier attempts that were wrong in each of those ways. I reviewed the diff and the tests myself, and the suite was run locally on my machine.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Pyright Type Completeness

View the full pyright --verifytypes output for this commit

Project (full chainladder package, at this PR's head): 15.0% of exported symbols fully typed (208 / 1384)

Known Ambiguous Unknown Total
Project (head) 208 110 1066 1384

Other symbols referenced but not exported by chainladder: 13

Known Ambiguous Unknown Total
Other (head) 3 1 9 13

Symbols without documentation:

  • Functions without docstring: 326
  • Functions without default param: 0
  • Classes without docstring: 10

Patch (exported symbols added or changed by this PR): 0.0% fully typed (0 / 8)

Known Ambiguous Unknown Total
Patch 0 0 8 8
Patch symbol details
Symbol Status Change
chainladder.methods.base.MethodBase.validate_ldf ❌ unknown new
chainladder.methods.tests.test_predict.test_predict_checks_before_the_index_is_borrowed ❌ unknown new
chainladder.methods.tests.test_predict.test_predict_rejects_a_different_single_group ❌ unknown new
chainladder.methods.tests.test_predict.test_predict_rejects_a_pattern_finer_than_the_triangle ❌ unknown new
chainladder.methods.tests.test_predict.test_predict_rejects_columns_the_model_was_not_fit_on ❌ unknown new
chainladder.methods.tests.test_predict.test_predict_rejects_index_values_the_model_never_saw ❌ unknown new
chainladder.methods.tests.test_predict.test_predict_still_allows_a_pattern_coarser_than_the_triangle ❌ unknown new
chainladder.methods.tests.test_predict.test_predict_still_allows_an_aggregate_pattern ❌ unknown new

@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.73%. Comparing base (99d1112) to head (956b847).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1310      +/-   ##
==========================================
+ Coverage   91.70%   91.73%   +0.02%     
==========================================
  Files          96       96              
  Lines        5464     5481      +17     
  Branches      704      709       +5     
==========================================
+ Hits         5011     5028      +17     
  Misses        328      328              
  Partials      125      125              
Flag Coverage Δ
unittests 91.73% <100.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@henrydingliu

Copy link
Copy Markdown
Member

thanks for the great PR! i have 3 very differently scoped comments

  • regarding ruff errors, please hold on a couple of days while we land a repo-wide cleanup
  • the conditions are pretty comprehensive for index matching. however, there's also matching on column values. i can create a new issue to log a future enhancement for it or we can add it into this PR. let me know
    • this wasn't an issue for sample_weight because sample_weight always has a different column value
  • the warning themselves should be a bit more mechanic-agnostic, i.e. just tell the user that key label/value doesn't match; no need to mention intersection.

@ppcvote

ppcvote commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

All three done or answered. I also refreshed the description above, which still carried the counts from before the rebase and the column check.

ruff. Holding until the cleanup lands.

Messages. Reworded, no mechanism named:

X has index values the model was not fit on: ['wkcomp']
The fitted pattern has index levels that X does not: ['GRNAME']. It cannot be applied to a triangle that does not carry them.
X has columns the model was not fit on: ['IncurLoss']

Columns. Added here rather than deferred, because the existing suite turned out to be unaffected by it. The case is worse than the index ones. Without this PR's check:

paid = clrd["CumPaidLoss"].groupby("LOB").sum()
incurred = clrd["IncurLoss"].groupby("LOB").sum()
cl.Chainladder().fit(cl.Development().fit_transform(paid)).predict(incurred)

returned an ultimate of 228,088,946 labelled IncurLoss, where fitting on the incurred triangle gives 150,105,776. A paid pattern on incurred data, overstated by half, with the right column name on it. paid -> [paid, incurred] was allowed too, broadcasting the one pattern across both.

What I can say for it is that the 1142 tests on main all still pass with the check in. What I cannot say is that no workflow outside this suite relies on the permissive behaviour, so if you would rather it were staged separately I will pull it back out.

The check is one direction, set(X.columns) - set(ldf.columns). The reverse is real and I have left it alone: fitting on [paid, incurred] and predicting on paid is allowed and returns both columns, which is the column form of the finer-grain case already covered on the index side. I did probe rejecting it, with a positive control confirming the probe fires on [paid, incurred] -> paid and passes paid -> paid, and the full suite came back clean at 1156. So it looks addable, but it is a second decision rather than part of this one, and I would rather you made it.

Branch as it stands: 1156 passed, 7 skipped. ruff check reports the same 15 findings on these three files as main does, and ruff format the same hunks.

… passed in

intersection() narrows two triangles to their shared index, which is the right
thing for the grain broadcast casact#400 asked for and the wrong thing for a mismatch.
Four mismatches were resolving quietly:

  X carries a group the model was never fit on. Those rows are dropped, so
  Chainladder returned 643 of the 775 rows it was handed, with no warning,
  while BF and CapeCod raised a numpy shape error naming no index value.

  ldf_ is at a finer grain than X. Neither side is narrowed, predict returns an
  object whose triangle and ldf_ disagree, and Chainladder built a 775 row
  ultimate_ from a 6 row input.

  Both sides are a single row, which intersection short circuits on, so a
  pattern fit on one group was applied to another and labelled as the group
  predicted on.

  X carries a column the model was never fit on. A paid pattern applied to an
  incurred triangle came back labelled incurred, 228,088,946 against its own
  150,105,776 on clrd.

validate_ldf checks these, in the shape validate_weight already uses. It runs
before the `X_new + (self.X_ ... * 0)` line rather than next to intersection,
because that addition borrows self.X_'s index when both sides are a single row,
which erases the identity the check needs to see. A test holds that ordering.

A pattern whose index is entirely the "(All)" sentinel is exempt from the index
checks: it carries no group identity, and test_different_backends relies on
that. sum() stamps "(All)" on whatever subset it was called on, so this exempts
a pattern summed from one line of business as readily as one summed from
everything. Separating those needs aggregation provenance on the Triangle.

A pattern coarser than X stays allowed, which is the casact#400 flow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@henrydingliu

Copy link
Copy Markdown
Member

clean-up landed!

returned an ultimate of 228,088,946 labelled IncurLoss, where fitting on the incurred triangle gives 150,105,776. A paid pattern on incurred data, overstated by half, with the right column name on it. paid -> [paid, incurred] was allowed too, broadcasting the one pattern across both.

i wasn't actually thinking about the single column case. if we were to check column value for single column triangle, it would be somewhat contrary to our approach on single index, no? let's lay out the different scenarios and align on the proper treatment

  • single column fit, single column predict, column values match
    • all is good.
  • single column fit, single column predict, column values mismatch
    • i'm leaning towards letting this happen
  • single column fit, multiple columns predict
    • given how we want the single index parallel to behave, do we also need to just let this happen?
  • multiple columns fit, single/multiple columns predict
    • we want this to check for mismatch and fail if any predict columns fall outside of fit columns

@ppcvote

ppcvote commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the cleanup. It had landed and touched all three files this PR does, so the branch went into conflict; separately, CI never ran on my previous push, which I cannot explain beyond noting it.

ruff check and ruff format --check are both clean on these files now, on the branch and on main alike, so the red that was sitting on this PR is gone rather than merely explained. CI on this head is 1157 passed, 7 skipped across the matrix, and removing validate_ldf from both call sites still fails exactly the five rejection tests and nothing else.

The one conflict was iloc[0,0] becoming iloc[0, 0] on the line my check sits above. Kept your formatting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chainladder.predict changes the shape of the result without saying so, where BornhuetterFerguson and CapeCod raise

2 participants