Chapter 10 - Cape Cod Technique#

The key innovation of the Stanard-Buhlmann (Cape Cod) method is that the ultimate expected loss ratio for all years combined is estimated from the overall reported claims experience, instead of being selected judgmentally, as in the Bornhuetter-Ferguson method.

– Friedland, Chapter 10

The Cape Cod (Stanard-Buhlmann) method is a close cousin of the Bornhuetter-Ferguson technique. It splits ultimate claims into the claims already reported (or paid) plus the expected unreported (or unpaid) claims:

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

The only difference from Bornhuetter-Ferguson is the source of the expected claims. Rather than selecting an a priori claim ratio judgmentally, Cape Cod derives it from the reported experience. The all-years-combined expected claim ratio is the reported claims divided by the used-up premium - the earned premium scaled by the percentage of claims expected to be reported to date:

\[\text{Expected Claim Ratio} = \frac{\sum \text{Reported Claims}}{\sum \text{Earned Premium} \times \left(\tfrac{1}{\text{CDF}}\right)}\]

In the chainladder package the method is implemented by CapeCod, which takes the exposure (earned premium) through sample_weight and returns the derived claim ratio in apriori_. This chapter recreates the Friedland Chapter 10 exhibits, reusing the development patterns selected in Chapter 7:

  • Exhibit I - U.S. Industry Auto

  • Exhibit II - XYZ Insurer (Auto BI), with on-level premium and adjusted claims

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

# Pull the single-column latest diagonal (or any 1x1xNx1 triangle) as a vector.
col = lambda t: t.to_frame(origin_as_datetime=False).iloc[:, 0].values

Exhibit I - U.S. Industry Auto#

The text works the Cape Cod method first for U.S. Industry Auto (Exhibit I), valued at 12/31/2007 over accident years 1998-2007. The reporting pattern is the Chapter 7 selection (three-year simple average with a 1.000 reported tail).

Development of expected claim ratio#

This recreates Exhibit I, Sheet 1. The used-up premium is the earned premium multiplied by the percentage reported (the inverse of the cumulative development factor). Dividing reported claims by used-up premium gives each year’s estimated claim ratio; the all-years-combined ratio is the CapeCod apriori that drives the projection.

ia = cl.load_sample("friedland_us_industry_auto")
ia_reported = ia["Reported Claims"]
ia_paid = ia["Paid Claims"]
ia_premium = ia["Earned Premium"].latest_diagonal
ia_years = list(ia_reported.origin.year)

# Chapter 7 selection: three-year simple average reported development, 1.000 tail.
# Friedland cumulates the age-to-age factors rounded to three decimals.
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_reported_dev.ldf_ = ia_reported_dev.ldf_.round(3)

# CapeCod derives the expected claim ratio (apriori) from the reported claims and
# the earned-premium exposure. With the default decay=1 and trend=0, every origin
# receives the same used-up-premium-weighted claim ratio.
ia_cc = cl.CapeCod().fit(ia_reported_dev, sample_weight=ia_premium)
ia_apriori = float(ia_cc.apriori_.to_frame().iloc[0, 0])

# model_diagnostics packages Latest, CDF, Ultimate, and IBNR by accident year.
ia_diag = cl.model_diagnostics(ia_cc)
ia_cdf = col(ia_diag["CDF"])
ia_pct_reported = 1 / ia_cdf
ia_reported_latest = col(ia_diag["Latest"])
ia_used_up = col(ia_premium) * ia_pct_reported

sheet1 = pd.DataFrame(index=ia_years)
sheet1["Earned Premium"] = col(ia_premium)
sheet1["Age"] = [(2007 - y) * 12 + 12 for y in ia_years]
sheet1["Reported Claims"] = ia_reported_latest
sheet1["CDF to Ultimate"] = ia_cdf.round(3)
sheet1["% Reported"] = ia_pct_reported.round(3)
sheet1["Used-Up Premium"] = ia_used_up.round(0)
sheet1["Estimated Claim Ratio"] = (ia_reported_latest / ia_used_up).round(3)
display(sheet1)
print(f"All-years estimated claim ratio (apriori): {ia_apriori:.3f}")
Earned Premium Age Reported Claims CDF to Ultimate % Reported Used-Up Premium Estimated Claim Ratio
1998 68574209.0 120 47742304.0 1.000 1.000 68574209.0 0.696
1999 68544981.0 108 51185767.0 1.000 1.000 68544981.0 0.747
2000 68907977.0 96 54837929.0 1.001 0.999 68839138.0 0.797
2001 72544955.0 84 56299562.0 1.003 0.997 72327827.0 0.778
2002 79228887.0 72 58592712.0 1.006 0.994 78755487.0 0.744
2003 86643542.0 60 57565344.0 1.011 0.989 85697352.0 0.672
2004 91763523.0 48 56976657.0 1.023 0.977 89685198.0 0.635
2005 94115312.0 36 56786410.0 1.051 0.952 89565455.0 0.634
2006 95272279.0 24 54641339.0 1.110 0.901 85858419.0 0.636
2007 95176240.0 12 48853563.0 1.292 0.774 73687173.0 0.663
All-years estimated claim ratio (apriori): 0.695

Projection of ultimate claims#

This recreates Exhibit I, Sheet 2. Expected claims are the earned premium times the derived claim ratio. The expected unreported claims (CapeCod.ibnr_) are the expected claims scaled by the percentage still to emerge, and the projected ultimate is reported claims plus expected unreported.

ia_expected_claims = col(ia_premium) * ia_apriori
ia_pct_unreported = 1 - ia_pct_reported
# CapeCod returns NaN IBNR for fully developed years (CDF = 1.000); the text
# shows these as zero, so coerce NaN to 0.
ia_ibnr = np.nan_to_num(col(ia_diag["IBNR"]))
ia_expected_unreported = ia_ibnr
ia_ult = col(ia_diag["Ultimate"])

