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
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
and accident year 2006 as
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