Skip to content

[FEAT] Add one-year CDR standard errors to MackChainladder - #1558

Merged
henrydingliu merged 4 commits into
casact:mainfrom
scks1:feat-one-year-cdr-1543
Nov 11, 2026
Merged

henrydingliu merged 4 commits into
casact:mainfrom
scks1:feat-one-year-cdr-1543

Conversation

@scks1

@scks1 scks1 commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary of Changes

Adds cdr_std_err_ and total_cdr_std_err_ to MackChainladder: the standard error of the one-year claims development result, by origin and in total, from Results 3.1 and 3.3 of Merz and Wüthrich (2008).

  • Both are properties computed when read, like mack_std_err_, so fit is unchanged. Reading them raises ValueError outside the paper's setting: a square triangle, volume-weighted factors from every link ratio, and no tail. A triangle with a missing cell above the latest diagonal gets NaN.
  • The figures follow sigma_interpolation. With "mack" they match Tables 2 and 4 of the paper for its triangle, the sample mw2008, within the tolerances below.
  • chainladder/methods/tests/test_mack.py checks Tables 2 and 4 on four stacked copies of mw2008, and the figures that Merz and Wüthrich (2014) print for the sample mw2014, through new mw2008 and mw2014 fixtures on the numpy and sparse backends. It also checks clrd by LOB against single-triangle fits and covers each ValueError path.
  • merz2008 and merz2014 are added to references.bib, and merz2008 is cited in a short paragraph of the MackChainladder section of the methods user guide.

Related GitHub Issue(s)

Part of #1543

AI/LLM Usage

This change was written with Árbol, an AI assistant running on Muse, working under my direction. It wrote the code and tests, checked the figures against the paper, ran the checks listed below, and drafted this description. I reviewed the diff and the results before submitting.

Additional Context for Reviewers

  • I used "Part of" because [FEAT] One-year claims development result (Merz–Wüthrich) for MackChainladder #1543 also asks about the multi-year sequence, which I'd add in a follow-up.
  • Table 2 prints the factors to 4 decimals, and the fitted ones round to them. It prints σ² to 2 decimals, and the estimates are within 0.015. Table 4 prints whole units, and every entry is within 1 except accident year 2, whose two entries are 1.44 and 2.19 above the computed values and are checked to 2.5.
  • For mw2014, Merz and Wüthrich (2014) print the factors and σ² (Table 1), the reserve, process, parameter and Mack errors by origin and in total (Table 2), and the one-year total, 1,843 (Table 3), but no one-year figures by origin. Each of these figures is within half a unit of its last printed digit.
  • test_mack.py passes (34 tests), and so does a Sphinx doctest build of the two properties. ruff check and format are clean.

Submitter's Checklist

  • I have reviewed and am adhering to the standards outlined in the project Governing Doc.
  • The PR subject title summarizes the changes, with one proper prefix ([FIX], [FEAT], [DOCS], [TST], [CHORE], or [BRK]).
  • I am a human (not a bot), and this PR form is written by a human.

Reviewer's Checklist

  • The implementation addresses the associated issue(s).
  • The implementation is appropriate, maintainable, and follows ARCHITECTURE.md.
  • PR subject title has the proper prefix and the subject is appropriate.
  • Relevant issue(s) are linked.
  • AI/LLM usage is disclosed and appropriate.
  • Documentation and tests are appropriate.
  • CI tests passed, or any failures are acceptable.
  • Leave a comment with the final recommendation (e.g. approve as is, request a secondary review, or flag an area for more review).

Add cdr_std_err_ and total_cdr_std_err_, the standard error of the
one-year claims development result by origin and in total, from Results
3.1 and 3.3 of Merz and Wüthrich (2008). Both are properties computed
when read, so fit is unchanged. Reading them raises ValueError for a
triangle that is not square, a tail, non-volume averages, excluded link
ratios or factors other than the triangle's own; a triangle with a
missing cell above the latest diagonal gets NaN.

The new tests check both attributes against R's CDR() output for
mw2008 and mw2014, check the paper's Table 4, check clrd by LOB against
single-triangle fits and cover each ValueError path. merz2008 and
gesmann2026 go into references.bib and are cited in the methods user
guide.

Part of casact#1543.

Developed with an AI assistant (Árbol, running Muse).
@github-actions

github-actions Bot commented Nov 9, 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): 17.1% of exported symbols fully typed (275 / 1609)

Known Ambiguous Unknown Total
Project (head) 275 101 1233 1609

Other symbols referenced but not exported by chainladder: 24

Known Ambiguous Unknown Total
Other (head) 11 1 12 24

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): 41.7% fully typed (10 / 24)

