Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 71 additions & 7 deletions chainladder/development/constant.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand All @@ -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_"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is a neat convenience feature. could you please either document it in the docstring or throw a warning?

patterns = patterns.cdf_
elif hasattr(patterns, "ldf_") and not hasattr(patterns, "cdf_"):
patterns = patterns.ldf_

if hasattr(patterns, "values") and hasattr(patterns, "ddims"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

a transformed triangle may have ldf_ and wouldn't get here

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
Expand Down
88 changes: 88 additions & 0 deletions chainladder/development/tests/test_constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,91 @@ 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)

# 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)
# 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)

# 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)
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)
Loading