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
52 changes: 32 additions & 20 deletions chainladder/methods/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from __future__ import annotations

import numpy as np
import pandas as pd
import warnings
from sklearn.base import BaseEstimator
Expand All @@ -17,11 +16,11 @@
if TYPE_CHECKING:
from chainladder.core import Triangle

class MethodBase(BaseEstimator, EstimatorIO, Common):

class MethodBase(BaseEstimator, EstimatorIO, Common):
_estimator_type = "chainladder"

def validate_X(self, X):
def validate_X(self, X): # noqa: N802
obj = X.copy()
if "ldf_" not in obj:
obj = Development().fit_transform(obj)
Expand All @@ -30,11 +29,11 @@ def validate_X(self, X):
return obj.val_to_dev()

def _align_cdf(self, X, sample_weight=None):
""" Vertically align CDF to origin period latest diagonal. """
return X.cdf_.align_pattern(X,sample_weight)
"""Vertically align CDF to origin period latest diagonal."""
return X.cdf_.align_pattern(X, sample_weight)

def _set_ult_attr(self, ultimate):
""" Ultimate scaffolding """
"""Ultimate scaffolding"""
from chainladder import options

xp = ultimate.get_array_module()
Expand All @@ -57,7 +56,7 @@ def latest_diagonal(self):
if self.X_.is_cumulative:
return self.X_.latest_diagonal
else:
return self.X_.sum('development')
return self.X_.sum("development")

