From 1fa8b496379214d96d35caad2dfec79b671fc050 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Thu, 18 Jun 2026 17:25:47 +0000 Subject: [PATCH 01/18] WIP --- chainladder/adjustments/disposal.py | 227 ++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 chainladder/adjustments/disposal.py diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py new file mode 100644 index 00000000..20c99ac7 --- /dev/null +++ b/chainladder/adjustments/disposal.py @@ -0,0 +1,227 @@ +# 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 chainladder.methods.chainladder import Chainladder +from sklearn.base import BaseEstimator, TransformerMixin +import numpy as np +import copy +import warnings +from chainladder.core.io import EstimatorIO +from chainladder.utils import TriangleWeight + + +class DisposalRate(BaseEstimator, TransformerMixin, EstimatorIO): + """ + Class to alter the bottom of a full Triangle using the Disposal Rate method described + by Friedland. + + Parameters + ---------- + 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 + drop: tuple or list of tuples + 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. 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. 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. + 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. + 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. + 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. + + .. note :: + + (Order of Drop Operations) + + When multiple drop parameters are used together, the weights are built in this order (steps 4 and 5 are reversed from `Development`): + + 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_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). + 5. ``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). + 6. Calculate the loss development factors using ``average`` method. + + Attributes + ---------- + disposal_rate_tri: Triangle + actual disposal rates by origin and development + + disposal_rate_: Triangle + fitted disposal rates + + 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. + + .. 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(np.round(base.adjusted_triangle_["Incurred"], 0)) + + .. 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 + + .. testcode:: + + print(np.round(tilted.adjusted_triangle_["Incurred"], 0)) + + .. 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 + + """ + + def __init__( + self, + n_periods: int = -1, + drop: tuple | list[tuple] | None = None, + drop_high: bool | int | list[bool] | list[int] | None = None, + drop_low: bool | int | list[bool] | list[int] | None = None, + preserve: int = 1, + drop_valuation: str | list[str] = None, + drop_above: float = np.inf, + drop_below: float = 0.00, + ): + self.n_periods = n_periods + self.drop_high = drop_high + self.drop_low = drop_low + self.preserve = preserve + self.drop_valuation = drop_valuation + self.drop_above = drop_above + self.drop_below = drop_below + self.drop = drop + + def fit(self, X, y=None, sample_weight=None): + #check for ultimate_ + if hasattr(X, "ultimate_"): + pass + else: + raise ValueError("X must have ultimate_") + #convert to numpy + if X.array_backend == "sparse": + X = X.set_backend("numpy").incr_to_cum() + else: + X = X.copy().incr_to_cum() + if X.ultimate_.array_backend == "sparse": + ult = X.ultimate_.set_backend("numpy") + else: + X = X.ultimate_.copy() + #get backend + self.xp = X.get_array_module() + self.disposal_rate_tri = X / ult.values + tw = TriangleWeight( + n_periods = self.n_periods, + drop_high = self.drop_high, + drop_low = self.drop_low, + drop_above = self.drop_above, + drop_below = self.drop_below, + drop_valuation = self.drop_valuation, + preserve = self.preserve, + drop = self.drop + ) + if hasattr(X, "w_"): + self.w_ = tw.fit(X=self.disposal_rate_tri * X.w_).w_.values + else: + self.w_ = tw.fit(X=self.disposal_rate_tri).w_.values + #calculate factors + super().fit(ult.values,self.disposal_rate_tri.values,self.w_) + #keep attributes + self.zeta_ = self._param_property(self.disposal_rate_tri,self.params_.slope_[...,0][..., None, :]) + return self + + def predict(self, X): + """ If X and self are of different shapes, align self to X, else + return self. + + Parameters + ---------- + X: Triangle + The triangle to be transformed + + Returns + ------- + X_new: New triangle with transformed attributes. + """ + X_new = copy.deepcopy(X) + X_new[self.paid_amount] = self.adjusted_triangle_[self.paid_amount] + X_new[self.incurred_amount] = self.adjusted_triangle_[self.incurred_amount] + X_new[self.reported_count] = self.adjusted_triangle_[self.reported_count] + X_new[self.closed_count] = self.adjusted_triangle_[self.closed_count] + X_new.a_ = self.a_ + X_new.b_ = self.b_ + return X_new + + def set_params(self, **params): + from chainladder.utils.utility_functions import read_json + + if type(params["reported_count_estimator"]) is str: + params["reported_count_estimator"] = read_json( + params["reported_count_estimator"] + ) + return super().set_params(**params) From 562024e45f17383457e70bd937bd11850dbc3b98 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Fri, 19 Jun 2026 01:31:15 +0000 Subject: [PATCH 02/18] making progress --- chainladder/__init__.py | 1 + chainladder/adjustments/__init__.py | 2 ++ chainladder/adjustments/disposal.py | 14 +++++++------- chainladder/tests/test_public_api.py | 1 + 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index ea6f1aa2..a32a7a1b 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -445,6 +445,7 @@ def describe_option(self, pat: str = "", _print_desc: bool=True) -> None | str: ParallelogramOLF, Trend, TrendConstant, + DisposalRate, ) from chainladder.tails import ( # noqa (API import) TailBase, diff --git a/chainladder/adjustments/__init__.py b/chainladder/adjustments/__init__.py index 06b6d161..ad3a660b 100644 --- a/chainladder/adjustments/__init__.py +++ b/chainladder/adjustments/__init__.py @@ -3,6 +3,7 @@ from chainladder.adjustments.parallelogram import ParallelogramOLF # noqa (API import) from chainladder.adjustments.trend import Trend # noqa (API import) from chainladder.adjustments.trend import TrendConstant # noqa (API import) +from chainladder.adjustments.disposal import DisposalRate # noqa (API import) __all__ = [ "BootstrapODPSample", @@ -10,4 +11,5 @@ "ParallelogramOLF", "Trend", "TrendConstant", + "DisposalRate" ] diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index 20c99ac7..c35bad8e 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -2,16 +2,15 @@ # 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.chainladder import Chainladder -from sklearn.base import BaseEstimator, TransformerMixin +from chainladder.methods import Chainladder +from chainladder.development import DevelopmentBase import numpy as np import copy import warnings -from chainladder.core.io import EstimatorIO from chainladder.utils import TriangleWeight -class DisposalRate(BaseEstimator, TransformerMixin, EstimatorIO): +class DisposalRate(TriangleWeight): """ Class to alter the bottom of a full Triangle using the Disposal Rate method described by Friedland. @@ -140,11 +139,12 @@ class DisposalRate(BaseEstimator, TransformerMixin, EstimatorIO): def __init__( self, n_periods: int = -1, + average: str | list[str] = 'volume', drop: tuple | list[tuple] | None = None, drop_high: bool | int | list[bool] | list[int] | None = None, drop_low: bool | int | list[bool] | list[int] | None = None, preserve: int = 1, - drop_valuation: str | list[str] = None, + drop_valuation: str | list[str] | None = None, drop_above: float = np.inf, drop_below: float = 0.00, ): @@ -171,7 +171,7 @@ def fit(self, X, y=None, sample_weight=None): if X.ultimate_.array_backend == "sparse": ult = X.ultimate_.set_backend("numpy") else: - X = X.ultimate_.copy() + ult = X.ultimate_.copy() #get backend self.xp = X.get_array_module() self.disposal_rate_tri = X / ult.values @@ -190,7 +190,7 @@ def fit(self, X, y=None, sample_weight=None): else: self.w_ = tw.fit(X=self.disposal_rate_tri).w_.values #calculate factors - super().fit(ult.values,self.disposal_rate_tri.values,self.w_) + super().fit(ult,self.disposal_rate_tri,self.w_) #keep attributes self.zeta_ = self._param_property(self.disposal_rate_tri,self.params_.slope_[...,0][..., None, :]) return self diff --git a/chainladder/tests/test_public_api.py b/chainladder/tests/test_public_api.py index f01c629d..6ea1c331 100644 --- a/chainladder/tests/test_public_api.py +++ b/chainladder/tests/test_public_api.py @@ -56,6 +56,7 @@ "ParallelogramOLF", "Trend", "TrendConstant", + "DisposalRate", # tails "TailBase", "TailConstant", From a85c673f42bf585665bf78dba9ac7f01605339df Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Fri, 19 Jun 2026 01:44:25 +0000 Subject: [PATCH 03/18] promoting _param_property --- chainladder/development/base.py | 48 ++++++++++++++++++++++---- chainladder/development/incremental.py | 16 +-------- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/chainladder/development/base.py b/chainladder/development/base.py index 47c13081..7e34fd7f 100644 --- a/chainladder/development/base.py +++ b/chainladder/development/base.py @@ -21,7 +21,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from chainladder.core.typing import TriangleLike + from chainladder.core import Triangle class DevelopmentBase( @@ -45,18 +45,18 @@ def fit(self, X, y=None, sample_weight=None): def _set_fit_groups( self, - X: TriangleLike - ) -> TriangleLike: + X: Triangle + ) -> Triangle: """ Used for assigning group_index in fit. Parameters ---------- - X: TriangleLike + X: Triangle Returns ------- - TriangleLike, after performing the groupby on it. + Triangle, after performing the groupby on it. """ backend = "numpy" if X.array_backend in ["sparse", "numpy"] else "cupy" @@ -383,4 +383,40 @@ def _drop(self, X): np.where(X.origin == item[0])[0][0], np.where(X.development == item[1])[0][0], ] = 0 - return arr[:, :-1] \ No newline at end of file + return arr[:, :-1] + + @staticmethod + def _param_property( + self, + X: Triangle, + params: np.ndarray + ) -> Triangle: + """ + Wrap an array of estimated parameters in a Triangle + + Parameters + ---------- + X: Triangle + The Triangle to wrap the parameters with + + params: np.ndarray + The parameters to be wrapped + + Returns + ------- + Triangle + The wrapped parameters + + """ + from chainladder import options + + obj: Triangle = X[X.origin == X.origin.min()] + xp = X.get_array_module() + obj.values = params + obj.valuation_date = pd.to_datetime(options.ULT_VAL) + obj.is_pattern = True + obj.is_additive = True + obj.is_cumulative = False + obj.virtual_columns.columns = {} + obj._set_slicers() + return obj diff --git a/chainladder/development/incremental.py b/chainladder/development/incremental.py index a79df253..90e80777 100644 --- a/chainladder/development/incremental.py +++ b/chainladder/development/incremental.py @@ -294,18 +294,4 @@ def transform(self, X): X_new = X.copy() for item in ["ldf_", "w_", "zeta_", "incremental_", "tri_zeta", "fit_zeta_", "sample_weight"]: X_new.__dict__[item] = self.__dict__[item] - return X_new - - def _param_property(self, factor, params): - from chainladder import options - - obj = factor[factor.origin == factor.origin.min()] - xp = factor.get_array_module() - obj.values = params - obj.valuation_date = pd.to_datetime(options.ULT_VAL) - obj.is_pattern = True - obj.is_additive = True - obj.is_cumulative = False - obj.virtual_columns.columns = {} - obj._set_slicers() - return obj + return X_new \ No newline at end of file From d18939452e70f39404c3ec9ba7aa7b03bb2c676c Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Fri, 19 Jun 2026 01:58:09 +0000 Subject: [PATCH 04/18] fixing bug --- chainladder/development/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/chainladder/development/base.py b/chainladder/development/base.py index 7e34fd7f..49c1192e 100644 --- a/chainladder/development/base.py +++ b/chainladder/development/base.py @@ -387,7 +387,6 @@ def _drop(self, X): @staticmethod def _param_property( - self, X: Triangle, params: np.ndarray ) -> Triangle: From 40f977f5b5879835b246baa09ccc1e0ce3d371c9 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Fri, 19 Jun 2026 06:09:50 +0000 Subject: [PATCH 05/18] full implementation w/ test --- chainladder/adjustments/disposal.py | 115 +++++++++++++----- .../adjustments/tests/test_disposal.py | 31 +++++ 2 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 chainladder/adjustments/tests/test_disposal.py diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index c35bad8e..560bb20e 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -7,12 +7,12 @@ import numpy as np import copy import warnings -from chainladder.utils import TriangleWeight +from chainladder.utils import TriangleWeight, concat +from chainladder import Triangle - -class DisposalRate(TriangleWeight): +class DisposalRate(DevelopmentBase): """ - Class to alter the bottom of a full Triangle using the Disposal Rate method described + Calculates the bottom of a fitted full_triangle_ using the Disposal Rate method described by Friedland. Parameters @@ -70,9 +70,12 @@ class DisposalRate(TriangleWeight): disposal_rate_tri: Triangle actual disposal rates by origin and development - disposal_rate_: Triangle + disposal_: Triangle fitted disposal rates + incr_disposal_: Triangle + incremental of disposal_ + Examples -------- ``trend`` tilts the case-adequacy adjustment before ``Incurred`` is rebuilt; @@ -149,6 +152,7 @@ def __init__( drop_below: float = 0.00, ): self.n_periods = n_periods + self.average = average self.drop_high = drop_high self.drop_low = drop_low self.preserve = preserve @@ -157,21 +161,41 @@ def __init__( self.drop_below = drop_below self.drop = drop - def fit(self, X, y=None, sample_weight=None): - #check for ultimate_ - if hasattr(X, "ultimate_"): - pass - else: - raise ValueError("X must have ultimate_") + def fit( + self, + X:Triangle, + y:None=None, + sample_weight:Triangle|None=None + ): + """ + Estimate disposal rate for a given Triangle and ultimate + + Parameters + ---------- + X : Triangle + Triangle to which the Disposal Rate method is applied + y : None + Ignored + sample_weight : Triangle + Ultimate + + Returns + ------- + self : object + Returns the instance itself. + + """ + 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 X.ultimate_.array_backend == "sparse": - ult = X.ultimate_.set_backend("numpy") + if sample_weight.array_backend == "sparse": + ult = sample_weight.set_backend("numpy") else: - ult = X.ultimate_.copy() + ult = sample_weight.copy() #get backend self.xp = X.get_array_module() self.disposal_rate_tri = X / ult.values @@ -190,12 +214,22 @@ def fit(self, X, y=None, sample_weight=None): else: self.w_ = tw.fit(X=self.disposal_rate_tri).w_.values #calculate factors - super().fit(ult,self.disposal_rate_tri,self.w_) + super().fit(ult.values,X.values,self.w_) #keep attributes - self.zeta_ = self._param_property(self.disposal_rate_tri,self.params_.slope_[...,0][..., None, :]) + 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_.is_cumulative = True + self.disposal_.is_pattern = False + self.incr_disposal_ = self.disposal_.cum_to_incr() + self.incr_disposal_.is_pattern = True + self.disposal_.is_pattern = True return self - def predict(self, X): + def transform( + self, + X: Triangle, + sample_weight: Triangle | None = None + ) -> Triangle: """ If X and self are of different shapes, align self to X, else return self. @@ -204,24 +238,45 @@ def predict(self, X): X: Triangle The triangle to be transformed + sample_weight: Triangle + Ultimate + Returns ------- X_new: New triangle with transformed attributes. """ + if sample_weight is None: + raise ValueError("sample_weight is required.") X_new = copy.deepcopy(X) - X_new[self.paid_amount] = self.adjusted_triangle_[self.paid_amount] - X_new[self.incurred_amount] = self.adjusted_triangle_[self.incurred_amount] - X_new[self.reported_count] = self.adjusted_triangle_[self.reported_count] - X_new[self.closed_count] = self.adjusted_triangle_[self.closed_count] - X_new.a_ = self.a_ - X_new.b_ = self.b_ + 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] + X_new.ldf_ = (X_new.cum_to_incr() + run_off).incr_to_cum().age_to_age return X_new + + def fit_transform(self, X, y=None, sample_weight=None): + """Fit and return predictions for VotingChainladder - def set_params(self, **params): - from chainladder.utils.utility_functions import read_json + Parameters + ---------- + X : Triangle + Loss data to which the model will be applied. - if type(params["reported_count_estimator"]) is str: - params["reported_count_estimator"] = read_json( - params["reported_count_estimator"] - ) - return super().set_params(**params) + y : None + Ignored + + sample_weight : Triangle, default=None + Ultimate + + Returns + ------- + X_new: Triangle + Loss data with VotingChainladder ultimate applied + """ + return self.fit(X, y, sample_weight).transform(X, sample_weight=sample_weight) + def _test(self, X, ult): + return 'test' \ No newline at end of file diff --git a/chainladder/adjustments/tests/test_disposal.py b/chainladder/adjustments/tests/test_disposal.py new file mode 100644 index 00000000..db04959f --- /dev/null +++ b/chainladder/adjustments/tests/test_disposal.py @@ -0,0 +1,31 @@ +import chainladder as cl +import numpy as np + + +def test_disposal(): + tri = cl.load_sample('friedland_gl_insurer')['Closed Claim Counts'] + ult_tri = cl.Triangle( + data = { + 'Closed Claim Counts':[873,720,626,629,588,553,438,609], + 'ay': [2001,2002,2003,2004,2005,2006,2007,2008], + 'dev':[2008,2008,2008,2008,2008,2008,2008,2008], + }, + origin = 'ay', + development='dev', + columns='Closed Claim Counts', + cumulative=True, + ) + dr = cl.DisposalRate(n_periods = 5, average = 'simple', drop_high = 1, drop_low = 1).fit_transform(X=tri,sample_weight=ult_tri) + assert np.all(dr.disposal_.round(3).values.flatten() - [.200,.433,.585,.710,.791,.862,.882,.912,1.000] <=0.001) + lhs = (dr.full_triangle_.cum_to_incr()-tri.cum_to_incr()).round(0).values.flatten() + rhs = np.array([ + 77., + 24., 70., + 12., 18., 54., + 46., 13., 19., 57., + 52., 45., 13., 19., 56., + 76., 49., 43., 12., 18., 54., + 67., 55., 36., 31., 9., 13., 39., + 140., 91., 75., 49., 42., 12., 18., 53. + ]) + assert np.all(lhs[~np.isnan(lhs)] - rhs <= 1) \ No newline at end of file From 7ea90e09bd3cf00f8154eb98dda3bbdfae46102b Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Fri, 19 Jun 2026 06:42:28 +0000 Subject: [PATCH 06/18] bugbot and tests --- chainladder/adjustments/disposal.py | 9 +++------ chainladder/adjustments/tests/test_disposal.py | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index 560bb20e..ed1fa61e 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -6,7 +6,6 @@ from chainladder.development import DevelopmentBase import numpy as np import copy -import warnings from chainladder.utils import TriangleWeight, concat from chainladder import Triangle @@ -259,7 +258,7 @@ def transform( return X_new def fit_transform(self, X, y=None, sample_weight=None): - """Fit and return predictions for VotingChainladder + """Fit and return transformed full_triangle_ based on the Disposal Rate Parameters ---------- @@ -275,8 +274,6 @@ def fit_transform(self, X, y=None, sample_weight=None): Returns ------- X_new: Triangle - Loss data with VotingChainladder ultimate applied + Triangle with new full_triangle_ """ - return self.fit(X, y, sample_weight).transform(X, sample_weight=sample_weight) - def _test(self, X, ult): - return 'test' \ No newline at end of file + return self.fit(X, y, sample_weight).transform(X, sample_weight=sample_weight) \ No newline at end of file diff --git a/chainladder/adjustments/tests/test_disposal.py b/chainladder/adjustments/tests/test_disposal.py index db04959f..322c6d34 100644 --- a/chainladder/adjustments/tests/test_disposal.py +++ b/chainladder/adjustments/tests/test_disposal.py @@ -16,7 +16,7 @@ def test_disposal(): cumulative=True, ) dr = cl.DisposalRate(n_periods = 5, average = 'simple', drop_high = 1, drop_low = 1).fit_transform(X=tri,sample_weight=ult_tri) - assert np.all(dr.disposal_.round(3).values.flatten() - [.200,.433,.585,.710,.791,.862,.882,.912,1.000] <=0.001) + assert np.all(abs(dr.disposal_.round(3).values.flatten() - [.200,.433,.585,.710,.791,.862,.882,.912,1.000] <=0.001)) lhs = (dr.full_triangle_.cum_to_incr()-tri.cum_to_incr()).round(0).values.flatten() rhs = np.array([ 77., @@ -28,4 +28,15 @@ def test_disposal(): 67., 55., 36., 31., 9., 13., 39., 140., 91., 75., 49., 42., 12., 18., 53. ]) - assert np.all(lhs[~np.isnan(lhs)] - rhs <= 1) \ No newline at end of file + assert np.all(abs(lhs[~np.isnan(lhs)] - rhs <= 1)) + +def test_disposal_no_weight(raa): + tri = raa.set_backend('sparse') + with pytest.raises(ValueError): + dr = cl.DisposalRate().fit(tri) + ult = cl.Chainladder().fit(tri).ultimate_ + dr = cl.DisposalRate().fit(tri,sample_weight=ult) + with pytest.raises(ValueError): + est = dr.transform(tri) + + \ No newline at end of file From 252878e9aa3a270a8b9454ab75c45f719ca82f94 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Fri, 19 Jun 2026 06:51:29 +0000 Subject: [PATCH 07/18] test fix --- chainladder/adjustments/tests/test_disposal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chainladder/adjustments/tests/test_disposal.py b/chainladder/adjustments/tests/test_disposal.py index 322c6d34..fd899173 100644 --- a/chainladder/adjustments/tests/test_disposal.py +++ b/chainladder/adjustments/tests/test_disposal.py @@ -1,6 +1,6 @@ import chainladder as cl import numpy as np - +import pytest def test_disposal(): tri = cl.load_sample('friedland_gl_insurer')['Closed Claim Counts'] From c6588fe50fca5397cd467842d140e92c87988034 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sat, 20 Jun 2026 23:22:33 +0000 Subject: [PATCH 08/18] 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 09/18] 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 10/18] 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 1b2fdee999edd5eb4973a69f98f9e9bc148f5bc9 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sat, 20 Jun 2026 23:22:33 +0000 Subject: [PATCH 11/18] 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 359eebb41e8f75d79ba34bc995c5861b3bea5a74 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sat, 20 Jun 2026 23:33:03 +0000 Subject: [PATCH 12/18] 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 d6bbdac03dd75d14ca6dbda57171f0d56aae24ae 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 13/18] 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 14/18] 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 15/18] 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 16/18] 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 17/18] 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): From ba539b1dfb3f160b939cdaefdcf2df0e618969f4 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Sun, 21 Jun 2026 07:15:36 +0000 Subject: [PATCH 18/18] more fixes --- chainladder/adjustments/disposal.py | 4 ++-- chainladder/adjustments/tests/test_disposal.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index cd4e811d..28b3e728 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -211,8 +211,8 @@ def fit( ult = sample_weight.copy() #calculate disposal rate triangle self.xp = X.get_array_module() - self.X_ = X.sort_index() - self.disposal_rate_tri = X / ult.values + self.X_ = X.incr_to_cum().sort_index() + self.disposal_rate_tri = self.X_ / ult.values #get weights for estimation tw = TriangleWeight( n_periods = self.n_periods, diff --git a/chainladder/adjustments/tests/test_disposal.py b/chainladder/adjustments/tests/test_disposal.py index 7f745218..8b298c10 100644 --- a/chainladder/adjustments/tests/test_disposal.py +++ b/chainladder/adjustments/tests/test_disposal.py @@ -50,7 +50,8 @@ def test_cl_parity(raa): def test_sparse_transform(raa): raa_sparse = raa.set_backend('sparse') + ult = cl.Chainladder().fit(raa_sparse).ultimate_.set_backend('sparse') + dr = cl.DisposalRate().fit_transform(raa_sparse,sample_weight=ult) from chainladder.utils.sparse import sp - dr = cl.DisposalRate().fit_transform(raa_sparse,sample_weight=cl.Chainladder().fit(raa_sparse).ultimate_) assert isinstance(dr.full_triangle_.values,sp.COO)