Heavy Tails & Skewness
Python · R · Download distributions module
The Catalog
Folder 3 of the Statistical Distributions catalog. The Gaussian family has light tails — extremes are astronomically rare and every shape is symmetric. Finance, insurance, hydrology and network data are not like that: crashes, floods and viral events occur far more often than a Normal permits, and losses are rarely symmetric with gains. This folder builds the heavy-tailed and skewed laws designed for exactly those features — nine with a from-scratch PDF, CDF, RNG and moments validated against scipy.stats, plus the -stable family, which has no closed-form density at all.
| Group | Distributions | What it adds |
|---|---|---|
| Heavier than Normal, still symmetric | Laplace, Logistic | excess kurtosis with all moments intact |
| Moments that vanish | Cauchy, Pareto, Lévy | no mean; moments only below ; infinite mean |
| A continuous tail knob | Student-, Generalized Error Distribution | df and shape tune kurtosis |
| Asymmetry | Skew-Normal | a shape parameter that tilts the Gaussian |
| The unifying family | -stable | no closed-form density — CMS sampling, CF validation |
| Finance capstone | Hansen's skew- | fat-tailed and skewed, standardized by construction |
What "heavy tail" means precisely
A heavy tail decays more slowly than the Normal's . Power-law tails, , are the extreme case: they decay so slowly that moments cease to exist. That is not an edge case handled quietly here — it is the content. The Cauchy has no mean, variance, skewness or kurtosis, so no moment table is printed for it at all; the Lévy, with its tail, has an infinite mean; and a Pareto() has moments only of order below — at the illustrated the mean and variance are finite while the skewness and kurtosis are reported as undefined. Positive excess kurtosis is the mild diagnostic of tail weight; a finite tail index is the severe one.
α-stable laws — sampling without a density
The unifying idea. The Central Limit Theorem says sums of finite-variance variables converge to the Normal. Drop the finite-variance assumption and sums converge instead to an -stable law — a family that contains the Normal (), the Cauchy () and the Lévy () as special cases. Stable laws have no closed-form density in general, which forces a different approach on both sides: they are sampled by the Chambers–Mallows–Stuck transform, and validated through the characteristic function instead of a density — the empirical against the theoretical across a grid, matching to Monte-Carlo error at . A single CMS sampler is then shown reproducing all three named special cases against their closed-form densities.
Skewness
Skewness is asymmetry — a longer tail on one side. The Skew-Normal adds a shape parameter that tilts the Gaussian (negative left, zero back to Normal, positive right), sampled by Azzalini's two-normal construction. The finance capstone is Hansen's skew-, fat-tailed and skewed, standardized to mean 0 and variance 1 by construction and drawn by numerical inverse-transform since it has no closed-form quantile. The notebooks verify it nests its symmetric parent exactly: at the Hansen density and the standardized Student- agree to machine precision.
Tail diagnostics
The folder closes with the tail-diagnostic toolkit — the part that matters when you have data rather than a generator. Survival curves on a log -axis order the laws by how slowly they decay, turning tail weight into something visible. The Hill estimator then goes the other direction, recovering the tail index from a sample: plotted against the number of upper order statistics , each curve settles onto its true over a moderate range — the flat region practitioners read off, and a direct demonstration of the bias–variance trade-off in choosing .
Notebooks
The Python notebook validates every density and CDF against scipy.stats. The R notebook re-derives all of it independently in base R, checking against the built-ins where they exist (dlogis, dcauchy, dt) and against each law's own special-case reductions where they do not — GED collapsing to Normal and Laplace, the stable law to Normal, Cauchy and Lévy — while recovering every CDF by integrating its own PDF rather than calling a library routine. Both notebooks close by timing the generators: the closed-form inverse transforms (Cauchy, Pareto) run ahead of scipy's generic .rvs, the construction-based ones (Student-, GED, Skew-Normal) run behind it but within the same order of magnitude, and the numerical inverse-CDF behind Hansen's skew- is the slowest in the set — it must integrate a density grid before it can draw a single value. That is the price of sampling a law you can only write down as a density.
Downloads
Distributions Module — Source Code
"""
heavytails.py -- From-scratch HEAVY-TAILED & SKEWED continuous distributions.
Backs the notebooks in Z-Statistical Distributions-HeavyTails-Skew (Folder 3 of
the Statistical Distributions catalog).
These are the laws built to model what the light-tailed Gaussian family misses:
fat tails (rare extremes) and asymmetry. Everything is implemented from the
definitions using only elementary math and a few special functions as primitives
(erf/erfc, the regularized incomplete gamma). No distribution objects. `scipy.stats`
(Python notebook) and R (R notebook) are used as INDEPENDENT references.
For every distribution we provide, where they are defined:
*_pdf(x, ...) probability density function
*_cdf(x, ...) cumulative distribution function
*_rng(..., n, rng) a random-number generator (the sampling ALGORITHM matters)
*_moments(...) mean, variance, skewness, excess kurtosis (closed form; NaN where undefined)
Highlights (the from-scratch RNG algorithms):
Laplace, Logistic, Cauchy, Pareto : inverse transform (closed-form quantile)
GED (exponential power) : signed Gamma^(1/beta)
Skew-Normal : Azzalini's delta|Z0| + sqrt(1-delta^2) Z1
alpha-Stable : Chambers-Mallows-Stuck (CMS) -- the general heavy-tail family
Levy : c / Normal^2 (the alpha=1/2 stable)
Hansen skew-t : numerical inverse-transform (no elementary quantile)
Parameterisations match scipy where a match exists:
laplace(loc, scale) ; logistic(loc, scale) ; cauchy(loc, scale)
student_t_ls(loc, scale, df) -- location-scale Student-t
gennorm/GED(loc, scale, beta) -- scipy.stats.gennorm(beta, loc, scale)
skewnorm(xi, omega, alpha) -- scipy.stats.skewnorm(alpha, loc=xi, scale=omega)
pareto(xm, alpha) -- scipy.stats.pareto(alpha, scale=xm)
levy(loc, scale) -- scipy.stats.levy(loc, scale)
hansen_skewt(eta, lam) -- standardized (mean 0, variance 1), -1<lam<1, eta>2
"""
import numpy as np
from scipy.special import erf, erfc, gamma as Gamma, gammaln, gammainc
SQRT2 = np.sqrt(2.0)
_trapz = getattr(np, "trapezoid", getattr(np, "trapz", None)) # np.trapz renamed in NumPy 2.0
# --------------------------------------------------------------------------- #
# Generic helpers #
# --------------------------------------------------------------------------- #
def emp_moments(x):
x = np.asarray(x, float); m = x.mean(); c2 = x.var()
return dict(mean=m, var=c2, skew=((x - m) ** 3).mean() / c2 ** 1.5,
exkurt=((x - m) ** 4).mean() / c2 ** 2 - 3)
def moments_from_pdf(pdf, lo, hi, n=800001):
"""Mean/var/skew/excess-kurtosis by numerical integration of a pdf on a fine
grid -- an independent cross-check where closed forms exist (and the only route
for the Hansen skew-t)."""
x = np.linspace(lo, hi, n)
px = np.asarray(pdf(x), float)
Z = _trapz(px, x); px = px / Z
m = _trapz(x * px, x)
c2 = _trapz((x - m) ** 2 * px, x)
c3 = _trapz((x - m) ** 3 * px, x)
c4 = _trapz((x - m) ** 4 * px, x)
sd = np.sqrt(c2)
return dict(mean=m, var=c2, skew=c3 / sd ** 3, exkurt=c4 / c2 ** 2 - 3)
def _grid_cdf(pdf, xs, lo, npts=20001):
"""CDF at each point of xs by cumulative-trapezoid integration of pdf from lo."""
xs = np.asarray(xs, float)
out = np.empty_like(xs)
for i, xi in enumerate(xs):
g = np.linspace(lo, xi, npts)
out[i] = _trapz(pdf(g), g)
return np.clip(out, 0, 1)
def inverse_transform_sample(pdf, lo, hi, n, rng, npts=200001):
"""General inverse-transform sampler for a density with no elementary quantile:
build the CDF on a fine grid (cumulative trapezoid), then map uniforms through
the inverse CDF by interpolation."""
g = np.linspace(lo, hi, npts)
pg = np.asarray(pdf(g), float)
cdf = np.concatenate([[0.0], np.cumsum((pg[1:] + pg[:-1]) / 2 * np.diff(g))])
cdf /= cdf[-1]
u = rng.random(n)
return np.interp(u, cdf, g)
# --------------------------------------------------------------------------- #
# Laplace(loc mu, scale b) -- double exponential #
# --------------------------------------------------------------------------- #
def laplace_pdf(x, mu, b):
x = np.asarray(x, float)
return np.exp(-np.abs(x - mu) / b) / (2 * b)
def laplace_cdf(x, mu, b):
x = np.asarray(x, float); z = (x - mu) / b
return np.where(x < mu, 0.5 * np.exp(z), 1 - 0.5 * np.exp(-z))
def laplace_rng(mu, b, n, rng):
"""Inverse transform: U-0.5 -> mu - b*sign(U-.5)*ln(1-2|U-.5|)."""
u = rng.random(n) - 0.5
return mu - b * np.sign(u) * np.log1p(-2 * np.abs(u))
def laplace_moments(mu, b):
return dict(mean=mu, var=2 * b ** 2, skew=0.0, exkurt=3.0)
# --------------------------------------------------------------------------- #
# Logistic(loc mu, scale s) #
# --------------------------------------------------------------------------- #
def logistic_pdf(x, mu, s):
z = (np.asarray(x, float) - mu) / s
e = np.exp(-np.abs(z)) # stable form
return e / (s * (1 + e) ** 2)
def logistic_cdf(x, mu, s):
z = (np.asarray(x, float) - mu) / s
return 1.0 / (1 + np.exp(-z))
def logistic_rng(mu, s, n, rng):
"""Inverse transform (the logit): mu + s*ln(U/(1-U))."""
u = rng.random(n)
return mu + s * np.log(u / (1 - u))
def logistic_moments(mu, s):
return dict(mean=mu, var=s ** 2 * np.pi ** 2 / 3, skew=0.0, exkurt=1.2)
# --------------------------------------------------------------------------- #
# Cauchy(loc x0, scale g) -- the no-moments distribution (t with df=1) #
# --------------------------------------------------------------------------- #
def cauchy_pdf(x, x0, g):
z = (np.asarray(x, float) - x0) / g
return 1.0 / (np.pi * g * (1 + z ** 2))
def cauchy_cdf(x, x0, g):
z = (np.asarray(x, float) - x0) / g
return 0.5 + np.arctan(z) / np.pi
def cauchy_rng(x0, g, n, rng):
"""Inverse transform: x0 + g*tan(pi*(U-1/2))."""
return x0 + g * np.tan(np.pi * (rng.random(n) - 0.5))
def cauchy_moments(x0, g):
return dict(mean=np.nan, var=np.nan, skew=np.nan, exkurt=np.nan) # none exist
# --------------------------------------------------------------------------- #
# Student-t, location-scale (loc mu, scale sig, df nu) -- tunable tails #
# --------------------------------------------------------------------------- #
def student_t_ls_pdf(x, mu, sig, nu):
z = (np.asarray(x, float) - mu) / sig
logp = gammaln((nu + 1) / 2) - gammaln(nu / 2) - 0.5 * np.log(nu * np.pi) \
- (nu + 1) / 2 * np.log1p(z ** 2 / nu)
return np.exp(logp) / sig
def student_t_ls_cdf(x, mu, sig, nu):
from scipy.special import betainc
z = (np.asarray(x, float) - mu) / sig
ib = betainc(nu / 2.0, 0.5, nu / (nu + z ** 2))
return np.where(z >= 0, 1 - 0.5 * ib, 0.5 * ib)
def student_t_ls_rng(mu, sig, nu, n, rng):
"""mu + sig * Z / sqrt(V/nu), Z~Normal, V~Chi-square(nu) (built from Gammas)."""
z = _std_normal(n, rng)
v = 2.0 * _gamma_shape(nu / 2.0, n, rng) # Chi-square(nu) = Gamma(nu/2, scale 2)
return mu + sig * z / np.sqrt(v / nu)
def student_t_ls_moments(mu, sig, nu):
mean = mu if nu > 1 else np.nan
var = sig ** 2 * nu / (nu - 2) if nu > 2 else np.nan
skew = 0.0 if nu > 3 else np.nan
exkurt = 6.0 / (nu - 4) if nu > 4 else np.nan
return dict(mean=mean, var=var, skew=skew, exkurt=exkurt)
# --------------------------------------------------------------------------- #
# Generalized Error Distribution / exponential power (loc mu, scale a, shape b) #
# pdf = b / (2 a Gamma(1/b)) * exp( -(|x-mu|/a)^b ) #
# --------------------------------------------------------------------------- #
def ged_pdf(x, mu, a, b):
z = np.abs(np.asarray(x, float) - mu) / a
return b / (2 * a * Gamma(1.0 / b)) * np.exp(-z ** b)
def ged_cdf(x, mu, a, b):
x = np.asarray(x, float); z = np.abs(x - mu) / a
half = 0.5 * gammainc(1.0 / b, z ** b) # regularized lower incomplete gamma
return np.where(x >= mu, 0.5 + half, 0.5 - half)
def ged_rng(mu, a, b, n, rng):
"""|X-mu| = a * G^(1/b) with G ~ Gamma(1/b, 1); attach a random sign."""
g = _gamma_shape(1.0 / b, n, rng)
sign = np.where(rng.random(n) < 0.5, -1.0, 1.0)
return mu + sign * a * g ** (1.0 / b)
def ged_moments(mu, a, b):
var = a ** 2 * Gamma(3.0 / b) / Gamma(1.0 / b)
exkurt = Gamma(5.0 / b) * Gamma(1.0 / b) / Gamma(3.0 / b) ** 2 - 3
return dict(mean=mu, var=var, skew=0.0, exkurt=exkurt)
# --------------------------------------------------------------------------- #
# Skew-Normal(loc xi, scale omega, shape alpha) -- Azzalini #
# --------------------------------------------------------------------------- #
def _std_normal_pdf(z):
return np.exp(-0.5 * z ** 2) / np.sqrt(2 * np.pi)
def _std_normal_cdf(z):
return 0.5 * (1 + erf(z / SQRT2))
def skewnorm_pdf(x, xi, omega, alpha):
z = (np.asarray(x, float) - xi) / omega
return 2.0 / omega * _std_normal_pdf(z) * _std_normal_cdf(alpha * z)
def skewnorm_cdf(x, xi, omega, alpha):
# no elementary form (needs Owen's T); integrate the density numerically
return _grid_cdf(lambda t: skewnorm_pdf(t, xi, omega, alpha), x, xi - 40 * omega)
def skewnorm_rng(xi, omega, alpha, n, rng):
"""Azzalini construction: delta=alpha/sqrt(1+alpha^2);
Z = delta*|U0| + sqrt(1-delta^2)*U1 with U0,U1 iid Normal."""
delta = alpha / np.sqrt(1 + alpha ** 2)
u0 = _std_normal(n, rng); u1 = _std_normal(n, rng)
z = delta * np.abs(u0) + np.sqrt(1 - delta ** 2) * u1
return xi + omega * z
def skewnorm_moments(xi, omega, alpha):
delta = alpha / np.sqrt(1 + alpha ** 2)
b = np.sqrt(2 / np.pi) * delta
mean = xi + omega * b
var = omega ** 2 * (1 - b ** 2)
skew = (4 - np.pi) / 2 * b ** 3 / (1 - b ** 2) ** 1.5
exkurt = 2 * (np.pi - 3) * b ** 4 / (1 - b ** 2) ** 2
return dict(mean=mean, var=var, skew=skew, exkurt=exkurt)
# --------------------------------------------------------------------------- #
# Pareto Type I (scale xm, shape alpha) -- the canonical power-law tail #
# --------------------------------------------------------------------------- #
def pareto_pdf(x, xm, alpha):
x = np.asarray(x, float)
return np.where(x >= xm, alpha * xm ** alpha / x ** (alpha + 1), 0.0)
def pareto_cdf(x, xm, alpha):
x = np.asarray(x, float)
return np.where(x >= xm, 1 - (xm / x) ** alpha, 0.0)
def pareto_rng(xm, alpha, n, rng):
"""Inverse transform: xm / U^(1/alpha) (U ~ Uniform(0,1))."""
return xm / rng.random(n) ** (1.0 / alpha)
def pareto_moments(xm, alpha):
mean = alpha * xm / (alpha - 1) if alpha > 1 else np.nan
var = xm ** 2 * alpha / ((alpha - 1) ** 2 * (alpha - 2)) if alpha > 2 else np.nan
skew = (2 * (1 + alpha) / (alpha - 3) * np.sqrt((alpha - 2) / alpha)) if alpha > 3 else np.nan
exkurt = (6 * (alpha ** 3 + alpha ** 2 - 6 * alpha - 2) /
(alpha * (alpha - 3) * (alpha - 4))) if alpha > 4 else np.nan
return dict(mean=mean, var=var, skew=skew, exkurt=exkurt)
# --------------------------------------------------------------------------- #
# Levy(loc mu, scale c) -- one-sided, the alpha=1/2 stable #
# --------------------------------------------------------------------------- #
def levy_pdf(x, mu, c):
x = np.asarray(x, float); z = x - mu
zp = np.where(z > 0, z, 1.0)
return np.where(z > 0, np.sqrt(c / (2 * np.pi)) * np.exp(-c / (2 * zp)) / zp ** 1.5, 0.0)
def levy_cdf(x, mu, c):
x = np.asarray(x, float); z = x - mu
zp = np.where(z > 0, z, np.inf)
return np.where(z > 0, erfc(np.sqrt(c / (2 * zp))), 0.0)
def levy_rng(mu, c, n, rng):
"""X = mu + c / Z^2 with Z ~ Normal(0,1) -- the alpha=1/2, beta=1 stable law."""
z = _std_normal(n, rng)
return mu + c / z ** 2
def levy_moments(mu, c):
return dict(mean=np.nan, var=np.nan, skew=np.nan, exkurt=np.nan) # even the mean is infinite
# --------------------------------------------------------------------------- #
# alpha-Stable(alpha, beta) -- Chambers-Mallows-Stuck sampler #
# Standard S(alpha, beta; loc 0, scale 1). No elementary pdf in general. #
# --------------------------------------------------------------------------- #
def stable_rng(alpha, beta, n, rng, loc=0.0, scale=1.0):
"""Chambers, Mallows & Stuck (1976). Draw W~Exp(1), U~Uniform(-pi/2, pi/2)."""
U = np.pi * (rng.random(n) - 0.5)
W = -np.log(rng.random(n))
if abs(alpha - 1.0) > 1e-8:
zeta = -beta * np.tan(np.pi * alpha / 2)
xi = np.arctan(-zeta) / alpha
X = ((1 + zeta ** 2) ** (1 / (2 * alpha))
* np.sin(alpha * (U + xi)) / np.cos(U) ** (1 / alpha)
* (np.cos(U - alpha * (U + xi)) / W) ** ((1 - alpha) / alpha))
else:
X = (2 / np.pi) * ((np.pi / 2 + beta * U) * np.tan(U)
- beta * np.log((np.pi / 2 * W * np.cos(U)) / (np.pi / 2 + beta * U)))
return loc + scale * X
def stable_cf(t, alpha, beta):
"""Theoretical characteristic function phi(t)=E[e^{itX}] of the standard stable
law (loc 0, scale 1), used to validate the CMS sampler where no pdf exists."""
t = np.asarray(t, float)
if abs(alpha - 1.0) > 1e-8:
phi = np.tan(np.pi * alpha / 2)
return np.exp(-np.abs(t) ** alpha * (1 - 1j * beta * np.sign(t) * phi))
else:
return np.exp(-np.abs(t) * (1 + 1j * beta * (2 / np.pi) * np.sign(t) * np.log(np.abs(t) + (t == 0))))
# --------------------------------------------------------------------------- #
# Hansen (1994) skew-t -- standardized (mean 0, var 1); heavy tails + skew #
# --------------------------------------------------------------------------- #
def _hansen_abc(eta, lam):
c = Gamma((eta + 1) / 2) / (np.sqrt(np.pi * (eta - 2)) * Gamma(eta / 2))
a = 4 * lam * c * (eta - 2) / (eta - 1)
b = np.sqrt(1 + 3 * lam ** 2 - a ** 2)
return a, b, c
def hansen_skewt_pdf(z, eta, lam):
z = np.asarray(z, float)
a, b, c = _hansen_abc(eta, lam)
denom = np.where(z < -a / b, 1 - lam, 1 + lam)
inner = 1 + 1.0 / (eta - 2) * ((b * z + a) / denom) ** 2
return b * c * inner ** (-(eta + 1) / 2)
def hansen_skewt_cdf(z, eta, lam):
return _grid_cdf(lambda t: hansen_skewt_pdf(t, eta, lam), z, -60.0)
def hansen_skewt_rng(eta, lam, n, rng):
"""No elementary quantile -> general inverse-transform sampling of the density."""
return inverse_transform_sample(lambda t: hansen_skewt_pdf(t, eta, lam), -60.0, 60.0, n, rng)
def hansen_skewt_moments(eta, lam):
# mean 0 and variance 1 by construction; skew/kurtosis by numerical integration
m = moments_from_pdf(lambda t: hansen_skewt_pdf(t, eta, lam), -80.0, 80.0)
return dict(mean=0.0, var=1.0, skew=m["skew"], exkurt=m["exkurt"])
# --------------------------------------------------------------------------- #
# Private RNG primitives (self-contained: Box-Muller normal, Marsaglia-Tsang) #
# --------------------------------------------------------------------------- #
def _std_normal(n, rng):
m = (n + 1) // 2
u1 = rng.random(m); u2 = rng.random(m)
r = np.sqrt(-2 * np.log(u1))
z = np.empty(2 * m)
z[:m] = r * np.cos(2 * np.pi * u2)
z[m:] = r * np.sin(2 * np.pi * u2)
return z[:n]
def _gamma_shape(k, n, rng):
"""Gamma(shape k, scale 1) by Marsaglia-Tsang (vectorised batch rejection)."""
n = int(n)
if k < 1:
g = _gamma_shape(k + 1.0, n, rng)
u = rng.random(n)
return g * u ** (1.0 / k)
d = k - 1.0 / 3.0; c = 1.0 / np.sqrt(9.0 * d)
res = np.empty(n); todo = np.ones(n, bool)
while todo.any():
m = int(todo.sum())
x = _std_normal(m, rng)
v = (1.0 + c * x) ** 3
u = rng.random(m)
ok = (v > 0) & (np.log(u) < 0.5 * x ** 2 + d - d * v + d * np.log(np.where(v > 0, v, 1.0)))
cur = np.where(todo)[0]; acc = cur[ok]
res[acc] = d * v[ok]; todo[acc] = False
return res
References
- Nolan, J. P. (2020). Univariate Stable Distributions: Models for Heavy Tailed Data. Springer. — the parameterisations, characteristic function and estimation of α-stable laws
- Chambers, J. M., Mallows, C. L. & Stuck, B. W. (1976). A method for simulating stable random variables. Journal of the American Statistical Association 71(354), 340–344. — the CMS transform implemented here, and its reduction to Normal, Cauchy and Lévy
- Hansen, B. E. (1994). Autoregressive conditional density estimation. International Economic Review 35(3), 705–730. — the skewed Student-t used as the finance capstone
- Azzalini, A. (1985). A class of distributions which includes the normal ones. Scandinavian Journal of Statistics 12(2), 171–178. — the Skew-Normal and the two-normal sampling construction
- Hill, B. M. (1975). A simple general approach to inference about the tail of a distribution. Annals of Statistics 3(5), 1163–1174. — the tail-index estimator and the order-statistic trade-off shown in the Hill plot
- Embrechts, P., Klüppelberg, C. & Mikosch, T. (1997). Modelling Extremal Events for Insurance and Finance. Springer. — regular variation, survival-curve diagnostics and the applied case for these laws