{"spec_id":"heatmap-loss-triangle","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\nheatmap-loss-triangle: Actuarial Loss Development Triangle\nLibrary: matplotlib 3.10.9 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.colors import LinearSegmentedColormap\nfrom matplotlib.patches import Patch, Rectangle\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint sequential colormap — single-polarity cumulative claim amounts\nimprint_seq = LinearSegmentedColormap.from_list(\"imprint_seq\", [\"#009E73\", \"#4467A3\"])\n\n# Data\nnp.random.seed(42)\n\naccident_years = list(range(2015, 2025))\ndevelopment_periods = list(range(1, 11))\nn_years = len(accident_years)\nn_periods = len(development_periods)\n\n# Age-to-age development factors (decreasing as claims mature)\ndev_factors = [2.50, 1.60, 1.30, 1.15, 1.08, 1.05, 1.03, 1.02, 1.01]\n\n# Generate cumulative paid claims triangle\ninitial_claims = np.array([4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 7000], dtype=float)\n\ntriangle = np.full((n_years, n_periods), np.nan)\nis_projected = np.full((n_years, n_periods), True)\n\n# Fill upper-left actual triangle\nfor i in range(n_years):\n    triangle[i, 0] = initial_claims[i]\n    n_actual = n_periods - i\n    for j in range(1, n_actual):\n        noise = 1 + np.random.uniform(-0.03, 0.03)\n        triangle[i, j] = triangle[i, j - 1] * dev_factors[j - 1] * noise\n    for j in range(n_actual):\n        is_projected[i, j] = False\n\n# Fill lower-right projected triangle using chain-ladder\nfor i in range(1, n_years):\n    n_actual = n_periods - i\n    for j in range(n_actual, n_periods):\n        triangle[i, j] = triangle[i, j - 1] * dev_factors[j - 1]\n\nvmin, vmax = np.nanmin(triangle), np.nanmax(triangle)\n\n# Plot — square canvas for symmetric heatmap grid (2400×2400 px)\nfig, ax = plt.subplots(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\n# Heatmap with Imprint sequential colormap\nim = ax.imshow(triangle, cmap=imprint_seq, vmin=vmin, vmax=vmax, aspect=\"auto\")\n\n# Colorbar — create before twin axis so layout is established correctly\ncbar = fig.colorbar(im, ax=ax, pad=0.02, shrink=0.80)\ncbar.set_label(\"Cumulative Claims ($)\", fontsize=8, color=INK)\ncbar.ax.tick_params(labelsize=7, colors=INK_SOFT)\ncbar.ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f\"{x:,.0f}\"))\ncbar.outline.set_edgecolor(INK_SOFT)\n\n# Hatching overlay on projected cells — theme-adaptive hatch color for visibility in dark mode\nhatch_color = INK_SOFT if THEME == \"dark\" else PAGE_BG\nfor i in range(n_years):\n    for j in range(n_periods):\n        if is_projected[i, j] and not np.isnan(triangle[i, j]):\n            rect = Rectangle((j - 0.5, i - 0.5), 1, 1, fill=False, hatch=\"///\", edgecolor=hatch_color, linewidth=0)\n            ax.add_patch(rect)\n\n# Cell borders\nfor i in range(n_years + 1):\n    ax.axhline(i - 0.5, color=PAGE_BG, linewidth=1.0)\nfor j in range(n_periods + 1):\n    ax.axvline(j - 0.5, color=PAGE_BG, linewidth=1.0)\n\n# Cell annotations — brightness-adaptive text color with dark-mode correction\n# All imprint_seq colors have brightness < 0.5, so use PAGE_BG (light) in light mode\n# and INK (cream) in dark mode to ensure readable contrast on dark cells\nfor i in range(n_years):\n    for j in range(n_periods):\n        val = triangle[i, j]\n        if np.isnan(val):\n            continue\n        norm_val = (val - vmin) / (vmax - vmin)\n        rgba = imprint_seq(norm_val)\n        brightness = 0.299 * rgba[0] + 0.587 * rgba[1] + 0.114 * rgba[2]\n        if brightness < 0.5:\n            text_color = PAGE_BG if THEME == \"light\" else INK\n        else:\n            text_color = INK if THEME == \"light\" else PAGE_BG\n        fontstyle = \"italic\" if is_projected[i, j] else \"normal\"\n        ax.text(\n            j,\n            i,\n            f\"{val:,.0f}\",\n            ha=\"center\",\n            va=\"center\",\n            fontsize=7,\n            color=text_color,\n            fontstyle=fontstyle,\n            fontweight=\"medium\",\n        )\n\n# Staircase diagonal — focal-point boundary between actual (upper-left) and projected (lower-right)\n# Starts at (n_periods-0.5, 0.5) and steps down-left to (0.5, n_years-0.5)\ndiag_xs = [n_periods - 0.5]\ndiag_ys = [0.5]\nfor i in range(1, n_years):\n    x_boundary = (n_periods - i) - 0.5\n    diag_xs.extend([x_boundary, x_boundary])\n    diag_ys.extend([i - 0.5, i + 0.5])\nax.plot(diag_xs, diag_ys, color=INK_SOFT, linewidth=2.5, zorder=5, alpha=0.8, solid_capstyle=\"butt\")\n\n# Axes styling\ntitle = \"heatmap-loss-triangle · python · matplotlib · anyplot.ai\"\nn_chars = len(title)\nratio = 67 / n_chars if n_chars > 67 else 1.0\ntitle_fontsize = max(8, round(12 * ratio))\n\nax.set_xticks(range(n_periods))\nax.set_xticklabels(development_periods, fontsize=8, color=INK_SOFT)\nax.set_yticks(range(n_years))\nax.set_yticklabels(accident_years, fontsize=8, color=INK_SOFT)\nax.set_xlabel(\"Development Period (Years)\", fontsize=10, color=INK, labelpad=8)\nax.set_ylabel(\"Accident Year\", fontsize=10, color=INK, labelpad=6)\nax.set_title(title, fontsize=title_fontsize, fontweight=\"medium\", color=INK, pad=20)\nax.tick_params(axis=\"both\", length=0, colors=INK_SOFT)\n\nfor spine in ax.spines.values():\n    spine.set_visible(False)\n\n# Development factors on a twin x-axis at the top\nax_top = ax.twiny()\nax_top.set_xlim(ax.get_xlim())\nax_top.set_xticks([j + 1 for j in range(len(dev_factors))])\nax_top.set_xticklabels([f\"{f:.2f}\" for f in dev_factors], fontsize=7, color=INK_MUTED)\nax_top.set_xlabel(\"Age-to-Age Dev. Factors (LDF)\", fontsize=8, color=INK_MUTED, labelpad=6)\nax_top.tick_params(length=0, colors=INK_MUTED, labelcolor=INK_MUTED)\nfor spine in ax_top.spines.values():\n    spine.set_visible(False)\n\n# Legend below the heatmap\nlegend_elements = [\n    Patch(facecolor=imprint_seq(0.55), edgecolor=PAGE_BG, label=\"Actual (Observed)\"),\n    Patch(facecolor=imprint_seq(0.55), edgecolor=hatch_color, hatch=\"///\", label=\"Projected (IBNR)\"),\n]\nleg = ax.legend(\n    handles=legend_elements,\n    loc=\"upper left\",\n    fontsize=7,\n    frameon=True,\n    facecolor=ELEVATED_BG,\n    edgecolor=INK_SOFT,\n    bbox_to_anchor=(0.0, -0.10),\n    bbox_transform=ax.transAxes,\n)\nplt.setp(leg.get_texts(), color=INK_SOFT)\n\nfig.subplots_adjust(left=0.10, bottom=0.14, top=0.92)\n\n# Save\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}