sheet2 = pd.DataFrame(index=ia_years)
sheet2["Earned Premium"] = col(ia_premium)
sheet2["Expected Claim Ratio"] = round(ia_apriori, 3)
sheet2["Expected Claims"] = ia_expected_claims.round(0)
sheet2["CDF to Ultimate"] = ia_cdf.round(3)
sheet2["% Unreported"] = ia_pct_unreported.round(3)
sheet2["Expected Unreported"] = ia_expected_unreported.round(0)
sheet2["Reported Claims"] = ia_reported_latest
sheet2["Projected Ultimate"] = ia_ult.round(0)
display(sheet2)
Earned Premium Expected Claim Ratio Expected Claims CDF to Ultimate % Unreported Expected Unreported Reported Claims Projected Ultimate
1998 68574209.0 0.695 47686679.0 1.000 0.000 0.0 47742304.0 47742304.0
1999 68544981.0 0.695 47666354.0 1.000 0.000 0.0 51185767.0 51185767.0
2000 68907977.0 0.695 47918782.0 1.001 0.001 47871.0 54837929.0 54885800.0
2001 72544955.0 0.695 50447946.0 1.003 0.003 150991.0 56299562.0 56450553.0
2002 79228887.0 0.695 55095969.0 1.006 0.006 329203.0 58592712.0 58921915.0
2003 86643542.0 0.695 60252139.0 1.011 0.011 657983.0 57565344.0 58223327.0
2004 91763523.0 0.695 63812587.0 1.023 0.023 1445272.0 56976657.0 58421929.0
2005 94115312.0 0.695 65448027.0 1.051 0.048 3163982.0 56786410.0 59950392.0
2006 95272279.0 0.695 66252584.0 1.110 0.099 6546422.0 54641339.0 61187761.0
2007 95176240.0 0.695 66185799.0 1.292 0.226 14943552.0 48853563.0 63797115.0

Development of unpaid claim estimate#

This recreates Exhibit I, Sheet 3. Case outstanding is reported minus paid, estimated IBNR is ultimate minus reported, and the total unpaid estimate is ultimate minus paid.

ia_paid_latest = col(ia_paid.latest_diagonal)
sheet3 = pd.DataFrame(index=ia_years)
sheet3["Reported Claims"] = ia_reported_latest
sheet3["Paid Claims"] = ia_paid_latest
sheet3["Projected Ultimate"] = ia_ult.round(0)
sheet3["Case Outstanding"] = (ia_reported_latest - ia_paid_latest).round(0)
sheet3["IBNR"] = ia_ibnr.round(0)
sheet3["Total Unpaid"] = (ia_ult - ia_paid_latest).round(0)
display(sheet3)
Reported Claims Paid Claims Projected Ultimate Case Outstanding IBNR Total Unpaid
1998 47742304.0 47644187.0 47742304.0 98117.0 0.0 98117.0
1999 51185767.0 51000534.0 51185767.0 185233.0 0.0 185233.0
2000 54837929.0 54533225.0 54885800.0 304704.0 47871.0 352575.0
2001 56299562.0 55878421.0 56450553.0 421141.0 150991.0 572132.0
2002 58592712.0 57807215.0 58921915.0 785497.0 329203.0 1114700.0
2003 57565344.0 55930654.0 58223327.0 1634690.0 657983.0 2292673.0
2004 56976657.0 53774672.0 58421929.0 3201985.0 1445272.0 4647257.0
2005 56786410.0 50644994.0 59950392.0 6141416.0 3163982.0 9305398.0
2006 54641339.0 43606497.0 61187761.0 11034842.0 6546422.0 17581264.0
2007 48853563.0 27229969.0 63797115.0 21623594.0 14943552.0 36567146.0

Reconciliation to Friedland#

The derived claim ratio, the projected ultimate claims, and the estimated IBNR are reconciled to the printed Exhibit I below. Because the text derives used-up premium from CDFs rounded to three decimals, the totals reconcile within a small relative tolerance.

# Exhibit I, Sheet 1 - all-years estimated claim ratio
assert np.isclose(ia_apriori, 0.695, atol=1e-3)
# Exhibit I, Sheet 1 - selected CDFs to ultimate
assert np.allclose(ia_cdf.round(3),
    [1.000, 1.000, 1.001, 1.003, 1.006, 1.011, 1.023, 1.051, 1.110, 1.292], atol=1e-3)
# Exhibit I, Sheet 2 - projected ultimate claims
assert np.isclose(ia_ult.sum(), 570800677, rtol=5e-3)
# Exhibit I, Sheet 3 - estimated IBNR
assert np.isclose(ia_ibnr.sum(), 27319090, rtol=5e-3)

Exhibit II - XYZ Insurer (Auto BI)#

Exhibit II applies the Cape Cod method to the XYZ Insurer - Auto BI data, valued at 12/31/2008 over accident years 1998-2008. Unlike U.S. Industry Auto, the text has detailed rate-change and legal-reform information, so it adjusts both premium and claims before deriving the claim ratio:

  • On-level premium: earned premium restated to the 2008 rate level.

  • Adjusted claims: reported claims trended for pure-premium inflation and adjusted for tort reform.

The all-years adjusted claim ratio is then detrended back to each accident year’s rate, inflation, and tort environment to give the expected claim ratios used in the projection.

Development of expected claim ratio#

