Files
distimate/src/distributions/normal.rs
T
2024-08-27 12:48:10 -06:00

743 lines
26 KiB
Rust

//! 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);
}
}
}