diff --git a/chainladder/adjustments/disposal.py b/chainladder/adjustments/disposal.py index fcd3a568f..2e4e3fa06 100644 --- a/chainladder/adjustments/disposal.py +++ b/chainladder/adjustments/disposal.py @@ -339,3 +339,28 @@ def fit_transform(self, X, y=None, sample_weight=None): Triangle with new full_triangle_ """ return self.fit(X, y, sample_weight).transform(X, sample_weight=sample_weight) + + def _param_property(self, X: Triangle, params: np.ndarray) -> Triangle: + """ + Private method to wrap a numpy array into a Triangle + + Uses and overrides DevelopmentBase._param_property. + + Parameters + ---------- + X : Triangle + The Triangle object that we want to place the parameter into + + params : np.ndarray + The parammeter we want to turn into a Triangle + + Returns + ------- + Triangle + A Triangle that mimics X but has params values + """ + obj = super()._param_property(X, params) + obj.values = params + obj.is_disposal_rate = True + + return obj diff --git a/chainladder/core/base.py b/chainladder/core/base.py index ac89fc131..ae20ae43b 100644 --- a/chainladder/core/base.py +++ b/chainladder/core/base.py @@ -28,6 +28,7 @@ from chainladder.utils.sparse import sp from chainladder.adjustments.disposal import DisposalMixin +from chainladder.development.incremental import IncrementalMixin from typing import Optional, TYPE_CHECKING @@ -50,6 +51,7 @@ class TriangleBase( Common, ABC, DisposalMixin, + IncrementalMixin, ): """This class handles the initialization of a triangle""" diff --git a/chainladder/core/common.py b/chainladder/core/common.py index 8f421cb51..53eee285d 100644 --- a/chainladder/core/common.py +++ b/chainladder/core/common.py @@ -82,7 +82,7 @@ def has_ldf(self): @property def has_zeta(self): - if hasattr(self, "zeta_"): + if hasattr(self, "_zeta_"): return True else: return False @@ -159,13 +159,6 @@ def pct_unreported_(self): ) return 1 - 1 / self.cdf_ - @property - def cum_zeta_(self): - if not self.has_zeta: - x = self.__class__.__name__ - raise AttributeError("'" + x + "' object has no attribute 'cum_zeta_'") - return self.zeta_.incr_to_cum() - @property def ibnr_(self): """ diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 45c1e649b..c89ecb00d 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -3200,7 +3200,7 @@ def test_has_zeta_true(raa: Triangle) -> None: None """ fitted = cl.IncrementalAdditive().fit(raa, sample_weight=raa.latest_diagonal) - assert fitted.has_zeta is True + assert fitted.has_zeta def test_has_zeta_false(raa: Triangle) -> None: @@ -3216,7 +3216,7 @@ def test_has_zeta_false(raa: Triangle) -> None: ------- None """ - assert cl.Development().fit(raa).has_zeta is False + assert not cl.Development().fit(raa).has_zeta def test_cum_zeta_raises_when_no_zeta(raa: Triangle) -> None: diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index a496d7edc..ade43f467 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -1046,6 +1046,17 @@ def is_disposal_rate(self) -> bool: def is_disposal_rate(self, is_dr: bool) -> None: self._is_disposal_rate = is_dr + @property + def is_additive(self) -> bool: + """ + Indicates whether the Triangle holds a normal additive ratio (which goes from tail to head) + """ + return getattr(self, "_is_additive", False) + + @is_additive.setter + def is_additive(self, is_add: bool) -> None: + self._is_additive = is_add + def align_pattern( self, X: Triangle, sample_weight: Triangle | None = None ) -> Triangle: @@ -1377,20 +1388,30 @@ def incr_to_cum(self, inplace=False): if inplace: xp = self.get_array_module() if not self.is_cumulative: - if self.is_pattern & (not self.is_disposal_rate): - if hasattr(self, "is_additive") and self.is_additive: - values = xp.nan_to_num(self.values[..., ::-1]) - values = num_to_value(values, 0) - self.values = ( - xp.cumsum(values, -1)[..., ::-1] * self.nan_triangle - ) - else: - values = xp.nan_to_num(self.values[..., ::-1]) - values = num_to_value(values, 1) - values = xp.cumprod(values, -1)[..., ::-1] - self.values = values * self.nan_triangle - values = num_to_value(values, self.get_array_module(values).nan) - else: + # fmt: off + if ( + self.is_pattern + and (not self.is_additive) + and (not self.is_disposal_rate) + ): # for multiplicative ldf triangles (mult from left to right) + # fmt: on + values = xp.nan_to_num(self.values[..., ::-1]) + values = num_to_value(values, 1) + values = xp.cumprod(values, -1)[..., ::-1] + self.values = values * self.nan_triangle + values = num_to_value(values, self.get_array_module(values).nan) + # fmt: off + elif ( + self.is_pattern + and self.is_additive + and (not self.is_disposal_rate) + ): # for additive factor triangles (sum from left to right) + # fmt: on + values = xp.nan_to_num(self.values[..., ::-1]) + values = num_to_value(values, 0) + self.values = (xp.cumsum(values, -1)[..., ::-1] * self.nan_triangle) + else: # for normal value triangles + # also for disposal rate triangles (sum from right to left) if self.array_backend not in ["sparse", "dask"]: self.values = ( xp.cumsum(xp.nan_to_num(self.values), 3) @@ -1466,7 +1487,13 @@ def cum_to_incr(self, inplace=False): if inplace: v = self.valuation_date if self.is_cumulative or self.is_cumulative is None: - if self.is_pattern & (not self.is_disposal_rate): + # fmt: off + if ( + self.is_pattern + and (not self.is_additive) + and (not self.is_disposal_rate) + ): # for multiplicative ldf triangles (div from left to right) + # fmt: on xp = self.get_array_module() self.values = num_to_value(xp.nan_to_num(self.values), 1) diff = self.iloc[..., :-1] / self.iloc[..., 1:].values @@ -1478,7 +1505,17 @@ def cum_to_incr(self, inplace=False): axis=3, ) self.values = self.values * self.nan_triangle - else: + # fmt: fn + elif ( + self.is_pattern + and self.is_additive + and (not self.is_disposal_rate) + ): # for additive factor triangles (diff from left to right) + # fmt: on + diff = self.iloc[..., :-1] - self.iloc[..., 1:].values + self = concat((diff, self.iloc[..., -1]), axis=3) + else: # for normal value triangles + # also for disposal rate triangles (diff from right to left) diff = self.iloc[..., 1:] - self.iloc[..., :-1].values self = concat((self.iloc[..., 0], diff), axis=3) self.is_cumulative = False diff --git a/chainladder/development/base.py b/chainladder/development/base.py index 922c4dbc2..4c09067da 100644 --- a/chainladder/development/base.py +++ b/chainladder/development/base.py @@ -409,11 +409,8 @@ def _param_property(X: Triangle, params: np.ndarray) -> Triangle: from chainladder import options obj: Triangle = X[X.origin == X.origin.min()] - obj.values = params obj.valuation_date = pd.to_datetime(options.ULT_VAL) obj.is_pattern = True - obj.is_additive = True - obj.is_cumulative = False obj.virtual_columns.columns = {} obj._set_slicers() return obj diff --git a/chainladder/development/development.py b/chainladder/development/development.py index 705e955fe..3559d65cd 100644 --- a/chainladder/development/development.py +++ b/chainladder/development/development.py @@ -4,7 +4,6 @@ from __future__ import annotations import numpy as np -import pandas as pd import warnings from chainladder.development.base import DevelopmentBase @@ -482,16 +481,28 @@ def transform(self, X): return X_new def _param_property(self, X, params, idx): - from chainladder import options + """ + Private method to wrap a numpy array into a Triangle + + Uses and overrides DevelopmentBase._param_property. + + Parameters + ---------- + X : Triangle + The Triangle object that we want to place the parameter into + + params : np.ndarray + The parammeter we want to turn into a Triangle - obj = X[X.origin == X.origin.min()] + Returns + ------- + Triangle + A Triangle that mimics X but has params values + """ + obj = super()._param_property(X, params) xp = X.get_array_module() obj.values = xp.ones(obj.shape)[..., :-1] * params[..., idx : idx + 1, :] obj.ddims = X.link_ratio.ddims - obj.valuation_date = pd.to_datetime(options.ULT_VAL) - obj.is_pattern = True obj.is_cumulative = False - obj.virtual_columns.columns = {} - obj._set_slicers() return obj diff --git a/chainladder/development/incremental.py b/chainladder/development/incremental.py index 0d7195af3..1b2386deb 100644 --- a/chainladder/development/incremental.py +++ b/chainladder/development/incremental.py @@ -1,13 +1,66 @@ # 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 chainladder.development import DevelopmentBase from chainladder.utils import TriangleWeight import numpy as np +import copy import warnings +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from chainladder.core import Triangle -class IncrementalAdditive(DevelopmentBase): + +class IncrementalMixin: + """ + This class provides attributes for the IncrementalAdditive development method + and transformed `Triangle` + """ + + @property + def zeta_(self) -> Triangle: + """ + Gets the estimated incremental additive factors + """ + if not hasattr(self, "_zeta_"): + raise AttributeError("`zeta_` has not been estimated.") + return self._zeta_ + + @zeta_.setter + def zeta_(self, value: Triangle) -> None: + """ + Sets zeta_ + """ + obj = copy.deepcopy(value) + obj.is_pattern = True + obj.is_additive = True + obj.is_cumulative = False + self._zeta_ = obj + + @property + def cum_zeta_(self) -> Triangle: + """ + Gets the estimated cumulative additive factors + """ + return self.zeta_.incr_to_cum() + + @cum_zeta_.setter + def cum_zeta_(self, value: Triangle) -> None: + """ + Sets cum_zeta_ + """ + obj = copy.deepcopy(value) + obj.is_pattern = True + obj.is_additive = True + obj.is_cumulative = True + self._zeta_ = obj.cum_to_incr() + + +class IncrementalAdditive(DevelopmentBase, IncrementalMixin): """ The Incremental Additive Method. @@ -311,7 +364,7 @@ def transform(self, X): for item in [ "ldf_", "w_", - "zeta_", + "_zeta_", "incremental_", "tri_zeta", "fit_zeta_", @@ -319,3 +372,28 @@ def transform(self, X): ]: X_new.__dict__[item] = self.__dict__[item] return X_new + + def _param_property(self, X, params): + """ + Private method to wrap a numpy array into a Triangle + + Uses and overrides DevelopmentBase._param_property. + + Parameters + ---------- + X : Triangle + The Triangle object that we want to place the parameter into + + params : np.ndarray + The parammeter we want to turn into a Triangle + + Returns + ------- + Triangle + A Triangle that mimics X but has params values + """ + obj = super()._param_property(X, params) + obj.values = params + obj.is_additive = True + + return obj diff --git a/chainladder/development/tests/test_incremental.py b/chainladder/development/tests/test_incremental.py index 58c6ed3c1..6ff4aa0fe 100644 --- a/chainladder/development/tests/test_incremental.py +++ b/chainladder/development/tests/test_incremental.py @@ -1,7 +1,14 @@ +from __future__ import annotations + import chainladder as cl import numpy as np import pytest +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from chainladder.core import Triangle + def test_schmidt(): tri = cl.load_sample("ia_sample") @@ -90,3 +97,42 @@ def test_pipeline(): assert np.array_equal( dev1.zeta_.values, dev2.named_steps.drop_hilo.zeta_.values, True ) + + +def test_setting_cum(atol: float) -> None: + """ + IncrementalMixin allows setting cumulative zeta. Validating that the setting function works properly. + + Parameters + ---------- + atol: float + the absolute tolerance for this test + + Returns + ------- + None + """ + tri = cl.load_sample("ia_sample") + ia = cl.IncrementalAdditive().fit( + tri.iloc[0, 0], sample_weight=tri.iloc[0, 1].latest_diagonal + ) + orig_zeta_ = ia.zeta_ + ia.cum_zeta_ = ia.cum_zeta_ * 2 + assert np.allclose(ia.zeta_.values, (orig_zeta_ * 2).values, atol=atol) + + +def test_no_zeta(raa: Triangle) -> None: + """ + calling zeta_ on a Triangle without _zeta_ raises + + Parameters + ---------- + raa: Triangle + the RAA sample dataset + + Returns + ------- + None + """ + with pytest.raises(AttributeError, match="has not been estimated"): + _ = raa.zeta_