Known Ambiguous Unknown Total
Patch 10 0 14 24
Patch symbol details
Symbol Status Change
chainladder.methods.mack.MackChainladder.cdr_std_err_ ❌ unknown new
chainladder.methods.mack.MackChainladder.total_cdr_std_err_ ❌ unknown new
chainladder.methods.tests.test_mack.CDR_UNSUPPORTED ❌ unknown new
chainladder.methods.tests.test_mack.MW2014_CDR_TOTAL ✅ known new
chainladder.methods.tests.test_mack.MW2014_LDF ✅ known new
chainladder.methods.tests.test_mack.MW2014_SIGMA2 ✅ known new
chainladder.methods.tests.test_mack.MW2014_TABLE2 ✅ known new
chainladder.methods.tests.test_mack.MW2014_TABLE2_TOTAL ❌ unknown new
chainladder.methods.tests.test_mack.TABLE2_LDF ✅ known new
chainladder.methods.tests.test_mack.TABLE2_SIGMA2 ✅ known new
chainladder.methods.tests.test_mack.TABLE4_CDR ✅ known new
chainladder.methods.tests.test_mack.TABLE4_COV ✅ known new
chainladder.methods.tests.test_mack.TABLE4_MACK ✅ known new
chainladder.methods.tests.test_mack.TABLE4_TOTAL ✅ known new
chainladder.methods.tests.test_mack.assert_within ❌ unknown new
chainladder.methods.tests.test_mack.by_origin ❌ unknown new
chainladder.methods.tests.test_mack.mack_sigma_fit ❌ unknown new
chainladder.methods.tests.test_mack.test_cdr_incremental_matches_cumulative ❌ unknown new
chainladder.methods.tests.test_mack.test_cdr_matches_merz2008 ❌ unknown new
chainladder.methods.tests.test_mack.test_cdr_matches_merz2014 ❌ unknown new
chainladder.methods.tests.test_mack.test_cdr_missing_cell_gives_nan ❌ unknown new
chainladder.methods.tests.test_mack.test_cdr_multi_triangle_matches_single ❌ unknown new
chainladder.methods.tests.test_mack.test_cdr_unsupported_pattern_raises_on_read ❌ unknown new
chainladder.methods.tests.test_mack.test_reading_cdr_leaves_mack_unchanged ❌ unknown new

@codecov

codecov Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.76%. Comparing base (83f94ff) to head (740379d).
⚠️ Report is 4 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1558      +/-   ##
==========================================
+ Coverage   94.59%   94.76%   +0.16%     
==========================================
  Files          98       98              
  Lines        6233     6318      +85     
  Branches      799      804       +5     
==========================================
+ Hits         5896     5987      +91     
+ Misses        216      212       -4     
+ Partials      121      119       -2     
Flag Coverage Δ
unittests 94.76% <100.00%> (+0.16%) ⬆️

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

this is a beefy PR. i'm good with the scope as is. will log some enh in the issue.

do you think it would make sense to merge test_mack_cdr with test_mack? i'm asking before a full review because i don't want any line-by-line comments to get lost in the shuffle

Move the one-year CDR tests from test_mack_cdr.py to the end of
test_mack.py, as asked in review. Their coverage table becomes the
module docstring, with a sentence on the existing Mack tests. The CDR
tests gain a cdr prefix (UNSUPPORTED becomes CDR_UNSUPPORTED) so they
read clearly beside the Mack tests; no test logic changed.

Developed with an AI assistant (Árbol, running Muse).
@scks1

scks1 commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Yes, that makes sense. Done in 8a7832d: the CDR tests now sit at the end of test_mack.py, and test_mack_cdr.py is gone. The coverage table became the module docstring, and the CDR tests gained a cdr prefix so they read clearly next to the Mack tests. No test logic changed, and the same 26 tests pass locally.

I can take the third item you logged on #1543, numbers for the one-year view section of the user guide, once this merges.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

@scks1

scks1 commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

CI on 8a7832d ran into today's GitHub Actions incident: the 3.12 and 3.13 test jobs and the Pyright check never started. The four test jobs that did run passed. Could you re-run the failed jobs when you get a chance?

(This comment was made with Árbol, an AI assistant, under my direction.)

@scks1 scks1 closed this Nov 9, 2026
@scks1 scks1 reopened this Nov 9, 2026
Comment thread chainladder/methods/mack.py Outdated
``mack_std_err_`` measures the error to ultimate, this attribute
measures the next year only, the horizon of a one-year solvency view.

The estimator assumes the paper's setting: a square triangle,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you make this docstring a bit more concise?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shortened in 8fdc25e. The cdr_std_err_ docstring now cites Result 3.1, says what the CDR is, points to mack_std_err_ for the error to ultimate, and notes sigma_interpolation and the NaN for missing cells. The supported setting is left to the Raises section. The description of total_cdr_std_err_ is down to two sentences.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

