Actuarial Loss Development Triangle — Altair

A triangular matrix visualization showing cumulative insurance claim payments developing over time. Rows represent accident/origin years, columns represent development periods (e.g., 1-10 years), with the upper-left triangle displaying actual observed cumulative claims and the lower-right triangle showing projected/estimated values (IBNR). This plot is essential for actuarial reserving, enabling analysts to visualize the chain-ladder method and identify development patterns in loss data.

Actuarial Loss Development Triangle rendered with Altair

Python source (Altair)

""" anyplot.ai
heatmap-loss-triangle: Actuarial Loss Development Triangle
Library: altair 6.1.0 | Python 3.13.13
Quality: 89/100 | Updated: 2026-06-03
"""

import os
import sys as _sys


# Prevent this file from shadowing the installed altair package.
# Python inserts the script's directory as sys.path[0] when running `python altair.py`.
_self_dir = os.path.dirname(os.path.abspath(__file__))
if _self_dir in _sys.path:
    _sys.path.remove(_self_dir)
del _sys, _self_dir

import altair as alt
import numpy as np
import pandas as pd
from PIL import Image


# Theme-adaptive chrome (Imprint palette)
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Canvas — square 2400×2400 for symmetric heatmap grid
TW, TH = 2400, 2400

# Imprint sequential colormap: brand green → blue (single-polarity, claim magnitude)
IMPRINT_SEQ_START = "#009E73"  # Imprint palette position 1
IMPRINT_SEQ_END = "#4467A3"  # Imprint palette position 3
AMBER = "#DDCC77"  # Imprint amber — IBNR boundary marker

# Data — cumulative paid claims triangle (10 accident years × 10 development periods)
np.random.seed(42)
accident_years = list(range(2015, 2025))
dev_periods = list(range(1, 11))
n_years = len(accident_years)

base_claims = np.array([3200, 3500, 3800, 4100, 3900, 4300, 4600, 4200, 4800, 5100]) * 1000
dev_factors = [2.50, 1.45, 1.22, 1.12, 1.07, 1.04, 1.025, 1.015, 1.008]

cumulative = np.zeros((n_years, len(dev_periods)))
for i in range(n_years):
    cumulative[i, 0] = base_claims[i] + np.random.normal(0, base_claims[i] * 0.05)
    for j in range(1, len(dev_periods)):
        noise = 1 + np.random.normal(0, 0.01)
        cumulative[i, j] = cumulative[i, j - 1] * dev_factors[j - 1] * noise

rows = []
for i, year in enumerate(accident_years):
    for j, period in enumerate(dev_periods):
        is_projected = (i + j) >= n_years
        amount = round(cumulative[i, j])
        label = f"{amount / 1e6:.1f}M" if amount >= 1e6 else f"{amount / 1e3:.0f}K"
        rows.append(
            {
                "Accident Year": str(year),
                "Development Period": period,
                "Cumulative Amount": amount,
                "Status": "Projected (IBNR)" if is_projected else "Actual",
                "Label": label,
            }
        )

df = pd.DataFrame(rows)

legend_data = pd.DataFrame(
    [{"legend_label": "Actual (Observed)", "legend_order": 0}, {"legend_label": "Projected (IBNR)", "legend_order": 1}]
)

factor_rows = []
for j, factor in enumerate(dev_factors):
    factor_rows.append({"Accident Year": "Dev Factor", "Development Period": j + 1, "Factor": f"{factor:.3f}"})
df_factors = pd.DataFrame(factor_rows)

year_order = [str(y) for y in accident_years] + ["Dev Factor"]
min_val = df["Cumulative Amount"].min()
max_val = df["Cumulative Amount"].max()

x_enc = alt.X(
    "Development Period:O",
    axis=alt.Axis(labelFontSize=10, titleFontSize=12, labelAngle=0, orient="top", titlePadding=10),
)
y_enc = alt.Y(
    "Accident Year:N",
    sort=[str(y) for y in accident_years],
    axis=alt.Axis(labelFontSize=10, titleFontSize=12, titlePadding=10),
)

