Actuarial Loss Development Triangle — Seaborn

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 Seaborn

Python source (Seaborn)

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

import os

import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib.colors import LinearSegmentedColormap


# Theme
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"

sns.set_theme(
    style="ticks",
    rc={
        "figure.facecolor": PAGE_BG,
        "axes.facecolor": PAGE_BG,
        "axes.edgecolor": INK_SOFT,
        "axes.labelcolor": INK,
        "text.color": INK,
        "xtick.color": INK_SOFT,
        "ytick.color": INK_SOFT,
        "grid.color": INK,
        "grid.alpha": 0.15,
        "legend.facecolor": ELEVATED_BG,
        "legend.edgecolor": INK_SOFT,
    },
)

# Imprint sequential colormap for continuous heatmap data (single-polarity)
imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", ["#009E73", "#4467A3"])

# 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)

# Base initial claims by accident year (in thousands)
base_claims = [4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 7000]

# Age-to-age development factors (decreasing as claims mature)
dev_factors = [2.50, 1.45, 1.22, 1.12, 1.07, 1.04, 1.025, 1.015, 1.008]

# Build cumulative triangle
cumulative = np.full((n_years, n_periods), np.nan)
is_projected = np.full((n_years, n_periods), False)

for i in range(n_years):
    cumulative[i, 0] = base_claims[i] + np.random.normal(0, 200)
    for j in range(1, n_periods):
        factor = dev_factors[j - 1] + np.random.normal(0, 0.02)
        cumulative[i, j] = cumulative[i, j - 1] * factor
    actual_periods = n_years - i
    for j in range(actual_periods, n_periods):
        is_projected[i, j] = True

heatmap_data = pd.DataFrame(
    cumulative, index=[str(y) for y in accident_years], columns=[str(p) for p in development_periods]
)

# Annotation strings
annot_labels = np.empty_like(cumulative, dtype=object)
for i in range(n_years):
    for j in range(n_periods):
        val = cumulative[i, j]
        annot_labels[i, j] = f"{val:,.0f}"
annot_df = pd.DataFrame(annot_labels, index=heatmap_data.index, columns=heatmap_data.columns)

# Masks for actual vs projected regions
mask_projected = pd.DataFrame(is_projected, index=heatmap_data.index, columns=heatmap_data.columns)
mask_actual = ~mask_projected

# Plot — square canvas for symmetric heatmap (2400 × 2400 px)
fig, ax = plt.subplots(figsize=(6, 6), dpi=400)

vmin, vmax = np.nanmin(cumulative), np.nanmax(cumulative)

# Draw actual cells (bold annotations)
sns.heatmap(
    heatmap_data,
    ax=ax,
    cmap=imprint_seq,
    vmin=vmin,
    vmax=vmax,
    mask=mask_projected,
    annot=annot_df,
    fmt="",
    annot_kws={"fontsize": 8, "fontweight": "bold"},
    linewidths=1.0,
    linecolor=PAGE_BG,
    cbar_kws={"label": "Cumulative Claims ($K)", "shrink": 0.8},
)

# Draw projected cells (italic annotations, no extra colorbar)
sns.heatmap(
    heatmap_data,
    ax=ax,
    cmap=imprint_seq,
    vmin=vmin,
    vmax=vmax,
    mask=mask_actual,
    annot=annot_df,
    fmt="",
    annot_kws={"fontsize": 8, "fontweight": "normal", "fontstyle": "italic"},
    linewidths=1.0,
    linecolor=PAGE_BG,
    cbar=False,
)

# Adaptive annotation text colors based on cell brightness in the colormap
for text in ax.texts:
    x, y = text.get_position()
    col, row = int(x), int(y)
    if 0 <= row < n_years and 0 <= col < n_periods:
        val = cumulative[row, col]
        norm_val = (val - vmin) / (vmax - vmin)
        text.set_color("white" if norm_val > 0.55 else INK)

# Hatching overlay for projected cells
for i in range(n_years):
    for j in range(n_periods):
        if is_projected[i, j]:
            ax.add_patch(mpatches.Rectangle((j, i), 1, 1, facecolor=PAGE_BG, edgecolor="none", alpha=0.2))
            ax.add_patch(
                mpatches.Rectangle((j, i), 1, 1, facecolor="none", edgecolor=INK_SOFT, hatch="////", linewidth=0)
            )

# Diagonal step-line marking the latest evaluation boundary (actual vs projected)
diag_x = [n_periods, n_periods]
diag_y = [0, 1]
for i in range(1, n_years):
    actual_periods = n_years - i
    diag_x.extend([actual_periods, actual_periods])
    diag_y.extend([i, i + 1])
ax.plot(diag_x, diag_y, color=INK, linewidth=2.5, linestyle="-", zorder=5, solid_capstyle="butt")

# Colorbar styling
cbar = ax.collections[0].colorbar
if cbar is not None:
    cbar.ax.tick_params(labelsize=8, labelcolor=INK_SOFT)
    cbar.set_label("Cumulative Claims ($K)", fontsize=10, color=INK)
    plt.setp(cbar.ax.yaxis.get_ticklabels(), color=INK_SOFT)

# Development factors text — placed above the heatmap
factor_text = "Dev Factors: " + "  ".join([f"{f:.3f}" for f in dev_factors])
ax.text(
    0.5,
    1.05,
    factor_text,
    transform=ax.transAxes,
    ha="center",
    va="bottom",
    fontsize=7,
    fontfamily="monospace",
    color=INK_MUTED,
    bbox={"boxstyle": "round,pad=0.3", "facecolor": ELEVATED_BG, "edgecolor": INK_SOFT, "linewidth": 0.5},
)

# Style
title = "heatmap-loss-triangle · python · seaborn · anyplot.ai"
n_chars = len(title)
ratio = 67 / n_chars if n_chars > 67 else 1.0
title_fontsize = max(8, round(12 * ratio))
ax.set_title(title, fontsize=title_fontsize, fontweight="medium", color=INK, pad=40)
ax.set_xlabel("Development Period (Years)", fontsize=10, color=INK)
ax.set_ylabel("Accident Year", fontsize=10, color=INK)
ax.tick_params(axis="both", labelsize=8)
ax.tick_params(axis="x", rotation=0)
ax.tick_params(axis="y", rotation=0)

# Legend for actual vs projected regions
actual_patch = mpatches.Patch(facecolor="#009E73", edgecolor=INK_SOFT, label="Actual")
projected_patch = mpatches.Patch(facecolor="#4467A3", edgecolor=INK_SOFT, hatch="///", label="Projected (IBNR)")
ax.legend(
    handles=[actual_patch, projected_patch],
    loc="upper center",
    bbox_to_anchor=(0.5, -0.12),
    fontsize=8,
    framealpha=0.9,
    facecolor=ELEVATED_BG,
    edgecolor=INK_SOFT,
    labelcolor=INK,
    ncol=2,
)

# Save — no bbox_inches='tight'; figsize×dpi fixes the canvas at 2400×2400
plt.tight_layout(rect=[0, 0.08, 1, 1])
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

Part of Actuarial Loss Development Triangle on anyplot.ai.

Other implementations