Comment thread chainladder/methods/mack.py Outdated
f"{sorted(str(v) for v in average)}."
)
# A link ratio enters f_j when origin i has C[i, j] and C[i, j + 1].
link = upper[:, 1:]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be simpler to feed C through a fresh Development and compare the resulting w_ against X.w_?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done in 8fdc25e. The check now fits Development() on C and compares its w_ with X.w_, which catches n_periods, drop, drop_high, drop_low and drop_valuation. fillna leaves w_ equal to the fresh fit's, so the check also compares ldf_ with the fresh fit's ldf_. average="simple" also leaves w_ unchanged, and the earlier average_ check catches it. The ValueError tests pass unchanged.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

Comment thread chainladder/methods/mack.py Outdated
"some (drop, drop_high, drop_low, drop_valuation, n_periods)."
)
with np.errstate(divide="ignore", invalid="ignore"):
S = np.where(pair, x, 0).sum(2) # S_j^I

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you sure that S, f, etc. all needs to bespoke logic? isn't this just volume-all chainladder? maybe i'm not understanding the intent. i do see the error check and the resumed use of self.ultimate_.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is volume-all chainladder, and 8fdc25e takes that part from the library: f is the fitted ldf_, S_j^{I+1} (2.10) is C.sum(axis="origin"), and S_j^I (2.9) is that sum less the latest diagonal. The link-ratio code is gone. Only the sums over later development periods inside Φ, Δ, Λ and Ξ stay bespoke, because Development does not compute them.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

Comment thread chainladder/methods/mack.py Outdated
S1 = S + diag # S_j^{I+1}
ult = self.ultimate_.set_backend("numpy").values[..., 0]
# Sums over the later periods j = d+1 .. J-1 for each d = 0 .. J-1.
A = diag * q / S1**2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are A, B, etc. notation from the paper? if these are just temp notation, could i trouble you to comment the source equation, such as "summand of (3.5)"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A and B were my own names. In 8fdc25e the temporaries carry the paper's symbols where it has them, Φ (3.5), Ψ (3.6), Δ (3.4), Λ (3.13) and Ξ (3.14), and the comments give the equation behind each. The two that are not the paper's are commented: q is σ_j² (3.3) over (f_j^I)², and shared is the sum that (3.4) and (3.13) have in common.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

Comment thread chainladder/methods/tests/test_mack.py Outdated
("mw2014", MW2014_CDR, MW2014_MACK, MW2014_TOTALS, 7),
],
)
def test_cdr_matches_r(sample, cdr, mack, totals, decimals, backend):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are ultimately trying to make this package standalone. hence we are trying to match to the paper, not to the R package.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 8fdc25e the tests check the paper only. test_cdr_matches_merz2008 checks the factors and σ² of Table 2 and both columns of Table 4 (per origin, the cov^½ row and the totals) on four stacked copies of mw2008. Table 4 prints whole units, and every entry is within 1 except accident year 2 (printed 1,488 and 1,566, computed 1,486.6 and 1,563.8), which is checked to 2.5. The R figures, the R references in the docstrings and user guide, and the gesmann2026 entry are gone. I dropped the mw2014 case, whose expected values came only from R.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

Comment thread chainladder/methods/tests/test_mack.py Outdated


@pytest.mark.parametrize("backend", ["numpy", "sparse"])
def test_cdr_matches_merz2008_table4(backend):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mw2008 gets used a few times. might as well add it to conftest.py

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to FIXTURES in conftest.py in 8fdc25e, with both runs like the other samples. The CDR tests that read mw2008 now take the fixture, so they run on the numpy and sparse backends.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

@henrydingliu henrydingliu self-assigned this Nov 10, 2026
- Compare w_ and ldf_ with a fresh Development on the triangle instead of
  recomputing the link ratios, and take S_j^I and S_j^{I+1} from triangle
  sums.
- Name the temporaries after Merz and Wüthrich (2008) and cite their
  equations.
- Test against Tables 2 and 4 of the paper on four stacked copies of
  mw2008. Drop the R figures, the mw2014 case and the gesmann2026 entry.
- Add an mw2008 fixture to conftest.py.
- Shorten the cdr_std_err_ and total_cdr_std_err_ docstrings.

Developed with an AI assistant (Árbol, running Muse).
@@ -1,6 +1,23 @@
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apologies for being vague with my previous request. I meant to say that public-facing, method-level docstring should be more concise. test module docstring is for internal documentation and should be descriptive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that's clear now. 740379d makes the module docstring descriptive again: a table of what each source checks for each attribute (merz2008, merz2014, per-triangle), a note per source giving the tables, pages and printed precision, and a summary of the other CDR tests. The two CDR tests that had one-line docstrings now say what they check and why. The cdr_std_err_ and total_cdr_std_err_ docstrings stay short.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

