From 4892df93bbeaddc80c4b3f7b82635486d5a6726d Mon Sep 17 00:00:00 2001 From: "henrydingliu@gmail.com" Date: Mon, 13 Jul 2026 04:58:08 +0000 Subject: [PATCH] initial implementation of TriangleSelector --- chainladder/__init__.py | 1 + chainladder/tests/test_public_api.py | 1 + chainladder/workflow/__init__.py | 2 + chainladder/workflow/tests/test_voting.py | 38 ++++++++ chainladder/workflow/voting.py | 101 +++++++++++++++++++++- 5 files changed, 139 insertions(+), 4 deletions(-) diff --git a/chainladder/__init__.py b/chainladder/__init__.py index d14caa1c1..d9024625b 100644 --- a/chainladder/__init__.py +++ b/chainladder/__init__.py @@ -508,6 +508,7 @@ def describe_option(self, pat: str = "", _print_desc: bool=True) -> None | str: GridSearch, Pipeline, VotingChainladder, + TriangleSelector, ) __version__ = version("chainladder") diff --git a/chainladder/tests/test_public_api.py b/chainladder/tests/test_public_api.py index 6ea1c331e..92515166d 100644 --- a/chainladder/tests/test_public_api.py +++ b/chainladder/tests/test_public_api.py @@ -75,6 +75,7 @@ "GridSearch", "Pipeline", "VotingChainladder", + "TriangleSelector", # package-level objects "Options", "options", diff --git a/chainladder/workflow/__init__.py b/chainladder/workflow/__init__.py index dc0251e89..fe6f35bfa 100644 --- a/chainladder/workflow/__init__.py +++ b/chainladder/workflow/__init__.py @@ -1,8 +1,10 @@ from chainladder.workflow.gridsearch import GridSearch, Pipeline # noqa (API import) from chainladder.workflow.voting import VotingChainladder # noqa (API import) +from chainladder.workflow.voting import TriangleSelector # noqa (API import) __all__ = [ "GridSearch", "Pipeline", "VotingChainladder", + "TriangleSelector" ] diff --git a/chainladder/workflow/tests/test_voting.py b/chainladder/workflow/tests/test_voting.py index efc47aee3..cda3672fa 100644 --- a/chainladder/workflow/tests/test_voting.py +++ b/chainladder/workflow/tests/test_voting.py @@ -1,7 +1,13 @@ +from __future__ import annotations + import numpy as np import chainladder as cl import pytest +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from chainladder import Triangle @pytest.fixture def triangle_data(): @@ -184,3 +190,35 @@ def test_voting(raa): 21605.83, ] ).all() + +def test_tri_sel(clrd:Triangle) -> None: + ''' + starter test for the TriangleSelector class + ''' + tri = clrd.sum() + assert tri['CumPaidLoss'] == cl.TriangleSelector('CumPaidLoss').fit_transform(tri) + +def test_mismatching_tri_sel(clrd:Triangle) -> None: + ''' + checking that an error is raised when different number of columns are specified by estimators in a VotingChainladder + ''' + tri = clrd.groupby('LOB').sum().loc['othliab'] + pipe_p = cl.Pipeline( + steps=[ + ('tri_sel', cl.TriangleSelector('CumPaidLoss')), + ('dev', cl.Development()), + ('model', cl.Chainladder()) + ] + ) + pipe_i = cl.Pipeline( + steps=[ + ('dev', cl.Development()), + ('model', cl.Chainladder()) + ] + ) + + estimators = [('incurred', pipe_i), ('paid', pipe_p)] + weights = np.array([[0.5, 0.5]] * 4 + [[0.75, 0.25]] * 3 + [[1, 0]] * 3) + vot = cl.VotingChainladder(estimators=estimators, weights=weights) + with pytest.raises(ValueError): + vot.fit(tri) \ No newline at end of file diff --git a/chainladder/workflow/voting.py b/chainladder/workflow/voting.py index 0c321e27e..afafac635 100644 --- a/chainladder/workflow/voting.py +++ b/chainladder/workflow/voting.py @@ -1,19 +1,32 @@ # 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 + from abc import abstractmethod import numpy as np from chainladder.methods.base import MethodBase from joblib import Parallel, delayed -from sklearn.base import clone +from sklearn.base import ( + BaseEstimator, + clone, + TransformerMixin +) from sklearn.ensemble._base import _fit_single_estimator, _BaseHeterogeneousEnsemble from sklearn.ensemble._voting import _BaseVoting from sklearn.utils import Bunch from sklearn.utils.validation import _deprecate_positional_args, check_is_fitted +from chainladder.core.io import EstimatorIO + from ..core.base import is_chainladder +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from chainladder import Triangle + class _BaseTriangleEnsemble(_BaseHeterogeneousEnsemble): """Base class for ensemble of triangle methods.""" @@ -404,11 +417,91 @@ def _get_ultimate(self, X, sample_weight=None): f" and {X.shape[1]} for X." ) weights = self.weights_ + + ultimates = [est.predict(X, sample_weight).ultimate_ for est in self.estimators_] + shape_check = list(set([ult.shape for ult in ultimates])) + if len(shape_check) > 1: + raise ValueError( + "Estimators returning ultimate_ of different shapes," + "likely due to a mis-specified TriangleSelector transformer in the pipeline" + ) + #weights are broadcasted to the shape of X. However ultimate_ does not always take the shape of X + #use shape_check to redim weights ultimate = sum( [ - est.predict(X, sample_weight).ultimate_ * weights[..., i, :] - for i, est in enumerate(self.estimators_) + ultimates[i] * weights[...,:shape_check[0][1], :, i, :] + for i, _ in enumerate(ultimates) ] - ) / weights.sum(axis=-2) + ) / weights[...,:shape_check[0][1], :, :, :].sum(axis=-2) return ultimate + +class TriangleSelector( + BaseEstimator, + TransformerMixin, + EstimatorIO, +): + """ + A transformer to assist with creating VotingChainladder workflows that weights between Incured/Paid or Reported/Closed. + + .. versionadded:: 0.10.0 + + Parameters + ---------- + col: str + which `column` to perform the subsequent estiamtion on + + Examples + -------- + Actuaries commonly uses both incurred and paid losses, or both reported and closed counts for estimating ultimate loss or ultimate count. We can use this helper class to create a singular VotingChainladder pipeline that weighs between incurred/paid methods, or reported/closed methods. + + .. testsetup:: + + import chainladder as cl + + .. testcode:: + + clrd = cl.load_sample('clrd').groupby('LOB').sum().loc['othliab'] + pipe_p = cl.Pipeline( + steps=[ + ('tri_sel', cl.TriangleSelector('CumPaidLoss')), + ('dev', cl.Development()), + ('model', cl.Chainladder()) + ] + ) + pipe_i = cl.Pipeline( + steps=[ + ('tri_sel', cl.TriangleSelector('IncurLoss')), + ('dev', cl.Development()), + ('model', cl.Chainladder()) + ] + ) + + estimators = [('incurred', pipe_i), ('paid', pipe_p)] + weights = np.array([[0.5, 0.5]] * 4 + [[0.75, 0.25]] * 3 + [[1, 0]] * 3) + vot = cl.VotingChainladder(estimators=estimators, weights=weights) + vot.fit(clrd) + print(vot.ultimate_) + + .. testoutput:: + + 2261 + 1988 323181.000000 + 1989 361191.317646 + 1990 384630.467254 + 1991 468742.807511 + 1992 483699.548213 + 1993 557869.162336 + 1994 625042.216134 + 1995 599845.906651 + 1996 672721.951809 + 1997 664061.404455 + """ + def __init__(self, col:str): + self.col = col + + def fit(self, X:Triangle, y:None=None): + return self + + def transform(self, X:Triangle): + return X[[self.col]] \ No newline at end of file