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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 53 additions & 5 deletions chainladder/adjustments/parallelogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down Expand Up @@ -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__(
Expand All @@ -212,13 +251,15 @@ def __init__(
approximation_grain="M",
policy_length=12,
vertical_line=False,
cumulative=False,
):
self.rate_history = rate_history
self.change_col = change_col
self.date_col = date_col
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.
Expand Down Expand Up @@ -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[
Expand All @@ -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.
Expand Down
125 changes: 125 additions & 0 deletions chainladder/adjustments/tests/test_parallelogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
40 changes: 33 additions & 7 deletions chainladder/utils/utility_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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():
Comment thread
henrydingliu marked this conversation as resolved.
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 = {
Expand Down
Loading