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
5 changes: 5 additions & 0 deletions chainladder/_config/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ class Options:
when comparing or concatenating them.
ULT_VAL: str
The default ultimate valuation datetime, precision set to default of Pandas installation.
NOMINAL_TAIL: float
The tail substituted when a fitted tail is at or below 1.0. ``log(tail - 1)`` is
undefined there, so the tail-weighted development age falls back to this value.
Must be greater than 1.

"""

Expand All @@ -65,6 +69,7 @@ def __init__(self):
self.ULT_VAL = str(
pd.Timestamp("2262-01-01") - pd.Timedelta(1, unit=__dt64_unit__) # noqa
)
self.NOMINAL_TAIL = 1.001
# Store initial values as defaults.
self._defaults = copy.deepcopy({
k: v for k, v in vars(self).items() if not k.startswith("_")
Expand Down
9 changes: 7 additions & 2 deletions chainladder/tails/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,17 @@ def _get_tail_weighted_time_period(self, X):
y = X.ldf_.values.copy()
xp = X.ldf_.get_array_module()
y[y <= 1] = xp.nan
reg = WeightedRegression(axis=3, xp=xp).fit(None, xp.log(y - 1), None)
tail = xp.prod(
self.ldf_.values[..., -self._ave_period[0] - 1 :], -1, keepdims=True
)
reg = WeightedRegression(axis=3, xp=xp).fit(None, xp.log(y - 1), None)
tail = tail if tail.max() > 1 else 1.001
# A tail of 1.0 or less has no development left to extrapolate from,
# and log(tail - 1) is undefined there. The nominal tail has to be
# substituted per element: testing tail.max() only skips the
# substitution entirely as soon as any one element exceeds 1.
from chainladder import options

tail = xp.where(tail > 1, tail, options.NOMINAL_TAIL)
time_pd = (xp.log(tail - 1) - reg.intercept_) / reg.slope_
return time_pd

Expand Down
75 changes: 75 additions & 0 deletions chainladder/tails/tests/test_exponential.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from __future__ import annotations

import warnings

import numpy as np

import chainladder as cl
import pytest

Expand Down Expand Up @@ -40,3 +44,74 @@ def test_errors_validation(tail_sample: Triangle) -> None:
"""
with pytest.raises(ValueError):
cl.TailCurve(errors="Ignore").fit_transform(tail_sample)


def test_no_log_warning_when_only_some_tails_exceed_one(clrd: Triangle) -> None:
"""
A tail at or below 1.0 must not reach ``log(tail - 1)``.

The nominal 1.001 used to be substituted only when the *largest* tail was
at or below 1, so an estimator producing a mix of tails skipped the
substitution entirely and evaluated the logarithm of a negative number.
``TailBondy`` on the grouped ``clrd`` sample gives 6 tails below 1 out of
12, with a maximum of 1.018. See #1414.

Parameters
----------
clrd: Triangle
The clrd sample data set.

Returns
-------
None
"""
triangle = clrd.groupby("LOB").sum()[["CumPaidLoss", "IncurLoss"]]
triangle["CaseIncurredLoss"] = triangle["IncurLoss"] - triangle["CumPaidLoss"]
development = cl.Development().fit_transform(
triangle[["CumPaidLoss", "CaseIncurredLoss"]]
)

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
cl.TailBondy().fit(development)

offending = [
w
for w in caught
if issubclass(w.category, RuntimeWarning)
and "log" in str(w.message)
and "tails/base.py" in str(w.filename).replace("\\", "/")
]
assert not offending, [str(w.message) for w in offending]


def test_nominal_tail_option_is_honoured(clrd: Triangle) -> None:
"""
``NOMINAL_TAIL`` sets the tail substituted for a tail at or below 1.0.

The full ``clrd`` sample has one index entry whose tail is exactly 1.0 while
its regression coefficients are finite, so the substituted value reaches
``sigma_``. See #1414.

Parameters
----------
clrd: Triangle
The clrd sample data set.

Returns
-------
None
"""
development = cl.Development().fit_transform(clrd["CumPaidLoss"])

assert cl.options.NOMINAL_TAIL == 1.001
baseline = cl.TailCurve().fit(development).sigma_.values.copy()

try:
cl.options.set_option("NOMINAL_TAIL", 1.5)
widened = cl.TailCurve().fit(development).sigma_.values
finally:
cl.options.set_option("NOMINAL_TAIL", 1.001)

assert not np.allclose(np.nan_to_num(baseline), np.nan_to_num(widened))
assert cl.options.NOMINAL_TAIL == 1.001
Loading