{"spec_id":"heatmap-loss-triangle","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nheatmap-loss-triangle: Actuarial Loss Development Triangle\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\n\n\nLetsPlot.setup_html()\n\n# Theme tokens (Imprint palette — theme-adaptive chrome)\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\"\nANYPLOT_AMBER = \"#DDCC77\"  # warning / caution anchor — outside the categorical pool\n\n# Data — cumulative paid claims triangle (10 accident years × 10 development periods)\nnp.random.seed(42)\naccident_years = list(range(2015, 2025))\ndev_periods = list(range(1, 11))\nn_years = len(accident_years)\nn_periods = len(dev_periods)\n\n# Age-to-age development factors (realistic chain-ladder factors)\nage_to_age_factors = [2.50, 1.45, 1.22, 1.12, 1.07, 1.04, 1.025, 1.015, 1.008]\n\n# Generate realistic initial claims and build cumulative triangle\ninitial_claims = np.random.uniform(8000, 15000, n_years)\ntriangle = np.full((n_years, n_periods), np.nan)\nfor i in range(n_years):\n    triangle[i, 0] = initial_claims[i]\n    for j in range(1, n_periods):\n        noise = max(np.random.normal(1.0, 0.02), 1.0 / age_to_age_factors[j - 1])\n        triangle[i, j] = triangle[i, j - 1] * age_to_age_factors[j - 1] * noise\n\n# Build main heatmap dataframe\nrows = []\nfor i in range(n_years):\n    for j in range(n_periods):\n        is_projected = (i + j) >= n_years\n        rows.append(\n            {\n                \"accident_year\": str(accident_years[i]),\n                \"dev_period\": str(dev_periods[j]),\n                \"cumulative\": triangle[i, j],\n                \"region\": \"Projected\" if is_projected else \"Actual\",\n            }\n        )\n\ndf = pd.DataFrame(rows)\ndf[\"label\"] = df[\"cumulative\"].apply(lambda v: f\"{v:,.0f}\")\n\n# Text color: white on dark cells, INK on lighter cells (contrast against fill gradient)\nmax_val = df[\"cumulative\"].max()\nmin_val = df[\"cumulative\"].min()\ndf[\"text_color\"] = df[\"cumulative\"].apply(lambda v: \"white\" if (v - min_val) / (max_val - min_val) > 0.55 else INK)\n\n# Build development factors row (9 factors + terminal \"—\")\nfactor_rows = []\nfor j in range(len(age_to_age_factors)):\n    factor_rows.append(\n        {\"accident_year\": \"Factor\", \"dev_period\": str(dev_periods[j]), \"label\": f\"{age_to_age_factors[j]:.3f}\"}\n    )\nfactor_rows.append({\"accident_year\": \"Factor\", \"dev_period\": str(dev_periods[-1]), \"label\": \"—\"})\ndf_factors = pd.DataFrame(factor_rows)\n\n# Y-axis ordering: Factor at bottom, 2024 above, 2015 at top\ny_order = [\"Factor\"] + [str(y) for y in reversed(accident_years)]\n\n# Separate actual and projected\ndf_actual = df[df[\"region\"] == \"Actual\"].copy()\ndf_projected = df[df[\"region\"] == \"Projected\"].copy()\n\n# Invisible points for Actual/Projected legend (lets-plot guide trick)\ndf_legend = pd.DataFrame(\n    {\n        \"x\": [str(dev_periods[0]), str(dev_periods[0])],\n        \"y\": [str(accident_years[0]), str(accident_years[0])],\n        \"region\": [\"Actual\", \"Projected\"],\n    }\n)\n\n# Split cell annotations by text color for contrast against fill gradient\ndf_light_text = df[df[\"text_color\"] == \"white\"].copy()\ndf_dark_text = df[df[\"text_color\"] != \"white\"].copy()\n\n# Focal point: identify peak projected (IBNR) cell for storytelling annotation\nmax_proj_idx = df_projected[\"cumulative\"].idxmax()\nmax_proj_row = df_projected.loc[max_proj_idx]\ndf_peak = pd.DataFrame(\n    {\n        \"accident_year\": [max_proj_row[\"accident_year\"]],\n        \"dev_period\": [max_proj_row[\"dev_period\"]],\n        \"text\": [f\"Peak IBNR\\n${max_proj_row['cumulative']:,.0f}\"],\n    }\n)\n\ntitle = \"heatmap-loss-triangle · python · letsplot · anyplot.ai\"\n\n# Theme-adaptive chrome\nanyplot_theme = theme(\n    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    panel_background=element_rect(fill=PAGE_BG),\n    panel_grid=element_blank(),\n    panel_border=element_blank(),\n    axis_line=element_blank(),\n    axis_ticks=element_blank(),\n    axis_title=element_text(color=INK, size=14, face=\"bold\"),\n    axis_text=element_text(color=INK_SOFT, size=12),\n    plot_title=element_text(color=INK, size=16, face=\"bold\"),\n    plot_subtitle=element_text(color=INK_SOFT, size=12, face=\"italic\"),\n    plot_caption=element_text(color=INK_MUTED, size=11),\n    legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    legend_text=element_text(color=INK_SOFT, size=13),\n    legend_title=element_text(color=INK, size=15, face=\"bold\"),\n    legend_position=\"right\",\n    plot_margin=[30, 20, 20, 20],\n)\n\n# Base flavor: lets-plot exclusive dark theme for dark renders\nbase_flavor = flavor_darcula() if THEME == \"dark\" else theme_minimal()\n\n# Plot\nplot = (\n    ggplot()\n    # Actual cells: border in INK_SOFT (theme-adaptive grey)\n    + geom_tile(aes(x=\"dev_period\", y=\"accident_year\", fill=\"cumulative\"), data=df_actual, color=INK_SOFT, size=1.0)\n    # Projected cells: amber border + slightly reduced alpha marks the IBNR region\n    + geom_tile(\n        aes(x=\"dev_period\", y=\"accident_year\", fill=\"cumulative\"),\n        data=df_projected,\n        color=ANYPLOT_AMBER,\n        size=1.4,\n        alpha=0.75,\n    )\n    # Cell annotations — split by text color for readability across gradient\n    + geom_text(aes(x=\"dev_period\", y=\"accident_year\", label=\"label\"), data=df_light_text, color=\"white\", size=4.5)\n    + geom_text(aes(x=\"dev_period\", y=\"accident_year\", label=\"label\"), data=df_dark_text, color=INK, size=4.5)\n    # Invisible points for Actual / Projected legend via guide_legend override_aes\n    + geom_point(aes(x=\"x\", y=\"y\", color=\"region\"), data=df_legend, size=0, alpha=0)\n    + scale_color_manual(\n        values={\"Actual\": INK_SOFT, \"Projected\": ANYPLOT_AMBER},\n        name=\"Cell Region\",\n        guide=guide_legend(override_aes={\"size\": 8, \"alpha\": 1, \"shape\": 15}),\n    )\n    # Factor row tiles with elevated background\n    + geom_tile(aes(x=\"dev_period\", y=\"accident_year\"), data=df_factors, fill=ELEVATED_BG, color=INK_SOFT, size=0.8)\n    # Factor labels in bold\n    + geom_text(\n        aes(x=\"dev_period\", y=\"accident_year\", label=\"label\"), data=df_factors, color=INK, size=4.0, fontface=\"bold\"\n    )\n    # Focal point: callout on peak projected (IBNR) cell for data storytelling\n    + geom_text(\n        aes(x=\"dev_period\", y=\"accident_year\", label=\"text\"),\n        data=df_peak,\n        color=ANYPLOT_AMBER,\n        size=3.8,\n        fontface=\"bold\",\n    )\n    # Imprint sequential colormap: brand green → blue (single-polarity magnitude)\n    + scale_fill_gradient(low=\"#009E73\", high=\"#4467A3\", name=\"Cumulative\\nClaims ($)\")\n    + scale_x_discrete(limits=[str(p) for p in dev_periods])\n    + scale_y_discrete(limits=y_order)\n    + labs(\n        x=\"Development Period (Years)\",\n        y=\"Accident / Origin Year\",\n        title=title,\n        subtitle=\"Chain-Ladder Loss Triangle  ·  Actual (grey border) vs Projected (amber border)\",\n        caption=\"Bottom row: Age-to-Age Development Factors\",\n    )\n    + ggsize(600, 600)\n    + base_flavor\n    + anyplot_theme\n)\n\n# Save\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}