def fit(self, X, y=None, sample_weight=None):
"""Applies the chainladder technique to triangle **X**
Expand Down Expand Up @@ -103,17 +102,17 @@ def predict(self, X, sample_weight=None):
X_new = X.val_to_dev()
if sum(X_new.ddims > self.ldf_.ddims.max()) > 0:
raise ValueError("X has ages that exceed those available in model.")
X_new = X_new + (self.X_.val_to_dev().iloc[0,0].sum(2) * 0)
X_new = X_new + (self.X_.val_to_dev().iloc[0, 0].sum(2) * 0)
self.validate_weight(X_new, sample_weight)
if sample_weight:
sample_weight = sample_weight.set_backend(X_new.array_backend)
X_new.ldf_ = self.ldf_
X_new, X_new.ldf_ = self.intersection(X_new, X_new.ldf_)
return X_new

def intersection(self, a, b):
""" Given two Triangles with mismatched indices, this method aligns
their indices """
"""Given two Triangles with mismatched indices, this method aligns
their indices"""
if len(a) == 1 and len(b) == 1:
return a, b
intersection = list(set(a.key_labels).intersection(set(b.key_labels)))
Expand All @@ -122,12 +121,24 @@ def intersection(self, a, b):
a_idx = a.index[intersection]
b_idx = b.index[intersection]
idx_intersection = list(
set(a_idx.set_index(intersection).index.intersection(
b_idx.set_index(intersection).index)))
set(
a_idx.set_index(intersection).index.intersection(
b_idx.set_index(intersection).index
)
)
)
if (len(a) == 1 or len(b) == 1) and idx_intersection == []:
return a, b
b = b.iloc[b_idx[b_idx[intersection].set_index(intersection).index.isin(idx_intersection)].index]
a = a.iloc[a_idx[a_idx[intersection].set_index(intersection).index.isin(idx_intersection)].index]
b = b.iloc[
b_idx[
b_idx[intersection].set_index(intersection).index.isin(idx_intersection)
].index
]
a = a.iloc[
a_idx[
a_idx[intersection].set_index(intersection).index.isin(idx_intersection)
].index
]
return a, b

def fit_predict(self, X, y=None, sample_weight=None):
Expand All @@ -146,17 +157,18 @@ def _include_process_variance(self):

@staticmethod
def validate_weight(
X: Triangle,
sample_weight: Triangle
X: Triangle,
sample_weight: Triangle,
) -> None:
'''
"""
Checks that the a aprior has valid dimensions
'''
"""
if (
sample_weight
and X.shape[:-1] != sample_weight.shape[:-1]
and sample_weight.shape[2] != 1
and sample_weight.shape[0] > 1
):
warnings.warn(
"X and sample_weight are not aligned. Broadcasting may occur.\n")
"X and sample_weight are not aligned. Broadcasting may occur.\n"
)
24 changes: 13 additions & 11 deletions chainladder/methods/capecod.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# 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/.
import warnings
import numpy as np

from chainladder.methods import Benktander

Expand All @@ -16,12 +15,12 @@ class CapeCod(Benktander):
The cape cod trend assumption. Any Trend transformer on X will
override this argument.
decay: float, optional (default=1.0)
The cape cod decay assumption. This parameter is required by the
The cape cod decay assumption. This parameter is required by the
Generalized Cape Cod Method, as discussed in [Using Best Practices to
Determine a Best Reserve Estimate](https://www.casact.org/sites/default/files/database/forum_98fforum_struhuss.pdf)
by Struzzieri and Hussian. As the `decay` factor approaches 1
(the default value), the result approaches the traditional Cape Cod
method. As the `decay` factor approaches 0, the result approaches
Determine a Best Reserve Estimate](https://www.casact.org/sites/default/files/database/forum_98fforum_struhuss.pdf)
by Struzzieri and Hussian. As the `decay` factor approaches 1
(the default value), the result approaches the traditional Cape Cod
method. As the `decay` factor approaches 0, the result approaches
the `Chainladder` method.
n_iters: int, optional (default=1)
Number of iterations to use in the Benktander model.
Expand Down Expand Up @@ -80,9 +79,9 @@ class CapeCod(Benktander):
cl.CapeCod().fit(X=xyz["Paid"], sample_weight=xyz["Premium"].latest_diagonal).ibnr_
)
print(ibnr)

.. testoutput::

2261
1998 NaN
1999 88.211299
Expand Down Expand Up @@ -324,10 +323,13 @@ def predict(self, X, sample_weight=None):
# If model was fit at a higher grain, then need to aggregate predicted aprioris too
if len(set(sample_weight.key_labels) - set(self.apriori_.key_labels)) > 1:
apriori_, detrended_apriori_ = self._get_capecod_aprioris(
X_new.groupby(self.apriori_.key_labels).sum(),
sample_weight.groupby(self.apriori_.key_labels).sum())
X_new.groupby(self.apriori_.key_labels).sum(),
sample_weight.groupby(self.apriori_.key_labels).sum(),
)
else:
apriori_, detrended_apriori_ = self._get_capecod_aprioris(X_new, sample_weight)
apriori_, detrended_apriori_ = self._get_capecod_aprioris(
X_new, sample_weight
)
X_new.expectation_ = sample_weight * detrended_apriori_
X_new = super().predict(X_new, X_new.expectation_)
X_new.apriori_ = apriori_
Expand Down
32 changes: 18 additions & 14 deletions chainladder/methods/mack.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# 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/.
import numpy as np
import pandas as pd
from chainladder.methods import Chainladder


class MackChainladder(Chainladder):
""" Basic stochastic chainladder method popularized by Thomas Mack
"""
Basic stochastic chainladder method popularized by Thomas Mack

Parameters
----------
Expand Down Expand Up @@ -232,15 +232,13 @@ def predict(self, X, sample_weight=None):
X_new._full_triangle_ = X_new.full_triangle_
X_new.parameter_risk_ = self._mack_recursion("parameter_risk_", X_new)
X_new.process_risk_ = self._mack_recursion("process_risk_", X_new)
X_new.total_process_risk_ = (X_new.process_risk_ ** 2).sum(axis="origin").sqrt()
X_new.total_process_risk_ = (X_new.process_risk_**2).sum(axis="origin").sqrt()
X_new.total_parameter_risk_ = self._mack_recursion(
"total_parameter_risk_", X_new
)
X_new.full_std_err_ = self._get_full_std_err_(X_new)
X_new.total_mack_std_err_ = self._get_total_mack_std_err_(X_new)
X_new.mack_std_err_ = (
X_new.parameter_risk_ ** 2 + X_new.process_risk_ ** 2
).sqrt()
X_new.mack_std_err_ = (X_new.parameter_risk_**2 + X_new.process_risk_**2).sqrt()
del X_new._full_triangle_
return X_new

Expand Down Expand Up @@ -295,7 +293,9 @@ def _get_full_std_err_(self, X=None):
val = xp.broadcast_to(xp.array(avg + [avg[-1]]), X.shape)
weight = xp.sqrt(full.values[..., : len(X.ddims)] ** (2 - val))
obj.values = X.sigma_.values / num_to_nan(weight)
w = lxp.concatenate((X.w_, lxp.ones((val.shape[0], val.shape[1], val.shape[2], 1))), 3)
w = lxp.concatenate(
(X.w_, lxp.ones((val.shape[0], val.shape[1], val.shape[2], 1))), 3
)
w[xp.isnan(w)] = 1
obj.values = xp.nan_to_num(obj.values) * xp.array(w)
obj.valuation_date = full.valuation_date
Expand Down Expand Up @@ -335,7 +335,7 @@ def total_process_risk_(self):
72 84 9999
2007 1039.901929 1069.726277 1069.726277
"""
return (self.process_risk_ ** 2).sum(axis="origin").sqrt()
return (self.process_risk_**2).sum(axis="origin").sqrt()

def _mack_recursion(self, est, X=None):
obj = X.copy()
Expand All @@ -348,16 +348,20 @@ def _mack_recursion(self, est, X=None):
future_std_err = (
X._full_triangle_ - X[X.valuation < X.valuation_date]
).iloc[:, :, :, : X.shape[3]] * X.std_err_.values
#sum applies auto_sparse, so backend needs to be forced
t1_t = xp.nan_to_num(future_std_err.sum("origin").set_backend(backend).values)
# sum applies auto_sparse, so backend needs to be forced
t1_t = xp.nan_to_num(
future_std_err.sum("origin").set_backend(backend).values
)
obj.odims = obj.odims[0:1]
else:
nans = xp.nan_to_num(X.nan_triangle[None, None])
nans = 1 - xp.concatenate((nans, xp.zeros((1, 1, X.shape[2], 1))), 3)
full_tri = X._full_triangle_.values[..., : len(X.ddims)]
if est == "parameter_risk_":
#std_err_ is always numpy
t1_t = xp.nan_to_num(full_tri) * obj.std_err_.set_backend(backend).values
# std_err_ is always numpy
t1_t = (
xp.nan_to_num(full_tri) * obj.std_err_.set_backend(backend).values
)
else:
t1_t = xp.nan_to_num(full_tri) * self._get_full_std_err_(X).values
extend = X.ldf_.shape[-1] - X.shape[-1] + 1
Expand Down Expand Up @@ -413,7 +417,7 @@ def mack_std_err_(self):
2012 673.828536 693.166178 693.166178
2013 876.437914 901.408385 901.408385
"""
return (self.parameter_risk_ ** 2 + self.process_risk_ ** 2).sqrt()
return (self.parameter_risk_**2 + self.process_risk_**2).sqrt()

@property
def total_mack_std_err_(self):
Expand Down Expand Up @@ -452,7 +456,7 @@ def total_mack_std_err_(self):
return self._get_total_mack_std_err_(self)

def _get_total_mack_std_err_(self, obj):
obj = obj.total_process_risk_ ** 2 + obj.total_parameter_risk_ ** 2
obj = obj.total_process_risk_**2 + obj.total_parameter_risk_**2
if obj.array_backend == "sparse":
out = obj.set_backend("numpy").sqrt().values[..., 0, -1]
else:
Expand Down
42 changes: 32 additions & 10 deletions chainladder/methods/tests/test_benktander.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import chainladder as cl
import pandas as pd


@pytest.fixture
def atol():
return 1e-5
Expand All @@ -19,6 +20,7 @@ def test_bk_fit_weight():
with pytest.raises(ValueError):
cl.Benktander().fit(raa)


@pytest.mark.parametrize("data", data)
def test_benktander_to_chainladder(data, atol):
tri = cl.load_sample(data)
Expand Down Expand Up @@ -57,6 +59,7 @@ def test_different_backends(clrd):
< 1
)


def test_odd_shaped_triangle():
df = pd.DataFrame({
"claim_year": 2000 + pd.Series([0] * 8 + [1] * 4),
Expand All @@ -73,8 +76,25 @@ def test_odd_shaped_triangle():
cumulative=False,
)
atr = tr.grain("OYDQ")
ult1 = cl.Benktander(apriori = 1,n_iters=10000).fit(cl.Development(average="volume").fit_transform(atr),sample_weight = atr.latest_diagonal).ultimate_.sum()
ult2 = cl.Benktander(apriori = 1,n_iters=10000).fit(cl.Development(average="volume").fit_transform(tr),sample_weight = tr.latest_diagonal).ultimate_.grain("OYDQ").sum()
ult1 = (
cl
.Benktander(apriori=1, n_iters=10000)
.fit(
cl.Development(average="volume").fit_transform(atr),
sample_weight=atr.latest_diagonal,
)
.ultimate_.sum()
)
ult2 = (
cl
.Benktander(apriori=1, n_iters=10000)
.fit(
cl.Development(average="volume").fit_transform(tr),
sample_weight=tr.latest_diagonal,
)
.ultimate_.grain("OYDQ")
.sum()
)
assert abs(ult1 - ult2) < 1e-5


Expand All @@ -83,19 +103,21 @@ def test_bf_apriori_sigma_is_lognormal():
tri = cl.load_sample("genins")
boot = cl.BootstrapODPSample(n_sims=50000, random_state=42).fit_transform(tri)
w = boot.latest_diagonal.copy()
w.values = np.ones_like(w.values) # sample_weight=1 -> expectation_ == raw multiplier
w.values = np.ones_like(
w.values
) # sample_weight=1 -> expectation_ == raw multiplier

sigma = 0.8
bf = cl.BornhuetterFerguson(
apriori=1.0, apriori_sigma=sigma, random_state=7
).fit(boot, sample_weight=w)
bf = cl.BornhuetterFerguson(apriori=1.0, apriori_sigma=sigma, random_state=7).fit(
boot, sample_weight=w
)

mult = np.nanmean(bf.expectation_.values[:, 0, :, 0], axis=1)
mult = mult[np.isfinite(mult)]

assert (mult > 0).all() # strictly positive -> lognormal, not normal
assert abs(mult.mean() - 1.0) < 0.02 # mean preserved (E = apriori)
assert abs(mult.std() - sigma) < 0.03 # SD preserved (== apriori_sigma)
assert (mult > 0).all() # strictly positive -> lognormal, not normal
assert abs(mult.mean() - 1.0) < 0.02 # mean preserved (E = apriori)
assert abs(mult.std() - sigma) < 0.03 # SD preserved (== apriori_sigma)


def test_capecod_apriori_sigma_is_positive():
Expand All @@ -108,4 +130,4 @@ def test_capecod_apriori_sigma_is_positive():

vals = cc.expectation_.values
vals = vals[np.isfinite(vals)]
assert (vals > 0).all() # ~10% would be negative under the old normal
assert (vals > 0).all() # ~10% would be negative under the old normal
Loading
Loading