This recreates Exhibit II, Sheet 1. The on-level and tort-reform factors are produced by ParallelogramOLF from the text’s rate-change and reform histories. The all-years apriori is then derived through a Pipeline that applies the tort-reform on-level factors, develops the reported claims on the floored Chapter 7 pattern (DevelopmentConstant), and fits CapeCod with the pure-premium trend, using on-level premium as the exposure. The resulting 70.8% adjusted claim ratio is detrended back per year in Column (15).

xyz = cl.load_sample("friedland_xyz_auto_bi")
xyz_reported = xyz["Reported Claims"]
xyz_paid = xyz["Paid Claims"]
xyz_premium = xyz["Earned Premium"].latest_diagonal
xyz_years = list(xyz_reported.origin.year)

# Reuse the Chapter 7 XYZ reported development, persisted there with to_json.
_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:
    xyz_reported_dev = cl.read_json(f.read())
xyz_reported_dev.ldf_ = xyz_reported_dev.ldf_.round(3)

# Adjustment methods (Exhibit II, Sheet 1). The text restates premium to the 2008
# rate level and adjusts reported claims for pure-premium trend and tort reform.
# The rate-change and tort-reform histories are from the text; the parallelogram
# method (vertical_line=True) reproduces its on-level and tort factors, and the
# 3.42% pure-premium trend is applied through CapeCod's trend parameter.
rate_history = pd.DataFrame({
    "date": ["1/1/1999", "1/1/2000", "1/1/2001", "1/1/2002", "1/1/2003",
             "1/1/2004", "1/1/2005", "1/1/2006", "1/1/2007", "1/1/2008"],
    "rate_change": [0.02, 0.02, 0.02, 0.02, 0.05, 0.075, 0.15, 0.10, -0.20, -0.20],
})
tort_history = pd.DataFrame({
    "date": ["1/1/2006", "1/1/2007"],
    "rate_change": [-0.1067, -0.25],
})
pp_trend_rate = 0.0342

onlevel = cl.ParallelogramOLF(
    rate_history, change_col="rate_change", date_col="date", vertical_line=True)
tort = cl.ParallelogramOLF(
    tort_history, change_col="rate_change", date_col="date", vertical_line=True)
onlevel_adj = col(onlevel.fit(xyz_premium).olf_)
tort_reform = col(tort.fit(xyz_reported.latest_diagonal).olf_)
pp_trend = (1 + pp_trend_rate) ** (max(xyz_years) - np.array(xyz_years))

# Reported cumulative development factors are floored at 1.0 (caps %reported at 100%).
xyz_cdf = np.maximum(
    xyz_reported_dev.cdf_.to_frame(origin_as_datetime=False).values.flatten()[::-1], 1.0).round(3)
xyz_pct_reported = 1 / xyz_cdf
xyz_reported_latest = col(xyz_reported.latest_diagonal)
xyz_prem = col(xyz_premium)

on_level_premium = xyz_prem * onlevel_adj
adjusted_claims = xyz_reported_latest * pp_trend * tort_reform
used_up_on_level = on_level_premium * xyz_pct_reported

# Chain the adjustments into a pipeline to derive the all-years apriori: the
# tort-reform OLF and the floored Chapter 7 pattern develop the reported claims,
# CapeCod applies the pure-premium trend, and on-level premium is the exposure.
floored_patterns = dict(zip(
    [int(age) for age in xyz_reported_dev.cdf_.ddims],
    np.maximum(xyz_reported_dev.cdf_.values.flatten(), 1.0)))
onlevel_premium = xyz_premium * onlevel.olf_
xyz_cc = cl.Pipeline([
    ("tort", tort),
    ("dev", cl.DevelopmentConstant(patterns=floored_patterns, style="cdf")),
    ("capecod", cl.CapeCod(trend=pp_trend_rate)),
]).fit(xyz_reported, sample_weight=onlevel_premium).named_steps["capecod"]
xyz_apriori = float(xyz_cc.apriori_.to_frame().iloc[0, 0])

sheet1 = pd.DataFrame(index=xyz_years)
sheet1["Earned Premium"] = xyz_prem
sheet1["On-Level Factor"] = onlevel_adj
sheet1["On-Level Premium"] = on_level_premium.round(0)
sheet1["Reported Claims"] = xyz_reported_latest
sheet1["Trend"] = pp_trend
sheet1["Tort Reform"] = tort_reform
sheet1["Adjusted Claims"] = adjusted_claims.round(0)
sheet1["CDF to Ultimate"] = xyz_cdf.round(3)
sheet1["% Reported"] = xyz_pct_reported.round(3)
sheet1["Used-Up On-Level Premium"] = used_up_on_level.round(0)
sheet1["Estimated Adjusted Claim Ratio"] = (adjusted_claims / used_up_on_level).round(3)
display(sheet1)
print(f"All-years adjusted claim ratio (apriori): {xyz_apriori:.3f}")
Earned Premium On-Level Factor On-Level Premium Reported Claims Trend Tort Reform Adjusted Claims CDF to Ultimate % Reported Used-Up On-Level Premium Estimated Adjusted Claim Ratio
1998 20000.0 0.989165 19783.0 15822.0 1.399733 0.669975 14838.0 1.000 1.000 19783.0 0.750
1999 31500.0 0.969770 30548.0 25107.0 1.353446 0.669975 22766.0 1.000 1.000 30548.0 0.745
2000 45000.0 0.950755 42784.0 37246.0 1.308688 0.669975 32657.0 1.000 1.000 42784.0 0.763
2001 50000.0 0.932113 46606.0 38798.0 1.265411 0.669975 32893.0 1.000 1.000 46606.0 0.706
2002 61183.0 0.913836 55911.0 48169.0 1.223565 0.669975 39487.0 1.003 0.997 55744.0 0.708
2003 69175.0 0.870320 60204.0 44373.0 1.183103 0.669975 35172.0 1.013 0.987 59432.0 0.592
2004 99322.0 0.809600 80411.0 70288.0 1.143979 0.669975 53871.0 1.064 0.940 75574.0 0.713
2005 138151.0 0.704000 97258.0 70655.0 1.106149 0.669975 52362.0 1.085 0.922 89639.0 0.584
2006 107578.0 0.640000 68850.0 48804.0 1.069570 0.750000 39149.0 1.196 0.836 57567.0 0.680
2007 62438.0 0.800000 49950.0 31732.0 1.034200 1.000000 32817.0 1.512 0.661 33036.0 0.993
2008 47797.0 1.000000 47797.0 18632.0 1.000000 1.000000 18632.0 2.551 0.392 18737.0 0.994
All-years adjusted claim ratio (apriori): 0.708

