Skip to content
Open
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
67 changes: 67 additions & 0 deletions chainladder/core/tests/test_triangle.py
Original file line number Diff line number Diff line change
Expand Up @@ -1633,6 +1633,73 @@ def test_shift_invalid_axis_raises(raa: Triangle) -> None:
raa.shift(axis=0)


def _ffill_source_triangle():
"""Triangle from #1030 - a mix of leading, interior, and not-yet-valued NaNs."""
df = pd.DataFrame({
"origin": [1985, 1985, 1985, 1985, 1986, 1986, 1986, 1987, 1987, 1988],
"development": [1985, 1986, 1987, 1988, 1986, 1987, 1988, 1987, 1988, 1988],
"paid": [500, np.nan, 700, np.nan, np.nan, 1000, 1100, 1200, 1300, np.nan],
})
return cl.Triangle(
data=df,
origin="origin",
development="development",
columns="paid",
cumulative=True,
)


def test_ffill_development_axis() -> None:
"""Interior NaNs fill forward from the last valid value; a leading NaN
(1986 at age 12) and not-yet-valued cells (1986 at 48, 1987 at 36/48)
stay NaN - ffill never writes into a cell that hasn't been valued yet."""
tri = _ffill_source_triangle()
frame = tri.ffill().to_frame(origin_as_datetime=False)
assert frame.loc["1985", 24] == 500.0
assert frame.loc["1985", 48] == 700.0
assert pd.isna(frame.loc["1986", 12])
assert pd.isna(frame.loc["1986", 48])
assert pd.isna(frame.loc["1987", 36])
assert pd.isna(frame.loc["1987", 48])


def test_ffill_origin_axis() -> None:
"""Same triangle, filled down the origin axis instead."""
tri = _ffill_source_triangle()
frame = tri.ffill(axis="origin").to_frame(origin_as_datetime=False)
assert frame.loc["1986", 12] == 500.0
assert frame.loc["1987", 24] == 1300.0
assert frame.loc["1988", 12] == 1200.0
assert pd.isna(frame.loc["1985", 24])
assert pd.isna(frame.loc["1988", 24])
assert pd.isna(frame.loc["1988", 36])


def test_ffill_does_not_mutate_original() -> None:
"""ffill returns a new Triangle; the source is untouched."""
tri = _ffill_source_triangle()
before = tri.to_frame(origin_as_datetime=False).copy()
tri.ffill()
pd.testing.assert_frame_equal(
before, tri.to_frame(origin_as_datetime=False), check_dtype=False
)


def test_ffill_invalid_axis_raises(raa: Triangle) -> None:
"""ffill() only supports the origin and development axes."""
with pytest.raises(
AttributeError,
match="ffill is only supported for the origin and development axes",
):
raa.ffill(axis="columns")

with pytest.raises(
AttributeError,
match="ffill is only supported for the origin and development axes",
):
raa.ffill(axis=0)


def test_array_protocol2(raa):
import numpy as np

Expand Down
109 changes: 109 additions & 0 deletions chainladder/core/triangle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2238,6 +2238,115 @@ def shift(self, periods=-1, axis=3):
else:
return out.shift(periods - 1 if periods > 0 else periods + 1, axis)

def ffill(self, axis=3):
"""Forward-fill missing values along an axis.

Only cells within the observed triangle (see ``nan_triangle``) are
filled; a cell that has not yet been valued is left as-is regardless
of what precedes it along the axis.

Parameters
----------
axis : {2 or 'origin', 3 or 'development'}, default 3
Fill direction.

Returns
-------
Triangle
updated with missing values filled forward

Examples
--------

.. testsetup::

import numpy as np

.. testcode::

import chainladder as cl
tri = cl.Triangle(
data={
'origin': [1985, 1985, 1985, 1985, 1986, 1986, 1986, 1987, 1987, 1988],
'development': [1985, 1986, 1987, 1988, 1986, 1987, 1988, 1987, 1988, 1988],
'paid': [500, np.nan, 700, np.nan, np.nan, 1000, 1100, 1200, 1300, np.nan],
},
origin='origin',
development='development',
columns=['paid'],
cumulative=True,
)
print(tri)

.. testoutput::
:options: +NORMALIZE_WHITESPACE

12 24 36 48
1985 500.0 NaN 700.0 NaN
1986 NaN 1000.0 1100.0 NaN
1987 1200.0 1300.0 NaN NaN
1988 NaN NaN NaN NaN

Fill along the development axis (the default). ``1986`` at age 12
stays missing because nothing precedes it, and ``1986`` at age 48 /
``1987`` at ages 36-48 stay missing because they have not yet been
valued.

.. testcode::

print(tri.ffill())

.. testoutput::
:options: +NORMALIZE_WHITESPACE

12 24 36 48
1985 500.0 500.0 700.0 700.0
1986 NaN 1000.0 1100.0 NaN
1987 1200.0 1300.0 NaN NaN
1988 NaN NaN NaN NaN

Fill along the origin axis. ``1985`` at age 24 stays missing because
nothing precedes it there, and ``1988`` at ages 24-48 stay missing
because they have not yet been valued.

.. testcode::

print(tri.ffill(axis='origin'))

.. testoutput::
:options: +NORMALIZE_WHITESPACE

12 24 36 48
1985 500.0 NaN 700.0 NaN
1986 500.0 1000.0 1100.0 NaN
1987 1200.0 1300.0 NaN NaN
1988 1200.0 NaN NaN NaN
"""
axis = self._get_axis(axis)
if axis < 2:
raise AttributeError(
"ffill is only supported for the origin and development axes"
)
xp = self.get_array_module()
n = self.shape[axis]
columns = (
[self.iloc[..., i : i + 1] for i in range(n)]
if axis == 3
else [self.iloc[..., i : i + 1, :] for i in range(n)]
)
filled = [columns[0]]
for current in columns[1:]:
previous = filled[-1]
is_missing = xp.nan_to_num(current.values) == 0
current = current.copy()
current.values = xp.where(is_missing, previous.values, current.values)
filled.append(current)
out = concat(filled, axis=axis)
# a value can never be carried into a cell that hasn't been valued yet
out.values = out.values * xp.nan_to_num(out.nan_triangle)
out.values = num_to_nan(out.values)
return out

def sort_axis(self, axis):
"""Method to sort a Triangle along a given axis

Expand Down
Loading