Skip to content
Property-cat reinsurance analytics for software engineers

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.

The aggregate EP curve answers the question: “What is the probability that total trial losses reach or exceed xx?”

Why this question rather than “What is the probability of exactly xx?” 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 xx?”

Formally:

EP(x)=P(L≥x)EP(x) = P(L \geq x)

where LL is the trial loss random variable. For a trial-based simulation with NN equiprobable trials:

EP(x)=1N∑j=1N1[Lj≥x]EP(x) = \frac{1}{N} \sum_{j=1}^{N} \mathbf{1}[L_j \geq x]

In plain English: count how many trials have total loss at or above xx, divide by the total number of trials NN. Here the inequality is ≥\geq rather than >>: the rr-th largest of NN equiprobable trials has exceedance probability exactly r/Nr/N.

Evaluated at each trial’s own loss, the count reduces to a rank, which is how the curve is built from the loss table.

  1. Compute total loss per trial LjL_j: the sum of all year event loss table (YELT) rows where trial_id = jj
  2. Sort the trial losses in descending order
  3. Assign rank rr to each (1 = largest loss)
  4. The exceedance probability of the rr-th ranked loss is r/Nr/N

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.

NN 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 Lj=0L_j = 0 and it still takes a rank, at the bottom of the curve. The rows cannot supply NN, 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 7/20=35%7/20 = 35\%; counting only the trials present would report 7/19≈37%7/19 \approx 37\%. The loader records the count, and every curve and metric inherits it.

caldera_re/aep.py Python
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 RR rows to NN trial totals is O(R)O(R), and sorting the totals is O(Nlog⁡N)O(N \log N), 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.

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.

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 Lj,t,eL_{j,t,e} for the loss of one occurrence: catalog event ee at timestamp tt in trial jj, 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.

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 xx?”

LjOEP=max⁡(t,e)Lj,t,eL_j^{\text{OEP}} = \max_{(t,e)} L_{j,t,e}
caldera_re/oep.py Python
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'])

The AEP curve uses the total loss across all occurrences. It answers: “What is the probability that total trial losses reach or exceed xx?”

LjAEP=∑(t,e)Lj,t,e=LjL_j^{\text{AEP}} = \sum_{(t,e)} L_{j,t,e} = L_j

This is the LjL_j of the sections above, so the curve built there is the AEP curve.

caldera_re/oep_and_aep.py Python
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 periodAEP (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.

  1. 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 P(X>x)P(X > x); the ≥\geq above is this site’s convention. ↩

  2. 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. ↩

  3. 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”. ↩

  4. 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. ↩