datarust_profile/profile/
column.rs1use std::collections::HashMap;
4
5use crate::infer;
6use crate::types::ColumnType;
7
8#[derive(Debug, Clone, PartialEq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize))]
13pub struct FiveNumber {
14 pub min: f64,
16 pub q1: f64,
18 pub median: f64,
20 pub q3: f64,
22 pub max: f64,
24}
25
26#[derive(Debug, Clone, PartialEq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize))]
33pub struct Histogram {
34 pub edges: Vec<f64>,
37 pub counts: Vec<usize>,
40}
41
42impl Histogram {
43 pub fn nbins(&self) -> usize {
45 self.counts.len()
46 }
47
48 pub fn max_count(&self) -> usize {
50 self.counts.iter().copied().max().unwrap_or(0)
51 }
52}
53
54#[derive(Debug, Clone, PartialEq)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize))]
60pub struct NumericStats {
61 pub mean: f64,
63 pub std: f64,
65 pub five: FiveNumber,
67 pub skewness: f64,
70 pub kurtosis: f64,
73 pub histogram: Histogram,
75 pub outlier_count: usize,
78 pub outlier_fraction: f64,
80}
81
82#[derive(Debug, Clone, PartialEq)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize))]
85pub struct CategoricalStats {
86 pub unique: usize,
88 pub top: String,
90 pub freq: usize,
92 pub imbalance_ratio: f64,
95 pub top_values: Vec<(String, usize)>,
98}
99
100#[derive(Debug, Clone, PartialEq)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize))]
103pub struct ColumnProfile {
104 pub name: String,
106 pub column_type: ColumnType,
108 pub count: usize,
110 pub missing_count: usize,
112 pub missing_fraction: f64,
114 pub numeric: Option<NumericStats>,
116 pub categorical: Option<CategoricalStats>,
118}
119
120const TOP_VALUES_CAP: usize = 8;
122
123pub(crate) struct PrecomputedStats {
131 pub(crate) mean: f64,
132 pub(crate) std: f64,
133 pub(crate) five: FiveNumber,
134}
135
136impl ColumnProfile {
137 pub(crate) fn from_numeric(name: String, values: &[f64]) -> Self {
139 Self::from_numeric_with_stats(name, values, None)
140 }
141
142 pub(crate) fn from_numeric_with_stats(
149 name: String,
150 values: &[f64],
151 precomputed: Option<PrecomputedStats>,
152 ) -> Self {
153 let count = values.len();
154 let present: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
155 let missing_count = count - present.len();
156 let missing_fraction = if count == 0 {
157 0.0
158 } else {
159 missing_count as f64 / count as f64
160 };
161
162 let numeric = if present.is_empty() {
163 None
164 } else {
165 let mut sorted = present.clone();
167 sorted.sort_by(|a, b| a.total_cmp(b));
168
169 let (mean, std, five) = match precomputed {
170 Some(p) => (p.mean, p.std, p.five),
171 None => {
172 let m = datarust::stats::mean(&present);
173 let s = datarust::stats::std(&present, 1);
174 let f = FiveNumber {
175 min: datarust::stats::quantile(&sorted, 0.0).unwrap_or(f64::NAN),
176 q1: datarust::stats::quantile(&sorted, 0.25).unwrap_or(f64::NAN),
177 median: datarust::stats::median_sorted(&sorted).unwrap_or(f64::NAN),
178 q3: datarust::stats::quantile(&sorted, 0.75).unwrap_or(f64::NAN),
179 max: datarust::stats::quantile(&sorted, 1.0).unwrap_or(f64::NAN),
180 };
181 (m, s, f)
182 }
183 };
184
185 let skew = super::distribution::skewness(&present, mean, std);
186 let kurt = super::distribution::kurtosis_excess(&present, mean, std);
187 let histogram = super::distribution::histogram(&sorted, five.min, five.max);
188 let (outlier_count, outlier_fraction) =
189 super::distribution::outlier_count(&sorted, five.q1, five.q3);
190
191 Some(NumericStats {
192 mean,
193 std,
194 five,
195 skewness: skew,
196 kurtosis: kurt,
197 histogram,
198 outlier_count,
199 outlier_fraction,
200 })
201 };
202
203 ColumnProfile {
204 name,
205 column_type: ColumnType::Numeric,
206 count,
207 missing_count,
208 missing_fraction,
209 numeric,
210 categorical: None,
211 }
212 }
213
214 pub(crate) fn from_strings(name: String, cells: &[String]) -> Self {
216 let count = cells.len();
217 let missing_count = cells.iter().filter(|c| infer::is_missing(c)).count();
218 let missing_fraction = if count == 0 {
219 0.0
220 } else {
221 missing_count as f64 / count as f64
222 };
223
224 let column_type = infer::infer_column(cells);
225 match column_type {
226 ColumnType::Numeric => {
227 let values = infer::parse_numeric_column(cells);
228 let mut p = Self::from_numeric(name, &values);
229 p.count = count;
232 p.missing_count = missing_count;
233 p.missing_fraction = missing_fraction;
234 p
235 }
236 ColumnType::Categorical => {
237 let categorical = compute_categorical(cells);
238 ColumnProfile {
239 name,
240 column_type,
241 count,
242 missing_count,
243 missing_fraction,
244 numeric: None,
245 categorical,
246 }
247 }
248 }
249 }
250}
251
252fn compute_categorical(cells: &[String]) -> Option<CategoricalStats> {
254 let mut counts: HashMap<&str, usize> = HashMap::new();
255 let mut order: Vec<&str> = Vec::new();
256 for cell in cells {
257 if infer::is_missing(cell) {
258 continue;
259 }
260 let trimmed = cell.trim();
261 match counts.get(trimmed) {
262 None => {
263 counts.insert(trimmed, 1);
264 order.push(trimmed);
265 }
266 Some(c) => *counts.get_mut(trimmed).unwrap() = c + 1,
267 }
268 }
269 if order.is_empty() {
270 return None;
271 }
272 let present_total: usize = order.iter().map(|k| counts[*k]).sum();
273
274 let mut entries: Vec<(&str, usize)> = order.iter().map(|k| (*k, counts[*k])).collect();
276 entries.sort_by_key(|&(_, c)| std::cmp::Reverse(c));
277 let top_values: Vec<(String, usize)> = entries
278 .into_iter()
279 .take(TOP_VALUES_CAP)
280 .map(|(k, c)| (k.to_string(), c))
281 .collect();
282
283 let (top, freq) = {
284 let first = top_values.first().expect("non-empty");
285 (first.0.clone(), first.1)
286 };
287 let imbalance_ratio = if present_total == 0 {
288 0.0
289 } else {
290 freq as f64 / present_total as f64
291 };
292
293 Some(CategoricalStats {
294 unique: order.len(),
295 top,
296 freq,
297 imbalance_ratio,
298 top_values,
299 })
300}