From affade37501388b7ab26e7bbdaac18fd8790bd27 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Tue, 26 May 2026 14:55:16 -0700 Subject: [PATCH 01/25] Fixed the bug --- chainladder/development/constant.py | 42 ++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index e223c25b..d995e8b7 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -6,7 +6,7 @@ class DevelopmentConstant(DevelopmentBase): - """ A Estimator that allows for including of external patterns into a + """A Estimator that allows for including of external patterns into a Development style model. When this estimator is fit against a triangle, only the grain of the existing triangle is retained. @@ -44,51 +44,75 @@ def fit(self, X, y=None, sample_weight=None): Parameters ---------- X : Triangle-like -     Set of LDFs to which the munich adjustment will be applied. + Set of LDFs to which the munich adjustment will be applied. y : Ignored sample_weight : Ignored Returns ------- self : object -     Returns the instance itself. + Returns the instance itself. """ from chainladder import options + + print("In DevelopmentConstant fit") + print("X\n", X) + print("X.is_cumulative\n", X.is_cumulative) + if X.is_cumulative == False: obj = self._set_fit_groups(X).incr_to_cum().val_to_dev().copy() + else: obj = self._set_fit_groups(X).val_to_dev().copy() + xp = obj.get_array_module() - obj = obj.iloc[..., :1, :-1]*0+1 + print("obj.iloc[..., :1, :] * 0 + 1\n", obj.iloc[..., :1, :] * 0 + 1) + # obj = obj.iloc[..., :1, :-1] * 0 + 1 + obj = obj.iloc[..., :1, :] * 0 + 1 + print("obj\n", obj) + if callable(self.patterns): if self.callable_axis == 0: - ldf = obj.index.apply(self.patterns, axis=1) + ldf = obj.index.apply(self.patterns, axis=1) ldf = ( pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) - .fillna(1)[obj.ddims].values) + .fillna(1)[obj.ddims] + .values + ) ldf = xp.array(ldf[:, None, None, :]) + print("ldf\n", ldf) + elif self.callable_axis == 1: ldf = obj.columns.to_frame(index=False).apply(self.patterns, axis=1) ldf = ( pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) - .fillna(1)[obj.ddims].values) + .fillna(1)[obj.ddims] + .values + ) ldf = xp.array(ldf[None, :, None, :]) + print("ldf\n", ldf) + else: - raise ValueError('callable axis needs to be 0 or 1') + raise ValueError("callable axis needs to be 0 or 1") + else: ldf = xp.array([float(self.patterns[item]) for item in obj.ddims]) ldf = ldf[None, None, None, :] + if self.style == "cdf": ldf = xp.concatenate((ldf[..., :-1] / ldf[..., 1:], ldf[..., -1:]), -1) + obj = obj * ldf obj._set_slicers() + self.ldf_ = obj self.ldf_.is_pattern = True self.ldf_.is_cumulative = False self.ldf_.valuation_date = pd.to_datetime(options.ULT_VAL) + return self def transform(self, X): - """ If X and self are of different shapes, align self to X, else + """If X and self are of different shapes, align self to X, else return self. Parameters From 3d2410256ff05ec21620cf7ae4245601caf3ba91 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Tue, 26 May 2026 17:16:12 -0700 Subject: [PATCH 02/25] Added some debugger to seperate out pattern vs triagnel length --- chainladder/development/constant.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index d995e8b7..044041e6 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -65,9 +65,22 @@ def fit(self, X, y=None, sample_weight=None): obj = self._set_fit_groups(X).val_to_dev().copy() xp = obj.get_array_module() - print("obj.iloc[..., :1, :] * 0 + 1\n", obj.iloc[..., :1, :] * 0 + 1) - # obj = obj.iloc[..., :1, :-1] * 0 + 1 - obj = obj.iloc[..., :1, :] * 0 + 1 + + if callable(self.patterns): + if self.callable_axis == 0: + pattern = self.patterns(obj.index.iloc[0]) + elif self.callable_axis == 1: + pattern = self.patterns(obj.columns.to_frame(index=False).iloc[0]) + else: + raise ValueError("callable axis needs to be 0 or 1") + else: + pattern = self.patterns + + if len(pattern) > len(obj.ddims): + obj = obj.iloc[..., :1, :-1] * 0 + 1 + else: + obj = obj.iloc[..., :1, :] * 0 + 1 + print("obj\n", obj) if callable(self.patterns): @@ -95,6 +108,8 @@ def fit(self, X, y=None, sample_weight=None): raise ValueError("callable axis needs to be 0 or 1") else: + print("self.patterns\n", self.patterns) + print("obj.ddims\n", obj.ddims) ldf = xp.array([float(self.patterns[item]) for item in obj.ddims]) ldf = ldf[None, None, None, :] From f51a46485de34caaa855db3e750daf98b13ae8fd Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Tue, 26 May 2026 17:56:07 -0700 Subject: [PATCH 03/25] Refactored the axis --- chainladder/development/constant.py | 49 +++++++++++++++-------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 044041e6..9c741b20 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -58,54 +58,55 @@ def fit(self, X, y=None, sample_weight=None): print("X\n", X) print("X.is_cumulative\n", X.is_cumulative) + # convert to cumulative triangle if X.is_cumulative == False: obj = self._set_fit_groups(X).incr_to_cum().val_to_dev().copy() - else: obj = self._set_fit_groups(X).val_to_dev().copy() xp = obj.get_array_module() if callable(self.patterns): - if self.callable_axis == 0: + if self.callable_axis == 0: # varying patterns by index pattern = self.patterns(obj.index.iloc[0]) - elif self.callable_axis == 1: + elif self.callable_axis == 1: # varying patterns by column pattern = self.patterns(obj.columns.to_frame(index=False).iloc[0]) else: raise ValueError("callable axis needs to be 0 or 1") - else: + + else: # static patterns pattern = self.patterns + print("pattern\n", pattern) + print("len(pattern)\n", len(pattern)) + print("len(obj.ddims)\n", len(obj.ddims)) + + # obj = obj.iloc[..., :1, :-1] * 0 + 1 if len(pattern) > len(obj.ddims): + print("len(pattern) > len(obj.ddims)") obj = obj.iloc[..., :1, :-1] * 0 + 1 else: - obj = obj.iloc[..., :1, :] * 0 + 1 + print("len(pattern) <= len(obj.ddims)") + obj = obj.iloc[..., :1, :-1] * 0 + 1 print("obj\n", obj) if callable(self.patterns): - if self.callable_axis == 0: - ldf = obj.index.apply(self.patterns, axis=1) - ldf = ( - pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) - .fillna(1)[obj.ddims] - .values + ldf = ( + pd.concat( + axis.apply(self.patterns, axis=1) + .apply(pd.DataFrame, index=[0]) + .values, + axis=0, ) + .fillna(1)[obj.ddims] + .values + ) + if self.callable_axis == 0: ldf = xp.array(ldf[:, None, None, :]) - print("ldf\n", ldf) - - elif self.callable_axis == 1: - ldf = obj.columns.to_frame(index=False).apply(self.patterns, axis=1) - ldf = ( - pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) - .fillna(1)[obj.ddims] - .values - ) - ldf = xp.array(ldf[None, :, None, :]) - print("ldf\n", ldf) - else: - raise ValueError("callable axis needs to be 0 or 1") + ldf = xp.array(ldf[None, :, None, :]) + print("ldf\n", ldf) else: print("self.patterns\n", self.patterns) From 7169d85813c799c94c54dcc31381d2faab0f2b1f Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Tue, 26 May 2026 18:46:42 -0700 Subject: [PATCH 04/25] Implemend the tail with TailConstant, but everything prior to that needs to be reduced by the tail --- chainladder/development/constant.py | 30 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 9c741b20..39840ce2 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -81,24 +81,32 @@ def fit(self, X, y=None, sample_weight=None): print("len(pattern)\n", len(pattern)) print("len(obj.ddims)\n", len(obj.ddims)) - # obj = obj.iloc[..., :1, :-1] * 0 + 1 - if len(pattern) > len(obj.ddims): - print("len(pattern) > len(obj.ddims)") - obj = obj.iloc[..., :1, :-1] * 0 + 1 + # the pattern provided is longer than the development triangle + if len(pattern) > len(obj.ddims) - 1: + from chainladder.tails import TailConstant + + sorted_keys = sorted(pattern.keys()) + excess_cdf = pattern[sorted_keys[len(obj.ddims) - 1]] + print("len(pattern) > len(obj.ddims) - 1") + print("excess_cdf\n", excess_cdf) + tail = TailConstant(tail=excess_cdf, projection_period=0).fit(obj) + dev_slice = slice(None, -1) if excess_cdf == 1 else slice(None) + obj = tail.ldf_.iloc[..., :1, dev_slice] * 0 + 1 else: - print("len(pattern) <= len(obj.ddims)") + print("len(pattern) < len(obj.ddims)") obj = obj.iloc[..., :1, :-1] * 0 + 1 print("obj\n", obj) if callable(self.patterns): + if self.callable_axis == 0: + ldf = obj.index.apply(self.patterns, axis=1) + elif self.callable_axis == 1: + ldf = obj.columns.to_frame(index=False).apply(self.patterns, axis=1) + else: + raise ValueError("callable axis needs to be 0 or 1") ldf = ( - pd.concat( - axis.apply(self.patterns, axis=1) - .apply(pd.DataFrame, index=[0]) - .values, - axis=0, - ) + pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) .fillna(1)[obj.ddims] .values ) From 5ecd5a5b268dd8c1e0d7405eadec2f66fbe4f618 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Tue, 26 May 2026 21:15:54 -0700 Subject: [PATCH 05/25] Working CDFs --- chainladder/development/constant.py | 34 ++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 39840ce2..b7611671 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -3,6 +3,7 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from chainladder.development.base import DevelopmentBase import pandas as pd +import numpy as np class DevelopmentConstant(DevelopmentBase): @@ -55,8 +56,6 @@ def fit(self, X, y=None, sample_weight=None): from chainladder import options print("In DevelopmentConstant fit") - print("X\n", X) - print("X.is_cumulative\n", X.is_cumulative) # convert to cumulative triangle if X.is_cumulative == False: @@ -78,20 +77,35 @@ def fit(self, X, y=None, sample_weight=None): pattern = self.patterns print("pattern\n", pattern) - print("len(pattern)\n", len(pattern)) - print("len(obj.ddims)\n", len(obj.ddims)) + # print("len(pattern)\n", len(pattern)) + # print("len(obj.ddims)\n", len(obj.ddims)) # the pattern provided is longer than the development triangle if len(pattern) > len(obj.ddims) - 1: + print("len(pattern) > len(obj.ddims) - 1") + from chainladder.tails import TailConstant sorted_keys = sorted(pattern.keys()) - excess_cdf = pattern[sorted_keys[len(obj.ddims) - 1]] - print("len(pattern) > len(obj.ddims) - 1") - print("excess_cdf\n", excess_cdf) - tail = TailConstant(tail=excess_cdf, projection_period=0).fit(obj) - dev_slice = slice(None, -1) if excess_cdf == 1 else slice(None) - obj = tail.ldf_.iloc[..., :1, dev_slice] * 0 + 1 + print("sorted_keys\n", sorted_keys) + tail_cdf = pattern[sorted_keys[len(obj.ddims) - 1]] + print("tail_cdf\n", tail_cdf) + + normalized_pattern_values = np.array(list(pattern.values())) / tail_cdf + + # Zip the original keys back with the new vectorized array + pattern = dict(zip(pattern.keys(), normalized_pattern_values)) + + print("pattern\n", pattern) + + tail = TailConstant(tail=tail_cdf, projection_period=0).fit(obj) + print("tail.cdf_\n", tail.cdf_) + + if tail_cdf == 1: + obj = tail.ldf_.iloc[..., :1, :-1] * 0 + 1 + else: + obj = tail.ldf_.iloc[..., :1, :] * 0 + 1 + else: print("len(pattern) < len(obj.ddims)") obj = obj.iloc[..., :1, :-1] * 0 + 1 From 70f1dab1a7c036d770baae637d48baeb0c884451 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Tue, 26 May 2026 21:19:36 -0700 Subject: [PATCH 06/25] Cleaned up --- chainladder/development/constant.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index b7611671..c61af58e 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -55,7 +55,7 @@ def fit(self, X, y=None, sample_weight=None): """ from chainladder import options - print("In DevelopmentConstant fit") + # print("In DevelopmentConstant fit") # convert to cumulative triangle if X.is_cumulative == False: @@ -76,30 +76,30 @@ def fit(self, X, y=None, sample_weight=None): else: # static patterns pattern = self.patterns - print("pattern\n", pattern) + # print("pattern\n", pattern) # print("len(pattern)\n", len(pattern)) # print("len(obj.ddims)\n", len(obj.ddims)) # the pattern provided is longer than the development triangle if len(pattern) > len(obj.ddims) - 1: - print("len(pattern) > len(obj.ddims) - 1") + # print("len(pattern) > len(obj.ddims) - 1") from chainladder.tails import TailConstant sorted_keys = sorted(pattern.keys()) - print("sorted_keys\n", sorted_keys) + # print("sorted_keys\n", sorted_keys) tail_cdf = pattern[sorted_keys[len(obj.ddims) - 1]] - print("tail_cdf\n", tail_cdf) + # print("tail_cdf\n", tail_cdf) normalized_pattern_values = np.array(list(pattern.values())) / tail_cdf # Zip the original keys back with the new vectorized array pattern = dict(zip(pattern.keys(), normalized_pattern_values)) - print("pattern\n", pattern) + # print("pattern\n", pattern) tail = TailConstant(tail=tail_cdf, projection_period=0).fit(obj) - print("tail.cdf_\n", tail.cdf_) + # print("tail.cdf_\n", tail.cdf_) if tail_cdf == 1: obj = tail.ldf_.iloc[..., :1, :-1] * 0 + 1 @@ -107,10 +107,10 @@ def fit(self, X, y=None, sample_weight=None): obj = tail.ldf_.iloc[..., :1, :] * 0 + 1 else: - print("len(pattern) < len(obj.ddims)") + # print("len(pattern) < len(obj.ddims)") obj = obj.iloc[..., :1, :-1] * 0 + 1 - print("obj\n", obj) + # print("obj\n", obj) if callable(self.patterns): if self.callable_axis == 0: @@ -131,8 +131,6 @@ def fit(self, X, y=None, sample_weight=None): print("ldf\n", ldf) else: - print("self.patterns\n", self.patterns) - print("obj.ddims\n", obj.ddims) ldf = xp.array([float(self.patterns[item]) for item in obj.ddims]) ldf = ldf[None, None, None, :] From 2f4e5e3ce003b1e6162462afbf168249f1c73463 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Tue, 26 May 2026 21:29:56 -0700 Subject: [PATCH 07/25] Added cdf tests --- chainladder/development/constant.py | 1 - .../development/tests/test_constant.py | 196 ++++++++++++++++-- 2 files changed, 173 insertions(+), 24 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index c61af58e..45d26882 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -128,7 +128,6 @@ def fit(self, X, y=None, sample_weight=None): ldf = xp.array(ldf[:, None, None, :]) else: ldf = xp.array(ldf[None, :, None, :]) - print("ldf\n", ldf) else: ldf = xp.array([float(self.patterns[item]) for item in obj.ddims]) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 1c84fc41..658d6d77 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -23,36 +23,186 @@ def test_constant_ldf(raa): dev_c = cl.DevelopmentConstant(patterns=link_ratios, style="ldf").fit(raa) assert xp.allclose(dev.ldf_.values, dev_c.ldf_.values, atol=1e-5) + def test_constant_callable_axis0(clrd, atol): - agway = clrd.loc['Agway Ins Co', 'CumPaidLoss'] + agway = clrd.loc["Agway Ins Co", "CumPaidLoss"] + def paid_cdfs(x): - """ A function that returns different CDFs depending on a specified LOB """ + """A function that returns different CDFs depending on a specified LOB""" cdfs = { - 'comauto': [3.832, 1.874, 1.386, 1.181, 1.085, 1.043, 1.022, 1.013, 1.007, 1], - 'medmal': [24.168, 4.127, 2.103, 1.528, 1.275, 1.161, 1.088, 1.047, 1.018, 1], - 'othliab': [10.887, 3.416, 1.957, 1.433, 1.231, 1.119, 1.06, 1.031, 1.011, 1], - 'ppauto': [2.559, 1.417, 1.181, 1.084, 1.04, 1.019, 1.009, 1.004, 1.001, 1], - 'prodliab': [13.703, 5.613, 2.92, 1.765, 1.385, 1.177, 1.072, 1.034, 1.008, 1], - 'wkcomp': [4.106, 1.865, 1.418, 1.234, 1.141, 1.09, 1.056, 1.03, 1.01, 1]} + "comauto": [ + 3.832, + 1.874, + 1.386, + 1.181, + 1.085, + 1.043, + 1.022, + 1.013, + 1.007, + 1, + ], + "medmal": [ + 24.168, + 4.127, + 2.103, + 1.528, + 1.275, + 1.161, + 1.088, + 1.047, + 1.018, + 1, + ], + "othliab": [ + 10.887, + 3.416, + 1.957, + 1.433, + 1.231, + 1.119, + 1.06, + 1.031, + 1.011, + 1, + ], + "ppauto": [2.559, 1.417, 1.181, 1.084, 1.04, 1.019, 1.009, 1.004, 1.001, 1], + "prodliab": [ + 13.703, + 5.613, + 2.92, + 1.765, + 1.385, + 1.177, + 1.072, + 1.034, + 1.008, + 1, + ], + "wkcomp": [4.106, 1.865, 1.418, 1.234, 1.141, 1.09, 1.056, 1.03, 1.01, 1], + } patterns = pd.DataFrame(cdfs, index=range(12, 132, 12)).T - return patterns.loc[x.loc['LOB']].to_dict() - model = cl.DevelopmentConstant(patterns=paid_cdfs, callable_axis=0, style='cdf') - assert abs(model.fit_transform(agway).cdf_.loc['comauto'].iloc[..., 0].sum() - 3.832) < atol + return patterns.loc[x.loc["LOB"]].to_dict() + + model = cl.DevelopmentConstant(patterns=paid_cdfs, callable_axis=0, style="cdf") + assert ( + abs(model.fit_transform(agway).cdf_.loc["comauto"].iloc[..., 0].sum() - 3.832) + < atol + ) + def test_constant_callable_axis1(clrd, atol): - agway = clrd.loc['Agway Ins Co', 'comauto'] + agway = clrd.loc["Agway Ins Co", "comauto"] cdfs = { - 'IncurLoss': [3.832, 1.874, 1.386, 1.181, 1.085, 1.043, 1.022, 1.013, 1.007, 1], - 'CumPaidLoss': [24.168, 4.127, 2.103, 1.528, 1.275, 1.161, 1.088, 1.047, 1.018, 1], - 'BulkLoss': [10.887, 3.416, 1.957, 1.433, 1.231, 1.119, 1.06, 1.031, 1.011, 1], - 'EarnedPremDIR': [2.559, 1.417, 1.181, 1.084, 1.04, 1.019, 1.009, 1.004, 1.001, 1], - 'EarnedPremCeded': [13.703, 5.613, 2.92, 1.765, 1.385, 1.177, 1.072, 1.034, 1.008, 1], - 'EarnedPremNet': [4.106, 1.865, 1.418, 1.234, 1.141, 1.09, 1.056, 1.03, 1.01, 1]} + "IncurLoss": [3.832, 1.874, 1.386, 1.181, 1.085, 1.043, 1.022, 1.013, 1.007, 1], + "CumPaidLoss": [ + 24.168, + 4.127, + 2.103, + 1.528, + 1.275, + 1.161, + 1.088, + 1.047, + 1.018, + 1, + ], + "BulkLoss": [10.887, 3.416, 1.957, 1.433, 1.231, 1.119, 1.06, 1.031, 1.011, 1], + "EarnedPremDIR": [ + 2.559, + 1.417, + 1.181, + 1.084, + 1.04, + 1.019, + 1.009, + 1.004, + 1.001, + 1, + ], + "EarnedPremCeded": [ + 13.703, + 5.613, + 2.92, + 1.765, + 1.385, + 1.177, + 1.072, + 1.034, + 1.008, + 1, + ], + "EarnedPremNet": [ + 4.106, + 1.865, + 1.418, + 1.234, + 1.141, + 1.09, + 1.056, + 1.03, + 1.01, + 1, + ], + } patterns = pd.DataFrame(cdfs, index=range(12, 132, 12)).T + def paid_cdfs(x): - """ A function that returns different CDFs depending on a specified column """ - return patterns.loc[x.loc['columns']].to_dict() + """A function that returns different CDFs depending on a specified column""" + return patterns.loc[x.loc["columns"]].to_dict() + with pytest.raises(ValueError): - xerror = cl.DevelopmentConstant(patterns=paid_cdfs, callable_axis=2, style='cdf').fit(agway) - lhs = cl.DevelopmentConstant(patterns=paid_cdfs, callable_axis=1, style='cdf').fit(agway).cdf_ - assert np.all(abs(lhs.values[0,:,0,:]-patterns.values[:,:-1]) < atol) \ No newline at end of file + xerror = cl.DevelopmentConstant( + patterns=paid_cdfs, callable_axis=2, style="cdf" + ).fit(agway) + lhs = ( + cl.DevelopmentConstant(patterns=paid_cdfs, callable_axis=1, style="cdf") + .fit(agway) + .cdf_ + ) + assert np.all(abs(lhs.values[0, :, 0, :] - patterns.values[:, :-1]) < atol) + + +def test_constant_pattern_no_tail(): + reported_patterns = { + 12: 4.0, + 24: 2.9, + 36: 1.8, + 48: 1.4, + 60: 1.2, + 72: 1.1, + 84: 1.03, + 96: 1.02, # 108: 1.005, + } + auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_BI_claim = cl.DevelopmentConstant( + patterns=reported_patterns, style="cdf" + ).fit_transform(auto_bi["Reported Claims"]) + + assert np.all( + np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) + == np.array([4.0, 2.9, 1.8, 1.4, 1.2, 1.1, 1.03, 1.02]) + ) + + +def test_constant_pattern_has_tail(): + reported_patterns = { + 12: 4.0, + 24: 2.9, + 36: 1.8, + 48: 1.4, + 60: 1.2, + 72: 1.1, + 84: 1.03, + 96: 1.02, + 108: 1.005, + } + auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_BI_claim = cl.DevelopmentConstant( + patterns=reported_patterns, style="cdf" + ).fit_transform(auto_bi["Reported Claims"]) + + assert np.all( + np.round(reported_BI_claim.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]) + ) From 904c72f9dc0097bacd7721dc0b2dd152b5ac17de Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Tue, 26 May 2026 21:30:40 -0700 Subject: [PATCH 08/25] fixed test comment --- chainladder/development/tests/test_constant.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 658d6d77..2f36889f 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -172,7 +172,8 @@ def test_constant_pattern_no_tail(): 60: 1.2, 72: 1.1, 84: 1.03, - 96: 1.02, # 108: 1.005, + 96: 1.02, + # 108: 1.005, } auto_bi = cl.load_sample("friedland_auto_bi_insurer") reported_BI_claim = cl.DevelopmentConstant( From 3c24edfbf91d5b8b7d3eb8298fa3151dc583a181 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 09:41:19 -0700 Subject: [PATCH 09/25] Added functionality to always work in CDF form --- chainladder/development/constant.py | 7 +++++- .../development/tests/test_constant.py | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 45d26882..29709e1a 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -76,6 +76,12 @@ def fit(self, X, y=None, sample_weight=None): else: # static patterns pattern = self.patterns + # convert patterns to CDFs so it's easier to work with + sorted_keys = sorted(pattern.keys()) + pattern_values = np.array([float(pattern[k]) for k in sorted_keys]) + if self.style == "ldf": + pattern = dict(zip(sorted_keys, np.cumprod(pattern_values[::-1])[::-1])) + # print("pattern\n", pattern) # print("len(pattern)\n", len(pattern)) # print("len(obj.ddims)\n", len(obj.ddims)) @@ -86,7 +92,6 @@ def fit(self, X, y=None, sample_weight=None): from chainladder.tails import TailConstant - sorted_keys = sorted(pattern.keys()) # print("sorted_keys\n", sorted_keys) tail_cdf = pattern[sorted_keys[len(obj.ddims) - 1]] # print("tail_cdf\n", tail_cdf) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 2f36889f..3c158b32 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -207,3 +207,27 @@ def test_constant_pattern_has_tail(): np.round(reported_BI_claim.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_pattern_has_longtail(): + reported_patterns = { + 12: 4.0, + 24: 2.9, + 36: 1.8, + 48: 1.4, + 60: 1.2, + 72: 1.1, + 84: 1.03, + 96: 1.02, + 108: 1.005, + 120: 1.003, + } + auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_BI_claim = cl.DevelopmentConstant( + patterns=reported_patterns, style="cdf" + ).fit_transform(auto_bi["Reported Claims"]) + + assert np.all( + np.round(reported_BI_claim.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]) + ) From 52020e4f08b4a74f3cc4b5a8b173d28a108003d6 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 12:43:13 -0700 Subject: [PATCH 10/25] Pass all existing, but tail still not applied correctly --- chainladder/development/constant.py | 88 ++++++++++++++++++----------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 29709e1a..88659d8e 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -4,6 +4,7 @@ from chainladder.development.base import DevelopmentBase import pandas as pd import numpy as np +import warnings class DevelopmentConstant(DevelopmentBase): @@ -63,59 +64,74 @@ def fit(self, X, y=None, sample_weight=None): else: obj = self._set_fit_groups(X).val_to_dev().copy() + n_dev_periods = len(obj.ddims) xp = obj.get_array_module() if callable(self.patterns): if self.callable_axis == 0: # varying patterns by index - pattern = self.patterns(obj.index.iloc[0]) + patterns = self.patterns(obj.index.iloc[0]) elif self.callable_axis == 1: # varying patterns by column - pattern = self.patterns(obj.columns.to_frame(index=False).iloc[0]) + patterns = self.patterns(obj.columns.to_frame(index=False).iloc[0]) else: raise ValueError("callable axis needs to be 0 or 1") else: # static patterns - pattern = self.patterns + patterns = self.patterns # convert patterns to CDFs so it's easier to work with - sorted_keys = sorted(pattern.keys()) - pattern_values = np.array([float(pattern[k]) for k in sorted_keys]) + sorted_keys = sorted(patterns.keys()) + pattern_values = np.array([float(patterns[k]) for k in sorted_keys]) if self.style == "ldf": - pattern = dict(zip(sorted_keys, np.cumprod(pattern_values[::-1])[::-1])) - - # print("pattern\n", pattern) - # print("len(pattern)\n", len(pattern)) - # print("len(obj.ddims)\n", len(obj.ddims)) - - # the pattern provided is longer than the development triangle - if len(pattern) > len(obj.ddims) - 1: - # print("len(pattern) > len(obj.ddims) - 1") - - from chainladder.tails import TailConstant - - # print("sorted_keys\n", sorted_keys) - tail_cdf = pattern[sorted_keys[len(obj.ddims) - 1]] - # print("tail_cdf\n", tail_cdf) - - normalized_pattern_values = np.array(list(pattern.values())) / tail_cdf + cdf_patterns = dict( + zip(sorted_keys, np.cumprod(pattern_values[::-1])[::-1]) + ) + else: + cdf_patterns = patterns - # Zip the original keys back with the new vectorized array - pattern = dict(zip(pattern.keys(), normalized_pattern_values)) + print("CDF patterns\n", cdf_patterns) - # print("pattern\n", pattern) + # patterns provided is longer than the triangle development periods, + # this step resizes and gets tail_cdf to apply to the tail of the triangle later + if len(cdf_patterns) > len(obj.ddims) - 1: + # print("patterns is longer than the triangle development periods") - tail = TailConstant(tail=tail_cdf, projection_period=0).fit(obj) - # print("tail.cdf_\n", tail.cdf_) + tail_key = sorted_keys[len(obj.ddims) - 1] + tail_cdf = cdf_patterns[tail_key] if tail_cdf == 1: - obj = tail.ldf_.iloc[..., :1, :-1] * 0 + 1 + obj = obj.iloc[..., :1, :-1] * 0 + 1 else: - obj = tail.ldf_.iloc[..., :1, :] * 0 + 1 - + obj = obj.iloc[..., :1, :] * 0 + 1 else: - # print("len(pattern) < len(obj.ddims)") obj = obj.iloc[..., :1, :-1] * 0 + 1 + tail_key = None + tail_cdf = 1 + + # warn if the patterns are shorter than the triangle development periods + if len(cdf_patterns) < n_dev_periods: + warnings.warn( + "Supplied patterns are shorter than the triangle development " + "periods. Missing ages will be filled with a factor of 1.0.", + UserWarning, + stacklevel=2, + ) + + # fill the cdf_patterns dictionary with 1.0 for any development periods + for ddim in obj.ddims: + if not any(ddim == k or int(ddim) == int(k) for k in cdf_patterns): + cdf_patterns[int(ddim)] = 1.0 + if self.style == "ldf" and not any( + ddim == k or int(ddim) == int(k) for k in patterns + ): + patterns[int(ddim)] = 1.0 + + print("obj to fill\n", obj) + print("tail_cdf", tail_key, tail_cdf) - # print("obj\n", obj) + # from chainladder.tails import TailConstant + + # tail = TailConstant(tail=tail_cdf, projection_period=0).fit(obj) + # print("tail.cdf_\n", tail.cdf_) if callable(self.patterns): if self.callable_axis == 0: @@ -124,24 +140,30 @@ def fit(self, X, y=None, sample_weight=None): ldf = obj.columns.to_frame(index=False).apply(self.patterns, axis=1) else: raise ValueError("callable axis needs to be 0 or 1") + ldf = ( pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) .fillna(1)[obj.ddims] .values ) + if self.callable_axis == 0: ldf = xp.array(ldf[:, None, None, :]) else: ldf = xp.array(ldf[None, :, None, :]) else: - ldf = xp.array([float(self.patterns[item]) for item in obj.ddims]) + fit_patterns = patterns if self.style == "ldf" else cdf_patterns + ldf = xp.array([float(fit_patterns[int(item)]) for item in obj.ddims]) ldf = ldf[None, None, None, :] if self.style == "cdf": ldf = xp.concatenate((ldf[..., :-1] / ldf[..., 1:], ldf[..., -1:]), -1) + print("final ldf\n", ldf) obj = obj * ldf + # tail = TailConstant(tail=tail_cdf, projection_period=0).fit(obj) + # print("tail.cdf_\n", tail.cdf_) obj._set_slicers() self.ldf_ = obj From cffdd7bfd7adcfad4bfd9dcf744f4ad277e46394 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 14:32:09 -0700 Subject: [PATCH 11/25] all tests passing, except when tail is longer by 1 --- chainladder/development/constant.py | 69 ++++++++++---- .../development/tests/test_constant.py | 89 ++++++++++++++----- 2 files changed, 117 insertions(+), 41 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 88659d8e..4f49eb4c 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -64,7 +64,6 @@ def fit(self, X, y=None, sample_weight=None): else: obj = self._set_fit_groups(X).val_to_dev().copy() - n_dev_periods = len(obj.ddims) xp = obj.get_array_module() if callable(self.patterns): @@ -90,31 +89,47 @@ def fit(self, X, y=None, sample_weight=None): print("CDF patterns\n", cdf_patterns) + n_dev_periods = len(obj.ddims) + print("triangle_periods\n", n_dev_periods) + print("pattern periods\n", len(cdf_patterns)) + # patterns provided is longer than the triangle development periods, # this step resizes and gets tail_cdf to apply to the tail of the triangle later - if len(cdf_patterns) > len(obj.ddims) - 1: - # print("patterns is longer than the triangle development periods") + if len(cdf_patterns) > n_dev_periods: + print( + "patterns is longer than the triangle development periods, tail needed" + ) - tail_key = sorted_keys[len(obj.ddims) - 1] + trimmed_keys = sorted_keys[:n_dev_periods] + print("trimmed_keys\n", trimmed_keys) + tail_key = trimmed_keys[-1] + print("tail_key\n", tail_key) tail_cdf = cdf_patterns[tail_key] + print("tail_cdf\n", tail_cdf) + for k in trimmed_keys: + cdf_patterns[int(k)] = float(cdf_patterns[k]) / tail_cdf if tail_cdf == 1: obj = obj.iloc[..., :1, :-1] * 0 + 1 else: obj = obj.iloc[..., :1, :] * 0 + 1 - else: - obj = obj.iloc[..., :1, :-1] * 0 + 1 - tail_key = None - tail_cdf = 1 # warn if the patterns are shorter than the triangle development periods - if len(cdf_patterns) < n_dev_periods: + elif len(cdf_patterns) < n_dev_periods: warnings.warn( "Supplied patterns are shorter than the triangle development " "periods. Missing ages will be filled with a factor of 1.0.", UserWarning, stacklevel=2, ) + obj = obj.iloc[..., :1, :-1] * 0 + 1 + tail_key = None + tail_cdf = 1 + # pattern is exact, no tail needed + else: + obj = obj.iloc[..., :1, :-1] * 0 + 1 + tail_key = None + tail_cdf = 1 # fill the cdf_patterns dictionary with 1.0 for any development periods for ddim in obj.ddims: @@ -125,13 +140,7 @@ def fit(self, X, y=None, sample_weight=None): ): patterns[int(ddim)] = 1.0 - print("obj to fill\n", obj) - print("tail_cdf", tail_key, tail_cdf) - - # from chainladder.tails import TailConstant - - # tail = TailConstant(tail=tail_cdf, projection_period=0).fit(obj) - # print("tail.cdf_\n", tail.cdf_) + print("TAIL BEGINS", tail_key, tail_cdf) if callable(self.patterns): if self.callable_axis == 0: @@ -157,16 +166,38 @@ def fit(self, X, y=None, sample_weight=None): ldf = xp.array([float(fit_patterns[int(item)]) for item in obj.ddims]) ldf = ldf[None, None, None, :] + print("ldf before cdf\n", ldf) + if self.style == "cdf": ldf = xp.concatenate((ldf[..., :-1] / ldf[..., 1:], ldf[..., -1:]), -1) - print("final ldf\n", ldf) - obj = obj * ldf - # tail = TailConstant(tail=tail_cdf, projection_period=0).fit(obj) + # apply tail_cdf to the last ldfs of the triangle + # print("before ldf\n", ldf) + # if len(cdf_patterns) > len(obj.ddims) - 1: + # ldf[..., -1] = ldf[..., -1] * tail_cdf + print("ldf after cdf\n", ldf) + + # from chainladder.tails import TailConstant + + # tail = TailConstant(tail=tail_cdf, projection_period=0).fit_transform(obj) + # print("tail.ldf_\n", tail.ldf_) # print("tail.cdf_\n", tail.cdf_) + + # if self.style == "ldf": + # ldf[..., -1] = ldf[..., -1] * tail_cdf + # else: + ldf[..., -1] = ldf[..., -1] * tail_cdf + + # print("ldf after tail\n", ldf) + + obj = obj * ldf obj._set_slicers() + print("obj filled \n", obj) + self.ldf_ = obj + # self.ldf_ = tail.ldf_ + print("FINAL self.ldf_\n", self.ldf_) self.ldf_.is_pattern = True self.ldf_.is_cumulative = False self.ldf_.valuation_date = pd.to_datetime(options.ULT_VAL) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 3c158b32..38c604c9 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -186,48 +186,93 @@ def test_constant_pattern_no_tail(): ) -def test_constant_pattern_has_tail(): +def test_constant_pattern_exact_cdf(): + auto_bi = cl.load_sample("friedland_auto_bi_insurer") reported_patterns = { - 12: 4.0, - 24: 2.9, - 36: 1.8, - 48: 1.4, - 60: 1.2, + 12: 1.1, + 24: 1.1, + 36: 1.1, + 48: 1.1, + 60: 1.1, 72: 1.1, - 84: 1.03, - 96: 1.02, - 108: 1.005, + 84: 1.1, + 96: 1.1, } - auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_BI_claim = cl.DevelopmentConstant( patterns=reported_patterns, style="cdf" ).fit_transform(auto_bi["Reported Claims"]) assert np.all( np.round(reported_BI_claim.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]) + == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) ) -def test_constant_pattern_has_longtail(): +def test_constant_pattern_exact_ldf(): + auto_bi = cl.load_sample("friedland_auto_bi_insurer") reported_patterns = { - 12: 4.0, - 24: 2.9, - 36: 1.8, - 48: 1.4, - 60: 1.2, + 12: 1.1, + 24: 1.1, + 36: 1.1, + 48: 1.1, + 60: 1.1, 72: 1.1, - 84: 1.03, - 96: 1.02, - 108: 1.005, - 120: 1.003, + 84: 1.1, + 96: 1.1, } + + reported_BI_claim = cl.DevelopmentConstant( + patterns=reported_patterns, style="ldf" + ).fit_transform(auto_bi["Reported Claims"]) + + assert np.all( + np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) + == np.array([2.143589, 1.948717, 1.771561, 1.61051, 1.4641, 1.331, 1.21, 1.1]) + ) + + +def test_constant_pattern_short_cdf(): auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_patterns = { + 12: 1.1, + 24: 1.1, + 36: 1.1, + 48: 1.1, + 60: 1.1, + 72: 1.1, + # 84: 1.1, + # 96: 1.1, + } + reported_BI_claim = cl.DevelopmentConstant( patterns=reported_patterns, style="cdf" ).fit_transform(auto_bi["Reported Claims"]) assert np.all( np.round(reported_BI_claim.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]) + == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.0, 1.0]) + ) + + +def test_constant_pattern_short_ldf(): + auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_patterns = { + 12: 1.1, + 24: 1.1, + 36: 1.1, + 48: 1.1, + 60: 1.1, + 72: 1.1, + # 84: 1.1, + # 96: 1.1, + } + + reported_BI_claim = cl.DevelopmentConstant( + patterns=reported_patterns, style="ldf" + ).fit_transform(auto_bi["Reported Claims"]) + + assert np.all( + np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) + == np.array([1.771561, 1.61051, 1.4641, 1.331, 1.21, 1.1, 1.0, 1.0]) ) From 2aea757611725c89cd37c1857cb5f81a152804f7 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 14:38:24 -0700 Subject: [PATCH 12/25] Passing long in CDF form --- .../development/tests/test_constant.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 38c604c9..7998ba73 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -276,3 +276,27 @@ def test_constant_pattern_short_ldf(): np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) == np.array([1.771561, 1.61051, 1.4641, 1.331, 1.21, 1.1, 1.0, 1.0]) ) + + +def test_constant_pattern_long_cdf(): + auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_patterns = { + 12: 1.1, + 24: 1.1, + 36: 1.1, + 48: 1.1, + 60: 1.1, + 72: 1.1, + 84: 1.1, + 96: 1.1, + 108: 1.1, + 120: 1.1, + } + + reported_BI_claim = cl.DevelopmentConstant( + patterns=reported_patterns, style="cdf" + ).fit_transform(auto_bi["Reported Claims"]) + assert np.all( + np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) + == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) + ) From a49528a7ea0c9a70acecfe1e6586462d781f2018 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 14:57:45 -0700 Subject: [PATCH 13/25] Passing all tests! --- chainladder/development/constant.py | 10 ++--- .../development/tests/test_constant.py | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 4f49eb4c..ed8dd1ed 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -102,10 +102,11 @@ def fit(self, X, y=None, sample_weight=None): trimmed_keys = sorted_keys[:n_dev_periods] print("trimmed_keys\n", trimmed_keys) - tail_key = trimmed_keys[-1] - print("tail_key\n", tail_key) + + tail_key = sorted_keys[n_dev_periods] tail_cdf = cdf_patterns[tail_key] - print("tail_cdf\n", tail_cdf) + print("TAIL BEGINS", tail_key, tail_cdf) + for k in trimmed_keys: cdf_patterns[int(k)] = float(cdf_patterns[k]) / tail_cdf @@ -125,6 +126,7 @@ def fit(self, X, y=None, sample_weight=None): obj = obj.iloc[..., :1, :-1] * 0 + 1 tail_key = None tail_cdf = 1 + # pattern is exact, no tail needed else: obj = obj.iloc[..., :1, :-1] * 0 + 1 @@ -188,8 +190,6 @@ def fit(self, X, y=None, sample_weight=None): # else: ldf[..., -1] = ldf[..., -1] * tail_cdf - # print("ldf after tail\n", ldf) - obj = obj * ldf obj._set_slicers() diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 7998ba73..6fc6bb15 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -300,3 +300,40 @@ def test_constant_pattern_long_cdf(): np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) ) + + +def test_constant_pattern_long_ldf(): + auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_patterns = { + 12: 1.1, + 24: 1.1, + 36: 1.1, + 48: 1.1, + 60: 1.1, + 72: 1.1, + 84: 1.1, + 96: 1.1, + 108: 1.1, + 120: 1.1, + } + + reported_BI_claim = cl.DevelopmentConstant( + patterns=reported_patterns, style="ldf" + ).fit_transform(auto_bi["Reported Claims"]) + + assert np.all( + np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) + == np.array( + [ + 2.593742, + 2.357948, + 2.143589, + 1.948717, + 1.771561, + 1.61051, + 1.4641, + 1.331, + 1.21, + ] + ) + ) From 3c4961d652808db02348ea1e1128877033407d7b Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 15:14:04 -0700 Subject: [PATCH 14/25] Passing all new tests, but failing one old one? --- chainladder/development/constant.py | 4 +- .../development/tests/test_constant.py | 88 +++++++++++-------- 2 files changed, 56 insertions(+), 36 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index ed8dd1ed..08e04e53 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -95,6 +95,8 @@ def fit(self, X, y=None, sample_weight=None): # patterns provided is longer than the triangle development periods, # this step resizes and gets tail_cdf to apply to the tail of the triangle later + # n_dev_periods - 1 has - 1 at the end because the last period is assumed at + # ultimate by defualt, unless there's a tail if len(cdf_patterns) > n_dev_periods: print( "patterns is longer than the triangle development periods, tail needed" @@ -129,7 +131,7 @@ def fit(self, X, y=None, sample_weight=None): # pattern is exact, no tail needed else: - obj = obj.iloc[..., :1, :-1] * 0 + 1 + obj = obj.iloc[..., :1, :] * 0 + 1 tail_key = None tail_cdf = 1 diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 6fc6bb15..037725cf 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -186,8 +186,7 @@ def test_constant_pattern_no_tail(): ) -def test_constant_pattern_exact_cdf(): - auto_bi = cl.load_sample("friedland_auto_bi_insurer") +def test_constant_pattern_exact_cdf(raa): reported_patterns = { 12: 1.1, 24: 1.1, @@ -197,20 +196,21 @@ def test_constant_pattern_exact_cdf(): 72: 1.1, 84: 1.1, 96: 1.1, + 108: 1.1, + 120: 1.1, } - reported_BI_claim = cl.DevelopmentConstant( + result = cl.DevelopmentConstant( patterns=reported_patterns, style="cdf" - ).fit_transform(auto_bi["Reported Claims"]) + ).fit_transform(raa) assert np.all( - np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) - == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) + np.round(result.cdf_.to_frame().values.flatten(), 6) + == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) ) -def test_constant_pattern_exact_ldf(): - auto_bi = cl.load_sample("friedland_auto_bi_insurer") +def test_constant_pattern_exact_ldf(raa): reported_patterns = { 12: 1.1, 24: 1.1, @@ -220,20 +220,34 @@ def test_constant_pattern_exact_ldf(): 72: 1.1, 84: 1.1, 96: 1.1, + 108: 1.1, + 120: 1.1, } - reported_BI_claim = cl.DevelopmentConstant( + result = cl.DevelopmentConstant( patterns=reported_patterns, style="ldf" - ).fit_transform(auto_bi["Reported Claims"]) + ).fit_transform(raa) assert np.all( - np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) - == np.array([2.143589, 1.948717, 1.771561, 1.61051, 1.4641, 1.331, 1.21, 1.1]) + np.round(result.cdf_.to_frame().values.flatten(), 6) + == np.array( + [ + 2.593742, + 2.357948, + 2.143589, + 1.948717, + 1.771561, + 1.61051, + 1.4641, + 1.331, + 1.21, + 1.1, + ] + ) ) -def test_constant_pattern_short_cdf(): - auto_bi = cl.load_sample("friedland_auto_bi_insurer") +def test_constant_pattern_short_cdf(raa): reported_patterns = { 12: 1.1, 24: 1.1, @@ -243,20 +257,21 @@ def test_constant_pattern_short_cdf(): 72: 1.1, # 84: 1.1, # 96: 1.1, + # 108: 1.1, + # 120: 1.1, } - reported_BI_claim = cl.DevelopmentConstant( + result = cl.DevelopmentConstant( patterns=reported_patterns, style="cdf" - ).fit_transform(auto_bi["Reported Claims"]) + ).fit_transform(raa) assert np.all( - np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) - == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.0, 1.0]) + np.round(result.cdf_.to_frame().values.flatten(), 6) + == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.0, 1.0, 1.0]) ) -def test_constant_pattern_short_ldf(): - auto_bi = cl.load_sample("friedland_auto_bi_insurer") +def test_constant_pattern_short_ldf(raa): reported_patterns = { 12: 1.1, 24: 1.1, @@ -266,20 +281,21 @@ def test_constant_pattern_short_ldf(): 72: 1.1, # 84: 1.1, # 96: 1.1, + # 108: 1.1, + # 120: 1.1, } - reported_BI_claim = cl.DevelopmentConstant( + result = cl.DevelopmentConstant( patterns=reported_patterns, style="ldf" - ).fit_transform(auto_bi["Reported Claims"]) + ).fit_transform(raa) assert np.all( - np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) - == np.array([1.771561, 1.61051, 1.4641, 1.331, 1.21, 1.1, 1.0, 1.0]) + np.round(result.cdf_.to_frame().values.flatten(), 6) + == np.array([1.771561, 1.61051, 1.4641, 1.331, 1.21, 1.1, 1.0, 1.0, 1.0]) ) -def test_constant_pattern_long_cdf(): - auto_bi = cl.load_sample("friedland_auto_bi_insurer") +def test_constant_pattern_long_cdf(raa): reported_patterns = { 12: 1.1, 24: 1.1, @@ -291,19 +307,19 @@ def test_constant_pattern_long_cdf(): 96: 1.1, 108: 1.1, 120: 1.1, + 132: 1.1, } - reported_BI_claim = cl.DevelopmentConstant( + result = cl.DevelopmentConstant( patterns=reported_patterns, style="cdf" - ).fit_transform(auto_bi["Reported Claims"]) + ).fit_transform(raa) assert np.all( - np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) - == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) + np.round(result.cdf_.to_frame().values.flatten(), 6) + == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) ) -def test_constant_pattern_long_ldf(): - auto_bi = cl.load_sample("friedland_auto_bi_insurer") +def test_constant_pattern_long_ldf(raa): reported_patterns = { 12: 1.1, 24: 1.1, @@ -315,16 +331,18 @@ def test_constant_pattern_long_ldf(): 96: 1.1, 108: 1.1, 120: 1.1, + 132: 1.1, } - reported_BI_claim = cl.DevelopmentConstant( + result = cl.DevelopmentConstant( patterns=reported_patterns, style="ldf" - ).fit_transform(auto_bi["Reported Claims"]) + ).fit_transform(raa) assert np.all( - np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) + np.round(result.cdf_.to_frame().values.flatten(), 6) == np.array( [ + 2.853117, 2.593742, 2.357948, 2.143589, From 0ec5dad30a1d99dd419b5343b3c1a7c2b632ed2c Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 15:19:21 -0700 Subject: [PATCH 15/25] Corrected old test --- chainladder/development/tests/test_constant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 037725cf..796b8d5e 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -160,7 +160,7 @@ def paid_cdfs(x): .fit(agway) .cdf_ ) - assert np.all(abs(lhs.values[0, :, 0, :] - patterns.values[:, :-1]) < atol) + assert np.all(abs(lhs.values[0, :, 0, :] - patterns.values) < atol) def test_constant_pattern_no_tail(): From fc15005f3efa1306fa9a1b21999768b90779e611 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 15:23:24 -0700 Subject: [PATCH 16/25] Added tailed example from the friedland text --- .../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 796b8d5e..61812f9c 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -186,6 +186,29 @@ def test_constant_pattern_no_tail(): ) +def test_constant_pattern_has_tail(): + reported_patterns = { + 12: 4.0, + 24: 2.9, + 36: 1.8, + 48: 1.4, + 60: 1.2, + 72: 1.1, + 84: 1.03, + 96: 1.02, + 108: 1.005, + } + auto_bi = cl.load_sample("friedland_auto_bi_insurer") + reported_BI_claim = cl.DevelopmentConstant( + patterns=reported_patterns, style="cdf" + ).fit_transform(auto_bi["Reported Claims"]) + + assert np.all( + np.round(reported_BI_claim.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_pattern_exact_cdf(raa): reported_patterns = { 12: 1.1, From 4176856b896f34f5e9824be6a150590d1968ad17 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 15:23:36 -0700 Subject: [PATCH 17/25] Cleaned up, removed debug statements --- chainladder/development/constant.py | 59 ++++------------------------- 1 file changed, 8 insertions(+), 51 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 08e04e53..452d3a66 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -59,7 +59,7 @@ def fit(self, X, y=None, sample_weight=None): # print("In DevelopmentConstant fit") # convert to cumulative triangle - if X.is_cumulative == False: + if not X.is_cumulative: obj = self._set_fit_groups(X).incr_to_cum().val_to_dev().copy() else: obj = self._set_fit_groups(X).val_to_dev().copy() @@ -73,7 +73,6 @@ def fit(self, X, y=None, sample_weight=None): patterns = self.patterns(obj.columns.to_frame(index=False).iloc[0]) else: raise ValueError("callable axis needs to be 0 or 1") - else: # static patterns patterns = self.patterns @@ -87,36 +86,21 @@ def fit(self, X, y=None, sample_weight=None): else: cdf_patterns = patterns - print("CDF patterns\n", cdf_patterns) - n_dev_periods = len(obj.ddims) - print("triangle_periods\n", n_dev_periods) - print("pattern periods\n", len(cdf_patterns)) # patterns provided is longer than the triangle development periods, # this step resizes and gets tail_cdf to apply to the tail of the triangle later # n_dev_periods - 1 has - 1 at the end because the last period is assumed at # ultimate by defualt, unless there's a tail if len(cdf_patterns) > n_dev_periods: - print( - "patterns is longer than the triangle development periods, tail needed" - ) - - trimmed_keys = sorted_keys[:n_dev_periods] - print("trimmed_keys\n", trimmed_keys) - - tail_key = sorted_keys[n_dev_periods] - tail_cdf = cdf_patterns[tail_key] - print("TAIL BEGINS", tail_key, tail_cdf) - - for k in trimmed_keys: + tail_cdf = cdf_patterns[sorted_keys[n_dev_periods]] + for k in sorted_keys[:n_dev_periods]: cdf_patterns[int(k)] = float(cdf_patterns[k]) / tail_cdf - - if tail_cdf == 1: - obj = obj.iloc[..., :1, :-1] * 0 + 1 - else: - obj = obj.iloc[..., :1, :] * 0 + 1 - + obj = ( + obj.iloc[..., :1, :-1] * 0 + 1 + if tail_cdf == 1 + else obj.iloc[..., :1, :] * 0 + 1 + ) # warn if the patterns are shorter than the triangle development periods elif len(cdf_patterns) < n_dev_periods: warnings.warn( @@ -126,13 +110,10 @@ def fit(self, X, y=None, sample_weight=None): stacklevel=2, ) obj = obj.iloc[..., :1, :-1] * 0 + 1 - tail_key = None tail_cdf = 1 - # pattern is exact, no tail needed else: obj = obj.iloc[..., :1, :] * 0 + 1 - tail_key = None tail_cdf = 1 # fill the cdf_patterns dictionary with 1.0 for any development periods @@ -144,8 +125,6 @@ def fit(self, X, y=None, sample_weight=None): ): patterns[int(ddim)] = 1.0 - print("TAIL BEGINS", tail_key, tail_cdf) - if callable(self.patterns): if self.callable_axis == 0: ldf = obj.index.apply(self.patterns, axis=1) @@ -159,47 +138,25 @@ def fit(self, X, y=None, sample_weight=None): .fillna(1)[obj.ddims] .values ) - if self.callable_axis == 0: ldf = xp.array(ldf[:, None, None, :]) else: ldf = xp.array(ldf[None, :, None, :]) - else: fit_patterns = patterns if self.style == "ldf" else cdf_patterns ldf = xp.array([float(fit_patterns[int(item)]) for item in obj.ddims]) ldf = ldf[None, None, None, :] - print("ldf before cdf\n", ldf) - if self.style == "cdf": ldf = xp.concatenate((ldf[..., :-1] / ldf[..., 1:], ldf[..., -1:]), -1) # apply tail_cdf to the last ldfs of the triangle - # print("before ldf\n", ldf) - # if len(cdf_patterns) > len(obj.ddims) - 1: - # ldf[..., -1] = ldf[..., -1] * tail_cdf - print("ldf after cdf\n", ldf) - - # from chainladder.tails import TailConstant - - # tail = TailConstant(tail=tail_cdf, projection_period=0).fit_transform(obj) - # print("tail.ldf_\n", tail.ldf_) - # print("tail.cdf_\n", tail.cdf_) - - # if self.style == "ldf": - # ldf[..., -1] = ldf[..., -1] * tail_cdf - # else: ldf[..., -1] = ldf[..., -1] * tail_cdf obj = obj * ldf obj._set_slicers() - print("obj filled \n", obj) - self.ldf_ = obj - # self.ldf_ = tail.ldf_ - print("FINAL self.ldf_\n", self.ldf_) self.ldf_.is_pattern = True self.ldf_.is_cumulative = False self.ldf_.valuation_date = pd.to_datetime(options.ULT_VAL) From 814fd16853c3f77bb8b1478c1a809039f710677a Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 15:40:11 -0700 Subject: [PATCH 18/25] Added incr test --- .../development/tests/test_constant.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index 61812f9c..d12c4584 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -378,3 +378,27 @@ def test_constant_pattern_long_ldf(raa): ] ) ) + + +def test_constant_incr(): + raa_incr = cl.load_sample("raa").cum_to_incr() + reported_patterns = { + 12: 4.0, + 24: 2.9, + 36: 1.8, + 48: 1.4, + 60: 1.2, + 72: 1.1, + 84: 1.03, + 96: 1.02, + 108: 1.005, + } + + result = cl.DevelopmentConstant( + patterns=reported_patterns, style="cdf" + ).fit_transform(raa_incr) + + assert np.all( + 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]) + ) From e70c59d4291c888aa6b0d3e7c26aad139f0dd7b5 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 15:40:37 -0700 Subject: [PATCH 19/25] removed print debugger --- chainladder/development/constant.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 452d3a66..5d44d3b0 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -56,8 +56,6 @@ def fit(self, X, y=None, sample_weight=None): """ from chainladder import options - # print("In DevelopmentConstant fit") - # convert to cumulative triangle if not X.is_cumulative: obj = self._set_fit_groups(X).incr_to_cum().val_to_dev().copy() From 69252a00a23ded733c70a5fdb162a2df3cf453f6 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 15:45:07 -0700 Subject: [PATCH 20/25] bugbot fix --- chainladder/development/constant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 5d44d3b0..ae7f9495 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -72,7 +72,7 @@ def fit(self, X, y=None, sample_weight=None): else: raise ValueError("callable axis needs to be 0 or 1") else: # static patterns - patterns = self.patterns + patterns = dict(self.patterns) # convert patterns to CDFs so it's easier to work with sorted_keys = sorted(patterns.keys()) From 6651cd21a312632a4aa97cd9e93147bff4cecfd1 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 15:58:13 -0700 Subject: [PATCH 21/25] Bugbot fix --- chainladder/development/constant.py | 70 +++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index ae7f9495..80e7a1a7 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -41,6 +41,32 @@ def __init__(self, patterns=None, style="ldf", callable_axis=0, groupby=None): self.callable_axis = callable_axis self.groupby = groupby + def _prepare_cdf_patterns(self, patterns, n_dev_periods): + + patterns = dict(patterns) + + sorted_keys = sorted(patterns.keys()) + pattern_values = np.array([float(patterns[k]) for k in sorted_keys]) + + # build the cdf patterns, up to the needed periods + if self.style == "ldf": + cdf_patterns = dict( + zip(sorted_keys, np.cumprod(pattern_values[::-1])[::-1]) + ) + else: + cdf_patterns = {int(k): float(patterns[k]) for k in sorted_keys} + + if len(cdf_patterns) > n_dev_periods: + tail_cdf = float(cdf_patterns[sorted_keys[n_dev_periods]]) + + for k in sorted_keys[:n_dev_periods]: + cdf_patterns[int(k)] = cdf_patterns[int(k)] / tail_cdf + + return cdf_patterns, tail_cdf + + else: + return patterns, 1.0 + def fit(self, X, y=None, sample_weight=None): """Fit the model with X. Parameters @@ -74,31 +100,21 @@ def fit(self, X, y=None, sample_weight=None): else: # static patterns patterns = dict(self.patterns) - # convert patterns to CDFs so it's easier to work with - sorted_keys = sorted(patterns.keys()) - pattern_values = np.array([float(patterns[k]) for k in sorted_keys]) - if self.style == "ldf": - cdf_patterns = dict( - zip(sorted_keys, np.cumprod(pattern_values[::-1])[::-1]) - ) - else: - cdf_patterns = patterns - n_dev_periods = len(obj.ddims) + cdf_patterns, tail_cdf = self._prepare_cdf_patterns(patterns, n_dev_periods) + sorted_keys = sorted(cdf_patterns.keys()) # patterns provided is longer than the triangle development periods, # this step resizes and gets tail_cdf to apply to the tail of the triangle later # n_dev_periods - 1 has - 1 at the end because the last period is assumed at # ultimate by defualt, unless there's a tail if len(cdf_patterns) > n_dev_periods: - tail_cdf = cdf_patterns[sorted_keys[n_dev_periods]] - for k in sorted_keys[:n_dev_periods]: - cdf_patterns[int(k)] = float(cdf_patterns[k]) / tail_cdf obj = ( obj.iloc[..., :1, :-1] * 0 + 1 if tail_cdf == 1 else obj.iloc[..., :1, :] * 0 + 1 ) + # warn if the patterns are shorter than the triangle development periods elif len(cdf_patterns) < n_dev_periods: warnings.warn( @@ -109,12 +125,13 @@ def fit(self, X, y=None, sample_weight=None): ) obj = obj.iloc[..., :1, :-1] * 0 + 1 tail_cdf = 1 + # pattern is exact, no tail needed else: obj = obj.iloc[..., :1, :] * 0 + 1 tail_cdf = 1 - # fill the cdf_patterns dictionary with 1.0 for any development periods + # fill the cdf_patterns dictionary with 1.0 for any missing development periods for ddim in obj.ddims: if not any(ddim == k or int(ddim) == int(k) for k in cdf_patterns): cdf_patterns[int(ddim)] = 1.0 @@ -125,31 +142,48 @@ def fit(self, X, y=None, sample_weight=None): if callable(self.patterns): if self.callable_axis == 0: - ldf = obj.index.apply(self.patterns, axis=1) + rows = obj.index elif self.callable_axis == 1: - ldf = obj.columns.to_frame(index=False).apply(self.patterns, axis=1) + rows = obj.columns.to_frame(index=False) else: raise ValueError("callable axis needs to be 0 or 1") + def _callable_row(row_pattern): + raw_patterns = self.patterns(row_pattern) + cdf_row, row_tail_cdf = self._prepare_cdf_patterns( + raw_patterns, n_dev_periods + ) + fit_row = raw_patterns if self.style == "ldf" else cdf_row + return dict(fit_row), row_tail_cdf + + prepared = rows.apply(_callable_row, axis=1) + fit_patterns = prepared.apply(lambda item: item[0]) + tail_cdfs = prepared.apply(lambda item: item[1]) + ldf = ( - pd.concat(ldf.apply(pd.DataFrame, index=[0]).values, axis=0) + pd.concat(fit_patterns.apply(pd.DataFrame, index=[0]).values, axis=0) .fillna(1)[obj.ddims] .values ) + if self.callable_axis == 0: ldf = xp.array(ldf[:, None, None, :]) + tail_cdfs = xp.array(tail_cdfs.values)[:, None, None] else: ldf = xp.array(ldf[None, :, None, :]) + tail_cdfs = xp.array(tail_cdfs.values)[None, :, None] + else: fit_patterns = patterns if self.style == "ldf" else cdf_patterns ldf = xp.array([float(fit_patterns[int(item)]) for item in obj.ddims]) ldf = ldf[None, None, None, :] + tail_cdfs = tail_cdf if self.style == "cdf": ldf = xp.concatenate((ldf[..., :-1] / ldf[..., 1:], ldf[..., -1:]), -1) # apply tail_cdf to the last ldfs of the triangle - ldf[..., -1] = ldf[..., -1] * tail_cdf + ldf[..., -1] = ldf[..., -1] * tail_cdfs obj = obj * ldf obj._set_slicers() From 8e06715b08c91756395b4ff1a0b9d7704cdfa7c8 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Wed, 27 May 2026 16:25:35 -0700 Subject: [PATCH 22/25] Clean up --- chainladder/development/constant.py | 97 ++++++++++++++--------------- 1 file changed, 48 insertions(+), 49 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 80e7a1a7..4fda7752 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -56,6 +56,7 @@ def _prepare_cdf_patterns(self, patterns, n_dev_periods): else: cdf_patterns = {int(k): float(patterns[k]) for k in sorted_keys} + # seperate the tail from the patterns if len(cdf_patterns) > n_dev_periods: tail_cdf = float(cdf_patterns[sorted_keys[n_dev_periods]]) @@ -65,7 +66,7 @@ def _prepare_cdf_patterns(self, patterns, n_dev_periods): return cdf_patterns, tail_cdf else: - return patterns, 1.0 + return cdf_patterns, 1.0 def fit(self, X, y=None, sample_weight=None): """Fit the model with X. @@ -89,91 +90,89 @@ def fit(self, X, y=None, sample_weight=None): obj = self._set_fit_groups(X).val_to_dev().copy() xp = obj.get_array_module() + tri_dev_periods = len(obj.ddims) if callable(self.patterns): - if self.callable_axis == 0: # varying patterns by index - patterns = self.patterns(obj.index.iloc[0]) - elif self.callable_axis == 1: # varying patterns by column - patterns = self.patterns(obj.columns.to_frame(index=False).iloc[0]) + # on index + if self.callable_axis == 0: + rows = obj.index + # on columns + elif self.callable_axis == 1: + rows = obj.columns.to_frame(index=False) else: raise ValueError("callable axis needs to be 0 or 1") - else: # static patterns - patterns = dict(self.patterns) - n_dev_periods = len(obj.ddims) - cdf_patterns, tail_cdf = self._prepare_cdf_patterns(patterns, n_dev_periods) - sorted_keys = sorted(cdf_patterns.keys()) + patterns = self.patterns(rows.iloc[0]) + else: + # force the patterns to a dictionary + patterns = dict(self.patterns) - # patterns provided is longer than the triangle development periods, - # this step resizes and gets tail_cdf to apply to the tail of the triangle later - # n_dev_periods - 1 has - 1 at the end because the last period is assumed at - # ultimate by defualt, unless there's a tail - if len(cdf_patterns) > n_dev_periods: - obj = ( - obj.iloc[..., :1, :-1] * 0 + 1 - if tail_cdf == 1 - else obj.iloc[..., :1, :] * 0 + 1 - ) + # seperate cdf_patterns and tail_cdf + cdf_patterns, tail_cdf = self._prepare_cdf_patterns(patterns, tri_dev_periods) - # warn if the patterns are shorter than the triangle development periods - elif len(cdf_patterns) < n_dev_periods: + if len(cdf_patterns) < tri_dev_periods: warnings.warn( "Supplied patterns are shorter than the triangle development " "periods. Missing ages will be filled with a factor of 1.0.", UserWarning, stacklevel=2, ) - obj = obj.iloc[..., :1, :-1] * 0 + 1 tail_cdf = 1 - # pattern is exact, no tail needed - else: - obj = obj.iloc[..., :1, :] * 0 + 1 + elif len(cdf_patterns) == tri_dev_periods: tail_cdf = 1 - # fill the cdf_patterns dictionary with 1.0 for any missing development periods - for ddim in obj.ddims: - if not any(ddim == k or int(ddim) == int(k) for k in cdf_patterns): - cdf_patterns[int(ddim)] = 1.0 - if self.style == "ldf" and not any( - ddim == k or int(ddim) == int(k) for k in patterns - ): - patterns[int(ddim)] = 1.0 + pattern_dev_periods = len(cdf_patterns) + + if pattern_dev_periods < tri_dev_periods: + include_last = False + elif pattern_dev_periods == tri_dev_periods: + include_last = True + else: + include_last = tail_cdf != 1 + + dev_slice = slice(None) if include_last else slice(None, -1) + + # this is the object to fill out the patterns, skeleton frame + obj = obj.iloc[..., :1, dev_slice] * 0 + 1 if callable(self.patterns): - if self.callable_axis == 0: - rows = obj.index - elif self.callable_axis == 1: - rows = obj.columns.to_frame(index=False) - else: - raise ValueError("callable axis needs to be 0 or 1") - def _callable_row(row_pattern): - raw_patterns = self.patterns(row_pattern) + def _callable_row(row): + raw_patterns = self.patterns(row) cdf_row, row_tail_cdf = self._prepare_cdf_patterns( - raw_patterns, n_dev_periods + raw_patterns, tri_dev_periods ) fit_row = raw_patterns if self.style == "ldf" else cdf_row return dict(fit_row), row_tail_cdf prepared = rows.apply(_callable_row, axis=1) - fit_patterns = prepared.apply(lambda item: item[0]) - tail_cdfs = prepared.apply(lambda item: item[1]) - ldf = ( - pd.concat(fit_patterns.apply(pd.DataFrame, index=[0]).values, axis=0) + pd.concat( + [pd.DataFrame(item[0], index=[0]) for item in prepared], + axis=0, + ) .fillna(1)[obj.ddims] .values ) + tail_cdfs = xp.array([item[1] for item in prepared]) if self.callable_axis == 0: ldf = xp.array(ldf[:, None, None, :]) - tail_cdfs = xp.array(tail_cdfs.values)[:, None, None] + tail_cdfs = tail_cdfs[:, None, None] else: ldf = xp.array(ldf[None, :, None, :]) - tail_cdfs = xp.array(tail_cdfs.values)[None, :, None] + tail_cdfs = tail_cdfs[None, :, None] else: + for ddim in obj.ddims: + if not any(ddim == k or int(ddim) == int(k) for k in cdf_patterns): + cdf_patterns[int(ddim)] = 1.0 + if self.style == "ldf" and not any( + ddim == k or int(ddim) == int(k) for k in patterns + ): + patterns[int(ddim)] = 1.0 + fit_patterns = patterns if self.style == "ldf" else cdf_patterns ldf = xp.array([float(fit_patterns[int(item)]) for item in obj.ddims]) ldf = ldf[None, None, None, :] From b0f47f4b86411755680ec3d2befd53847223e73a Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Thu, 28 May 2026 16:39:23 -0700 Subject: [PATCH 23/25] Simplified the period comparison --- chainladder/development/constant.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 4fda7752..34f8755e 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -107,24 +107,18 @@ def fit(self, X, y=None, sample_weight=None): # force the patterns to a dictionary patterns = dict(self.patterns) - # seperate cdf_patterns and tail_cdf + # separate the cdf patterns from the tail; _prepare_cdf_patterns already + # returns tail_cdf=1 when the patterns do not extend past the triangle. cdf_patterns, tail_cdf = self._prepare_cdf_patterns(patterns, tri_dev_periods) + pattern_dev_periods = len(cdf_patterns) - if len(cdf_patterns) < tri_dev_periods: + if pattern_dev_periods < tri_dev_periods: warnings.warn( "Supplied patterns are shorter than the triangle development " "periods. Missing ages will be filled with a factor of 1.0.", UserWarning, stacklevel=2, ) - tail_cdf = 1 - - elif len(cdf_patterns) == tri_dev_periods: - tail_cdf = 1 - - pattern_dev_periods = len(cdf_patterns) - - if pattern_dev_periods < tri_dev_periods: include_last = False elif pattern_dev_periods == tri_dev_periods: include_last = True From 7e9945f6860b86890886d4524255ef4c0807db46 Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Thu, 28 May 2026 16:44:37 -0700 Subject: [PATCH 24/25] Cleaned up --- chainladder/development/constant.py | 38 ++++++++++++++--------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/chainladder/development/constant.py b/chainladder/development/constant.py index 34f8755e..cfce1d37 100644 --- a/chainladder/development/constant.py +++ b/chainladder/development/constant.py @@ -48,25 +48,24 @@ def _prepare_cdf_patterns(self, patterns, n_dev_periods): sorted_keys = sorted(patterns.keys()) pattern_values = np.array([float(patterns[k]) for k in sorted_keys]) - # build the cdf patterns, up to the needed periods + # convert ldfs to cdfs; cdf patterns are used as-is if self.style == "ldf": - cdf_patterns = dict( - zip(sorted_keys, np.cumprod(pattern_values[::-1])[::-1]) - ) + cdf_values = np.cumprod(pattern_values[::-1])[::-1] else: - cdf_patterns = {int(k): float(patterns[k]) for k in sorted_keys} + cdf_values = pattern_values - # seperate the tail from the patterns - if len(cdf_patterns) > n_dev_periods: - tail_cdf = float(cdf_patterns[sorted_keys[n_dev_periods]]) + cdf_patterns = {int(k): float(v) for k, v in zip(sorted_keys, cdf_values)} - for k in sorted_keys[:n_dev_periods]: - cdf_patterns[int(k)] = cdf_patterns[int(k)] / tail_cdf + # patterns that fit within the triangle have no tail + if len(cdf_patterns) <= n_dev_periods: + return cdf_patterns, 1.0 - return cdf_patterns, tail_cdf + # separate the tail factor and rebase the remaining cdfs onto it + tail_cdf = cdf_patterns[int(sorted_keys[n_dev_periods])] + for k in sorted_keys[:n_dev_periods]: + cdf_patterns[int(k)] /= tail_cdf - else: - return cdf_patterns, 1.0 + return cdf_patterns, tail_cdf def fit(self, X, y=None, sample_weight=None): """Fit the model with X. @@ -112,6 +111,7 @@ def fit(self, X, y=None, sample_weight=None): cdf_patterns, tail_cdf = self._prepare_cdf_patterns(patterns, tri_dev_periods) pattern_dev_periods = len(cdf_patterns) + # determine whether to include the last development period in the patterns if pattern_dev_periods < tri_dev_periods: warnings.warn( "Supplied patterns are shorter than the triangle development " @@ -159,15 +159,13 @@ def _callable_row(row): tail_cdfs = tail_cdfs[None, :, None] else: + fit_patterns = patterns if self.style == "ldf" else cdf_patterns + + # fill any triangle ages missing from the patterns with a factor of 1.0 for ddim in obj.ddims: - if not any(ddim == k or int(ddim) == int(k) for k in cdf_patterns): - cdf_patterns[int(ddim)] = 1.0 - if self.style == "ldf" and not any( - ddim == k or int(ddim) == int(k) for k in patterns - ): - patterns[int(ddim)] = 1.0 + if not any(ddim == k or int(ddim) == int(k) for k in fit_patterns): + fit_patterns[int(ddim)] = 1.0 - fit_patterns = patterns if self.style == "ldf" else cdf_patterns ldf = xp.array([float(fit_patterns[int(item)]) for item in obj.ddims]) ldf = ldf[None, None, None, :] tail_cdfs = tail_cdf From 6f28c21a695c767b0cd12ae8ee69d1766a448f5f Mon Sep 17 00:00:00 2001 From: Kenneth Hsu Date: Fri, 29 May 2026 12:47:24 -0700 Subject: [PATCH 25/25] Added asserts for ldfs --- .../development/tests/test_constant.py | 139 +++++++++++++++--- 1 file changed, 117 insertions(+), 22 deletions(-) diff --git a/chainladder/development/tests/test_constant.py b/chainladder/development/tests/test_constant.py index d12c4584..7e3ea677 100644 --- a/chainladder/development/tests/test_constant.py +++ b/chainladder/development/tests/test_constant.py @@ -184,6 +184,24 @@ def test_constant_pattern_no_tail(): np.round(reported_BI_claim.cdf_.to_frame().values.flatten(), 6) == np.array([4.0, 2.9, 1.8, 1.4, 1.2, 1.1, 1.03, 1.02]) ) + assert np.all( + np.round(reported_BI_claim.ldf_.to_frame().values.flatten(), 6) + == np.round( + np.array( + [ + 4.0 / 2.9, + 2.9 / 1.8, + 1.8 / 1.4, + 1.4 / 1.2, + 1.2 / 1.1, + 1.1 / 1.03, + 1.03 / 1.02, + 1.02, + ] + ), + 6, + ) + ) def test_constant_pattern_has_tail(): @@ -207,6 +225,25 @@ def test_constant_pattern_has_tail(): np.round(reported_BI_claim.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]) ) + assert np.all( + np.round(reported_BI_claim.ldf_.to_frame().values.flatten(), 6) + == np.round( + np.array( + [ + 4.0 / 2.9, + 2.9 / 1.8, + 1.8 / 1.4, + 1.4 / 1.2, + 1.2 / 1.1, + 1.1 / 1.03, + 1.03 / 1.02, + 1.02 / 1.005, + 1.005, + ] + ), + 6, + ) + ) def test_constant_pattern_exact_cdf(raa): @@ -228,9 +265,13 @@ def test_constant_pattern_exact_cdf(raa): ).fit_transform(raa) assert np.all( - np.round(result.cdf_.to_frame().values.flatten(), 6) + result.cdf_.to_frame().values.flatten() == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) ) + assert np.all( + result.ldf_.to_frame().values.flatten() + == np.array([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1]) + ) def test_constant_pattern_exact_ldf(raa): @@ -253,17 +294,37 @@ def test_constant_pattern_exact_ldf(raa): assert np.all( np.round(result.cdf_.to_frame().values.flatten(), 6) + == np.round( + np.array( + [ + 1.1**10, + 1.1**9, + 1.1**8, + 1.1**7, + 1.1**6, + 1.1**5, + 1.1**4, + 1.1**3, + 1.1**2, + 1.1, + ] + ), + 6, + ) + ) + assert np.all( + result.ldf_.to_frame().values.flatten() == np.array( [ - 2.593742, - 2.357948, - 2.143589, - 1.948717, - 1.771561, - 1.61051, - 1.4641, - 1.331, - 1.21, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, 1.1, ] ) @@ -289,9 +350,13 @@ def test_constant_pattern_short_cdf(raa): ).fit_transform(raa) assert np.all( - np.round(result.cdf_.to_frame().values.flatten(), 6) + result.cdf_.to_frame().values.flatten() == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.0, 1.0, 1.0]) ) + assert np.all( + result.ldf_.to_frame().values.flatten() + == np.array([1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.0, 1.0, 1.0]) + ) def test_constant_pattern_short_ldf(raa): @@ -314,7 +379,13 @@ def test_constant_pattern_short_ldf(raa): assert np.all( np.round(result.cdf_.to_frame().values.flatten(), 6) - == np.array([1.771561, 1.61051, 1.4641, 1.331, 1.21, 1.1, 1.0, 1.0, 1.0]) + == np.round( + np.array([1.1**6, 1.1**5, 1.1**4, 1.1**3, 1.1**2, 1.1, 1.0, 1.0, 1.0]), 6 + ) + ) + assert np.all( + result.ldf_.to_frame().values.flatten() + == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.0, 1.0, 1.0]) ) @@ -340,6 +411,10 @@ def test_constant_pattern_long_cdf(raa): np.round(result.cdf_.to_frame().values.flatten(), 6) == np.array([1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1]) ) + assert np.all( + np.round(result.ldf_.to_frame().values.flatten(), 6) + == np.array([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1]) + ) def test_constant_pattern_long_ldf(raa): @@ -363,18 +438,38 @@ def test_constant_pattern_long_ldf(raa): assert np.all( np.round(result.cdf_.to_frame().values.flatten(), 6) + == np.round( + np.array( + [ + 1.1**11, + 1.1**10, + 1.1**9, + 1.1**8, + 1.1**7, + 1.1**6, + 1.1**5, + 1.1**4, + 1.1**3, + 1.1**2, + ] + ), + 6, + ) + ) + assert np.all( + result.ldf_.to_frame().values.flatten() == np.array( [ - 2.853117, - 2.593742, - 2.357948, - 2.143589, - 1.948717, - 1.771561, - 1.61051, - 1.4641, - 1.331, - 1.21, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1, + 1.1**2, ] ) )