Chapter 15 - Monitoring Unpaid Claim Estimates#

Deviations of actual development from projected development of claims or claim counts are one of the most useful diagnostic tools for evaluating the accuracy of the unpaid claim estimate.

– Friedland, Chapter 15

The last part of Chapter 15 is a roll-forward: take the ultimates and reporting pattern selected at one valuation, and compare actual reported claims in the next period with the amount that pattern said should emerge.

This notebook recreates Friedland’s DC Insurer monitoring exhibits (Exhibit IV, Sheets 1-4). friedland_dc_insurer is the quarterly reported triangle through 36 months, plus the 12/31/2007 and 12/31/2008 diagonals. Selected CDFs stop at 36 months (age-to-ult 1.000), so later ages are treated as fully reported.

Expected emergence in the next calendar period comes from the fitted Chainladder model: full_triangle_.dev_to_val() at the later valuation minus the prior latest_diagonal. That is the same pattern as the gallery Actual vs Expected example. For each accident year that is the Friedland formula

\[ \frac{\text{Ultimate}_{t_0} - \text{Reported}_{t_0}}{1 - p_{t_0}} \times (p_{t_1} - p_{t_0}) \]

where \(p_t = 1 / \text{CDF}_t\).

import numpy as np
import pandas as pd
import chainladder as cl
from IPython.display import display

pd.set_option("display.max_columns", None)
pd.set_option("display.width", 1000)


def as_series(tri):
    s = tri.to_frame(origin_as_datetime=False).iloc[:, 0]
    s.index = [int(getattr(i, "year", i)) for i in s.index]
    return s

Exhibit IV, Sheet 1 - Reported claims triangle#

DC Insurer reports quarterly. The sample carries ages 3 through 36, which is the printed Sheet 1 triangle. The selected tail is 1.000 at 36 months, so older accident years are carried at that age-36 value through 12/31/2007.

tri = cl.load_sample("friedland_dc_insurer")
tri = tri[tri.origin < "2008"]
tri_2007 = tri[tri.valuation < "2008"]

selected_cdf = {
    3: 4.125,
    6: 2.177,
    9: 1.487,
    12: 1.136,
    15: 1.016,
    18: 1.008,
    21: 1.003,
    24: 1.001,
    27: 1.000,
    30: 1.000,
    33: 1.000,
    36: 1.000,
}
for age in (int(a) for a in tri_2007.ddims):
    selected_cdf.setdefault(age, 1.000)

dev = cl.DevelopmentConstant(patterns=selected_cdf, style="cdf").fit_transform(tri_2007)
model = cl.Chainladder().fit(dev)

display(tri_2007[tri_2007.development <= 36])
display(dev.cdf_[dev.cdf_.ddims <= 36])
3 6 9 12 15 18 21 24 27 30 33 36
1997 861 1,668 2,459 3,255 3,366 3,385 3,385 3,374 3,372 3,374 3,376 3,376
1998 878 1,493 2,248 2,756 2,826 2,812 2,805 2,804 2,785 2,787 2,787 2,788
1999 463 786 1,166 1,605 1,673 1,642 1,646 1,645 1,649 1,649 1,649 1,649
2000 511 806 1,112 1,530 1,684 1,689 1,686 1,688 1,686 1,687 1,687 1,687
2001 414 750 1,264 1,836 2,088 2,078 2,081 2,086 2,086 2,087 2,087 2,088
2002 502 961 1,424 2,016 2,307 2,330 2,342 2,348 2,352 2,354 2,355 2,355
2003 614 1,231 1,940 2,576 2,878 2,936 2,977 2,988 2,992 2,992 2,994 2,994
2004 833 1,576 2,181 3,048 3,407 3,406 3,397 3,403 3,407 3,410 3,412 3,412
2005 675 1,248 1,833 2,601 2,792 2,791 2,803 2,810 2,813 2,814 2,814 2,814
2006 764 1,374 2,157 2,531 2,897 2,930 2,945 2,949
2007 754 1,468 1,987 2,463
3-Ult 6-Ult 9-Ult 12-Ult 15-Ult 18-Ult 21-Ult 24-Ult 27-Ult 30-Ult 33-Ult 36-Ult
(All) 4.1250 2.1770 1.4870 1.1360 1.0160 1.0080 1.0030 1.0010 1.0000 1.0000 1.0000 1.0000

