Actuarial Loss Development Triangle — Bokeh

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 Bokeh

Python source (Bokeh)

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

# Remove this script's directory from sys.path so 'import bokeh' finds the installed package,
# not this file (bokeh.py shadows the bokeh package when run from its own directory).
import os as _os
import sys as _sys


_here = _os.path.dirname(_os.path.abspath(__file__))
_sys.path = [p for p in _sys.path if _os.path.abspath(p) != _here]

import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import (
    BasicTicker,
    ColorBar,
    ColumnDataSource,
    HoverTool,
    Legend,
    LegendItem,
    LinearColorMapper,
    Title,
)
from bokeh.plotting import figure
from bokeh.transform import transform
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# 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: brand green (#009E73) → blue (#4467A3)
_seq_r = np.linspace(0, 68, 256).astype(int)
_seq_g = np.linspace(158, 103, 256).astype(int)
_seq_b = np.linspace(115, 163, 256).astype(int)
IMPRINT_SEQ = [f"#{r:02X}{g:02X}{b:02X}" for r, g, b in zip(_seq_r, _seq_g, _seq_b, strict=True)]

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

initial_claims = np.array([4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 7000], dtype=float)
initial_claims += np.random.normal(0, 200, 10)
dev_factors = np.array([2.50, 1.45, 1.22, 1.12, 1.07, 1.04, 1.025, 1.015, 1.008])

full_triangle = np.zeros((10, 10))
for i in range(10):
    full_triangle[i, 0] = initial_claims[i]
    for j in range(1, 10):
        full_triangle[i, j] = full_triangle[i, j - 1] * dev_factors[j - 1] * np.random.normal(1.0, 0.02)

# Actual: upper-left triangle where row + col < 10; remainder is projected (IBNR)
is_actual = np.array([[i + j < 10 for j in range(10)] for i in range(10)])

min_val = full_triangle.min()
max_val = full_triangle.max()

actual_x, actual_y, actual_val = [], [], []
proj_x, proj_y, proj_val = [], [], []
all_x, all_y, all_text, all_tc, all_status, all_val_list = [], [], [], [], [], []

for i in range(10):
    for j in range(10):
        x = str(dev_periods[j])
        y = str(accident_years[i])
        val = full_triangle[i, j]
        norm = (val - min_val) / (max_val - min_val)
        # Text must contrast against cell fill (Imprint seq: green→blue), not the page bg
        tc = "#F0EFE8" if norm > 0.45 else "#1A1A17"
        all_x.append(x)
        all_y.append(y)
        all_text.append(f"{val:,.0f}")
        all_tc.append(tc)
        all_status.append("Actual" if is_actual[i, j] else "Projected (IBNR)")
        all_val_list.append(val)
        if is_actual[i, j]:
            actual_x.append(x)
            actual_y.append(y)
            actual_val.append(val)
        else:
            proj_x.append(x)
            proj_y.append(y)
            proj_val.append(val)

source_actual = ColumnDataSource(data={"x": actual_x, "y": actual_y, "value": actual_val})
source_proj = ColumnDataSource(data={"x": proj_x, "y": proj_y, "value": proj_val})
source_text = ColumnDataSource(
    data={"x": all_x, "y": all_y, "text": all_text, "text_color": all_tc, "status": all_status, "value": all_val_list}
)

mapper = LinearColorMapper(palette=IMPRINT_SEQ, low=min_val, high=max_val)

# Title length-adjusted font size (floor 34pt per bokeh library rules)
title_str = "Actuarial Loss Development Triangle · heatmap-loss-triangle · python · bokeh · anyplot.ai"
_n = len(title_str)
title_pt = max(34, round(50 * 67 / _n)) if _n > 67 else 50

dev_labels = [str(d) for d in dev_periods]
year_labels = [str(y) for y in accident_years]

