Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5d38b37
vdims
priyam0k Aug 25, 2026
bb2d905
style: fix ruff check and format issues on touched files
priyam0k Aug 25, 2026
3a4b500
fix(estimator): use columns_label for parameter DataFrames to fix doc…
priyam0k Sep 4, 2026
34d912e
feat: deprecate kdims in favor of Triangle.index and Triangle.key_lab…
priyam0k Sep 4, 2026
e2960e6
style: address N802 and formatting on touched files after main rebase
priyam0k Sep 5, 2026
b1dbbf9
fix(core): preserve legacy pickle compatibility for kdims/vdims migra…
priyam0k Sep 5, 2026
2798397
refactor(core): store columns as pd.Index and index as pd.DataFrame d…
priyam0k Sep 9, 2026
5386e8c
Fix Triangle index copy isolation, JSON I/O, and bootstrap dtypes
priyam0k Sep 9, 2026
f961ddd
style: run ruff format on changed core files
priyam0k Sep 9, 2026
1d6b0d5
fix kdims key labels
priyam0k Sep 12, 2026
4d5476d
test: improve patch coverage for dims deprecation and concat
priyam0k Sep 12, 2026
3c58110
Implement TriangleAxis for columns
priyam0k Sep 20, 2026
dba7735
Fix sort_index mutation
priyam0k Sep 20, 2026
dd86969
Address review comments
priyam0k Sep 22, 2026
bab08a2
Use integer axis keys
priyam0k Sep 28, 2026
10c4cae
Move axis helpers
priyam0k Sep 28, 2026
3a5c13d
Fix trailing newline
priyam0k Sep 29, 2026
261af10
Add patch coverage tests
priyam0k Oct 2, 2026
5228bd1
Validate boolean series length
priyam0k Oct 2, 2026
b04e0d6
Format core typing
priyam0k Oct 2, 2026
59e55dc
Fix duplicate column assignment
priyam0k Oct 3, 2026
4d2de42
Implement index axis descriptor
priyam0k Sep 29, 2026
c1339d2
Drop Triangle._index alias
priyam0k Oct 7, 2026
f60498b
Address review requested renames
priyam0k Oct 9, 2026
4df8368
Fix concat nan index
priyam0k Oct 9, 2026
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
17 changes: 5 additions & 12 deletions chainladder/adjustments/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,20 +271,13 @@ def _get_simulation(self, X, exp_incr_triangle):
resampled_triangles = (resampled_residual * xp.sqrt(abs(b)) + b).cumsum(2)
resampled_triangles = resampled_triangles[None, ...].swapaxes(0, 1)
obj = X.copy()
obj.values = resampled_triangles
if X.key_labels == ["Total"]:
obj.kdims = np.arange(self.n_sims)
obj.key_labels = ["Simulation_#"]
obj.index = pd.DataFrame({"Simulation_#": np.arange(self.n_sims)})
else:
obj.kdims = np.concat(
[
np.tile(X.kdims, (self.n_sims, 1)),
np.arange(self.n_sims).reshape(-1, 1),
],
axis=1,
)
obj.key_labels = X.key_labels + ["Simulation_#"]
obj.values = resampled_triangles
obj._set_slicers()
obj_idx = pd.concat([X.index] * self.n_sims, ignore_index=True)
obj_idx["Simulation_#"] = np.arange(self.n_sims)
obj.index = obj_idx
return obj, scale_phi

def _get_design_matrix(self, X):
Expand Down
13 changes: 3 additions & 10 deletions chainladder/adjustments/tests/test_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,6 @@ def test_bs_multiple_cols():
def test_multi_index(clrd):
tri = clrd["CumPaidLoss"].sum()
resampled_triangles = cl.BootstrapODPSample().fit(tri).resampled_triangles_
assert np.all(
resampled_triangles.index
== pd.DataFrame(
np.concat(
(np.array([["(All)", "(All)"]] * 1000), np.arange(1000).reshape(-1, 1)),
axis=1,
),
columns=["GRNAME", "LOB", "Simulation_#"],
)
)
expected = pd.concat([tri.index] * 1000, ignore_index=True)
expected["Simulation_#"] = np.arange(1000)
pd.testing.assert_frame_equal(resampled_triangles.index, expected)
164 changes: 164 additions & 0 deletions chainladder/core/axis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Axis descriptor for Triangle dimensions, analogous to Pandas AxisProperty."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Callable
import pandas as pd

