diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcf8a2..9a80267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this crate are documented here. The format is based on [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pre-`1.0`, minor versions may contain breaking changes. +## [Unreleased] + +### Added +- **Pareto (Type I) distribution.** `ParetoDistribution` (in `distributions::positive`) implements + `Pdf`, `Cdf`, `Quantile`, `Moments`, and `Sample`, with scipy-validated golden fixtures + (`dist_pareto.json`) and full equivalence, moment-convergence, tail-stress, and parameter-sweep + coverage. The mean is defined for `α > 1` and the variance for `α > 2`, reported as `None` + (matching scipy's NaN) otherwise. + ## [0.2.1] — 2026-08-02 A release-engineering fix. The library's public API, numerics, and behaviour are identical diff --git a/README.md b/README.md index 2a8af76..dfc8c39 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ assert_eq!(z.variance(), Some(1.0)); ## What's in the box **Distributions** — `pdf`/`pmf`, `cdf`, `quantile`, `moments`, log-space `cdf`/`sf`, and -seeded sampling for 14 families: normal, uniform, Cauchy, Laplace, exponential, gamma, beta, -Weibull, log-normal, chi-squared, Student's t, F, binomial, and Poisson. +seeded sampling for 15 families: normal, uniform, Cauchy, Laplace, exponential, gamma, beta, +Weibull, log-normal, Pareto, chi-squared, Student's t, F, binomial, and Poisson. **Hypothesis tests** — parametric (one-sample / independent / paired / Welch t-tests, ANOVA, variance), non-parametric (Mann–Whitney, Wilcoxon, Kruskal–Wallis, Friedman), categorical diff --git a/reference/gen/gen_distributions.py b/reference/gen/gen_distributions.py index b6ef83f..9342eaf 100644 --- a/reference/gen/gen_distributions.py +++ b/reference/gen/gen_distributions.py @@ -196,6 +196,20 @@ def gen_beta(): ) +def gen_pareto(): + alpha, x_m = 3.0, 1.5 + d = stats.pareto(b=alpha, scale=x_m) + # Support is [x_m, inf); span the body and a stretch of the right tail. + xs = np.linspace(x_m, 10.0 * x_m, 41) + _continuous( + "dist_pareto", + d, + {"shape_parameter": alpha, "scale_parameter": x_m}, + xs, + library="scipy.stats.pareto", + ) + + def gen_chi_squared(): df = 5 d = stats.chi2(df) @@ -272,6 +286,7 @@ def main(): gen_lognormal() gen_gamma() gen_beta() + gen_pareto() gen_chi_squared() gen_students_t() gen_f() diff --git a/reference/golden/dist_pareto.json b/reference/golden/dist_pareto.json new file mode 100644 index 0000000..e422d69 --- /dev/null +++ b/reference/golden/dist_pareto.json @@ -0,0 +1,164 @@ +{ + "_provenance": { + "library": "scipy.stats.pareto", + "seed": null, + "version": "1.17.1" + }, + "cdf": [ + 0.0, + 0.45600897585189837, + 0.6719832711468284, + 0.7872078679890812, + 0.8542061525003645, + 0.8957866883777732, + 0.9229457827263708, + 0.9414309338013978, + 0.9544460641399417, + 0.9638736684765582, + 0.9708693673190715, + 0.9761693672855308, + 0.9802578327048743, + 0.9834620750496202, + 0.9860087759952569, + 0.9880583090379009, + 0.9897263088682502, + 0.991097580670177, + 0.9922352788165788, + 0.9931870906865294, + 0.9939894815927873, + 0.9946706587873467, + 0.9952526734865969, + 0.9957529329813488, + 0.996185302734375, + 0.9965609194166997, + 0.996888797454103, + 0.9971762862578633, + 0.9974294182516446, + 0.9976531761821877, + 0.997851700177906, + 0.9980284494133543, + 0.9981863292755474, + 0.998327792097945, + 0.998454917485834, + 0.9985694767693645, + 0.9986729850272901, + 0.998766743314212, + 0.9988518731187762, + 0.9989293446245765, + 0.999 + ], + "mean": 2.25, + "p": [ + 1e-06, + 0.01, + 0.1, + 0.25, + 0.5, + 0.75, + 0.9, + 0.99, + 0.999999 + ], + "params": { + "shape_parameter": 3.0, + "scale_parameter": 1.5 + }, + "pdf": [ + 2.0, + 0.8881486108540435, + 0.452436867383685, + 0.2540801576249777, + 0.15346720789435317, + 0.09808311682091929, + 0.06557805725415249, + 0.045490536853283275, + 0.03253852561432736, + 0.02388517786673836, + 0.017926543188263717, + 0.013715472065881537, + 0.010671441781149007, + 0.008426968127582046, + 0.006742758556502664, + 0.005459058725531029, + 0.00446682223119557, + 0.003690122001999209, + 0.0030751371023450117, + 0.0025830935785670452, + 0.002185643057168226, + 0.0018617785895732007, + 0.0015957400045052722, + 0.0013755682651501848, + 0.0011920928955078125, + 0.0010382130062793384, + 0.0009083803053713994, + 0.0007982229659750625, + 0.0007042689721521838, + 0.0006237405495846521, + 0.0005543999540887538, + 0.0004944327490020461, + 0.0004423587132811302, + 0.0003969633001911029, + 0.00035724451194589426, + 0.0003223714322558984, + 0.00029165164235383227, + 0.00026450545539688795, + 0.0002404454201515884, + 0.00021905992336031734, + 0.0002 + ], + "ppf": [ + 1.5000005000003334, + 1.5050335947719786, + 1.5536162529769295, + 1.6509636244473134, + 1.8898815748423097, + 2.381101577952299, + 3.231652035047826, + 6.962383250419166, + 149.9999999985622 + ], + "variance": 1.6875, + "x": [ + 1.5, + 1.8375, + 2.175, + 2.5125, + 2.85, + 3.1875, + 3.525, + 3.8625, + 4.2, + 4.5375, + 4.875, + 5.2125, + 5.55, + 5.8875, + 6.225, + 6.5625, + 6.9, + 7.2375, + 7.575, + 7.9125, + 8.25, + 8.5875, + 8.925, + 9.2625, + 9.6, + 9.9375, + 10.275, + 10.6125, + 10.95, + 11.2875, + 11.625, + 11.9625, + 12.3, + 12.6375, + 12.975, + 13.3125, + 13.65, + 13.9875, + 14.325, + 14.6625, + 15.0 + ] +} diff --git a/src/distributions/positive/mod.rs b/src/distributions/positive/mod.rs index bb9dc2f..324599c 100644 --- a/src/distributions/positive/mod.rs +++ b/src/distributions/positive/mod.rs @@ -6,4 +6,5 @@ pub mod beta; pub mod exponential; pub mod gamma; pub mod lognormal; +pub mod pareto; pub mod weibull; diff --git a/src/distributions/positive/pareto.rs b/src/distributions/positive/pareto.rs new file mode 100644 index 0000000..531e559 --- /dev/null +++ b/src/distributions/positive/pareto.rs @@ -0,0 +1,152 @@ +//! Pareto (Type I) distribution numerics, for the [`ParetoDistribution`]. +//! +//! Equivalent to `scipy.stats.pareto(b = shape_parameter, scale = scale_parameter)`: +//! a power-law distribution on `[scale_parameter, ∞)` whose density falls off as +//! `x ** -(α + 1)` above the scale. Every function is closed form; sampling is the +//! inverse-CDF transform of a single uniform draw. +//! +//! The mean is defined only for `α > 1` (`α · x_m / (α - 1)`) and the variance only +//! for `α > 2` (`α · x_m² / ((α - 1)² · (α - 2))`); both report `None` (matching +//! scipy's NaN) otherwise. +//! +//! # Examples +//! +//! ``` +//! use stats_claw::distributions::{Cdf, Moments, Pdf}; +//! use stats_claw::distributions::ParetoDistribution; +//! +//! let d = ParetoDistribution { shape_parameter: 2.0, scale_parameter: 1.0, ..Default::default() }; +//! // pdf at the scale is the shape. +//! assert!((d.pdf(1.0) - 2.0).abs() < 1e-12, "pdf was {}", d.pdf(1.0)); +//! // cdf at 2 is 1 - (1/2)² = 0.75. +//! assert!((d.cdf(2.0) - 0.75).abs() < 1e-12); +//! // mean is α·x_m/(α-1) = 2. +//! assert_eq!(d.mean(), Some(2.0)); +//! ``` + +use super::super::{Cdf, Moments, Pdf, Quantile, Sample}; +use crate::distributions::ParetoDistribution; +use crate::rng::SplitMix64; + +impl Pdf for ParetoDistribution { + fn pdf(&self, x: f64) -> f64 { + if x < self.scale_parameter { + 0.0 + } else { + let alpha = self.shape_parameter; + let x_m = self.scale_parameter; + alpha * x_m.powf(alpha) / x.powf(alpha + 1.0) + } + } +} + +impl Cdf for ParetoDistribution { + fn cdf(&self, x: f64) -> f64 { + if x < self.scale_parameter { + 0.0 + } else { + let alpha = self.shape_parameter; + let x_m = self.scale_parameter; + // `1 - (x_m / x) ** alpha` is exact; no `1 - cdf` cancellation because we + // evaluate the survival directly, matching scipy's closed form. + 1.0 - (x_m / x).powf(alpha) + } + } +} + +impl Quantile for ParetoDistribution { + fn quantile(&self, p: f64) -> f64 { + let alpha = self.shape_parameter; + let x_m = self.scale_parameter; + // Direct inverse CDF: x_m / (1 - p) ** (1 / alpha). + x_m / (1.0 - p).powf(1.0 / alpha) + } +} + +impl Moments for ParetoDistribution { + fn mean(&self) -> Option { + let alpha = self.shape_parameter; + let x_m = self.scale_parameter; + if alpha > 1.0 { + Some(alpha * x_m / (alpha - 1.0)) + } else { + None + } + } + + fn variance(&self) -> Option { + let alpha = self.shape_parameter; + let x_m = self.scale_parameter; + if alpha > 2.0 { + Some(alpha * x_m * x_m / ((alpha - 1.0) * (alpha - 1.0) * (alpha - 2.0))) + } else { + None + } + } +} + +impl Sample for ParetoDistribution { + fn sample(&self, rng: &mut SplitMix64) -> f64 { + self.quantile(rng.next_f64()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The density at the scale equals the shape parameter. + #[test] + fn density_at_scale_is_shape() { + let d = ParetoDistribution { + shape_parameter: 2.0, + scale_parameter: 1.0, + ..Default::default() + }; + assert!((d.pdf(1.0) - 2.0).abs() < 1e-12, "was {}", d.pdf(1.0)); + } + + /// The CDF at `2·x_m` with `α = 2` is `1 - (1/2)² = 0.75`. + #[test] + fn cdf_at_double_scale() { + let d = ParetoDistribution { + shape_parameter: 2.0, + scale_parameter: 1.0, + ..Default::default() + }; + assert!((d.cdf(2.0) - 0.75).abs() < 1e-12); + } + + /// The mean is `α·x_m/(α-1)` for `α > 1`. + #[test] + fn mean_is_defined_above_one() { + let d = ParetoDistribution { + shape_parameter: 3.0, + scale_parameter: 1.5, + ..Default::default() + }; + assert_eq!(d.mean(), Some(2.25)); + } + + /// The mean is undefined for `α ≤ 1`, matching scipy's NaN. + #[test] + fn mean_undefined_at_or_below_one() { + let d = ParetoDistribution { + shape_parameter: 1.0, + scale_parameter: 1.0, + ..Default::default() + }; + assert_eq!(d.mean(), None); + } + + /// The variance is undefined for `α ≤ 2`. + #[test] + fn variance_undefined_at_or_below_two() { + let d = ParetoDistribution { + shape_parameter: 2.0, + scale_parameter: 1.0, + ..Default::default() + }; + assert_eq!(d.variance(), None); + } +} diff --git a/src/distributions/types.rs b/src/distributions/types.rs index 4632da9..fc5e8f7 100644 --- a/src/distributions/types.rs +++ b/src/distributions/types.rs @@ -190,3 +190,16 @@ pub struct WeibullDistribution { /// Free-text description. pub description: String, } + +/// Pareto (Type I) power-law distribution on `[scale_parameter, ∞)`. +#[derive(Debug, Clone, Default)] +pub struct ParetoDistribution { + /// Shape parameter `α > 0`; controls the tail index. + pub shape_parameter: f64, + /// Scale (minimum) parameter `x_m > 0`; the lower bound of the support. + pub scale_parameter: f64, + /// Unique name identifying a distribution. + pub distribution_name: String, + /// Free-text description. + pub description: String, +} diff --git a/tests/dist/coverage.rs b/tests/dist/coverage.rs index 5dc1add..1b3f5e5 100644 --- a/tests/dist/coverage.rs +++ b/tests/dist/coverage.rs @@ -18,7 +18,7 @@ use super::{check_moment_convergence, check_pmf_tail_stress, check_tail_stress}; use stats_claw::distributions::{ BetaDistribution, BinomialDistribution, CauchyDistribution, ChiSquaredDistribution, ExponentialDistribution, FDistribution, GammaDistribution, LaplaceDistribution, - LogNormalDistribution, NormalDistribution, PoissonDistribution, TDistribution, + LogNormalDistribution, NormalDistribution, ParetoDistribution, PoissonDistribution, TDistribution, UniformDistribution, WeibullDistribution, }; @@ -242,6 +242,24 @@ fn poisson_moment_convergence() { ); } +#[test] +fn pareto_moment_convergence() { + // Pareto(α=3, x_m=1.5): mean=2.25, var=1.6875. Finite moments; heavy right tail + // earns a slightly looser variance tolerance than the light-tailed families. + check_moment_convergence( + &ParetoDistribution { + shape_parameter: 3.0, + scale_parameter: 1.5, + ..Default::default() + }, + 47, + 0.08, + 0.03, + 0.25, + 0.10, + ); +} + /// Cauchy has no finite moments; `check_moment_convergence` short-circuits via /// the `None` guard and performs no assertion — this test documents the skip. #[test] @@ -415,6 +433,18 @@ fn f_tail_stress() { ); } +#[test] +fn pareto_tail_stress() { + check_tail_stress( + &ParetoDistribution { + shape_parameter: 3.0, + scale_parameter: 1.5, + ..Default::default() + }, + POSITIVE_GRID, + ); +} + // ─── Discrete PMF tail-stress ──────────────────────────────────────────────── // // Binomial and Poisson implement `Pmf + Cdf`, not `Pdf + Cdf`, so diff --git a/tests/dist/param_sweep.rs b/tests/dist/param_sweep.rs index 1dc9045..b47844c 100644 --- a/tests/dist/param_sweep.rs +++ b/tests/dist/param_sweep.rs @@ -16,7 +16,7 @@ use super::check_param_sweep; use stats_claw::distributions::{ BetaDistribution, CauchyDistribution, ChiSquaredDistribution, ExponentialDistribution, FDistribution, GammaDistribution, LaplaceDistribution, LogNormalDistribution, - NormalDistribution, TDistribution, UniformDistribution, WeibullDistribution, + NormalDistribution, ParetoDistribution, TDistribution, UniformDistribution, WeibullDistribution, }; /// Shared interior probability grid used for round-trip checks across all sweeps. @@ -456,3 +456,44 @@ fn f_param_sweep() { let xs = &[0.0, 0.01, 0.1, 0.5, 1.0, 3.0, 10.0, 50.0]; check_param_sweep(cases, xs, SWEEP_PS); } + +#[test] +fn pareto_param_sweep() { + let cases: &[(ParetoDistribution, &str)] = &[ + ( + ParetoDistribution { + shape_parameter: 0.5, + scale_parameter: 1.0, + ..Default::default() + }, + "alpha0.5", + ), + ( + ParetoDistribution { + shape_parameter: 1.0, + scale_parameter: 1.0, + ..Default::default() + }, + "alpha1", + ), + ( + ParetoDistribution { + shape_parameter: 2.0, + scale_parameter: 1.0, + ..Default::default() + }, + "alpha2", + ), + ( + ParetoDistribution { + shape_parameter: 3.0, + scale_parameter: 2.0, + ..Default::default() + }, + "alpha3-xm2", + ), + ]; + // Support starts at `scale_parameter`; below it pdf=0 and cdf=0 (both valid). + let xs = &[0.5, 1.0, 2.0, 5.0, 20.0, 100.0, 500.0]; + check_param_sweep(cases, xs, SWEEP_PS); +} diff --git a/tests/dist/positive.rs b/tests/dist/positive.rs index 4a403e1..60465f0 100644 --- a/tests/dist/positive.rs +++ b/tests/dist/positive.rs @@ -4,7 +4,7 @@ use super::{check_continuous_grid, check_moments, check_round_trip, check_sampling_ks}; use stats_claw::distributions::{ BetaDistribution, ExponentialDistribution, GammaDistribution, LogNormalDistribution, - WeibullDistribution, + ParetoDistribution, WeibullDistribution, }; const PS: &[f64] = &[1e-6, 0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 1.0 - 1e-6]; @@ -147,3 +147,31 @@ fn beta_moments_match() -> Result<(), super::HarnessError> { fn beta_sampling_reproducible_and_fits_cdf() { check_sampling_ks(&beta(), 23); } + +fn pareto() -> ParetoDistribution { + ParetoDistribution { + shape_parameter: 3.0, + scale_parameter: 1.5, + ..Default::default() + } +} + +#[test] +fn pareto_pdf_cdf_ppf_match_scipy() -> Result<(), super::HarnessError> { + check_continuous_grid("dist_pareto", &pareto()) +} + +#[test] +fn pareto_cdf_quantile_round_trips() { + check_round_trip(&pareto(), PS); +} + +#[test] +fn pareto_moments_match() -> Result<(), super::HarnessError> { + check_moments("dist_pareto", &pareto()) +} + +#[test] +fn pareto_sampling_reproducible_and_fits_cdf() { + check_sampling_ks(&pareto(), 29) +}