# Plot — square canvas (2400×2400) for symmetric grid; x-axis at top per actuarial convention
p = figure(
    width=2400,
    height=2400,
    x_range=dev_labels,
    y_range=list(reversed(year_labels)),
    title=title_str,
    x_axis_location="above",
    toolbar_location=None,
    min_border_bottom=160,
    min_border_left=200,
    min_border_top=260,
    min_border_right=130,
)

# Actual cells: solid fill, page-bg border for clean separation
r_actual = p.rect(
    x="x",
    y="y",
    width=1,
    height=1,
    source=source_actual,
    fill_color=transform("value", mapper),
    fill_alpha=1.0,
    line_color=PAGE_BG,
    line_width=3,
)

# Projected cells: reduced opacity + dashed border to flag IBNR estimates
r_proj = p.rect(
    x="x",
    y="y",
    width=1,
    height=1,
    source=source_proj,
    fill_color=transform("value", mapper),
    fill_alpha=0.55,
    line_color=INK_SOFT,
    line_width=2,
    line_dash="dashed",
)

# Cell annotations — 22pt for readability across 100 cells at full resolution
p.text(
    x="x",
    y="y",
    text="text",
    source=source_text,
    text_align="center",
    text_baseline="middle",
    text_font_size="22pt",
    text_color="text_color",
)

hover = HoverTool(
    renderers=[r_actual, r_proj],
    tooltips=[("Accident Year", "@y"), ("Dev Period", "@x"), ("Status", "@status"), ("Cumulative", "$@value{0,0}")],
)
p.add_tools(hover)

# Theme-adaptive chrome
p.title.text_font_size = f"{title_pt}pt"
p.title.text_color = INK
p.title.align = "center"
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = INK_SOFT

p.xaxis.axis_label = "Development Period (Years)"
p.yaxis.axis_label = "Accident Year"
p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK
p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT
p.axis.axis_line_color = None
p.axis.major_tick_line_color = None
p.grid.grid_line_color = None

# Legend (below figure, horizontal)
legend = Legend(
    items=[
        LegendItem(label="Actual (Observed)", renderers=[r_actual]),
        LegendItem(label="Projected (IBNR Estimate)", renderers=[r_proj]),
    ],
    orientation="horizontal",
    label_text_font_size="34pt",
    label_text_color=INK_SOFT,
    glyph_width=50,
    glyph_height=40,
    border_line_color=None,
    background_fill_color=ELEVATED_BG,
    background_fill_alpha=0.0,
    spacing=40,
)
p.add_layout(legend, "below")

# Development factors subtitle
_factor_str = "  ".join([f"F{j + 1}→{j + 2}: {dev_factors[j]:.3f}" for j in range(9)])
p.add_layout(
    Title(
        text=f"Age-to-Age Development Factors:  {_factor_str}",
        text_font_size="24pt",
        text_color=INK_MUTED,
        align="center",
    ),
    "below",
)

# Color bar (Imprint seq scale)
color_bar = ColorBar(
    color_mapper=mapper,
    ticker=BasicTicker(desired_num_ticks=8),
    label_standoff=14,
    major_label_text_font_size="28pt",
    major_label_text_color=INK_SOFT,
    title="Cumulative Claims ($K)",
    title_text_font_size="30pt",
    title_text_color=INK,
    width=50,
    location=(0, 0),
)
p.add_layout(color_bar, "right")

# Save interactive HTML — use absolute path so the file lands in the script's directory
_impl_dir = Path(_os.path.dirname(_os.path.abspath(__file__)))
_html_path = _impl_dir / f"plot-{THEME}.html"
_png_path = _impl_dir / f"plot-{THEME}.png"

output_file(str(_html_path))
save(p)

# Screenshot with headless Chrome — force exact viewport via CDP to avoid
# set_window_size() overhead (outer vs inner window discrepancy in headless)
W, H = 2400, 2400
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{_html_path.resolve()}")
time.sleep(3)
driver.save_screenshot(str(_png_path))
driver.quit()

Part of Actuarial Loss Development Triangle on anyplot.ai.

Other implementations