diff --git a/chainladder/_config/options.py b/chainladder/_config/options.py index d125a1f80..cc28b854d 100644 --- a/chainladder/_config/options.py +++ b/chainladder/_config/options.py @@ -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. """ @@ -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("_") diff --git a/chainladder/tails/base.py b/chainladder/tails/base.py index daa968a7a..4797f5f15 100644 --- a/chainladder/tails/base.py +++ b/chainladder/tails/base.py @@ -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 diff --git a/chainladder/tails/tests/test_exponential.py b/chainladder/tails/tests/test_exponential.py index f4170030e..9ef7efa32 100644 --- a/chainladder/tails/tests/test_exponential.py +++ b/chainladder/tails/tests/test_exponential.py @@ -1,5 +1,9 @@ from __future__ import annotations +import warnings + +import numpy as np + import chainladder as cl import pytest @@ -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