diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 4e47728c..8fe5a08d 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -45,6 +45,79 @@ def test_link_ratio(raa, atol): raa.link_ratio * raa.iloc[:, :, :-1, :-1].values - raa.values[:, :, :-1, 1:] ).sum().sum() < atol + +def test_link_ratio_sets_pattern_metadata(raa: Triangle) -> None: + """ + When called on a Triangle that is not already a pattern, link_ratio should + return a new object with is_pattern=True, is_cumulative=False, and the + length of the origin and development axis reduced by 1. + + Parameters + ---------- + raa : Triangle + The RAA sample dataset Triangle. + + Returns + ------- + None + """ + assert not raa.is_pattern + assert not raa.is_full + + lr = raa.link_ratio + + assert lr.is_pattern + assert not lr.is_cumulative + # Both the origin and development axes shrink by one: development because + # ratios need adjacent pairs of columns, and origin since the last period only has 1 value. + assert lr.shape == (raa.shape[0], raa.shape[1], raa.shape[2] - 1, raa.shape[3] - 1) + + +def test_link_ratio_converts_zero_ratios_to_nan() -> None: + """ + obj.values = num_to_nan(obj.values) in link_ratio should turn any literal + zero age-to-age ratio into NaN rather than leaving it as 0. This is an intentional + design choice to improve memory efficiency - see GH#181: + + https://github.com/casact/chainladder-python/issues/181 + + Returns + ------- + None + """ + raa = cl.load_sample("raa").set_backend("numpy") + # Force a literal zero into the next-development cell so the ratio itself + # computes to exactly 0.0, distinct from the ordinary NaNs already in raa. + raa.values[0, 0, 0, 1] = 0.0 + + lr = raa.link_ratio.set_backend("numpy") + + assert np.isnan(lr.values[0, 0, 0, 0]) + + +def test_link_ratio_on_pattern_returns_self(raa: Triangle) -> None: + """ + When a Triangle already carries is_pattern=True (it is already a set of + link ratios / development patterns), link_ratio should short-circuit and + return the exact same object rather than recomputing ratios from it. + + Parameters + ---------- + raa : Triangle + The RAA sample dataset Triangle. + + Returns + ------- + None + """ + lr = raa.link_ratio + assert lr.is_pattern + + result = lr.link_ratio + + assert result is lr + + def test_align_pattern(raa, atol): with pytest.raises(ValueError): raa.align_pattern(raa) @@ -170,6 +243,28 @@ def test_trend(raa, atol): assert abs((raa.trend(0.05).trend((1 / 1.05) - 1) - raa).sum().sum()) < 1e-5 +def test_trend_invalid_axis_raises(raa: Triangle) -> None: + """ + trend() only supports trending along the origin or valuation axes + (accepting either the string names or their positional equivalents, 2 and + -2). Any other axis value should raise ValueError rather than silently + doing something unexpected. + + Parameters + ---------- + raa : Triangle + The RAA sample dataset Triangle. + + Returns + ------- + None + """ + with pytest.raises( + ValueError, match="Only origin and valuation axes are supported for trending" + ): + raa.trend(0.05, axis="development") + + def test_shift(qtr): x = qtr.iloc[0, 0] xp = x.get_array_module() @@ -230,10 +325,151 @@ def test_origin_and_value_setters(raa): ) +def test_index_setter_with_dataframe(clrd: Triangle) -> None: + """ + Assigning a pandas DataFrame to Triangle.index should replace kdims with + the DataFrame's values, replace key_labels with its columns, and rebuild + the slicers so that .loc/.iloc reflect the new labels. + + Parameters + ---------- + clrd : Triangle + The clrd sample dataset Triangle. + + Returns + ------- + None + """ + tri = clrd.iloc[:3] + new_index = pd.DataFrame({"Company": ["A", "B", "C"]}) + + tri.index = new_index + + assert tri.key_labels == ["Company"] + np.testing.assert_array_equal(tri.kdims, new_index.values) + # _set_slicers() must have rebuilt .loc against the new key label. + assert tri.loc["A"].kdims.tolist() == [["A"]] + + +def test_index_setter_length_mismatch_raises(clrd: Triangle) -> None: + """ + Attempt to reassign index with a DataFrame of incorrect row count. Raise an error. + + Parameters + ---------- + clrd : Triangle + The clrd sample dataset Triangle. + + Returns + ------- + None + """ + tri = clrd.iloc[:3] + mismatched_index = pd.DataFrame({"Company": ["A", "B"]}) + + with pytest.raises(ValueError): + tri.index = mismatched_index + + +def test_index_setter_non_dataframe_raises(clrd: Triangle) -> None: + """ + Triangle.index only accepts a pandas DataFrame. Assigning any other type + (e.g. a list) should raise a TypeError rather than being coerced. + + Parameters + ---------- + clrd : Triangle + The clrd sample dataset Triangle. + + Returns + ------- + None + """ + tri = clrd.iloc[:3] + + with pytest.raises(TypeError, match="index must be a pandas DataFrame"): + tri.index = ["A", "B", "C"] + + +def test_set_index_inplace(clrd: Triangle) -> None: + """ + Triangle.set_index(value, inplace=True) should mutate the calling + Triangle's index via the index setter and return that same object. + + Parameters + ---------- + clrd : Triangle + The clrd sample dataset Triangle. + + Returns + ------- + None + """ + tri = clrd.iloc[:3] + new_index = pd.DataFrame({"Company": ["A", "B", "C"]}) + + result = tri.set_index(new_index, inplace=True) + + assert result is tri + assert tri.key_labels == ["Company"] + np.testing.assert_array_equal(tri.kdims, new_index.values) + + +def test_set_index_not_inplace(clrd: Triangle) -> None: + """ + Triangle.set_index(value) with the default inplace=False should operate + on a copy: it returns a distinct Triangle with the new index applied, + leaving the original Triangle's kdims/key_labels untouched. + + Parameters + ---------- + clrd : Triangle + The clrd sample dataset Triangle. + + Returns + ------- + None + """ + tri = clrd.iloc[:3] + original_kdims = tri.kdims.copy() + original_key_labels = list(tri.key_labels) + new_index = pd.DataFrame({"Company": ["A", "B", "C"]}) + + result = tri.set_index(new_index) + + assert result is not tri + assert result.key_labels == ["Company"] + np.testing.assert_array_equal(result.kdims, new_index.values) + assert tri.key_labels == original_key_labels + np.testing.assert_array_equal(tri.kdims, original_kdims) + + def test_valdev1(qtr): assert qtr.dev_to_val().val_to_dev() == qtr +def test_dev_to_val_inplace_on_val_tri_returns_self(qtr: Triangle) -> None: + """ + Execute dev_to_val() on a triangle that is already a valuation triangle. Should + leave the triangle unchanged. + + Parameters + ---------- + qtr : Triangle + The qtr sample dataset Triangle. + + Returns + ------- + 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 + + def test_valdev2(qtr): a = qtr.dev_to_val().grain("OYDY").val_to_dev() b = qtr.grain("OYDY") @@ -429,6 +665,67 @@ def test_auto_sparse_disabled_returns_self(prism: Triangle) -> None: cl.options.reset_option("AUTO_SPARSE") +def test_init_defaults_array_backend_to_option() -> None: + """ + When array_backend is not passed to the constructor (i.e. it is None), + Triangle.__init__ should fall back to cl.options.ARRAY_BACKEND rather than + a hardcoded default. + + Returns + ------- + None + """ + df = pd.DataFrame({ + "origin": [2000, 2000, 2001, 2001], + "development": [2000, 2001, 2001, 2002], + "value": [100, 200, 300, 400], + }) + cl.options.set_option("AUTO_SPARSE", False) + cl.options.set_option("ARRAY_BACKEND", "sparse") + try: + tri = cl.Triangle( + df, + origin="origin", + development="development", + columns="value", + cumulative=True, + ) + assert tri.array_backend == "sparse" + finally: + cl.options.reset_option("AUTO_SPARSE") + cl.options.reset_option("ARRAY_BACKEND") + + +def test_init_calls_set_backend_when_auto_sparse_disabled() -> None: + """ + When cl.options.AUTO_SPARSE is False, Triangle.__init__ should route + through self.set_backend(backend=array_backend) rather than + self._auto_sparse(), landing on exactly the requested backend. + + Returns + ------- + None + """ + df = pd.DataFrame({ + "origin": [2000, 2000, 2001, 2001], + "development": [2000, 2001, 2001, 2002], + "value": [100, 200, 300, 400], + }) + cl.options.set_option("AUTO_SPARSE", False) + try: + tri = cl.Triangle( + df, + origin="origin", + development="development", + columns="value", + cumulative=True, + array_backend="numpy", + ) + assert tri.array_backend == "numpy" + finally: + cl.options.reset_option("AUTO_SPARSE") + + def test_auto_sparse_converts_numpy_to_sparse(prism: Triangle) -> None: """ _auto_sparse() should convert a numpy-backed triangle to sparse when it is @@ -747,6 +1044,104 @@ def test_sort_axis(clrd): ).sort_axis(3) == clrd.sort_axis(1) +def test_sort_axis_columns_reorders_values() -> None: + """ + sort_axis('columns') on a Triangle with out-of-order columns should not + just relabel the vdims, it should also permute the underlying values so + that each column's data stays matched to its (now reordered) label. + + Returns + ------- + None + """ + df = pd.DataFrame( + data={ + "origin": [2020, 2020, 2021, 2021], + "development": [2020, 2021, 2021, 2021], + "reported": [100, 200, 110, 110], + "paid": [50, 100, 60, 60], + } + ) + tr = cl.Triangle( + data=df, + origin="origin", + development="development", + columns=["reported", "paid"], + cumulative=True, + ) + sorted_tr = tr.sort_axis("columns") + + assert list(tr.columns) == ["reported", "paid"] + assert list(sorted_tr.columns) == ["paid", "reported"] + np.testing.assert_array_equal(sorted_tr["paid"].values, tr["paid"].values) + np.testing.assert_array_equal(sorted_tr["reported"].values, tr["reported"].values) + + # If the triangle is already sorted, leave values unchanged. + already_sorted = sorted_tr.sort_axis("columns") + assert list(already_sorted.columns) == ["paid", "reported"] + np.testing.assert_array_equal(already_sorted.values, sorted_tr.values) + + +def test_sort_axis_origin_reorders_values(raa: Triangle) -> None: + """ + sort_axis('origin') on a Triangle with out-of-order origin periods should + not just relabel the odims, it should also permute the underlying values + so that each origin row's data stays matched to its (now reordered) + label. + + Parameters + ---------- + raa : Triangle + The RAA sample dataset Triangle. + + Returns + ------- + None + """ + reversed_tr = raa.iloc[..., ::-1, :] + + assert list(reversed_tr.origin) != list(raa.origin) + + sorted_tr = reversed_tr.sort_axis("origin") + assert list(sorted_tr.origin) == list(raa.origin) + np.testing.assert_array_equal(sorted_tr.values, raa.values) + + # When sorting an already-sorted triangle, leave values unchanged. + already_sorted = sorted_tr.sort_axis("origin") + assert list(already_sorted.origin) == list(sorted_tr.origin) + np.testing.assert_array_equal(already_sorted.values, sorted_tr.values) + + +def test_sort_axis_development_reorders_values(raa: Triangle) -> None: + """ + sort_axis('development') on a Triangle with out-of-order development + periods should not just relabel the ddims, it should also permute the + underlying values so that each development column's data stays matched + to its (now reordered) label. + + Parameters + ---------- + raa : Triangle + The RAA sample dataset Triangle. + + Returns + ------- + None + """ + reversed_tr = raa.iloc[..., ::-1] + + assert list(reversed_tr.development) != list(raa.development) + + sorted_tr = reversed_tr.sort_axis("development") + assert list(sorted_tr.development) == list(raa.development) + np.testing.assert_array_equal(sorted_tr.values, raa.values) + + # When sorting an already-sorted triangle, leave values unchanged. + already_sorted = sorted_tr.sort_axis("development") + assert list(already_sorted.development) == list(sorted_tr.development) + np.testing.assert_array_equal(already_sorted.values, sorted_tr.values) + + def test_shift(raa): assert ( raa.iloc[..., 1:-1, 1:-1] @@ -759,6 +1154,51 @@ def test_shift(raa): ).to_frame(origin_as_datetime=False).fillna(0).sum().sum() == 0 +def test_shift_zero_periods_returns_self(raa: Triangle) -> None: + """ + shift(periods=0) should short-circuit and return the same Triangle + unchanged, rather than performing any lagging. + + Parameters + ---------- + raa : Triangle + The RAA sample dataset Triangle. + + Returns + ------- + None + """ + assert raa.shift(periods=0) is raa + assert raa.shift(periods=0, axis=2) is raa + assert raa.shift(periods=0, axis=3) is raa + + +def test_shift_invalid_axis_raises(raa: Triangle) -> None: + """ + shift() only supports lagging along the origin or development axes + (axis 2 or 3). Requesting the index or columns axis (0/1, or their string + names) should raise AttributeError. + + Parameters + ---------- + raa : Triangle + The RAA sample dataset Triangle. + + Returns + ------- + None + """ + with pytest.raises( + AttributeError, match="Lagging only supported for origin and development axes" + ): + raa.shift(axis="columns") + + with pytest.raises( + AttributeError, match="Lagging only supported for origin and development axes" + ): + raa.shift(axis=0) + + def test_array_protocol2(raa): import numpy as np