diff --git a/chainladder/core/base.py b/chainladder/core/base.py index d15d6cd16..21e5a1cb4 100644 --- a/chainladder/core/base.py +++ b/chainladder/core/base.py @@ -32,7 +32,6 @@ from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: - from chainladder import Triangle from pandas import DataFrame, Series from numpy.typing import ArrayLike from pandas.core.indexes.datetimes import DatetimeIndex @@ -666,12 +665,16 @@ def get_array_module( + ", ".join([*modules]) ) from e - def _auto_sparse(self) -> Triangle: + def _auto_sparse(self) -> None: """ Auto sparsifies at 30Mb or more and 20% density or less. + + Returns + ------- + None """ if not options.AUTO_SPARSE: - return self + return None n = np.prod(list(self.shape) + [8 / 1e6]) if ( self.array_backend == "numpy" @@ -683,7 +686,7 @@ def _auto_sparse(self) -> Triangle: self.values.density < 0.2 and n > 30 ): self.set_backend("numpy", inplace=True) - return self + return None @property def valuation(self): diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 56e300999..24afdc236 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -1313,7 +1313,7 @@ def agg_func( ) obj._set_slicers() if auto_sparse: - obj = obj._auto_sparse() + obj._auto_sparse() obj.values = cast("BackendArray", num_to_nan(obj.values)) if not keepdims and obj.shape == (1, 1, 1, 1): return obj.values[0, 0, 0, 0] @@ -1380,7 +1380,7 @@ def aggregate(i, obj, axis, v): obj.odims = odims.values obj._set_slicers() if auto_sparse: - obj = obj._auto_sparse() + obj._auto_sparse() return obj set_method(cls=cls, func=agg_func, k=k) diff --git a/chainladder/core/tests/test_grain.py b/chainladder/core/tests/test_grain.py index df395c9f3..c732f261d 100644 --- a/chainladder/core/tests/test_grain.py +++ b/chainladder/core/tests/test_grain.py @@ -10,8 +10,19 @@ from chainladder import Triangle -def test_grain(qtr): - # this test is dense only in practice, since grain() applies auto_sparse, which is True by default +def test_grain(qtr: Triangle) -> None: + """ + Tests quarterly to annual grain conversion + + Parameters + ---------- + qtr : Triangle + The qtr sample dataset Triangle. + + Returns + ------- + None + """ actual = qtr.iloc[0, 0].grain("OYDY") nan = np.nan expected = np.array([ @@ -28,7 +39,7 @@ def test_grain(qtr): [21, 422, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan], [13, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan, nan], ]) - np.testing.assert_array_equal(actual.values[0, 0, :, :], expected) + np.testing.assert_array_equal(actual.to_frame().values, expected) def test_grain_returns_valid_tri(qtr): diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index cd3515747..be79d12b5 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -472,10 +472,9 @@ def test_dev_to_val_inplace_on_val_tri_returns_self(qtr: Triangle) -> None: """ val_tri = qtr.dev_to_val() assert val_tri.is_val_tri - - result = val_tri.dev_to_val(inplace=True) - - assert result is val_tri + new_val_tri = val_tri.copy() + new_val_tri.dev_to_val(inplace=True) + assert new_val_tri == val_tri def test_valdev2(qtr): @@ -491,8 +490,25 @@ def test_valdev3(qtr): def test_valdev4(raa: Triangle) -> None: - lhs = raa.dev_to_val()[raa.dev_to_val().development >= "1989"].values.flatten() - rhs = raa[raa.valuation >= "1989"].dev_to_val().values.flatten() + """ + Tests slicing using the development axis of a valuation Triangle + + Parameters + ---------- + raa : Triangle + The raa sample dataset Triangle. + + Returns + ------- + None + """ + lhs = ( + raa + .dev_to_val()[raa.dev_to_val().development >= "1989"] + .to_frame() + .values.flatten() + ) + rhs = raa[raa.valuation >= "1989"].dev_to_val().to_frame().values.flatten() np.testing.assert_array_equal(lhs[~np.isnan(lhs)], rhs[~np.isnan(rhs)]) @@ -1011,8 +1027,8 @@ def test_auto_sparse_disabled_returns_self(prism: Triangle) -> None: dense = small_prism.set_backend("numpy") cl.options.set_option("AUTO_SPARSE", False) try: - result = dense._auto_sparse() - assert result is dense + result = dense.copy() + result._auto_sparse() assert result.array_backend == "numpy" finally: cl.options.reset_option("AUTO_SPARSE") @@ -1165,9 +1181,9 @@ def test_auto_sparse_converts_numpy_to_sparse(prism: Triangle) -> None: dense = small_prism.set_backend("numpy") assert dense.array_backend == "numpy" - result = dense._auto_sparse() + result = dense.copy() + result._auto_sparse() - assert result is dense assert result.array_backend == "sparse" @@ -3433,7 +3449,7 @@ def test_feed_age_into_valuation_raises() -> None: def test_fill(clrd: Triangle) -> None: """ - ``Fill`` method works as intended + ``fill`` method works as intended """ fill_tri = clrd.iloc[2:4, 4:6].fill(100) # (10 + 1) * 10 / 2 is the number of valid values in one single triangle @@ -3445,8 +3461,33 @@ def test_fill(clrd: Triangle) -> None: def test_full_fill(raa: Triangle) -> None: """ - ``Fill`` method works as intended on full triangle + ``fill`` method works as intended on full triangle """ full_tri = cl.Chainladder().fit(raa).full_triangle_ fill_full_tri = full_tri.fill(200) assert np.all(fill_full_tri.values == np.broadcast_to([200], (1, 1, 10, 12))) + + +def test_dev_val_inplace(raa: Triangle) -> None: + """ + ``dev_to_val`` and ``val_to_dev`` methods respect ``inplace`` + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + raa_copy = raa.copy() + raa_copy2 = raa.copy() + assert raa_copy2.dev_to_val(True) is None + assert raa_copy == raa + assert raa_copy2 == raa_copy.dev_to_val() + raa_copy = raa.copy().dev_to_val() + raa_copy2 = raa_copy.copy() + assert raa_copy2.val_to_dev(True) is None + assert raa_copy2 == raa + assert raa_copy == raa_copy2.dev_to_val() diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 38efe3324..bac96c082 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -722,7 +722,7 @@ def __init__( if not options.AUTO_SPARSE or array_backend == "cupy": self.set_backend(backend=array_backend, inplace=True) else: - self = self._auto_sparse() + self._auto_sparse() self._set_slicers() # Deal with special properties if self.is_pattern: @@ -1599,37 +1599,46 @@ def _dstep(self): "Y": {"Y": 1}, } - def _val_dev(self, sign, inplace=False): + def _val_dev(self, sign: int) -> None: + """ + Helper function for mutating triangle between development lag + and valuation. + + Parameters + ---------- + sign : int (1 or -1) + Whether to mutate the existing Triangle from development lag + to valuation (1) or from valuation to development lag (-1) + + Returns + ------- + None + """ backend = self.array_backend - obj = self.set_backend("sparse") - if not inplace: - obj.values = obj.values.copy() - scale = self._dstep()[obj.development_grain][obj.origin_grain] - offset = np.arange(obj.shape[-2]) * scale + self.set_backend("sparse", True) + scale = self._dstep()[self.development_grain][self.origin_grain] + offset = np.arange(self.shape[-2]) * scale min_slide = -offset.max() - if (obj.values.coords[-2] == np.arange(1)).all(): + if (self.values.coords[-2] == np.arange(1)).all(): # Unique edge case #239 offset = offset[-1:] * sign - offset = offset[obj.values.coords[-2]] * sign # [0] - obj.values.coords[-1] = obj.values.coords[-1] + offset - ddims = obj.valuation[obj.valuation <= obj.valuation_date] + offset = offset[self.values.coords[-2]] * sign # [0] + self.values.coords[-1] = self.values.coords[-1] + offset + ddims = self.valuation[self.valuation <= self.valuation_date] ddims = len(ddims.drop_duplicates()) if ddims == 1 and sign == -1: - ddims = len(obj.odims) - if obj.values.density > 0: - if obj.values.coords[-1].min() < 0: - obj.values.coords[-1] = obj.values.coords[-1] - min( - obj.values.coords[-1].min(), min_slide + ddims = len(self.odims) + if self.values.density > 0: + if self.values.coords[-1].min() < 0: + self.values.coords[-1] = self.values.coords[-1] - min( + self.values.coords[-1].min(), min_slide ) - ddims = np.max([np.max(obj.values.coords[-1]) + 1, ddims]) - obj.values.shape = tuple(list(obj.shape[:-1]) + [ddims]) - if not options.AUTO_SPARSE or backend == "cupy": - obj = obj.set_backend(backend) - else: - obj = obj._auto_sparse() - return obj + ddims = np.max([np.max(self.values.coords[-1]) + 1, ddims]) + self.values.shape = tuple(list(self.shape[:-1]) + [ddims]) + self.set_backend(backend, True) + return None - def dev_to_val(self, inplace=False): + def dev_to_val(self, inplace: bool = False) -> Triangle | None: """ Converts triangle from a development lag triangle to a valuation triangle. @@ -1642,8 +1651,12 @@ def dev_to_val(self, inplace=False): Returns ------- - Triangle - Updated instance of the triangle with valuation periods. + Triangle | None + If ``inplace=False``, returns new instance of ``Triangle`` with + valuation periods. + + If ``inplace=True``, ``Triangle`` is mutated in place and ``None`` + is returned Examples -------- @@ -1688,34 +1701,30 @@ def dev_to_val(self, inplace=False): 2012 NaN NaN NaN NaN NaN 5102.0 9650.0 2013 NaN NaN NaN NaN NaN NaN 6283.0 """ - if self.is_val_tri: - if inplace: - return self - else: - return self.copy() - is_cumulative = self.is_cumulative - if self.is_full: - if is_cumulative: - obj = self.cum_to_incr(inplace=inplace) - else: - obj = self.copy() - if self.is_ultimate: + if inplace: + if self.is_val_tri: + return None + obj = self.cum_to_incr() + if self.is_full and self.is_ultimate: ultimate = obj.iloc[..., -1:] obj = obj.iloc[..., :-1] - else: - obj = self - obj = obj._val_dev(1, inplace) - ddims = obj.valuation[obj.valuation <= obj.valuation_date] - obj.ddims = ddims.drop_duplicates().sort_values() - if self.is_full: - if self.is_ultimate: + obj._val_dev(1) + ddims = obj.valuation[obj.valuation <= obj.valuation_date] + obj.ddims = ddims.drop_duplicates().sort_values() + if self.is_full and self.is_ultimate: ultimate.ddims = pd.DatetimeIndex(ultimate.valuation[0:1]) obj = concat((obj, ultimate), -1) - if is_cumulative: - obj = obj.incr_to_cum(inplace=inplace) - return obj + if self.is_cumulative: + obj = obj.incr_to_cum() + self.values = obj.values + self.ddims = obj.ddims + return None + else: + obj = self.copy() + obj.dev_to_val(True) + return obj - def val_to_dev(self, inplace=False): + def val_to_dev(self, inplace: bool = False) -> Triangle | None: """ Converts triangle from a valuation triangle to a development lag triangle. @@ -1728,7 +1737,12 @@ def val_to_dev(self, inplace=False): Returns ------- - Updated instance of triangle with development lags + Triangle | None + If ``inplace=False``, returns new instance of ``Triangle`` with + development lags. + + If ``inplace=True``, ``Triangle`` is mutated in place and ``None`` + is returned Examples -------- @@ -1756,30 +1770,34 @@ def val_to_dev(self, inplace=False): 2012 5102.0 9650.0 NaN NaN NaN NaN NaN 2013 6283.0 NaN NaN NaN NaN NaN NaN """ - if not self.is_val_tri: - if inplace: - return self + if inplace: + if not self.is_val_tri: + return None + obj = self.copy() + if self.is_ultimate and self.shape[-1] > 1: + ultimate = obj.iloc[..., -1:] + ultimate.ddims = np.array([9999]) + obj = obj.iloc[..., :-1] + obj._val_dev(-1) + val_0 = obj.valuation[0] + if self.ddims.shape[-1] == 1 and self.ddims[0] == self.valuation_date: + origin_0 = pd.to_datetime(obj.odims[-1]) else: - return self.copy() - if self.is_ultimate and self.shape[-1] > 1: - ultimate = self.iloc[..., -1:] - ultimate.ddims = np.array([9999]) - obj = self.iloc[..., :-1]._val_dev(-1, inplace) - else: - obj = self.copy()._val_dev(-1, inplace) - val_0 = obj.valuation[0] - if self.ddims.shape[-1] == 1 and self.ddims[0] == self.valuation_date: - origin_0 = pd.to_datetime(obj.odims[-1]) + origin_0 = pd.to_datetime(obj.odims[0]) + lag_0 = (val_0.year - origin_0.year) * 12 + val_0.month - origin_0.month + 1 + scale = self._dstep()["M"][obj.development_grain] + obj.ddims = np.arange(obj.values.shape[-1]) * scale + lag_0 + if self.is_ultimate and self.shape[-1] > 1: + prune = obj[obj.origin == obj.origin.max()] + obj = obj.iloc[..., : (prune.valuation <= prune.valuation_date).sum()] + obj = concat((obj, ultimate), -1) + self.values = obj.values + self.ddims = obj.ddims + return None else: - origin_0 = pd.to_datetime(obj.odims[0]) - lag_0 = (val_0.year - origin_0.year) * 12 + val_0.month - origin_0.month + 1 - scale = self._dstep()["M"][obj.development_grain] - obj.ddims = np.arange(obj.values.shape[-1]) * scale + lag_0 - prune = obj[obj.origin == obj.origin.max()] - if self.is_ultimate and self.shape[-1] > 1: - obj = obj.iloc[..., : (prune.valuation <= prune.valuation_date).sum()] - obj = concat((obj, ultimate), -1) - return obj + obj = self.copy() + obj.val_to_dev(True) + return obj def grain(self, grain="", trailing=False, inplace=False): """