Projection of ultimate claims#

This recreates Exhibit II, Sheet 2. The all-years adjusted claim ratio is detrended back to each accident year’s environment (Column 15 of Sheet 1) to give the per-year expected claim ratios. Expected claims are earned premium times that ratio; the projected ultimate is reported claims plus expected unreported.

# Detrend the all-years apriori back to each year's rate, inflation, and tort
# environment (Exhibit II, Sheet 1, Column 15).
xyz_unadjusted_ratio = xyz_apriori * onlevel_adj / pp_trend / tort_reform
xyz_expected_claims = xyz_prem * xyz_unadjusted_ratio
xyz_pct_unreported = 1 - xyz_pct_reported
xyz_expected_unreported = xyz_expected_claims * xyz_pct_unreported
xyz_ult = xyz_reported_latest + xyz_expected_unreported

sheet2 = pd.DataFrame(index=xyz_years)
sheet2["Earned Premium"] = xyz_prem
sheet2["Expected Claim Ratio"] = xyz_unadjusted_ratio.round(3)
sheet2["Expected Claims"] = xyz_expected_claims.round(0)
sheet2["CDF to Ultimate"] = xyz_cdf.round(3)
sheet2["% Unreported"] = xyz_pct_unreported.round(3)
sheet2["Expected Unreported"] = xyz_expected_unreported.round(0)
sheet2["Reported Claims"] = xyz_reported_latest
sheet2["Projected Ultimate"] = xyz_ult.round(0)
display(sheet2)
Earned Premium Expected Claim Ratio Expected Claims CDF to Ultimate % Unreported Expected Unreported Reported Claims Projected Ultimate
1998 20000.0 0.746 14926.0 1.000 0.000 0.0 15822.0 15822.0
1999 31500.0 0.757 23836.0 1.000 0.000 0.0 25107.0 25107.0
2000 45000.0 0.767 34525.0 1.000 0.000 0.0 37246.0 37246.0
2001 50000.0 0.778 38895.0 1.000 0.000 0.0 38798.0 38798.0
2002 61183.0 0.789 48257.0 1.003 0.003 144.0 48169.0 48313.0
2003 69175.0 0.777 53739.0 1.013 0.013 690.0 44373.0 45063.0
2004 99322.0 0.747 74231.0 1.064 0.060 4465.0 70288.0 74753.0
2005 138151.0 0.672 92854.0 1.085 0.078 7274.0 70655.0 77929.0
2006 107578.0 0.564 60727.0 1.196 0.164 9952.0 48804.0 58756.0
2007 62438.0 0.547 34173.0 1.512 0.339 11572.0 31732.0 43304.0
2008 47797.0 0.708 33818.0 2.551 0.608 20561.0 18632.0 39193.0

Development of unpaid claim estimate#

This recreates Exhibit II, Sheet 3: case outstanding, estimated IBNR, and total unpaid follow from the projected ultimates.

xyz_paid_latest = col(xyz_paid.latest_diagonal)
sheet3 = pd.DataFrame(index=xyz_years)
sheet3["Reported Claims"] = xyz_reported_latest
sheet3["Paid Claims"] = xyz_paid_latest
sheet3["Projected Ultimate"] = xyz_ult.round(0)
sheet3["Case Outstanding"] = (xyz_reported_latest - xyz_paid_latest).round(0)
sheet3["IBNR"] = (xyz_ult - xyz_reported_latest).round(0)
sheet3["Total Unpaid"] = (xyz_ult - xyz_paid_latest).round(0)
display(sheet3)
Reported Claims Paid Claims Projected Ultimate Case Outstanding IBNR Total Unpaid
1998 15822.0 15822.0 15822.0 0.0 0.0 0.0
1999 25107.0 24817.0 25107.0 290.0 0.0 290.0
2000 37246.0 36782.0 37246.0 464.0 0.0 464.0
2001 38798.0 38519.0 38798.0 279.0 0.0 279.0
2002 48169.0 44437.0 48313.0 3732.0 144.0 3876.0
2003 44373.0 39320.0 45063.0 5053.0 690.0 5743.0
2004 70288.0 52811.0 74753.0 17477.0 4465.0 21942.0
2005 70655.0 40026.0 77929.0 30629.0 7274.0 37903.0
2006 48804.0 22819.0 58756.0 25985.0 9952.0 35937.0
2007 31732.0 11865.0 43304.0 19867.0 11572.0 31439.0
2008 18632.0 3409.0 39193.0 15223.0 20561.0 35784.0

Reconciliation to Friedland#

The derived adjusted claim ratio, the per-year detrended claim ratios, and the projected ultimate and IBNR totals are reconciled to the printed Exhibit II below (values in $000).

