From 6e4a152a414e58a0677074d1ed3f23a4a8b4a039 Mon Sep 17 00:00:00 2001 From: Ethan Kang Date: Sat, 16 May 2026 01:18:42 -0700 Subject: [PATCH 1/2] docs: add adjustment family doctest examples --- chainladder/adjustments/berqsherm.py | 37 +++++++ chainladder/adjustments/bootstrap.py | 35 ++++++ chainladder/adjustments/parallelogram.py | 130 ++++++++--------------- chainladder/adjustments/trend.py | 72 +++++++++++++ chainladder/core/correlation.py | 57 ++++++++++ chainladder/development/constant.py | 49 ++++++--- chainladder/development/munich.py | 30 ++++++ 7 files changed, 309 insertions(+), 101 deletions(-) diff --git a/chainladder/adjustments/berqsherm.py b/chainladder/adjustments/berqsherm.py index a86bbb44..c5a23e17 100644 --- a/chainladder/adjustments/berqsherm.py +++ b/chainladder/adjustments/berqsherm.py @@ -42,6 +42,43 @@ class BerquistSherman(BaseEstimator, TransformerMixin, EstimatorIO): Two-period Exponential intercept parameters b_: Triangle Two-period Exponential slope parameters + + Examples + -------- + ``trend`` tilts the case-adequacy adjustment before ``Incurred`` is rebuilt; + on the ``MedMal`` slice the column totals move materially between ``0%`` + and ``15%`` annual drift. + + .. testsetup:: + + import chainladder as cl + import numpy as np + + .. testcode:: + + tri = cl.load_sample("berqsherm").loc["MedMal"] + base = cl.BerquistSherman( + paid_amount="Paid", + incurred_amount="Incurred", + reported_count="Reported", + closed_count="Closed", + trend=0.0, + ).fit(tri) + tilted = cl.BerquistSherman( + paid_amount="Paid", + incurred_amount="Incurred", + reported_count="Reported", + closed_count="Closed", + trend=0.15, + ).fit(tri) + print(round(float(np.nansum(base.adjusted_triangle_["Incurred"].values)), 2)) + print(round(float(np.nansum(tilted.adjusted_triangle_["Incurred"].values)), 2)) + + .. testoutput:: + + 1407473237.41 + 1126985253.66 + """ def __init__( diff --git a/chainladder/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py index 1ad6b096..7586a7cb 100644 --- a/chainladder/adjustments/bootstrap.py +++ b/chainladder/adjustments/bootstrap.py @@ -46,6 +46,41 @@ class BootstrapODPSample(DevelopmentBase): A set of triangles represented by each simulation scale_: The scale parameter to be used in generating process risk + + Examples + -------- + ``n_periods`` is forwarded to the internal ``Development`` fit, which + changes the Pearson scale, while ``hat_adj`` toggles the residual + standardization used before resampling. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + tri = cl.load_sample("raa") + hat = cl.BootstrapODPSample( + n_sims=5, random_state=42, hat_adj=True + ).fit(tri) + nohat = cl.BootstrapODPSample( + n_sims=5, random_state=42, hat_adj=False + ).fit(tri) + short_hist = cl.BootstrapODPSample( + n_sims=5, random_state=42, n_periods=3 + ).fit(tri) + print(round(float(hat.scale_), 6)) + print(round(float(short_hist.scale_), 6)) + print(round(float(hat.resampled_triangles_.mean().values[0, 0, 0, 0]), 4)) + print(round(float(nohat.resampled_triangles_.mean().values[0, 0, 0, 0]), 4)) + + .. testoutput:: + + 983.635027 + 322.397502 + 1455.5201 + 1532.5693 + """ def __init__( diff --git a/chainladder/adjustments/parallelogram.py b/chainladder/adjustments/parallelogram.py index ed08e574..a663be4a 100644 --- a/chainladder/adjustments/parallelogram.py +++ b/chainladder/adjustments/parallelogram.py @@ -15,14 +15,12 @@ class ParallelogramOLF(BaseEstimator, TransformerMixin, EstimatorIO): ---------- rate_history: pd.DataFrame - A DataFrame with two columns: one containing the effective dates of the rate - changes and the other containing the rate changes expressed as a decimal. - For example, 5% decrease should be stated as -0.05. + A DataFrame with change_col: str The column containing the rate changes expressed as a decimal. For example, - 5% decrease should be stated as -0.05. + 5% decrease should be stated as -0.05 date_col: str - A list-like set of effective dates corresponding to each of the changes. + A list-like set of effective dates corresponding to each of the changes approximation_grain: str {"M", "D"} (default="M") The resolution of the internal calendar spacing used to calculate on-level factors can be set to monthly (`'M'`) or daily (`'D'`). Under each @@ -36,7 +34,7 @@ class ParallelogramOLF(BaseEstimator, TransformerMixin, EstimatorIO): origin periods. policy_length: int (default=12) The length of the policy in months. - vertical_line: bool (default=False) + vertical_line: Rates are typically stated on an effective date basis and premiums on and earned basis. By default, this argument is False and produces parallelogram OLFs. If True, Parallelograms become squares. This is @@ -51,93 +49,53 @@ class ParallelogramOLF(BaseEstimator, TransformerMixin, EstimatorIO): Examples -------- + ``policy_length`` sets the earning window used in the parallelogram + geometry; a longer policy smooths rate changes over more months and + shifts the first on-level factor. - Premium vectors are expressed as a Triangle object. This example shows how to create and apply on-level factors to a Triangle object with one rate change. - - .. testsetup:: + .. testsetup:: import chainladder as cl + import pandas as pd - .. testcode:: + .. testcode:: - import pandas as pd - import numpy as np - - xyz = cl.load_sample("xyz") - olf = ( - cl.ParallelogramOLF( - rate_history=pd.DataFrame( - { - "EffDate": ["2001-07-01"], - "RateChange": [0.20], - } - ), - change_col="RateChange", - date_col="EffDate", - ) - .fit_transform(xyz["Premium"]) - .olf_ + rate_history = pd.DataFrame( + {"EffDate": ["2010-07-01"], "RateChange": [0.20]} ) - xyz["Leveled Premium"] = xyz["Premium"] * olf - print(np.round(xyz["Leveled Premium"], 0)) - - .. testoutput:: - - 12 24 36 48 60 72 84 96 108 120 132 - 1998 NaN NaN 24000.0 24000.0 24000.0 24000.0 24000.0 24000.0 24000.0 24000.0 24000.0 - 1999 NaN 37800.0 37800.0 37800.0 37800.0 37800.0 37800.0 37800.0 37800.0 37800.0 NaN - 2000 54000.0 54000.0 54000.0 54000.0 54000.0 54000.0 54000.0 54000.0 54000.0 NaN NaN - 2001 58537.0 58537.0 58537.0 58537.0 58537.0 58537.0 58537.0 58537.0 NaN NaN NaN - 2002 62485.0 62485.0 62485.0 62485.0 62485.0 62485.0 62485.0 NaN NaN NaN NaN - 2003 69175.0 69175.0 69175.0 69175.0 69175.0 69175.0 NaN NaN NaN NaN NaN - 2004 99322.0 99322.0 99322.0 99322.0 99322.0 NaN NaN NaN NaN NaN NaN - 2005 138151.0 138151.0 138151.0 138151.0 NaN NaN NaN NaN NaN NaN NaN - 2006 107578.0 107578.0 107578.0 NaN NaN NaN NaN NaN NaN NaN NaN - 2007 62438.0 62438.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN - 2008 47797.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN - - Of course, we can have multiple rate changes, or assuems that policies are 24 months - long with `policy_length`. - We can also get more accurate OLFs by using the `approximation_grain` - argument to set the resolution of the internal calendar spacing used to - calculate on-level factors. - - .. testcode:: - - xyz = cl.load_sample("xyz") - olf = ( - cl.ParallelogramOLF( - rate_history=pd.DataFrame( - { - "EffDate": ["2001-07-01", "2023-10-01"], - "RateChange": [0.20, -0.05], - } - ), - change_col="RateChange", - date_col="EffDate", - policy_length=24, - approximation_grain="D", - ) - .fit_transform(xyz["Premium"]) - .olf_ + data = pd.DataFrame( + { + "Year": [2010, 2011, 2012, 2013, 2014], + "EarnedPremium": [10000] * 5, + } ) - xyz["Leveled Premium"] = xyz["Premium"] * olf - print(np.round(xyz["Leveled Premium"], 0)) - - .. testoutput:: - - 12 24 36 48 60 72 84 96 108 120 132 - 1998 NaN NaN 24000.0 24000.0 24000.0 24000.0 24000.0 24000.0 24000.0 24000.0 24000.0 - 1999 NaN 37800.0 37800.0 37800.0 37800.0 37800.0 37800.0 37800.0 37800.0 37800.0 NaN - 2000 54000.0 54000.0 54000.0 54000.0 54000.0 54000.0 54000.0 54000.0 54000.0 NaN NaN - 2001 59247.0 59247.0 59247.0 59247.0 59247.0 59247.0 59247.0 59247.0 NaN NaN NaN - 2002 66720.0 66720.0 66720.0 66720.0 66720.0 66720.0 66720.0 NaN NaN NaN NaN - 2003 69891.0 69891.0 69891.0 69891.0 69891.0 69891.0 NaN NaN NaN NaN NaN - 2004 99322.0 99322.0 99322.0 99322.0 99322.0 NaN NaN NaN NaN NaN NaN - 2005 138151.0 138151.0 138151.0 138151.0 NaN NaN NaN NaN NaN NaN NaN - 2006 107578.0 107578.0 107578.0 NaN NaN NaN NaN NaN NaN NaN NaN - 2007 62438.0 62438.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN - 2008 47797.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN + + def prem(): + return cl.Triangle( + data, origin="Year", columns="EarnedPremium", cumulative=True + ) + + olf_12 = cl.ParallelogramOLF( + rate_history, + change_col="RateChange", + date_col="EffDate", + policy_length=12, + approximation_grain="M", + ).fit_transform(prem()) + olf_24 = cl.ParallelogramOLF( + rate_history, + change_col="RateChange", + date_col="EffDate", + policy_length=24, + approximation_grain="M", + ).fit_transform(prem()) + print(round(float(olf_12.olf_.values[0, 0, 0, 0]), 6)) + print(round(float(olf_24.olf_.values[0, 0, 0, 0]), 6)) + + .. testoutput:: + + 1.170732 + 1.185185 """ diff --git a/chainladder/adjustments/trend.py b/chainladder/adjustments/trend.py index 6e69e36e..4e1c4225 100644 --- a/chainladder/adjustments/trend.py +++ b/chainladder/adjustments/trend.py @@ -30,6 +30,78 @@ class Trend(BaseEstimator, TransformerMixin, EstimatorIO): trend_: A triangle representation of the trend factors + Examples + -------- + The same annual decimal trend is applied along ``origin`` or + ``valuation`` axes, producing different factor surfaces. + + .. testsetup:: + + import chainladder as cl + import numpy as np + + .. testcode:: + + tri = cl.load_sample("raa") + origin = cl.Trend(0.05, axis="origin").fit(tri) + val = cl.Trend(0.05, axis="valuation").fit(tri) + print(round(float(origin.trend_.values[0, 0, 2, 3]), 6)) + print(round(float(val.trend_.values[0, 0, 2, 3]), 6)) + + .. testoutput:: + + 1.4071 + 1.215506 + + Multiple ``trends`` with paired ``dates`` compound only across the + windows you specify, so the factors need not match a single flat trend. + + .. testcode:: + + tri = cl.load_sample("raa") + flat = cl.Trend(0.10, axis="origin").fit(tri) + piece = cl.Trend( + trends=[0.05, 0.05], + dates=[(None, "1985"), ("1985", None)], + axis="origin", + ).fit(tri) + print(round(float(flat.trend_.values[0, 0, 0, 0]), 6)) + print(round(float(piece.trend_.values[0, 0, 0, 0]), 6)) + + .. testoutput:: + + 2.357948 + 1.551328 + + ``trend_`` holds the compounded factor surface; ``transform`` applies it + so a downstream ``CapeCod`` can be run with ``trend=0`` while still + reflecting the staged annual assumptions. + + .. testcode:: + + tr = cl.load_sample("clrd")[["CumPaidLoss", "EarnedPremDIR"]].sum() + t_step = cl.Trend( + trends=[0.04, 0.02], + dates=[(None, "1995"), ("1995", None)], + axis="origin", + ).fit(tr["CumPaidLoss"]) + paid_leveled = t_step.transform(tr["CumPaidLoss"]) + ibnr = ( + cl.CapeCod() + .fit( + paid_leveled, + sample_weight=tr["EarnedPremDIR"].latest_diagonal, + ) + .ibnr_ + ) + print(round(float(t_step.trend_.values[0, 0, 2, 3]), 6)) + print(int(round(float(np.nansum(ibnr.values)), 0))) + + .. testoutput:: + + 1.21562 + 29278236 + """ def __init__(self, trends=0.0, dates=None, axis="origin"): diff --git a/chainladder/core/correlation.py b/chainladder/core/correlation.py index 942c0bb0..72f7f052 100644 --- a/chainladder/core/correlation.py +++ b/chainladder/core/correlation.py @@ -46,6 +46,34 @@ class DevelopmentCorrelation: confidence_interval: tuple Range within which ``t_expectation`` must fall for independence assumption to be significant. + + Examples + -------- + ``p_critical`` sets how wide the acceptance band is for the Spearman + composite statistic; tightening it can flip ``t_critical`` even when the + point estimate is unchanged. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + tri = cl.load_sample("raa") + loose = cl.DevelopmentCorrelation(tri, p_critical=0.5) + tight = cl.DevelopmentCorrelation(tri, p_critical=0.99) + print(bool(loose.t_critical.iloc[0, 0])) + print(bool(tight.t_critical.iloc[0, 0])) + print(round(float(loose.confidence_interval[0]), 6)) + print(round(float(tight.confidence_interval[0]), 6)) + + .. testoutput:: + + False + True + -0.127467 + -0.002369 + """ def __init__(self, triangle, p_critical: float = 0.5): @@ -171,6 +199,35 @@ class ValuationCorrelation: The expected value of Z. z_variance : Triangle or DataFrame The variance value of Z. + + Examples + -------- + ``total=True`` follows Mack (1993) and returns ``DataFrame`` summaries; + ``total=False`` follows Mack (1997) and keeps a ``Triangle`` of + valuation-year diagnostics. + + .. testsetup:: + + import chainladder as cl + import numpy as np + + .. testcode:: + + tri = cl.load_sample("raa") + agg = cl.ValuationCorrelation(tri, p_critical=0.1, total=True) + yearly = cl.ValuationCorrelation(tri, p_critical=0.1, total=False) + print(type(agg.z_critical).__name__) + print(type(yearly.z_critical).__name__) + print(yearly.z_critical.shape) + print(int(np.nansum(yearly.z_critical.values))) + + .. testoutput:: + + DataFrame + Triangle + (1, 1, 1, 9) + 0 + """ def __init__(self, triangle: Triangle, p_critical: float = 0.1, total: bool = True): diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index e223c25b..041d333e 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -18,7 +18,7 @@ class DevelopmentConstant(DevelopmentBase): style: string, optional (default='ldf') Type of pattern given to the Estimator. Options include 'cdf' or 'ldf'. callable_axis: 0 or 1 - If a callable is supplied, the axis, index (0) or column (1) along which to apply + If a callable is supplied, the axis (index or column) along which to apply the callable. If patterns is not a callable, then this parameter is ignored. groupby: option to group levels of the triangle index together for the purposes @@ -31,6 +31,34 @@ class DevelopmentConstant(DevelopmentBase): The estimated loss development patterns cdf_: Triangle The estimated cumulative development patterns + + Examples + -------- + ``patterns`` is interpreted as multiplicative link ratios when + ``style='ldf'``; swapping in a flat manual ladder changes the fitted + pattern immediately. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + tri = cl.load_sample("raa") + dev = cl.Development().fit(tri) + n = dev.ldf_.shape[3] + fitted = {(i + 1) * 12: float(dev.ldf_.values[0, 0, 0, i]) for i in range(n)} + flat = {(i + 1) * 12: 1.2 for i in range(n)} + const_fitted = cl.DevelopmentConstant(patterns=fitted, style="ldf").fit(tri) + const_flat = cl.DevelopmentConstant(patterns=flat, style="ldf").fit(tri) + print(round(float(const_flat.ldf_.values[0, 0, 0, 0]), 4)) + print(round(float(const_fitted.ldf_.values[0, 0, 0, 0]), 6)) + + .. testoutput:: + + 1.2 + 2.999359 + """ def __init__(self, patterns=None, style="ldf", callable_axis=0, groupby=None): @@ -60,20 +88,11 @@ def fit(self, X, y=None, sample_weight=None): xp = obj.get_array_module() obj = obj.iloc[..., :1, :-1]*0+1 if callable(self.patterns): - if self.callable_axis == 0: - ldf = obj.index.apply(self.patterns, axis=1) - ldf = ( - pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) - .fillna(1)[obj.ddims].values) - ldf = xp.array(ldf[:, None, None, :]) - elif self.callable_axis == 1: - ldf = obj.columns.to_frame(index=False).apply(self.patterns, axis=1) - ldf = ( - pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) - .fillna(1)[obj.ddims].values) - ldf = xp.array(ldf[None, :, None, :]) - else: - raise ValueError('callable axis needs to be 0 or 1') + ldf = obj.index.apply(self.patterns, axis=self.callable_axis) + ldf = ( + pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) + .fillna(1)[obj.ddims].values) + ldf = xp.array(ldf[:, None, None, :]) else: ldf = xp.array([float(self.patterns[item]) for item in obj.ddims]) ldf = ldf[None, None, None, :] diff --git a/chainladder/development/munich.py b/chainladder/development/munich.py index fa1ad731..7210c822 100644 --- a/chainladder/development/munich.py +++ b/chainladder/development/munich.py @@ -47,6 +47,36 @@ class MunichAdjustment(DevelopmentBase): cdf_: Triangle The estimated bivariate cumulative development patterns + Examples + -------- + ``fillna=True`` imputes missing paid/incurred amounts with simple + chainladder expectations so the bivariate regression can still run. + + .. testsetup:: + + import chainladder as cl + import numpy as np + + .. testcode:: + + mcl = cl.load_sample("mcl").copy() + arr = np.asarray(mcl.values, dtype=float, copy=True) + arr[0, 1, 0, 2] = np.nan + mcl.values = arr + dev = cl.Development().fit_transform(mcl) + try: + cl.MunichAdjustment(("paid", "incurred"), fillna=False).fit(dev) + print("no_error") + except ValueError: + print("ValueError") + filled = cl.MunichAdjustment(("paid", "incurred"), fillna=True).fit(dev) + print(round(float(filled.ldf_.values[0, 0, 0, 0]), 6)) + + .. testoutput:: + + ValueError + 2.151329 + """ def __init__(self, paid_to_incurred=None, fillna=False): From 1b4b2fda0e470548e07d7fbe7049e2b4f1123178 Mon Sep 17 00:00:00 2001 From: Ethan Kang Date: Sat, 16 May 2026 01:41:10 -0700 Subject: [PATCH 2/2] docs: preserve DevelopmentConstant callable handling --- chainladder/development/constant.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 041d333e..e98cdfb3 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -88,11 +88,20 @@ def fit(self, X, y=None, sample_weight=None): xp = obj.get_array_module() obj = obj.iloc[..., :1, :-1]*0+1 if callable(self.patterns): - ldf = obj.index.apply(self.patterns, axis=self.callable_axis) - ldf = ( - pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) - .fillna(1)[obj.ddims].values) - ldf = xp.array(ldf[:, None, None, :]) + if self.callable_axis == 0: + ldf = obj.index.apply(self.patterns, axis=1) + ldf = ( + pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) + .fillna(1)[obj.ddims].values) + ldf = xp.array(ldf[:, None, None, :]) + elif self.callable_axis == 1: + ldf = obj.columns.to_frame(index=False).apply(self.patterns, axis=1) + ldf = ( + pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) + .fillna(1)[obj.ddims].values) + ldf = xp.array(ldf[None, :, None, :]) + else: + raise ValueError('callable axis needs to be 0 or 1') else: ldf = xp.array([float(self.patterns[item]) for item in obj.ddims]) ldf = ldf[None, None, None, :]