Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
23b3976
Release prep
kennethshsu Jul 14, 2026
1865e79
Updated release notes
kennethshsu Jul 14, 2026
e185808
feat(core): add index/columns/origin/development alternatives to Tria…
priyam0k Jul 15, 2026
a1e721b
docs(core): add drop() examples and wrap TriangleProtocol.drop signature
priyam0k Jul 22, 2026
2a7c50e
fix(core): handle integer labels in Triangle.drop()
priyam0k Jul 22, 2026
8157830
fix(core): preserve list-like handling in Triangle.drop()
priyam0k Jul 22, 2026
74126e2
Merge branch 'main' of https://github.com/casact/chainladder-python i…
kennethshsu Jul 22, 2026
b213147
Sync from main, added a few more updates
kennethshsu Jul 22, 2026
65f70d0
docs(core): show original columns in drop() example
priyam0k Jul 23, 2026
e4c0ff6
Added release date
kennethshsu Jul 23, 2026
2289b78
Bump uv
kennethshsu Jul 23, 2026
69a84b6
Apply lognormal fix for apriori draws and add tests
Jul 24, 2026
935c567
Added sphinx-book-theme dependency
kennethshsu Jul 24, 2026
8383809
Added contributors full names
kennethshsu Jul 24, 2026
2f7a702
Added contributor fullnames and missing tickets
kennethshsu Jul 24, 2026
d539991
Tighten sphinx-book-theme version
kennethshsu Jul 24, 2026
8b4e131
bug bot fix
kennethshsu Jul 24, 2026
fcdfd1d
Fix documentation build warnings and cross-references
salexanian Jul 14, 2026
6816c74
Restore local Sphinx extension path
salexanian Jul 15, 2026
bb8c1e9
Fix Development reference in user guide
salexanian Jul 26, 2026
bb186ec
Restore user guide section links.
salexanian Jul 26, 2026
559bbbd
Fix Triangle display crash when stored in a DataFrame cell (GH #142)
priyam0k Jul 25, 2026
5fe779b
Remove dead backend-reset code in _prep_columns (#1045)
priyam0k Jul 25, 2026
4f40a4b
Merge pull request #1150 from casact/#1148-RTC_bugs
kennethshsu Jul 27, 2026
7fa55ce
Merge pull request #1149 from friman-howard/fix/1143_bf_lognormal_apr…
kennethshsu Jul 27, 2026
a9f45e1
Merge pull request #1131 from priyam0k/feature/drop-axis-alternatives
genedan Jul 28, 2026
cef932c
FEAT: Explicitly forbid in-place assignment on sparse array.
genedan Jul 29, 2026
23e48ab
FIX: Fix bug in column assignment with a sparse backend.
genedan Jul 29, 2026
3837ec7
FEAT: Enable Triangle __array__ for sparse backend.
genedan Jul 29, 2026
5cb7d16
TEST: Fix tests.
genedan Jul 29, 2026
996b2f9
FIX: Apply bugbot fix.
genedan Jul 29, 2026
254ca05
TST: Add concurrency guard.
genedan Jul 29, 2026
73c691a
Merge pull request #1165 from casact/prerelease
kennethshsu Jul 29, 2026
f48bd83
Merge pull request #1164 from casact/sparse_fixes
genedan Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ on:

pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI concurrency cancels unrelated PRs

Medium Severity

The new concurrency group keys on github.head_ref, so pull requests from different forks that share a common branch name (for example patch-1 or fix) land in the same group. With cancel-in-progress: true, one contributor’s run can cancel another’s unrelated checks.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f48bd83. Configure here.


jobs:
linux:
name: (${{ matrix.python-version }}, ${{ matrix.os }})
Expand Down
6 changes: 3 additions & 3 deletions chainladder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def get_option(
"""
Get the option value for the specified option.

.. deprecated:: 0.9.3
.. deprecated:: 0.10.0
The ``option`` parameter is deprecated; use ``pat`` instead.

Parameters
Expand Down Expand Up @@ -226,7 +226,7 @@ def set_option(
"""
Set the option value for the specified option.

.. deprecated:: 0.9.3
.. deprecated:: 0.10.0
The ``option`` parameter is deprecated; use ``pat`` instead.

Parameters
Expand Down Expand Up @@ -283,7 +283,7 @@ def reset_option(
Restores the default value for the specified option. Restores default values for
all options if pat is None.

.. deprecated:: 0.9.3
.. deprecated:: 0.10.0
The ``option`` parameter is deprecated; use ``pat`` instead.

Parameters
Expand Down
2 changes: 2 additions & 0 deletions chainladder/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,8 @@ def subtriangles(self):
return [k for k, v in vars(self).items() if isinstance(v, TriangleBase)]

def __array__(self):
if self.array_backend == "sparse":
return self.values.todense()
return self.values

def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
Expand Down
20 changes: 11 additions & 9 deletions chainladder/core/dunders.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@ def _prep_index(self, x, y):
raise ValueError('Index broadcasting is ambiguous between ' + str(x_labels) + ' and ' + str(y_labels))

def _prep_columns(self, x, y):
x_backend, y_backend = x.array_backend, y.array_backend

if len(x.columns) == 1 and len(y.columns) > 1:
x.vdims = y.vdims
elif len(y.columns) == 1 and len(x.columns) > 1:
Expand Down Expand Up @@ -140,13 +138,7 @@ def _prep_columns(self, x, y):
# Ensure both triangles have the same column order
x = x[new_x_cols]
y = y[new_x_cols]

# Reset backends only if they've changed
if x.array_backend != x_backend:
x = x.set_backend(x_backend, inplace=True)
if y.array_backend != y_backend:
y = y.set_backend(y_backend, inplace=True)


return x, y

def _prep_origin_development(self, obj, other):
Expand Down Expand Up @@ -290,6 +282,16 @@ def __rsub__(self, other):
def __len__(self):
return self.shape[0]

# A Triangle is a 4-D container, not a 1-D sequence. Without this, Python
# falls back to the legacy iteration protocol (repeatedly calling
# __getitem__(0), __getitem__(1), ...), which treats the integer as a
# column label and raises. That also makes libraries such as pandas
# misclassify a Triangle as a sequence (see is_sequence) and attempt to
# iterate it when formatting a Triangle stored in a DataFrame cell,
# crashing the display. Declaring the type non-iterable makes pandas fall
# back to str(triangle) and render the summary instead (GH #142).
__iter__ = None

def __neg__(self):
obj = self.copy()
obj.values = -obj.values
Expand Down
89 changes: 78 additions & 11 deletions chainladder/core/pandas.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,33 +601,100 @@ def drop(
self,
labels: str | int | list | None = None,
axis: Literal["index", "columns", "origin", "development"] | int = 1,
index: str | int | list | None = None,
columns: str | int | list | None = None,
origin: str | int | list | None = None,
development: str | int | list | None = None,
) -> Triangle:
"""Drop specified labels from rows or columns.

Remove rows or columns by specifying label names and corresponding axis,
or by specifying directly index or column names.
Remove labels by specifying label names and corresponding axis, or by
specifying directly ``index``, ``columns``, ``origin``, or
``development`` names.

Parameters
-----------

labels: str | int | list | None
Index or column labels to drop.
Index or column labels to drop. A single label or list-like.

axis: {0 or ‘index’, 1 or ‘columns’}, default 1
Whether to drop labels from the index (0 or ‘index’)
or columns (1 or ‘columns’).
axis: {0 or ‘index’, 1 or ‘columns’, 2 or 'origin', 3 or 'development'}, default 1
The axis to drop ``labels`` from.

index: str | int | list | None
Alternative to ``axis=0``. Equivalent to ``labels, axis=0``.

columns: str | int | list | None
Alternative to ``axis=1``. Equivalent to ``labels, axis=1``.

origin: str | int | list | None
Alternative to ``axis=2``. Equivalent to ``labels, axis=2``.

development: str | int | list | None
Alternative to ``axis=3``. Equivalent to ``labels, axis=3``.

Returns
-------
Triangle

Examples
--------

Drop a single column with the ``labels``/``axis`` form or the
``columns`` alternative; the two are equivalent.

.. testsetup::

import chainladder as cl

.. testcode::

tri = cl.load_sample('clrd')
print(tri.columns.tolist())
print(tri.drop(columns='CumPaidLoss').columns.tolist())

.. testoutput::

['IncurLoss', 'CumPaidLoss', 'BulkLoss', 'EarnedPremDIR', 'EarnedPremCeded', 'EarnedPremNet']
['IncurLoss', 'BulkLoss', 'EarnedPremDIR', 'EarnedPremCeded', 'EarnedPremNet']

A list of labels can be dropped from an axis as well.

.. testcode::

print(tri.drop(columns=['CumPaidLoss', 'IncurLoss']).columns.tolist())

.. testoutput::

['BulkLoss', 'EarnedPremDIR', 'EarnedPremCeded', 'EarnedPremNet']

"""
axis = self._get_axis(axis)
labels = [labels] if type(labels) is str else list(labels)
if axis == 1:
return self[[item for item in self.columns if item not in labels]]
alternatives = {0: index, 1: columns, 2: origin, 3: development}
if any(value is not None for value in alternatives.values()):
if labels is not None:
raise ValueError(
"Cannot specify both 'labels' and any of 'index', "
"'columns', 'origin', or 'development'."
)
to_drop = {
ax: value for ax, value in alternatives.items() if value is not None
}
else:
raise NotImplementedError("Triangle.drop() only implemented for column axis.")
to_drop = {self._get_axis(axis): labels}
result = self
for ax, ax_labels in to_drop.items():
ax_labels = (
[ax_labels] if np.isscalar(ax_labels) else list(ax_labels)
)
if ax == 1:
result = result[
[item for item in result.columns if item not in ax_labels]
]
else:
raise NotImplementedError(
"Triangle.drop() only implemented for column axis."
)
return result

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drop() silently no-ops without labels

Low Severity

When drop() is called with no labels and no index/columns/origin/development alternatives, labels stays None. Because np.isscalar(None) is true, that becomes [None], nothing is removed, and the original triangle is returned. The previous implementation raised on list(None), and pandas raises when nothing is specified.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f48bd83. Configure here.


@property
def T(self) -> DataFrame: # noqa: N802
Expand Down
29 changes: 17 additions & 12 deletions chainladder/core/slice.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,20 +541,25 @@ def __setitem__(
i = np.where(self.vdims == key)[0][0]
# Case sparse backend.
if self.array_backend == "sparse":
# Cast value to sparse backend.
value = cast("Triangle", value)
after = cast("COO", value.values)
# Unwrap a Triangle-valued assignment to its raw array. A raw
# COO array can also be passed directly, mirroring the numpy
# branch below.
if isinstance(value, TriangleSlicer):
value = cast("Triangle", value)
after = cast("COO", value.values)
else:
after = cast("COO", value)

# Drop existing data where key matches, reassign coordinates.
before = self.drop(key).values
before = cast("COO", before)
bc = before.coords[1, :]
before.coords[1] = np.where(bc >= i, bc + 1, bc,)
# Filter out existing data at the target column directly from
# self.values' own coordinates.
before = cast("COO", self.values)
keep = before.coords[1, :] != i

# Append assigned data and new coordinates.
after.coords[1] = i
coords = np.concatenate((before.coords, after.coords), axis=1)
data = np.concatenate((before.data, after.data))
after_coords = after.coords.copy()
after_coords[1] = i
coords = np.concatenate((before.coords[:, keep], after_coords), axis=1)
data = np.concatenate((before.data[keep], after.data))

# Create new sparse matrix with updated coords and data, assign to backend array.
self.values = xp.COO(
Expand All @@ -577,7 +582,7 @@ def __setitem__(
value = self.iloc[:, 0] * 0 + value
try:
self.values = xp.concatenate((self.values, value.values), axis=1)
except (ValueError, AttributeError):
except (ValueError, AttributeError, AssertionError):
# For misaligned triangle support.
conc = (self.values, (self.iloc[:, 0] * 0 + cast("Triangle", value)).values)
self.values = xp.concatenate(conc, axis=1)
Expand Down
44 changes: 44 additions & 0 deletions chainladder/core/tests/test_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import sys


from collections.abc import Iterable
from chainladder.core.display import TriangleDisplay
from lxml import etree, html as lxml_html
from unittest import mock
Expand Down Expand Up @@ -259,6 +260,49 @@ def test_repr_format_semi_annual(prism: Triangle) -> None:
assert any("H1" in str(i) or "H2" in str(i) for i in df.index)


def test_triangle_not_iterable(raa: Triangle) -> None:
"""
A Triangle is a 4-D container, not a 1-D sequence, so it must not be
iterable (GH #142). This prevents pandas from misclassifying it as a
sequence and iterating it while formatting a DataFrame cell.

Parameters
----------
raa: Triangle
The raa sample data set.

Returns
-------
None

"""
assert not isinstance(raa, Iterable)
with pytest.raises(TypeError):
iter(raa)


def test_triangle_in_dataframe_cell_display(clrd: Triangle) -> None:
"""
Storing a Triangle in a DataFrame cell and displaying the DataFrame must
not crash (GH #142).

Parameters
----------
clrd: Triangle
The clrd sample data set.

Returns
-------
None

"""
df = pd.DataFrame(data=[["clrd", clrd]], columns=["name", "cl_triangle"])
# Both the text and HTML representations previously raised because pandas
# iterated the Triangle stored in the cell.
assert isinstance(repr(df), str)
assert isinstance(df._repr_html_(), str)


def test_heatmap_multi_raises(clrd: Triangle) -> None:
"""
Heatmap only works on a single-dimension triangle. Raise a ValueError if multidimensional.
Expand Down
27 changes: 20 additions & 7 deletions chainladder/core/tests/test_slicing.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,19 @@ def test_loc_ellipsis(clrd):

def test_missing_first_lag(raa):
x = raa.copy()
x.values[:, :, :, 0] = 0

def set_missing(values) -> None:
"""
Sets the missing values for the first lag.
"""
values[:, :, :, 0] = 0

if x.array_backend == "sparse":
# Sparse COO arrays don't support in-place item assignment.
with pytest.raises(TypeError, match="sparse backend"):
set_missing(x.values)
return
set_missing(x.values)
x = x.sum(0)
assert x.link_ratio.shape == (1, 1, 9, 9)

Expand Down Expand Up @@ -434,7 +446,8 @@ def test_setitem_virtual_column_numpy_backend(raa: Triangle) -> None:
None
"""
tri = raa.copy()
assert tri.array_backend == "numpy"
if tri.array_backend == "sparse":
pytest.skip("Test is specific to the numpy backend.")
tri["double"] = lambda x: x["values"] * 2
assert "double" in tri.columns
assert tri["double"] == tri["values"] * 2
Expand All @@ -454,7 +467,8 @@ def test_setitem_value_backend_conversion(raa: Triangle) -> None:
None
"""
tri = raa.copy()
value = (tri["values"] * 2).set_backend("sparse")
other_backend = "numpy" if tri.array_backend == "sparse" else "sparse"
value = (tri["values"] * 2).set_backend(other_backend)
assert tri.array_backend != value.array_backend
tri["values"] = value
assert tri.array_backend == raa.array_backend
Expand All @@ -475,15 +489,14 @@ def test_setitem_existing_column_triangle_value(raa: Triangle) -> None:
None
"""
tri = raa.copy()
assert tri.array_backend != "sparse"
value = tri["values"] * 2
tri["values"] = value
assert tri["values"] == raa["values"] * 2


def test_setitem_existing_column_array_value(raa: Triangle) -> None:
"""
Reassign an existing column to a raw array value on a non-sparse backend.
Reassign an existing column to a raw array value.

Parameters
----------
Expand All @@ -495,7 +508,6 @@ def test_setitem_existing_column_array_value(raa: Triangle) -> None:
None
"""
tri = raa.copy()
assert tri.array_backend != "sparse"
value = (tri["values"] * 3).values
assert not isinstance(value, type(tri))
tri["values"] = value
Expand Down Expand Up @@ -534,7 +546,8 @@ def test_setitem_new_column_misaligned_triangle(raa: Triangle) -> None:
tri["misaligned"] = misaligned
# Check the shape, new column should be added.
assert tri.shape == (1, 2, 10, 10)
new_col = tri["misaligned"]
new_col = tri["misaligned"].set_backend("numpy")
misaligned = misaligned.set_backend("numpy")
# Origin periods 1985 and prior should be nan.
assert np.isnan(new_col.values[0, 0, :5, :]).all()
# Origin periods 1986 and beyond should match.
Expand Down
Loading
Loading