Continuous Distributions & Loss Generation
PDFs, CDFs, and inverse CDFs with scipy.stats — and a synthetic motor portfolio with realistic, long-tailed claims.
Lesson 7 · OverviewBy the end of this lesson you can…
- Use
scipy.stats“frozen” distributions consistently - Tell apart the PDF, CDF, survival function, and PPF (inverse CDF)
- Parameterize the Normal, Truncated Normal, Exponential, and Lognormal correctly
- Explain inverse-transform sampling
- Compare light and heavy tails with numbers, not adjectives
- Generate driver profiles and capped, deductible-adjusted claims
Companion notebook: lesson-07-continuous-losses.ipynb — worked examples and self-checking exercises.
The scipy.stats Toolkit
One consistent interface for over a hundred distributions.
scipy.statsFrozen distributions
import numpy as np
from scipy import stats
N = stats.norm(loc=100, scale=15) # "freeze" the parameters
N.pdf(100) # 0.026596 density at 100
N.cdf(130) # 0.97725 P(X ≤ 130)
N.sf(130) # 0.02275 P(X > 130) = 1 - cdf
N.ppf(0.975) # 129.3995 inverse CDF: the 97.5% quantile
N.mean(), N.std() # (100.0, 15.0)
rng = np.random.default_rng(11)
N.rvs(size=3, random_state=rng) # [100.51 120.4 118.37]
Use sf(x) rather than 1 - cdf(x) for far-tail probabilities: it keeps precision where the CDF rounds to 1.
- Create the distribution once with its parameters, then ask it questions
- Same method names for every distribution — swap
normforlognorm, nothing else changes - Pass your seeded
Generatorasrandom_state— Lesson 6 reproducibility still applies - Methods accept arrays too:
N.cdf([90, 130])
VocabularyPDF, CDF, PPF — three views of one distribution
PDF — pdf(x)
Density: relative likelihood near x. Areas under it are probabilities.
For discrete distributions: pmf(k) = P(X = k)
CDF — cdf(x)
P(X ≤ x). Rises from 0 to 1.
sf(x) = 1 − CDF: the survival / exceedance probability
PPF — ppf(p)
The inverse CDF: the value x with P(X ≤ x) = p.
A quantile. VaR at 99.5% is ppf(0.995).
“What’s the chance a claim exceeds 50,000?” → sf(50_000). “What claim size is exceeded only 1 time in 200?” → ppf(0.995). Most actuarial questions are one of these two.
Four Distributions & Their Traps
Getting the parameters right is most of the battle.
ExponentialMemoryless waiting times and simple severities
E = stats.expon(scale=5000) # scale = mean = 1/λ
E.mean() # 5000.0
E.cdf(5000) # 0.632121 1 - e^{-1}
E.ppf(0.5) # 3465.74 median = mean × ln 2
E.ppf(0.99) # 23025.85
scipy uses scale = 1/λ, not the rate λ. stats.expon(5000) without the keyword sets loc — a shift — instead. Always name your parameters.
- Mean = standard deviation =
scale - Memoryless: the expected excess over any threshold is always the mean
LognormalThe classic claim-severity model
# If ln X ~ Normal(mu, sigma):
# s = sigma, scale = exp(mu)
sigma = 1.0
mu = np.log(5000) - sigma**2 / 2 # chosen so the mean is 5000
L = stats.lognorm(s=sigma, scale=np.exp(mu))
L.mean() # 5000.0
L.median() # 3032.65 median < mean: right-skewed
L.std() # 6554.16
L.ppf(0.995) # 39855.77
| Math | scipy.stats.lognorm |
|---|---|
| σ (sd of ln X) | s=sigma |
| μ (mean of ln X) | scale=np.exp(mu) |
| median | = scale |
| mean | exp(μ + σ²/2) |
The single most common scipy bug in actuarial work: passing μ as scale, or σ² as s. Check L.mean() and L.median() against what you intended.
Truncated NormalBounded quantities like ages
m, s = 42, 14 # underlying normal
lo, hi = 18, 85 # licensing and cut-off ages
# Bounds must be in STANDARD units: (bound - m) / s
a, b = (lo - m) / s, (hi - m) / s # (-1.7143, 3.0714)
T = stats.truncnorm(a, b, loc=m, scale=s)
T.mean(), T.std() # (43.2923, 12.6417)
T.cdf(25) # 0.0723 under-25s
T.ppf([0, 0.5, 1]) # [18. 42.74 85. ]
stats.truncnorm(18, 85, loc=42, scale=14).mean()
# 294.77 — bounds read as 18 and 85 standard deviations!
- A normal distribution with everything outside [lo, hi] removed and the rest rescaled
- Its mean is not
m— trimming the left tail more than the right pulls it up to 43.29
truncnorm’s a and b are in standard deviations from loc, not in the units of the data. Pass raw ages and you get nonsense, silently.
SamplingInverse-transform sampling
If U ~ Uniform(0, 1), then F−1(U) has CDF F. So any distribution with a PPF can be simulated from uniforms:
rng = np.random.default_rng(5)
u = rng.random(5)
# [0.805 0.8079 0.5153 0.2858 0.0539]
E.ppf(u)
# [8173.85 8249.76 3621.39 1682.97 277.2 ]
This is essentially what .rvs() does for you.
Why learn it? It links a simulated loss to a percentile. Feed the same uniforms into two models and you compare them scenario by scenario — the foundation of stress testing and of dependence modeling with copulas (Course 3).
High u → large loss. Sort your uniforms and you’ve sorted your losses.
TailsLight vs. heavy: same mean, very different risk
Exponential and Lognormal (σ = 1), both with mean 5,000. Probability a claim exceeds k × the mean:
| k | Exponential | Lognormal | Ratio |
|---|---|---|---|
| 2 | 1.35 × 10⁻¹ | 1.16 × 10⁻¹ | 0.9 |
| 5 | 6.74 × 10⁻³ | 1.75 × 10⁻² | 2.6 |
| 10 | 4.54 × 10⁻⁵ | 2.54 × 10⁻³ | 56 |
| 20 | 2.06 × 10⁻⁹ | 2.36 × 10⁻⁴ | 115,000 |
Skewness: exponential 2.0, lognormal 6.18.
Mean excess e(d) = E[X − d | X > d], from 1,000,000 lognormal draws:
| Threshold d | Exponential | Lognormal |
|---|---|---|
| 5,000 | 5,000 | 6,201 |
| 20,000 | 5,000 | 11,746 |
| 50,000 | 5,000 | 19,533 |
A mean excess that grows with the threshold is the signature of a heavy tail: the bigger a claim already is, the bigger you should expect it to get. That’s why excess-of-loss layers are hard to price.
Actuarial Lab · 75 minA driver profile generator
Build a synthetic motor portfolio of 10,000 drivers — each with an age, an annual mileage, a claim count, and claim amounts after a deductible and a policy limit.
18 – 85
median 12,000
One seeded generator for everything, and draw in the order shown on the next slide — then your numbers will match the answer table.
Lab · Code skeletonFrom drivers to paid claims
rng = np.random.default_rng(2026)
n = 10_000
age_dist = stats.truncnorm((18 - 42) / 14, (85 - 42) / 14,
loc=42, scale=14)
ages = np.floor(age_dist.rvs(size=n, random_state=rng)).astype(int)
mileage = stats.lognorm(s=0.5, scale=12_000).rvs(size=n, random_state=rng)
base_freq = 0.08
age_factor = np.where(ages < 25, 1.8, np.where(ages >= 70, 1.3, 1.0))
freq = base_freq * (mileage / 12_000) ** 0.5 * age_factor
claims = rng.poisson(freq) # claim count per driver
sev_dist = stats.lognorm(s=1.2, scale=4_000)
deductible, limit = 500, 50_000
ground_up = sev_dist.rvs(size=claims.sum(), random_state=rng)
paid = np.minimum(np.maximum(ground_up - deductible, 0), limit)
| Check | Value |
|---|---|
| Mean age · under-25 share | 43.06 · 6.55% |
| Median · mean mileage | 12,000 · 13,652 |
| Mean frequency | 0.0876 |
| Total claims | 852 |
| Claims per driver 0 / 1 / 2 / 3 / 4 | 9,189 / 773 / 36 / 1 / 1 |
| Below deductible · hit the limit | 34 · 15 |
| Mean paid per claim | 7,132.35 |
| Loss cost per driver | 607.68 |
Lab · TasksValidate, then explore
Validate
- Reproduce the answer table
- Compare the simulated mean paid per claim with theory:
E[min(X, 50,500)] − E[min(X, 500)]usingsev_dist.expect(...)→ 7,114.09. Within a standard error? - Histogram
ages: check the hard edges at 18 and 85
Explore
- Raise the limit to 250,000. How much does loss cost rise — and why so little?
- Which drivers generate the most loss per head: under-25s, over-70s, or high-mileage?
Package it
Move the generator into src/foundations/simulation.py as a function:
def simulate_portfolio(n: int, rng: np.random.Generator,
deductible: float = 500,
limit: float = 50_000) -> dict:
"""Simulate drivers and their paid claims.
Returns a dict with arrays: ages, mileage,
claims (count per driver), paid (per claim).
"""
...
This function is the frequency–severity engine of the next lesson’s capstone. Commit it.
RecapLesson 7 checklist
- I freeze
scipy.statsdistributions and name every parameter - I know when to use
pdf,cdf,sf, andppf - I can parameterize a lognormal from μ and σ — and check it
- I give
truncnormits bounds in standard units
- I can explain inverse-transform sampling
- I can compare tails with exceedance probabilities and mean excess
- I apply deductibles and limits with
np.minimum/np.maximum simulate_portfolio()lives insrc/foundations/
Capstone: A Financial Simulation Engine
Modules, docstrings, and pytest — then a Monte Carlo library that computes a portfolio’s VaR and TVaR.