Reproducible timing comparisons of gators against scikit-learn and
feature-engine for transformers that have a genuinely equivalent
implementation in more than one library. The goal is a credible, rerunnable
number — not a marketing multiplier.
- Data: synthetic DataFrame with 8 numeric columns (10% nulls) and 4
categorical columns (cardinality 20), generated once per row count and
materialized natively for each library — a polars
DataFrameforgators, a pandasDataFrameforscikit-learn/feature-engine. No conversion cost is included in either timing. - Timing: for each case, 1 untimed warm-up run followed by 3 timed runs of
fit(X)+transform(X)on a fresh estimator instance; the minimum of the 3 timed runs is reported (reduces noise from OS scheduling/GC). - Fairness: only transformer pairs that implement the same algorithm are
compared (e.g. gators' frequency-based
OrdinalEncodervsfeature_engine.encoding.OrdinalEncoder(encoding_method="arbitrary"), not against a supervised variant). Where no equivalent exists in a library (e.g. WOE encoding isn't in scikit-learn,StandardScalerisn't reimplemented in feature-engine), the cell is reported asn/arather than omitted or faked. - Single-threaded fairness note: gators/Polars uses all available CPU cores by default; scikit-learn and feature-engine here run with their default (mostly single-threaded) settings. This mirrors realistic out-of-the-box usage of each library, not a controlled thread-for-thread comparison.
- Correctness first:
check_parity.pyasserts algorithm-level invariants (no nulls remain, values stay in bounds, bin/category counts match the source data) for every case before timings are trusted. Exact value-for-value equality isn't checked, since each library orders categories/bins differently.
pip install -e ".[benchmarks]"
python benchmarks/check_parity.py # verify all implementations produce correct output first
python benchmarks/run_benchmarks.pyThis writes results/results_<n_rows>.csv and results/summary.md, and
prints the same summary to stdout. Results depend on hardware, OS, and
library versions — always regenerate on your own machine before citing a
number.
- Hardware: Apple M3 Max, 16 cores
- OS: macOS 26.6.2 (arm64)
- Python: 3.14.5
gators1.3.0,polars1.43.0,scikit-learn1.7.2,feature-engine1.9.4,pandas3.0.3
See results/summary.md for the full generated tables (50,000 and 500,000 row datasets). Summary at 500,000 rows:
| Transformer | gators (s) | scikit-learn (s) | feature-engine (s) | speedup vs sklearn | speedup vs feature-engine |
|---|---|---|---|---|---|
| NumericImputer (mean) | 0.005 | 0.025 | 0.012 | 5.2x | 2.4x |
| StandardScaler | 0.002 | 0.009 | n/a | 3.8x | n/a |
| QuantileClipper | 0.005 | n/a | 0.056 | n/a | 12.3x |
| EqualSizeDiscretizer (5 bins) | 0.025 | 0.112 | 0.182 | 4.5x | 7.3x |
| OneHotEncoder | 0.045 | 0.283 | 0.320 | 6.2x | 7.0x |
| OrdinalEncoder | 0.029 | 0.267 | 0.127 | 9.1x | 4.3x |
| TargetEncoder | 0.029 | 0.442 | 0.163 | 15.0x | 5.5x |
| WOEEncoder | 0.028 | n/a | 0.181 | n/a | 6.5x |
- These are micro-benchmarks on synthetic data with fixed cardinality/null ratio; real datasets will shift absolute numbers (relative ordering tends to be more stable).
- Numbers are single-machine, single-run-session measurements, not averaged over multiple processes/machines — treat as directional, not a formal statistical benchmark.
n/ameans "no comparable implementation exists", not "0x" or "untested".
A separate script sweeps rows, feature count, missingness, cardinality, and thread count — the axes the single-point comparison above cannot speak to, and in particular a controlled, single-thread-vs-single-thread comparison that isolates how much of the headline speedup above comes from Polars' default multi-threading rather than the transformer implementation itself.
A full grid across every axis (rows × features × missingness × cardinality × threads × libraries) is combinatorially intractable on a single laptop, and some cells don't fit in memory regardless of time budget (10,000,000 rows × 1,000 float64 columns is 80GB for one array). We instead sweep one axis at a time around a fixed baseline (8 numeric + 4 categorical columns, cardinality 20, 10% nulls). This is a deliberate scope decision: OFAT cannot detect interaction effects between axes, and the ranges covered here (10⁴–10⁶ rows, ~6–80 columns) are narrower than a full 10⁴–10⁷ rows / 10–1,000 feature study would require — both stated as open follow-up work, not hidden.
python benchmarks/run_benchmarks.py # first, if results_50000.csv / results_500000.csv don't exist yet
python benchmarks/run_matrix_benchmarks.pyWrites one CSV per axis plus results/matrix/summary.md. The row and thread
sweeps reuse run_benchmarks.py's existing 50,000/500,000-row output
directly (for the sklearn/feature-engine reference numbers) rather than
re-measuring them, so results stay consistent across scripts.
Extending the row axis down to 10,000 and up to 1,000,000 rows confirms and
sharpens the trend already visible at 50k/500k: every transformer's speedup
vs. scikit-learn keeps growing with row count rather than plateauing —
e.g. TargetEncoder goes from 4.1x (10k rows) → 7.3x (50k) → 15.0x (500k) →
18.0x (1,000,000 rows). The same pattern holds on the feature-count axis
(more columns, not just more rows, also widens the gap).
This is the single most important correction in this benchmark matrix. Using
POLARS_MAX_THREADS=1 (set before Polars' first import, via a subprocess,
since the thread pool is fixed for the process's lifetime), we re-measured
three transformers at 500,000 rows with Gators pinned to one thread, holding
scikit-learn's (already single-threaded) numbers fixed:
| Transformer | gators, 1 thread (s) | gators, default threads (s) | sklearn (s) | speedup, 1-thread-vs-1-thread | speedup, default-vs-1-thread (previously reported) |
|---|---|---|---|---|---|
| NumericImputer (mean) | 0.0053 | 0.0026 | 0.0250 | 4.72x | 9.62x |
| OneHotEncoder | 0.0663 | 0.0362 | 0.2835 | 4.28x | 7.83x |
| TargetEncoder | 0.0923 | 0.0230 | 0.4415 | 4.78x | 19.20x |
Once thread count is controlled for, the three transformers converge to a
tight, consistent ~4.3–4.8x advantage over scikit-learn — a genuinely
measured, apples-to-apples number. The previously reported default-vs-default
speedups (up to 19x for TargetEncoder) were real, but roughly half to
two-thirds of that number was Polars using multiple cores against a
single-threaded scikit-learn, not a 15–19x faster implementation. Both
numbers are legitimate and are reported together deliberately: the
default-vs-default number reflects realistic out-of-the-box usage (Section
above); the thread-isolated number is the fairer algorithmic comparison. This
directly resolves the thread-asymmetry caveat in the main methodology section
rather than leaving it as an acknowledged-but-unquantified limitation.
The cardinality sweep (a single categorical column, cardinality 5 → 1,000,
50,000 rows) shows that OneHotEncoder's speedup over scikit-learn is
cardinality-dependent: 3.8x faster at cardinality 5, 3.0x at cardinality 20,
2.0x at cardinality 100, and statistically indistinguishable from parity
(1.00x, 0.0115s vs. 0.0115s) at cardinality 1,000 — the only point anywhere
in this matrix where a Gators transformer does not clearly outperform
scikit-learn. feature-engine, by contrast, gets dramatically slower as
cardinality grows (about 120x slower than Gators at cardinality 1,000), so
the narrowing is specific to the scikit-learn comparison. We report this
trend as measured, without smoothing it into a flat "always faster" claim:
at very high cardinality, whether Gators is faster than scikit-learn is not
a given.
A full factorial grid (to detect interaction effects between axes), rows
beyond 1,000,000 or features beyond ~80 (memory-bound on this hardware),
thread sweeps for the other 5 transformer cases, multi-machine variance, and
whether OneHotEncoder's narrowing advantage continues past cardinality
1,000 or appears in other wide-output transformers.
See paper/gators_paper.md Section 9 for the prioritized follow-up list.
A separate script measures native Polars transform() versus an exported
ONNX Runtime session — the question the fit/transform benchmark above does
not answer, namely whether the ONNX export path (see the root README's ONNX
Export section) is actually worth using at inference time, not just
numerically correct.
- Pipelines: three fitted
gators.pipeline.Pipelines restricted to ONNX-exportable transformers (impute_scale,impute_clip_discretize,impute_encode_scale), fit on 50,000 rows, evaluated on an independently sampled 100,000-row "serving" set. - Batch-size sweep: each pipeline is timed at batch sizes 1 / 10 / 100 / 1,000 / 10,000 / 100,000 — batch size 1 is the realistic "one prediction request" serving scenario; larger batches represent bulk scoring/offline transform.
- Correctness first: for every pipeline, ONNX Runtime output is checked
against native Polars output (
atol=1e-4) before any timing is trusted — same principle ascheck_parity.py. - Timing: best-of-5 wall-clock time after 2 warm-up calls, one call per batch size (not chunked further).
- Memory:
resource.getrusage().ru_maxrssdelta around each call. This is a process-wide, monotonically non-decreasing high-water mark, not a precise per-call allocation count — a delta of 0 means "did not set a new peak", not "used no memory". Treat these numbers as directional only. - ONNX Runtime configuration:
CPUExecutionProvider, default graph optimizations (ORT_ENABLE_ALL),intra_op_num_threads=0(let ORT choose),inter_op_num_threads=1— the defaultscreate_sessionships with, tuned for single-request latency rather than bulk throughput. A thread-swept comparison is listed as follow-up work, not included here.
pip install -e ".[onnx,benchmarks]"
python benchmarks/run_onnx_benchmarks.pyWrites results/onnx_results.csv and results/onnx_summary.md, and prints
the same summary to stdout.
Across all three pipelines, the same qualitative crossover appears: ONNX
Runtime is faster at small batch sizes (single-row up to ~100–1,000 rows),
native Polars is faster at large batch sizes (10,000+ rows) — e.g. for
impute_scale, ONNX is ~1.6–1.8x faster than Polars at batch size 1–10, but
~12x slower at batch size 10,000 and ~68x slower at 100,000. See
results/onnx_summary.md for full per-pipeline
tables.
This is the expected shape of the trade-off, not a surprise to be explained
away: Polars' advantage comes from parallelizing a query plan across cores
over a large batch, which only pays off once a batch is large enough to
amortize scheduling overhead; ONNX Runtime's advantage at small batches comes
from a lighter-weight, single-graph-execution call with none of the
Python-object/query-planning overhead that transform() pays per call
regardless of row count. In other words: use the ONNX export path for
low-latency, one-row-at-a-time serving; use native Polars for bulk/batch
scoring — the two paths are complementary, not "one strictly replaces the
other".
- Concurrent/sustained-load throughput (many simultaneous requests) — this only measures single-threaded, single-request-at-a-time latency.
- Non-CPU execution providers, other thread configurations, or
float32graphs (the benchmark usespipeline_to_onnx'sfloat64default). - Behavior on pipelines containing the
feature_generation_strtransformers that have no ONNX converter at all (Section on ONNX Export in the root README) — those pipelines cannot take this path by construction.