Chapter 9 - Bornhuetter-Ferguson Technique#
The Bornhuetter-Ferguson technique is essentially a blend of the development technique and the expected claims technique.
– Friedland, Chapter 9
The Bornhuetter-Ferguson (BF) method splits ultimate claims into the claims already reported (or paid) plus the expected unreported (or unpaid) claims. The expected piece is an a priori estimate of ultimate claims (from the expected claims technique of Chapter 8), scaled by the percentage still to emerge that is implied by the development pattern:
In the chainladder package the method is implemented by
BornhuetterFerguson, which takes the a priori through sample_weight. This
chapter recreates the Friedland Chapter 9 exhibits, reusing the development
patterns selected in Chapter 7 and the expected claims from Chapter 8:
Exhibit I — U.S. Industry Auto
Exhibit II — XYZ Insurer (Auto BI)
Exhibit III — U.S. PP Auto (impact of changing conditions)
import numpy as np
import pandas as pd
import chainladder as cl
import os
from IPython.display import display
pd.set_option("display.max_columns", None)
pd.set_option("display.width", 1000)
Exhibit I — U.S. Industry Auto#
The text works the Bornhuetter-Ferguson method first for U.S. Industry Auto (Exhibit I), valued at 12/31/2007 over accident years 1998-2007. The reporting and payment patterns are the Chapter 7 selection (three-year simple average with a 1.000 reported / 1.002 paid tail), and the a priori expected claims come from the expected claims technique of Chapter 8.
Projection of ultimate claims#
This recreates Exhibit I, Sheet 1. Because the text cumulates and rounds the
selected CDFs to three decimals, we drive BornhuetterFerguson with those
rounded patterns via DevelopmentConstant so the projection reconciles exactly.
ia = cl.load_sample("friedland_us_industry_auto")
ia_reported = ia["Reported Claims"]
ia_paid = ia["Paid Claims"]
ia_years = list(ia_reported.origin.year)
# Chapter 7 selection: three-year simple average development with a constant
# tail. Friedland rounds the age-to-age factors to three decimals before
# cumulating them into CDFs.
ia_reported_dev = cl.TailConstant(tail=1.000, projection_period=0).fit_transform(
cl.Development(n_periods=3, average="simple").fit_transform(ia_reported))
ia_paid_dev = cl.TailConstant(tail=1.002, projection_period=0).fit_transform(
cl.Development(n_periods=3, average="simple").fit_transform(ia_paid))
ia_reported_dev.ldf_ = ia_reported_dev.ldf_.round(3)
ia_paid_dev.ldf_ = ia_paid_dev.ldf_.round(3)
# A priori expected claims from the expected claims technique (Chapter 8, $000).
ia_expected = np.array([51430657, 51408736, 51680983, 54408716, 59421665,
56318302, 59646290, 61174953, 61926981, 61864556], dtype=float)
ia_apriori = ia_reported.latest_diagonal.copy()
ia_apriori.iloc[0, 0] = ia_expected.reshape(ia_apriori.shape)
ia_bf_reported = cl.BornhuetterFerguson(apriori=1.0).fit(ia_reported_dev, sample_weight=ia_apriori)
ia_bf_paid = cl.BornhuetterFerguson(apriori=1.0).fit(ia_paid_dev, sample_weight=ia_apriori)
# model_diagnostics summarises Latest, CDF, Ultimate, and IBNR per accident year.
ia_rep = cl.model_diagnostics(ia_bf_reported).to_frame(origin_as_datetime=False).T
ia_pd = cl.model_diagnostics(ia_bf_paid).to_frame(origin_as_datetime=False).T
ia_projection = pd.DataFrame(index=ia_years)
ia_projection["Expected Claims"] = ia_expected
# Derive the percentages from the rounded CDFs so the displayed CDF and
# percentage columns are internally consistent (as in the text).
ia_cdf_reported = ia_rep["CDF"].round(3)
ia_cdf_paid = ia_pd["CDF"].round(3)
ia_projection["CDF Reported"] = ia_cdf_reported.values
ia_projection["CDF Paid"] = ia_cdf_paid.values
ia_projection["% Unreported"] = (1 - 1 / ia_cdf_reported).round(3).values
ia_projection["% Unpaid"] = (1 - 1 / ia_cdf_paid).round(3).values
ia_projection["Reported"] = ia_rep["Latest"].values
ia_projection["Paid"] = ia_pd["Latest"].values
ia_projection["BF Ultimate (Reported)"] = ia_rep["Ultimate"].round(0).values
ia_projection["BF Ultimate (Paid)"] = ia_pd["Ultimate"].round(0).values
display(ia_projection)
| Expected Claims | CDF Reported | CDF Paid | % Unreported | % Unpaid | Reported | Paid | BF Ultimate (Reported) | BF Ultimate (Paid) | |
|---|---|---|---|---|---|---|---|---|---|
| 1998 | 51430657.0 | 1.000 | 1.002 | 0.000 | 0.002 | 47742304.0 | 47644187.0 | 47742304.0 | 47746843.0 |
| 1999 | 51408736.0 | 1.000 | 1.004 | 0.000 | 0.004 | 51185767.0 | 51000534.0 | 51185767.0 | 51205554.0 |
| 2000 | 51680983.0 | 1.001 | 1.006 | 0.001 | 0.006 | 54837929.0 | 54533225.0 | 54889558.0 | 54842075.0 |
| 2001 | 54408716.0 | 1.003 | 1.011 | 0.003 | 0.011 | 56299562.0 | 55878421.0 | 56462408.0 | 56472644.0 |
| 2002 | 59421665.0 | 1.006 | 1.020 | 0.006 | 0.020 | 58592712.0 | 57807215.0 | 58947762.0 | 58980423.0 |
| 2003 | 56318302.0 | 1.011 | 1.040 | 0.011 | 0.038 | 57565344.0 | 55930654.0 | 58180367.0 | 58071953.0 |
| 2004 | 59646290.0 | 1.023 | 1.085 | 0.022 | 0.078 | 56976657.0 | 53774672.0 | 58327568.0 | 58460755.0 |
| 2005 | 61174953.0 | 1.051 | 1.184 | 0.049 | 0.155 | 56786410.0 | 50644994.0 | 59743817.0 | 60152879.0 |
| 2006 | 61926981.0 | 1.110 | 1.404 | 0.099 | 0.288 | 54641339.0 | 43606497.0 | 60760348.0 | 61433803.0 |
| 2007 | 61864556.0 | 1.292 | 2.390 | 0.226 | 0.582 | 48853563.0 | 27229969.0 | 62821457.0 | 63210141.0 |
Development of unpaid claim estimate#
This recreates Exhibit I, Sheet 2: case outstanding, estimated IBNR, and total unpaid follow from the projected ultimates. Following the text, IBNR is ultimate minus reported claims and total unpaid is ultimate minus paid claims.
ia_unpaid = pd.DataFrame(index=ia_years)
ia_unpaid["BF Ultimate (Reported)"] = ia_rep["Ultimate"].round(0).values
ia_unpaid["BF Ultimate (Paid)"] = ia_pd["Ultimate"].round(0).values
ia_unpaid["Case Outstanding"] = (ia_rep["Latest"] - ia_pd["Latest"]).round(0).values
ia_unpaid["IBNR (Reported)"] = ia_rep["IBNR"].round(0).values
ia_unpaid["IBNR (Paid)"] = (ia_pd["Ultimate"] - ia_rep["Latest"]).round(0).values
ia_unpaid["Total Unpaid (Reported)"] = (ia_rep["Ultimate"] - ia_pd["Latest"]).round(0).values
ia_unpaid["Total Unpaid (Paid)"] = ia_pd["IBNR"].round(0).values
display(ia_unpaid)
| BF Ultimate (Reported) | BF Ultimate (Paid) | Case Outstanding | IBNR (Reported) | IBNR (Paid) | Total Unpaid (Reported) | Total Unpaid (Paid) | |
|---|---|---|---|---|---|---|---|
| 1998 | 47742304.0 | 47746843.0 | 98117.0 | 0.0 | 4539.0 | 98117.0 | 102656.0 |
| 1999 | 51185767.0 | 51205554.0 | 185233.0 | 0.0 | 19787.0 | 185233.0 | 205020.0 |
| 2000 | 54889558.0 | 54842075.0 | 304704.0 | 51629.0 | 4146.0 | 356333.0 | 308850.0 |
| 2001 | 56462408.0 | 56472644.0 | 421141.0 | 162846.0 | 173082.0 | 583987.0 | 594223.0 |
| 2002 | 58947762.0 | 58980423.0 | 785497.0 | 355050.0 | 387711.0 | 1140547.0 | 1173208.0 |
| 2003 | 58180367.0 | 58071953.0 | 1634690.0 | 615023.0 | 506609.0 | 2249713.0 | 2141299.0 |
| 2004 | 58327568.0 | 58460755.0 | 3201985.0 | 1350911.0 | 1484098.0 | 4552896.0 | 4686083.0 |
| 2005 | 59743817.0 | 60152879.0 | 6141416.0 | 2957407.0 | 3366469.0 | 9098823.0 | 9507885.0 |
| 2006 | 60760348.0 | 61433803.0 | 11034842.0 | 6119009.0 | 6792464.0 | 17153851.0 | 17827306.0 |
| 2007 | 62821457.0 | 63210141.0 | 21623594.0 | 13967894.0 | 14356578.0 | 35591488.0 | 35980172.0 |
Reconciliation to Friedland#
The selected CDFs, projected ultimates, and estimated IBNR are reconciled to the printed Exhibit I below.
# Exhibit I, Sheet 1 - selected CDFs to ultimate
assert np.allclose(ia_projection["CDF Reported"].values,
[1.000, 1.000, 1.001, 1.003, 1.006, 1.011, 1.023, 1.051, 1.110, 1.292], atol=1e-3)
assert np.allclose(ia_projection["CDF Paid"].values,
[1.002, 1.004, 1.006, 1.011, 1.020, 1.040, 1.085, 1.184, 1.404, 2.390], atol=1e-3)
# Exhibit I, Sheet 1 - projected ultimate claims (reconcile within rounding tolerance)
assert np.isclose(ia_rep["Ultimate"].sum(), 569091348, rtol=5e-3)
assert np.isclose(ia_pd["Ultimate"].sum(), 570568198, rtol=5e-3)
# Exhibit I, Sheet 2 - estimated IBNR
assert np.isclose(ia_unpaid["IBNR (Reported)"].sum(), 25609761, rtol=5e-3)
assert np.isclose(ia_unpaid["IBNR (Paid)"].sum(), 27086611, rtol=5e-3)
Exhibit II — XYZ Insurer (Auto BI)#
Exhibit II applies the same Bornhuetter-Ferguson method to the XYZ Insurer - Auto BI data, valued at 12/31/2008 over accident years 1998-2008.
The data#
The XYZ Insurer Auto BI reported and paid triangles run from accident year 1998 to 2008. Their most recent diagonal (12/31/2008) is the actual claims the BF method builds on.
tri = cl.load_sample("friedland_xyz_auto_bi")
reported = tri["Reported Claims"]
paid = tri["Paid Claims"]
years = list(reported.origin.year)
col = lambda t: t.to_frame(origin_as_datetime=False).iloc[:, 0].values
reported_latest = col(reported.latest_diagonal)
paid_latest = col(paid.latest_diagonal)
claims = pd.DataFrame(index=years)
claims["Reported"] = reported_latest
claims["Paid"] = paid_latest
display(claims)
| Reported | Paid | |
|---|---|---|
| 1998 | 15822.0 | 15822.0 |
| 1999 | 25107.0 | 24817.0 |
| 2000 | 37246.0 | 36782.0 |
| 2001 | 38798.0 | 38519.0 |
| 2002 | 48169.0 | 44437.0 |
| 2003 | 44373.0 | 39320.0 |
| 2004 | 70288.0 | 52811.0 |
| 2005 | 70655.0 | 40026.0 |
| 2006 | 48804.0 | 22819.0 |
| 2007 | 31732.0 | 11865.0 |
| 2008 | 18632.0 | 3409.0 |
Development patterns#
The BF method reuses the development pattern selected for XYZ in Chapter 7: a volume-weighted two-period average with a 1.000 reported tail and a 1.010 paid tail. Following Friedland, the age-to-age factors are rounded to three decimals before being cumulated to CDFs. The reported CDFs for the oldest accident years fall just below 1.0, so they are capped at 1.0 (this avoids negative implied unreported percentages; Friedland notes the cap is not strictly required).
# Reuse the Chapter 7 XYZ selection. The fitted reported and paid development
# estimators were persisted in Chapter 7 with to_pickle; recall them here with
# read_json instead of refitting, keeping the two chapters in sync. JSON is
# used rather than a pickle so the saved estimators load reliably across
# package and dependency versions.
_data_dir = os.path.join(os.path.dirname(cl.__file__), "utils", "data")
with open(os.path.join(_data_dir, "friedland_ch7_xyz_reported.json")) as f:
reported_dev = cl.read_json(f.read())
with open(os.path.join(_data_dir, "friedland_ch7_xyz_paid.json")) as f:
paid_dev = cl.read_json(f.read())
# Friedland cumulates CDFs from age-to-age factors rounded to three decimals.
reported_dev.ldf_ = reported_dev.ldf_.round(3)
paid_dev.ldf_ = paid_dev.ldf_.round(3)
# CDF to ultimate per accident year (oldest origin -> highest maturity).
reported_cdf = np.maximum(reported_dev.cdf_.to_frame(origin_as_datetime=False).values.flatten()[::-1], 1.0)
paid_cdf = paid_dev.cdf_.to_frame(origin_as_datetime=False).values.flatten()[::-1]
pct_unreported = 1 - 1 / reported_cdf
pct_unpaid = 1 - 1 / paid_cdf
patterns = pd.DataFrame(index=years)
patterns["CDF Reported"] = reported_cdf.round(3)
patterns["CDF Paid"] = paid_cdf.round(3)
patterns["% Unreported"] = pct_unreported.round(3)
patterns["% Unpaid"] = pct_unpaid.round(3)
display(patterns)
| CDF Reported | CDF Paid | % Unreported | % Unpaid | |
|---|---|---|---|---|
| 1998 | 1.000 | 1.010 | 0.000 | 0.010 |
| 1999 | 1.000 | 1.014 | 0.000 | 0.014 |
| 2000 | 1.000 | 1.031 | 0.000 | 0.030 |
| 2001 | 1.000 | 1.054 | 0.000 | 0.051 |
| 2002 | 1.003 | 1.116 | 0.003 | 0.104 |
| 2003 | 1.013 | 1.268 | 0.013 | 0.211 |
| 2004 | 1.064 | 1.525 | 0.060 | 0.344 |
| 2005 | 1.085 | 2.007 | 0.078 | 0.502 |
| 2006 | 1.196 | 3.160 | 0.164 | 0.684 |
| 2007 | 1.512 | 6.569 | 0.339 | 0.848 |
| 2008 | 2.551 | 21.999 | 0.608 | 0.955 |
Expected claims (a priori)#
The a priori expected claims come from the expected claims technique (Chapter 8):
earned premium multiplied by a selected claim ratio. The earned premium is now
carried in the friedland_xyz_auto_bi sample and read directly from its latest
diagonal. The expected claims feed the BF method as the sample_weight.
# Earned premium is carried in the friedland_xyz_auto_bi sample ($000).
earned_premium = col(tri["Earned Premium"].latest_diagonal)
# A priori expected claims from the expected claims technique (Chapter 8, $000).
expected_claims = [15670, 24680, 35256, 39174, 47935, 54197, 86528, 108241, 70769, 39841, 39429]
def as_diagonal(tri, vec):
"""Build a per-origin (latest-diagonal) triangle from a vector of values."""
d = tri.latest_diagonal.copy()
d.iloc[0, 0] = np.asarray(vec, dtype=float).reshape(d.shape)
return d
apriori = as_diagonal(reported, expected_claims)
priori = pd.DataFrame(index=years)
priori["Earned Premium"] = earned_premium
priori["Claim Ratio"] = (np.array(expected_claims) / np.array(earned_premium)).round(3)
priori["Expected Claims"] = expected_claims
display(priori)
| Earned Premium | Claim Ratio | Expected Claims | |
|---|---|---|---|
| 1998 | 20000.0 | 0.784 | 15670 |
| 1999 | 31500.0 | 0.783 | 24680 |
| 2000 | 45000.0 | 0.783 | 35256 |
| 2001 | 50000.0 | 0.783 | 39174 |
| 2002 | 61183.0 | 0.783 | 47935 |
| 2003 | 69175.0 | 0.783 | 54197 |
| 2004 | 99322.0 | 0.871 | 86528 |
| 2005 | 138151.0 | 0.783 | 108241 |
| 2006 | 107578.0 | 0.658 | 70769 |
| 2007 | 62438.0 | 0.638 | 39841 |
| 2008 | 47797.0 | 0.825 | 39429 |
Projection of ultimate claims#
Applying BornhuetterFerguson on both the reported and paid bases produces the
projected ultimate claims. The reported IBNR is floored at zero for the capped
accident years. This recreates the Ultimate Claims Projection exhibit.
bf_reported = cl.BornhuetterFerguson(apriori=1.0).fit(reported_dev, sample_weight=apriori)
bf_paid = cl.BornhuetterFerguson(apriori=1.0).fit(paid_dev, sample_weight=apriori)
reported_ibnr = np.nan_to_num(np.maximum(col(bf_reported.ibnr_), 0.0))
reported_ult = reported_latest + reported_ibnr
paid_ult = col(bf_paid.ultimate_)
projection = pd.DataFrame(index=years)
projection["Reported"] = reported_latest
projection["Paid"] = paid_latest
projection["CDF Reported"] = reported_cdf.round(3)
projection["CDF Paid"] = paid_cdf.round(3)
projection["% Unreported"] = pct_unreported.round(3)
projection["% Unpaid"] = pct_unpaid.round(3)
projection["Earned Premium"] = earned_premium
projection["Expected Claims"] = expected_claims
projection["BF Ultimate (Reported)"] = reported_ult.round(0)
projection["BF Ultimate (Paid)"] = paid_ult.round(0)
display(projection)
| Reported | Paid | CDF Reported | CDF Paid | % Unreported | % Unpaid | Earned Premium | Expected Claims | BF Ultimate (Reported) | BF Ultimate (Paid) | |
|---|---|---|---|---|---|---|---|---|---|---|
| 1998 | 15822.0 | 15822.0 | 1.000 | 1.010 | 0.000 | 0.010 | 20000.0 | 15670 | 15822.0 | 15977.0 |
| 1999 | 25107.0 | 24817.0 | 1.000 | 1.014 | 0.000 | 0.014 | 31500.0 | 24680 | 25107.0 | 25159.0 |
| 2000 | 37246.0 | 36782.0 | 1.000 | 1.031 | 0.000 | 0.030 | 45000.0 | 35256 | 37246.0 | 37851.0 |
| 2001 | 38798.0 | 38519.0 | 1.000 | 1.054 | 0.000 | 0.051 | 50000.0 | 39174 | 38798.0 | 40525.0 |
| 2002 | 48169.0 | 44437.0 | 1.003 | 1.116 | 0.003 | 0.104 | 61183.0 | 47935 | 48309.0 | 49425.0 |
| 2003 | 44373.0 | 39320.0 | 1.013 | 1.268 | 0.013 | 0.211 | 69175.0 | 54197 | 45066.0 | 50773.0 |
| 2004 | 70288.0 | 52811.0 | 1.064 | 1.525 | 0.060 | 0.344 | 99322.0 | 86528 | 75462.0 | 82612.0 |
| 2005 | 70655.0 | 40026.0 | 1.085 | 2.007 | 0.078 | 0.502 | 138151.0 | 108241 | 79123.0 | 94345.0 |
| 2006 | 48804.0 | 22819.0 | 1.196 | 3.160 | 0.164 | 0.684 | 107578.0 | 70769 | 60378.0 | 71190.0 |
| 2007 | 31732.0 | 11865.0 | 1.512 | 6.569 | 0.339 | 0.848 | 62438.0 | 39841 | 45229.0 | 45641.0 |
| 2008 | 18632.0 | 3409.0 | 2.551 | 21.999 | 0.608 | 0.955 | 47797.0 | 39429 | 42607.0 | 41046.0 |
Development of unpaid claim estimate#
From the projected ultimates, the IBNR and total unpaid estimates follow by simple differences: IBNR is ultimate minus reported claims, and total unpaid is ultimate minus paid claims. This recreates the Unpaid Claims exhibit.
unpaid = pd.DataFrame(index=years)
unpaid["BF Ultimate (Reported)"] = reported_ult.round(0)
unpaid["BF Ultimate (Paid)"] = paid_ult.round(0)
unpaid["Case Outstanding"] = (reported_latest - paid_latest).round(0)
unpaid["IBNR (Reported)"] = (reported_ult - reported_latest).round(0)
unpaid["IBNR (Paid)"] = (paid_ult - reported_latest).round(0)
unpaid["Total Unpaid (Reported)"] = (reported_ult - paid_latest).round(0)
unpaid["Total Unpaid (Paid)"] = (paid_ult - paid_latest).round(0)
display(unpaid)
| BF Ultimate (Reported) | BF Ultimate (Paid) | Case Outstanding | IBNR (Reported) | IBNR (Paid) | Total Unpaid (Reported) | Total Unpaid (Paid) | |
|---|---|---|---|---|---|---|---|
| 1998 | 15822.0 | 15977.0 | 0.0 | 0.0 | 155.0 | 0.0 | 155.0 |
| 1999 | 25107.0 | 25159.0 | 290.0 | 0.0 | 52.0 | 290.0 | 342.0 |
| 2000 | 37246.0 | 37851.0 | 464.0 | 0.0 | 605.0 | 464.0 | 1069.0 |
| 2001 | 38798.0 | 40525.0 | 279.0 | 0.0 | 1727.0 | 279.0 | 2006.0 |
| 2002 | 48309.0 | 49425.0 | 3732.0 | 140.0 | 1256.0 | 3872.0 | 4988.0 |
| 2003 | 45066.0 | 50773.0 | 5053.0 | 693.0 | 6400.0 | 5746.0 | 11453.0 |
| 2004 | 75462.0 | 82612.0 | 17477.0 | 5174.0 | 12324.0 | 22651.0 | 29801.0 |
| 2005 | 79123.0 | 94345.0 | 30629.0 | 8468.0 | 23690.0 | 39097.0 | 54319.0 |
| 2006 | 60378.0 | 71190.0 | 25985.0 | 11574.0 | 22386.0 | 37559.0 | 48371.0 |
| 2007 | 45229.0 | 45641.0 | 19867.0 | 13497.0 | 13909.0 | 33364.0 | 33776.0 |
| 2008 | 42607.0 | 41046.0 | 15223.0 | 23975.0 | 22414.0 | 39198.0 | 37637.0 |
Reconciliation to Friedland#
The selected CDFs, the projected ultimate claims, and the IBNR estimates are reconciled to the printed Chapter 9 exhibit below (values in $000).
# Selected CDFs to ultimate
assert np.allclose(reported_cdf.round(3),
[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)
assert np.allclose(paid_cdf.round(3),
[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)
# Projected ultimate claims
assert np.allclose(reported_ult,
[15822, 25107, 37246, 38798, 48309, 45066, 75462, 79123, 60378, 45229, 42607], atol=1)
assert np.allclose(paid_ult,
[15977, 25159, 37851, 40525, 49425, 50773, 82612, 94345, 71190, 45641, 41046], atol=1)
# IBNR (ultimate minus reported)
assert np.allclose(unpaid["IBNR (Reported)"].values,
[0, 0, 0, 0, 140, 693, 5174, 8468, 11574, 13497, 23975], atol=1)
assert np.allclose(unpaid["IBNR (Paid)"].values,
[155, 52, 605, 1727, 1256, 6400, 12324, 23690, 22386, 13909, 22414], atol=1)
Exhibit III — U.S. PP Auto (Impact of Changing Conditions)#
Exhibit III applies the Bornhuetter-Ferguson method to the four U.S. PP Auto scenarios that Friedland uses to study a changing environment (valued at 12/31/2008, accident years 1999-2008):
Steady-State
Increasing Claim Ratios
Increasing Case Outstanding Strength
Increasing Claim Ratios and Case Outstanding Strength
All four scenarios share the same a priori expected claims (a 70% expected claim
ratio applied to earned premium, from Chapter 8) and the same five-year simple
average development selection from Chapter 7. Because Friedland rounds both the
cumulative development factors and the resulting percentages, we fold the rounded
percentages into an effective CDF so BornhuetterFerguson reproduces the text.
Note on sample data. The reported figures for the two case outstanding strength scenarios do not yet reconcile exactly. The reported development in the
friedland_uspp_auto_increasing_caseandfriedland_uspp_increasing_claim_casesamples differs slightly from the text (a known data correction to these CSVs is still outstanding), so the reconciliation below asserts the reported basis only for the two scenarios with clean data, and the paid basis for all four.
def pp_bf_scenario(sample):
"""Recreate a U.S. PP Auto Bornhuetter-Ferguson scenario (Exhibit III)."""
tri = cl.load_sample(sample)
reported = tri["Reported Claims"]
paid = tri["Paid Claims"]
years = list(reported.origin.year)
getcol = lambda t: t.to_frame(origin_as_datetime=False).iloc[:, 0].values
# A priori expected claims: a 70% expected claim ratio on earned premium.
expected = np.round(0.70 * getcol(tri["Earned Premium"].latest_diagonal))
# Chapter 7 selection: five-year simple average development. CDFs are
# cumulated from the age-to-age factors, rounded to three decimals, and
# capped at a minimum of 1.000.
reported_dev = cl.TailConstant(tail=1.0, projection_period=0).fit_transform(
cl.Development(n_periods=5, average="simple").fit_transform(reported))
paid_dev = cl.TailConstant(tail=1.0, projection_period=0).fit_transform(
cl.Development(n_periods=5, average="simple").fit_transform(paid))
ages = [int(a) for a in reported.development.values]
reported_cdf = np.maximum(
reported_dev.cdf_.to_frame(origin_as_datetime=False).values.flatten(), 1.0).round(3)
paid_cdf = np.maximum(
paid_dev.cdf_.to_frame(origin_as_datetime=False).values.flatten(), 1.0).round(3)
# Friedland rounds the percentage unreported / unpaid to three decimals; fold
# those back into an effective CDF so BornhuetterFerguson matches the text.
pct_unrep = np.round(1 - 1 / reported_cdf, 3)
pct_unpaid = np.round(1 - 1 / paid_cdf, 3)
reported_eff = 1.0 / (1.0 - pct_unrep)
paid_eff = 1.0 / (1.0 - pct_unpaid)
apriori = reported.latest_diagonal.copy()
apriori.iloc[0, 0] = expected.reshape(apriori.shape)
reported_pat = cl.DevelopmentConstant(
patterns=dict(zip(ages, reported_eff)), style="cdf").fit_transform(reported)
paid_pat = cl.DevelopmentConstant(
patterns=dict(zip(ages, paid_eff)), style="cdf").fit_transform(paid)
bf_reported = cl.BornhuetterFerguson(apriori=1.0).fit(reported_pat, sample_weight=apriori)
bf_paid = cl.BornhuetterFerguson(apriori=1.0).fit(paid_pat, sample_weight=apriori)
reported_latest = getcol(reported.latest_diagonal)
paid_latest = getcol(paid.latest_diagonal)
ult_reported = np.nan_to_num(getcol(bf_reported.ultimate_))
ult_paid = np.nan_to_num(getcol(bf_paid.ultimate_))
out = pd.DataFrame(index=years)
out["Expected Claims"] = expected
out["Reported"] = reported_latest
out["Paid"] = paid_latest
out["CDF Reported"] = reported_cdf[::-1]
out["CDF Paid"] = paid_cdf[::-1]
out["% Unreported"] = pct_unrep[::-1]
out["% Unpaid"] = pct_unpaid[::-1]
out["BF Ultimate (Reported)"] = ult_reported.round(0)
out["BF Ultimate (Paid)"] = ult_paid.round(0)
out["IBNR (Reported)"] = (ult_reported - reported_latest).round(0)
out["IBNR (Paid)"] = (ult_paid - reported_latest).round(0)
return out
pp_scenarios = {
"Steady-State": "friedland_uspp_auto_steady_state",
"Increasing Claim Ratios": "friedland_uspp_auto_increasing_claim",
"Increasing Case Outstanding Strength": "friedland_uspp_auto_increasing_case",
"Increasing Claim Ratios and Case Outstanding Strength": "friedland_uspp_increasing_claim_case",
}
pp_exhibits = {name: pp_bf_scenario(sample) for name, sample in pp_scenarios.items()}
for name, table in pp_exhibits.items():
print(name)
display(table)
Steady-State
| Expected Claims | Reported | Paid | CDF Reported | CDF Paid | % Unreported | % Unpaid | BF Ultimate (Reported) | BF Ultimate (Paid) | IBNR (Reported) | IBNR (Paid) | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1999 | 700000.0 | 700000.0 | 700000.0 | 1.000 | 1.000 | 0.00 | 0.00 | 700000.0 | 700000.0 | 0.0 | 0.0 |
| 2000 | 735000.0 | 735000.0 | 735000.0 | 1.000 | 1.000 | 0.00 | 0.00 | 735000.0 | 735000.0 | 0.0 | 0.0 |
| 2001 | 771750.0 | 771750.0 | 764033.0 | 1.000 | 1.010 | 0.00 | 0.01 | 771750.0 | 771750.0 | 0.0 | 0.0 |
| 2002 | 810338.0 | 810338.0 | 802234.0 | 1.000 | 1.010 | 0.00 | 0.01 | 810338.0 | 810337.0 | 0.0 | -1.0 |
| 2003 | 850854.0 | 842346.0 | 833837.0 | 1.010 | 1.020 | 0.01 | 0.02 | 850855.0 | 850854.0 | 8509.0 | 8508.0 |
| 2004 | 893397.0 | 884463.0 | 857661.0 | 1.010 | 1.042 | 0.01 | 0.04 | 893397.0 | 893397.0 | 8934.0 | 8934.0 |
| 2005 | 938067.0 | 919306.0 | 863022.0 | 1.020 | 1.087 | 0.02 | 0.08 | 938067.0 | 938067.0 | 18761.0 | 18761.0 |
| 2006 | 984970.0 | 935722.0 | 827375.0 | 1.053 | 1.190 | 0.05 | 0.16 | 984970.0 | 984970.0 | 49248.0 | 49248.0 |
| 2007 | 1034219.0 | 930797.0 | 734295.0 | 1.111 | 1.408 | 0.10 | 0.29 | 1034219.0 | 1034219.0 | 103422.0 | 103422.0 |
| 2008 | 1085930.0 | 836166.0 | 456090.0 | 1.299 | 2.381 | 0.23 | 0.58 | 1085930.0 | 1085929.0 | 249764.0 | 249763.0 |
Increasing Claim Ratios
| Expected Claims | Reported | Paid | CDF Reported | CDF Paid | % Unreported | % Unpaid | BF Ultimate (Reported) | BF Ultimate (Paid) | IBNR (Reported) | IBNR (Paid) | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1999 | 700000.0 | 700000.0 | 700000.0 | 1.000 | 1.000 | 0.00 | 0.00 | 700000.0 | 700000.0 | 0.0 | 0.0 |
| 2000 | 735000.0 | 735000.0 | 735000.0 | 1.000 | 1.000 | 0.00 | 0.00 | 735000.0 | 735000.0 | 0.0 | 0.0 |
| 2001 | 771750.0 | 771750.0 | 764033.0 | 1.000 | 1.010 | 0.00 | 0.01 | 771750.0 | 771750.0 | 0.0 | 0.0 |
| 2002 | 810338.0 | 810338.0 | 802234.0 | 1.000 | 1.010 | 0.00 | 0.01 | 810338.0 | 810337.0 | 0.0 | -1.0 |
| 2003 | 850854.0 | 842346.0 | 833837.0 | 1.010 | 1.020 | 0.01 | 0.02 | 850855.0 | 850854.0 | 8509.0 | 8508.0 |
| 2004 | 893397.0 | 1010815.0 | 980184.0 | 1.010 | 1.042 | 0.01 | 0.04 | 1019749.0 | 1015920.0 | 8934.0 | 5105.0 |
| 2005 | 938067.0 | 1116300.0 | 1047955.0 | 1.020 | 1.087 | 0.02 | 0.08 | 1135061.0 | 1123000.0 | 18761.0 | 6700.0 |
| 2006 | 984970.0 | 1203071.0 | 1063768.0 | 1.053 | 1.190 | 0.05 | 0.16 | 1252320.0 | 1221363.0 | 49248.0 | 18292.0 |
| 2007 | 1034219.0 | 1263224.0 | 996544.0 | 1.111 | 1.408 | 0.10 | 0.29 | 1366646.0 | 1296468.0 | 103422.0 | 33244.0 |
| 2008 | 1085930.0 | 1194523.0 | 651558.0 | 1.299 | 2.381 | 0.23 | 0.58 | 1444287.0 | 1281397.0 | 249764.0 | 86874.0 |
Increasing Case Outstanding Strength
| Expected Claims | Reported | Paid | CDF Reported | CDF Paid | % Unreported | % Unpaid | BF Ultimate (Reported) | BF Ultimate (Paid) | IBNR (Reported) | IBNR (Paid) | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1999 | 700000.0 | 700000.0 | 700000.0 | 1.000 | 1.000 | 0.000 | 0.00 | 700000.0 | 700000.0 | 0.0 | 0.0 |
| 2000 | 735000.0 | 735000.0 | 735000.0 | 1.000 | 1.000 | 0.000 | 0.00 | 735000.0 | 735000.0 | 0.0 | 0.0 |
| 2001 | 771750.0 | 771750.0 | 764033.0 | 1.000 | 1.010 | 0.000 | 0.01 | 771750.0 | 771750.0 | 0.0 | 0.0 |
| 2002 | 810338.0 | 810338.0 | 802234.0 | 1.000 | 1.010 | 0.000 | 0.01 | 810338.0 | 810337.0 | 0.0 | -1.0 |
| 2003 | 850854.0 | 842346.0 | 833837.0 | 1.010 | 1.020 | 0.010 | 0.02 | 850855.0 | 850854.0 | 8509.0 | 8508.0 |
| 2004 | 893397.0 | 884463.0 | 857661.0 | 1.010 | 1.042 | 0.010 | 0.04 | 893397.0 | 893397.0 | 8934.0 | 8934.0 |
| 2005 | 938067.0 | 933377.0 | 863022.0 | 1.020 | 1.087 | 0.020 | 0.08 | 952138.0 | 938067.0 | 18761.0 | 4690.0 |
| 2006 | 984970.0 | 962808.0 | 827375.0 | 1.054 | 1.190 | 0.051 | 0.16 | 1013041.0 | 984970.0 | 50233.0 | 22162.0 |
| 2007 | 1034219.0 | 979922.0 | 734295.0 | 1.118 | 1.408 | 0.106 | 0.29 | 1089549.0 | 1034219.0 | 109627.0 | 54297.0 |
| 2008 | 1085930.0 | 931185.0 | 456090.0 | 1.317 | 2.381 | 0.241 | 0.58 | 1192894.0 | 1085929.0 | 261709.0 | 154744.0 |
Increasing Claim Ratios and Case Outstanding Strength
| Expected Claims | Reported | Paid | CDF Reported | CDF Paid | % Unreported | % Unpaid | BF Ultimate (Reported) | BF Ultimate (Paid) | IBNR (Reported) | IBNR (Paid) | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1999 | 700000.0 | 700000.0 | 700000.0 | 1.000 | 1.000 | 0.000 | 0.00 | 700000.0 | 700000.0 | 0.0 | 0.0 |
| 2000 | 735000.0 | 735000.0 | 735000.0 | 1.000 | 1.000 | 0.000 | 0.00 | 735000.0 | 735000.0 | 0.0 | 0.0 |
| 2001 | 771750.0 | 771750.0 | 764033.0 | 1.000 | 1.010 | 0.000 | 0.01 | 771750.0 | 771750.0 | 0.0 | 0.0 |
| 2002 | 810338.0 | 810338.0 | 802234.0 | 1.000 | 1.010 | 0.000 | 0.01 | 810338.0 | 810337.0 | 0.0 | -1.0 |
| 2003 | 850854.0 | 842346.0 | 833837.0 | 1.010 | 1.020 | 0.010 | 0.02 | 850855.0 | 850854.0 | 8509.0 | 8508.0 |
| 2004 | 893397.0 | 1010815.0 | 980184.0 | 1.010 | 1.042 | 0.010 | 0.04 | 1019749.0 | 1015920.0 | 8934.0 | 5105.0 |
| 2005 | 938067.0 | 1133386.0 | 1047955.0 | 1.020 | 1.087 | 0.020 | 0.08 | 1152147.0 | 1123000.0 | 18761.0 | -10386.0 |
| 2006 | 984970.0 | 1237897.0 | 1063768.0 | 1.054 | 1.190 | 0.051 | 0.16 | 1288130.0 | 1221363.0 | 50233.0 | -16534.0 |
| 2007 | 1034219.0 | 1329895.0 | 996544.0 | 1.118 | 1.408 | 0.106 | 0.29 | 1439522.0 | 1296468.0 | 109627.0 | -33427.0 |
| 2008 | 1085930.0 | 1330264.0 | 651558.0 | 1.317 | 2.381 | 0.241 | 0.58 | 1591973.0 | 1281397.0 | 261709.0 | -48867.0 |
Reconciliation to Friedland#
We reconcile the estimated IBNR totals to the printed Exhibit III. The reported basis is checked for the two scenarios with clean sample data; the paid basis is checked for all four scenarios.
pp_ibnr = {name: (table["IBNR (Reported)"].sum(), table["IBNR (Paid)"].sum())
for name, table in pp_exhibits.items()}
# Reported basis - scenarios with clean sample data
assert abs(pp_ibnr["Steady-State"][0] - 438638) < 5
assert abs(pp_ibnr["Increasing Claim Ratios"][0] - 438638) < 5
# Paid basis - all four scenarios
assert abs(pp_ibnr["Steady-State"][1] - 438638) < 5
assert abs(pp_ibnr["Increasing Claim Ratios"][1] - 158724) < 5
assert abs(pp_ibnr["Increasing Case Outstanding Strength"][1] - 253336) < 5
assert abs(pp_ibnr["Increasing Claim Ratios and Case Outstanding Strength"][1] - (-95600)) < 5