mod schema;
pub use schema::{FeatureSchema, Schema, ValidationIssue, ValidationReport, DEFAULT_MAX_NULL_RATE};
use std::collections::BTreeMap;
use std::fmt;
pub const DEFAULT_TOP_K: usize = 10;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ContinuousProfile {
pub count: usize,
pub missing: usize,
pub min: f64,
pub max: f64,
pub mean: f64,
pub std: f64,
}
impl ContinuousProfile {
pub fn compute(data: &[f64]) -> Self {
let mut count = 0usize;
let mut missing = 0usize;
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
let mut sum = 0.0;
for &v in data {
if v.is_finite() {
count += 1;
min = min.min(v);
max = max.max(v);
sum += v;
} else {
missing += 1;
}
}
if count == 0 {
return Self {
count: 0,
missing,
min: f64::NAN,
max: f64::NAN,
mean: f64::NAN,
std: f64::NAN,
};
}
let mean = sum / count as f64;
let var = data
.iter()
.filter(|v| v.is_finite())
.map(|&v| (v - mean).powi(2))
.sum::<f64>()
/ count as f64;
Self {
count,
missing,
min,
max,
mean,
std: var.sqrt(),
}
}
pub fn missing_rate(&self) -> f64 {
let total = self.count + self.missing;
if total == 0 {
0.0
} else {
self.missing as f64 / total as f64
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct CategoricalProfile {
pub count: usize,
pub missing: usize,
pub cardinality: usize,
pub top: Vec<(String, usize)>,
}
impl CategoricalProfile {
pub fn compute(data: &[&str]) -> Self {
Self::compute_top_k(data, DEFAULT_TOP_K)
}
pub fn compute_top_k(data: &[&str], k: usize) -> Self {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
let mut missing = 0usize;
for &label in data {
if label.is_empty() {
missing += 1;
} else {
*counts.entry(label).or_insert(0) += 1;
}
}
let count = data.len() - missing;
let cardinality = counts.len();
let mut ranked: Vec<(String, usize)> = counts
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
ranked.sort_by_key(|entry| std::cmp::Reverse(entry.1));
ranked.truncate(k);
Self {
count,
missing,
cardinality,
top: ranked,
}
}
pub fn missing_rate(&self) -> f64 {
let total = self.count + self.missing;
if total == 0 {
0.0
} else {
self.missing as f64 / total as f64
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum FeatureProfile {
Continuous(ContinuousProfile),
Categorical(CategoricalProfile),
}
impl FeatureProfile {
pub fn missing_rate(&self) -> f64 {
match self {
FeatureProfile::Continuous(p) => p.missing_rate(),
FeatureProfile::Categorical(p) => p.missing_rate(),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FeatureProfileEntry {
pub name: String,
pub profile: FeatureProfile,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DatasetProfile {
pub features: Vec<FeatureProfileEntry>,
}
impl DatasetProfile {
pub fn new() -> Self {
Self::default()
}
pub fn profile_continuous(&mut self, name: impl Into<String>, data: &[f64]) -> &mut Self {
self.features.push(FeatureProfileEntry {
name: name.into(),
profile: FeatureProfile::Continuous(ContinuousProfile::compute(data)),
});
self
}
pub fn profile_categorical(&mut self, name: impl Into<String>, data: &[&str]) -> &mut Self {
self.features.push(FeatureProfileEntry {
name: name.into(),
profile: FeatureProfile::Categorical(CategoricalProfile::compute(data)),
});
self
}
pub fn get(&self, name: &str) -> Option<&FeatureProfile> {
self.features
.iter()
.find(|f| f.name == name)
.map(|f| &f.profile)
}
}
impl fmt::Display for DatasetProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "DataProfile ({} features)", self.features.len())?;
for entry in &self.features {
match &entry.profile {
FeatureProfile::Continuous(p) => writeln!(
f,
" {:<20} continuous n={} missing={} ({:.1}%) min={:.4} max={:.4} mean={:.4} std={:.4}",
entry.name,
p.count,
p.missing,
p.missing_rate() * 100.0,
p.min,
p.max,
p.mean,
p.std,
)?,
FeatureProfile::Categorical(p) => {
let top = p
.top
.iter()
.take(3)
.map(|(c, n)| format!("{c}={n}"))
.collect::<Vec<_>>()
.join(", ");
writeln!(
f,
" {:<20} categorical n={} missing={} ({:.1}%) cardinality={} top: {}",
entry.name,
p.count,
p.missing,
p.missing_rate() * 100.0,
p.cardinality,
top,
)?
}
}
}
Ok(())
}
}