From 3651c2f1a2c432d4302778ef01b019a66c98b2f6 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Mon, 12 Oct 2026 00:19:31 +0530 Subject: [PATCH 1/2] Support tabular constant patterns --- chainladder/development/constant.py | 78 +++++++++++++++++-- .../development/tests/test_constant.py | 65 ++++++++++++++++ 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 64ea8cfda..6890527db 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.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 Any, Literal + from chainladder.development.base import DevelopmentBase import numpy as np import pandas as pd @@ -14,9 +18,12 @@ class DevelopmentConstant(DevelopmentBase): Parameters ---------- - patterns: dict or callable - A dictionary key:value representation of age(in months):value. If callable - is supplied, callable must return a dict for each element of the callable axis + patterns: dict, Series, DataFrame, Triangle, estimator, or callable + A representation of development factors indexed by age (in months). Can be + a dictionary of age:value, a pandas Series, a 1-row or 1-column DataFrame, + a 1D Triangle pattern (such as another estimator's ``ldf_`` or ``cdf_``), + or a fitted estimator. If callable is supplied, callable must return a dict + for each element of the callable axis. style: Literal['cdf', 'ldf'], optional (default='ldf') Type of pattern given to the Estimator. `cdf` for cumulative development factors and `ldf` for (non-cumulative) loss development factors. @@ -107,7 +114,13 @@ class DevelopmentConstant(DevelopmentBase): """ - def __init__(self, patterns=None, style="ldf", callable_axis=0, groupby=None): + def __init__( + self, + patterns: Any = None, + style: Literal["cdf", "ldf"] = "ldf", + callable_axis: int = 0, + groupby: Any = None, + ): self.patterns = patterns self.style = style self.callable_axis = callable_axis @@ -152,9 +165,11 @@ def fit(self, X, y=None, sample_weight=None): raise ValueError("callable axis needs to be 0 or 1") pattern_length = len(sample_pattern) pattern_ddims = sorted(sample_pattern.keys()) + patterns_dict = None else: - pattern_length = len(self.patterns) - pattern_ddims = sorted(self.patterns.keys()) + patterns_dict = self._normalize_patterns(self.patterns, self.style) + pattern_length = len(patterns_dict) + pattern_ddims = sorted(patterns_dict.keys()) # pattern supplied is much shorter than the triangle if pattern_length < len(obj.ddims) - 1: @@ -198,7 +213,7 @@ def fit(self, X, y=None, sample_weight=None): else: raise ValueError("callable axis needs to be 0 or 1") else: - ldf = xp.array([self.patterns.get(item, 1.0) for item in obj.ddims]) + ldf = xp.array([patterns_dict.get(item, 1.0) for item in obj.ddims]) ldf = ldf[None, None, None, :] if self.style == "cdf": @@ -213,6 +228,55 @@ def fit(self, X, y=None, sample_weight=None): self.ldf_.valuation_date = pd.to_datetime(options.ULT_VAL) return self + @staticmethod + def _normalize_patterns(patterns, style="ldf"): + """Normalize pattern input (dict, Series, DataFrame, Triangle, estimator) into a dict.""" + if hasattr(patterns, "cdf_") and style == "cdf": + patterns = patterns.cdf_ + elif hasattr(patterns, "ldf_") and style == "ldf": + patterns = patterns.ldf_ + elif hasattr(patterns, "cdf_") and not hasattr(patterns, "ldf_"): + patterns = patterns.cdf_ + elif hasattr(patterns, "ldf_") and not hasattr(patterns, "cdf_"): + patterns = patterns.ldf_ + + if hasattr(patterns, "values") and hasattr(patterns, "ddims"): + if ( + hasattr(patterns, "shape") + and len(patterns.shape) >= 3 + and patterns.shape[2] > 1 + ): + raise ValueError( + "Triangle pattern must have a single origin row (e.g. model.ldf_ or model.cdf_)." + ) + vals = patterns.values[0, 0, 0, :] + s = pd.Series(vals, index=patterns.ddims, dtype=float) + elif isinstance(patterns, pd.DataFrame): + if patterns.shape[0] == 1: + s = patterns.iloc[0].astype(float) + elif patterns.shape[1] == 1: + s = patterns.iloc[:, 0].astype(float) + else: + raise ValueError("DataFrame pattern must have 1 row or 1 column.") + elif isinstance(patterns, pd.Series): + s = patterns.astype(float).copy() + elif isinstance(patterns, dict): + s = pd.Series(patterns, dtype=float) + else: + raise TypeError( + f"Unsupported pattern type: {type(patterns)}. " + f"Expected dict, Series, DataFrame, Triangle, estimator, or callable." + ) + + parsed_index = [] + for x in s.index: + try: + parsed_index.append(int(str(x).split("-")[0])) + except (ValueError, TypeError): + parsed_index.append(x) + s.index = parsed_index + return s.to_dict() + def transform(self, X): """ If X and self are of different shapes, align self to X, else diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index f9c27f43c..2c670e285 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -476,3 +476,68 @@ def test_constant_incr(): np.round(result.cdf_.to_frame().values.flatten(), 6) == np.array([4.0, 2.9, 1.8, 1.4, 1.2, 1.1, 1.03, 1.02, 1.005]) ) + + +def test_constant_series_pattern(raa): + dev = cl.Development().fit(raa) + # Series with integer index + s_int = pd.Series(dev.ldf_.values[0, 0, 0, :], index=dev.ldf_.ddims) + model_int = cl.DevelopmentConstant(patterns=s_int, style="ldf").fit(raa) + assert np.allclose(dev.ldf_.values, model_int.ldf_.values) + + # Series with interval string index (e.g. '12-24') + s_str = pd.Series( + dev.ldf_.values[0, 0, 0, :], index=[f"{d}-{d + 12}" for d in dev.ldf_.ddims] + ) + model_str = cl.DevelopmentConstant(patterns=s_str, style="ldf").fit(raa) + assert np.allclose(dev.ldf_.values, model_str.ldf_.values) + + +def test_constant_dataframe_pattern(raa): + dev = cl.Development().fit(raa) + # 1-row DataFrame + df_row = pd.DataFrame([dev.ldf_.values[0, 0, 0, :]], columns=dev.ldf_.ddims) + model_row = cl.DevelopmentConstant(patterns=df_row, style="ldf").fit(raa) + assert np.allclose(dev.ldf_.values, model_row.ldf_.values) + + # 1-column DataFrame + df_col = pd.DataFrame(dev.ldf_.values[0, 0, 0, :], index=dev.ldf_.ddims) + model_col = cl.DevelopmentConstant(patterns=df_col, style="ldf").fit(raa) + assert np.allclose(dev.ldf_.values, model_col.ldf_.values) + + +def test_constant_triangle_pattern(raa): + dev = cl.Development().fit(raa) + # Triangle pattern directly from dev.ldf_ + model_ldf = cl.DevelopmentConstant(patterns=dev.ldf_, style="ldf").fit(raa) + assert np.allclose(dev.ldf_.values, model_ldf.ldf_.values) + + # Triangle pattern directly from dev.cdf_ + model_cdf = cl.DevelopmentConstant(patterns=dev.cdf_, style="cdf").fit(raa) + assert np.allclose(dev.cdf_.values, model_cdf.cdf_.values) + + +def test_constant_estimator_pattern(raa): + dev = cl.Development().fit(raa) + # Fitted Development estimator with style='ldf' + model_est_ldf = cl.DevelopmentConstant(patterns=dev, style="ldf").fit(raa) + assert np.allclose(dev.ldf_.values, model_est_ldf.ldf_.values) + + # Fitted Development estimator with style='cdf' + model_est_cdf = cl.DevelopmentConstant(patterns=dev, style="cdf").fit(raa) + assert np.allclose(dev.cdf_.values, model_est_cdf.cdf_.values) + + +def test_constant_invalid_pattern_types(raa): + # 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.DevelopmentConstant(patterns=df_invalid).fit(raa) + + # Multi-origin Triangle pattern + with pytest.raises(ValueError, match="must have a single origin row"): + cl.DevelopmentConstant(patterns=raa).fit(raa) + + # Unsupported pattern type + with pytest.raises(TypeError, match="Unsupported pattern type"): + cl.DevelopmentConstant(patterns=[1.2, 1.1]).fit(raa) From f9fd4835c08c2be55409d93127a542c74f730c9d Mon Sep 17 00:00:00 2001 From: priyam0k Date: Mon, 12 Oct 2026 01:04:46 +0530 Subject: [PATCH 2/2] Add pattern coverage tests --- .../development/tests/test_constant.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 2c670e285..5c969fcda 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -492,6 +492,11 @@ def test_constant_series_pattern(raa): model_str = cl.DevelopmentConstant(patterns=s_str, style="ldf").fit(raa) assert np.allclose(dev.ldf_.values, model_str.ldf_.values) + # Series with non-numeric index labels (fallback to original keys) + s_non_numeric = pd.Series([1.5, 1.2], index=["dev_12", "dev_24"]) + normalized = cl.DevelopmentConstant._normalize_patterns(s_non_numeric) + assert normalized == {"dev_12": 1.5, "dev_24": 1.2} + def test_constant_dataframe_pattern(raa): dev = cl.Development().fit(raa) @@ -527,6 +532,24 @@ def test_constant_estimator_pattern(raa): model_est_cdf = cl.DevelopmentConstant(patterns=dev, style="cdf").fit(raa) assert np.allclose(dev.cdf_.values, model_est_cdf.cdf_.values) + # Object with only cdf_ (no ldf_) with default/ldf style fallback + class OnlyCDF: + def __init__(self, cdf): + self.cdf_ = cdf + + model_only_cdf = cl.DevelopmentConstant(patterns=OnlyCDF(dev.cdf_)).fit(raa) + assert np.allclose(dev.cdf_.values, model_only_cdf.ldf_.values) + + # Object with only ldf_ (no cdf_) with cdf style fallback + class OnlyLDF: + def __init__(self, ldf): + self.ldf_ = ldf + + model_only_ldf = cl.DevelopmentConstant( + patterns=OnlyLDF(dev.ldf_), style="cdf" + ).fit(raa) + assert np.allclose(dev.ldf_.values, model_only_ldf.cdf_.values) + def test_constant_invalid_pattern_types(raa): # Invalid DataFrame shape (>1 row and >1 col)