# Exhibit II, Sheet 1 - all-years adjusted claim ratio
assert np.isclose(xyz_apriori, 0.708, atol=2e-3)
# Exhibit II, Sheet 1 - selected CDFs to ultimate (limited to a minimum of 1.00)
assert np.allclose(xyz_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)
# Exhibit II, Sheet 1 - detrended (unadjusted) claim ratios, Column 15
assert np.allclose(xyz_unadjusted_ratio,
    [0.746, 0.757, 0.767, 0.778, 0.789, 0.777, 0.747, 0.672, 0.565, 0.547, 0.708], atol=2e-3)
# Exhibit II, Sheet 2 - projected ultimate claims
assert np.isclose(xyz_ult.sum(), 504300, rtol=5e-3)
# Exhibit II, Sheet 3 - estimated IBNR
assert np.isclose((xyz_ult - xyz_reported_latest).sum(), 54674, rtol=5e-3)

Comparison of methods#

This recreates Exhibit II, Sheets 4 and 5, which compare the Cape Cod projection against the development, expected claims, and Bornhuetter-Ferguson methods from Chapters 7-9. The non-Cape-Cod columns are the published results of those chapters (cited in the text’s column notes); the Cape Cod column is the one computed in this chapter. Values in $000.

# Ultimate claims by method (Sheet 4). Every column other than Cape Cod is the
# published result of a prior chapter - development (Chapter 7), expected claims
# (Chapter 8), and Bornhuetter-Ferguson (Chapter 9) - reproduced from the text's
# column notes for comparison. The Cape Cod column is computed above.
summary_ult = pd.DataFrame(index=xyz_years)
summary_ult["Reported"] = xyz_reported_latest
summary_ult["Paid"] = xyz_paid_latest
summary_ult["Development (Reported)"] = [15822, 25082, 36948, 38487, 48313, 44950, 74787, 76661, 58370, 47979, 47530]
summary_ult["Development (Paid)"] = [15980, 25164, 37922, 40600, 49592, 49858, 80537, 80333, 72108, 77941, 74995]
summary_ult["Expected Claims"] = [15660, 24665, 35235, 39150, 47906, 54164, 86509, 108172, 70786, 39835, 39433]
summary_ult["BF (Reported)"] = [15822, 25107, 37246, 38798, 48312, 45068, 75492, 79129, 60404, 45221, 42607]
summary_ult["BF (Paid)"] = [15977, 25158, 37841, 40525, 49417, 50768, 82593, 94301, 71205, 45636, 41049]
summary_ult["Cape Cod"] = xyz_ult.round(0)
display(summary_ult)

# Estimated IBNR by method (Sheet 5): projected ultimate minus reported claims.
summary_ibnr = pd.DataFrame(index=xyz_years)
summary_ibnr["Case Outstanding"] = (xyz_reported_latest - xyz_paid_latest).round(0)
summary_ibnr["Development (Reported)"] = [0, -25, -298, -310, 145, 577, 4498, 6006, 9566, 16247, 28898]
summary_ibnr["Development (Paid)"] = [158, 58, 676, 1802, 1423, 5485, 10249, 9678, 23304, 46209, 56363]
summary_ibnr["Expected Claims"] = [-162, -442, -2011, 352, -262, 9791, 16221, 37517, 21982, 8103, 20801]
summary_ibnr["BF (Reported)"] = [0, 0, 0, 0, 143, 695, 5204, 8474, 11600, 13489, 23975]
summary_ibnr["BF (Paid)"] = [155, 51, 595, 1728, 1248, 6396, 12305, 23646, 22401, 13904, 22417]
summary_ibnr["Cape Cod"] = (xyz_ult - xyz_reported_latest).round(0)
display(summary_ibnr)

# Reconcile the Cape Cod column of Sheets 4 and 5 to the printed exhibit.
assert np.isclose(summary_ult["Cape Cod"].sum(), 504300, rtol=5e-3)
assert np.isclose(summary_ibnr["Cape Cod"].sum(), 54674, rtol=5e-3)
Reported Paid Development (Reported) Development (Paid) Expected Claims BF (Reported) BF (Paid) Cape Cod
1998 15822.0 15822.0 15822 15980 15660 15822 15977 15822.0
1999 25107.0 24817.0 25082 25164 24665 25107 25158 25107.0
2000 37246.0 36782.0 36948 37922 35235 37246 37841 37246.0
2001 38798.0 38519.0 38487 40600 39150 38798 40525 38798.0
2002 48169.0 44437.0 48313 49592 47906 48312 49417 48313.0
2003 44373.0 39320.0 44950 49858 54164 45068 50768 45063.0
2004 70288.0 52811.0 74787 80537 86509 75492 82593 74753.0
2005 70655.0 40026.0 76661 80333 108172 79129 94301 77929.0
2006 48804.0 22819.0 58370 72108 70786 60404 71205 58756.0
2007 31732.0 11865.0 47979 77941 39835 45221 45636 43304.0
2008 18632.0 3409.0 47530 74995 39433 42607 41049 39193.0
Case Outstanding Development (Reported) Development (Paid) Expected Claims BF (Reported) BF (Paid) Cape Cod
1998 0.0 0 158 -162 0 155 0.0
1999 290.0 -25 58 -442 0 51 0.0
2000 464.0 -298 676 -2011 0 595 0.0
2001 279.0 -310 1802 352 0 1728 0.0
2002 3732.0 145 1423 -262 143 1248 144.0
2003 5053.0 577 5485 9791 695 6396 690.0
2004 17477.0 4498 10249 16221 5204 12305 4465.0
2005 30629.0 6006 9678 37517 8474 23646 7274.0
2006 25985.0 9566 23304 21982 11600 22401 9952.0
2007 19867.0 16247 46209 8103 13489 13904 11572.0
2008 15223.0 28898 56363 20801 23975 22417 20561.0

Exhibit III - U.S. PP Auto (Impact of Changing Conditions)#

Exhibit III applies the Cape Cod method to the four U.S. PP Auto scenarios 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

Unlike Bornhuetter-Ferguson (whose a priori is fixed at a 70% claim ratio for all four scenarios), Cape Cod derives the expected claim ratio from each scenario’s reported experience. This is what makes it more responsive when claim ratios rise (Scenario 2) but prone to overstatement when case-outstanding strength increases (Scenarios 3 and 4).

The development selection is the Chapter 7 five-year simple average with a 1.000 tail, and reported CDFs are limited to a minimum of 1.000.

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 IBNR total only for the two scenarios with clean data. The derived claim ratio reconciles for all four.

def pp_cc_scenario(sample):
    """Recreate a U.S. PP Auto Cape Cod scenario (Exhibit III)."""
    tri = cl.load_sample(sample)
    reported = tri["Reported Claims"]
    paid = tri["Paid Claims"]
    premium = tri["Earned Premium"].latest_diagonal
    years = list(reported.origin.year)

    # Chapter 7 selection: five-year simple average reported development, 1.000
    # tail; CDFs cumulated from age-to-age factors rounded to three decimals.
    reported_dev = cl.TailConstant(tail=1.0, projection_period=0).fit_transform(
        cl.Development(n_periods=5, average="simple").fit_transform(reported))
    reported_dev.ldf_ = reported_dev.ldf_.round(3)

    cc = cl.CapeCod().fit(reported_dev, sample_weight=premium)
    apriori = float(cc.apriori_.to_frame().iloc[0, 0])

    # model_diagnostics packages Latest, CDF, Ultimate, and IBNR by accident year.
    diag = cl.model_diagnostics(cc)
    cdf = np.maximum(col(diag["CDF"]), 1.0).round(3)
    prem = col(premium)
    reported_latest = col(diag["Latest"])
    used_up = prem / cdf
    # CapeCod returns NaN IBNR for fully developed years (CDF = 1.000); coerce to 0.
    ibnr = np.nan_to_num(col(diag["IBNR"]))

    out = pd.DataFrame(index=years)
    out["Earned Premium"] = prem
    out["Reported Claims"] = reported_latest
    out["CDF to Ultimate"] = cdf.round(3)
    out["% Reported"] = (1 / cdf).round(3)
    out["Used-Up Premium"] = used_up.round(0)
    out["Estimated Claim Ratio"] = (reported_latest / used_up).round(3)
    out["Expected Claims"] = (prem * apriori).round(0)
    out["Projected Ultimate"] = col(diag["Ultimate"]).round(0)
    out["IBNR"] = ibnr.round(0)
    return apriori, 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_results = {name: pp_cc_scenario(sample) for name, sample in pp_scenarios.items()}
for name, (apriori, table) in pp_results.items():
    print(f"{name} - all-years estimated claim ratio {apriori:.3f}")
    display(table)
Steady-State - all-years estimated claim ratio 0.700
Earned Premium Reported Claims CDF to Ultimate % Reported Used-Up Premium Estimated Claim Ratio Expected Claims Projected Ultimate IBNR
1999 1000000.000 700000.0 1.000 1.000 1000000.0 0.700 700059.0 700000.0 0.0
2000 1050000.000 735000.0 1.000 1.000 1050000.0 0.700 735062.0 735000.0 0.0
2001 1102500.000 771750.0 1.000 1.000 1102500.0 0.700 771815.0 771750.0 0.0
2002 1157625.000 810338.0 1.000 1.000 1157625.0 0.700 810405.0 810338.0 0.0
2003 1215506.250 842346.0 1.010 0.990 1203472.0 0.700 850926.0 850771.0 8425.0
2004 1276281.563 884463.0 1.010 0.990 1263645.0 0.700 893472.0 893309.0 8846.0
2005 1340095.641 919306.0 1.020 0.980 1313819.0 0.700 938146.0 937791.0 18485.0
2006 1407100.423 935722.0 1.053 0.950 1336278.0 0.700 985053.0 985074.0 49352.0
2007 1477455.444 930797.0 1.112 0.899 1328647.0 0.701 1034306.0 1034718.0 103921.0
2008 1551328.216 836166.0 1.300 0.769 1193329.0 0.701 1086021.0 1086512.0 250346.0
Increasing Claim Ratios - all-years estimated claim ratio 0.807
Earned Premium Reported Claims CDF to Ultimate % Reported Used-Up Premium Estimated Claim Ratio Expected Claims Projected Ultimate IBNR
1999 1000000.000 700000.0 1.000 1.000 1000000.0 0.700 807293.0 700000.0 0.0
2000 1050000.000 735000.0 1.000 1.000 1050000.0 0.700 847658.0 735000.0 0.0
2001 1102500.000 771750.0 1.000 1.000 1102500.0 0.700 890041.0 771750.0 0.0
2002 1157625.000 810338.0 1.000 1.000 1157625.0 0.700 934543.0 810338.0 0.0
2003 1215506.250 842346.0 1.010 0.990 1203472.0 0.700 981270.0 852062.0 9716.0
2004 1276281.563 1010815.0 1.010 0.990 1263645.0 0.800 1030333.0 1021016.0 10201.0
2005 1340095.641 1116300.0 1.020 0.980 1313819.0 0.850 1081850.0 1137617.0 21317.0
2006 1407100.423 1203071.0 1.053 0.950 1336278.0 0.900 1135942.0 1259983.0 56912.0
2007 1477455.444 1263224.0 1.112 0.899 1328647.0 0.951 1192740.0 1383064.0 119840.0
2008 1551328.216 1194523.0 1.300 0.769 1193329.0 1.001 1252377.0 1483217.0 288694.0
Increasing Case Outstanding Strength - all-years estimated claim ratio 0.717
Earned Premium Reported Claims CDF to Ultimate % Reported Used-Up Premium Estimated Claim Ratio Expected Claims Projected Ultimate IBNR
1999 1000000.000 700000.0 1.000 1.000 1000000.0 0.700 716820.0 700000.0 0.0
2000 1050000.000 735000.0 1.000 1.000 1050000.0 0.700 752661.0 735000.0 0.0
2001 1102500.000 771750.0 1.000 1.000 1102500.0 0.700 790294.0 771750.0 0.0
2002 1157625.000 810338.0 1.000 1.000 1157625.0 0.700 829809.0 810338.0 0.0
2003 1215506.250 842346.0 1.010 0.990 1203472.0 0.700 871300.0 850973.0 8627.0
2004 1276281.563 884463.0 1.010 0.990 1263645.0 0.700 914865.0 893521.0 9058.0
2005 1340095.641 933377.0 1.019 0.981 1315109.0 0.710 960608.0 951371.0 17994.0
2006 1407100.423 962808.0 1.054 0.949 1335010.0 0.721 1008638.0 1014247.0 51439.0
2007 1477455.444 979922.0 1.117 0.895 1322700.0 0.741 1059070.0 1090823.0 110901.0
2008 1551328.216 931185.0 1.316 0.760 1178821.0 0.790 1112024.0 1198066.0 266881.0
Increasing Claim Ratios and Case Outstanding Strength - all-years estimated claim ratio 0.830
Earned Premium Reported Claims CDF to Ultimate % Reported Used-Up Premium Estimated Claim Ratio Expected Claims Projected Ultimate IBNR
1999 1000000.000 700000.0 1.000 1.000 1000000.0 0.700 830029.0 700000.0 0.0
2000 1050000.000 735000.0 1.000 1.000 1050000.0 0.700 871530.0 735000.0 0.0
2001 1102500.000 771750.0 1.000 1.000 1102500.0 0.700 915107.0 771750.0 0.0
2002 1157625.000 810338.0 1.000 1.000 1157625.0 0.700 960862.0 810338.0 0.0
2003 1215506.250 842346.0 1.010 0.990 1203472.0 0.700 1008905.0 852335.0 9989.0
2004 1276281.563 1010815.0 1.010 0.990 1263645.0 0.800 1059351.0 1021304.0 10489.0
2005 1340095.641 1133386.0 1.019 0.981 1315109.0 0.862 1112318.0 1154222.0 20836.0
2006 1407100.423 1237897.0 1.054 0.949 1335010.0 0.927 1167934.0 1297460.0 59563.0
2007 1477455.444 1329895.0 1.117 0.895 1322700.0 1.005 1226331.0 1458311.0 128416.0
2008 1551328.216 1330264.0 1.316 0.760 1178821.0 1.128 1287647.0 1639294.0 309030.0

Reconciliation to Friedland#

We reconcile the derived all-years claim ratio for all four scenarios, and the estimated IBNR total for the two scenarios with clean sample data.

pp_apriori = {name: apriori for name, (apriori, table) in pp_results.items()}
pp_ibnr = {name: table["IBNR"].sum() for name, (apriori, table) in pp_results.items()}

# All four scenarios reproduce the text's all-years estimated claim ratio.
assert np.isclose(pp_apriori["Steady-State"], 0.700, atol=2e-3)
assert np.isclose(pp_apriori["Increasing Claim Ratios"], 0.807, atol=2e-3)
assert np.isclose(pp_apriori["Increasing Case Outstanding Strength"], 0.717, atol=2e-3)
assert np.isclose(pp_apriori["Increasing Claim Ratios and Case Outstanding Strength"], 0.831, atol=2e-3)

# Estimated IBNR reconciles for the two scenarios with clean sample data.
assert np.isclose(pp_ibnr["Steady-State"], 438638, rtol=5e-3)
assert np.isclose(pp_ibnr["Increasing Claim Ratios"], 505828, rtol=5e-3)

Exhibit IV - U.S. Auto (Impact of Change in Product Mix)#

Exhibit IV applies the Cape Cod method to a combined private-passenger and commercial automobile portfolio under two scenarios (valued at 12/31/2008, accident years 1999-2008):

  1. Steady-State (No Change in Product Mix)

  2. Changing Product Mix (commercial auto growing faster than private passenger)

The earned premium follows the text’s Exhibit IV, Sheet 1 assumption: $2,000,000 for the first year (1999) with 5% annual growth, and in the change scenario commercial auto grows 30% per year from 2005. The development selection is the Chapter 7 five-year simple average with a 1.000 tail.

Cape Cod generates the correct IBNR when the product mix is stable, but understates it when the mix shifts, because the reporting pattern changes and the method does not respond appropriately.

# The friedland_us_auto sample carries all scenarios under a Scenario index, with
# earned premium per the text's Exhibit IV, Sheet 1 assumptions. Select the two
# product-mix scenarios by label.
us_auto = cl.load_sample("friedland_us_auto")
us_auto_scenarios = {
    "Steady-State (No Change in Product Mix)": "Steady State",
    "Changing Product Mix": "Changing Product Mix",
}


