diff --git a/chainladder/utils/data/_manifest.py b/chainladder/utils/data/_manifest.py index 7cc2b9064..5a1b27176 100644 --- a/chainladder/utils/data/_manifest.py +++ b/chainladder/utils/data/_manifest.py @@ -1,4 +1,5 @@ -"""Central registry of bundled sample datasets. +""" +Central registry of bundled sample datasets. Single source of truth for the metadata of every CSV in ``chainladder/utils/data/``. Consumed by: @@ -139,6 +140,13 @@ ], "cumulative": True, }, + "friedland_dc_insurer": { + "origin": "Accident Year", + "development": "Valuation", + "index": None, + "columns": ["Reported Claims"], + "cumulative": True, + }, "friedland_gl_insurer": { "origin": "Accident Year", "development": "Calendar Year", diff --git a/chainladder/utils/data/friedland_dc_insurer.csv b/chainladder/utils/data/friedland_dc_insurer.csv new file mode 100644 index 000000000..41c627588 --- /dev/null +++ b/chainladder/utils/data/friedland_dc_insurer.csv @@ -0,0 +1,140 @@ +Accident Year,Valuation,Reported Claims +1997,1997-03,861 +1997,1997-06,1668 +1997,1997-09,2459 +1997,1997-12,3255 +1997,1998-03,3366 +1997,1998-06,3385 +1997,1998-09,3385 +1997,1998-12,3374 +1997,1999-03,3372 +1997,1999-06,3374 +1997,1999-09,3376 +1997,1999-12,3376 +1997,2007-12,3376 +1997,2008-12,3376 +1998,1998-03,878 +1998,1998-06,1493 +1998,1998-09,2248 +1998,1998-12,2756 +1998,1999-03,2826 +1998,1999-06,2812 +1998,1999-09,2805 +1998,1999-12,2804 +1998,2000-03,2785 +1998,2000-06,2787 +1998,2000-09,2787 +1998,2000-12,2788 +1998,2007-12,2788 +1998,2008-12,2788 +1999,1999-03,463 +1999,1999-06,786 +1999,1999-09,1166 +1999,1999-12,1605 +1999,2000-03,1673 +1999,2000-06,1642 +1999,2000-09,1646 +1999,2000-12,1645 +1999,2001-03,1649 +1999,2001-06,1649 +1999,2001-09,1649 +1999,2001-12,1649 +1999,2007-12,1649 +1999,2008-12,1649 +2000,2000-03,511 +2000,2000-06,806 +2000,2000-09,1112 +2000,2000-12,1530 +2000,2001-03,1684 +2000,2001-06,1689 +2000,2001-09,1686 +2000,2001-12,1688 +2000,2002-03,1686 +2000,2002-06,1687 +2000,2002-09,1687 +2000,2002-12,1687 +2000,2007-12,1687 +2000,2008-12,1687 +2001,2001-03,414 +2001,2001-06,750 +2001,2001-09,1264 +2001,2001-12,1836 +2001,2002-03,2088 +2001,2002-06,2078 +2001,2002-09,2081 +2001,2002-12,2086 +2001,2003-03,2086 +2001,2003-06,2087 +2001,2003-09,2087 +2001,2003-12,2088 +2001,2007-12,2088 +2001,2008-12,2096 +2002,2002-03,502 +2002,2002-06,961 +2002,2002-09,1424 +2002,2002-12,2016 +2002,2003-03,2307 +2002,2003-06,2330 +2002,2003-09,2342 +2002,2003-12,2348 +2002,2004-03,2352 +2002,2004-06,2354 +2002,2004-09,2355 +2002,2004-12,2355 +2002,2007-12,2355 +2002,2008-12,2340 +2003,2003-03,614 +2003,2003-06,1231 +2003,2003-09,1940 +2003,2003-12,2576 +2003,2004-03,2878 +2003,2004-06,2936 +2003,2004-09,2977 +2003,2004-12,2988 +2003,2005-03,2992 +2003,2005-06,2992 +2003,2005-09,2994 +2003,2005-12,2994 +2003,2007-12,2994 +2003,2008-12,3007 +2004,2004-03,833 +2004,2004-06,1576 +2004,2004-09,2181 +2004,2004-12,3048 +2004,2005-03,3407 +2004,2005-06,3406 +2004,2005-09,3397 +2004,2005-12,3403 +2004,2006-03,3407 +2004,2006-06,3410 +2004,2006-09,3412 +2004,2006-12,3412 +2004,2007-12,3412 +2004,2008-12,3392 +2005,2005-03,675 +2005,2005-06,1248 +2005,2005-09,1833 +2005,2005-12,2601 +2005,2006-03,2792 +2005,2006-06,2791 +2005,2006-09,2803 +2005,2006-12,2810 +2005,2007-03,2813 +2005,2007-06,2814 +2005,2007-09,2814 +2005,2007-12,2814 +2005,2008-12,2885 +2006,2006-03,764 +2006,2006-06,1374 +2006,2006-09,2157 +2006,2006-12,2531 +2006,2007-03,2897 +2006,2007-06,2930 +2006,2007-09,2945 +2006,2007-12,2949 +2006,2008-12,3030 +2007,2007-03,754 +2007,2007-06,1468 +2007,2007-09,1987 +2007,2007-12,2463 +2007,2008-12,2733 diff --git a/docs/_toc.yml b/docs/_toc.yml index eec4660c2..72ae4f43a 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -42,6 +42,7 @@ parts: - file: friedland/chapter_9.ipynb - file: friedland/chapter_10.ipynb - file: friedland/chapter_11.ipynb + - file: friedland/chapter_15_monitoring.ipynb - chapters: - file: gallery/index.md - chapters: diff --git a/docs/friedland/chapter_15_monitoring.ipynb b/docs/friedland/chapter_15_monitoring.ipynb new file mode 100644 index 000000000..4e32cacfd --- /dev/null +++ b/docs/friedland/chapter_15_monitoring.ipynb @@ -0,0 +1,359 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ch15m-intro", + "metadata": {}, + "source": [ + "# Chapter 15 - Monitoring Unpaid Claim Estimates\n", + "\n", + "> Deviations of actual development from projected development of claims or\n", + "> claim counts are one of the most useful diagnostic tools for evaluating the\n", + "> accuracy of the unpaid claim estimate.\n", + ">\n", + "> -- Friedland, Chapter 15\n", + "\n", + "The last part of Chapter 15 is a **roll-forward**: take the ultimates and\n", + "reporting pattern selected at one valuation, and compare actual reported\n", + "claims in the next period with the amount that pattern said should emerge.\n", + "\n", + "This notebook recreates Friedland's **DC Insurer** monitoring exhibits\n", + "(*Exhibit IV, Sheets 1-4*). `friedland_dc_insurer` is the quarterly reported\n", + "triangle through 36 months, plus the 12/31/2007 and 12/31/2008 diagonals.\n", + "Selected CDFs stop at 36 months (age-to-ult 1.000), so later ages are treated\n", + "as fully reported.\n", + "\n", + "Expected emergence in the next calendar period comes from the fitted\n", + "`Chainladder` model: `full_triangle_.dev_to_val()` at the later valuation\n", + "minus the prior `latest_diagonal`. That is the same pattern as the gallery\n", + "Actual vs Expected example. For each accident year that is the Friedland\n", + "formula\n", + "\n", + "$$\n", + "\\frac{\\text{Ultimate}_{t_0} - \\text{Reported}_{t_0}}{1 - p_{t_0}}\n", + "\\times (p_{t_1} - p_{t_0})\n", + "$$\n", + "\n", + "where $p_t = 1 / \\text{CDF}_t$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ch15m-imports", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import chainladder as cl\n", + "from IPython.display import display\n", + "\n", + "pd.set_option(\"display.max_columns\", None)\n", + "pd.set_option(\"display.width\", 1000)\n", + "\n", + "\n", + "def as_series(tri):\n", + " s = tri.to_frame(origin_as_datetime=False).iloc[:, 0]\n", + " s.index = [int(getattr(i, \"year\", i)) for i in s.index]\n", + " return s" + ] + }, + { + "cell_type": "markdown", + "id": "ch15m-sheet1-md", + "metadata": {}, + "source": [ + "## Exhibit IV, Sheet 1 - Reported claims triangle\n", + "\n", + "DC Insurer reports quarterly. The sample carries ages 3 through 36, which is\n", + "the printed Sheet 1 triangle. The selected tail is 1.000 at 36 months, so\n", + "older accident years are carried at that age-36 value through 12/31/2007." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ch15m-sheet1", + "metadata": {}, + "outputs": [], + "source": [ + "tri = cl.load_sample(\"friedland_dc_insurer\")\n", + "tri = tri[tri.origin < \"2008\"]\n", + "tri_2007 = tri[tri.valuation < \"2008\"]\n", + "\n", + "selected_cdf = {\n", + " 3: 4.125,\n", + " 6: 2.177,\n", + " 9: 1.487,\n", + " 12: 1.136,\n", + " 15: 1.016,\n", + " 18: 1.008,\n", + " 21: 1.003,\n", + " 24: 1.001,\n", + " 27: 1.000,\n", + " 30: 1.000,\n", + " 33: 1.000,\n", + " 36: 1.000,\n", + "}\n", + "for age in (int(a) for a in tri_2007.ddims):\n", + " selected_cdf.setdefault(age, 1.000)\n", + "\n", + "dev = cl.DevelopmentConstant(patterns=selected_cdf, style=\"cdf\").fit_transform(tri_2007)\n", + "model = cl.Chainladder().fit(dev)\n", + "\n", + "display(tri_2007[tri_2007.development <= 36])\n", + "display(dev.cdf_[dev.cdf_.ddims <= 36])" + ] + }, + { + "cell_type": "markdown", + "id": "ch15m-sheet2-md", + "metadata": {}, + "source": [ + "## Exhibit IV, Sheet 2 - Ultimates at 12/31/2007\n", + "\n", + "DC Insurer selects ultimates with the reported development technique. Age 12\n", + "uses a 1.136 CDF (88.0% reported); age 24 uses 1.001 (99.9% reported); age 36\n", + "and later are 1.000.\n", + "\n", + "`model_diagnostics` pulls Latest, CDF, and Ultimate from the fitted\n", + "`Chainladder` model. The text's worked example for accident year 2007 is\n", + "$2{,}463 \\times 1.136 = 2{,}798$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ch15m-sheet2", + "metadata": {}, + "outputs": [], + "source": [ + "diag = cl.model_diagnostics(model)\n", + "years = [int(year) for year in tri_2007.origin.year]\n", + "sheet2 = pd.DataFrame(index=years)\n", + "sheet2[\"Age\"] = (2007 - sheet2.index + 1) * 12\n", + "sheet2[\"Reported at 12/31/07\"] = as_series(diag[\"Latest\"])\n", + "sheet2[\"CDF to Ultimate\"] = np.round(as_series(diag[\"CDF\"]), 3)\n", + "sheet2[\"Projected Ultimate\"] = np.round(as_series(diag[\"Ultimate\"]), 0)\n", + "display(sheet2)\n", + "print(f\"Total projected ultimate: {sheet2['Projected Ultimate'].sum():,.0f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ch15m-sheet3-md", + "metadata": {}, + "source": [ + "## Exhibit IV, Sheet 3 - Annual monitoring test\n", + "\n", + "One year later, compare calendar-year 2008 actual reported claims with the\n", + "amount implied by the 12/31/2007 model. Slice `full_triangle_` at the 2008\n", + "valuation for the expected cumulative; subtract the 2007 `latest_diagonal`\n", + "for expected emergence.\n", + "\n", + "The text works accident year 2007 as\n", + "\n", + "$$\n", + "\\frac{2{,}798 - 2{,}463}{1 - 0.880} \\times (0.999 - 0.880) = 332\n", + "$$\n", + "\n", + "and accident year 2006 as\n", + "\n", + "$$\n", + "\\frac{2{,}952 - 2{,}949}{1 - 0.999} \\times (1.000 - 0.999) = 3.\n", + "$$\n", + "\n", + "Older years are fully reported, so expected emergence is zero." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ch15m-sheet3", + "metadata": {}, + "outputs": [], + "source": [ + "expected_cum = model.full_triangle_.dev_to_val()\n", + "expected_cum = expected_cum[expected_cum.valuation == tri.valuation_date]\n", + "\n", + "reported_2007 = as_series(tri_2007.latest_diagonal)\n", + "reported_2008 = as_series(tri.latest_diagonal)\n", + "expected = np.round(as_series(expected_cum) - reported_2007, 0)\n", + "actual = reported_2008 - reported_2007\n", + "\n", + "pct_2007 = 1.0 / as_series(diag[\"CDF\"]).to_numpy()\n", + "cdf_2008 = np.array([selected_cdf.get(int(age) + 12, 1.000) for age in sheet2[\"Age\"]])\n", + "pct_2008 = 1.0 / cdf_2008\n", + "\n", + "sheet3 = pd.DataFrame(index=years)\n", + "sheet3[\"Selected Ultimate\"] = sheet2[\"Projected Ultimate\"]\n", + "sheet3[\"% Reported 12/31/07\"] = np.round(pct_2007, 3)\n", + "sheet3[\"% Reported 12/31/08\"] = np.round(pct_2008, 3)\n", + "sheet3[\"Reported 12/31/07\"] = reported_2007\n", + "sheet3[\"Reported 12/31/08\"] = reported_2008\n", + "sheet3[\"Actual\"] = actual\n", + "sheet3[\"Expected\"] = expected\n", + "sheet3[\"Difference\"] = actual - expected\n", + "display(sheet3)\n", + "display(sheet3[[\"Actual\", \"Expected\", \"Difference\"]].sum().rename(\"Total\").to_frame().T)" + ] + }, + { + "cell_type": "markdown", + "id": "ch15m-sheet4-md", + "metadata": {}, + "source": [ + "## Exhibit IV, Sheet 4 - Monthly monitoring test\n", + "\n", + "DC Insurer has quarterly development factors. Monthly percent-reported values\n", + "are **linear interpolations of the quarterly percent reported**. Between age 12\n", + "(88.0%) and age 15 ($1 / 1.016 \\approx 98.4%$) that gives 91.5% at 13 months\n", + "and 95.0% at 14 months, matching the printed January / February 2008 template.\n", + "\n", + "Expected monthly emergence uses the fitted `ibnr_` and those interpolated\n", + "percents: $\\text{IBNR} \\times (p_{t+1} - p_t) / (1 - p_t)$. The quarterly\n", + "triangle cannot hold monthly CDFs, so the interpolation stays on the selected\n", + "pattern rather than on `DevelopmentConstant`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ch15m-sheet4", + "metadata": {}, + "outputs": [], + "source": [ + "def pct_reported_at(age):\n", + " \"\"\"Linearly interpolate percent reported between quarterly CDF ages.\"\"\"\n", + " knots = np.array(sorted(selected_cdf))\n", + " pcts = 1.0 / np.array([selected_cdf[k] for k in knots])\n", + " if age <= knots[0]:\n", + " return float(pcts[0])\n", + " if age >= knots[-1]:\n", + " return 1.0\n", + " return float(np.interp(age, knots, pcts))\n", + "\n", + "\n", + "pct_jan = np.array([pct_reported_at(age + 1) for age in sheet2[\"Age\"]])\n", + "pct_feb = np.array([pct_reported_at(age + 2) for age in sheet2[\"Age\"]])\n", + "\n", + "# Printed latest reported at 1/31/08 and 2/29/08 for the two immature years.\n", + "reported_jan = reported_2007.copy()\n", + "reported_feb = reported_2007.copy()\n", + "reported_jan.loc[2007] = 2473\n", + "reported_feb.loc[2007] = 2538\n", + "reported_jan.loc[2006] = 2951\n", + "reported_feb.loc[2006] = 2986\n", + "\n", + "ibnr = as_series(model.ibnr_)\n", + "unreported = 1.0 - pct_2007\n", + "scale = np.divide(\n", + " ibnr.to_numpy(), unreported, out=np.zeros(len(ibnr)), where=unreported > 0\n", + ")\n", + "expected_jan = np.round(scale * (pct_jan - pct_2007), 0)\n", + "unreported_jan = 1.0 - pct_jan\n", + "remaining = reported_jan.to_numpy()\n", + "ibnr_jan = sheet2[\"Projected Ultimate\"].to_numpy() - remaining\n", + "scale_feb = np.divide(\n", + " ibnr_jan, unreported_jan, out=np.zeros(len(ibnr)), where=unreported_jan > 0\n", + ")\n", + "expected_feb = np.round(scale_feb * (pct_feb - pct_jan), 0)\n", + "\n", + "actual_jan = reported_jan - reported_2007\n", + "actual_feb = reported_feb - reported_jan\n", + "\n", + "sheet4 = pd.DataFrame(index=years)\n", + "sheet4[\"Selected Ultimate\"] = sheet2[\"Projected Ultimate\"]\n", + "sheet4[\"% Reported 12/31/07\"] = np.round(pct_2007, 3)\n", + "sheet4[\"% Reported 1/31/08\"] = np.round(pct_jan, 3)\n", + "sheet4[\"% Reported 2/29/08\"] = np.round(pct_feb, 3)\n", + "sheet4[\"Reported 12/31/07\"] = reported_2007\n", + "sheet4[\"Reported 1/31/08\"] = reported_jan\n", + "sheet4[\"Reported 2/29/08\"] = reported_feb\n", + "sheet4[\"Actual Jan\"] = actual_jan\n", + "sheet4[\"Expected Jan\"] = expected_jan\n", + "sheet4[\"Diff Jan\"] = actual_jan - expected_jan\n", + "sheet4[\"Actual Feb\"] = actual_feb\n", + "sheet4[\"Expected Feb\"] = expected_feb\n", + "sheet4[\"Diff Feb\"] = actual_feb - expected_feb\n", + "display(sheet4)\n", + "display(\n", + " sheet4[\n", + " [\n", + " \"Actual Jan\",\n", + " \"Expected Jan\",\n", + " \"Diff Jan\",\n", + " \"Actual Feb\",\n", + " \"Expected Feb\",\n", + " \"Diff Feb\",\n", + " ]\n", + " ]\n", + " .sum()\n", + " .rename(\"Total\")\n", + " .to_frame()\n", + " .T\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ch15m-recon-md", + "metadata": {}, + "source": [ + "## Reconciliation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ch15m-recon", + "metadata": {}, + "outputs": [], + "source": [ + "# Exhibit IV, Sheet 1\n", + "sheet1 = tri_2007[tri_2007.development <= 36].to_frame(origin_as_datetime=False)\n", + "sheet1.index = [int(getattr(i, \"year\", i)) for i in sheet1.index]\n", + "assert sheet1.loc[1997, 3] == 861\n", + "assert sheet1.loc[2007, 12] == 2463\n", + "assert sheet1.loc[2006, 24] == 2949\n", + "\n", + "# Exhibit IV, Sheet 2\n", + "assert sheet2.loc[2007, \"Projected Ultimate\"] == 2798\n", + "assert sheet2.loc[2006, \"Projected Ultimate\"] == 2952\n", + "\n", + "# Exhibit IV, Sheet 3\n", + "assert sheet3.loc[2007, \"% Reported 12/31/07\"] == 0.880\n", + "assert sheet3.loc[2007, \"% Reported 12/31/08\"] == 0.999\n", + "assert sheet3.loc[2007, \"Expected\"] == 332\n", + "assert sheet3.loc[2006, \"Expected\"] == 3\n", + "assert sheet3[\"Expected\"].sum() == 335\n", + "\n", + "# Exhibit IV, Sheet 4 - interpolated percent reported for AY 2007\n", + "assert np.isclose(sheet4.loc[2007, \"% Reported 1/31/08\"], 0.915, atol=5e-4)\n", + "assert np.isclose(sheet4.loc[2007, \"% Reported 2/29/08\"], 0.950, atol=5e-4)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}