use scirs2_core::numeric::Float;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::fmt::Debug;
use super::benchmark::TestResult;
use super::types::*;
use crate::error::{OptimError, Result};
use crate::numeric::{count_as, scalar_as};
use crate::EvaluationMetric;
#[derive(Debug)]
pub struct StatisticalAnalyzer<T: Float + Debug + Send + Sync + 'static> {
statistical_tests: Vec<StatisticalTest<T>>,
analysis_methods: Vec<AnalysisMethod>,
significance_thresholds: HashMap<String, T>,
multiple_comparison: MultipleComparisonCorrection,
}
#[derive(Debug, Clone)]
pub struct AnalysisReport<T: Float + Debug + Send + Sync + 'static> {
pub descriptive: Option<DescriptiveStats<T>>,
pub lag1_autocorrelation: Option<T>,
}
#[derive(Debug)]
pub struct StatisticalTest<T: Float + Debug + Send + Sync + 'static> {
pub name: String,
pub test_type: StatisticalTestType,
pub test_statistic: T,
pub p_value: T,
pub effect_size: T,
pub confidence_interval: (T, T),
}
impl<T: Float + Debug + Default + std::iter::Sum + Send + Sync> StatisticalAnalyzer<T> {
pub(crate) fn new() -> Self {
Self {
statistical_tests: Vec::new(),
analysis_methods: vec![
AnalysisMethod::DescriptiveStatistics,
AnalysisMethod::CorrelationAnalysis,
],
significance_thresholds: {
let mut thresholds = HashMap::new();
thresholds.insert(
"alpha".to_string(),
scalar_as::<T>(0.05, "default significance level")
.unwrap_or_else(|_| T::zero()),
);
thresholds
},
multiple_comparison: MultipleComparisonCorrection::BenjaminiHochberg,
}
}
pub fn analysis_methods(&self) -> &[AnalysisMethod] {
&self.analysis_methods
}
pub fn set_analysis_methods(&mut self, methods: Vec<AnalysisMethod>) -> Result<()> {
for method in &methods {
match method {
AnalysisMethod::DescriptiveStatistics | AnalysisMethod::CorrelationAnalysis => {}
other => {
return Err(OptimError::NotImplemented(format!(
"StatisticalAnalyzer implements DescriptiveStatistics and \
CorrelationAnalysis; {:?} has no implementation here",
other
)))
}
}
}
self.analysis_methods = methods;
Ok(())
}
pub fn analyze(&self, values: &[T]) -> Result<AnalysisReport<T>> {
let mut report = AnalysisReport {
descriptive: None,
lag1_autocorrelation: None,
};
for method in &self.analysis_methods {
match method {
AnalysisMethod::DescriptiveStatistics => {
report.descriptive = Some(self.compute_descriptive_stats(values)?);
}
AnalysisMethod::CorrelationAnalysis => {
report.lag1_autocorrelation = self.lag1_autocorrelation(values)?;
}
other => {
return Err(OptimError::NotImplemented(format!(
"StatisticalAnalyzer has no implementation for {:?}",
other
)))
}
}
}
Ok(report)
}
fn lag1_autocorrelation(&self, values: &[T]) -> Result<Option<T>> {
if values.len() < 2 {
return Ok(None);
}
let count: T = count_as(values.len(), "sample size")?;
let mean = values.iter().cloned().sum::<T>() / count;
let mut denominator = T::zero();
for &value in values {
let centered = value - mean;
denominator = denominator + centered * centered;
}
if denominator <= T::zero() {
return Ok(None);
}
let mut numerator = T::zero();
for window in values.windows(2) {
numerator = numerator + (window[0] - mean) * (window[1] - mean);
}
Ok(Some(numerator / denominator))
}
pub fn tests(&self) -> &[StatisticalTest<T>] {
&self.statistical_tests
}
pub fn multiple_comparison(&self) -> &MultipleComparisonCorrection {
&self.multiple_comparison
}
pub fn set_multiple_comparison(&mut self, correction: MultipleComparisonCorrection) {
self.multiple_comparison = correction;
}
pub fn adjusted_p_values(&self) -> Result<Vec<T>> {
let m = self.statistical_tests.len();
if m == 0 {
return Ok(Vec::new());
}
let raw: Vec<T> = self
.statistical_tests
.iter()
.map(|test| test.p_value)
.collect();
let count: T = count_as(m, "number of statistical tests")?;
let adjusted = match self.multiple_comparison {
MultipleComparisonCorrection::None => raw,
MultipleComparisonCorrection::Bonferroni => {
raw.into_iter().map(|p| (p * count).min(T::one())).collect()
}
MultipleComparisonCorrection::Sidak => raw
.into_iter()
.map(|p| {
let complement = (T::one() - p).max(T::zero());
(T::one() - complement.powf(count)).min(T::one())
})
.collect(),
MultipleComparisonCorrection::HolmBonferroni => Self::step_down(&raw, |rank, p| {
let factor: T = count_as((m - rank).max(1), "Holm rank factor")?;
Ok((p * factor).min(T::one()))
})?,
MultipleComparisonCorrection::BenjaminiHochberg => {
Self::step_up(&raw, count, T::one())?
}
MultipleComparisonCorrection::BenjaminiYekutieli => {
let mut harmonic = T::zero();
for k in 1..=m {
let denominator: T = count_as(k, "harmonic-series term")?;
harmonic = harmonic + T::one() / denominator;
}
Self::step_up(&raw, count, harmonic)?
}
};
Ok(adjusted)
}
pub fn significant_tests(&self, alpha: T) -> Result<Vec<usize>> {
let adjusted = self.adjusted_p_values()?;
Ok(adjusted
.into_iter()
.enumerate()
.filter(|(_, p)| *p <= alpha)
.map(|(index, _)| index)
.collect())
}
fn step_down<F>(raw: &[T], scale: F) -> Result<Vec<T>>
where
F: Fn(usize, T) -> Result<T>,
{
let mut order: Vec<usize> = (0..raw.len()).collect();
order.sort_by(|&a, &b| raw[a].partial_cmp(&raw[b]).unwrap_or(Ordering::Equal));
let mut adjusted = vec![T::zero(); raw.len()];
let mut running = T::zero();
for (rank, &index) in order.iter().enumerate() {
let scaled = scale(rank, raw[index])?;
running = running.max(scaled);
adjusted[index] = running.min(T::one());
}
Ok(adjusted)
}
fn step_up(raw: &[T], count: T, factor: T) -> Result<Vec<T>> {
let mut order: Vec<usize> = (0..raw.len()).collect();
order.sort_by(|&a, &b| raw[b].partial_cmp(&raw[a]).unwrap_or(Ordering::Equal));
let mut adjusted = vec![T::zero(); raw.len()];
let mut running = T::one();
for (position, &index) in order.iter().enumerate() {
let rank = raw.len() - position;
let rank_value: T = count_as(rank, "Benjamini rank")?;
let scaled = raw[index] * count * factor / rank_value;
running = running.min(scaled);
adjusted[index] = running.min(T::one()).max(T::zero());
}
Ok(adjusted)
}
pub(crate) fn compute_confidence_intervals(
&self,
results: &[TestResult<T>],
) -> Result<HashMap<EvaluationMetric, (T, T)>> {
let mut intervals = HashMap::new();
if !results.is_empty() {
let scores: Vec<T> = results.iter().map(|r| r.score).collect();
let count: T = count_as(scores.len(), "confidence-interval sample size")?;
let mean = scores.iter().cloned().sum::<T>() / count;
let std_dev = if scores.len() > 1 {
let degrees_of_freedom: T =
count_as(scores.len() - 1, "confidence-interval degrees of freedom")?;
let variance =
scores.iter().map(|&s| (s - mean) * (s - mean)).sum::<T>() / degrees_of_freedom;
variance.sqrt()
} else {
T::zero()
};
let z: T = scalar_as(1.96, "95% normal quantile")?;
let margin = std_dev * z / count.sqrt();
intervals.insert(
EvaluationMetric::FinalPerformance,
(mean - margin, mean + margin),
);
}
Ok(intervals)
}
pub fn add_test(&mut self, test: StatisticalTest<T>) {
self.statistical_tests.push(test);
}
pub fn get_threshold(&self, name: &str) -> Option<T> {
self.significance_thresholds.get(name).copied()
}
pub fn set_threshold(&mut self, name: String, value: T) {
self.significance_thresholds.insert(name, value);
}
pub fn compute_descriptive_stats(&self, values: &[T]) -> Result<DescriptiveStats<T>> {
if values.is_empty() {
return Ok(DescriptiveStats {
mean: T::zero(),
median: T::zero(),
std_dev: T::zero(),
min: T::zero(),
max: T::zero(),
count: 0,
});
}
let count = values.len();
let count_t: T = count_as(count, "descriptive sample size")?;
let mean = sum_of(values) / count_t;
let variance = values.iter().map(|&v| (v - mean) * (v - mean)).sum::<T>() / count_t;
let std_dev = variance.sqrt();
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let min = sorted.first().copied().unwrap_or(T::zero());
let max = sorted.last().copied().unwrap_or(T::zero());
let median = if count.is_multiple_of(2) {
(sorted[count / 2 - 1] + sorted[count / 2])
/ scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::one())
} else {
sorted[count / 2]
};
Ok(DescriptiveStats {
mean,
median,
std_dev,
min,
max,
count,
})
}
}
#[derive(Debug, Clone)]
pub struct DescriptiveStats<T: Float + Debug + Send + Sync + 'static> {
pub mean: T,
pub median: T,
pub std_dev: T,
pub min: T,
pub max: T,
pub count: usize,
}
fn sum_of<T: Float + std::iter::Sum>(values: &[T]) -> T {
values.iter().cloned().sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_with_p(name: &str, p_value: f64) -> StatisticalTest<f64> {
StatisticalTest {
name: name.to_string(),
test_type: StatisticalTestType::TTest,
test_statistic: 0.0,
p_value,
effect_size: 0.0,
confidence_interval: (0.0, 0.0),
}
}
fn analyzer_with(ps: &[f64]) -> StatisticalAnalyzer<f64> {
let mut analyzer = StatisticalAnalyzer::<f64>::new();
for (index, &p) in ps.iter().enumerate() {
analyzer.add_test(test_with_p(&format!("t{index}"), p));
}
analyzer
}
#[test]
fn no_correction_returns_the_raw_p_values() {
let mut analyzer = analyzer_with(&[0.01, 0.04, 0.20]);
analyzer.set_multiple_comparison(MultipleComparisonCorrection::None);
let adjusted = analyzer.adjusted_p_values().expect("adjustment");
assert_eq!(adjusted, vec![0.01, 0.04, 0.20]);
}
#[test]
fn bonferroni_and_sidak_multiply_by_the_family_size() {
let mut analyzer = analyzer_with(&[0.01, 0.04, 0.20]);
analyzer.set_multiple_comparison(MultipleComparisonCorrection::Bonferroni);
let bonferroni = analyzer.adjusted_p_values().expect("adjustment");
assert!((bonferroni[0] - 0.03).abs() < 1e-12);
assert!((bonferroni[1] - 0.12).abs() < 1e-12);
assert!((bonferroni[2] - 0.6).abs() < 1e-12);
analyzer.set_multiple_comparison(MultipleComparisonCorrection::Sidak);
let sidak = analyzer.adjusted_p_values().expect("adjustment");
for (s, b) in sidak.iter().zip(bonferroni.iter()) {
assert!(*s <= *b + 1e-12, "sidak {s} exceeded bonferroni {b}");
assert!((0.0..=1.0).contains(s));
}
}
#[test]
fn holm_is_monotone_and_less_conservative_than_bonferroni() {
let mut analyzer = analyzer_with(&[0.20, 0.01, 0.04]);
analyzer.set_multiple_comparison(MultipleComparisonCorrection::Bonferroni);
let bonferroni = analyzer.adjusted_p_values().expect("adjustment");
analyzer.set_multiple_comparison(MultipleComparisonCorrection::HolmBonferroni);
let holm = analyzer.adjusted_p_values().expect("adjustment");
assert!((holm[1] - 0.03).abs() < 1e-12);
assert!((holm[2] - 0.08).abs() < 1e-12);
assert!((holm[0] - 0.20).abs() < 1e-12);
for (h, b) in holm.iter().zip(bonferroni.iter()) {
assert!(*h <= *b + 1e-12);
}
}
#[test]
fn benjamini_hochberg_is_monotone_and_yekutieli_is_stricter() {
let mut analyzer = analyzer_with(&[0.001, 0.008, 0.039, 0.041, 0.042]);
analyzer.set_multiple_comparison(MultipleComparisonCorrection::BenjaminiHochberg);
let bh = analyzer.adjusted_p_values().expect("adjustment");
for window in bh.windows(2) {
assert!(
window[0] <= window[1] + 1e-12,
"BH broke monotonicity: {bh:?}"
);
}
assert!((bh[0] - 0.005).abs() < 1e-12);
analyzer.set_multiple_comparison(MultipleComparisonCorrection::BenjaminiYekutieli);
let by = analyzer.adjusted_p_values().expect("adjustment");
for (y, h) in by.iter().zip(bh.iter()) {
assert!(*y >= *h - 1e-12, "Yekutieli must not be looser than BH");
assert!((0.0..=1.0).contains(y));
}
}
#[test]
fn significance_uses_the_adjusted_values_not_the_raw_ones() {
let mut analyzer = analyzer_with(&[0.02, 0.03, 0.04]);
analyzer.set_multiple_comparison(MultipleComparisonCorrection::None);
assert_eq!(
analyzer.significant_tests(0.05).expect("significance"),
vec![0, 1, 2]
);
analyzer.set_multiple_comparison(MultipleComparisonCorrection::Bonferroni);
assert!(analyzer
.significant_tests(0.05)
.expect("significance")
.is_empty());
assert_eq!(analyzer.tests().len(), 3);
assert_eq!(
analyzer.multiple_comparison(),
&MultipleComparisonCorrection::Bonferroni
);
}
#[test]
fn an_empty_family_has_no_adjusted_values() {
let analyzer = StatisticalAnalyzer::<f64>::new();
assert!(analyzer.adjusted_p_values().expect("adjustment").is_empty());
assert!(analyzer
.significant_tests(0.05)
.expect("significance")
.is_empty());
}
#[test]
fn analyze_runs_exactly_the_enabled_methods() {
let mut analyzer = StatisticalAnalyzer::<f64>::new();
let series = [1.0, 2.0, 3.0, 4.0, 5.0];
let report = analyzer.analyze(&series).expect("analysis");
let descriptive = report.descriptive.expect("descriptive statistics enabled");
assert_eq!(descriptive.count, 5);
assert!((descriptive.mean - 3.0).abs() < 1e-12);
let autocorrelation = report
.lag1_autocorrelation
.expect("correlation analysis enabled");
assert!(
(autocorrelation - 0.4).abs() < 1e-12,
"got {autocorrelation}"
);
let alternating = [1.0, -1.0, 1.0, -1.0, 1.0, -1.0];
let flipped = analyzer
.analyze(&alternating)
.expect("analysis")
.lag1_autocorrelation
.expect("correlation analysis enabled");
assert!(flipped < 0.0, "got {flipped}");
analyzer
.set_analysis_methods(vec![AnalysisMethod::DescriptiveStatistics])
.expect("descriptive statistics are implemented");
let report = analyzer.analyze(&series).expect("analysis");
assert!(report.descriptive.is_some());
assert!(
report.lag1_autocorrelation.is_none(),
"a disabled analysis must not run"
);
}
#[test]
fn an_unimplemented_analysis_is_rejected_not_silently_skipped() {
let mut analyzer = StatisticalAnalyzer::<f64>::new();
let err = analyzer
.set_analysis_methods(vec![AnalysisMethod::PrincipalComponentAnalysis])
.expect_err("PCA has no implementation here");
assert!(matches!(err, OptimError::NotImplemented(_)));
assert_eq!(analyzer.analysis_methods().len(), 2);
}
#[test]
fn autocorrelation_is_undefined_for_short_or_constant_samples() {
let analyzer = StatisticalAnalyzer::<f64>::new();
assert!(analyzer
.analyze(&[])
.expect("analysis")
.lag1_autocorrelation
.is_none());
assert!(analyzer
.analyze(&[1.0])
.expect("analysis")
.lag1_autocorrelation
.is_none());
assert!(
analyzer
.analyze(&[2.0, 2.0, 2.0])
.expect("analysis")
.lag1_autocorrelation
.is_none(),
"a constant series has no autocorrelation to report"
);
}
#[test]
fn confidence_intervals_survive_degenerate_samples() {
let analyzer = StatisticalAnalyzer::<f64>::new();
assert!(analyzer
.compute_confidence_intervals(&[])
.expect("intervals")
.is_empty());
}
}