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/chainladder/__init__.py b/chainladder/__init__.py
index 011e4084..4f09953b 100644
--- a/chainladder/__init__.py
+++ b/chainladder/__init__.py
@@ -1,50 +1,128 @@
+"""
+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.
+
+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
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+import copy
import numpy as np
import pandas as pd
-from sklearn.utils import deprecated
+from importlib.metadata import version
-_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])
-)
+# 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:
- 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))
-
- def describe_option(self):
- pass
+ """
+ Used to set defaults for array backend and datetime units.
-options = Options()
+ Attributes
+ ----------
+
+ 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.
+ 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.
+
+ """
+ def __init__(self):
+ self.ARRAY_BACKEND = "numpy"
+ self.AUTO_SPARSE = True
+ self.ARRAY_PRIORITY = ["dask", "sparse", "cupy", "numpy"]
+ self.ULT_VAL = str(
+ pd.Timestamp("2262-01-01") - \
+ pd.Timedelta(1, unit=__dt64_unit__)
+ )
+ # Store initial values as defaults.
+ 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:
+ """
+ Get the option value for the specified option.
+
+ Parameters
+ ----------
+ option: str
+ The option you wish to get the values for.
+
+ Returns
+ -------
+ The option value.
+
+ """
+ self._validate_option(option)
+ return getattr(self, option)
-@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 set_option(
+ self,
+ option: str,
+ value: str | bool | list
+ ) -> None:
+ """
+ Set the option value for the specified option.
-@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)
+ Parameters
+ ----------
+ option: str
+ The option you wish to set the value for.
+ value: str | bool | list
+ The option value.
+
+ Returns
+ -------
+ None
+
+ """
+ self._validate_option(option)
+ setattr(self, option, value)
+
+ def reset_option(self, option: str | None = None) -> None:
+ """
+ Restores the default value for the specified option. Restores default values for
+ all options if option is None.
+
+ Returns
+ -------
+ None
+
+ """
+
+ if option is not None:
+ self._validate_option(option)
+ setattr(self, option, copy.deepcopy(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)}.")
+
+ def describe_option(self, option: str) -> str:
+ pass
+
+options = Options()
from chainladder.utils import * # noqa (API Import)
@@ -55,10 +133,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/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py
index 1ad6b096..2ffd0777 100644
--- a/chainladder/adjustments/bootstrap.py
+++ b/chainladder/adjustments/bootstrap.py
@@ -46,6 +46,74 @@ 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
+
+ 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::
+
+ 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__(
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/correlation.py b/chainladder/core/correlation.py
index 942c0bb0..71f88e09 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,53 @@ 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.
+
+ .. 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.
+
+ 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)
+ 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):
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/development/development.py b/chainladder/development/development.py
index 41742ac5..ef38393e 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,178 @@ 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.
+
+ Let's start with a triangle and inspect its link ratios.
+
+ .. testsetup::
+
+ import chainladder as cl
+
+ .. 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. By using
+ the `fit_transform` method, we can see the link ratios after the drop.
+
+ .. 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)
+
+ .. 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.
+ 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::
+
+ 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::
+
+ 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.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.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.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.
+
+ .. 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__(
diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py
index 0b269158..0aeffd27 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,14 +180,14 @@ 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
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,22 +195,100 @@ def test_reset_option() -> None:
"""
- original_ult_val = cl.options.ULT_VAL
- original_dt64_unit = cl.options.DT64_UNIT
- original_dt64_dtype = cl.options.DT64_DTYPE
+ original_backend = cl.options.ARRAY_BACKEND
+ original_auto_sparse = cl.options.AUTO_SPARSE
+ original_array_priority = cl.options.ARRAY_PRIORITY
- 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')
+ 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()
+
+ assert cl.options.ARRAY_BACKEND == original_backend
+ assert cl.options.AUTO_SPARSE == original_auto_sparse
+ assert cl.options.ARRAY_PRIORITY == original_array_priority
+
+ finally:
+ # Manual reset in case of test failure.
+ 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:
+ """
+ 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.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('ULT_VAL') == cl.options.ULT_VAL
- cl.options.reset_option()
+def test_set_option_consistency() -> None:
+ """
+ When set_option changes an option value, get_option should return the new option value.
+
+ Returns
+ -------
+ None
+
+ """
+ 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.reset_option('ARRAY_BACKEND')
+
+def test_reset_single_option() -> None:
+ """
+ Set an option and check its value, then reset it and check its value.
+
+ Returns
+ -------
+ None
+
+ """
+ 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'
- assert cl.options.AUTO_SPARSE == True
- assert cl.options.ARRAY_PRIORITY == ['dask', 'sparse', 'cupy', 'numpy']
- 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_reset_option_invalid() -> None:
+ """
+ Supply in invalid option to cl.options.reset_option() and raise an error.
+
+ Returns
+ -------
+ None
+ """
+ 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
diff --git a/conftest.py b/conftest.py
index 11e0c3bc..867c8e0b 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,64 +33,72 @@ 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")
- return cl.load_sample("raa")
+ 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")
- return cl.load_sample("quarterly")
+ 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")
- return cl.load_sample("clrd")
+ 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")
- return cl.load_sample("genins")
+ yield from _sample_fixture(request, "genins")
@pytest.fixture
def prism(request):
- cl.options.set_option("ARRAY_BACKEND", "numpy")
- return cl.load_sample("prism")
+ 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")
- return cl.load_sample("prism").sum()
+ 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")
- return cl.load_sample("xyz")
+ yield from _sample_fixture(request, "xyz")
@pytest.fixture
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..8ae43361
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,46 @@
+# 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.
+
+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, 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. |
+| `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`). |
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
+
+
+
+
+
+
12
+
24
+
36
+
48
+
60
+
72
+
84
+
96
+
108
+
120
+
+
+
+
+
1998
+
37,017,487
+
43,169,009
+
45,568,919
+
46,784,558
+
47,337,318
+
47,533,264
+
47,634,419
+
47,689,655
+
47,724,678
+
47,742,304
+
+
+
1999
+
38,954,484
+
46,045,718
+
48,882,924
+
50,219,672
+
50,729,292
+
50,926,779
+
51,069,285
+
51,163,540
+
51,185,767
+
+
+
+
2000
+
41,155,776
+
49,371,478
+
52,358,476
+
53,780,322
+
54,303,086
+
54,582,950
+
54,742,188
+
54,837,929
+
+
+
+
+
2001
+
42,394,069
+
50,584,112
+
53,704,296
+
55,150,118
+
55,895,583
+
56,156,727
+
56,299,562
+
+
+
+
+
+
2002
+
44,755,243
+
52,971,643
+
56,102,312
+
57,703,851
+
58,363,564
+
58,592,712
+
+
+
+
+
+
+
2003
+
45,163,102
+
52,497,731
+
55,468,551
+
57,015,411
+
57,565,344
+
+
+
+
+
+
+
+
2004
+
45,417,309
+
52,640,322
+
55,553,673
+
56,976,657
+
+
+
+
+
+
+
+
+
2005
+
46,360,869
+
53,790,061
+
56,786,410
+
+
+
+
+
+
+
+
+
+
2006
+
46,582,684
+
54,641,339
+
+
+
+
+
+
+
+
+
+
+
2007
+
48,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": [
+ "
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.