From e8407ce6ee919dc834a7f2a657be7343e5977168 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Mon, 9 Nov 2026 21:09:18 +0530 Subject: [PATCH] Support CaseOutstanding medial averages --- chainladder/core/pandas.py | 33 ++- chainladder/development/outstanding.py | 188 +++++++++++++++--- .../development/tests/test_outstanding.py | 118 +++++++++++ 3 files changed, 296 insertions(+), 43 deletions(-) diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index cd0defa5f..ed63c467e 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -1321,24 +1321,21 @@ def agg_func( func: Callable = getattr(xp, v) kwargs.update({"keepdims": True}) # Apply the function on the requested axis. - if axis in [0, 1] and not obj.is_full: - with warnings.catch_warnings(): - # the lower right half of an undeveloped triangle will always emit an - # empty or All-NaN slice warning when aggregating by index or column - # (depending on `func`; e.g. nanmean warns "empty slice", - # nanmax warns All-NaN slice - warnings.filterwarnings( - "ignore", - category=RuntimeWarning, - message=".*empty slice.*", - ) - warnings.filterwarnings( - "ignore", - category=RuntimeWarning, - message=".*All-NaN slice.*", - ) - obj.values = func(obj.values, axis=axis, *args, **kwargs) - else: + with warnings.catch_warnings(): + # Undeveloped triangles or all-NaN slices can emit an + # empty or All-NaN slice warning when aggregating + # (depending on `func`; e.g. nanmean warns "empty slice", + # nanmax warns "All-NaN slice") + warnings.filterwarnings( + "ignore", + category=RuntimeWarning, + message=".*empty slice.*", + ) + warnings.filterwarnings( + "ignore", + category=RuntimeWarning, + message=".*All-NaN slice.*", + ) obj.values = func(obj.values, axis=axis, *args, **kwargs) # Aggregation function will collapse a dimension, so diff --git a/chainladder/development/outstanding.py b/chainladder/development/outstanding.py index cc76ae231..183381cfa 100644 --- a/chainladder/development/outstanding.py +++ b/chainladder/development/outstanding.py @@ -5,8 +5,9 @@ from typing import TYPE_CHECKING, Any, Literal -from chainladder.development import Development, DevelopmentBase +from chainladder.development import DevelopmentBase from chainladder import options +from chainladder.utils import TriangleWeight from chainladder.utils.utility_functions import concat from chainladder.utils.utility_functions import num_to_nan @@ -49,8 +50,48 @@ class CaseOutstanding(DevelopmentBase): case_n_periods: integer, optional (default=-1) number of origin periods to be used in the case pattern averages. For all origin periods, set case_n_periods=-1 + paid_drop_high: bool, int, list of bools, or list of ints (default=None) + Drops highest link ratio(s) from paid pattern average calculation. + paid_drop_low: bool, int, list of bools, or list of ints (default=None) + Drops lowest link ratio(s) from paid pattern average calculation. + case_drop_high: bool, int, list of bools, or list of ints (default=None) + Drops highest link ratio(s) from case pattern average calculation. + case_drop_low: bool, int, list of bools, or list of ints (default=None) + Drops lowest link ratio(s) from case pattern average calculation. + paid_drop: tuple or list of tuples (default=None) + Drops specific origin/development combination(s) from paid pattern. + case_drop: tuple or list of tuples (default=None) + Drops specific origin/development combination(s) from case pattern. + paid_drop_valuation: str or list of str (default=None) + Drops specific valuation periods from paid pattern. + case_drop_valuation: str or list of str (default=None) + Drops specific valuation periods from case pattern. + paid_drop_above: float (default=np.inf) + Drops paid ratios above given parameter. + case_drop_above: float (default=np.inf) + Drops case ratios above given parameter. + paid_drop_below: float (default=0.00) + Drops paid ratios below given parameter. + case_drop_below: float (default=0.00) + Drops case ratios below given parameter. + paid_preserve: int (default=1) + Minimum number of ratios required for paid pattern calculation. + case_preserve: int (default=1) + Minimum number of ratios required for case pattern calculation. groupby: optional Grouping option. + n_periods: integer, optional (default=None) + Shorthand setting both paid_n_periods and case_n_periods if not explicitly specified. + drop_high: bool, int, or list (default=None) + Shorthand setting both paid_drop_high and case_drop_high if not explicitly specified. + drop_low: bool, int, or list (default=None) + Shorthand setting both paid_drop_low and case_drop_low if not explicitly specified. + drop: tuple or list of tuples (default=None) + Shorthand setting both paid_drop and case_drop if not explicitly specified. + drop_valuation: str or list of str (default=None) + Shorthand setting both paid_drop_valuation and case_drop_valuation if not explicitly specified. + preserve: int, optional (default=None) + Shorthand setting both paid_preserve and case_preserve if not explicitly specified. Attributes ---------- @@ -135,12 +176,52 @@ def __init__( paid_to_incurred=None, paid_n_periods=-1, case_n_periods=-1, + paid_drop_high=None, + paid_drop_low=None, + case_drop_high=None, + case_drop_low=None, + paid_drop=None, + case_drop=None, + paid_drop_valuation=None, + case_drop_valuation=None, + paid_drop_above=np.inf, + case_drop_above=np.inf, + paid_drop_below=0.00, + case_drop_below=0.00, + paid_preserve=1, + case_preserve=1, groupby=None, + n_periods=None, + drop_high=None, + drop_low=None, + drop=None, + drop_valuation=None, + preserve=None, ): self.paid_to_incurred = paid_to_incurred self.paid_n_periods = paid_n_periods self.case_n_periods = case_n_periods + self.paid_drop_high = paid_drop_high + self.paid_drop_low = paid_drop_low + self.case_drop_high = case_drop_high + self.case_drop_low = case_drop_low + self.paid_drop = paid_drop + self.case_drop = case_drop + self.paid_drop_valuation = paid_drop_valuation + self.case_drop_valuation = case_drop_valuation + self.paid_drop_above = paid_drop_above + self.case_drop_above = case_drop_above + self.paid_drop_below = paid_drop_below + self.case_drop_below = case_drop_below + self.paid_preserve = paid_preserve + self.case_preserve = case_preserve self.groupby = groupby + self.n_periods = n_periods + self.drop_high = drop_high + self.drop_low = drop_low + self.drop = drop + self.drop_valuation = drop_valuation + self.preserve = preserve def fit(self, X, y=None, sample_weight=None): """ @@ -161,24 +242,87 @@ def fit(self, X, y=None, sample_weight=None): backend = "cupy" if X.array_backend == "cupy" else "numpy" self.X_ = X.copy() - self.paid_w_ = ( - Development(n_periods=self.paid_n_periods).fit(self.X_.sum(0).sum(1)).w_ + paid_n_periods = ( + self.paid_n_periods + if self.paid_n_periods != -1 or self.n_periods is None + else self.n_periods ) - self.case_w_ = ( - Development(n_periods=self.case_n_periods).fit(self.X_.sum(0).sum(1)).w_ + paid_drop_high = ( + self.paid_drop_high if self.paid_drop_high is not None else self.drop_high + ) + paid_drop_low = ( + self.paid_drop_low if self.paid_drop_low is not None else self.drop_low + ) + paid_drop = self.paid_drop if self.paid_drop is not None else self.drop + paid_drop_valuation = ( + self.paid_drop_valuation + if self.paid_drop_valuation is not None + else self.drop_valuation + ) + paid_drop_above = self.paid_drop_above + paid_drop_below = self.paid_drop_below + paid_preserve = ( + self.paid_preserve + if self.paid_preserve != 1 or self.preserve is None + else self.preserve ) - case_to_prior_case_naned = self.case_to_prior_case_ - case_to_prior_case_naned.values = num_to_nan(self.case_to_prior_case_.values) - - self.case_ldf_ = self.case_to_prior_case_.mean(2) # this has the wrong value - self.case_ldf_.values = case_to_prior_case_naned.mean(axis=2).values - - paid_to_prior_case_naned = self.paid_to_prior_case_ - paid_to_prior_case_naned.values = num_to_nan(self.paid_to_prior_case_.values) + case_n_periods = ( + self.case_n_periods + if self.case_n_periods != -1 or self.n_periods is None + else self.n_periods + ) + case_drop_high = ( + self.case_drop_high if self.case_drop_high is not None else self.drop_high + ) + case_drop_low = ( + self.case_drop_low if self.case_drop_low is not None else self.drop_low + ) + case_drop = self.case_drop if self.case_drop is not None else self.drop + case_drop_valuation = ( + self.case_drop_valuation + if self.case_drop_valuation is not None + else self.drop_valuation + ) + case_drop_above = self.case_drop_above + case_drop_below = self.case_drop_below + case_preserve = ( + self.case_preserve + if self.case_preserve != 1 or self.preserve is None + else self.preserve + ) - self.paid_ldf_ = self.paid_to_prior_case_.mean(2) # this has the wrong value - self.paid_ldf_.values = paid_to_prior_case_naned.mean(axis=2).values + paid_ratio = self.paid_to_prior_case_ + tw_paid = TriangleWeight( + n_periods=paid_n_periods, + drop=paid_drop, + drop_high=paid_drop_high, + drop_low=paid_drop_low, + drop_valuation=paid_drop_valuation, + drop_above=paid_drop_above, + drop_below=paid_drop_below, + preserve=paid_preserve, + ).fit(paid_ratio) + self.paid_w_ = tw_paid.w_ + self.paid_ldf_ = (paid_ratio * self.paid_w_).mean(axis=2) + xp = self.paid_ldf_.get_array_module() + self.paid_ldf_.values[xp.isnan(self.paid_ldf_.values)] = 0.0 + + case_ratio = self.case_to_prior_case_ + tw_case = TriangleWeight( + n_periods=case_n_periods, + drop=case_drop, + drop_high=case_drop_high, + drop_low=case_drop_low, + drop_valuation=case_drop_valuation, + drop_above=case_drop_above, + drop_below=case_drop_below, + preserve=case_preserve, + ).fit(case_ratio) + self.case_w_ = tw_case.w_ + self.case_ldf_ = (case_ratio * self.case_w_).mean(axis=2) + xp = self.case_ldf_.get_array_module() + self.case_ldf_.values[xp.isnan(self.case_ldf_.values)] = 1.0 self.ldf_ = self._set_ldf(self.X_).set_backend(backend) @@ -271,11 +415,8 @@ def case_to_prior_case_(self): paid_tri = paid_tri.groupby(self.groupby).sum() incurred_tri = incurred_tri.groupby(self.groupby).sum() - out = ( - (incurred_tri - paid_tri).iloc[..., 1:] - * self.case_w_ - / (incurred_tri - paid_tri).iloc[..., :-1].values - ) + prior_case = num_to_nan((incurred_tri - paid_tri).iloc[..., :-1].values) + out = (incurred_tri - paid_tri).iloc[..., 1:] / prior_case out.is_pattern = True out.is_cumulative = False @@ -290,11 +431,8 @@ def paid_to_prior_case_(self): paid_tri = paid_tri.groupby(self.groupby).sum() incurred_tri = incurred_tri.groupby(self.groupby).sum() - out = ( - paid_tri.cum_to_incr().iloc[..., 1:] - * self.paid_w_ - / (incurred_tri - paid_tri).iloc[..., :-1].values - ) + prior_case = num_to_nan((incurred_tri - paid_tri).iloc[..., :-1].values) + out = paid_tri.cum_to_incr().iloc[..., 1:] / prior_case out.is_pattern = True return out diff --git a/chainladder/development/tests/test_outstanding.py b/chainladder/development/tests/test_outstanding.py index c177d70a5..c91a399d6 100644 --- a/chainladder/development/tests/test_outstanding.py +++ b/chainladder/development/tests/test_outstanding.py @@ -320,3 +320,121 @@ def test_approach_2_single_channel_case_triangle_and_transform(): ).fit(tri_grouped) assert m_grp.case_to_prior_case_ is not None assert m_grp.paid_to_prior_case_ is not None + + +def test_case_outstanding_medial_averages(): + """Verify medial averages (5x1) in CaseOutstanding match Friedland Ch 12 Exhibit I.""" + tri = cl.load_sample("usauto") + model = cl.CaseOutstanding( + paid_to_incurred=("paid", "incurred"), + paid_n_periods=5, + paid_drop_high=1, + paid_drop_low=1, + case_n_periods=5, + case_drop_high=1, + case_drop_low=1, + ).fit(tri) + + expected_paid_medial = np.array([ + [0.835, 0.712, 0.714, 0.692, 0.641, 0.624, 0.546, 0.437, 0.524] + ]) + assert np.allclose( + model.paid_ldf_.to_frame(origin_as_datetime=False).values, + expected_paid_medial, + atol=1e-3, + ) + + expected_case_medial = np.array([ + [0.527, 0.562, 0.530, 0.488, 0.515, 0.542, 0.642, 0.674, 0.580] + ]) + assert np.allclose( + model.case_ldf_.to_frame(origin_as_datetime=False).values, + expected_case_medial, + atol=1e-3, + ) + + # Shorthand parameter equivalence + model_short = cl.CaseOutstanding( + paid_to_incurred=("paid", "incurred"), + n_periods=5, + drop_high=1, + drop_low=1, + ).fit(tri) + assert np.allclose(model.paid_ldf_.values, model_short.paid_ldf_.values) + assert np.allclose(model.case_ldf_.values, model_short.case_ldf_.values) + + # Asymmetric selection: paid 5x1 medial and case 3-year latest + m_asym = cl.CaseOutstanding( + paid_to_incurred=("paid", "incurred"), + paid_n_periods=5, + paid_drop_high=1, + paid_drop_low=1, + case_n_periods=3, + ).fit(tri) + assert np.allclose(m_asym.paid_ldf_.values, model.paid_ldf_.values) + m_3 = cl.CaseOutstanding( + paid_to_incurred=("paid", "incurred"), + paid_n_periods=3, + case_n_periods=3, + ).fit(tri) + assert np.allclose(m_asym.case_ldf_.values, m_3.case_ldf_.values) + + +def test_case_outstanding_unweighted_ratios_preserved(): + """Verify diagnostic ratios remain unweighted across historical origin periods.""" + tri = cl.load_sample("usauto") + m = cl.CaseOutstanding( + paid_to_incurred=("paid", "incurred"), + n_periods=3, + drop_high=1, + drop_low=1, + ).fit(tri) + + paid_ratios = m.paid_to_prior_case_.to_frame() + case_ratios = m.case_to_prior_case_.to_frame() + + assert (paid_ratios.iloc[:-3] != 0).any().any() + assert (case_ratios.iloc[:-3] != 0).any().any() + + m_full = cl.CaseOutstanding(paid_to_incurred=("paid", "incurred")).fit(tri) + assert np.allclose( + m.paid_to_prior_case_.values, m_full.paid_to_prior_case_.values, equal_nan=True + ) + assert np.allclose( + m.case_to_prior_case_.values, m_full.case_to_prior_case_.values, equal_nan=True + ) + + +def test_case_outstanding_clone_and_sklearn_compatibility(): + """Verify scikit-learn clone compatibility.""" + from sklearn.base import clone + + m = cl.CaseOutstanding( + paid_to_incurred=("paid", "incurred"), + paid_n_periods=5, + paid_drop_high=1, + paid_drop_low=1, + case_n_periods=3, + ) + m_clone = clone(m) + assert m_clone.paid_n_periods == 5 + assert m_clone.paid_drop_high == 1 + assert m_clone.paid_drop_low == 1 + assert m_clone.case_n_periods == 3 + + +def test_case_outstanding_zero_case_reserve_warning(): + """Verify tail runoff with zero case reserve executes cleanly without warnings.""" + import warnings + + xyz = cl.load_sample("friedland_xyz_auto_bi") + with warnings.catch_warnings(): + warnings.filterwarnings("error", category=RuntimeWarning) + model = cl.CaseOutstanding( + paid_to_incurred=("Paid Claims", "Reported Claims"), + n_periods=5, + drop_high=1, + drop_low=1, + ).fit(xyz) + assert model.paid_ldf_ is not None + assert not model.case_ldf_.to_frame().isna().any().any()