{"spec_id":"heatmap-loss-triangle","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nheatmap-loss-triangle: Actuarial Loss Development Triangle\nLibrary: seaborn 0.13.2 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\n\nimport matplotlib.patches as mpatches\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\nfrom matplotlib.colors import LinearSegmentedColormap\n\n\n# Theme\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\nsns.set_theme(\n    style=\"ticks\",\n    rc={\n        \"figure.facecolor\": PAGE_BG,\n        \"axes.facecolor\": PAGE_BG,\n        \"axes.edgecolor\": INK_SOFT,\n        \"axes.labelcolor\": INK,\n        \"text.color\": INK,\n        \"xtick.color\": INK_SOFT,\n        \"ytick.color\": INK_SOFT,\n        \"grid.color\": INK,\n        \"grid.alpha\": 0.15,\n        \"legend.facecolor\": ELEVATED_BG,\n        \"legend.edgecolor\": INK_SOFT,\n    },\n)\n\n# Imprint sequential colormap for continuous heatmap data (single-polarity)\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# Base initial claims by accident year (in thousands)\nbase_claims = [4200, 4500, 4800, 5100, 5400, 5700, 6000, 6300, 6600, 7000]\n\n# Age-to-age development factors (decreasing as claims mature)\ndev_factors = [2.50, 1.45, 1.22, 1.12, 1.07, 1.04, 1.025, 1.015, 1.008]\n\n# Build cumulative triangle\ncumulative = np.full((n_years, n_periods), np.nan)\nis_projected = np.full((n_years, n_periods), False)\n\nfor i in range(n_years):\n    cumulative[i, 0] = base_claims[i] + np.random.normal(0, 200)\n    for j in range(1, n_periods):\n        factor = dev_factors[j - 1] + np.random.normal(0, 0.02)\n        cumulative[i, j] = cumulative[i, j - 1] * factor\n    actual_periods = n_years - i\n    for j in range(actual_periods, n_periods):\n        is_projected[i, j] = True\n\nheatmap_data = pd.DataFrame(\n    cumulative, index=[str(y) for y in accident_years], columns=[str(p) for p in development_periods]\n)\n\n# Annotation strings\nannot_labels = np.empty_like(cumulative, dtype=object)\nfor i in range(n_years):\n    for j in range(n_periods):\n        val = cumulative[i, j]\n        annot_labels[i, j] = f\"{val:,.0f}\"\nannot_df = pd.DataFrame(annot_labels, index=heatmap_data.index, columns=heatmap_data.columns)\n\n# Masks for actual vs projected regions\nmask_projected = pd.DataFrame(is_projected, index=heatmap_data.index, columns=heatmap_data.columns)\nmask_actual = ~mask_projected\n\n# Plot — square canvas for symmetric heatmap (2400 × 2400 px)\nfig, ax = plt.subplots(figsize=(6, 6), dpi=400)\n\nvmin, vmax = np.nanmin(cumulative), np.nanmax(cumulative)\n\n# Draw actual cells (bold annotations)\nsns.heatmap(\n    heatmap_data,\n    ax=ax,\n    cmap=imprint_seq,\n    vmin=vmin,\n    vmax=vmax,\n    mask=mask_projected,\n    annot=annot_df,\n    fmt=\"\",\n    annot_kws={\"fontsize\": 8, \"fontweight\": \"bold\"},\n    linewidths=1.0,\n    linecolor=PAGE_BG,\n    cbar_kws={\"label\": \"Cumulative Claims ($K)\", \"shrink\": 0.8},\n)\n\n# Draw projected cells (italic annotations, no extra colorbar)\nsns.heatmap(\n    heatmap_data,\n    ax=ax,\n    cmap=imprint_seq,\n    vmin=vmin,\n    vmax=vmax,\n    mask=mask_actual,\n    annot=annot_df,\n    fmt=\"\",\n    annot_kws={\"fontsize\": 8, \"fontweight\": \"normal\", \"fontstyle\": \"italic\"},\n    linewidths=1.0,\n    linecolor=PAGE_BG,\n    cbar=False,\n)\n\n# Adaptive annotation text colors based on cell brightness in the colormap\nfor text in ax.texts:\n    x, y = text.get_position()\n    col, row = int(x), int(y)\n    if 0 <= row < n_years and 0 <= col < n_periods:\n        val = cumulative[row, col]\n        norm_val = (val - vmin) / (vmax - vmin)\n        text.set_color(\"white\" if norm_val > 0.55 else INK)\n\n# Hatching overlay for projected cells\nfor i in range(n_years):\n    for j in range(n_periods):\n        if is_projected[i, j]:\n            ax.add_patch(mpatches.Rectangle((j, i), 1, 1, facecolor=PAGE_BG, edgecolor=\"none\", alpha=0.2))\n            ax.add_patch(\n                mpatches.Rectangle((j, i), 1, 1, facecolor=\"none\", edgecolor=INK_SOFT, hatch=\"////\", linewidth=0)\n            )\n\n# Diagonal step-line marking the latest evaluation boundary (actual vs projected)\ndiag_x = [n_periods, n_periods]\ndiag_y = [0, 1]\nfor i in range(1, n_years):\n    actual_periods = n_years - i\n    diag_x.extend([actual_periods, actual_periods])\n    diag_y.extend([i, i + 1])\nax.plot(diag_x, diag_y, color=INK, linewidth=2.5, linestyle=\"-\", zorder=5, solid_capstyle=\"butt\")\n\n# Colorbar styling\ncbar = ax.collections[0].colorbar\nif cbar is not None:\n    cbar.ax.tick_params(labelsize=8, labelcolor=INK_SOFT)\n    cbar.set_label(\"Cumulative Claims ($K)\", fontsize=10, color=INK)\n    plt.setp(cbar.ax.yaxis.get_ticklabels(), color=INK_SOFT)\n\n# Development factors text — placed above the heatmap\nfactor_text = \"Dev Factors: \" + \"  \".join([f\"{f:.3f}\" for f in dev_factors])\nax.text(\n    0.5,\n    1.05,\n    factor_text,\n    transform=ax.transAxes,\n    ha=\"center\",\n    va=\"bottom\",\n    fontsize=7,\n    fontfamily=\"monospace\",\n    color=INK_MUTED,\n    bbox={\"boxstyle\": \"round,pad=0.3\", \"facecolor\": ELEVATED_BG, \"edgecolor\": INK_SOFT, \"linewidth\": 0.5},\n)\n\n# Style\ntitle = \"heatmap-loss-triangle · python · seaborn · anyplot.ai\"\nn_chars = len(title)\nratio = 67 / n_chars if n_chars > 67 else 1.0\ntitle_fontsize = max(8, round(12 * ratio))\nax.set_title(title, fontsize=title_fontsize, fontweight=\"medium\", color=INK, pad=40)\nax.set_xlabel(\"Development Period (Years)\", fontsize=10, color=INK)\nax.set_ylabel(\"Accident Year\", fontsize=10, color=INK)\nax.tick_params(axis=\"both\", labelsize=8)\nax.tick_params(axis=\"x\", rotation=0)\nax.tick_params(axis=\"y\", rotation=0)\n\n# Legend for actual vs projected regions\nactual_patch = mpatches.Patch(facecolor=\"#009E73\", edgecolor=INK_SOFT, label=\"Actual\")\nprojected_patch = mpatches.Patch(facecolor=\"#4467A3\", edgecolor=INK_SOFT, hatch=\"///\", label=\"Projected (IBNR)\")\nax.legend(\n    handles=[actual_patch, projected_patch],\n    loc=\"upper center\",\n    bbox_to_anchor=(0.5, -0.12),\n    fontsize=8,\n    framealpha=0.9,\n    facecolor=ELEVATED_BG,\n    edgecolor=INK_SOFT,\n    labelcolor=INK,\n    ncol=2,\n)\n\n# Save — no bbox_inches='tight'; figsize×dpi fixes the canvas at 2400×2400\nplt.tight_layout(rect=[0, 0.08, 1, 1])\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}