use color_eyre::Result;
use color_eyre::eyre::Report;
use polars::polars_compute::rolling::QuantileMethod;
use polars::prelude::*;
use std::collections::HashMap;
use std::ops::Range;
pub fn collect_lazy(
lf: LazyFrame,
use_streaming: bool,
) -> std::result::Result<DataFrame, PolarsError> {
#[cfg(feature = "streaming")]
{
if may_stream(&lf, use_streaming) && !sorts_by_one_wide_key(&lf) {
lf.collect_with_engine(Engine::Streaming)
.map(|result| result.unwrap_single())
} else {
lf.collect()
}
}
#[cfg(not(feature = "streaming"))]
{
let _ = use_streaming; lf.collect()
}
}
pub fn may_stream(lf: &LazyFrame, wanted: bool) -> bool {
use polars::lazy::dsl::{DslPlan, FileScanDsl};
wanted
&& !lf.logical_plan.into_iter().any(|node| match node {
DslPlan::Scan { scan_type, .. } => {
matches!(scan_type.as_ref(), FileScanDsl::Anonymous { .. })
}
_ => false,
})
}
#[cfg(feature = "streaming")]
fn sorts_by_one_wide_key(lf: &LazyFrame) -> bool {
use polars::lazy::dsl::DslPlan;
let wide_sort = |node: &DslPlan| match node {
DslPlan::Sort {
input,
by_column,
sort_options,
..
} if by_column.len() == 1 && !sort_options.maintain_order => {
LazyFrame::from((**input).clone())
.select([by_column[0].clone()])
.collect_schema()
.ok()
.and_then(|schema| schema.get_at_index(0).map(|(_, dtype)| dtype.clone()))
.is_some_and(|dtype| dtype.is_decimal() || dtype == DataType::Int128)
}
_ => false,
};
lf.logical_plan.into_iter().any(|node| match node {
DslPlan::Slice {
input, offset: 0, ..
} => input.into_iter().any(wide_sort),
DslPlan::Sort { sort_options, .. } => sort_options.limit.is_some() && wide_sort(node),
_ => false,
})
}
#[derive(Clone)]
pub struct ColumnStatistics {
pub name: String,
pub count: usize,
pub null_count: usize,
pub numeric_stats: Option<NumericStatistics>,
pub categorical_stats: Option<CategoricalStatistics>,
pub temporal_stats: Option<TemporalStatistics>,
}
#[derive(Clone)]
pub struct NumericStatistics {
pub mean: f64,
pub std: f64,
pub min: f64,
pub max: f64,
pub median: f64,
pub q25: f64,
pub q75: f64,
pub percentiles: HashMap<u8, f64>, pub skewness: f64,
pub kurtosis: f64,
}
#[derive(Clone)]
pub struct CategoricalStatistics {
pub min: Option<String>, pub max: Option<String>, }
#[derive(Clone, Default)]
pub struct TemporalStatistics {
pub mean: Option<String>,
pub min: Option<String>,
pub q25: Option<String>,
pub median: Option<String>,
pub q75: Option<String>,
pub max: Option<String>,
}
#[derive(Clone)]
pub struct DistributionAnalysis {
pub column_name: String,
pub distribution_type: DistributionType,
pub confidence: f64,
pub characteristics: DistributionCharacteristics,
pub outliers: OutlierAnalysis,
pub percentiles: PercentileBreakdown,
pub sorted_sample_values: Vec<f64>,
pub fits: Vec<(
DistributionType,
crate::analysis::distribution_fit::FitOutcome,
)>,
pub qq: Vec<(DistributionType, Vec<f64>)>,
pub histogram: HistogramCache,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HistogramKey {
pub family: DistributionType,
pub bins: usize,
pub log: bool,
pub range: (f64, f64),
pub samples: usize,
}
#[derive(Debug)]
pub struct Histogram {
pub counts: Vec<usize>,
pub top: f64,
pub curve: Vec<(f64, f64)>,
}
#[derive(Debug, Default)]
pub struct HistogramCache(std::sync::Mutex<Option<(HistogramKey, std::sync::Arc<Histogram>)>>);
impl Clone for HistogramCache {
fn clone(&self) -> Self {
Self::default()
}
}
#[cfg(test)]
thread_local! {
pub(crate) static HISTOGRAMS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
impl DistributionAnalysis {
pub fn fit(
&self,
family: DistributionType,
) -> Option<&crate::analysis::distribution_fit::FitOutcome> {
self.fits
.iter()
.find(|(fitted, _)| *fitted == family)
.map(|(_, outcome)| outcome)
}
pub fn qq(&self, family: DistributionType) -> Option<&[f64]> {
self.qq
.iter()
.find(|(fitted, _)| *fitted == family)
.map(|(_, quantiles)| quantiles.as_slice())
}
pub fn histogram(&self, key: HistogramKey) -> std::sync::Arc<Histogram> {
let mut cache = self
.histogram
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((cached, histogram)) = cache.as_ref()
&& *cached == key
{
return std::sync::Arc::clone(histogram);
}
#[cfg(test)]
HISTOGRAMS_BUILT.with(|built| built.set(built.get() + 1));
let histogram = std::sync::Arc::new(self.build_histogram(key));
*cache = Some((key, std::sync::Arc::clone(&histogram)));
histogram
}
fn build_histogram(&self, key: HistogramKey) -> Histogram {
let HistogramKey {
family,
bins,
log,
range: (low, high),
samples,
} = key;
let sorted = &self.sorted_sample_values;
let n = sorted.len() as f64;
let edges: Vec<f64> = if log {
let (log_low, log_high) = (low.ln(), high.ln());
let width = (log_high - log_low) / bins as f64;
(0..=bins)
.map(|i| (log_low + i as f64 * width).exp())
.collect()
} else {
let width = (high - low) / bins as f64;
(0..=bins).map(|i| low + i as f64 * width).collect()
};
let below = |edge: f64| sorted.partition_point(|value| *value < edge);
let counts: Vec<usize> = (0..bins)
.map(|i| {
let end = if i + 1 == bins {
sorted.partition_point(|value| *value <= edges[i + 1])
} else {
below(edges[i + 1])
};
end.saturating_sub(below(edges[i]))
})
.collect();
let fitted = self
.fit(family)
.and_then(|outcome| outcome.test())
.map(|test| &test.fitted);
let expected: Vec<f64> = match fitted {
Some(fitted) => edges
.windows(2)
.enumerate()
.map(|(i, edge)| {
let upper = if i + 1 == bins {
fitted.cdf(edge[1])
} else {
fitted.cdf_below(edge[1])
};
(upper - fitted.cdf_below(edge[0])).max(0.0) * n
})
.collect(),
None => vec![0.0; bins],
};
let tallest = counts.iter().copied().max().unwrap_or(0);
let expected_top = expected.iter().copied().fold(0.0, f64::max);
let top = (tallest.max(expected_top.ceil() as usize).max(1) as f64 / 2.0).ceil() * 2.0;
let height = |count: f64| count / top * 100.0;
let curve = match fitted {
Some(fitted) if !fitted.discrete() && !log && high > low => {
let bin_width = (high - low) / bins as f64;
(0..samples)
.map(|i| {
let x = low + i as f64 / (samples - 1) as f64 * (high - low);
(x, height(fitted.density(x) * bin_width * n))
})
.filter(|(_, y)| y.is_finite())
.collect()
}
Some(_) => edges
.windows(2)
.zip(&expected)
.map(|(edge, count)| {
let center = if log {
(edge[0] * edge[1]).sqrt().ln()
} else {
(edge[0] + edge[1]) / 2.0
};
(center, height(*count))
})
.collect(),
None => Vec::new(),
};
Histogram { counts, top, curve }
}
}
#[derive(Clone)]
pub struct DistributionCharacteristics {
pub shapiro_wilk_stat: Option<f64>,
pub shapiro_wilk_pvalue: Option<f64>,
pub skewness: f64,
pub kurtosis: f64,
pub mean: f64,
pub median: f64,
pub std_dev: f64,
pub coefficient_of_variation: f64,
}
#[derive(Clone)]
pub struct OutlierAnalysis {
pub total_count: usize,
pub percentage: f64,
pub iqr_count: usize,
pub zscore_count: usize,
}
#[derive(Clone)]
pub struct PercentileBreakdown {
pub p25: f64,
pub p50: f64,
pub p75: f64,
pub p99: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CorrelationMethod {
#[default]
Pearson,
Spearman,
}
impl CorrelationMethod {
pub fn toggled(self) -> Self {
match self {
Self::Pearson => Self::Spearman,
Self::Spearman => Self::Pearson,
}
}
}
#[derive(Clone)]
pub struct CorrelationMatrix {
pub columns: Vec<String>, pub correlations: Vec<Vec<f64>>, pub p_values: Option<Vec<Vec<f64>>>, pub sample_sizes: Vec<Vec<usize>>, pub rank_correlations: Option<Vec<Vec<f64>>>,
pub rank_p_values: Option<Vec<Vec<f64>>>,
}
pub const RANK_VALUES: usize = 64 * 1024 * 1024;
impl CorrelationMatrix {
pub fn coefficient(&self, method: CorrelationMethod, row: usize, col: usize) -> f64 {
let matrix = match method {
CorrelationMethod::Pearson => Some(&self.correlations),
CorrelationMethod::Spearman => self.rank_correlations.as_ref(),
};
matrix
.and_then(|m| m.get(row))
.and_then(|r| r.get(col))
.copied()
.unwrap_or(f64::NAN)
}
pub fn p_value(&self, method: CorrelationMethod, row: usize, col: usize) -> Option<f64> {
let matrix = match method {
CorrelationMethod::Pearson => self.p_values.as_ref(),
CorrelationMethod::Spearman => self.rank_p_values.as_ref(),
};
matrix.and_then(|m| m.get(row)?.get(col).copied())
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DistributionType {
#[default]
Normal,
LogNormal,
Uniform,
PowerLaw,
Exponential,
Beta,
Gamma,
ChiSquared,
StudentsT,
Poisson,
Bernoulli,
Binomial,
Geometric,
Weibull,
Constant,
Unknown,
}
impl std::fmt::Display for DistributionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DistributionType::Normal => write!(f, "Normal"),
DistributionType::LogNormal => write!(f, "Log-Normal"),
DistributionType::Uniform => write!(f, "Uniform"),
DistributionType::PowerLaw => write!(f, "Power Law"),
DistributionType::Exponential => write!(f, "Exponential"),
DistributionType::Beta => write!(f, "Beta"),
DistributionType::Gamma => write!(f, "Gamma"),
DistributionType::ChiSquared => write!(f, "Chi-Squared"),
DistributionType::StudentsT => write!(f, "Student's t"),
DistributionType::Poisson => write!(f, "Poisson"),
DistributionType::Bernoulli => write!(f, "Bernoulli"),
DistributionType::Binomial => write!(f, "Binomial"),
DistributionType::Geometric => write!(f, "Geometric"),
DistributionType::Weibull => write!(f, "Weibull"),
DistributionType::Constant => write!(f, "Constant"),
DistributionType::Unknown => write!(f, "No clear fit"),
}
}
}
#[derive(Clone)]
pub struct AnalysisResults {
pub column_statistics: Vec<ColumnStatistics>,
pub total_rows: usize,
pub sample_size: Option<usize>,
pub per_value: Option<usize>,
pub correlation_matrix: Option<CorrelationMatrix>,
pub distribution_analyses: Vec<DistributionAnalysis>,
}
#[derive(Debug, Clone, Copy)]
pub struct ComputeOptions {
pub include_distribution_info: bool,
pub include_distribution_analyses: bool,
pub include_correlation_matrix: bool,
pub include_skewness_kurtosis_outliers: bool,
pub polars_streaming: bool,
}
impl Default for ComputeOptions {
fn default() -> Self {
Self {
include_distribution_info: false,
include_distribution_analyses: false,
include_correlation_matrix: false,
include_skewness_kurtosis_outliers: false,
polars_streaming: true,
}
}
}
fn temporal_stats_of(series: &Series) -> Result<Option<TemporalStatistics>> {
if !is_temporal_type(series.dtype()) {
return Ok(None);
}
let frame = DataFrame::new_infer_height(vec![series.clone().into()])?;
let schema = frame.schema().clone();
let agg_df = frame
.lazy()
.select(build_describe_aggregation_exprs(&schema))
.collect()?;
Ok(parse_describe_agg_row(&agg_df, &schema)
.pop()
.and_then(|stats| stats.temporal_stats))
}
fn get_value_str(df: &DataFrame, col_name: &str, row: usize) -> Option<String> {
match df.column(col_name).ok()?.get(row).ok()? {
AnyValue::Null => None,
v => Some(crate::exact::str_value(&v).to_string()),
}
}
pub fn compute_statistics_for_sample(
lf: &LazyFrame,
sample: &crate::analysis::sampling::Sample,
known_total: Option<usize>,
options: ComputeOptions,
) -> Result<AnalysisResults> {
let schema = lf.clone().collect_schema()?;
let use_streaming = options.polars_streaming;
let rows = crate::analysis::sampling::read(lf, sample, known_total, use_streaming)?;
let total_rows = rows.total_rows;
let actual_sample_size = rows.sample_size;
let per_value = rows.per_value.as_ref().map(|per_value| per_value.kept);
let df = rows.df;
let mut column_statistics = Vec::new();
let mut distribution_analyses = Vec::new();
for (name, dtype) in schema.iter() {
let col = df.column(name)?;
let series = col.as_materialized_series();
let count = series.len();
let null_count = series.null_count();
let numeric = if is_numeric_type(dtype) {
Some(NumericColumn::of(series)?)
} else {
None
};
let numeric_stats = numeric
.as_ref()
.map(|column| compute_numeric_stats(column, options.include_skewness_kurtosis_outliers))
.transpose()?;
let categorical_stats = if is_categorical_type(dtype) {
Some(compute_categorical_stats(series)?)
} else {
None
};
if options.include_distribution_info
&& options.include_distribution_analyses
&& null_count < count
&& let (Some(column), Some(stats)) = (&numeric, &numeric_stats)
{
distribution_analyses.push(distribution_analysis(
name,
column,
stats,
actual_sample_size.unwrap_or(count),
));
}
column_statistics.push(ColumnStatistics {
name: name.to_string(),
count,
null_count,
numeric_stats,
categorical_stats,
temporal_stats: temporal_stats_of(series)?,
});
}
let correlation_matrix = if options.include_correlation_matrix {
compute_correlation_matrix(&df).ok()
} else {
None
};
Ok(AnalysisResults {
column_statistics,
total_rows,
sample_size: actual_sample_size,
per_value,
correlation_matrix,
distribution_analyses,
})
}
pub fn analysis_results_from_describe(
column_statistics: Vec<ColumnStatistics>,
total_rows: usize,
sample_size: Option<usize>,
) -> AnalysisResults {
AnalysisResults {
column_statistics,
total_rows,
sample_size,
per_value: None,
correlation_matrix: None,
distribution_analyses: Vec::new(),
}
}
fn build_describe_aggregation_exprs(schema: &Schema) -> Vec<Expr> {
let mut exprs = Vec::new();
for (name, dtype) in schema.iter() {
let name = name.as_str();
let prefix = format!("{}::", name);
exprs.push(col(name).count().alias(format!("{}count", prefix)));
exprs.push(
col(name)
.null_count()
.alias(format!("{}null_count", prefix)),
);
if is_numeric_type(dtype) {
let c = col(name).cast(DataType::Float64);
exprs.push(c.clone().mean().alias(format!("{}mean", prefix)));
exprs.push(c.clone().std(1).alias(format!("{}std", prefix)));
exprs.push(c.clone().min().alias(format!("{}min", prefix)));
let numbers = c.clone().drop_nans();
exprs.push(
numbers
.clone()
.quantile(lit(0.25), QuantileMethod::Nearest)
.alias(format!("{}q25", prefix)),
);
exprs.push(
numbers
.clone()
.quantile(lit(0.5), QuantileMethod::Nearest)
.alias(format!("{}median", prefix)),
);
exprs.push(
numbers
.clone()
.quantile(lit(0.75), QuantileMethod::Nearest)
.alias(format!("{}q75", prefix)),
);
exprs.push(c.max().alias(format!("{}max", prefix)));
} else if is_categorical_type(dtype) {
exprs.push(col(name).min().alias(format!("{}min", prefix)));
exprs.push(col(name).max().alias(format!("{}max", prefix)));
} else if is_temporal_type(dtype) {
let physical = dtype.to_physical();
let back = |e: Expr| e.cast(physical.clone()).cast(dtype.clone());
let p = col(name).cast(physical.clone());
exprs.push(
back(p.clone().cast(DataType::Float64).mean()).alias(format!("{}mean", prefix)),
);
exprs.push(col(name).min().alias(format!("{}min", prefix)));
for (q, stat) in [(0.25, "q25"), (0.5, "median"), (0.75, "q75")] {
exprs.push(
back(p.clone().quantile(lit(q), QuantileMethod::Nearest))
.alias(format!("{}{}", prefix, stat)),
);
}
exprs.push(col(name).max().alias(format!("{}max", prefix)));
}
}
exprs
}
fn parse_describe_agg_row(agg_df: &DataFrame, schema: &Schema) -> Vec<ColumnStatistics> {
let row = 0usize;
let mut column_statistics = Vec::with_capacity(schema.len());
for (name, dtype) in schema.iter() {
let name_str = name.as_str();
let prefix = format!("{}::", name_str);
let count: usize = agg_df
.column(&format!("{}count", prefix))
.ok()
.map(|s| match s.get(row) {
Ok(AnyValue::UInt32(x)) => x as usize,
_ => 0,
})
.unwrap_or(0);
let null_count: usize = agg_df
.column(&format!("{}null_count", prefix))
.ok()
.map(|s| match s.get(row) {
Ok(AnyValue::UInt32(x)) => x as usize,
_ => 0,
})
.unwrap_or(0);
let numeric_stats = if is_numeric_type(dtype) {
let mean = get_f64(agg_df, &format!("{}mean", prefix), row);
let std = get_f64(agg_df, &format!("{}std", prefix), row);
let min = get_f64(agg_df, &format!("{}min", prefix), row);
let q25 = get_f64(agg_df, &format!("{}q25", prefix), row);
let median = get_f64(agg_df, &format!("{}median", prefix), row);
let q75 = get_f64(agg_df, &format!("{}q75", prefix), row);
let max = get_f64(agg_df, &format!("{}max", prefix), row);
let mut percentiles = HashMap::new();
percentiles.insert(25u8, q25);
percentiles.insert(50u8, median);
percentiles.insert(75u8, q75);
Some(NumericStatistics {
mean,
std,
min,
max,
median,
q25,
q75,
percentiles,
skewness: 0.0,
kurtosis: 3.0,
})
} else {
None
};
let categorical_stats = if is_categorical_type(dtype) {
let min = get_str(agg_df, &format!("{}min", prefix), row);
let max = get_str(agg_df, &format!("{}max", prefix), row);
Some(CategoricalStatistics { min, max })
} else {
None
};
let temporal_stats = is_temporal_type(dtype).then(|| {
let value = |stat: &str| get_value_str(agg_df, &format!("{}{}", prefix, stat), row);
TemporalStatistics {
mean: value("mean"),
min: value("min"),
q25: value("q25"),
median: value("median"),
q75: value("q75"),
max: value("max"),
}
});
column_statistics.push(ColumnStatistics {
name: name_str.to_string(),
count,
null_count,
numeric_stats,
categorical_stats,
temporal_stats,
});
}
column_statistics
}
pub fn compute_describe_from_lazy(
lf: &LazyFrame,
known_total: Option<usize>,
sample: &crate::analysis::sampling::Sample,
polars_streaming: bool,
) -> Result<AnalysisResults> {
let schema = lf.clone().collect_schema()?;
if sample.method != crate::analysis::sampling::SampleMethod::EveryRow {
let rows = crate::analysis::sampling::read(lf, sample, known_total, polars_streaming)?;
let mut results = compute_describe_single_aggregation(
&rows.df,
&schema,
rows.total_rows,
rows.sample_size,
polars_streaming,
)?;
results.per_value = rows.per_value.map(|per_value| per_value.kept);
return Ok(results);
}
let total_rows = match known_total {
Some(total) => total,
None => crate::analysis::sampling::count_rows(lf, polars_streaming)?,
};
let exprs = build_describe_aggregation_exprs(&schema);
let agg_df = collect_lazy(lf.clone().select(exprs), polars_streaming).map_err(Report::from)?;
let column_statistics = parse_describe_agg_row(&agg_df, &schema);
Ok(analysis_results_from_describe(
column_statistics,
total_rows,
None,
))
}
pub fn compute_describe_single_aggregation(
df: &DataFrame,
schema: &Schema,
total_rows: usize,
sample_size: Option<usize>,
polars_streaming: bool,
) -> Result<AnalysisResults> {
let exprs = build_describe_aggregation_exprs(schema);
let agg_df =
collect_lazy(df.clone().lazy().select(exprs), polars_streaming).map_err(Report::from)?;
let column_statistics = parse_describe_agg_row(&agg_df, schema);
Ok(analysis_results_from_describe(
column_statistics,
total_rows,
sample_size,
))
}
fn get_f64(df: &DataFrame, col_name: &str, row: usize) -> f64 {
df.column(col_name)
.ok()
.and_then(|s| {
let v = s.get(row).ok()?;
match v {
AnyValue::Float64(x) => Some(x),
AnyValue::Float32(x) => Some(x as f64),
AnyValue::Int32(x) => Some(x as f64),
AnyValue::Int64(x) => Some(x as f64),
AnyValue::UInt32(x) => Some(x as f64),
AnyValue::Null => Some(f64::NAN),
_ => None,
}
})
.unwrap_or(f64::NAN)
}
fn get_str(df: &DataFrame, col_name: &str, row: usize) -> Option<String> {
df.column(col_name).ok().and_then(|s| {
s.get(row)
.ok()
.map(|v| crate::exact::str_value(&v).to_string())
})
}
fn is_numeric_type(dtype: &DataType) -> bool {
dtype.is_numeric()
}
fn is_categorical_type(dtype: &DataType) -> bool {
matches!(dtype, DataType::String | DataType::Categorical(..))
}
fn is_temporal_type(dtype: &DataType) -> bool {
matches!(
dtype,
DataType::Date | DataType::Datetime(..) | DataType::Time | DataType::Duration(_)
)
}
struct NumericColumn {
floats: Float64Chunked,
finite: Vec<f64>,
}
impl NumericColumn {
fn of(series: &Series) -> Result<Self> {
let floats = series.cast(&DataType::Float64)?.f64()?.clone();
let finite = floats.iter().flatten().filter(|v| v.is_finite()).collect();
Ok(Self { floats, finite })
}
fn spread(&self) -> Vec<f64> {
const MAX_VALUES: usize = 10_000;
let step = self.floats.len().div_ceil(MAX_VALUES).max(1);
self.floats
.iter()
.step_by(step)
.flatten()
.filter(|v| v.is_finite())
.collect()
}
}
fn compute_numeric_stats(
column: &NumericColumn,
include_advanced: bool,
) -> Result<NumericStatistics> {
let floats = column.floats.clone().into_series();
let mean = floats.mean().unwrap_or(f64::NAN);
let std = floats.std(1).unwrap_or(f64::NAN);
let min = floats.min::<f64>()?.unwrap_or(f64::NAN);
let max = floats.max::<f64>()?.unwrap_or(f64::NAN);
let floats = floats.f64()?;
let numbers = floats.filter(&floats.is_not_nan())?;
const PERCENTILES: [u8; 7] = [1, 5, 25, 50, 75, 95, 99];
let quantiles = PERCENTILES.map(|p| f64::from(p) / 100.0);
let values = numbers.quantiles(&quantiles, QuantileMethod::Nearest)?;
let percentiles: HashMap<u8, f64> = PERCENTILES
.into_iter()
.zip(values)
.map(|(p, value)| (p, value.unwrap_or(f64::NAN)))
.collect();
let median = percentiles[&50];
let q25 = percentiles[&25];
let q75 = percentiles[&75];
let (skewness, kurtosis) = if include_advanced {
skewness_and_kurtosis(&column.finite)
} else {
(0.0, 3.0)
};
Ok(NumericStatistics {
mean,
std,
min,
max,
median,
q25,
q75,
percentiles,
skewness,
kurtosis,
})
}
fn mean_and_std(values: &[f64]) -> (f64, f64) {
let n = values.len() as f64;
if values.len() < 2 {
return (values.first().copied().unwrap_or(f64::NAN), f64::NAN);
}
let mean = values.iter().sum::<f64>() / n;
let sum_squares: f64 = values.iter().map(|v| (v - mean).powi(2)).sum();
(mean, (sum_squares / (n - 1.0)).sqrt())
}
fn skewness_and_kurtosis(values: &[f64]) -> (f64, f64) {
let count = values.len();
if count < 3 || values.iter().all(|v| *v == values[0]) {
return (0.0, 3.0);
}
let n = count as f64;
let (mean, std) = mean_and_std(values);
let (mut cubes, mut fourths) = (0.0, 0.0);
for v in values {
let z = (v - mean) / std;
let z2 = z * z;
cubes += z2 * z;
fourths += z2 * z2;
}
let skewness = n / ((n - 1.0) * (n - 2.0)) * cubes;
if count < 4 {
return (skewness, 3.0);
}
let excess = n * (n + 1.0) / ((n - 1.0) * (n - 2.0) * (n - 3.0)) * fourths
- 3.0 * (n - 1.0) * (n - 1.0) / ((n - 2.0) * (n - 3.0));
(skewness, excess + 3.0)
}
struct OutlierTest {
lower_fence: f64,
upper_fence: f64,
mean: f64,
std: f64,
}
impl OutlierTest {
fn new(values: &[f64], q25: f64, q75: f64) -> Option<Self> {
let (mean, std) = mean_and_std(values);
if q25.is_nan() || q75.is_nan() || std.is_nan() || std == 0.0 {
return None;
}
let iqr = q75 - q25;
Some(Self {
lower_fence: q25 - 1.5 * iqr,
upper_fence: q75 + 1.5 * iqr,
mean,
std,
})
}
fn beyond_fences(&self, value: f64) -> bool {
value < self.lower_fence || value > self.upper_fence
}
fn z_score(&self, value: f64) -> f64 {
(value - self.mean).abs() / self.std
}
}
const Z_THRESHOLD: f64 = 3.0;
fn compute_categorical_stats(series: &Series) -> Result<CategoricalStatistics> {
let min = if let Ok(str_series) = series.str() {
let mut min_val: Option<String> = None;
for s in str_series.iter().flatten() {
let s_str = s.to_string();
min_val = match min_val {
None => Some(s_str.clone()),
Some(ref current) if s_str < *current => Some(s_str),
Some(current) => Some(current),
};
}
min_val
} else {
None
};
let max = if let Ok(str_series) = series.str() {
let mut max_val: Option<String> = None;
for s in str_series.iter().flatten() {
let s_str = s.to_string();
max_val = match max_val {
None => Some(s_str.clone()),
Some(ref current) if s_str > *current => Some(s_str),
Some(current) => Some(current),
};
}
max_val
} else {
None
};
Ok(CategoricalStatistics { min, max })
}
const FIT_SEED: u64 = 0x5eed_d157;
struct ColumnFit {
distribution_type: DistributionType,
confidence: f64,
fits: Vec<(
DistributionType,
crate::analysis::distribution_fit::FitOutcome,
)>,
}
fn infer_distribution(values: &[f64], rows: usize) -> ColumnFit {
let unknown = ColumnFit {
distribution_type: DistributionType::Unknown,
confidence: 0.0,
fits: Vec::new(),
};
if rows < 3 || values.is_empty() {
return unknown;
}
let mean: f64 = values.iter().sum::<f64>() / values.len() as f64;
let variance: f64 =
values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (values.len() - 1) as f64;
let std = variance.sqrt();
if std == 0.0 {
return ColumnFit {
distribution_type: DistributionType::Constant,
confidence: 1.0,
fits: Vec::new(),
};
}
let counts = values.iter().all(|v| *v >= 0.0 && *v == v.floor());
let fits = crate::analysis::distribution_fit::test_all(values, FIT_SEED);
let distribution_type = crate::analysis::distribution_fit::select(&fits, counts);
let confidence = fits
.iter()
.find(|(family, _)| *family == distribution_type)
.and_then(|(_, outcome)| outcome.p_value())
.or_else(|| {
fits.iter()
.filter_map(|(_, outcome)| outcome.p_value())
.max_by(f64::total_cmp)
})
.unwrap_or(0.0);
ColumnFit {
distribution_type,
confidence,
fits,
}
}
fn approximate_shapiro_wilk(sorted: &[f64]) -> (Option<f64>, Option<f64>) {
let n = sorted.len();
if n < 3 {
return (None, None);
}
let mean: f64 = sorted.iter().sum::<f64>() / n as f64;
let variance: f64 = sorted.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
let std = variance.sqrt();
if std == 0.0 {
return (None, None);
}
let mut sum_expected_sq = 0.0;
let mut sum_data_sq = 0.0;
let mut sum_product = 0.0;
for (i, &value) in sorted.iter().enumerate() {
let p = (i as f64 + 1.0 - 0.375) / (n as f64 + 0.25);
let expected_quantile = crate::analysis::distribution_fit::normal_quantile(p);
let standardized_value = (value - mean) / std;
sum_expected_sq += expected_quantile * expected_quantile;
sum_data_sq += standardized_value * standardized_value;
sum_product += expected_quantile * standardized_value;
}
let sw_stat = if sum_expected_sq > 0.0 && sum_data_sq > 0.0 {
(sum_product * sum_product) / (sum_expected_sq * sum_data_sq)
} else {
0.0
};
let sw_stat = sw_stat.clamp(0.0, 1.0);
(Some(sw_stat), shapiro_francia_pvalue(sw_stat, n))
}
fn shapiro_francia_pvalue(w: f64, n: usize) -> Option<f64> {
if !(5..=5_000).contains(&n) {
return None;
}
if w >= 1.0 {
return Some(1.0);
}
let u = (n as f64).ln();
let v = u.ln();
let mu = -1.2725 + 1.0521 * (v - u);
let sigma = 1.0308 - 0.26758 * (v + 2.0 / u);
let z = ((1.0 - w).ln() - mu) / sigma;
Some((1.0 - crate::analysis::distribution_fit::normal_cdf(z)).clamp(0.0, 1.0))
}
fn distribution_analysis(
column_name: &str,
column: &NumericColumn,
numeric_stats: &NumericStatistics,
rows: usize,
) -> DistributionAnalysis {
let spread = column.spread();
let fit = infer_distribution(&spread, rows);
const MAX_VALUES: usize = 5_000;
let step = spread.len().div_ceil(MAX_VALUES).max(1);
let mut sorted_sample_values: Vec<f64> = spread.into_iter().step_by(step).collect();
sorted_sample_values.sort_by(f64::total_cmp);
let (sw_stat, sw_pvalue) = approximate_shapiro_wilk(&sorted_sample_values);
let coefficient_of_variation = if numeric_stats.mean != 0.0 {
numeric_stats.std / numeric_stats.mean.abs()
} else {
0.0
};
let characteristics = DistributionCharacteristics {
shapiro_wilk_stat: sw_stat,
shapiro_wilk_pvalue: sw_pvalue,
skewness: numeric_stats.skewness,
kurtosis: numeric_stats.kurtosis,
mean: numeric_stats.mean,
median: numeric_stats.median,
std_dev: numeric_stats.std,
coefficient_of_variation,
};
let qq = fit
.fits
.iter()
.filter_map(|(family, outcome)| {
let test = outcome.test()?;
Some((
*family,
crate::analysis::distribution_fit::qq_quantiles(
&test.fitted,
sorted_sample_values.len(),
),
))
})
.collect();
let outliers = compute_outlier_analysis(&column.finite, numeric_stats);
let percentiles = PercentileBreakdown {
p25: numeric_stats.q25,
p50: numeric_stats.median,
p75: numeric_stats.q75,
p99: numeric_stats
.percentiles
.get(&99)
.copied()
.unwrap_or(f64::NAN),
};
DistributionAnalysis {
column_name: column_name.to_string(),
distribution_type: fit.distribution_type,
confidence: fit.confidence,
characteristics,
outliers,
percentiles,
sorted_sample_values,
fits: fit.fits,
qq,
histogram: HistogramCache::default(),
}
}
fn compute_outlier_analysis(values: &[f64], numeric_stats: &NumericStatistics) -> OutlierAnalysis {
let mut analysis = OutlierAnalysis {
total_count: 0,
percentage: 0.0,
iqr_count: 0,
zscore_count: 0,
};
let Some(test) = OutlierTest::new(values, numeric_stats.q25, numeric_stats.q75) else {
return analysis;
};
for &value in values {
let beyond_fences = test.beyond_fences(value);
let beyond_z = test.z_score(value) > Z_THRESHOLD;
if !beyond_fences && !beyond_z {
continue;
}
analysis.total_count += 1;
analysis.iqr_count += usize::from(beyond_fences);
analysis.zscore_count += usize::from(beyond_z);
}
analysis.percentage = analysis.total_count as f64 / values.len() as f64 * 100.0;
analysis
}
pub fn compute_correlation_matrix(df: &DataFrame) -> Result<CorrelationMatrix> {
let columns = df
.schema()
.iter()
.filter(|(_, dtype)| is_numeric_type(dtype))
.count()
.max(1);
let band = CORRELATION_SCRATCH_BYTES / (columns * std::mem::size_of::<f64>());
correlation_matrix_in_bands(df, band.max(MIN_BAND_ROWS))
}
const CORRELATION_SCRATCH_BYTES: usize = 64 * 1024 * 1024;
const MIN_BAND_ROWS: usize = 1024;
const CAST_ROWS: usize = 16 * 1024;
fn correlation_matrix_in_bands(df: &DataFrame, band: usize) -> Result<CorrelationMatrix> {
let schema = df.schema();
let numeric_cols: Vec<String> = schema
.iter()
.filter(|(_, dtype)| is_numeric_type(dtype))
.map(|(name, _)| name.to_string())
.collect();
if numeric_cols.len() < 2 {
return Err(color_eyre::eyre::eyre!(
"Need at least 2 numeric columns for correlation matrix"
));
}
let series = numeric_cols
.iter()
.map(|name| Ok(df.column(name)?.as_materialized_series()))
.collect::<Result<Vec<_>>>()?;
let n = numeric_cols.len();
let rows = df.height();
let band = band.clamp(1, rows.max(1));
let mut shifts = vec![Shift::default(); n];
across_threads(
series.iter().zip(shifts.iter_mut()).collect(),
|(series, shift)| *shift = Shift::new(series),
);
let mut sums: Vec<Vec<PairSums>> = (0..n)
.map(|i| vec![PairSums::default(); n - i - 1])
.collect();
let mut bands: Vec<Vec<f64>> = (0..n).map(|_| Vec::with_capacity(band)).collect();
for start in (0..rows).step_by(band) {
let within = start..(start + band).min(rows);
across_threads(
series.iter().zip(&shifts).zip(bands.iter_mut()).collect(),
|((series, shift), values)| shift.fill(series, within.clone(), values),
);
let (bands, shifts) = (&bands, &shifts);
across_threads(sums.iter_mut().enumerate().collect(), |(i, row)| {
for (k, sums) in row.iter_mut().enumerate() {
let j = i + 1 + k;
let both = shifts[i].complete && shifts[j].complete;
sums.add_pairs(&bands[i], &bands[j], both);
}
});
}
let mut correlations = vec![vec![1.0; n]; n];
let mut p_values = vec![vec![0.0; n]; n];
let mut sample_sizes = vec![vec![0; n]; n];
for (i, row) in sums.iter().enumerate() {
for (k, sums) in row.iter().enumerate() {
let j = i + 1 + k;
let sample_size = sums.count;
sample_sizes[i][j] = sample_size;
sample_sizes[j][i] = sample_size;
let correlation = if sample_size < 3 {
f64::NAN
} else {
sums.correlation()
};
correlations[i][j] = correlation;
correlations[j][i] = correlation;
if !correlation.is_nan() {
let p_value = compute_correlation_p_value(correlation, sample_size);
p_values[i][j] = p_value;
p_values[j][i] = p_value;
}
}
}
let ranked = (rows.saturating_mul(n) <= RANK_VALUES)
.then(|| rank_correlation_matrix(&series, &sample_sizes));
let (rank_correlations, rank_p_values) = ranked.unzip();
Ok(CorrelationMatrix {
columns: numeric_cols,
correlations,
p_values: Some(p_values),
sample_sizes,
rank_correlations,
rank_p_values,
})
}
const NO_RANK: u32 = u32::MAX;
struct Ranked {
ranks: Vec<u32>,
order: Vec<u32>,
complete: bool,
}
impl Ranked {
fn new(series: &Series) -> Option<Self> {
let rows = series.len();
if rows >= (NO_RANK / 2) as usize {
return None;
}
let mut values = Vec::with_capacity(rows);
let mut row = 0u32;
for_each_float(series, 0..rows, |v| {
if let Some(v) = v.filter(|v| v.is_finite()) {
values.push((v, row));
}
row += 1;
});
values.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let mut ranks = vec![NO_RANK; rows];
let mut start = 0;
while start < values.len() {
let end = start
+ values[start..]
.iter()
.take_while(|(v, _)| *v == values[start].0)
.count();
let doubled = (start + 1 + end) as u32;
for &(_, row) in &values[start..end] {
ranks[row as usize] = doubled;
}
start = end;
}
Some(Self {
complete: values.len() == rows,
order: values.into_iter().map(|(_, row)| row).collect(),
ranks,
})
}
fn ranks_beside(&self, other: &Ranked, out: &mut Vec<u32>, kept: &mut Vec<u32>) -> usize {
out.clear();
out.resize(self.ranks.len(), NO_RANK);
kept.clear();
kept.extend(
self.order
.iter()
.copied()
.filter(|&row| other.ranks[row as usize] != NO_RANK),
);
let mut start = 0;
while start < kept.len() {
let tie = self.ranks[kept[start] as usize];
let end = start
+ kept[start..]
.iter()
.take_while(|&&row| self.ranks[row as usize] == tie)
.count();
let doubled = (start + 1 + end) as u32;
for &row in &kept[start..end] {
out[row as usize] = doubled;
}
start = end;
}
kept.len()
}
}
fn rank_correlation_matrix(
series: &[&Series],
sample_sizes: &[Vec<usize>],
) -> (Vec<Vec<f64>>, Vec<Vec<f64>>) {
let n = series.len();
let mut ranked: Vec<Option<Ranked>> = (0..n).map(|_| None).collect();
across_threads(
series.iter().zip(ranked.iter_mut()).collect(),
|(series, ranked)| *ranked = Ranked::new(series),
);
let mut rows: Vec<Vec<f64>> = (0..n).map(|i| vec![f64::NAN; n - i - 1]).collect();
let ranked = &ranked;
across_threads(rows.iter_mut().enumerate().collect(), |(i, row)| {
let (mut a, mut b, mut kept) = (Vec::new(), Vec::new(), Vec::new());
for (k, rho) in row.iter_mut().enumerate() {
let (Some(x), Some(y)) = (&ranked[i], &ranked[i + 1 + k]) else {
continue;
};
let mut sums = PairSums::default();
if x.complete && y.complete {
let mean = (x.ranks.len() + 1) as f64;
for (&rx, &ry) in x.ranks.iter().zip(&y.ranks) {
sums.add(rx as f64 - mean, ry as f64 - mean);
}
} else {
let pairs = x.ranks_beside(y, &mut a, &mut kept);
y.ranks_beside(x, &mut b, &mut kept);
let mean = (pairs + 1) as f64;
for (&rx, &ry) in a.iter().zip(&b) {
if rx != NO_RANK {
sums.add(rx as f64 - mean, ry as f64 - mean);
}
}
}
if sums.count >= 3 {
*rho = sums.correlation();
}
}
});
let mut rho = vec![vec![1.0; n]; n];
let mut p_values = vec![vec![0.0; n]; n];
for (i, row) in rows.iter().enumerate() {
for (k, &r) in row.iter().enumerate() {
let j = i + 1 + k;
rho[i][j] = r;
rho[j][i] = r;
if !r.is_nan() {
let p = compute_correlation_p_value(r, sample_sizes[i][j]);
p_values[i][j] = p;
p_values[j][i] = p;
}
}
}
(rho, p_values)
}
pub(crate) fn cores() -> usize {
static CORES: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
*CORES.get_or_init(|| std::thread::available_parallelism().map_or(1, usize::from))
}
fn across_threads<T: Send>(items: Vec<T>, work: impl Fn(T) + Sync) {
let threads = cores().min(items.len()).max(1);
let mut shares: Vec<Vec<T>> = (0..threads).map(|_| Vec::new()).collect();
for (k, item) in items.into_iter().enumerate() {
shares[k % threads].push(item);
}
let work = &work;
std::thread::scope(|scope| {
let handles: Vec<_> = shares
.into_iter()
.map(|share| scope.spawn(move || share.into_iter().for_each(work)))
.collect();
for handle in handles {
if let Err(panic) = handle.join() {
std::panic::resume_unwind(panic);
}
}
});
}
fn float_piece(series: &Series, start: usize, len: usize) -> Option<Float64Chunked> {
let piece = series
.slice(start as i64, len)
.cast(&DataType::Float64)
.ok()?;
piece.f64().ok().cloned()
}
fn for_each_float(series: &Series, rows: Range<usize>, mut f: impl FnMut(Option<f64>)) {
for start in rows.clone().step_by(CAST_ROWS) {
let len = CAST_ROWS.min(rows.end - start);
match float_piece(series, start, len) {
Some(floats) => floats.iter().for_each(&mut f),
None => (0..len).for_each(|_| f(None)),
}
}
}
#[derive(Clone, Copy, Default)]
struct Shift {
mean: f64,
complete: bool,
}
impl Shift {
fn new(series: &Series) -> Self {
let (mut sum, mut count) = (0.0, 0usize);
for_each_float(series, 0..series.len(), |v| {
if let Some(v) = v.filter(|v| v.is_finite()) {
sum += v;
count += 1;
}
});
Self {
mean: if count > 0 { sum / count as f64 } else { 0.0 },
complete: count == series.len(),
}
}
fn fill(&self, series: &Series, rows: Range<usize>, values: &mut Vec<f64>) {
values.clear();
for_each_float(series, rows, |v| {
values.push(
v.filter(|v| v.is_finite())
.map_or(f64::NAN, |v| v - self.mean),
);
});
}
}
#[derive(Clone, Copy, Default)]
struct PairSums {
count: usize,
x: f64,
y: f64,
xx: f64,
yy: f64,
xy: f64,
}
impl PairSums {
fn add(&mut self, v1: f64, v2: f64) {
self.count += 1;
self.x += v1;
self.y += v2;
self.xx += v1 * v1;
self.yy += v2 * v2;
self.xy += v1 * v2;
}
fn add_pairs(&mut self, a: &[f64], b: &[f64], both: bool) {
let mut sums = *self;
for (&v1, &v2) in a.iter().zip(b) {
if !both && (v1.is_nan() || v2.is_nan()) {
continue;
}
sums.add(v1, v2);
}
*self = sums;
}
fn spreads(&self) -> (f64, f64, f64) {
let n = self.count as f64;
(
self.xx - self.x * self.x / n,
self.yy - self.y * self.y / n,
self.xy - self.x * self.y / n,
)
}
fn correlation(&self) -> f64 {
let (sxx, syy, sxy) = self.spreads();
if self.count < 2 || sxx <= self.xx * 1e-12 || syy <= self.yy * 1e-12 {
return f64::NAN;
}
(sxy / (sxx * syy).sqrt()).clamp(-1.0, 1.0)
}
}
fn compute_correlation_p_value(correlation: f64, n: usize) -> f64 {
if n < 3 || correlation.is_nan() {
return 1.0;
}
if correlation.abs() >= 1.0 {
return 0.0;
}
let df = (n - 2) as f64;
crate::analysis::distribution_fit::beta_inc(df / 2.0, 0.5, 1.0 - correlation * correlation)
.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod normality_tests;
#[cfg(test)]
pub(crate) mod describe_tests;
#[cfg(all(test, feature = "streaming"))]
mod streaming_guard_tests;