From 530b69995c461d65eaf751080a9b9f71872e10da Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan Date: Mon, 9 Nov 2026 15:17:06 +0000 Subject: [PATCH 1/4] Add one-year CDR standard errors to MackChainladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #1543. Developed with an AI assistant (Árbol, running Muse). --- chainladder/methods/mack.py | 208 +++++++++++++++ chainladder/methods/tests/test_mack_cdr.py | 286 +++++++++++++++++++++ docs/library/references.bib | 17 ++ docs/user_guide/methods.ipynb | 10 + 4 files changed, 521 insertions(+) create mode 100644 chainladder/methods/tests/test_mack_cdr.py diff --git a/chainladder/methods/mack.py b/chainladder/methods/mack.py index 96e4a4081..06e90d889 100644 --- a/chainladder/methods/mack.py +++ b/chainladder/methods/mack.py @@ -1,6 +1,7 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +import numpy as np import pandas as pd from chainladder.methods import Chainladder @@ -39,6 +40,12 @@ class MackChainladder(Chainladder): The total prediction error by origin period total_mack_std_err_: The total prediction error across all origin periods + cdr_std_err_: + The one-year standard error of the claims development result by + origin period (Merz and Wüthrich 2008, ``merz2008``) + total_cdr_std_err_: + The one-year standard error of the claims development result across + all origin periods See Also -------- @@ -465,6 +472,207 @@ def _get_total_mack_std_err_(self, obj): out = obj.sqrt().values[..., 0, -1] return pd.DataFrame(out, index=obj.index, columns=obj.columns) + @property + def cdr_std_err_(self): + """ + One-year standard error of the claims development result by origin + period. + + The claims development result (CDR) of an origin period is the change + in its estimated ultimate from this valuation to the next, once the + next diagonal is observed and the chainladder factors are estimated + again. The CDR is predicted by zero, and ``cdr_std_err_`` is the square + root of the conditional mean squared error of that prediction, Result + 3.1 (eq. 3.9) of Merz and Wüthrich (2008, ``merz2008``). Where + ``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, + development factors volume-weighted over all periods with no link + ratios excluded, and no tail beyond the default ``TailConstant(1.0)``. + Reading the attribute raises ``ValueError`` for any other development + pattern, and ``fit`` is unaffected. A triangle with missing cells + above the latest diagonal gets NaN. The figures follow the + ``sigma_interpolation`` chosen in :class:`Development`; ``"mack"`` + matches ``est.sigma="Mack"`` in the ``CDR()`` function of R's + ChainLadder package (``gesmann2026``). The attribute describes the + fitted triangle, and :meth:`predict` does not attach it. + + Returns + ------- + Triangle + One value per origin, shaped like ``ultimate_``. The oldest + origin is fully developed and is NaN, as in ``mack_std_err_``. + + Raises + ------ + ValueError + If the triangle is not square, has a tail, or its development + factors are not volume-weighted over every link ratio of the + triangle itself. + + Examples + -------- + The triangle of Merz and Wüthrich (2008), with Mack's extrapolation + for the last sigma. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + tr = cl.load_sample('mw2008') + dev = cl.Development(sigma_interpolation='mack').fit_transform(tr) + model = cl.MackChainladder().fit(dev) + print(model.cdr_std_err_) + + .. testoutput:: + + 2261 + 2001 NaN + 2002 566.174395 + 2003 1486.560344 + 2004 3923.098608 + 2005 9722.859763 + 2006 28442.621556 + 2007 20954.286973 + 2008 28119.317963 + 2009 53320.821049 + """ + obj = self.ultimate_.set_backend("numpy") + obj.values = np.sqrt(self._get_cdr_msep()[0])[..., None] + return obj.set_backend(self.X_.array_backend) + + @property + def total_cdr_std_err_(self): + """ + One-year standard error of the claims development result summed over + all origin periods, Result 3.3 (eq. 3.15) of Merz and Wüthrich (2008, + ``merz2008``). + + The total exceeds the quadrature sum of ``cdr_std_err_`` because all + origins share the estimated age-to-age factors. The supported setting + and the ``ValueError`` cases are those of ``cdr_std_err_``. + + Returns + ------- + DataFrame + One value per (index, column) combination of the fitted Triangle. + + Examples + -------- + On the paper's triangle the one-year error is about three quarters of + Mack's error to ultimate. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + tr = cl.load_sample('mw2008') + dev = cl.Development(sigma_interpolation='mack').fit_transform(tr) + model = cl.MackChainladder().fit(dev) + print(model.total_cdr_std_err_) + print(model.total_mack_std_err_) + + .. testoutput:: + + columns values + (Total,) 81080.546787 + columns values + (Total,) 108401.387451 + """ + obj = self.X_ + out = np.sqrt(self._get_cdr_msep()[1]) + return pd.DataFrame(out, index=obj.index, columns=obj.columns) + + def _get_cdr_msep(self): + """ + Mean squared errors of the one-year claims development result. + + Returns the per-origin values, shape (index, columns, origin), and + the totals over origins, shape (index, columns), following Results 3.1 + and 3.3 of ``merz2008`` with I = J = n - 1 and 0-based origins. + """ + X = self.X_.set_backend("numpy") + C = (X if X.is_cumulative else X.incr_to_cum()).values + a, b, n, m = C.shape + msg = "cdr_std_err_ and total_cdr_std_err_ need " + upper = np.add.outer(np.arange(n), np.arange(m)) <= n - 1 + if n != m or not np.array_equal(~np.isnan(X.nan_triangle), upper): + raise ValueError( + msg + "a square triangle valued at its latest diagonal; this " + f"one has {n} origins and {m} development periods." + ) + J = m - 1 + ldf = X.ldf_.set_backend("numpy").values[..., 0, :] + sigma = X.sigma_.set_backend("numpy").values[..., 0, :] + if not ( + np.all(np.nan_to_num(ldf[..., J:], nan=1.0) == 1) + and np.all(np.nan_to_num(sigma[..., J:]) == 0) + ): + raise ValueError( + msg + "a pattern without a tail, as Merz and Wüthrich (2008) " + "assume; fit without a tail estimator or with TailConstant(1.0)." + ) + average = set(np.asarray(X.average_).ravel()) + if average != {"volume"}: + raise ValueError( + msg + "volume-weighted development factors; this pattern uses " + 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:] + x, y = C[..., :J], C[..., 1:] + pair = link & ~np.isnan(x) & ~np.isnan(y) + w = np.nan_to_num(np.broadcast_to(np.asarray(X.w_), pair.shape), nan=1.0) + if np.any(w[pair] == 0): + raise ValueError( + msg + "every link ratio of the triangle; this pattern excludes " + "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 + f_own = np.where(pair, y, 0).sum(2) / S + f = np.broadcast_to(ldf[..., :J], S.shape) + known = np.isfinite(f_own) & np.isfinite(f) + if not np.allclose(f[known], f_own[known], rtol=1e-8, atol=0): + raise ValueError( + msg + "development factors estimated from this triangle's " + "own link ratios; ldf_ differs from them, as after " + "Development(fillna=...)." + ) + q = np.broadcast_to(sigma[..., :J], S.shape) ** 2 / f**2 + latest = C[..., np.arange(n), J - np.arange(n)] # C_{i, I-i} + diag = latest[..., ::-1][..., :J] # C_{I-j, j} + 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 + B = (diag / S1) ** 2 * q / S + zero = np.zeros((a, b, 1)) + TA = np.concatenate((np.cumsum(A[..., ::-1], -1)[..., -2::-1], zero), -1) + TB = np.concatenate((np.cumsum(B[..., ::-1], -1)[..., -2::-1], zero), -1) + i = np.arange(1, n) + d = J - i # next development period of origin i + qd, Sd, S1d = q[..., d], S[..., d], S1[..., d] + Phi = TA[..., d] + Delta = qd / Sd + TB[..., d] + Lam = latest[..., i] / S1d * qd / Sd + TB[..., d] + Xi = Phi + qd / S1d + msep = np.full((a, b, n), np.nan) + msep[..., 1:] = ult[..., i] ** 2 * (qd / latest[..., i] + Phi + Delta) + later = np.cumsum(ult[..., :0:-1], -1)[..., ::-1] # sum of ult_k, k >= i + later = np.concatenate((later[..., 1:], zero), -1) # k > i + cross = 2 * (ult[..., i] * later * (Xi + Lam)).sum(-1) + total = msep[..., 1:].sum(-1) + cross + complete = ~np.isnan(C[..., upper]).any(-1) + msep[~complete] = np.nan + total[~complete] = np.nan + return msep, total + @property def summary_(self): """ diff --git a/chainladder/methods/tests/test_mack_cdr.py b/chainladder/methods/tests/test_mack_cdr.py new file mode 100644 index 000000000..724398beb --- /dev/null +++ b/chainladder/methods/tests/test_mack_cdr.py @@ -0,0 +1,286 @@ +""" +Tests for the one-year claims development result of MackChainladder. + +Sources are cited by their keys in docs/library/references.bib. + +What this module checks for each attribute, by source: + +==================== ========= ========= ========= ============ +Attribute R mw2008 R mw2014 merz2008 per-triangle +==================== ========= ========= ========= ============ +cdr_std_err_ published published published unchanged +total_cdr_std_err_ published published published unchanged +mack_std_err_ published published no test no test +total_mack_std_err_ published published no test no test +==================== ========= ========= ========= ============ + +published + R mw2008 and R mw2014 are the CDR() examples printed on the reference page + of gesmann2026, for MackChainLadder fits with est.sigma="Mack". Each value + matches to half a unit in the last printed decimal (4 decimals for mw2008, + 7 for mw2014), for every origin and the total. merz2008 is the one-year + column of Table 4, printed in whole units and checked to 2 units for the + reason given in its test. +unchanged + A fit on clrd summed by LOB, paid and incurred, gives each of its twelve + triangles the values of a fit on that triangle alone. + +The Mack rows confirm that sigma_interpolation="mack" reproduces R's +est.sigma="Mack"; test_mack.py checks the Mack attributes against mack1994 and +mack1999. + +The R and paper figures are checked on the numpy and sparse backends. Other +tests check that an incremental triangle gives the cumulative values, that a +triangle with a missing cell gets NaN, that each unsupported development +pattern fits and then raises ValueError on reading the CDR attributes, and +that reading them leaves the Mack attributes as they were. +""" + +from __future__ import annotations + +import numpy as np + +import chainladder as cl +import pytest + +# gesmann2026, CDR() reference page: CDR(1)S.E. and Mack.S.E. for MW2008 by +# origin (1 to 9), then the totals. +MW2008_CDR = [ + 0.0000, + 566.1744, + 1486.5603, + 3923.0986, + 9722.8598, + 28442.6216, + 20954.2870, + 28119.3180, + 53320.8210, +] +MW2008_MACK = [ + 0.0000, + 566.1744, + 1563.8075, + 4157.2733, + 10536.4380, + 30319.4638, + 35967.0384, + 45090.1821, + 69552.3397, +] +MW2008_TOTALS = (81080.5468, 108401.3875) + +# gesmann2026, CDR() reference page: the same columns for MW2014, origins 1 +# to 17. +MW2014_CDR = [ + 0.0000000, + 0.4083149, + 2.5393857, + 16.7232632, + 156.4022713, + 137.6522771, + 171.1812092, + 70.3161155, + 271.6352221, + 310.1268449, + 103.3834357, + 632.6388191, + 315.0489135, + 406.1424672, + 285.2076540, + 668.2337878, + 733.2222786, +] +MW2014_MACK = [ + 0.0000000, + 0.4083149, + 2.5652899, + 16.8984949, + 157.2756452, + 207.1650862, + 261.9266093, + 292.2622285, + 390.5874717, + 502.0606072, + 486.0911099, + 806.9028971, + 793.9381916, + 891.6613403, + 916.4940218, + 1106.1262716, + 1295.6909824, +] +MW2014_TOTALS = (1842.8507073, 3233.6807352) + +# merz2008, Table 4 (p. 562): the one-year column for accident years 1 to 8, +# the sample's origins 2002 to 2009, then the total. +TABLE4_CDR = [567, 1488, 3923, 9723, 28443, 20954, 28119, 53320] +TABLE4_TOTAL = 81080 + +UNSUPPORTED = { + "tail": ( + lambda t: cl.TailCurve().fit_transform(cl.Development().fit_transform(t)), + "without a tail", + ), + "n_periods": (lambda t: cl.Development(n_periods=5).fit_transform(t), "every"), + "drop_high": (lambda t: cl.Development(drop_high=1).fit_transform(t), "every"), + "drop": (lambda t: cl.Development(drop=("2003", 12)).fit_transform(t), "every"), + "simple": ( + lambda t: cl.Development(average="simple").fit_transform(t), + "volume-weighted", + ), + "fillna": (lambda t: cl.Development(fillna=1.0).fit_transform(t), "own link"), + "not_square": (lambda t: t[t.origin < "2009"], "square"), +} + + +def mack_sigma_fit(tri): + """MackChainladder with Mack's extrapolation of the last sigma.""" + dev = cl.Development(sigma_interpolation="mack").fit_transform(tri) + return cl.MackChainladder().fit(dev) + + +def by_origin(triangle): + """Values of a single-triangle per-origin Triangle as a numpy vector.""" + return triangle.set_backend("numpy").values[0, 0, :, -1] + + +@pytest.mark.parametrize("backend", ["numpy", "sparse"]) +@pytest.mark.parametrize( + "sample, cdr, mack, totals, decimals", + [ + ("mw2008", MW2008_CDR, MW2008_MACK, MW2008_TOTALS, 4), + ("mw2014", MW2014_CDR, MW2014_MACK, MW2014_TOTALS, 7), + ], +) +def test_matches_r(sample, cdr, mack, totals, decimals, backend): + """ + One-year and Mack standard errors match R's CDR() output. + + The figures are the MW2008 and MW2014 examples of CDR() in gesmann2026, + fitted with est.sigma="Mack"; the samples mw2008 and mw2014 hold the same + triangles. Each value matches to half a unit in the last decimal R + prints. R prints 0 for the oldest origin, which is + fully developed; cdr_std_err_ and mack_std_err_ give NaN there. + """ + model = mack_sigma_fit(cl.load_sample(sample).set_backend(backend)) + atol = 0.5 * 10.0**-decimals + assert np.isnan(by_origin(model.cdr_std_err_)[0]) + assert np.isnan(by_origin(model.mack_std_err_)[0]) + np.testing.assert_allclose( + by_origin(model.cdr_std_err_)[1:], cdr[1:], rtol=0, atol=atol + ) + np.testing.assert_allclose( + by_origin(model.mack_std_err_)[1:], mack[1:], rtol=0, atol=atol + ) + np.testing.assert_allclose( + model.total_cdr_std_err_.values[0, 0], totals[0], rtol=0, atol=atol + ) + np.testing.assert_allclose( + model.total_mack_std_err_.values[0, 0], totals[1], rtol=0, atol=atol + ) + + +@pytest.mark.parametrize("backend", ["numpy", "sparse"]) +def test_matches_merz2008_table4(backend): + """ + One-year standard errors match merz2008, Table 4, to 2 units. + + Table 4 prints whole units of $1,000, and Table 2 prints the sigma^2 + estimates to two decimals. Against the unrounded figures that R and this + implementation share, the entries for accident years 1 and 2 are 0.83 and + 1.44 higher; every other entry and the total are within 1. + """ + model = mack_sigma_fit(cl.load_sample("mw2008").set_backend(backend)) + np.testing.assert_allclose( + by_origin(model.cdr_std_err_)[1:], TABLE4_CDR, rtol=0, atol=2 + ) + np.testing.assert_allclose( + model.total_cdr_std_err_.values[0, 0], TABLE4_TOTAL, rtol=0, atol=2 + ) + + +def test_multi_triangle_matches_single(clrd): + """ + A fit on several triangles gives each the values of its own fit. + + clrd summed by LOB, paid and incurred, is twelve complete triangles. Both + attributes of the joint fit match a fit on each triangle alone. + """ + tri = clrd.groupby("LOB").sum()[["CumPaidLoss", "IncurLoss"]] + model = cl.MackChainladder().fit(tri) + per = model.cdr_std_err_.set_backend("numpy").values + total = model.total_cdr_std_err_.values + assert np.isfinite(total).all() + for i in range(len(tri.index)): + for j in range(len(tri.columns)): + single = cl.MackChainladder().fit(tri.iloc[i, j]) + np.testing.assert_allclose( + per[i, j, :, 0], by_origin(single.cdr_std_err_), rtol=1e-10 + ) + np.testing.assert_allclose( + total[i, j], single.total_cdr_std_err_.values[0, 0], rtol=1e-10 + ) + + +def test_incremental_matches_cumulative(): + """An incremental triangle gives the values of its cumulative form.""" + tri = cl.load_sample("mw2008") + cumulative = mack_sigma_fit(tri) + incremental = mack_sigma_fit(tri.cum_to_incr()) + np.testing.assert_allclose( + by_origin(incremental.cdr_std_err_), + by_origin(cumulative.cdr_std_err_), + rtol=1e-12, + ) + np.testing.assert_allclose( + incremental.total_cdr_std_err_.values, + cumulative.total_cdr_std_err_.values, + rtol=1e-12, + ) + + +def test_missing_cell_gives_nan(): + """ + A triangle with a missing cell above the latest diagonal gets NaN. + + The estimators of merz2008 use every cell of the upper triangle. Removing + one cell from mw2008 leaves MackChainladder able to fit, and both CDR + attributes are NaN. + """ + tri = cl.load_sample("mw2008") + tri.values = tri.values.copy() + tri.values[0, 0, 3, 1] = np.nan + model = mack_sigma_fit(tri) + assert np.isfinite(model.total_mack_std_err_.values).all() + assert np.isnan(by_origin(model.cdr_std_err_)).all() + assert np.isnan(model.total_cdr_std_err_.values).all() + + +@pytest.mark.parametrize("name", list(UNSUPPORTED)) +def test_unsupported_pattern_raises_on_read(name): + """ + Unsupported development patterns fit, then raise on reading the CDR. + + merz2008 assumes a square triangle, volume-weighted factors from every + link ratio of the triangle and no tail. MackChainladder still fits each + pattern here and gives its Mack standard errors; only the two CDR + attributes raise ValueError. + """ + transform, message = UNSUPPORTED[name] + model = cl.MackChainladder().fit(transform(cl.load_sample("mw2008"))) + assert np.isfinite(model.total_mack_std_err_.values).all() + with pytest.raises(ValueError, match=message): + _ = model.cdr_std_err_ + with pytest.raises(ValueError, match=message): + _ = model.total_cdr_std_err_ + + +def test_reading_cdr_leaves_mack_unchanged(): + """Reading the CDR attributes leaves the fitted Mack attributes as they were.""" + model = mack_sigma_fit(cl.load_sample("mw2008")) + summary = model.summary_.values.copy() + total = model.total_mack_std_err_.values.copy() + assert np.isfinite(by_origin(model.cdr_std_err_)[1:]).all() + assert np.isfinite(model.total_cdr_std_err_.values).all() + np.testing.assert_array_equal(model.summary_.values, summary) + np.testing.assert_array_equal(model.total_mack_std_err_.values, total) diff --git a/docs/library/references.bib b/docs/library/references.bib index 9d849aefd..3cef929c7 100644 --- a/docs/library/references.bib +++ b/docs/library/references.bib @@ -103,3 +103,20 @@ @article{boor2006 year = {2006}, url = {https://www.casact.org/sites/default/files/database/forum_06wforum_06w348.pdf} } + +@article{merz2008, + author = {Merz, M. and Wüthrich, M. V.}, + title = {Modelling the {C}laims {D}evelopment {R}esult for {S}olvency {P}urposes}, + journal = {Casualty Actuarial Society E-Forum, Fall 2008}, + pages = {542--568}, + year = {2008}, + url = {https://www.casact.org/abstract/modeling-claims-development-result-solvency-purposes} +} + +@manual{gesmann2026, + author = {Gesmann, M. and Murphy, D. and Zhang, Y. and Carrato, A. and Wuthrich, M. and Concina, F. and {Dal Moro}, E.}, + title = {{ChainLadder}: {S}tatistical {M}ethods and {M}odels for {C}laims {R}eserving in {G}eneral {I}nsurance}, + note = {R package version 0.2.21}, + year = {2026}, + url = {https://mages.github.io/ChainLadder/} +} diff --git a/docs/user_guide/methods.ipynb b/docs/user_guide/methods.ipynb index 7bab41c2c..013e66ae1 100644 --- a/docs/user_guide/methods.ipynb +++ b/docs/user_guide/methods.ipynb @@ -549,6 +549,16 @@ "`Chainladder` should be used.\n", "```\n", "\n", + "### One-year view\n", + "\n", + "`mack_std_err_` is the prediction error of the reserve to ultimate. For a\n", + "one-year view, `cdr_std_err_` and `total_cdr_std_err_` give the standard error\n", + "of the claims development result, the change in the estimated ultimate once\n", + "the next diagonal is observed {cite}`merz2008`. They need a square triangle,\n", + "volume-weighted factors from every link ratio and no tail, and they follow the\n", + "`sigma_interpolation` of `Development`. With `sigma_interpolation=\"mack\"` they\n", + "match the `CDR()` examples of R's ChainLadder package {cite}`gesmann2026`.\n", + "\n", "### Examples\n", "\n", "::::{grid}\n", From 8a7832d077eb7725ac707ed1c4ef13767002f4d0 Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan Date: Mon, 9 Nov 2026 13:24:16 -0500 Subject: [PATCH 2/4] Merge the CDR tests into test_mack.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- chainladder/methods/tests/test_mack.py | 284 ++++++++++++++++++++ chainladder/methods/tests/test_mack_cdr.py | 286 --------------------- 2 files changed, 284 insertions(+), 286 deletions(-) delete mode 100644 chainladder/methods/tests/test_mack_cdr.py diff --git a/chainladder/methods/tests/test_mack.py b/chainladder/methods/tests/test_mack.py index 9e9bfb9a0..098c9f450 100644 --- a/chainladder/methods/tests/test_mack.py +++ b/chainladder/methods/tests/test_mack.py @@ -1,6 +1,47 @@ +""" +Tests for MackChainladder. + +test_mack1994_hardcode and test_mack1999_hardcode check the Mack attributes +against mack1994 and mack1999. The tests from test_cdr_matches_r on cover the +one-year claims development result and cite sources by their keys in +docs/library/references.bib. What they check for each attribute, by source: + +==================== ========= ========= ========= ============ +Attribute R mw2008 R mw2014 merz2008 per-triangle +==================== ========= ========= ========= ============ +cdr_std_err_ published published published unchanged +total_cdr_std_err_ published published published unchanged +mack_std_err_ published published no test no test +total_mack_std_err_ published published no test no test +==================== ========= ========= ========= ============ + +published + R mw2008 and R mw2014 are the CDR() examples printed on the reference page + of gesmann2026, for MackChainLadder fits with est.sigma="Mack". Each value + matches to half a unit in the last printed decimal (4 decimals for mw2008, + 7 for mw2014), for every origin and the total. merz2008 is the one-year + column of Table 4, printed in whole units and checked to 2 units for the + reason given in its test. +unchanged + A fit on clrd summed by LOB, paid and incurred, gives each of its twelve + triangles the values of a fit on that triangle alone. + +The Mack rows confirm that sigma_interpolation="mack" reproduces R's +est.sigma="Mack". + +The R and paper figures are checked on the numpy and sparse backends. Other +CDR tests check that an incremental triangle gives the cumulative values, +that a triangle with a missing cell gets NaN, that each unsupported +development pattern fits and then raises ValueError on reading the CDR +attributes, and that reading them leaves the Mack attributes as they were. +""" + +from __future__ import annotations + import numpy as np import chainladder as cl +import pytest def test_mack_to_triangle(): @@ -84,3 +125,246 @@ def test_mack1994_hardcode(raa): assert np.allclose(sigma_sq[6:8], sigma_rhs[6:8], atol=0.01) ibnr_rhs = model.summary_.values[0, 0, :, -1] assert np.allclose(ibnr_se, ibnr_rhs[1:], atol=1) + + +# gesmann2026, CDR() reference page: CDR(1)S.E. and Mack.S.E. for MW2008 by +# origin (1 to 9), then the totals. +MW2008_CDR = [ + 0.0000, + 566.1744, + 1486.5603, + 3923.0986, + 9722.8598, + 28442.6216, + 20954.2870, + 28119.3180, + 53320.8210, +] +MW2008_MACK = [ + 0.0000, + 566.1744, + 1563.8075, + 4157.2733, + 10536.4380, + 30319.4638, + 35967.0384, + 45090.1821, + 69552.3397, +] +MW2008_TOTALS = (81080.5468, 108401.3875) + +# gesmann2026, CDR() reference page: the same columns for MW2014, origins 1 +# to 17. +MW2014_CDR = [ + 0.0000000, + 0.4083149, + 2.5393857, + 16.7232632, + 156.4022713, + 137.6522771, + 171.1812092, + 70.3161155, + 271.6352221, + 310.1268449, + 103.3834357, + 632.6388191, + 315.0489135, + 406.1424672, + 285.2076540, + 668.2337878, + 733.2222786, +] +MW2014_MACK = [ + 0.0000000, + 0.4083149, + 2.5652899, + 16.8984949, + 157.2756452, + 207.1650862, + 261.9266093, + 292.2622285, + 390.5874717, + 502.0606072, + 486.0911099, + 806.9028971, + 793.9381916, + 891.6613403, + 916.4940218, + 1106.1262716, + 1295.6909824, +] +MW2014_TOTALS = (1842.8507073, 3233.6807352) + +# merz2008, Table 4 (p. 562): the one-year column for accident years 1 to 8, +# the sample's origins 2002 to 2009, then the total. +TABLE4_CDR = [567, 1488, 3923, 9723, 28443, 20954, 28119, 53320] +TABLE4_TOTAL = 81080 + +CDR_UNSUPPORTED = { + "tail": ( + lambda t: cl.TailCurve().fit_transform(cl.Development().fit_transform(t)), + "without a tail", + ), + "n_periods": (lambda t: cl.Development(n_periods=5).fit_transform(t), "every"), + "drop_high": (lambda t: cl.Development(drop_high=1).fit_transform(t), "every"), + "drop": (lambda t: cl.Development(drop=("2003", 12)).fit_transform(t), "every"), + "simple": ( + lambda t: cl.Development(average="simple").fit_transform(t), + "volume-weighted", + ), + "fillna": (lambda t: cl.Development(fillna=1.0).fit_transform(t), "own link"), + "not_square": (lambda t: t[t.origin < "2009"], "square"), +} + + +def mack_sigma_fit(tri): + """MackChainladder with Mack's extrapolation of the last sigma.""" + dev = cl.Development(sigma_interpolation="mack").fit_transform(tri) + return cl.MackChainladder().fit(dev) + + +def by_origin(triangle): + """Values of a single-triangle per-origin Triangle as a numpy vector.""" + return triangle.set_backend("numpy").values[0, 0, :, -1] + + +@pytest.mark.parametrize("backend", ["numpy", "sparse"]) +@pytest.mark.parametrize( + "sample, cdr, mack, totals, decimals", + [ + ("mw2008", MW2008_CDR, MW2008_MACK, MW2008_TOTALS, 4), + ("mw2014", MW2014_CDR, MW2014_MACK, MW2014_TOTALS, 7), + ], +) +def test_cdr_matches_r(sample, cdr, mack, totals, decimals, backend): + """ + One-year and Mack standard errors match R's CDR() output. + + The figures are the MW2008 and MW2014 examples of CDR() in gesmann2026, + fitted with est.sigma="Mack"; the samples mw2008 and mw2014 hold the same + triangles. Each value matches to half a unit in the last decimal R + prints. R prints 0 for the oldest origin, which is + fully developed; cdr_std_err_ and mack_std_err_ give NaN there. + """ + model = mack_sigma_fit(cl.load_sample(sample).set_backend(backend)) + atol = 0.5 * 10.0**-decimals + assert np.isnan(by_origin(model.cdr_std_err_)[0]) + assert np.isnan(by_origin(model.mack_std_err_)[0]) + np.testing.assert_allclose( + by_origin(model.cdr_std_err_)[1:], cdr[1:], rtol=0, atol=atol + ) + np.testing.assert_allclose( + by_origin(model.mack_std_err_)[1:], mack[1:], rtol=0, atol=atol + ) + np.testing.assert_allclose( + model.total_cdr_std_err_.values[0, 0], totals[0], rtol=0, atol=atol + ) + np.testing.assert_allclose( + model.total_mack_std_err_.values[0, 0], totals[1], rtol=0, atol=atol + ) + + +@pytest.mark.parametrize("backend", ["numpy", "sparse"]) +def test_cdr_matches_merz2008_table4(backend): + """ + One-year standard errors match merz2008, Table 4, to 2 units. + + Table 4 prints whole units of $1,000, and Table 2 prints the sigma^2 + estimates to two decimals. Against the unrounded figures that R and this + implementation share, the entries for accident years 1 and 2 are 0.83 and + 1.44 higher; every other entry and the total are within 1. + """ + model = mack_sigma_fit(cl.load_sample("mw2008").set_backend(backend)) + np.testing.assert_allclose( + by_origin(model.cdr_std_err_)[1:], TABLE4_CDR, rtol=0, atol=2 + ) + np.testing.assert_allclose( + model.total_cdr_std_err_.values[0, 0], TABLE4_TOTAL, rtol=0, atol=2 + ) + + +def test_cdr_multi_triangle_matches_single(clrd): + """ + A fit on several triangles gives each the values of its own fit. + + clrd summed by LOB, paid and incurred, is twelve complete triangles. Both + attributes of the joint fit match a fit on each triangle alone. + """ + tri = clrd.groupby("LOB").sum()[["CumPaidLoss", "IncurLoss"]] + model = cl.MackChainladder().fit(tri) + per = model.cdr_std_err_.set_backend("numpy").values + total = model.total_cdr_std_err_.values + assert np.isfinite(total).all() + for i in range(len(tri.index)): + for j in range(len(tri.columns)): + single = cl.MackChainladder().fit(tri.iloc[i, j]) + np.testing.assert_allclose( + per[i, j, :, 0], by_origin(single.cdr_std_err_), rtol=1e-10 + ) + np.testing.assert_allclose( + total[i, j], single.total_cdr_std_err_.values[0, 0], rtol=1e-10 + ) + + +def test_cdr_incremental_matches_cumulative(): + """An incremental triangle gives the values of its cumulative form.""" + tri = cl.load_sample("mw2008") + cumulative = mack_sigma_fit(tri) + incremental = mack_sigma_fit(tri.cum_to_incr()) + np.testing.assert_allclose( + by_origin(incremental.cdr_std_err_), + by_origin(cumulative.cdr_std_err_), + rtol=1e-12, + ) + np.testing.assert_allclose( + incremental.total_cdr_std_err_.values, + cumulative.total_cdr_std_err_.values, + rtol=1e-12, + ) + + +def test_cdr_missing_cell_gives_nan(): + """ + A triangle with a missing cell above the latest diagonal gets NaN. + + The estimators of merz2008 use every cell of the upper triangle. Removing + one cell from mw2008 leaves MackChainladder able to fit, and both CDR + attributes are NaN. + """ + tri = cl.load_sample("mw2008") + tri.values = tri.values.copy() + tri.values[0, 0, 3, 1] = np.nan + model = mack_sigma_fit(tri) + assert np.isfinite(model.total_mack_std_err_.values).all() + assert np.isnan(by_origin(model.cdr_std_err_)).all() + assert np.isnan(model.total_cdr_std_err_.values).all() + + +@pytest.mark.parametrize("name", list(CDR_UNSUPPORTED)) +def test_cdr_unsupported_pattern_raises_on_read(name): + """ + Unsupported development patterns fit, then raise on reading the CDR. + + merz2008 assumes a square triangle, volume-weighted factors from every + link ratio of the triangle and no tail. MackChainladder still fits each + pattern here and gives its Mack standard errors; only the two CDR + attributes raise ValueError. + """ + transform, message = CDR_UNSUPPORTED[name] + model = cl.MackChainladder().fit(transform(cl.load_sample("mw2008"))) + assert np.isfinite(model.total_mack_std_err_.values).all() + with pytest.raises(ValueError, match=message): + _ = model.cdr_std_err_ + with pytest.raises(ValueError, match=message): + _ = model.total_cdr_std_err_ + + +def test_reading_cdr_leaves_mack_unchanged(): + """Reading the CDR attributes leaves the fitted Mack attributes as they were.""" + model = mack_sigma_fit(cl.load_sample("mw2008")) + summary = model.summary_.values.copy() + total = model.total_mack_std_err_.values.copy() + assert np.isfinite(by_origin(model.cdr_std_err_)[1:]).all() + assert np.isfinite(model.total_cdr_std_err_.values).all() + np.testing.assert_array_equal(model.summary_.values, summary) + np.testing.assert_array_equal(model.total_mack_std_err_.values, total) diff --git a/chainladder/methods/tests/test_mack_cdr.py b/chainladder/methods/tests/test_mack_cdr.py deleted file mode 100644 index 724398beb..000000000 --- a/chainladder/methods/tests/test_mack_cdr.py +++ /dev/null @@ -1,286 +0,0 @@ -""" -Tests for the one-year claims development result of MackChainladder. - -Sources are cited by their keys in docs/library/references.bib. - -What this module checks for each attribute, by source: - -==================== ========= ========= ========= ============ -Attribute R mw2008 R mw2014 merz2008 per-triangle -==================== ========= ========= ========= ============ -cdr_std_err_ published published published unchanged -total_cdr_std_err_ published published published unchanged -mack_std_err_ published published no test no test -total_mack_std_err_ published published no test no test -==================== ========= ========= ========= ============ - -published - R mw2008 and R mw2014 are the CDR() examples printed on the reference page - of gesmann2026, for MackChainLadder fits with est.sigma="Mack". Each value - matches to half a unit in the last printed decimal (4 decimals for mw2008, - 7 for mw2014), for every origin and the total. merz2008 is the one-year - column of Table 4, printed in whole units and checked to 2 units for the - reason given in its test. -unchanged - A fit on clrd summed by LOB, paid and incurred, gives each of its twelve - triangles the values of a fit on that triangle alone. - -The Mack rows confirm that sigma_interpolation="mack" reproduces R's -est.sigma="Mack"; test_mack.py checks the Mack attributes against mack1994 and -mack1999. - -The R and paper figures are checked on the numpy and sparse backends. Other -tests check that an incremental triangle gives the cumulative values, that a -triangle with a missing cell gets NaN, that each unsupported development -pattern fits and then raises ValueError on reading the CDR attributes, and -that reading them leaves the Mack attributes as they were. -""" - -from __future__ import annotations - -import numpy as np - -import chainladder as cl -import pytest - -# gesmann2026, CDR() reference page: CDR(1)S.E. and Mack.S.E. for MW2008 by -# origin (1 to 9), then the totals. -MW2008_CDR = [ - 0.0000, - 566.1744, - 1486.5603, - 3923.0986, - 9722.8598, - 28442.6216, - 20954.2870, - 28119.3180, - 53320.8210, -] -MW2008_MACK = [ - 0.0000, - 566.1744, - 1563.8075, - 4157.2733, - 10536.4380, - 30319.4638, - 35967.0384, - 45090.1821, - 69552.3397, -] -MW2008_TOTALS = (81080.5468, 108401.3875) - -# gesmann2026, CDR() reference page: the same columns for MW2014, origins 1 -# to 17. -MW2014_CDR = [ - 0.0000000, - 0.4083149, - 2.5393857, - 16.7232632, - 156.4022713, - 137.6522771, - 171.1812092, - 70.3161155, - 271.6352221, - 310.1268449, - 103.3834357, - 632.6388191, - 315.0489135, - 406.1424672, - 285.2076540, - 668.2337878, - 733.2222786, -] -MW2014_MACK = [ - 0.0000000, - 0.4083149, - 2.5652899, - 16.8984949, - 157.2756452, - 207.1650862, - 261.9266093, - 292.2622285, - 390.5874717, - 502.0606072, - 486.0911099, - 806.9028971, - 793.9381916, - 891.6613403, - 916.4940218, - 1106.1262716, - 1295.6909824, -] -MW2014_TOTALS = (1842.8507073, 3233.6807352) - -# merz2008, Table 4 (p. 562): the one-year column for accident years 1 to 8, -# the sample's origins 2002 to 2009, then the total. -TABLE4_CDR = [567, 1488, 3923, 9723, 28443, 20954, 28119, 53320] -TABLE4_TOTAL = 81080 - -UNSUPPORTED = { - "tail": ( - lambda t: cl.TailCurve().fit_transform(cl.Development().fit_transform(t)), - "without a tail", - ), - "n_periods": (lambda t: cl.Development(n_periods=5).fit_transform(t), "every"), - "drop_high": (lambda t: cl.Development(drop_high=1).fit_transform(t), "every"), - "drop": (lambda t: cl.Development(drop=("2003", 12)).fit_transform(t), "every"), - "simple": ( - lambda t: cl.Development(average="simple").fit_transform(t), - "volume-weighted", - ), - "fillna": (lambda t: cl.Development(fillna=1.0).fit_transform(t), "own link"), - "not_square": (lambda t: t[t.origin < "2009"], "square"), -} - - -def mack_sigma_fit(tri): - """MackChainladder with Mack's extrapolation of the last sigma.""" - dev = cl.Development(sigma_interpolation="mack").fit_transform(tri) - return cl.MackChainladder().fit(dev) - - -def by_origin(triangle): - """Values of a single-triangle per-origin Triangle as a numpy vector.""" - return triangle.set_backend("numpy").values[0, 0, :, -1] - - -@pytest.mark.parametrize("backend", ["numpy", "sparse"]) -@pytest.mark.parametrize( - "sample, cdr, mack, totals, decimals", - [ - ("mw2008", MW2008_CDR, MW2008_MACK, MW2008_TOTALS, 4), - ("mw2014", MW2014_CDR, MW2014_MACK, MW2014_TOTALS, 7), - ], -) -def test_matches_r(sample, cdr, mack, totals, decimals, backend): - """ - One-year and Mack standard errors match R's CDR() output. - - The figures are the MW2008 and MW2014 examples of CDR() in gesmann2026, - fitted with est.sigma="Mack"; the samples mw2008 and mw2014 hold the same - triangles. Each value matches to half a unit in the last decimal R - prints. R prints 0 for the oldest origin, which is - fully developed; cdr_std_err_ and mack_std_err_ give NaN there. - """ - model = mack_sigma_fit(cl.load_sample(sample).set_backend(backend)) - atol = 0.5 * 10.0**-decimals - assert np.isnan(by_origin(model.cdr_std_err_)[0]) - assert np.isnan(by_origin(model.mack_std_err_)[0]) - np.testing.assert_allclose( - by_origin(model.cdr_std_err_)[1:], cdr[1:], rtol=0, atol=atol - ) - np.testing.assert_allclose( - by_origin(model.mack_std_err_)[1:], mack[1:], rtol=0, atol=atol - ) - np.testing.assert_allclose( - model.total_cdr_std_err_.values[0, 0], totals[0], rtol=0, atol=atol - ) - np.testing.assert_allclose( - model.total_mack_std_err_.values[0, 0], totals[1], rtol=0, atol=atol - ) - - -@pytest.mark.parametrize("backend", ["numpy", "sparse"]) -def test_matches_merz2008_table4(backend): - """ - One-year standard errors match merz2008, Table 4, to 2 units. - - Table 4 prints whole units of $1,000, and Table 2 prints the sigma^2 - estimates to two decimals. Against the unrounded figures that R and this - implementation share, the entries for accident years 1 and 2 are 0.83 and - 1.44 higher; every other entry and the total are within 1. - """ - model = mack_sigma_fit(cl.load_sample("mw2008").set_backend(backend)) - np.testing.assert_allclose( - by_origin(model.cdr_std_err_)[1:], TABLE4_CDR, rtol=0, atol=2 - ) - np.testing.assert_allclose( - model.total_cdr_std_err_.values[0, 0], TABLE4_TOTAL, rtol=0, atol=2 - ) - - -def test_multi_triangle_matches_single(clrd): - """ - A fit on several triangles gives each the values of its own fit. - - clrd summed by LOB, paid and incurred, is twelve complete triangles. Both - attributes of the joint fit match a fit on each triangle alone. - """ - tri = clrd.groupby("LOB").sum()[["CumPaidLoss", "IncurLoss"]] - model = cl.MackChainladder().fit(tri) - per = model.cdr_std_err_.set_backend("numpy").values - total = model.total_cdr_std_err_.values - assert np.isfinite(total).all() - for i in range(len(tri.index)): - for j in range(len(tri.columns)): - single = cl.MackChainladder().fit(tri.iloc[i, j]) - np.testing.assert_allclose( - per[i, j, :, 0], by_origin(single.cdr_std_err_), rtol=1e-10 - ) - np.testing.assert_allclose( - total[i, j], single.total_cdr_std_err_.values[0, 0], rtol=1e-10 - ) - - -def test_incremental_matches_cumulative(): - """An incremental triangle gives the values of its cumulative form.""" - tri = cl.load_sample("mw2008") - cumulative = mack_sigma_fit(tri) - incremental = mack_sigma_fit(tri.cum_to_incr()) - np.testing.assert_allclose( - by_origin(incremental.cdr_std_err_), - by_origin(cumulative.cdr_std_err_), - rtol=1e-12, - ) - np.testing.assert_allclose( - incremental.total_cdr_std_err_.values, - cumulative.total_cdr_std_err_.values, - rtol=1e-12, - ) - - -def test_missing_cell_gives_nan(): - """ - A triangle with a missing cell above the latest diagonal gets NaN. - - The estimators of merz2008 use every cell of the upper triangle. Removing - one cell from mw2008 leaves MackChainladder able to fit, and both CDR - attributes are NaN. - """ - tri = cl.load_sample("mw2008") - tri.values = tri.values.copy() - tri.values[0, 0, 3, 1] = np.nan - model = mack_sigma_fit(tri) - assert np.isfinite(model.total_mack_std_err_.values).all() - assert np.isnan(by_origin(model.cdr_std_err_)).all() - assert np.isnan(model.total_cdr_std_err_.values).all() - - -@pytest.mark.parametrize("name", list(UNSUPPORTED)) -def test_unsupported_pattern_raises_on_read(name): - """ - Unsupported development patterns fit, then raise on reading the CDR. - - merz2008 assumes a square triangle, volume-weighted factors from every - link ratio of the triangle and no tail. MackChainladder still fits each - pattern here and gives its Mack standard errors; only the two CDR - attributes raise ValueError. - """ - transform, message = UNSUPPORTED[name] - model = cl.MackChainladder().fit(transform(cl.load_sample("mw2008"))) - assert np.isfinite(model.total_mack_std_err_.values).all() - with pytest.raises(ValueError, match=message): - _ = model.cdr_std_err_ - with pytest.raises(ValueError, match=message): - _ = model.total_cdr_std_err_ - - -def test_reading_cdr_leaves_mack_unchanged(): - """Reading the CDR attributes leaves the fitted Mack attributes as they were.""" - model = mack_sigma_fit(cl.load_sample("mw2008")) - summary = model.summary_.values.copy() - total = model.total_mack_std_err_.values.copy() - assert np.isfinite(by_origin(model.cdr_std_err_)[1:]).all() - assert np.isfinite(model.total_cdr_std_err_.values).all() - np.testing.assert_array_equal(model.summary_.values, summary) - np.testing.assert_array_equal(model.total_mack_std_err_.values, total) From 8fdc25e8288dcfd95e7a02fdacdd54c86b216468 Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan Date: Tue, 10 Nov 2026 10:49:05 +0000 Subject: [PATCH 3/4] Address review of the one-year CDR standard errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- chainladder/methods/mack.py | 136 +++++++------- chainladder/methods/tests/test_mack.py | 246 ++++++++----------------- conftest.py | 4 + docs/library/references.bib | 8 - docs/user_guide/methods.ipynb | 3 +- 5 files changed, 145 insertions(+), 252 deletions(-) diff --git a/chainladder/methods/mack.py b/chainladder/methods/mack.py index 06e90d889..0af8a48ce 100644 --- a/chainladder/methods/mack.py +++ b/chainladder/methods/mack.py @@ -476,40 +476,25 @@ def _get_total_mack_std_err_(self, obj): def cdr_std_err_(self): """ One-year standard error of the claims development result by origin - period. - - The claims development result (CDR) of an origin period is the change - in its estimated ultimate from this valuation to the next, once the - next diagonal is observed and the chainladder factors are estimated - again. The CDR is predicted by zero, and ``cdr_std_err_`` is the square - root of the conditional mean squared error of that prediction, Result - 3.1 (eq. 3.9) of Merz and Wüthrich (2008, ``merz2008``). Where - ``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, - development factors volume-weighted over all periods with no link - ratios excluded, and no tail beyond the default ``TailConstant(1.0)``. - Reading the attribute raises ``ValueError`` for any other development - pattern, and ``fit`` is unaffected. A triangle with missing cells - above the latest diagonal gets NaN. The figures follow the - ``sigma_interpolation`` chosen in :class:`Development`; ``"mack"`` - matches ``est.sigma="Mack"`` in the ``CDR()`` function of R's - ChainLadder package (``gesmann2026``). The attribute describes the - fitted triangle, and :meth:`predict` does not attach it. + period, Result 3.1 (eq. 3.9) of Merz and Wüthrich (2008, ``merz2008``). + + The claims development result is the change in an origin's estimated + ultimate once the next diagonal is observed. ``mack_std_err_`` is the + counterpart to ultimate. The values follow the ``sigma_interpolation`` + of :class:`Development`, and missing cells above the latest diagonal + give NaN. Returns ------- Triangle - One value per origin, shaped like ``ultimate_``. The oldest - origin is fully developed and is NaN, as in ``mack_std_err_``. + One value per origin, shaped like ``ultimate_``, NaN for the + fully developed oldest origin. Raises ------ ValueError If the triangle is not square, has a tail, or its development - factors are not volume-weighted over every link ratio of the - triangle itself. + factors are not volume-weighted over every link ratio. Examples -------- @@ -549,11 +534,9 @@ def total_cdr_std_err_(self): """ One-year standard error of the claims development result summed over all origin periods, Result 3.3 (eq. 3.15) of Merz and Wüthrich (2008, - ``merz2008``). - - The total exceeds the quadrature sum of ``cdr_std_err_`` because all - origins share the estimated age-to-age factors. The supported setting - and the ``ValueError`` cases are those of ``cdr_std_err_``. + ``merz2008``). It exceeds the quadrature sum of ``cdr_std_err_`` + because all origins share the estimated factors, and it raises + ``ValueError`` where ``cdr_std_err_`` does. Returns ------- @@ -593,11 +576,15 @@ def _get_cdr_msep(self): Mean squared errors of the one-year claims development result. Returns the per-origin values, shape (index, columns, origin), and - the totals over origins, shape (index, columns), following Results 3.1 - and 3.3 of ``merz2008`` with I = J = n - 1 and 0-based origins. + the totals over origins, shape (index, columns), from Results 3.1 and + 3.3 of ``merz2008`` with I = J = n - 1. Origins i and development + periods j are 0-based, as in the paper, and the equation numbers in + the comments are the paper's. """ + from chainladder.development import Development + X = self.X_.set_backend("numpy") - C = (X if X.is_cumulative else X.incr_to_cum()).values + C = X if X.is_cumulative else X.incr_to_cum() a, b, n, m = C.shape msg = "cdr_std_err_ and total_cdr_std_err_ need " upper = np.add.outer(np.arange(n), np.arange(m)) <= n - 1 @@ -623,52 +610,55 @@ def _get_cdr_msep(self): msg + "volume-weighted development factors; this pattern uses " 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:] - x, y = C[..., :J], C[..., 1:] - pair = link & ~np.isnan(x) & ~np.isnan(y) - w = np.nan_to_num(np.broadcast_to(np.asarray(X.w_), pair.shape), nan=1.0) - if np.any(w[pair] == 0): + # A default Development on C gives the paper's factors (2.9), and the + # fitted w_ and ldf_ must match it. + fresh = Development().fit(C) + w, w_fresh = np.broadcast_arrays(np.asarray(X.w_), np.asarray(fresh.w_)) + if not np.array_equal(w, w_fresh, equal_nan=True): raise ValueError( msg + "every link ratio of the triangle; this pattern excludes " "some (drop, drop_high, drop_low, drop_valuation, n_periods)." ) + f = ldf[..., :J] # f_j^I, (2.9) + if not np.allclose( + f, fresh.ldf_.values[..., 0, :], rtol=1e-8, atol=0, equal_nan=True + ): + raise ValueError( + msg + "development factors estimated from this triangle's " + "own link ratios; ldf_ differs from them, as after " + "Development(fillna=...)." + ) + latest = C.latest_diagonal.values[..., 0] # C_{i, I-i} + diag = latest[..., ::-1][..., :J] # C_{I-j, j} + S1 = C.sum(axis="origin").values[..., 0, :J] # S_j^{I+1}, (2.10) + S = S1 - diag # S_j^I, (2.9) + ult = self.ultimate_.set_backend("numpy").values[..., 0] # C_{i,J}^I, (2.11) + zero = np.zeros((a, b, 1)) + + def later(t): + """Sum of t over the positions after each position, last axis.""" + return np.concatenate((np.cumsum(t[..., ::-1], -1)[..., -2::-1], zero), -1) + with np.errstate(divide="ignore", invalid="ignore"): - S = np.where(pair, x, 0).sum(2) # S_j^I - f_own = np.where(pair, y, 0).sum(2) / S - f = np.broadcast_to(ldf[..., :J], S.shape) - known = np.isfinite(f_own) & np.isfinite(f) - if not np.allclose(f[known], f_own[known], rtol=1e-8, atol=0): - raise ValueError( - msg + "development factors estimated from this triangle's " - "own link ratios; ldf_ differs from them, as after " - "Development(fillna=...)." - ) - q = np.broadcast_to(sigma[..., :J], S.shape) ** 2 / f**2 - latest = C[..., np.arange(n), J - np.arange(n)] # C_{i, I-i} - diag = latest[..., ::-1][..., :J] # C_{I-j, j} - 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 - B = (diag / S1) ** 2 * q / S - zero = np.zeros((a, b, 1)) - TA = np.concatenate((np.cumsum(A[..., ::-1], -1)[..., -2::-1], zero), -1) - TB = np.concatenate((np.cumsum(B[..., ::-1], -1)[..., -2::-1], zero), -1) - i = np.arange(1, n) - d = J - i # next development period of origin i - qd, Sd, S1d = q[..., d], S[..., d], S1[..., d] - Phi = TA[..., d] - Delta = qd / Sd + TB[..., d] - Lam = latest[..., i] / S1d * qd / Sd + TB[..., d] - Xi = Phi + qd / S1d + q = sigma[..., :J] ** 2 / f**2 # sigma_j^2 (3.3) over (f_j^I)^2 + i = np.arange(1, n) # origins with a next diagonal + d = J - i # their next development period, I - i + qd, Sd, S1d, Ci = q[..., d], S[..., d], S1[..., d], latest[..., i] + # Sums over j = I-i+1 .. J-1: the summands of Phi (3.5), and those + # of the sum that Delta (3.4) and Lambda (3.13) share. + Phi = later(diag * q / S1**2)[..., d] + shared = later((diag / S1) ** 2 * q / S)[..., d] + Psi = qd / Ci # (3.6) + Delta = qd / Sd + shared # (3.4) + Lam = Ci / S1d * qd / Sd + shared # Lambda, (3.13) + Xi = Phi + qd / S1d # (3.14) msep = np.full((a, b, n), np.nan) - msep[..., 1:] = ult[..., i] ** 2 * (qd / latest[..., i] + Phi + Delta) - later = np.cumsum(ult[..., :0:-1], -1)[..., ::-1] # sum of ult_k, k >= i - later = np.concatenate((later[..., 1:], zero), -1) # k > i - cross = 2 * (ult[..., i] * later * (Xi + Lam)).sum(-1) - total = msep[..., 1:].sum(-1) + cross - complete = ~np.isnan(C[..., upper]).any(-1) + # (3.9), with Gamma = Phi + Psi (3.7) + msep[..., 1:] = ult[..., i] ** 2 * (Phi + Psi + Delta) + # (3.15): 2 * sum over k > i > 0 of C_{i,J} C_{k,J} (Xi + Lambda) + cross = ult[..., i] * later(ult[..., 1:]) * (Xi + Lam) + total = msep[..., 1:].sum(-1) + 2 * cross.sum(-1) + complete = ~np.isnan(C.values[..., upper]).any(-1) msep[~complete] = np.nan total[~complete] = np.nan return msep, total diff --git a/chainladder/methods/tests/test_mack.py b/chainladder/methods/tests/test_mack.py index 098c9f450..5ebf94c93 100644 --- a/chainladder/methods/tests/test_mack.py +++ b/chainladder/methods/tests/test_mack.py @@ -2,43 +2,19 @@ Tests for MackChainladder. test_mack1994_hardcode and test_mack1999_hardcode check the Mack attributes -against mack1994 and mack1999. The tests from test_cdr_matches_r on cover the -one-year claims development result and cite sources by their keys in -docs/library/references.bib. What they check for each attribute, by source: - -==================== ========= ========= ========= ============ -Attribute R mw2008 R mw2014 merz2008 per-triangle -==================== ========= ========= ========= ============ -cdr_std_err_ published published published unchanged -total_cdr_std_err_ published published published unchanged -mack_std_err_ published published no test no test -total_mack_std_err_ published published no test no test -==================== ========= ========= ========= ============ - -published - R mw2008 and R mw2014 are the CDR() examples printed on the reference page - of gesmann2026, for MackChainLadder fits with est.sigma="Mack". Each value - matches to half a unit in the last printed decimal (4 decimals for mw2008, - 7 for mw2014), for every origin and the total. merz2008 is the one-year - column of Table 4, printed in whole units and checked to 2 units for the - reason given in its test. -unchanged - A fit on clrd summed by LOB, paid and incurred, gives each of its twelve - triangles the values of a fit on that triangle alone. - -The Mack rows confirm that sigma_interpolation="mack" reproduces R's -est.sigma="Mack". - -The R and paper figures are checked on the numpy and sparse backends. Other -CDR tests check that an incremental triangle gives the cumulative values, -that a triangle with a missing cell gets NaN, that each unsupported -development pattern fits and then raises ValueError on reading the CDR -attributes, and that reading them leaves the Mack attributes as they were. +against mack1994 and mack1999. The tests from test_cdr_matches_merz2008 on +cover the one-year claims development result of merz2008 (keys of +docs/library/references.bib). They check the paper's figures for its own +triangle, the sample mw2008, on stacked copies and on both backends, then a +fit on several triangles against a fit on each alone, incremental input, NaN +for a missing cell, ValueError for each unsupported development pattern, and +the Mack attributes after reading the CDR ones. """ from __future__ import annotations import numpy as np +import pandas as pd import chainladder as cl import pytest @@ -127,78 +103,17 @@ def test_mack1994_hardcode(raa): assert np.allclose(ibnr_se, ibnr_rhs[1:], atol=1) -# gesmann2026, CDR() reference page: CDR(1)S.E. and Mack.S.E. for MW2008 by -# origin (1 to 9), then the totals. -MW2008_CDR = [ - 0.0000, - 566.1744, - 1486.5603, - 3923.0986, - 9722.8598, - 28442.6216, - 20954.2870, - 28119.3180, - 53320.8210, -] -MW2008_MACK = [ - 0.0000, - 566.1744, - 1563.8075, - 4157.2733, - 10536.4380, - 30319.4638, - 35967.0384, - 45090.1821, - 69552.3397, -] -MW2008_TOTALS = (81080.5468, 108401.3875) - -# gesmann2026, CDR() reference page: the same columns for MW2014, origins 1 -# to 17. -MW2014_CDR = [ - 0.0000000, - 0.4083149, - 2.5393857, - 16.7232632, - 156.4022713, - 137.6522771, - 171.1812092, - 70.3161155, - 271.6352221, - 310.1268449, - 103.3834357, - 632.6388191, - 315.0489135, - 406.1424672, - 285.2076540, - 668.2337878, - 733.2222786, -] -MW2014_MACK = [ - 0.0000000, - 0.4083149, - 2.5652899, - 16.8984949, - 157.2756452, - 207.1650862, - 261.9266093, - 292.2622285, - 390.5874717, - 502.0606072, - 486.0911099, - 806.9028971, - 793.9381916, - 891.6613403, - 916.4940218, - 1106.1262716, - 1295.6909824, -] -MW2014_TOTALS = (1842.8507073, 3233.6807352) - -# merz2008, Table 4 (p. 562): the one-year column for accident years 1 to 8, -# the sample's origins 2002 to 2009, then the total. +# merz2008, Table 2 (p. 560): f_j^I and sigma_j^2 for j = 0 to 7. +TABLE2_LDF = [1.4759, 1.0719, 1.0232, 1.0161, 1.0063, 1.0056, 1.0013, 1.0011] +TABLE2_SIGMA2 = [911.43, 189.82, 97.81, 178.75, 20.64, 3.23, 0.36, 0.04] + +# merz2008, Table 4 (p. 562), accident years 1 to 8 (the sample's origins 2002 +# to 2009): the one-year column, msep^(1/2) of the observable CDR around 0, and +# Mack's column. Then the cov^(1/2) row and the totals of the two columns. TABLE4_CDR = [567, 1488, 3923, 9723, 28443, 20954, 28119, 53320] -TABLE4_TOTAL = 81080 +TABLE4_MACK = [567, 1566, 4157, 10536, 30319, 35967, 45090, 69552] +TABLE4_COV = [39746, 50361] +TABLE4_TOTAL = [81080, 108401] CDR_UNSUPPORTED = { "tail": ( @@ -224,63 +139,55 @@ def mack_sigma_fit(tri): def by_origin(triangle): - """Values of a single-triangle per-origin Triangle as a numpy vector.""" - return triangle.set_backend("numpy").values[0, 0, :, -1] - - -@pytest.mark.parametrize("backend", ["numpy", "sparse"]) -@pytest.mark.parametrize( - "sample, cdr, mack, totals, decimals", - [ - ("mw2008", MW2008_CDR, MW2008_MACK, MW2008_TOTALS, 4), - ("mw2014", MW2014_CDR, MW2014_MACK, MW2014_TOTALS, 7), - ], -) -def test_cdr_matches_r(sample, cdr, mack, totals, decimals, backend): - """ - One-year and Mack standard errors match R's CDR() output. + """Values of a per-origin Triangle as an array, origin on the last axis.""" + return triangle.set_backend("numpy").values[..., -1] - The figures are the MW2008 and MW2014 examples of CDR() in gesmann2026, - fitted with est.sigma="Mack"; the samples mw2008 and mw2014 hold the same - triangles. Each value matches to half a unit in the last decimal R - prints. R prints 0 for the oldest origin, which is - fully developed; cdr_std_err_ and mack_std_err_ give NaN there. - """ - model = mack_sigma_fit(cl.load_sample(sample).set_backend(backend)) - atol = 0.5 * 10.0**-decimals - assert np.isnan(by_origin(model.cdr_std_err_)[0]) - assert np.isnan(by_origin(model.mack_std_err_)[0]) - np.testing.assert_allclose( - by_origin(model.cdr_std_err_)[1:], cdr[1:], rtol=0, atol=atol - ) - np.testing.assert_allclose( - by_origin(model.mack_std_err_)[1:], mack[1:], rtol=0, atol=atol - ) - np.testing.assert_allclose( - model.total_cdr_std_err_.values[0, 0], totals[0], rtol=0, atol=atol - ) - np.testing.assert_allclose( - model.total_mack_std_err_.values[0, 0], totals[1], rtol=0, atol=atol - ) +def assert_within(actual, expected, tol): + """Assert that every entry of actual is less than tol from expected.""" + gap = np.abs(np.asarray(actual) - expected) + np.testing.assert_array_less(gap, np.broadcast_to(tol, gap.shape)) -@pytest.mark.parametrize("backend", ["numpy", "sparse"]) -def test_cdr_matches_merz2008_table4(backend): - """ - One-year standard errors match merz2008, Table 4, to 2 units. - Table 4 prints whole units of $1,000, and Table 2 prints the sigma^2 - estimates to two decimals. Against the unrounded figures that R and this - implementation share, the entries for accident years 1 and 2 are 0.83 and - 1.44 higher; every other entry and the total are within 1. +def test_cdr_matches_merz2008(mw2008): """ - model = mack_sigma_fit(cl.load_sample("mw2008").set_backend(backend)) - np.testing.assert_allclose( - by_origin(model.cdr_std_err_)[1:], TABLE4_CDR, rtol=0, atol=2 - ) - np.testing.assert_allclose( - model.total_cdr_std_err_.values[0, 0], TABLE4_TOTAL, rtol=0, atol=2 + Four copies of mw2008, two index rows by two columns, each give the + figures of merz2008. + + Table 2 prints the factors to 4 decimals and sigma^2 to 2. The factors + round to the printed ones, and the printed sigma^2 are within 0.015 of + the estimates. Table 4 prints whole units of $1,000. Every entry is within + 1 except accident year 2, printed as 1,488 and 1,566 against estimates of + 1,486.6 and 1,563.8, and checked to 2.5. The cov^(1/2) row is the square + root of the total's square less the sum of the per-origin squares. + Accident year 1 has one development period left, so its one-year and Mack + errors are equal (Table 4 prints 567 in both columns). + """ + copy = mw2008.copy() + copy.index = pd.DataFrame({mw2008.key_labels[0]: ["Copy"]}) + tri = cl.concat([mw2008, copy], axis=0) + tri["copy"] = tri["values"] + dev = cl.Development(sigma_interpolation="mack").fit_transform(tri) + model = cl.MackChainladder().fit(dev) + assert model.cdr_std_err_.shape == (2, 2, 9, 1) + ldf = dev.ldf_.set_backend("numpy").values[..., 0, :] + sigma2 = dev.sigma_.set_backend("numpy").values[..., 0, :] ** 2 + assert_within(ldf, TABLE2_LDF, 5e-5) + assert_within(sigma2, TABLE2_SIGMA2, 0.015) + + cdr = by_origin(model.cdr_std_err_) + mack = by_origin(model.mack_std_err_) + assert np.isnan(cdr[..., 0]).all() and np.isnan(mack[..., 0]).all() + tol = np.where(np.arange(1, 9) == 2, 2.5, 1.0) + assert_within(cdr[..., 1:], TABLE4_CDR, tol) + assert_within(mack[..., 1:], TABLE4_MACK, tol) + total = np.stack( + (model.total_cdr_std_err_.values, model.total_mack_std_err_.values), -1 ) + assert_within(total, TABLE4_TOTAL, 1) + squares = np.stack((np.nansum(cdr**2, -1), np.nansum(mack**2, -1)), -1) + assert_within(np.sqrt(total**2 - squares), TABLE4_COV, 1) + np.testing.assert_allclose(cdr[..., 1], mack[..., 1], rtol=1e-12) def test_cdr_multi_triangle_matches_single(clrd): @@ -292,25 +199,24 @@ def test_cdr_multi_triangle_matches_single(clrd): """ tri = clrd.groupby("LOB").sum()[["CumPaidLoss", "IncurLoss"]] model = cl.MackChainladder().fit(tri) - per = model.cdr_std_err_.set_backend("numpy").values + per = by_origin(model.cdr_std_err_) total = model.total_cdr_std_err_.values assert np.isfinite(total).all() for i in range(len(tri.index)): for j in range(len(tri.columns)): single = cl.MackChainladder().fit(tri.iloc[i, j]) np.testing.assert_allclose( - per[i, j, :, 0], by_origin(single.cdr_std_err_), rtol=1e-10 + per[i, j], by_origin(single.cdr_std_err_)[0, 0], rtol=1e-10 ) np.testing.assert_allclose( total[i, j], single.total_cdr_std_err_.values[0, 0], rtol=1e-10 ) -def test_cdr_incremental_matches_cumulative(): +def test_cdr_incremental_matches_cumulative(mw2008): """An incremental triangle gives the values of its cumulative form.""" - tri = cl.load_sample("mw2008") - cumulative = mack_sigma_fit(tri) - incremental = mack_sigma_fit(tri.cum_to_incr()) + cumulative = mack_sigma_fit(mw2008) + incremental = mack_sigma_fit(mw2008.cum_to_incr()) np.testing.assert_allclose( by_origin(incremental.cdr_std_err_), by_origin(cumulative.cdr_std_err_), @@ -323,7 +229,7 @@ def test_cdr_incremental_matches_cumulative(): ) -def test_cdr_missing_cell_gives_nan(): +def test_cdr_missing_cell_gives_nan(mw2008): """ A triangle with a missing cell above the latest diagonal gets NaN. @@ -331,17 +237,17 @@ def test_cdr_missing_cell_gives_nan(): one cell from mw2008 leaves MackChainladder able to fit, and both CDR attributes are NaN. """ - tri = cl.load_sample("mw2008") + tri = mw2008.set_backend("numpy") tri.values = tri.values.copy() tri.values[0, 0, 3, 1] = np.nan - model = mack_sigma_fit(tri) + model = mack_sigma_fit(tri.set_backend(mw2008.array_backend)) assert np.isfinite(model.total_mack_std_err_.values).all() assert np.isnan(by_origin(model.cdr_std_err_)).all() assert np.isnan(model.total_cdr_std_err_.values).all() @pytest.mark.parametrize("name", list(CDR_UNSUPPORTED)) -def test_cdr_unsupported_pattern_raises_on_read(name): +def test_cdr_unsupported_pattern_raises_on_read(name, mw2008): """ Unsupported development patterns fit, then raise on reading the CDR. @@ -351,7 +257,7 @@ def test_cdr_unsupported_pattern_raises_on_read(name): attributes raise ValueError. """ transform, message = CDR_UNSUPPORTED[name] - model = cl.MackChainladder().fit(transform(cl.load_sample("mw2008"))) + model = cl.MackChainladder().fit(transform(mw2008)) assert np.isfinite(model.total_mack_std_err_.values).all() with pytest.raises(ValueError, match=message): _ = model.cdr_std_err_ @@ -359,12 +265,12 @@ def test_cdr_unsupported_pattern_raises_on_read(name): _ = model.total_cdr_std_err_ -def test_reading_cdr_leaves_mack_unchanged(): +def test_reading_cdr_leaves_mack_unchanged(mw2008): """Reading the CDR attributes leaves the fitted Mack attributes as they were.""" - model = mack_sigma_fit(cl.load_sample("mw2008")) - summary = model.summary_.values.copy() + model = mack_sigma_fit(mw2008) + summary = model.summary_.set_backend("numpy").values.copy() total = model.total_mack_std_err_.values.copy() - assert np.isfinite(by_origin(model.cdr_std_err_)[1:]).all() + assert np.isfinite(by_origin(model.cdr_std_err_)[..., 1:]).all() assert np.isfinite(model.total_cdr_std_err_.values).all() - np.testing.assert_array_equal(model.summary_.values, summary) + np.testing.assert_array_equal(model.summary_.set_backend("numpy").values, summary) np.testing.assert_array_equal(model.total_mack_std_err_.values, total) diff --git a/conftest.py b/conftest.py index 7818dadaf..b61d42be6 100644 --- a/conftest.py +++ b/conftest.py @@ -49,6 +49,10 @@ "data": "xyz", "runs": ["normal_run", "sparse_only_run"], }, + "mw2008": { + "data": "mw2008", + "runs": ["normal_run", "sparse_only_run"], + }, } diff --git a/docs/library/references.bib b/docs/library/references.bib index 3cef929c7..1488d0557 100644 --- a/docs/library/references.bib +++ b/docs/library/references.bib @@ -112,11 +112,3 @@ @article{merz2008 year = {2008}, url = {https://www.casact.org/abstract/modeling-claims-development-result-solvency-purposes} } - -@manual{gesmann2026, - author = {Gesmann, M. and Murphy, D. and Zhang, Y. and Carrato, A. and Wuthrich, M. and Concina, F. and {Dal Moro}, E.}, - title = {{ChainLadder}: {S}tatistical {M}ethods and {M}odels for {C}laims {R}eserving in {G}eneral {I}nsurance}, - note = {R package version 0.2.21}, - year = {2026}, - url = {https://mages.github.io/ChainLadder/} -} diff --git a/docs/user_guide/methods.ipynb b/docs/user_guide/methods.ipynb index 013e66ae1..9cb27c033 100644 --- a/docs/user_guide/methods.ipynb +++ b/docs/user_guide/methods.ipynb @@ -557,7 +557,8 @@ "the next diagonal is observed {cite}`merz2008`. They need a square triangle,\n", "volume-weighted factors from every link ratio and no tail, and they follow the\n", "`sigma_interpolation` of `Development`. With `sigma_interpolation=\"mack\"` they\n", - "match the `CDR()` examples of R's ChainLadder package {cite}`gesmann2026`.\n", + "are within 1.5 of every one-year figure in Table 4 of the paper, whose triangle\n", + "is the sample `mw2008`.\n", "\n", "### Examples\n", "\n", From 740379d27bb5ff8f9244b95af040715b57fd0b74 Mon Sep 17 00:00:00 2001 From: Simon Koch-Sultan Date: Tue, 10 Nov 2026 19:07:46 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Restore=20the=20mw2014=20test,=20against=20?= =?UTF-8?q?Merz=20and=20W=C3=BCthrich=20(2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- chainladder/methods/tests/test_mack.py | 145 +++++++++++++++++++++++-- conftest.py | 4 + docs/library/references.bib | 8 ++ 3 files changed, 149 insertions(+), 8 deletions(-) diff --git a/chainladder/methods/tests/test_mack.py b/chainladder/methods/tests/test_mack.py index 5ebf94c93..e9ada0036 100644 --- a/chainladder/methods/tests/test_mack.py +++ b/chainladder/methods/tests/test_mack.py @@ -3,12 +3,52 @@ test_mack1994_hardcode and test_mack1999_hardcode check the Mack attributes against mack1994 and mack1999. The tests from test_cdr_matches_merz2008 on -cover the one-year claims development result of merz2008 (keys of -docs/library/references.bib). They check the paper's figures for its own -triangle, the sample mw2008, on stacked copies and on both backends, then a -fit on several triangles against a fit on each alone, incremental input, NaN -for a missing cell, ValueError for each unsupported development pattern, and -the Mack attributes after reading the CDR ones. +cover the one-year claims development result (CDR) and cite sources by their +keys in docs/library/references.bib. What these CDR tests check for each +attribute, by source: + +======================== ======== ======== ============ +Attribute merz2008 merz2014 per-triangle +======================== ======== ======== ============ +ldf_, sigma_ Table 2 Table 1 -- +ibnr_ -- Table 2 -- +process_risk_ -- Table 2 -- +parameter_risk_ -- Table 2 -- +mack_std_err_ Table 4 Table 2 -- +total_process_risk_ -- Table 2 -- +total_parameter_risk_ -- Table 2 -- +total_mack_std_err_ Table 4 Table 2 -- +cdr_std_err_ Table 4 -- clrd +total_cdr_std_err_ Table 4 Table 3 clrd +======================== ======== ======== ============ + +merz2008 + The paper prints Tables 2 and 4 for its own triangle, the sample mw2008, + with Mack's extrapolation of the last sigma (sigma_interpolation="mack"). + Table 2 (p. 560) gives the factors to 4 decimals and sigma^2 to 2. Table 4 + (p. 562) gives the one-year and Mack columns per accident year, the + cov^(1/2) row and the totals, in whole units of $1,000. +merz2014 + Table 1 (p. 28) of this paper is the 17-year triangle of the sample + mw2014, fitted with Mack's extrapolation too (p. 25). Page numbers are + those of the version dated July 3, 2015. Table 1 gives the factors to 5 + decimals and sigma^2 to 4. Table 2 (p. 30) gives, per accident year and in + total, the reserve and the square roots of the process variance, the + parameter uncertainty and Mack's msep, in whole units, with the Mack + total to 1 decimal. Table 3 (p. 31), row t = 17, gives the square root of + the one-year uncertainty in total, 1,843. The paper prints no one-year + figures per accident year, so the per-origin CDR is checked on mw2008 only. +per-triangle + A fit on clrd summed by LOB, paid and incurred, gives each of its twelve + triangles the values of a fit on that triangle alone. + +test_cdr_matches_merz2008 and test_cdr_matches_merz2014 state the tolerance +for each printed figure. The mw2008, mw2014 and clrd fixtures run these checks +on the numpy and sparse backends. Other CDR tests check that an incremental +triangle gives the values of its cumulative form, that a triangle with a +missing cell gets NaN, that each unsupported development pattern fits and then +raises ValueError on reading the CDR attributes, and that reading them leaves +the Mack attributes as they were. """ from __future__ import annotations @@ -115,6 +155,48 @@ def test_mack1994_hardcode(raa): TABLE4_COV = [39746, 50361] TABLE4_TOTAL = [81080, 108401] +# fmt: off +# merz2014, Table 1 (p. 28): f_j and sigma_j^2 for j = 0 to 15, the last +# sigma_j^2 by Mack's extrapolation. +MW2014_LDF = [ + 1.51105, 1.05369, 1.02681, 1.01709, 1.01284, 1.01098, 1.00292, 1.01098, + 1.00698, 1.00141, 1.00574, 1.00386, 1.00359, 1.00042, 1.00035, 1.00004, +] +MW2014_SIGMA2 = [ + 29.4993, 16.8327, 2.6478, 5.4444, 3.2477, 11.4238, 0.1882, 2.6132, + 2.0863, 0.0542, 0.9079, 0.5397, 0.7202, 0.0079, 0.0002, 0.0000, +] + +# merz2014, Table 2 (p. 30), accident years 2 to 17 (the sample's origins +# 2002 to 2017; accident year 1 prints 0 throughout): the reserve, then the +# square roots of the process variance, the parameter uncertainty and Mack's +# msep. +MW2014_TABLE2 = { + "ibnr_": [ + 1, 10, 21, 118, 223, 362, 469, 654, + 1009, 1012, 1407, 1493, 1918, 2458, 3384, 9597, + ], + "process_risk_": [ + 0, 2, 15, 140, 186, 238, 262, 358, + 464, 452, 760, 756, 851, 877, 1067, 1256, + ], + "parameter_risk_": [ + 0, 2, 9, 72, 92, 109, 129, 156, + 192, 179, 271, 242, 265, 265, 293, 319, + ], + "mack_std_err_": [ + 0, 3, 17, 157, 207, 262, 292, 391, + 502, 486, 807, 794, 892, 916, 1106, 1296, + ], +} +# fmt: on +# merz2014, Table 2 (p. 30): the totals of the same four columns. +MW2014_TABLE2_TOTAL = [24135, 2467, 2090, 3233.7] + +# merz2014, Table 3 (p. 31), row t = 17: the square root of the one-year +# uncertainty in total. +MW2014_CDR_TOTAL = 1843 + CDR_UNSUPPORTED = { "tail": ( lambda t: cl.TailCurve().fit_transform(cl.Development().fit_transform(t)), @@ -190,6 +272,41 @@ def test_cdr_matches_merz2008(mw2008): np.testing.assert_allclose(cdr[..., 1], mack[..., 1], rtol=1e-12) +def test_cdr_matches_merz2014(mw2014): + """ + mw2014 gives the figures that merz2014 prints for it. + + Every figure is within half a unit in its last printed decimal: the + factors of Table 1 to 5e-6 and sigma^2 to 5e-5; the reserve and the three + errors of Table 2 per accident year, and their totals, to 0.5, except the + Mack total, printed to 1 decimal and checked to 0.05; the one-year total + of Table 3 to 0.5. Table 3 gives the one-year uncertainty for each + accounting year t; the test reads its row t = 17, the latest diagonal, as + total_cdr_std_err_. Table 2 checks process_risk_ and parameter_risk_ as + the two parts of Mack's msep. Accident year 1 has no reserve: the paper + prints 0, and chainladder gives 0 or NaN. + """ + dev = cl.Development(sigma_interpolation="mack").fit_transform(mw2014) + model = cl.MackChainladder().fit(dev) + ldf = dev.ldf_.set_backend("numpy").values[..., 0, :] + sigma2 = dev.sigma_.set_backend("numpy").values[..., 0, :] ** 2 + assert_within(ldf, MW2014_LDF, 5e-6) + assert_within(sigma2, MW2014_SIGMA2, 5e-5) + + for attr, printed in MW2014_TABLE2.items(): + values = by_origin(getattr(model, attr)) + assert (np.nan_to_num(values[..., 0]) == 0).all() + assert_within(values[..., 1:], printed, 0.5) + totals = [ + np.nansum(by_origin(model.ibnr_)), + by_origin(model.total_process_risk_).item(), + by_origin(model.total_parameter_risk_).item(), + model.total_mack_std_err_.values.item(), + ] + assert_within(totals, MW2014_TABLE2_TOTAL, [0.5, 0.5, 0.5, 0.05]) + assert_within(model.total_cdr_std_err_.values, MW2014_CDR_TOTAL, 0.5) + + def test_cdr_multi_triangle_matches_single(clrd): """ A fit on several triangles gives each the values of its own fit. @@ -214,7 +331,13 @@ def test_cdr_multi_triangle_matches_single(clrd): def test_cdr_incremental_matches_cumulative(mw2008): - """An incremental triangle gives the values of its cumulative form.""" + """ + An incremental triangle gives the values of its cumulative form. + + The estimators of merz2008 are defined on cumulative claims, and the CDR + attributes accumulate an incremental fit before using it. mw2008 fitted + as cum_to_incr() and as it is gives the same per-origin and total errors. + """ cumulative = mack_sigma_fit(mw2008) incremental = mack_sigma_fit(mw2008.cum_to_incr()) np.testing.assert_allclose( @@ -266,7 +389,13 @@ def test_cdr_unsupported_pattern_raises_on_read(name, mw2008): def test_reading_cdr_leaves_mack_unchanged(mw2008): - """Reading the CDR attributes leaves the fitted Mack attributes as they were.""" + """ + Reading the CDR attributes leaves the fitted Mack attributes as they were. + + cdr_std_err_ and total_cdr_std_err_ are computed from the fitted model + each time they are read. After reading both, summary_ and + total_mack_std_err_ hold the values they had before. + """ model = mack_sigma_fit(mw2008) summary = model.summary_.set_backend("numpy").values.copy() total = model.total_mack_std_err_.values.copy() diff --git a/conftest.py b/conftest.py index b61d42be6..9ad2c7a87 100644 --- a/conftest.py +++ b/conftest.py @@ -53,6 +53,10 @@ "data": "mw2008", "runs": ["normal_run", "sparse_only_run"], }, + "mw2014": { + "data": "mw2014", + "runs": ["normal_run", "sparse_only_run"], + }, } diff --git a/docs/library/references.bib b/docs/library/references.bib index 1488d0557..4e10e1c81 100644 --- a/docs/library/references.bib +++ b/docs/library/references.bib @@ -112,3 +112,11 @@ @article{merz2008 year = {2008}, url = {https://www.casact.org/abstract/modeling-claims-development-result-solvency-purposes} } + +@article{merz2014, + author = {Merz, M. and Wüthrich, M. V.}, + title = {Claims {R}un-{O}ff {U}ncertainty: {T}he {F}ull {P}icture}, + journal = {Swiss Finance Institute Research Paper No. 14-69}, + year = {2014}, + url = {https://doi.org/10.2139/ssrn.2524352} +}