from chainladder.utils.utility_functions import _get_axis_name, _get_axis_number

if TYPE_CHECKING:
from chainladder.core.triangle import Triangle


class TriangleAxis:
"""
Comment thread
genedan marked this conversation as resolved.
Generalized descriptor for representing a Triangle dimension,
analogous to Pandas AxisProperty.

Parameters
----------
axis : int or str
Axis number (0..3) or canonical name ('index', 'columns', 'origin',
'development') used to store and access raw axis data in ``obj._axes``.
fget : callable, optional
Transformation function taking ``(obj, raw)`` and returning the
public axis representation. If None, the raw value is returned.
fset : callable, optional
Transformation function taking ``(obj, value)`` and returning the
raw axis representation to be stored in ``obj._axes``. If None,
the value is stored as-is.
doc : str, optional
Docstring for the descriptor attribute on the class.
"""

def __init__(
self,
axis: int | str,
*,
fget: Callable[[Triangle, Any], Any] | None = None,
fset: Callable[[Triangle, Any], Any] | None = None,
doc: str | None = None,
Comment thread
genedan marked this conversation as resolved.
) -> None:
"""
Initialize a TriangleAxis descriptor.

Parameters
----------
axis : int or str
Axis number or name mapped to an integer in ``obj._axes``.
fget : callable, optional
Getter callable mapping ``(obj, raw)`` to public representation.
fset : callable, optional
Setter callable mapping ``(obj, value)`` to internal representation.
doc : str, optional
Docstring for the attribute.
"""
self.axis: int = _get_axis_number(axis)
self.key: int = self.axis
self.fget = fget # raw -> public; identity if None
self.fset = fset # (obj, public) -> raw; identity if None
self.__doc__ = doc

def __get__(self, obj: Triangle | None, objtype: type | None = None) -> Any:
"""
Access the axis value on a Triangle instance or return descriptor on class.

Parameters
----------
obj : Triangle or None
Instance of Triangle or None if accessed via class.
objtype : type, optional
Type of the owning class.

Returns
-------
Any
The descriptor itself if accessed on class, or the transformed
axis value if accessed on an instance.

Raises
------
AttributeError
If the axis key has not been initialized in ``obj._axes``.
"""
if obj is None:
return self
if not hasattr(obj, "_axes") or self.axis not in obj._axes:
axis_name = _get_axis_name(self.axis)
raise AttributeError(
f"'{type(obj).__name__}' object has no attribute '{axis_name}'"
)
raw = obj._axes[self.axis]
return self.fget(obj, raw) if self.fget else raw

def __set__(self, obj: Triangle, value: Any) -> None:
"""
Set the axis value on a Triangle instance.

Transforms ``value`` using ``fset`` if defined, stores the raw value
in ``obj._axes[self.axis]``, and updates slicers if necessary.

Parameters
----------
obj : Triangle
Instance of Triangle.
value : Any
New axis value provided by the user.
"""
raw = self.fset(obj, value) if self.fset else value
if not hasattr(obj, "_axes"):
obj._axes = {}
obj._axes[self.axis] = raw
if hasattr(obj, "virtual_columns"):
obj._set_slicers()


def _set_columns(obj: Triangle, value: Any) -> pd.Index:
"""
Validate and transform assigned columns into a pandas Index.

Parameters
----------
obj : Triangle
The Triangle instance being updated.
value : Any
The new column labels. Can be a string, list, or pandas Index.

Returns
-------
pd.Index
A pandas Index named ``"columns"``.
"""
if isinstance(value, str):
value = [value]
if hasattr(obj, "values") and obj.values is not None:
obj._len_check(range(obj.values.shape[1]), value)
elif hasattr(obj, "_axes") and (1 in obj._axes or "columns" in obj._axes):
obj._len_check(obj.columns, value)
return pd.Index(value, name="columns")


