diff --git a/chainladder/adjustments/parallelogram.py b/chainladder/adjustments/parallelogram.py index 8f0deb98e..91545714c 100644 --- a/chainladder/adjustments/parallelogram.py +++ b/chainladder/adjustments/parallelogram.py @@ -42,6 +42,14 @@ 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 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. Attributes ---------- @@ -202,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__( @@ -212,6 +251,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 +259,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 +297,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 +315,22 @@ 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: + # 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() + 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..9ef84e145 100644 --- a/chainladder/adjustments/tests/test_parallelogram.py +++ b/chainladder/adjustments/tests/test_parallelogram.py @@ -382,3 +382,128 @@ 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) + + +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 8588d03eb..ce54d1755 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -433,8 +433,17 @@ 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 (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"]: raise ValueError("approximation_grain must be M or D") @@ -456,12 +465,29 @@ 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"), 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. 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 + ) + cum_rate_changes = pd.Series( + np.cumprod(1 + rate_changes.values), rate_changes.index + ) crl = cum_rate_changes.iloc[-1] rolling_num_base = {