diff --git a/chainladder/adjustments/tests/test_trend.py b/chainladder/adjustments/tests/test_trend.py index 1b6cbc9c..c82f3283 100644 --- a/chainladder/adjustments/tests/test_trend.py +++ b/chainladder/adjustments/tests/test_trend.py @@ -1,5 +1,9 @@ import chainladder as cl import numpy as np +import pandas as pd +import pytest + +from sklearn.base import clone def test_trend1(clrd): @@ -23,21 +27,371 @@ def test_trend1(clrd): def test_trend2(raa): - tri = raa - assert ( - abs( - cl - .Trend( - trends=[0.05, 0.05], - dates=[(None, "1985"), ("1985", None)], - axis="origin", - ) - .fit(tri) - .trend_ - * tri - - tri.trend(0.05, axis="origin") + """Two 5% segments that meet at 1985 compound to a single 5% trend.""" + trended = ( + cl + .Trend( + trends=[0.05, 0.05], + dates=[(None, "1985"), ("1985", None)], + axis="origin", + ) + .fit(raa) + .trend_ + * raa + ) + expected = raa.trend(0.05, axis="origin") + assert np.allclose( + np.nan_to_num(trended.set_backend("numpy").values), + np.nan_to_num(expected.set_backend("numpy").values), + atol=1e-6, + ) + + +@pytest.mark.parametrize("trend", [0.05, 0.0, -0.03]) +def test_trend_origin_factors_compound_to_the_latest_origin(raa, trend): + """ + On the origin axis every cell of an origin year carries the same factor: + (1 + trend) raised to the years from that origin to the latest, which is + 1.0 because it is the anchor. raa runs 1981 through 1990. + """ + trend_ = cl.Trend(trend, axis="origin").fit(raa).trend_.set_backend("numpy").values + expected = np.tile(((1 + trend) ** np.arange(9, -1, -1))[:, None], (1, 10)) + observed = ~np.isnan(trend_[0, 0]) + assert np.allclose(trend_[0, 0][observed], expected[observed], rtol=1e-12) + + +@pytest.mark.parametrize("trend", [0.05, 0.0, -0.03]) +def test_trend_valuation_factors_compound_to_the_valuation_date(raa, trend): + """ + On the valuation axis the factor follows the cell's diagonal rather than its + row: (1 + trend) raised to the years from that cell's valuation to the + valuation date. raa cell (i, j) is valued at year-end 1981 + i + j. + """ + trend_ = ( + cl.Trend(trend, axis="valuation").fit(raa).trend_.set_backend("numpy").values + ) + i, j = np.indices((10, 10)) + expected = (1 + trend) ** (9 - i - j) + observed = ~np.isnan(trend_[0, 0]) + assert np.allclose(trend_[0, 0][observed], expected[observed], rtol=1e-12) + + +@pytest.mark.parametrize("axis", ["origin", "valuation"]) +def test_trend_segments_apply_over_their_own_date_ranges(raa, axis): + """ + Each segment trends only across the dates it covers, and the segments + compound. Here 10% runs from 1990 back to the 1985 boundary -- 1.0, 1.1, + 1.21, ... up the origins -- and 5% carries the rest of the way back to 1981. + The boundary is 1985-01-01 while origin periods end on 12-31, so the years + either side of it are split part-way through. + """ + trend_ = ( + cl + .Trend(trends=[0.05, 0.10], dates=[("1985", None), (None, "1985")], axis=axis) + .fit(raa) + .trend_.set_backend("numpy") + .values + ) + diagonal = [ + 2.042868, + 1.945589, + 1.852942, + 1.764707, + 1.610510, + 1.464100, + 1.331000, + 1.210000, + 1.100000, + 1.000000, + ] + if axis == "origin": + # Constant across development: the factor depends only on the origin. + expected = np.tile(np.array(diagonal)[:, None], (1, 10)) + else: + # Constant along each diagonal: the factor depends only on the valuation. + # Cells past the last diagonal fall outside the list and stay NaN, which + # `observed` then drops -- but only after `trend_` has agreed they are NaN. + i, j = np.indices((10, 10)) + expected = np.where( + i + j < 10, np.array(diagonal)[np.minimum(i + j, 9)], np.nan ) - .sum() - .sum() - < 1e-6 + observed = ~np.isnan(trend_[0, 0]) + assert np.allclose(trend_[0, 0][observed], expected[observed], rtol=1e-6) + + +@pytest.mark.parametrize("axis", ["origin", "valuation"]) +def test_trend_is_shaped_like_the_triangle(raa, axis): + """By default `trend_` is defined exactly where the Triangle is.""" + trend_ = cl.Trend(0.05, axis=axis).fit(raa).trend_.set_backend("numpy").values + assert np.array_equal(np.isnan(trend_), np.isnan(raa.set_backend("numpy").values)) + + +def test_trend_leaves_internal_gaps_empty(clrd): + """ + Cells missing inside the triangle are empty in `trend_` too, not just the + ones past the latest diagonal. + """ + tri = clrd["CumPaidLoss"].set_backend("numpy") + # Guard the premise: some cells are missing inside the triangle, not merely + # beyond the latest diagonal. Without these the test proves nothing. + inside = ~np.isnan(np.asarray(tri.nan_triangle, dtype="float64")) + assert np.isnan(tri.values[:, :, inside]).any() + + trend_ = cl.Trend(0.05).fit(tri).trend_.values + assert np.array_equal(np.isnan(trend_), np.isnan(tri.values)) + + +@pytest.mark.parametrize("axis", ["origin", "valuation"]) +def test_trend_full_fills_the_rectangle(raa, axis): + """ + `full_triangle` returns a factor for every cell of the origin x development rectangle, + including the cells past the valuation date that the default leaves empty. + """ + default = cl.Trend(0.05, axis=axis).fit(raa).trend_.set_backend("numpy").values + full_triangle = ( + cl + .Trend(0.05, axis=axis, full_triangle=True) + .fit(raa) + .trend_.set_backend("numpy") + .values + ) + assert np.isnan(default).sum() > 0 + assert np.isnan(full_triangle).sum() == 0 + assert full_triangle.shape == default.shape + + +@pytest.mark.parametrize("axis", ["origin", "valuation"]) +def test_trend_full_agrees_with_default_where_both_defined(raa, axis): + """ + Filling the rectangle must not disturb the cells the default already covers. + raa's valuation date lands on an origin period end, so the two agree exactly; + a Triangle valued part-way through an origin period would not, because the + default clips that final step and `full_triangle` does not. + """ + default = cl.Trend(0.05, axis=axis).fit(raa).trend_.set_backend("numpy").values + full_triangle = ( + cl + .Trend(0.05, axis=axis, full_triangle=True) + .fit(raa) + .trend_.set_backend("numpy") + .values + ) + observed = ~np.isnan(default) + assert np.allclose(default[observed], full_triangle[observed], rtol=1e-12) + + +@pytest.mark.parametrize("axis", ["origin", "valuation"]) +def test_trend_full_keeps_accruing_past_the_valuation_date(raa, axis): + """ + Cells beyond the valuation date must carry the trend forward rather than + repeat the boundary value. `start` is both the zero point and the clip + boundary in Triangle.trend, so getting this wrong flattens the extension. + """ + full_triangle = cl.Trend(0.05, axis=axis, full_triangle=True).fit(raa).trend_ + default = cl.Trend(0.05, axis=axis).fit(raa).trend_ + future = np.isnan(default.set_backend("numpy").values) + extended = full_triangle.set_backend("numpy").values[future] + assert len(np.unique(np.round(extended, 6))) > 1 + + +def test_trend_base_period_moves_the_anchor(raa): + """ + The anchored period gets a factor of 1.0, and every other period is stated + against it. `trend_` stays a multiplier TO that period's level, so later + origins fall below 1.0 when anchoring on the earliest. + """ + trend_ = ( + cl + .Trend(0.05, axis="origin", base_period=1981) + .fit(raa) + .trend_.set_backend("numpy") + .values + ) + assert trend_[0, 0, 0, 0] == pytest.approx(1.0) + assert trend_[0, 0, -1, 0] == pytest.approx(1 / 1.05**9, rel=1e-6) + + +@pytest.mark.parametrize("full_triangle", [False, True]) +def test_trend_base_period_at_latest_origin_is_a_noop(raa, full_triangle): + """ + The default already anchors on the latest period, so naming it explicitly + must change nothing. + """ + implicit = ( + cl.Trend(0.05, axis="origin", full_triangle=full_triangle).fit(raa).trend_ + ) + explicit = ( + cl + .Trend(0.05, axis="origin", full_triangle=full_triangle, base_period=1990) + .fit(raa) + .trend_ + ) + implicit = implicit.set_backend("numpy").values + explicit = explicit.set_backend("numpy").values + assert np.allclose(np.nan_to_num(implicit), np.nan_to_num(explicit), rtol=1e-12) + + +def test_trend_base_period_rescales_uniformly(raa): + """ + Rebasing only moves the zero point, so every factor shifts by one common + ratio rather than changing shape. + """ + default = cl.Trend(0.05, axis="origin").fit(raa).trend_.set_backend("numpy").values + rebased = ( + cl + .Trend(0.05, axis="origin", base_period=1985) + .fit(raa) + .trend_.set_backend("numpy") + .values + ) + observed = ~np.isnan(default) + ratio = default[observed] / rebased[observed] + assert np.allclose(ratio, ratio[0], rtol=1e-12) + + +def test_trend_base_period_defined_where_data_is_missing(clrd): + """ + The anchor is read off a full grid, not off the data, so a base period the + Triangle happens to be missing still rebases rather than poisoning the + result with NaN. + """ + tri = clrd["CumPaidLoss"] + trend_ = cl.Trend(0.05, axis="origin", base_period=1995).fit(tri).trend_ + default = cl.Trend(0.05, axis="origin").fit(tri).trend_ + trend_ = trend_.set_backend("numpy").values + default = default.set_backend("numpy").values + assert np.array_equal(np.isnan(trend_), np.isnan(default)) + + +def test_trend_base_period_ignores_the_development_grain(qtr): + """ + Only the trended axis is searched: qtr has quarterly development but annual + origins, so a bare year still resolves against the origins alone. + """ + trend_ = ( + cl + .Trend(0.05, axis="origin", full_triangle=True, base_period=1997) + .fit(qtr["paid"]) + .trend_.set_backend("numpy") + .values + ) + position = list(qtr.origin.astype(str)).index("1997") + assert trend_[0, 0, position, 0] == pytest.approx(1.0) + + +def _fiscal_year_origin_triangle(): + """ + The smallest Triangle with a fiscal (July-June) annual origin axis: three + fiscal years, each valued through fiscal year-end. + """ + years = pd.period_range("2018", "2020", freq="Y-JUN") + rows = [ + (origin.to_timestamp(how="s"), valuation.to_timestamp(how="e"), 1.0) + for i, origin in enumerate(years) + for valuation in years[i:] + ] + return cl.Triangle( + pd.DataFrame(rows, columns=["origin", "valuation", "paid"]), + origin="origin", + development="valuation", + columns=["paid"], + cumulative=True, + trailing=True, + ) + + +def test_trend_default_anchor_preserves_fiscal_origin_frequency(): + """ + Trend should work on a fiscal year. + """ + tri = _fiscal_year_origin_triangle() + trend_ = cl.Trend(0.10, axis="origin").fit(tri).trend_ + + origins = list(tri.origin.astype(str)) + assert trend_.values[0, 0, origins.index("2020"), 0] == pytest.approx(1.0) + assert trend_.values[0, 0, origins.index("2019"), 0] == pytest.approx(1.10) + assert trend_.values[0, 0, origins.index("2018"), 0] == pytest.approx(1.21) + + +def _quarterly_origin_triangle(): + """ + The smallest Triangle with a quarterly origin axis: the four quarters of 2017, + each valued through 2017Q4. Enough for a bare year to span several origin + periods, which is all the base-period resolution tests need. + """ + quarters = pd.period_range("2017Q1", "2017Q4", freq="Q") + rows = [ + (origin.to_timestamp(), valuation.to_timestamp(), 1.0) + for i, origin in enumerate(quarters) + for valuation in quarters[i:] + ] + return cl.Triangle( + pd.DataFrame(rows, columns=["origin", "valuation", "paid"]), + origin="origin", + development="valuation", + columns=["paid"], + cumulative=True, + ) + + +def test_trend_base_period_coarser_than_grain_takes_earliest(): + """ + Against a quarterly origin axis a bare year spans four periods, which carry + different factors. It resolves to the earliest of them, so 2017 anchors on + 2017Q1 rather than on 2017Q4. + """ + tri = _quarterly_origin_triangle() + origins = list(tri.origin.astype(str)) + + coarse = ( + cl + .Trend(0.05, axis="origin", full_triangle=True, base_period=2017) + .fit(tri) + .trend_.values + ) + assert coarse[0, 0, origins.index("2017Q1"), 0] == pytest.approx(1.0) + assert coarse[0, 0, origins.index("2017Q4"), 0] != pytest.approx(1.0) + + exact = ( + cl + .Trend(0.05, axis="origin", full_triangle=True, base_period="2017Q3") + .fit(tri) + .trend_.values ) + assert exact[0, 0, origins.index("2017Q3"), 0] == pytest.approx(1.0) + assert exact[0, 0, origins.index("2017Q1"), 0] != pytest.approx(1.0) + + +@pytest.mark.parametrize("axis", ["origin", "valuation"]) +def test_trend_base_period_outside_the_triangle_raises(raa, axis): + """A base period that matches nothing should say so rather than yield NaN.""" + with pytest.raises(ValueError, match="does not match"): + cl.Trend(0.05, axis=axis, base_period=1800).fit(raa) + + +@pytest.mark.parametrize("full_triangle", [False, True]) +@pytest.mark.parametrize("base_period", [None, 1981]) +def test_trend_preserves_backend(raa, full_triangle, base_period): + """ + The full grid is built in numpy internally, so the fitted factors still have + to come back on whatever backend was passed in. + """ + trend_ = ( + cl + .Trend(0.05, full_triangle=full_triangle, base_period=base_period) + .fit(raa) + .trend_ + ) + assert trend_.array_backend == raa.array_backend + + +def test_trend_new_params_survive_sklearn_clone(): + """ + sklearn reads constructor arguments back off identically named attributes, + so a mismatch breaks get_params, clone, Pipeline and serialization. + """ + estimator = cl.Trend(0.05, base_period=1981, full_triangle=True) + params = estimator.get_params() + assert params["base_period"] == 1981 + assert params["full_triangle"] is True + assert clone(estimator).get_params() == params diff --git a/chainladder/adjustments/trend.py b/chainladder/adjustments/trend.py index 425b0f47..70bc66a8 100644 --- a/chainladder/adjustments/trend.py +++ b/chainladder/adjustments/trend.py @@ -1,10 +1,26 @@ +""" +Support trending of model inputs. +""" + # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from __future__ import annotations +import numpy as np +import pandas as pd from sklearn.base import BaseEstimator, TransformerMixin from chainladder.core.io import EstimatorIO +from typing import Literal, TYPE_CHECKING, TypeAlias + +if TYPE_CHECKING: # pragma: no cover + from chainladder import Triangle + from collections.abc import Sequence + from pandas import Period, Timestamp + +DateBound: TypeAlias = str | pd.Period | pd.Timestamp | None + class Trend(BaseEstimator, TransformerMixin, EstimatorIO): """ @@ -20,8 +36,13 @@ class Trend(BaseEstimator, TransformerMixin, EstimatorIO): 5% decrease should be stated as -0.05 dates: list of date-likes A list-like of (start, end) dates to correspond to the `trend` list. - axis: str (options: [‘origin’, ‘valuation’]) + axis : {'origin', 'valuation', 2, -2} The axis on which to apply the trend + base_period: int or str, optional + The period whose factor is set to 1.0, so that ``trend_`` states every + other period relative to it. + full_triangle: bool (default=False) + When set to ``True``, returns a full triangle of trend factors. Attributes ---------- @@ -155,20 +176,237 @@ class Trend(BaseEstimator, TransformerMixin, EstimatorIO): 29278236 26370689 + By default, trend factors are set relative the most recent origin or valuation period. + This can be changed via ``base_period``, which sets the trend factors to be relative + to that period. + + .. testcode:: + + tri = cl.load_sample("raa") + rebased = cl.Trend(0.05, axis="origin", base_period=1981).fit(tri) + print(np.round(rebased.trend_, 4)) + + .. testoutput:: + :options: +NORMALIZE_WHITESPACE + + 12 24 36 48 60 72 84 96 108 120 + 1981 1.0000 1.0000 1.0000 1.0000 1.0000 1.0000 1.0000 1.0000 1.0000 1.0 + 1982 0.9524 0.9524 0.9524 0.9524 0.9524 0.9524 0.9524 0.9524 0.9524 NaN + 1983 0.9070 0.9070 0.9070 0.9070 0.9070 0.9070 0.9070 0.9070 NaN NaN + 1984 0.8638 0.8638 0.8638 0.8638 0.8638 0.8638 0.8638 NaN NaN NaN + 1985 0.8227 0.8227 0.8227 0.8227 0.8227 0.8227 NaN NaN NaN NaN + 1986 0.7835 0.7835 0.7835 0.7835 0.7835 NaN NaN NaN NaN NaN + 1987 0.7462 0.7462 0.7462 0.7462 NaN NaN NaN NaN NaN NaN + 1988 0.7107 0.7107 0.7107 NaN NaN NaN NaN NaN NaN NaN + 1989 0.6768 0.6768 NaN NaN NaN NaN NaN NaN NaN NaN + 1990 0.6446 NaN NaN NaN NaN NaN NaN NaN NaN NaN + + Toggle ``full_triangle=True`` to return a full triangle of trend factors. + + .. testcode:: + + print(np.round(cl.Trend(0.05, axis="valuation", full_triangle=True).fit(tri).trend_, 4)) + + .. testoutput:: + :options: +NORMALIZE_WHITESPACE + + 12 24 36 48 60 72 84 96 108 120 + 1981 1.5513 1.4775 1.4071 1.3401 1.2763 1.2155 1.1576 1.1025 1.0500 1.0000 + 1982 1.4775 1.4071 1.3401 1.2763 1.2155 1.1576 1.1025 1.0500 1.0000 0.9524 + 1983 1.4071 1.3401 1.2763 1.2155 1.1576 1.1025 1.0500 1.0000 0.9524 0.9070 + 1984 1.3401 1.2763 1.2155 1.1576 1.1025 1.0500 1.0000 0.9524 0.9070 0.8638 + 1985 1.2763 1.2155 1.1576 1.1025 1.0500 1.0000 0.9524 0.9070 0.8638 0.8227 + 1986 1.2155 1.1576 1.1025 1.0500 1.0000 0.9524 0.9070 0.8638 0.8227 0.7835 + 1987 1.1576 1.1025 1.0500 1.0000 0.9524 0.9070 0.8638 0.8227 0.7835 0.7462 + 1988 1.1025 1.0500 1.0000 0.9524 0.9070 0.8638 0.8227 0.7835 0.7462 0.7107 + 1989 1.0500 1.0000 0.9524 0.9070 0.8638 0.8227 0.7835 0.7462 0.7107 0.6768 + 1990 1.0000 0.9524 0.9070 0.8638 0.8227 0.7835 0.7462 0.7107 0.6768 0.6446 + """ - def __init__(self, trends=0.0, dates=None, axis="origin"): + # Fitted attributes. + trend_: Triangle + + def __init__( + self, + trends: float | int | list[float | int] = 0.0, + dates: tuple[DateBound, DateBound] + | list[tuple[DateBound, DateBound]] + | None = None, + axis: Literal["origin", "valuation", 2, -2] = "origin", + base_period: int | str | None = None, + full_triangle: bool = False, + ): self.trends = trends self.dates = dates - self.axis = axis + self.axis: Literal["origin", "valuation", 2, -2] = axis + self.base_period = base_period + self.full_triangle = full_triangle - def fit(self, X, y=None, sample_weight=None): + def _accumulate( + self, + obj: Triangle, + trends: Sequence[float | int], + dates: Sequence[tuple[DateBound, DateBound]], + default_start: Timestamp, + ) -> Triangle: + """ + Apply each trend segment to ``obj`` in turn, compounding the segments. + + Parameters + ---------- + obj: Triangle + The Triangle the segments are applied to. Passing a Triangle of 1s + yields the factors themselves; passing data yields trended data. + trends: sequence of float + The annual trend of each segment, expressed as a decimal. + dates: Sequence[tuple[DateBound, DateBound]] + The ``(start, end)`` bounds of each segment, positionally paired with + ``trends``. Either bound may be None. + default_start: Timestamp + The default starting date of a segment, if the segment has no starting date. + + Returns + ------- + Triangle + ``obj`` multiplied by the compounded factors of every segment. + """ + for i, trend in enumerate(trends): + start = default_start if dates[i][0] is None else dates[i][0] + obj = obj.trend( + trend=trend, + axis=self.axis, + start=start, + end=dates[i][1], + ) + return obj + + @staticmethod + def _grid(X: Triangle) -> Triangle: # noqa sklearn convention + """ + Fill X with 1s, including lower triangle NaNs, creating a full triangle of 1s. + + Parameters + ---------- + X : Triangle, + The triangle to fill. + + Returns + ------- + Triangle + A full triangle of 1s, on the numpy backend. + + Notes + ----- + The grid is densified because it is a full rectangle: every cell is + occupied, so a COO array would store a coordinate per cell and gain + nothing. Trending it on the sparse backend measures several times slower + than trending it dense. + """ + grid = X.copy().set_backend("numpy") + grid.valuation_date = grid.valuation.max() + return (grid * 0 + 1).fillna(1) + + def _latest_period( + self, + X: Triangle, # noqa sklearn convention + ) -> Period: + """ + The latest period of the trended axis, which the estimator normalizes on + when no ``base_period`` is given. + + Parameters + ---------- + X: Triangle + The Triangle being fit. + + Returns + ------- + Period + The last origin when trending on origin, otherwise the period of X's + valuation date. + """ + if self.axis in ["origin", 2, -2]: + return X.origin[-1] + return X.valuation_date.to_period("M") + + def _get_rebasing_factor( + self, + factors: Triangle, + base_period: int | str | Period, + ) -> float | int: + """ + Calculate a scalar used to adjust a triangle of trend factors to the period + specified by base_period. + + Parameters + ---------- + factors: Triangle + A set of trend factors, prior to base period adjustment. Must be a full triangle. + base_period: int, str or Period + The period which the trend factors are relative to. + + Returns + ------- + float | int + The factor at ``base_period``. + + Raises + ------ + ValueError + If ``base_period`` matches no period on the axis being trended. + """ + # A Period's str() drops its frequency (a fiscal Y-JUN period and a + # calendar Y-DEC period both stringify to e.g. "2020"), so an + # already-built Period -- notably the default anchor from + # _latest_period, which carries the triangle's real origin freq -- + # must be used as-is rather than round-tripped through str(). + period = ( + base_period + if isinstance(base_period, pd.Period) + else pd.Period(str(base_period)) + ) + if not isinstance(period, pd.Period): + raise ValueError(f"base_period {base_period!r} could not be parsed.") # noqa pandas-stubs + lo, hi = period.to_timestamp(how="s"), period.to_timestamp(how="e") + values = np.asarray(factors.values)[0, 0] + if self.axis in ["origin", 2, -2]: + starts = factors.origin.to_timestamp(how="s") + matches = np.where((starts >= lo) & (starts <= hi))[0] + position = (int(matches[0]), 0) if len(matches) else None + axis_label = "origin" + first, last = factors.origin[0], factors.origin[-1] + # Case valuation. + else: + valuation = pd.DatetimeIndex(np.array(factors.valuation)) + matches = np.argwhere( + ((valuation >= lo) & (valuation <= hi)).reshape( + factors.shape[-2:], order="f" + ) + ) + position = tuple(matches[0]) if len(matches) else None + axis_label = "valuation" + first, last = f"{valuation.min():%Y-%m}", f"{valuation.max():%Y-%m}" + + if position is None: + raise ValueError( + f"base_period {base_period!r} does not match any {axis_label} period. " # noqa pandas-stubs + f"{axis_label.capitalize()}s run {first} through {last}." + ) + return float(values[position]) + + def fit( + self, + X: Triangle, # noqa sklearn convention + y: None = None, # noqa (needed for Pipeline) + sample_weight: Triangle | None = None, # noqa + ) -> Trend: """ Fit the model with X. Parameters ---------- - X: Triangle-like + X: Triangle Data to which the model will be applied. y: Ignored sample_weight: Ignored @@ -178,19 +416,31 @@ def fit(self, X, y=None, sample_weight=None): self: object Returns the instance itself. """ - trends = self.trends if type(self.trends) is list else [self.trends] + trends = self.trends if isinstance(self.trends, list) else [self.trends] dates = [(None, None)] if self.dates is None else self.dates - dates = [dates] if type(dates) is not list else dates + dates = dates if isinstance(dates, list) else [dates] if type(dates[0]) is not tuple: raise AttributeError( "Dates must be specified as a tuple of start and end dates" ) - self.trend_ = X.copy() - for i, trend in enumerate(trends): - self.trend_ = self.trend_.trend( - trend, self.axis, start=dates[i][0], end=dates[i][1] - ) - self.trend_ = self.trend_ / X + grid = self._grid(X) + factors = self._accumulate( + obj=grid, + trends=trends, + dates=dates, + default_start=( + grid.valuation_date if self.full_triangle else X.valuation_date + ), + ) + anchor = ( + self._latest_period(X) if self.base_period is None else self.base_period + ) + self.trend_ = factors / self._get_rebasing_factor(factors, anchor) + if not self.full_triangle: + self.trend_ = self.trend_ * (X / X) + self.trend_.valuation_date = X.valuation_date + if X.array_backend != self.trend_.array_backend: + self.trend_ = self.trend_.set_backend(X.array_backend) return self def transform(self, X, y=None, sample_weight=None): diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index a496d7ed..beaa2375 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -23,7 +23,7 @@ except ImportError: db = None -from typing import cast, Optional, TYPE_CHECKING +from typing import cast, Literal, Optional, TYPE_CHECKING if TYPE_CHECKING: from pandas import DataFrame, Series @@ -1898,7 +1898,7 @@ def grain(self, grain="", trailing=False, inplace=False): def trend( self, trend=0.0, - axis="origin", + axis: Literal["origin", "valuation", 2, -2] = "origin", start=None, end=None, ultimate_lag=None, @@ -1913,7 +1913,7 @@ def trend( ---------- trend : float The annual amount of the trend. Use 1/(1+trend)-1 to detrend. - axis : str (options: ['origin', 'valuation']) + axis : {'origin', 'valuation', 2, -2} The axis on which to apply the trend start: date The start date from which trend should be calculated. If none is