Applications · Section 2
Risk profiling: the post-model report
How a cat modeller turns a cedent's loss sets into the post-model report an underwriter reads before structuring a deal: loss-set reconciliation, exposure summary, and the subject loss risk profile.
The business problem
Section titled “The business problem”It is renewal season at Caldera Re. SunCoast Insurance (the multi-peril US insurer behind three of the six contracts in the portfolio) wants its reinsurance program renewed, and its broker has sent the submission: the package of exposure data, historical losses, and desired structure that opens every placement.
The cat modeller has run SunCoast’s exposure data through the vendor models. Now comes the deliverable this story is about: the post-model report, the write-up of model output that characterises the cedent’s exposure and subject loss for the underwriter, before any contract is applied. Names for this artifact vary by organisation (modelling summary, account analytics report). This story works from what a report of this kind has to answer, not from any one organisation’s format.
The underwriter prices off the model output in this report rather than rerunning the models.1 That makes the report’s job precise: answer the questions the underwriter would otherwise have to guess at.
- What arrived, and does it validate and reconcile? Which loss sets, and from which models?
- Where is the book exposed? The book is the set of risks SunCoast has underwritten: the policies whose losses this submission is asking Caldera Re to help carry. Which perils, geographies, and lines of business carry the risk?
- What does the subject loss look like? Expected loss, volatility, and, above all, the tail.
- What drives the tail? One giant event, or many medium ones? Which peril, which geography?
Everything in this story uses tools the Analytics Toolkit already built: trials and year event loss tables (YELTs), exceedance probability (EP) curves, and the metrics expected loss (EL), Value at Risk (VaR), and Tail Value at Risk (TVaR). This is the Toolkit exercised end to end on a real task: measuring the object before anything transforms it. The measuring starts where the vendor models stop, with one loss set per peril.
The pipeline
Section titled “The pipeline”Everything left of the loss sets (exposure data preparation, geocoding, model configuration) is the catastrophe modelling process itself, and stays out of scope here (the trial worldview page sketches where trials come from; model internals are a discipline of their own). Our pipeline starts where the model output arrives: one loss set per peril, each a YELT. Each arrives with a label: a free-text title, and fields that claim what the YELT holds (the cedent, the peril, the regions, the model and the date of the run).
The split by peril is the normal state of affairs, not an inconvenience. Hurricane and earthquake come from different models, and a company’s standard view can name a different vendor for each peril; either way the event catalogs differ.2 A submission covering two perils arrives as two loss sets, and nothing guarantees they agree on schema, units, or conventions until you validate and reconcile them.
Step 1: validate and reconcile the loss sets
Section titled “Step 1: validate and reconcile the loss sets”Before any metric is quoted, the analyst answers a prior question: do these loss sets validate and reconcile? The workflow is the same everywhere, even when the tooling differs:
- Validate each loss set at the ingestion boundary: schema, sign convention, trial range, and the label against the rows. One check per invariant, and each raises on failure.
- Compute standalone metrics per loss set: each peril’s distribution on its own.
- Combine and recompute: the same metrics on the row union of the loss sets, once they agree on the trial count and no cedent’s peril is delivered twice.
- Reconcile: check that standalone and combined views relate the way arithmetic says they must.
The reconciliation step is a real check because expected loss and the tail metrics behave differently under combination, and only one of them gives an exact check. Expected loss is a mean, and trial by trial the combined loss is the hurricane loss plus the earthquake loss, so the means add exactly:
That identity is the check: if the combined EL is anything other than the sum of the parts, something is structurally wrong (duplicated rows, a dropped trial, a faulty join). A quantile carries no such identity. The combined is not the sum of the standalone VaRs, and, as the numbers below show, the difference is not an error to investigate but diversification, measured for the first time.
The code takes the submission the way it arrives: a list of loss sets, each a YELT with its label. loss_sets.py holds the checks. For each loss set, validate() derives the cedent, the peril and the regions from the rows and compares them with the label, because a mislabelled loss set passes every check on the table itself. The demo data ships one file per cedent, so this module’s loader cuts SunCoast’s file into the two loss sets a modelling team would deliver, each with its own row numbering; the validation and the row union assume nothing about where a loss set came from. combine() also refuses loss sets that disagree on the trial count. That check is necessary, not sufficient. A row union is a sum by trial_id only when the loss sets come from one simulation, where trial is the same simulated future in each. Two separate simulations with the same trial count pass the check. SunCoast’s two loss sets meet the condition because the demo data’s YELTs are sampled from one simulation. Delivered by two separate models, as their labels describe, they would share no trials. Adding them trial by trial would treat hurricane and earthquake losses as independent, a modelling assumption the rows cannot establish; portfolio analytics covers the case.
"""A submission as it arrives: a list of loss sets, each a YELT with a label.
A cedent's submission is not one table. Each peril is run through its own
catastrophe model, and each model run is delivered as a loss set: a YELT plus
what the modelling team wrote about it. The title is free text. The other
fields of the label (cedent, peril, regions, model, run date) are what the
label claims the YELT holds, and a claim is checked against the rows before
anything is computed from them.
The demo data ships one CSV per cedent, so `suncoast_submission()` cuts
SunCoast's file into the two loss sets a modelling team would deliver, each
with its own row index, as two separate files would have. That cut is the
loader's. `validate()` and `combine()` take loss sets from anywhere and assume
nothing about where they came from.
"""
from dataclasses import dataclass
import pandas as pd
from caldera_re.data import cedents
from caldera_re.utils import trial_count
SCHEMA = ['trial_id', 'timestamp', 'event_id', 'peril', 'geography_id',
'lob_id', 'cedent_id', 'loss', 'tiv']
@dataclass(frozen=True, eq=False)
class LossSet:
"""One model run's YELT and the label it was delivered with."""
title: str # free text, as the modelling team named the run
cedent: str
peril: str
regions: tuple # the geographies the run is said to cover
model: str
run_date: str
yelt: pd.DataFrame
def contents(yelt: pd.DataFrame) -> dict:
"""What the rows themselves say the loss set holds."""
return {
'cedents': sorted(yelt['cedent_id'].unique()),
'perils': sorted(yelt['peril'].unique()),
'regions': sorted(yelt['geography_id'].unique()),
'rows': len(yelt),
'trials': trial_count(yelt),
}
def _require(condition, title: str, problem: str) -> None:
"""One check. Raises instead of asserting: `python -O` strips asserts, and
a validation that can be switched off is not a validation."""
if not condition:
raise ValueError(f"{title}: {problem}")
def validate(ls: LossSet) -> dict:
"""Check one loss set at the ingestion boundary; return its contents.
Two kinds of check. The first kind is about the table: schema, sign
convention, trial range. The second is about the label: the cedent and
peril it names must be the ones the rows hold, and no row may fall outside
the regions it names. A mislabelled or mis-scoped loss set passes every
check of the first kind.
"""
y = ls.yelt
_require(list(y.columns) == SCHEMA, ls.title, "schema drift")
_require(len(y) > 0, ls.title, "no rows")
n = trial_count(y)
_require((y['loss'] > 0).all(), ls.title, "sign-convention violation in loss")
_require((y['tiv'] > 0).all(), ls.title, "sign-convention violation in tiv")
_require(y['trial_id'].between(1, n).all(), ls.title, "trial_id outside 1..N")
found = contents(y)
_require(found['cedents'] == [ls.cedent], ls.title,
f"labelled {ls.cedent}, holds {found['cedents']}")
_require(found['perils'] == [ls.peril], ls.title,
f"labelled {ls.peril}, holds {found['perils']}")
outside = sorted(set(found['regions']) - set(ls.regions))
_require(not outside, ls.title,
f"labelled {sorted(ls.regions)}, also holds {outside}")
return found
def combine(loss_sets: list) -> pd.DataFrame:
"""The submission's combined YELT: the row union of its loss sets.
Every loss set is validated here, so a combined YELT is a validated one
whoever asks for it. A row union is a sum of losses only if the loss sets
share trial identity (trial 7 is the same simulated future in each) and no
cedent's peril is delivered twice, which would count that loss twice.
The trial-count check below is necessary, not sufficient: two separate
simulations with the same number of trials pass it. Shared trial identity
comes from how the loss sets were produced (one simulation), which the
rows do not show.
"""
if not loss_sets:
raise ValueError("a submission needs at least one loss set")
for ls in loss_sets:
validate(ls)
counts = {trial_count(ls.yelt) for ls in loss_sets}
if len(counts) != 1:
raise ValueError(f"loss sets disagree on the trial count: {sorted(counts)}")
scopes = [(ls.cedent, ls.peril) for ls in loss_sets]
if len(set(scopes)) != len(scopes):
raise ValueError(f"a cedent's peril is delivered twice: {scopes}")
# Each loss set has its own row index, so the union takes a new one. The
# trial count is set on the result explicitly: concat keeps attrs only
# when every input carries the same ones.
combined = pd.concat([ls.yelt for ls in loss_sets], ignore_index=True)
combined.attrs['n_trials'] = counts.pop()
return combined
def suncoast_submission() -> list:
"""SunCoast's 2024 renewal submission: two loss sets, one per peril.
The model names and run dates are placeholders. They name no vendor and
no product release.
"""
yelt = cedents.suncoast
def run(peril):
# A delivered file has its own row numbering and is nobody's slice.
return yelt[yelt['peril'] == peril].reset_index(drop=True)
return [
LossSet(title='SunCoast 2024 renewal - US hurricane, run 3 FINAL',
cedent='SunCoast', peril='HU',
regions=('FL', 'GA', 'LA', 'MS', 'TX'),
model='hurricane model A', run_date='2023-11-06', yelt=run('HU')),
LossSet(title='SunCoast_EQ_CA-AZ_2024 (resubmitted)',
cedent='SunCoast', peril='EQ',
regions=('AZ', 'CA'),
model='earthquake model B', run_date='2023-11-20', yelt=run('EQ')),
]
if __name__ == '__main__':
for ls in suncoast_submission():
found = validate(ls)
print(f"{ls.title!r}")
print(f" label : {ls.cedent}, {ls.peril}, regions {list(ls.regions)}, "
f"{ls.model}, run {ls.run_date}")
print(f" contents: {found['cedents'][0]}, {found['perils'][0]}, "
f"regions {found['regions']}, {found['rows']} rows, "
f"{found['trials']} trials") loss_set_reconciliation.py validates the list, shows the label check rejecting the earthquake run relabelled as hurricane (a valid schema, positive losses, a valid trial range, and the wrong peril), then combines and compares:
"""Loss-set preparation and reconciliation for the SunCoast submission.
A submission rarely arrives as one table. Each peril is modelled
separately -- a hurricane model and an earthquake model, often from
different vendors -- and each model run arrives as its own loss set.
Before any risk profile is computed, the analyst validates every loss
set and checks that the combined view reconciles with the parts.
Metrics are read off AEP curves with the shared Toolkit helpers
(aep, expected_loss, standard_deviation, var, tvar) -- the same
functions used everywhere else on the site, not re-derived here.
"""
from dataclasses import replace
from caldera_re.aep import aep
from caldera_re.expected_loss import expected_loss
from caldera_re.loss_sets import combine, suncoast_submission, validate
from caldera_re.report import heading
from caldera_re.standard_deviation import standard_deviation
from caldera_re.utils import trial_count
from caldera_re.var import var
from caldera_re.tvar import tvar
ALPHA = 0.90 # confidence level for the tail metrics
# The submission arrives as a list of loss sets: one YELT per model run, each
# with a label (a free-text title, and the cedent, peril, regions, model and
# run date it claims).
submission = suncoast_submission()
# --- Validate each loss set at the ingestion boundary ----------------
# validate() (loss_sets.py) makes one check per invariant and
# raises on the first that fails: schema, sign convention, trial range, and
# the label against the rows. These are the silent killers: none of them
# crashes anything downstream.
for ls in submission:
found = validate(ls)
print(f"{ls.title!r}: {found['rows']} rows, {found['trials']} trials, "
f"{found['perils']} in {found['regions']}")
print("Validation passed: schema, sign convention, trial range, label\n")
# A label can be wrong while the table is fine. Relabel the earthquake run
# as hurricane: every check on the table still passes, and the label check
# stops it.
mislabelled = replace(submission[1], peril='HU')
try:
validate(mislabelled)
except ValueError as error:
print(f"Rejected: {error}\n")
# combine() validates every loss set again, then takes the row union once
# they agree on the trial count and no cedent's peril is delivered twice.
yelt = combine(submission)
loss_sets = {ls.peril: ls.yelt for ls in submission}
# --- Standalone metrics per loss set, then combined ------------------
# aep() ranks total loss per trial into an AEP curve; the metric
# helpers read off that curve. Each loss set records its trial count, so a
# loss set missing a trial entirely still ranks it, at zero loss.
def summarize(df):
curve = aep(df)
return {
'rows': len(df),
'el': expected_loss(curve),
'sd': standard_deviation(curve),
'var': var(curve, ALPHA),
'tvar': tvar(curve, ALPHA),
'max': curve['loss'].max(),
}
rows = {name: summarize(ls) for name, ls in loss_sets.items()}
rows['Combined'] = summarize(yelt)
heading(f"Loss-set reconciliation: SunCoast, {trial_count(yelt)} trials (AEP basis)")
header = f"{'Loss set':10s} {'Rows':>5s} {'EL':>8s} {'Std':>8s} " \
f"{'VaR90':>8s} {'TVaR90':>8s} {'Max':>8s}"
print(header)
print("-" * len(header))
for name, m in rows.items():
print(f"{name:10s} {m['rows']:5d} {m['el']:7.1f}M {m['sd']:7.1f}M "
f"{m['var']:7.1f}M {m['tvar']:7.1f}M {m['max']:7.1f}M")
sum_el = rows['HU']['el'] + rows['EQ']['el']
sum_var = rows['HU']['var'] + rows['EQ']['var']
sum_tvar = rows['HU']['tvar'] + rows['EQ']['tvar']
print("-" * len(header))
print(f"{'Sum':10s} {'':5s} {sum_el:7.1f}M {'':8s} "
f"{sum_var:7.1f}M {sum_tvar:7.1f}M")
print()
print("Reconciliation:")
print(f" EL adds exactly: {rows['HU']['el']:.1f} + {rows['EQ']['el']:.1f}"
f" = {sum_el:.1f} = combined {rows['Combined']['el']:.1f}")
print(f" -> a necessary check: a mismatch means duplicated rows,")
print(f" dropped trials, or a bad join. It is not sufficient --")
print(f" a matching EL says nothing about the tail or event order.")
print(f" Tail does NOT add: combined VaR90 {rows['Combined']['var']:.1f}M"
f" < {sum_var:.1f}M sum.")
print(f" -> the perils reach their worst losses in different trials,")
print(f" so the combined tail diversifies below the naive sum.") All figures below come from the 20-trial demo tier.
| Loss set | Rows | EL | Std | VaR(90%) | TVaR(90%) | Max |
|---|---|---|---|---|---|---|
| HU | 400 | $254.6M | $133.0M | $471.8M | $532.4M | $593.0M |
| EQ | 1,257 | $21.0M | $6.9M | $31.2M | $32.7M | $34.2M |
| Sum | $275.6M | $503.0M | $565.1M | |||
| Combined | 1,657 | $275.6M | $135.3M | $489.1M | $552.6M | $616.2M |
The EL check passes exactly: $254.6M + $21.0M = $275.6M, matching the combined loss set’s EL and the average loss the Toolkit established for SunCoast. The row counts are YELT rows, not occurrences (one row per occurrence, cedent, geography, and line of business), so the 1,657 rows collapse to 530 occurrences, 26.5 per trial on average; Trial 9, the one the Financial Modelling chapter dissects, holds 30.
The tail is the more interesting half. The standalone VaR(90%)s sum to $503.0M, but the combined VaR(90%) is $489.1M, $14M lower, and the reason is concrete. With trials the 90% tail is the second-worst trial (); hurricane’s second-worst trial is trial 2, earthquake’s is trial 12: different trials. The $503.0M sum implicitly prices a trial in which both perils hit their second-worst at once, which never happens in the data. The combined VaR is instead the total loss of trial 2, the second-worst trial by total loss, in which earthquake contributed only $17.3M rather than its own 1-in-10 amount of $31.2M. That $14M shortfall is diversification: the perils reach their tails in different trials, so the tail of the book’s subject loss comes in below the sum of the standalone tails.
The sum of standalone VaRs is the no-diversification benchmark: the subject-loss tail the book would show if the two perils always peaked together (the comonotonic case, both perils ranking every trial identically, in which VaR genuinely does add). The distance from that benchmark down to the combined tail is the first quantitative measure of diversification benefit; the portfolio analytics story measures it properly, across many contracts at once. One honest caveat on the benchmark: plain VaR is not guaranteed to fall below the standalone sum the way it does here. It is the reason the site prefers TVaR, which is sub-additive and can never exceed the sum of standalone TVaRs. Here VaR falls below the standalone sum; the analysis can proceed.
The qualifier “the way it does here” is deliberate. Take two independent exposures, each paying $100M with probability 6% and nothing otherwise. A 6% loss sits past the 1-in-10 cutoff, so each standalone is $0, and so is their sum. But combine them: the chance that at least one pays rises to , which clears the 10% tail, so the combined is $100M, above the $0 sum. Nothing became riskier; pooling moved a loss across VaR’s single quantile cutoff, and VaR, which ignores everything past that point, missed it. This is the formal statement that VaR is not sub-additive, and the regime it fails in (rare losses whose probabilities sit near the quantile, weakly dependent across sources) is the one a cat reinsurer writes in.3 TVaR averages the whole tail, so a loss slightly beyond the cutoff still counts, and it can never exceed the standalone sum.
The verified rows, grouped by peril, geography, and line of business, decompose the $275.6M expected loss into the exposure picture.
Step 2: the exposure picture
Section titled “Step 2: the exposure picture”With the loss sets verified, the report turns to the book itself. The YELT’s tiv column, the total insured value (TIV) each occurrence touched, lets the report characterise the book from the model-output side, on axes the underwriter will structure against:
- Concentration: which perils, geographies, and lines of business the loss falls on. We measure this as each slice’s expected annual loss: its share of the $275.6M book EL, computed by averaging the slice’s loss across the 20 trials. Summed over any one dimension, these contributions add back to the book EL, so the chart below is a decomposition of expected loss, not a set of unrelated totals.
- Exposed value: how much insured value stands behind each slice (the TIV the events touched), which says whether a concentration of loss reflects a large book or an unusually severe one.
- Severity: how hard events damage what they touch, measured as the aggregate damage ratio, over the slice. Severity that is uniform across slices is reassuring; an outlier slice is where a data problem would surface.
SunCoast expected annual loss by geography — each geography's mean contribution to a year's loss, the seven bars summing to the book's $275.6M expected loss. Labels show each geography's share; hover for the aggregate damage ratio. Texas — not Florida — carries the largest share.
Expected annual loss by peril is each peril’s contribution to the book EL, so these are exactly the per-loss-set ELs from the reconciliation table above:
| Peril | Expected annual loss | Share | Damage ratio |
|---|---|---|---|
| Hurricane (FL, GA, MS, LA, TX) | $254.6M | 92.4% | 11.6% |
| Earthquake (CA, AZ) | $21.0M | 7.6% | 12.2% |
- This is a hurricane book. 92% of expected loss is hurricane, on an exposed value many times the earthquake book’s. The earthquake exposure is genuinely secondary: SunCoast writes it at roughly a third of market scale (the Caldera Re data records the 0.35 scaling), and the loss sets reflect that.
- The expected loss is Gulf-wide, not concentrated in Florida. Texas carries 36.4% of expected loss, more than twice Florida’s 15.9%. A reader who knows SunCoast as “the Florida cedent” learns something here, and so does the underwriter renewing a Florida-only layer: most of this book’s expected loss falls outside that layer’s geography.
- Severity is homogeneous. Aggregate damage ratios sit between 10.1% and 13.4% across every peril, geography, and line of business. A slice far outside that band would need explaining before the rest of the report is trusted. A clean spread does not mean the exposure data was right, though: a model cannot tell that its input was wrong, and inaccurate input produces plausible-looking output.4
"""Exposure summary from model output: expected annual loss and damage ratios.
The YELT's tiv column carries the total insured value each occurrence
touched. Slicing loss and TIV by peril, geography, and line of business
answers the underwriter's first questions about the book: where the
expected loss concentrates, and how severely events damage what they hit.
"""
from caldera_re.loss_sets import combine, suncoast_submission
from caldera_re.report import heading
from caldera_re.utils import trial_count
# The combined submission. combine() validates every loss set it is given.
yelt = combine(suncoast_submission())
N = trial_count(yelt) # trials simulated, recorded by the loader
def slice_summary(df, dim):
"""Expected annual loss, loss share, and damage ratio per slice.
Expected annual loss is the slice's contribution to the book's
expected loss: total slice loss divided by N trials. Summed over a
dimension it equals EL, so this decomposes EL by where it comes from.
The aggregate damage ratio is sum(loss) / sum(tiv) over the slice's
occurrence rows -- the TIV-weighted mean severity of the events that
hit it. The per-trial averaging cancels in the ratio, so it reads
the same whether computed on totals or annual means.
"""
g = df.groupby(dim).agg(
loss=('loss', 'sum'),
tiv=('tiv', 'sum'),
)
g['annual_loss'] = g['loss'] / N
g['loss_share'] = g['loss'] / df['loss'].sum()
g['damage_ratio'] = g['loss'] / g['tiv']
return g.sort_values('loss', ascending=False)
for dim, label in [('peril', 'peril'),
('geography_id', 'geography'),
('lob_id', 'line of business')]:
heading(f"By {label} (expected annual loss)")
s = slice_summary(yelt, dim)
for key, r in s.iterrows():
print(f" {key:12s} {r['annual_loss']:6.1f}M/yr ({r['loss_share']:5.1%})"
f" damage ratio {r['damage_ratio']:5.1%}")
print(f" total {s['annual_loss'].sum():.1f}M/yr (= book expected loss)\n")
# --- The single largest modelled occurrence -------------------------
occ = (yelt.groupby(['trial_id', 'timestamp', 'event_id'])
.agg(loss=('loss', 'sum'), tiv=('tiv', 'sum'),
peril=('peril', 'first'),
geographies=('geography_id',
lambda values: '/'.join(sorted(values.unique()))))
.reset_index())
top = occ.loc[occ['loss'].idxmax()]
recurs = occ[occ['event_id'] == top['event_id']]['trial_id'].nunique()
heading("Largest modelled occurrence")
print(f" Event {top['event_id']:.0f} ({top['peril']}, "
f"{top['geographies']}): {top['loss']:.1f}M loss, "
f"{top['loss'] / top['tiv']:.1%} damage ratio")
print(f" Appears in {recurs} of {N} trials -- it alone sets the"
f" OEP tail of the book") Expected loss by slice says nothing about the tail, which the subject loss profile measures from the same loss sets.
Step 3: the subject loss profile
Section titled “Step 3: the subject loss profile”The core of the report is the distribution itself, characterised the way the Toolkit taught: per-peril and combined aggregate exceedance probability (AEP) curves, then the headline metrics with return periods.
SunCoast subject loss AEP curves per loss set (20 trials, log return-period axis). Each curve has its own checkbox.
| Metric | Value | What it says |
|---|---|---|
| EL | $275.6M | Mean annual loss across all 20 trials |
| Standard deviation (coefficient of variation) | $135.3M (0.49) | Volatility around the mean; the coefficient of variation is the standard deviation over EL, 135.3 / 275.6 |
| AEP VaR(80%) | $378.2M | 1-in-5 annual loss |
| AEP VaR(90%) | $489.1M | 1-in-10 annual loss |
| AEP TVaR(90%) | $552.6M | Average of the worst 10% of trials |
| AEP VaR(95%) | $616.2M | 1-in-20 annual loss, the worst trial |
| OEP VaR(90%) | $174.1M | 1-in-10 largest single occurrence |
With trials, the VaR computation is concrete enough to check by eye. Writing for the -th largest annual loss (the order-statistic notation from the Toolkit), at the tail holds trials, so , the second-largest annual loss, and averages the worst two. At the tail is a single trial, so VaR, TVaR, and the maximum coincide at $616.2M. The 20-trial dataset has hit its resolution limit, which the report must state explicitly.
- In these 20 trials, the tail is a hurricane tail. The earthquake curve in the AEP chart above stays near zero: its worst trial is $34.2M, smaller than the average hurricane trial. In the two tail trials that define TVaR(90%), hurricane losses are 96% of the total. Earthquake matters in the body of the distribution (a steady $10–34M in every trial) but contributes nothing material to the tail. That finding describes the sample, not earthquake risk: the costliest quake in the 20 trials costs SunCoast $10.4M, and in a real, heavy-tailed earthquake catalog, earthquake losses appear in the tail at return periods that 20 trials cannot resolve.
- Bad trials are many-storm trials. The 1-in-10 occurrence loss is $174.1M while the 1-in-10 annual loss is $489.1M. That $315M gap is multi-occurrence risk, the distinction between occurrence exceedance probability (OEP) and AEP made concrete. SunCoast’s worst trials are bad because several storms make landfall, not because a single storm is exceptionally large: the worst trial holds 29 occurrences. And the OEP curve’s flat top has a specific cause: a single catalog event, a $174.1M Texas hurricane, recurs in 7 of 20 trials and alone sets the occurrence tail.
The gap between the two curves below is the visual form of that finding. The aggregate (annual) curve sits well above the occurrence (single-largest) curve at every return period, and the distance between them is exactly the multi-occurrence risk:
SunCoast subject loss EP curves (20 trials). Move your cursor over the chart for details. The dashed AEP curve is always at or above the solid OEP curve — the gap reflects multi-occurrence risk.
The profile itself composes the pieces the Toolkit already provides: build the AEP and OEP curves, compute the metrics, attribute the tail.
"""The subject-loss profile: the metrics core of the post-model report.
Picks up where loss_set_reconciliation (the validated, combined book)
and exposure_summary (where the loss sits) leave off, and characterizes
the subject-loss distribution itself: headline metrics, the occurrence
vs aggregate view, and what drives the tail. Every metric is read off
an AEP or OEP curve with the shared Toolkit helpers.
"""
from caldera_re.aep import aep
from caldera_re.oep import oep
from caldera_re.loss_sets import combine, suncoast_submission
from caldera_re.expected_loss import expected_loss
from caldera_re.standard_deviation import standard_deviation
from caldera_re.var import var
from caldera_re.tvar import tvar
from caldera_re.utils import trial_count
# The combined submission. combine() validates every loss set it is given.
yelt = combine(suncoast_submission())
N = trial_count(yelt) # trials simulated, recorded by the loader
aep_curve = aep(yelt)
oep_curve = oep(yelt)
el = expected_loss(aep_curve)
sd = standard_deviation(aep_curve)
print("=" * 58)
print(" SUBJECT-LOSS PROFILE -- SunCoast Insurance")
print(f" All perils, {N} trials, values in USD M")
print("=" * 58)
print("\n1. Headline metrics (AEP basis)")
print(f" EL {el:7.1f}M")
print(f" Std {sd:7.1f}M (CV {sd / el:.2f})")
for alpha in [0.80, 0.90, 0.95]:
rp = 1 / (1 - alpha)
print(f" VaR({alpha:.0%}) {var(aep_curve, alpha):6.1f}M"
f" TVaR({alpha:.0%}) {tvar(aep_curve, alpha):6.1f}M"
f" (1-in-{rp:.0f})")
print("\n2. Occurrence vs aggregate (1-in-10)")
oep_v = var(oep_curve, 0.90)
aep_v = var(aep_curve, 0.90)
print(f" OEP VaR(90%) {oep_v:6.1f}M (largest single occurrence)")
print(f" AEP VaR(90%) {aep_v:6.1f}M (total annual loss)")
print(f" The {aep_v - oep_v:.0f}M gap is multi-occurrence risk: bad trials")
print(f" are bad because many storms make landfall, not one giant storm.")
print("\n3. Tail attribution (worst 2 trials = the 90% tail)")
for tid in aep_curve.head(2)['trial_id']:
t = yelt[yelt['trial_id'] == tid]
total = t['loss'].sum()
hu = t.loc[t['peril'] == 'HU', 'loss'].sum()
n_occ = t.groupby(['timestamp', 'event_id']).ngroups
print(f" Trial {tid}: {total:6.1f}M total, "
f"{hu / total:5.1%} hurricane, {n_occ} occurrences")
print("\n4. Reading for the underwriter")
print(" The tail is a hurricane tail; earthquake is immaterial beyond")
print(" the body of the distribution. Structure accordingly.") Each of the two findings above has an underwriting implication.
What the underwriter reads off the report
Section titled “What the underwriter reads off the report”The report exists to drive the decisions of the person structuring the renewal.
| Finding | Underwriting implication |
|---|---|
| Tail is 96% hurricane in the 20-trial sample | Tail-protecting structures should attach on the hurricane book. The sample shows no need for earthquake occurrence protection, but it holds no large quake: whether SunCoast needs dedicated earthquake occurrence protection can only be answered at return periods beyond 1-in-20 |
| Bad trials are many-storm trials ($315M OEP–AEP gap) | A single occurrence layer will be hit repeatedly in bad trials: reinstatement terms and aggregate protections are what the negotiation is about |
| Texas carries 36% of expected loss; Florida 16% | A Florida-only layer leaves most of the book’s expected loss outside its geography. The renewal conversation should ask whether that is intentional |
| One catalog event sets the OEP plateau | The occurrence tail is concentrated, not smooth: per-event terms are sensitive to a handful of catalog entries |
| Damage ratios homogeneous (10.1–13.4%) | No exposure-data anomalies visible from model output |
| 20 trials → resolution floor at 1-in-20 | Quotes beyond VaR(90%) need a larger trial set before anyone prices off them |
None of this is a structuring decision. Attachment points, limits, and prices belong to the standalone pricing story, where contract terms start transforming this distribution. The post-model report’s job ends there: the underwriter now knows what the subject loss looks like before anything reshapes it.
At production scale
Section titled “At production scale”At production scale discusses what changes with the trial count: row counts, loss-set bookkeeping, currency, reproducibility, and the partition-by-trial shape every pipeline in this chapter shares. Specific to a post-model report is the row arithmetic of a single loss set. The row count is the trial count times the occurrences per trial times the rows per occurrence, one for each geography and line of business the event touches (3.1 on average for SunCoast). A hurricane loss set holds a handful of occurrences per trial. What produces the largest loss sets is not the occurrence count but the resolution: summarised by county or CRESTA zone rather than by state, one continental earthquake touches hundreds of rows rather than three.
| Loss set | Trials | Occurrences per trial | Rows per occurrence | Rows | What fits |
|---|---|---|---|---|---|
| SunCoast, 20-trial demo tier | 20 | 26.5 | 3.13 | 1,657 | A table you can read by eye |
| SunCoast, 1,000-trial demo tier | 1,000 | 25.8 | 3.09 | 79,473 | One in-memory frame, instantly |
| Production, state-resolution hurricane book | 10,000 to 1,000,000+ | ~5 | ~3 | 10⁵ to 10⁷ | One in-memory frame; seconds at the top of the range6 |
| Production, county-resolution earthquake loss set | 10,000 to 1,000,000+ | ~5 | ~200 | 10⁷ to 10⁹ | Columnar storage and a query engine at the top of the range, not one frame |
The row counts are exact and the two ratios are rounded, so the demo rows do not multiply out to the last row. The metrics keep their shape at every row count (each is a groupby plus a sort), and the extra trials add resolution, not speed: the 1-in-200 and 1-in-500 quotes that capital decisions need and 20 trials cannot resolve. The demo data ships a 1,000-trial tier to make that contrast runnable.
This arithmetic covers one loss set, and a reinsurer’s portfolio tracks tens of thousands.
What carries forward
Section titled “What carries forward”That is risk profiling: the Toolkit’s metrics, composed into the artifact that starts every deal. The standalone pricing story hands this risk profile to the underwriter’s structuring problem, where financial terms begin transforming the distribution the report just measured.
Footnotes
Section titled “Footnotes”-
Alan Calder, Andrew Couper, and Joseph Lo, Catastrophe Model Blending: Techniques and Governance, Institute and Faculty of Actuaries, 2012, §1.4: “When pricing, underwriters review output from the vendor models.” ↩
-
Calder, Couper, and Lo, Catastrophe Model Blending, §§1.5 and 1.7. A company’s standard view of a peril “will either be a single vendor model or a blend of two or more models”, and one arrangement is “selecting one individual vendor model for each region and peril”. CAS Reinsurance Boot Camp (Sigona), “Catastrophe Modeling Overview”, 2019: vendors differ in their interpretation of the historical record and of the scientific data, which is why two vendors’ losses for one book differ. ↩
-
Philippe Artzner, Freddy Delbaen, Jean-Marc Eber, and David Heath, “Coherent Measures of Risk”, Mathematical Finance 9(3), 1999, §3, gives the same construction with two independent identically distributed net worths: “the 10% values at risk of X1 and X2 being equal to 0, whereas an easy calculation showing that the 10% value at risk of X1 + X2 is certainly larger than 0”. The paper’s summary names the regime, that value at risk “does not behave nicely with respect to addition of risks, even independent ones, creating severe aggregation problems”. ↩
-
LMA Exposure Management Working Group, Catastrophe Modelling Guidance for Non-Catastrophe Modellers, Lloyd’s Market Association, June 2013: “Models are unable to identify inaccurate data”, so “output will be presented back to the user with no indication that the results being analysed are inappropriate”, and inaccuracies that are systematic or fall on peak risks “have the potential to significantly mislead”. ↩
-
Insurance Information Institute, “The Northridge, CA, Earthquake 20 Years After: Facts, Figures and Perspectives”, Triple-I, 2014, table updated 2021. The 1994 earthquake is “the costliest earthquake in U.S. history in terms of insured losses (both actual and adjusted for inflation)”; the page’s table of the costliest US earthquakes (Munich Re data through 2020) gives Northridge insured losses of $15.3 billion when they occurred and $27.1 billion in 2020 dollars. ↩
-
DuckDB Labs, Database-like ops benchmark, the maintained fork of H2O.ai’s db-benchmark. Under pandas 2.2.3 its ten grouped aggregations over a 10,000,000-row, 0.5 GB table each complete in 0.13 to 7.6 seconds on one cloud instance, while at 100,000,000 rows the same queries take 1.3 to 100 seconds. The measurements are not from a laptop. ↩