def _set_index(obj: Triangle, value: Any) -> pd.DataFrame:
"""
Validate and transform assigned index into a pandas DataFrame.

Parameters
----------
obj : Triangle
The Triangle instance being updated.
value : Any
The new index labels. Must be a pandas DataFrame.

Returns
-------
pd.DataFrame
A reset-index pandas DataFrame.
"""
if not isinstance(value, pd.DataFrame):
raise TypeError("index must be a pandas DataFrame")
if hasattr(obj, "values") and obj.values is not None:
obj._len_check(range(obj.values.shape[0]), value)
elif hasattr(obj, "_axes") and (0 in obj._axes or "index" in obj._axes):
obj._len_check(obj.index, value)
return value.copy().reset_index(drop=True)
19 changes: 12 additions & 7 deletions chainladder/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,9 @@ def _aggregate_data(
return data_agg

@staticmethod
def _set_kdims(data_agg: DataFrame, index: list) -> tuple[np.ndarray, np.ndarray]:
def _factorize_index(
data_agg: DataFrame, index: list
) -> tuple[np.ndarray, np.ndarray]:
"""
Sets the key dimension of the triangle.

Expand All @@ -302,15 +304,18 @@ def _set_kdims(data_agg: DataFrame, index: list) -> tuple[np.ndarray, np.ndarray
"""

# Get unique values of index and assign an integer value to each one.
kdims: DataFrame = (
unique_keys: DataFrame = (
data_agg[index].drop_duplicates().reset_index(drop=True).reset_index()
)

# Map these integers back to the agg data to generate a key index.
key_idx: np.ndarray = (
data_agg[index].merge(kdims, how="left", on=index)["index"].values[None].T
index_idx: np.ndarray = (
data_agg[index]
.merge(unique_keys, how="left", on=index)["index"]
.values[None]
.T
)
return kdims.drop(labels="index", axis=1).values, key_idx
return unique_keys.drop(labels="index", axis=1).values, index_idx

@staticmethod
def _set_odims(
Expand Down Expand Up @@ -353,7 +358,7 @@ def _set_ddims(
@staticmethod
def _set_values(
data_agg: DataFrame,
key_idx: np.ndarray,
index_idx: np.ndarray,
columns: list,
orig_idx: np.ndarray,
dev_idx: np.ndarray,
Expand All @@ -368,7 +373,7 @@ def _set_values(
tuple([np.concatenate((orig_idx, dev_idx), 1)] * len(columns)), 0
)
coords: np.ndarray = np.concatenate(
(np.concatenate(tuple([key_idx] * len(columns)), 0), val_idx, coords), 1
(np.concatenate(tuple([index_idx] * len(columns)), 0), val_idx, coords), 1
)
amts: np.ndarray = np.concatenate([
data_agg[col].fillna(0).values for col in data_agg[columns]
Expand Down
2 changes: 1 addition & 1 deletion chainladder/core/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ def ibnr_(self):
else:
ld = self.latest_diagonal if self.is_cumulative else self.sum(axis=3)
ibnr = self.ultimate_ - ld
ibnr.vdims = self.ultimate_.vdims
ibnr.columns = self.ultimate_.columns
return ibnr

@property
Expand Down
10 changes: 5 additions & 5 deletions chainladder/core/correlation.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def __init__(self, triangle, p_critical: float = 0.5):

# final big T
self.t_expectation = pd.DataFrame(
t_expectation[..., 0, 0], columns=triangle.vdims, index=idx
t_expectation[..., 0, 0], columns=triangle.columns, index=idx
)

# table of Spearman's rank coefficients Tk, can be used to verify consistency with paper
Expand Down Expand Up @@ -350,17 +350,17 @@ def p_z_lower(z: int, n: int, p: float = 0.5) -> float:
(self.range[0] > VarZ.sum(axis=-1))
| (VarZ.sum(axis=-1) > self.range[1])
)[..., 0],
columns=triangle.vdims,
columns=triangle.columns,
index=idx,
)
self.z = pd.DataFrame(
z.sum(axis=-1)[..., 0], columns=triangle.vdims, index=idx
z.sum(axis=-1)[..., 0], columns=triangle.columns, index=idx
)
self.z_expectation = pd.DataFrame(
EZ.sum(axis=-1)[..., 0], columns=triangle.vdims, index=idx
EZ.sum(axis=-1)[..., 0], columns=triangle.columns, index=idx
)
self.z_variance = pd.DataFrame(
VarZ.sum(axis=-1)[..., 0], columns=triangle.vdims, index=idx
VarZ.sum(axis=-1)[..., 0], columns=triangle.columns, index=idx
)


Expand Down
2 changes: 1 addition & 1 deletion chainladder/core/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def _summary_frame(self) -> DataFrame:
"O" + self.origin_grain + "D" + self.development_grain,
self.shape,
self.key_labels,
list(self.vdims),
list(self.columns),
],
index=["Valuation:", "Grain:", "Shape:", "Index:", "Columns:"],
name="Triangle Summary",
Expand Down
54 changes: 29 additions & 25 deletions chainladder/core/dunders.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,28 @@ def _compatibility_check(self, x, y):
return x, y

def _prep_index(self, x, y):
if x.kdims.shape[0] == 1 and y.kdims.shape[0] > 1:
x.kdims = y.kdims
x.key_labels = y.key_labels
if len(x.index) == 1 and len(y.index) > 1:
x._axes[0] = y.index.copy()
x.key_labels = list(y.key_labels)
x._set_slicers()
return x, y
if x.kdims.shape[0] > 1 and y.kdims.shape[0] == 1:
y.kdims = x.kdims
y.key_labels = x.key_labels
if len(x.index) > 1 and len(y.index) == 1:
y._axes[0] = x.index.copy()
y.key_labels = list(x.key_labels)
y._set_slicers()
return x, y
if x.kdims.shape[0] == y.kdims.shape[0] == 1 and x.key_labels != y.key_labels:
kdims = x.kdims if len(x.key_labels) > len(y.key_labels) else y.kdims
key_labels = (
x.key_labels if len(x.key_labels) > len(y.key_labels) else y.key_labels
if len(x.index) == len(y.index) == 1 and x.key_labels != y.key_labels:
index = (
x.index.copy()
if len(x.key_labels) > len(y.key_labels)
else y.index.copy()
)
x.kdims = y.kdims = kdims
x.key_labels = y.key_labels = key_labels
x._axes[0] = index.copy()
x.key_labels = list(index.columns)
x._set_slicers()
y._axes[0] = index.copy()
y.key_labels = list(index.columns)
y._set_slicers()
return x, y

# Use sets for faster operations
Expand All @@ -95,14 +102,10 @@ def _prep_index(self, x, y):
common = x_labels.intersection(y_labels)

if common == x_labels or common == y_labels:
if x_labels != y_labels or x.kdims.shape[0] != y.kdims.shape[0]:
if x_labels != y_labels or len(x.index) != len(y.index):
x = x.groupby(list(common))
y = y.groupby(list(common))
elif (
x.kdims.shape[0] > 1
and not np.array_equal(x.kdims, y.kdims)
and not x.index.equals(y.index)
):
elif len(x.index) > 1 and not x.index.equals(y.index):
x = x.sort_index()
try:
y = y.loc[x.index]
Expand All @@ -124,12 +127,14 @@ def _prep_index(self, x, y):

def _prep_columns(self, x, y):
if len(x.columns) == 1 and len(y.columns) > 1:
x.vdims = y.vdims
x._axes[1] = y.columns
x._set_slicers()
elif len(y.columns) == 1 and len(x.columns) > 1:
y.vdims = x.vdims
elif len(y.columns) == len(x.columns) == 1 and x.columns != y.columns:
y.vdims = x.vdims
elif x.shape[1] == y.shape[1] and np.array_equal(x.columns, y.columns):
y._axes[1] = x.columns
y._set_slicers()
elif len(y.columns) == len(x.columns) == 1 and not x.columns.equals(y.columns):
y.columns = x.columns
elif x.shape[1] == y.shape[1] and x.columns.equals(y.columns):
return x, y
else:
# Find columns to add to each triangle
Expand Down Expand Up @@ -260,8 +265,7 @@ def _slice_or_nan(obj, other, k):
.reset_index()
)
new_idx = new_idx[new_obj.key_labels].iloc[-1:]
new_obj.kdims = new_idx.values
new_obj.key_labels = list(new_idx.columns)
new_obj.index = new_idx
return new_obj

@staticmethod
Expand Down
Loading
Loading