Monte Carlo¶
riskpy.mc is for the case where the formula is too awkward to solve. Instead
of integrating, you feed it random inputs a few hundred thousand times and read
the distribution that falls out.
The shape of it¶
from riskpy import viz
from riskpy.mc import (
Model, Poisson, LogNormal, Normal, PERT,
Gamma, NegativeBinomial, Pareto, Mixture, Truncated, Categorical,
)
model = Model(
claim_count = Poisson(mean=140),
severity = LogNormal.from_moments(mean=18_000, sd=42_000),
inflation = Normal(mean=0.043, sd=0.012),
reinsurance_recovery = PERT(low=0.0, mode=0.15, high=0.45),
)
@model.formula
def annual_loss(claim_count, severity, inflation, reinsurance_recovery):
gross = claim_count * severity * (1 + inflation)
return gross * (1 - reinsurance_recovery)
result = model.run(200_000, seed=42)
Your formula is called once, with NumPy arrays of length trials — not once
per trial. Two hundred thousand trials is a single vectorised expression.
If your formula has branching NumPy cannot express, pass vectorised=False and
it is looped instead. Correct, just slower.
Always pass a seed
seed=42 makes a run reproducible. Without one you get a different answer
every time, which is fine for exploring and miserable for a report someone
else has to check.
Without the decorator¶
simulate() is the same engine with no Model object, which reads better when
you are sweeping scenarios in a loop:
from riskpy.mc import simulate, Constant
result = simulate(
lambda claims, severity: claims * severity,
trials=100_000, seed=42, label="annual loss",
claims=Poisson(mean=140),
severity=LogNormal.from_moments(mean=18_000, sd=42_000),
)
Or let the model sweep for you — same seed for every scenario, so the differences are the scenarios and not the noise:
scenarios = model.sweep("reinsurance_recovery", {
"no cover": Constant(0.0),
"quota share 15%": Constant(0.15),
"negotiated": PERT(0.05, 0.25, 0.5),
}, trials=100_000, seed=42)
viz.compare(scenarios)
Distributions¶
| Continuous | Discrete | Composite |
|---|---|---|
Normal(mean, sd) |
Poisson(mean) |
Constant(value) |
LogNormal(mu, sigma) |
NegativeBinomial(n_success, p) |
Empirical(data) |
Uniform(low, high) |
Bernoulli(p) |
Categorical(values, probs) |
Triangular(low, mode, high) |
Binomial(trials, p) |
Mixture([dists], weights) |
PERT(low, mode, high, lam=4) |
Truncated(dist, low, high) |
|
Exponential(scale) |
||
Gamma(shape, scale) |
||
Beta(a, b) |
||
Pareto(xm, alpha) |
||
Weibull(shape, scale) |
||
StudentT(df, loc, scale) |
Alternative constructors exist where the raw parameters are never the numbers you actually have:
LogNormal.from_moments(mean=18_000, sd=42_000) # not mu and sigma of the log
LogNormal.from_median_cv(median=12_000, cv=1.8) # how reinsurance quotes them
Gamma.from_moments(mean=12, sd=4)
NegativeBinomial.from_mean_dispersion(mean=140, dispersion=2.4)
Pareto with a light tail index
Pareto has an infinite mean for alpha <= 1 and infinite variance for
alpha <= 2. The sample mean will still print a number. It will not mean
anything — and Pareto(...).moments() will say inf, which is the
honest answer.
Parameters are validated when the distribution is constructed, not when it is sampled — a negative standard deviation is a typo, and finding out after a ten-second run is worse than useless.
The analytic layer¶
Every distribution knows more than how to sample itself:
d = Gamma(2.5, 1.7)
d.pdf(x), d.cdf(x), d.ppf(q) # vectorised; scalar in, scalar out
d.moments() # Moments(mean=4.25, variance=7.225) with .sd and .cv
d.support() # (0.0, inf)
The quantile functions are accurate to around 1e-10 relative or better across the whole range, and to machine precision in the extreme tails, where the survival function is inverted directly rather than the CDF. They are what make Latin hypercube sampling, truncation and correlated inputs possible, and they are built on a dependency-free special-function layer (incomplete gamma and beta, Student-t) that is checked against SciPy in the test suite without ever requiring it.
Composites¶
Mixture([LogNormal(8, 1), Pareto(50_000, 2.5)], weights=[0.9, 0.1]) # attritional + large
Truncated(LogNormal(8, 1), high=1_000_000) # a policy limit
Truncated(Normal(0.04, 0.02), low=0.0) # a rate that cannot go negative
Categorical([0.9, 1.0, 1.15], probs=[0.2, 0.6, 0.2]) # scenario weights
Truncated samples by inversion, so it never rejects and never loops, and its
moments are integrated numerically to about 1e-8 relative.
Dependence¶
Inputs are independent unless you say otherwise. Two ways to say otherwise:
model.correlate("claim_count", "inflation", 0.3) # Spearman rank correlation
model.correlate("severity", "inflation", 0.2)
result = simulate(fn, ..., correlation={("a", "b"): 0.7}) # or a full matrix
This is Iman–Conover: the samples are drawn independently and then re-paired so their ranks follow a correlated normal score matrix. The marginals are untouched — every sampled value is kept — which is why it works with any distribution and with Latin hypercube draws. The recovered rank correlation lands within 0.01 of the target at 100k trials.
A set of pairwise correlations can be jointly impossible; the error names the smallest eigenvalue rather than letting a Cholesky failure surface later.
For tail dependence a correlation cannot express, simulate accepts
copula= — any object with .dim and .uniforms(n, rng) returning an
(n, dim) array of correlated uniforms, which are then pushed through each
distribution's ppf.

