Actuarial Loss Development Triangle — Matplotlib

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 Matplotlib

Python source (Matplotlib)

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

import os

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import LinearSegmentedColormap
from matplotlib.patches import Patch, Rectangle


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

# Imprint sequential colormap — single-polarity cumulative claim amounts
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)

# Age-to-age development factors (decreasing as claims mature)
dev_factors = [2.50, 1.60, 1.30, 1.15, 1.08, 1.05, 1.03, 1.02, 1.01]

# Generate cumulative paid claims triangle
initial_claims = np.array([4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 7000], dtype=float)

triangle = np.full((n_years, n_periods), np.nan)
is_projected = np.full((n_years, n_periods), True)

# Fill upper-left actual triangle
for i in range(n_years):
    triangle[i, 0] = initial_claims[i]
    n_actual = n_periods - i
    for j in range(1, n_actual):
        noise = 1 + np.random.uniform(-0.03, 0.03)
        triangle[i, j] = triangle[i, j - 1] * dev_factors[j - 1] * noise
    for j in range(n_actual):
        is_projected[i, j] = False

# Fill lower-right projected triangle using chain-ladder
for i in range(1, n_years):
    n_actual = n_periods - i
    for j in range(n_actual, n_periods):
        triangle[i, j] = triangle[i, j - 1] * dev_factors[j - 1]

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

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

# Heatmap with Imprint sequential colormap
im = ax.imshow(triangle, cmap=imprint_seq, vmin=vmin, vmax=vmax, aspect="auto")

# Colorbar — create before twin axis so layout is established correctly
cbar = fig.colorbar(im, ax=ax, pad=0.02, shrink=0.80)
cbar.set_label("Cumulative Claims ($)", fontsize=8, color=INK)
cbar.ax.tick_params(labelsize=7, colors=INK_SOFT)
cbar.ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:,.0f}"))
cbar.outline.set_edgecolor(INK_SOFT)

# Hatching overlay on projected cells — theme-adaptive hatch color for visibility in dark mode
hatch_color = INK_SOFT if THEME == "dark" else PAGE_BG
for i in range(n_years):
    for j in range(n_periods):
        if is_projected[i, j] and not np.isnan(triangle[i, j]):
            rect = Rectangle((j - 0.5, i - 0.5), 1, 1, fill=False, hatch="///", edgecolor=hatch_color, linewidth=0)
            ax.add_patch(rect)

# Cell borders
for i in range(n_years + 1):
    ax.axhline(i - 0.5, color=PAGE_BG, linewidth=1.0)
for j in range(n_periods + 1):
    ax.axvline(j - 0.5, color=PAGE_BG, linewidth=1.0)

# Cell annotations — brightness-adaptive text color with dark-mode correction
# All imprint_seq colors have brightness < 0.5, so use PAGE_BG (light) in light mode
# and INK (cream) in dark mode to ensure readable contrast on dark cells
for i in range(n_years):
    for j in range(n_periods):
        val = triangle[i, j]
        if np.isnan(val):
            continue
        norm_val = (val - vmin) / (vmax - vmin)
        rgba = imprint_seq(norm_val)
        brightness = 0.299 * rgba[0] + 0.587 * rgba[1] + 0.114 * rgba[2]
        if brightness < 0.5:
            text_color = PAGE_BG if THEME == "light" else INK
        else:
            text_color = INK if THEME == "light" else PAGE_BG
        fontstyle = "italic" if is_projected[i, j] else "normal"
        ax.text(
            j,
            i,
            f"{val:,.0f}",
            ha="center",
            va="center",
            fontsize=7,
            color=text_color,
            fontstyle=fontstyle,
            fontweight="medium",
        )

# Staircase diagonal — focal-point boundary between actual (upper-left) and projected (lower-right)
# Starts at (n_periods-0.5, 0.5) and steps down-left to (0.5, n_years-0.5)
diag_xs = [n_periods - 0.5]
diag_ys = [0.5]
for i in range(1, n_years):
    x_boundary = (n_periods - i) - 0.5
    diag_xs.extend([x_boundary, x_boundary])
    diag_ys.extend([i - 0.5, i + 0.5])
ax.plot(diag_xs, diag_ys, color=INK_SOFT, linewidth=2.5, zorder=5, alpha=0.8, solid_capstyle="butt")

# Axes styling
title = "heatmap-loss-triangle · python · matplotlib · 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_xticks(range(n_periods))
ax.set_xticklabels(development_periods, fontsize=8, color=INK_SOFT)
ax.set_yticks(range(n_years))
ax.set_yticklabels(accident_years, fontsize=8, color=INK_SOFT)
ax.set_xlabel("Development Period (Years)", fontsize=10, color=INK, labelpad=8)
ax.set_ylabel("Accident Year", fontsize=10, color=INK, labelpad=6)
ax.set_title(title, fontsize=title_fontsize, fontweight="medium", color=INK, pad=20)
ax.tick_params(axis="both", length=0, colors=INK_SOFT)

for spine in ax.spines.values():
    spine.set_visible(False)

# Development factors on a twin x-axis at the top
ax_top = ax.twiny()
ax_top.set_xlim(ax.get_xlim())
ax_top.set_xticks([j + 1 for j in range(len(dev_factors))])
ax_top.set_xticklabels([f"{f:.2f}" for f in dev_factors], fontsize=7, color=INK_MUTED)
ax_top.set_xlabel("Age-to-Age Dev. Factors (LDF)", fontsize=8, color=INK_MUTED, labelpad=6)
ax_top.tick_params(length=0, colors=INK_MUTED, labelcolor=INK_MUTED)
for spine in ax_top.spines.values():
    spine.set_visible(False)

# Legend below the heatmap
legend_elements = [
    Patch(facecolor=imprint_seq(0.55), edgecolor=PAGE_BG, label="Actual (Observed)"),
    Patch(facecolor=imprint_seq(0.55), edgecolor=hatch_color, hatch="///", label="Projected (IBNR)"),
]
leg = ax.legend(
    handles=legend_elements,
    loc="upper left",
    fontsize=7,
    frameon=True,
    facecolor=ELEVATED_BG,
    edgecolor=INK_SOFT,
    bbox_to_anchor=(0.0, -0.10),
    bbox_transform=ax.transAxes,
)
plt.setp(leg.get_texts(), color=INK_SOFT)

fig.subplots_adjust(left=0.10, bottom=0.14, top=0.92)

# Save
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

Part of Actuarial Loss Development Triangle on anyplot.ai.

Other implementations