return np.concatenate((np.cumsum(t[..., ::-1], -1)[..., -2::-1], zero), -1)

with np.errstate(divide="ignore", invalid="ignore"):
q = sigma[..., :J] ** 2 / f**2 # sigma_j^2 (3.3) over (f_j^I)^2

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a blocker to approval. but just curious if you tried to do this through the triangle api, without falling back to numpy manipulation. we try to be intentional and comprehensive with our pandas passthroughs, groupby aggregations, and basic dunders. this is a good live test of how comprehensive triangle arithmetic is.. and whatever you can't easily accomplish here through the triangle api, we should raise as issue to contemplate adding to the library.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried it. A version of _get_cdr_msep written only with Triangle operations (origin sums, a valuation filter for the latest diagonal, latest_diagonal, shift along both axes, fillzero and arithmetic) gives the same figures as the numpy code: the same NaN cells and a largest relative difference of 1.7e-16 on mw2008 (cumulative, incremental, one missing cell), mw2014, raa and clrd by LOB, numpy and sparse. It is 8.5 to 17 times slower per call (235–484 ms against 27–29 ms; the most on the 17-year mw2014). I have it on a local branch and can push it if you want to see it.

What got in the way:

Given the speed, I'd keep numpy here and revisit once those are fixed, but I can switch if you'd rather have the Triangle version now.

(This reply was made with Árbol, an AI assistant, under my direction.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please keep numpy version

only 4 bugs is better than i expected. the ~10x speed degradation is worse than i expected. is it loops?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do: the PR stays on the numpy version (740379d).

Mostly loops, plus one slow property. Taking the fastest of 15 calls on the numpy backend, the Triangle version ran 232 ms against 28 ms for numpy on mw2008 (9×9), and 447 ms against 29 ms on mw2014 (17×17).

  • Loops: the two shift loops make 47 / 95 Triangle operations, a count that grows with the triangle. Replacing them with a numpy cumsum on .values brings the call to 105 / 122 ms, so they are 62% / 78% of the gap.
  • valuation: on triangles with valuation dates on the development axis (latest_diagonal, ultimate_) it builds a DataFrame and calls unstack, which costs 2.5 / 3.9 ms against 0.04 ms on the original triangle, and + and - call it up to twice through nan_triangle. It accounted for 42% / 53% of the profiled time. Building the same index with a single np.repeat and no DataFrame brings the call to 159 / 248 ms, with identical results.
  • With both changes the call takes 73 ms on either size, still 2.5–2.6x numpy. That remainder doesn't grow with size, which points to per-operation overhead: a Triangle + takes about 0.5 ms where the numpy add takes about 1 µs.

The valuation change should help arithmetic on any triangle with valuation dates on its development axis, so I can open a separate issue for it.

(This reply was made with Árbol, an AI assistant, under my direction.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please open an issue on the valuation change.

thanks for the investigation! please keep the triangle version around, which we can land after dealing with those bugs. we need more corpus on the triangle API.

np.testing.assert_array_less(gap, np.broadcast_to(tol, gap.shape))


def test_cdr_matches_merz2008(mw2008):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apologies for being vague. by suggesting to fixturize mw2008, i didn't mean to get rid of the mw2014 tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. 740379d brings mw2014 back as a fixture and tests it against Merz and Wüthrich (2014), the paper the sample comes from (its Table 1 is the same triangle): the factors and σ² of Table 1, the reserve and the process, parameter and Mack errors of Table 2 by origin and in total, and the one-year total of Table 3, each within half a unit of its last printed digit. The paper prints only the total one-year figure for this triangle (1,843), so per-origin CDR is still checked against Table 4 of Merz and Wüthrich (2008) on mw2008. merz2014 is added to references.bib.

(The commit and this reply were made with Árbol, an AI assistant, under my direction.)

- Test mw2014 against the figures that Merz and Wüthrich (2014) print
  for it: Table 1's factors and sigma^2, Table 2's reserve, process,
  parameter and Mack errors by origin and in total, and Table 3's
  one-year total. The paper prints no one-year figures by origin.
- Add an mw2014 fixture to conftest.py and merz2014 to references.bib.
- Make the test module docstring descriptive again, with a table of what
  each source checks for each attribute, and expand the two one-line
  CDR test docstrings.

Developed with an AI assistant (Árbol, running Muse).
@henrydingliu
henrydingliu merged commit 0e5a7a9 into casact:main Nov 11, 2026
10 of 11 checks passed
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.

2 participants