use crate::prelude::*;
use statrs::distribution::Beta;
#[derive(Debug, Clone)]
pub struct Pert {
min: f64,
mode: f64,
max: f64,
shape: f64,
inner: Beta,
}
#[allow(dead_code)]
impl Pert {
pub fn new(min: f64, likely: f64, max: f64) -> Result<Self> {
Self::new_with_shape(min, likely, max, 4.0)
}
pub fn new_with_shape(min: f64, likely: f64, max: f64, shape: f64) -> Result<Self> {
if min >= max {
return Err(Error::InvalidRange { min, max });
}
if likely < min || likely > max {
return Err(Error::InvalidMode { min, likely, max });
}
if !(2.0..=6.0).contains(&shape) {
return Err(Error::InvalidShape(shape));
}
let alpha = 1.0 + shape * ((likely - min) / (max - min));
let beta = 1.0 + shape * ((max - likely) / (max - min));
Ok(Pert {
min,
mode: likely,
max,
shape,
inner: Beta::new(alpha, beta)?,
})
}
pub fn alpha(&self) -> f64 {
self.inner.shape_a()
}
pub fn beta(&self) -> f64 {
self.inner.shape_b()
}
pub fn shape(&self) -> f64 {
self.shape
}
pub fn kurtosis(&self) -> f64 {
let (a, b) = (self.alpha(), self.beta());
(6.0 * ((a - b).powi(2) * (a + b + 1.0) - a * b * (a + b + 2.0)))
/ (a * b * (a + b + 2.0) * (a + b + 3.0))
}
}
impl EstimationDistribution for Pert {}
impl Distribution<f64> for Pert {
fn mean(&self) -> Option<f64> {
self.inner
.mean()
.map(|m| self.min + (self.max - self.min) * m)
}
fn variance(&self) -> Option<f64> {
self.inner
.variance()
.map(|v| (self.max - self.min).powi(2) * v)
}
fn skewness(&self) -> Option<f64> {
self.inner.skewness()
}
fn entropy(&self) -> Option<f64> {
self.inner.entropy()
}
}
impl Median<f64> for Pert {
fn median(&self) -> f64 {
self.min + (self.max - self.min) * self.inner.inverse_cdf(0.5)
}
}
impl Mode<f64> for Pert {
fn mode(&self) -> f64 {
self.mode
}
}
impl Continuous<f64, f64> for Pert {
fn pdf(&self, x: f64) -> f64 {
if x < self.min || x > self.max {
0.0
} else {
let scaled_x = (x - self.min) / (self.max - self.min);
self.inner.pdf(scaled_x) / (self.max - self.min)
}
}
fn ln_pdf(&self, x: f64) -> f64 {
if x < self.min || x > self.max {
f64::NEG_INFINITY
} else {
let scaled_x = (x - self.min) / (self.max - self.min);
self.inner.ln_pdf(scaled_x) - (self.max - self.min).ln()
}
}
}
impl ContinuousCDF<f64, f64> for Pert {
fn cdf(&self, x: f64) -> f64 {
if x <= self.min {
0.0
} else if x >= self.max {
1.0
} else {
let scaled_x = (x - self.min) / (self.max - self.min);
self.inner.cdf(scaled_x)
}
}
fn inverse_cdf(&self, p: f64) -> f64 {
if p <= 0.0 {
self.min
} else if p >= 1.0 {
self.max
} else {
self.min + (self.max - self.min) * self.inner.inverse_cdf(p)
}
}
}
impl Min<f64> for Pert {
fn min(&self) -> f64 {
self.min
}
}
impl Max<f64> for Pert {
fn max(&self) -> f64 {
self.max
}
}
impl RandDistribution<f64> for Pert {
fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> f64 {
let u: f64 = rng.gen();
self.inverse_cdf(u)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use rand::distributions::Distribution as RandDistribution;
use rand::rngs::StdRng;
use rand::SeedableRng;
use statrs::statistics::{Data, Distribution, OrderStatistics};
#[test]
fn test_new() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_eq!(pert.min(), 1.0);
assert_eq!(pert.mode(), 2.0);
assert_eq!(pert.max(), 3.0);
}
#[test]
fn test_min() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_eq!(pert.min(), 1.0);
}
#[test]
fn test_max() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_eq!(pert.max(), 3.0);
}
#[test]
fn test_alpha_beta() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_relative_eq!(pert.alpha(), 3.0, epsilon = 1e-6);
assert_relative_eq!(pert.beta(), 3.0, epsilon = 1e-6);
}
#[test]
fn test_mean() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_relative_eq!(pert.mean().unwrap(), 2.0, epsilon = 1e-6);
}
#[test]
fn test_median() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_relative_eq!(pert.median(), 2.0, epsilon = 1e-3);
}
#[test]
fn test_mode() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_eq!(pert.mode(), 2.0);
}
#[test]
fn test_variance() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_relative_eq!(pert.variance().unwrap(), 1.0 / 7.0, epsilon = 1e-6);
}
#[test]
fn test_skewness() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_relative_eq!(pert.skewness().unwrap(), 0.0, epsilon = 1e-6);
}
#[test]
fn test_kurtosis() {
let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 4.0).unwrap();
assert_relative_eq!(pert.kurtosis(), -0.66666667, epsilon = 1e-6);
}
#[test]
fn test_asymmetric_distribution() {
let pert = Pert::new_with_shape(0.0, 1.0, 5.0, 4.0).unwrap();
assert_relative_eq!(pert.alpha(), 1.8, epsilon = 1e-6);
assert_relative_eq!(pert.beta(), 4.2, epsilon = 1e-6);
assert_relative_eq!(pert.mean().unwrap(), 1.5, epsilon = 1e-6);
assert_relative_eq!(pert.median(), 1.375, epsilon = 1e-2);
assert_relative_eq!(pert.variance().unwrap(), 0.75, epsilon = 1e-6);
assert_relative_eq!(pert.kurtosis(), -0.2222222, epsilon = 1e-6);
}
#[test]
#[should_panic(
expected = "called `Result::unwrap()` on an `Err` value: InvalidRange { min: 3.0, max: 1.0 }"
)]
fn test_invalid_parameters() {
Pert::new_with_shape(3.0, 2.0, 1.0, 4.0).unwrap(); }
fn sample_statistics(dist: &Pert, n: usize) -> (f64, f64, f64, f64) {
let mut rng = StdRng::seed_from_u64(42); let samples: Vec<f64> = (0..n).map(|_| dist.sample(&mut rng)).collect();
let mean = samples.iter().sum::<f64>() / n as f64;
let variance = samples.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
let std_dev = variance.sqrt();
let mut sorted_samples = samples.clone();
sorted_samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = if n % 2 == 0 {
(sorted_samples[n / 2 - 1] + sorted_samples[n / 2]) / 2.0
} else {
sorted_samples[n / 2]
};
(mean, variance, std_dev, median)
}
#[test]
fn test_pert_distribution_statistics() {
let min = 1.0;
let mode = 2.0;
let max = 5.0;
let shape = 4.0;
let dist = Pert::new_with_shape(min, mode, max, shape).unwrap();
let n = 100_000; let epsilon = 0.01;
let (sample_mean, sample_variance, sample_std_dev, sample_median) =
sample_statistics(&dist, n);
assert_relative_eq!(
sample_mean,
dist.mean().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
assert_relative_eq!(
sample_variance,
dist.variance().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
assert_relative_eq!(
sample_std_dev,
dist.std_dev().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
assert_relative_eq!(
sample_median,
dist.median(),
epsilon = epsilon,
max_relative = epsilon
);
}
#[test]
fn test_pert_distribution_bounds() {
let min = 1.0;
let mode = 2.0;
let max = 3.0;
let shape = 4.0;
let dist = Pert::new_with_shape(min, mode, max, shape).unwrap();
let n = 100_000;
let mut rng = StdRng::seed_from_u64(42);
let samples: Vec<f64> = (0..n).map(|_| dist.sample(&mut rng)).collect();
assert!(samples.iter().all(|&x| x >= min && x <= max));
assert!(samples.iter().any(|&x| x < mode));
assert!(samples.iter().any(|&x| x > mode));
}
fn sample_skewness(data: &[f64]) -> f64 {
let n = data.len() as f64;
let mean = data.iter().sum::<f64>() / n;
let m2 = data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
let m3 = data.iter().map(|x| (x - mean).powi(3)).sum::<f64>() / n;
m3 / m2.powf(1.5)
}
fn sample_kurtosis(data: &[f64]) -> f64 {
let n = data.len() as f64;
let mean = data.iter().sum::<f64>() / n;
let m2 = data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
let m4 = data.iter().map(|x| (x - mean).powi(4)).sum::<f64>() / n;
m4 / m2.powi(2) - 3.0
}
#[test]
fn test_distribution_statistics() {
let test_cases = vec![
(0.0, 5.0, 10.0, 4.0), (0.0, 2.0, 10.0, 2.0), (0.0, 9.0, 10.0, 6.0), (-10.0, 0.0, 10.0, 4.0), (100.0, 400.0, 1000.0, 4.0), ];
for (min, mode, max, shape) in test_cases {
let pert = Pert::new_with_shape(min, mode, max, shape).unwrap();
let n = 25_000; let mut rng = StdRng::seed_from_u64(42);
let samples: Vec<f64> = (0..n).map(|_| pert.sample(&mut rng)).collect();
let mut data = Data::new(samples.clone());
let sample_mean = data.mean().unwrap();
let theoretical_mean = pert.mean().unwrap();
assert_relative_eq!(
sample_mean,
theoretical_mean,
epsilon = 0.1,
max_relative = 0.1
);
let sample_variance = data.variance().unwrap();
let theoretical_variance = pert.variance().unwrap();
assert_relative_eq!(
sample_variance,
theoretical_variance,
epsilon = 0.05,
max_relative = 0.05
);
let sample_std_dev = data.std_dev().unwrap();
let theoretical_std_dev = pert.std_dev().unwrap();
assert_relative_eq!(
sample_std_dev,
theoretical_std_dev,
epsilon = 0.1,
max_relative = 0.1
);
let sample_skewness = sample_skewness(&samples);
let theoretical_skewness = pert.skewness().unwrap();
assert_relative_eq!(
sample_skewness,
theoretical_skewness,
epsilon = 0.1,
max_relative = 0.1
);
let sample_median = data.median();
let theoretical_median = pert.median();
assert_relative_eq!(
sample_median,
theoretical_median,
epsilon = 0.1,
max_relative = 0.1
);
assert!(pert.mode() >= min && pert.mode() <= max);
let sample_min = data.min();
let sample_max = data.max();
assert!(sample_min >= pert.min());
assert!(sample_max <= pert.max());
let sample_kurtosis = sample_kurtosis(&samples);
let theoretical_kurtosis = pert.kurtosis();
assert_relative_eq!(
sample_kurtosis,
theoretical_kurtosis,
epsilon = 0.1,
max_relative = 0.1
);
let percentiles = [0.1, 0.25, 0.5, 0.75, 0.9];
for p in percentiles.iter() {
let sample_percentile = data.percentile((100.0 * p) as usize);
let theoretical_percentile = pert.inverse_cdf(*p);
assert_relative_eq!(
sample_percentile,
theoretical_percentile,
epsilon = 0.05,
max_relative = 0.05
);
let sample_cdf =
samples.iter().filter(|&x| x <= &sample_percentile).count() as f64 / n as f64;
let theoretical_cdf = pert.cdf(sample_percentile);
assert_relative_eq!(
sample_cdf,
theoretical_cdf,
epsilon = 0.01,
max_relative = 0.01
);
}
println!(
"Test passed for PERT({}, {}, {}, {})",
min, mode, max, shape
);
}
}
}