use anyhow::Result;
use polars::prelude::*;
pub trait Describable {
fn describe(&self, percentiles: Option<Vec<f64>>) -> Result<DataFrame>;
}
impl Describable for DataFrame {
fn describe(&self, percentiles: Option<Vec<f64>>) -> Result<DataFrame> {
let lf = self.clone().lazy();
describe_lazy_impl(&lf, percentiles)
}
}
impl Describable for LazyFrame {
fn describe(&self, percentiles: Option<Vec<f64>>) -> Result<DataFrame> {
describe_lazy_impl(self, percentiles)
}
}
#[allow(clippy::too_many_lines)]
fn describe_lazy_impl(lazy_frame: &LazyFrame, percentiles: Option<Vec<f64>>) -> Result<DataFrame> {
use polars::lazy::dsl;
use polars::prelude::{NULL, QuantileMethod};
let mut lf_mut = lazy_frame.clone();
let schema = lf_mut.collect_schema()?;
if schema.is_empty() {
return Err(anyhow::anyhow!(
"cannot describe a LazyFrame that has no columns"
));
}
let percentiles = percentiles.unwrap_or_else(|| vec![0.25, 0.50, 0.75]);
let mut metrics = vec![
"count".to_string(),
"null_count".to_string(),
"mean".to_string(),
"std".to_string(),
"min".to_string(),
];
for p in &percentiles {
#[allow(clippy::cast_possible_truncation)]
metrics.push(format!("{}%", (p * 100.0) as i32));
}
metrics.push("max".to_string());
let skip_minmax = |dtype: &DataType| -> bool {
dtype.is_nested()
|| matches!(
dtype,
DataType::Categorical(..) | DataType::Null | DataType::Unknown(_)
)
};
let mut metric_exprs = Vec::new();
for (col_name, dtype) in schema.iter() {
let col_name_str = col_name.to_string();
let col = dsl::col(&col_name_str);
let is_numeric = dtype.is_numeric();
let is_temporal = !is_numeric && dtype.is_temporal();
let count_expr = col.clone().count().alias(format!("count:{col_name_str}"));
let null_count_expr = col
.clone()
.null_count()
.alias(format!("null_count:{col_name_str}"));
let mean_expr = if is_temporal || is_numeric || dtype == &DataType::Boolean {
if dtype == &DataType::Boolean {
col.clone().cast(DataType::Float64).mean()
} else {
col.clone().mean()
}
} else {
dsl::lit(NULL).cast(DataType::Float64)
};
let mean_expr = mean_expr.alias(format!("mean:{col_name_str}"));
let std_expr = if is_numeric {
col.clone().std(1) } else {
dsl::lit(NULL).cast(DataType::Float64)
};
let std_expr = std_expr.alias(format!("std:{col_name_str}"));
let min_expr = if skip_minmax(dtype) {
dsl::lit(NULL).cast(DataType::Float64)
} else {
col.clone().min()
};
let min_expr = min_expr.alias(format!("min:{col_name_str}"));
let max_expr = if skip_minmax(dtype) {
dsl::lit(NULL).cast(DataType::Float64)
} else {
col.clone().max()
};
let max_expr = max_expr.alias(format!("max:{col_name_str}"));
let mut pct_exprs = Vec::new();
for (i, p) in percentiles.iter().enumerate() {
let pct_expr = if is_numeric || is_temporal {
col.clone().quantile(dsl::lit(*p), QuantileMethod::Linear)
} else {
dsl::lit(NULL).cast(DataType::Float64)
};
pct_exprs.push(pct_expr.alias(format!("{p}:{i}:{col_name_str}")));
}
metric_exprs.push(count_expr);
metric_exprs.push(null_count_expr);
metric_exprs.push(mean_expr);
metric_exprs.push(std_expr);
metric_exprs.push(min_expr);
metric_exprs.extend(pct_exprs);
metric_exprs.push(max_expr);
}
let df_metrics = lazy_frame.clone().select(metric_exprs).collect()?;
let n_metrics = metrics.len();
let mut result_series = Vec::new();
result_series.push(Series::new("statistic".into(), metrics.clone()));
for (col_name, dtype) in schema.iter() {
let col_name_str = col_name.to_string();
let mut col_values = Vec::new();
let is_numeric_result = dtype.is_numeric()
|| dtype.is_nested()
|| matches!(dtype, DataType::Null | DataType::Boolean);
for metric_idx in 0..n_metrics {
let metric_name = match metric_idx {
0 => format!("count:{col_name_str}"),
1 => format!("null_count:{col_name_str}"),
2 => format!("mean:{col_name_str}"),
3 => format!("std:{col_name_str}"),
4 => format!("min:{col_name_str}"),
i if i < n_metrics - 1 => {
let pct_idx = i - 5;
let p = &percentiles[pct_idx];
format!("{p}:{pct_idx}:{col_name_str}")
}
_ => format!("max:{col_name_str}"),
};
if let Ok(val) = df_metrics.column(&metric_name)?.get(0) {
let formatted = if val.is_null() {
"null".to_string()
} else if metric_idx <= 1 {
format!("{val}")
} else if is_numeric_result && (metric_idx == 2 || metric_idx == 3) {
format!("{val:.6}")
} else if dtype == &DataType::Boolean
&& (metric_idx == 4 || metric_idx == n_metrics - 1)
{
if metric_idx == 4 {
"false".to_string()
} else {
"true".to_string()
}
} else {
format!("{val}")
};
col_values.push(formatted);
} else {
col_values.push("null".to_string());
}
}
result_series.push(Series::new(col_name_str.into(), col_values));
}
DataFrame::new(
n_metrics,
result_series.into_iter().map(Column::from).collect(),
)
.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_describe_numeric() -> Result<()> {
let df = df! {
"ints" => [1, 2, 3, 4, 5],
"floats" => [1.0, 2.0, 3.0, 4.0, 5.0],
}?;
let stats = df.describe(None)?;
assert_eq!(stats.shape(), (9, 3));
assert!(stats.column("statistic").is_ok());
Ok(())
}
#[test]
fn test_describe_with_custom_percentiles() -> Result<()> {
let df = df! {
"values" => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
}?;
let stats = df.describe(Some(vec![0.1, 0.5, 0.9]))?;
assert_eq!(stats.height(), 9);
Ok(())
}
#[test]
fn test_describe_mixed_types() -> Result<()> {
let df = df! {
"numbers" => [1, 2, 3],
"strings" => ["a", "b", "c"],
"bools" => [true, false, true],
}?;
let stats = df.describe(None)?;
assert_eq!(stats.width(), 4);
Ok(())
}
#[test]
fn test_describe_lazy_frame() -> Result<()> {
let df = df! {
"a" => [1, 2, 3, 4, 5],
"b" => [10.0, 20.0, 30.0, 40.0, 50.0],
}?;
let lf = df.lazy();
let stats = lf.describe(None)?;
assert_eq!(stats.shape(), (9, 3));
Ok(())
}
}