From 876be18daa554bb853746b0fa5029aeea06c72a2 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Fri, 22 May 2026 14:24:52 -0700 Subject: [PATCH 01/24] Improved docstring and added examples --- chainladder/development/development.py | 115 ++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/chainladder/development/development.py b/chainladder/development/development.py index 41742ac5..518fd6cb 100644 --- a/chainladder/development/development.py +++ b/chainladder/development/development.py @@ -34,33 +34,40 @@ class Development(DevelopmentBase): n_periods: integer, optional (default = -1) number of origin periods to be used in the ldf average calculation. For all origin periods, set n_periods = -1 - average: string or float, optional (default = 'volume') - type of averaging to use for ldf average calculation. Options include - 'volume', 'simple', 'regression', and 'geometric'. If numeric values are supplied, + average: literal (or list of literals), or float, optional (default = 'volume') + type of averaging to use for ldf average calculation. + Options include 'volume', 'simple', 'regression', and 'geometric'. If numeric values are supplied, then (2-average) in the style of Zehnwirth & Barnett is used for the exponent of the regression weights. sigma_interpolation: string optional (default = 'log-linear') Options include 'log-linear' and 'mack' drop: tuple or list of tuples - Drops specific origin/development combination(s) + Drops specific origin/development combination(s). See order of operations + below when combined with multiple drop parameters. drop_high: bool, int, list of bools, or list of ints (default = None) Drops highest (by rank) link ratio(s) from LDF calculation If a boolean variable is passed, drop_high is set to 1, dropping only the - highest value - Note that drop_high is performed after consideration of n_periods (if used) + highest value. Protected by ``preserve``. + See order of operations below when combined with multiple drop parameters. drop_low: bool, int, list of bools, or list of ints (default = None) Drops lowest (by rank) link ratio(s) from LDF calculation If a boolean variable is passed, drop_low is set to 1, dropping only the - lowest value - Note that drop_low is performed after consideration of n_periods (if used) + lowest value. Protected by ``preserve``. + See order of operations below when combined with multiple drop parameters. drop_above: float or list of floats (default = numpy.inf) - Drops all link ratio(s) above the given parameter from the LDF calculation + Drops all link ratio(s) above the given parameter from the LDF calculation. + Protected by ``preserve``. + See order of operations below when combined with multiple drop parameters. drop_below: float or list of floats (default = 0.00) - Drops all link ratio(s) below the given parameter from the LDF calculation + Drops all link ratio(s) below the given parameter from the LDF calculation. + Protected by ``preserve``. + See order of operations below when combined with multiple drop parameters. preserve: int (default = 1) - The minimum number of link ratio(s) required for LDF calculation + The minimum number of link ratio(s) required for LDF calculation. + See order of operations below when combined with multiple drop parameters. drop_valuation: str or list of str (default = None) Drops specific valuation periods. str must be date convertible. + See order of operations below when combined with multiple drop parameters. fillna: float, (default = None) Used to fill in zero or nan values of an triangle with some non-zero amount. When an link-ratio has zero as its denominator, it is automatically @@ -72,6 +79,20 @@ class Development(DevelopmentBase): of estimating patterns. If omitted, each level of the triangle index will receive its own patterns. + Notes (Order of Drop Operations) + ----- + When multiple drop parameters are used together, the weights are built in this order: + + 1. ``n_periods`` — limit to the most recent origin periods. + 2. ``drop`` — remove specific origin/development cells. + 3. ``drop_valuation`` — remove entire valuation diagonal in the triangle. + 4. ``drop_high`` / ``drop_low`` — remove highest/lowest link ratios by rank + (eligible factors from ``n_periods`` are used; protected by ``preserve``, + which may relax exclusions from this step if too few ratios would remain then this step is skipped). + 5. ``drop_above`` / ``drop_below`` — remove link ratios outside a range + (Protected by``preserve``, which may relax exclusions from this step if too few ratios would remain + then this step is skipped). + 6. Calculate the loss development factors using ``average`` method. Attributes ---------- @@ -87,6 +108,78 @@ class Development(DevelopmentBase): A Triangle representing the weighted standardized residuals of the estimator as described in Barnett and Zehnwirth. + Examples + -------- + + There are lots of parameters to control the development pattern selection. + One should exercise caution when multiple drop parameters are used together. + + We can drop a specific origin/development combination by passing a tuple to the drop parameter. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + tri = cl.load_sample("xyz") + ldf = cl.Development(drop=("2004", 12)).fit(tri["Incurred"]).ldf_ + print(ldf) + + .. testoutput:: + + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + (All) 1.582216 1.339353 1.193406 1.095906 1.076968 1.033612 1.019016 0.997636 0.992918 0.999179 + + We can also drop all link ratio(s) above the given parameter by passing a list of floats to the drop_above parameter. + + .. testcode:: + + tri = cl.load_sample("xyz") + ldf = ( + cl.Development(drop_above=[2.0, 1.5, 1.3, 1.2, 1.1, 1.07, 1.05, 1.03, 1.01, 1.00]) + .fit(tri["Incurred"]) + .ldf_ + ) + print(ldf) + + .. testoutput:: + + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + (All) 1.582216 1.301914 1.142205 1.071625 1.059935 1.022835 1.00511 0.997636 0.992918 0.999179 + + We can also use multiple drop parameters together. + Let's say, we want to drop all link ratio(s) above 1.2 and below 1.0, but only drop these link ratios when there are 5 or more link ratios remaining. + + .. testcode:: + + tri = cl.load_sample("xyz") + ldf = ( + cl.Development(drop_above=1.2, drop_below=1.0, preserve=5).fit(tri["Incurred"]).ldf_ + ) + print(ldf) + + .. testoutput:: + + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + (All) 1.675693 1.339353 1.193406 1.119324 1.076968 1.033612 1.019016 0.997636 0.992918 0.999179 + + Using other average methods, we can see that the loss development factors are different. + + .. testcode:: + + tri = cl.load_sample("xyz") + ldf = ( + cl.Development(average="simple").fit(tri["Incurred"]).ldf_ + ) + print(ldf) + + .. testoutput:: + + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + (All) 1.659537 1.35064 1.22277 1.119155 1.079301 1.039863 1.031011 0.997274 0.990571 0.999179 + + """ def __init__( From 2763b68a453209de358f498fe175fdff9bcd7c7a Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Fri, 22 May 2026 15:11:40 -0700 Subject: [PATCH 02/24] Added the link ratios --- chainladder/development/development.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/chainladder/development/development.py b/chainladder/development/development.py index 518fd6cb..173f4b61 100644 --- a/chainladder/development/development.py +++ b/chainladder/development/development.py @@ -114,7 +114,7 @@ class Development(DevelopmentBase): There are lots of parameters to control the development pattern selection. One should exercise caution when multiple drop parameters are used together. - We can drop a specific origin/development combination by passing a tuple to the drop parameter. + Let's start with a triangle and inspect its link ratios. .. testsetup:: @@ -123,6 +123,26 @@ class Development(DevelopmentBase): .. testcode:: tri = cl.load_sample("xyz") + print(tri["Incurred"].link_ratio) + + .. testoutput:: + + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + 1998 NaN NaN 1.108227 1.067528 1.064392 1.044146 1.114243 0.987596 0.979707 0.999179 + 1999 NaN 1.237646 1.197135 1.144305 1.057447 1.055967 0.988085 1.011131 1.001436 NaN + 2000 1.196032 1.168062 1.239452 1.086354 1.168543 1.072291 1.015048 0.993094 NaN NaN + 2001 1.353175 1.313547 1.264295 1.286967 1.085689 1.037834 1.006668 NaN NaN NaN + 2002 1.590040 1.308591 1.413078 1.179122 1.096524 0.989076 NaN NaN NaN NaN + 2003 1.760957 1.786055 1.337353 1.089595 1.003210 NaN NaN NaN NaN NaN + 2004 2.364225 1.465057 1.218140 0.980211 NaN NaN NaN NaN NaN NaN + 2005 1.654181 1.482965 1.004478 NaN NaN NaN NaN NaN NaN NaN + 2006 1.728479 1.043199 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 1.629204 NaN NaN NaN NaN NaN NaN NaN NaN NaN + + We can drop a specific origin/development combination by passing a tuple to the drop parameter. + + .. testcode:: + ldf = cl.Development(drop=("2004", 12)).fit(tri["Incurred"]).ldf_ print(ldf) From 30c63e793cbb6a6c891ea7f81219c735a5070186 Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Sun, 24 May 2026 00:30:00 +0530 Subject: [PATCH 03/24] docs: add doctest examples for correlation classes Adds Sphinx doctest Examples sections to DevelopmentCorrelation and ValuationCorrelation. Each example opens with the Mack chain-ladder assumption being tested, prints the full decision signal (statistic, confidence band, and boolean) rather than a single boolean, and ties the result back to the chain-ladder workflow. Refs #704 --- chainladder/core/correlation.py | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/chainladder/core/correlation.py b/chainladder/core/correlation.py index 942c0bb0..a39bc445 100644 --- a/chainladder/core/correlation.py +++ b/chainladder/core/correlation.py @@ -46,6 +46,45 @@ class DevelopmentCorrelation: confidence_interval: tuple Range within which ``t_expectation`` must fall for independence assumption to be significant. + + Examples + -------- + + Mack (1997) lists "successive development factors are uncorrelated" as + one of the assumptions underpinning the chain-ladder method. Before + relying on a ``Chainladder`` or ``MackChainladder`` ultimate it is good + practice to test that assumption on the triangle at hand. + ``DevelopmentCorrelation`` performs Mack's weighted Spearman rank test + across consecutive development columns and exposes both the test + statistic ``t_expectation`` and the no-correlation + ``confidence_interval``, so the decision is visible rather than reduced + to a single boolean. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + raa = cl.load_sample('raa') + dc = cl.DevelopmentCorrelation(raa, p_critical=0.5) + print(round(float(dc.t_expectation.iloc[0, 0]), 4)) + print(round(float(dc.confidence_interval[0]), 4)) + print(round(float(dc.confidence_interval[1]), 4)) + print(bool(dc.t_critical.iloc[0, 0])) + + .. testoutput:: + + 0.0696 + -0.1275 + 0.1275 + False + + The Spearman statistic ``0.0696`` lies inside the 50% confidence band + ``(-0.1275, 0.1275)`` derived from ``t_variance = 2 / ((I - 2)(I - 3))``, + so the test does not reject independence and chain-ladder is appropriate + for RAA on this dimension. See the Mack chain-ladder section of the user + guide for the full assumption set. """ def __init__(self, triangle, p_critical: float = 0.5): @@ -171,6 +210,50 @@ class ValuationCorrelation: The expected value of Z. z_variance : Triangle or DataFrame The variance value of Z. + + Examples + -------- + + Mack's second prerequisite for the chain-ladder method is that no + calendar period systematically inflates or deflates link ratios (for + example from a one-off reserve strengthening or a change in case + reserving practice). ``ValuationCorrelation`` flags any diagonal on + which the split of high versus low link ratios is unlikely under random + ordering, and can be evaluated per-diagonal (``total=False``, Mack 1997) + or for the whole triangle (``total=True``, Mack 1993). + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + raa = cl.load_sample('raa') + vc = cl.ValuationCorrelation(raa, p_critical=0.1, total=False) + print(vc.z_critical) + + .. testoutput:: + + 1982 1983 1984 1985 1986 1987 1988 1989 1990 + 1981 False False False False False False False False False + + No diagonal crosses the 90% threshold, so the calendar-effect assumption + is supported. If any cell read ``True`` you would inspect that diagonal + before relying on Mack or chain-ladder ultimates. + + .. testcode:: + + vc_total = cl.ValuationCorrelation(raa, p_critical=0.1, total=True) + print(round(float(vc_total.z.iloc[0, 0]), 4)) + print(bool(vc_total.z_critical.iloc[0, 0])) + + .. testoutput:: + + 14.0 + False + + The whole-triangle ``z`` statistic also falls inside the no-effect band, + confirming the per-diagonal result. """ def __init__(self, triangle: Triangle, p_critical: float = 0.1, total: bool = True): From 78272e80125316e53618e38a155ae8c00ad762e7 Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Sat, 23 May 2026 23:26:57 +0530 Subject: [PATCH 04/24] docs: add BootstrapODPSample doctest examples Adds Sphinx doctest Examples section to the BootstrapODPSample class showing basic fit (resampled_triangles_.shape and scale_), downstream stochastic IBNR via Chainladder, and the effect of drop_high on scale_. Uses random_state=42 and n_sims=100 for deterministic, fast output. Refs #704 --- chainladder/adjustments/bootstrap.py | 66 ++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/chainladder/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py index 1ad6b096..3be7eb7b 100644 --- a/chainladder/adjustments/bootstrap.py +++ b/chainladder/adjustments/bootstrap.py @@ -46,6 +46,72 @@ class BootstrapODPSample(DevelopmentBase): A set of triangles represented by each simulation scale_: The scale parameter to be used in generating process risk + + Examples + -------- + + Generate ODP bootstrap samples of the RAA sample triangle. The estimator + re-samples standardized Pearson residuals to produce ``n_sims`` synthetic + triangles stacked along the index axis of ``resampled_triangles_``, and + exposes the scale parameter ``scale_`` used to generate process risk. + ``random_state`` and a small ``n_sims`` keep the output deterministic + and fast. + + .. testsetup:: + + import warnings + warnings.filterwarnings("ignore") + import chainladder as cl + + .. testcode:: + + raa = cl.load_sample('raa') + boot = cl.BootstrapODPSample(n_sims=100, random_state=42).fit(raa) + print(boot.resampled_triangles_.shape) + print(round(float(boot.scale_), 2)) + + .. testoutput:: + + (100, 1, 10, 10) + 983.64 + + Because ``resampled_triangles_`` is itself a Triangle (with the simulation + index along the first axis), it can be fed straight into any downstream + reserving estimator to obtain a stochastic distribution of ultimates and + IBNR. Below, a deterministic chain-ladder is fit on the resampled triangle + and the total IBNR is summarised across the 100 simulations. + + .. testcode:: + + sims = cl.BootstrapODPSample( + n_sims=100, random_state=42 + ).fit_transform(raa) + ibnr = cl.Chainladder().fit(sims).ibnr_.sum('origin') + print(ibnr.shape) + print(round(float(ibnr.mean()), 2)) + print(round(float(ibnr.std()), 2)) + + .. testoutput:: + + (100, 1, 1, 1) + 51301.13 + 16149.47 + + Outlier link ratios can distort the residual distribution that the + bootstrap re-samples from. Setting ``drop_high=True`` excludes the highest + link ratio in each development column before computing residuals, which + shrinks ``scale_`` and tightens the simulated distribution. + + .. testcode:: + + boot_dh = cl.BootstrapODPSample( + n_sims=100, random_state=42, drop_high=True + ).fit(raa) + print(round(float(boot_dh.scale_), 2)) + + .. testoutput:: + + 648.94 """ def __init__( From 04e95f0859435ec5121b173c0a3a27543289e699 Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Sun, 24 May 2026 02:36:30 +0530 Subject: [PATCH 05/24] docs: reframe drop_high example as sensitivity check Rewords the paragraph introducing the drop_high=True example to describe it as a leave-one-out sensitivity check on the column maxima rather than outlier removal, since drop_high mechanically removes the column max without any outlier test. Addresses review feedback on #836. --- chainladder/adjustments/bootstrap.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/chainladder/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py index 3be7eb7b..2ffd0777 100644 --- a/chainladder/adjustments/bootstrap.py +++ b/chainladder/adjustments/bootstrap.py @@ -97,10 +97,12 @@ class BootstrapODPSample(DevelopmentBase): 51301.13 16149.47 - Outlier link ratios can distort the residual distribution that the - bootstrap re-samples from. Setting ``drop_high=True`` excludes the highest - link ratio in each development column before computing residuals, which - shrinks ``scale_`` and tightens the simulated distribution. + The estimator also supports a leave-one-out sensitivity check on the + residual distribution. Setting ``drop_high=True`` excludes the highest + link ratio in each development column before computing residuals, without + making any outlier judgement, so the resulting ``scale_`` measures how + influential the column maxima are on the bootstrap. For the RAA triangle + this shrinks ``scale_`` from 983.64 to 648.94. .. testcode:: From 3d827d9eaacb83de31cbafa18298fe1cf9c08f65 Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Mon, 25 May 2026 23:38:09 +0530 Subject: [PATCH 06/24] docs: move per-diagonal vs total mode note between testcode blocks Per @henrydingliu review on #844: opening paragraph now scoped to the calendar-effect concept; the per-diagonal vs whole-triangle distinction is introduced as a transition between the two testcode blocks. Refs #704 --- chainladder/core/correlation.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/chainladder/core/correlation.py b/chainladder/core/correlation.py index a39bc445..71f88e09 100644 --- a/chainladder/core/correlation.py +++ b/chainladder/core/correlation.py @@ -219,8 +219,7 @@ class ValuationCorrelation: example from a one-off reserve strengthening or a change in case reserving practice). ``ValuationCorrelation`` flags any diagonal on which the split of high versus low link ratios is unlikely under random - ordering, and can be evaluated per-diagonal (``total=False``, Mack 1997) - or for the whole triangle (``total=True``, Mack 1993). + ordering. .. testsetup:: @@ -241,6 +240,10 @@ class ValuationCorrelation: is supported. If any cell read ``True`` you would inspect that diagonal before relying on Mack or chain-ladder ultimates. + The same test can be aggregated to a whole-triangle form + (``total=True``, Mack 1993) instead of the per-diagonal form + (``total=False``, Mack 1997) shown above: + .. testcode:: vc_total = cl.ValuationCorrelation(raa, p_critical=0.1, total=True) From cd0a78f59019befde44701fdd05bad8f1e76608c Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Tue, 26 May 2026 01:51:47 +0530 Subject: [PATCH 07/24] docs: add README documenting docs build sources and outputs (refs #845) --- docs/README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/README.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..a6b87b0e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,65 @@ +# chainladder documentation + +This folder contains everything that builds the [chainladder-python docs site](https://chainladder-python.readthedocs.io/). It is organised as a [Jupyter Book](https://jupyterbook.org/) v1.x project on top of Sphinx, with the API reference produced by `sphinx.ext.autosummary` from the package docstrings. + +If you only want to read the docs, use the published site. This README is for contributors editing the docs. + +## Building the docs locally + +From the repository root: + +```bash +pip install -e .[docs] +cd docs +jb build . +``` + +The rendered site is written to `docs/_build/html/`. Open `docs/_build/html/index.html` in a browser to review changes. + +`jb build .` reads `_config.yml` and `_toc.yml`, then hands off to Sphinx using the extensions declared in `conf.py`. A full build currently emits warnings; cleaning those up is tracked under [#841](https://github.com/casact/chainladder-python/issues/841). + +The underlying Sphinx targets are also available through `make html`, `make doctest`, `make linkcheck` (or `make.bat` on Windows) if you want finer-grained control. + +## Source files (edit these) + +| File / folder | Purpose | +| --- | --- | +| `intro.md` | Landing page of the docs site. | +| `_toc.yml` | Book structure and page ordering. | +| `_config.yml` | Jupyter Book configuration (title, theme, Sphinx extensions). | +| `conf.py` | Sphinx configuration (autosummary, intersphinx, numpydoc, etc.). | +| `getting_started/` | Install, quickstart, and tutorial notebooks. | +| `user_guide/` | Long-form topic notebooks (triangle, development, tails, methods, adjustments, workflow, utils). | +| `library/api.md` | API reference index. Lists every public class/function via `autosummary` directives. | +| `library/releases.md` | Release notes. | +| `gallery/` | Example notebooks rendered into the gallery. | +| `images/` | Static images referenced from the notebooks and markdown. | +| `Makefile`, `make.bat` | Sphinx build entry points. | + +To document a new public class or function, add it to the appropriate `autosummary` block in `library/api.md` and write the docstring in the source module under `chainladder/`. The API page itself is generated from the docstring; you do not author per-class `.rst` files by hand. + +## Generated files (do not edit) + +These are produced by the build and should not be edited directly. Edits will be overwritten. + +| Path | Produced by | +| --- | --- | +| `_build/` | `jb build .` / `sphinx-build`. Gitignored. | +| `library/generated/*.rst` | `sphinx.ext.autosummary` (`autosummary_generate = True` in `conf.py`, driven by the `:toctree: generated/` directives in `library/api.md`). | + +## What to edit for which part of the site + +| You want to change... | Edit... | +| --- | --- | +| The home page | `intro.md` | +| Page order or sidebar nav | `_toc.yml` | +| Site title, theme, Sphinx extensions | `_config.yml` and `conf.py` | +| Which classes/functions appear in the API reference | `library/api.md` (autosummary lists) | +| The content of a class or function's API page | The docstring of that class/function in `chainladder/**/*.py` | +| Release notes | `library/releases.md` | +| A tutorial or user guide topic | The relevant notebook under `getting_started/` or `user_guide/` | +| A gallery example | The relevant notebook under `gallery/` | + +## Known issues + +A clean `jb build .` currently emits a number of Sphinx warnings; cleanup is tracked in [#841](https://github.com/casact/chainladder-python/issues/841) and its sub-issues. Please do not silence warnings ad-hoc as part of unrelated PRs. From bb0e64d8dce3334fd0b5a40547057a994cf1c19a Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Mon, 25 May 2026 21:36:31 -0700 Subject: [PATCH 08/24] Added the transformed link ratio triangle using fit_transform to examples --- chainladder/development/development.py | 94 ++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 5 deletions(-) diff --git a/chainladder/development/development.py b/chainladder/development/development.py index 173f4b61..c84eaf95 100644 --- a/chainladder/development/development.py +++ b/chainladder/development/development.py @@ -139,9 +139,30 @@ class Development(DevelopmentBase): 2006 1.728479 1.043199 NaN NaN NaN NaN NaN NaN NaN NaN 2007 1.629204 NaN NaN NaN NaN NaN NaN NaN NaN NaN - We can drop a specific origin/development combination by passing a tuple to the drop parameter. + We can drop a specific origin/development combination by passing a tuple to the drop parameter. By using + the `fit_transform` method, we can see the link ratios after the drop. - .. testcode:: + .. testcode:: + + print(cl.Development(drop=("2004", 12)).fit_transform(tri["Incurred"]).link_ratio) + + .. testoutput:: + + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + 1998 NaN NaN 1.108227 1.067528 1.064392 1.044146 1.114243 0.987596 0.979707 0.999179 + 1999 NaN 1.237646 1.197135 1.144305 1.057447 1.055967 0.988085 1.011131 1.001436 NaN + 2000 1.196032 1.168062 1.239452 1.086354 1.168543 1.072291 1.015048 0.993094 NaN NaN + 2001 1.353175 1.313547 1.264295 1.286967 1.085689 1.037834 1.006668 NaN NaN NaN + 2002 1.590040 1.308591 1.413078 1.179122 1.096524 0.989076 NaN NaN NaN NaN + 2003 1.760957 1.786055 1.337353 1.089595 1.003210 NaN NaN NaN NaN NaN + 2004 NaN 1.465057 1.218140 0.980211 NaN NaN NaN NaN NaN NaN + 2005 1.654181 1.482965 1.004478 NaN NaN NaN NaN NaN NaN NaN + 2006 1.728479 1.043199 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 1.629204 NaN NaN NaN NaN NaN NaN NaN NaN NaN + + We can also inspect the LDFs with the `ldf_` property. + + .. testcode:: ldf = cl.Development(drop=("2004", 12)).fit(tri["Incurred"]).ldf_ print(ldf) @@ -152,6 +173,36 @@ class Development(DevelopmentBase): (All) 1.582216 1.339353 1.193406 1.095906 1.076968 1.033612 1.019016 0.997636 0.992918 0.999179 We can also drop all link ratio(s) above the given parameter by passing a list of floats to the drop_above parameter. + Let's inspect the link ratios after the drop. + + .. testcode:: + + tri = cl.load_sample("xyz") + print(cl.Development(drop_above=[2.0, 1.5, 1.3, 1.2, 1.1, 1.07, 1.05, 1.03, 1.01, 1.00]).fit_transform(tri["Incurred"]).link_ratio) + + .. testoutput:: + + print( + cl.Development(drop_above=[2.0, 1.5, 1.3, 1.2, 1.1, 1.07, 1.05, 1.03, 1.01, 1.00]) + .fit_transform(tri["Incurred"]) + .link_ratio + ) + + .. testoutput:: + + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + 1998 NaN NaN 1.108227 1.067528 1.064392 1.044146 NaN 0.987596 0.979707 0.999179 + 1999 NaN 1.237646 1.197135 1.144305 1.057447 1.055967 0.988085 1.011131 1.001436 NaN + 2000 1.196032 1.168062 1.239452 1.086354 NaN NaN 1.015048 0.993094 NaN NaN + 2001 1.353175 1.313547 1.264295 NaN 1.085689 1.037834 1.006668 NaN NaN NaN + 2002 1.590040 1.308591 NaN 1.179122 1.096524 0.989076 NaN NaN NaN NaN + 2003 1.760957 NaN NaN 1.089595 1.003210 NaN NaN NaN NaN NaN + 2004 NaN 1.465057 1.218140 0.980211 NaN NaN NaN NaN NaN NaN + 2005 1.654181 1.482965 1.004478 NaN NaN NaN NaN NaN NaN NaN + 2006 1.728479 1.043199 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 1.629204 NaN NaN NaN NaN NaN NaN NaN NaN NaN + + Then, let the LDFs. .. testcode:: @@ -169,20 +220,53 @@ class Development(DevelopmentBase): (All) 1.582216 1.301914 1.142205 1.071625 1.059935 1.022835 1.00511 0.997636 0.992918 0.999179 We can also use multiple drop parameters together. - Let's say, we want to drop all link ratio(s) above 1.2 and below 1.0, but only drop these link ratios when there are 5 or more link ratios remaining. + Let's say, we want to drop all link ratio(s) above 1.25 and below 1.0, but only drop these link ratios when + there are 3 or more link ratios remaining. .. testcode:: tri = cl.load_sample("xyz") + print( + cl.Development(drop_above=1.25, drop_below=1.0, preserve=3) + .fit_transform(tri["Incurred"]) + .link_ratio + ) + + .. testoutput:: + + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + 1998 NaN NaN 1.108227 1.067528 1.064392 1.044146 1.114243 0.987596 0.979707 0.999179 + 1999 NaN 1.237646 1.197135 1.144305 1.057447 1.055967 NaN 1.011131 1.001436 NaN + 2000 1.196032 1.168062 1.239452 1.086354 1.168543 1.072291 1.015048 0.993094 NaN NaN + 2001 1.353175 NaN NaN NaN 1.085689 1.037834 1.006668 NaN NaN NaN + 2002 1.590040 NaN NaN 1.179122 1.096524 NaN NaN NaN NaN NaN + 2003 1.760957 NaN NaN 1.089595 1.003210 NaN NaN NaN NaN NaN + 2004 2.364225 NaN 1.218140 NaN NaN NaN NaN NaN NaN NaN + 2005 1.654181 NaN 1.004478 NaN NaN NaN NaN NaN NaN NaN + 2006 1.728479 1.043199 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 1.629204 NaN NaN NaN NaN NaN NaN NaN NaN NaN + + Notice that the link ratios above 1.25 and below 1.0 are not dropped, unless there are 3 or more link ratios + remaining. For example, the 12-24 link ratio is not dropped because there are 2 valid non-NaN link ratios + remaining after removing all link ratios above 1.25 and below 1.0. While the 24-36 link ratio is dropped + because there are still 3 valid non-NaN link ratios remaining after removing all link ratios above 1.25 and + below 1.0. + + Let's inspect the LDFs. + + .. testcode:: + ldf = ( - cl.Development(drop_above=1.2, drop_below=1.0, preserve=5).fit(tri["Incurred"]).ldf_ + cl.Development(drop_above=1.25, drop_below=1.0, preserve=3) + .fit(tri["Incurred"]) + .ldf_ ) print(ldf) .. testoutput:: 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 - (All) 1.675693 1.339353 1.193406 1.119324 1.076968 1.033612 1.019016 0.997636 0.992918 0.999179 + (All) 1.675693 1.105627 1.127842 1.119324 1.076968 1.053434 1.027623 0.997636 0.992918 0.999179 Using other average methods, we can see that the loss development factors are different. From 8219602e765d5e1b0b49ad84a6e8f2effe48f858 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Mon, 25 May 2026 22:03:37 -0700 Subject: [PATCH 09/24] Removed the multiple testouputs --- chainladder/development/development.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/chainladder/development/development.py b/chainladder/development/development.py index c84eaf95..ef38393e 100644 --- a/chainladder/development/development.py +++ b/chainladder/development/development.py @@ -178,10 +178,6 @@ class Development(DevelopmentBase): .. testcode:: tri = cl.load_sample("xyz") - print(cl.Development(drop_above=[2.0, 1.5, 1.3, 1.2, 1.1, 1.07, 1.05, 1.03, 1.01, 1.00]).fit_transform(tri["Incurred"]).link_ratio) - - .. testoutput:: - print( cl.Development(drop_above=[2.0, 1.5, 1.3, 1.2, 1.1, 1.07, 1.05, 1.03, 1.01, 1.00]) .fit_transform(tri["Incurred"]) From 9669d2d116a502cdafc5ed0309d384201e5dfaef Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Tue, 26 May 2026 11:42:41 +0530 Subject: [PATCH 10/24] docs: address review feedback on docs README (refs #845) - Drop the jb build internals + duplicate warnings note (kennethshsu) - Rename 'tutorial notebooks' to 'onboarding and Quickstart notebooks' (kennethshsu) - Drop the 'What to edit for which part of the site' table and 'Known issues' section as redundant with the Source files table and #841 (kennethshsu) --- docs/README.md | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/docs/README.md b/docs/README.md index a6b87b0e..8ae43361 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,8 +16,6 @@ jb build . The rendered site is written to `docs/_build/html/`. Open `docs/_build/html/index.html` in a browser to review changes. -`jb build .` reads `_config.yml` and `_toc.yml`, then hands off to Sphinx using the extensions declared in `conf.py`. A full build currently emits warnings; cleaning those up is tracked under [#841](https://github.com/casact/chainladder-python/issues/841). - The underlying Sphinx targets are also available through `make html`, `make doctest`, `make linkcheck` (or `make.bat` on Windows) if you want finer-grained control. ## Source files (edit these) @@ -28,7 +26,7 @@ The underlying Sphinx targets are also available through `make html`, `make doct | `_toc.yml` | Book structure and page ordering. | | `_config.yml` | Jupyter Book configuration (title, theme, Sphinx extensions). | | `conf.py` | Sphinx configuration (autosummary, intersphinx, numpydoc, etc.). | -| `getting_started/` | Install, quickstart, and tutorial notebooks. | +| `getting_started/` | Install, onboarding, and Quickstart notebooks. | | `user_guide/` | Long-form topic notebooks (triangle, development, tails, methods, adjustments, workflow, utils). | | `library/api.md` | API reference index. Lists every public class/function via `autosummary` directives. | | `library/releases.md` | Release notes. | @@ -46,20 +44,3 @@ These are produced by the build and should not be edited directly. Edits will be | --- | --- | | `_build/` | `jb build .` / `sphinx-build`. Gitignored. | | `library/generated/*.rst` | `sphinx.ext.autosummary` (`autosummary_generate = True` in `conf.py`, driven by the `:toctree: generated/` directives in `library/api.md`). | - -## What to edit for which part of the site - -| You want to change... | Edit... | -| --- | --- | -| The home page | `intro.md` | -| Page order or sidebar nav | `_toc.yml` | -| Site title, theme, Sphinx extensions | `_config.yml` and `conf.py` | -| Which classes/functions appear in the API reference | `library/api.md` (autosummary lists) | -| The content of a class or function's API page | The docstring of that class/function in `chainladder/**/*.py` | -| Release notes | `library/releases.md` | -| A tutorial or user guide topic | The relevant notebook under `getting_started/` or `user_guide/` | -| A gallery example | The relevant notebook under `gallery/` | - -## Known issues - -A clean `jb build .` currently emits a number of Sphinx warnings; cleanup is tracked in [#841](https://github.com/casact/chainladder-python/issues/841) and its sub-issues. Please do not silence warnings ad-hoc as part of unrelated PRs. From 4633bda4016d68f0ace1b9bf60ec173fb04cece7 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 13:41:03 -0500 Subject: [PATCH 11/24] [REFACTOR]: Remove repetitive code. Remove dead Python 3.8 code. Add missing unit tests. --- chainladder/__init__.py | 109 +++++++++++++++------- chainladder/utils/tests/test_utilities.py | 91 ++++++++++++++++++ 2 files changed, 165 insertions(+), 35 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index 011e4084..db5503ae 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -1,37 +1,82 @@ import numpy as np import pandas as pd +from importlib.metadata import version from sklearn.utils import deprecated -_DT64_DTYPE = pd.to_datetime(["2000-01-01"]).dtype -_ULT_VAL: str = str( - pd.Timestamp("2262-01-01") - \ - pd.Timedelta(1, unit=np.datetime_data(_DT64_DTYPE)[0]) -) - class Options: - ARRAY_BACKEND = "numpy" - AUTO_SPARSE = True - ARRAY_PRIORITY = ["dask", "sparse", "cupy", "numpy"] - ULT_VAL: str = _ULT_VAL - DT64_UNIT: str = np.datetime_data(_DT64_DTYPE)[0] - DT64_DTYPE: str = str(_DT64_DTYPE) - - @classmethod - def get_option(cls, option=None): - return getattr(cls, option) - - @classmethod - def set_option(cls, option, value): - setattr(cls, option, value) - - def reset_option(self): - self.set_option('ARRAY_BACKEND', "numpy") - self.set_option('AUTO_SPARSE', True) - self.set_option('ARRAY_PRIORITY', ["dask", "sparse", "cupy", "numpy"]) - self.set_option('ULT_VAL', _ULT_VAL) - self.set_option('DT64_UNIT', np.datetime_data(_DT64_DTYPE)[0]) - self.set_option('DT64_DTYPE', str(_DT64_DTYPE)) + """ + Used to set defaults for array backend and datetime units. + + Attributes + ---------- + + ARRAY_BACKEND: str + The default array backend for chainladder. + AUTO_SPARSE: bool + + + """ + def __init__(self): + self.ARRAY_BACKEND = "numpy" + self.AUTO_SPARSE = True + self.ARRAY_PRIORITY = ["dask", "sparse", "cupy", "numpy"] + self.DT64_DTYPE: str = pd.to_datetime(["2000-01-01"]).dtype.name + self.DT64_UNIT: str = np.datetime_data(self.DT64_DTYPE)[0] + self.ULT_VAL = str( + pd.Timestamp("2262-01-01") - \ + pd.Timedelta(1, unit=self.DT64_UNIT) + ) + + def get_option(self, option: str) -> str | bool | list: + """ + Get the option value for the specified option. + + Parameters + ---------- + option: str + The option you wish to get the values for. + + Returns + ------- + The option value. + + """ + return getattr(self, option) + + def set_option( + self, + option: str, + value: str | bool | list + ) -> None: + """ + Set the option value for the specified option. + + Parameters + ---------- + option: str + The option you wish to set the value for. + value: str | bool | list + The option value. + + Returns + ------- + None + + """ + + setattr(self, option, value) + + def reset_option(self) -> None: + """ + Restores the default options. + + Returns + ------- + None + + """ + self.__init__() def describe_option(self): pass @@ -55,10 +100,4 @@ def auto_sparse(auto_sparse=True): from chainladder.methods import * # noqa (API Import) from chainladder.workflow import * # noqa (API Import) -try: - from importlib.metadata import version - __version__ = version("chainladder") -except ImportError: - # Fallback for Python < 3.8 - from importlib_metadata import version - __version__ = version("chainladder") +__version__ = version("chainladder") diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 0b269158..9a12174a 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -211,3 +211,94 @@ def test_reset_option() -> None: assert cl.options.ULT_VAL == original_ult_val assert cl.options.DT64_UNIT == original_dt64_unit assert cl.options.DT64_DTYPE == original_dt64_dtype + + +def test_options_defaults() -> None: + """ + When initialized, default options should be correct and accessible from the options variable. + + Returns + ------- + None + + """ + options = cl.Options() + assert options.ARRAY_BACKEND == "numpy" + assert options.AUTO_SPARSE == True + assert options.ARRAY_PRIORITY == ["dask", "sparse", "cupy", "numpy"] + assert isinstance(options.DT64_DTYPE, str) + assert isinstance(options.DT64_UNIT, str) + assert isinstance(options.ULT_VAL, str) + + +def test_get_option() -> None: + """ + get_option should return the appropriate attribute value. + + Returns + ------- + None + + """ + assert cl.options.get_option('ARRAY_BACKEND') == cl.options.ARRAY_BACKEND + assert cl.options.get_option('AUTO_SPARSE') == cl.options.AUTO_SPARSE + assert cl.options.get_option('ARRAY_PRIORITY') == cl.options.ARRAY_PRIORITY + assert cl.options.get_option('DT64_DTYPE') == cl.options.DT64_DTYPE + assert cl.options.get_option('DT64_UNIT') == cl.options.DT64_UNIT + assert cl.options.get_option('ULT_VAL') == cl.options.ULT_VAL + + +def test_set_option_consistency() -> None: + """ + When set_option changes an option value, get_option should return the new option value. + + Returns + ------- + None + + """ + original = cl.options.ARRAY_BACKEND + try: + cl.options.set_option('ARRAY_BACKEND', 'sparse') + assert cl.options.ARRAY_BACKEND == 'sparse' + assert cl.options.get_option('ARRAY_BACKEND') == 'sparse' + finally: + # Reset the options to default if the test fails. + cl.options.set_option('ARRAY_BACKEND', original) + + +def test_deprecated_array_backend() -> None: + """ + Trigger the deprecation warning on cl.array_backend() + + Returns + ------- + None + + """ + original = cl.options.ARRAY_BACKEND + try: + with pytest.warns(FutureWarning): + cl.array_backend('sparse') + assert cl.options.ARRAY_BACKEND == 'sparse' + finally: + # Reset the options to default if the test fails. + cl.options.set_option('ARRAY_BACKEND', original) + + +def test_deprecated_auto_sparse() -> None: + """ + Trigger the deprecation warning on cl.auto_sparse() + + Returns + ------- + None + + """ + original = cl.options.AUTO_SPARSE + try: + with pytest.warns(FutureWarning): + cl.auto_sparse(False) + assert cl.options.AUTO_SPARSE == False + finally: + cl.options.set_option('AUTO_SPARSE', original) From f11497b6a7aebc4447d4008d1b99b6d7411d5163 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 14:15:32 -0500 Subject: [PATCH 12/24] [FIX]: Limit test to only those values meant to be changed. Use realistic values for changed options. --- chainladder/utils/tests/test_utilities.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 9a12174a..c9564a73 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -184,7 +184,7 @@ def test_date_delta_adjustment() -> None: def test_reset_option() -> None: """ - Tests cl.options.reset_option. + Change some of the options and then reset them. Values after reset should match the original values. Returns ------- @@ -192,25 +192,21 @@ def test_reset_option() -> None: """ + original_backend = cl.options.ARRAY_BACKEND + original_auto_sparse = cl.options.AUTO_SPARSE + original_array_priority = cl.options.ARRAY_PRIORITY original_ult_val = cl.options.ULT_VAL - original_dt64_unit = cl.options.DT64_UNIT - original_dt64_dtype = cl.options.DT64_DTYPE cl.options.set_option('ARRAY_BACKEND', 'sparse') cl.options.set_option('AUTO_SPARSE', False) - cl.options.set_option('ARRAY_PRIORITY', ['numpy']) - cl.options.set_option('ULT_VAL', 'fake') - cl.options.set_option('DT64_UNIT', 'fake') - cl.options.set_option('DT64_DTYPE', 'fake') + cl.options.set_option('ARRAY_PRIORITY', ['sparse', 'dask', 'numpy', 'cupy']) cl.options.reset_option() - assert cl.options.ARRAY_BACKEND == 'numpy' - assert cl.options.AUTO_SPARSE == True - assert cl.options.ARRAY_PRIORITY == ['dask', 'sparse', 'cupy', 'numpy'] + assert cl.options.ARRAY_BACKEND == original_backend + assert cl.options.AUTO_SPARSE == original_auto_sparse + assert cl.options.ARRAY_PRIORITY == original_array_priority assert cl.options.ULT_VAL == original_ult_val - assert cl.options.DT64_UNIT == original_dt64_unit - assert cl.options.DT64_DTYPE == original_dt64_dtype def test_options_defaults() -> None: From ee4525e6f6af62e910e67586f22d6495257f2db2 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 14:20:14 -0500 Subject: [PATCH 13/24] [DOCS]: Finish updating Options docstring. --- chainladder/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index db5503ae..f7ae007e 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -14,7 +14,14 @@ class Options: ARRAY_BACKEND: str The default array backend for chainladder. AUTO_SPARSE: bool - + Controls whether chainladder automatically converts a triangle's backing array to a sparse representation + when it would be memory-efficient to do so. + DT64_DTYPE: str + The default datetime64 data type, extracted from Pandas installation. + DT64_UNIT: str + The default datetime64 precision, extracted from Pandas installation. + ULT_VAL: str + The default ultimate valuation datetime, precision set to default of Pandas installation. """ def __init__(self): From ce9f9acca54120d8c03eb6e9309380bf4699711a Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 14:22:43 -0500 Subject: [PATCH 14/24] [FIX]: Fix ending state of test. --- chainladder/utils/tests/test_utilities.py | 25 +++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index c9564a73..a464771a 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -197,16 +197,25 @@ def test_reset_option() -> None: original_array_priority = cl.options.ARRAY_PRIORITY original_ult_val = cl.options.ULT_VAL - cl.options.set_option('ARRAY_BACKEND', 'sparse') - cl.options.set_option('AUTO_SPARSE', False) - cl.options.set_option('ARRAY_PRIORITY', ['sparse', 'dask', 'numpy', 'cupy']) + try: + + cl.options.set_option('ARRAY_BACKEND', 'sparse') + cl.options.set_option('AUTO_SPARSE', False) + cl.options.set_option('ARRAY_PRIORITY', ['sparse', 'dask', 'numpy', 'cupy']) - cl.options.reset_option() + cl.options.reset_option() - assert cl.options.ARRAY_BACKEND == original_backend - assert cl.options.AUTO_SPARSE == original_auto_sparse - assert cl.options.ARRAY_PRIORITY == original_array_priority - assert cl.options.ULT_VAL == original_ult_val + assert cl.options.ARRAY_BACKEND == original_backend + assert cl.options.AUTO_SPARSE == original_auto_sparse + assert cl.options.ARRAY_PRIORITY == original_array_priority + assert cl.options.ULT_VAL == original_ult_val + + finally: + # Manual reset in case of test failure. + cl.options.ARRAY_BACKEND = original_backend + cl.options.AUTO_SPARSE = original_auto_sparse + cl.options.ARRAY_PRIORITY = original_array_priority + cl.options.ULT_VAL = original_ult_val def test_options_defaults() -> None: From d7d22acf472a3c1d0384bc62671e0e6280222a10 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 14:57:54 -0500 Subject: [PATCH 15/24] [FIX]: Reset backend after sparse-only run. --- chainladder/utils/tests/test_utilities.py | 9 +++------ conftest.py | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index a464771a..f148f847 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -195,7 +195,6 @@ def test_reset_option() -> None: original_backend = cl.options.ARRAY_BACKEND original_auto_sparse = cl.options.AUTO_SPARSE original_array_priority = cl.options.ARRAY_PRIORITY - original_ult_val = cl.options.ULT_VAL try: @@ -208,14 +207,12 @@ def test_reset_option() -> None: assert cl.options.ARRAY_BACKEND == original_backend assert cl.options.AUTO_SPARSE == original_auto_sparse assert cl.options.ARRAY_PRIORITY == original_array_priority - assert cl.options.ULT_VAL == original_ult_val finally: # Manual reset in case of test failure. - cl.options.ARRAY_BACKEND = original_backend - cl.options.AUTO_SPARSE = original_auto_sparse - cl.options.ARRAY_PRIORITY = original_array_priority - cl.options.ULT_VAL = original_ult_val + cl.options.set_option('ARRAY_BACKEND', original_backend) + cl.options.set_option('AUTO_SPARSE', original_auto_sparse) + cl.options.set_option('ARRAY_PRIORITY', original_array_priority) def test_options_defaults() -> None: diff --git a/conftest.py b/conftest.py index 11e0c3bc..44494621 100644 --- a/conftest.py +++ b/conftest.py @@ -27,7 +27,8 @@ def raa(request): cl.options.set_option("ARRAY_BACKEND", "sparse") else: cl.options.set_option("ARRAY_BACKEND", "numpy") - return cl.load_sample("raa") + yield cl.load_sample("raa") + cl.options.set_option("ARRAY_BACKEND", "numpy") @pytest.fixture @@ -36,7 +37,8 @@ def qtr(request): cl.options.set_option("ARRAY_BACKEND", "sparse") else: cl.options.set_option("ARRAY_BACKEND", "numpy") - return cl.load_sample("quarterly") + yield cl.load_sample("quarterly") + cl.options.set_option("ARRAY_BACKEND", "numpy") @pytest.fixture @@ -45,7 +47,8 @@ def clrd(request): cl.options.set_option("ARRAY_BACKEND", "sparse") else: cl.options.set_option("ARRAY_BACKEND", "numpy") - return cl.load_sample("clrd") + yield cl.load_sample("clrd") + cl.options.set_option("ARRAY_BACKEND", "numpy") @pytest.fixture @@ -54,13 +57,15 @@ def genins(request): cl.options.set_option("ARRAY_BACKEND", "sparse") else: cl.options.set_option("ARRAY_BACKEND", "numpy") - return cl.load_sample("genins") + yield cl.load_sample("genins") + cl.options.set_option("ARRAY_BACKEND", "numpy") @pytest.fixture def prism(request): cl.options.set_option("ARRAY_BACKEND", "numpy") - return cl.load_sample("prism") + yield cl.load_sample("prism") + cl.options.set_option("ARRAY_BACKEND", "numpy") @pytest.fixture @@ -69,7 +74,8 @@ def prism_dense(request): cl.options.set_option("ARRAY_BACKEND", "sparse") else: cl.options.set_option("ARRAY_BACKEND", "numpy") - return cl.load_sample("prism").sum() + yield cl.load_sample("prism").sum() + cl.options.set_option("ARRAY_BACKEND", "numpy") @pytest.fixture @@ -78,7 +84,8 @@ def xyz(request): cl.options.set_option("ARRAY_BACKEND", "sparse") else: cl.options.set_option("ARRAY_BACKEND", "numpy") - return cl.load_sample("xyz") + yield cl.load_sample("xyz") + cl.options.set_option("ARRAY_BACKEND", "numpy") @pytest.fixture From 51a1ad1fe17f4d39a38320f621f035b0f3597e22 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 15:21:03 -0500 Subject: [PATCH 16/24] [REFACTOR] Create template fixture for sample data sets. --- conftest.py | 96 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/conftest.py b/conftest.py index 44494621..36af3fba 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,18 @@ +from __future__ import annotations + import pytest import chainladder as cl +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + from chainladder import Triangle + from typing import ( + Any, + Callable + ) + def pytest_generate_tests(metafunc): if "raa" in metafunc.fixturenames: @@ -21,71 +33,77 @@ def pytest_generate_tests(metafunc): metafunc.parametrize("xyz", ["normal_run", "sparse_only_run"], indirect=True) +def _sample_fixture( + request: Any, + sample: str, + transform: Callable[[Triangle], Triangle] | None = None + ) -> Iterator[Triangle]: + """ + Common template fixture for using sample data in unit tests. + + Parameters + ---------- + request:Any + The pytest request built-in. + sample: str + The name of the sample data set to be loaded, e.g., raa, clrd, etc. + transform: Callable[[Triangle], Triangle] | None + An optional transformation to be applied to the triangle supplied as a lambda function. + + Yields + ------- + A Triangle, with backend set according to request.param. + + """ + + # Set the backend to sparse for a sparse-only-run. + cl.options.set_option("ARRAY_BACKEND", "sparse" if request.param == "sparse_only_run" else "numpy") + # Load the sample data. + tri = cl.load_sample(sample) + # Apply a transformation if supplied, then yield the triangle to the test. + yield transform(tri) if transform else tri + # After the test, reset the backend to default numpy. + cl.options.set_option("ARRAY_BACKEND", "numpy") + + @pytest.fixture def raa(request): - if request.param == "sparse_only_run": - cl.options.set_option("ARRAY_BACKEND", "sparse") - else: - cl.options.set_option("ARRAY_BACKEND", "numpy") - yield cl.load_sample("raa") - cl.options.set_option("ARRAY_BACKEND", "numpy") + yield from _sample_fixture(request, "raa") @pytest.fixture def qtr(request): - if request.param == "sparse_only_run": - cl.options.set_option("ARRAY_BACKEND", "sparse") - else: - cl.options.set_option("ARRAY_BACKEND", "numpy") - yield cl.load_sample("quarterly") - cl.options.set_option("ARRAY_BACKEND", "numpy") + yield from _sample_fixture(request, "quarterly") @pytest.fixture def clrd(request): - if request.param == "sparse_only_run": - cl.options.set_option("ARRAY_BACKEND", "sparse") - else: - cl.options.set_option("ARRAY_BACKEND", "numpy") - yield cl.load_sample("clrd") - cl.options.set_option("ARRAY_BACKEND", "numpy") + yield from _sample_fixture(request, "clrd") + + +@pytest.fixture +def clrd(request): + yield from _sample_fixture(request, "clrd") @pytest.fixture def genins(request): - if request.param == "sparse_only_run": - cl.options.set_option("ARRAY_BACKEND", "sparse") - else: - cl.options.set_option("ARRAY_BACKEND", "numpy") - yield cl.load_sample("genins") - cl.options.set_option("ARRAY_BACKEND", "numpy") + yield from _sample_fixture(request, "genins") @pytest.fixture def prism(request): - cl.options.set_option("ARRAY_BACKEND", "numpy") - yield cl.load_sample("prism") - cl.options.set_option("ARRAY_BACKEND", "numpy") + yield from _sample_fixture(request, "prism") @pytest.fixture def prism_dense(request): - if request.param == "sparse_only_run": - cl.options.set_option("ARRAY_BACKEND", "sparse") - else: - cl.options.set_option("ARRAY_BACKEND", "numpy") - yield cl.load_sample("prism").sum() - cl.options.set_option("ARRAY_BACKEND", "numpy") + yield from _sample_fixture(request, "prism", transform=lambda t: t.sum()) @pytest.fixture def xyz(request): - if request.param == "sparse_only_run": - cl.options.set_option("ARRAY_BACKEND", "sparse") - else: - cl.options.set_option("ARRAY_BACKEND", "numpy") - yield cl.load_sample("xyz") - cl.options.set_option("ARRAY_BACKEND", "numpy") + yield from _sample_fixture(request, "xyz") @pytest.fixture From 70fe2a540608a8dad8f1a255e5b7b4a51f3a10ca Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 15:24:54 -0500 Subject: [PATCH 17/24] [Fix]: Remove duplicate fixture. --- conftest.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/conftest.py b/conftest.py index 36af3fba..867c8e0b 100644 --- a/conftest.py +++ b/conftest.py @@ -81,11 +81,6 @@ def clrd(request): yield from _sample_fixture(request, "clrd") -@pytest.fixture -def clrd(request): - yield from _sample_fixture(request, "clrd") - - @pytest.fixture def genins(request): yield from _sample_fixture(request, "genins") From 5dd0800f3430e0397d2aaed239325c32577ac302 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 22:44:06 -0500 Subject: [PATCH 18/24] [REFACTOR]: Move datetime defaults out of Options. Add validation to option getters and setters. Deprecate cl.array_backend() and cl.auto_sparse() --- chainladder/__init__.py | 50 +++++++++++++---------- chainladder/core/base.py | 10 +++-- chainladder/core/pandas.py | 6 ++- chainladder/utils/tests/test_utilities.py | 41 +++++++------------ chainladder/utils/utility_functions.py | 9 ++-- 5 files changed, 61 insertions(+), 55 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index f7ae007e..7ff1b94c 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -1,7 +1,12 @@ import numpy as np import pandas as pd from importlib.metadata import version -from sklearn.utils import deprecated + + +# Get the default datetime64 data type and precision, extracted from Pandas installation. +# Used for cross-version compatibility between Pandas 2 and Pandas 3. +__dt64_dtype__: str = pd.to_datetime(["2000-01-01"]).dtype.name +__dt64_unit__: str = np.datetime_data(__dt64_dtype__)[0] class Options: @@ -16,10 +21,9 @@ class Options: AUTO_SPARSE: bool Controls whether chainladder automatically converts a triangle's backing array to a sparse representation when it would be memory-efficient to do so. - DT64_DTYPE: str - The default datetime64 data type, extracted from Pandas installation. - DT64_UNIT: str - The default datetime64 precision, extracted from Pandas installation. + ARRAY_PRIORITY: list + Determines which backend wins when two triangles with different backends interact, i.e., + when comparing or concatenating them. ULT_VAL: str The default ultimate valuation datetime, precision set to default of Pandas installation. @@ -28,12 +32,12 @@ def __init__(self): self.ARRAY_BACKEND = "numpy" self.AUTO_SPARSE = True self.ARRAY_PRIORITY = ["dask", "sparse", "cupy", "numpy"] - self.DT64_DTYPE: str = pd.to_datetime(["2000-01-01"]).dtype.name - self.DT64_UNIT: str = np.datetime_data(self.DT64_DTYPE)[0] self.ULT_VAL = str( pd.Timestamp("2262-01-01") - \ - pd.Timedelta(1, unit=self.DT64_UNIT) + pd.Timedelta(1, unit=__dt64_unit__) ) + # Store initial values as defaults. + self.defaults = {**vars(self)} def get_option(self, option: str) -> str | bool | list: """ @@ -49,6 +53,7 @@ def get_option(self, option: str) -> str | bool | list: The option value. """ + self._validate_option(option) return getattr(self, option) def set_option( @@ -71,32 +76,35 @@ def set_option( None """ - + self._validate_option(option) setattr(self, option, value) - def reset_option(self) -> None: + def reset_option(self, option: str | None = None) -> None: """ - Restores the default options. + Restores the default value for the specified option. Restores default values for + all options if option is None. Returns ------- None """ - self.__init__() - def describe_option(self): - pass + if option: + self._validate_option(option) + setattr(self, option, self.defaults[option]) + else: + self.__init__() -options = Options() + def _validate_option(self, option: str) -> None: + + if option not in self.defaults: + raise ValueError(f"Invalid option(s): {option}. Must be one of {list(self.defaults)}.") -@deprecated("In an upcoming version of the package, this function will be deprecated. Use `chainladder.options.set_option('ARRAY_BACKEND', value)` to avoid breakage.") -def array_backend(array_backend="numpy"): - options.set_option('ARRAY_BACKEND', array_backend) + def describe_option(self, option: str) -> str: + pass -@deprecated("In an upcoming version of the package, this function will be deprecated. Use `chainladder.options.set_option('AUTO_SPARSE', value)` to avoid breakage.") -def auto_sparse(auto_sparse=True): - options.set_option('AUTO_SPARSE', auto_sparse) +options = Options() from chainladder.utils import * # noqa (API Import) diff --git a/chainladder/core/base.py b/chainladder/core/base.py index 4812344e..d5a967f8 100644 --- a/chainladder/core/base.py +++ b/chainladder/core/base.py @@ -9,7 +9,11 @@ from abc import ABC, abstractmethod -from chainladder import options +from chainladder import ( + __dt64_unit__, + __dt64_dtype__, + options +) from chainladder.core.common import Common from chainladder.core.display import TriangleDisplay @@ -534,12 +538,12 @@ def valuation(self): ddim_arr = ddims - ddims[0] origin = np.minimum(self.odims, np.datetime64(self.valuation_date)) val_array = origin.astype("datetime64[M]") + np.timedelta64(ddims[0], "M") - val_array = val_array.astype(options.DT64_DTYPE) - np.timedelta64(1, options.DT64_UNIT) + val_array = val_array.astype(__dt64_dtype__) - np.timedelta64(1, __dt64_unit__) val_array = val_array[:, None] s = slice(None, -1) if ddims[-1] == 9999 else slice(None, None) val_array = ( val_array.astype("datetime64[M]") + ddim_arr[s][None, :] + 1 - ).astype(options.DT64_DTYPE) - np.timedelta64(1, options.DT64_UNIT) + ).astype(__dt64_dtype__) - np.timedelta64(1, __dt64_unit__) if ddims[-1] == 9999: ult = np.repeat(np.datetime64(options.ULT_VAL), val_array.shape[0])[:, None] val_array = np.concatenate( diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 7a5d1c30..7e62b9c0 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -6,7 +6,9 @@ import numpy as np import pandas as pd -from chainladder import options +from chainladder import ( + __dt64_dtype__ +) from chainladder.utils.utility_functions import num_to_nan from typing import TYPE_CHECKING @@ -535,7 +537,7 @@ def agg_func( # If axis is development, set the ddims to be the valuation date. if axis == 3 and obj.values.shape[axis] == 1 and len(obj.ddims) > 1: obj.ddims = pd.DatetimeIndex( - [self.valuation_date], dtype=options.DT64_DTYPE, freq=None + [self.valuation_date], dtype=__dt64_dtype__, freq=None ) obj._set_slicers() if auto_sparse: diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index f148f847..48a1f6b4 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -4,6 +4,9 @@ import copy import numpy as np +from chainladder import ( + __dt64_unit__ +) from chainladder.utils.utility_functions import date_delta_adjustment from pathlib import Path @@ -177,7 +180,7 @@ def test_date_delta_adjustment() -> None: expected = ( "2025-10-31 23:59:59.999999999" - if cl.options.DT64_UNIT == "ns" + if __dt64_unit__ == "ns" else "2025-10-31 23:59:59.999999" ) assert result == expected @@ -228,8 +231,6 @@ def test_options_defaults() -> None: assert options.ARRAY_BACKEND == "numpy" assert options.AUTO_SPARSE == True assert options.ARRAY_PRIORITY == ["dask", "sparse", "cupy", "numpy"] - assert isinstance(options.DT64_DTYPE, str) - assert isinstance(options.DT64_UNIT, str) assert isinstance(options.ULT_VAL, str) @@ -245,8 +246,6 @@ def test_get_option() -> None: assert cl.options.get_option('ARRAY_BACKEND') == cl.options.ARRAY_BACKEND assert cl.options.get_option('AUTO_SPARSE') == cl.options.AUTO_SPARSE assert cl.options.get_option('ARRAY_PRIORITY') == cl.options.ARRAY_PRIORITY - assert cl.options.get_option('DT64_DTYPE') == cl.options.DT64_DTYPE - assert cl.options.get_option('DT64_UNIT') == cl.options.DT64_UNIT assert cl.options.get_option('ULT_VAL') == cl.options.ULT_VAL @@ -268,39 +267,29 @@ def test_set_option_consistency() -> None: # Reset the options to default if the test fails. cl.options.set_option('ARRAY_BACKEND', original) - -def test_deprecated_array_backend() -> None: +def test_reset_single_option() -> None: """ - Trigger the deprecation warning on cl.array_backend() + Set an option and check its value, then reset it and check its value. Returns ------- None """ - original = cl.options.ARRAY_BACKEND - try: - with pytest.warns(FutureWarning): - cl.array_backend('sparse') - assert cl.options.ARRAY_BACKEND == 'sparse' - finally: - # Reset the options to default if the test fails. - cl.options.set_option('ARRAY_BACKEND', original) + cl.options.set_option('ARRAY_BACKEND', 'sparse') + assert cl.options.ARRAY_BACKEND == 'sparse' + # Return backend to original state. + cl.options.reset_option('ARRAY_BACKEND') + assert cl.options.ARRAY_BACKEND == 'numpy' -def test_deprecated_auto_sparse() -> None: +def test_reset_option_invalid() -> None: """ - Trigger the deprecation warning on cl.auto_sparse() + Supply in invalid option to cl.options.reset_option() and raise an error. Returns ------- None - """ - original = cl.options.AUTO_SPARSE - try: - with pytest.warns(FutureWarning): - cl.auto_sparse(False) - assert cl.options.AUTO_SPARSE == False - finally: - cl.options.set_option('AUTO_SPARSE', original) + with pytest.raises(ValueError): + cl.options.reset_option('NOT_A_REAL_OPTION') \ No newline at end of file diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index d15b0ba6..a124a37f 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -10,7 +10,10 @@ import numpy as np import pandas as pd -from chainladder import options +from chainladder import ( + __dt64_unit__, + __dt64_dtype__ +) from chainladder.utils.sparse import sp from io import StringIO from patsy import dmatrix # noqa @@ -648,7 +651,7 @@ def concat( if ignore_index and axis == 0: out.key_labels = ["Index"] out.valuation_date = pd.Series([obj.valuation_date for obj in objs]).max() - if out.ddims.dtype == options.DT64_DTYPE and type(out.ddims) == np.ndarray: + if out.ddims.dtype == __dt64_dtype__ and type(out.ddims) == np.ndarray: out.ddims = pd.DatetimeIndex(out.ddims) out._set_slicers() if sort: @@ -931,7 +934,7 @@ def date_delta_adjustment(date: str) -> str: res: str = str( pd.Timestamp(date) - \ - pd.Timedelta(1, unit=options.DT64_UNIT) + pd.Timedelta(1, unit=__dt64_unit__) ) return res \ No newline at end of file From 3daaacb77ab32e6ee4bcb6ab669ae67d5da096f5 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 26 May 2026 22:53:54 -0500 Subject: [PATCH 19/24] FIX: Apply Bugbot fix. --- chainladder/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index 7ff1b94c..b50b80dd 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -1,3 +1,4 @@ +import copy import numpy as np import pandas as pd from importlib.metadata import version @@ -37,7 +38,7 @@ def __init__(self): pd.Timedelta(1, unit=__dt64_unit__) ) # Store initial values as defaults. - self.defaults = {**vars(self)} + self.defaults = copy.deepcopy(vars(self)) def get_option(self, option: str) -> str | bool | list: """ From 17953c3348e0600253e7ca4a99253fcc424454d5 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Wed, 27 May 2026 08:21:19 -0500 Subject: [PATCH 20/24] FIX: Apply Bugbot fix. --- chainladder/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index b50b80dd..7922a226 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -1,4 +1,6 @@ import copy +from threading import settrace_all_threads + import numpy as np import pandas as pd from importlib.metadata import version @@ -38,7 +40,7 @@ def __init__(self): pd.Timedelta(1, unit=__dt64_unit__) ) # Store initial values as defaults. - self.defaults = copy.deepcopy(vars(self)) + self._defaults = copy.deepcopy({k: v for k, v in vars(self).items() if not k.startswith('_')}) def get_option(self, option: str) -> str | bool | list: """ @@ -91,16 +93,16 @@ def reset_option(self, option: str | None = None) -> None: """ - if option: + if option is not None: self._validate_option(option) - setattr(self, option, self.defaults[option]) + setattr(self, option, self._defaults[option]) else: self.__init__() def _validate_option(self, option: str) -> None: - if option not in self.defaults: - raise ValueError(f"Invalid option(s): {option}. Must be one of {list(self.defaults)}.") + if option not in self._defaults: + raise ValueError(f"Invalid option(s): {option}. Must be one of {list(self._defaults)}.") def describe_option(self, option: str) -> str: pass From 3f33270370efe0e41597facd169a2f51b04f4335 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Wed, 27 May 2026 08:32:24 -0500 Subject: [PATCH 21/24] FIX: Apply Bugbot fix. --- chainladder/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index 7922a226..1bf0c5f3 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -1,6 +1,4 @@ import copy -from threading import settrace_all_threads - import numpy as np import pandas as pd from importlib.metadata import version @@ -95,7 +93,7 @@ def reset_option(self, option: str | None = None) -> None: if option is not None: self._validate_option(option) - setattr(self, option, self._defaults[option]) + setattr(self, option, copy.deepcopy(self._defaults[option])) else: self.__init__() From d6b43156dc1151972a2fbd6e1e0b235fb2b0e60d Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Wed, 27 May 2026 08:46:48 -0500 Subject: [PATCH 22/24] DOCS: Add docstring, clean up test. --- chainladder/__init__.py | 14 ++++++++++++++ chainladder/utils/tests/test_utilities.py | 3 +-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index 1bf0c5f3..c3d60320 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -1,3 +1,17 @@ +""" +The chainladder-python package was built to be able to handle all of your actuarial reserving needs in python. +It consists of popular actuarial tools, such as triangle data manipulation, link ratios calculation, and +IBNR estimates using both deterministic and stochastic models. We build this package so you no longer have to rely +on outdated software and tools when performing actuarial pricing or reserving indications. + +This package strives to be minimalistic in needing its own API. The syntax mimics popular packages such as pandas for +data manipulation and scikit-learn for model construction. An actuary that is already familiar with these tools will be +able to pick up this package with ease. You will be able to save your mental energy for actual actuarial work. +""" +# 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 copy import numpy as np import pandas as pd diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 48a1f6b4..0aeffd27 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -258,14 +258,13 @@ def test_set_option_consistency() -> None: None """ - original = cl.options.ARRAY_BACKEND try: cl.options.set_option('ARRAY_BACKEND', 'sparse') assert cl.options.ARRAY_BACKEND == 'sparse' assert cl.options.get_option('ARRAY_BACKEND') == 'sparse' finally: # Reset the options to default if the test fails. - cl.options.set_option('ARRAY_BACKEND', original) + cl.options.reset_option('ARRAY_BACKEND') def test_reset_single_option() -> None: """ From 8030ac6c54b61f6a88a1e73565654481fea51ec8 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Wed, 27 May 2026 09:00:19 -0500 Subject: [PATCH 23/24] DOCS: Update docstring. --- chainladder/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index c3d60320..4f09953b 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -7,6 +7,9 @@ This package strives to be minimalistic in needing its own API. The syntax mimics popular packages such as pandas for data manipulation and scikit-learn for model construction. An actuary that is already familiar with these tools will be able to pick up this package with ease. You will be able to save your mental energy for actual actuarial work. + +The __init__.py file governs package configuration, including datetime datatypes and precision, backend and ultimate +valuation defaults, as well as package metadata such as version number. """ # 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 From 49b2a31bd97eff3079cdd335ffcb967fc48a405f Mon Sep 17 00:00:00 2001 From: henrydingliu <106109320+henrydingliu@users.noreply.github.com> Date: Wed, 27 May 2026 18:25:31 -0700 Subject: [PATCH 24/24] Friedland Chapter 6 and half of Chapter 7 (#837) first batch of deliverables for friedland reconstruction --- .github/CODEOWNERS | 2 +- docs/_toc.yml | 7 +- docs/friedland/chapter_6.rst | 307 +++++++ docs/friedland/chapter_7.rst | 738 +++++++++++++++ docs/friedland/chapter_8.ipynb | 853 ++++++++++++++++++ docs/friedland/index.rst | 8 + .../generated/chainladder.Development.rst | 2 +- pyproject.toml | 2 +- 8 files changed, 1915 insertions(+), 4 deletions(-) create mode 100644 docs/friedland/chapter_6.rst create mode 100644 docs/friedland/chapter_7.rst create mode 100644 docs/friedland/chapter_8.ipynb create mode 100644 docs/friedland/index.rst diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a48f15f8..56d5b788 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ ## code changes will send PR to following users -* @jbogaardt @kennethshsu @genedan @henrydingliu \ No newline at end of file +* @jbogaardt @kennethshsu @genedan @henrydingliu diff --git a/docs/_toc.yml b/docs/_toc.yml index 2db37224..6229fceb 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -30,6 +30,11 @@ parts: - file: user_guide/adjustments.ipynb - file: user_guide/workflow.ipynb - file: user_guide/utilities.ipynb + - chapters: + - file: friedland/index.rst + sections: + - file: friedland/chapter_6.rst + - file: friedland/chapter_7.rst - chapters: - file: gallery/index.md - chapters: @@ -45,4 +50,4 @@ parts: - file: library/contributing.md - file: library/roadmap.md - chapters: - - file: library/releases.md \ No newline at end of file + - file: library/releases.md diff --git a/docs/friedland/chapter_6.rst b/docs/friedland/chapter_6.rst new file mode 100644 index 00000000..e28c3e3d --- /dev/null +++ b/docs/friedland/chapter_6.rst @@ -0,0 +1,307 @@ +================================================================ +Chapter 6 - The Development Triangle as a Diagnostic Tool +================================================================ + +This chapter dives deeper into understanding the triangle. We will demonstrate how to manipulate a ``Triangle`` in the chainladder package by recreating the various tables in this chapter. + +.. doctest:: + + >>> import numpy as np + >>> import pandas as pd + >>> import chainladder as cl + >>> pd.set_option('display.max_columns', None) + >>> pd.set_option('display.width', 1000) + >>> tri = cl.load_sample('friedland_xyz_auto_bi') + +Table 1 - Summary of Earned Premium and Rate Changes +####################################################### + +We need to manually load this table of premium and rate change figures. Note that we are not loading the last two columns, as they can be derived based on premium and rate change. + +.. doctest:: + + >>> data = [ + ... [2002, 61183, 0], + ... [2003, 69175, .05], + ... [2004, 99322, .075], + ... [2005, 138151, .15], + ... [2006, 107578, .1], + ... [2007, 62438, -.2], + ... [2008, 47797, -.2] + ... ] + >>> columns = [ + ... 'Calendar Year', + ... 'Earned Premiums', + ... 'Rate Changes' + ... ] + >>> df_prem = pd.DataFrame(data, columns=columns) + >>> df_prem['Date'] = pd.to_datetime(df_prem['Calendar Year'].astype(int).astype(str) + '-01-01') # see discussion below on why we are doing this + >>> df_prem['On-level Factor'] = cl.parallelogram_olf(df_prem['Rate Changes'],df_prem['Date'],vertical_line = True).reset_index()['OLF'] + >>> df_prem['Cumulative Average Rate Level'] = ((1 + df_prem['Rate Changes']).product() / df_prem['On-level Factor'] - 1).round(decimals=3) + >>> df_prem['Premium Change'] = df_prem['Earned Premiums'].div(df_prem['Earned Premiums'].shift(1)).dropna() + >>> df_prem['Annual Exposure Change'] = (df_prem['Premium Change'] / (1 + df_prem['Rate Changes']) - 1).round(decimals=3) + >>> df_prem[['Calendar Year','Earned Premiums','Rate Changes','Cumulative Average Rate Level','Annual Exposure Change']] + Calendar Year Earned Premiums Rate Changes Cumulative Average Rate Level Annual Exposure Change + 0 2002 61183 0.000 0.000 NaN + 1 2003 69175 0.050 0.050 0.077 + 2 2004 99322 0.075 0.129 0.336 + 3 2005 138151 0.150 0.298 0.210 + 4 2006 107578 0.100 0.428 -0.292 + 5 2007 62438 -0.200 0.142 -0.275 + 6 2008 47797 -0.200 -0.086 -0.043 + +We take a different approach from Friedland to calculate the on-level factors in order to leverage the functionality available in the chainladder package. This approach is more direct since we are almost always after on-leveled premium. We are calculating Cumulative Average Rate Level merely to demonstrate parity with the text. + + To simplify the analysis in this chapter and in Part 3, assume that the rate changes in the above table represent the average earned rate level for the year + + -- Friedland, p84 + +What this assumption means in practice, is that the rate change figures are already on an earned basis. This tells us to do these two things as we call the utility function ``parallelogram_olf`` + +* setting the rate change dates to the beginning of the year +* specifying that ``vertial_line = True`` + +Note that this utility function is related to, but not to be confused with, the estimator ``ParallelogramOLF``. + +Table 2 - Reported Claim Development Triangle +################################################## + +We don't need the full triangle for this chapter. Here is how to filter a triangle based on accident year and development age. + +.. doctest:: + + >>> tri = tri[tri.origin >= '2002'][tri.development <= 84] + >>> tri['Reported Claims'] + 12 24 36 48 60 72 84 + 2002 12811.0 20370.0 26656.0 37667.0 44414.0 48701.0 48169.0 + 2003 9651.0 16995.0 30354.0 40594.0 44231.0 44373.0 NaN + 2004 16995.0 40180.0 58866.0 71707.0 70288.0 NaN NaN + 2005 28674.0 47432.0 70340.0 70655.0 NaN NaN NaN + 2006 27066.0 46783.0 48804.0 NaN NaN NaN NaN + 2007 19477.0 31732.0 NaN NaN NaN NaN NaN + 2008 18632.0 NaN NaN NaN NaN NaN NaN + +Table 3 - Paid Claim Development Triangle +################################################## + +.. doctest:: + + >>> tri['Paid Claims'] + 12 24 36 48 60 72 84 + 2002 2318.0 7932.0 13822.0 22095.0 31945.0 40629.0 44437.0 + 2003 1743.0 6240.0 12683.0 22892.0 34505.0 39320.0 NaN + 2004 2221.0 9898.0 25950.0 43439.0 52811.0 NaN NaN + 2005 3043.0 12219.0 27073.0 40026.0 NaN NaN NaN + 2006 3531.0 11778.0 22819.0 NaN NaN NaN NaN + 2007 3529.0 11865.0 NaN NaN NaN NaN NaN + 2008 3409.0 NaN NaN NaN NaN NaN NaN + +Table 4 - Ratio of Reported Claims to Earned Premium +####################################################### + +To divide losses by premium, we need to turn premium from a ``Series`` into a ``Triangle``. Here is a nifty trick to do that. + +.. doctest:: + + >>> tri['Reported Claims'] * 0 + df_prem["Earned Premiums"] + 12 24 36 48 60 72 84 + 2002 61183.0 69175.0 99322.0 138151.0 107578.0 62438.0 47797.0 + 2003 61183.0 69175.0 99322.0 138151.0 107578.0 62438.0 NaN + 2004 61183.0 69175.0 99322.0 138151.0 107578.0 NaN NaN + 2005 61183.0 69175.0 99322.0 138151.0 NaN NaN NaN + 2006 61183.0 69175.0 99322.0 NaN NaN NaN NaN + 2007 61183.0 69175.0 NaN NaN NaN NaN NaN + 2008 61183.0 NaN NaN NaN NaN NaN NaN + +That didn't quite work. We want each accident year to have the same premium. The reason why this is happening is that development period is the last dimension (i.e. down a row in a triangle), and origin period (accident year) is the second-last dimension (i.e. rows down a column). Any 1-D collection is automatically assumed to be values down a row, rather than rows down a column (a little unintuitive, as a ``pandas.DataFrame`` is displayed vertically.). So we need a little help in ``numpy`` land to rectify the problem. + +.. doctest:: + + >>> prem_tri = tri['Reported Claims'] * 0 + df_prem["Earned Premiums"].to_numpy().reshape(-1,1) + >>> prem_tri + 12 24 36 48 60 72 84 + 2002 61183.0 61183.0 61183.0 61183.0 61183.0 61183.0 61183.0 + 2003 69175.0 69175.0 69175.0 69175.0 69175.0 69175.0 NaN + 2004 99322.0 99322.0 99322.0 99322.0 99322.0 NaN NaN + 2005 138151.0 138151.0 138151.0 138151.0 NaN NaN NaN + 2006 107578.0 107578.0 107578.0 NaN NaN NaN NaN + 2007 62438.0 62438.0 NaN NaN NaN NaN NaN + 2008 47797.0 NaN NaN NaN NaN NaN NaN + +Now we can divide two triangles seamlessly + +.. doctest:: + + >>> (tri['Reported Claims'] / prem_tri).round(decimals=3) + 12 24 36 48 60 72 84 + 2002 0.209 0.333 0.436 0.616 0.726 0.796 0.787 + 2003 0.140 0.246 0.439 0.587 0.639 0.641 NaN + 2004 0.171 0.405 0.593 0.722 0.708 NaN NaN + 2005 0.208 0.343 0.509 0.511 NaN NaN NaN + 2006 0.252 0.435 0.454 NaN NaN NaN NaN + 2007 0.312 0.508 NaN NaN NaN NaN NaN + 2008 0.390 NaN NaN NaN NaN NaN NaN + +Table 5 - Ratio of Reported Claims to On-Level Earned Premium +################################################################ + + We calculate the on-level premium using the average rate level changes by year and restating the earned premium for each year as if it was written at the 2008 rate level. + + -- Friedland, p84 + +We don't need to follow Friedland's approach here, as we already got on-level factors. + +.. doctest:: + + >>> ol_prem_tri = prem_tri * df_prem["On-level Factor"].to_numpy().reshape(-1,1) + >>> ol_prem_tri + 12 24 36 48 60 72 84 + 2002 55911.227988 55911.227988 55911.227988 55911.227988 55911.227988 55911.227988 55911.227988 + 2003 60204.386000 60204.386000 60204.386000 60204.386000 60204.386000 60204.386000 NaN + 2004 80411.091200 80411.091200 80411.091200 80411.091200 80411.091200 NaN NaN + 2005 97258.304000 97258.304000 97258.304000 97258.304000 NaN NaN NaN + 2006 68849.920000 68849.920000 68849.920000 NaN NaN NaN NaN + 2007 49950.400000 49950.400000 NaN NaN NaN NaN NaN + 2008 47797.000000 NaN NaN NaN NaN NaN NaN + +And the actual Table 5 is straight-forward. + +.. doctest:: + + >>> (tri['Reported Claims'] / ol_prem_tri).round(decimals=3) + 12 24 36 48 60 72 84 + 2002 0.229 0.364 0.477 0.674 0.794 0.871 0.862 + 2003 0.160 0.282 0.504 0.674 0.735 0.737 NaN + 2004 0.211 0.500 0.732 0.892 0.874 NaN NaN + 2005 0.295 0.488 0.723 0.726 NaN NaN NaN + 2006 0.393 0.679 0.709 NaN NaN NaN NaN + 2007 0.390 0.635 NaN NaN NaN NaN NaN + 2008 0.390 NaN NaN NaN NaN NaN NaN + +Table 6 - Ratio of Paid Claims-to-Reported Claims +####################################################### + +.. doctest:: + + >>> (tri['Paid Claims'] / tri['Reported Claims']).round(decimals=3) + 12 24 36 48 60 72 84 + 2002 0.181 0.389 0.519 0.587 0.719 0.834 0.923 + 2003 0.181 0.367 0.418 0.564 0.780 0.886 NaN + 2004 0.131 0.246 0.441 0.606 0.751 NaN NaN + 2005 0.106 0.258 0.385 0.566 NaN NaN NaN + 2006 0.130 0.252 0.468 NaN NaN NaN NaN + 2007 0.181 0.374 NaN NaN NaN NaN NaN + 2008 0.183 NaN NaN NaN NaN NaN NaN + +Table 7 - Ratio of Paid Claims to Earned Premium +####################################################### + +.. doctest:: + + >>> (tri['Paid Claims'] / ol_prem_tri).round(decimals=3) + 12 24 36 48 60 72 84 + 2002 0.041 0.142 0.247 0.395 0.571 0.727 0.795 + 2003 0.029 0.104 0.211 0.380 0.573 0.653 NaN + 2004 0.028 0.123 0.323 0.540 0.657 NaN NaN + 2005 0.031 0.126 0.278 0.412 NaN NaN NaN + 2006 0.051 0.171 0.331 NaN NaN NaN NaN + 2007 0.071 0.238 NaN NaN NaN NaN NaN + 2008 0.071 NaN NaN NaN NaN NaN NaN + +Table 8 - Reported Claim Count Development Triangle +####################################################### + +The count data is stored under a different name. + +.. doctest:: + + >>> tri_cnt = cl.load_sample('friedland_xyz_freq_sev') + >>> tri_cnt = tri_cnt[tri_cnt.origin >= '2002'][tri_cnt.development <= 84] + >>> tri_cnt["Reported Claim Counts"] + 12 24 36 48 60 72 84 + 2002 1342.0 1514.0 1548.0 1557.0 1549.0 1552.0 1554.0 + 2003 1373.0 1616.0 1630.0 1626.0 1629.0 1629.0 NaN + 2004 1932.0 2168.0 2234.0 2249.0 2258.0 NaN NaN + 2005 2067.0 2293.0 2367.0 2390.0 NaN NaN NaN + 2006 1473.0 1645.0 1657.0 NaN NaN NaN NaN + 2007 1192.0 1264.0 NaN NaN NaN NaN NaN + 2008 1036.0 NaN NaN NaN NaN NaN NaN + +Table 9 - Closed Claim Count Development Triangle +####################################################### + +.. doctest:: + + >>> tri_cnt["Closed Claim Counts"] + 12 24 36 48 60 72 84 + 2002 203.0 607.0 841.0 1089.0 1327.0 1464.0 1523.0 + 2003 181.0 614.0 941.0 1263.0 1507.0 1568.0 NaN + 2004 235.0 848.0 1442.0 1852.0 2029.0 NaN NaN + 2005 295.0 1119.0 1664.0 1946.0 NaN NaN NaN + 2006 307.0 906.0 1201.0 NaN NaN NaN NaN + 2007 329.0 791.0 NaN NaN NaN NaN NaN + 2008 276.0 NaN NaN NaN NaN NaN NaN + +Table 10 - Ratio of Closed-to-Reported Claim Counts +####################################################### + +.. doctest:: + + >>> (tri_cnt["Closed Claim Counts"] / tri_cnt["Reported Claim Counts"]).round(decimals=3) + 12 24 36 48 60 72 84 + 2002 0.151 0.401 0.543 0.699 0.857 0.943 0.98 + 2003 0.132 0.380 0.577 0.777 0.925 0.963 NaN + 2004 0.122 0.391 0.645 0.823 0.899 NaN NaN + 2005 0.143 0.488 0.703 0.814 NaN NaN NaN + 2006 0.208 0.551 0.725 NaN NaN NaN NaN + 2007 0.276 0.626 NaN NaN NaN NaN NaN + 2008 0.266 NaN NaN NaN NaN NaN NaN + +Table 12 – Average Reported Claim Development Triangle +####################################################### + +The losses are stored in the thousands. When calcualting severity, we need to multiply back the thousand. + +.. doctest:: + + >>> (tri["Reported Claims"] / tri_cnt["Reported Claim Counts"] * 1000).round(decimals=0) + 12 24 36 48 60 72 84 + 2002 9546.0 13454.0 17220.0 24192.0 28673.0 31380.0 30997.0 + 2003 7029.0 10517.0 18622.0 24966.0 27152.0 27239.0 NaN + 2004 8797.0 18533.0 26350.0 31884.0 31128.0 NaN NaN + 2005 13872.0 20686.0 29717.0 29563.0 NaN NaN NaN + 2006 18375.0 28440.0 29453.0 NaN NaN NaN NaN + 2007 16340.0 25104.0 NaN NaN NaN NaN NaN + 2008 17985.0 NaN NaN NaN NaN NaN NaN + +We see some very slight differences with the table in the text, likely due to rounding (the losses were rounded to the nearest thousand). + +Table 13 – Average Paid Claim Development Triangle +####################################################### + +.. doctest:: + + >>> (tri["Paid Claims"] / tri_cnt["Closed Claim Counts"] * 1000).round(decimals=0) + 12 24 36 48 60 72 84 + 2002 11419.0 13068.0 16435.0 20289.0 24073.0 27752.0 29177.0 + 2003 9630.0 10163.0 13478.0 18125.0 22896.0 25077.0 NaN + 2004 9451.0 11672.0 17996.0 23455.0 26028.0 NaN NaN + 2005 10315.0 10920.0 16270.0 20568.0 NaN NaN NaN + 2006 11502.0 13000.0 19000.0 NaN NaN NaN NaN + 2007 10726.0 15000.0 NaN NaN NaN NaN NaN + 2008 12351.0 NaN NaN NaN NaN NaN NaN + +Table 14 – Average Case Outstanding Development Triangle +######################################################### + +.. doctest:: + + >>> ((tri["Reported Claims"] - tri["Paid Claims"]) / (tri_cnt["Reported Claim Counts"] - tri_cnt["Closed Claim Counts"]) * 1000).round(decimals=0) + 12 24 36 48 60 72 84 + 2002 9212.0 13713.0 18153.0 33274.0 56167.0 91727.0 120387.0 + 2003 6634.0 10734.0 25647.0 48766.0 79721.0 82836.0 NaN + 2004 8706.0 22941.0 41561.0 71204.0 76319.0 NaN NaN + 2005 14464.0 29994.0 61546.0 68984.0 NaN NaN NaN + 2006 20184.0 47368.0 56985.0 NaN NaN NaN NaN + 2007 18480.0 42002.0 NaN NaN NaN NaN NaN + 2008 20030.0 NaN NaN NaN NaN NaN NaN diff --git a/docs/friedland/chapter_7.rst b/docs/friedland/chapter_7.rst new file mode 100644 index 00000000..64f6f1b1 --- /dev/null +++ b/docs/friedland/chapter_7.rst @@ -0,0 +1,738 @@ +================================================================ +Chapter 7 - Development Technique +================================================================ + + The development technique, also known as the chain ladder technique, is one of the most frequently used methodologies for estimating unpaid claims. + + -- Friedland, p84 + +This chapter covers the foundational development/chainladder method. In the chainladder package, this is implemented in the ``Development`` estimator. + +.. doctest:: + + >>> import numpy as np + >>> import pandas as pd + >>> import chainladder as cl + >>> pd.set_option('display.max_columns', None) + >>> pd.set_option('display.width', 1000) + +Exhibit I Sheet 1 p106 +########################## + +Diving straight into Exhibit 1. + +PART 1 - Data Triangle +----------------------- + +We have already imported the necessary packages loading the ``Triangle`` at the top of p106. Let's take a look at the ``Triangle`` we just loaded. + +.. doctest:: + :hide: + + >>> tri = cl.load_sample('friedland_us_industry_auto') + >>> tri['Reported Claims'] + 12 24 36 48 60 72 84 96 108 120 + 1998 37017487.0 43169009.0 45568919.0 46784558.0 47337318.0 47533264.0 47634419.0 47689655.0 47724678.0 47742304.0 + 1999 38954484.0 46045718.0 48882924.0 50219672.0 50729292.0 50926779.0 51069285.0 51163540.0 51185767.0 NaN + 2000 41155776.0 49371478.0 52358476.0 53780322.0 54303086.0 54582950.0 54742188.0 54837929.0 NaN NaN + 2001 42394069.0 50584112.0 53704296.0 55150118.0 55895583.0 56156727.0 56299562.0 NaN NaN NaN + 2002 44755243.0 52971643.0 56102312.0 57703851.0 58363564.0 58592712.0 NaN NaN NaN NaN + 2003 45163102.0 52497731.0 55468551.0 57015411.0 57565344.0 NaN NaN NaN NaN NaN + 2004 45417309.0 52640322.0 55553673.0 56976657.0 NaN NaN NaN NaN NaN NaN + 2005 46360869.0 53790061.0 56786410.0 NaN NaN NaN NaN NaN NaN NaN + 2006 46582684.0 54641339.0 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 48853563.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN + +.. code-block:: + + >>> tri = cl.load_sample('friedland_us_industry_auto') + +.. raw:: html + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1224364860728496108120
199837,017,48743,169,00945,568,91946,784,55847,337,31847,533,26447,634,41947,689,65547,724,67847,742,304
199938,954,48446,045,71848,882,92450,219,67250,729,29250,926,77951,069,28551,163,54051,185,767
200041,155,77649,371,47852,358,47653,780,32254,303,08654,582,95054,742,18854,837,929
200142,394,06950,584,11253,704,29655,150,11855,895,58356,156,72756,299,562
200244,755,24352,971,64356,102,31257,703,85158,363,56458,592,712
200345,163,10252,497,73155,468,55157,015,41157,565,344
200445,417,30952,640,32255,553,67356,976,657
200546,360,86953,790,06156,786,410
200646,582,68454,641,339
200748,853,563
+ +PART 2 - Age-to-Age Factors +---------------------------- + +To calculate age-to-age factors, use the ``age-to-age`` attribute of the ``Triangle``. + +.. doctest:: + + >>> tri['Reported Claims'].age_to_age.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + 1998 1.166 1.056 1.027 1.012 1.004 1.002 1.001 1.001 1.0 + 1999 1.182 1.062 1.027 1.010 1.004 1.003 1.002 1.000 NaN + 2000 1.200 1.061 1.027 1.010 1.005 1.003 1.002 NaN NaN + 2001 1.193 1.062 1.027 1.014 1.005 1.003 NaN NaN NaN + 2002 1.184 1.059 1.029 1.011 1.004 NaN NaN NaN NaN + 2003 1.162 1.057 1.028 1.010 NaN NaN NaN NaN NaN + 2004 1.159 1.055 1.026 NaN NaN NaN NaN NaN NaN + 2005 1.160 1.056 NaN NaN NaN NaN NaN NaN NaN + 2006 1.173 NaN NaN NaN NaN NaN NaN NaN NaN + +PART 3 - Average Age-to-Age Factors +------------------------------------ + +To calculate the average age-to-age factors, we will use the ``Development`` estimator to ``fit_transform`` the original ``Triangle``. This calculates the averages but also preserves the ability to apply other estimators later. The specific choices of average paramters (n_period, etc.) are provided to ``Development``. The attribute for the calculated average age-to-age factors is the ``ldf_``. + +.. doctest:: + + # Simple Average + # Latest 5 + >>> reported_simple_5 = cl.Development(n_periods=5, average='simple').fit_transform(tri['Reported Claims']) + >>> reported_simple_5.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.168 1.058 1.027 1.011 1.004 1.003 1.002 1.001 1.0 + + # Latest 3 + >>> reported_simple_3 = cl.Development(n_periods=3, average='simple').fit_transform(tri['Reported Claims']) + >>> reported_simple_3.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.164 1.056 1.027 1.012 1.005 1.003 1.002 1.001 1.0 + + # Medial Average + # Latest 5x1 + >>> reported_medial_5x1 = cl.Development(n_periods=5, average='simple',drop_high = 1, drop_low = 1).fit_transform(tri['Reported Claims']) + >>> reported_medial_5x1.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.165 1.057 1.027 1.01 1.004 1.003 1.002 1.001 1.0 + + # Volume-weighted Average + # Latest 5 + >>> reported_volume_5 = cl.Development(n_periods=5, average='volume').fit_transform(tri['Reported Claims']) + >>> reported_volume_5.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.168 1.058 1.027 1.011 1.004 1.003 1.002 1.001 1.0 + + # Latest 3 + >>> reported_volume_3 = cl.Development(n_periods=3, average='volume').fit_transform(tri['Reported Claims']) + >>> reported_volume_3.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.164 1.056 1.027 1.012 1.005 1.003 1.002 1.001 1.0 + + # Geometric Average + # Latest 4 + >>> reported_geometric_4 = cl.Development(n_periods=4, average='geometric').fit_transform(tri['Reported Claims']) + >>> reported_geometric_4.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.164 1.057 1.027 1.011 1.004 1.003 1.002 1.001 1.0 + +PART 4 - Selected Age-to-Age Factors +-------------------------------------- + +For the prior selected, we need to create a hard-coded pattern, using the ``DevelopmentConstant`` estimator. In a production workflow, you can save the development pattern from the prior analysis and load for reference in a subsequent analysis. + +We will also be using the ``TailConstant`` estimator to add a tail factor to selected development patterns. We add the tail factor to a transformed ``Triangle`` (i.e. applying ``fit_transforme`` of the ``Development`` estimator to a ``Triangle``) by using ``fit_transform`` once again. + +.. doctest:: + + # Prior Selected + >>> reported_prior_method = cl.DevelopmentConstant( + ... patterns = { + ... 12:1.16, + ... 24:1.057, + ... 36:1.028, + ... 48:1.012, + ... 60:1.005, + ... 72:1.003, + ... 84:1.001, + ... 96:1.001, + ... 108:1.000 + ... }, + ... style='ldf' + ... ) + >>> reported_prior_ft = reported_prior_method.fit_transform(tri['Reported Claims']) + >>> reported_tail_method = cl.TailConstant( + ... tail = 1, + ... projection_period = 0 + ... ) + >>> reported_prior_selected = reported_tail_method.fit_transform(reported_prior_ft) + >>> reported_prior_selected.ldf_ + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + (All) 1.16 1.057 1.028 1.012 1.005 1.003 1.001 1.001 1.0 1.0 + +Next we can select some factors. We can also reuse the `TailConstant` from the previous step. This is fairly common in practice, as tail factors are selected less frequently than the development pattern itself, so need to be carried from analysis to analysis. + +.. doctest:: + + # Selected + >>> reported_selected_pattern = reported_tail_method.fit_transform(reported_simple_3) + >>> reported_selected_pattern.ldf_.round(decimals=3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + (All) 1.164 1.056 1.027 1.012 1.005 1.003 1.002 1.001 1.0 1.0 + +The Development estimator has a ``cdf_`` attribute that will automatically multiply age-to-age factors cumulatively into age-to-ultimate factors. The Friedland text uses the rounded LDF to calculate CDF. This can be achieved in this package by using the ``incr_to_cum()`` method of the rounded age-to-age factors. + +.. doctest:: + + # CDF to Ultimate + # First without rounding + >>> reported_selected_pattern.cdf_ + 12-Ult 24-Ult 36-Ult 48-Ult 60-Ult 72-Ult 84-Ult 96-Ult 108-Ult 120-Ult + (All) 1.289977 1.108139 1.049493 1.021554 1.009908 1.0053 1.00254 1.000954 1.000369 1.0 + + # Then with rounding + >>> reported_selected_cdf = reported_selected_pattern.ldf_.round(decimals = 3).incr_to_cum().round(decimals = 3) + >>> reported_selected_cdf + 12-Ult 24-Ult 36-Ult 48-Ult 60-Ult 72-Ult 84-Ult 96-Ult 108-Ult 120-Ult + (All) 1.292 1.11 1.051 1.023 1.011 1.006 1.003 1.001 1.0 1.0 + +To calculate % reported, we will use ``Triangle`` manipulation from Chapter 5 directly on the development pattern (which is also a ``Triangle``). + +.. doctest:: + + # Percent Reported + >>> (1 / reported_selected_cdf).round(decimals = 3) + 12-Ult 24-Ult 36-Ult 48-Ult 60-Ult 72-Ult 84-Ult 96-Ult 108-Ult 120-Ult + (All) 0.774 0.901 0.951 0.978 0.989 0.994 0.997 0.999 1.0 1.0 + +Exhibit I Sheet 2 p107 +########################## + +Moving onto the next page, all the calculations are identical to the previous page. We will manually repeat the same code. In a production workflow, commonly repeated methods and selections can be streamlined, which we will demonstrate in Exhibit II. + +PART 1 - Data Triangle +----------------------- + +.. doctest:: + + >>> tri['Paid Claims'] + 12 24 36 48 60 72 84 96 108 120 + 1998 18539254.0 33231039.0 40062008.0 43892039.0 45896535.0 46765422.0 47221322.0 47446877.0 47555456.0 47644187.0 + 1999 20410193.0 36090684.0 43259402.0 47159241.0 49208532.0 50162043.0 50625757.0 50878808.0 51000534.0 NaN + 2000 22120843.0 38976014.0 46389282.0 50562385.0 52735280.0 53740101.0 54284334.0 54533225.0 NaN NaN + 2001 22992259.0 40096198.0 47767835.0 52093916.0 54363436.0 55378801.0 55878421.0 NaN NaN NaN + 2002 24092782.0 41795313.0 49903803.0 54352884.0 56754376.0 57807215.0 NaN NaN NaN NaN + 2003 24084451.0 41399612.0 49070332.0 53584201.0 55930654.0 NaN NaN NaN NaN NaN + 2004 24369770.0 41489863.0 49236678.0 53774672.0 NaN NaN NaN NaN NaN NaN + 2005 25100697.0 42702229.0 50644994.0 NaN NaN NaN NaN NaN NaN NaN + 2006 25608776.0 43606497.0 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 27229969.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN + +PART 2 - Age-to-Age Factors +---------------------------- + +.. doctest:: + + >>> tri['Paid Claims'].age_to_age.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + 1998 1.792 1.206 1.096 1.046 1.019 1.010 1.005 1.002 1.002 + 1999 1.768 1.199 1.090 1.043 1.019 1.009 1.005 1.002 NaN + 2000 1.762 1.190 1.090 1.043 1.019 1.010 1.005 NaN NaN + 2001 1.744 1.191 1.091 1.044 1.019 1.009 NaN NaN NaN + 2002 1.735 1.194 1.089 1.044 1.019 NaN NaN NaN NaN + 2003 1.719 1.185 1.092 1.044 NaN NaN NaN NaN NaN + 2004 1.703 1.187 1.092 NaN NaN NaN NaN NaN NaN + 2005 1.701 1.186 NaN NaN NaN NaN NaN NaN NaN + 2006 1.703 NaN NaN NaN NaN NaN NaN NaN NaN + +PART 3 - Average Age-to-Age Factors +------------------------------------ + +.. doctest:: + + # Simple Average + # Latest 5 + >>> paid_simple_5 = cl.Development(n_periods=5, average='simple').fit_transform(tri['Paid Claims']) + >>> paid_simple_5.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.712 1.189 1.091 1.044 1.019 1.01 1.005 1.002 1.002 + + # Latest 3 + >>> paid_simple_3 = cl.Development(n_periods=3, average='simple').fit_transform(tri['Paid Claims']) + >>> paid_simple_3.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.702 1.186 1.091 1.044 1.019 1.009 1.005 1.002 1.002 + + # Medial Average + # Latest 5x1 + >>> paid_medial_5x1 = cl.Development(n_periods=5, average='simple',drop_high = 1, drop_low = 1).fit_transform(tri['Paid Claims']) + >>> paid_medial_5x1.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.708 1.188 1.091 1.044 1.019 1.009 1.005 1.002 1.002 + + # Volume-weighted Average + # Latest 5 + >>> paid_volume_5 = cl.Development(n_periods=5, average='volume').fit_transform(tri['Paid Claims']) + >>> paid_volume_5.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.712 1.189 1.091 1.044 1.019 1.01 1.005 1.002 1.002 + + # Latest 3 + >>> paid_volume_3 = cl.Development(n_periods=3, average='volume').fit_transform(tri['Paid Claims']) + >>> paid_volume_3.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.702 1.186 1.091 1.044 1.019 1.009 1.005 1.002 1.002 + + # Geometric Average + # Latest 4 + >>> paid_geometric_4 = cl.Development(n_periods=4, average='geometric').fit_transform(tri['Paid Claims']) + >>> paid_geometric_4.ldf_.round(decimals = 3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 + (All) 1.706 1.188 1.091 1.044 1.019 1.01 1.005 1.002 1.002 + +PART 4 - Selected Age-to-Age Factors +-------------------------------------- + +.. doctest:: + + # Prior Selected + >>> paid_prior_method = cl.DevelopmentConstant( + ... patterns = { + ... 12:1.707, + ... 24:1.189, + ... 36:1.091, + ... 48:1.044, + ... 60:1.019, + ... 72:1.01, + ... 84:1.005, + ... 96:1.003, + ... 108:1.001 + ... }, + ... style='ldf' + ... ) + >>> paid_prior_ft = paid_prior_method.fit_transform(tri['Paid Claims']) + >>> paid_tail_method = cl.TailConstant( + ... tail = 1.002, + ... projection_period = 0 + ... ) + >>> paid_prior_selected = paid_tail_method.fit_transform(paid_prior_ft) + >>> paid_prior_selected.ldf_ + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + (All) 1.707 1.189 1.091 1.044 1.019 1.01 1.005 1.003 1.001 1.002 + + # Selected + >>> paid_selected_pattern = paid_tail_method.fit_transform(paid_simple_3) + >>> paid_selected_pattern.ldf_.round(decimals=3) + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + (All) 1.702 1.186 1.091 1.044 1.019 1.009 1.005 1.002 1.002 1.002 + + # CDF to Ultimate + >>> paid_selected_cdf = paid_selected_pattern.ldf_.round(decimals = 3).incr_to_cum().round(decimals = 3) + >>> paid_selected_cdf + 12-Ult 24-Ult 36-Ult 48-Ult 60-Ult 72-Ult 84-Ult 96-Ult 108-Ult 120-Ult + (All) 2.39 1.404 1.184 1.085 1.04 1.02 1.011 1.006 1.004 1.002 + + # Percent Reported + >>> (1 / paid_selected_cdf).round(decimals = 3) + 12-Ult 24-Ult 36-Ult 48-Ult 60-Ult 72-Ult 84-Ult 96-Ult 108-Ult 120-Ult + (All) 0.418 0.712 0.845 0.922 0.962 0.98 0.989 0.994 0.996 0.998 + +Exhibit I Sheet 3 p108 +########################## + +This is a common report layout for reserving analyses. Some ``Pandas`` manipulation is needed to retrieve all the figures from the transformed ``Triangle`` objects and achieve the tabular look. We will create a function to reuse the manipulation throughout this chapter. + +.. doctest:: + + >>> def development_summary(reported: cl.Triangle(), paid: cl.Triangle()) -> pd.DataFrame(): + ... output = pd.DataFrame() # initializing a DataFrame + ... output["Reported Claims"] = reported.latest_diagonal.to_frame(origin_as_datetime=False) # using a vector of losses to anchor the exhibit index + ... age = reported.development.iloc[::-1] # flipping the age order + ... age.index = output.index # forcing the index to match + ... output['Age'] = age + ... output = output[['Age','Reported Claims']] # reordering the columns + ... output ['Paid Claims'] = paid.latest_diagonal.to_frame(origin_as_datetime=False) # adding in paid losses + ... reported_cdf = reported.cdf_.T # transposing the CDF + ... reported_cdf.index = output.index[::-1] # forcing the index to match + ... output["Reported CDF"] = reported_cdf + ... paid_cdf = paid.cdf_.T + ... paid_cdf.index = output.index[::-1] + ... output["Paid CDF"] = paid_cdf + ... output["Reported Ultimate"] = cl.Chainladder().fit(reported).ultimate_.to_frame(origin_as_datetime=False) # using the Chainladder estimator to return the ultimate + ... output["Paid Ultimate"] = cl.Chainladder().fit(paid).ultimate_.to_frame(origin_as_datetime=False) + ... return output + >>> exhibit = development_summary(reported_selected_pattern,paid_selected_pattern) + >>> exhibit + Age Reported Claims Paid Claims Reported CDF Paid CDF Reported Ultimate Paid Ultimate + 1998 120 47742304.0 47644187.0 1.000000 1.002000 4.774230e+07 4.773948e+07 + 1999 108 51185767.0 51000534.0 1.000369 1.003870 5.120467e+07 5.119788e+07 + 2000 96 54837929.0 54533225.0 1.000954 1.006219 5.489024e+07 5.487237e+07 + 2001 84 56299562.0 55878421.0 1.002540 1.011036 5.644257e+07 5.649507e+07 + 2002 72 58592712.0 57807215.0 1.005300 1.020604 5.890327e+07 5.899830e+07 + 2003 60 57565344.0 55930654.0 1.009908 1.039752 5.813573e+07 5.815399e+07 + 2004 48 56976657.0 53774672.0 1.021554 1.085341 5.820476e+07 5.836386e+07 + 2005 36 56786410.0 50644994.0 1.049493 1.184218 5.959697e+07 5.997474e+07 + 2006 24 54641339.0 43606497.0 1.108139 1.404485 6.055018e+07 6.124466e+07 + 2007 12 48853563.0 27229969.0 1.289977 2.390688 6.301997e+07 6.509837e+07 + +Unfortunately this does not match the table from the text, due to rounding. We will construct a separate, rounded exhibit to reconcile to the text. + +.. doctest:: + + >>> def rounded_development_summary(reported: cl.Triangle(), paid: cl.Triangle()) -> pd.DataFrame(): + ... output = pd.DataFrame() # initializing a DataFrame + ... output["Reported Claims"] = reported.latest_diagonal.to_frame(origin_as_datetime=False) # using a vector of losses to anchor the exhibit index + ... age = reported.development.iloc[::-1] # flipping the age order + ... age.index = output.index # forcing the index to match + ... output['Age'] = age + ... output = output[['Age','Reported Claims']] # reordering the columns + ... output ['Paid Claims'] = paid.latest_diagonal.to_frame(origin_as_datetime=False) # adding in paid losses + ... reported_cdf = reported.ldf_.round(decimals = 3).incr_to_cum().round(decimals = 3).T + ... reported_cdf.index = output.index[::-1] + ... output["Reported CDF"] = reported_cdf + ... paid_cdf = paid.ldf_.round(decimals = 3).incr_to_cum().round(decimals = 3).T + ... paid_cdf.index = output.index[::-1] + ... output["Paid CDF"] = paid_cdf + ... output["Reported Ultimate"] = (output['Reported Claims'] * output["Reported CDF"]).round(decimals = 0) # taking a short cut to calculate the ultimate without using Chainladder + ... output["Paid Ultimate"] = (output['Paid Claims'] * output["Paid CDF"]).round(decimals = 0) + ... return output + >>> rounded_exhibit = rounded_development_summary(reported_selected_pattern,paid_selected_pattern) + >>> rounded_exhibit[['Reported CDF','Paid CDF','Reported Ultimate','Paid Ultimate']] # only displaying the rounded columns + Reported CDF Paid CDF Reported Ultimate Paid Ultimate + 1998 1.000 1.002 47742304.0 47739475.0 + 1999 1.000 1.004 51185767.0 51204536.0 + 2000 1.001 1.006 54892767.0 54860424.0 + 2001 1.003 1.011 56468461.0 56493084.0 + 2002 1.006 1.020 58944268.0 58963359.0 + 2003 1.011 1.040 58198563.0 58167880.0 + 2004 1.023 1.085 58287120.0 58345519.0 + 2005 1.051 1.184 59682517.0 59963673.0 + 2006 1.110 1.404 60651886.0 61223522.0 + 2007 1.292 2.390 63118803.0 65079626.0 + +Exhibit I Sheet 4 p109 +########################## + +This is another common report layout for reserving analyses. The manipulation here are more straight-forward that the previous exhibit. + +.. doctest:: + + >>> def unpaid_summary(dev_sum: pd.DataFrame()) -> pd.DataFrame(): + ... output = dev_sum.loc[:,['Reported Claims','Paid Claims','Reported Ultimate','Paid Ultimate']] + ... output['Case Outstanding'] = output['Reported Claims'] - output['Paid Claims'] + ... output['Reported Method IBNR'] = output['Reported Ultimate'] - output['Reported Claims'] + ... output['Paid Method IBNR'] = output['Paid Ultimate'] - output['Reported Claims'] + ... output['Reported Method Unpaid'] = output['Reported Method IBNR'] + output['Case Outstanding'] + ... output['Paid Method Unpaid'] = output['Paid Method IBNR'] + output['Case Outstanding'] + ... return output + >>> unpaid_exhibit = unpaid_summary(rounded_exhibit) + >>> unpaid_exhibit[['Case Outstanding','Reported Method IBNR','Paid Method IBNR','Reported Method Unpaid','Paid Method Unpaid']] + Case Outstanding Reported Method IBNR Paid Method IBNR Reported Method Unpaid Paid Method Unpaid + 1998 98117.0 0.0 -2829.0 98117.0 95288.0 + 1999 185233.0 0.0 18769.0 185233.0 204002.0 + 2000 304704.0 54838.0 22495.0 359542.0 327199.0 + 2001 421141.0 168899.0 193522.0 590040.0 614663.0 + 2002 785497.0 351556.0 370647.0 1137053.0 1156144.0 + 2003 1634690.0 633219.0 602536.0 2267909.0 2237226.0 + 2004 3201985.0 1310463.0 1368862.0 4512448.0 4570847.0 + 2005 6141416.0 2896107.0 3177263.0 9037523.0 9318679.0 + 2006 11034842.0 6010547.0 6582183.0 17045389.0 17617025.0 + 2007 21623594.0 14265240.0 16226063.0 35888834.0 37849657.0 + +Exhibit II Sheet 1 p110 +################################ + +Now that we have walked through an analysis step by step, let's introduce some scaling by streamlining the entire exhibit into single function. + +.. doctest:: + + >>> def dev_exhibit(tri: cl.Triangle, avg_params: dict[str,int], selected_avg: str, tail: float) -> dict[cl.Triangle()]: + ... print('PART 1 - Data Triangle') + ... print(tri) + ... print('PART 2 - Age-to-Age Factors') + ... print(tri.age_to_age) + ... devs = {} + ... print('PART 3 - Average Age-to-Age Factor') + ... for k,v in avg_params.items(): + ... devs[k] = cl.Development(**v).fit_transform(tri) + ... def print_ldfs(ldf_dict:dict[cl.Triangle()]): + ... print(pd.concat([v.to_frame().rename(index={'(All)':k}) for k,v in ldf_dict.items()])) + ... return None + ... print_ldfs({k:v.ldf_.round(decimals=3) for k,v in devs.items()}) + ... devs["Selected"] = cl.TailConstant(tail = tail, projection_period = 0).fit_transform(devs[selected_avg]) + ... selected = {} + ... selected['CDF to Ultimate'] = devs["Selected"].ldf_.round(decimals=3).incr_to_cum().round(decimals=3) + ... selected['Percent Reported'] = (1/selected['CDF to Ultimate']).round(decimals=3) + ... print('PART 4 - Selected Age-to-Age Factor') + ... print_ldfs({'Selected':devs['Selected'].ldf_.round(decimals=3)}) + ... print_ldfs(selected) + ... return devs + >>> import re + >>> tri = cl.load_sample('friedland_xyz_auto_bi') + >>> assumptions_list = ['simple_5','simple_3','simple_2','volume_4','volume_3','volume_2','geometric_3'] + >>> assumptions = {x:{'n_periods':int(re.match(r'.+_(.+)', x).group(1)),'average':re.match(r'(.+)_', x).group(1)} for x in assumptions_list} + >>> assumptions['medial 5x1'] = {'n_periods':5, 'average':'simple','drop_high':1, 'drop_low':1} + >>> reported_devs = dev_exhibit(tri['Reported Claims'],assumptions,'volume_2',1) + PART 1 - Data Triangle + 12 24 36 48 60 72 84 96 108 120 132 + 1998 NaN NaN 11171.0 12380.0 13216.0 14067.0 14688.0 16366.0 16163.0 15835.0 15822.0 + 1999 NaN 13255.0 16405.0 19639.0 22473.0 23764.0 25094.0 24795.0 25071.0 25107.0 NaN + 2000 15676.0 18749.0 21900.0 27144.0 29488.0 34458.0 36949.0 37505.0 37246.0 NaN NaN + 2001 11827.0 16004.0 21022.0 26578.0 34205.0 37136.0 38541.0 38798.0 NaN NaN NaN + 2002 12811.0 20370.0 26656.0 37667.0 44414.0 48701.0 48169.0 NaN NaN NaN NaN + 2003 9651.0 16995.0 30354.0 40594.0 44231.0 44373.0 NaN NaN NaN NaN NaN + 2004 16995.0 40180.0 58866.0 71707.0 70288.0 NaN NaN NaN NaN NaN NaN + 2005 28674.0 47432.0 70340.0 70655.0 NaN NaN NaN NaN NaN NaN NaN + 2006 27066.0 46783.0 48804.0 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 19477.0 31732.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN + 2008 18632.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN + PART 2 - Age-to-Age Factors + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + 1998 NaN NaN 1.108227 1.067528 1.064392 1.044146 1.114243 0.987596 0.979707 0.999179 + 1999 NaN 1.237646 1.197135 1.144305 1.057447 1.055967 0.988085 1.011131 1.001436 NaN + 2000 1.196032 1.168062 1.239452 1.086354 1.168543 1.072291 1.015048 0.993094 NaN NaN + 2001 1.353175 1.313547 1.264295 1.286967 1.085689 1.037834 1.006668 NaN NaN NaN + 2002 1.590040 1.308591 1.413078 1.179122 1.096524 0.989076 NaN NaN NaN NaN + 2003 1.760957 1.786055 1.337353 1.089595 1.003210 NaN NaN NaN NaN NaN + 2004 2.364225 1.465057 1.218140 0.980211 NaN NaN NaN NaN NaN NaN + 2005 1.654181 1.482965 1.004478 NaN NaN NaN NaN NaN NaN NaN + 2006 1.728479 1.043199 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 1.629204 NaN NaN NaN NaN NaN NaN NaN NaN NaN + PART 3 - Average Age-to-Age Factor + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + simple_5 1.827 1.417 1.247 1.124 1.082 1.040 1.031 0.997 0.991 0.999 + simple_3 1.671 1.330 1.187 1.083 1.062 1.033 1.003 0.997 0.991 0.999 + simple_2 1.679 1.263 1.111 1.035 1.050 1.013 1.011 1.002 0.991 0.999 + volume_4 1.802 1.376 1.185 1.094 1.081 1.033 1.019 0.998 0.993 0.999 + volume_3 1.674 1.325 1.147 1.060 1.060 1.028 1.005 0.998 0.993 0.999 + volume_2 1.687 1.265 1.102 1.020 1.050 1.010 1.011 1.000 0.993 0.999 + geometric_3 1.670 1.314 1.178 1.080 1.061 1.033 1.003 0.997 0.991 0.999 + medial 5x1 1.715 1.419 1.273 1.118 1.080 1.046 1.011 0.993 0.991 0.999 + PART 4 - Selected Age-to-Age Factor + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 132-144 + Selected 1.687 1.265 1.102 1.02 1.05 1.01 1.011 1.0 0.993 0.999 1.0 + 12-Ult 24-Ult 36-Ult 48-Ult 60-Ult 72-Ult 84-Ult 96-Ult 108-Ult 120-Ult 132-Ult + CDF to Ultimate 2.551 1.512 1.196 1.085 1.064 1.013 1.003 0.992 0.992 0.999 1.0 + Percent Reported 0.392 0.661 0.836 0.922 0.940 0.987 0.997 1.008 1.008 1.001 1.0 + +Exhibit II Sheet 2 p111 +########################## + +.. doctest:: + + >>> paid_devs = dev_exhibit(tri['Paid Claims'],assumptions,'volume_2',1.01) + PART 1 - Data Triangle + 12 24 36 48 60 72 84 96 108 120 132 + 1998 NaN NaN 6309.0 8521.0 10082.0 11620.0 13242.0 14419.0 15311.0 15764.0 15822.0 + 1999 NaN 4666.0 9861.0 13971.0 18127.0 22032.0 23511.0 24146.0 24592.0 24817.0 NaN + 2000 1302.0 6513.0 12139.0 17828.0 24030.0 28853.0 33222.0 35902.0 36782.0 NaN NaN + 2001 1539.0 5952.0 12319.0 18609.0 24387.0 31090.0 37070.0 38519.0 NaN NaN NaN + 2002 2318.0 7932.0 13822.0 22095.0 31945.0 40629.0 44437.0 NaN NaN NaN NaN + 2003 1743.0 6240.0 12683.0 22892.0 34505.0 39320.0 NaN NaN NaN NaN NaN + 2004 2221.0 9898.0 25950.0 43439.0 52811.0 NaN NaN NaN NaN NaN NaN + 2005 3043.0 12219.0 27073.0 40026.0 NaN NaN NaN NaN NaN NaN NaN + 2006 3531.0 11778.0 22819.0 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 3529.0 11865.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN + 2008 3409.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN + PART 2 - Age-to-Age Factors + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + 1998 NaN NaN 1.350610 1.183194 1.152549 1.139587 1.088884 1.061863 1.029587 1.003679 + 1999 NaN 2.113373 1.416793 1.297473 1.215425 1.067130 1.027009 1.018471 1.009149 NaN + 2000 5.002304 1.863811 1.468655 1.347880 1.200707 1.151423 1.080669 1.024511 NaN NaN + 2001 3.867446 2.069724 1.510593 1.310495 1.274860 1.192345 1.039088 NaN NaN NaN + 2002 3.421915 1.742562 1.598539 1.445802 1.271842 1.093726 NaN NaN NaN NaN + 2003 3.580034 2.032532 1.804936 1.507295 1.139545 NaN NaN NaN NaN NaN + 2004 4.456551 2.621742 1.673950 1.215751 NaN NaN NaN NaN NaN NaN + 2005 4.015445 2.215648 1.478447 NaN NaN NaN NaN NaN NaN NaN + 2006 3.335599 1.937426 NaN NaN NaN NaN NaN NaN NaN NaN + 2007 3.362142 NaN NaN NaN NaN NaN NaN NaN NaN NaN + PART 3 - Average Age-to-Age Factor + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 + simple_5 3.750 2.110 1.613 1.365 1.220 1.129 1.059 1.035 1.019 1.004 + simple_3 3.571 2.258 1.652 1.390 1.229 1.146 1.049 1.035 1.019 1.004 + simple_2 3.349 2.077 1.576 1.362 1.206 1.143 1.060 1.021 1.019 1.004 + volume_4 3.713 2.206 1.615 1.342 1.218 1.128 1.056 1.030 1.017 1.004 + volume_3 3.550 2.238 1.619 1.349 1.222 1.141 1.051 1.030 1.017 1.004 + volume_2 3.349 2.079 1.574 1.316 1.203 1.136 1.059 1.022 1.017 1.004 + geometric_3 3.558 2.241 1.647 1.384 1.227 1.145 1.049 1.035 1.019 1.004 + medial 5x1 3.653 2.062 1.594 1.368 1.229 1.128 1.060 1.025 1.019 1.004 + PART 4 - Selected Age-to-Age Factor + 12-24 24-36 36-48 48-60 60-72 72-84 84-96 96-108 108-120 120-132 132-144 + Selected 3.349 2.079 1.574 1.316 1.203 1.136 1.059 1.022 1.017 1.004 1.01 + 12-Ult 24-Ult 36-Ult 48-Ult 60-Ult 72-Ult 84-Ult 96-Ult 108-Ult 120-Ult 132-Ult + CDF to Ultimate 21.999 6.569 3.160 2.007 1.525 1.268 1.116 1.054 1.031 1.014 1.01 + Percent Reported 0.045 0.152 0.316 0.498 0.656 0.789 0.896 0.949 0.970 0.986 0.99 + +Exhibit II Sheet 3 p112 +########################## + +.. doctest:: + + >>> exhibit = rounded_development_summary(reported_devs["Selected"],paid_devs["Selected"]) + >>> exhibit + Age Reported Claims Paid Claims Reported CDF Paid CDF Reported Ultimate Paid Ultimate + 1998 132 15822.0 15822.0 1.000 1.010 15822.0 15980.0 + 1999 120 25107.0 24817.0 0.999 1.014 25082.0 25164.0 + 2000 108 37246.0 36782.0 0.992 1.031 36948.0 37922.0 + 2001 96 38798.0 38519.0 0.992 1.054 38488.0 40599.0 + 2002 84 48169.0 44437.0 1.003 1.116 48314.0 49592.0 + 2003 72 44373.0 39320.0 1.013 1.268 44950.0 49858.0 + 2004 60 70288.0 52811.0 1.064 1.525 74786.0 80537.0 + 2005 48 70655.0 40026.0 1.085 2.007 76661.0 80332.0 + 2006 36 48804.0 22819.0 1.196 3.160 58370.0 72108.0 + 2007 24 31732.0 11865.0 1.512 6.569 47979.0 77941.0 + 2008 12 18632.0 3409.0 2.551 21.999 47530.0 74995.0 + +Exhibit II Sheet 4 p113 +########################## + +.. doctest:: + + >>> unpaid_exhibit = unpaid_summary(exhibit) + >>> unpaid_exhibit[['Case Outstanding','Reported Method IBNR','Paid Method IBNR','Reported Method Unpaid','Paid Method Unpaid']] + Case Outstanding Reported Method IBNR Paid Method IBNR Reported Method Unpaid Paid Method Unpaid + 1998 0.0 0.0 158.0 0.0 158.0 + 1999 290.0 -25.0 57.0 265.0 347.0 + 2000 464.0 -298.0 676.0 166.0 1140.0 + 2001 279.0 -310.0 1801.0 -31.0 2080.0 + 2002 3732.0 145.0 1423.0 3877.0 5155.0 + 2003 5053.0 577.0 5485.0 5630.0 10538.0 + 2004 17477.0 4498.0 10249.0 21975.0 27726.0 + 2005 30629.0 6006.0 9677.0 36635.0 40306.0 + 2006 25985.0 9566.0 23304.0 35551.0 49289.0 + 2007 19867.0 16247.0 46209.0 36114.0 66076.0 + 2008 15223.0 28898.0 56363.0 44121.0 71586.0 + +Exhibit III Sheet 1 p114 +########################## + +WIP diff --git a/docs/friedland/chapter_8.ipynb b/docs/friedland/chapter_8.ipynb new file mode 100644 index 00000000..6b9f3515 --- /dev/null +++ b/docs/friedland/chapter_8.ipynb @@ -0,0 +1,853 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 3, + "id": "9bf32865", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import chainladder as cl\n", + "\n", + "pd.set_option('display.max_columns', None)\n", + "pd.set_option('display.width', 1000)\n", + "\n", + "auto_bi = cl.load_sample('friedland_auto_bi_insurer')\n", + "\n", + "# Exhibit — Reported / Paid / reform factors (1)–(2) / adjusted claims / premium / LDF (7)–(8) / ultimate / claim ratio\n", + "exhibit = pd.DataFrame(\n", + " [\n", + " [2000, 10_000_000, 9_500_000, 1.005, 1.050, 10_050_000, 9_975_000, 10_012_500, 24_000_000, 2.954, 0.670, 19_816_540, 0.830],\n", + " [2001, 8_000_000, 7_200_000, 1.020, 1.150, 8_160_000, 8_280_000, 8_220_000, 18_000_000, 2.580, 0.670, 14_209_092, 0.790],\n", + " [2002, 9_400_000, 7_600_000, 1.030, 1.250, 9_682_000, 9_500_000, 9_591_000, 19_000_000, 2.253, 0.670, 14_477_710, 0.760],\n", + " [2003, 15_600_000, 7_800_000, 1.100, 1.350, 17_160_000, 10_530_000, 13_845_000, 23_000_000, 1.968, 0.670, 18_255_463, 0.790],\n", + " [2004, 16_500_000, 11_200_000, 1.200, 1.750, 19_800_000, 19_600_000, 19_700_000, 32_000_000, 1.719, 0.750, 25_398_225, 0.790],\n", + " [2005, 18_500_000, 10_200_000, 1.400, 2.500, 25_900_000, 25_500_000, 25_700_000, 47_000_000, 1.501, 1.000, 38_575_700, 0.820],\n", + " [2006, 16_500_000, 6_000_000, 1.800, 5.000, 29_700_000, 30_000_000, 29_850_000, 50_000_000, 1.311, 1.000, 39_133_350, 0.780],\n", + " [2007, 14_000_000, 3_000_000, 2.900, 15.000, 40_600_000, 45_000_000, 42_800_000, 57_000_000, 1.145, 1.000, 49_006_000, 0.860],\n", + " [2008, 8_700_000, 750_000, 4.000, 90.000, None, None, None, None, None, None, None, None],\n", + " ],\n", + " columns=[\n", + " 'Accident Year',\n", + " 'Reported',\n", + " 'Paid',\n", + " '(1)',\n", + " '(2)',\n", + " 'Reported to 7/1/08',\n", + " 'Paid Reform',\n", + " 'Reported Paid Claims',\n", + " 'Premium',\n", + " '(7)',\n", + " '(8)',\n", + " 'Claims',\n", + " 'Claim Ratio',\n", + " ],\n", + ")\n", + "exhibit" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f84de055", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
2008
200010,000,000
20018,000,000
20029,400,000
200315,600,000
200416,500,000
200518,500,000
200616,500,000
200714,000,000
20088,700,000
" + ], + "text/plain": [ + " 2008\n", + "2000 10000000.0\n", + "2001 8000000.0\n", + "2002 9400000.0\n", + "2003 15600000.0\n", + "2004 16500000.0\n", + "2005 18500000.0\n", + "2006 16500000.0\n", + "2007 14000000.0\n", + "2008 8700000.0" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Page 140, Exhibit 1 column 2\n", + "auto_bi = cl.load_sample(\"friedland_auto_bi_insurer\")\n", + "auto_bi[\"Reported Claims\"].latest_diagonal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f5519bd6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
2008
20009,500,000
20017,200,000
20027,600,000
20037,800,000
200411,200,000
200510,200,000
20066,000,000
20073,000,000
2008750,000
" + ], + "text/plain": [ + " 2008\n", + "2000 9500000.0\n", + "2001 7200000.0\n", + "2002 7600000.0\n", + "2003 7800000.0\n", + "2004 11200000.0\n", + "2005 10200000.0\n", + "2006 6000000.0\n", + "2007 3000000.0\n", + "2008 750000.0" + ] + }, + "execution_count": null, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Page 140, Exhibit 1 column 3\n", + "auto_bi[\"Paid Claims\"].latest_diagonal" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "ca66fbf5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
DevelopmentConstant(patterns={'paid': {12: 90.0, 24: 15.0, 36: 5.0, 48: 2.5,\n",
+              "                                       60: 1.75, 72: 1.35, 84: 1.25, 96: 1.15,\n",
+              "                                       108: 1.05},\n",
+              "                              'reported': {12: 4.0, 24: 2.9, 36: 1.8, 48: 1.4,\n",
+              "                                           60: 1.2, 72: 1.1, 84: 1.03, 96: 1.02,\n",
+              "                                           108: 1.005}},\n",
+              "                    style='cdf')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
" + ], + "text/plain": [ + "DevelopmentConstant(patterns={'paid': {12: 90.0, 24: 15.0, 36: 5.0, 48: 2.5,\n", + " 60: 1.75, 72: 1.35, 84: 1.25, 96: 1.15,\n", + " 108: 1.05},\n", + " 'reported': {12: 4.0, 24: 2.9, 36: 1.8, 48: 1.4,\n", + " 60: 1.2, 72: 1.1, 84: 1.03, 96: 1.02,\n", + " 108: 1.005}},\n", + " style='cdf')" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Page 140, Exhibit 1 column 4-5\n", + "# Direct age(in months): value representation per LOB\n", + "patterns = {\n", + " 'reported': {12: 4.0, 24: 2.9, 36: 1.8, 48: 1.4, 60: 1.2, 72: 1.1, 84: 1.03, 96: 1.02, 108: 1.005},\n", + " 'paid': {12: 90.0, 24: 15.0, 36: 5.0, 48: 2.5, 60: 1.75, 72: 1.35, 84: 1.25, 96: 1.15, 108: 1.05}\n", + "}\n", + "\n", + "cl.DevelopmentConstant(patterns=patterns, style='cdf')\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c74260ee", + "metadata": {}, + "outputs": [], + "source": [ + "cl.DevelopmentConstant(patterns=patterns, style='cdf').fit(auto_bi[\"Paid Claims\"]).ldf_" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/friedland/index.rst b/docs/friedland/index.rst new file mode 100644 index 00000000..d4db4f72 --- /dev/null +++ b/docs/friedland/index.rst @@ -0,0 +1,8 @@ +====================================== +:octicon:`log` Friedland Recreation +====================================== + +Estimating Unpaid Claims Using Basic Techniques +------------------------------------------------- + +This is the CAS Exam 5 textbook for reserving. We will recreate exhibits from this key text using the chainladder package. diff --git a/docs/library/generated/chainladder.Development.rst b/docs/library/generated/chainladder.Development.rst index bde06734..216caf8b 100644 --- a/docs/library/generated/chainladder.Development.rst +++ b/docs/library/generated/chainladder.Development.rst @@ -3,4 +3,4 @@ .. currentmodule:: chainladder -.. autoclass:: Development \ No newline at end of file +.. autoclass:: Development diff --git a/pyproject.toml b/pyproject.toml index a1e1e8aa..f87e7525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ docs = [ "ipython", "parso>=0.8", "polars", # For docs examples - "statsmodels", # For docs example + "statsmodels" # For docs example ] test = [ "lxml",