From 5d38b37a6912e582bdd521bc42176710e8e7c8e7 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Wed, 26 Aug 2026 04:33:25 +0530 Subject: [PATCH 01/25] vdims --- chainladder/core/common.py | 2 +- chainladder/core/correlation.py | 10 ++--- chainladder/core/display.py | 2 +- chainladder/core/dunders.py | 6 +-- chainladder/core/pandas.py | 8 ++-- chainladder/core/slice.py | 16 ++++---- chainladder/core/tests/test_triangle.py | 26 ++++++++++--- chainladder/core/triangle.py | 45 ++++++++++++++++++----- chainladder/development/clark.py | 6 +-- chainladder/development/munich.py | 4 +- chainladder/tails/bondy.py | 6 +-- chainladder/tails/curve.py | 4 +- chainladder/utils/tests/test_utilities.py | 6 +-- chainladder/utils/utility_functions.py | 2 +- 14 files changed, 91 insertions(+), 52 deletions(-) diff --git a/chainladder/core/common.py b/chainladder/core/common.py index 9c3d44761..8b73648b8 100644 --- a/chainladder/core/common.py +++ b/chainladder/core/common.py @@ -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 diff --git a/chainladder/core/correlation.py b/chainladder/core/correlation.py index dfa88981b..ed832c509 100644 --- a/chainladder/core/correlation.py +++ b/chainladder/core/correlation.py @@ -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 @@ -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 ) diff --git a/chainladder/core/display.py b/chainladder/core/display.py index e2015b5f5..1c6b5bc13 100644 --- a/chainladder/core/display.py +++ b/chainladder/core/display.py @@ -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", diff --git a/chainladder/core/dunders.py b/chainladder/core/dunders.py index e340366e1..567359edd 100644 --- a/chainladder/core/dunders.py +++ b/chainladder/core/dunders.py @@ -124,11 +124,11 @@ 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._vdims = y._vdims elif len(y.columns) == 1 and len(x.columns) > 1: - y.vdims = x.vdims + y._vdims = x._vdims elif len(y.columns) == len(x.columns) == 1 and x.columns != y.columns: - y.vdims = x.vdims + y._vdims = x._vdims elif x.shape[1] == y.shape[1] and np.array_equal(x.columns, y.columns): return x, y else: diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 56e300999..968de19b5 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -171,7 +171,7 @@ def to_frame( tri: ndarray = np.squeeze(self.set_backend("numpy").values) axes_lookup: dict = { 0: self.kdims, - 1: self.vdims, + 1: self._vdims, 2: self.origin, 3: self.development, } @@ -1302,8 +1302,8 @@ def agg_func( # adjust the dimensions of the original object to match that of the aggregation. if axis == 0 and obj.values.shape[axis] == 1 and len(obj.kdims) > 1: obj.kdims = np.array([["(All)"] * len(obj.key_labels)]) - if axis == 1 and obj.values.shape[axis] == 1 and len(obj.vdims) > 1: - obj.vdims = np.array([0]) + if axis == 1 and obj.values.shape[axis] == 1 and len(obj._vdims) > 1: + obj._vdims = np.array([0]) if axis == 2 and obj.values.shape[axis] == 1 and len(obj.odims) > 1: obj.odims = obj.odims[0:1] # If axis is development, set the ddims to be the valuation date. @@ -1369,7 +1369,7 @@ def aggregate(i, obj, axis, v): obj.key_labels = index.columns.tolist() obj.kdims = index.values if self.axis == 1: - obj.vdims = pd.DataFrame(group_index).values[:, 0] + obj._vdims = pd.DataFrame(group_index).values[:, 0] if self.axis == 2: odims = self.obj._to_datetime( pd.Series(self.groups.indices.keys()).to_frame(), [0] diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py index 1b464205b..798a758f6 100644 --- a/chainladder/core/slice.py +++ b/chainladder/core/slice.py @@ -64,7 +64,7 @@ def get_idx(self, idx: tuple[_AxisKey, _AxisKey, _AxisKey, _AxisKey]) -> Triangl obj.values = obj.values[i_idx, :, o_idx, d_idx][:, c_idx, ...] # Set the new dimension values. obj.kdims = obj.kdims[i_idx] - obj.vdims = obj.vdims[c_idx] + obj._vdims = obj._vdims[c_idx] obj.odims, obj.ddims = obj.odims[o_idx], obj.ddims[d_idx] obj.valuation_date = cast( pd.Timestamp, np.minimum(obj.valuation.max(), obj.valuation_date) @@ -811,7 +811,7 @@ def __getitem__( [key] if isinstance(key, (str, int, float, np.generic)) else key ) # Identify the position of each requested element within the valuation dimension. - idx = [list(self.vdims).index(item) for item in keys] + idx = [list(self._vdims).index(item) for item in keys] return self.iloc[:, idx] def __setitem__( @@ -825,7 +825,7 @@ def __setitem__( Parameters ---------- key: str | int - The vdims label of the column to set. + The column label of the column to set. value: int | float | TriangleSlicer | Callable[[Triangle], TriangleSlicer] The value(s) to assign to the column. A callable defines a virtual (lazily-computed) column. @@ -873,10 +873,10 @@ def __setitem__( if callable(value): self.virtual_columns[key] = value if self.array_backend == "sparse": - if key not in self.vdims: + if key not in self._vdims: k, v, o, d = self.values.shape self.values.shape = k, v + 1, o, d - self.vdims = np.append(self.vdims, key) + self._vdims = np.append(self._vdims, key) return # Create a placeholder column. value = (self.iloc[:, 0].copy() * xp.nan).set_backend(self.array_backend) @@ -888,8 +888,8 @@ def __setitem__( if value.array_backend != self.array_backend: value = value.set_backend(self.array_backend) # Key exists in columns, replace data. - if key in self.vdims: - i = np.where(self.vdims == key)[0][0] + if key in self._vdims: + i = np.where(self._vdims == key)[0][0] # Case sparse backend. if self.array_backend == "sparse": # Unwrap a Triangle-valued assignment to its raw array. A raw @@ -927,7 +927,7 @@ def __setitem__( cast(np.ndarray, self.values)[:, i : i + 1] = value # Key is new, create a column and update data. else: - self.vdims = np.append(self.vdims, key) + self._vdims = np.append(self._vdims, key) if isinstance(value, (int, float, np.number)): # Broadcast scalar across the Triangle's shape. value = self.iloc[:, 0] * 0 + value diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index cd3515747..97f4fcad5 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -324,12 +324,14 @@ def test_origin_and_value_setters(raa): raa2 = raa.copy() raa.columns = list(raa.columns) raa.origin = list(raa.origin) - assert np.all(( - np.all(raa2.origin == raa.origin), - np.all(raa2.development == raa.development), - np.all(raa2.odims == raa.odims), - np.all(raa2.vdims == raa.vdims), - )) + assert np.all( + ( + np.all(raa2.origin == raa.origin), + np.all(raa2.development == raa.development), + np.all(raa2.odims == raa.odims), + np.all(raa2.columns == raa.columns), + ) + ) def test_index_setter_with_dataframe(clrd: Triangle) -> None: @@ -452,6 +454,18 @@ def test_set_index_not_inplace(clrd: Triangle) -> None: np.testing.assert_array_equal(tri.kdims, original_kdims) +def test_vdims_deprecation_warning(raa): + """Accessing or setting vdims should emit a FutureWarning.""" + with pytest.warns(FutureWarning, match="'vdims' attribute is deprecated"): + v = raa.vdims + assert np.all(v == raa.columns.values) + + raa2 = raa.copy() + with pytest.warns(FutureWarning, match="'vdims' attribute is deprecated"): + raa2.vdims = ["NewColumn"] + assert list(raa2.columns) == ["NewColumn"] + + def test_valdev1(qtr): assert qtr.dev_to_val().val_to_dev() == qtr diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 38efe3324..68c7813e5 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -618,14 +618,14 @@ def __init__( self.kdims: np.ndarray key_idx: np.ndarray - self.vdims: np.ndarray + self._vdims: np.ndarray self.odims: np.ndarray orig_idx: np.ndarray self.ddims: ArrayLike dev_idx: np.ndarray self.kdims, key_idx = self._set_kdims(data_agg, index) - self.vdims = np.array(columns) + self._vdims = np.array(columns) self.odims, orig_idx = self._set_odims(data_agg, date_axes) self.ddims, dev_idx = self._set_ddims(data_agg, date_axes) @@ -708,7 +708,7 @@ def __init__( sorted=True, shape=( len(self.kdims), - len(self.vdims), + len(self._vdims), len(self.odims), len(self.ddims), ), @@ -797,16 +797,41 @@ def index(self, value) -> None: @property def columns(self): - return pd.Index(self.vdims, name="columns") + return pd.Index(self._vdims, name="columns") @columns.setter def columns(self, value): self._len_check(self.columns, value) - self.vdims = [value] if type(value) is str else value - if type(self.vdims) is list: - self.vdims = np.array(self.vdims) + vdims = [value] if type(value) is str else value + if type(vdims) is list: + vdims = np.array(vdims) + self._vdims = vdims self._set_slicers() + @property + def vdims(self): + warnings.warn( + "The 'vdims' attribute is deprecated and will be removed in a future release. " + "Use 'Triangle.columns' or 'Triangle.columns_label' instead.", + FutureWarning, + stacklevel=2, + ) + return self._vdims + + @vdims.setter + def vdims(self, value): + warnings.warn( + "The 'vdims' attribute is deprecated and will be removed in a future release. " + "Use 'Triangle.columns' instead.", + FutureWarning, + stacklevel=2, + ) + if type(value) is str: + value = np.array([value]) + elif type(value) is list: + value = np.array(value) + self._vdims = value + @property def columns_label(self) -> list: return self.columns.to_list() @@ -2426,10 +2451,10 @@ def sort_axis(self, axis): return self.sort_index() obj = self.copy() if axis == 1: - sort = pd.Series(self.vdims).sort_values().index - if np.any(sort != pd.Series(self.vdims).index): + sort = pd.Series(self._vdims).sort_values().index + if np.any(sort != pd.Series(self._vdims).index): obj.values = obj.values[:, list(sort), ...] - obj.vdims = obj.vdims[list(sort)] + obj._vdims = obj._vdims[list(sort)] if axis == 2: sort = pd.Series(self.odims).sort_values().index if np.any(sort != pd.Series(self.odims).index): diff --git a/chainladder/development/clark.py b/chainladder/development/clark.py index adda5e414..1dd595612 100644 --- a/chainladder/development/clark.py +++ b/chainladder/development/clark.py @@ -329,10 +329,10 @@ def ldf(age): self.ldf_ = obj self.ldf_.valuation_date = pd.to_datetime(options.ULT_VAL) rows = X.index.set_index(X.key_labels).index - self.omega_ = pd.DataFrame(params[..., 0, 0], index=rows, columns=X.vdims) - self.theta_ = pd.DataFrame(params[..., 0, 1], index=rows, columns=X.vdims) + self.omega_ = pd.DataFrame(params[..., 0, 0], index=rows, columns=X.columns) + self.theta_ = pd.DataFrame(params[..., 0, 1], index=rows, columns=X.columns) if sample_weight: - self.elr_ = pd.DataFrame(params[..., 0, 2], index=rows, columns=X.vdims) + self.elr_ = pd.DataFrame(params[..., 0, 2], index=rows, columns=X.columns) ultimate_ = ( self._G(age=(latest_age - age_offset)[::-1]).swapaxes(-1, -2) * ld.values ) diff --git a/chainladder/development/munich.py b/chainladder/development/munich.py index 87f34b3f3..cad2efd34 100644 --- a/chainladder/development/munich.py +++ b/chainladder/development/munich.py @@ -364,11 +364,11 @@ def _get_mcl_cdf(self, X, munich_full_triangle_): cdf_triangle = cdf_triangle[..., -1:] / cdf_triangle[..., :-1] paid = [item[0] for item in p_to_i] for n, item in enumerate(paid): - idx = np.where(X.cdf_.vdims == item)[0][0] + idx = np.where(X.cdf_.columns == item)[0][0] obj.values[:, idx : idx + 1, ...] = cdf_triangle[0, :, n : n + 1, ...] incurred = [item[1] for item in p_to_i] for n, item in enumerate(incurred): - idx = np.where(X.cdf_.vdims == item)[0][0] + idx = np.where(X.cdf_.columns == item)[0][0] obj.values[:, idx : idx + 1, ...] = cdf_triangle[1, :, n : n + 1, ...] obj._set_slicers() return obj diff --git a/chainladder/tails/bondy.py b/chainladder/tails/bondy.py index ae32f1cd8..712d256ca 100644 --- a/chainladder/tails/bondy.py +++ b/chainladder/tails/bondy.py @@ -161,7 +161,7 @@ def fit(self, X, y=None, sample_weight=None): obj = Development().fit_transform(X) if "ldf_" not in X else X b_optimized = [] initial = xp.where(obj.ddims == earliest_age)[0][0] if earliest_age else 0 - for num in range(len(obj.vdims)): + for num in range(len(obj.columns)): b0 = (xp.ones(obj.shape[0]) * 0.5)[:, None] data = xp.log(obj.ldf_.values[:, num, 0, initial:]) b0 = xp.concatenate((b0, data[..., 0:1]), axis=1) @@ -192,9 +192,9 @@ def fit(self, X, y=None, sample_weight=None): ) fitted = xp.repeat(fitted, self.ldf_.shape[2], axis=2) rows = X.index.set_index(X.key_labels).index - self.b_ = pd.DataFrame(self.b_[..., 0, 0], index=rows, columns=X.vdims) + self.b_ = pd.DataFrame(self.b_[..., 0, 0], index=rows, columns=X.columns) self.earliest_ldf_ = pd.DataFrame( - self.earliest_ldf_[..., 0, 0], index=rows, columns=X.vdims + self.earliest_ldf_[..., 0, 0], index=rows, columns=X.columns ) self.ldf_.values = xp.concatenate( ( diff --git a/chainladder/tails/curve.py b/chainladder/tails/curve.py index 7782f460b..8566ea904 100644 --- a/chainladder/tails/curve.py +++ b/chainladder/tails/curve.py @@ -290,7 +290,7 @@ def slope_(self): """Does not work with munich""" rows = self.ldf_.index.set_index(self.ldf_.key_labels).index return pd.DataFrame( - self._slope_[..., 0, 0], index=rows, columns=self.ldf_.vdims + self._slope_[..., 0, 0], index=rows, columns=self.ldf_.columns ) @property @@ -298,5 +298,5 @@ def intercept_(self): """Does not work with munich""" rows = self.ldf_.index.set_index(self.ldf_.key_labels).index return pd.DataFrame( - self._intercept_[..., 0, 0], index=rows, columns=self.ldf_.vdims + self._intercept_[..., 0, 0], index=rows, columns=self.ldf_.columns ) diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 96e5e7243..713c76a82 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -60,7 +60,7 @@ def test_triangle_json_io(clrd): clrd2 = cl.read_json(clrd.to_json(), array_backend=clrd.array_backend) assert clrd == clrd2 assert np.all(clrd.kdims == clrd2.kdims) - assert np.all(clrd.vdims == clrd2.vdims) + assert np.all(clrd.columns == clrd2.columns) assert np.all(clrd.odims == clrd2.odims) assert np.all(clrd.ddims == clrd2.ddims) assert np.all(clrd.valuation == clrd2.valuation) @@ -370,7 +370,7 @@ def test_load_sample_uspp() -> None: "friedland_uspp_increasing_claim_case", ]: tri = cl.load_sample(key) - assert set(str(c) for c in tri.vdims) == { + assert set(str(c) for c in tri.columns) == { "Reported Claims", "Paid Claims", "Earned Premium", @@ -396,7 +396,7 @@ def test_load_sample_clrd2025() -> None: "EarnedPremCeded", "EarnedPremNet", } - assert set(str(c) for c in tri.vdims) == expected_columns + assert set(str(c) for c in tri.columns) == expected_columns # Accident years span 1998-2007. assert str(tri.origin.min()) == "1998" diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index a7822e062..aa1f3bed7 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -703,7 +703,7 @@ def concat( if list(objs[num].columns) != all_columns: objs[num] = objs[num][all_columns] objs = set_common_backend(objs) - mapper = {0: "kdims", 1: "vdims", 2: "odims", 3: "ddims"} + mapper = {0: "kdims", 1: "_vdims", 2: "odims", 3: "ddims"} for k in mapper.keys(): if k != axis and k != 1: # All non-concat axes must be identical a = np.array([getattr(obj, mapper[k]) for obj in objs]) From bb2d9050ce6d9503449bb29e27fd65e3e7ff6173 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Wed, 26 Aug 2026 04:58:25 +0530 Subject: [PATCH 02/25] style: fix ruff check and format issues on touched files --- chainladder/tails/curve.py | 1 - 1 file changed, 1 deletion(-) diff --git a/chainladder/tails/curve.py b/chainladder/tails/curve.py index 8566ea904..4ec706f2b 100644 --- a/chainladder/tails/curve.py +++ b/chainladder/tails/curve.py @@ -174,7 +174,6 @@ def fit(self, X, y=None, sample_weight=None): self : object Returns the instance itself. """ - X = X.copy() xp = X.get_array_module() if type(self.fit_period) is slice: From 3a4b500f3f07f6bb2d691f82b6ad3c7e241ae269 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Fri, 4 Sep 2026 16:05:09 +0530 Subject: [PATCH 03/25] fix(estimator): use columns_label for parameter DataFrames to fix doctests and ruff formatting --- chainladder/development/clark.py | 12 +++++++++--- chainladder/tails/bondy.py | 4 ++-- chainladder/tails/curve.py | 4 ++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/chainladder/development/clark.py b/chainladder/development/clark.py index 1dd595612..3fa7c6a16 100644 --- a/chainladder/development/clark.py +++ b/chainladder/development/clark.py @@ -329,10 +329,16 @@ def ldf(age): self.ldf_ = obj self.ldf_.valuation_date = pd.to_datetime(options.ULT_VAL) rows = X.index.set_index(X.key_labels).index - self.omega_ = pd.DataFrame(params[..., 0, 0], index=rows, columns=X.columns) - self.theta_ = pd.DataFrame(params[..., 0, 1], index=rows, columns=X.columns) + self.omega_ = pd.DataFrame( + params[..., 0, 0], index=rows, columns=X.columns_label + ) + self.theta_ = pd.DataFrame( + params[..., 0, 1], index=rows, columns=X.columns_label + ) if sample_weight: - self.elr_ = pd.DataFrame(params[..., 0, 2], index=rows, columns=X.columns) + self.elr_ = pd.DataFrame( + params[..., 0, 2], index=rows, columns=X.columns_label + ) ultimate_ = ( self._G(age=(latest_age - age_offset)[::-1]).swapaxes(-1, -2) * ld.values ) diff --git a/chainladder/tails/bondy.py b/chainladder/tails/bondy.py index 712d256ca..c718fb82f 100644 --- a/chainladder/tails/bondy.py +++ b/chainladder/tails/bondy.py @@ -192,9 +192,9 @@ def fit(self, X, y=None, sample_weight=None): ) fitted = xp.repeat(fitted, self.ldf_.shape[2], axis=2) rows = X.index.set_index(X.key_labels).index - self.b_ = pd.DataFrame(self.b_[..., 0, 0], index=rows, columns=X.columns) + self.b_ = pd.DataFrame(self.b_[..., 0, 0], index=rows, columns=X.columns_label) self.earliest_ldf_ = pd.DataFrame( - self.earliest_ldf_[..., 0, 0], index=rows, columns=X.columns + self.earliest_ldf_[..., 0, 0], index=rows, columns=X.columns_label ) self.ldf_.values = xp.concatenate( ( diff --git a/chainladder/tails/curve.py b/chainladder/tails/curve.py index 4ec706f2b..ecc363535 100644 --- a/chainladder/tails/curve.py +++ b/chainladder/tails/curve.py @@ -289,7 +289,7 @@ def slope_(self): """Does not work with munich""" rows = self.ldf_.index.set_index(self.ldf_.key_labels).index return pd.DataFrame( - self._slope_[..., 0, 0], index=rows, columns=self.ldf_.columns + self._slope_[..., 0, 0], index=rows, columns=self.ldf_.columns_label ) @property @@ -297,5 +297,5 @@ def intercept_(self): """Does not work with munich""" rows = self.ldf_.index.set_index(self.ldf_.key_labels).index return pd.DataFrame( - self._intercept_[..., 0, 0], index=rows, columns=self.ldf_.columns + self._intercept_[..., 0, 0], index=rows, columns=self.ldf_.columns_label ) From 34d912efd1c6662b5ec90f195259413599795414 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 5 Sep 2026 04:54:05 +0530 Subject: [PATCH 04/25] feat: deprecate kdims in favor of Triangle.index and Triangle.key_labels (#1010) --- chainladder/adjustments/bootstrap.py | 11 ++------ chainladder/core/dunders.py | 23 ++++++++------- chainladder/core/pandas.py | 8 +++--- chainladder/core/slice.py | 4 +-- chainladder/core/tests/test_triangle.py | 15 ++++++++-- chainladder/core/triangle.py | 34 +++++++++++++++++++---- chainladder/methods/benktander.py | 3 +- chainladder/utils/tests/test_utilities.py | 2 +- chainladder/utils/utility_functions.py | 2 +- 9 files changed, 66 insertions(+), 36 deletions(-) diff --git a/chainladder/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py index a25929712..892a9b1f7 100644 --- a/chainladder/adjustments/bootstrap.py +++ b/chainladder/adjustments/bootstrap.py @@ -219,11 +219,6 @@ def fit(self, X, y=None, sample_weight=None): self.resampled_triangles_, self.scale_ = self._get_simulation( X, exp_incr_triangle ) - # n_obs = xp.nansum(self.w_) - # n_origin_params = X.shape[2] - # n_dev_params = X.shape[3] - 1 - # deg_free = n_obs - n_origin_params - n_dev_params - # deg_free_adj_fctr = xp.sqrt(n_obs / deg_free) return self def _get_simulation(self, X, exp_incr_triangle): @@ -272,12 +267,12 @@ def _get_simulation(self, X, exp_incr_triangle): resampled_triangles = resampled_triangles[None, ...].swapaxes(0, 1) obj = X.copy() if X.key_labels == ["Total"]: - obj.kdims = np.arange(self.n_sims) + obj._kdims = np.arange(self.n_sims) obj.key_labels = ["Simulation_#"] else: - obj.kdims = np.concat( + obj._kdims = np.concat( [ - np.tile(X.kdims, (self.n_sims, 1)), + np.tile(X._kdims, (self.n_sims, 1)), np.arange(self.n_sims).reshape(-1, 1), ], axis=1, diff --git a/chainladder/core/dunders.py b/chainladder/core/dunders.py index 567359edd..f27d4bdab 100644 --- a/chainladder/core/dunders.py +++ b/chainladder/core/dunders.py @@ -72,20 +72,20 @@ 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 + if x._kdims.shape[0] == 1 and y._kdims.shape[0] > 1: + x._kdims = y._kdims x.key_labels = y.key_labels return x, y - if x.kdims.shape[0] > 1 and y.kdims.shape[0] == 1: - y.kdims = x.kdims + if x._kdims.shape[0] > 1 and y._kdims.shape[0] == 1: + y._kdims = x._kdims y.key_labels = x.key_labels 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 + 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 ) - x.kdims = y.kdims = kdims + x._kdims = y._kdims = kdims x.key_labels = y.key_labels = key_labels return x, y @@ -95,12 +95,12 @@ 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 x._kdims.shape[0] != y._kdims.shape[0]: 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) + x._kdims.shape[0] > 1 + and not np.array_equal(x._kdims, y._kdims) and not x.index.equals(y.index) ): x = x.sort_index() @@ -260,8 +260,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 diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 968de19b5..182a0c67f 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -170,7 +170,7 @@ def to_frame( elif len(axes) in [1, 2]: tri: ndarray = np.squeeze(self.set_backend("numpy").values) axes_lookup: dict = { - 0: self.kdims, + 0: self._kdims, 1: self._vdims, 2: self.origin, 3: self.development, @@ -1300,8 +1300,8 @@ def agg_func( # Aggregation function will collapse a dimension, so # adjust the dimensions of the original object to match that of the aggregation. - if axis == 0 and obj.values.shape[axis] == 1 and len(obj.kdims) > 1: - obj.kdims = np.array([["(All)"] * len(obj.key_labels)]) + if axis == 0 and obj.values.shape[axis] == 1 and len(obj._kdims) > 1: + obj._kdims = np.array([["(All)"] * len(obj.key_labels)]) if axis == 1 and obj.values.shape[axis] == 1 and len(obj._vdims) > 1: obj._vdims = np.array([0]) if axis == 2 and obj.values.shape[axis] == 1 and len(obj.odims) > 1: @@ -1367,7 +1367,7 @@ def aggregate(i, obj, axis, v): else: index = pd.DataFrame(group_index) obj.key_labels = index.columns.tolist() - obj.kdims = index.values + obj._kdims = index.values if self.axis == 1: obj._vdims = pd.DataFrame(group_index).values[:, 0] if self.axis == 2: diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py index 798a758f6..0eb65756f 100644 --- a/chainladder/core/slice.py +++ b/chainladder/core/slice.py @@ -63,7 +63,7 @@ def get_idx(self, idx: tuple[_AxisKey, _AxisKey, _AxisKey, _AxisKey]) -> Triangl else: obj.values = obj.values[i_idx, :, o_idx, d_idx][:, c_idx, ...] # Set the new dimension values. - obj.kdims = obj.kdims[i_idx] + obj._kdims = obj._kdims[i_idx] obj._vdims = obj._vdims[c_idx] obj.odims, obj.ddims = obj.odims[o_idx], obj.ddims[d_idx] obj.valuation_date = cast( @@ -383,7 +383,7 @@ def index_key(self, key: _LabelKey) -> np.ndarray: ).values.flatten() # Case scalar, locate position in first level of index. else: - idx = np.where(self.obj.kdims[:, 0] == key)[0] + idx = np.where(self.obj._kdims[:, 0] == key)[0] return idx def other_key( diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 97f4fcad5..182a556c8 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -174,8 +174,7 @@ def test_sum_of_diff_eq_diff_of_sum(clrd): def test_append(raa): raa2 = raa.copy() - raa2.kdims = np.array([["P2"]]) - raa.append(raa2).sum() == raa * 2 + raa2.index = pd.DataFrame([["P2"]], columns=raa.key_labels) assert raa.append(raa2).sum() == 2 * raa @@ -466,6 +465,18 @@ def test_vdims_deprecation_warning(raa): assert list(raa2.columns) == ["NewColumn"] +def test_kdims_deprecation_warning(raa): + """Accessing or setting kdims should emit a FutureWarning.""" + with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): + k = raa.kdims + assert np.all(k == raa.index.values) + + raa2 = raa.copy() + with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): + raa2.kdims = np.array([["P2"]]) + assert list(raa2.index.values) == [["P2"]] + + def test_valdev1(qtr): assert qtr.dev_to_val().val_to_dev() == qtr diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 68c7813e5..98ccfb5ef 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -616,7 +616,7 @@ def __init__( self.index_label: list = index data_agg[index[0]] = "Total" - self.kdims: np.ndarray + self._kdims: np.ndarray key_idx: np.ndarray self._vdims: np.ndarray self.odims: np.ndarray @@ -624,7 +624,7 @@ def __init__( self.ddims: ArrayLike dev_idx: np.ndarray - self.kdims, key_idx = self._set_kdims(data_agg, index) + self._kdims, key_idx = self._set_kdims(data_agg, index) self._vdims = np.array(columns) self.odims, orig_idx = self._set_odims(data_agg, date_axes) self.ddims, dev_idx = self._set_ddims(data_agg, date_axes) @@ -707,7 +707,7 @@ def __init__( has_duplicates=False, sorted=True, shape=( - len(self.kdims), + len(self._kdims), len(self._vdims), len(self.odims), len(self.ddims), @@ -783,18 +783,42 @@ def index(self) -> DataFrame: """ Returns a DataFrame of the unique values of the index. """ - return pd.DataFrame(list(self.kdims), columns=self.key_labels) + return pd.DataFrame(list(self._kdims), columns=self.key_labels) @index.setter def index(self, value) -> None: self._len_check(self.index, value) if type(value) is pd.DataFrame: - self.kdims = value.values + self._kdims = value.values self.key_labels = list(value.columns) self._set_slicers() else: raise TypeError("index must be a pandas DataFrame") + @property + def kdims(self): + warnings.warn( + "The 'kdims' attribute is deprecated and will be removed in a future release. " + "Use 'Triangle.index' or 'Triangle.key_labels' instead.", + FutureWarning, + stacklevel=2, + ) + return self._kdims + + @kdims.setter + def kdims(self, value): + warnings.warn( + "The 'kdims' attribute is deprecated and will be removed in a future release. " + "Use 'Triangle.index' or 'Triangle.key_labels' instead.", + FutureWarning, + stacklevel=2, + ) + if type(value) is str: + value = np.array([[value]]) + elif type(value) is list: + value = np.array(value) + self._kdims = value + @property def columns(self): return pd.Index(self._vdims, name="columns") diff --git a/chainladder/methods/benktander.py b/chainladder/methods/benktander.py index 7ae68b8a6..8c45a4745 100644 --- a/chainladder/methods/benktander.py +++ b/chainladder/methods/benktander.py @@ -271,8 +271,9 @@ def _get_benktander_aprioris(self, X, sample_weight): apriori = random_state.lognormal(mu_log, sigma_log, X.shape[0]) apriori = apriori.reshape(X.shape[0], -1)[..., None, None] apriori = sample_weight * apriori - apriori.kdims = X.kdims + apriori._kdims = X._kdims apriori.key_labels = X.key_labels + apriori._set_slicers() else: apriori = sample_weight * self.apriori apriori.columns = sample_weight.columns diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 713c76a82..00e06aca0 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -59,7 +59,7 @@ def from_sequence(seq): def test_triangle_json_io(clrd): clrd2 = cl.read_json(clrd.to_json(), array_backend=clrd.array_backend) assert clrd == clrd2 - assert np.all(clrd.kdims == clrd2.kdims) + assert clrd.index.equals(clrd2.index) assert np.all(clrd.columns == clrd2.columns) assert np.all(clrd.odims == clrd2.odims) assert np.all(clrd.ddims == clrd2.ddims) diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index aa1f3bed7..157764f47 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -703,7 +703,7 @@ def concat( if list(objs[num].columns) != all_columns: objs[num] = objs[num][all_columns] objs = set_common_backend(objs) - mapper = {0: "kdims", 1: "_vdims", 2: "odims", 3: "ddims"} + mapper = {0: "_kdims", 1: "_vdims", 2: "odims", 3: "ddims"} for k in mapper.keys(): if k != axis and k != 1: # All non-concat axes must be identical a = np.array([getattr(obj, mapper[k]) for obj in objs]) From e2960e6a06445ad82257e3ffe304125ac755c4e5 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 5 Sep 2026 16:41:26 +0530 Subject: [PATCH 05/25] style: address N802 and formatting on touched files after main rebase --- chainladder/core/tests/test_triangle.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 182a556c8..2b82d8221 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -323,14 +323,12 @@ def test_origin_and_value_setters(raa): raa2 = raa.copy() raa.columns = list(raa.columns) raa.origin = list(raa.origin) - assert np.all( - ( - np.all(raa2.origin == raa.origin), - np.all(raa2.development == raa.development), - np.all(raa2.odims == raa.odims), - np.all(raa2.columns == raa.columns), - ) - ) + assert np.all(( + np.all(raa2.origin == raa.origin), + np.all(raa2.development == raa.development), + np.all(raa2.odims == raa.odims), + np.all(raa2.columns == raa.columns), + )) def test_index_setter_with_dataframe(clrd: Triangle) -> None: From b1dbbf9809803f93ca01f3dc31b0cbf959e19798 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 5 Sep 2026 17:58:50 +0530 Subject: [PATCH 06/25] fix(core): preserve legacy pickle compatibility for kdims/vdims migration --- chainladder/core/tests/test_triangle.py | 22 ++++++++++++++++++++++ chainladder/core/triangle.py | 8 ++++++++ 2 files changed, 30 insertions(+) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 2b82d8221..64b2d0816 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -475,6 +475,28 @@ def test_kdims_deprecation_warning(raa): assert list(raa2.index.values) == [["P2"]] +def test_legacy_pickle_compatibility(raa): + """Pickles saved prior to kdims/vdims rename should unpickle cleanly.""" + import pickle + + state = raa.__dict__.copy() + # Simulate a legacy serialized Triangle containing kdims and vdims + state["kdims"] = state.pop("_kdims") + state["vdims"] = state.pop("_vdims") + + restored = cl.Triangle.__new__(cl.Triangle) + restored.__setstate__(state) + + assert restored.index.equals(raa.index) + assert list(restored.columns) == list(raa.columns) + assert restored.loc["Total"].shape == raa.loc["Total"].shape + assert restored == raa + + # Verify re-pickling the migrated instance works + roundtripped = pickle.loads(pickle.dumps(restored)) + assert roundtripped == raa + + def test_valdev1(qtr): assert qtr.dev_to_val().val_to_dev() == qtr diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 98ccfb5ef..dbad7eed3 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -2201,6 +2201,14 @@ def copy(self): X.values = X.values.copy() return X + def __setstate__(self, state: dict) -> None: + """Migrate legacy pickled instances with 'kdims'/'vdims' to '_kdims'/'_vdims'.""" + if "kdims" in state and "_kdims" not in state: + state["_kdims"] = state.pop("kdims") + if "vdims" in state and "_vdims" not in state: + state["_vdims"] = state.pop("vdims") + self.__dict__.update(state) + def development_correlation(self, p_critical=0.5): """ Mack (1997) test for correlations between subsequent development From 279839764cc425eb2738e4b692d486c6795c38ab Mon Sep 17 00:00:00 2001 From: priyam0k Date: Wed, 9 Sep 2026 17:07:20 +0530 Subject: [PATCH 07/25] refactor(core): store columns as pd.Index and index as pd.DataFrame directly --- chainladder/adjustments/bootstrap.py | 7 ++- chainladder/core/dunders.py | 47 ++++++++------- chainladder/core/pandas.py | 17 +++--- chainladder/core/slice.py | 18 +++--- chainladder/core/tests/test_triangle.py | 48 ++++++++++----- chainladder/core/triangle.py | 78 ++++++++++++++----------- chainladder/methods/benktander.py | 4 +- chainladder/utils/utility_functions.py | 71 +++++++++++++++------- 8 files changed, 176 insertions(+), 114 deletions(-) diff --git a/chainladder/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py index 892a9b1f7..9ab098315 100644 --- a/chainladder/adjustments/bootstrap.py +++ b/chainladder/adjustments/bootstrap.py @@ -267,16 +267,17 @@ def _get_simulation(self, X, exp_incr_triangle): resampled_triangles = resampled_triangles[None, ...].swapaxes(0, 1) obj = X.copy() if X.key_labels == ["Total"]: - obj._kdims = np.arange(self.n_sims) + obj._index = pd.DataFrame({"Simulation_#": np.arange(self.n_sims)}) obj.key_labels = ["Simulation_#"] else: - obj._kdims = np.concat( + kdims = np.concat( [ - np.tile(X._kdims, (self.n_sims, 1)), + np.tile(X.index.to_numpy(dtype=str), (self.n_sims, 1)), np.arange(self.n_sims).reshape(-1, 1), ], axis=1, ) + obj._index = pd.DataFrame(kdims, columns=X.key_labels + ["Simulation_#"]) obj.key_labels = X.key_labels + ["Simulation_#"] obj.values = resampled_triangles obj._set_slicers() diff --git a/chainladder/core/dunders.py b/chainladder/core/dunders.py index f27d4bdab..edf4ba30e 100644 --- a/chainladder/core/dunders.py +++ b/chainladder/core/dunders.py @@ -72,21 +72,24 @@ 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._index = 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._index = 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 - ) - x._kdims = y._kdims = kdims - x.key_labels = y.key_labels = 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._index = index.copy() + x.key_labels = list(index.columns) + x._set_slicers() + y._index = index.copy() + y.key_labels = list(index.columns) + y._set_slicers() return x, y # Use sets for faster operations @@ -95,12 +98,11 @@ 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) + len(x.index) > 1 and not x.index.equals(y.index) ): x = x.sort_index() @@ -124,12 +126,15 @@ 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._columns = 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._columns = x.columns + y._set_slicers() + elif len(y.columns) == len(x.columns) == 1 and not x.columns.equals(y.columns): + y._columns = x.columns + y._set_slicers() + elif x.shape[1] == y.shape[1] and x.columns.equals(y.columns): return x, y else: # Find columns to add to each triangle diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 182a0c67f..eb19a07ab 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -170,8 +170,8 @@ def to_frame( elif len(axes) in [1, 2]: tri: ndarray = np.squeeze(self.set_backend("numpy").values) axes_lookup: dict = { - 0: self._kdims, - 1: self._vdims, + 0: self.index.values, + 1: self.columns.values, 2: self.origin, 3: self.development, } @@ -1300,10 +1300,10 @@ def agg_func( # Aggregation function will collapse a dimension, so # adjust the dimensions of the original object to match that of the aggregation. - if axis == 0 and obj.values.shape[axis] == 1 and len(obj._kdims) > 1: - obj._kdims = np.array([["(All)"] * len(obj.key_labels)]) - if axis == 1 and obj.values.shape[axis] == 1 and len(obj._vdims) > 1: - obj._vdims = np.array([0]) + if axis == 0 and obj.values.shape[axis] == 1 and len(obj.index) > 1: + obj._index = pd.DataFrame([["(All)"] * len(obj.key_labels)], columns=obj.key_labels) + if axis == 1 and obj.values.shape[axis] == 1 and len(obj.columns) > 1: + obj._columns = pd.Index([0], name="columns") if axis == 2 and obj.values.shape[axis] == 1 and len(obj.odims) > 1: obj.odims = obj.odims[0:1] # If axis is development, set the ddims to be the valuation date. @@ -1366,10 +1366,9 @@ def aggregate(i, obj, axis, v): obj.index = index else: index = pd.DataFrame(group_index) - obj.key_labels = index.columns.tolist() - obj._kdims = index.values + obj.index = index if self.axis == 1: - obj._vdims = pd.DataFrame(group_index).values[:, 0] + obj.columns = group_index if self.axis == 2: odims = self.obj._to_datetime( pd.Series(self.groups.indices.keys()).to_frame(), [0] diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py index 0eb65756f..28eb5cbd0 100644 --- a/chainladder/core/slice.py +++ b/chainladder/core/slice.py @@ -63,8 +63,8 @@ def get_idx(self, idx: tuple[_AxisKey, _AxisKey, _AxisKey, _AxisKey]) -> Triangl else: obj.values = obj.values[i_idx, :, o_idx, d_idx][:, c_idx, ...] # Set the new dimension values. - obj._kdims = obj._kdims[i_idx] - obj._vdims = obj._vdims[c_idx] + obj._index = obj._index.iloc[i_idx].reset_index(drop=True) + obj._columns = obj._columns[c_idx] obj.odims, obj.ddims = obj.odims[o_idx], obj.ddims[d_idx] obj.valuation_date = cast( pd.Timestamp, np.minimum(obj.valuation.max(), obj.valuation_date) @@ -383,7 +383,7 @@ def index_key(self, key: _LabelKey) -> np.ndarray: ).values.flatten() # Case scalar, locate position in first level of index. else: - idx = np.where(self.obj._kdims[:, 0] == key)[0] + idx = np.where(self.obj.index.iloc[:, 0].values == key)[0] return idx def other_key( @@ -811,7 +811,7 @@ def __getitem__( [key] if isinstance(key, (str, int, float, np.generic)) else key ) # Identify the position of each requested element within the valuation dimension. - idx = [list(self._vdims).index(item) for item in keys] + idx = [list(self.columns).index(item) for item in keys] return self.iloc[:, idx] def __setitem__( @@ -873,10 +873,10 @@ def __setitem__( if callable(value): self.virtual_columns[key] = value if self.array_backend == "sparse": - if key not in self._vdims: + if key not in self.columns: k, v, o, d = self.values.shape self.values.shape = k, v + 1, o, d - self._vdims = np.append(self._vdims, key) + self._columns = self.columns.append(pd.Index([key])) return # Create a placeholder column. value = (self.iloc[:, 0].copy() * xp.nan).set_backend(self.array_backend) @@ -888,8 +888,8 @@ def __setitem__( if value.array_backend != self.array_backend: value = value.set_backend(self.array_backend) # Key exists in columns, replace data. - if key in self._vdims: - i = np.where(self._vdims == key)[0][0] + if key in self.columns: + i = self.columns.get_loc(key) # Case sparse backend. if self.array_backend == "sparse": # Unwrap a Triangle-valued assignment to its raw array. A raw @@ -927,7 +927,7 @@ def __setitem__( cast(np.ndarray, self.values)[:, i : i + 1] = value # Key is new, create a column and update data. else: - self._vdims = np.append(self._vdims, key) + self._columns = self.columns.append(pd.Index([key])) if isinstance(value, (int, float, np.number)): # Broadcast scalar across the Triangle's shape. value = self.iloc[:, 0] * 0 + value diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 64b2d0816..83169d73a 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -352,9 +352,9 @@ def test_index_setter_with_dataframe(clrd: Triangle) -> None: tri.index = new_index assert tri.key_labels == ["Company"] - np.testing.assert_array_equal(tri.kdims, new_index.values) + assert tri.index.equals(new_index) # _set_slicers() must have rebuilt .loc against the new key label. - assert tri.loc["A"].kdims.tolist() == [["A"]] + assert tri.loc["A"].index.values.tolist() == [["A"]] assert tri.loc["A"] == clrd.iloc[:1] @@ -400,8 +400,8 @@ def test_index_setter_non_dataframe_raises(clrd: Triangle) -> None: 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. + Triangle.set_index(value, inplace=True) should mutate the Triangle and + return self. Parameters ---------- @@ -419,14 +419,14 @@ def test_set_index_inplace(clrd: Triangle) -> None: assert result is tri assert tri.key_labels == ["Company"] - np.testing.assert_array_equal(tri.kdims, new_index.values) + assert tri.index.equals(new_index) 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. + leaving the original Triangle's index/key_labels untouched. Parameters ---------- @@ -438,7 +438,7 @@ def test_set_index_not_inplace(clrd: Triangle) -> None: None """ tri = clrd.iloc[:3] - original_kdims = tri.kdims.copy() + original_index = tri.index.copy() original_key_labels = list(tri.key_labels) new_index = pd.DataFrame({"Company": ["A", "B", "C"]}) @@ -446,9 +446,9 @@ def test_set_index_not_inplace(clrd: Triangle) -> None: assert result is not tri assert result.key_labels == ["Company"] - np.testing.assert_array_equal(result.kdims, new_index.values) + assert result.index.equals(new_index) assert tri.key_labels == original_key_labels - np.testing.assert_array_equal(tri.kdims, original_kdims) + assert tri.index.equals(original_index) def test_vdims_deprecation_warning(raa): @@ -476,23 +476,43 @@ def test_kdims_deprecation_warning(raa): def test_legacy_pickle_compatibility(raa): - """Pickles saved prior to kdims/vdims rename should unpickle cleanly.""" + """Pickles saved prior to kdims/vdims migration should unpickle cleanly.""" import pickle + # 1. Simulate oldest serialized Triangle containing kdims and vdims state = raa.__dict__.copy() - # Simulate a legacy serialized Triangle containing kdims and vdims - state["kdims"] = state.pop("_kdims") - state["vdims"] = state.pop("_vdims") + state.pop("_index", None) + state.pop("_columns", None) + state["kdims"] = raa.index.values + state["vdims"] = raa.columns.values restored = cl.Triangle.__new__(cl.Triangle) restored.__setstate__(state) + assert isinstance(restored.columns, pd.Index) + assert isinstance(restored.index, pd.DataFrame) assert restored.index.equals(raa.index) assert list(restored.columns) == list(raa.columns) assert restored.loc["Total"].shape == raa.loc["Total"].shape assert restored == raa - # Verify re-pickling the migrated instance works + # 2. Simulate intermediate serialized Triangle containing _kdims and _vdims + state_mid = raa.__dict__.copy() + state_mid.pop("_index", None) + state_mid.pop("_columns", None) + state_mid["_kdims"] = raa.index.values + state_mid["_vdims"] = raa.columns.values + + restored_mid = cl.Triangle.__new__(cl.Triangle) + restored_mid.__setstate__(state_mid) + + assert isinstance(restored_mid.columns, pd.Index) + assert isinstance(restored_mid.index, pd.DataFrame) + assert restored_mid.index.equals(raa.index) + assert list(restored_mid.columns) == list(raa.columns) + assert restored_mid == raa + + # 3. Verify re-pickling the migrated instance works roundtripped = pickle.loads(pickle.dumps(restored)) assert roundtripped == raa diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index dbad7eed3..5eaabeb1a 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -616,23 +616,24 @@ def __init__( self.index_label: list = index data_agg[index[0]] = "Total" - self._kdims: np.ndarray + self._index: DataFrame key_idx: np.ndarray - self._vdims: np.ndarray + self._columns: pd.Index self.odims: np.ndarray orig_idx: np.ndarray self.ddims: ArrayLike dev_idx: np.ndarray - self._kdims, key_idx = self._set_kdims(data_agg, index) - self._vdims = np.array(columns) + self.key_labels: list = index + kdims_arr, key_idx = self._set_kdims(data_agg, index) + self._index = pd.DataFrame(list(kdims_arr), columns=self.key_labels) + self._columns = pd.Index(columns, name="columns") self.odims, orig_idx = self._set_odims(data_agg, date_axes) self.ddims, dev_idx = self._set_ddims(data_agg, date_axes) # Set remaining triangle properties. val_date: Timestamp = data_agg["__development__"].max() val_date = val_date.compute() if hasattr(val_date, "compute") else val_date - self.key_labels: list = index self.valuation_date: Timestamp = val_date if cumulative is None: @@ -707,8 +708,8 @@ def __init__( has_duplicates=False, sorted=True, shape=( - len(self._kdims), - len(self._vdims), + len(self._index), + len(self._columns), len(self.odims), len(self.ddims), ), @@ -783,13 +784,13 @@ def index(self) -> DataFrame: """ Returns a DataFrame of the unique values of the index. """ - return pd.DataFrame(list(self._kdims), columns=self.key_labels) + return self._index @index.setter def index(self, value) -> None: self._len_check(self.index, value) - if type(value) is pd.DataFrame: - self._kdims = value.values + if isinstance(value, pd.DataFrame): + self._index = value self.key_labels = list(value.columns) self._set_slicers() else: @@ -803,7 +804,7 @@ def kdims(self): FutureWarning, stacklevel=2, ) - return self._kdims + return self.index.values @kdims.setter def kdims(self, value): @@ -813,23 +814,25 @@ def kdims(self, value): FutureWarning, stacklevel=2, ) - if type(value) is str: + if isinstance(value, str): value = np.array([[value]]) - elif type(value) is list: + elif isinstance(value, list): + value = np.array(value) + elif not isinstance(value, np.ndarray): value = np.array(value) - self._kdims = value + self._index = pd.DataFrame(value, columns=self.key_labels) + self._set_slicers() @property - def columns(self): - return pd.Index(self._vdims, name="columns") + def columns(self) -> pd.Index: + return self._columns @columns.setter def columns(self, value): self._len_check(self.columns, value) - vdims = [value] if type(value) is str else value - if type(vdims) is list: - vdims = np.array(vdims) - self._vdims = vdims + if isinstance(value, str): + value = [value] + self._columns = pd.Index(value, name="columns") self._set_slicers() @property @@ -840,7 +843,7 @@ def vdims(self): FutureWarning, stacklevel=2, ) - return self._vdims + return self.columns.values @vdims.setter def vdims(self, value): @@ -850,11 +853,9 @@ def vdims(self, value): FutureWarning, stacklevel=2, ) - if type(value) is str: - value = np.array([value]) - elif type(value) is list: - value = np.array(value) - self._vdims = value + if isinstance(value, str): + value = [value] + self.columns = value @property def columns_label(self) -> list: @@ -2202,11 +2203,20 @@ def copy(self): return X def __setstate__(self, state: dict) -> None: - """Migrate legacy pickled instances with 'kdims'/'vdims' to '_kdims'/'_vdims'.""" - if "kdims" in state and "_kdims" not in state: - state["_kdims"] = state.pop("kdims") - if "vdims" in state and "_vdims" not in state: - state["_vdims"] = state.pop("vdims") + """Migrate legacy pickled instances with 'kdims'/'_kdims' to '_index' and 'vdims'/'_vdims' to '_columns'.""" + key_labels = state.get("key_labels", ["Total"]) + if "_index" not in state: + raw_kdims = state.pop("_kdims", None) + if raw_kdims is None: + raw_kdims = state.pop("kdims", None) + if raw_kdims is not None: + state["_index"] = pd.DataFrame(list(raw_kdims), columns=key_labels) + if "_columns" not in state: + raw_vdims = state.pop("_vdims", None) + if raw_vdims is None: + raw_vdims = state.pop("vdims", None) + if raw_vdims is not None: + state["_columns"] = pd.Index(raw_vdims, name="columns") self.__dict__.update(state) def development_correlation(self, p_critical=0.5): @@ -2483,10 +2493,10 @@ def sort_axis(self, axis): return self.sort_index() obj = self.copy() if axis == 1: - sort = pd.Series(self._vdims).sort_values().index - if np.any(sort != pd.Series(self._vdims).index): + sort = pd.Series(self.columns).sort_values().index + if np.any(sort != pd.Series(self.columns).index): obj.values = obj.values[:, list(sort), ...] - obj._vdims = obj._vdims[list(sort)] + obj.columns = self.columns[list(sort)] if axis == 2: sort = pd.Series(self.odims).sort_values().index if np.any(sort != pd.Series(self.odims).index): diff --git a/chainladder/methods/benktander.py b/chainladder/methods/benktander.py index 8c45a4745..0755e1ec3 100644 --- a/chainladder/methods/benktander.py +++ b/chainladder/methods/benktander.py @@ -271,8 +271,8 @@ def _get_benktander_aprioris(self, X, sample_weight): apriori = random_state.lognormal(mu_log, sigma_log, X.shape[0]) apriori = apriori.reshape(X.shape[0], -1)[..., None, None] apriori = sample_weight * apriori - apriori._kdims = X._kdims - apriori.key_labels = X.key_labels + apriori._index = X.index.copy() + apriori.key_labels = list(X.key_labels) apriori._set_slicers() else: apriori = sample_weight * self.apriori diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 157764f47..ea69c794d 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -703,31 +703,58 @@ def concat( if list(objs[num].columns) != all_columns: objs[num] = objs[num][all_columns] objs = set_common_backend(objs) - mapper = {0: "_kdims", 1: "_vdims", 2: "odims", 3: "ddims"} - for k in mapper.keys(): - if k != axis and k != 1: # All non-concat axes must be identical - a = np.array([getattr(obj, mapper[k]) for obj in objs]) - assert np.all(a == a[0]) - else: # All elements of concat axis must be unique - if ignore_index: - new_axis = np.arange( - np.sum([len(getattr(obj, mapper[axis])) for obj in objs]) - ) - new_axis = new_axis[:, None] if axis == 0 else new_axis - else: - new_axis = np.concatenate([getattr(obj, mapper[axis]) for obj in objs]) - if axis == 0: - assert len(pd.DataFrame(new_axis).drop_duplicates()) == len(new_axis) - else: - assert len(new_axis) == len(set(new_axis)) + if axis != 0: + for obj in objs[1:]: + assert obj.index.equals(objs[0].index) + if axis != 2: + a = np.array([obj.odims for obj in objs]) + assert np.all(a == a[0]) + if axis != 3: + a = np.array([obj.ddims for obj in objs]) + assert np.all(a == a[0]) + out = copy.deepcopy(objs[0]) out.values = xp.concatenate([obj.values for obj in objs], axis=axis) - setattr(out, mapper[axis], new_axis) - if ignore_index and axis == 0: - out.key_labels = ["Index"] + + if axis == 0: + if ignore_index: + new_axis = np.arange(sum([len(obj.index) for obj in objs]))[:, None] + out._index = pd.DataFrame(new_axis, columns=["Index"]) + out.key_labels = ["Index"] + else: + new_axis = pd.concat([obj.index for obj in objs], ignore_index=True) + assert len(new_axis.drop_duplicates()) == len(new_axis) + out._index = new_axis + out.key_labels = list(new_axis.columns) + elif axis == 1: + if ignore_index: + new_axis = pd.Index( + np.arange(sum([len(obj.columns) for obj in objs])), name="columns" + ) + else: + new_axis = pd.Index( + np.concatenate([obj.columns for obj in objs]), name="columns" + ) + assert len(new_axis) == len(set(new_axis)) + out._columns = new_axis + elif axis == 2: + if ignore_index: + new_axis = np.arange(sum([len(obj.odims) for obj in objs])) + else: + new_axis = np.concatenate([obj.odims for obj in objs]) + assert len(new_axis) == len(set(new_axis)) + out.odims = new_axis + elif axis == 3: + if ignore_index: + new_axis = np.arange(sum([len(obj.ddims) for obj in objs])) + else: + new_axis = np.concatenate([obj.ddims for obj in objs]) + assert len(new_axis) == len(set(new_axis)) + out.ddims = new_axis + if out.ddims.dtype == __dt64_dtype__ and type(out.ddims) is np.ndarray: + out.ddims = pd.DatetimeIndex(out.ddims) + out.valuation_date = pd.Series([obj.valuation_date for obj in objs]).max() - if out.ddims.dtype == __dt64_dtype__ and type(out.ddims) is np.ndarray: - out.ddims = pd.DatetimeIndex(out.ddims) out._set_slicers() if sort: return out.sort_axis(axis) From 5386e8cdec4faade33bdbc8fcaf546fa4554aac4 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Wed, 9 Sep 2026 17:30:20 +0530 Subject: [PATCH 08/25] Fix Triangle index copy isolation, JSON I/O, and bootstrap dtypes --- chainladder/adjustments/bootstrap.py | 13 +++---- .../adjustments/tests/test_bootstrap.py | 13 ++----- chainladder/core/io.py | 6 +++- chainladder/core/slice.py | 2 ++ chainladder/core/tests/test_triangle.py | 35 +++++++++++++++++++ chainladder/core/triangle.py | 8 +++-- chainladder/utils/utility_functions.py | 4 +++ 7 files changed, 59 insertions(+), 22 deletions(-) diff --git a/chainladder/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py index 9ab098315..308b731b6 100644 --- a/chainladder/adjustments/bootstrap.py +++ b/chainladder/adjustments/bootstrap.py @@ -270,15 +270,10 @@ def _get_simulation(self, X, exp_incr_triangle): obj._index = pd.DataFrame({"Simulation_#": np.arange(self.n_sims)}) obj.key_labels = ["Simulation_#"] else: - kdims = np.concat( - [ - np.tile(X.index.to_numpy(dtype=str), (self.n_sims, 1)), - np.arange(self.n_sims).reshape(-1, 1), - ], - axis=1, - ) - obj._index = pd.DataFrame(kdims, columns=X.key_labels + ["Simulation_#"]) - obj.key_labels = X.key_labels + ["Simulation_#"] + obj_idx = pd.concat([X.index] * self.n_sims, ignore_index=True) + obj_idx["Simulation_#"] = np.arange(self.n_sims) + obj._index = obj_idx + obj.key_labels = list(obj_idx.columns) obj.values = resampled_triangles obj._set_slicers() return obj, scale_phi diff --git a/chainladder/adjustments/tests/test_bootstrap.py b/chainladder/adjustments/tests/test_bootstrap.py index a0d683e59..75ba06ccb 100644 --- a/chainladder/adjustments/tests/test_bootstrap.py +++ b/chainladder/adjustments/tests/test_bootstrap.py @@ -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) diff --git a/chainladder/core/io.py b/chainladder/core/io.py index c41d7f16b..26a0b0c94 100644 --- a/chainladder/core/io.py +++ b/chainladder/core/io.py @@ -104,7 +104,11 @@ def to_json(self): json_dict["sub_tris"] = { sub_tri: getattr(self, sub_tri).to_json() for sub_tri in sub_tris } - dfs = [k for k, v in vars(self).items() if isinstance(v, pd.DataFrame)] + dfs = [ + k + for k, v in vars(self).items() + if isinstance(v, pd.DataFrame) and k != "_index" + ] json_dict["dfs"] = {df: getattr(self, df).to_json() for df in dfs} dfs = [k for k, v in vars(self).items() if isinstance(v, pd.Series)] json_dict["dfs"].update({ diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py index 28eb5cbd0..c9fd22e99 100644 --- a/chainladder/core/slice.py +++ b/chainladder/core/slice.py @@ -797,6 +797,8 @@ def __getitem__( return self._slice(key, "odims") # Case index. if isinstance(key, pd.Series): + if pd.api.types.is_bool_dtype(key): + return self.iloc[np.where(key.to_numpy())[0]] return self.iloc[self.index[key].index] elif key in self.key_labels: return self.index[key] diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 83169d73a..735bbab69 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -3515,3 +3515,38 @@ def test_full_fill(raa: Triangle) -> None: full_tri = cl.Chainladder().fit(raa).full_triangle_ fill_full_tri = full_tri.fill(200) assert np.all(fill_full_tri.values == np.broadcast_to([200], (1, 1, 10, 12))) + + +def test_json_roundtrip_preserves_dataframe_index(raa, clrd) -> None: + """JSON serialization roundtrip must preserve Triangle._index as a pd.DataFrame.""" + r_single = cl.read_json(raa.to_json()) + assert isinstance(r_single.index, pd.DataFrame) + assert isinstance(r_single._index, pd.DataFrame) + assert r_single.index.iloc[:, 0].tolist() == ["Total"] + assert r_single == raa + + r_multi = cl.read_json(clrd.to_json()) + assert isinstance(r_multi.index, pd.DataFrame) + assert isinstance(r_multi._index, pd.DataFrame) + pd.testing.assert_frame_equal(r_multi.index, clrd.index) + assert r_multi == clrd + + +def test_triangle_copy_isolated_index(raa) -> None: + """Triangle.copy() must not share the mutable _index DataFrame.""" + copied = raa.copy() + assert copied.index is not raa.index + copied.index.iloc[0, 0] = "Modified" + assert raa.index.iloc[0, 0] == "Total" + + +def test_triangle_index_setter_resets_row_index(raa) -> None: + """Assigning a DataFrame with non-default index to Triangle.index resets row index.""" + custom_df = pd.DataFrame({"Total": ["Total"]}, index=[42]) + raa1 = raa.copy() + raa1.index = custom_df + assert list(raa1.index.index) == [0] + # Boolean Series slicing should work cleanly + mask = raa1.index["Total"] == "Total" + sliced = raa1[mask] + assert sliced == raa1 diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 5eaabeb1a..78d826f74 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -790,7 +790,7 @@ def index(self) -> DataFrame: def index(self, value) -> None: self._len_check(self.index, value) if isinstance(value, pd.DataFrame): - self._index = value + self._index = value.copy().reset_index(drop=True) self.key_labels = list(value.columns) self._set_slicers() else: @@ -820,7 +820,9 @@ def kdims(self, value): value = np.array(value) elif not isinstance(value, np.ndarray): value = np.array(value) - self._index = pd.DataFrame(value, columns=self.key_labels) + self._index = pd.DataFrame(value, columns=self.key_labels).reset_index( + drop=True + ) self._set_slicers() @property @@ -2200,6 +2202,8 @@ def copy(self): X.__dict__.update(vars(self)) X._set_slicers() X.values = X.values.copy() + X._index = self._index.copy() + X._columns = self._columns.copy() return X def __setstate__(self, state: dict) -> None: diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index ea69c794d..21b748209 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -428,10 +428,14 @@ def read_json(json_str, array_backend=None): setattr(getattr(tri, k), "development_grain", tri.development_grain) if "dfs" in json_dict.keys(): for k, v in json_dict["dfs"].items(): + if k == "_index": + continue df = pd.read_json(StringIO(v)) if len(df.columns) == 1: df = df.iloc[:, 0] setattr(tri, k, df) + if isinstance(tri._index, pd.Series): + tri._index = tri._index.to_frame() if array_backend: return tri.set_backend(array_backend) else: From f961dddbfbf7305c1115f276d20001de7c91fc11 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Wed, 9 Sep 2026 17:54:15 +0530 Subject: [PATCH 09/25] style: run ruff format on changed core files --- chainladder/core/dunders.py | 11 ++++++----- chainladder/core/pandas.py | 4 +++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/chainladder/core/dunders.py b/chainladder/core/dunders.py index edf4ba30e..492624a78 100644 --- a/chainladder/core/dunders.py +++ b/chainladder/core/dunders.py @@ -83,7 +83,11 @@ def _prep_index(self, x, y): y._set_slicers() return x, y 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() + index = ( + x.index.copy() + if len(x.key_labels) > len(y.key_labels) + else y.index.copy() + ) x._index = index.copy() x.key_labels = list(index.columns) x._set_slicers() @@ -101,10 +105,7 @@ def _prep_index(self, x, y): if x_labels != y_labels or len(x.index) != len(y.index): x = x.groupby(list(common)) y = y.groupby(list(common)) - elif ( - len(x.index) > 1 - 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] diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index eb19a07ab..8045a5e15 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -1301,7 +1301,9 @@ def agg_func( # Aggregation function will collapse a dimension, so # adjust the dimensions of the original object to match that of the aggregation. if axis == 0 and obj.values.shape[axis] == 1 and len(obj.index) > 1: - obj._index = pd.DataFrame([["(All)"] * len(obj.key_labels)], columns=obj.key_labels) + obj._index = pd.DataFrame( + [["(All)"] * len(obj.key_labels)], columns=obj.key_labels + ) if axis == 1 and obj.values.shape[axis] == 1 and len(obj.columns) > 1: obj._columns = pd.Index([0], name="columns") if axis == 2 and obj.values.shape[axis] == 1 and len(obj.odims) > 1: From 1d6b0d5a5cf9e6743344bf28475235e8e5b7bdf1 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 12 Sep 2026 15:01:06 +0530 Subject: [PATCH 10/25] fix kdims key labels --- chainladder/core/tests/test_triangle.py | 20 ++++++++++++++ chainladder/core/triangle.py | 36 ++++++++++++++++++++----- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 735bbab69..4bb5aa6d0 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -474,6 +474,26 @@ def test_kdims_deprecation_warning(raa): raa2.kdims = np.array([["P2"]]) assert list(raa2.index.values) == [["P2"]] + # Setting wider kdims array and follow-up key_labels assignment + raa3 = raa.copy() + with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): + raa3.kdims = np.array([["P2", "CA"]]) + raa3.key_labels = ["Company", "State"] + assert raa3.key_labels == ["Company", "State"] + assert list(raa3.index.columns) == ["Company", "State"] + indexed = raa3.index.set_index(raa3.key_labels) + assert list(indexed.index.names) == ["Company", "State"] + + +def test_key_labels_setter(raa): + """Setting key_labels should update Triangle.index columns and slicers.""" + tri = raa.copy() + tri.key_labels = ["NewCompany"] + assert tri.key_labels == ["NewCompany"] + assert list(tri.index.columns) == ["NewCompany"] + indexed = tri.index.set_index(tri.key_labels) + assert list(indexed.index.names) == ["NewCompany"] + def test_legacy_pickle_compatibility(raa): """Pickles saved prior to kdims/vdims migration should unpickle cleanly.""" diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 78d826f74..b6af9fcb4 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -624,9 +624,8 @@ def __init__( self.ddims: ArrayLike dev_idx: np.ndarray - self.key_labels: list = index kdims_arr, key_idx = self._set_kdims(data_agg, index) - self._index = pd.DataFrame(list(kdims_arr), columns=self.key_labels) + self._index = pd.DataFrame(list(kdims_arr), columns=index) self._columns = pd.Index(columns, name="columns") self.odims, orig_idx = self._set_odims(data_agg, date_axes) self.ddims, dev_idx = self._set_ddims(data_agg, date_axes) @@ -791,11 +790,26 @@ def index(self, value) -> None: self._len_check(self.index, value) if isinstance(value, pd.DataFrame): self._index = value.copy().reset_index(drop=True) - self.key_labels = list(value.columns) self._set_slicers() else: raise TypeError("index must be a pandas DataFrame") + @property + def key_labels(self) -> list: + """ + Returns a list of the labels corresponding to the levels of the index. + """ + return list(self._index.columns) + + @key_labels.setter + def key_labels(self, value) -> None: + if isinstance(value, str): + value = [value] + else: + value = list(value) + self._index.columns = value + self._set_slicers() + @property def kdims(self): warnings.warn( @@ -820,9 +834,17 @@ def kdims(self, value): value = np.array(value) elif not isinstance(value, np.ndarray): value = np.array(value) - self._index = pd.DataFrame(value, columns=self.key_labels).reset_index( - drop=True - ) + if value.ndim == 1: + value = value.reshape(-1, 1) + + n_cols = value.shape[1] if value.ndim > 1 else 1 + if len(self._index.columns) == n_cols: + cols = list(self._index.columns) + elif n_cols == 1: + cols = ["Total"] + else: + cols = [f"key_{i}" for i in range(n_cols)] + self._index = pd.DataFrame(value, columns=cols).reset_index(drop=True) self._set_slicers() @property @@ -2208,7 +2230,7 @@ def copy(self): def __setstate__(self, state: dict) -> None: """Migrate legacy pickled instances with 'kdims'/'_kdims' to '_index' and 'vdims'/'_vdims' to '_columns'.""" - key_labels = state.get("key_labels", ["Total"]) + key_labels = state.pop("key_labels", ["Total"]) if "_index" not in state: raw_kdims = state.pop("_kdims", None) if raw_kdims is None: From 4d5476dcae1f713e48e0947ff1ddb12891c965e8 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 12 Sep 2026 21:47:17 +0530 Subject: [PATCH 11/25] test: improve patch coverage for dims deprecation and concat --- chainladder/core/tests/test_triangle.py | 53 ++++++++++++++++++++++- chainladder/core/triangle.py | 6 ++- chainladder/utils/tests/test_utilities.py | 18 ++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 4bb5aa6d0..11f87df44 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -462,6 +462,13 @@ def test_vdims_deprecation_warning(raa): raa2.vdims = ["NewColumn"] assert list(raa2.columns) == ["NewColumn"] + with pytest.warns(FutureWarning, match="'vdims' attribute is deprecated"): + raa2.vdims = "SingleColumn" + assert list(raa2.columns) == ["SingleColumn"] + + raa2.columns = "DirectColumn" + assert list(raa2.columns) == ["DirectColumn"] + def test_kdims_deprecation_warning(raa): """Accessing or setting kdims should emit a FutureWarning.""" @@ -474,6 +481,18 @@ def test_kdims_deprecation_warning(raa): raa2.kdims = np.array([["P2"]]) assert list(raa2.index.values) == [["P2"]] + with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): + raa2.kdims = "P_str" + assert list(raa2.index.values) == [["P_str"]] + + with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): + raa2.kdims = ["P_1d"] + assert list(raa2.index.values) == [["P_1d"]] + + with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): + raa2.kdims = (["P_tuple"],) + assert list(raa2.index.values) == [["P_tuple"]] + # Setting wider kdims array and follow-up key_labels assignment raa3 = raa.copy() with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): @@ -484,6 +503,11 @@ def test_kdims_deprecation_warning(raa): indexed = raa3.index.set_index(raa3.key_labels) assert list(indexed.index.names) == ["Company", "State"] + # Assigning single-col kdims to multi-col triangle resets cols to ['Total'] + with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): + raa3.kdims = np.array([["P_single"]]) + assert list(raa3.index.columns) == ["Total"] + def test_key_labels_setter(raa): """Setting key_labels should update Triangle.index columns and slicers.""" @@ -494,6 +518,19 @@ def test_key_labels_setter(raa): indexed = tri.index.set_index(tri.key_labels) assert list(indexed.index.names) == ["NewCompany"] + tri.key_labels = "SingleCompany" + assert tri.key_labels == ["SingleCompany"] + assert list(tri.index.columns) == ["SingleCompany"] + + +def test_series_indexing(raa): + """Indexing Triangle by boolean Series or label Series should work.""" + s_bool = pd.Series([True], index=raa.index.index) + assert raa[s_bool].shape == raa.shape + + s_label = pd.Series(["Total"]) + assert raa[s_label].shape == raa.shape + def test_legacy_pickle_compatibility(raa): """Pickles saved prior to kdims/vdims migration should unpickle cleanly.""" @@ -532,7 +569,21 @@ def test_legacy_pickle_compatibility(raa): assert list(restored_mid.columns) == list(raa.columns) assert restored_mid == raa - # 3. Verify re-pickling the migrated instance works + # 3. Simulate fallback when no kdims/vdims keys are found + state_empty = raa.__dict__.copy() + state_empty.pop("_index", None) + state_empty.pop("_columns", None) + state_empty.pop("kdims", None) + state_empty.pop("_kdims", None) + state_empty.pop("vdims", None) + state_empty.pop("_vdims", None) + + restored_empty = cl.Triangle.__new__(cl.Triangle) + restored_empty.__setstate__(state_empty) + assert list(restored_empty.columns) == ["values"] + assert list(restored_empty.index.columns) == ["Total"] + + # 4. Verify re-pickling the migrated instance works roundtripped = pickle.loads(pickle.dumps(restored)) assert roundtripped == raa diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index b6af9fcb4..1b0010b44 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -853,9 +853,9 @@ def columns(self) -> pd.Index: @columns.setter def columns(self, value): - self._len_check(self.columns, value) if isinstance(value, str): value = [value] + self._len_check(self.columns, value) self._columns = pd.Index(value, name="columns") self._set_slicers() @@ -2237,12 +2237,16 @@ def __setstate__(self, state: dict) -> None: raw_kdims = state.pop("kdims", None) if raw_kdims is not None: state["_index"] = pd.DataFrame(list(raw_kdims), columns=key_labels) + else: + state["_index"] = pd.DataFrame([["Total"]], columns=["Total"]) if "_columns" not in state: raw_vdims = state.pop("_vdims", None) if raw_vdims is None: raw_vdims = state.pop("vdims", None) if raw_vdims is not None: state["_columns"] = pd.Index(raw_vdims, name="columns") + else: + state["_columns"] = pd.Index(["values"], name="columns") self.__dict__.update(state) def development_correlation(self, p_critical=0.5): diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 00e06aca0..e613f99fc 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -612,6 +612,24 @@ def test_concat_axis1_duplicate_columns(raa: Triangle) -> None: cl.concat([raa, raa], axis=1) +def test_concat_ignore_index_axes(raa: Triangle) -> None: + """Test concat with ignore_index=True along axes 1, 2, and 3.""" + t1 = copy.deepcopy(raa).rename("columns", ["A"]) + t2 = copy.deepcopy(raa).rename("columns", ["B"]) + res1 = cl.concat([t1, t2], axis=1, ignore_index=True) + assert list(res1.columns) == [0, 1] + + o1 = raa.iloc[:, :, :5, :] + o2 = raa.iloc[:, :, 5:, :] + res2 = cl.concat([o1, o2], axis=2, ignore_index=True) + assert len(res2.odims) == len(raa.odims) + + d1 = raa.iloc[:, :, :, :5] + d2 = raa.iloc[:, :, :, 5:] + res3 = cl.concat([d1, d2], axis=3, ignore_index=True) + assert len(res3.ddims) == len(raa.ddims) + + def test_maximum_2(raa: Triangle) -> None: """ Run cl.maximum(raa, 5000) and check if each element in the resulting triangle is at least 5000. From 3c58110ac9241e3610b581f6742504b6ba4ceccb Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sun, 20 Sep 2026 16:03:01 +0530 Subject: [PATCH 12/25] Implement TriangleAxis for columns --- chainladder/adjustments/bootstrap.py | 9 +- chainladder/core/axis.py | 57 ++++++++++++ chainladder/core/dunders.py | 7 +- chainladder/core/pandas.py | 4 +- chainladder/core/slice.py | 6 +- chainladder/core/tests/test_triangle.py | 68 +++----------- chainladder/core/triangle.py | 118 ++++++++---------------- chainladder/methods/benktander.py | 4 +- chainladder/utils/utility_functions.py | 2 +- 9 files changed, 122 insertions(+), 153 deletions(-) create mode 100644 chainladder/core/axis.py diff --git a/chainladder/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py index 308b731b6..84f7fc5af 100644 --- a/chainladder/adjustments/bootstrap.py +++ b/chainladder/adjustments/bootstrap.py @@ -266,16 +266,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._index = pd.DataFrame({"Simulation_#": np.arange(self.n_sims)}) - obj.key_labels = ["Simulation_#"] + obj.index = pd.DataFrame({"Simulation_#": np.arange(self.n_sims)}) else: obj_idx = pd.concat([X.index] * self.n_sims, ignore_index=True) obj_idx["Simulation_#"] = np.arange(self.n_sims) - obj._index = obj_idx - obj.key_labels = list(obj_idx.columns) - obj.values = resampled_triangles - obj._set_slicers() + obj.index = obj_idx return obj, scale_phi def _get_design_matrix(self, X): diff --git a/chainladder/core/axis.py b/chainladder/core/axis.py new file mode 100644 index 000000000..68ff04542 --- /dev/null +++ b/chainladder/core/axis.py @@ -0,0 +1,57 @@ +"""Axis descriptor for Triangle dimensions, analogous to Pandas AxisProperty.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable +import pandas as pd + +if TYPE_CHECKING: + from chainladder.core.triangle import Triangle + + +class TriangleAxis: + """ + Generalized descriptor for representing a Triangle dimension, + analogous to Pandas AxisProperty. + """ + + def __init__( + self, + key: str, + *, + fget: Callable[[Triangle, Any], Any] | None = None, + fset: Callable[[Triangle, Any], Any] | None = None, + doc: str | None = None, + ) -> None: + self.key = key + 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: + if obj is None: + return self + if not hasattr(obj, "_axes") or self.key not in obj._axes: + raise AttributeError( + f"'{type(obj).__name__}' object has no attribute '{self.key}'" + ) + raw = obj._axes[self.key] + return self.fget(obj, raw) if self.fget else raw + + def __set__(self, obj: Triangle, value: Any) -> None: + raw = self.fset(obj, value) if self.fset else value + if not hasattr(obj, "_axes"): + obj._axes = {} + obj._axes[self.key] = raw + if hasattr(obj, "virtual_columns"): + obj._set_slicers() + + +def _set_columns(obj: Triangle, value: Any) -> pd.Index: + 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 "columns" in obj._axes: + obj._len_check(obj.columns, value) + return pd.Index(value, name="columns") diff --git a/chainladder/core/dunders.py b/chainladder/core/dunders.py index 492624a78..b5e1edeaf 100644 --- a/chainladder/core/dunders.py +++ b/chainladder/core/dunders.py @@ -127,14 +127,13 @@ def _prep_index(self, x, y): def _prep_columns(self, x, y): if len(x.columns) == 1 and len(y.columns) > 1: - x._columns = y.columns + x._axes["columns"] = y.columns x._set_slicers() elif len(y.columns) == 1 and len(x.columns) > 1: - y._columns = x.columns + y._axes["columns"] = x.columns y._set_slicers() elif len(y.columns) == len(x.columns) == 1 and not x.columns.equals(y.columns): - y._columns = x.columns - y._set_slicers() + y.columns = x.columns elif x.shape[1] == y.shape[1] and x.columns.equals(y.columns): return x, y else: diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 8045a5e15..19b999b08 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -1301,11 +1301,11 @@ def agg_func( # Aggregation function will collapse a dimension, so # adjust the dimensions of the original object to match that of the aggregation. if axis == 0 and obj.values.shape[axis] == 1 and len(obj.index) > 1: - obj._index = pd.DataFrame( + obj.index = pd.DataFrame( [["(All)"] * len(obj.key_labels)], columns=obj.key_labels ) if axis == 1 and obj.values.shape[axis] == 1 and len(obj.columns) > 1: - obj._columns = pd.Index([0], name="columns") + obj.columns = pd.Index([0], name="columns") if axis == 2 and obj.values.shape[axis] == 1 and len(obj.odims) > 1: obj.odims = obj.odims[0:1] # If axis is development, set the ddims to be the valuation date. diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py index c9fd22e99..fb92cc916 100644 --- a/chainladder/core/slice.py +++ b/chainladder/core/slice.py @@ -64,7 +64,7 @@ def get_idx(self, idx: tuple[_AxisKey, _AxisKey, _AxisKey, _AxisKey]) -> Triangl obj.values = obj.values[i_idx, :, o_idx, d_idx][:, c_idx, ...] # Set the new dimension values. obj._index = obj._index.iloc[i_idx].reset_index(drop=True) - obj._columns = obj._columns[c_idx] + obj.columns = obj.columns[c_idx] obj.odims, obj.ddims = obj.odims[o_idx], obj.ddims[d_idx] obj.valuation_date = cast( pd.Timestamp, np.minimum(obj.valuation.max(), obj.valuation_date) @@ -878,7 +878,7 @@ def __setitem__( if key not in self.columns: k, v, o, d = self.values.shape self.values.shape = k, v + 1, o, d - self._columns = self.columns.append(pd.Index([key])) + self.columns = self.columns.append(pd.Index([key])) return # Create a placeholder column. value = (self.iloc[:, 0].copy() * xp.nan).set_backend(self.array_backend) @@ -929,7 +929,6 @@ def __setitem__( cast(np.ndarray, self.values)[:, i : i + 1] = value # Key is new, create a column and update data. else: - self._columns = self.columns.append(pd.Index([key])) if isinstance(value, (int, float, np.number)): # Broadcast scalar across the Triangle's shape. value = self.iloc[:, 0] * 0 + value @@ -942,6 +941,7 @@ def __setitem__( (self.iloc[:, 0] * 0 + cast("Triangle", value)).values, ) self.values = xp.concatenate(conc, axis=1) + self.columns = self.columns.append(pd.Index([key])) def _slice_valuation(self: TriangleProtocol, key: np.ndarray) -> Triangle: """ diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 11f87df44..0bd8af317 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -451,62 +451,19 @@ def test_set_index_not_inplace(clrd: Triangle) -> None: assert tri.index.equals(original_index) -def test_vdims_deprecation_warning(raa): - """Accessing or setting vdims should emit a FutureWarning.""" - with pytest.warns(FutureWarning, match="'vdims' attribute is deprecated"): - v = raa.vdims - assert np.all(v == raa.columns.values) - - raa2 = raa.copy() - with pytest.warns(FutureWarning, match="'vdims' attribute is deprecated"): - raa2.vdims = ["NewColumn"] - assert list(raa2.columns) == ["NewColumn"] - - with pytest.warns(FutureWarning, match="'vdims' attribute is deprecated"): - raa2.vdims = "SingleColumn" - assert list(raa2.columns) == ["SingleColumn"] - - raa2.columns = "DirectColumn" - assert list(raa2.columns) == ["DirectColumn"] - +def test_triangle_columns_setter(raa): + """Setting Triangle.columns should update columns and slicers, and validate lengths.""" + tri = raa.copy() + tri.columns = ["NewColumn"] + assert list(tri.columns) == ["NewColumn"] + assert tri["NewColumn"].shape == tri.shape -def test_kdims_deprecation_warning(raa): - """Accessing or setting kdims should emit a FutureWarning.""" - with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): - k = raa.kdims - assert np.all(k == raa.index.values) + tri.columns = "SingleColumn" + assert list(tri.columns) == ["SingleColumn"] + assert tri["SingleColumn"].shape == tri.shape - raa2 = raa.copy() - with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): - raa2.kdims = np.array([["P2"]]) - assert list(raa2.index.values) == [["P2"]] - - with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): - raa2.kdims = "P_str" - assert list(raa2.index.values) == [["P_str"]] - - with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): - raa2.kdims = ["P_1d"] - assert list(raa2.index.values) == [["P_1d"]] - - with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): - raa2.kdims = (["P_tuple"],) - assert list(raa2.index.values) == [["P_tuple"]] - - # Setting wider kdims array and follow-up key_labels assignment - raa3 = raa.copy() - with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): - raa3.kdims = np.array([["P2", "CA"]]) - raa3.key_labels = ["Company", "State"] - assert raa3.key_labels == ["Company", "State"] - assert list(raa3.index.columns) == ["Company", "State"] - indexed = raa3.index.set_index(raa3.key_labels) - assert list(indexed.index.names) == ["Company", "State"] - - # Assigning single-col kdims to multi-col triangle resets cols to ['Total'] - with pytest.warns(FutureWarning, match="'kdims' attribute is deprecated"): - raa3.kdims = np.array([["P_single"]]) - assert list(raa3.index.columns) == ["Total"] + with pytest.raises(ValueError, match="Length mismatch"): + tri.columns = ["Col1", "Col2"] def test_key_labels_setter(raa): @@ -538,6 +495,7 @@ def test_legacy_pickle_compatibility(raa): # 1. Simulate oldest serialized Triangle containing kdims and vdims state = raa.__dict__.copy() + state.pop("_axes", None) state.pop("_index", None) state.pop("_columns", None) state["kdims"] = raa.index.values @@ -555,6 +513,7 @@ def test_legacy_pickle_compatibility(raa): # 2. Simulate intermediate serialized Triangle containing _kdims and _vdims state_mid = raa.__dict__.copy() + state_mid.pop("_axes", None) state_mid.pop("_index", None) state_mid.pop("_columns", None) state_mid["_kdims"] = raa.index.values @@ -571,6 +530,7 @@ def test_legacy_pickle_compatibility(raa): # 3. Simulate fallback when no kdims/vdims keys are found state_empty = raa.__dict__.copy() + state_empty.pop("_axes", None) state_empty.pop("_index", None) state_empty.pop("_columns", None) state_empty.pop("kdims", None) diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 1b0010b44..84dc1f52a 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -11,6 +11,7 @@ import numpy as np import warnings from chainladder.core.base import TriangleBase +from chainladder.core.axis import TriangleAxis, _set_columns from chainladder.utils.sparse import sp from chainladder.core.slice import VirtualColumns from chainladder.core.correlation import DevelopmentCorrelation, ValuationCorrelation @@ -28,7 +29,7 @@ except ImportError: db = None -from typing import cast, Optional, TYPE_CHECKING, Self +from typing import Any, cast, Optional, TYPE_CHECKING, Self if TYPE_CHECKING: from pandas import DataFrame, Series @@ -465,6 +466,12 @@ class Triangle(TriangleBase): 1982 12000.0 """ + columns = TriangleAxis( + "columns", + fset=_set_columns, + doc="Represents the value dimension of the triangle.", + ) + @_deprecated_rename_argument("development", "valuation", remove_in_version="v2.0") @_deprecated_rename_argument( "development_format", @@ -489,6 +496,7 @@ def __init__( *args, **kwargs, ): + self._axes: dict[str, Any] = {} # If data are present, validate the dimensions. if data is None: @@ -618,7 +626,6 @@ def __init__( self._index: DataFrame key_idx: np.ndarray - self._columns: pd.Index self.odims: np.ndarray orig_idx: np.ndarray self.ddims: ArrayLike @@ -626,7 +633,7 @@ def __init__( kdims_arr, key_idx = self._set_kdims(data_agg, index) self._index = pd.DataFrame(list(kdims_arr), columns=index) - self._columns = pd.Index(columns, name="columns") + self.columns = columns self.odims, orig_idx = self._set_odims(data_agg, date_axes) self.ddims, dev_idx = self._set_ddims(data_agg, date_axes) @@ -708,7 +715,7 @@ def __init__( sorted=True, shape=( len(self._index), - len(self._columns), + len(self.columns), len(self.odims), len(self.ddims), ), @@ -787,7 +794,10 @@ def index(self) -> DataFrame: @index.setter def index(self, value) -> None: - self._len_check(self.index, value) + if hasattr(self, "values") and self.values is not None: + self._len_check(range(self.values.shape[0]), value) + else: + self._len_check(self.index, value) if isinstance(value, pd.DataFrame): self._index = value.copy().reset_index(drop=True) self._set_slicers() @@ -811,75 +821,18 @@ def key_labels(self, value) -> None: self._set_slicers() @property - def kdims(self): - warnings.warn( - "The 'kdims' attribute is deprecated and will be removed in a future release. " - "Use 'Triangle.index' or 'Triangle.key_labels' instead.", - FutureWarning, - stacklevel=2, - ) - return self.index.values - - @kdims.setter - def kdims(self, value): - warnings.warn( - "The 'kdims' attribute is deprecated and will be removed in a future release. " - "Use 'Triangle.index' or 'Triangle.key_labels' instead.", - FutureWarning, - stacklevel=2, - ) - if isinstance(value, str): - value = np.array([[value]]) - elif isinstance(value, list): - value = np.array(value) - elif not isinstance(value, np.ndarray): - value = np.array(value) - if value.ndim == 1: - value = value.reshape(-1, 1) - - n_cols = value.shape[1] if value.ndim > 1 else 1 - if len(self._index.columns) == n_cols: - cols = list(self._index.columns) - elif n_cols == 1: - cols = ["Total"] - else: - cols = [f"key_{i}" for i in range(n_cols)] - self._index = pd.DataFrame(value, columns=cols).reset_index(drop=True) - self._set_slicers() - - @property - def columns(self) -> pd.Index: - return self._columns + def _columns(self) -> pd.Index: + return self.columns - @columns.setter - def columns(self, value): + @_columns.setter + def _columns(self, value: Any) -> None: if isinstance(value, str): value = [value] - self._len_check(self.columns, value) - self._columns = pd.Index(value, name="columns") - self._set_slicers() - - @property - def vdims(self): - warnings.warn( - "The 'vdims' attribute is deprecated and will be removed in a future release. " - "Use 'Triangle.columns' or 'Triangle.columns_label' instead.", - FutureWarning, - stacklevel=2, - ) - return self.columns.values - - @vdims.setter - def vdims(self, value): - warnings.warn( - "The 'vdims' attribute is deprecated and will be removed in a future release. " - "Use 'Triangle.columns' instead.", - FutureWarning, - stacklevel=2, - ) - if isinstance(value, str): - value = [value] - self.columns = value + if not hasattr(self, "_axes"): + self._axes = {} + self._axes["columns"] = pd.Index(value, name="columns") + if hasattr(self, "virtual_columns"): + self._set_slicers() @property def columns_label(self) -> list: @@ -2222,14 +2175,15 @@ def copy(self): """ X = object.__new__(self.__class__) X.__dict__.update(vars(self)) + if hasattr(self, "_axes"): + X._axes = {k: v.copy() for k, v in self._axes.items()} X._set_slicers() X.values = X.values.copy() X._index = self._index.copy() - X._columns = self._columns.copy() return X def __setstate__(self, state: dict) -> None: - """Migrate legacy pickled instances with 'kdims'/'_kdims' to '_index' and 'vdims'/'_vdims' to '_columns'.""" + """Migrate legacy pickled instances with 'kdims'/'_kdims' to '_index' and 'vdims'/'_vdims'/'_columns' to '_axes'.""" key_labels = state.pop("key_labels", ["Total"]) if "_index" not in state: raw_kdims = state.pop("_kdims", None) @@ -2239,14 +2193,18 @@ def __setstate__(self, state: dict) -> None: state["_index"] = pd.DataFrame(list(raw_kdims), columns=key_labels) else: state["_index"] = pd.DataFrame([["Total"]], columns=["Total"]) - if "_columns" not in state: - raw_vdims = state.pop("_vdims", None) - if raw_vdims is None: - raw_vdims = state.pop("vdims", None) - if raw_vdims is not None: - state["_columns"] = pd.Index(raw_vdims, name="columns") + if "_axes" not in state: + state["_axes"] = {} + if "columns" not in state["_axes"]: + raw_cols = state.pop("_columns", None) + if raw_cols is None: + raw_cols = state.pop("_vdims", None) + if raw_cols is None: + raw_cols = state.pop("vdims", None) + if raw_cols is not None: + state["_axes"]["columns"] = pd.Index(raw_cols, name="columns") else: - state["_columns"] = pd.Index(["values"], name="columns") + state["_axes"]["columns"] = pd.Index(["values"], name="columns") self.__dict__.update(state) def development_correlation(self, p_critical=0.5): diff --git a/chainladder/methods/benktander.py b/chainladder/methods/benktander.py index 0755e1ec3..add140083 100644 --- a/chainladder/methods/benktander.py +++ b/chainladder/methods/benktander.py @@ -271,9 +271,7 @@ def _get_benktander_aprioris(self, X, sample_weight): apriori = random_state.lognormal(mu_log, sigma_log, X.shape[0]) apriori = apriori.reshape(X.shape[0], -1)[..., None, None] apriori = sample_weight * apriori - apriori._index = X.index.copy() - apriori.key_labels = list(X.key_labels) - apriori._set_slicers() + apriori.index = X.index.copy() else: apriori = sample_weight * self.apriori apriori.columns = sample_weight.columns diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 21b748209..82d7a2c7b 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -740,7 +740,7 @@ def concat( np.concatenate([obj.columns for obj in objs]), name="columns" ) assert len(new_axis) == len(set(new_axis)) - out._columns = new_axis + out.columns = new_axis elif axis == 2: if ignore_index: new_axis = np.arange(sum([len(obj.odims) for obj in objs])) From dba77352535b12a993e93bf06b6ec9d888f04eea Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sun, 20 Sep 2026 16:29:22 +0530 Subject: [PATCH 13/25] Fix sort_index mutation --- chainladder/core/pandas.py | 11 +++++++++-- chainladder/core/tests/test_triangle.py | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 19b999b08..061f47390 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -1141,10 +1141,17 @@ def sort_index(self, *args, **kwargs) -> Triangle: ------- Triangle """ + inplace = kwargs.pop("inplace", False) sorted_index: DataFrame = cast( - "DataFrame", self.index.sort_values(self.key_labels, *args, **kwargs) + "DataFrame", + self.index.copy().sort_values(self.key_labels, *args, **kwargs), ) - return self.iloc[sorted_index.index] + result = self.iloc[sorted_index.index] + if inplace: + self.values = result.values + self.index = result.index + return self + return result def exp(self) -> Triangle: """ diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 0bd8af317..9976d6701 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -3581,3 +3581,12 @@ def test_triangle_index_setter_resets_row_index(raa) -> None: mask = raa1.index["Total"] == "Total" sliced = raa1[mask] assert sliced == raa1 + + +def test_sort_index_inplace(clrd: Triangle) -> None: + """sort_index with inplace=True updates values and index in sync without crashing.""" + shuffled = clrd.iloc[::-1].copy() + result = shuffled.sort_index(inplace=True) + assert result is shuffled + pd.testing.assert_frame_equal(shuffled.index, clrd.index) + assert shuffled == clrd From dd86969e9943f030e558b11d02329605bc5e4b49 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Tue, 22 Sep 2026 06:50:30 +0530 Subject: [PATCH 14/25] Address review comments --- chainladder/core/axis.py | 77 +++++++++++++++++++++++++ chainladder/core/tests/test_triangle.py | 11 ++++ chainladder/core/triangle.py | 25 ++++---- chainladder/development/clark.py | 6 +- chainladder/tails/bondy.py | 6 +- chainladder/tails/curve.py | 6 +- 6 files changed, 111 insertions(+), 20 deletions(-) diff --git a/chainladder/core/axis.py b/chainladder/core/axis.py index 68ff04542..cf804bbb0 100644 --- a/chainladder/core/axis.py +++ b/chainladder/core/axis.py @@ -13,6 +13,20 @@ class TriangleAxis: """ Generalized descriptor for representing a Triangle dimension, analogous to Pandas AxisProperty. + + Parameters + ---------- + key : str + Key used to store and access the 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__( @@ -23,12 +37,47 @@ def __init__( fset: Callable[[Triangle, Any], Any] | None = None, doc: str | None = None, ) -> None: + """ + Initialize a TriangleAxis descriptor. + + Parameters + ---------- + key : str + Internal storage key 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.key = key 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.key not in obj._axes: @@ -39,6 +88,19 @@ def __get__(self, obj: Triangle | None, objtype: type | None = None) -> Any: 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.key]``, 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 = {} @@ -48,6 +110,21 @@ def __set__(self, obj: Triangle, value: Any) -> None: 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: diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 9976d6701..e8feaad57 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -466,6 +466,17 @@ def test_triangle_columns_setter(raa): tri.columns = ["Col1", "Col2"] +def test_triangle_axes(raa): + """Triangle.axes should return a list of [index, columns, origin, development].""" + axes = raa.axes + assert isinstance(axes, list) + assert len(axes) == 4 + assert axes[0].equals(raa.index) + assert axes[1].equals(raa.columns) + assert axes[2].equals(raa.origin) + assert axes[3].equals(raa.development) + + def test_key_labels_setter(raa): """Setting key_labels should update Triangle.index columns and slicers.""" tri = raa.copy() diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 84dc1f52a..fb0179d7d 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -29,7 +29,13 @@ except ImportError: db = None -from typing import Any, cast, Optional, TYPE_CHECKING, Self +from typing import ( + Any, + cast, + Optional, + TYPE_CHECKING, + Self, +) if TYPE_CHECKING: from pandas import DataFrame, Series @@ -821,18 +827,11 @@ def key_labels(self, value) -> None: self._set_slicers() @property - def _columns(self) -> pd.Index: - return self.columns - - @_columns.setter - def _columns(self, value: Any) -> None: - if isinstance(value, str): - value = [value] - if not hasattr(self, "_axes"): - self._axes = {} - self._axes["columns"] = pd.Index(value, name="columns") - if hasattr(self, "virtual_columns"): - self._set_slicers() + def axes(self) -> list: + """ + Return a list representing the axes of the Triangle. + """ + return [self.index, self.columns, self.origin, self.development] @property def columns_label(self) -> list: diff --git a/chainladder/development/clark.py b/chainladder/development/clark.py index 3fa7c6a16..f254a6490 100644 --- a/chainladder/development/clark.py +++ b/chainladder/development/clark.py @@ -330,14 +330,14 @@ def ldf(age): self.ldf_.valuation_date = pd.to_datetime(options.ULT_VAL) rows = X.index.set_index(X.key_labels).index self.omega_ = pd.DataFrame( - params[..., 0, 0], index=rows, columns=X.columns_label + params[..., 0, 0], index=rows, columns=X.columns.to_list() ) self.theta_ = pd.DataFrame( - params[..., 0, 1], index=rows, columns=X.columns_label + params[..., 0, 1], index=rows, columns=X.columns.to_list() ) if sample_weight: self.elr_ = pd.DataFrame( - params[..., 0, 2], index=rows, columns=X.columns_label + params[..., 0, 2], index=rows, columns=X.columns.to_list() ) ultimate_ = ( self._G(age=(latest_age - age_offset)[::-1]).swapaxes(-1, -2) * ld.values diff --git a/chainladder/tails/bondy.py b/chainladder/tails/bondy.py index c718fb82f..5c1072d88 100644 --- a/chainladder/tails/bondy.py +++ b/chainladder/tails/bondy.py @@ -192,9 +192,11 @@ def fit(self, X, y=None, sample_weight=None): ) fitted = xp.repeat(fitted, self.ldf_.shape[2], axis=2) rows = X.index.set_index(X.key_labels).index - self.b_ = pd.DataFrame(self.b_[..., 0, 0], index=rows, columns=X.columns_label) + self.b_ = pd.DataFrame( + self.b_[..., 0, 0], index=rows, columns=X.columns.to_list() + ) self.earliest_ldf_ = pd.DataFrame( - self.earliest_ldf_[..., 0, 0], index=rows, columns=X.columns_label + self.earliest_ldf_[..., 0, 0], index=rows, columns=X.columns.to_list() ) self.ldf_.values = xp.concatenate( ( diff --git a/chainladder/tails/curve.py b/chainladder/tails/curve.py index ecc363535..bb90db9fa 100644 --- a/chainladder/tails/curve.py +++ b/chainladder/tails/curve.py @@ -289,7 +289,7 @@ def slope_(self): """Does not work with munich""" rows = self.ldf_.index.set_index(self.ldf_.key_labels).index return pd.DataFrame( - self._slope_[..., 0, 0], index=rows, columns=self.ldf_.columns_label + self._slope_[..., 0, 0], index=rows, columns=self.ldf_.columns.to_list() ) @property @@ -297,5 +297,7 @@ def intercept_(self): """Does not work with munich""" rows = self.ldf_.index.set_index(self.ldf_.key_labels).index return pd.DataFrame( - self._intercept_[..., 0, 0], index=rows, columns=self.ldf_.columns_label + self._intercept_[..., 0, 0], + index=rows, + columns=self.ldf_.columns.to_list(), ) From bab08a223fa5ae3cfeb23f831faa9c9fe55c14ce Mon Sep 17 00:00:00 2001 From: priyam0k Date: Tue, 29 Sep 2026 01:35:15 +0530 Subject: [PATCH 15/25] Use integer axis keys --- chainladder/core/axis.py | 29 ++++++----- chainladder/core/dunders.py | 4 +- chainladder/core/tests/test_triangle.py | 69 ++++++++++++++++++++++--- chainladder/core/triangle.py | 36 +++++++++---- chainladder/core/typing.py | 48 +++++++++++++++++ 5 files changed, 154 insertions(+), 32 deletions(-) diff --git a/chainladder/core/axis.py b/chainladder/core/axis.py index cf804bbb0..1b6a8269f 100644 --- a/chainladder/core/axis.py +++ b/chainladder/core/axis.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING, Any, Callable import pandas as pd +from chainladder.core.typing import _get_axis_name, _get_axis_number + if TYPE_CHECKING: from chainladder.core.triangle import Triangle @@ -16,8 +18,9 @@ class TriangleAxis: Parameters ---------- - key : str - Key used to store and access the raw axis data in ``obj._axes``. + 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. @@ -31,7 +34,7 @@ class TriangleAxis: def __init__( self, - key: str, + axis: int | str, *, fget: Callable[[Triangle, Any], Any] | None = None, fset: Callable[[Triangle, Any], Any] | None = None, @@ -42,8 +45,8 @@ def __init__( Parameters ---------- - key : str - Internal storage key in ``obj._axes``. + 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 @@ -51,7 +54,8 @@ def __init__( doc : str, optional Docstring for the attribute. """ - self.key = key + 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 @@ -80,11 +84,12 @@ def __get__(self, obj: Triangle | None, objtype: type | None = None) -> Any: """ if obj is None: return self - if not hasattr(obj, "_axes") or self.key not in obj._axes: + 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 '{self.key}'" + f"'{type(obj).__name__}' object has no attribute '{axis_name}'" ) - raw = obj._axes[self.key] + raw = obj._axes[self.axis] return self.fget(obj, raw) if self.fget else raw def __set__(self, obj: Triangle, value: Any) -> None: @@ -92,7 +97,7 @@ 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.key]``, and updates slicers if necessary. + in ``obj._axes[self.axis]``, and updates slicers if necessary. Parameters ---------- @@ -104,7 +109,7 @@ def __set__(self, obj: Triangle, value: Any) -> None: raw = self.fset(obj, value) if self.fset else value if not hasattr(obj, "_axes"): obj._axes = {} - obj._axes[self.key] = raw + obj._axes[self.axis] = raw if hasattr(obj, "virtual_columns"): obj._set_slicers() @@ -129,6 +134,6 @@ def _set_columns(obj: Triangle, value: Any) -> pd.Index: 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 "columns" in obj._axes: + 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") diff --git a/chainladder/core/dunders.py b/chainladder/core/dunders.py index b5e1edeaf..c8cbd3fb4 100644 --- a/chainladder/core/dunders.py +++ b/chainladder/core/dunders.py @@ -127,10 +127,10 @@ def _prep_index(self, x, y): def _prep_columns(self, x, y): if len(x.columns) == 1 and len(y.columns) > 1: - x._axes["columns"] = y.columns + x._axes[1] = y.columns x._set_slicers() elif len(y.columns) == 1 and len(x.columns) > 1: - y._axes["columns"] = x.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 diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index e8feaad57..ef2f36ba6 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -10,6 +10,7 @@ import warnings from chainladder.core.common import Common +from chainladder.core.typing import _axis_orders, _get_axis_name, _get_axis_number from chainladder.utils.utility_functions import date_delta_adjustment from chainladder.utils.sparse import COO @@ -477,6 +478,44 @@ def test_triangle_axes(raa): assert axes[3].equals(raa.development) +def test_axis_mappings(): + """Verify axis number and name resolutions in chainladder.core.typing.""" + for idx, name in enumerate(_axis_orders): + assert _get_axis_number(idx) == idx + assert _get_axis_number(idx - 4) == idx + assert _get_axis_number(name) == idx + assert _get_axis_name(idx) == name + assert _get_axis_name(idx - 4) == name + assert _get_axis_name(name) == name + + with pytest.raises( + ValueError, match="No axis named invalid for object type Triangle" + ): + _get_axis_number("invalid") + + with pytest.raises(ValueError, match=r"No axis named 5 for object type Triangle"): + _get_axis_number(5) + + +def test_triangle_axis_descriptor(): + """Verify TriangleAxis descriptor correctly uses integer axis key.""" + from chainladder.core.axis import TriangleAxis + + class MockTriangle: + col = TriangleAxis(1) + + obj = MockTriangle() + with pytest.raises( + AttributeError, match="'MockTriangle' object has no attribute 'columns'" + ): + _ = obj.col + + obj.col = pd.Index(["a", "b"], name="columns") + assert 1 in obj._axes + assert list(obj.col) == ["a", "b"] + assert obj.col.name == "columns" + + def test_key_labels_setter(raa): """Setting key_labels should update Triangle.index columns and slicers.""" tri = raa.copy() @@ -501,7 +540,7 @@ def test_series_indexing(raa): def test_legacy_pickle_compatibility(raa): - """Pickles saved prior to kdims/vdims migration should unpickle cleanly.""" + """Pickles saved prior to kdims/vdims migration should unpickle cleanly with warning.""" import pickle # 1. Simulate oldest serialized Triangle containing kdims and vdims @@ -513,7 +552,10 @@ def test_legacy_pickle_compatibility(raa): state["vdims"] = raa.columns.values restored = cl.Triangle.__new__(cl.Triangle) - restored.__setstate__(state) + with pytest.warns( + UserWarning, match="legacy pickled Triangle instance was detected" + ): + restored.__setstate__(state) assert isinstance(restored.columns, pd.Index) assert isinstance(restored.index, pd.DataFrame) @@ -521,6 +563,8 @@ def test_legacy_pickle_compatibility(raa): assert list(restored.columns) == list(raa.columns) assert restored.loc["Total"].shape == raa.loc["Total"].shape assert restored == raa + assert 1 in restored._axes + assert restored._axes[1].equals(raa.columns) # 2. Simulate intermediate serialized Triangle containing _kdims and _vdims state_mid = raa.__dict__.copy() @@ -531,15 +575,20 @@ def test_legacy_pickle_compatibility(raa): state_mid["_vdims"] = raa.columns.values restored_mid = cl.Triangle.__new__(cl.Triangle) - restored_mid.__setstate__(state_mid) + with pytest.warns( + UserWarning, match="legacy pickled Triangle instance was detected" + ): + restored_mid.__setstate__(state_mid) assert isinstance(restored_mid.columns, pd.Index) assert isinstance(restored_mid.index, pd.DataFrame) assert restored_mid.index.equals(raa.index) assert list(restored_mid.columns) == list(raa.columns) assert restored_mid == raa + assert 1 in restored_mid._axes + assert restored_mid._axes[1].equals(raa.columns) - # 3. Simulate fallback when no kdims/vdims keys are found + # 3. Simulate fallback when no kdims/vdims keys are found (no warning) state_empty = raa.__dict__.copy() state_empty.pop("_axes", None) state_empty.pop("_index", None) @@ -550,13 +599,19 @@ def test_legacy_pickle_compatibility(raa): state_empty.pop("_vdims", None) restored_empty = cl.Triangle.__new__(cl.Triangle) - restored_empty.__setstate__(state_empty) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + restored_empty.__setstate__(state_empty) assert list(restored_empty.columns) == ["values"] assert list(restored_empty.index.columns) == ["Total"] + assert 1 in restored_empty._axes - # 4. Verify re-pickling the migrated instance works - roundtripped = pickle.loads(pickle.dumps(restored)) + # 4. Verify re-pickling the migrated instance works without warnings + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + roundtripped = pickle.loads(pickle.dumps(restored)) assert roundtripped == raa + assert 1 in roundtripped._axes def test_valdev1(qtr): diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index fb0179d7d..f1dab9d4a 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -473,7 +473,7 @@ class Triangle(TriangleBase): """ columns = TriangleAxis( - "columns", + 1, fset=_set_columns, doc="Represents the value dimension of the triangle.", ) @@ -502,7 +502,7 @@ def __init__( *args, **kwargs, ): - self._axes: dict[str, Any] = {} + self._axes: dict[int, Any] = {} # If data are present, validate the dimensions. if data is None: @@ -2183,6 +2183,17 @@ def copy(self): def __setstate__(self, state: dict) -> None: """Migrate legacy pickled instances with 'kdims'/'_kdims' to '_index' and 'vdims'/'_vdims'/'_columns' to '_axes'.""" + has_legacy_keys = any( + k in state for k in ("kdims", "_kdims", "vdims", "_vdims", "_columns") + ) + if has_legacy_keys: + warnings.warn( + "A legacy pickled Triangle instance was detected. Please re-save your " + "triangle with chainladder 0.11.0+ as legacy pickle support will be " + "removed in a future release.", + UserWarning, + stacklevel=2, + ) key_labels = state.pop("key_labels", ["Total"]) if "_index" not in state: raw_kdims = state.pop("_kdims", None) @@ -2194,16 +2205,19 @@ def __setstate__(self, state: dict) -> None: state["_index"] = pd.DataFrame([["Total"]], columns=["Total"]) if "_axes" not in state: state["_axes"] = {} - if "columns" not in state["_axes"]: - raw_cols = state.pop("_columns", None) - if raw_cols is None: - raw_cols = state.pop("_vdims", None) - if raw_cols is None: - raw_cols = state.pop("vdims", None) - if raw_cols is not None: - state["_axes"]["columns"] = pd.Index(raw_cols, name="columns") + if 1 not in state["_axes"]: + if "columns" in state["_axes"]: + state["_axes"][1] = state["_axes"].pop("columns") else: - state["_axes"]["columns"] = pd.Index(["values"], name="columns") + raw_cols = state.pop("_columns", None) + if raw_cols is None: + raw_cols = state.pop("_vdims", None) + if raw_cols is None: + raw_cols = state.pop("vdims", None) + if raw_cols is not None: + state["_axes"][1] = pd.Index(raw_cols, name="columns") + else: + state["_axes"][1] = pd.Index(["values"], name="columns") self.__dict__.update(state) def development_correlation(self, p_critical=0.5): diff --git a/chainladder/core/typing.py b/chainladder/core/typing.py index 29496b61c..8177ecfda 100644 --- a/chainladder/core/typing.py +++ b/chainladder/core/typing.py @@ -49,6 +49,54 @@ # before index_key/other_key resolve it to a positional _AxisKey. _LabelKey: TypeAlias = IndexExpression | str | pd.Series | pd.DataFrame +# Canonical ordering of Triangle dimensions. +_axis_orders: tuple[str, ...] = ("index", "columns", "origin", "development") + +# Lookup mapping supporting integers (positive and negative) and string names. +_axis_to_axis_number: dict[int | str, int] = { + **{i: i for i in range(4)}, + **{i - 4: i for i in range(4)}, + **{name: i for i, name in enumerate(_axis_orders)}, +} + + +def _get_axis_number(axis: int | str) -> int: + """ + Convert an axis name or integer (-4..3) to its canonical integer 0..3. + + Parameters + ---------- + axis : int or str + Axis name or integer representation. + + Returns + ------- + int + Canonical integer axis in 0..3. + """ + if axis not in _axis_to_axis_number: + raise ValueError( + f"No axis named {axis} for object type Triangle. " + f"Valid axes are {_axis_orders} or integers 0..3." + ) + return _axis_to_axis_number[axis] + + +def _get_axis_name(axis: int | str) -> str: + """ + Convert an axis name or integer to its canonical name. + + Parameters + ---------- + axis : int or str + Axis name or integer representation. + + Returns + ------- + str + Canonical axis name ('index', 'columns', 'origin', 'development'). + """ + return _axis_orders[_get_axis_number(axis)] class TriangleProtocol(Protocol): """ From 10c4caea4d32027dd391a64e75cb42f1c6d52756 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Tue, 29 Sep 2026 04:19:48 +0530 Subject: [PATCH 16/25] Move axis helpers --- chainladder/core/axis.py | 2 +- chainladder/core/tests/test_triangle.py | 8 +++- chainladder/core/typing.py | 49 ------------------------ chainladder/utils/utility_functions.py | 51 +++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 52 deletions(-) diff --git a/chainladder/core/axis.py b/chainladder/core/axis.py index 1b6a8269f..dc477c457 100644 --- a/chainladder/core/axis.py +++ b/chainladder/core/axis.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Callable import pandas as pd -from chainladder.core.typing import _get_axis_name, _get_axis_number +from chainladder.utils.utility_functions import _get_axis_name, _get_axis_number if TYPE_CHECKING: from chainladder.core.triangle import Triangle diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index ef2f36ba6..bd2597cd1 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -10,8 +10,12 @@ import warnings from chainladder.core.common import Common -from chainladder.core.typing import _axis_orders, _get_axis_name, _get_axis_number -from chainladder.utils.utility_functions import date_delta_adjustment +from chainladder.utils.utility_functions import ( + _axis_orders, + _get_axis_name, + _get_axis_number, + date_delta_adjustment, +) from chainladder.utils.sparse import COO from io import StringIO diff --git a/chainladder/core/typing.py b/chainladder/core/typing.py index 8177ecfda..0a832529c 100644 --- a/chainladder/core/typing.py +++ b/chainladder/core/typing.py @@ -49,55 +49,6 @@ # before index_key/other_key resolve it to a positional _AxisKey. _LabelKey: TypeAlias = IndexExpression | str | pd.Series | pd.DataFrame -# Canonical ordering of Triangle dimensions. -_axis_orders: tuple[str, ...] = ("index", "columns", "origin", "development") - -# Lookup mapping supporting integers (positive and negative) and string names. -_axis_to_axis_number: dict[int | str, int] = { - **{i: i for i in range(4)}, - **{i - 4: i for i in range(4)}, - **{name: i for i, name in enumerate(_axis_orders)}, -} - - -def _get_axis_number(axis: int | str) -> int: - """ - Convert an axis name or integer (-4..3) to its canonical integer 0..3. - - Parameters - ---------- - axis : int or str - Axis name or integer representation. - - Returns - ------- - int - Canonical integer axis in 0..3. - """ - if axis not in _axis_to_axis_number: - raise ValueError( - f"No axis named {axis} for object type Triangle. " - f"Valid axes are {_axis_orders} or integers 0..3." - ) - return _axis_to_axis_number[axis] - - -def _get_axis_name(axis: int | str) -> str: - """ - Convert an axis name or integer to its canonical name. - - Parameters - ---------- - axis : int or str - Axis name or integer representation. - - Returns - ------- - str - Canonical axis name ('index', 'columns', 'origin', 'development'). - """ - return _axis_orders[_get_axis_number(axis)] - class TriangleProtocol(Protocol): """ Common interface expected for Triangle mixins. diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 82d7a2c7b..2c2850329 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -1243,3 +1243,54 @@ def warn_exclusions_ignored(preserve): + " link ratio(s) is required for development estimation." ) warnings.warn(warning) + + +# Canonical ordering of Triangle dimensions. +_axis_orders: tuple[str, ...] = ("index", "columns", "origin", "development") + +# Lookup mapping supporting integers (positive and negative) and string names. +_axis_to_axis_number: dict[int | str, int] = { + **{i: i for i in range(4)}, + **{i - 4: i for i in range(4)}, + **{name: i for i, name in enumerate(_axis_orders)}, +} + + +def _get_axis_number(axis: int | str) -> int: + """ + Convert an axis name or integer (-4..3) to its canonical integer 0..3. + + Parameters + ---------- + axis : int or str + Axis name or integer representation. + + Returns + ------- + int + Canonical integer axis in 0..3. + """ + if axis not in _axis_to_axis_number: + raise ValueError( + f"No axis named {axis} for object type Triangle. " + f"Valid axes are {_axis_orders} or integers 0..3." + ) + return _axis_to_axis_number[axis] + + +def _get_axis_name(axis: int | str) -> str: + """ + Convert an axis name or integer to its canonical name. + + Parameters + ---------- + axis : int or str + Axis name or integer representation. + + Returns + ------- + str + Canonical axis name ('index', 'columns', 'origin', 'development'). + """ + return _axis_orders[_get_axis_number(axis)] + From 3a5c13dfb459c03ee3bfc3b5fb414e492af212fc Mon Sep 17 00:00:00 2001 From: priyam0k Date: Tue, 29 Sep 2026 22:44:10 +0530 Subject: [PATCH 17/25] Fix trailing newline --- chainladder/utils/utility_functions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 2c2850329..3165794f4 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -1293,4 +1293,3 @@ def _get_axis_name(axis: int | str) -> str: Canonical axis name ('index', 'columns', 'origin', 'development'). """ return _axis_orders[_get_axis_number(axis)] - From 261af10dbb98196208114a48bcd26765f07d85a4 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Fri, 2 Oct 2026 13:59:37 +0530 Subject: [PATCH 18/25] Add patch coverage tests --- chainladder/core/tests/test_triangle.py | 78 +++++++++++++++++++++-- chainladder/utils/tests/test_utilities.py | 7 +- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index bd2597cd1..93c5c3c94 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -502,8 +502,11 @@ def test_axis_mappings(): def test_triangle_axis_descriptor(): - """Verify TriangleAxis descriptor correctly uses integer axis key.""" - from chainladder.core.axis import TriangleAxis + """Verify TriangleAxis descriptor correctly uses integer axis key and handles edge cases.""" + from chainladder.core.axis import TriangleAxis, _set_columns + + assert isinstance(cl.Triangle.columns, TriangleAxis) + assert cl.Triangle.columns.__doc__ is not None class MockTriangle: col = TriangleAxis(1) @@ -514,11 +517,42 @@ class MockTriangle: ): _ = obj.col + # Setting when _axes is not yet present on instance initializes _axes + assert not hasattr(obj, "_axes") obj.col = pd.Index(["a", "b"], name="columns") + assert hasattr(obj, "_axes") assert 1 in obj._axes assert list(obj.col) == ["a", "b"] assert obj.col.name == "columns" + # Custom fget mapping + class CustomAxisTriangle: + col = TriangleAxis(1, fget=lambda o, raw: [x.upper() for x in raw]) + + c_obj = CustomAxisTriangle() + c_obj.col = pd.Index(["x", "y"], name="columns") + assert c_obj.col == ["X", "Y"] + + # _set_columns with single string and len_check without values + class MockColumnsTriangle: + def __init__(self): + self.values = None + self._axes = {} + + def _len_check(self, target, val): + if len(target) != len(val): + raise ValueError("length mismatch") + + columns = TriangleAxis(1, fset=_set_columns) + + m = MockColumnsTriangle() + m.columns = "single_col" + assert list(m.columns) == ["single_col"] + + m.columns = ["a"] + with pytest.raises(ValueError, match="length mismatch"): + m.columns = ["a", "b"] + def test_key_labels_setter(raa): """Setting key_labels should update Triangle.index columns and slicers.""" @@ -592,7 +626,31 @@ def test_legacy_pickle_compatibility(raa): assert 1 in restored_mid._axes assert restored_mid._axes[1].equals(raa.columns) - # 3. Simulate fallback when no kdims/vdims keys are found (no warning) + # 3. Simulate serialized Triangle containing _columns + state_col = raa.__dict__.copy() + state_col.pop("_axes", None) + state_col.pop("_index", None) + state_col.pop("_columns", None) + state_col["_kdims"] = raa.index.values + state_col["_columns"] = list(raa.columns) + + restored_col = cl.Triangle.__new__(cl.Triangle) + with pytest.warns( + UserWarning, match="legacy pickled Triangle instance was detected" + ): + restored_col.__setstate__(state_col) + assert list(restored_col.columns) == list(raa.columns) + assert 1 in restored_col._axes + + # 4. Simulate string-keyed "columns" in _axes + state_str = raa.__dict__.copy() + state_str["_axes"] = {"columns": pd.Index(list(raa.columns), name="columns")} + restored_str = cl.Triangle.__new__(cl.Triangle) + restored_str.__setstate__(state_str) + assert list(restored_str.columns) == list(raa.columns) + assert 1 in restored_str._axes + + # 5. Simulate fallback when no kdims/vdims keys are found (no warning) state_empty = raa.__dict__.copy() state_empty.pop("_axes", None) state_empty.pop("_index", None) @@ -610,7 +668,7 @@ def test_legacy_pickle_compatibility(raa): assert list(restored_empty.index.columns) == ["Total"] assert 1 in restored_empty._axes - # 4. Verify re-pickling the migrated instance works without warnings + # 6. Verify re-pickling the migrated instance works without warnings with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) roundtripped = pickle.loads(pickle.dumps(restored)) @@ -618,6 +676,18 @@ def test_legacy_pickle_compatibility(raa): assert 1 in roundtripped._axes +def test_sort_axis_columns_out_of_order(raa): + """Verify sort_axis(1) reorders values when columns are unsorted.""" + import copy + + t1 = copy.deepcopy(raa).rename("columns", ["B"]) + t2 = copy.deepcopy(raa).rename("columns", ["A"]) + combined = cl.concat([t1, t2], axis=1) + assert list(combined.columns) == ["B", "A"] + sorted_tri = combined.sort_axis(1) + assert list(sorted_tri.columns) == ["A", "B"] + + def test_valdev1(qtr): assert qtr.dev_to_val().val_to_dev() == qtr diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index e613f99fc..c2925eccb 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -613,7 +613,12 @@ def test_concat_axis1_duplicate_columns(raa: Triangle) -> None: def test_concat_ignore_index_axes(raa: Triangle) -> None: - """Test concat with ignore_index=True along axes 1, 2, and 3.""" + """Test concat with ignore_index=True along axes 0, 1, 2, and 3.""" + res0 = cl.concat([raa, raa], axis=0, ignore_index=True) + assert len(res0.index) == 2 + assert res0.key_labels == ["Index"] + assert list(res0.index["Index"]) == [0, 1] + t1 = copy.deepcopy(raa).rename("columns", ["A"]) t2 = copy.deepcopy(raa).rename("columns", ["B"]) res1 = cl.concat([t1, t2], axis=1, ignore_index=True) From 5228bd1a607cb4875d7cbd516a98a4873276c465 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Fri, 2 Oct 2026 15:01:08 +0530 Subject: [PATCH 19/25] Validate boolean series length --- chainladder/core/slice.py | 8 +++++++- chainladder/core/tests/test_triangle.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py index fb92cc916..08d4fe4a1 100644 --- a/chainladder/core/slice.py +++ b/chainladder/core/slice.py @@ -9,6 +9,7 @@ import numpy as np import pandas as pd +from pandas.core.indexing import check_bool_indexer from chainladder.core.typing import _AxisKey, _LabelKey, TriangleProtocol @@ -798,7 +799,12 @@ def __getitem__( # Case index. if isinstance(key, pd.Series): if pd.api.types.is_bool_dtype(key): - return self.iloc[np.where(key.to_numpy())[0]] + if len(key) != len(self.index): + raise IndexError( + f"Boolean index has wrong length: {len(key)} instead of {len(self.index)}" + ) + mask = check_bool_indexer(self.index.index, key) + return self.iloc[np.where(mask)[0]] return self.iloc[self.index[key].index] elif key in self.key_labels: return self.index[key] diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 93c5c3c94..6d0a3817e 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -576,6 +576,28 @@ def test_series_indexing(raa): s_label = pd.Series(["Total"]) assert raa[s_label].shape == raa.shape + # Length mismatch raises IndexError + s_short = pd.Series([], dtype=bool) + with pytest.raises(IndexError, match="Boolean index has wrong length"): + _ = raa[s_short] + + s_long = pd.Series([True, False]) + with pytest.raises(IndexError, match="Boolean index has wrong length"): + _ = raa[s_long] + + # Index misalignment raises IndexingError + s_misaligned = pd.Series([True], index=[99]) + with pytest.raises(pd.errors.IndexingError): + _ = raa[s_misaligned] + + # Permuted index aligns correctly to instance index + two_row = cl.concat([raa, raa], axis=0, ignore_index=True) + two_row.index = pd.DataFrame({"Company": ["A", "B"]}) + s_perm = pd.Series([False, True], index=[1, 0]) + sliced = two_row[s_perm] + assert len(sliced.index) == 1 + assert sliced.index.iloc[0]["Company"] == "A" + def test_legacy_pickle_compatibility(raa): """Pickles saved prior to kdims/vdims migration should unpickle cleanly with warning.""" From b04e0d69c9c821155bf0e88e31a2e64ed8e2b3a1 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 3 Oct 2026 04:47:09 +0530 Subject: [PATCH 20/25] Format core typing --- chainladder/core/typing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/chainladder/core/typing.py b/chainladder/core/typing.py index 0a832529c..29496b61c 100644 --- a/chainladder/core/typing.py +++ b/chainladder/core/typing.py @@ -49,6 +49,7 @@ # before index_key/other_key resolve it to a positional _AxisKey. _LabelKey: TypeAlias = IndexExpression | str | pd.Series | pd.DataFrame + class TriangleProtocol(Protocol): """ Common interface expected for Triangle mixins. From 59e55dcb0d02478b440392eef5a9719140ff6f23 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Sat, 3 Oct 2026 12:35:07 +0530 Subject: [PATCH 21/25] Fix duplicate column assignment --- chainladder/core/slice.py | 14 ++++++++++---- chainladder/core/tests/test_triangle.py | 12 ++++++++++++ chainladder/core/triangle.py | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py index 08d4fe4a1..ca36f4ed6 100644 --- a/chainladder/core/slice.py +++ b/chainladder/core/slice.py @@ -891,13 +891,22 @@ def __setitem__( # Otherwise, remove column from virtual columns if previously reserved for lazy evaluation. else: self.virtual_columns.pop(key) + if isinstance(value, (int, float, np.number)): + # Broadcast scalar across the Triangle's shape. + value = self.iloc[:, 0] * 0 + value if isinstance(value, TriangleSlicer): value = cast("TriangleProtocol", cast(object, value)) if value.array_backend != self.array_backend: value = value.set_backend(self.array_backend) # Key exists in columns, replace data. if key in self.columns: - i = self.columns.get_loc(key) + loc = self.columns.get_loc(key) + if isinstance(loc, slice): + i = loc.start or 0 + elif isinstance(loc, np.ndarray): + i = int(np.where(loc)[0][0]) + else: + i = int(loc) # Case sparse backend. if self.array_backend == "sparse": # Unwrap a Triangle-valued assignment to its raw array. A raw @@ -935,9 +944,6 @@ def __setitem__( cast(np.ndarray, self.values)[:, i : i + 1] = value # Key is new, create a column and update data. else: - if isinstance(value, (int, float, np.number)): - # Broadcast scalar across the Triangle's shape. - value = self.iloc[:, 0] * 0 + value try: self.values = xp.concatenate((self.values, value.values), axis=1) except (ValueError, AttributeError, AssertionError): diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 6d0a3817e..5413384f1 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -3752,3 +3752,15 @@ def test_sort_index_inplace(clrd: Triangle) -> None: assert result is shuffled pd.testing.assert_frame_equal(shuffled.index, clrd.index) assert shuffled == clrd + + +def test_duplicate_columns_assignment(raa) -> None: + """Assigning to duplicate column label updates first matching column without error.""" + r1 = raa.copy() + r1.columns = ["a"] + r2 = raa.copy() + r2.columns = ["b"] + two = cl.concat([r1, r2], axis=1) + two.columns = ["col", "col"] + two["col"] = 42 + assert two.values[0, 0, 0, 0] == 42 diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index f1dab9d4a..cc6c428da 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -475,7 +475,7 @@ class Triangle(TriangleBase): columns = TriangleAxis( 1, fset=_set_columns, - doc="Represents the value dimension of the triangle.", + doc="Represents the column axis of the triangle.", ) @_deprecated_rename_argument("development", "valuation", remove_in_version="v2.0") From 4d2de42440ab2f635cd858de62ce397a84233f56 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Wed, 30 Sep 2026 00:03:03 +0530 Subject: [PATCH 22/25] Implement index axis descriptor --- chainladder/core/axis.py | 25 ++++ chainladder/core/dunders.py | 8 +- chainladder/core/slice.py | 2 +- chainladder/core/tests/test_triangle.py | 154 ++++++++++++++++++++---- chainladder/core/triangle.py | 62 +++++----- chainladder/utils/utility_functions.py | 4 +- 6 files changed, 197 insertions(+), 58 deletions(-) diff --git a/chainladder/core/axis.py b/chainladder/core/axis.py index dc477c457..86ac3c434 100644 --- a/chainladder/core/axis.py +++ b/chainladder/core/axis.py @@ -137,3 +137,28 @@ def _set_columns(obj: Triangle, value: Any) -> pd.Index: 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) diff --git a/chainladder/core/dunders.py b/chainladder/core/dunders.py index c8cbd3fb4..da39ae1a6 100644 --- a/chainladder/core/dunders.py +++ b/chainladder/core/dunders.py @@ -73,12 +73,12 @@ def _compatibility_check(self, x, y): def _prep_index(self, x, y): if len(x.index) == 1 and len(y.index) > 1: - x._index = y.index.copy() + x._axes[0] = y.index.copy() x.key_labels = list(y.key_labels) x._set_slicers() return x, y if len(x.index) > 1 and len(y.index) == 1: - y._index = x.index.copy() + y._axes[0] = x.index.copy() y.key_labels = list(x.key_labels) y._set_slicers() return x, y @@ -88,10 +88,10 @@ def _prep_index(self, x, y): if len(x.key_labels) > len(y.key_labels) else y.index.copy() ) - x._index = index.copy() + x._axes[0] = index.copy() x.key_labels = list(index.columns) x._set_slicers() - y._index = index.copy() + y._axes[0] = index.copy() y.key_labels = list(index.columns) y._set_slicers() return x, y diff --git a/chainladder/core/slice.py b/chainladder/core/slice.py index ca36f4ed6..440870e2c 100644 --- a/chainladder/core/slice.py +++ b/chainladder/core/slice.py @@ -64,7 +64,7 @@ def get_idx(self, idx: tuple[_AxisKey, _AxisKey, _AxisKey, _AxisKey]) -> Triangl else: obj.values = obj.values[i_idx, :, o_idx, d_idx][:, c_idx, ...] # Set the new dimension values. - obj._index = obj._index.iloc[i_idx].reset_index(drop=True) + obj.index = obj.index.iloc[i_idx].reset_index(drop=True) obj.columns = obj.columns[c_idx] obj.odims, obj.ddims = obj.odims[o_idx], obj.ddims[d_idx] obj.valuation_date = cast( diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 5413384f1..f88a7489f 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -502,28 +502,53 @@ def test_axis_mappings(): def test_triangle_axis_descriptor(): - """Verify TriangleAxis descriptor correctly uses integer axis key and handles edge cases.""" - from chainladder.core.axis import TriangleAxis, _set_columns + """Verify TriangleAxis descriptor correctly uses integer axis keys for index and columns.""" + from chainladder.core.axis import TriangleAxis, _set_columns, _set_index + assert isinstance(cl.Triangle.index, TriangleAxis) + assert cl.Triangle.index.__doc__ is not None assert isinstance(cl.Triangle.columns, TriangleAxis) assert cl.Triangle.columns.__doc__ is not None class MockTriangle: - col = TriangleAxis(1) + def __init__(self): + self.values = None + self._axes = {} + + def _len_check(self, target, obj): + if len(target) != len(obj): + raise ValueError("length mismatch") + + index = TriangleAxis(0, fset=_set_index) + columns = TriangleAxis(1, fset=_set_columns) obj = MockTriangle() - with pytest.raises( - AttributeError, match="'MockTriangle' object has no attribute 'columns'" - ): - _ = obj.col + with pytest.raises(TypeError, match="index must be a pandas DataFrame"): + obj.index = "not_a_df" + + df = pd.DataFrame({"Company": ["A", "B"]}) + obj.index = df + assert 0 in obj._axes + pd.testing.assert_frame_equal(obj.index, df) + + # When self.values is None and 0 in _axes, _set_index checks against obj.index + df_same_len = pd.DataFrame({"Company": ["C", "D"]}) + obj.index = df_same_len + pd.testing.assert_frame_equal(obj.index, df_same_len) + + with pytest.raises(ValueError, match="length mismatch"): + obj.index = pd.DataFrame({"Company": ["A", "B", "C"]}) - # Setting when _axes is not yet present on instance initializes _axes - assert not hasattr(obj, "_axes") - obj.col = pd.Index(["a", "b"], name="columns") - assert hasattr(obj, "_axes") + # When self.values is not None, _set_index checks against range(self.values.shape[0]) + obj.values = np.zeros((2, 3)) + obj.index = df + with pytest.raises(ValueError, match="length mismatch"): + obj.index = pd.DataFrame({"Company": ["A"]}) + + obj.columns = ["a", "b", "c"] assert 1 in obj._axes - assert list(obj.col) == ["a", "b"] - assert obj.col.name == "columns" + assert list(obj.columns) == ["a", "b", "c"] + assert obj.columns.name == "columns" # Custom fget mapping class CustomAxisTriangle: @@ -554,6 +579,52 @@ def _len_check(self, target, val): m.columns = ["a", "b"] +def test_triangle_index_property_and_alias(raa): + """Verify Triangle.index descriptor, _index backward-compat alias, and slicer reset.""" + assert raa._index.equals(raa.index) + tri = raa.copy() + tri._index = tri.index.copy() + assert tri._index.equals(raa.index) + assert tri.index.equals(raa.index) + + tri.index = pd.DataFrame({"Total": ["A"]}) + assert list(tri.index["Total"]) == ["A"] + assert list(tri._index["Total"]) == ["A"] + + +def test_prep_index_single_element_different_key_labels(raa): + """Verify arithmetic index broadcasting when both triangles have 1 row but differing key_labels.""" + x = raa.copy() + x.key_labels = ["LabelA"] + y = raa.copy() + y.index = pd.DataFrame([["B", "C"]], columns=["LabelB", "LabelC"]) + + z1 = x + y + assert z1.key_labels == ["LabelB", "LabelC"] + assert 0 in z1._axes + + z2 = y + x + assert z2.key_labels == ["LabelB", "LabelC"] + assert 0 in z2._axes + + +def test_concat_axis0_index(raa): + """Verify cl.concat along axis 0 updates index and key_labels.""" + out_ignore = cl.concat([raa, raa], axis=0, ignore_index=True) + assert len(out_ignore.index) == 2 + assert out_ignore.key_labels == ["Index"] + assert 0 in out_ignore._axes + + r1 = raa.copy() + r1.index = pd.DataFrame({"Item": ["A"]}) + r2 = raa.copy() + r2.index = pd.DataFrame({"Item": ["B"]}) + out_keep = cl.concat([r1, r2], axis=0, ignore_index=False) + assert len(out_keep.index) == 2 + assert list(out_keep.index["Item"]) == ["A", "B"] + assert 0 in out_keep._axes + + def test_key_labels_setter(raa): """Setting key_labels should update Triangle.index columns and slicers.""" tri = raa.copy() @@ -623,6 +694,8 @@ def test_legacy_pickle_compatibility(raa): assert list(restored.columns) == list(raa.columns) assert restored.loc["Total"].shape == raa.loc["Total"].shape assert restored == raa + assert 0 in restored._axes + assert restored._axes[0].equals(raa.index) assert 1 in restored._axes assert restored._axes[1].equals(raa.columns) @@ -645,10 +718,30 @@ def test_legacy_pickle_compatibility(raa): assert restored_mid.index.equals(raa.index) assert list(restored_mid.columns) == list(raa.columns) assert restored_mid == raa + assert 0 in restored_mid._axes + assert restored_mid._axes[0].equals(raa.index) assert 1 in restored_mid._axes assert restored_mid._axes[1].equals(raa.columns) - # 3. Simulate serialized Triangle containing _columns + # 3. Simulate unprefixed kdims/vdims legacy pickle + state_unprefixed = raa.__dict__.copy() + state_unprefixed.pop("_axes", None) + state_unprefixed.pop("_index", None) + state_unprefixed.pop("_columns", None) + state_unprefixed.pop("_kdims", None) + state_unprefixed.pop("_vdims", None) + state_unprefixed["kdims"] = raa.index.values + state_unprefixed["vdims"] = raa.columns.values + + restored_unprefixed = cl.Triangle.__new__(cl.Triangle) + with pytest.warns( + UserWarning, match="legacy pickled Triangle instance was detected" + ): + restored_unprefixed.__setstate__(state_unprefixed) + assert 0 in restored_unprefixed._axes + assert restored_unprefixed.index.equals(raa.index) + + # 4. Simulate serialized Triangle containing _columns state_col = raa.__dict__.copy() state_col.pop("_axes", None) state_col.pop("_index", None) @@ -664,15 +757,30 @@ def test_legacy_pickle_compatibility(raa): assert list(restored_col.columns) == list(raa.columns) assert 1 in restored_col._axes - # 4. Simulate string-keyed "columns" in _axes - state_str = raa.__dict__.copy() - state_str["_axes"] = {"columns": pd.Index(list(raa.columns), name="columns")} + # 5. Simulate string-keyed _axes + state_str_axes = raa.__dict__.copy() + state_str_axes.pop("_index", None) + state_str_axes.pop("_columns", None) + state_str_axes["_axes"] = { + "index": raa.index.copy(), + "columns": raa.columns.copy(), + } restored_str = cl.Triangle.__new__(cl.Triangle) - restored_str.__setstate__(state_str) - assert list(restored_str.columns) == list(raa.columns) + restored_str.__setstate__(state_str_axes) + assert 0 in restored_str._axes assert 1 in restored_str._axes - - # 5. Simulate fallback when no kdims/vdims keys are found (no warning) + assert restored_str.index.equals(raa.index) + + # 6. Simulate redundant _index when 0 already in _axes + state_redundant = raa.__dict__.copy() + state_redundant["_axes"] = {0: raa.index.copy(), 1: raa.columns.copy()} + state_redundant["_index"] = raa.index.copy() + restored_red = cl.Triangle.__new__(cl.Triangle) + restored_red.__setstate__(state_redundant) + assert 0 in restored_red._axes + assert "_index" not in restored_red.__dict__ + + # 7. Simulate fallback when no kdims/vdims keys are found (no warning) state_empty = raa.__dict__.copy() state_empty.pop("_axes", None) state_empty.pop("_index", None) @@ -688,13 +796,15 @@ def test_legacy_pickle_compatibility(raa): restored_empty.__setstate__(state_empty) assert list(restored_empty.columns) == ["values"] assert list(restored_empty.index.columns) == ["Total"] + assert 0 in restored_empty._axes assert 1 in restored_empty._axes - # 6. Verify re-pickling the migrated instance works without warnings + # 8. Verify re-pickling the migrated instance works without warnings with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) roundtripped = pickle.loads(pickle.dumps(restored)) assert roundtripped == raa + assert 0 in roundtripped._axes assert 1 in roundtripped._axes diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index cc6c428da..623d1ace3 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -11,7 +11,7 @@ import numpy as np import warnings from chainladder.core.base import TriangleBase -from chainladder.core.axis import TriangleAxis, _set_columns +from chainladder.core.axis import TriangleAxis, _set_columns, _set_index from chainladder.utils.sparse import sp from chainladder.core.slice import VirtualColumns from chainladder.core.correlation import DevelopmentCorrelation, ValuationCorrelation @@ -472,6 +472,11 @@ class Triangle(TriangleBase): 1982 12000.0 """ + index = TriangleAxis( + 0, + fset=_set_index, + doc="Represents the index dimension of the triangle.", + ) columns = TriangleAxis( 1, fset=_set_columns, @@ -630,7 +635,6 @@ def __init__( self.index_label: list = index data_agg[index[0]] = "Total" - self._index: DataFrame key_idx: np.ndarray self.odims: np.ndarray orig_idx: np.ndarray @@ -638,7 +642,7 @@ def __init__( dev_idx: np.ndarray kdims_arr, key_idx = self._set_kdims(data_agg, index) - self._index = pd.DataFrame(list(kdims_arr), columns=index) + self.index = pd.DataFrame(list(kdims_arr), columns=index) self.columns = columns self.odims, orig_idx = self._set_odims(data_agg, date_axes) self.ddims, dev_idx = self._set_ddims(data_agg, date_axes) @@ -720,7 +724,7 @@ def __init__( has_duplicates=False, sorted=True, shape=( - len(self._index), + len(self.index), len(self.columns), len(self.odims), len(self.ddims), @@ -792,30 +796,22 @@ def _split_ult( return data, ult @property - def index(self) -> DataFrame: + def _index(self) -> DataFrame: """ - Returns a DataFrame of the unique values of the index. + Backward-compatibility alias for Triangle.index. """ - return self._index + return self.index - @index.setter - def index(self, value) -> None: - if hasattr(self, "values") and self.values is not None: - self._len_check(range(self.values.shape[0]), value) - else: - self._len_check(self.index, value) - if isinstance(value, pd.DataFrame): - self._index = value.copy().reset_index(drop=True) - self._set_slicers() - else: - raise TypeError("index must be a pandas DataFrame") + @_index.setter + def _index(self, value: Any) -> None: + self.index = value @property def key_labels(self) -> list: """ Returns a list of the labels corresponding to the levels of the index. """ - return list(self._index.columns) + return list(self.index.columns) @key_labels.setter def key_labels(self, value) -> None: @@ -823,7 +819,7 @@ def key_labels(self, value) -> None: value = [value] else: value = list(value) - self._index.columns = value + self.index.columns = value self._set_slicers() @property @@ -2178,7 +2174,6 @@ def copy(self): X._axes = {k: v.copy() for k, v in self._axes.items()} X._set_slicers() X.values = X.values.copy() - X._index = self._index.copy() return X def __setstate__(self, state: dict) -> None: @@ -2195,16 +2190,25 @@ def __setstate__(self, state: dict) -> None: stacklevel=2, ) key_labels = state.pop("key_labels", ["Total"]) - if "_index" not in state: - raw_kdims = state.pop("_kdims", None) - if raw_kdims is None: - raw_kdims = state.pop("kdims", None) - if raw_kdims is not None: - state["_index"] = pd.DataFrame(list(raw_kdims), columns=key_labels) - else: - state["_index"] = pd.DataFrame([["Total"]], columns=["Total"]) if "_axes" not in state: state["_axes"] = {} + if 0 not in state["_axes"]: + if "_index" in state: + state["_axes"][0] = state.pop("_index") + elif "index" in state["_axes"]: + state["_axes"][0] = state["_axes"].pop("index") + else: + raw_kdims = state.pop("_kdims", None) + if raw_kdims is None: + raw_kdims = state.pop("kdims", None) + if raw_kdims is not None: + state["_axes"][0] = pd.DataFrame( + list(raw_kdims), columns=key_labels + ) + else: + state["_axes"][0] = pd.DataFrame([["Total"]], columns=["Total"]) + else: + state.pop("_index", None) if 1 not in state["_axes"]: if "columns" in state["_axes"]: state["_axes"][1] = state["_axes"].pop("columns") diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 3165794f4..ab54a3406 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -723,12 +723,12 @@ def concat( if axis == 0: if ignore_index: new_axis = np.arange(sum([len(obj.index) for obj in objs]))[:, None] - out._index = pd.DataFrame(new_axis, columns=["Index"]) + out.index = pd.DataFrame(new_axis, columns=["Index"]) out.key_labels = ["Index"] else: new_axis = pd.concat([obj.index for obj in objs], ignore_index=True) assert len(new_axis.drop_duplicates()) == len(new_axis) - out._index = new_axis + out.index = new_axis out.key_labels = list(new_axis.columns) elif axis == 1: if ignore_index: From c1339d25d4a9113439af77bd00d1f7522fd63931 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Wed, 7 Oct 2026 08:55:54 +0530 Subject: [PATCH 23/25] Drop Triangle._index alias --- chainladder/core/tests/test_triangle.py | 15 +++++---------- chainladder/core/triangle.py | 11 ----------- chainladder/utils/utility_functions.py | 4 ++-- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index f88a7489f..542579f39 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -579,17 +579,14 @@ def _len_check(self, target, val): m.columns = ["a", "b"] -def test_triangle_index_property_and_alias(raa): - """Verify Triangle.index descriptor, _index backward-compat alias, and slicer reset.""" - assert raa._index.equals(raa.index) +def test_triangle_index_property(raa): + """Verify Triangle.index descriptor and slicer reset.""" tri = raa.copy() - tri._index = tri.index.copy() - assert tri._index.equals(raa.index) + tri.index = tri.index.copy() assert tri.index.equals(raa.index) tri.index = pd.DataFrame({"Total": ["A"]}) assert list(tri.index["Total"]) == ["A"] - assert list(tri._index["Total"]) == ["A"] def test_prep_index_single_element_different_key_labels(raa): @@ -3821,22 +3818,20 @@ def test_full_fill(raa: Triangle) -> None: def test_json_roundtrip_preserves_dataframe_index(raa, clrd) -> None: - """JSON serialization roundtrip must preserve Triangle._index as a pd.DataFrame.""" + """JSON serialization roundtrip must preserve Triangle.index as a pd.DataFrame.""" r_single = cl.read_json(raa.to_json()) assert isinstance(r_single.index, pd.DataFrame) - assert isinstance(r_single._index, pd.DataFrame) assert r_single.index.iloc[:, 0].tolist() == ["Total"] assert r_single == raa r_multi = cl.read_json(clrd.to_json()) assert isinstance(r_multi.index, pd.DataFrame) - assert isinstance(r_multi._index, pd.DataFrame) pd.testing.assert_frame_equal(r_multi.index, clrd.index) assert r_multi == clrd def test_triangle_copy_isolated_index(raa) -> None: - """Triangle.copy() must not share the mutable _index DataFrame.""" + """Triangle.copy() must not share the mutable index DataFrame.""" copied = raa.copy() assert copied.index is not raa.index copied.index.iloc[0, 0] = "Modified" diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 623d1ace3..65b0151fc 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -795,17 +795,6 @@ def _split_ult( data = data[data[valuation[0]] != options.ULT_VAL] return data, ult - @property - def _index(self) -> DataFrame: - """ - Backward-compatibility alias for Triangle.index. - """ - return self.index - - @_index.setter - def _index(self, value: Any) -> None: - self.index = value - @property def key_labels(self) -> list: """ diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index ab54a3406..61837fe6b 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -434,8 +434,8 @@ def read_json(json_str, array_backend=None): if len(df.columns) == 1: df = df.iloc[:, 0] setattr(tri, k, df) - if isinstance(tri._index, pd.Series): - tri._index = tri._index.to_frame() + if isinstance(tri.index, pd.Series): + tri.index = tri.index.to_frame() if array_backend: return tri.set_backend(array_backend) else: From f60498b899acef7ecd3dff674c46dd87ee7f4d94 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Fri, 9 Oct 2026 11:44:24 +0530 Subject: [PATCH 24/25] Address review requested renames --- chainladder/adjustments/bootstrap.py | 5 +++++ chainladder/core/base.py | 19 ++++++++++++------- chainladder/core/triangle.py | 14 ++++++++------ chainladder/utils/tests/test_utilities.py | 9 +++++++++ chainladder/utils/utility_functions.py | 4 +++- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/chainladder/adjustments/bootstrap.py b/chainladder/adjustments/bootstrap.py index 84f7fc5af..ae4921dda 100644 --- a/chainladder/adjustments/bootstrap.py +++ b/chainladder/adjustments/bootstrap.py @@ -219,6 +219,11 @@ def fit(self, X, y=None, sample_weight=None): self.resampled_triangles_, self.scale_ = self._get_simulation( X, exp_incr_triangle ) + # n_obs = xp.nansum(self.w_) + # n_origin_params = X.shape[2] + # n_dev_params = X.shape[3] - 1 + # deg_free = n_obs - n_origin_params - n_dev_params + # deg_free_adj_fctr = xp.sqrt(n_obs / deg_free) return self def _get_simulation(self, X, exp_incr_triangle): diff --git a/chainladder/core/base.py b/chainladder/core/base.py index d15d6cd16..4f30769a1 100644 --- a/chainladder/core/base.py +++ b/chainladder/core/base.py @@ -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. @@ -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( @@ -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, @@ -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] diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 65b0151fc..850e47185 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -11,7 +11,9 @@ import numpy as np import warnings from chainladder.core.base import TriangleBase -from chainladder.core.axis import TriangleAxis, _set_columns, _set_index +from chainladder.core.axis import TriangleAxis +from chainladder.core.axis import _set_columns +from chainladder.core.axis import _set_index from chainladder.utils.sparse import sp from chainladder.core.slice import VirtualColumns from chainladder.core.correlation import DevelopmentCorrelation, ValuationCorrelation @@ -475,7 +477,7 @@ class Triangle(TriangleBase): index = TriangleAxis( 0, fset=_set_index, - doc="Represents the index dimension of the triangle.", + doc="Represents the index axis of the triangle.", ) columns = TriangleAxis( 1, @@ -635,14 +637,14 @@ def __init__( self.index_label: list = index data_agg[index[0]] = "Total" - key_idx: np.ndarray + index_idx: np.ndarray self.odims: np.ndarray orig_idx: np.ndarray self.ddims: ArrayLike dev_idx: np.ndarray - kdims_arr, key_idx = self._set_kdims(data_agg, index) - self.index = pd.DataFrame(list(kdims_arr), columns=index) + index_values, index_idx = self._factorize_index(data_agg, index) + self.index = pd.DataFrame(list(index_values), columns=index) self.columns = columns self.odims, orig_idx = self._set_odims(data_agg, date_axes) self.ddims, dev_idx = self._set_ddims(data_agg, date_axes) @@ -707,7 +709,7 @@ def __init__( coords, amts = self._set_values( data_agg=data_agg, - key_idx=key_idx, + index_idx=index_idx, columns=columns, orig_idx=orig_idx, dev_idx=dev_idx, diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index c2925eccb..dc725e055 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -635,6 +635,15 @@ def test_concat_ignore_index_axes(raa: Triangle) -> None: assert len(res3.ddims) == len(raa.ddims) +def test_concat_axis_1_index_dtype_relaxed(raa: Triangle) -> None: + """Verify concat on axis 1 succeeds when index values match but row index labels differ.""" + t1 = copy.deepcopy(raa).rename("columns", ["A"]) + t2 = copy.deepcopy(raa).rename("columns", ["B"]) + t2.index = pd.DataFrame(t2.index.values, columns=t2.index.columns, index=[10]) + res = cl.concat([t1, t2], axis=1) + assert list(res.columns) == ["A", "B"] + + def test_maximum_2(raa: Triangle) -> None: """ Run cl.maximum(raa, 5000) and check if each element in the resulting triangle is at least 5000. diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 61837fe6b..43f75f254 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -709,7 +709,9 @@ def concat( objs = set_common_backend(objs) if axis != 0: for obj in objs[1:]: - assert obj.index.equals(objs[0].index) + assert list(obj.index.columns) == list( + objs[0].index.columns + ) and np.array_equal(obj.index.values, objs[0].index.values) if axis != 2: a = np.array([obj.odims for obj in objs]) assert np.all(a == a[0]) From 4df836836d830447197853e2adc3274464f374e1 Mon Sep 17 00:00:00 2001 From: priyam0k Date: Fri, 9 Oct 2026 12:36:52 +0530 Subject: [PATCH 25/25] Fix concat nan index --- chainladder/utils/tests/test_utilities.py | 11 +++++++++++ chainladder/utils/utility_functions.py | 13 ++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index dc725e055..ae355634e 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -644,6 +644,17 @@ def test_concat_axis_1_index_dtype_relaxed(raa: Triangle) -> None: assert list(res.columns) == ["A", "B"] +def test_concat_axis_1_index_nan_preserved(raa: Triangle) -> None: + """Verify concat on axis 1 succeeds when index contains NaN entries.""" + t1 = copy.deepcopy(raa).rename("columns", ["A"]) + t2 = copy.deepcopy(raa).rename("columns", ["B"]) + t1.index = pd.DataFrame([["Total", np.nan]], columns=["Key1", "Key2"]) + t2.index = pd.DataFrame([["Total", np.nan]], columns=["Key1", "Key2"]) + res = cl.concat([t1, t2], axis=1) + assert list(res.columns) == ["A", "B"] + assert pd.isna(res.index.iloc[0, 1]) + + def test_maximum_2(raa: Triangle) -> None: """ Run cl.maximum(raa, 5000) and check if each element in the resulting triangle is at least 5000. diff --git a/chainladder/utils/utility_functions.py b/chainladder/utils/utility_functions.py index 43f75f254..0b9401392 100644 --- a/chainladder/utils/utility_functions.py +++ b/chainladder/utils/utility_functions.py @@ -709,9 +709,16 @@ def concat( objs = set_common_backend(objs) if axis != 0: for obj in objs[1:]: - assert list(obj.index.columns) == list( - objs[0].index.columns - ) and np.array_equal(obj.index.values, objs[0].index.values) + assert ( + list(obj.index.columns) == list(objs[0].index.columns) + and obj.index.shape == objs[0].index.shape + and bool( + ( + (obj.index.values == objs[0].index.values) + | (pd.isna(obj.index.values) & pd.isna(objs[0].index.values)) + ).all() + ) + ) if axis != 2: a = np.array([obj.odims for obj in objs]) assert np.all(a == a[0])