From fbf8e6b92e86791fed4fa4807aa314a4e267d9b6 Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:12:20 +0530 Subject: [PATCH 1/3] Support cumulative on-level factors in ParallelogramOLF Adds a cumulative flag to ParallelogramOLF and parallelogram_olf so that a rate history can be stated as cumulative on-level factors instead of incremental rate changes. Each factor is in force from its effective date until the next one, and the earliest factor extends backwards to cover the lookback window. The implied rate level is the reciprocal of the factor, so all downstream logic (rolling average, vertical_line, leap year handling, current rate level normalization) is unchanged. Refs #922 --- chainladder/adjustments/parallelogram.py | 23 +++++-- .../adjustments/tests/test_parallelogram.py | 68 +++++++++++++++++++ chainladder/utils/utility_functions.py | 30 ++++++-- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/chainladder/adjustments/parallelogram.py b/chainladder/adjustments/parallelogram.py index 8f0deb98e..6e3072678 100644 --- a/chainladder/adjustments/parallelogram.py +++ b/chainladder/adjustments/parallelogram.py @@ -42,6 +42,12 @@ class ParallelogramOLF(BaseEstimator, TransformerMixin, EstimatorIO): parallelogram OLFs. If True, Parallelograms become squares. This is commonly seen in Workers Compensation with benefit on-leveling or if the premium origin is also stated on an effective date basis. + cumulative: bool (default=False) + By default, `change_col` holds incremental rate changes. If True, it + instead holds cumulative on-level factors stated relative to one + another, which avoids having to back into the incremental changes. + Each factor is in force from its effective date until the next one, + and the earliest factor is extended backwards. Attributes ---------- @@ -212,6 +218,7 @@ def __init__( approximation_grain="M", policy_length=12, vertical_line=False, + cumulative=False, ): self.rate_history = rate_history self.change_col = change_col @@ -219,6 +226,7 @@ def __init__( self.approximation_grain = approximation_grain self.policy_length = policy_length self.vertical_line = vertical_line + self.cumulative = cumulative def fit(self, X, y=None, sample_weight=None): """Fit the model with X. @@ -256,14 +264,14 @@ def fit(self, X, y=None, sample_weight=None): policy_length=self.policy_length, vertical_line=self.vertical_line, approximation_grain=self.approximation_grain, + cumulative=self.cumulative, ) if len(groups) > 0: tris = [] for item in idx.index.set_index(groups).iterrows(): r = self.rate_history.set_index(groups).loc[item[0]].copy() - r[self.change_col] = r[self.change_col] + 1 - r = (r.groupby(self.date_col)[self.change_col].prod() - 1).reset_index() + r = self._combine_duplicate_dates(r) date = r[self.date_col] values = r[self.change_col] olf = parallelogram_olf(values=values, dates=date, **kw).values[ @@ -274,15 +282,20 @@ def fit(self, X, y=None, sample_weight=None): tris.append((idx.loc[item[0]] * 0 + 1) * olf) self.olf_ = concat(tris, 0).latest_diagonal else: - r = self.rate_history.copy() - r[self.change_col] = r[self.change_col] + 1 - r = (r.groupby(self.date_col)[self.change_col].prod() - 1).reset_index() + r = self._combine_duplicate_dates(self.rate_history.copy()) date = r[self.date_col] values = r[self.change_col] olf = parallelogram_olf(values=values, dates=date, **kw) self.olf_ = ((idx * 0 + 1) * olf.values[None, None]).latest_diagonal return self + def _combine_duplicate_dates(self, r): + """Collapse multiple rate entries sharing an effective date.""" + if self.cumulative: + return r.groupby(self.date_col)[self.change_col].prod().reset_index() + r[self.change_col] = r[self.change_col] + 1 + return (r.groupby(self.date_col)[self.change_col].prod() - 1).reset_index() + def transform(self, X, y=None, sample_weight=None): """If X and self are of different shapes, align self to X, else return self. diff --git a/chainladder/adjustments/tests/test_parallelogram.py b/chainladder/adjustments/tests/test_parallelogram.py index 6325dd750..15cf6bf47 100644 --- a/chainladder/adjustments/tests/test_parallelogram.py +++ b/chainladder/adjustments/tests/test_parallelogram.py @@ -382,3 +382,71 @@ def test_rate_impact_beginning_of_year(): np.where(monthly > daily, ">", np.where(monthly == daily, "=", "<")), np.array([">", ">", ">", "=", "="]), ) # this is true becuase there are less "days" in the first half of the year (from Jan - Jun) compared to (Jul - Dec), and only the first three origins would need to be brought to current rate level + + +def test_cumulative_tort_reform(): + """Cumulative on-level factors can be supplied directly. See GH #922.""" + tort = pd.DataFrame( + { + "EffDate": ["1998-01-01", "2003-01-01", "2004-01-01"], + "Factor": [0.67, 0.75, 1.00], + } + ) + olf = ( + cl.ParallelogramOLF( + rate_history=tort, + change_col="Factor", + date_col="EffDate", + policy_length=12, + approximation_grain="M", + vertical_line=True, + cumulative=True, + ) + .fit_transform(cl.load_sample("friedland_gl_self_insurer")["Reported Claims"]) + .olf_ + ) + assert np.all( + np.round(olf.to_frame().values.flatten(), 6) + == [0.67, 0.67, 0.67, 0.67, 0.67, 0.75, 1.0, 1.0, 1.0, 1.0, 1.0] + ) + + +def test_cumulative_matches_incremental(): + """Cumulative factors and their incremental equivalent agree.""" + data = pd.DataFrame( + {"Year": list(range(2006, 2016)), "EarnedPremium": [10_000] * 10} + ) + prem_tri = cl.Triangle( + data, origin="Year", columns="EarnedPremium", cumulative=True + ) + dates = ["2006-01-01", "2010-07-01", "2011-01-01", "2012-07-01", "2013-04-01"] + changes = [0.0, 0.035, 0.05, 0.10, -0.01] + levels = np.cumprod(np.array(changes) + 1) + factors = levels[-1] / levels + + for grain in ["M", "D"]: + for vertical_line in [True, False]: + kw = dict( + date_col="EffDate", + approximation_grain=grain, + vertical_line=vertical_line, + ) + incremental = cl.ParallelogramOLF( + pd.DataFrame({"EffDate": dates, "RateChange": changes}), + change_col="RateChange", + **kw, + ).fit_transform(prem_tri) + cumulative = cl.ParallelogramOLF( + pd.DataFrame({"EffDate": dates, "Factor": factors}), + change_col="Factor", + cumulative=True, + **kw, + ).fit_transform(prem_tri) + assert np.allclose( + incremental.olf_.values, cumulative.olf_.values + ), (grain, vertical_line) + + +def test_cumulative_rejects_non_positive(): + with pytest.raises(ValueError, match="positive"): + cl.parallelogram_olf([1.0, 0.0], ["2010-01-01", "2011-01-01"], cumulative=True) diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 8588d03eb..9b99e0d5e 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -433,8 +433,16 @@ def parallelogram_olf( policy_length=12, approximation_grain="M", vertical_line=False, + cumulative=False, ): - """Parallelogram approach to on-leveling.""" + """Parallelogram approach to on-leveling. + + When ``cumulative`` is False (default), ``values`` are incremental rate + changes expressed as decimals. When True, ``values`` are cumulative + on-level factors already stated relative to one another, and each value is + in force from its effective date until the next one. The earliest value is + extended backwards to cover the lookback window. + """ if approximation_grain not in ["M", "D"]: raise ValueError("approximation_grain must be M or D") @@ -456,12 +464,20 @@ def parallelogram_olf( freq={"M": "MS", "D": "D"}[approximation_grain], ) - rate_changes = pd.Series(np.array(values), np.array(dates)).reindex( - date_idx, fill_value=0 - ) - cum_rate_changes = pd.Series( - np.cumprod(1 + rate_changes.values), rate_changes.index - ) + if cumulative: + factors = pd.Series(np.array(values, dtype="float64"), np.array(dates)) + if (factors <= 0).any(): + raise ValueError("cumulative on-level factors must be positive") + # An on-level factor is the current rate level divided by the rate + # level in force, so the implied rate level is its reciprocal. + cum_rate_changes = (1 / factors).reindex(date_idx).ffill().bfill() + else: + rate_changes = pd.Series(np.array(values), np.array(dates)).reindex( + date_idx, fill_value=0 + ) + cum_rate_changes = pd.Series( + np.cumprod(1 + rate_changes.values), rate_changes.index + ) crl = cum_rate_changes.iloc[-1] rolling_num_base = { From ebf009d4a56dad6d8b6c23beb2dad06c9a589f2a Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:45:47 +0530 Subject: [PATCH 2/3] fix(parallelogram): honor pre-window and duplicate cumulative factors Addresses two Cursor Bugbot findings on #1181. (1) The cumulative branch reindexed factors onto the calendar grid before ffill/bfill, dropping any factor whose effective date was off-grid or predated the triangle window (start_date is anchored to X.origin[0]); replaced with a backward/asof match via searchsorted so each factor stays in force from its date until the next and the earliest extends back. (2) _combine_duplicate_dates multiplied cumulative factors sharing a date; cumulative factors are absolute levels, so last-wins instead. Added tests for a factor predating the window and duplicate-date last-wins. --- chainladder/adjustments/parallelogram.py | 4 +- .../adjustments/tests/test_parallelogram.py | 57 +++++++++++++++++++ chainladder/utils/utility_functions.py | 17 ++++-- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/chainladder/adjustments/parallelogram.py b/chainladder/adjustments/parallelogram.py index 6e3072678..953e4cc54 100644 --- a/chainladder/adjustments/parallelogram.py +++ b/chainladder/adjustments/parallelogram.py @@ -292,7 +292,9 @@ def fit(self, X, y=None, sample_weight=None): def _combine_duplicate_dates(self, r): """Collapse multiple rate entries sharing an effective date.""" if self.cumulative: - return r.groupby(self.date_col)[self.change_col].prod().reset_index() + # Cumulative factors are absolute rate levels, not compounding + # increments, so the last factor stated for a date wins. + return r.groupby(self.date_col)[self.change_col].last().reset_index() r[self.change_col] = r[self.change_col] + 1 return (r.groupby(self.date_col)[self.change_col].prod() - 1).reset_index() diff --git a/chainladder/adjustments/tests/test_parallelogram.py b/chainladder/adjustments/tests/test_parallelogram.py index 15cf6bf47..9ef84e145 100644 --- a/chainladder/adjustments/tests/test_parallelogram.py +++ b/chainladder/adjustments/tests/test_parallelogram.py @@ -450,3 +450,60 @@ def test_cumulative_matches_incremental(): def test_cumulative_rejects_non_positive(): with pytest.raises(ValueError, match="positive"): cl.parallelogram_olf([1.0, 0.0], ["2010-01-01", "2011-01-01"], cumulative=True) + + +def test_cumulative_factor_predates_window(): + """A factor in force before the triangle window is honored, not dropped. + + The earliest factor's effective date (2000) predates the triangle's + lookback window (origins start 2003), so it must still apply to the first + origins rather than being backfilled with a later factor. See GH #922. + """ + data = pd.DataFrame({"Year": list(range(2003, 2009)), "EarnedPremium": [1000] * 6}) + prem_tri = cl.Triangle( + data, origin="Year", columns="EarnedPremium", cumulative=True + ) + factors = pd.DataFrame( + {"EffDate": ["2000-01-01", "2005-01-01"], "Factor": [0.5, 1.0]} + ) + olf = ( + cl.ParallelogramOLF( + rate_history=factors, + change_col="Factor", + date_col="EffDate", + approximation_grain="M", + vertical_line=True, + cumulative=True, + ) + .fit_transform(prem_tri) + .olf_ + ) + assert np.all( + np.round(olf.to_frame().values.flatten(), 6) == [0.5, 0.5, 1.0, 1.0, 1.0, 1.0] + ) + + +def test_cumulative_duplicate_date_last_wins(): + """Duplicate effective dates keep the last cumulative factor, not a product.""" + dup = pd.DataFrame( + { + "EffDate": ["1998-01-01", "2003-01-01", "2003-01-01", "2004-01-01"], + "Factor": [0.67, 0.67, 0.75, 1.00], + } + ) + olf = ( + cl.ParallelogramOLF( + rate_history=dup, + change_col="Factor", + date_col="EffDate", + approximation_grain="M", + vertical_line=True, + cumulative=True, + ) + .fit_transform(cl.load_sample("friedland_gl_self_insurer")["Reported Claims"]) + .olf_ + ) + assert np.all( + np.round(olf.to_frame().values.flatten(), 6) + == [0.67, 0.67, 0.67, 0.67, 0.67, 0.75, 1.0, 1.0, 1.0, 1.0, 1.0] + ) diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 9b99e0d5e..0d491acaa 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -465,12 +465,21 @@ def parallelogram_olf( ) if cumulative: - factors = pd.Series(np.array(values, dtype="float64"), np.array(dates)) + factors = pd.Series( + np.array(values, dtype="float64"), pd.to_datetime(np.array(dates)) + ).sort_index() if (factors <= 0).any(): raise ValueError("cumulative on-level factors must be positive") - # An on-level factor is the current rate level divided by the rate - # level in force, so the implied rate level is its reciprocal. - cum_rate_changes = (1 / factors).reindex(date_idx).ffill().bfill() + # An on-level factor is the current rate level divided by the rate level + # in force, so the implied rate level is its reciprocal. Each factor is + # in force from its effective date until the next (a backward/asof match, + # so off-grid or pre-window dates are honored); dates before the first + # factor take the earliest, extending it back over the lookback window. + level = 1 / factors + pos = np.searchsorted(level.index.values, date_idx.values, side="right") - 1 + cum_rate_changes = pd.Series( + level.values[np.clip(pos, 0, None)], index=date_idx + ) else: rate_changes = pd.Series(np.array(values), np.array(dates)).reindex( date_idx, fill_value=0 From b5c84cab967504700bf0f6d936268e4a352758db Mon Sep 17 00:00:00 2001 From: priyam0k <87162535+priyam0k@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:38:26 +0530 Subject: [PATCH 3/3] docs(parallelogram): clarify 1-centric cumulative rate levels and add example --- chainladder/adjustments/parallelogram.py | 39 ++++++++++++++++++++++-- chainladder/utils/utility_functions.py | 7 +++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/chainladder/adjustments/parallelogram.py b/chainladder/adjustments/parallelogram.py index 953e4cc54..91545714c 100644 --- a/chainladder/adjustments/parallelogram.py +++ b/chainladder/adjustments/parallelogram.py @@ -43,9 +43,11 @@ class ParallelogramOLF(BaseEstimator, TransformerMixin, EstimatorIO): commonly seen in Workers Compensation with benefit on-leveling or if the premium origin is also stated on an effective date basis. cumulative: bool (default=False) - By default, `change_col` holds incremental rate changes. If True, it - instead holds cumulative on-level factors stated relative to one - another, which avoids having to back into the incremental changes. + By default, `change_col` holds incremental rate changes expressed as decimals + (0-centric, e.g. 0.05 for a +5% rate change). If True, `change_col` instead + holds cumulative rate level factors stated relative to one another (1-centric, + e.g. 0.67 for 67% rate level, 0.75 for 75% rate level, 1.00 for current level). + This avoids having to back into incremental changes. Each factor is in force from its effective date until the next one, and the earliest factor is extended backwards. @@ -208,6 +210,37 @@ def prem(): 2013 1.000000 2014 1.000000 + Cumulative rate level factors (1-centric) can also be supplied directly with + ``cumulative=True``: + + .. testcode:: + + tort = pd.DataFrame( + { + "EffDate": ["1998-01-01", "2003-01-01", "2004-01-01"], + "Factor": [0.67, 0.75, 1.00], + } + ) + prem_tri = cl.load_sample("friedland_gl_self_insurer")["Reported Claims"] + olf = ( + cl.ParallelogramOLF( + rate_history=tort, + change_col="Factor", + date_col="EffDate", + policy_length=12, + approximation_grain="M", + vertical_line=True, + cumulative=True, + ) + .fit_transform(prem_tri) + .olf_ + ) + print(np.round(olf.to_frame().values.flatten()[:5], 2)) + + .. testoutput:: + + [0.67 0.67 0.67 0.67 0.67] + """ def __init__( diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 0d491acaa..ce54d1755 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -438,9 +438,10 @@ def parallelogram_olf( """Parallelogram approach to on-leveling. When ``cumulative`` is False (default), ``values`` are incremental rate - changes expressed as decimals. When True, ``values`` are cumulative - on-level factors already stated relative to one another, and each value is - in force from its effective date until the next one. The earliest value is + changes expressed as decimals (0-centric, e.g. 0.05 for +5%). When True, + ``values`` are cumulative rate level factors stated relative to one another + (1-centric, e.g. 0.67 for 67% rate level, 1.00 for current level), and each value is + in force from its effective date until the next one. The earliest value is extended backwards to cover the lookback window. """ if approximation_grain not in ["M", "D"]: