From c6588fe50fca5397cd467842d140e92c87988034 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sat, 20 Jun 2026 23:22:33 +0000 Subject: [PATCH 1/7] docstring and additional tests --- chainladder/adjustments/disposal.py | 96 ++++++++++--------- .../adjustments/tests/test_disposal.py | 22 +++-- 2 files changed, 66 insertions(+), 52 deletions(-) diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index ed1fa61e..4c6be658 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -77,11 +77,9 @@ class DisposalRate(DevelopmentBase): Examples -------- - ``trend`` tilts the case-adequacy adjustment before ``Incurred`` is rebuilt; - on the ``MedMal`` slice the inner diagonals of the adjusted ``Incurred`` - triangle restate materially between ``0%`` and ``15%`` annual drift, while - the latest diagonal is preserved. - + This adjustment method re-apportions future loss emergence based on a '% of ultimate' emergence pattern. + The ultimate can come from another triangle. A common use case is to forecast payment pattern based on incurred ultimate. + .. testsetup:: import chainladder as cl @@ -89,52 +87,60 @@ class DisposalRate(DevelopmentBase): .. 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(np.round(base.adjusted_triangle_["Incurred"], 0)) + clrd = cl.load_sample('clrd').sum() + ult = cl.Chainladder().fit(clrd['IncurLoss']).ultimate_ + dr = cl.DisposalRate().fit_transform(clrd['CumPaidLoss'],sample_weight = ult) + + Once we apply this adjustment method via a `fit_transform`, we can examin the emergence pattern via `disposal_rate_tri`. + + .. testcode:: + + dr.disposal_rate_tri .. testoutput:: - :options: +NORMALIZE_WHITESPACE - - 12 24 36 48 60 72 84 96 - 1969 9883293.0 27420103.0 35879085.0 43105257.0 33438702.0 30397324.0 25723694.0 23506000.0 - 1970 8641763.0 31305782.0 41543535.0 48550616.0 38203864.0 36222888.0 32216000.0 NaN - 1971 11733960.0 43887171.0 61649896.0 64917222.0 51410209.0 48377000.0 NaN NaN - 1972 13638651.0 50987209.0 66696278.0 72777529.0 61163000.0 NaN NaN NaN - 1973 14387930.0 45470590.0 56577593.0 73733000.0 NaN NaN NaN NaN - 1974 13630366.0 47189379.0 63477000.0 NaN NaN NaN NaN NaN - 1975 15036351.0 48904000.0 NaN NaN NaN NaN NaN NaN - 1976 15791000.0 NaN NaN NaN NaN NaN NaN NaN + + 12 24 36 48 60 72 84 96 108 120 + 1988 0.313923 0.619459 0.774429 0.865377 0.919077 0.948898 0.964643 0.973184 0.980224 0.983063 + 1989 0.321526 0.626023 0.781086 0.872345 0.924842 0.952533 0.967690 0.977373 0.981938 NaN + 1990 0.329567 0.634056 0.790752 0.880273 0.927029 0.952951 0.968379 0.976049 NaN NaN + 1991 0.330035 0.636233 0.791888 0.881010 0.929460 0.954694 0.968533 NaN NaN NaN + 1992 0.342613 0.650521 0.801875 0.885976 0.932865 0.956495 NaN NaN NaN NaN + 1993 0.353784 0.663303 0.810639 0.894414 0.939009 NaN NaN NaN NaN NaN + 1994 0.367530 0.670460 0.814661 0.897244 NaN NaN NaN NaN NaN NaN + 1995 0.379650 0.680979 0.821603 NaN NaN NaN NaN NaN NaN NaN + 1996 0.395603 0.688621 NaN NaN NaN NaN NaN NaN NaN NaN + 1997 0.393820 NaN NaN NaN NaN NaN NaN NaN NaN NaN + + The estimated pattern is stored in `disposal_`. .. testcode:: - print(np.round(tilted.adjusted_triangle_["Incurred"], 0)) + dr.disposal_ + + .. testoutput:: + + 12-Ult 24-Ult 36-Ult 48-Ult 60-Ult 72-Ult 84-Ult 96-Ult 108-Ult 120-Ult 132-Ult + (All) 0.112105 0.336242 0.545897 0.693774 0.812877 0.905045 0.942998 0.974365 0.990868 1.0 1.0 + `full_triangle_` now reflects the disposal-rate-based forecast. + + .. testcode:: + + dr.full_triangle_ + .. testoutput:: - :options: +NORMALIZE_WHITESPACE - - 12 24 36 48 60 72 84 96 - 1969 3793504.0 12084942.0 18563821.0 25924316.0 23516364.0 24979245.0 24016864.0 23506000.0 - 1970 3760482.0 15830500.0 24615996.0 33169802.0 30722141.0 33362729.0 32216000.0 NaN - 1971 5982185.0 25583831.0 41384825.0 50323342.0 46191356.0 48377000.0 NaN NaN - 1972 7819355.0 33794110.0 51361061.0 64559286.0 61163000.0 NaN NaN NaN - 1973 9533246.0 34585431.0 49667342.0 73733000.0 NaN NaN NaN NaN - 1974 10348458.0 41241243.0 63477000.0 NaN NaN NaN NaN NaN - 1975 13102479.0 48904000.0 NaN NaN NaN NaN NaN NaN - 1976 15791000.0 NaN NaN NaN NaN NaN NaN NaN + + 12 24 36 48 60 72 84 96 108 120 9999 + 1988 3577780.0 7.059966e+06 8.826151e+06 9.862687e+06 1.047470e+07 1.081458e+07 1.099401e+07 1.109136e+07 1.117159e+07 1.120395e+07 1.139698e+07 + 1989 4090680.0 7.964702e+06 9.937520e+06 1.109859e+07 1.176649e+07 1.211879e+07 1.231163e+07 1.243483e+07 1.249290e+07 1.251646e+07 1.272270e+07 + 1990 4578442.0 8.808486e+06 1.098535e+07 1.222900e+07 1.287854e+07 1.323867e+07 1.345299e+07 1.355956e+07 1.363458e+07 1.366101e+07 1.389229e+07 + 1991 4648756.0 8.961755e+06 1.115424e+07 1.240959e+07 1.309204e+07 1.344748e+07 1.364241e+07 1.375400e+07 1.382878e+07 1.385512e+07 1.408564e+07 + 1992 5139142.0 9.757699e+06 1.202798e+07 1.328948e+07 1.399282e+07 1.434727e+07 1.454438e+07 1.465904e+07 1.473589e+07 1.476295e+07 1.499983e+07 + 1993 5653379.0 1.059942e+07 1.295381e+07 1.429252e+07 1.500514e+07 1.533589e+07 1.553037e+07 1.564351e+07 1.571933e+07 1.574603e+07 1.597976e+07 + 1994 6246447.0 1.139496e+07 1.384576e+07 1.524933e+07 1.593547e+07 1.629529e+07 1.650686e+07 1.662994e+07 1.671242e+07 1.674147e+07 1.699574e+07 + 1995 6473843.0 1.161215e+07 1.401010e+07 1.527961e+07 1.597603e+07 1.634123e+07 1.655597e+07 1.668088e+07 1.676460e+07 1.679409e+07 1.705215e+07 + 1996 6591599.0 1.147391e+07 1.365956e+07 1.491261e+07 1.559998e+07 1.596045e+07 1.617240e+07 1.629570e+07 1.637833e+07 1.640743e+07 1.666215e+07 + 1997 6451896.0 1.106345e+07 1.330436e+07 1.458909e+07 1.529384e+07 1.566342e+07 1.588073e+07 1.600714e+07 1.609186e+07 1.612170e+07 1.638286e+07 """ @@ -216,7 +222,7 @@ def fit( super().fit(ult.values,X.values,self.w_) #keep attributes self.disposal_ = self._param_property(self.disposal_rate_tri,self.params_.slope_[...,0][..., None, :]) - self.disposal_ = concat((self.disposal_,(ult/ult).iloc[:,:,0,:].rename("development", [9999])),axis=3) + self.disposal_ = concat((self.disposal_,(X.latest_diagonal*0 + 1).iloc[:,:,0,:].rename("development", [9999])),axis=3) self.disposal_.is_cumulative = True self.disposal_.is_pattern = False self.incr_disposal_ = self.disposal_.cum_to_incr() diff --git a/chainladder/adjustments/tests/test_disposal.py b/chainladder/adjustments/tests/test_disposal.py index fd899173..814f21e2 100644 --- a/chainladder/adjustments/tests/test_disposal.py +++ b/chainladder/adjustments/tests/test_disposal.py @@ -2,7 +2,7 @@ import numpy as np import pytest -def test_disposal(): +def test_friedland_fidelity(): tri = cl.load_sample('friedland_gl_insurer')['Closed Claim Counts'] ult_tri = cl.Triangle( data = { @@ -30,13 +30,21 @@ def test_disposal(): ]) assert np.all(abs(lhs[~np.isnan(lhs)] - rhs <= 1)) -def test_disposal_no_weight(raa): - tri = raa.set_backend('sparse') +def test_no_weight_exception(raa): with pytest.raises(ValueError): - dr = cl.DisposalRate().fit(tri) - ult = cl.Chainladder().fit(tri).ultimate_ - dr = cl.DisposalRate().fit(tri,sample_weight=ult) + dr = cl.DisposalRate().fit(raa) + ult = cl.Chainladder().fit(raa).ultimate_ + dr = cl.DisposalRate().fit(raa,sample_weight=ult) with pytest.raises(ValueError): - est = dr.transform(tri) + est = dr.transform(raa) +def test_cl_parity(raa): + """ + A no-tail, full-triangle, volume-weighted Chainladder estimator coincides with the disposal rate adjustment. + """ + tri = raa.set_backend('sparse') + dev = cl.Development().fit_transform(tri) + est = cl.Chainladder().fit(dev) + dr = cl.DisposalRate().fit_transform(raa,sample_weight=est.ultimate_) + assert np.all(dr.full_triangle_.round(3).values[...,:-1] == est.full_triangle_.round(3).values[...,:-2]) \ No newline at end of file From 3bb4e35a0876d8051625e247a358a7a7c05e2990 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sat, 20 Jun 2026 23:33:03 +0000 Subject: [PATCH 2/7] bugbot proof --- chainladder/adjustments/tests/test_disposal.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/chainladder/adjustments/tests/test_disposal.py b/chainladder/adjustments/tests/test_disposal.py index 814f21e2..e830adaf 100644 --- a/chainladder/adjustments/tests/test_disposal.py +++ b/chainladder/adjustments/tests/test_disposal.py @@ -47,4 +47,10 @@ def test_cl_parity(raa): est = cl.Chainladder().fit(dev) dr = cl.DisposalRate().fit_transform(raa,sample_weight=est.ultimate_) assert np.all(dr.full_triangle_.round(3).values[...,:-1] == est.full_triangle_.round(3).values[...,:-2]) + +def test_sparse_transform(raa): + raa_sparse = raa.set_backend('sparse') + from chainladder.utils.sparse import sp + dr = cl.DisposalRate().fit_transform(raa,sample_weight=cl.Chainladder().fit(raa).ultimate_) + assert isinstance(dr.full_triangle_.values,sp.COO) \ No newline at end of file From 4b8dea47530274534923514af064fa11e58ded92 Mon Sep 17 00:00:00 2001 From: henrydingliu <106109320+henrydingliu@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:22:48 -0700 Subject: [PATCH 3/7] Update test_disposal.py --- chainladder/adjustments/tests/test_disposal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chainladder/adjustments/tests/test_disposal.py b/chainladder/adjustments/tests/test_disposal.py index e830adaf..7f745218 100644 --- a/chainladder/adjustments/tests/test_disposal.py +++ b/chainladder/adjustments/tests/test_disposal.py @@ -51,6 +51,6 @@ def test_cl_parity(raa): def test_sparse_transform(raa): raa_sparse = raa.set_backend('sparse') from chainladder.utils.sparse import sp - dr = cl.DisposalRate().fit_transform(raa,sample_weight=cl.Chainladder().fit(raa).ultimate_) + dr = cl.DisposalRate().fit_transform(raa_sparse,sample_weight=cl.Chainladder().fit(raa_sparse).ultimate_) assert isinstance(dr.full_triangle_.values,sp.COO) - \ No newline at end of file + From 3726b603d9a3345080fdcd9bec078cce288706cc Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sun, 21 Jun 2026 04:24:05 +0000 Subject: [PATCH 4/7] more bugbot fixes --- chainladder/adjustments/disposal.py | 33 +++++++++++++++-------------- chainladder/methods/base.py | 9 +++++++- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index 4c6be658..b35b80c7 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -3,6 +3,7 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from chainladder.methods import Chainladder +from chainladder.methods.base import validate_weight from chainladder.development import DevelopmentBase import numpy as np import copy @@ -192,18 +193,14 @@ def fit( """ if sample_weight is None: raise ValueError("sample_weight is required.") - #convert to numpy - if X.array_backend == "sparse": - X = X.set_backend("numpy").incr_to_cum() - else: - X = X.copy().incr_to_cum() - if sample_weight.array_backend == "sparse": - ult = sample_weight.set_backend("numpy") - else: - ult = sample_weight.copy() - #get backend - self.xp = X.get_array_module() + #validate dimensions of sample weight + validate_weight(X, sample_weight) + #align backeneds + ult = sample_weight.set_backend(X.array_backend).sort_index() + #calculate disposal rate triangle + self.X_ = X.sort_index() self.disposal_rate_tri = X / ult.values + #get weights for estimation tw = TriangleWeight( n_periods = self.n_periods, drop_high = self.drop_high, @@ -214,16 +211,17 @@ def fit( preserve = self.preserve, drop = self.drop ) - if hasattr(X, "w_"): - self.w_ = tw.fit(X=self.disposal_rate_tri * X.w_).w_.values + if hasattr(self.X_, "w_"): + self.w_ = tw.fit(X=self.disposal_rate_tri * self.X_.w_).w_.values else: self.w_ = tw.fit(X=self.disposal_rate_tri).w_.values #calculate factors - super().fit(ult.values,X.values,self.w_) + super().fit(ult.values,self.X_.values,self.w_) #keep attributes self.disposal_ = self._param_property(self.disposal_rate_tri,self.params_.slope_[...,0][..., None, :]) - self.disposal_ = concat((self.disposal_,(X.latest_diagonal*0 + 1).iloc[:,:,0,:].rename("development", [9999])),axis=3) + self.disposal_ = concat((self.disposal_,(self.X_.latest_diagonal*0 + 1).iloc[:,:,0,:].rename("development", [9999])),axis=3) self.disposal_.is_cumulative = True + #pattern multiples from tail and additive adds from head self.disposal_.is_pattern = False self.incr_disposal_ = self.disposal_.cum_to_incr() self.incr_disposal_.is_pattern = True @@ -253,10 +251,13 @@ def transform( if sample_weight is None: raise ValueError("sample_weight is required.") X_new = copy.deepcopy(X) + #validate dimensions of sample weight + validate_weight(X, sample_weight) + #align backeneds + X_new.ultimate_ = sample_weight.set_backend(self.X_.array_backend).latest_diagonal X_new.disposal_rate_tri = self.disposal_rate_tri X_new.disposal_ = self.disposal_ X_new.incr_disposal_ = self.incr_disposal_ - X_new.ultimate_ = sample_weight.latest_diagonal ibnr_pct = 1 - X_new.disposal_.align_pattern(X_new.disposal_rate_tri) run_off = X_new.incr_disposal_ / ibnr_pct * X_new.ibnr_ run_off = run_off[run_off.valuation > X_new.valuation_date] diff --git a/chainladder/methods/base.py b/chainladder/methods/base.py index 7ad3117e..b2668b15 100644 --- a/chainladder/methods/base.py +++ b/chainladder/methods/base.py @@ -138,7 +138,14 @@ def _include_process_variance(self): process_var = None return process_var - def validate_weight(self, X, sample_weight): + @staticmethod + def validate_weight( + X: Triangle, + sample_weight: Triangle + ) -> None: + ''' + Checks that the a aprior has valid dimensions + ''' if ( sample_weight and X.shape[:-1] != sample_weight.shape[:-1] From 0ffc46ebb6efe403ae759b4f02f5039854912974 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sun, 21 Jun 2026 04:30:40 +0000 Subject: [PATCH 5/7] bug fix --- chainladder/adjustments/disposal.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index b35b80c7..4a6c5fdd 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -2,8 +2,7 @@ # 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/. -from chainladder.methods import Chainladder -from chainladder.methods.base import validate_weight +from chainladder.methods import Chainladder, MethodBase from chainladder.development import DevelopmentBase import numpy as np import copy @@ -194,7 +193,7 @@ def fit( if sample_weight is None: raise ValueError("sample_weight is required.") #validate dimensions of sample weight - validate_weight(X, sample_weight) + MethodBase().validate_weight(X, sample_weight) #align backeneds ult = sample_weight.set_backend(X.array_backend).sort_index() #calculate disposal rate triangle @@ -252,7 +251,7 @@ def transform( raise ValueError("sample_weight is required.") X_new = copy.deepcopy(X) #validate dimensions of sample weight - validate_weight(X, sample_weight) + MethodBase().validate_weight(X, sample_weight) #align backeneds X_new.ultimate_ = sample_weight.set_backend(self.X_.array_backend).latest_diagonal X_new.disposal_rate_tri = self.disposal_rate_tri From a232d5efb53ed8c294fe060bb631ebac2873756f Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sun, 21 Jun 2026 04:48:43 +0000 Subject: [PATCH 6/7] fix --- chainladder/adjustments/disposal.py | 1 + 1 file changed, 1 insertion(+) diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index 4a6c5fdd..82fd3135 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -197,6 +197,7 @@ def fit( #align backeneds ult = sample_weight.set_backend(X.array_backend).sort_index() #calculate disposal rate triangle + self.xp = X.get_array_module() self.X_ = X.sort_index() self.disposal_rate_tri = X / ult.values #get weights for estimation From b736bc0597953a76652573f5cab48b739b834826 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sun, 21 Jun 2026 06:50:04 +0000 Subject: [PATCH 7/7] forcing numpy after all --- chainladder/adjustments/disposal.py | 19 ++++++++++++++++--- chainladder/methods/base.py | 6 ++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index 82fd3135..cd4e811d 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -1,13 +1,19 @@ # 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/. +from __future__ import annotations from chainladder.methods import Chainladder, MethodBase from chainladder.development import DevelopmentBase import numpy as np import copy from chainladder.utils import TriangleWeight, concat -from chainladder import Triangle + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from chainladder.core import Triangle + class DisposalRate(DevelopmentBase): """ @@ -194,8 +200,15 @@ def fit( raise ValueError("sample_weight is required.") #validate dimensions of sample weight MethodBase().validate_weight(X, sample_weight) - #align backeneds - ult = sample_weight.set_backend(X.array_backend).sort_index() + #set backeneds to numpy + if X.array_backend == "sparse": + X = X.set_backend("numpy") + else: + X = X.copy() + if sample_weight.array_backend == "sparse": + ult = sample_weight.set_backend("numpy") + else: + ult = sample_weight.copy() #calculate disposal rate triangle self.xp = X.get_array_module() self.X_ = X.sort_index() diff --git a/chainladder/methods/base.py b/chainladder/methods/base.py index b2668b15..6fcf0537 100644 --- a/chainladder/methods/base.py +++ b/chainladder/methods/base.py @@ -1,6 +1,8 @@ # 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/. +from __future__ import annotations + import numpy as np import pandas as pd import warnings @@ -10,6 +12,10 @@ from chainladder.core.io import EstimatorIO from chainladder.core.common import Common +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from chainladder.core import Triangle class MethodBase(BaseEstimator, EstimatorIO, Common):