Exhibit IV, Sheet 2 - Ultimates at 12/31/2007#

DC Insurer selects ultimates with the reported development technique. Age 12 uses a 1.136 CDF (88.0% reported); age 24 uses 1.001 (99.9% reported); age 36 and later are 1.000.

model_diagnostics pulls Latest, CDF, and Ultimate from the fitted Chainladder model. The text’s worked example for accident year 2007 is \(2{,}463 \times 1.136 = 2{,}798\).

diag = cl.model_diagnostics(model)
years = [int(year) for year in tri_2007.origin.year]
sheet2 = pd.DataFrame(index=years)
sheet2["Age"] = (2007 - sheet2.index + 1) * 12
sheet2["Reported at 12/31/07"] = as_series(diag["Latest"])
sheet2["CDF to Ultimate"] = np.round(as_series(diag["CDF"]), 3)
sheet2["Projected Ultimate"] = np.round(as_series(diag["Ultimate"]), 0)
display(sheet2)
print(f"Total projected ultimate: {sheet2['Projected Ultimate'].sum():,.0f}")
Age Reported at 12/31/07 CDF to Ultimate Projected Ultimate
1997 132 3376.0 1.000 3376.0
1998 120 2788.0 1.000 2788.0
1999 108 1649.0 1.000 1649.0
2000 96 1687.0 1.000 1687.0
2001 84 2088.0 1.000 2088.0
2002 72 2355.0 1.000 2355.0
2003 60 2994.0 1.000 2994.0
2004 48 3412.0 1.000 3412.0
2005 36 2814.0 1.000 2814.0
2006 24 2949.0 1.001 2952.0
2007 12 2463.0 1.136 2798.0
Total projected ultimate: 28,913

Exhibit IV, Sheet 3 - Annual monitoring test#

One year later, compare calendar-year 2008 actual reported claims with the amount implied by the 12/31/2007 model. Slice full_triangle_ at the 2008 valuation for the expected cumulative; subtract the 2007 latest_diagonal for expected emergence.

The text works accident year 2007 as

\[ \frac{2{,}798 - 2{,}463}{1 - 0.880} \times (0.999 - 0.880) = 332 \]

and accident year 2006 as

\[ \frac{2{,}952 - 2{,}949}{1 - 0.999} \times (1.000 - 0.999) = 3. \]

Older years are fully reported, so expected emergence is zero.

expected_cum = model.full_triangle_.dev_to_val()
expected_cum = expected_cum[expected_cum.valuation == tri.valuation_date]

reported_2007 = as_series(tri_2007.latest_diagonal)
reported_2008 = as_series(tri.latest_diagonal)
expected = np.round(as_series(expected_cum) - reported_2007, 0)
actual = reported_2008 - reported_2007

pct_2007 = 1.0 / as_series(diag["CDF"]).to_numpy()
cdf_2008 = np.array([selected_cdf.get(int(age) + 12, 1.000) for age in sheet2["Age"]])
pct_2008 = 1.0 / cdf_2008

sheet3 = pd.DataFrame(index=years)
sheet3["Selected Ultimate"] = sheet2["Projected Ultimate"]
sheet3["% Reported 12/31/07"] = np.round(pct_2007, 3)
sheet3["% Reported 12/31/08"] = np.round(pct_2008, 3)
sheet3["Reported 12/31/07"] = reported_2007
sheet3["Reported 12/31/08"] = reported_2008
sheet3["Actual"] = actual
sheet3["Expected"] = expected
sheet3["Difference"] = actual - expected
display(sheet3)
display(sheet3[["Actual", "Expected", "Difference"]].sum().rename("Total").to_frame().T)
Selected Ultimate % Reported 12/31/07 % Reported 12/31/08 Reported 12/31/07 Reported 12/31/08 Actual Expected Difference
1997 3376.0 1.000 1.000 3376.0 3376.0 0.0 0.0 0.0
1998 2788.0 1.000 1.000 2788.0 2788.0 0.0 0.0 0.0
1999 1649.0 1.000 1.000 1649.0 1649.0 0.0 0.0 0.0
2000 1687.0 1.000 1.000 1687.0 1687.0 0.0 0.0 0.0
2001 2088.0 1.000 1.000 2088.0 2096.0 8.0 0.0 8.0
2002 2355.0 1.000 1.000 2355.0 2340.0 -15.0 0.0 -15.0
2003 2994.0 1.000 1.000 2994.0 3007.0 13.0 0.0 13.0
2004 3412.0 1.000 1.000 3412.0 3392.0 -20.0 0.0 -20.0
2005 2814.0 1.000 1.000 2814.0 2885.0 71.0 0.0 71.0
2006 2952.0 0.999 1.000 2949.0 3030.0 81.0 3.0 78.0
2007 2798.0 0.880 0.999 2463.0 2733.0 270.0 332.0 -62.0
Actual Expected Difference
Total 408.0 335.0 73.0

Exhibit IV, Sheet 4 - Monthly monitoring test#

DC Insurer has quarterly development factors. Monthly percent-reported values are linear interpolations of the quarterly percent reported. Between age 12 (88.0%) and age 15 (\(1 / 1.016 \approx 98.4%\)) that gives 91.5% at 13 months and 95.0% at 14 months, matching the printed January / February 2008 template.

Expected monthly emergence uses the fitted 12/31/2007 ibnr_ and those interpolated percents for both January and February: \(\text{IBNR}_{12/31/07} \times (p_{t+1} - p_t) / (1 - p_{12/31/07})\). That is Friedland’s column notes (10) and (13): February does not rebase on January actuals. The quarterly triangle cannot hold monthly CDFs, so the interpolation stays on the selected pattern rather than on DevelopmentConstant.

def pct_reported_at(age):
    """Linearly interpolate percent reported between quarterly CDF ages."""
    knots = np.array(sorted(selected_cdf))
    pcts = 1.0 / np.array([selected_cdf[k] for k in knots])
    if age <= knots[0]:
        return float(pcts[0])
    if age >= knots[-1]:
        return 1.0
    return float(np.interp(age, knots, pcts))


pct_jan = np.array([pct_reported_at(age + 1) for age in sheet2["Age"]])
pct_feb = np.array([pct_reported_at(age + 2) for age in sheet2["Age"]])

# Printed latest reported at 1/31/08 and 2/29/08 for the two immature years.
reported_jan = reported_2007.copy()
reported_feb = reported_2007.copy()
reported_jan.loc[2007] = 2473
reported_feb.loc[2007] = 2538
reported_jan.loc[2006] = 2951
reported_feb.loc[2006] = 2986
reported_jan.loc[2005] = 2825
reported_feb.loc[2005] = 2832
reported_jan.loc[2004] = 3422
reported_feb.loc[2004] = 3422
reported_feb.loc[2003] = 2998
reported_jan.loc[2001] = 2096
reported_feb.loc[2001] = 2096

ibnr = as_series(model.ibnr_)
unreported = 1.0 - pct_2007
scale = np.divide(
    ibnr.to_numpy(), unreported, out=np.zeros(len(ibnr)), where=unreported > 0
)
expected_jan = np.round(scale * (pct_jan - pct_2007), 0)
expected_feb = np.round(scale * (pct_feb - pct_jan), 0)

actual_jan = reported_jan - reported_2007
actual_feb = reported_feb - reported_jan

sheet4 = pd.DataFrame(index=years)
sheet4["Selected Ultimate"] = sheet2["Projected Ultimate"]
sheet4["% Reported 12/31/07"] = np.round(pct_2007, 3)
sheet4["% Reported 1/31/08"] = np.round(pct_jan, 3)
sheet4["% Reported 2/29/08"] = np.round(pct_feb, 3)
sheet4["Reported 12/31/07"] = reported_2007
sheet4["Reported 1/31/08"] = reported_jan
sheet4["Reported 2/29/08"] = reported_feb
sheet4["Actual Jan"] = actual_jan
sheet4["Expected Jan"] = expected_jan
sheet4["Diff Jan"] = actual_jan - expected_jan
sheet4["Actual Feb"] = actual_feb
sheet4["Expected Feb"] = expected_feb
sheet4["Diff Feb"] = actual_feb - expected_feb
display(sheet4)
display(
    sheet4[
        [
            "Actual Jan",
            "Expected Jan",
            "Diff Jan",
            "Actual Feb",
            "Expected Feb",
            "Diff Feb",
        ]
    ]
    .sum()
    .rename("Total")
    .to_frame()
    .T
)
Selected Ultimate % Reported 12/31/07 % Reported 1/31/08 % Reported 2/29/08 Reported 12/31/07 Reported 1/31/08 Reported 2/29/08 Actual Jan Expected Jan Diff Jan Actual Feb Expected Feb Diff Feb
1997 3376.0 1.000 1.000 1.00 3376.0 3376.0 3376.0 0.0 0.0 0.0 0.0 0.0 0.0
1998 2788.0 1.000 1.000 1.00 2788.0 2788.0 2788.0 0.0 0.0 0.0 0.0 0.0 0.0
1999 1649.0 1.000 1.000 1.00 1649.0 1649.0 1649.0 0.0 0.0 0.0 0.0 0.0 0.0
2000 1687.0 1.000 1.000 1.00 1687.0 1687.0 1687.0 0.0 0.0 0.0 0.0 0.0 0.0
2001 2088.0 1.000 1.000 1.00 2088.0 2096.0 2096.0 8.0 0.0 8.0 0.0 0.0 0.0
2002 2355.0 1.000 1.000 1.00 2355.0 2355.0 2355.0 0.0 0.0 0.0 0.0 0.0 0.0
2003 2994.0 1.000 1.000 1.00 2994.0 2994.0 2998.0 0.0 0.0 0.0 4.0 0.0 4.0
2004 3412.0 1.000 1.000 1.00 3412.0 3422.0 3422.0 10.0 0.0 10.0 0.0 0.0 0.0
2005 2814.0 1.000 1.000 1.00 2814.0 2825.0 2832.0 11.0 0.0 11.0 7.0 0.0 7.0
2006 2952.0 0.999 0.999 1.00 2949.0 2951.0 2986.0 2.0 1.0 1.0 35.0 1.0 34.0
2007 2798.0 0.880 0.915 0.95 2463.0 2473.0 2538.0 10.0 97.0 -87.0 65.0 97.0 -32.0
Actual Jan Expected Jan Diff Jan Actual Feb Expected Feb Diff Feb
Total 41.0 98.0 -57.0 111.0 98.0 13.0

Reconciliation#

# Exhibit IV, Sheet 1
sheet1 = tri_2007[tri_2007.development <= 36].to_frame(origin_as_datetime=False)
sheet1.index = [int(getattr(i, "year", i)) for i in sheet1.index]
assert sheet1.loc[1997, 3] == 861
assert sheet1.loc[2007, 12] == 2463
assert sheet1.loc[2006, 24] == 2949

# Exhibit IV, Sheet 2
assert sheet2.loc[2007, "Projected Ultimate"] == 2798
assert sheet2.loc[2006, "Projected Ultimate"] == 2952

# Exhibit IV, Sheet 3
assert sheet3.loc[2007, "% Reported 12/31/07"] == 0.880
assert sheet3.loc[2007, "% Reported 12/31/08"] == 0.999
assert sheet3.loc[2007, "Expected"] == 332
assert sheet3.loc[2006, "Expected"] == 3
assert sheet3["Expected"].sum() == 335

# Exhibit IV, Sheet 4 - interpolated percent reported and expected emergence
assert np.isclose(sheet4.loc[2007, "% Reported 1/31/08"], 0.915, atol=5e-4)
assert np.isclose(sheet4.loc[2007, "% Reported 2/29/08"], 0.950, atol=5e-4)
assert sheet4.loc[2007, "Expected Jan"] == 97
assert sheet4.loc[2007, "Expected Feb"] == 97
assert sheet4.loc[2006, "Expected Jan"] == 1
assert sheet4.loc[2006, "Expected Feb"] == 1