Latin hypercube sampling¶
One draw from each of n equal-probability strata, in random order. Same
marginal, far less noise: the standard deviation of the estimated mean falls
by a factor of two to three for typical loss models, at no cost. Distributions
without a quantile function fall back to plain sampling for that input.
Reading the result¶
result.mean, result.std, result.median
result.skewness, result.kurtosis # how badly a normal approximation will do
result.standard_error # how much the mean would move on a new seed
result.percentile(99.5)
result.var(0.995) # Value at Risk
result.tvar(0.995) # mean loss beyond VaR
result.cdf(5e6) # P(output <= x)
result.prob_above(5e6)
result.describe() # a dict of the lot
result.values # the raw array
summary() returns the headline table as a string. For the model on this page,
with its two correlations and sampling="lhs":
Monte Carlo — annual_loss
trials 200,000 seed 42
mean 2,178,785.66 ± 23,229.21 (95% CI)
std dev 5,300,213.95 skew +25.06
min / max 1,809.39 / 737,951,531.59
level VaR TVaR
50.000% 847,483.26 3,988,906.14
75.000% 2,134,575.78 6,608,175.09
90.000% 4,916,632.64 11,708,574.21
95.000% 8,128,962.41 17,180,392.03
99.000% 20,664,267.01 37,318,456.01
99.500% 29,220,862.23 50,413,329.25
Quantile queries sort once and cache the order, so asking for a dozen VaR levels costs one sort, not twelve.
VaR and TVaR¶
var(0.995) is the loss exceeded 0.5% of the time. tvar(0.995) is the
average of those worst 0.5% of outcomes, so it is always at least as large.
Prefer TVaR for the reason regulators do: VaR tells you where the threshold is and nothing whatsoever about how bad things get past it. Two portfolios with identical VaR can have wildly different tails.
Did I run enough trials?¶
standard_error is how much the mean would wobble on a different seed. It
shrinks with the square root of the trial count, so ten times the precision
costs a hundred times the work — or one switch to sampling="lhs".
What is driving the answer?¶
Spearman rank correlation by default — the relationship between an input
and a total is usually monotonic and rarely linear. method="pearson" for the
linear case and method="contribution" for each input's share of explained
variance. Plot it with result.plot("tornado").
Exporting¶
result.to_frame() # pandas DataFrame (needs pandas): every input column plus the output
result.histogram(60) # (edges, counts)
result.exceedance_curve()
Common errors, and what they mean¶
"The formula returned shape (), expected (200000,)" — your formula collapsed
the arrays to a scalar. Usually an if (use np.where), a min() (use
np.minimum), or a sum() that should not be there.
"12,431 of 200,000 trials produced NaN or infinity" — nearly always a division by a variable that can reach zero, or a log of one that can go negative. The count tells you how much of the tail is involved.
"claim_count must be a Distribution, got float" — wrap fixed numbers in
Constant(...). This is deliberate: silently accepting a bare number makes it
easy to freeze an input you meant to vary.
"correlation matrix is not positive semi-definite" — the pairwise correlations you asked for cannot all hold at once. Reduce the strongest ones.