From ecf50e72a7161ea4bbd0fdbc23603ed0bd5d910c Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Wed, 8 Jul 2026 11:09:42 -0500 Subject: [PATCH 01/10] FIX: Fix doctest. --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 266dcbf4..65cffdf1 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -76,4 +76,4 @@ jobs: - name: Install dependencies run: uv sync --extra docs - name: Run doctests - run: uv run --directory docs jb build . --builder=custom --custom-builder=doctest + run: uv run --directory docs sphinx-build -b doctest . _build/doctest From c3e839eb9237209c38d1206962b660373126f207 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Wed, 8 Jul 2026 11:44:19 -0500 Subject: [PATCH 02/10] FIX: Fix doctest. --- .github/workflows/pytest.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 65cffdf1..459ec988 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -75,5 +75,11 @@ jobs: python-version: "3.12" - name: Install dependencies run: uv sync --extra docs + - name: Generate autosummary stubs + # sphinx-build decides which documents the doctest builder will test + # *before* the autosummary extension has generated library/generated/*.rst + # (those files are gitignored, so they don't exist on a fresh checkout). + # Without this, every class/method docstring page is silently skipped. + run: uv run --directory docs sphinx-autogen -o library/generated library/api.md - name: Run doctests run: uv run --directory docs sphinx-build -b doctest . _build/doctest From c0c9c57381cb1c22c2a3997619f0bdc15713b5b8 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Wed, 8 Jul 2026 11:59:23 -0500 Subject: [PATCH 03/10] FIX: Fix doctest. --- .github/workflows/pytest.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 459ec988..392cdb6f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -76,10 +76,6 @@ jobs: - name: Install dependencies run: uv sync --extra docs - name: Generate autosummary stubs - # sphinx-build decides which documents the doctest builder will test - # *before* the autosummary extension has generated library/generated/*.rst - # (those files are gitignored, so they don't exist on a fresh checkout). - # Without this, every class/method docstring page is silently skipped. run: uv run --directory docs sphinx-autogen -o library/generated library/api.md - name: Run doctests - run: uv run --directory docs sphinx-build -b doctest . _build/doctest + run: uv run --directory docs jb build . --builder=custom --custom-builder=doctest From 36de87c4bcc7dc802b1e20f3048c1a69015ed038 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 30 Jun 2026 15:22:55 -0500 Subject: [PATCH 04/10] TEST: Add backend tests. --- chainladder/core/tests/test_triangle.py | 172 ++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 4e47728c..1930216e 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -8,7 +8,9 @@ import pandas as pd import pytest +from chainladder.core.common import Common from chainladder.utils.utility_functions import date_delta_adjustment +from chainladder.utils.sparse import COO from io import StringIO @@ -1636,3 +1638,173 @@ def test_to_datetime_uninferrable_format_raises() -> None: columns='value', cumulative=True ) + + +def test_set_backend_via_ldf(raa: Triangle) -> None: + """ + Call set_backend on a fitted estimator. The estimator has no array_backend attribute + of its own, so set_backend resolves the old backend through ldf_ (common.py lines 217-218). + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + dev = cl.Development().fit(raa) + dev.set_backend("sparse", inplace=True, deep=True) + assert dev.ldf_.array_backend == "sparse" + + +def test_set_backend_no_array_backend_raises() -> None: + """ + Call set_backend on an unfitted estimator. The estimator has neither array_backend + nor ldf_, so set_backend raises ValueError (common.py lines 219-220). + + Returns + ------- + None + """ + with pytest.raises(ValueError, match="Unable to determine array backend"): + cl.Development().set_backend("sparse", inplace=True) + + +def test_set_backend_inplace_updates_array_backend_attr(raa: Triangle) -> None: + """ + set_backend(inplace=True) updates self.array_backend when the attribute exists + (common.py line 257, true branch of line 256). When called on an object without + array_backend (e.g. an unfitted estimator) the attribute is not added. + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + # TRUE branch: Triangle has array_backend → it is updated in-place + tri = raa.set_backend("numpy") + tri.set_backend("sparse", inplace=True) + assert tri.array_backend == "sparse" + + # FALSE branch: estimator has no array_backend → attribute is not added + dev = cl.Development().fit(raa.set_backend("numpy")) + dev.set_backend("sparse", inplace=True) + assert not hasattr(dev, "array_backend") + + +def test_set_backend_deep_propagates_to_nested_common(raa: Triangle) -> None: + """ + set_backend with deep=True iterates vars(self) and recursively converts every + nested Common instance (common.py lines 252-255). Without deep=True the nested + attributes keep their original backend; with deep=True all are converted. + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + dev = cl.Development().fit(raa.set_backend("numpy")) + common_attrs = [k for k, v in vars(dev).items() if isinstance(v, Common)] + + # deep=False: nested Triangle attributes keep numpy + dev.set_backend("sparse", inplace=True, deep=False) + assert all(getattr(dev, k).array_backend == "numpy" for k in common_attrs) + + # deep=True: every nested Common attribute is converted to sparse + dev.set_backend("sparse", inplace=True, deep=True) + assert all(getattr(dev, k).array_backend == "sparse" for k in common_attrs) + + +def test_set_backend_inplace_mutates_values(raa: Triangle) -> None: + """ + set_backend(inplace=True) reassigns self.values via the conversion lookup + (common.py lines 248-251). Verify the in-place mutation produces the correct + array type: numpy -> sparse yields a COO, sparse -> numpy yields an ndarray. + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + numpy_tri = raa.set_backend("numpy") + numpy_tri.set_backend("sparse", inplace=True) + assert isinstance(numpy_tri.values, COO) + + numpy_tri.set_backend("numpy", inplace=True) + assert isinstance(numpy_tri.values, np.ndarray) + + +def test_set_backend_invalid_raises(raa: Triangle) -> None: + """ + Pass an unsupported backend name to set_backend. Should raise AttributeError + (common.py line 259: the else branch of `if backend in [...]`). + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + with pytest.raises(AttributeError): + raa.set_backend("invalid_backend", inplace=True) + + +def test_set_backend_roundtrip(raa: Triangle) -> None: + """ + Convert numpy → sparse → numpy and verify values are preserved. + Exercises lookup["sparse"]["numpy"] (sp.array) and lookup["numpy"]["sparse"] + (x.todense()) in the set_backend conversion table. + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + sparse_raa = raa.set_backend("sparse") + assert sparse_raa.array_backend == "sparse" + restored = sparse_raa.set_backend("numpy") + assert restored.array_backend == "numpy" + assert restored == raa + + +def test_set_backend_from_dask(raa: Triangle) -> None: + """ + Convert a dask-backend triangle to numpy. Exercises the compute() call on + lines 224-226 of common.py, which is only reached when old_backend == 'dask' + and backend != 'dask'. + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + with pytest.warns(DeprecationWarning, match="dask"): + dask_raa = raa.set_backend("dask") + result = dask_raa.set_backend("numpy") + assert result.array_backend == "numpy" + assert result == raa From a5ecef9f70835ff49c54c45946fcf43b0da9b4b3 Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 30 Jun 2026 19:13:48 -0500 Subject: [PATCH 05/10] TEST: Add tests for setting backend, validating assumption. --- chainladder/core/common.py | 6 + chainladder/core/tests/test_triangle.py | 170 ++++++++++++++++++------ 2 files changed, 138 insertions(+), 38 deletions(-) diff --git a/chainladder/core/common.py b/chainladder/core/common.py index 77f84d51..900ceb9b 100644 --- a/chainladder/core/common.py +++ b/chainladder/core/common.py @@ -269,9 +269,15 @@ def _validate_assumption( axis: Literal[0, 1, 2, 3] ) -> np.ndarray: """ + Used by development estimators to turn user-supplied assumptions into a uniform NumPy array + shaped to broadcast over the triangle. Parameters ---------- + value: str | int | float | list | tuple | set | np.ndarray | dict | Callable + The user-supplied assumption. + axis: Literal[0, 1, 2, 3] + The axis to broadcast over. """ if type(value) in (int, float, str): diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 1930216e..439ec57d 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -1436,6 +1436,126 @@ def test_validate_assumption(raa: Triangle) -> None: value=raa, axis=3 # noqa - incorrect type provided on purpose. ) + +@pytest.mark.parametrize("value", [1, 1.5, "volume"]) +def test_validate_assumption_scalar(raa: Triangle, value: int | float | str) -> None: + """ + Scalar int, float, and str values are broadcast across the axis. + + Parameters + ---------- + raa: Triangle + The raa sample data set Triangle. + value: int | float | str + A user-supplied assumption. + + Returns + ------- + None + """ + result = raa._validate_assumption(raa, value, axis=3) + assert result.shape == (1, 1, 1, raa.shape[3]) + assert (result.flat[0] == value) + + +@pytest.mark.parametrize("value", [ + [1] * 10, + (1,) * 10, + np.ones(10), +]) +def test_validate_assumption_sequence(raa: Triangle, value: list | tuple | np.ndarray) -> None: + """ + list, tuple, and ndarray values are wrapped in np.array and reshaped. + + Parameters + ---------- + raa: Triangle + The raa sample data set Triangle. + value: list | tuple | np.ndarray + A user-supplied assumption. + + Returns + ------- + None + """ + result = raa._validate_assumption(raa, value, axis=3) + assert result.shape == (1, 1, 1, raa.shape[3]) + + +def test_validate_assumption_set(raa: Triangle) -> None: + """ + set values reach the sequence branch without raising. + + Parameters + ---------- + raa: Triangle + The raa sample data set Triangle. + + Returns + ------- + None + """ + # np.array(set) produces a 0-d object array in NumPy 2.x, so we only + # assert the call succeeds, not the resulting shape. + raa._validate_assumption(raa, {1, 2, 3}, axis=3) + + +def test_validate_assumption_dict(raa: Triangle) -> None: + """ + Dict values are mapped by axis label. + + Parameters + ---------- + raa: Triangle + The raa sample data set Triangle. + + Returns + ------- + None + """ + dev_periods = raa._get_axis_value(3).tolist() + value = {p: float(i) for i, p in enumerate(dev_periods)} + result = raa._validate_assumption(raa, value, axis=3) + assert result.shape == (1, 1, 1, raa.shape[3]) + np.testing.assert_array_equal(result.flat[:], list(value.values())) + + +def test_validate_assumption_callable(raa: Triangle) -> None: + """ + Callable values are applied to each axis label. + + Parameters + ---------- + raa: Triangle + The raa sample data set Triangle. + + Returns + ------- + None + """ + result = raa._validate_assumption(raa, lambda x: x * 2, axis=3) + assert result.shape == (1, 1, 1, raa.shape[3]) + expected = np.array([p * 2 for p in raa._get_axis_value(3).tolist()]) + np.testing.assert_array_equal(result.flatten(), expected) + + +def test_validate_assumption_axis2(raa: Triangle) -> None: + """ + axis=2 produces shape (1, 1, n_origin, 1). + + Parameters + ---------- + raa: Triangle + The raa sample data set Triangle. + + Returns + ------- + None + """ + result = raa._validate_assumption(raa, 1, axis=2) + assert result.shape == (1, 1, raa.shape[2], 1) + + def test_xs(clrd): # when slicing with .loc on the first term in the index, Triangle will drop the term assert clrd.xs('Adriatic Ins Co') == clrd.loc['Adriatic Ins Co'] @@ -1643,7 +1763,7 @@ def test_to_datetime_uninferrable_format_raises() -> None: def test_set_backend_via_ldf(raa: Triangle) -> None: """ Call set_backend on a fitted estimator. The estimator has no array_backend attribute - of its own, so set_backend resolves the old backend through ldf_ (common.py lines 217-218). + of its own, so set_backend resolves the old backend through ldf_. Parameters ---------- @@ -1662,7 +1782,7 @@ def test_set_backend_via_ldf(raa: Triangle) -> None: def test_set_backend_no_array_backend_raises() -> None: """ Call set_backend on an unfitted estimator. The estimator has neither array_backend - nor ldf_, so set_backend raises ValueError (common.py lines 219-220). + nor ldf_, so set_backend raises ValueError. Returns ------- @@ -1674,9 +1794,8 @@ def test_set_backend_no_array_backend_raises() -> None: def test_set_backend_inplace_updates_array_backend_attr(raa: Triangle) -> None: """ - set_backend(inplace=True) updates self.array_backend when the attribute exists - (common.py line 257, true branch of line 256). When called on an object without - array_backend (e.g. an unfitted estimator) the attribute is not added. + set_backend(inplace=True) updates self.array_backend when the attribute exists. + When called on an object without array_backend, the attribute is not added. Parameters ---------- @@ -1687,12 +1806,12 @@ def test_set_backend_inplace_updates_array_backend_attr(raa: Triangle) -> None: ------- None """ - # TRUE branch: Triangle has array_backend → it is updated in-place + # Triangle has array_backend, updated in-place. tri = raa.set_backend("numpy") tri.set_backend("sparse", inplace=True) assert tri.array_backend == "sparse" - # FALSE branch: estimator has no array_backend → attribute is not added + # Estimator has no array_backend, attribute is not added. dev = cl.Development().fit(raa.set_backend("numpy")) dev.set_backend("sparse", inplace=True) assert not hasattr(dev, "array_backend") @@ -1701,7 +1820,7 @@ def test_set_backend_inplace_updates_array_backend_attr(raa: Triangle) -> None: def test_set_backend_deep_propagates_to_nested_common(raa: Triangle) -> None: """ set_backend with deep=True iterates vars(self) and recursively converts every - nested Common instance (common.py lines 252-255). Without deep=True the nested + nested Common instance. Without deep=True, the nested attributes keep their original backend; with deep=True all are converted. Parameters @@ -1727,9 +1846,9 @@ def test_set_backend_deep_propagates_to_nested_common(raa: Triangle) -> None: def test_set_backend_inplace_mutates_values(raa: Triangle) -> None: """ - set_backend(inplace=True) reassigns self.values via the conversion lookup - (common.py lines 248-251). Verify the in-place mutation produces the correct - array type: numpy -> sparse yields a COO, sparse -> numpy yields an ndarray. + set_backend(inplace=True) reassigns self.values. + Verify the in-place mutation produces the correct array type: numpy to sparse yields a COO, + sparse to numpy yields an ndarray. Parameters ---------- @@ -1750,8 +1869,7 @@ def test_set_backend_inplace_mutates_values(raa: Triangle) -> None: def test_set_backend_invalid_raises(raa: Triangle) -> None: """ - Pass an unsupported backend name to set_backend. Should raise AttributeError - (common.py line 259: the else branch of `if backend in [...]`). + Pass an unsupported backend name to set_backend. Should raise AttributeError. Parameters ---------- @@ -1768,9 +1886,7 @@ def test_set_backend_invalid_raises(raa: Triangle) -> None: def test_set_backend_roundtrip(raa: Triangle) -> None: """ - Convert numpy → sparse → numpy and verify values are preserved. - Exercises lookup["sparse"]["numpy"] (sp.array) and lookup["numpy"]["sparse"] - (x.todense()) in the set_backend conversion table. + Convert numpy to sparse and back to numpy, and verify values are preserved. Parameters ---------- @@ -1786,25 +1902,3 @@ def test_set_backend_roundtrip(raa: Triangle) -> None: restored = sparse_raa.set_backend("numpy") assert restored.array_backend == "numpy" assert restored == raa - - -def test_set_backend_from_dask(raa: Triangle) -> None: - """ - Convert a dask-backend triangle to numpy. Exercises the compute() call on - lines 224-226 of common.py, which is only reached when old_backend == 'dask' - and backend != 'dask'. - - Parameters - ---------- - raa : Triangle - The raa sample data set. - - Returns - ------- - None - """ - with pytest.warns(DeprecationWarning, match="dask"): - dask_raa = raa.set_backend("dask") - result = dask_raa.set_backend("numpy") - assert result.array_backend == "numpy" - assert result == raa From 3d6dde36754c91bb1547c6191170fbbcd0b83dbe Mon Sep 17 00:00:00 2001 From: Gene Dan Date: Tue, 30 Jun 2026 19:30:47 -0500 Subject: [PATCH 06/10] TEST: Add tests for zeta attribute. --- chainladder/core/tests/test_triangle.py | 72 +++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 439ec57d..7d81ac3b 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -1902,3 +1902,75 @@ def test_set_backend_roundtrip(raa: Triangle) -> None: restored = sparse_raa.set_backend("numpy") assert restored.array_backend == "numpy" assert restored == raa + + +def test_has_zeta_true(raa: Triangle) -> None: + """ + has_zeta returns True after fitting IncrementalAdditive, which sets zeta_. + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + fitted = cl.IncrementalAdditive().fit(raa, sample_weight=raa.latest_diagonal) + assert fitted.has_zeta is True + + +def test_has_zeta_false(raa: Triangle) -> None: + """ + has_zeta returns False for an estimator that does not set zeta_. + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + assert cl.Development().fit(raa).has_zeta is False + + +def test_cum_zeta_raises_when_no_zeta(raa: Triangle) -> None: + """ + cum_zeta_ raises AttributeError when the estimator has no zeta_. + + Parameters + ---------- + raa : Triangle + The raa sample data set. + + Returns + ------- + None + """ + with pytest.raises(AttributeError): + _ = cl.Development().fit(raa).cum_zeta_ + + +def test_cum_zeta_returns_incr_to_cum(atol) -> None: + """ + cum_zeta_ returns zeta_.incr_to_cum() when zeta_ is present. + + Parameters + ---------- + atol : float + Absolute tolerance fixture. + + Returns + ------- + None + """ + ia = cl.load_sample("ia_sample") + fitted = cl.IncrementalAdditive().fit(ia["loss"], sample_weight=ia["exposure"].latest_diagonal) + np.testing.assert_allclose( + fitted.cum_zeta_.values.flatten(), + [0.888447, 0.645235, 0.423275, 0.269296, 0.127443, 0.036770], + atol=atol, + ) From b9065777fd04bfa53ddecbb45407a020d4766058 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Fri, 10 Jul 2026 03:21:51 +0000 Subject: [PATCH 07/10] restoring mack calculations --- .../development/tests/test_development.py | 44 +--------- chainladder/methods/tests/test_mack.py | 24 +++++- chainladder/utils/weighted_regression.py | 85 ++++++++++++++++--- 3 files changed, 96 insertions(+), 57 deletions(-) diff --git a/chainladder/development/tests/test_development.py b/chainladder/development/tests/test_development.py index d8bd8c72..430687e3 100644 --- a/chainladder/development/tests/test_development.py +++ b/chainladder/development/tests/test_development.py @@ -814,46 +814,4 @@ def test_pipeline(clrd): dev2 = pipe.fit(X=clrd) assert np.array_equal( dev1.w_v2_.values, dev2.named_steps.drop_hilo.w_v2_.values, True - ) - -def test_sigma1(atol): - data = { - "valuation": [ - 1981,1982,1983,1984,1985, - 1982,1983,1984,1985, - 1983,1984,1985, - 1984,1985, - 1985, - ], - "origin": [ - 1981,1981,1981,1981,1981, - 1982,1982,1982,1982, - 1983,1983,1983, - 1984,1984, - 1985, - ], - "values": [ - 1000,1385,1700,1905,2000, - 1000,1395,1700,1895, - 1000,1405,1700, - 1000,1415, - 1000, - ], - } - tri = cl.Triangle( - data, - origin="origin", - development="valuation", - columns=["values"], - cumulative=True, - ) - assert np.allclose( - cl.Development(average='simple').fit(tri).sigma_.values, - cl.Development(average='volume').fit(tri).sigma_.values, - atol = atol - ) - assert np.allclose( - cl.Development(average='simple').fit(tri).sigma_.values, - cl.Development(average='regression').fit(tri).sigma_.values, - atol = atol - ) + ) \ No newline at end of file diff --git a/chainladder/methods/tests/test_mack.py b/chainladder/methods/tests/test_mack.py index ac9bf6c5..16ada183 100644 --- a/chainladder/methods/tests/test_mack.py +++ b/chainladder/methods/tests/test_mack.py @@ -27,4 +27,26 @@ def test_multi_triangle_mack(clrd,atol): mack = cl.MackChainladder().fit(tri) for i in range(len(tri.index)): for j in range(len(tri.columns)): - assert np.all(abs(mack.full_std_err_.iloc[i,j].values-cl.MackChainladder().fit(tri.iloc[i,j]).full_std_err_.values) < atol) \ No newline at end of file + assert np.all(abs(mack.full_std_err_.iloc[i,j].values-cl.MackChainladder().fit(tri.iloc[i,j]).full_std_err_.values) < atol) + +def test_mack_hardcode(): + """ + Reconciles key MackChainladder statistics to values provided in the paper + """ + #source dfomr Table 1, p365 of Mack(1997) + ldf_se = [2.24,.517,.122,.051,.042,.023,.015,.012] + sigma = [1337,988.5,440.1,207.0,164.2,74.6,35.49,16.89] + #source from Table 2, p366 of Mack(1997) + ibnr_se = [0,61,140,319,596,1038,1298,1806,2182] + + tri = cl.load_sample("mortgage") + dev = cl.Development(sigma_interpolation = 'mack').fit_transform(tri) + model = cl.MackChainladder().fit(dev) + ldf_rhs = dev.std_err_.values.flatten() + assert np.allclose(ldf_se[0],ldf_rhs[0],atol=0.01) + assert np.allclose(ldf_se[1:],ldf_rhs[1:],atol=0.001) + sigma_rhs = dev.sigma_.values.flatten() + assert np.allclose(sigma[0],sigma_rhs[0],atol=1) + assert np.allclose(sigma[1:],sigma_rhs[1:],atol=0.1) + ibnr_rhs = model.summary_.values[0,0,:,-1]/1000 + assert np.allclose(ibnr_se,np.nan_to_num(ibnr_rhs,nan=0),atol=1,equal_nan=True) \ No newline at end of file diff --git a/chainladder/utils/weighted_regression.py b/chainladder/utils/weighted_regression.py index 1497587a..f128119f 100644 --- a/chainladder/utils/weighted_regression.py +++ b/chainladder/utils/weighted_regression.py @@ -1,25 +1,60 @@ # 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 from chainladder.utils.sparse import sp from sklearn.base import BaseEstimator import warnings +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from types import ModuleType + class WeightedRegression(BaseEstimator): - """Helper class that fits a system of regression equations + """ + Helper class that fits a system of regression equations as a closed-form solution. This greatly speeds up the implementation of the Mack stochastic properties. + + Parameters + ---------- + axis: integer (default = 2) + the axis along with the perform the regression; + axis of 2 is along the origin periods; + axis of 3 is along the development periods; + thru_orig: bool (default = False) + whether the regression is forced to go through the origin + xp: ModuleType (default = numpy) + array module for calculations + + Attributes + ---------- + slope_: Triangle + coefficients of the regression + sigma_: Triangle + standard deviation of the Triangle values according to Mack (1997), not be confused with sigma from the regression + std_err_: DatetimeIndex + standard error of estimator``slope_`` """ - def __init__(self, axis=None, thru_orig=False, xp=None, average=None): + def __init__( + self, + axis: int = 2, + thru_orig: bool = False, + xp: ModuleType = np, + ): self.axis = axis self.thru_orig = thru_orig self.xp = xp - self.average = average def infer_x_w(self): + """ + Creates dummy X and/or w for the regression when none are given + """ xp = self.xp if self.w is None: self.w = xp.ones(self.y.shape) @@ -27,7 +62,32 @@ def infer_x_w(self): self.x = xp.cumsum(xp.ones(self.y.shape), self.axis) return self - def fit(self, X, y=None, sample_weight=None, average=None): + def fit( + self, + X, + y=None, + sample_weight=None, + average: Literal["volume", "simple", "regression", "geometric"] | None = None + ): + """ + Fit the model with X. + + Parameters + ---------- + X : Array + independent variable for the regression + y : None + dependent variable for the regression + sample_weight : None + which (x,y) pairs should be used for the regression + average: literal (or list of literals), or None, optional (default = None) + type of averaging to use; dictates the weights used in the regression + + Returns + ------- + self : object + Returns the instance itself. + """ self.x = X self.y = y self.w = sample_weight @@ -44,6 +104,10 @@ def fit(self, X, y=None, sample_weight=None, average=None): return self def _fit_OLS_thru_orig(self): + """ + Given a set of w, x, y, and an axis, this Function returns OLS slope + and other statistics, while forcing an intercept of 0 + """ from chainladder.utils.utility_functions import num_to_nan x = self.x @@ -106,19 +170,14 @@ def _fit_OLS_thru_orig(self): mse_denom = xp.nansum((y * 0 + 1) * (xp.nan_to_num(w) != 0), axis) - 1 mse_denom = num_to_nan(mse_denom) mse = wss_residual / mse_denom - + sigma = xp.sqrt(mse) std_err = xp.sqrt(mse / denominator) - sigma = std_err * xp.sqrt(mse_denom + 1) - - coef = coef[..., None] - sigma = sigma[..., None] - std_err = std_err[..., None] self._w_reg = w - self.slope_ = coef - self.sigma_ = sigma - self.std_err_ = std_err + self.slope_ = coef[..., None] + self.sigma_ = sigma[..., None] + self.std_err_ = std_err[..., None] return self From 4b4eb711f0a0edba403e554d614ad0d8ed9aa342 Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Fri, 10 Jul 2026 03:47:31 +0000 Subject: [PATCH 08/10] adding additional typing and test --- chainladder/methods/tests/test_mack.py | 29 ++++++++++++++++++++---- chainladder/utils/weighted_regression.py | 15 ++++++------ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/chainladder/methods/tests/test_mack.py b/chainladder/methods/tests/test_mack.py index 16ada183..29648d82 100644 --- a/chainladder/methods/tests/test_mack.py +++ b/chainladder/methods/tests/test_mack.py @@ -29,14 +29,14 @@ def test_multi_triangle_mack(clrd,atol): for j in range(len(tri.columns)): assert np.all(abs(mack.full_std_err_.iloc[i,j].values-cl.MackChainladder().fit(tri.iloc[i,j]).full_std_err_.values) < atol) -def test_mack_hardcode(): +def test_mack1997_hardcode(): """ Reconciles key MackChainladder statistics to values provided in the paper """ - #source dfomr Table 1, p365 of Mack(1997) + #sourced from Table 1, p365 of Mack(1997) ldf_se = [2.24,.517,.122,.051,.042,.023,.015,.012] sigma = [1337,988.5,440.1,207.0,164.2,74.6,35.49,16.89] - #source from Table 2, p366 of Mack(1997) + #sourced from Table 2, p366 of Mack(1997) ibnr_se = [0,61,140,319,596,1038,1298,1806,2182] tri = cl.load_sample("mortgage") @@ -49,4 +49,25 @@ def test_mack_hardcode(): assert np.allclose(sigma[0],sigma_rhs[0],atol=1) assert np.allclose(sigma[1:],sigma_rhs[1:],atol=0.1) ibnr_rhs = model.summary_.values[0,0,:,-1]/1000 - assert np.allclose(ibnr_se,np.nan_to_num(ibnr_rhs,nan=0),atol=1,equal_nan=True) \ No newline at end of file + assert np.allclose(ibnr_se,np.nan_to_num(ibnr_rhs,nan=0),atol=1,equal_nan=True) + +def test_mack1994_hardcode(raa): + """ + Reconciles key MackChainladder statistics to values provided in the paper + """ + #sourced from top table on p130 of Mack(1994) + sigma_sq = [27883,1109,691,61.2,119,40.8,1.34,7.88] + #sourced from bottom table on p130 of Mack(1994) + ibnr_se = [206,623,747,1469,2002,2209,5358,6333,24566] + + dev = cl.Development(sigma_interpolation = 'mack').fit_transform(raa) + model = cl.MackChainladder().fit(dev) + sigma_rhs = dev.sigma_.values.flatten() ** 2 + print(sigma_rhs) + assert np.allclose(sigma_sq[:3],sigma_rhs[:3],atol=1) + assert np.allclose(sigma_sq[3:4],sigma_rhs[3:4],atol=0.1) + assert np.allclose(sigma_sq[4:5],sigma_rhs[4:5],atol=1) + assert np.allclose(sigma_sq[5:6],sigma_rhs[5:6],atol=0.1) + assert np.allclose(sigma_sq[6:8],sigma_rhs[6:8],atol=0.01) + ibnr_rhs = model.summary_.values[0,0,:,-1] + assert np.allclose(ibnr_se,ibnr_rhs[1:],atol=1) \ No newline at end of file diff --git a/chainladder/utils/weighted_regression.py b/chainladder/utils/weighted_regression.py index f128119f..5add4f24 100644 --- a/chainladder/utils/weighted_regression.py +++ b/chainladder/utils/weighted_regression.py @@ -12,7 +12,8 @@ if TYPE_CHECKING: # pragma: no cover from types import ModuleType - + from typing import Literal + from chainladder.core.typing import BackendArray class WeightedRegression(BaseEstimator): """ @@ -64,9 +65,9 @@ def infer_x_w(self): def fit( self, - X, - y=None, - sample_weight=None, + X:BackendArray, + y:BackendArray|None=None, + sample_weight:BackendArray|None=None, average: Literal["volume", "simple", "regression", "geometric"] | None = None ): """ @@ -76,10 +77,10 @@ def fit( ---------- X : Array independent variable for the regression - y : None + y : Array or None (default = None) dependent variable for the regression - sample_weight : None - which (x,y) pairs should be used for the regression + sample_weight : Array or None (default = None) + which (x,y) pairs should be used in the regression average: literal (or list of literals), or None, optional (default = None) type of averaging to use; dictates the weights used in the regression From 225ea8a557debcd666ddd6964b250927af60b182 Mon Sep 17 00:00:00 2001 From: henrydingliu <106109320+henrydingliu@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:40:53 -0700 Subject: [PATCH 09/10] Update test_mack.py --- chainladder/methods/tests/test_mack.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/chainladder/methods/tests/test_mack.py b/chainladder/methods/tests/test_mack.py index 29648d82..fb63e695 100644 --- a/chainladder/methods/tests/test_mack.py +++ b/chainladder/methods/tests/test_mack.py @@ -63,11 +63,10 @@ def test_mack1994_hardcode(raa): dev = cl.Development(sigma_interpolation = 'mack').fit_transform(raa) model = cl.MackChainladder().fit(dev) sigma_rhs = dev.sigma_.values.flatten() ** 2 - print(sigma_rhs) assert np.allclose(sigma_sq[:3],sigma_rhs[:3],atol=1) assert np.allclose(sigma_sq[3:4],sigma_rhs[3:4],atol=0.1) assert np.allclose(sigma_sq[4:5],sigma_rhs[4:5],atol=1) assert np.allclose(sigma_sq[5:6],sigma_rhs[5:6],atol=0.1) assert np.allclose(sigma_sq[6:8],sigma_rhs[6:8],atol=0.01) ibnr_rhs = model.summary_.values[0,0,:,-1] - assert np.allclose(ibnr_se,ibnr_rhs[1:],atol=1) \ No newline at end of file + assert np.allclose(ibnr_se,ibnr_rhs[1:],atol=1) From b45ab45028077a46350e5ed89c736291e592e86c Mon Sep 17 00:00:00 2001 From: henrydingliu <106109320+henrydingliu@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:59:25 -0700 Subject: [PATCH 10/10] Doctest fix (#1099) * Update common.py * Update bornferg.py * Update clark.py * Update capecod.py * Update benktander.py * Update chainladder.py * Update chainladder.py * Update bornferg.py * Update bornferg.py * Update benktander.py * Update benktander.py * Update clark.py * Update bornferg.py --- chainladder/core/common.py | 11 ++++++++++ chainladder/development/clark.py | 32 +++++++++++++++--------------- chainladder/methods/benktander.py | 9 +++------ chainladder/methods/bornferg.py | 13 ++++++------ chainladder/methods/capecod.py | 2 +- chainladder/methods/chainladder.py | 3 ++- 6 files changed, 40 insertions(+), 30 deletions(-) diff --git a/chainladder/core/common.py b/chainladder/core/common.py index 900ceb9b..2d9b1b8f 100644 --- a/chainladder/core/common.py +++ b/chainladder/core/common.py @@ -174,6 +174,17 @@ def pipe(self, func, *args, **kwargs): >>> import chainladder as cl >>> raa = cl.load_sample('raa') >>> raa.pipe(lambda tri: tri.loc[..., 48:]) + 48 60 72 84 96 108 120 + 1981 11805.0 13539.0 16181.0 18009.0 18608.0 18662.0 18834.0 + 1982 10666.0 13782.0 15599.0 15496.0 16169.0 16704.0 NaN + 1983 16141.0 18735.0 22214.0 22863.0 23466.0 NaN NaN + 1984 21266.0 23425.0 26083.0 27067.0 NaN NaN NaN + 1985 22169.0 25955.0 26180.0 NaN NaN NaN NaN + 1986 12935.0 15852.0 NaN NaN NaN NaN NaN + 1987 12314.0 NaN NaN NaN NaN NaN NaN + 1988 NaN NaN NaN NaN NaN NaN NaN + 1989 NaN NaN NaN NaN NaN NaN NaN + 1990 NaN NaN NaN NaN NaN NaN NaN """ return func(self, *args, **kwargs) diff --git a/chainladder/development/clark.py b/chainladder/development/clark.py index 6a39e3ad..413e5420 100644 --- a/chainladder/development/clark.py +++ b/chainladder/development/clark.py @@ -134,29 +134,29 @@ class ClarkLDF(DevelopmentBase): clrd = cl.load_sample("clrd")[["CumPaidLoss"]] print(len(clrd.index)) m = cl.ClarkLDF(groupby="LOB").fit(clrd) - print(m.omega_.round(3)) - print(m.theta_.round(3)) + print(m.omega_.round(2)) + print(m.theta_.round(2)) .. testoutput:: :options: +NORMALIZE_WHITESPACE 775 CumPaidLoss - LOB - comauto 1.082 - medmal 1.889 - othliab 1.468 - ppauto 1.149 - prodliab 1.441 - wkcomp 1.107 + LOB + comauto 1.08 + medmal 1.89 + othliab 1.47 + ppauto 1.15 + prodliab 1.44 + wkcomp 1.11 CumPaidLoss - LOB - comauto 20.481 - medmal 35.128 - othliab 37.745 - ppauto 10.023 - prodliab 64.352 - wkcomp 20.111 + LOB + comauto 20.48 + medmal 35.13 + othliab 37.75 + ppauto 10.02 + prodliab 64.35 + wkcomp 20.11 """ diff --git a/chainladder/methods/benktander.py b/chainladder/methods/benktander.py index a83536dd..bcf84390 100644 --- a/chainladder/methods/benktander.py +++ b/chainladder/methods/benktander.py @@ -52,7 +52,6 @@ class Benktander(MethodBase): .. testcode:: xyz = cl.load_sample("xyz") - ibnr = cl.Benktander().fit(X=xyz["Paid"], sample_weight=xyz["Premium"].latest_diagonal).ibnr_ print(ibnr) @@ -76,7 +75,6 @@ class Benktander(MethodBase): .. testcode:: xyz = cl.load_sample("xyz") - bk_ibnr = ( cl.Benktander(n_iters=1) .fit(X=xyz["Paid"], sample_weight=xyz["Premium"].latest_diagonal) @@ -109,7 +107,6 @@ class Benktander(MethodBase): .. testcode:: xyz = cl.load_sample("xyz") - bk_ibnr = cl.Benktander(n_iters=1000).fit(X=xyz["Paid"], sample_weight=xyz["Premium"].latest_diagonal).ibnr_ cl_ibnr = cl.Chainladder().fit(xyz["Paid"]).ibnr_ print(bk_ibnr - cl_ibnr) @@ -165,7 +162,6 @@ def fit(self, X, y=None, sample_weight=None): .. testcode:: xyz = cl.load_sample("xyz") - ultimate = ( cl.Benktander(apriori=1, n_iters=2) .fit(X=xyz["Paid"], sample_weight=xyz["Premium"].latest_diagonal) @@ -218,6 +214,7 @@ def predict(self, X, sample_weight=None): current Triangle and a refreshed apriori. .. testsetup:: + import chainladder as cl .. testcode:: @@ -229,8 +226,8 @@ def predict(self, X, sample_weight=None): model = cl.Benktander(apriori=1.0, n_iters=2).fit( tr_prior, sample_weight=apriori_prior ) - - print(model.predict(tr, sample_weight=apriori).ultimate_) + ultimate = model.predict(tr, sample_weight=apriori).ultimate_ + print(ultimate) .. testoutput:: diff --git a/chainladder/methods/bornferg.py b/chainladder/methods/bornferg.py index 695a6f49..ee968b2e 100644 --- a/chainladder/methods/bornferg.py +++ b/chainladder/methods/bornferg.py @@ -53,7 +53,6 @@ class BornhuetterFerguson(Benktander): raa = cl.load_sample("raa") premium = raa.latest_diagonal * 0 + 40_000 # zero out and add 40,000 to each origin - ibnr = cl.BornhuetterFerguson(apriori=0.7).fit(X=raa, sample_weight=premium).ibnr_ print(ibnr) @@ -77,7 +76,6 @@ class BornhuetterFerguson(Benktander): raa = cl.load_sample("raa") premium = raa.latest_diagonal * 0 + 40_000 * 0.7 # premium is modified by 70% - ibnr = cl.BornhuetterFerguson().fit(X=raa, sample_weight=premium).ibnr_ print(ibnr) @@ -123,13 +121,15 @@ def fit(self, X, y=None, sample_weight=None): Fit returns the estimator itself, with ``ultimate_`` populated. .. testsetup:: + import chainladder as cl .. testcode:: tr = cl.load_sample('ukmotor') apriori = cl.Chainladder().fit(tr).ultimate_ * 0 + 14000 - print(cl.BornhuetterFerguson(apriori=1.0).fit(tr, sample_weight=apriori)) + model = cl.BornhuetterFerguson(apriori=1.0).fit(tr, sample_weight=apriori) + print(model) .. testoutput:: @@ -160,6 +160,7 @@ def predict(self, X, sample_weight=None): current Triangle and a refreshed apriori. .. testsetup:: + import chainladder as cl .. testcode:: @@ -171,10 +172,10 @@ def predict(self, X, sample_weight=None): model = cl.BornhuetterFerguson(apriori=1.0).fit( tr_prior, sample_weight=apriori_prior ) + ultimate = model.predict(tr, sample_weight=apriori).ultimate_ + print(ultimate) - print(model.predict(tr, sample_weight=apriori).ultimate_) - - .. testoutput + .. testoutput:: 2261 2007 12690.000000 diff --git a/chainladder/methods/capecod.py b/chainladder/methods/capecod.py index 4175e2ae..1470104d 100644 --- a/chainladder/methods/capecod.py +++ b/chainladder/methods/capecod.py @@ -218,7 +218,7 @@ def fit(self, X, y=None, sample_weight=None): exposure = cl.Chainladder().fit(tr).ultimate_ * 0 + 20000 print(cl.CapeCod(trend=0.05).fit(tr, sample_weight=exposure)) - .. testoutput: + .. testoutput:: CapeCod(trend=0.05) """ diff --git a/chainladder/methods/chainladder.py b/chainladder/methods/chainladder.py index 893933f3..2ab4ca61 100644 --- a/chainladder/methods/chainladder.py +++ b/chainladder/methods/chainladder.py @@ -124,12 +124,13 @@ def fit(self, X, y=None, sample_weight=None): attribute access. .. testsetup:: + import chainladder as cl .. testcode:: tr = cl.load_sample('ukmotor') - cl.Chainladder().fit(tr) + print(cl.Chainladder().fit(tr)) .. testoutput::