feat: initial commit

This commit is contained in:
swaits committed 2024-08-27 12:48:10 -06:00
commit 749184191d
14 files changed
+3036

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
mod normal;
mod pert;
mod triangular;
pub use self::normal::Normal;
pub use self::pert::Pert;
pub use self::triangular::Triangular;
+742
View File
@@ -0,0 +1,742 @@
//! Normal (Gaussian) distribution implementation for estimation.
//!
//! This module provides an implementation of a modified Normal distribution,
//! which is defined by minimum, most likely (mode), and maximum values.
//! It's designed for use in estimation scenarios where a range and most likely
//! value are known, but the underlying distribution is assumed to be normal.
//!
//! # Examples
//!
//! ```
//! use distimate::prelude::*;
//! use distimate::Normal;
//! use approx::assert_relative_eq;
//!
//! let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
//! assert_eq!(normal.mode(), 2.0);
//! assert_relative_eq!(normal.mean().unwrap(), 2.0, epsilon = 1e-6);
//! ```
use crate::prelude::*;
use statrs::distribution::Normal as StatrsNormal;
/// Represents a modified Normal distribution for estimation purposes.
///
/// This Normal distribution is defined by minimum, most likely (mode), and maximum values.
/// It uses these to create an underlying standard Normal distribution, but provides
/// methods to work within the specified range.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
/// use approx::assert_relative_eq;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.min(), 1.0);
/// assert_eq!(normal.mode(), 2.0);
/// assert_eq!(normal.max(), 3.0);
/// ```
#[derive(Debug, Clone)]
pub struct Normal {
inner: StatrsNormal,
min: f64,
likely: f64,
max: f64,
}
impl Normal {
/// Creates a new Normal distribution with the given minimum, most likely (mode), and maximum values.
///
/// # Arguments
///
/// * `min` - The minimum value of the distribution
/// * `likely` - The most likely value (mode) of the distribution
/// * `max` - The maximum value of the distribution
///
/// # Returns
///
/// Returns a `Result` containing the new `Normal` instance if the parameters are valid,
/// or an `Error` if the parameters are invalid.
///
/// # Errors
///
/// Returns an error if:
/// - `min` is greater than or equal to `max`
/// - `likely` is less than `min` or greater than `max`
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.min(), 1.0);
/// assert_eq!(normal.mode(), 2.0);
/// assert_eq!(normal.max(), 3.0);
/// ```
pub fn new(min: f64, likely: f64, max: f64) -> Result<Self> {
if min >= max {
return Err(Error::InvalidRange { min, max });
}
if likely < min || likely > max {
return Err(Error::InvalidMode { min, likely, max });
}
let mean = likely;
let std_dev = (max - min) / 6.0; // Assuming 99.7% of values fall within the range
Ok(Normal {
inner: StatrsNormal::new(mean, std_dev)?,
min,
likely,
max,
})
}
/// Clamps a value to the [min, max] range of the distribution.
fn clamp(&self, x: f64) -> f64 {
x.max(self.min).min(self.max)
}
}
/// Implementation of the `EstimationDistribution` trait for the Normal distribution.
///
/// This trait marks the Normal distribution as suitable for use in estimation contexts.
/// It doesn't add any new methods, but signals that this distribution is appropriate
/// for modeling uncertain estimates or measurements within a specified range.
///
/// The Normal distribution in this context is particularly useful when:
/// - There's a most likely value (mode) with symmetric uncertainty around it.
/// - The minimum and maximum values represent extreme scenarios.
/// - The underlying process is believed to follow a bell-shaped curve.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// // The Normal distribution can now be used in contexts requiring an EstimationDistribution
/// ```
impl EstimationDistribution for Normal {}
/// Implementation of the `Distribution` trait for the Normal distribution.
///
/// This implementation provides methods to calculate various statistical properties
/// of the Normal distribution, adapted for estimation scenarios with specified
/// minimum, most likely (mode), and maximum values.
///
/// # Note
///
/// The calculated values are based on the underlying Normal distribution and may
/// theoretically extend beyond the specified [min, max] range. For practical
/// estimation purposes, you may want to consider clamping extreme values.
impl Distribution<f64> for Normal {
/// Calculates the mean of the Normal distribution.
///
/// For this adapted Normal distribution, the mean is equal to the most likely value (mode).
///
/// # Returns
///
/// Returns `Some(mean)` where `mean` is the most likely value specified during construction.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.mean(), Some(2.0));
/// ```
fn mean(&self) -> Option<f64> {
self.inner.mean()
}
/// Calculates the variance of the Normal distribution.
///
/// The variance is calculated based on the standard deviation derived from
/// the specified min and max values during construction.
///
/// # Returns
///
/// Returns `Some(variance)` where `variance` is the square of the standard deviation.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.variance().unwrap(), 0.1111111111111111); // (1/3)^2
/// ```
fn variance(&self) -> Option<f64> {
self.inner.variance()
}
/// Calculates the skewness of the Normal distribution.
///
/// The Normal distribution is always symmetric, so the skewness is always 0.
///
/// # Returns
///
/// Always returns `Some(0.0)`.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.skewness(), Some(0.0));
/// ```
fn skewness(&self) -> Option<f64> {
Some(0.0) // Normal distribution is always symmetric
}
/// Calculates the entropy of the Normal distribution.
///
/// The entropy is a measure of the average amount of information contained in
/// the distribution. For a Normal distribution, it depends on the standard deviation.
///
/// # Returns
///
/// Returns `Some(entropy)` where `entropy` is calculated based on the
/// standard deviation of the distribution.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert!(normal.entropy().is_some());
/// ```
fn entropy(&self) -> Option<f64> {
self.inner.entropy()
}
}
/// Implementation of the `Median` trait for the Normal distribution.
///
/// For a Normal distribution adapted for estimation purposes, the median is equal
/// to the most likely value (mode) specified during construction. This is because
/// the Normal distribution is symmetric around its mean, which in this case is set
/// to the most likely value.
impl Median<f64> for Normal {
/// Calculates the median of the Normal distribution.
///
/// For this adapted Normal distribution, the median is equal to the most likely
/// value (mode) specified during construction.
///
/// # Returns
///
/// Returns the median of the distribution as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.median(), 2.0);
/// ```
///
/// # Note
///
/// In a standard Normal distribution, the median, mean, and mode are all equal.
/// In this adapted version for estimation, we maintain this property by setting
/// all of these to the most likely value provided during construction.
fn median(&self) -> f64 {
self.likely
}
}
/// Implementation of the `Mode` trait for the Normal distribution.
///
/// For a Normal distribution adapted for estimation purposes, the mode is set to
/// the most likely value specified during construction. This aligns with the
/// typical use case in estimation scenarios where the most likely value is known.
impl Mode<f64> for Normal {
/// Returns the mode of the Normal distribution.
///
/// The mode is the most likely value of the distribution, which in this case
/// is explicitly set during the construction of the Normal distribution instance.
///
/// # Returns
///
/// Returns the mode of the distribution as an f64 value, which is equal to
/// the 'likely' value provided when creating the distribution.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.mode(), 2.0);
/// ```
///
/// # Note
///
/// In a standard Normal distribution, the mode, median, and mean are all equal.
/// In this adapted version for estimation, we maintain this property by setting
/// all of these to the most likely value provided during construction.
fn mode(&self) -> f64 {
self.likely
}
}
/// Implementation of the `Continuous` trait for the Normal distribution.
///
/// This implementation provides methods to calculate the probability density function (PDF)
/// and its natural logarithm for the Normal distribution, adapted for estimation scenarios
/// with specified minimum and maximum values.
impl Continuous<f64, f64> for Normal {
/// Calculates the probability density function (PDF) of the Normal distribution at a given point.
///
/// The PDF represents the relative likelihood of the distribution taking on a specific value.
/// For this adapted Normal distribution, the PDF is set to 0 for values outside the [min, max] range.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the PDF
///
/// # Returns
///
/// The value of the PDF at the given point as an f64. Returns 0 if `x` is outside the [min, max] range.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert!(normal.pdf(2.0) > 0.0);
/// assert_eq!(normal.pdf(0.0), 0.0); // Outside the distribution's range
/// ```
fn pdf(&self, x: f64) -> f64 {
if x < self.min || x > self.max {
0.0
} else {
self.inner.pdf(x)
}
}
/// Calculates the natural logarithm of the probability density function (PDF) of the Normal distribution at a given point.
///
/// This method is particularly useful for numerical stability in calculations involving very small probability densities.
/// For this adapted Normal distribution, the ln_pdf is set to negative infinity for values outside the [min, max] range.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the log PDF
///
/// # Returns
///
/// The natural logarithm of the PDF at the given point as an f64.
/// Returns negative infinity if `x` is outside the [min, max] range.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert!(normal.ln_pdf(2.0).is_finite());
/// assert_eq!(normal.ln_pdf(0.0), f64::NEG_INFINITY); // Outside the distribution's range
/// ```
fn ln_pdf(&self, x: f64) -> f64 {
if x < self.min || x > self.max {
f64::NEG_INFINITY
} else {
self.inner.ln_pdf(x)
}
}
}
/// Implementation of the `ContinuousCDF` trait for the Normal distribution.
///
/// This implementation provides methods to calculate the cumulative distribution function (CDF)
/// and its inverse for the Normal distribution, adapted for estimation scenarios with
/// specified minimum and maximum values.
impl ContinuousCDF<f64, f64> for Normal {
/// Calculates the cumulative distribution function (CDF) of the Normal distribution at a given point.
///
/// The CDF represents the probability that a random variable from this distribution
/// will be less than or equal to the given value. For this adapted Normal distribution,
/// the CDF is clamped to 0 for values below the minimum and to 1 for values above the maximum.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the CDF
///
/// # Returns
///
/// The value of the CDF at the given point as an f64, in the range [0, 1].
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
/// use approx::assert_relative_eq;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(normal.cdf(2.0), 0.5, epsilon = 1e-6);
/// assert_eq!(normal.cdf(0.0), 0.0); // Below minimum
/// assert_eq!(normal.cdf(4.0), 1.0); // Above maximum
/// ```
fn cdf(&self, x: f64) -> f64 {
if x <= self.min {
0.0
} else if x >= self.max {
1.0
} else {
self.inner.cdf(x)
}
}
/// Calculates the inverse of the cumulative distribution function (inverse CDF)
/// of the Normal distribution for a given probability.
///
/// This function is also known as the quantile function. It returns the value x
/// for which P(X ≤ x) = p, where p is the given probability. For this adapted
/// Normal distribution, the result is clamped to the [min, max] range.
///
/// # Arguments
///
/// * `p` - The probability for which to calculate the inverse CDF, must be in the range [0, 1]
///
/// # Returns
///
/// The value x for which the CDF of the distribution equals the given probability,
/// clamped to the [min, max] range of the distribution.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
/// use approx::assert_relative_eq;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(normal.inverse_cdf(0.5), 2.0, epsilon = 1e-6);
/// assert_eq!(normal.inverse_cdf(0.0), 1.0); // Clamped to minimum
/// assert_eq!(normal.inverse_cdf(1.0), 3.0); // Clamped to maximum
/// ```
fn inverse_cdf(&self, p: f64) -> f64 {
self.clamp(self.inner.inverse_cdf(p))
}
}
/// Implementation of the `Min` trait for the Normal distribution.
///
/// This implementation provides a method to retrieve the minimum value
/// of the Normal distribution adapted for estimation scenarios.
impl Min<f64> for Normal {
/// Returns the minimum value of the Normal distribution.
///
/// In the context of this estimation-focused Normal distribution,
/// the minimum value represents the lower bound of the possible outcomes.
/// It's one of the three key parameters (along with the most likely value
/// and the maximum) used to define this adapted Normal distribution.
///
/// # Returns
///
/// The minimum value of the distribution as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.min(), 1.0);
/// ```
///
/// # Note
///
/// While a standard Normal distribution extends infinitely in both directions,
/// this adapted version for estimation purposes has a defined minimum value.
/// This allows for more practical modeling in scenarios where there's a known
/// lower limit to possible outcomes.
fn min(&self) -> f64 {
self.min
}
}
/// Implementation of the `Max` trait for the Normal distribution.
///
/// This implementation provides a method to retrieve the maximum value
/// of the Normal distribution adapted for estimation scenarios.
impl Max<f64> for Normal {
/// Returns the maximum value of the Normal distribution.
///
/// In the context of this estimation-focused Normal distribution,
/// the maximum value represents the upper bound of the possible outcomes.
/// It's one of the three key parameters (along with the minimum value
/// and the most likely value) used to define this adapted Normal distribution.
///
/// # Returns
///
/// The maximum value of the distribution as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(normal.max(), 3.0);
/// ```
///
/// # Note
///
/// While a standard Normal distribution extends infinitely in both directions,
/// this adapted version for estimation purposes has a defined maximum value.
/// This allows for more practical modeling in scenarios where there's a known
/// upper limit to possible outcomes.
fn max(&self) -> f64 {
self.max
}
}
/// Implementation of the `rand::distributions::Distribution` trait for the Normal distribution.
///
/// This implementation allows for random sampling from the Normal distribution
/// adapted for estimation scenarios, using any random number generator that
/// implements the `rand::Rng` trait.
impl RandDistribution<f64> for Normal {
/// Generates a random sample from the Normal distribution.
///
/// This method uses the underlying Normal distribution to generate a sample,
/// then clamps the result to ensure it falls within the specified [min, max] range.
/// This approach maintains the shape of the Normal distribution within the
/// specified range while ensuring all samples are valid for the estimation scenario.
///
/// # Arguments
///
/// * `rng` - A mutable reference to a random number generator that implements the `rand::Rng` trait
///
/// # Returns
///
/// A random sample from the Normal distribution as an f64, guaranteed to be
/// within the [min, max] range specified during the distribution's construction.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Normal;
/// use rand::prelude::*;
///
/// let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
/// let mut rng = StdRng::seed_from_u64(42); // For reproducibility
///
/// let sample = normal.sample(&mut rng);
/// assert!(sample >= 1.0 && sample <= 3.0);
/// ```
///
/// # Note
///
/// While this method ensures all samples fall within the specified range,
/// it may lead to a slight overrepresentation of the minimum and maximum values
/// compared to a true Normal distribution. This trade-off is often acceptable
/// in practical estimation scenarios where staying within the specified range is crucial.
fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> f64 {
self.clamp(self.inner.sample(rng))
}
}
#[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 normal = Normal::new(1.0, 2.0, 3.0).unwrap();
assert_eq!(normal.min(), 1.0);
assert_eq!(normal.mode(), 2.0);
assert_eq!(normal.max(), 3.0);
assert_eq!(normal.mean().unwrap(), 2.0);
assert_relative_eq!(normal.std_dev().unwrap(), 1.0 / 3.0, epsilon = 1e-6);
}
#[test]
fn test_invalid_parameters() {
assert!(Normal::new(3.0, 2.0, 1.0).is_err()); // min > max
assert!(Normal::new(1.0, 0.5, 3.0).is_err()); // likely < min
assert!(Normal::new(1.0, 3.5, 3.0).is_err()); // likely > max
}
#[test]
fn test_pdf() {
let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
assert_relative_eq!(normal.pdf(2.0), normal.inner.pdf(2.0), epsilon = 1e-6);
assert_eq!(normal.pdf(0.5), 0.0); // Below min
assert_eq!(normal.pdf(3.5), 0.0); // Above max
}
#[test]
fn test_cdf() {
let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
assert_relative_eq!(normal.cdf(2.0), 0.5, epsilon = 1e-6);
assert_eq!(normal.cdf(0.5), 0.0); // Below min
assert_eq!(normal.cdf(3.5), 1.0); // Above max
}
#[test]
fn test_inverse_cdf() {
let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
assert_relative_eq!(normal.inverse_cdf(0.5), 2.0, epsilon = 1e-6);
assert_eq!(normal.inverse_cdf(0.0), 1.0); // Clamped to min
assert_eq!(normal.inverse_cdf(1.0), 3.0); // Clamped to max
}
#[test]
fn test_sampling() {
let normal = Normal::new(1.0, 2.0, 3.0).unwrap();
let mut rng = StdRng::seed_from_u64(42);
let samples: Vec<f64> = (0..1000).map(|_| normal.sample(&mut rng)).collect();
assert!(samples.iter().all(|&x| (1.0..=3.0).contains(&x)));
let mean = samples.iter().sum::<f64>() / samples.len() as f64;
assert_relative_eq!(mean, 2.0, epsilon = 0.1);
}
#[test]
fn test_distribution_statistics() {
let test_cases = vec![
(1.0, 2.0, 3.0), // Standard case
(0.0, 5.0, 10.0), // Wider range
(-5.0, 0.0, 5.0), // Range including negative values
(100.0, 200.0, 300.0), // Larger values
];
for (min, likely, max) in test_cases {
let normal = Normal::new(min, likely, max).unwrap();
let n = 100_000; // Number of samples
let mut rng = StdRng::seed_from_u64(42); // Fixed seed for reproducibility
let samples: Vec<f64> = (0..n).map(|_| normal.sample(&mut rng)).collect();
let mut data = Data::new(samples.clone());
// Mean
let sample_mean = data.mean().unwrap();
let theoretical_mean = normal.mean().unwrap();
assert_relative_eq!(
sample_mean,
theoretical_mean,
epsilon = 0.01,
max_relative = 0.01
);
// Variance
let sample_variance = data.variance().unwrap();
let theoretical_variance = normal.variance().unwrap();
assert_relative_eq!(
sample_variance,
theoretical_variance,
epsilon = 0.05,
max_relative = 0.05
);
// Standard deviation
let sample_std_dev = data.std_dev().unwrap();
let theoretical_std_dev = normal.std_dev().unwrap();
assert_relative_eq!(
sample_std_dev,
theoretical_std_dev,
epsilon = 0.05,
max_relative = 0.05
);
// Skewness
let sample_skewness = 0.0; // normal distribution has no skewness
let theoretical_skewness = normal.skewness().unwrap();
assert_relative_eq!(
sample_skewness,
theoretical_skewness,
epsilon = 0.1,
max_relative = 0.1
);
// Median
let sample_median = data.median();
let theoretical_median = normal.median();
assert_relative_eq!(
sample_median,
theoretical_median,
epsilon = 0.01,
max_relative = 0.01
);
// Mode (we can't reliably estimate the mode from samples, so we'll check if it's within the range)
assert!(normal.mode() >= min && normal.mode() <= max);
// Min and Max
let sample_min = data.min();
let sample_max = data.max();
assert!(sample_min >= normal.min());
assert!(sample_max <= normal.max());
// CDF and Inverse CDF
let percentiles = [0.1, 0.25, 0.5, 0.75, 0.9];
for &p in percentiles.iter() {
let sample_percentile = data.percentile((p * 100.0) as usize);
let theoretical_percentile = normal.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 = normal.cdf(sample_percentile);
assert_relative_eq!(
sample_cdf,
theoretical_cdf,
epsilon = 0.01,
max_relative = 0.01
);
}
// PDF (we'll check a few points within the range)
let pdf_points = [min, (min + likely) / 2.0, likely, (likely + max) / 2.0, max];
for &x in pdf_points.iter() {
let sample_pdf = samples.iter().filter(|&s| (s - x).abs() < 0.1).count() as f64
/ (n as f64 * 0.2);
let theoretical_pdf = normal.pdf(x);
assert_relative_eq!(
sample_pdf,
theoretical_pdf,
epsilon = 0.1,
max_relative = 0.1
);
}
println!("Test passed for Normal({}, {}, {})", min, likely, max);
}
}
}
+961
View File
@@ -0,0 +1,961 @@
//! PERT (Program Evaluation and Review Technique) distribution implementation.
//!
//! This module provides an implementation of the PERT distribution, which is commonly used
//! in project management and risk analysis. The PERT distribution is a special case of the
//! Beta distribution, defined by its minimum, most likely (mode), and maximum values.
//!
//! # Examples
//!
//! ```
//! use distimate::prelude::*;
//! use distimate::Pert;
//! use approx::assert_relative_eq;
//!
//! let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
//! assert_eq!(pert.mode(), 2.0);
//! assert_relative_eq!(pert.mean().unwrap(), 2.0, epsilon = 1e-6);
//! ```
use crate::prelude::*;
use statrs::distribution::Beta;
/// Represents a PERT (Program Evaluation and Review Technique) distribution.
///
/// The PERT distribution is defined by its minimum, most likely (mode), and maximum values,
/// along with an optional shape parameter.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(pert.min(), 1.0);
/// assert_eq!(pert.mode(), 2.0);
/// assert_eq!(pert.max(), 3.0);
/// assert_relative_eq!(pert.mean().unwrap(), 2.0, epsilon = 1e-6);
/// ```
#[derive(Debug, Clone)]
pub struct Pert {
min: f64,
mode: f64,
max: f64,
shape: f64,
inner: Beta,
}
#[allow(dead_code)]
impl Pert {
/// Creates a new PERT distribution with the given minimum, likely, and maximum values.
///
/// This method uses the default shape parameter of 4.0.
///
/// # Arguments
///
/// * `min` - The minimum value of the distribution
/// * `likely` - The most likely value (mode) of the distribution
/// * `max` - The maximum value of the distribution
///
/// # Returns
///
/// Returns a `Result` containing the new `Pert` instance if the parameters are valid,
/// or an `Error` if the parameters are invalid.
///
/// # Errors
///
/// Returns an error if:
/// - `min` is greater than or equal to `max`
/// - `likely` is less than `min` or greater than `max`
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(pert.min(), 1.0);
/// assert_eq!(pert.mode(), 2.0);
/// assert_eq!(pert.max(), 3.0);
/// ```
pub fn new(min: f64, likely: f64, max: f64) -> Result<Self> {
Self::new_with_shape(min, likely, max, 4.0)
}
/// Creates a new PERT distribution with the given minimum, likely, maximum, and shape values.
///
/// # Arguments
///
/// * `min` - The minimum value of the distribution
/// * `likely` - The most likely value (mode) of the distribution
/// * `max` - The maximum value of the distribution
/// * `shape` - The shape parameter of the distribution (must be between 2.0 and 6.0, inclusive)
///
/// # Returns
///
/// Returns a `Result` containing the new `Pert` instance if the parameters are valid,
/// or an `Error` if the parameters are invalid.
///
/// # Errors
///
/// Returns an error if:
/// - `min` is greater than or equal to `max`
/// - `likely` is less than `min` or greater than `max`
/// - `shape` is not between 2.0 and 6.0 (inclusive)
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
///
/// let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 5.0).unwrap();
/// assert_eq!(pert.min(), 1.0);
/// assert_eq!(pert.mode(), 2.0);
/// assert_eq!(pert.max(), 3.0);
/// ```
pub fn new_with_shape(min: f64, likely: f64, max: f64, shape: f64) -> Result<Self> {
// validate inputs
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)?,
})
}
/// Returns the alpha parameter of the underlying Beta distribution.
///
/// The alpha parameter is calculated based on the minimum, mode, maximum,
/// and shape parameters of the PERT distribution.
///
/// # Returns
///
/// The alpha parameter as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.alpha(), 3.0, epsilon = 1e-6);
/// ```
pub fn alpha(&self) -> f64 {
self.inner.shape_a()
}
/// Returns the beta parameter of the underlying Beta distribution.
///
/// The beta parameter is calculated based on the minimum, mode, maximum,
/// and shape parameters of the PERT distribution.
///
/// # Returns
///
/// The beta parameter as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.beta(), 3.0, epsilon = 1e-6);
/// ```
pub fn beta(&self) -> f64 {
self.inner.shape_b()
}
/// Returns the shape parameter of the PERT distribution.
///
/// The shape parameter influences the peakedness of the distribution.
/// A higher value results in a more peaked distribution around the mode.
///
/// # Returns
///
/// The shape parameter as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
///
/// let pert = Pert::new_with_shape(1.0, 2.0, 3.0, 5.0).unwrap();
/// assert_eq!(pert.shape(), 5.0);
/// ```
pub fn shape(&self) -> f64 {
self.shape
}
/// Calculates the excess kurtosis of the PERT distribution.
///
/// Excess kurtosis is a measure of the "tailedness" of the probability distribution
/// compared to a normal distribution. A positive excess kurtosis indicates heavier tails
/// and a higher, sharper peak, while a negative excess kurtosis indicates lighter tails
/// and a lower, more rounded peak.
///
/// # Returns
///
/// The excess kurtosis as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.kurtosis(), -0.6666667, epsilon = 1e-6);
/// ```
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))
}
}
/// Implementation of the `EstimationDistribution` trait for the PERT distribution.
///
/// This trait marks the PERT distribution as suitable for use in estimation contexts.
/// It doesn't add any new methods, but signals that this distribution is appropriate
/// for modeling uncertain estimates or durations.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// // The fact that we can create a Pert instance demonstrates
/// // that it implements EstimationDistribution
/// ```
impl EstimationDistribution for Pert {}
/// Implementation of the `Distribution` trait for the PERT distribution.
///
/// This implementation provides methods to calculate various statistical properties
/// of the PERT distribution.
impl Distribution<f64> for Pert {
/// Calculates the mean of the PERT distribution.
///
/// # Returns
///
/// The mean of the distribution as an `Option<f64>`. Always returns `Some(value)`
/// as the PERT distribution always has a defined mean.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.mean().unwrap(), 2.0, epsilon = 1e-6);
/// ```
fn mean(&self) -> Option<f64> {
self.inner
.mean()
.map(|m| self.min + (self.max - self.min) * m)
}
/// Calculates the variance of the PERT distribution.
///
/// # Returns
///
/// The variance of the distribution as an `Option<f64>`. Always returns `Some(value)`
/// as the PERT distribution always has a defined variance.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.variance().unwrap(), 1.0 / 7.0, epsilon = 1e-6);
/// ```
fn variance(&self) -> Option<f64> {
self.inner
.variance()
.map(|v| (self.max - self.min).powi(2) * v)
}
/// Calculates the skewness of the PERT distribution.
///
/// # Returns
///
/// The skewness of the distribution as an `Option<f64>`. Always returns `Some(value)`
/// as the PERT distribution always has a defined skewness.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.skewness().unwrap(), 0.0, epsilon = 1e-6);
/// ```
fn skewness(&self) -> Option<f64> {
self.inner.skewness()
}
/// Calculates the entropy of the Pert distribution.
///
/// The entropy is a measure of the average amount of information contained in the distribution.
///
/// # Returns
///
/// The entropy of the distribution as an `Option<f64>`. Always returns `Some(value)`
/// as the Pert distribution always has a defined entropy.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.entropy().unwrap(), -0.267864, epsilon = 1e-6);
/// ```
fn entropy(&self) -> Option<f64> {
self.inner.entropy()
}
}
/// Implementation of the `Median` trait for the PERT distribution.
///
/// The median of a PERT distribution is the value that separates the lower and upper halves
/// of the probability distribution. For a PERT distribution, this is calculated using
/// the inverse cumulative distribution function (inverse CDF) at probability 0.5.
impl Median<f64> for Pert {
/// Calculates the median of the PERT distribution.
///
/// The median is the value separating the higher half from the lower half of the distribution.
///
/// # Returns
///
/// The median of the distribution as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.median(), 2.0, epsilon = 1e-4);
/// ```
fn median(&self) -> f64 {
self.min + (self.max - self.min) * self.inner.inverse_cdf(0.5)
}
}
/// Implementation of the `Mode` trait for the PERT distribution.
impl Mode<f64> for Pert {
/// Returns the mode of the PERT distribution.
///
/// The mode is the most likely value of the distribution, which is one of the
/// defining parameters of the PERT distribution.
///
/// # Returns
///
/// The mode of the distribution as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(pert.mode(), 2.0);
/// ```
fn mode(&self) -> f64 {
self.mode
}
}
/// Implementation of the `Continuous` trait for the PERT distribution.
///
/// This implementation provides methods to calculate the probability density function (PDF)
/// and its natural logarithm for the PERT distribution. These functions are crucial for
/// understanding the likelihood of different outcomes within the distribution's range.
///
/// The PERT distribution is continuous over its defined range (minimum to maximum),
/// with the PDF representing the relative likelihood of each possible value.
impl Continuous<f64, f64> for Pert {
/// Calculates the probability density function (PDF) of the PERT distribution at a given point.
///
/// The PDF represents the relative likelihood of the distribution taking on a specific value.
/// For the PERT distribution, the PDF is non-zero only within the range [min, max] and
/// reaches its peak at the mode.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the PDF
///
/// # Returns
///
/// The value of the PDF at the given point as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.pdf(2.0), 0.9375, epsilon = 1e-6);
/// assert_eq!(pert.pdf(0.0), 0.0); // Outside the distribution's range
/// ```
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)
}
}
/// Calculates the natural logarithm of the probability density function (PDF) of the PERT distribution at a given point.
///
/// This method is particularly useful for numerical stability in calculations involving very small probability densities.
/// It's often used in statistical inference and machine learning algorithms.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the log PDF
///
/// # Returns
///
/// The natural logarithm of the PDF at the given point as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.ln_pdf(2.0), -0.064538, epsilon = 1e-6);
/// assert_eq!(pert.ln_pdf(0.0), f64::NEG_INFINITY); // Outside the distribution's range
/// ```
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()
}
}
}
/// Implementation of the `ContinuousCDF` trait for the PERT distribution.
///
/// This implementation provides methods to calculate the cumulative distribution function (CDF)
/// and its inverse for the PERT distribution. These functions are essential for understanding
/// the probability of the distribution taking on a value less than or equal to a given point,
/// and for generating random variates from the distribution.
impl ContinuousCDF<f64, f64> for Pert {
/// Calculates the cumulative distribution function (CDF) of the PERT distribution at a given point.
///
/// The CDF represents the probability that a random variable from this distribution
/// will be less than or equal to the given value.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the CDF
///
/// # Returns
///
/// The value of the CDF at the given point as an f64, in the range [0, 1].
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.cdf(2.0), 0.5, epsilon = 1e-6);
/// assert_eq!(pert.cdf(1.0), 0.0);
/// assert_eq!(pert.cdf(3.0), 1.0);
/// ```
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)
}
}
/// Calculates the inverse of the cumulative distribution function (inverse CDF)
/// of the PERT distribution for a given probability.
///
/// This function is also known as the quantile function. It returns the value x
/// for which P(X ≤ x) = p, where p is the given probability.
///
/// # Arguments
///
/// * `p` - The probability for which to calculate the inverse CDF, must be in the range [0, 1]
///
/// # Returns
///
/// The value x for which the CDF of the distribution equals the given probability.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.inverse_cdf(0.5), 2.0, epsilon = 1e-4);
/// assert_relative_eq!(pert.inverse_cdf(0.0), 1.0, epsilon = 1e-4);
/// assert_relative_eq!(pert.inverse_cdf(1.0), 3.0, epsilon = 1e-4);
/// ```
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)
}
}
}
/// Implementation of the `Min` trait for the PERT distribution.
///
/// This implementation provides a method to retrieve the minimum value
/// of the PERT distribution, which is one of its defining parameters.
impl Min<f64> for Pert {
/// Returns the minimum value of the PERT distribution.
///
/// The minimum value represents the lowest possible outcome in the distribution.
///
/// # Returns
///
/// The minimum value of the distribution as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(pert.min(), 1.0);
/// ```
fn min(&self) -> f64 {
self.min
}
}
/// Implementation of the `Max` trait for the PERT distribution.
///
/// This implementation provides a method to retrieve the maximum value
/// of the PERT distribution, which is one of its defining parameters.
impl Max<f64> for Pert {
/// Returns the maximum value of the PERT distribution.
///
/// The maximum value represents the highest possible outcome in the distribution.
///
/// # Returns
///
/// The maximum value of the distribution as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(pert.max(), 3.0);
/// ```
fn max(&self) -> f64 {
self.max
}
}
/// Implementation of the `rand::distributions::Distribution` trait for the PERT distribution.
///
/// This implementation allows for random sampling from the PERT distribution using
/// any random number generator that implements the `rand::Rng` trait.
impl RandDistribution<f64> for Pert {
/// Generates a random sample from the PERT distribution.
///
/// This method uses the inverse transform sampling technique: it generates a uniform
/// random number between 0 and 1, then applies the inverse CDF to this number to
/// produce a sample from the PERT distribution.
///
/// # Arguments
///
/// * `rng` - A random number generator that implements the `rand::Rng` trait
///
/// # Returns
///
/// A random sample from the PERT distribution as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use rand::prelude::*;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// let mut rng = StdRng::seed_from_u64(42); // For reproducibility
///
/// let sample = pert.sample(&mut rng);
/// assert!(sample >= 1.0 && sample <= 3.0);
/// ```
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.skewness().unwrap(), 0.5773502, 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(); // min > max
}
fn sample_statistics(dist: &Pert, n: usize) -> (f64, f64, f64, f64) {
let mut rng = StdRng::seed_from_u64(42); // Fixed seed for reproducibility
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; // Number of samples
let epsilon = 0.01; // Tolerance for relative equality
let (sample_mean, sample_variance, sample_std_dev, sample_median) =
sample_statistics(&dist, n);
// Test mean
assert_relative_eq!(
sample_mean,
dist.mean().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
// Test variance
assert_relative_eq!(
sample_variance,
dist.variance().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
// Test standard deviation
assert_relative_eq!(
sample_std_dev,
dist.std_dev().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
// Test median
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), // Standard PERT
(0.0, 2.0, 10.0, 2.0), // Modified PERT with lower shape
(0.0, 9.0, 10.0, 6.0), // Modified PERT with higher shape
(-10.0, 0.0, 10.0, 4.0), // Standard PERT with negative values
(100.0, 400.0, 1000.0, 4.0), // Standard PERT with larger values
];
for (min, mode, max, shape) in test_cases {
let pert = Pert::new_with_shape(min, mode, max, shape).unwrap();
let n = 25_000; // Number of samples
let mut rng = StdRng::seed_from_u64(42); // Fixed seed for reproducibility
let samples: Vec<f64> = (0..n).map(|_| pert.sample(&mut rng)).collect();
let mut data = Data::new(samples.clone());
// Mean
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
);
// Variance
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
);
// Standard deviation
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
);
// Skewness
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
);
// Median
let sample_median = data.median();
let theoretical_median = pert.median();
assert_relative_eq!(
sample_median,
theoretical_median,
epsilon = 0.1,
max_relative = 0.1
);
// Mode (we can't reliably estimate the mode from samples, so we'll check if it's within the range)
assert!(pert.mode() >= min && pert.mode() <= max);
// Min and Max
let sample_min = data.min();
let sample_max = data.max();
assert!(sample_min >= pert.min());
assert!(sample_max <= pert.max());
// Excess Kurtosis
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
);
// CDF and Inverse CDF
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
);
}
}
}
+712
View File
@@ -0,0 +1,712 @@
//! Triangular distribution implementation.
//!
//! This module provides an implementation of the Triangular distribution, which is often used
//! in project management, risk analysis, and other fields where limited sample data is available.
//! The Triangular distribution is defined by its minimum, most likely (mode), and maximum values.
//!
//! The Triangular distribution is particularly useful when the relationship between variables is known,
//! but data is scarce. It provides a simple and intuitive way to model situations where you can estimate
//! the minimum, maximum, and most likely values.
//!
//! # Examples
//!
//! ```
//! use distimate::prelude::*;
//! use distimate::Triangular;
//! use approx::assert_relative_eq;
//!
//! let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
//! assert_eq!(triangular.mode(), 2.0);
//! assert_relative_eq!(triangular.mean().unwrap(), 2.0, epsilon = 1e-6);
//! ```
use crate::prelude::*;
use statrs::distribution::Triangular as StatrsTriangular;
/// Represents a Triangular distribution.
///
/// The Triangular distribution is defined by its minimum, most likely (mode), and maximum values.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(triangular.min(), 1.0);
/// assert_eq!(triangular.mode(), 2.0);
/// assert_eq!(triangular.max(), 3.0);
/// assert_relative_eq!(triangular.mean().unwrap(), 2.0, epsilon = 1e-4);
/// ```
#[derive(Debug, Clone)]
pub struct Triangular {
inner: StatrsTriangular,
}
impl Triangular {
/// Creates a new Triangular distribution with the given minimum, likely, and maximum values.
///
/// # Arguments
///
/// * `min` - The minimum value of the distribution
/// * `likely` - The most likely value (mode) of the distribution
/// * `max` - The maximum value of the distribution
///
/// # Returns
///
/// Returns a `Result` containing the new `Triangular` instance if the parameters are valid,
/// or an `Error` if the parameters are invalid.
///
/// # Errors
///
/// Returns an error if:
/// - `min` is greater than or equal to `max`
/// - `likely` is less than `min` or greater than `max`
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(triangular.min(), 1.0);
/// assert_eq!(triangular.mode(), 2.0);
/// assert_eq!(triangular.max(), 3.0);
/// ```
pub fn new(min: f64, likely: f64, max: f64) -> Result<Self> {
if min >= max {
return Err(Error::InvalidRange { min, max });
}
if likely < min || likely > max {
return Err(Error::InvalidMode { min, likely, max });
}
Ok(Triangular {
inner: StatrsTriangular::new(min, max, likely)?,
})
}
}
/// Implementation of the `EstimationDistribution` trait for the Triangular distribution.
///
/// This trait marks the Triangular distribution as suitable for use in estimation contexts.
/// It doesn't add any new methods, but signals that this distribution is appropriate
/// for modeling uncertain estimates or durations.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// // The fact that we can create a Triangular instance demonstrates
/// // that it implements EstimationDistribution
/// ```
impl EstimationDistribution for Triangular {}
/// Implementation of the `Distribution` trait for the Triangular distribution.
///
/// This implementation provides methods to calculate various statistical properties
/// of the Triangular distribution.
impl Distribution<f64> for Triangular {
/// Calculates the mean of the Triangular distribution.
///
/// # Returns
///
/// The mean of the distribution as an `Option<f64>`. Always returns `Some(value)`
/// as the Triangular distribution always has a defined mean.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.mean().unwrap(), 2.0, epsilon = 1e-6);
/// ```
fn mean(&self) -> Option<f64> {
self.inner.mean()
}
/// Calculates the variance of the Triangular distribution.
///
/// # Returns
///
/// The variance of the distribution as an `Option<f64>`. Always returns `Some(value)`
/// as the Triangular distribution always has a defined variance.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.variance().unwrap(), 0.1666667, epsilon = 1e-6);
/// ```
fn variance(&self) -> Option<f64> {
self.inner.variance()
}
/// Calculates the skewness of the Triangular distribution.
///
/// # Returns
///
/// The skewness of the distribution as an `Option<f64>`. Always returns `Some(value)`
/// as the Triangular distribution always has a defined skewness.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.skewness().unwrap(), 0.0, epsilon = 1e-6);
/// ```
fn skewness(&self) -> Option<f64> {
self.inner.skewness()
}
/// Calculates the entropy of the Triangular distribution.
///
/// The entropy is a measure of the average amount of information contained in the distribution.
///
/// # Returns
///
/// The entropy of the distribution as an `Option<f64>`. Always returns `Some(value)`
/// as the Triangular distribution always has a defined entropy.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.entropy().unwrap(), 0.5, epsilon = 1e-6);
/// ```
fn entropy(&self) -> Option<f64> {
self.inner.entropy()
}
}
/// Implementation of the `Median` trait for the Triangular distribution.
impl Median<f64> for Triangular {
/// Calculates the median of the Triangular distribution.
///
/// The median is the value separating the higher half from the lower half of the distribution.
///
/// # Returns
///
/// The median of the distribution as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.median(), 2.0, epsilon = 1e-6);
/// ```
fn median(&self) -> f64 {
self.inner.median()
}
}
/// Implementation of the `Mode` trait for the Triangular distribution.
impl Mode<f64> for Triangular {
/// Returns the mode of the Triangular distribution.
///
/// The mode is the most likely value of the distribution, which is one of the
/// defining parameters of the Triangular distribution.
///
/// # Returns
///
/// The mode of the distribution as an f64 value.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(triangular.mode(), 2.0);
/// ```
fn mode(&self) -> f64 {
self.inner.mode().unwrap()
}
}
/// Implementation of the `Continuous` trait for the Triangular distribution.
///
/// This implementation provides methods to calculate the probability density function (PDF)
/// and its natural logarithm for the Triangular distribution.
impl Continuous<f64, f64> for Triangular {
/// Calculates the probability density function (PDF) of the Triangular distribution at a given point.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the PDF
///
/// # Returns
///
/// The value of the PDF at the given point as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.pdf(2.0), 1.0, epsilon = 1e-6);
/// assert_eq!(triangular.pdf(0.0), 0.0); // Outside the distribution's range
/// ```
fn pdf(&self, x: f64) -> f64 {
self.inner.pdf(x)
}
/// Calculates the natural logarithm of the probability density function (PDF) of the Triangular distribution at a given point.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the log PDF
///
/// # Returns
///
/// The natural logarithm of the PDF at the given point as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.ln_pdf(2.0), 0.0, epsilon = 1e-6);
/// assert_eq!(triangular.ln_pdf(0.0), f64::NEG_INFINITY); // Outside the distribution's range
/// ```
fn ln_pdf(&self, x: f64) -> f64 {
self.inner.ln_pdf(x)
}
}
/// Implementation of the `ContinuousCDF` trait for the Triangular distribution.
impl ContinuousCDF<f64, f64> for Triangular {
/// Calculates the cumulative distribution function (CDF) of the Triangular distribution at a given point.
///
/// # Arguments
///
/// * `x` - The point at which to calculate the CDF
///
/// # Returns
///
/// The value of the CDF at the given point as an f64, in the range [0, 1].
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.cdf(2.0), 0.5, epsilon = 1e-6);
/// assert_eq!(triangular.cdf(1.0), 0.0);
/// assert_eq!(triangular.cdf(3.0), 1.0);
/// ```
fn cdf(&self, x: f64) -> f64 {
self.inner.cdf(x)
}
/// Calculates the inverse of the cumulative distribution function (inverse CDF)
/// of the Triangular distribution for a given probability.
///
/// # Arguments
///
/// * `p` - The probability for which to calculate the inverse CDF, must be in the range [0, 1]
///
/// # Returns
///
/// The value x for which the CDF of the distribution equals the given probability.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use approx::assert_relative_eq;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(triangular.inverse_cdf(0.5), 2.0, epsilon = 1e-4);
/// assert_relative_eq!(triangular.inverse_cdf(0.0), 1.0, epsilon = 1e-4);
/// assert_relative_eq!(triangular.inverse_cdf(1.0), 3.0, epsilon = 1e-4);
/// ```
fn inverse_cdf(&self, p: f64) -> f64 {
self.inner.inverse_cdf(p)
}
}
/// Implementation of the `Min` trait for the Triangular distribution.
impl Min<f64> for Triangular {
/// Returns the minimum value of the Triangular distribution.
///
/// # Returns
///
/// The minimum value of the distribution as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(triangular.min(), 1.0);
/// ```
fn min(&self) -> f64 {
self.inner.min()
}
}
/// Implementation of the `Max` trait for the Triangular distribution.
impl Max<f64> for Triangular {
/// Returns the maximum value of the Triangular distribution.
///
/// # Returns
///
/// The maximum value of the distribution as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(triangular.max(), 3.0);
/// ```
fn max(&self) -> f64 {
self.inner.max()
}
}
/// Implementation of the `rand::distributions::Distribution` trait for the Triangular distribution.
///
/// This implementation allows for random sampling from the Triangular distribution using
/// any random number generator that implements the `rand::Rng` trait.
impl RandDistribution<f64> for Triangular {
/// Generates a random sample from the Triangular distribution.
///
/// This method uses the inverse transform sampling technique: it generates a uniform
/// random number between 0 and 1, then applies the inverse CDF to this number to
/// produce a sample from the Triangular distribution.
///
/// # Arguments
///
/// * `rng` - A random number generator that implements the `rand::Rng` trait
///
/// # Returns
///
/// A random sample from the Triangular distribution as an f64.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Triangular;
/// use rand::prelude::*;
///
/// let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
/// let mut rng = StdRng::seed_from_u64(42); // For reproducibility
///
/// let sample = triangular.sample(&mut rng);
/// assert!(sample >= 1.0 && sample <= 3.0);
/// ```
fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> f64 {
self.inner.sample(rng)
}
}
#[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 triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
assert_eq!(triangular.min(), 1.0);
assert_eq!(triangular.mode(), 2.0);
assert_eq!(triangular.max(), 3.0);
}
#[test]
fn test_min() {
let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
assert_eq!(triangular.min(), 1.0);
}
#[test]
fn test_max() {
let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
assert_eq!(triangular.max(), 3.0);
}
#[test]
fn test_mean() {
let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
assert_relative_eq!(triangular.mean().unwrap(), 2.0, epsilon = 1e-6);
}
#[test]
fn test_median() {
let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
assert_relative_eq!(triangular.median(), 2.0, epsilon = 1e-6);
}
#[test]
fn test_mode() {
let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
assert_eq!(triangular.mode(), 2.0);
}
#[test]
fn test_variance() {
let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
assert_relative_eq!(triangular.variance().unwrap(), 0.1666667, epsilon = 1e-6);
}
#[test]
fn test_skewness() {
let triangular = Triangular::new(1.0, 2.0, 3.0).unwrap();
assert_relative_eq!(triangular.skewness().unwrap(), 0.0, epsilon = 1e-6);
}
#[test]
fn test_asymmetric_distribution() {
let triangular = Triangular::new(0.0, 1.0, 5.0).unwrap();
assert_relative_eq!(triangular.mean().unwrap(), 2.0, epsilon = 1e-6);
assert_relative_eq!(triangular.median(), 1.83772234, epsilon = 1e-6);
assert_relative_eq!(triangular.variance().unwrap(), 1.1666667, epsilon = 1e-6);
assert_relative_eq!(triangular.skewness().unwrap(), 0.47613605, 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() {
Triangular::new(3.0, 2.0, 1.0).unwrap(); // min > max
}
fn sample_statistics(dist: &Triangular, n: usize) -> (f64, f64, f64, f64) {
let mut rng = StdRng::seed_from_u64(42); // Fixed seed for reproducibility
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_triangular_distribution_statistics() {
let min = 1.0;
let mode = 2.0;
let max = 5.0;
let dist = Triangular::new(min, mode, max).unwrap();
let n = 100_000; // Number of samples
let epsilon = 0.01; // Tolerance for relative equality
let (sample_mean, sample_variance, sample_std_dev, sample_median) =
sample_statistics(&dist, n);
// Test mean
assert_relative_eq!(
sample_mean,
dist.mean().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
// Test variance
assert_relative_eq!(
sample_variance,
dist.variance().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
// Test standard deviation
assert_relative_eq!(
sample_std_dev,
dist.std_dev().unwrap(),
epsilon = epsilon,
max_relative = epsilon
);
// Test median
assert_relative_eq!(
sample_median,
dist.median(),
epsilon = epsilon,
max_relative = epsilon
);
}
#[test]
fn test_triangular_distribution_bounds() {
let min = 1.0;
let mode = 2.0;
let max = 3.0;
let dist = Triangular::new(min, mode, max).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)
}
#[test]
fn test_distribution_statistics() {
let test_cases = vec![
(0.0, 5.0, 10.0), // Standard Triangular
(0.0, 2.0, 10.0), // Right-skewed Triangular
(0.0, 9.0, 10.0), // Left-skewed Triangular
(-10.0, 0.0, 10.0), // Symmetric Triangular with negative values
(100.0, 400.0, 1000.0), // Triangular with larger values
];
for (min, mode, max) in test_cases {
let triangular = Triangular::new(min, mode, max).unwrap();
let n = 25_000; // Number of samples
let mut rng = StdRng::seed_from_u64(42); // Fixed seed for reproducibility
let samples: Vec<f64> = (0..n).map(|_| triangular.sample(&mut rng)).collect();
let mut data = Data::new(samples.clone());
// Mean
let sample_mean = data.mean().unwrap();
let theoretical_mean = triangular.mean().unwrap();
assert_relative_eq!(
sample_mean,
theoretical_mean,
epsilon = 0.1,
max_relative = 0.1
);
// Variance
let sample_variance = data.variance().unwrap();
let theoretical_variance = triangular.variance().unwrap();
assert_relative_eq!(
sample_variance,
theoretical_variance,
epsilon = 0.05,
max_relative = 0.05
);
// Standard deviation
let sample_std_dev = data.std_dev().unwrap();
let theoretical_std_dev = triangular.std_dev().unwrap();
assert_relative_eq!(
sample_std_dev,
theoretical_std_dev,
epsilon = 0.1,
max_relative = 0.1
);
// Skewness
let sample_skewness = sample_skewness(&samples);
let theoretical_skewness = triangular.skewness().unwrap();
assert_relative_eq!(
sample_skewness,
theoretical_skewness,
epsilon = 0.1,
max_relative = 0.1
);
// Median
let sample_median = data.median();
let theoretical_median = triangular.median();
assert_relative_eq!(
sample_median,
theoretical_median,
epsilon = 0.1,
max_relative = 0.1
);
// Mode (we can't reliably estimate the mode from samples, so we'll check if it's within the range)
assert!(triangular.mode() >= min && triangular.mode() <= max);
// Min and Max
let sample_min = data.min();
let sample_max = data.max();
assert!(sample_min >= triangular.min());
assert!(sample_max <= triangular.max());
// CDF and Inverse CDF
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 = triangular.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 = triangular.cdf(sample_percentile);
assert_relative_eq!(
sample_cdf,
theoretical_cdf,
epsilon = 0.01,
max_relative = 0.01
);
}
println!("Test passed for Triangular({}, {}, {})", min, mode, max);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Insufficient data: at least {required} data points required, but got {provided}")]
InsufficientData { required: usize, provided: usize },
#[error("Invalid data: {0}")]
InvalidData(String),
#[error("Fitting error: {0}")]
FittingError(String),
#[error("Fit quality check error: {0}")]
FitQualityError(String),
#[error("Invalid parameter: {0}")]
InvalidParameter(String),
#[error("Math error: {0}")]
MathError(String),
#[error("Invalid percentile: {0}. Must be between 0 and 1.")]
InvalidPercentile(f64),
#[error("Invalid confidence level: {0}. Must be between 0 and 1.")]
InvalidConfidenceLevel(f64),
#[error("Invalid shape: {0}. Must be between 0 and 1.")]
InvalidShape(f64),
#[error("Invalid parameters: min ({min}) must be less than max ({max}).")]
InvalidRange { min: f64, max: f64 },
#[error("Likely value ({likely}) must be between min ({min}) and max ({max}).")]
InvalidMode { min: f64, likely: f64, max: f64 },
#[error("statrs::StatsError: {0}.")]
StatsError(#[from] statrs::StatsError),
}
+296
View File
@@ -0,0 +1,296 @@
use rand::distributions::Distribution as RandDistribution;
use statrs::{
distribution::{Continuous, ContinuousCDF},
statistics::{Distribution, Max, Median, Min, Mode},
};
use crate::{Error, EstimationFitQuality, Result};
/// A trait for probability distributions used in estimation scenarios.
///
/// This trait combines several statistical traits and provides additional methods
/// specific to estimation and risk analysis. It's designed to be implemented by
/// distributions that model uncertainty in estimates, such as project durations,
/// costs, or other quantifiable outcomes.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.expected_value(), 2.0, epsilon = 1e-6);
/// assert_relative_eq!(pert.optimistic_estimate(), 1.37847, epsilon = 1e-4);
/// assert_relative_eq!(pert.pessimistic_estimate(), 2.62152, epsilon = 1e-4);
/// ```
pub trait EstimationDistribution:
Distribution<f64>
+ Continuous<f64, f64>
+ ContinuousCDF<f64, f64>
+ Median<f64>
+ Mode<f64>
+ Min<f64>
+ Max<f64>
+ RandDistribution<f64> // TODO: add TryFrom (x2) here
{
/// Returns the estimate at a given percentile.
///
/// # Arguments
///
/// * `p` - The percentile as a float between 0.0 and 1.0.
///
/// # Returns
///
/// The estimated value at the given percentile, or an error if the percentile is invalid.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.percentile_estimate(0.5).unwrap(), 2.0, epsilon = 1e-4);
/// ```
fn percentile_estimate(&self, p: f64) -> Result<f64> {
if !(0.0..=1.0).contains(&p) {
Err(Error::InvalidPercentile(p))
} else {
Ok(self.inverse_cdf(p))
}
}
/// Returns the optimistic (best-case) estimate, typically the 5th percentile.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.optimistic_estimate(), 1.37847, epsilon = 1e-4);
/// ```
fn optimistic_estimate(&self) -> f64 {
self.percentile_estimate(0.05).unwrap()
}
/// Returns the pessimistic (worst-case) estimate, typically the 95th percentile.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.pessimistic_estimate(), 2.62152, epsilon = 1e-4);
/// ```
fn pessimistic_estimate(&self) -> f64 {
self.percentile_estimate(0.95).unwrap()
}
/// Returns the most likely estimate (mode).
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_eq!(pert.most_likely_estimate(), 2.0);
/// ```
fn most_likely_estimate(&self) -> f64 {
self.mode()
}
/// Returns the expected value (mean) of the distribution.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.expected_value(), 2.0, epsilon = 1e-6);
/// ```
fn expected_value(&self) -> f64 {
self.mean().unwrap()
}
/// Returns the standard deviation of the distribution.
///
/// # Examples
///
/// ```
/// use distimate::prelude::*;
/// use distimate::Pert;
/// use approx::assert_relative_eq;
///
/// let pert = Pert::new(1.0, 2.0, 3.0).unwrap();
/// assert_relative_eq!(pert.uncertainty(), 0.377964, epsilon = 1e-4);
/// ```
fn uncertainty(&self) -> f64 {
self.std_dev().unwrap()
}
fn probability_of_completion(&self, estimate: f64) -> f64 {
self.cdf(estimate)
}
/// Calculates the risk of exceeding a given estimate.
///
/// This method computes the probability that the actual value will be greater than
/// the provided estimate. It's particularly useful in project management and risk
/// analysis to quantify the likelihood of cost overruns or schedule delays.
///
/// The risk of overrun is calculated as 1 minus the probability of completion:
/// Risk = 1 - P(X <= estimate), where X is the random variable representing the
/// actual cost or time.
///
/// For example:
/// - A risk of 0.2 (20%) means there's a 20% chance the project will exceed the estimate.
/// - A risk of 0.5 (50%) means it's equally likely to be above or below the estimate.
/// - A risk of 0.8 (80%) suggests a high likelihood of exceeding the estimate.
///
/// # Arguments
///
/// * `estimate` - The point estimate to evaluate the risk against.
///
/// # Returns
///
/// A float between 0 and 1 representing the probability of exceeding the estimate.
fn risk_of_overrun(&self, estimate: f64) -> f64 {
1.0 - self.probability_of_completion(estimate)
}
/// Returns a confidence interval for the estimate.
///
/// A confidence interval provides a range of values that is likely to contain
/// the true value with a certain level of confidence. It's useful for expressing
/// the uncertainty in an estimate.
///
/// This method calculates a two-sided confidence interval, meaning it provides
/// both a lower and upper bound. The interval is centered around the median of
/// the distribution.
///
/// # Arguments
///
/// * `confidence_level` - A value between 0 and 1 representing the desired level of confidence.
/// Common values are 0.95 for a 95% confidence interval or 0.99 for a 99% confidence interval.
///
/// # Returns
///
/// A tuple (lower, upper) representing the lower and upper bounds of the confidence interval.
///
/// # Example
///
/// If you call `confidence_interval(0.95)` and get (100, 150), it means:
/// "We are 95% confident that the true value lies between 100 and 150."
///
/// # Note
///
/// The width of the interval indicates the precision of the estimate. A narrower interval
/// suggests a more precise estimate, while a wider interval indicates more uncertainty.
fn confidence_interval(&self, confidence_level: f64) -> (f64, f64) {
let lower = self.percentile_estimate((1.0 - confidence_level) / 2.0);
let upper = self.percentile_estimate(1.0 - (1.0 - confidence_level) / 2.0);
(lower.unwrap(), upper.unwrap())
}
/// Evaluates how well the distribution fits a given dataset in the context of estimation.
///
/// This method provides a measure of how accurately the distribution represents
/// the provided set of estimates or actual values.
///
/// # Arguments
///
/// * `data` - A slice of f64 values representing historical estimates or actual values.
///
/// # Returns
///
/// Returns an `EstimationFitQuality` struct containing various fit metrics.
///
/// # Errors
///
/// Returns an `Error` if there's an issue calculating the fit,
/// such as invalid data points or errors in percentile calculations.
fn evaluate_fit_quality(&self, data: &[f64]) -> Result<EstimationFitQuality> {
let n = data.len();
if n == 0 {
return Err(Error::InsufficientData {
required: 1,
provided: 0,
});
}
let mean = data.iter().sum::<f64>() / n as f64;
let median = self.median();
let mode = self.mode();
let mse = data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
let rmse = mse.sqrt();
let mape = data.iter().map(|&x| ((x - mean) / x).abs()).sum::<f64>() / n as f64 * 100.0;
let within_50 = self.calculate_within_interval(data, 0.25, 0.75)?;
let within_68 = self.calculate_within_interval(data, 0.16, 0.84)?;
let within_95 = self.calculate_within_interval(data, 0.025, 0.975)?;
Ok(EstimationFitQuality {
mean_absolute_percentage_error: mape,
root_mean_square_error: rmse,
distribution_mean: mean,
distribution_median: median,
distribution_mode: mode,
within_50_percent: within_50,
within_68_percent: within_68,
within_95_percent: within_95,
sample_size: n,
})
}
/// Helper method to calculate the proportion of data within a given interval.
///
/// # Arguments
///
/// * `data` - A slice of f64 values representing the dataset.
/// * `lower_percentile` - The lower bound of the interval as a percentile (0.0 to 1.0).
/// * `upper_percentile` - The upper bound of the interval as a percentile (0.0 to 1.0).
///
/// # Returns
///
/// Returns the proportion of data points within the specified interval.
///
/// # Errors
///
/// Returns an `Error` if there's an issue calculating the percentiles.
fn calculate_within_interval(
&self,
data: &[f64],
lower_percentile: f64,
upper_percentile: f64,
) -> Result<f64> {
let lower_bound = self
.percentile_estimate(lower_percentile)
.map_err(|e| Error::FitQualityError(format!("Error calculating lower bound: {}", e)))?;
let upper_bound = self
.percentile_estimate(upper_percentile)
.map_err(|e| Error::FitQualityError(format!("Error calculating upper bound: {}", e)))?;
let count_within = data
.iter()
.filter(|&&x| x >= lower_bound && x <= upper_bound)
.count();
Ok(count_within as f64 / data.len() as f64)
}
}
+22
View File
@@ -0,0 +1,22 @@
/// Represents the quality of fit for an estimation distribution.
#[derive(Debug, Clone, PartialEq)]
pub struct EstimationFitQuality {
/// Mean Absolute Percentage Error (MAPE)
pub mean_absolute_percentage_error: f64,
/// Root Mean Square Error (RMSE)
pub root_mean_square_error: f64,
/// Mean of the fitted distribution
pub distribution_mean: f64,
/// Median of the fitted distribution
pub distribution_median: f64,
/// Mode of the fitted distribution
pub distribution_mode: f64,
/// Proportion of data within 50% confidence interval (interquartile range)
pub within_50_percent: f64,
/// Proportion of data within 68% confidence interval (approx. 1 std dev)
pub within_68_percent: f64,
/// Proportion of data within 95% confidence interval (approx. 2 std dev)
pub within_95_percent: f64,
/// The number of data points used in the calculation
pub sample_size: usize,
}
+74
View File
@@ -0,0 +1,74 @@
//! # Distimate: Estimation Distributions for Project Planning and Risk Analysis
//!
//! `distimate` is a Rust crate that provides a set of probability distributions
//! commonly used in estimation, along with a trait for working with these
//! distributions in an estimation context. It's designed to assist in project
//! planning, risk analysis, and decision-making under uncertainty.
//!
//! ## Features
//!
//! - A variety of estimation-focused probability distributions
//! - A unified `EstimationDistribution` trait for working with these distributions
//! - Tools for calculating confidence intervals, risk assessments, and fit quality
//! - Easy-to-use prelude module for convenient imports
//!
//! ## Quick Start
//!
//! To get started with `distimate`, add it to your `Cargo.toml`:
//!
//! ```toml
//! [dependencies]
//! distimate = "0.1.0" # replace with the current version
//! ```
//!
//! Then, in your Rust file:
//!
//! ```rust
//! use distimate::prelude::*;
//! use distimate::Pert;
//!
//! fn main() -> Result<()> {
//! let pert = Pert::new(1.0, 2.0, 3.0)?;
//! println!("Expected value: {}", pert.expected_value());
//! println!("95% confidence interval: {:?}", pert.confidence_interval(0.95));
//! Ok(())
//! }
//! ```
//!
//! ## Available Distributions
//!
//! - PERT (Program Evaluation and Review Technique)
//! - Triangular
//! - Normal
//!
//! ## The EstimationDistribution Trait
//!
//! The `EstimationDistribution` trait provides a unified interface for working
//! with probability distributions in an estimation context. It includes methods
//! for calculating percentiles, confidence intervals, and assessing risk.
//!
//! ## Error Handling
//!
//! This crate uses a custom `Error` type and `Result` alias for error handling.
//! Most methods that can fail will return a `Result<T, Error>`.
//!
//! ## Prelude
//!
//! For convenience, commonly used items are re-exported in the `prelude` module.
//! You can import all of these at once with `use distimate::prelude::*;`.
// Module declarations
mod distributions;
mod error;
mod estimation_distribution;
mod fit_quality;
pub mod prelude;
mod result;
// Re-exports
pub use error::Error;
pub use estimation_distribution::EstimationDistribution;
pub use fit_quality::EstimationFitQuality;
pub use result::Result;
pub use distributions::{Normal, Pert, Triangular};
+8
View File
@@ -0,0 +1,8 @@
pub use crate::{Error, EstimationDistribution, EstimationFitQuality, Result};
pub use rand::distributions::Distribution as RandDistribution;
pub use statrs::{
distribution::{Beta, Continuous, ContinuousCDF},
statistics::{Distribution, Max, Median, Min, Mode},
};
+3
View File
@@ -0,0 +1,3 @@
use crate::Error;
pub type Result<T> = std::result::Result<T, Error>;