Example 3: claims to capital¶
One block of business carried across the whole ecosystem: fit a severity body
(lossmodels), diagnose and fit the tail (extremeloss), splice, convolve
with frequency, reinsure and measure the capital (risksim), price it
(ratingmodels), and report the result (actuarialpy). Every number on this
page is the output of this exact fixed-seed run. The page is pinned by a
regression test in the extremeloss suite, so these numbers cannot silently
drift.
import numpy as np
import lossmodels as lm
import extremeloss as xl
import risksim as rs
import ratingmodels as rm
import actuarialpy as ap
rng = np.random.default_rng(20260702)
# stand-in for a claims extract: 2,500 ground-up severities
losses = lm.Burr(2.2, 20_000, 1.6).sample(2500, rng=rng)
Fit the body¶
Complete-data maximum likelihood is one call (fitting under deductibles and limits is covered on the lossmodels page):
body = lm.fit_lognormal(losses)
# -> Lognormal(mu=9.204, sigma=0.945)
Diagnose and fit the tail¶
Scan candidate thresholds before committing to one:
scan = xl.threshold_diagnostic_table(
losses, np.quantile(losses, [0.90, 0.925, 0.95, 0.975])
)
# threshold 29,289 32,666 38,886 48,917
# n_exc 250 188 125 63
# xi 0.242 0.206 0.217 -0.041
The shape estimate is stable around \(\hat\xi \approx 0.21\)–\(0.24\) through the 95th percentile and degenerates at the 97.5th, where only 63 exceedances remain — so take the threshold at the 95th:
u = float(np.quantile(losses, 0.95))
fit = xl.fit_pot(losses, threshold=u)
# -> GPDFit: threshold=38,886 xi=0.217 beta=15,281 (125 exceedances, 5.0%)
xl.return_level(200, fit)
# -> 84,555 (the 1-in-200 claim; identical to gpd_var(0.995, ...))
Splice and convolve¶
The fitted tail reattaches to the lognormal body as a single
lossmodels.SplicedSeverity (mass-matching at the threshold — see
Conventions), and eight years of
claim counts give the frequency:
sev = xl.splice_gpd_tail(body, fit)
# -> SplicedSeverity, mean 13,937
counts = np.array([242, 166, 153, 164, 195, 163, 162, 176])
freq = lm.fit_negbinomial(counts)
# -> NegativeBinomial(r=65.3, p=0.269), mean ~178 claims per year
crm = lm.CollectiveRiskModel(freq, sev)
crm.mean()
# -> 2,475,636
Reinsure and measure the capital¶
The collective risk model drops straight into a risksim portfolio — anything
with a .sample method does. An aggregate stop-loss of 1.5M excess of 3.2M
(limit is the layer width, per the
coverage semantics) reshapes the tail:
port = rs.Portfolio([rs.PortfolioItem("commercial_block", crm)])
treaty = rs.AggregateLayer(attachment=3_200_000, limit=1_500_000,
name="agg_stop_loss")
res = port.simulate(100_000, contract=treaty, rng=7)
rs.metrics.var(res.gross_losses, 0.99) # -> 3,503,943
rs.metrics.tvar(res.gross_losses, 0.99) # -> 3,683,961
res.ceded_losses.mean() # -> 9,168
rs.metrics.tvar(res.retained_losses, 0.99) # -> 3,200,000
The retained TVaR₉₉ sits exactly at the attachment: gross TVaR₉₉ is below the
layer’s exhaustion point (4.7M), so the treaty pins the 1-in-100 retained tail
at 3.2M. The rng argument threads one generator through the simulation —
rerunning with rng=7 reproduces every array bit for bit.
Price it¶
exposure = 12_500.0
loss_cost = crm.mean() / exposure # -> 198.05 per unit-year
ret = rm.RetentionLoad(fixed_expense=22.0, variable_expense_ratio=0.09,
profit_margin=0.03, lae_ratio=0.05)
pe = rm.PricingEvaluation(loss_cost=loss_cost, current_rate=255.0,
retention=ret, exposure=exposure, persistency=0.90)
pe.premium_for_margin(0.03) # -> 261.31
pe.at(0.0).margin_rate # -> 2.10 (dollars per unit — a 0.8% margin at 255)
The capital view informs the margin: a 6% cost of capital on the retained 1-in-100 requirement (TVaR₉₉ less the mean, 730,705) is 3.51 per unit-year, comfortably inside the 7.84 per unit that a 3% margin on 261.31 provides.
Report it¶
uw = ap.UnderwritingSummary.from_per_exposure(
revenue_per_exposure={"premium": 261.31},
loss_per_exposure={"expected_losses": 198.05 * 1.05}, # incl. LAE
expense_per_exposure=0.09 * 261.31 + 22.0,
exposure=exposure,
)
uw.loss_ratio # -> 0.7958
uw.expense_ratio # -> 0.1742
uw.combined_ratio # -> 0.9700
uw.gain_per_exposure # -> 7.84
The identities close the loop: the combined ratio is \(1 - 0.03\) — exactly one minus the priced margin, because the denominators line up — and the gain per unit equals margin × premium. That reconciliation is not a coincidence; it is the contract pinned on the conventions page.