alidenewade's picture
Initial release: 7-tab simulator with synced animations on Reliability / OEP / Pricing / Economics + 16 paper figures
c561a2a verified
Raw
History Blame Contribute Delete
3.08 kB
"""Severity model — paper §8: lognormal body, GPD tail (POT).
Implements only the CPU operations that the Space needs (sample, quantile,
TVaR). The full statistics+inference machinery lives in dcrisk.severity.
"""
from __future__ import annotations
import numpy as np
from scipy import stats
# ---------- Lognormal body ----------------------------------------------------
def lognormal_sample(mu: float, sigma: float, size: int, rng) -> np.ndarray:
"""Draw size samples from LogN(mu, sigma^2)."""
return rng.lognormal(mean=mu, sigma=sigma, size=size)
def lognormal_quantile(p: float, mu: float, sigma: float) -> float:
return float(stats.lognorm.ppf(p, s=sigma, scale=np.exp(mu)))
# ---------- Generalised Pareto tail (POT) ------------------------------------
def gpd_quantile(p, xi: float, sigma: float, threshold: float = 0.0):
"""Inverse CDF of GPD(ξ, σ) shifted to `threshold`. Vectorised over p."""
p = np.asarray(p)
if abs(xi) < 1e-9:
return threshold + sigma * (-np.log1p(-p))
return threshold + (sigma / xi) * ((1 - p) ** (-xi) - 1)
def gpd_sample(xi: float, sigma: float, threshold: float, size: int, rng) -> np.ndarray:
"""Inverse-CDF sampling from GPD."""
u = rng.uniform(size=size)
return gpd_quantile(u, xi, sigma, threshold)
def gpd_tvar(alpha: float, xi: float, sigma: float, threshold: float = 0.0) -> float:
"""Tail-VaR at level α (closed form for GPD with ξ < 1)."""
if xi >= 1.0:
return float("inf")
var_alpha = gpd_quantile(alpha, xi, sigma, threshold)
return float((var_alpha + sigma - xi * threshold) / (1.0 - xi))
# ---------- Compound NB-GPD aggregate (single year, no climate uplift) -------
def compound_one_year(
nu: float, lam: float,
body_mu: float, body_sigma: float,
tail_xi: float, tail_sigma: float, tail_threshold: float,
tail_fraction: float,
rng,
) -> float:
"""One realisation of S = Σ X_j with frequency NB(ν, ν/(ν+λ)) and
body/tail mixture severity (with prob `tail_fraction` we draw GPD).
"""
p = nu / (nu + lam)
n_events = int(rng.negative_binomial(nu, p))
if n_events == 0:
return 0.0
is_tail = rng.uniform(size=n_events) < tail_fraction
n_tail = int(is_tail.sum())
n_body = n_events - n_tail
body = lognormal_sample(body_mu, body_sigma, n_body, rng) if n_body else np.empty(0)
tail = gpd_sample(tail_xi, tail_sigma, tail_threshold, n_tail, rng) if n_tail else np.empty(0)
return float(body.sum() + tail.sum())
def simulate_annual_losses(
n_years: int,
nu: float, lam: float,
body_mu: float, body_sigma: float,
tail_xi: float, tail_sigma: float, tail_threshold: float,
tail_fraction: float,
seed: int = 42,
) -> np.ndarray:
rng = np.random.default_rng(seed)
out = np.empty(n_years, dtype=np.float64)
for y in range(n_years):
out[y] = compound_one_year(
nu, lam, body_mu, body_sigma,
tail_xi, tail_sigma, tail_threshold, tail_fraction,
rng,
)
return out