Actuarial Loss Development Triangle — plotnine

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 plotnine

Python source (plotnine)

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

import os

import numpy as np
import pandas as pd
from plotnine import (
    aes,
    element_blank,
    element_rect,
    element_text,
    geom_text,
    geom_tile,
    ggplot,
    labs,
    scale_alpha_manual,
    scale_color_identity,
    scale_fill_gradient,
    scale_x_continuous,
    scale_y_reverse,
    theme,
    theme_minimal,
)


# Imprint palette — theme-adaptive chrome
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"

# Actuarial loss development triangle data
np.random.seed(42)
accident_years = list(range(2015, 2025))
development_periods = list(range(1, 11))
n_years = len(accident_years)
n_periods = len(development_periods)

# 2019 is a catastrophe year with ~50% higher initial claims
base_claims = np.array([3200, 3450, 3100, 3600, 5200, 3500, 3900, 4100, 3700, 4200])
dev_factors = np.array([2.50, 1.60, 1.30, 1.15, 1.08, 1.05, 1.03, 1.02, 1.01])

cumulative = np.zeros((n_years, n_periods))
for i in range(n_years):
    cumulative[i, 0] = base_claims[i] + np.random.normal(0, 100)
    for j in range(1, n_periods):
        noise = 1 + np.random.normal(0, 0.02)
        cumulative[i, j] = cumulative[i, j - 1] * dev_factors[j - 1] * noise
cumulative = np.round(cumulative, 0).astype(int)

rows = []
for i, ay in enumerate(accident_years):
    for j, dp in enumerate(development_periods):
        is_projected = (i + j) >= n_years
        rows.append(
            {
                "accident_year": ay,
                "development_period": dp,
                "cumulative_amount": cumulative[i, j],
                "is_projected": is_projected,
            }
        )

df = pd.DataFrame(rows)
df["label"] = df["cumulative_amount"].apply(lambda v: f"{v:,}")
df["region"] = df["is_projected"].map({False: "Actual", True: "Projected"})

# Theme-adaptive text color for cell annotations
amount_thresh = df["cumulative_amount"].quantile(0.45)
light_ink = "#FAF8F1" if THEME == "light" else "#F0EFE8"
df["text_color"] = df["cumulative_amount"].apply(lambda v: light_ink if v > amount_thresh else INK)

# Development factors row positioned below main triangle
dev_factor_y = max(accident_years) + 1.3
df_dev = pd.DataFrame(
    {
        "development_period": [j + 1.5 for j in range(len(dev_factors))],
        "accident_year": dev_factor_y,
        "label": [f"{f:.2f}" for f in dev_factors],
        "cumulative_amount": 0.0,
        "region": "Actual",
    }
)

plot = (
    ggplot(df, aes(x="development_period", y="accident_year"))
    + geom_tile(aes(fill="cumulative_amount", alpha="region"), color=PAGE_BG, size=0.8)
    + geom_text(aes(label="label", color="text_color"), size=3.0)
    + geom_text(
        aes(x="development_period", y="accident_year", label="label"),
        data=df_dev,
        size=3.0,
        color="#009E73",
        fontweight="bold",
        inherit_aes=False,
    )
    # Imprint sequential: brand green (low magnitude) → blue (high magnitude)
    + scale_fill_gradient(low="#009E73", high="#4467A3", name="Cumulative\nAmount")
    + scale_alpha_manual(values={"Actual": 1.0, "Projected": 0.35}, name="Region")
    + scale_color_identity()
    + scale_x_continuous(breaks=development_periods, labels=[str(d) for d in development_periods], expand=(0, 0.5))
    + scale_y_reverse(
        breaks=accident_years + [dev_factor_y],
        labels=[str(y) for y in accident_years] + ["Dev Factor"],
        expand=(0, 0.2, 0, 1.0),
    )
    + labs(
        x="Development Period (Years)",
        y="Accident Year",
        title="heatmap-loss-triangle · python · plotnine · anyplot.ai",
        subtitle="Cumulative Paid Claims — Actual vs Projected  ·  Development Factors shown below",
    )
    + theme_minimal()
    + theme(
        figure_size=(6, 6),
        plot_title=element_text(size=12, ha="center", weight="bold", color=INK),
        plot_subtitle=element_text(size=9, ha="center", color=INK_SOFT),
        plot_margin=0.02,
        axis_title=element_text(size=10, color=INK),
        axis_text_x=element_text(size=8, color=INK_SOFT),
        axis_text_y=element_text(size=8, color=INK_SOFT),
        legend_title=element_text(size=8, weight="bold", color=INK),
        legend_text=element_text(size=7, color=INK_SOFT),
        legend_position="right",
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_box_margin=0,
        panel_grid_major=element_blank(),
        panel_grid_minor=element_blank(),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    )
)

plot.save(f"plot-{THEME}.png", dpi=400, width=6, height=6, units="in")

Part of Actuarial Loss Development Triangle on anyplot.ai.

Other implementations