// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package stats import ( "sourcedock.dev/petrbalvin/tensor/internal/base" "sourcedock.dev/petrbalvin/tensor/internal/core" ) import ( "math" ) // Distribution additions to the families of distrib.go and cdf.go: the // Weibull, lognormal, Pareto and negative binomial laws, and the // Dirichlet. The continuous laws carry closed density, CDF and // quantile forms; the negative binomial follows the discrete house // shape of Poisson and the binomial, counting on the same integer axis // the Poisson counts on. The support convention of the existing // distributions holds throughout: a CDF is 0 below the support and a // density 0 outside it, while a faulty parameter is an error and never // a silent value. // WeibullDensity returns the Weibull density with shape k > 0 and // scale λ > 0 at x ≥ 0: (k/λ)(x/λ)^{k−1}e^{−(x/λ)^k}. Below the // support, and at +∞, the density is 0; at x = 0 the formula speaks // for itself, 0 for k > 1, the finite 1/λ for k = 1 and the +Inf the // integrable singularity carries for k < 1. func WeibullDensity(x, k, lambda float64) (float64, error) { if !(k > 0) || math.IsInf(k, 0) { return 0, base.Errf("WeibullDensity: shape k must be finite and positive, got %g", k) } if !(lambda > 0) || math.IsInf(lambda, 0) { return 0, base.Errf("WeibullDensity: scale λ must be finite and positive, got %g", lambda) } if math.IsNaN(x) { return 0, base.Errf("WeibullDensity: x must be a number, got %g", x) } if x < 0 || math.IsInf(x, 1) { return 0, nil } z := x / lambda return k / lambda * math.Pow(z, k-1) * math.Exp(-math.Pow(z, k)), nil } // WeibullCDF returns P(X ≤ x) for X ~ Weibull(k, λ), the closed form // 1 − e^{−(x/λ)^k}. func WeibullCDF(x, k, lambda float64) (float64, error) { if !(k > 0) || math.IsInf(k, 0) { return 0, base.Errf("WeibullCDF: shape k must be finite and positive, got %g", k) } if !(lambda > 0) || math.IsInf(lambda, 0) { return 0, base.Errf("WeibullCDF: scale λ must be finite and positive, got %g", lambda) } if math.IsNaN(x) { return 0, base.Errf("WeibullCDF: x must be a number, got %g", x) } if x <= 0 { return 0, nil } // Expm1 keeps the left tail, as in ExponentialCDF. return -math.Expm1(-math.Pow(x/lambda, k)), nil } // WeibullQuantile returns the q-quantile of Weibull(k, λ), the closed // form λ(−ln(1−q))^{1/k}. func WeibullQuantile(q, k, lambda float64) (float64, error) { if !(k > 0) || math.IsInf(k, 0) { return 0, base.Errf("WeibullQuantile: shape k must be finite and positive, got %g", k) } if !(lambda > 0) || math.IsInf(lambda, 0) { return 0, base.Errf("WeibullQuantile: scale λ must be finite and positive, got %g", lambda) } // NaN-rejecting on purpose, as in NormalQuantile. if !(q >= 0 && q <= 1) { return 0, base.Errf("WeibullQuantile: q must lie in [0, 1], got %g", q) } if q == 0 || q == 1 { return 0, base.Errf("WeibullQuantile: q = %g has no finite quantile", q) } return lambda * math.Pow(-math.Log1p(-q), 1/k), nil } // LognormalDensity returns the lognormal density with location μ and // log-scale σ > 0 at x > 0: 1/(xσ√(2π))e^{−(ln x−μ)²/(2σ²)}. The // support convention gives 0 at x ≤ 0 and at +∞. func LognormalDensity(x, mu, sigma float64) (float64, error) { if math.IsNaN(mu) || math.IsInf(mu, 0) { return 0, base.Errf("LognormalDensity: location μ must be finite, got %g", mu) } if !(sigma > 0) || math.IsInf(sigma, 0) { return 0, base.Errf("LognormalDensity: log-scale σ must be finite and positive, got %g", sigma) } if math.IsNaN(x) { return 0, base.Errf("LognormalDensity: x must be a number, got %g", x) } if x <= 0 || math.IsInf(x, 1) { return 0, nil } z := (math.Log(x) - mu) / sigma // The tail is assembled in log space: at a subnormal x both the // numerator and the denominator of the closed form underflow to // exact zero, and their division reports NaN for a point inside // the support whose density is an honest 0. return math.Exp(-z*z/2 - math.Log(x) - math.Log(sigma) - 0.5*math.Log(2*math.Pi)), nil } // LognormalCDF returns P(X ≤ x) for X ~ lognormal(μ, σ), the normal // CDF at (ln x − μ)/σ, the reduction the law is named for. func LognormalCDF(x, mu, sigma float64) (float64, error) { if math.IsNaN(mu) || math.IsInf(mu, 0) { return 0, base.Errf("LognormalCDF: location μ must be finite, got %g", mu) } if !(sigma > 0) || math.IsInf(sigma, 0) { return 0, base.Errf("LognormalCDF: log-scale σ must be finite and positive, got %g", sigma) } if math.IsNaN(x) { return 0, base.Errf("LognormalCDF: x must be a number, got %g", x) } if x <= 0 { return 0, nil } return NormalCDF((math.Log(x) - mu) / sigma), nil } // LognormalQuantile returns the q-quantile of lognormal(μ, σ) through // the existing normal quantile: e^{μ + σ·Φ^{−1}(q)}. func LognormalQuantile(q, mu, sigma float64) (float64, error) { if math.IsNaN(mu) || math.IsInf(mu, 0) { return 0, base.Errf("LognormalQuantile: location μ must be finite, got %g", mu) } if !(sigma > 0) || math.IsInf(sigma, 0) { return 0, base.Errf("LognormalQuantile: log-scale σ must be finite and positive, got %g", sigma) } z, err := NormalQuantile(q) if err != nil { return 0, base.Errf("LognormalQuantile: %w", err) } return math.Exp(mu + sigma*z), nil } // ParetoDensity returns the Pareto density with scale x_m > 0 and tail // index α > 0 at x ≥ x_m: α·x_m^α/x^{α+1}. Below the support, and at // +∞, the density is 0. func ParetoDensity(x, xm, alpha float64) (float64, error) { if !(xm > 0) || math.IsInf(xm, 0) { return 0, base.Errf("ParetoDensity: scale x_m must be finite and positive, got %g", xm) } if !(alpha > 0) || math.IsInf(alpha, 0) { return 0, base.Errf("ParetoDensity: tail index α must be finite and positive, got %g", alpha) } if math.IsNaN(x) { return 0, base.Errf("ParetoDensity: x must be a number, got %g", x) } if x < xm || math.IsInf(x, 1) { return 0, nil } return alpha / x * math.Pow(xm/x, alpha), nil } // ParetoCDF returns P(X ≤ x) for X ~ Pareto(x_m, α), the closed form // 1 − (x_m/x)^α, evaluated through Expm1 so the answers just above the // support keep their digits. func ParetoCDF(x, xm, alpha float64) (float64, error) { if !(xm > 0) || math.IsInf(xm, 0) { return 0, base.Errf("ParetoCDF: scale x_m must be finite and positive, got %g", xm) } if !(alpha > 0) || math.IsInf(alpha, 0) { return 0, base.Errf("ParetoCDF: tail index α must be finite and positive, got %g", alpha) } if math.IsNaN(x) { return 0, base.Errf("ParetoCDF: x must be a number, got %g", x) } if x < xm { return 0, nil } return -math.Expm1(alpha * math.Log(xm/x)), nil } // ParetoQuantile returns the q-quantile of Pareto(x_m, α), the closed // form x_m(1−q)^{−1/α}. func ParetoQuantile(q, xm, alpha float64) (float64, error) { if !(xm > 0) || math.IsInf(xm, 0) { return 0, base.Errf("ParetoQuantile: scale x_m must be finite and positive, got %g", xm) } if !(alpha > 0) || math.IsInf(alpha, 0) { return 0, base.Errf("ParetoQuantile: tail index α must be finite and positive, got %g", alpha) } if !(q >= 0 && q <= 1) { return 0, base.Errf("ParetoQuantile: q must lie in [0, 1], got %g", q) } if q == 0 || q == 1 { return 0, base.Errf("ParetoQuantile: q = %g has no finite quantile", q) } return xm * math.Pow(1-q, -1/alpha), nil } // NegativeBinomialPMF returns P(X = k), the probability of k failures // before the r-th success in independent trials of probability p: the // law the Poisson draws and BinomialDraws count on the same integer // axis. The mass is assembled in log space with lgamma, the form that // keeps every term representable for large r and k. func NegativeBinomialPMF(k, r int, p float64) (float64, error) { if r < 1 { return 0, base.Errf("NegativeBinomialPMF: r must be ≥ 1, got %d", r) } if !(p > 0 && p < 1) { return 0, base.Errf("NegativeBinomialPMF: p must lie in (0, 1), got %g", p) } if k < 0 { return 0, nil } lf := logGamma(float64(r+k)) - logGamma(float64(r)) - logGamma(float64(k+1)) + float64(r)*math.Log(p) + float64(k)*math.Log1p(-p) return math.Exp(lf), nil } // NegativeBinomialCDF returns P(X ≤ k) for the number of failures X // before the r-th success, by direct summation of the PMF terms under // the multiplicative recurrence term_{j+1} = term_j·(r+j)/(j+1)·(1−p). // Every term is positive, so the sum carries no cancellation; the same // probability equals the regularised beta I_p(r, k+1), which the tests // hold the summation against. The summation seeds from p^r, and a seed // the format cannot hold rounds to an exact zero the recurrence never // recovers from: every later term would stay zero while the true mass // sits further out. That far regime answers through the beta identity // instead, which keeps the whole support live. func NegativeBinomialCDF(k, r int, p float64) (float64, error) { if r < 1 { return 0, base.Errf("NegativeBinomialCDF: r must be ≥ 1, got %d", r) } if !(p > 0 && p < 1) { return 0, base.Errf("NegativeBinomialCDF: p must lie in (0, 1), got %g", p) } if k < 0 { return 0, nil } term := math.Exp(float64(r) * math.Log(p)) if term == 0 { // p^r underflowed: the recurrence multiplies zeros, so the sum // would answer 0 at every k. The identity I_p(r, k+1) = P(X ≤ k) // evaluates the same probability through the incomplete beta, // accurate across this regime. return BetaIncomplete(p, float64(r), float64(k+1)) } sum := 0.0 for j := 0; j <= k; j++ { sum += term term *= (float64(r) + float64(j)) / float64(j+1) * (1 - p) } return sum, nil } // NegativeBinomialQuantile returns the smallest k with P(X ≤ k) ≥ q, // through the same discrete bracketed search the Poisson and binomial // quantiles use. func NegativeBinomialQuantile(q, p float64, r int) (float64, error) { if r < 1 { return 0, base.Errf("NegativeBinomialQuantile: r must be ≥ 1, got %d", r) } if !(p > 0 && p < 1) { return 0, base.Errf("NegativeBinomialQuantile: p must lie in (0, 1), got %g", p) } return discreteQuantile("NegativeBinomialQuantile", q, func(k int) (float64, error) { return NegativeBinomialCDF(k, r, p) }) } // logGamma wraps math.Lgamma, keeping the log-space call sites free of // the ignored-error idiom. func logGamma(x float64) float64 { l, _ := math.Lgamma(x) return l } // DirichletDensity returns the Dirichlet density with concentration // vector α at the simplex point x: Πx_i^{α_i−1}/B(α), the normalising // constant assembled with lgamma. Both vectors must be finite, every // α_i positive and every x_i non-negative, and the x must sum to 1 (to // within 1e-9); a boundary x_i = 0 gives +Inf below α_i = 1, the value // 1 continues to contribute nothing at α_i = 1 exactly, and 0 above. func DirichletDensity(alpha, x []float64) (float64, error) { const name = "DirichletDensity" if len(alpha) < 2 { return 0, base.Errf("%s: needs at least two components, got %d", name, len(alpha)) } if len(x) != len(alpha) { return 0, base.Errf("%s: the concentration has %d components, the point %d", name, len(alpha), len(x)) } total := 0.0 for i, a := range alpha { if !(a > 0) || math.IsInf(a, 0) { return 0, base.Errf("%s: alpha[%d] must be finite and positive, got %g", name, i, a) } total += a } sum := 0.0 for i, v := range x { if math.IsNaN(v) || math.IsInf(v, 0) { return 0, base.Errf("%s: x[%d] must be finite, got %g", name, i, v) } if v < 0 { return 0, base.Errf("%s: x[%d] = %g lies outside the simplex", name, i, v) } sum += v } if math.Abs(sum-1) > 1e-9 { return 0, base.Errf("%s: the point must sum to 1, got %g", name, sum) } // ln B(α) = Σ lgamma(α_i) − lgamma(α₀). lb := -logGamma(total) for _, a := range alpha { lb += logGamma(a) } s := -lb for i, a := range alpha { switch { case x[i] == 0: if a < 1 { return math.Inf(1), nil } if a > 1 { return 0, nil } default: s += (a - 1) * math.Log(x[i]) } } return math.Exp(s), nil } // DirichletMean returns the mean of the Dirichlet with concentration // α: the normalised concentration α_i/α₀. func DirichletMean(alpha []float64) ([]float64, error) { if len(alpha) < 2 { return nil, base.Errf("DirichletMean: needs at least two components, got %d", len(alpha)) } total := 0.0 for i, a := range alpha { if !(a > 0) || math.IsInf(a, 0) { return nil, base.Errf("DirichletMean: alpha[%d] must be finite and positive, got %g", i, a) } total += a } mean := make([]float64, len(alpha)) for i, a := range alpha { mean[i] = a / total } return mean, nil } // DirichletMode returns the interior mode (α_i−1)/(α₀−k), which exists // only when every concentration exceeds 1; any α_i ≤ 1 pushes the mode // onto the boundary and is refused rather than answered with a vector // that is not a mode. func DirichletMode(alpha []float64) ([]float64, error) { if len(alpha) < 2 { return nil, base.Errf("DirichletMode: needs at least two components, got %d", len(alpha)) } total := 0.0 for i, a := range alpha { if !(a > 0) || math.IsInf(a, 0) { return nil, base.Errf("DirichletMode: alpha[%d] must be finite and positive, got %g", i, a) } if a <= 1 { return nil, base.Errf("DirichletMode: alpha[%d] = %g leaves no interior mode; every concentration must exceed 1", i, a) } total += a } den := total - float64(len(alpha)) mode := make([]float64, len(alpha)) for i, a := range alpha { mode[i] = (a - 1) / den } return mode, nil } // DirichletDraws returns n draws from the Dirichlet with concentration // α, as an (n, k) array whose rows are the draws. Each row scales the // k independent gamma(α_i, 1) draws of GammaDraws' generator to sum to // one; the all-underflow row (every α_i far below the float64 floor) // would otherwise divide by zero and falls back to the uniform row. func DirichletDraws(g *core.Generator, n int, alpha []float64) (*core.Array, error) { const name = "DirichletDraws" if n < 1 { return nil, base.Errf("%s: n must be ≥ 1", name) } if len(alpha) < 2 { return nil, base.Errf("%s: needs at least two components, got %d", name, len(alpha)) } for i, a := range alpha { if !(a > 0) || math.IsInf(a, 0) { return nil, base.Errf("%s: alpha[%d] must be finite and positive, got %g", name, i, a) } } k := len(alpha) flat := make([]float64, n*k) for r := range n { sum := 0.0 row := flat[r*k : r*k+k] for c, a := range alpha { row[c] = gammaOne(g, a) sum += row[c] } if sum == 0 { // Every gamma draw underflowed: the uniform row is the // honest stand-in, an Inf row would poison the draw. for c := range row { row[c] = 1 / float64(k) } continue } for c := range row { row[c] /= sum } } return floatsToArray(flat, []int{n, k}), nil } // gammaOne draws one gamma(α, 1) variate, the single-draw form of the // GammaDraws loop: Marsaglia-Tsang for α ≥ 1 and the boost with the // exponential for α < 1, reusing the package squeeze. func gammaOne(g *core.Generator, alpha float64) float64 { if alpha >= 1 { d := alpha - 1.0/3 return gammaMarsagliaTsang(g, alpha, d, 1/math.Sqrt(9*d)) } boost := alpha + 1 d := boost - 1.0/3 v := gammaMarsagliaTsang(g, boost, d, 1/math.Sqrt(9*d)) return v * math.Pow(g.Unit(), 1/alpha) }