def us_auto_cc_scenario(scenario):
    """Recreate a U.S. Auto product-mix Cape Cod scenario (Exhibit IV)."""
    tri = us_auto.loc[scenario]
    reported = tri["Reported Claims"]
    premium = tri["Earned Premium"].latest_diagonal
    years = list(reported.origin.year)

    # Chapter 7 selection: five-year simple average reported development, 1.000 tail.
    reported_dev = cl.TailConstant(tail=1.0, projection_period=0).fit_transform(
        cl.Development(n_periods=5, average="simple").fit_transform(reported))
    reported_dev.ldf_ = reported_dev.ldf_.round(3)

    cc = cl.CapeCod().fit(reported_dev, sample_weight=premium)
    apriori = float(cc.apriori_.to_frame().iloc[0, 0])

    # model_diagnostics packages Latest, CDF, Ultimate, and IBNR by accident year.
    diag = cl.model_diagnostics(cc)
    cdf = np.maximum(col(diag["CDF"]), 1.0).round(3)
    prem = col(premium)
    reported_latest = col(diag["Latest"])
    used_up = prem / cdf
    # CapeCod returns NaN IBNR for fully developed years (CDF = 1.000); coerce to 0.
    ibnr = np.nan_to_num(col(diag["IBNR"]))

    out = pd.DataFrame(index=years)
    out["Earned Premium"] = prem
    out["Reported Claims"] = reported_latest
    out["CDF to Ultimate"] = cdf.round(3)
    out["% Reported"] = (1 / cdf).round(3)
    out["Used-Up Premium"] = used_up.round(0)
    out["Estimated Claim Ratio"] = (reported_latest / used_up).round(3)
    out["Expected Claims"] = (prem * apriori).round(0)
    out["Projected Ultimate"] = col(diag["Ultimate"]).round(0)
    out["IBNR"] = ibnr.round(0)
    return apriori, out


us_auto_results = {
    label: us_auto_cc_scenario(scenario) for label, scenario in us_auto_scenarios.items()
}
for name, (apriori, table) in us_auto_results.items():
    print(f"{name} - all-years estimated claim ratio {apriori:.3f}")
    display(table)
Steady-State (No Change in Product Mix) - all-years estimated claim ratio 0.750
Earned Premium Reported Claims CDF to Ultimate % Reported Used-Up Premium Estimated Claim Ratio Expected Claims Projected Ultimate IBNR
1999 2000000.0 1500000.0 1.000 1.000 2000000.0 0.750 1500509.0 1500000.0 0.0
2000 2100000.0 1575000.0 1.000 1.000 2100000.0 0.750 1575535.0 1575000.0 0.0
2001 2205000.0 1653750.0 1.000 1.000 2205000.0 0.750 1654312.0 1653750.0 0.0
2002 2315250.0 1736438.0 1.000 1.000 2315250.0 0.750 1737027.0 1736438.0 0.0
2003 2431013.0 1814751.0 1.005 0.995 2418918.0 0.750 1823879.0 1823825.0 9074.0
2004 2552563.0 1885068.0 1.016 0.984 2512365.0 0.750 1915072.0 1915329.0 30261.0
2005 2680191.0 1948499.0 1.032 0.969 2597084.0 0.750 2010826.0 2011439.0 62940.0
2006 2814201.0 1937577.0 1.090 0.917 2581836.0 0.750 2111367.0 2112126.0 174549.0
2007 2954911.0 1852729.0 1.197 0.835 2468597.0 0.751 2216936.0 2217516.0 364787.0
2008 3102656.0 1568393.0 1.484 0.674 2090739.0 0.750 2327782.0 2327823.0 759430.0
Changing Product Mix - all-years estimated claim ratio 0.750
Earned Premium Reported Claims CDF to Ultimate % Reported Used-Up Premium Estimated Claim Ratio Expected Claims Projected Ultimate IBNR
1999 2000000.0 1500000.0 1.000 1.000 2000000.0 0.750 1499954.0 1500000.0 0.0
2000 2100000.0 1575000.0 1.000 1.000 2100000.0 0.750 1574952.0 1575000.0 0.0
2001 2205000.0 1653750.0 1.000 1.000 2205000.0 0.750 1653699.0 1653750.0 0.0
2002 2315250.0 1736438.0 1.000 1.000 2315250.0 0.750 1736384.0 1736438.0 0.0
2003 2431013.0 1814751.0 1.005 0.995 2418918.0 0.750 1823204.0 1823822.0 9071.0
2004 2552563.0 1885068.0 1.016 0.984 2512365.0 0.750 1914363.0 1915317.0 30249.0
2005 2999262.0 2193545.0 1.032 0.969 2906262.0 0.755 2249377.0 2263952.0 70407.0
2006 3564016.0 2471446.0 1.090 0.917 3269739.0 0.756 2672930.0 2692420.0 220974.0
2007 4281446.0 2680487.0 1.200 0.833 3567872.0 0.751 3210986.0 3216150.0 535663.0
2008 5196516.0 2556695.0 1.500 0.667 3464344.0 0.738 3897267.0 3856268.0 1299573.0

Reconciliation to Friedland#

Both scenarios derive a 75.0% all-years claim ratio. The steady-state IBNR reconciles to the actual requirement, while the changing-product-mix IBNR is understated relative to the actual, as the text describes.

us_apriori = {name: apriori for name, (apriori, table) in us_auto_results.items()}
us_ibnr = {name: table["IBNR"].sum() for name, (apriori, table) in us_auto_results.items()}

# Both scenarios derive the text's 75.0% all-years estimated claim ratio.
assert np.isclose(us_apriori["Steady-State (No Change in Product Mix)"], 0.750, atol=2e-3)
assert np.isclose(us_apriori["Changing Product Mix"], 0.750, atol=2e-3)

# Steady-state reconciles to the actual IBNR requirement; the changing product mix
# understates it (the text's actual is 2,391,084).
assert np.isclose(us_ibnr["Steady-State (No Change in Product Mix)"], 1394634, rtol=6e-3)
assert np.isclose(us_ibnr["Changing Product Mix"], 2168000, rtol=6e-3)