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:

\[\text{Ultimate} = \text{Actual} + \text{Expected Claims} \times \left(1 - \frac{1}{\text{CDF}}\right)\]

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):

  1. Steady-State

  2. Increasing Claim Ratios

  3. Increasing Case Outstanding Strength

  4. 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_case and friedland_uspp_increasing_claim_case samples 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