diff --git a/docs/_toc.yml b/docs/_toc.yml
index 87f3af4d..b7e069eb 100644
--- a/docs/_toc.yml
+++ b/docs/_toc.yml
@@ -35,6 +35,7 @@ parts:
sections:
- file: friedland/chapter_6.rst
- file: friedland/chapter_7.rst
+ - file: friedland/chapter_9.ipynb
- chapters:
- file: gallery/index.md
- chapters:
diff --git a/docs/friedland/chapter_9.ipynb b/docs/friedland/chapter_9.ipynb
new file mode 100644
index 00000000..e74e8171
--- /dev/null
+++ b/docs/friedland/chapter_9.ipynb
@@ -0,0 +1,1055 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "b33bad90",
+ "metadata": {},
+ "source": [
+ "# Chapter 9 - Bornhuetter-Ferguson Technique\n",
+ "\n",
+ "> The Bornhuetter-Ferguson technique is essentially a blend of the development\n",
+ "> technique and the expected claims technique.\n",
+ ">\n",
+ "> -- Friedland, Chapter 9\n",
+ "\n",
+ "The Bornhuetter-Ferguson (BF) method splits ultimate claims into the claims\n",
+ "already reported (or paid) plus the *expected* unreported (or unpaid) claims. The\n",
+ "expected piece is an a priori estimate of ultimate claims (from the expected\n",
+ "claims technique of Chapter 8), scaled by the percentage still to emerge that is\n",
+ "implied by the development pattern:\n",
+ "\n",
+ "$$\\text{Ultimate} = \\text{Actual} + \\text{Expected Claims} \\times \\left(1 - \\frac{1}{\\text{CDF}}\\right)$$\n",
+ "\n",
+ "In the chainladder package the method is implemented by\n",
+ "`BornhuetterFerguson`, which takes the a priori through `sample_weight`. This\n",
+ "chapter recreates the **XYZ Insurer - Auto BI** example (Friedland Chapter 9),\n",
+ "reusing the development pattern selected for XYZ in Chapter 7."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "4a72e71f",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-12T03:29:01.978518Z",
+ "iopub.status.busy": "2026-07-12T03:29:01.977902Z",
+ "iopub.status.idle": "2026-07-12T03:29:12.474059Z",
+ "shell.execute_reply": "2026-07-12T03:29:12.472675Z"
+ }
+ },
+ "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)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8d5d61e0",
+ "metadata": {},
+ "source": [
+ "## The data\n",
+ "\n",
+ "The XYZ Insurer Auto BI reported and paid triangles run from accident year 1998\n",
+ "to 2008. Their most recent diagonal (12/31/2008) is the actual claims the BF\n",
+ "method builds on."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "2cd269eb",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-12T03:29:12.482109Z",
+ "iopub.status.busy": "2026-07-12T03:29:12.479795Z",
+ "iopub.status.idle": "2026-07-12T03:29:12.759383Z",
+ "shell.execute_reply": "2026-07-12T03:29:12.757240Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " Reported | \n",
+ " Paid | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 1998 | \n",
+ " 15822.0 | \n",
+ " 15822.0 | \n",
+ "
\n",
+ " \n",
+ " | 1999 | \n",
+ " 25107.0 | \n",
+ " 24817.0 | \n",
+ "
\n",
+ " \n",
+ " | 2000 | \n",
+ " 37246.0 | \n",
+ " 36782.0 | \n",
+ "
\n",
+ " \n",
+ " | 2001 | \n",
+ " 38798.0 | \n",
+ " 38519.0 | \n",
+ "
\n",
+ " \n",
+ " | 2002 | \n",
+ " 48169.0 | \n",
+ " 44437.0 | \n",
+ "
\n",
+ " \n",
+ " | 2003 | \n",
+ " 44373.0 | \n",
+ " 39320.0 | \n",
+ "
\n",
+ " \n",
+ " | 2004 | \n",
+ " 70288.0 | \n",
+ " 52811.0 | \n",
+ "
\n",
+ " \n",
+ " | 2005 | \n",
+ " 70655.0 | \n",
+ " 40026.0 | \n",
+ "
\n",
+ " \n",
+ " | 2006 | \n",
+ " 48804.0 | \n",
+ " 22819.0 | \n",
+ "
\n",
+ " \n",
+ " | 2007 | \n",
+ " 31732.0 | \n",
+ " 11865.0 | \n",
+ "
\n",
+ " \n",
+ " | 2008 | \n",
+ " 18632.0 | \n",
+ " 3409.0 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " Reported Paid\n",
+ "1998 15822.0 15822.0\n",
+ "1999 25107.0 24817.0\n",
+ "2000 37246.0 36782.0\n",
+ "2001 38798.0 38519.0\n",
+ "2002 48169.0 44437.0\n",
+ "2003 44373.0 39320.0\n",
+ "2004 70288.0 52811.0\n",
+ "2005 70655.0 40026.0\n",
+ "2006 48804.0 22819.0\n",
+ "2007 31732.0 11865.0\n",
+ "2008 18632.0 3409.0"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "tri = cl.load_sample(\"friedland_xyz_auto_bi\")\n",
+ "reported = tri[\"Reported Claims\"]\n",
+ "paid = tri[\"Paid Claims\"]\n",
+ "years = list(reported.origin.year)\n",
+ "\n",
+ "col = lambda t: t.to_frame(origin_as_datetime=False).iloc[:, 0].values\n",
+ "reported_latest = col(reported.latest_diagonal)\n",
+ "paid_latest = col(paid.latest_diagonal)\n",
+ "\n",
+ "claims = pd.DataFrame(index=years)\n",
+ "claims[\"Reported\"] = reported_latest\n",
+ "claims[\"Paid\"] = paid_latest\n",
+ "display(claims)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e58c06e4",
+ "metadata": {},
+ "source": [
+ "## Development patterns\n",
+ "\n",
+ "The BF method reuses the development pattern selected for XYZ in Chapter 7: a\n",
+ "volume-weighted two-period average with a 1.000 reported tail and a 1.010 paid\n",
+ "tail. Following Friedland, the age-to-age factors are rounded to three decimals\n",
+ "before being cumulated to CDFs. The reported CDFs for the oldest accident years\n",
+ "fall just below 1.0, so they are capped at 1.0 (this avoids negative implied\n",
+ "unreported percentages; Friedland notes the cap is not strictly required)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "0c81b46d",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-12T03:29:12.770896Z",
+ "iopub.status.busy": "2026-07-12T03:29:12.770091Z",
+ "iopub.status.idle": "2026-07-12T03:29:13.309797Z",
+ "shell.execute_reply": "2026-07-12T03:29:13.307366Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " CDF Reported | \n",
+ " CDF Paid | \n",
+ " % Unreported | \n",
+ " % Unpaid | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 1998 | \n",
+ " 1.000 | \n",
+ " 1.010 | \n",
+ " 0.000 | \n",
+ " 0.010 | \n",
+ "
\n",
+ " \n",
+ " | 1999 | \n",
+ " 1.000 | \n",
+ " 1.014 | \n",
+ " 0.000 | \n",
+ " 0.014 | \n",
+ "
\n",
+ " \n",
+ " | 2000 | \n",
+ " 1.000 | \n",
+ " 1.031 | \n",
+ " 0.000 | \n",
+ " 0.030 | \n",
+ "
\n",
+ " \n",
+ " | 2001 | \n",
+ " 1.000 | \n",
+ " 1.054 | \n",
+ " 0.000 | \n",
+ " 0.051 | \n",
+ "
\n",
+ " \n",
+ " | 2002 | \n",
+ " 1.003 | \n",
+ " 1.116 | \n",
+ " 0.003 | \n",
+ " 0.104 | \n",
+ "
\n",
+ " \n",
+ " | 2003 | \n",
+ " 1.013 | \n",
+ " 1.268 | \n",
+ " 0.013 | \n",
+ " 0.211 | \n",
+ "
\n",
+ " \n",
+ " | 2004 | \n",
+ " 1.064 | \n",
+ " 1.525 | \n",
+ " 0.060 | \n",
+ " 0.344 | \n",
+ "
\n",
+ " \n",
+ " | 2005 | \n",
+ " 1.085 | \n",
+ " 2.007 | \n",
+ " 0.078 | \n",
+ " 0.502 | \n",
+ "
\n",
+ " \n",
+ " | 2006 | \n",
+ " 1.196 | \n",
+ " 3.160 | \n",
+ " 0.164 | \n",
+ " 0.684 | \n",
+ "
\n",
+ " \n",
+ " | 2007 | \n",
+ " 1.512 | \n",
+ " 6.569 | \n",
+ " 0.339 | \n",
+ " 0.848 | \n",
+ "
\n",
+ " \n",
+ " | 2008 | \n",
+ " 2.551 | \n",
+ " 21.999 | \n",
+ " 0.608 | \n",
+ " 0.955 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " CDF Reported CDF Paid % Unreported % Unpaid\n",
+ "1998 1.000 1.010 0.000 0.010\n",
+ "1999 1.000 1.014 0.000 0.014\n",
+ "2000 1.000 1.031 0.000 0.030\n",
+ "2001 1.000 1.054 0.000 0.051\n",
+ "2002 1.003 1.116 0.003 0.104\n",
+ "2003 1.013 1.268 0.013 0.211\n",
+ "2004 1.064 1.525 0.060 0.344\n",
+ "2005 1.085 2.007 0.078 0.502\n",
+ "2006 1.196 3.160 0.164 0.684\n",
+ "2007 1.512 6.569 0.339 0.848\n",
+ "2008 2.551 21.999 0.608 0.955"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "reported_dev = cl.TailConstant(tail=1.00, projection_period=0).fit_transform(\n",
+ " cl.Development(n_periods=2, average=\"volume\").fit_transform(reported))\n",
+ "paid_dev = cl.TailConstant(tail=1.01, projection_period=0).fit_transform(\n",
+ " cl.Development(n_periods=2, average=\"volume\").fit_transform(paid))\n",
+ "\n",
+ "# Friedland cumulates CDFs from age-to-age factors rounded to three decimals.\n",
+ "reported_dev.ldf_ = reported_dev.ldf_.round(3)\n",
+ "paid_dev.ldf_ = paid_dev.ldf_.round(3)\n",
+ "\n",
+ "# CDF to ultimate per accident year (oldest origin -> highest maturity).\n",
+ "reported_cdf = np.maximum(reported_dev.cdf_.to_frame(origin_as_datetime=False).values.flatten()[::-1], 1.0)\n",
+ "paid_cdf = paid_dev.cdf_.to_frame(origin_as_datetime=False).values.flatten()[::-1]\n",
+ "pct_unreported = 1 - 1 / reported_cdf\n",
+ "pct_unpaid = 1 - 1 / paid_cdf\n",
+ "\n",
+ "patterns = pd.DataFrame(index=years)\n",
+ "patterns[\"CDF Reported\"] = reported_cdf.round(3)\n",
+ "patterns[\"CDF Paid\"] = paid_cdf.round(3)\n",
+ "patterns[\"% Unreported\"] = pct_unreported.round(3)\n",
+ "patterns[\"% Unpaid\"] = pct_unpaid.round(3)\n",
+ "display(patterns)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f7e34483",
+ "metadata": {},
+ "source": [
+ "## Expected claims (a priori)\n",
+ "\n",
+ "The a priori expected claims come from the expected claims technique (Chapter 8):\n",
+ "earned premium multiplied by a selected claim ratio. The earned premium is now\n",
+ "carried in the `friedland_xyz_auto_bi` sample and read directly from its latest\n",
+ "diagonal. The expected claims feed the BF method as the `sample_weight`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "0aa8a19c",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-12T03:29:13.315701Z",
+ "iopub.status.busy": "2026-07-12T03:29:13.314642Z",
+ "iopub.status.idle": "2026-07-12T03:29:13.372866Z",
+ "shell.execute_reply": "2026-07-12T03:29:13.364671Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " Earned Premium | \n",
+ " Claim Ratio | \n",
+ " Expected Claims | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 1998 | \n",
+ " 20000.0 | \n",
+ " 0.784 | \n",
+ " 15670 | \n",
+ "
\n",
+ " \n",
+ " | 1999 | \n",
+ " 31500.0 | \n",
+ " 0.783 | \n",
+ " 24680 | \n",
+ "
\n",
+ " \n",
+ " | 2000 | \n",
+ " 45000.0 | \n",
+ " 0.783 | \n",
+ " 35256 | \n",
+ "
\n",
+ " \n",
+ " | 2001 | \n",
+ " 50000.0 | \n",
+ " 0.783 | \n",
+ " 39174 | \n",
+ "
\n",
+ " \n",
+ " | 2002 | \n",
+ " 61183.0 | \n",
+ " 0.783 | \n",
+ " 47935 | \n",
+ "
\n",
+ " \n",
+ " | 2003 | \n",
+ " 69175.0 | \n",
+ " 0.783 | \n",
+ " 54197 | \n",
+ "
\n",
+ " \n",
+ " | 2004 | \n",
+ " 99322.0 | \n",
+ " 0.871 | \n",
+ " 86528 | \n",
+ "
\n",
+ " \n",
+ " | 2005 | \n",
+ " 138151.0 | \n",
+ " 0.783 | \n",
+ " 108241 | \n",
+ "
\n",
+ " \n",
+ " | 2006 | \n",
+ " 107578.0 | \n",
+ " 0.658 | \n",
+ " 70769 | \n",
+ "
\n",
+ " \n",
+ " | 2007 | \n",
+ " 62438.0 | \n",
+ " 0.638 | \n",
+ " 39841 | \n",
+ "
\n",
+ " \n",
+ " | 2008 | \n",
+ " 47797.0 | \n",
+ " 0.825 | \n",
+ " 39429 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " Earned Premium Claim Ratio Expected Claims\n",
+ "1998 20000.0 0.784 15670\n",
+ "1999 31500.0 0.783 24680\n",
+ "2000 45000.0 0.783 35256\n",
+ "2001 50000.0 0.783 39174\n",
+ "2002 61183.0 0.783 47935\n",
+ "2003 69175.0 0.783 54197\n",
+ "2004 99322.0 0.871 86528\n",
+ "2005 138151.0 0.783 108241\n",
+ "2006 107578.0 0.658 70769\n",
+ "2007 62438.0 0.638 39841\n",
+ "2008 47797.0 0.825 39429"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Earned premium is carried in the friedland_xyz_auto_bi sample ($000).\n",
+ "earned_premium = col(tri[\"Earned Premium\"].latest_diagonal)\n",
+ "\n",
+ "# A priori expected claims from the expected claims technique (Chapter 8, $000).\n",
+ "expected_claims = [15670, 24680, 35256, 39174, 47935, 54197, 86528, 108241, 70769, 39841, 39429]\n",
+ "\n",
+ "\n",
+ "def as_diagonal(tri, vec):\n",
+ " \"\"\"Build a per-origin (latest-diagonal) triangle from a vector of values.\"\"\"\n",
+ " d = tri.latest_diagonal.copy()\n",
+ " d.values = d.values * 0 + np.array(vec, dtype=float).reshape(d.shape)\n",
+ " return d\n",
+ "\n",
+ "\n",
+ "apriori = as_diagonal(reported, expected_claims)\n",
+ "\n",
+ "priori = pd.DataFrame(index=years)\n",
+ "priori[\"Earned Premium\"] = earned_premium\n",
+ "priori[\"Claim Ratio\"] = (np.array(expected_claims) / np.array(earned_premium)).round(3)\n",
+ "priori[\"Expected Claims\"] = expected_claims\n",
+ "display(priori)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c683f4d9",
+ "metadata": {},
+ "source": [
+ "## Projection of ultimate claims\n",
+ "\n",
+ "Applying `BornhuetterFerguson` on both the reported and paid bases produces the\n",
+ "projected ultimate claims. The reported IBNR is floored at zero for the capped\n",
+ "accident years. This recreates the *Ultimate Claims Projection* exhibit."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "a758ad45",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-12T03:29:13.378914Z",
+ "iopub.status.busy": "2026-07-12T03:29:13.378145Z",
+ "iopub.status.idle": "2026-07-12T03:29:13.924391Z",
+ "shell.execute_reply": "2026-07-12T03:29:13.920829Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " Reported | \n",
+ " Paid | \n",
+ " CDF Reported | \n",
+ " CDF Paid | \n",
+ " % Unreported | \n",
+ " % Unpaid | \n",
+ " Earned Premium | \n",
+ " Expected Claims | \n",
+ " BF Ultimate (Reported) | \n",
+ " BF Ultimate (Paid) | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 1998 | \n",
+ " 15822.0 | \n",
+ " 15822.0 | \n",
+ " 1.000 | \n",
+ " 1.010 | \n",
+ " 0.000 | \n",
+ " 0.010 | \n",
+ " 20000.0 | \n",
+ " 15670 | \n",
+ " 15822.0 | \n",
+ " 15977.0 | \n",
+ "
\n",
+ " \n",
+ " | 1999 | \n",
+ " 25107.0 | \n",
+ " 24817.0 | \n",
+ " 1.000 | \n",
+ " 1.014 | \n",
+ " 0.000 | \n",
+ " 0.014 | \n",
+ " 31500.0 | \n",
+ " 24680 | \n",
+ " 25107.0 | \n",
+ " 25159.0 | \n",
+ "
\n",
+ " \n",
+ " | 2000 | \n",
+ " 37246.0 | \n",
+ " 36782.0 | \n",
+ " 1.000 | \n",
+ " 1.031 | \n",
+ " 0.000 | \n",
+ " 0.030 | \n",
+ " 45000.0 | \n",
+ " 35256 | \n",
+ " 37246.0 | \n",
+ " 37851.0 | \n",
+ "
\n",
+ " \n",
+ " | 2001 | \n",
+ " 38798.0 | \n",
+ " 38519.0 | \n",
+ " 1.000 | \n",
+ " 1.054 | \n",
+ " 0.000 | \n",
+ " 0.051 | \n",
+ " 50000.0 | \n",
+ " 39174 | \n",
+ " 38798.0 | \n",
+ " 40525.0 | \n",
+ "
\n",
+ " \n",
+ " | 2002 | \n",
+ " 48169.0 | \n",
+ " 44437.0 | \n",
+ " 1.003 | \n",
+ " 1.116 | \n",
+ " 0.003 | \n",
+ " 0.104 | \n",
+ " 61183.0 | \n",
+ " 47935 | \n",
+ " 48309.0 | \n",
+ " 49425.0 | \n",
+ "
\n",
+ " \n",
+ " | 2003 | \n",
+ " 44373.0 | \n",
+ " 39320.0 | \n",
+ " 1.013 | \n",
+ " 1.268 | \n",
+ " 0.013 | \n",
+ " 0.211 | \n",
+ " 69175.0 | \n",
+ " 54197 | \n",
+ " 45066.0 | \n",
+ " 50773.0 | \n",
+ "
\n",
+ " \n",
+ " | 2004 | \n",
+ " 70288.0 | \n",
+ " 52811.0 | \n",
+ " 1.064 | \n",
+ " 1.525 | \n",
+ " 0.060 | \n",
+ " 0.344 | \n",
+ " 99322.0 | \n",
+ " 86528 | \n",
+ " 75462.0 | \n",
+ " 82612.0 | \n",
+ "
\n",
+ " \n",
+ " | 2005 | \n",
+ " 70655.0 | \n",
+ " 40026.0 | \n",
+ " 1.085 | \n",
+ " 2.007 | \n",
+ " 0.078 | \n",
+ " 0.502 | \n",
+ " 138151.0 | \n",
+ " 108241 | \n",
+ " 79123.0 | \n",
+ " 94345.0 | \n",
+ "
\n",
+ " \n",
+ " | 2006 | \n",
+ " 48804.0 | \n",
+ " 22819.0 | \n",
+ " 1.196 | \n",
+ " 3.160 | \n",
+ " 0.164 | \n",
+ " 0.684 | \n",
+ " 107578.0 | \n",
+ " 70769 | \n",
+ " 60378.0 | \n",
+ " 71190.0 | \n",
+ "
\n",
+ " \n",
+ " | 2007 | \n",
+ " 31732.0 | \n",
+ " 11865.0 | \n",
+ " 1.512 | \n",
+ " 6.569 | \n",
+ " 0.339 | \n",
+ " 0.848 | \n",
+ " 62438.0 | \n",
+ " 39841 | \n",
+ " 45229.0 | \n",
+ " 45641.0 | \n",
+ "
\n",
+ " \n",
+ " | 2008 | \n",
+ " 18632.0 | \n",
+ " 3409.0 | \n",
+ " 2.551 | \n",
+ " 21.999 | \n",
+ " 0.608 | \n",
+ " 0.955 | \n",
+ " 47797.0 | \n",
+ " 39429 | \n",
+ " 42607.0 | \n",
+ " 41046.0 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " Reported Paid CDF Reported CDF Paid % Unreported % Unpaid Earned Premium Expected Claims BF Ultimate (Reported) BF Ultimate (Paid)\n",
+ "1998 15822.0 15822.0 1.000 1.010 0.000 0.010 20000.0 15670 15822.0 15977.0\n",
+ "1999 25107.0 24817.0 1.000 1.014 0.000 0.014 31500.0 24680 25107.0 25159.0\n",
+ "2000 37246.0 36782.0 1.000 1.031 0.000 0.030 45000.0 35256 37246.0 37851.0\n",
+ "2001 38798.0 38519.0 1.000 1.054 0.000 0.051 50000.0 39174 38798.0 40525.0\n",
+ "2002 48169.0 44437.0 1.003 1.116 0.003 0.104 61183.0 47935 48309.0 49425.0\n",
+ "2003 44373.0 39320.0 1.013 1.268 0.013 0.211 69175.0 54197 45066.0 50773.0\n",
+ "2004 70288.0 52811.0 1.064 1.525 0.060 0.344 99322.0 86528 75462.0 82612.0\n",
+ "2005 70655.0 40026.0 1.085 2.007 0.078 0.502 138151.0 108241 79123.0 94345.0\n",
+ "2006 48804.0 22819.0 1.196 3.160 0.164 0.684 107578.0 70769 60378.0 71190.0\n",
+ "2007 31732.0 11865.0 1.512 6.569 0.339 0.848 62438.0 39841 45229.0 45641.0\n",
+ "2008 18632.0 3409.0 2.551 21.999 0.608 0.955 47797.0 39429 42607.0 41046.0"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "bf_reported = cl.BornhuetterFerguson(apriori=1.0).fit(reported_dev, sample_weight=apriori)\n",
+ "bf_paid = cl.BornhuetterFerguson(apriori=1.0).fit(paid_dev, sample_weight=apriori)\n",
+ "\n",
+ "reported_ibnr = np.nan_to_num(np.maximum(col(bf_reported.ibnr_), 0.0))\n",
+ "reported_ult = reported_latest + reported_ibnr\n",
+ "paid_ult = col(bf_paid.ultimate_)\n",
+ "\n",
+ "projection = pd.DataFrame(index=years)\n",
+ "projection[\"Reported\"] = reported_latest\n",
+ "projection[\"Paid\"] = paid_latest\n",
+ "projection[\"CDF Reported\"] = reported_cdf.round(3)\n",
+ "projection[\"CDF Paid\"] = paid_cdf.round(3)\n",
+ "projection[\"% Unreported\"] = pct_unreported.round(3)\n",
+ "projection[\"% Unpaid\"] = pct_unpaid.round(3)\n",
+ "projection[\"Earned Premium\"] = earned_premium\n",
+ "projection[\"Expected Claims\"] = expected_claims\n",
+ "projection[\"BF Ultimate (Reported)\"] = reported_ult.round(0)\n",
+ "projection[\"BF Ultimate (Paid)\"] = paid_ult.round(0)\n",
+ "display(projection)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "db8655a7",
+ "metadata": {},
+ "source": [
+ "## Development of unpaid claim estimate\n",
+ "\n",
+ "From the projected ultimates, the IBNR and total unpaid estimates follow by\n",
+ "simple differences: IBNR is ultimate minus reported claims, and total unpaid is\n",
+ "ultimate minus paid claims. This recreates the *Unpaid Claims* exhibit."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "7ced2979",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-12T03:29:13.932277Z",
+ "iopub.status.busy": "2026-07-12T03:29:13.931532Z",
+ "iopub.status.idle": "2026-07-12T03:29:13.992950Z",
+ "shell.execute_reply": "2026-07-12T03:29:13.989459Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " BF Ultimate (Reported) | \n",
+ " BF Ultimate (Paid) | \n",
+ " Case Outstanding | \n",
+ " IBNR (Reported) | \n",
+ " IBNR (Paid) | \n",
+ " Total Unpaid (Reported) | \n",
+ " Total Unpaid (Paid) | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 1998 | \n",
+ " 15822.0 | \n",
+ " 15977.0 | \n",
+ " 0.0 | \n",
+ " 0.0 | \n",
+ " 155.0 | \n",
+ " 0.0 | \n",
+ " 155.0 | \n",
+ "
\n",
+ " \n",
+ " | 1999 | \n",
+ " 25107.0 | \n",
+ " 25159.0 | \n",
+ " 290.0 | \n",
+ " 0.0 | \n",
+ " 52.0 | \n",
+ " 290.0 | \n",
+ " 342.0 | \n",
+ "
\n",
+ " \n",
+ " | 2000 | \n",
+ " 37246.0 | \n",
+ " 37851.0 | \n",
+ " 464.0 | \n",
+ " 0.0 | \n",
+ " 605.0 | \n",
+ " 464.0 | \n",
+ " 1069.0 | \n",
+ "
\n",
+ " \n",
+ " | 2001 | \n",
+ " 38798.0 | \n",
+ " 40525.0 | \n",
+ " 279.0 | \n",
+ " 0.0 | \n",
+ " 1727.0 | \n",
+ " 279.0 | \n",
+ " 2006.0 | \n",
+ "
\n",
+ " \n",
+ " | 2002 | \n",
+ " 48309.0 | \n",
+ " 49425.0 | \n",
+ " 3732.0 | \n",
+ " 140.0 | \n",
+ " 1256.0 | \n",
+ " 3872.0 | \n",
+ " 4988.0 | \n",
+ "
\n",
+ " \n",
+ " | 2003 | \n",
+ " 45066.0 | \n",
+ " 50773.0 | \n",
+ " 5053.0 | \n",
+ " 693.0 | \n",
+ " 6400.0 | \n",
+ " 5746.0 | \n",
+ " 11453.0 | \n",
+ "
\n",
+ " \n",
+ " | 2004 | \n",
+ " 75462.0 | \n",
+ " 82612.0 | \n",
+ " 17477.0 | \n",
+ " 5174.0 | \n",
+ " 12324.0 | \n",
+ " 22651.0 | \n",
+ " 29801.0 | \n",
+ "
\n",
+ " \n",
+ " | 2005 | \n",
+ " 79123.0 | \n",
+ " 94345.0 | \n",
+ " 30629.0 | \n",
+ " 8468.0 | \n",
+ " 23690.0 | \n",
+ " 39097.0 | \n",
+ " 54319.0 | \n",
+ "
\n",
+ " \n",
+ " | 2006 | \n",
+ " 60378.0 | \n",
+ " 71190.0 | \n",
+ " 25985.0 | \n",
+ " 11574.0 | \n",
+ " 22386.0 | \n",
+ " 37559.0 | \n",
+ " 48371.0 | \n",
+ "
\n",
+ " \n",
+ " | 2007 | \n",
+ " 45229.0 | \n",
+ " 45641.0 | \n",
+ " 19867.0 | \n",
+ " 13497.0 | \n",
+ " 13909.0 | \n",
+ " 33364.0 | \n",
+ " 33776.0 | \n",
+ "
\n",
+ " \n",
+ " | 2008 | \n",
+ " 42607.0 | \n",
+ " 41046.0 | \n",
+ " 15223.0 | \n",
+ " 23975.0 | \n",
+ " 22414.0 | \n",
+ " 39198.0 | \n",
+ " 37637.0 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " BF Ultimate (Reported) BF Ultimate (Paid) Case Outstanding IBNR (Reported) IBNR (Paid) Total Unpaid (Reported) Total Unpaid (Paid)\n",
+ "1998 15822.0 15977.0 0.0 0.0 155.0 0.0 155.0\n",
+ "1999 25107.0 25159.0 290.0 0.0 52.0 290.0 342.0\n",
+ "2000 37246.0 37851.0 464.0 0.0 605.0 464.0 1069.0\n",
+ "2001 38798.0 40525.0 279.0 0.0 1727.0 279.0 2006.0\n",
+ "2002 48309.0 49425.0 3732.0 140.0 1256.0 3872.0 4988.0\n",
+ "2003 45066.0 50773.0 5053.0 693.0 6400.0 5746.0 11453.0\n",
+ "2004 75462.0 82612.0 17477.0 5174.0 12324.0 22651.0 29801.0\n",
+ "2005 79123.0 94345.0 30629.0 8468.0 23690.0 39097.0 54319.0\n",
+ "2006 60378.0 71190.0 25985.0 11574.0 22386.0 37559.0 48371.0\n",
+ "2007 45229.0 45641.0 19867.0 13497.0 13909.0 33364.0 33776.0\n",
+ "2008 42607.0 41046.0 15223.0 23975.0 22414.0 39198.0 37637.0"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "unpaid = pd.DataFrame(index=years)\n",
+ "unpaid[\"BF Ultimate (Reported)\"] = reported_ult.round(0)\n",
+ "unpaid[\"BF Ultimate (Paid)\"] = paid_ult.round(0)\n",
+ "unpaid[\"Case Outstanding\"] = (reported_latest - paid_latest).round(0)\n",
+ "unpaid[\"IBNR (Reported)\"] = (reported_ult - reported_latest).round(0)\n",
+ "unpaid[\"IBNR (Paid)\"] = (paid_ult - reported_latest).round(0)\n",
+ "unpaid[\"Total Unpaid (Reported)\"] = (reported_ult - paid_latest).round(0)\n",
+ "unpaid[\"Total Unpaid (Paid)\"] = (paid_ult - paid_latest).round(0)\n",
+ "display(unpaid)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b135b0ef",
+ "metadata": {},
+ "source": [
+ "## Reconciliation to Friedland\n",
+ "\n",
+ "The selected CDFs, the projected ultimate claims, and the IBNR estimates are\n",
+ "reconciled to the printed Chapter 9 exhibit below (values in $000)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "7ee5e130",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-12T03:29:13.999515Z",
+ "iopub.status.busy": "2026-07-12T03:29:13.998754Z",
+ "iopub.status.idle": "2026-07-12T03:29:14.015590Z",
+ "shell.execute_reply": "2026-07-12T03:29:14.014244Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "# Selected CDFs to ultimate\n",
+ "assert np.allclose(reported_cdf.round(3),\n",
+ " [1.000, 1.000, 1.000, 1.000, 1.003, 1.013, 1.064, 1.085, 1.196, 1.512, 2.551], atol=1e-3)\n",
+ "assert np.allclose(paid_cdf.round(3),\n",
+ " [1.010, 1.014, 1.031, 1.054, 1.116, 1.268, 1.525, 2.007, 3.160, 6.569, 21.999], atol=1e-3)\n",
+ "# Projected ultimate claims\n",
+ "assert np.allclose(reported_ult,\n",
+ " [15822, 25107, 37246, 38798, 48309, 45066, 75462, 79123, 60378, 45229, 42607], atol=1)\n",
+ "assert np.allclose(paid_ult,\n",
+ " [15977, 25159, 37851, 40525, 49425, 50773, 82612, 94345, 71190, 45641, 41046], atol=1)\n",
+ "# IBNR (ultimate minus reported)\n",
+ "assert np.allclose(unpaid[\"IBNR (Reported)\"].values,\n",
+ " [0, 0, 0, 0, 140, 693, 5174, 8468, 11574, 13497, 23975], atol=1)\n",
+ "assert np.allclose(unpaid[\"IBNR (Paid)\"].values,\n",
+ " [155, 52, 605, 1727, 1256, 6400, 12324, 23690, 22386, 13909, 22414], atol=1)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "chainladder",
+ "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.13.5"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}