Skip to content

cum_to_incr() produces spurious values on triangles produced by .grain(). #692

Description

@radders1981

Are you on the latest chainladder version?

  • Yes, this bug occurs on the latest version.

Describe the bug in words

BUG: cum_to_incr() produces spurious values on triangles produced by .grain().

When a monthly triangle is aggregated to annual via .grain("OYDY"),
cum_to_incr() replaces NaN with 0 before differencing, producing
(0 - prior_value) instead of NaN for unobserved cells.

This does NOT occur on triangles built directly at the target grain,
only on triangles produced via .grain().

Observed in: chainladder 0.9.1
Environment: pandas 2.3.3, numpy 2.4.4, macOS arm64

How can the bug be reproduced?

import chainladder as cl
import numpy as np
import pandas as pd

# Build a 24-month triangle from simple data
rows = []
for year in [2022, 2023]:
    for month in range(1, 13):
        origin = f"{year}-{month:02d}-01"
        origin_dt = pd.Timestamp(origin)
        # Each origin develops until 2023-12
        end = pd.Timestamp("2023-12-01")
        dev_months = (end.year - origin_dt.year) * 12 + (end.month - origin_dt.month) + 1
        for d in range(1, dev_months + 1):
            dev_dt = origin_dt + pd.DateOffset(months=d - 1)
            rows.append({
                "origin": origin,
                "dev": dev_dt.strftime("%Y-%m-%d"),
                "value": 1000 * d, 
            })

data = pd.DataFrame(rows)
tri = cl.Triangle(
    data=data, origin="origin", development="dev",
    columns=["value"], cumulative=True,
)
print(f"Monthly triangle: shape={tri.shape}\n")

# --- Test 1: grain to annual, then cum_to_incr ---
tri_y = tri.grain("OYDY")
incr_y = tri_y.cum_to_incr()

print("ANNUAL via .grain('OYDY'):")
print(f"  Cumulative:\n{tri_y.to_frame()}\n")
print(f"  cum_to_incr():\n{incr_y.to_frame()}\n")

# Expected
cum = tri_y.values[0, 0].copy()
expected = cum.copy()
expected[:, 1:] = cum[:, 1:] - cum[:, :-1]
print(f"  Expected (numpy):\n{expected}\n")

# Flag bugs
print("  Problem cells:")
for r in range(cum.shape[0]):
    for c in range(cum.shape[1]):
        cum_val = cum[r, c]
        incr_val = incr_y.values[0, 0, r, c]
        if np.isnan(cum_val) and not np.isnan(incr_val):
            print(f"    row={r} col={c}: cumulative=NaN, cum_to_incr={incr_val:.0f} <-- BUG")

# --- Test 2: grain to quarterly, then cum_to_incr ---
tri_q = tri.grain("OQDQ")
incr_q = tri_q.cum_to_incr()

print("\nQUARTERLY via .grain('OQDQ'):")
print(f"  Cumulative:\n{tri_q.to_frame()}\n")
print(f"  cum_to_incr():\n{incr_q.to_frame()}\n")

cum_q = tri_q.values[0, 0].copy()
print("  Problem cells:")
found = False
for r in range(cum_q.shape[0]):
    for c in range(cum_q.shape[1]):
        cum_val = cum_q[r, c]
        incr_val = incr_q.values[0, 0, r, c]
        if np.isnan(cum_val) and not np.isnan(incr_val):
            print(f"    row={r} col={c}: cumulative=NaN, cum_to_incr={incr_val:.0f} <-- BUG")
            found = True
if not found:
    print("None (quarterly is correct)")

What is the expected behavior?

assert np.isnan(incr_vals[1, 1]), (
    f"Expected incremental (2023, dev 24) to be NaN, got {incr_vals[1, 1]}. "
    f"cum_to_incr() appears to fill NaN with 0 before differencing, "
    f"producing 0 - {cum_vals[1, 0]:.0f} = {incr_vals[1, 1]:.0f}"
)

Metadata

Metadata

Labels

No labels
No labels

Type

Fields

Priority

Low

Effort

Medium

Scope

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions