use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use crate::error::{Error, Result};
use crate::pipeline::Pipeline;
use crate::table::{ColKind, Table};
use crate::transform::{OneHotEncoder, PowerTransform, SimpleImputer, StandardScaler, Winsorize};
#[derive(Clone, Debug)]
pub struct Profile {
overview: Overview,
columns: Vec<ColumnProfile>,
missingness: Missingness,
correlations: CorrMatrix,
target: Option<TargetProfile>,
alerts: Vec<Alert>,
}
#[derive(Clone, Debug)]
pub struct Overview {
pub nrows: usize,
pub ncols: usize,
pub n_numeric: usize,
pub n_categorical: usize,
pub n_datetime: usize,
pub n_boolean: usize,
pub missing_cells: usize,
pub total_cells: usize,
pub duplicate_rows: usize,
}
#[derive(Clone, Debug)]
pub enum ColumnProfile {
Numeric(NumericProfile),
Categorical(CategoricalProfile),
}
impl ColumnProfile {
pub fn name(&self) -> &str {
match self {
ColumnProfile::Numeric(n) => &n.name,
ColumnProfile::Categorical(c) => &c.name,
}
}
pub fn missing(&self) -> usize {
match self {
ColumnProfile::Numeric(n) => n.missing,
ColumnProfile::Categorical(c) => c.missing,
}
}
}
#[derive(Clone, Debug)]
pub struct NumericProfile {
pub name: String,
pub count: usize,
pub missing: usize,
pub mean: f64,
pub std: f64,
pub min: f64,
pub p25: f64,
pub median: f64,
pub p75: f64,
pub max: f64,
pub skew: f64,
pub kurtosis: f64,
pub zeros: usize,
pub distinct: usize,
pub histogram: Vec<HistBin>,
pub outliers: usize,
pub outliers_z: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct HistBin {
pub lo: f64,
pub hi: f64,
pub count: usize,
}
#[derive(Clone, Debug)]
pub struct CategoricalProfile {
pub name: String,
pub count: usize,
pub missing: usize,
pub distinct: usize,
pub top: Vec<(String, usize)>,
}
#[derive(Clone, Debug)]
pub struct Missingness {
pub per_column: Vec<(String, usize)>,
pub total: usize,
pub co_missing: Vec<(String, String, f64)>,
}
#[derive(Clone, Debug)]
pub struct CorrMatrix {
pub columns: Vec<String>,
pub matrix: Vec<Vec<f64>>,
pub spearman: Vec<Vec<f64>>,
pub high_pairs: Vec<(String, String, f64)>,
}
#[derive(Clone, Debug)]
pub struct TargetProfile {
pub name: String,
pub kind: TargetKind,
}
#[derive(Clone, Debug)]
pub enum TargetKind {
Classification { classes: Vec<(String, usize)> },
Regression { correlations: Vec<(String, f64)> },
}
#[derive(Clone, Debug)]
pub struct Alert {
pub column: Option<String>,
pub message: String,
pub suggested: &'static str,
}
impl fmt::Display for Alert {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.column {
Some(c) => write!(f, "[{}] {} → {}", c, self.message, self.suggested),
None => write!(f, "{} → {}", self.message, self.suggested),
}
}
}
impl Profile {
pub fn of(table: &Table) -> Result<Profile> {
Profile::build(table, None)
}
pub fn of_with_target(table: &Table, target: &str) -> Result<Profile> {
if table.series(target).is_err() {
return Err(Error::Schema(format!(
"Profile: no target column '{target}'"
)));
}
Profile::build(table, Some(target))
}
fn build(table: &Table, target: Option<&str>) -> Result<Profile> {
let schema = table.schema();
let nrows = table.nrows();
let mut columns = Vec::new();
let mut per_column_missing = Vec::new();
let mut null_masks: Vec<(String, Vec<Option<f64>>)> = Vec::new();
let (mut n_numeric, mut n_categorical, mut n_datetime, mut n_boolean) = (0, 0, 0, 0);
for (name, kind) in &schema {
match kind {
ColKind::Numeric => n_numeric += 1,
ColKind::Categorical => n_categorical += 1,
ColKind::Datetime => n_datetime += 1,
ColKind::Boolean => n_boolean += 1,
}
let missing = table.null_count(name)?;
per_column_missing.push((name.clone(), missing));
if missing > 0 {
let mask: Vec<Option<f64>> = if *kind == ColKind::Numeric {
table
.column_f64(name)?
.iter()
.map(|v| Some(if v.is_none() { 1.0 } else { 0.0 }))
.collect()
} else {
table
.column_strings(name)?
.iter()
.map(|v| Some(if v.is_none() { 1.0 } else { 0.0 }))
.collect()
};
null_masks.push((name.clone(), mask));
}
let profile = if *kind == ColKind::Numeric {
ColumnProfile::Numeric(numeric_profile(name, table)?)
} else {
ColumnProfile::Categorical(categorical_profile(name, table)?)
};
columns.push(profile);
}
let mut co_missing = Vec::new();
for i in 0..null_masks.len() {
for j in (i + 1)..null_masks.len() {
let phi = pearson(&null_masks[i].1, &null_masks[j].1);
if phi.is_finite() && phi.abs() > 0.5 {
co_missing.push((null_masks[i].0.clone(), null_masks[j].0.clone(), phi));
}
}
}
let missing_total: usize = per_column_missing.iter().map(|(_, m)| m).sum();
let overview = Overview {
nrows,
ncols: schema.len(),
n_numeric,
n_categorical,
n_datetime,
n_boolean,
missing_cells: missing_total,
total_cells: nrows * schema.len(),
duplicate_rows: table.duplicate_rows(),
};
let missingness = Missingness {
per_column: per_column_missing,
total: missing_total,
co_missing,
};
let correlations = correlations(table, &schema)?;
let target_profile = match target {
Some(t) => Some(target_profile(table, &schema, t)?),
None => None,
};
let alerts = alerts(&overview, &columns, &correlations, &target_profile);
Ok(Profile {
overview,
columns,
missingness,
correlations,
target: target_profile,
alerts,
})
}
pub fn overview(&self) -> &Overview {
&self.overview
}
pub fn columns(&self) -> &[ColumnProfile] {
&self.columns
}
pub fn missingness(&self) -> &Missingness {
&self.missingness
}
pub fn correlations(&self) -> &CorrMatrix {
&self.correlations
}
pub fn target(&self) -> Option<&TargetProfile> {
self.target.as_ref()
}
pub fn alerts(&self) -> &[Alert] {
&self.alerts
}
pub fn summary(&self) -> String {
let o = &self.overview;
let mut s = format!(
"{} rows × {} cols ({} numeric, {} categorical, {} datetime, {} bool)\n\
missing: {}/{} cells duplicate rows: {}\n",
o.nrows,
o.ncols,
o.n_numeric,
o.n_categorical,
o.n_datetime,
o.n_boolean,
o.missing_cells,
o.total_cells,
o.duplicate_rows,
);
if !self.alerts.is_empty() {
s.push_str(&format!("{} alerts:\n", self.alerts.len()));
for a in &self.alerts {
s.push_str(&format!(" {a}\n"));
}
}
s
}
pub fn suggest_pipeline(&self) -> Pipeline {
let mut pipe = Pipeline::new();
if self.missingness.total > 0 {
pipe = pipe.step("impute", SimpleImputer::median());
}
if self.alerts.iter().any(|a| a.suggested == "Winsorize") {
pipe = pipe.step("winsorize", Winsorize::new());
}
if self.alerts.iter().any(|a| a.suggested == "PowerTransform") {
pipe = pipe.step("power", PowerTransform::yeo_johnson());
}
let has_low_card_cat = self.columns.iter().any(|c| match c {
ColumnProfile::Categorical(c) => c.distinct >= 2 && c.distinct <= 15,
_ => false,
});
if has_low_card_cat {
pipe = pipe.step("encode", OneHotEncoder::infer());
}
if self.overview.n_numeric > 0 {
pipe = pipe.step("scale", StandardScaler::new());
}
#[cfg(feature = "preprocessing")]
if self.alerts.iter().any(|a| a.suggested == "Smote") {
pipe = pipe.balance(crate::balance::Smote::new());
}
pipe
}
pub fn to_html(&self, path: impl AsRef<Path>) -> Result<()> {
let html = self.render_html();
std::fs::write(path.as_ref(), html)
.map_err(|e| Error::Backend(format!("write report: {e}")))
}
}
fn numeric_profile(name: &str, table: &Table) -> Result<NumericProfile> {
let raw = table.column_f64(name)?;
let missing = raw.iter().filter(|v| v.is_none()).count();
let mut present: Vec<f64> = raw
.into_iter()
.flatten()
.filter(|v| v.is_finite())
.collect();
let count = present.len();
if count == 0 {
return Ok(NumericProfile {
name: name.into(),
count: 0,
missing,
mean: f64::NAN,
std: f64::NAN,
min: f64::NAN,
p25: f64::NAN,
median: f64::NAN,
p75: f64::NAN,
max: f64::NAN,
skew: f64::NAN,
kurtosis: f64::NAN,
zeros: 0,
distinct: 0,
histogram: Vec::new(),
outliers: 0,
outliers_z: 0,
});
}
present.sort_by(f64::total_cmp);
let n = count as f64;
let mean = present.iter().sum::<f64>() / n;
let var = present.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
let std = var.sqrt();
let (skew, kurtosis) = if std > f64::EPSILON {
let m3 = present
.iter()
.map(|x| ((x - mean) / std).powi(3))
.sum::<f64>()
/ n;
let m4 = present
.iter()
.map(|x| ((x - mean) / std).powi(4))
.sum::<f64>()
/ n
- 3.0;
(m3, m4)
} else {
(0.0, 0.0)
};
let (min, max) = (present[0], present[count - 1]);
let p25 = quantile(&present, 0.25);
let median = quantile(&present, 0.50);
let p75 = quantile(&present, 0.75);
let iqr = p75 - p25;
let (lo, hi) = (p25 - 1.5 * iqr, p75 + 1.5 * iqr);
let outliers = present.iter().filter(|&&x| x < lo || x > hi).count();
let outliers_z = if std > f64::EPSILON {
present
.iter()
.filter(|&&x| ((x - mean) / std).abs() > 3.0)
.count()
} else {
0
};
let zeros = present.iter().filter(|&&x| x == 0.0).count();
let distinct = {
let mut d = present.clone();
d.dedup();
d.len()
};
let histogram = histogram(&present, min, max, 10);
Ok(NumericProfile {
name: name.into(),
count,
missing,
mean,
std,
min,
p25,
median,
p75,
max,
skew,
kurtosis,
zeros,
distinct,
histogram,
outliers,
outliers_z,
})
}
fn categorical_profile(name: &str, table: &Table) -> Result<CategoricalProfile> {
let raw = table.column_strings(name)?;
let missing = raw.iter().filter(|v| v.is_none()).count();
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
let mut count = 0;
for v in raw.into_iter().flatten() {
*counts.entry(v).or_insert(0) += 1;
count += 1;
}
let distinct = counts.len();
let mut top: Vec<(String, usize)> = counts.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
top.truncate(10);
Ok(CategoricalProfile {
name: name.into(),
count,
missing,
distinct,
top,
})
}
fn quantile(sorted: &[f64], q: f64) -> f64 {
if sorted.is_empty() {
return f64::NAN;
}
if sorted.len() == 1 {
return sorted[0];
}
let pos = q * (sorted.len() - 1) as f64;
let lo = pos.floor() as usize;
let hi = pos.ceil() as usize;
let frac = pos - lo as f64;
sorted[lo] * (1.0 - frac) + sorted[hi] * frac
}
fn histogram(sorted: &[f64], min: f64, max: f64, bins: usize) -> Vec<HistBin> {
if min == max {
return vec![HistBin {
lo: min,
hi: max,
count: sorted.len(),
}];
}
let width = (max - min) / bins as f64;
let mut out: Vec<HistBin> = (0..bins)
.map(|i| HistBin {
lo: min + i as f64 * width,
hi: min + (i + 1) as f64 * width,
count: 0,
})
.collect();
for &x in sorted {
let mut idx = ((x - min) / width) as usize;
if idx >= bins {
idx = bins - 1;
}
out[idx].count += 1;
}
out
}
fn numeric_columns(schema: &[(String, ColKind)]) -> Vec<String> {
schema
.iter()
.filter(|(_, k)| *k == ColKind::Numeric)
.map(|(n, _)| n.clone())
.collect()
}
fn correlations(table: &Table, schema: &[(String, ColKind)]) -> Result<CorrMatrix> {
let columns = numeric_columns(schema);
let cols: Vec<Vec<Option<f64>>> = columns
.iter()
.map(|n| table.column_f64(n))
.collect::<Result<_>>()?;
let k = columns.len();
let mut matrix = vec![vec![f64::NAN; k]; k];
let mut spearman_mat = vec![vec![f64::NAN; k]; k];
let mut high_pairs = Vec::new();
for i in 0..k {
matrix[i][i] = 1.0;
spearman_mat[i][i] = 1.0;
for j in (i + 1)..k {
let r = pearson(&cols[i], &cols[j]);
matrix[i][j] = r;
matrix[j][i] = r;
let rs = spearman(&cols[i], &cols[j]);
spearman_mat[i][j] = rs;
spearman_mat[j][i] = rs;
if r.is_finite() && r.abs() > 0.95 {
high_pairs.push((columns[i].clone(), columns[j].clone(), r));
}
}
}
Ok(CorrMatrix {
columns,
matrix,
spearman: spearman_mat,
high_pairs,
})
}
fn spearman(xs: &[Option<f64>], ys: &[Option<f64>]) -> f64 {
let pairs: Vec<(f64, f64)> = xs
.iter()
.zip(ys)
.filter_map(|(a, b)| match (a, b) {
(Some(a), Some(b)) if a.is_finite() && b.is_finite() => Some((*a, *b)),
_ => None,
})
.collect();
if pairs.len() < 2 {
return f64::NAN;
}
let rx = ranks(&pairs.iter().map(|(a, _)| *a).collect::<Vec<_>>());
let ry = ranks(&pairs.iter().map(|(_, b)| *b).collect::<Vec<_>>());
let rx: Vec<Option<f64>> = rx.into_iter().map(Some).collect();
let ry: Vec<Option<f64>> = ry.into_iter().map(Some).collect();
pearson(&rx, &ry)
}
fn ranks(values: &[f64]) -> Vec<f64> {
let n = values.len();
let mut idx: Vec<usize> = (0..n).collect();
idx.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
let mut out = vec![0.0; n];
let mut i = 0;
while i < n {
let mut j = i + 1;
while j < n && values[idx[j]] == values[idx[i]] {
j += 1;
}
let avg = ((i + 1 + j) as f64) / 2.0;
for &k in &idx[i..j] {
out[k] = avg;
}
i = j;
}
out
}
fn pearson(xs: &[Option<f64>], ys: &[Option<f64>]) -> f64 {
let pairs: Vec<(f64, f64)> = xs
.iter()
.zip(ys)
.filter_map(|(a, b)| match (a, b) {
(Some(a), Some(b)) if a.is_finite() && b.is_finite() => Some((*a, *b)),
_ => None,
})
.collect();
let n = pairs.len() as f64;
if n < 2.0 {
return f64::NAN;
}
let mx = pairs.iter().map(|(a, _)| a).sum::<f64>() / n;
let my = pairs.iter().map(|(_, b)| b).sum::<f64>() / n;
let mut cov = 0.0;
let mut vx = 0.0;
let mut vy = 0.0;
for (a, b) in &pairs {
cov += (a - mx) * (b - my);
vx += (a - mx).powi(2);
vy += (b - my).powi(2);
}
if vx <= 0.0 || vy <= 0.0 {
return f64::NAN;
}
cov / (vx.sqrt() * vy.sqrt())
}
fn target_profile(
table: &Table,
schema: &[(String, ColKind)],
target: &str,
) -> Result<TargetProfile> {
let kind = table.kind(target)?;
let is_classification = match kind {
ColKind::Categorical | ColKind::Boolean => true,
ColKind::Numeric => {
let vals: Vec<f64> = table.column_f64(target)?.into_iter().flatten().collect();
let integral = vals.iter().all(|v| v.fract() == 0.0);
let distinct = {
let mut d: Vec<i64> = vals.iter().map(|v| *v as i64).collect();
d.sort_unstable();
d.dedup();
d.len()
};
integral && distinct <= 20
}
ColKind::Datetime => false,
};
if is_classification {
let raw = if kind == ColKind::Numeric {
table
.column_f64(target)?
.into_iter()
.map(|o| o.map(|v| format!("{}", v as i64)))
.collect::<Vec<_>>()
} else {
table.column_strings(target)?
};
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for v in raw.into_iter().flatten() {
*counts.entry(v).or_insert(0) += 1;
}
let mut classes: Vec<(String, usize)> = counts.into_iter().collect();
classes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Ok(TargetProfile {
name: target.into(),
kind: TargetKind::Classification { classes },
})
} else {
let y = table.column_f64(target)?;
let mut correlations: Vec<(String, f64)> = numeric_columns(schema)
.into_iter()
.filter(|n| n != target)
.map(|n| {
let x = table.column_f64(&n).unwrap_or_default();
(n, pearson(&x, &y))
})
.collect();
correlations.sort_by(|a, b| {
b.1.abs()
.partial_cmp(&a.1.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(TargetProfile {
name: target.into(),
kind: TargetKind::Regression { correlations },
})
}
}
fn alerts(
overview: &Overview,
columns: &[ColumnProfile],
corr: &CorrMatrix,
target: &Option<TargetProfile>,
) -> Vec<Alert> {
let mut out = Vec::new();
let n = overview.nrows.max(1) as f64;
for col in columns {
match col {
ColumnProfile::Numeric(np) => {
if np.missing as f64 / n > 0.2 {
out.push(Alert {
column: Some(np.name.clone()),
message: format!("{:.0}% missing", 100.0 * np.missing as f64 / n),
suggested: "SimpleImputer",
});
}
if np.distinct <= 1 {
out.push(Alert {
column: Some(np.name.clone()),
message: "constant / zero-variance".into(),
suggested: "Drop",
});
}
if np.skew.is_finite() && np.skew.abs() > 2.0 {
out.push(Alert {
column: Some(np.name.clone()),
message: format!("skewed (skew {:.1})", np.skew),
suggested: "PowerTransform",
});
}
if np.outliers as f64 / n > 0.01 {
out.push(Alert {
column: Some(np.name.clone()),
message: format!("{} IQR outliers", np.outliers),
suggested: "Winsorize",
});
}
}
ColumnProfile::Categorical(cp) => {
if cp.missing as f64 / n > 0.2 {
out.push(Alert {
column: Some(cp.name.clone()),
message: format!("{:.0}% missing", 100.0 * cp.missing as f64 / n),
suggested: "SimpleImputer",
});
}
if cp.distinct > 20 {
out.push(Alert {
column: Some(cp.name.clone()),
message: format!("high cardinality ({} levels)", cp.distinct),
suggested: "TargetEncoder",
});
} else if cp.distinct >= 2 {
out.push(Alert {
column: Some(cp.name.clone()),
message: format!("categorical ({} levels)", cp.distinct),
suggested: "OneHotEncoder",
});
}
}
}
}
for (a, b, r) in &corr.high_pairs {
out.push(Alert {
column: Some(format!("{a} ~ {b}")),
message: format!("correlated |r| = {:.2}", r.abs()),
suggested: "drop one",
});
}
if let Some(TargetProfile {
kind: TargetKind::Classification { classes },
..
}) = target
{
if let (Some((_, max)), Some((_, min))) = (classes.first(), classes.last()) {
if *min > 0 && *max as f64 / *min as f64 >= 3.0 {
out.push(Alert {
column: None,
message: format!("class imbalance {}:{}", max, min),
suggested: "Smote",
});
}
}
}
out
}
#[path = "profile_render.rs"]
mod render;
#[cfg(test)]
#[path = "profile_tests.rs"]
mod tests;