Analytics Toolkit · Section 3
Exceedance probability curves
Exceedance probability curves: what they represent, how to read them, and how to build them from trial data.
The trial worldview gives us a table of losses. Building an exceedance probability (EP) curve turns that table into the standard visualisation of catastrophe risk: a chart showing the probability that losses reach or exceed any given threshold. Two summaries of a trial’s losses give two curves: its total gives the aggregate exceedance probability (AEP) curve, and its single largest occurrence gives the occurrence exceedance probability (OEP) curve.
What an EP curve represents
Section titled “What an EP curve represents”The aggregate EP curve answers the question: “What is the probability that total trial losses reach or exceed ?”
Why this question rather than “What is the probability of exactly ?” Because in a continuous loss distribution, the probability of any exact value is zero. The useful question is always about exceedance: how likely is it that losses are at least this bad? This framing directly maps to business decisions: “What is the chance we lose more than our risk appetite?” or “How much capital do we need to survive a 1-in-100 trial?”
When each trial represents one simulated year (as the major vendor models do, and as the Caldera Re dataset assumes), the EP curve answers the more specific question: “What is the probability that annual losses reach or exceed ?”
Formally:
where is the trial loss random variable. For a trial-based simulation with equiprobable trials:
In plain English: count how many trials have total loss at or above , divide by the total number of trials . Here the inequality is rather than : the -th largest of equiprobable trials has exceedance probability exactly .
Evaluated at each trial’s own loss, the count reduces to a rank, which is how the curve is built from the loss table.
Building an EP curve
Section titled “Building an EP curve”- Compute total loss per trial : the sum of all year event loss table (YELT) rows where
trial_id= - Sort the trial losses in descending order
- Assign rank to each (1 = largest loss)
- The exceedance probability of the -th ranked loss is
Step 2 assumes the loss-positive sign convention: the worst trials are the largest values, so descending order puts the tail at the top. Under a loss-negative convention, the sort flips to ascending and every rank formula in this chapter is mirrored.
in step 4 is the number of trials simulated, not the number of trials with rows. A trial without occurrences has no row in the YELT, so its total is and it still takes a rank, at the bottom of the curve. The rows cannot supply , so the loader records it on the YELT (df.attrs['n_trials']), a filtered slice keeps it, and aep() reads it from there. A YELT without the record is an error, never a count of the trials present. Trident Re’s 20-trial YELT holds rows for 19 trials, and seven trial totals reach or exceed $20M. The exceedance probability at $20M is ; counting only the trials present would report . The loader records the count, and every curve and metric inherits it.
from caldera_re.data import cedents
from caldera_re.report import table
from caldera_re.utils import all_trials, trial_count
def aep(yelt_df, n_trials=None):
"""Build an AEP (Aggregate Exceedance Probability) curve from a YELT.
Returns a DataFrame with columns: trial_id, loss, rank, ep, rp
sorted by loss descending (rank 1 = largest), one row for each
simulated trial. A trial with no row in the YELT ranks at zero loss, so
N, the ranks, EP and every metric read off the curve use the trials
simulated, not the trials present.
N comes from the YELT (trial_count); pass n_trials only for a frame
built from arrays, which carries no count of its own.
"""
N = trial_count(yelt_df, n_trials)
trial_losses = all_trials(yelt_df.groupby('trial_id')['loss'].sum(), N)
curve = trial_losses.sort_values(ascending=False).reset_index()
curve.columns = ['trial_id', 'loss']
curve['rank'] = range(1, N + 1)
curve['ep'] = curve['rank'] / N
curve['rp'] = 1 / curve['ep']
return curve
if __name__ == '__main__':
curve = aep(cedents.suncoast)
table(['Rank', 'Trial Loss', 'Exceedance Prob', 'Return Period'],
curve[['rank', 'loss', 'ep', 'rp']],
fmt=['{:.0f}', '{:.1f}M', '{:.1%}', '{:.1f} years']) The cost is one grouping and one sort: reducing rows to trial totals is , and sorting the totals is , which at a million trials is an 8 MB vector of floats, sorted once and then read by every metric. The sorted curve is the object to cache, not the metrics derived from it.
Reading the aggregate EP curve
Section titled “Reading the aggregate EP curve”SunCoast subject loss AEP curve (20 trials). Each point is one trial's total loss. Hover for details. The dashed line shows the mean loss across all trials.
SunCoast’s subject losses, the cedent’s losses before any contract terms are applied:
- There is a 5% chance (1-in-20-year) of a loss of $616.2M or more: that is the single worst trial
- There is a 10% chance (1-in-10-year) of losses reaching or exceeding $489.1M
- There is a 50% chance of reaching or exceeding $274.6M: half the trials produce losses at or above this amount
- The average loss across all trials is $275.6M
These quotes come from the 20-trial demo tier: the curve-building mechanics are exact at any trial count, but the curve stops at the 1-in-20 return period. A production curve built from 10,000 to 1,000,000+ trials reaches return periods 500 to 50,000 times longer.
This curve ranks each trial’s total; ranking each trial’s largest occurrence instead gives the occurrence curve, which answers a different question.
OEP vs. AEP
Section titled “OEP vs. AEP”There are two standard EP curves, distinguished by how they summarise occurrences within a trial.3 To define them, we need occurrence-level losses. Write for the loss of one occurrence: catalog event at timestamp in trial , summed over the rows it has in the cedent’s YELT, one per geography and line of business. Trial 9 of SunCoast’s YELT works both reductions on one trial and shows why they start from occurrences, not rows.
OEP (Occurrence EP)
Section titled “OEP (Occurrence EP)”The OEP curve uses the largest single occurrence per trial. It answers: “What is the probability that the peak single-occurrence loss in a trial reaches or exceeds ?”
from caldera_re.data import cedents
from caldera_re.report import table
from caldera_re.utils import all_trials, trial_count
def oep(yelt_df, n_trials=None):
"""Build an OEP (Occurrence Exceedance Probability) curve from a YELT.
Returns a DataFrame with columns: trial_id, loss, rank, ep, rp
sorted by loss descending (rank 1 = largest), one row for each
simulated trial. A trial with no occurrence has a largest occurrence of
zero; groupby().max() alone would drop it and shorten N.
N comes from the YELT (trial_count); pass n_trials only for a frame
built from arrays.
"""
N = trial_count(yelt_df, n_trials)
# Sum per occurrence (across geographies/LOBs), then max per trial.
# An occurrence is keyed by (trial_id, timestamp, event_id): the
# same catalog event recurring within a trial is two occurrences.
occ_losses = yelt_df.groupby(
['trial_id', 'timestamp', 'event_id'])['loss'].sum()
trial_maxes = all_trials(occ_losses.groupby('trial_id').max(), N)
curve = trial_maxes.sort_values(ascending=False).reset_index()
curve.columns = ['trial_id', 'loss']
curve['rank'] = range(1, N + 1)
curve['ep'] = curve['rank'] / N
curve['rp'] = 1 / curve['ep']
return curve
if __name__ == '__main__':
curve = oep(cedents.suncoast)
table(['Rank', 'Occurrence Loss', 'Exceedance Prob', 'Return Period'],
curve[['rank', 'loss', 'ep', 'rp']],
fmt=['{:.0f}', '{:.1f}M', '{:.1%}', '{:.1f} years']) AEP (Aggregate EP)
Section titled “AEP (Aggregate EP)”The AEP curve uses the total loss across all occurrences. It answers: “What is the probability that total trial losses reach or exceed ?”
This is the of the sections above, so the curve built there is the AEP curve.
from caldera_re.aep import aep
from caldera_re.oep import oep
from caldera_re.data import cedents
from caldera_re.report import table
oep_curve = oep(cedents.suncoast)
aep_curve = aep(cedents.suncoast)
N = len(oep_curve) # a curve ranks every simulated trial
table(['Rank', 'OEP Loss', 'AEP Loss', 'Exceedance Prob', 'Return Period'],
zip(range(1, N + 1), oep_curve['loss'], aep_curve['loss'],
oep_curve['ep'], oep_curve['rp']),
fmt=['{}', '{:.1f}M', '{:.1f}M', '{:.1%}', '{:.1f} yrs'])
print(f"\nOEP mean: {oep_curve['loss'].mean():.1f}M "
f"| AEP mean: {aep_curve['loss'].mean():.1f}M")
print(f"OEP max: {oep_curve['loss'].max():.1f}M "
f"| AEP max: {aep_curve['loss'].max():.1f}M") The AEP is always greater than or equal to the OEP at every return period, because total loss is at least as large as the largest occurrence in every trial, and sorting preserves that inequality rank by rank. The difference between them reflects multi-occurrence risk: the accumulation of losses from multiple occurrences in the same trial. On SunCoast’s 20-trial demo tier that gap is large at every return period:
| Return period | AEP (trial total) | OEP (largest occurrence) | Gap: multi-occurrence risk |
|---|---|---|---|
| 1-in-20 (worst trial) | $616.2M | $174.1M | $442.1M |
| 1-in-10 | $489.1M | $174.1M | $314.9M4 |
| 1-in-2 (median trial) | $274.6M | $132.8M | $141.8M |
The worst trial’s total is three and a half times its largest single occurrence: SunCoast’s bad trials are many-storm trials, not single-storm trials. The OEP curve’s flat top has a specific cause: one catalog event, a $174.1M Texas hurricane, is the largest occurrence in seven of the twenty trials, so the occurrence curve reads $174.1M from the 1-in-20 return period down to 1-in-2.86. The explorer below plots both curves.
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 EP curve gives us the full loss distribution. Business decisions need single numbers from it, and the core risk metrics are derived from this curve: its mean, one rank, and the average of its worst ranks.
Footnotes
Section titled “Footnotes”-
Natalia Humphreys, “Exceedance Probability in Catastrophe Modeling”, Casualty Actuarial Society E-Forum, Winter 2021: “Using probabilistic terminology, EP(x) is the survival function of X”, and exceedance probability is “one of the most commonly used metrics in catastrophe modeling”. Oasis LMF, ORD Output Definitions, Open Results Data standard, records the exceedance probability curves as “the key ORD outputs and the ones most typically used in cat modelling”. Neither source gives a reason for the choice; the explanation here is this site’s. Both define exceedance strictly, as ; the above is this site’s convention. ↩
-
Moody’s, “Catastrophe Risk Modeling”, whose own primer describes the other orientation: an EP curve displayed “with the losses (often in millions) running along the X-axis and the exceedance probability running along the Y-axis”. The axis choice here is a presentation convention of this site, not an industry standard. ↩
-
Humphreys, “Exceedance Probability in Catastrophe Modeling”. OEP and AEP are “two primary metrics used in catastrophe modeling”: the OEP “is the probability that the largest loss in a year exceeds a certain amount of loss”, the AEP “the probability that the sum of losses in a year exceeds a certain amount of loss”. Oasis LMF, ORD Output Definitions, enumerates the same two as the curve types of its exceedance-probability table, “maximum of any one event’s losses in a year” and “sum of losses from all events in a year”. ↩
-
The gap is taken on the unrounded losses and displayed to one decimal; the two displayed figures differ by $315.0M. The convention is stated in the Caldera Re appendix. ↩