Skip to content

Commit 0e5a7a9

Browse files
authored
[FEAT] Add one-year CDR standard errors to MackChainladder (#1558)
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.
1 parent 8e2e53c commit 0e5a7a9

5 files changed

Lines changed: 553 additions & 0 deletions

File tree

‎chainladder/methods/mack.py‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# This Source Code Form is subject to the terms of the Mozilla Public
22
# License, v. 2.0. If a copy of the MPL was not distributed with this
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
import numpy as np
45
import pandas as pd
56
from chainladder.methods import Chainladder
67

@@ -39,6 +40,12 @@ class MackChainladder(Chainladder):
3940
The total prediction error by origin period
4041
total_mack_std_err_:
4142
The total prediction error across all origin periods
43+
cdr_std_err_:
44+
The one-year standard error of the claims development result by
45+
origin period (Merz and Wüthrich 2008, ``merz2008``)
46+
total_cdr_std_err_:
47+
The one-year standard error of the claims development result across
48+
all origin periods
4249
4350
See Also
4451
--------
@@ -465,6 +472,197 @@ def _get_total_mack_std_err_(self, obj):
465472
out = obj.sqrt().values[..., 0, -1]
466473
return pd.DataFrame(out, index=obj.index, columns=obj.columns)
467474

475+
@property
476+
def cdr_std_err_(self):
477+
"""
478+
One-year standard error of the claims development result by origin
479+
period, Result 3.1 (eq. 3.9) of Merz and Wüthrich (2008, ``merz2008``).
480+
481+
The claims development result is the change in an origin's estimated
482+
ultimate once the next diagonal is observed. ``mack_std_err_`` is the
483+
counterpart to ultimate. The values follow the ``sigma_interpolation``
484+
of :class:`Development`, and missing cells above the latest diagonal
485+
give NaN.
486+
487+
Returns
488+
-------
489+
Triangle
490+
One value per origin, shaped like ``ultimate_``, NaN for the
491+
fully developed oldest origin.
492+
493+
Raises
494+
------
495+
ValueError
496+
If the triangle is not square, has a tail, or its development
497+
factors are not volume-weighted over every link ratio.
498+
499+
Examples
500+
--------
501+
The triangle of Merz and Wüthrich (2008), with Mack's extrapolation
502+
for the last sigma.
503+
504+
.. testsetup::
505+
506+
import chainladder as cl
507+
508+
.. testcode::
509+
510+
tr = cl.load_sample('mw2008')
511+
dev = cl.Development(sigma_interpolation='mack').fit_transform(tr)
512+
model = cl.MackChainladder().fit(dev)
513+
print(model.cdr_std_err_)
514+
515+
.. testoutput::
516+
517+
2261
518+
2001 NaN
519+
2002 566.174395
520+
2003 1486.560344
521+
2004 3923.098608
522+
2005 9722.859763
523+
2006 28442.621556
524+
2007 20954.286973
525+
2008 28119.317963
526+
2009 53320.821049
527+
"""
528+
obj = self.ultimate_.set_backend("numpy")
529+
obj.values = np.sqrt(self._get_cdr_msep()[0])[..., None]
530+
return obj.set_backend(self.X_.array_backend)
531+
532+
@property
533+
def total_cdr_std_err_(self):
534+
"""
535+
One-year standard error of the claims development result summed over
536+
all origin periods, Result 3.3 (eq. 3.15) of Merz and Wüthrich (2008,
537+
``merz2008``). It exceeds the quadrature sum of ``cdr_std_err_``
538+
because all origins share the estimated factors, and it raises
539+
``ValueError`` where ``cdr_std_err_`` does.
540+
541+
Returns
542+
-------
543+
DataFrame
544+
One value per (index, column) combination of the fitted Triangle.
545+
546+
Examples
547+
--------
548+
On the paper's triangle the one-year error is about three quarters of
549+
Mack's error to ultimate.
550+
551+
.. testsetup::
552+
553+
import chainladder as cl
554+
555+
.. testcode::
556+
557+
tr = cl.load_sample('mw2008')
558+
dev = cl.Development(sigma_interpolation='mack').fit_transform(tr)
559+
model = cl.MackChainladder().fit(dev)
560+
print(model.total_cdr_std_err_)
561+
print(model.total_mack_std_err_)
562+
563+
.. testoutput::
564+
565+
columns values
566+
(Total,) 81080.546787
567+
columns values
568+
(Total,) 108401.387451
569+
"""
570+
obj = self.X_
571+
out = np.sqrt(self._get_cdr_msep()[1])
572+
return pd.DataFrame(out, index=obj.index, columns=obj.columns)
573+
574+
def _get_cdr_msep(self):
575+
"""
576+
Mean squared errors of the one-year claims development result.
577+
578+
Returns the per-origin values, shape (index, columns, origin), and
579+
the totals over origins, shape (index, columns), from Results 3.1 and
580+
3.3 of ``merz2008`` with I = J = n - 1. Origins i and development
581+
periods j are 0-based, as in the paper, and the equation numbers in
582+
the comments are the paper's.
583+
"""
584+
from chainladder.development import Development
585+
586+
X = self.X_.set_backend("numpy")
587+
C = X if X.is_cumulative else X.incr_to_cum()
588+
a, b, n, m = C.shape
589+
msg = "cdr_std_err_ and total_cdr_std_err_ need "
590+
upper = np.add.outer(np.arange(n), np.arange(m)) <= n - 1
591+
if n != m or not np.array_equal(~np.isnan(X.nan_triangle), upper):
592+
raise ValueError(
593+
msg + "a square triangle valued at its latest diagonal; this "
594+
f"one has {n} origins and {m} development periods."
595+
)
596+
J = m - 1
597+
ldf = X.ldf_.set_backend("numpy").values[..., 0, :]
598+
sigma = X.sigma_.set_backend("numpy").values[..., 0, :]
599+
if not (
600+
np.all(np.nan_to_num(ldf[..., J:], nan=1.0) == 1)
601+
and np.all(np.nan_to_num(sigma[..., J:]) == 0)
602+
):
603+
raise ValueError(
604+
msg + "a pattern without a tail, as Merz and Wüthrich (2008) "
605+
"assume; fit without a tail estimator or with TailConstant(1.0)."
606+
)
607+
average = set(np.asarray(X.average_).ravel())
608+
if average != {"volume"}:
609+
raise ValueError(
610+
msg + "volume-weighted development factors; this pattern uses "
611+
f"{sorted(str(v) for v in average)}."
612+
)
613+
# A default Development on C gives the paper's factors (2.9), and the
614+
# fitted w_ and ldf_ must match it.
615+
fresh = Development().fit(C)
616+
w, w_fresh = np.broadcast_arrays(np.asarray(X.w_), np.asarray(fresh.w_))
617+
if not np.array_equal(w, w_fresh, equal_nan=True):
618+
raise ValueError(
619+
msg + "every link ratio of the triangle; this pattern excludes "
620+
"some (drop, drop_high, drop_low, drop_valuation, n_periods)."
621+
)
622+
f = ldf[..., :J] # f_j^I, (2.9)
623+
if not np.allclose(
624+
f, fresh.ldf_.values[..., 0, :], rtol=1e-8, atol=0, equal_nan=True
625+
):
626+
raise ValueError(
627+
msg + "development factors estimated from this triangle's "
628+
"own link ratios; ldf_ differs from them, as after "
629+
"Development(fillna=...)."
630+
)
631+
latest = C.latest_diagonal.values[..., 0] # C_{i, I-i}
632+
diag = latest[..., ::-1][..., :J] # C_{I-j, j}
633+
S1 = C.sum(axis="origin").values[..., 0, :J] # S_j^{I+1}, (2.10)
634+
S = S1 - diag # S_j^I, (2.9)
635+
ult = self.ultimate_.set_backend("numpy").values[..., 0] # C_{i,J}^I, (2.11)
636+
zero = np.zeros((a, b, 1))
637+
638+
def later(t):
639+
"""Sum of t over the positions after each position, last axis."""
640+
return np.concatenate((np.cumsum(t[..., ::-1], -1)[..., -2::-1], zero), -1)
641+
642+
with np.errstate(divide="ignore", invalid="ignore"):
643+
q = sigma[..., :J] ** 2 / f**2 # sigma_j^2 (3.3) over (f_j^I)^2
644+
i = np.arange(1, n) # origins with a next diagonal
645+
d = J - i # their next development period, I - i
646+
qd, Sd, S1d, Ci = q[..., d], S[..., d], S1[..., d], latest[..., i]
647+
# Sums over j = I-i+1 .. J-1: the summands of Phi (3.5), and those
648+
# of the sum that Delta (3.4) and Lambda (3.13) share.
649+
Phi = later(diag * q / S1**2)[..., d]
650+
shared = later((diag / S1) ** 2 * q / S)[..., d]
651+
Psi = qd / Ci # (3.6)
652+
Delta = qd / Sd + shared # (3.4)
653+
Lam = Ci / S1d * qd / Sd + shared # Lambda, (3.13)
654+
Xi = Phi + qd / S1d # (3.14)
655+
msep = np.full((a, b, n), np.nan)
656+
# (3.9), with Gamma = Phi + Psi (3.7)
657+
msep[..., 1:] = ult[..., i] ** 2 * (Phi + Psi + Delta)
658+
# (3.15): 2 * sum over k > i > 0 of C_{i,J} C_{k,J} (Xi + Lambda)
659+
cross = ult[..., i] * later(ult[..., 1:]) * (Xi + Lam)
660+
total = msep[..., 1:].sum(-1) + 2 * cross.sum(-1)
661+
complete = ~np.isnan(C.values[..., upper]).any(-1)
662+
msep[~complete] = np.nan
663+
total[~complete] = np.nan
664+
return msep, total
665+
468666
@property
469667
def summary_(self):
470668
"""

0 commit comments

Comments
 (0)