From a55f33d4ab68f2e0556eb296b97d2e2bcdc064f2 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Mon, 28 Sep 2026 12:16:44 +0530 Subject: [PATCH 1/5] Add Approach 2 --- chainladder/development/outstanding.py | 209 +++++++++++++++++- .../development/tests/test_outstanding.py | 134 +++++++++++ 2 files changed, 333 insertions(+), 10 deletions(-) diff --git a/chainladder/development/outstanding.py b/chainladder/development/outstanding.py index 3cc73cc8a..b0bec33c3 100644 --- a/chainladder/development/outstanding.py +++ b/chainladder/development/outstanding.py @@ -29,15 +29,28 @@ class CaseOutstanding(DevelopmentBase): Parameters ---------- - paid_to_incurred: tuple or list of tuples + paid_to_incurred: tuple or list of tuples, optional A tuple representing the paid and incurred ``columns`` of the triangles - such as ``('paid', 'incurred')`` + such as ``('paid', 'incurred')``. Required for Approach 1. paid_n_periods: integer, optional (default=-1) - number of origin periods to be used in the paid pattern averages. For + number of origin periods to be used in the paid pattern averages (Approach 1). For all origin periods, set paid_n_periods=-1 case_n_periods: integer, optional (default=-1) - number of origin periods to be used in the case pattern averages. For + number of origin periods to be used in the case pattern averages (Approach 1). For all origin periods, set case_n_periods=-1 + groupby: optional + Grouping option for Approach 1. + reported_pattern: dict, Series, DataFrame, Triangle, or Development estimator, optional + Benchmark reported loss development pattern (Approach 2). + paid_pattern: dict, Series, DataFrame, Triangle, or Development estimator, optional + Benchmark paid loss development pattern (Approach 2). + style: {'cdf', 'ldf'}, optional (default='cdf') + Whether `reported_pattern` and `paid_pattern` are cumulative development + factors ('cdf') or incremental link ratios ('ldf') (Approach 2). + approach: {1, 2, None}, optional (default=None) + Explicitly select Approach 1 or Approach 2. If None, auto-detected: + Approach 2 if `reported_pattern` or `paid_pattern` is provided; + otherwise Approach 1. Attributes ---------- @@ -47,13 +60,17 @@ class CaseOutstanding(DevelopmentBase): cdf_: Triangle The estimated (multiplicative) cumulative development patterns. case_to_prior_case_: Triangle - Case-to-prior-case incremental ratios by origin (for review). + Case-to-prior-case incremental ratios by origin (for review, Approach 1). case_ldf_: Triangle - Selected case-to-prior-case ratios averaged across origins. + Selected case-to-prior-case ratios (Approach 1) or implied case link ratios (Approach 2). paid_to_prior_case_: Triangle - Paid-to-prior-case incremental ratios by origin (for review). + Paid-to-prior-case incremental ratios by origin (for review, Approach 1). paid_ldf_: Triangle - Selected paid-to-prior-case ratios averaged across origins. + Selected paid-to-prior-case ratios averaged across origins (Approach 1). + case_cdf_: Triangle + Implied cumulative case development factors to ultimate (Approach 2). + approach_: int + The fitted approach (1 or 2). Examples -------- @@ -115,15 +132,59 @@ class CaseOutstanding(DevelopmentBase): [0.5378 0.5541 0.5253 0.4981] [0.534 0.5638 0.5296 0.49 ] + Approach 2 derives an implied case development pattern from benchmark reported + and paid development patterns (Friedland 2010, Exhibit III): + + .. testcode:: + + import pandas as pd + import chainladder as cl + + case_data = pd.DataFrame({ + "origin": [1998, 1999, 2000, 2001, 2002, 2003], + "development": [132, 120, 108, 96, 84, 72], + "case": [500000, 650000, 800000, 850000, 975000, 1000000], + }) + case_tri = cl.Triangle( + case_data, origin="origin", development="development", columns="case", cumulative=True + ).val_to_dev() + + rep_cdfs = {132: 1.015, 120: 1.020, 108: 1.030, 96: 1.051, 84: 1.077, 72: 1.131} + paid_cdfs = {132: 1.046, 120: 1.067, 108: 1.109, 96: 1.187, 84: 1.306, 72: 1.489} + + model = cl.CaseOutstanding( + reported_pattern=rep_cdfs, + paid_pattern=paid_cdfs, + style="cdf", + ).fit(case_tri) + + print(np.round(model.case_cdf_["case"].values[0, 0, 0, 5:], 3)) + + .. testoutput:: + + [1.545 1.439 1.445 1.421 1.454 1.506] + """ def __init__( - self, paid_to_incurred=None, paid_n_periods=-1, case_n_periods=-1, groupby=None + self, + paid_to_incurred=None, + paid_n_periods=-1, + case_n_periods=-1, + groupby=None, + reported_pattern=None, + paid_pattern=None, + style="cdf", + approach=None, ): self.paid_to_incurred = paid_to_incurred self.paid_n_periods = paid_n_periods self.case_n_periods = case_n_periods self.groupby = groupby + self.reported_pattern = reported_pattern + self.paid_pattern = paid_pattern + self.style = style + self.approach = approach def fit(self, X, y=None, sample_weight=None): """ @@ -132,7 +193,9 @@ def fit(self, X, y=None, sample_weight=None): Parameters ---------- X : Triangle - Triangle with paid and incurred columns for ``paid_to_incurred``. + Triangle to fit. For Approach 1, a Triangle with paid and incurred + columns for ``paid_to_incurred``. For Approach 2, a Triangle of + case outstanding (or with paid and incurred columns). y : Ignored sample_weight : Ignored @@ -144,6 +207,23 @@ def fit(self, X, y=None, sample_weight=None): backend = "cupy" if X.array_backend == "cupy" else "numpy" self.X_ = X.copy() + # Determine approach + approach = self.approach + if approach is None: + if self.reported_pattern is not None or self.paid_pattern is not None: + approach = 2 + else: + approach = 1 + self.approach_ = approach + + if self.approach_ == 1: + return self._fit_approach_1(X, backend) + elif self.approach_ == 2: + return self._fit_approach_2(X, backend) + else: + raise ValueError(f"Unknown approach: {self.approach_}. Must be 1 or 2.") + + def _fit_approach_1(self, X, backend): self.paid_w_ = ( Development(n_periods=self.paid_n_periods).fit(self.X_.sum(0).sum(1)).w_ ) @@ -167,6 +247,95 @@ def fit(self, X, y=None, sample_weight=None): return self + def _fit_approach_2(self, X, backend): + from chainladder.development.constant import DevelopmentConstant + + if self.reported_pattern is None or self.paid_pattern is None: + raise ValueError( + "Approach 2 requires both reported_pattern and paid_pattern." + ) + + # 1. Determine case triangle + if self.paid_to_incurred is not None: + case_tri = X[self.paid_to_incurred[1]] - X[self.paid_to_incurred[0]] + elif len(X.columns) > 1 and "incurred" in X.columns and "paid" in X.columns: + case_tri = X["incurred"] - X["paid"] + elif len(X.columns) > 1 and "Incurred" in X.columns and "Paid" in X.columns: + case_tri = X["Incurred"] - X["Paid"] + else: + case_tri = X + + obj = self._set_fit_groups(case_tri).val_to_dev().copy() + + # 2. Extract series of CDFs + rep_s = self._extract_cdf_series(self.reported_pattern, self.style) + paid_s = self._extract_cdf_series(self.paid_pattern, self.style) + + # 3. Determine common development ages + common_devs = sorted(set(rep_s.index).intersection(set(paid_s.index))) + if not common_devs: + raise ValueError( + "reported_pattern and paid_pattern share no common development ages." + ) + + # 4. Implied Case CDF formula (Friedland Exhibit III): + # Case_CDF = [((CDF_rep - 1.0) * CDF_paid) / (CDF_paid - CDF_rep)] + 1.0 + case_cdf_dict = {} + for d in obj.ddims: + if d in rep_s and d in paid_s: + r = float(rep_s[d]) + p = float(paid_s[d]) + den = p - r + if np.isclose(den, 0.0) or den < 0: + case_cdf_dict[d] = 1.0 + else: + case_cdf_dict[d] = ((r - 1.0) * p) / den + 1.0 + else: + case_cdf_dict[d] = 1.0 + + dev_const = DevelopmentConstant(patterns=case_cdf_dict, style="cdf").fit(obj) + + self.ldf_ = dev_const.ldf_.set_backend(backend) + self.case_ldf_ = dev_const.ldf_.set_backend(backend) + self.case_cdf_ = dev_const.cdf_.set_backend(backend) + self.case = case_tri + return self + + @staticmethod + def _extract_cdf_series(pattern, style="cdf"): + """Extract a pd.Series of CDF factors indexed by integer development ages.""" + if hasattr(pattern, "cdf_"): + pattern = pattern.cdf_ + elif hasattr(pattern, "ldf_") and not hasattr(pattern, "cdf_"): + pattern = pattern.ldf_ + style = "ldf" + + if isinstance(pattern, dict): + s = pd.Series(pattern, dtype=float) + elif isinstance(pattern, pd.Series): + s = pattern.astype(float).copy() + elif isinstance(pattern, pd.DataFrame): + if pattern.shape[0] == 1: + s = pattern.iloc[0].astype(float) + elif pattern.shape[1] == 1: + s = pattern.iloc[:, 0].astype(float) + else: + raise ValueError("DataFrame pattern must have 1 row or 1 column.") + elif hasattr(pattern, "values") and hasattr(pattern, "ddims"): + s = pd.Series(pattern.values[0, 0, 0, :], index=pattern.ddims, dtype=float) + else: + raise TypeError(f"Unsupported pattern type: {type(pattern)}") + + try: + s.index = [int(str(x).split("-")[0]) for x in s.index] + except (ValueError, TypeError): + pass + s = s.sort_index() + + if style == "ldf": + s = s.iloc[::-1].cumprod().iloc[::-1] + return s + def _set_ldf(self, X): paid_tri = X[self.paid_to_incurred[0]] incurred_tri = X[self.paid_to_incurred[1]] @@ -247,6 +416,10 @@ def _set_ldf(self, X): @property def case_to_prior_case_(self): + if getattr(self, "approach_", 1) == 2: + raise AttributeError( + f"'{self.__class__.__name__}' object in Approach 2 has no attribute 'case_to_prior_case_'" + ) paid_tri = self.X_[self.paid_to_incurred[0]] incurred_tri = self.X_[self.paid_to_incurred[1]] @@ -266,6 +439,10 @@ def case_to_prior_case_(self): @property def paid_to_prior_case_(self): + if getattr(self, "approach_", 1) == 2: + raise AttributeError( + f"'{self.__class__.__name__}' object in Approach 2 has no attribute 'paid_to_prior_case_'" + ) paid_tri = self.X_[self.paid_to_incurred[0]] incurred_tri = self.X_[self.paid_to_incurred[1]] @@ -296,9 +473,21 @@ def transform(self, X): ------- X_new : New triangle with transformed attributes. """ + if getattr(self, "approach_", 1) == 2: + X_new = X.copy() + X_new.group_index = self._set_transform_groups(X_new) + X_new.ldf_ = self.ldf_ + X_new.case_ldf_ = self.case_ldf_ + X_new.case_cdf_ = self.case_cdf_ + X_new._set_slicers() + return X_new + X_new = X.copy() X_new.ldf_ = self._set_ldf(X_new).set_backend(self.ldf_.array_backend) X_new._set_slicers() X_new.paid_ldf_ = self.paid_ldf_ X_new.case_ldf_ = self.case_ldf_ return X_new + + +ImpliedCaseDevelopment = CaseOutstanding diff --git a/chainladder/development/tests/test_outstanding.py b/chainladder/development/tests/test_outstanding.py index efe5fbbfe..c9a17bea9 100644 --- a/chainladder/development/tests/test_outstanding.py +++ b/chainladder/development/tests/test_outstanding.py @@ -54,3 +54,137 @@ def test_outstanding_friedland_example(): model.case_ldf_.to_frame(origin_as_datetime=False).values - expected_case_ldf < 0.001 ).all() + + +def test_approach_2_friedland_exhibit_iii(): + import pandas as pd + import pytest + + case_data = pd.DataFrame({ + "origin": [1998, 1999, 2000, 2001, 2002, 2003], + "valuation": 2008, + "case": [500000, 650000, 800000, 850000, 975000, 1000000], + }) + case_tri = cl.Triangle( + case_data, + origin="origin", + valuation="valuation", + columns="case", + cumulative=True, + ).val_to_dev() + + rep_cdfs = {132: 1.015, 120: 1.020, 108: 1.030, 96: 1.051, 84: 1.077, 72: 1.131} + paid_cdfs = {132: 1.046, 120: 1.067, 108: 1.109, 96: 1.187, 84: 1.306, 72: 1.489} + + model = cl.CaseOutstanding( + reported_pattern=rep_cdfs, + paid_pattern=paid_cdfs, + style="cdf", + ).fit(case_tri) + + assert model.approach_ == 2 + # Verify individual case CDF factors match Friedland Exhibit III + assert np.isclose( + model.case_cdf_.to_frame().loc["(All)", "132-Ult"], 1.506, atol=1e-3 + ) + assert np.isclose( + model.case_cdf_.to_frame().loc["(All)", "120-Ult"], 1.454, atol=1e-3 + ) + assert np.isclose( + model.case_cdf_.to_frame().loc["(All)", "108-Ult"], 1.421, atol=1e-3 + ) + assert np.isclose( + model.case_cdf_.to_frame().loc["(All)", "96-Ult"], 1.445, atol=1e-3 + ) + assert np.isclose( + model.case_cdf_.to_frame().loc["(All)", "84-Ult"], 1.439, atol=1e-3 + ) + assert np.isclose( + model.case_cdf_.to_frame().loc["(All)", "72-Ult"], 1.545, atol=1e-3 + ) + + # Chainladder projection + cl_model = cl.Chainladder().fit(model.transform(case_tri)) + assert np.isclose(cl_model.ultimate_.sum().sum(), 7011175, rtol=1e-3) + + # Approach 1 properties should raise AttributeError in Approach 2 + with pytest.raises(AttributeError): + _ = model.case_to_prior_case_ + with pytest.raises(AttributeError): + _ = model.paid_to_prior_case_ + + +def test_approach_2_with_ldf_style(): + import pandas as pd + + case_data = pd.DataFrame({ + "origin": [2001, 2002, 2003], + "valuation": 2003, + "case": [1000, 2000, 3000], + }) + case_tri = cl.Triangle( + case_data, + origin="origin", + valuation="valuation", + columns="case", + cumulative=True, + ).val_to_dev() + + # Link ratios + rep_ldfs = {12: 1.20, 24: 1.10, 36: 1.00} + paid_ldfs = {12: 1.50, 24: 1.25, 36: 1.05} + + model = cl.CaseOutstanding( + reported_pattern=rep_ldfs, + paid_pattern=paid_ldfs, + style="ldf", + approach=2, + ).fit(case_tri) + + assert model.approach_ == 2 + assert hasattr(model, "case_cdf_") + assert hasattr(model, "ldf_") + + +def test_approach_2_with_development_estimator(): + tri = cl.load_sample("usauto") + case = tri["incurred"] - tri["paid"] + + rep_dev = cl.Development().fit(tri["incurred"]) + paid_dev = cl.Development().fit(tri["paid"]) + + model = cl.CaseOutstanding( + reported_pattern=rep_dev, + paid_pattern=paid_dev, + ).fit(case) + + assert model.approach_ == 2 + cl_model = cl.Chainladder().fit(model.transform(case)) + assert cl_model.ultimate_ is not None + + +def test_approach_2_validation_errors(): + import pytest + + tri = cl.load_sample("usauto") + case = tri["incurred"] - tri["paid"] + + # Missing reported_pattern + with pytest.raises( + ValueError, match="requires both reported_pattern and paid_pattern" + ): + cl.CaseOutstanding(approach=2, paid_pattern={12: 1.5}).fit(case) + + # No common development ages + with pytest.raises(ValueError, match="share no common development ages"): + cl.CaseOutstanding( + reported_pattern={12: 1.2}, + paid_pattern={24: 1.5}, + approach=2, + ).fit(case) + + +def test_implied_case_development_alias(): + from chainladder.development.outstanding import ImpliedCaseDevelopment + + assert ImpliedCaseDevelopment is cl.CaseOutstanding From 7e7af1f03df05dc9430a05c26ea7a5d7e2eb22c7 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Wed, 7 Oct 2026 22:29:42 +0530 Subject: [PATCH 2/5] Address PR 1473 feedback --- chainladder/__init__.py | 1 + chainladder/development/__init__.py | 6 +- chainladder/development/outstanding.py | 5 +- .../development/tests/test_outstanding.py | 130 ++++++++++++++++++ chainladder/tests/test_public_api.py | 1 + docs/library/api.md | 1 + 6 files changed, 141 insertions(+), 3 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index b297867b3..252fd24ce 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -68,6 +68,7 @@ DevelopmentConstant, ClarkLDF, CaseOutstanding, + ImpliedCaseDevelopment, DevelopmentML, TweedieGLM, BarnettZehnwirth, diff --git a/chainladder/development/__init__.py b/chainladder/development/__init__.py index 2179745bc..6956bd744 100644 --- a/chainladder/development/__init__.py +++ b/chainladder/development/__init__.py @@ -4,7 +4,10 @@ from chainladder.development.incremental import IncrementalAdditive # noqa (API import) from chainladder.development.constant import DevelopmentConstant # noqa (API import) from chainladder.development.clark import ClarkLDF # noqa (API import) -from chainladder.development.outstanding import CaseOutstanding # noqa (API import) +from chainladder.development.outstanding import ( + CaseOutstanding, + ImpliedCaseDevelopment, +) # noqa (API import) from chainladder.development.learning import DevelopmentML # noqa (API import) from chainladder.development.glm import TweedieGLM # noqa (API import) from chainladder.development.barnzehn import BarnettZehnwirth # noqa (API import) @@ -17,6 +20,7 @@ "DevelopmentConstant", "ClarkLDF", "CaseOutstanding", + "ImpliedCaseDevelopment", "DevelopmentML", "TweedieGLM", "BarnettZehnwirth", diff --git a/chainladder/development/outstanding.py b/chainladder/development/outstanding.py index b0bec33c3..736385434 100644 --- a/chainladder/development/outstanding.py +++ b/chainladder/development/outstanding.py @@ -142,11 +142,11 @@ class CaseOutstanding(DevelopmentBase): case_data = pd.DataFrame({ "origin": [1998, 1999, 2000, 2001, 2002, 2003], - "development": [132, 120, 108, 96, 84, 72], + "valuation": 2008, "case": [500000, 650000, 800000, 850000, 975000, 1000000], }) case_tri = cl.Triangle( - case_data, origin="origin", development="development", columns="case", cumulative=True + case_data, origin="origin", valuation="valuation", columns="case", cumulative=True ).val_to_dev() rep_cdfs = {132: 1.015, 120: 1.020, 108: 1.030, 96: 1.051, 84: 1.077, 72: 1.131} @@ -306,6 +306,7 @@ def _extract_cdf_series(pattern, style="cdf"): """Extract a pd.Series of CDF factors indexed by integer development ages.""" if hasattr(pattern, "cdf_"): pattern = pattern.cdf_ + style = "cdf" elif hasattr(pattern, "ldf_") and not hasattr(pattern, "cdf_"): pattern = pattern.ldf_ style = "ldf" diff --git a/chainladder/development/tests/test_outstanding.py b/chainladder/development/tests/test_outstanding.py index c9a17bea9..6f186f5a6 100644 --- a/chainladder/development/tests/test_outstanding.py +++ b/chainladder/development/tests/test_outstanding.py @@ -186,5 +186,135 @@ def test_approach_2_validation_errors(): def test_implied_case_development_alias(): from chainladder.development.outstanding import ImpliedCaseDevelopment + from chainladder.development import ImpliedCaseDevelopment as DevImplied + import chainladder as cl_root assert ImpliedCaseDevelopment is cl.CaseOutstanding + assert DevImplied is cl.CaseOutstanding + assert cl_root.ImpliedCaseDevelopment is cl.CaseOutstanding + + +def test_approach_2_pattern_input_types(): + import pandas as pd + import pytest + + tri = cl.load_sample("usauto") + case = tri["incurred"] - tri["paid"] + + # pd.Series inputs + rep_s = pd.Series({12: 1.2, 24: 1.1, 36: 1.0}) + paid_s = pd.Series({12: 1.5, 24: 1.25, 36: 1.05}) + m_series = cl.CaseOutstanding( + reported_pattern=rep_s, paid_pattern=paid_s, style="ldf" + ).fit(case) + assert m_series.case_cdf_ is not None + + # pd.DataFrame 1-row input + df_1row = pd.DataFrame([[1.2, 1.1, 1.0]], columns=[12, 24, 36]) + m_df1 = cl.CaseOutstanding( + reported_pattern=df_1row, paid_pattern=paid_s, style="ldf" + ).fit(case) + assert m_df1.case_cdf_ is not None + + # pd.DataFrame 1-column input + df_1col = pd.DataFrame([1.2, 1.1, 1.0], index=[12, 24, 36]) + m_df2 = cl.CaseOutstanding( + reported_pattern=df_1col, paid_pattern=paid_s, style="ldf" + ).fit(case) + assert m_df2.case_cdf_ is not None + + # Invalid DataFrame shape (>1 row and >1 col) + df_invalid = pd.DataFrame([[1.2, 1.1], [1.0, 1.0]], columns=[12, 24]) + with pytest.raises(ValueError, match="must have 1 row or 1 column"): + cl.CaseOutstanding(reported_pattern=df_invalid, paid_pattern=paid_s).fit(case) + + # Unsupported pattern type + with pytest.raises(TypeError, match="Unsupported pattern type"): + cl.CaseOutstanding(reported_pattern=[1.2, 1.1], paid_pattern=paid_s).fit(case) + + +def test_approach_2_estimator_with_style_ldf_and_no_double_cumprod(): + """Verify BugBot fix: estimator with cdf_ is not reconverted/double-cumprodded when style='ldf'.""" + import pandas as pd + + tri = cl.load_sample("usauto") + case = tri["incurred"] - tri["paid"] + + rep_dev = cl.Development().fit(tri["incurred"]) + paid_dev = cl.Development().fit(tri["paid"]) + + # style='ldf' passed with Development estimator should use estimator.cdf_ directly + m = cl.CaseOutstanding( + reported_pattern=rep_dev, + paid_pattern=paid_dev, + style="ldf", + ).fit(case) + assert m.case_cdf_ is not None + + # Mock object with only ldf_ and no cdf_ + class MockLDFPattern: + def __init__(self, ldf_dict): + self.ldf_ = pd.Series(ldf_dict) + + mock_rep = MockLDFPattern({12: 1.2, 24: 1.1, 36: 1.0}) + mock_paid = MockLDFPattern({12: 1.5, 24: 1.25, 36: 1.05}) + m_mock = cl.CaseOutstanding( + reported_pattern=mock_rep, + paid_pattern=mock_paid, + style="ldf", + ).fit(case) + assert m_mock.case_cdf_ is not None + + +def test_approach_2_auto_column_inference_and_den_guard(): + import pytest + + tri = cl.load_sample("usauto") + + # Incurred and paid auto-detection + tri_sub = tri[["incurred", "paid"]] + m = cl.CaseOutstanding( + reported_pattern={12: 1.1, 24: 1.05}, + paid_pattern={12: 1.3, 24: 1.15}, + ).fit(tri_sub) + assert m.case_cdf_ is not None + + # Denominator <= 0 guard (CDF_paid <= CDF_rep fallback to 1.0) + m_guard = cl.CaseOutstanding( + reported_pattern={12: 1.5, 24: 1.2}, + paid_pattern={12: 1.2, 24: 1.1}, # paid < rep + ).fit(tri_sub) + assert np.isclose(m_guard.case_cdf_.to_frame().loc["(All)", "12-Ult"], 1.0) + + # Explicit paid_to_incurred in Approach 2 + m_explicit = cl.CaseOutstanding( + paid_to_incurred=("paid", "incurred"), + reported_pattern={12: 1.1, 24: 1.05}, + paid_pattern={12: 1.3, 24: 1.15}, + ).fit(tri) + assert m_explicit.case_cdf_ is not None + + # Capitalized Incurred and Paid column names + tri_cap = tri.copy() + tri_cap.columns = [ + "Incurred" if c == "incurred" else "Paid" if c == "paid" else c + for c in tri_cap.columns + ] + m_cap = cl.CaseOutstanding( + reported_pattern={12: 1.1, 24: 1.05}, + paid_pattern={12: 1.3, 24: 1.15}, + ).fit(tri_cap[["Incurred", "Paid"]]) + assert m_cap.case_cdf_ is not None + + # Groupby in Approach 1 + tri_grouped = cl.load_sample("clrd") + m_grp = cl.CaseOutstanding( + paid_to_incurred=("CumPaidLoss", "IncurLoss"), + groupby="LOB", + ).fit(tri_grouped) + assert m_grp.case_to_prior_case_ is not None + assert m_grp.paid_to_prior_case_ is not None + + # Invalid approach value + with pytest.raises(ValueError, match="Unknown approach"): + cl.CaseOutstanding(approach=99).fit(tri) diff --git a/chainladder/tests/test_public_api.py b/chainladder/tests/test_public_api.py index a14673293..d4cee8b4c 100644 --- a/chainladder/tests/test_public_api.py +++ b/chainladder/tests/test_public_api.py @@ -49,6 +49,7 @@ "DevelopmentConstant", "ClarkLDF", "CaseOutstanding", + "ImpliedCaseDevelopment", "DevelopmentML", "TweedieGLM", "BarnettZehnwirth", diff --git a/docs/library/api.md b/docs/library/api.md index daab693d2..ea8f7963d 100644 --- a/docs/library/api.md +++ b/docs/library/api.md @@ -57,6 +57,7 @@ Classes IncrementalAdditive ClarkLDF CaseOutstanding + ImpliedCaseDevelopment TweedieGLM DevelopmentML BarnettZehnwirth From 55c5f3f110be3096ee189ebf87326ff700894fbe Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 10 Oct 2026 00:09:38 +0530 Subject: [PATCH 3/5] Decouple ImpliedCaseDevelopment estimator --- chainladder/__init__.py | 1 + chainladder/development/__init__.py | 2 + chainladder/development/outstanding.py | 447 ++++++++++-------- .../development/tests/test_outstanding.py | 94 ++-- chainladder/tests/test_public_api.py | 1 + docs/library/api.md | 1 + 6 files changed, 307 insertions(+), 239 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index 252fd24ce..cfccea312 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -69,6 +69,7 @@ ClarkLDF, CaseOutstanding, ImpliedCaseDevelopment, + ImpliedCase, DevelopmentML, TweedieGLM, BarnettZehnwirth, diff --git a/chainladder/development/__init__.py b/chainladder/development/__init__.py index 6956bd744..7a5e15f48 100644 --- a/chainladder/development/__init__.py +++ b/chainladder/development/__init__.py @@ -7,6 +7,7 @@ from chainladder.development.outstanding import ( CaseOutstanding, ImpliedCaseDevelopment, + ImpliedCase, ) # noqa (API import) from chainladder.development.learning import DevelopmentML # noqa (API import) from chainladder.development.glm import TweedieGLM # noqa (API import) @@ -21,6 +22,7 @@ "ClarkLDF", "CaseOutstanding", "ImpliedCaseDevelopment", + "ImpliedCase", "DevelopmentML", "TweedieGLM", "BarnettZehnwirth", diff --git a/chainladder/development/outstanding.py b/chainladder/development/outstanding.py index 736385434..a41ca4b05 100644 --- a/chainladder/development/outstanding.py +++ b/chainladder/development/outstanding.py @@ -25,32 +25,25 @@ class CaseOutstanding(DevelopmentBase): the estimator also builds origin-specific implied multiplicative ``ldf_`` so standard IBNR methods can be applied. + For Approach 2 (Friedland 2010, Chapter 12, Exhibit III) using benchmark + reported and paid development patterns, see + :class:`~chainladder.ImpliedCaseDevelopment`. + .. versionadded:: 0.8.0 Parameters ---------- paid_to_incurred: tuple or list of tuples, optional A tuple representing the paid and incurred ``columns`` of the triangles - such as ``('paid', 'incurred')``. Required for Approach 1. + such as ``('paid', 'incurred')``. paid_n_periods: integer, optional (default=-1) - number of origin periods to be used in the paid pattern averages (Approach 1). For + number of origin periods to be used in the paid pattern averages. For all origin periods, set paid_n_periods=-1 case_n_periods: integer, optional (default=-1) - number of origin periods to be used in the case pattern averages (Approach 1). For + number of origin periods to be used in the case pattern averages. For all origin periods, set case_n_periods=-1 groupby: optional - Grouping option for Approach 1. - reported_pattern: dict, Series, DataFrame, Triangle, or Development estimator, optional - Benchmark reported loss development pattern (Approach 2). - paid_pattern: dict, Series, DataFrame, Triangle, or Development estimator, optional - Benchmark paid loss development pattern (Approach 2). - style: {'cdf', 'ldf'}, optional (default='cdf') - Whether `reported_pattern` and `paid_pattern` are cumulative development - factors ('cdf') or incremental link ratios ('ldf') (Approach 2). - approach: {1, 2, None}, optional (default=None) - Explicitly select Approach 1 or Approach 2. If None, auto-detected: - Approach 2 if `reported_pattern` or `paid_pattern` is provided; - otherwise Approach 1. + Grouping option. Attributes ---------- @@ -60,17 +53,13 @@ class CaseOutstanding(DevelopmentBase): cdf_: Triangle The estimated (multiplicative) cumulative development patterns. case_to_prior_case_: Triangle - Case-to-prior-case incremental ratios by origin (for review, Approach 1). + Case-to-prior-case incremental ratios by origin (for review). case_ldf_: Triangle - Selected case-to-prior-case ratios (Approach 1) or implied case link ratios (Approach 2). + Selected case-to-prior-case ratios averaged across origins. paid_to_prior_case_: Triangle - Paid-to-prior-case incremental ratios by origin (for review, Approach 1). + Paid-to-prior-case incremental ratios by origin (for review). paid_ldf_: Triangle - Selected paid-to-prior-case ratios averaged across origins (Approach 1). - case_cdf_: Triangle - Implied cumulative case development factors to ultimate (Approach 2). - approach_: int - The fitted approach (1 or 2). + Selected paid-to-prior-case ratios averaged across origins. Examples -------- @@ -132,38 +121,6 @@ class CaseOutstanding(DevelopmentBase): [0.5378 0.5541 0.5253 0.4981] [0.534 0.5638 0.5296 0.49 ] - Approach 2 derives an implied case development pattern from benchmark reported - and paid development patterns (Friedland 2010, Exhibit III): - - .. testcode:: - - import pandas as pd - import chainladder as cl - - case_data = pd.DataFrame({ - "origin": [1998, 1999, 2000, 2001, 2002, 2003], - "valuation": 2008, - "case": [500000, 650000, 800000, 850000, 975000, 1000000], - }) - case_tri = cl.Triangle( - case_data, origin="origin", valuation="valuation", columns="case", cumulative=True - ).val_to_dev() - - rep_cdfs = {132: 1.015, 120: 1.020, 108: 1.030, 96: 1.051, 84: 1.077, 72: 1.131} - paid_cdfs = {132: 1.046, 120: 1.067, 108: 1.109, 96: 1.187, 84: 1.306, 72: 1.489} - - model = cl.CaseOutstanding( - reported_pattern=rep_cdfs, - paid_pattern=paid_cdfs, - style="cdf", - ).fit(case_tri) - - print(np.round(model.case_cdf_["case"].values[0, 0, 0, 5:], 3)) - - .. testoutput:: - - [1.545 1.439 1.445 1.421 1.454 1.506] - """ def __init__( @@ -172,19 +129,21 @@ def __init__( paid_n_periods=-1, case_n_periods=-1, groupby=None, - reported_pattern=None, - paid_pattern=None, - style="cdf", - approach=None, + **kwargs, ): + if ( + "reported_pattern" in kwargs + or "paid_pattern" in kwargs + or kwargs.get("approach") == 2 + ): + raise ValueError( + "Approach 2 has been decoupled into cl.ImpliedCaseDevelopment (or cl.ImpliedCase). " + "Use cl.ImpliedCaseDevelopment(reported_pattern=..., paid_pattern=...) instead." + ) self.paid_to_incurred = paid_to_incurred self.paid_n_periods = paid_n_periods self.case_n_periods = case_n_periods self.groupby = groupby - self.reported_pattern = reported_pattern - self.paid_pattern = paid_pattern - self.style = style - self.approach = approach def fit(self, X, y=None, sample_weight=None): """ @@ -193,9 +152,7 @@ def fit(self, X, y=None, sample_weight=None): Parameters ---------- X : Triangle - Triangle to fit. For Approach 1, a Triangle with paid and incurred - columns for ``paid_to_incurred``. For Approach 2, a Triangle of - case outstanding (or with paid and incurred columns). + Triangle with paid and incurred columns for ``paid_to_incurred``. y : Ignored sample_weight : Ignored @@ -207,23 +164,6 @@ def fit(self, X, y=None, sample_weight=None): backend = "cupy" if X.array_backend == "cupy" else "numpy" self.X_ = X.copy() - # Determine approach - approach = self.approach - if approach is None: - if self.reported_pattern is not None or self.paid_pattern is not None: - approach = 2 - else: - approach = 1 - self.approach_ = approach - - if self.approach_ == 1: - return self._fit_approach_1(X, backend) - elif self.approach_ == 2: - return self._fit_approach_2(X, backend) - else: - raise ValueError(f"Unknown approach: {self.approach_}. Must be 1 or 2.") - - def _fit_approach_1(self, X, backend): self.paid_w_ = ( Development(n_periods=self.paid_n_periods).fit(self.X_.sum(0).sum(1)).w_ ) @@ -247,96 +187,6 @@ def _fit_approach_1(self, X, backend): return self - def _fit_approach_2(self, X, backend): - from chainladder.development.constant import DevelopmentConstant - - if self.reported_pattern is None or self.paid_pattern is None: - raise ValueError( - "Approach 2 requires both reported_pattern and paid_pattern." - ) - - # 1. Determine case triangle - if self.paid_to_incurred is not None: - case_tri = X[self.paid_to_incurred[1]] - X[self.paid_to_incurred[0]] - elif len(X.columns) > 1 and "incurred" in X.columns and "paid" in X.columns: - case_tri = X["incurred"] - X["paid"] - elif len(X.columns) > 1 and "Incurred" in X.columns and "Paid" in X.columns: - case_tri = X["Incurred"] - X["Paid"] - else: - case_tri = X - - obj = self._set_fit_groups(case_tri).val_to_dev().copy() - - # 2. Extract series of CDFs - rep_s = self._extract_cdf_series(self.reported_pattern, self.style) - paid_s = self._extract_cdf_series(self.paid_pattern, self.style) - - # 3. Determine common development ages - common_devs = sorted(set(rep_s.index).intersection(set(paid_s.index))) - if not common_devs: - raise ValueError( - "reported_pattern and paid_pattern share no common development ages." - ) - - # 4. Implied Case CDF formula (Friedland Exhibit III): - # Case_CDF = [((CDF_rep - 1.0) * CDF_paid) / (CDF_paid - CDF_rep)] + 1.0 - case_cdf_dict = {} - for d in obj.ddims: - if d in rep_s and d in paid_s: - r = float(rep_s[d]) - p = float(paid_s[d]) - den = p - r - if np.isclose(den, 0.0) or den < 0: - case_cdf_dict[d] = 1.0 - else: - case_cdf_dict[d] = ((r - 1.0) * p) / den + 1.0 - else: - case_cdf_dict[d] = 1.0 - - dev_const = DevelopmentConstant(patterns=case_cdf_dict, style="cdf").fit(obj) - - self.ldf_ = dev_const.ldf_.set_backend(backend) - self.case_ldf_ = dev_const.ldf_.set_backend(backend) - self.case_cdf_ = dev_const.cdf_.set_backend(backend) - self.case = case_tri - return self - - @staticmethod - def _extract_cdf_series(pattern, style="cdf"): - """Extract a pd.Series of CDF factors indexed by integer development ages.""" - if hasattr(pattern, "cdf_"): - pattern = pattern.cdf_ - style = "cdf" - elif hasattr(pattern, "ldf_") and not hasattr(pattern, "cdf_"): - pattern = pattern.ldf_ - style = "ldf" - - if isinstance(pattern, dict): - s = pd.Series(pattern, dtype=float) - elif isinstance(pattern, pd.Series): - s = pattern.astype(float).copy() - elif isinstance(pattern, pd.DataFrame): - if pattern.shape[0] == 1: - s = pattern.iloc[0].astype(float) - elif pattern.shape[1] == 1: - s = pattern.iloc[:, 0].astype(float) - else: - raise ValueError("DataFrame pattern must have 1 row or 1 column.") - elif hasattr(pattern, "values") and hasattr(pattern, "ddims"): - s = pd.Series(pattern.values[0, 0, 0, :], index=pattern.ddims, dtype=float) - else: - raise TypeError(f"Unsupported pattern type: {type(pattern)}") - - try: - s.index = [int(str(x).split("-")[0]) for x in s.index] - except (ValueError, TypeError): - pass - s = s.sort_index() - - if style == "ldf": - s = s.iloc[::-1].cumprod().iloc[::-1] - return s - def _set_ldf(self, X): paid_tri = X[self.paid_to_incurred[0]] incurred_tri = X[self.paid_to_incurred[1]] @@ -417,10 +267,6 @@ def _set_ldf(self, X): @property def case_to_prior_case_(self): - if getattr(self, "approach_", 1) == 2: - raise AttributeError( - f"'{self.__class__.__name__}' object in Approach 2 has no attribute 'case_to_prior_case_'" - ) paid_tri = self.X_[self.paid_to_incurred[0]] incurred_tri = self.X_[self.paid_to_incurred[1]] @@ -440,10 +286,6 @@ def case_to_prior_case_(self): @property def paid_to_prior_case_(self): - if getattr(self, "approach_", 1) == 2: - raise AttributeError( - f"'{self.__class__.__name__}' object in Approach 2 has no attribute 'paid_to_prior_case_'" - ) paid_tri = self.X_[self.paid_to_incurred[0]] incurred_tri = self.X_[self.paid_to_incurred[1]] @@ -474,15 +316,6 @@ def transform(self, X): ------- X_new : New triangle with transformed attributes. """ - if getattr(self, "approach_", 1) == 2: - X_new = X.copy() - X_new.group_index = self._set_transform_groups(X_new) - X_new.ldf_ = self.ldf_ - X_new.case_ldf_ = self.case_ldf_ - X_new.case_cdf_ = self.case_cdf_ - X_new._set_slicers() - return X_new - X_new = X.copy() X_new.ldf_ = self._set_ldf(X_new).set_backend(self.ldf_.array_backend) X_new._set_slicers() @@ -491,4 +324,236 @@ def transform(self, X): return X_new -ImpliedCaseDevelopment = CaseOutstanding +class ImpliedCaseDevelopment(DevelopmentBase): + """ + Implied Case Development Estimator (Friedland Approach 2). + + An Estimator that derives implied case development factors (LDFs/CDFs) + from benchmark reported and paid development patterns (Friedland 2010, + Chapter 12, Exhibit III). + + This method is commonly used by self-insurers or for excess/reinsurance + layers where credible historical triangles are unavailable, but carried + case reserves and industry/benchmark development patterns are known. + + Parameters + ---------- + reported_pattern : dict, Series, DataFrame, Triangle, or Development + Benchmark reported loss development pattern. Can be cumulative (CDF) + or incremental (LDF), as specified by ``style``. + paid_pattern : dict, Series, DataFrame, Triangle, or Development + Benchmark paid loss development pattern. Can be cumulative (CDF) + or incremental (LDF), as specified by ``style``. + style : {'cdf', 'ldf'}, optional (default='cdf') + Whether ``reported_pattern`` and ``paid_pattern`` are cumulative development + factors ('cdf') or incremental link ratios ('ldf'). + groupby : list, str, or callable, optional + Grouping option for the triangle index. + + Attributes + ---------- + ldf_ : Triangle + Estimated loss development patterns for case outstanding. + case_ldf_ : Triangle + Alias for ``ldf_``. + case_cdf_ : Triangle + Implied cumulative case development factors to ultimate. + case_ : Triangle + The case triangle used during fitting. + + Examples + -------- + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + import pandas as pd + import numpy as np + import chainladder as cl + + case_data = pd.DataFrame({ + "origin": [1998, 1999, 2000, 2001, 2002, 2003], + "valuation": 2008, + "case": [500000, 650000, 800000, 850000, 975000, 1000000], + }) + case_tri = cl.Triangle( + case_data, origin="origin", valuation="valuation", columns="case", cumulative=True + ).val_to_dev() + + rep_cdfs = {132: 1.015, 120: 1.020, 108: 1.030, 96: 1.051, 84: 1.077, 72: 1.131} + paid_cdfs = {132: 1.046, 120: 1.067, 108: 1.109, 96: 1.187, 84: 1.306, 72: 1.489} + + model = cl.ImpliedCaseDevelopment( + reported_pattern=rep_cdfs, + paid_pattern=paid_cdfs, + style="cdf", + ).fit(case_tri) + + print(np.round(model.case_cdf_["case"].values[0, 0, 0, 5:], 3)) + + .. testoutput:: + + [1.545 1.439 1.445 1.421 1.454 1.506] + + """ + + def __init__( + self, + reported_pattern=None, + paid_pattern=None, + style="cdf", + groupby=None, + ): + self.reported_pattern = reported_pattern + self.paid_pattern = paid_pattern + self.style = style + self.groupby = groupby + + def fit(self, X, y=None, sample_weight=None): + """ + Fit the model with X. + + Parameters + ---------- + X : Triangle + Triangle to fit. Can be a 1-channel case outstanding triangle, + or a 2-channel triangle with paid and incurred columns (from which + case outstanding is computed as incurred - paid). + y : Ignored + sample_weight : Ignored + + Returns + ------- + self : object + Returns the instance itself. + """ + from chainladder.development.constant import DevelopmentConstant + + backend = "cupy" if X.array_backend == "cupy" else "numpy" + self.X_ = X.copy() + + if self.reported_pattern is None or self.paid_pattern is None: + raise ValueError( + "ImpliedCaseDevelopment requires both reported_pattern and paid_pattern." + ) + + # 1. Determine case triangle + if len(X.columns) > 1 and "incurred" in X.columns and "paid" in X.columns: + case_tri = (X["incurred"] - X["paid"]).copy() + case_tri.columns = ["case"] + elif len(X.columns) > 1 and "Incurred" in X.columns and "Paid" in X.columns: + case_tri = (X["Incurred"] - X["Paid"]).copy() + case_tri.columns = ["case"] + else: + case_tri = X + + obj = self._set_fit_groups(case_tri).val_to_dev().copy() + + # 2. Extract series of CDFs + rep_s = self._extract_cdf_series(self.reported_pattern, self.style) + paid_s = self._extract_cdf_series(self.paid_pattern, self.style) + + # 3. Determine common development ages + common_devs = sorted(set(rep_s.index).intersection(set(paid_s.index))) + if not common_devs: + raise ValueError( + "reported_pattern and paid_pattern share no common development ages." + ) + + # 4. Implied Case CDF formula (Friedland Exhibit III): + # Case_CDF = [((CDF_rep - 1.0) * CDF_paid) / (CDF_paid - CDF_rep)] + 1.0 + case_cdf_dict = {} + for d in obj.ddims: + if d in rep_s and d in paid_s: + r = float(rep_s[d]) + p = float(paid_s[d]) + den = p - r + if np.isclose(den, 0.0) or den < 0: + case_cdf_dict[d] = 1.0 + else: + case_cdf_dict[d] = ((r - 1.0) * p) / den + 1.0 + else: + case_cdf_dict[d] = 1.0 + + dev_const = DevelopmentConstant(patterns=case_cdf_dict, style="cdf").fit(obj) + + self.ldf_ = dev_const.ldf_.set_backend(backend) + self.case_ldf_ = dev_const.ldf_.set_backend(backend) + self.case_cdf_ = dev_const.cdf_.set_backend(backend) + self.case_ = case_tri + return self + + def transform(self, X): + """ + Transform the triangle by attaching implied case development factors. + + If X is a multi-channel triangle with paid and incurred columns, the + case triangle (incurred - paid) is extracted before attaching ldf_. + + Parameters + ---------- + X : Triangle + The triangle to be transformed. + + Returns + ------- + X_new : Triangle + Transformed case triangle with ldf_, case_ldf_, and case_cdf_ attached. + """ + if len(X.columns) > 1 and "incurred" in X.columns and "paid" in X.columns: + case_tri = (X["incurred"] - X["paid"]).copy() + case_tri.columns = ["case"] + elif len(X.columns) > 1 and "Incurred" in X.columns and "Paid" in X.columns: + case_tri = (X["Incurred"] - X["Paid"]).copy() + case_tri.columns = ["case"] + else: + case_tri = X + + X_new = case_tri.copy() + X_new.group_index = self._set_transform_groups(X_new) + X_new.ldf_ = self.ldf_ + X_new.case_ldf_ = self.case_ldf_ + X_new.case_cdf_ = self.case_cdf_ + X_new._set_slicers() + return X_new + + @staticmethod + def _extract_cdf_series(pattern, style="cdf"): + """Extract a pd.Series of CDF factors indexed by integer development ages.""" + if hasattr(pattern, "cdf_"): + pattern = pattern.cdf_ + style = "cdf" + elif hasattr(pattern, "ldf_") and not hasattr(pattern, "cdf_"): + pattern = pattern.ldf_ + style = "ldf" + + if isinstance(pattern, dict): + s = pd.Series(pattern, dtype=float) + elif isinstance(pattern, pd.Series): + s = pattern.astype(float).copy() + elif isinstance(pattern, pd.DataFrame): + if pattern.shape[0] == 1: + s = pattern.iloc[0].astype(float) + elif pattern.shape[1] == 1: + s = pattern.iloc[:, 0].astype(float) + else: + raise ValueError("DataFrame pattern must have 1 row or 1 column.") + elif hasattr(pattern, "values") and hasattr(pattern, "ddims"): + s = pd.Series(pattern.values[0, 0, 0, :], index=pattern.ddims, dtype=float) + else: + raise TypeError(f"Unsupported pattern type: {type(pattern)}") + + try: + s.index = [int(str(x).split("-")[0]) for x in s.index] + except (ValueError, TypeError): + pass + s = s.sort_index() + + if style == "ldf": + s = s.iloc[::-1].cumprod().iloc[::-1] + return s + + +ImpliedCase = ImpliedCaseDevelopment diff --git a/chainladder/development/tests/test_outstanding.py b/chainladder/development/tests/test_outstanding.py index 6f186f5a6..2c89f5ace 100644 --- a/chainladder/development/tests/test_outstanding.py +++ b/chainladder/development/tests/test_outstanding.py @@ -58,7 +58,6 @@ def test_outstanding_friedland_example(): def test_approach_2_friedland_exhibit_iii(): import pandas as pd - import pytest case_data = pd.DataFrame({ "origin": [1998, 1999, 2000, 2001, 2002, 2003], @@ -76,13 +75,12 @@ def test_approach_2_friedland_exhibit_iii(): rep_cdfs = {132: 1.015, 120: 1.020, 108: 1.030, 96: 1.051, 84: 1.077, 72: 1.131} paid_cdfs = {132: 1.046, 120: 1.067, 108: 1.109, 96: 1.187, 84: 1.306, 72: 1.489} - model = cl.CaseOutstanding( + model = cl.ImpliedCaseDevelopment( reported_pattern=rep_cdfs, paid_pattern=paid_cdfs, style="cdf", ).fit(case_tri) - assert model.approach_ == 2 # Verify individual case CDF factors match Friedland Exhibit III assert np.isclose( model.case_cdf_.to_frame().loc["(All)", "132-Ult"], 1.506, atol=1e-3 @@ -107,12 +105,6 @@ def test_approach_2_friedland_exhibit_iii(): cl_model = cl.Chainladder().fit(model.transform(case_tri)) assert np.isclose(cl_model.ultimate_.sum().sum(), 7011175, rtol=1e-3) - # Approach 1 properties should raise AttributeError in Approach 2 - with pytest.raises(AttributeError): - _ = model.case_to_prior_case_ - with pytest.raises(AttributeError): - _ = model.paid_to_prior_case_ - def test_approach_2_with_ldf_style(): import pandas as pd @@ -134,14 +126,12 @@ def test_approach_2_with_ldf_style(): rep_ldfs = {12: 1.20, 24: 1.10, 36: 1.00} paid_ldfs = {12: 1.50, 24: 1.25, 36: 1.05} - model = cl.CaseOutstanding( + model = cl.ImpliedCase( reported_pattern=rep_ldfs, paid_pattern=paid_ldfs, style="ldf", - approach=2, ).fit(case_tri) - assert model.approach_ == 2 assert hasattr(model, "case_cdf_") assert hasattr(model, "ldf_") @@ -153,12 +143,11 @@ def test_approach_2_with_development_estimator(): rep_dev = cl.Development().fit(tri["incurred"]) paid_dev = cl.Development().fit(tri["paid"]) - model = cl.CaseOutstanding( + model = cl.ImpliedCaseDevelopment( reported_pattern=rep_dev, paid_pattern=paid_dev, ).fit(case) - assert model.approach_ == 2 cl_model = cl.Chainladder().fit(model.transform(case)) assert cl_model.ultimate_ is not None @@ -173,25 +162,36 @@ def test_approach_2_validation_errors(): with pytest.raises( ValueError, match="requires both reported_pattern and paid_pattern" ): - cl.CaseOutstanding(approach=2, paid_pattern={12: 1.5}).fit(case) + cl.ImpliedCaseDevelopment(paid_pattern={12: 1.5}).fit(case) # No common development ages with pytest.raises(ValueError, match="share no common development ages"): - cl.CaseOutstanding( + cl.ImpliedCaseDevelopment( reported_pattern={12: 1.2}, paid_pattern={24: 1.5}, - approach=2, ).fit(case) + # Calling CaseOutstanding with Approach 2 arguments raises helpful ValueError + with pytest.raises(ValueError, match="Approach 2 has been decoupled"): + cl.CaseOutstanding(reported_pattern={12: 1.2}, paid_pattern={12: 1.5}).fit(tri) + with pytest.raises(ValueError, match="Approach 2 has been decoupled"): + cl.CaseOutstanding(approach=2).fit(tri) + def test_implied_case_development_alias(): - from chainladder.development.outstanding import ImpliedCaseDevelopment - from chainladder.development import ImpliedCaseDevelopment as DevImplied + from chainladder.development.outstanding import ImpliedCaseDevelopment, ImpliedCase + from chainladder.development import ( + ImpliedCaseDevelopment as DevImplied, + ImpliedCase as DevImpliedShort, + ) import chainladder as cl_root - assert ImpliedCaseDevelopment is cl.CaseOutstanding - assert DevImplied is cl.CaseOutstanding - assert cl_root.ImpliedCaseDevelopment is cl.CaseOutstanding + assert ImpliedCase is ImpliedCaseDevelopment + assert DevImpliedShort is ImpliedCaseDevelopment + assert cl_root.ImpliedCase is ImpliedCaseDevelopment + assert DevImplied is ImpliedCaseDevelopment + assert cl_root.ImpliedCaseDevelopment is ImpliedCaseDevelopment + assert ImpliedCaseDevelopment is not cl.CaseOutstanding def test_approach_2_pattern_input_types(): @@ -204,21 +204,21 @@ def test_approach_2_pattern_input_types(): # pd.Series inputs rep_s = pd.Series({12: 1.2, 24: 1.1, 36: 1.0}) paid_s = pd.Series({12: 1.5, 24: 1.25, 36: 1.05}) - m_series = cl.CaseOutstanding( + m_series = cl.ImpliedCaseDevelopment( reported_pattern=rep_s, paid_pattern=paid_s, style="ldf" ).fit(case) assert m_series.case_cdf_ is not None # pd.DataFrame 1-row input df_1row = pd.DataFrame([[1.2, 1.1, 1.0]], columns=[12, 24, 36]) - m_df1 = cl.CaseOutstanding( + m_df1 = cl.ImpliedCaseDevelopment( reported_pattern=df_1row, paid_pattern=paid_s, style="ldf" ).fit(case) assert m_df1.case_cdf_ is not None # pd.DataFrame 1-column input df_1col = pd.DataFrame([1.2, 1.1, 1.0], index=[12, 24, 36]) - m_df2 = cl.CaseOutstanding( + m_df2 = cl.ImpliedCaseDevelopment( reported_pattern=df_1col, paid_pattern=paid_s, style="ldf" ).fit(case) assert m_df2.case_cdf_ is not None @@ -226,11 +226,11 @@ def test_approach_2_pattern_input_types(): # Invalid DataFrame shape (>1 row and >1 col) df_invalid = pd.DataFrame([[1.2, 1.1], [1.0, 1.0]], columns=[12, 24]) with pytest.raises(ValueError, match="must have 1 row or 1 column"): - cl.CaseOutstanding(reported_pattern=df_invalid, paid_pattern=paid_s).fit(case) + cl.ImpliedCaseDevelopment(reported_pattern=df_invalid, paid_pattern=paid_s).fit(case) # Unsupported pattern type with pytest.raises(TypeError, match="Unsupported pattern type"): - cl.CaseOutstanding(reported_pattern=[1.2, 1.1], paid_pattern=paid_s).fit(case) + cl.ImpliedCaseDevelopment(reported_pattern=[1.2, 1.1], paid_pattern=paid_s).fit(case) def test_approach_2_estimator_with_style_ldf_and_no_double_cumprod(): @@ -244,7 +244,7 @@ def test_approach_2_estimator_with_style_ldf_and_no_double_cumprod(): paid_dev = cl.Development().fit(tri["paid"]) # style='ldf' passed with Development estimator should use estimator.cdf_ directly - m = cl.CaseOutstanding( + m = cl.ImpliedCaseDevelopment( reported_pattern=rep_dev, paid_pattern=paid_dev, style="ldf", @@ -258,7 +258,7 @@ def __init__(self, ldf_dict): mock_rep = MockLDFPattern({12: 1.2, 24: 1.1, 36: 1.0}) mock_paid = MockLDFPattern({12: 1.5, 24: 1.25, 36: 1.05}) - m_mock = cl.CaseOutstanding( + m_mock = cl.ImpliedCaseDevelopment( reported_pattern=mock_rep, paid_pattern=mock_paid, style="ldf", @@ -266,47 +266,48 @@ def __init__(self, ldf_dict): assert m_mock.case_cdf_ is not None -def test_approach_2_auto_column_inference_and_den_guard(): - import pytest - +def test_approach_2_auto_column_inference_and_transform_bugbot_fix(): tri = cl.load_sample("usauto") - # Incurred and paid auto-detection + # Incurred and paid auto-detection from 2-channel triangle tri_sub = tri[["incurred", "paid"]] - m = cl.CaseOutstanding( + m = cl.ImpliedCaseDevelopment( reported_pattern={12: 1.1, 24: 1.05}, paid_pattern={12: 1.3, 24: 1.15}, ).fit(tri_sub) assert m.case_cdf_ is not None + # Verify transform on 2-channel triangle extracts 1-channel case triangle with ldf_ + # This directly addresses and validates Bugbot's review concern + transformed = m.transform(tri_sub) + assert len(transformed.columns) == 1 + assert transformed.columns[0] == "case" + assert hasattr(transformed, "ldf_") + + # Verify downstream Chainladder model runs seamlessly on transformed triangle + cl_model = cl.Chainladder().fit(transformed) + assert cl_model.ultimate_ is not None + # Denominator <= 0 guard (CDF_paid <= CDF_rep fallback to 1.0) - m_guard = cl.CaseOutstanding( + m_guard = cl.ImpliedCaseDevelopment( reported_pattern={12: 1.5, 24: 1.2}, paid_pattern={12: 1.2, 24: 1.1}, # paid < rep ).fit(tri_sub) assert np.isclose(m_guard.case_cdf_.to_frame().loc["(All)", "12-Ult"], 1.0) - # Explicit paid_to_incurred in Approach 2 - m_explicit = cl.CaseOutstanding( - paid_to_incurred=("paid", "incurred"), - reported_pattern={12: 1.1, 24: 1.05}, - paid_pattern={12: 1.3, 24: 1.15}, - ).fit(tri) - assert m_explicit.case_cdf_ is not None - # Capitalized Incurred and Paid column names tri_cap = tri.copy() tri_cap.columns = [ "Incurred" if c == "incurred" else "Paid" if c == "paid" else c for c in tri_cap.columns ] - m_cap = cl.CaseOutstanding( + m_cap = cl.ImpliedCaseDevelopment( reported_pattern={12: 1.1, 24: 1.05}, paid_pattern={12: 1.3, 24: 1.15}, ).fit(tri_cap[["Incurred", "Paid"]]) assert m_cap.case_cdf_ is not None - # Groupby in Approach 1 + # Groupby in Approach 1 CaseOutstanding tri_grouped = cl.load_sample("clrd") m_grp = cl.CaseOutstanding( paid_to_incurred=("CumPaidLoss", "IncurLoss"), @@ -315,6 +316,3 @@ def test_approach_2_auto_column_inference_and_den_guard(): assert m_grp.case_to_prior_case_ is not None assert m_grp.paid_to_prior_case_ is not None - # Invalid approach value - with pytest.raises(ValueError, match="Unknown approach"): - cl.CaseOutstanding(approach=99).fit(tri) diff --git a/chainladder/tests/test_public_api.py b/chainladder/tests/test_public_api.py index d4cee8b4c..867c44561 100644 --- a/chainladder/tests/test_public_api.py +++ b/chainladder/tests/test_public_api.py @@ -50,6 +50,7 @@ "ClarkLDF", "CaseOutstanding", "ImpliedCaseDevelopment", + "ImpliedCase", "DevelopmentML", "TweedieGLM", "BarnettZehnwirth", diff --git a/docs/library/api.md b/docs/library/api.md index ea8f7963d..64470c61b 100644 --- a/docs/library/api.md +++ b/docs/library/api.md @@ -58,6 +58,7 @@ Classes ClarkLDF CaseOutstanding ImpliedCaseDevelopment + ImpliedCase TweedieGLM DevelopmentML BarnettZehnwirth From 7325832b57683989a3fcc8ebe3ece307198cc9d9 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 10 Oct 2026 00:14:04 +0530 Subject: [PATCH 4/5] Format unit test cases --- chainladder/development/tests/test_outstanding.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/chainladder/development/tests/test_outstanding.py b/chainladder/development/tests/test_outstanding.py index 2c89f5ace..f60e18588 100644 --- a/chainladder/development/tests/test_outstanding.py +++ b/chainladder/development/tests/test_outstanding.py @@ -226,11 +226,15 @@ def test_approach_2_pattern_input_types(): # Invalid DataFrame shape (>1 row and >1 col) df_invalid = pd.DataFrame([[1.2, 1.1], [1.0, 1.0]], columns=[12, 24]) with pytest.raises(ValueError, match="must have 1 row or 1 column"): - cl.ImpliedCaseDevelopment(reported_pattern=df_invalid, paid_pattern=paid_s).fit(case) + cl.ImpliedCaseDevelopment(reported_pattern=df_invalid, paid_pattern=paid_s).fit( + case + ) # Unsupported pattern type with pytest.raises(TypeError, match="Unsupported pattern type"): - cl.ImpliedCaseDevelopment(reported_pattern=[1.2, 1.1], paid_pattern=paid_s).fit(case) + cl.ImpliedCaseDevelopment(reported_pattern=[1.2, 1.1], paid_pattern=paid_s).fit( + case + ) def test_approach_2_estimator_with_style_ldf_and_no_double_cumprod(): @@ -315,4 +319,3 @@ def test_approach_2_auto_column_inference_and_transform_bugbot_fix(): ).fit(tri_grouped) assert m_grp.case_to_prior_case_ is not None assert m_grp.paid_to_prior_case_ is not None - From 8221ddd59ccbf9178d8a38f5a1f72b6d8464c88f Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sun, 11 Oct 2026 23:35:49 +0530 Subject: [PATCH 5/5] Address review feedback --- chainladder/development/outstanding.py | 101 ++++++++---------- .../development/tests/test_outstanding.py | 70 +++++------- 2 files changed, 69 insertions(+), 102 deletions(-) diff --git a/chainladder/development/outstanding.py b/chainladder/development/outstanding.py index a41ca4b05..0f4964269 100644 --- a/chainladder/development/outstanding.py +++ b/chainladder/development/outstanding.py @@ -1,6 +1,10 @@ # 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 typing import TYPE_CHECKING, Any + from chainladder.development import Development, DevelopmentBase from chainladder import options from chainladder.utils.utility_functions import concat @@ -9,6 +13,9 @@ import numpy as np import pandas as pd +if TYPE_CHECKING: + from chainladder.core import Triangle + class CaseOutstanding(DevelopmentBase): """ @@ -33,7 +40,7 @@ class CaseOutstanding(DevelopmentBase): Parameters ---------- - paid_to_incurred: tuple or list of tuples, optional + paid_to_incurred: tuple or list of tuples A tuple representing the paid and incurred ``columns`` of the triangles such as ``('paid', 'incurred')``. paid_n_periods: integer, optional (default=-1) @@ -129,17 +136,7 @@ def __init__( paid_n_periods=-1, case_n_periods=-1, groupby=None, - **kwargs, ): - if ( - "reported_pattern" in kwargs - or "paid_pattern" in kwargs - or kwargs.get("approach") == 2 - ): - raise ValueError( - "Approach 2 has been decoupled into cl.ImpliedCaseDevelopment (or cl.ImpliedCase). " - "Use cl.ImpliedCaseDevelopment(reported_pattern=..., paid_pattern=...) instead." - ) self.paid_to_incurred = paid_to_incurred self.paid_n_periods = paid_n_periods self.case_n_periods = case_n_periods @@ -347,17 +344,16 @@ class ImpliedCaseDevelopment(DevelopmentBase): style : {'cdf', 'ldf'}, optional (default='cdf') Whether ``reported_pattern`` and ``paid_pattern`` are cumulative development factors ('cdf') or incremental link ratios ('ldf'). - groupby : list, str, or callable, optional - Grouping option for the triangle index. Attributes ---------- - ldf_ : Triangle - Estimated loss development patterns for case outstanding. - case_ldf_ : Triangle - Alias for ``ldf_``. case_cdf_ : Triangle Implied cumulative case development factors to ultimate. + case_ldf_ : Triangle + Incremental link ratios implied by ``case_cdf_``. + ldf_ : Triangle + Estimated loss development patterns for case outstanding (alias for ``case_ldf_`` + for downstream compatibility with estimators like :class:`~chainladder.Chainladder`). case_ : Triangle The case triangle used during fitting. @@ -401,26 +397,22 @@ class ImpliedCaseDevelopment(DevelopmentBase): def __init__( self, - reported_pattern=None, - paid_pattern=None, - style="cdf", - groupby=None, + reported_pattern: Any = None, + paid_pattern: Any = None, + style: str = "cdf", ): self.reported_pattern = reported_pattern self.paid_pattern = paid_pattern self.style = style - self.groupby = groupby - def fit(self, X, y=None, sample_weight=None): + def fit(self, X: Triangle, y: Any = None, sample_weight: Any = None): """ Fit the model with X. Parameters ---------- X : Triangle - Triangle to fit. Can be a 1-channel case outstanding triangle, - or a 2-channel triangle with paid and incurred columns (from which - case outstanding is computed as incurred - paid). + Triangle of case reserves to fit. y : Ignored sample_weight : Ignored @@ -439,17 +431,15 @@ def fit(self, X, y=None, sample_weight=None): "ImpliedCaseDevelopment requires both reported_pattern and paid_pattern." ) - # 1. Determine case triangle - if len(X.columns) > 1 and "incurred" in X.columns and "paid" in X.columns: - case_tri = (X["incurred"] - X["paid"]).copy() - case_tri.columns = ["case"] - elif len(X.columns) > 1 and "Incurred" in X.columns and "Paid" in X.columns: - case_tri = (X["Incurred"] - X["Paid"]).copy() - case_tri.columns = ["case"] - else: - case_tri = X + if len(X.columns) > 1: + raise ValueError( + f"ImpliedCaseDevelopment expects a 1-channel Triangle representing case reserves, " + f"but received {len(X.columns)} columns ({list(X.columns)}). " + f"Compute case reserves (e.g. incurred - paid) before fitting." + ) - obj = self._set_fit_groups(case_tri).val_to_dev().copy() + case_tri = X + obj = case_tri.val_to_dev().copy() # 2. Extract series of CDFs rep_s = self._extract_cdf_series(self.reported_pattern, self.style) @@ -485,34 +475,27 @@ def fit(self, X, y=None, sample_weight=None): self.case_ = case_tri return self - def transform(self, X): + def transform(self, X: Triangle) -> Triangle: """ - Transform the triangle by attaching implied case development factors. + Transform the triangle by attaching implied case development factors. - If X is a multi-channel triangle with paid and incurred columns, the - case triangle (incurred - paid) is extracted before attaching ldf_. - - Parameters - ---------- - X : Triangle - The triangle to be transformed. + Parameters + ---------- + X : Triangle + The case triangle to be transformed. Returns - ------- - X_new : Triangle - Transformed case triangle with ldf_, case_ldf_, and case_cdf_ attached. + ------- + X_new : Triangle + Transformed case triangle with ldf_, case_ldf_, and case_cdf_ attached. """ - if len(X.columns) > 1 and "incurred" in X.columns and "paid" in X.columns: - case_tri = (X["incurred"] - X["paid"]).copy() - case_tri.columns = ["case"] - elif len(X.columns) > 1 and "Incurred" in X.columns and "Paid" in X.columns: - case_tri = (X["Incurred"] - X["Paid"]).copy() - case_tri.columns = ["case"] - else: - case_tri = X + if len(X.columns) > 1: + raise ValueError( + f"ImpliedCaseDevelopment expects a 1-channel Triangle representing case reserves, " + f"but received {len(X.columns)} columns ({list(X.columns)})." + ) - X_new = case_tri.copy() - X_new.group_index = self._set_transform_groups(X_new) + X_new = X.copy() X_new.ldf_ = self.ldf_ X_new.case_ldf_ = self.case_ldf_ X_new.case_cdf_ = self.case_cdf_ @@ -520,7 +503,7 @@ def transform(self, X): return X_new @staticmethod - def _extract_cdf_series(pattern, style="cdf"): + def _extract_cdf_series(pattern: Any, style: str = "cdf") -> pd.Series: """Extract a pd.Series of CDF factors indexed by integer development ages.""" if hasattr(pattern, "cdf_"): pattern = pattern.cdf_ diff --git a/chainladder/development/tests/test_outstanding.py b/chainladder/development/tests/test_outstanding.py index f60e18588..079552a76 100644 --- a/chainladder/development/tests/test_outstanding.py +++ b/chainladder/development/tests/test_outstanding.py @@ -82,23 +82,11 @@ def test_approach_2_friedland_exhibit_iii(): ).fit(case_tri) # Verify individual case CDF factors match Friedland Exhibit III - assert np.isclose( - model.case_cdf_.to_frame().loc["(All)", "132-Ult"], 1.506, atol=1e-3 - ) - assert np.isclose( - model.case_cdf_.to_frame().loc["(All)", "120-Ult"], 1.454, atol=1e-3 - ) - assert np.isclose( - model.case_cdf_.to_frame().loc["(All)", "108-Ult"], 1.421, atol=1e-3 - ) - assert np.isclose( - model.case_cdf_.to_frame().loc["(All)", "96-Ult"], 1.445, atol=1e-3 - ) - assert np.isclose( - model.case_cdf_.to_frame().loc["(All)", "84-Ult"], 1.439, atol=1e-3 - ) - assert np.isclose( - model.case_cdf_.to_frame().loc["(All)", "72-Ult"], 1.545, atol=1e-3 + expected_cdfs = np.array([1.545, 1.439, 1.445, 1.421, 1.454, 1.506]) + assert np.allclose( + model.case_cdf_["case"].values[0, 0, 0, 5:], + expected_cdfs, + atol=1e-3, ) # Chainladder projection @@ -171,11 +159,11 @@ def test_approach_2_validation_errors(): paid_pattern={24: 1.5}, ).fit(case) - # Calling CaseOutstanding with Approach 2 arguments raises helpful ValueError - with pytest.raises(ValueError, match="Approach 2 has been decoupled"): - cl.CaseOutstanding(reported_pattern={12: 1.2}, paid_pattern={12: 1.5}).fit(tri) - with pytest.raises(ValueError, match="Approach 2 has been decoupled"): - cl.CaseOutstanding(approach=2).fit(tri) + # Calling CaseOutstanding with unexpected keyword arguments raises TypeError + with pytest.raises(TypeError): + cl.CaseOutstanding(reported_pattern={12: 1.2}, paid_pattern={12: 1.5}) + with pytest.raises(TypeError): + cl.CaseOutstanding(approach=2) def test_implied_case_development_alias(): @@ -270,47 +258,43 @@ def __init__(self, ldf_dict): assert m_mock.case_cdf_ is not None -def test_approach_2_auto_column_inference_and_transform_bugbot_fix(): +def test_approach_2_single_channel_case_triangle_and_transform(): + import pytest + tri = cl.load_sample("usauto") + case = tri["incurred"] - tri["paid"] - # Incurred and paid auto-detection from 2-channel triangle - tri_sub = tri[["incurred", "paid"]] + # 1-channel case triangle fits cleanly m = cl.ImpliedCaseDevelopment( reported_pattern={12: 1.1, 24: 1.05}, paid_pattern={12: 1.3, 24: 1.15}, - ).fit(tri_sub) + ).fit(case) assert m.case_cdf_ is not None - # Verify transform on 2-channel triangle extracts 1-channel case triangle with ldf_ - # This directly addresses and validates Bugbot's review concern - transformed = m.transform(tri_sub) + # Verify transform on 1-channel case triangle attaches ldf_ and case_cdf_ + transformed = m.transform(case) assert len(transformed.columns) == 1 - assert transformed.columns[0] == "case" assert hasattr(transformed, "ldf_") + assert hasattr(transformed, "case_cdf_") # Verify downstream Chainladder model runs seamlessly on transformed triangle cl_model = cl.Chainladder().fit(transformed) assert cl_model.ultimate_ is not None + # Multi-channel triangle raises ValueError per sklearn conventions + tri_multi = tri[["incurred", "paid"]] + with pytest.raises(ValueError, match="expects a 1-channel Triangle"): + m.fit(tri_multi) + with pytest.raises(ValueError, match="expects a 1-channel Triangle"): + m.transform(tri_multi) + # Denominator <= 0 guard (CDF_paid <= CDF_rep fallback to 1.0) m_guard = cl.ImpliedCaseDevelopment( reported_pattern={12: 1.5, 24: 1.2}, paid_pattern={12: 1.2, 24: 1.1}, # paid < rep - ).fit(tri_sub) + ).fit(case) assert np.isclose(m_guard.case_cdf_.to_frame().loc["(All)", "12-Ult"], 1.0) - # Capitalized Incurred and Paid column names - tri_cap = tri.copy() - tri_cap.columns = [ - "Incurred" if c == "incurred" else "Paid" if c == "paid" else c - for c in tri_cap.columns - ] - m_cap = cl.ImpliedCaseDevelopment( - reported_pattern={12: 1.1, 24: 1.05}, - paid_pattern={12: 1.3, 24: 1.15}, - ).fit(tri_cap[["Incurred", "Paid"]]) - assert m_cap.case_cdf_ is not None - # Groupby in Approach 1 CaseOutstanding tri_grouped = cl.load_sample("clrd") m_grp = cl.CaseOutstanding(