# Heatmap cells with Imprint sequential colormap encoding cumulative claim magnitude
heatmap = (
    alt.Chart(df)
    .mark_rect(stroke=PAGE_BG, strokeWidth=1.5, cornerRadius=1)
    .encode(
        x=x_enc,
        y=y_enc,
        color=alt.Color(
            "Cumulative Amount:Q",
            scale=alt.Scale(range=[IMPRINT_SEQ_START, IMPRINT_SEQ_END], domain=[min_val, max_val]),
            legend=alt.Legend(
                title="Cumulative Claims",
                titleFontSize=10,
                labelFontSize=10,
                gradientLength=200,
                gradientThickness=12,
                orient="right",
                offset=12,
            ),
        ),
        opacity=alt.when(alt.datum.Status == "Actual").then(alt.value(1.0)).otherwise(alt.value(0.6)),
        tooltip=[
            alt.Tooltip("Accident Year:N"),
            alt.Tooltip("Development Period:O"),
            alt.Tooltip("Cumulative Amount:Q", format=",.0f", title="Cumulative ($)"),
            alt.Tooltip("Status:N"),
        ],
    )
)

# Dashed amber border on projected cells — marks the IBNR evaluation boundary
projected_df = df[df["Status"] == "Projected (IBNR)"].copy()
projected_border = (
    alt.Chart(projected_df)
    .mark_rect(stroke=AMBER, strokeWidth=2.5, strokeDash=[6, 3], filled=False, cornerRadius=1)
    .encode(x=x_enc, y=y_enc)
)

# Cell text annotations — ink color adapts for contrast over light/dark cells
text = (
    alt.Chart(df)
    .mark_text(fontSize=12, fontWeight="bold")
    .encode(
        x=x_enc,
        y=y_enc,
        text="Label:N",
        color=alt.when(alt.datum["Cumulative Amount"] > (max_val * 0.55))
        .then(alt.value(PAGE_BG))
        .otherwise(alt.value(INK)),
    )
)

# Dev factors background row (age-to-age chain-ladder factors)
factor_bg = (
    alt.Chart(df_factors)
    .mark_rect(fill=ELEVATED_BG, stroke=INK_SOFT, strokeWidth=1, cornerRadius=1)
    .encode(x=alt.X("Development Period:O"), y=alt.Y("Accident Year:N", sort=year_order))
)
factor_text = (
    alt.Chart(df_factors)
    .mark_text(fontSize=11, fontWeight="bold", color=INK_SOFT)
    .encode(x=alt.X("Development Period:O"), y=alt.Y("Accident Year:N", sort=year_order), text="Factor:N")
)

# Status legend placed at bottom to avoid crowding the right-side color legend
status_legend = (
    alt.Chart(legend_data)
    .mark_square(size=150, stroke=INK_SOFT, strokeWidth=1)
    .encode(
        opacity=alt.Opacity(
            "legend_label:N",
            scale=alt.Scale(domain=["Actual (Observed)", "Projected (IBNR)"], range=[1.0, 0.6]),
            legend=alt.Legend(
                title="Status",
                titleFontSize=10,
                labelFontSize=10,
                orient="bottom",
                offset=12,
                symbolType="square",
                symbolSize=150,
                symbolStrokeWidth=1.5,
                symbolFillColor=IMPRINT_SEQ_START,
            ),
        )
    )
)

chart = (
    (heatmap + projected_border + text + factor_bg + factor_text + status_legend)
    .properties(
        width=360,
        height=380,
        background=PAGE_BG,
        title=alt.Title(
            "heatmap-loss-triangle · python · altair · anyplot.ai",
            subtitle=[
                "Cumulative paid claims development triangle with chain-ladder projections.",
                "Full opacity = actual observed  |  Faded + dashed border = projected (IBNR)  |  Bottom row = age-to-age factors.",
            ],
            fontSize=16,
            subtitleFontSize=11,
            subtitleColor=INK_MUTED,
            color=INK,
            anchor="start",
            offset=12,
        ),
        padding={"left": 20, "right": 20, "top": 20, "bottom": 20},
    )
    .configure_axis(grid=False, domainWidth=0, labelColor=INK_SOFT, titleColor=INK, tickColor=INK_SOFT)
    .configure_view(strokeWidth=0, fill=PAGE_BG)
    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)
    .configure_title(color=INK)
)

# Save PNG then pad to exact 2400×2400 target (square format for symmetric heatmap)
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
chart.save(f"plot-{THEME}.html")

_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

Part of Actuarial Loss Development Triangle on anyplot.ai.

Other implementations