The boundary type,
and the typed layer in front of it.
Frame is the numeric boundary the whole API speaks. Table (feature eda) is the polars-backed, dtype-aware world that ingests real CSV/Parquet and lowers into it.
One contiguous f64 buffer, plus a schema.
Everything the public API speaks is a Frame: a contiguous, row-major f64 buffer with named columns. It is what lets a linfa model and a smartcore DenseMatrix meet in one signature without your code ever naming their array versions — each backend converts Frame ⇄ its native type inside the adapter only. A Dataset pairs a frame with a target.
use millwright::prelude::*; let x = Frame::from_rows( vec![vec![0.0, 0.1], vec![0.4, 0.2], vec![9.0, 9.1], vec![9.4, 8.7]], vec!["a".into(), "b".into()], )?; assert_eq!(x.shape(), (4, 2)); // (rows, cols) let train = Dataset::new(x.clone(), vec![0.0, 0.0, 1.0, 1.0])?; let _features = train.features(); // &Frame let _target = train.target(); // &[f64]
The task — classification vs. regression — is inferred from the target: an all-integral target is class labels, anything else is regression. A pure-numeric CSV loads directly with Frame::from_csv; typed data uses Table below.
Table reads it, Profile reports it.
Behind the eda feature, a polars-backed Table reads real CSV/Parquet — strings, categories, dates, booleans, nulls — and a Profile returns a typed analysis (not just an HTML blob) and drafts the preprocessing.
let table = Table::from_csv("customers.csv")?; // or ::from_parquet(…) // a typed profile — overview, per-column stats, missingness, correlations, alerts let profile = Profile::of_with_target(&table, "churned")?; println!("{}", profile.summary()); for alert in profile.alerts() { println!("{alert}"); // "[city] categorical (3 levels) → OneHotEncoder" } profile.to_html("eda_report.html")?; // a shareable report
Because Millwright owns EDA and the pipeline, the profile drafts the starting preprocessing from its own findings — the loop scikit-learn can't close:
// lower the typed table to the numeric world let train = table.into_dataset("churned")?; // categoricals encoded, nulls → NaN // EDA drafts the pipeline; you just add the model let mut pipe = profile.suggest_pipeline() // impute · encode · scale, from the alerts .estimator("rf", RandomForest::new()); pipe.fit(&train)?;
cargo run --example explore --features "eda smartcore-backend"
Types flow through the pipeline.
A Frame carries a per-column Dtype (defaulting to Numeric). Table marks the columns it knows are Categorical as it lowers — so preprocessing doesn't have to guess: scalers, Winsorize, and PowerTransform pass categorical columns through untouched, and OneHotEncoder encodes by dtype rather than a value heuristic.
// a genuinely-integer feature is NOT wrongly one-hot'd; the categorical one is let f = Frame::from_rows(rows, cols)? .with_dtypes(vec![Dtype::Categorical, Dtype::Numeric])?; let out = OneHotEncoder::infer().fit_transform(&f)?; // expands only column 0 // or one-hot at the Table boundary, with real category names let train = table.into_dataset_with("churned", CategoryEncoding::OneHot)?;
Nominal categories become "{col}={value}" indicator columns instead of ordinal codes — the correct representation for linear and tree models.