diff --git a/chainladder/__init__.py b/chainladder/__init__.py index b297867b3..cfccea312 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -68,6 +68,8 @@ DevelopmentConstant, ClarkLDF, CaseOutstanding, + ImpliedCaseDevelopment, + ImpliedCase, DevelopmentML, TweedieGLM, BarnettZehnwirth, diff --git a/chainladder/development/__init__.py b/chainladder/development/__init__.py index 2179745bc..7a5e15f48 100644 --- a/chainladder/development/__init__.py +++ b/chainladder/development/__init__.py @@ -4,7 +4,11 @@ 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, + ImpliedCase, +) # 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 +21,8 @@ "DevelopmentConstant", "ClarkLDF", "CaseOutstanding", + "ImpliedCaseDevelopment", + "ImpliedCase", "DevelopmentML", "TweedieGLM", "BarnettZehnwirth", diff --git a/chainladder/development/outstanding.py b/chainladder/development/outstanding.py index 3cc73cc8a..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): """ @@ -25,19 +32,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 A tuple representing the paid and incurred ``columns`` of the triangles - such as ``('paid', 'incurred')`` + such as ``('paid', 'incurred')``. paid_n_periods: integer, optional (default=-1) 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. For all origin periods, set case_n_periods=-1 + groupby: optional + Grouping option. Attributes ---------- @@ -118,7 +131,11 @@ class CaseOutstanding(DevelopmentBase): """ 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, ): self.paid_to_incurred = paid_to_incurred self.paid_n_periods = paid_n_periods @@ -302,3 +319,224 @@ def transform(self, X): X_new.paid_ldf_ = self.paid_ldf_ X_new.case_ldf_ = self.case_ldf_ return X_new + + +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'). + + Attributes + ---------- + 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. + + 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: Any = None, + paid_pattern: Any = None, + style: str = "cdf", + ): + self.reported_pattern = reported_pattern + self.paid_pattern = paid_pattern + self.style = style + + def fit(self, X: Triangle, y: Any = None, sample_weight: Any = None): + """ + Fit the model with X. + + Parameters + ---------- + X : Triangle + Triangle of case reserves to fit. + 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." + ) + + 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." + ) + + 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) + 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: Triangle) -> Triangle: + """ + Transform the triangle by attaching implied case development factors. + + Parameters + ---------- + X : Triangle + The case triangle to be transformed. + + Returns + ------- + X_new : Triangle + Transformed case triangle with ldf_, case_ldf_, and case_cdf_ attached. + """ + 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 = X.copy() + 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: 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_ + 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 efe5fbbfe..079552a76 100644 --- a/chainladder/development/tests/test_outstanding.py +++ b/chainladder/development/tests/test_outstanding.py @@ -54,3 +54,252 @@ 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 + + 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) + + # Verify individual case CDF factors match Friedland Exhibit III + 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 + cl_model = cl.Chainladder().fit(model.transform(case_tri)) + assert np.isclose(cl_model.ultimate_.sum().sum(), 7011175, rtol=1e-3) + + +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.ImpliedCase( + reported_pattern=rep_ldfs, + paid_pattern=paid_ldfs, + style="ldf", + ).fit(case_tri) + + 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.ImpliedCaseDevelopment( + reported_pattern=rep_dev, + paid_pattern=paid_dev, + ).fit(case) + + 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.ImpliedCaseDevelopment(paid_pattern={12: 1.5}).fit(case) + + # No common development ages + with pytest.raises(ValueError, match="share no common development ages"): + cl.ImpliedCaseDevelopment( + reported_pattern={12: 1.2}, + paid_pattern={24: 1.5}, + ).fit(case) + + # 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(): + from chainladder.development.outstanding import ImpliedCaseDevelopment, ImpliedCase + from chainladder.development import ( + ImpliedCaseDevelopment as DevImplied, + ImpliedCase as DevImpliedShort, + ) + import chainladder as cl_root + + 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(): + 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.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.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.ImpliedCaseDevelopment( + 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.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 + ) + + +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.ImpliedCaseDevelopment( + 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.ImpliedCaseDevelopment( + reported_pattern=mock_rep, + paid_pattern=mock_paid, + style="ldf", + ).fit(case) + assert m_mock.case_cdf_ is not None + + +def test_approach_2_single_channel_case_triangle_and_transform(): + import pytest + + tri = cl.load_sample("usauto") + case = tri["incurred"] - tri["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(case) + assert m.case_cdf_ is not None + + # Verify transform on 1-channel case triangle attaches ldf_ and case_cdf_ + transformed = m.transform(case) + assert len(transformed.columns) == 1 + 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(case) + assert np.isclose(m_guard.case_cdf_.to_frame().loc["(All)", "12-Ult"], 1.0) + + # Groupby in Approach 1 CaseOutstanding + 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 diff --git a/chainladder/tests/test_public_api.py b/chainladder/tests/test_public_api.py index a14673293..867c44561 100644 --- a/chainladder/tests/test_public_api.py +++ b/chainladder/tests/test_public_api.py @@ -49,6 +49,8 @@ "DevelopmentConstant", "ClarkLDF", "CaseOutstanding", + "ImpliedCaseDevelopment", + "ImpliedCase", "DevelopmentML", "TweedieGLM", "BarnettZehnwirth", diff --git a/docs/library/api.md b/docs/library/api.md index daab693d2..64470c61b 100644 --- a/docs/library/api.md +++ b/docs/library/api.md @@ -57,6 +57,8 @@ Classes IncrementalAdditive ClarkLDF CaseOutstanding + ImpliedCaseDevelopment + ImpliedCase TweedieGLM DevelopmentML BarnettZehnwirth