|
1 | 1 | # This Source Code Form is subject to the terms of the Mozilla Public |
2 | 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this |
3 | 3 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 4 | +import numpy as np |
4 | 5 | import pandas as pd |
5 | 6 | from chainladder.methods import Chainladder |
6 | 7 |
|
@@ -39,6 +40,12 @@ class MackChainladder(Chainladder): |
39 | 40 | The total prediction error by origin period |
40 | 41 | total_mack_std_err_: |
41 | 42 | 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 |
42 | 49 |
|
43 | 50 | See Also |
44 | 51 | -------- |
@@ -465,6 +472,197 @@ def _get_total_mack_std_err_(self, obj): |
465 | 472 | out = obj.sqrt().values[..., 0, -1] |
466 | 473 | return pd.DataFrame(out, index=obj.index, columns=obj.columns) |
467 | 474 |
|
| 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 | + |
468 | 666 | @property |
469 | 667 | def summary_(self): |
470 | 668 | """ |
|
0 commit comments