Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1fa8b49
WIP
henrydingliu Jun 18, 2026
15d9ad6
Merge branch 'casact:main' into disposal
henrydingliu Jun 18, 2026
562024e
making progress
henrydingliu Jun 19, 2026
a85c673
promoting _param_property
henrydingliu Jun 19, 2026
d189394
fixing bug
henrydingliu Jun 19, 2026
2ce6895
Merge pull request #12 from henrydingliu/master
henrydingliu Jun 19, 2026
40f977f
full implementation w/ test
henrydingliu Jun 19, 2026
06f5676
Merge pull request #1021 from henrydingliu/disposal
henrydingliu Jun 19, 2026
7ea90e0
bugbot and tests
henrydingliu Jun 19, 2026
252878e
test fix
henrydingliu Jun 19, 2026
99b7219
Merge pull request #1023 from henrydingliu/disposal
henrydingliu Jun 19, 2026
c6588fe
docstring and additional tests
henrydingliu Jun 20, 2026
3bb4e35
bugbot proof
henrydingliu Jun 20, 2026
4b8dea4
Update test_disposal.py
henrydingliu Jun 21, 2026
1b2fdee
docstring and additional tests
henrydingliu Jun 20, 2026
359eebb
bugbot proof
henrydingliu Jun 20, 2026
d6bbdac
Update test_disposal.py
henrydingliu Jun 21, 2026
3726b60
more bugbot fixes
henrydingliu Jun 21, 2026
0ffc46e
bug fix
henrydingliu Jun 21, 2026
a232d5e
fix
henrydingliu Jun 21, 2026
b736bc0
forcing numpy after all
henrydingliu Jun 21, 2026
0c1830d
Merge branch 'disposal_rate_method' into disposal
henrydingliu Jun 21, 2026
87857fe
Merge pull request #1039 from henrydingliu/disposal
henrydingliu Jun 21, 2026
6eb6afd
more fixes
henrydingliu Jun 21, 2026
ba539b1
more fixes
henrydingliu Jun 21, 2026
7459e8e
Merge pull request #1041 from casact/main
henrydingliu Jun 21, 2026
663a860
addressing reviewer comments
henrydingliu Jun 26, 2026
df8e2f9
added missing dependencies
henrydingliu Jun 26, 2026
335ddca
Merge pull request #1048 from henrydingliu/disposal
henrydingliu Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions chainladder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,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,
Expand Down
2 changes: 2 additions & 0 deletions chainladder/adjustments/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
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",
"BerquistSherman",
"ParallelogramOLF",
"Trend",
"TrendConstant",
"DisposalRate"
]
299 changes: 299 additions & 0 deletions chainladder/adjustments/disposal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
# 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 typing import TYPE_CHECKING

if TYPE_CHECKING:
from chainladder.core import Triangle


class DisposalRate(DevelopmentBase):
"""
Calculates the bottom of a fitted 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_: Triangle
fitted disposal rates

incr_disposal_: Triangle
incremental of disposal_

Examples
--------
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
import numpy as np

.. testcode::

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 examine the emergence pattern via `disposal_rate_tri`.

.. testcode::

dr.disposal_rate_tri

.. testoutput::

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::

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::

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

"""

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 = None,
drop_above: float = np.inf,
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
self.drop_valuation = drop_valuation
self.drop_above = drop_above
self.drop_below = drop_below
self.drop = drop

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.")
#validate dimensions of sample weight
MethodBase().validate_weight(X, sample_weight)
#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.incr_to_cum().sort_index()
self.disposal_rate_tri = self.X_ / ult.values
#get weights for estimation
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(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,self.X_.values,self.w_)
Comment thread
cursor[bot] marked this conversation as resolved.
#keep attributes
self.disposal_ = self._param_property(self.disposal_rate_tri,self.params_.slope_[...,0][..., None, :])
self.disposal_ = concat((self.disposal_,(self.X_.latest_diagonal*0 + 1).iloc[:,:,0,:].rename("development", [9999])),axis=3)
Comment thread
genedan marked this conversation as resolved.
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
self.disposal_.is_pattern = True
return self

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.

Parameters
----------
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)
#validate dimensions of 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
X_new.disposal_ = self.disposal_
X_new.incr_disposal_ = self.incr_disposal_
ibnr_pct = 1 - X_new.disposal_.align_pattern(X_new.disposal_rate_tri)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale disposal rates in transform

Medium Severity

transform always assigns disposal_rate_tri from the fitted object instead of deriving it from the current X and sample_weight. align_pattern then uses that stale triangle to position the fitted pattern, so updated diagonals or a different X than in fit can yield wrong ibnr_pct, run-off, and ldf_ unless fit is run again on the same data.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 335ddca. Configure here.

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
Comment thread
cursor[bot] marked this conversation as resolved.
return X_new
Comment thread
cursor[bot] marked this conversation as resolved.

def fit_transform(self, X, y=None, sample_weight=None):
"""Fit and return transformed full_triangle_ based on the Disposal Rate

Parameters
----------
X : Triangle
Loss data to which the model will be applied.

y : None
Ignored

sample_weight : Triangle, default=None
Ultimate

Returns
-------
X_new: Triangle
Triangle with new full_triangle_
"""
return self.fit(X, y, sample_weight).transform(X, sample_weight=sample_weight)
71 changes: 71 additions & 0 deletions chainladder/adjustments/tests/test_disposal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

import chainladder as cl
import numpy as np
import pytest

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from chainladder.core import Triangle

def test_friedland_fidelity() -> None:
'''
Reconciles to Chapter 11 Exhibit 5 of the Friedland reserving textbook
'''
tri = cl.load_sample('friedland_gl_insurer')
ccc_dev = cl.Development(n_periods=3, average='volume').fit_transform(tri['Closed Claim Counts'])
ccc_dev.ldf_ = ccc_dev.ldf_.round(3)
ccc_dev_wtail = cl.TailConstant(tail = 1.100, projection_period = 0).fit_transform(ccc_dev)
ccc_ult = cl.Chainladder().fit(ccc_dev_wtail).ultimate_
rcc_dev = cl.Development(n_periods=3, average='volume').fit_transform(tri['Reported Claim Counts'])
rcc_dev.ldf_ = rcc_dev.ldf_.round(3)
rcc_ult = cl.Chainladder().fit(rcc_dev).ultimate_
ult = (ccc_ult + rcc_ult) / 2
dr = cl.DisposalRate(n_periods = 5, average = 'simple', drop_high = 1, drop_low = 1).fit_transform(X=tri['Closed Claim Counts'],sample_weight=ult)
assert np.all(dr.disposal_.round(3).values.flatten() == [.200,.433,.585,.710,.791,.862,.882,.912,1.000])
#Friedland uses rounded ultimates to calculate bottom half of the triangle, which introduces some rounding discrepancies with the implementation
lhs = (dr.full_triangle_.cum_to_incr()-tri['Closed Claim Counts'].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., 43., 12., 18., 53.
])
assert np.all(abs(lhs[~np.isnan(lhs)] - rhs <= 1))
Comment thread
genedan marked this conversation as resolved.

def test_no_weight_exception(raa:Triangle) -> None:
'''
sample_weight is optional in the default sklearn API. however, we require sample_weight to provide the a priori ultimate.
'''
with pytest.raises(ValueError):
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(raa)

def test_cl_parity(raa:Triangle) -> None:
"""
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])

def test_sparse_transform(raa:Triangle) -> None:
"""
if the supplied Triangle is sparse, then the resulting full_triangle_ is also sparse
"""
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
assert isinstance(dr.full_triangle_.values,sp.COO)

Loading
Loading