# Risk profiling: the post-model report

> How a cat modeller turns a cedent's loss sets into the post-model report: loss-set reconciliation, exposure summary, and the subject loss risk profile.

Source: https://reinsurance.dev/applications/risk-profiling/

## 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](https://reinsurance.dev/caldera-re/#portfolio)) wants its reinsurance program renewed, and its broker has sent the **[submission](https://reinsurance.dev/glossary/#submission)**: the package of exposure data, historical losses, and desired structure that opens every placement.

The [cat modeller](https://reinsurance.dev/foundations/personas/#the-catastrophe-modeller) has run SunCoast's exposure data through the vendor models. Now comes the deliverable this story is about: the **[post-model report](https://reinsurance.dev/glossary/#post-model-report)**, the write-up of model output that characterises the cedent's exposure and **[subject loss](https://reinsurance.dev/glossary/#subject-loss)** for the [underwriter](https://reinsurance.dev/foundations/personas/#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.[^underwriter-reads] 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](https://reinsurance.dev/glossary/#book-of-business) 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](https://reinsurance.dev/toolkit/) already built: [trials and year event loss tables (YELTs)](https://reinsurance.dev/toolkit/trials/), [exceedance probability (EP) curves](https://reinsurance.dev/toolkit/distributions/), and the [metrics](https://reinsurance.dev/toolkit/metrics/) [expected loss (EL)](https://reinsurance.dev/glossary/#el-expected-loss), [Value at Risk (VaR)](https://reinsurance.dev/glossary/#var-value-at-risk), and [Tail Value at Risk (TVaR)](https://reinsurance.dev/glossary/#tvar-tail-value-at-risk). 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](https://reinsurance.dev/applications/risk-profiling/#the-pipeline).

## The pipeline

```mermaid
graph TB
    E[Cedent exposure data]:::Losses --> M1(Hurricane model):::Process
    E --> M2(Earthquake model):::Process
    M1 --> L1[HU loss set]:::Losses
    M2 --> L2[EQ loss set]:::Losses
    L1 --> R(Validate + reconcile):::Process
    L2 --> R
    R --> S(Exposure summary):::Process
    R --> Q(Subject loss profile):::Process
    S --> D[Post-model report]:::Metric
    Q --> D
    D --> U[Underwriter decision]:::Contract

    classDef Losses fill:#F6C1A2,stroke:#F2A87D,stroke-width:2px
    classDef Contract fill:#DDEEF8,stroke:#99CDEB,stroke-width:2px
    classDef Process fill:#FAC7D2,stroke:#F58EA5,stroke-width:2px
    classDef Metric fill:#CEF3DF,stroke:#9CE7BF,stroke-width:2px
```

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](https://reinsurance.dev/toolkit/trials/) 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](https://reinsurance.dev/glossary/#loss-set)** per peril, each a [YELT](https://reinsurance.dev/toolkit/trials/#the-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).

:::note[In Practice]
We assume here that each loss set arrives as a YELT we can consume directly. Not every vendor obliges. Some ship only an **[event loss table (ELT)](https://reinsurance.dev/toolkit/trials/#event-loss-tables)**, events with rates but no trial structure, and leave you to sample it into a YELT first, drawing occurrences per trial from each event's rate and assigning timestamps before any aggregate or portfolio metric is possible. Sampling an ELT into a YELT is out of scope for this page.
:::

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.[^per-peril-models] 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](https://reinsurance.dev/applications/risk-profiling/#step-1-validate-and-reconcile-the-loss-sets).

## 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:

1. **Validate each loss set at the ingestion boundary**: schema, [sign convention](https://reinsurance.dev/toolkit/trials/#sign-convention), trial range, and the label against the rows. One check per invariant, and each raises on failure.
2. **Compute standalone metrics per loss set**: each peril's distribution on its own.
3. **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.
4. **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:

$$
\text{EL}_{\text{combined}} = \text{EL}_{\text{HU}} + \text{EL}_{\text{EQ}}
$$

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 $\text{VaR}_\alpha$ 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 $j$ 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](https://reinsurance.dev/applications/portfolio-analytics/#at-production-scale) covers the case.

Python (`python/caldera_re/loss_sets.py`, runnable on the web page):

```python
"""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:

Python (`python/caldera_re/loss_set_reconciliation.py`, runnable on the web page):

```python
"""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](https://reinsurance.dev/toolkit/trials/#demo-scale).

| 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](https://reinsurance.dev/toolkit/distributions/#reading-the-aggregate-ep-curve) 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](https://reinsurance.dev/financial-modelling/dissection/), 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 $N = 20$ trials the 90% tail is the second-worst trial ($k = 2$); 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](https://reinsurance.dev/applications/portfolio-analytics/) 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](https://reinsurance.dev/toolkit/metrics/tail-value-at-risk/), which is [sub-additive](https://reinsurance.dev/toolkit/metrics/var-vs-tvar/#sub-additivity) 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 $\text{VaR}_{0.90}$ is \$0, and so is their sum. But combine them: the chance that at least one pays rises to $1 - 0.94^2 \approx 11.6\%$, which clears the 10% tail, so the combined $\text{VaR}_{0.90}$ 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](https://reinsurance.dev/toolkit/metrics/var-vs-tvar/#sub-additivity), 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.[^var-digital] [TVaR](https://reinsurance.dev/toolkit/metrics/tail-value-at-risk/) averages the whole tail, so a loss slightly beyond the cutoff still counts, and it can never exceed the standalone sum.

:::note[From the Field]
A loss set once arrived from an accounting-system extract with losses as negative values, that system's own convention. Nothing crashed. Combining it with the other perils' loss-positive data quietly reduced the book's total modelled loss, and every tail metric shrank. The error surfaced weeks later, in a pricing review, as "why did the combined book get safer when we added a peril?" A one-line sign check at ingest, the same one in the code above, would have caught it the moment the file arrived.
:::

:::tip[Key Insight]
Reconciliation is regression testing for data. Standalone metrics are the unit tests; the combined view is the integration test; the additivity of EL is the invariant that must hold exactly. But like any single assertion it is necessary, not sufficient. A matching EL catches structural breakage (duplicated rows, dropped trials, bad joins) yet says nothing about the tail or about the order of events within a trial. Two loss sets can share an expected loss and still differ where it matters: sparse, heavily-layered books can post near-identical ELs over very different tails, and EL ignores within-trial sequencing, which aggregate contracts depend on. Treat a reconciliation failure like a failing build; treat a reconciliation pass as one green test, not a full validation.
:::

The verified rows, grouped by peril, geography, and line of business, decompose the \$275.6M expected loss into [the exposure picture](https://reinsurance.dev/applications/risk-profiling/#step-2-the-exposure-picture).

## 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)](https://reinsurance.dev/toolkit/trials/#tiv-and-the-damage-ratio) 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](https://reinsurance.dev/glossary/#damage-ratio), $\sum \text{loss} / \sum \text{tiv}$ over the slice. Severity that is uniform across slices is reassuring; an outlier slice is where a data problem would surface.

*[Interactive figure: Exposure mix chart. See the web page: https://reinsurance.dev/applications/risk-profiling/]*

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](https://reinsurance.dev/caldera-re/#the-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](https://reinsurance.dev/caldera-re/#portfolio): most of this book's expected loss falls outside that layer's geography.
- **Severity is homogeneous.** Aggregate [damage ratios](https://reinsurance.dev/glossary/#damage-ratio) 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.[^data-quality]

Python (`python/caldera_re/exposure_summary.py`, runnable on the web page):

```python
"""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")
```

:::note[In Practice]
A full post-model report has an exposure-input half as well: TIV by county, construction and occupancy mix, data-quality grading of the cedent's schedule. That half is produced inside the catastrophe modelling process, from the exposure data itself. It cannot be derived from a YELT, and this site deliberately treats it as upstream (see [the TIV caution](https://reinsurance.dev/toolkit/trials/#tiv-and-the-damage-ratio): exposed TIV is not book TIV). A YELT supports the model-output half, and that half is what this analysis works from.
:::

Expected loss by slice says nothing about the tail, which [the subject loss profile](https://reinsurance.dev/applications/risk-profiling/#step-3-the-subject-loss-profile) measures from the same loss sets.

## Step 3: the subject loss profile

The core of the report is the distribution itself, characterised the way [the Toolkit taught](https://reinsurance.dev/toolkit/distributions/): per-peril and combined [aggregate exceedance probability (AEP)](https://reinsurance.dev/glossary/#aep-aggregate-exceedance-probability) curves, then the headline metrics with return periods.

*[Interactive figure: Peril EP curve. See the web page: https://reinsurance.dev/applications/risk-profiling/]*

| 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 $N = 20$ trials, the [VaR computation](https://reinsurance.dev/toolkit/metrics/value-at-risk/) is concrete enough to check by eye. Writing $L_{(r)}$ for the [$r$-th largest annual loss](https://reinsurance.dev/toolkit/metrics/value-at-risk/) (the order-statistic notation from the Toolkit), at $\alpha = 0.90$ the tail holds $k = 2$ trials, so $\text{VaR}_{0.90} = L_{(2)} = 489.1\text{M}$, the second-largest annual loss, and $\text{TVaR}_{0.90}$ averages the worst two. At $\alpha = 0.95$ 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](https://reinsurance.dev/toolkit/trials/#demo-scale).
- **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](https://reinsurance.dev/toolkit/distributions/#oep-vs-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:

*[Interactive figure: Caldera EP curve. See the web page: https://reinsurance.dev/applications/risk-profiling/]*

The profile itself composes the pieces the Toolkit already provides: build the AEP and OEP curves, compute the metrics, attribute the tail.

Python (`python/caldera_re/risk_profile_report.py`, runnable on the web page):

```python
"""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.")
```

:::caution[Watch Out]
"The 1-in-20 loss is \$616.2M" does not mean a worse year is impossible. It means the 20-trial sample cannot see past its largest member. Quoting VaR(95%) from 20 trials as if it had the same evidential weight as VaR(95%) from 100,000 trials is a category error. A real report quotes tail metrics only where the trial count supports them, and says so explicitly.
:::

:::note[Caldera Re]
In the demo data, SunCoast's earthquake loss distribution has no heavy tail. SunCoast's largest earthquake occurrence loss is \$10.4M in the 20-trial tier and \$14.5M in the 1,000-trial tier, which already contains 299 of the catalog's 300 US earthquake events: no trial count can contain a quake larger than the catalog's largest. The thin tail suits the demo's aggregate examples: SunCoast's earthquake total, summed over many moderate quakes, exceeds [Contract 2](https://reinsurance.dev/financial-modelling/contracts/aggxol/)'s \$15M aggregate attachment in 16 of the 20 trials. It is a limitation of the demo data, not a property of earthquake risk. For scale: Northridge (1994), the costliest US earthquake by insured loss, cost insurers \$15.3 billion when it occurred, \$27.1 billion in 2020 dollars.[^northridge]
:::

Each of the two findings above has [an underwriting implication](https://reinsurance.dev/applications/risk-profiling/#what-the-underwriter-reads-off-the-report).

## What the underwriter reads off the report

The report exists to drive the decisions of the person [structuring the renewal](https://reinsurance.dev/foundations/personas/#the-underwriter).

| 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](https://reinsurance.dev/applications/standalone-pricing/), 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

[At production scale](https://reinsurance.dev/applications/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+](https://reinsurance.dev/toolkit/trials/#demo-scale) | ~5 | ~3 | 10⁵ to 10⁷ | One in-memory frame; seconds at the top of the range[^pandas-scale] |
| 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](https://reinsurance.dev/toolkit/trials/#demo-scale) to make that contrast runnable.

:::note[Consider]
Post-model reporting should be a build artifact, not a document. Generate it from versioned inputs with a pipeline; render it to whatever the underwriter wants to read. Teams that hand-craft these reports spend their week formatting; teams that generate them spend it on the one thing a machine cannot do: deciding what the numbers mean.
:::

This arithmetic covers one loss set, and [a reinsurer's portfolio tracks tens of thousands](https://reinsurance.dev/applications/production-scale/#loss-set-multiplicity).

## What carries forward

That is risk profiling: the Toolkit's metrics, composed into the artifact that starts every deal. The [standalone pricing story](https://reinsurance.dev/applications/standalone-pricing/) hands this risk profile to the underwriter's structuring problem, where [financial terms](https://reinsurance.dev/financial-modelling/composition/) begin transforming the distribution the report just measured.

[^underwriter-reads]: Alan Calder, Andrew Couper, and Joseph Lo, [*Catastrophe Model Blending: Techniques and Governance*](https://www.actuaries.org.uk/system/files/documents/pdf/catastrophe-model-blending.pdf), Institute and Faculty of Actuaries, 2012, §1.4: "When pricing, underwriters review output from the vendor models."

[^per-peril-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"](https://www.casact.org/sites/default/files/old/las_2019_reinsurance_bootcamp_sigona.pdf), 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.

[^var-digital]: Philippe Artzner, Freddy Delbaen, Jean-Marc Eber, and David Heath, ["Coherent Measures of Risk"](https://onlinelibrary.wiley.com/doi/full/10.1111/1467-9965.00068), *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".

[^data-quality]: LMA Exposure Management Working Group, [*Catastrophe Modelling Guidance for Non-Catastrophe Modellers*](https://web.actuaries.ie/sites/default/files/erm-resources/catastrophe_modelling_guidance_for_non_catastrophe_modellers.pdf), 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".

[^northridge]: Insurance Information Institute, ["The Northridge, CA, Earthquake 20 Years After: Facts, Figures and Perspectives"](https://www.iii.org/article/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.

[^pandas-scale]: DuckDB Labs, [Database-like ops benchmark](https://duckdblabs.github.io/db-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.
