Preprocessing performance - CyrilB1531/lodestar GitHub Wiki

Development build. This page describes main, not a released package. The latest published Lodestar.Preprocessing is 0.2.0 — read its documentation.

Home › Preprocessing

Performance — Lodestar.Preprocessing

What Lodestar.Preprocessing costs against the library a reader would otherwise reach for. How to read a row, and what this page leaves out: docs/guides/performance.md.

The splitters against ML.NET and scikit-learn (issue #762)

Full method and what cannot be made to agree: bench/README.md. Machine: the AMD Ryzen 7 8700G named above, on 2026-09-16. BenchmarkDotNet 0.14.0, default job, one run. Five folds, three classes at 60 / 30 / 10.

ML.NET's split is lazy, so it appears twice: the call, and the call followed by reading which rows each fold holds. Only the second is a split a caller can fit on, and it is the one that scales with the data — the construction is flat in the row count because it builds ten wrappers and stops.

rows operation Lodestar ML.NET 5.0.0 ML.NET / Lodestar
10,000 five folds, constructed 52.52 μs 79.19 μs 1.51
10,000 five folds, rows read 52.52 μs 3,330.43 μs 63.44
10,000 train/test, constructed 3.11 μs 15.70 μs 5.05
10,000 train/test, rows read 3.11 μs 592.81 μs 190.55
100,000 five folds, constructed 695.48 μs 78.35 μs 0.11
100,000 five folds, rows read 695.48 μs 25,527.38 μs 36.71
100,000 train/test, constructed 56.81 μs 16.30 μs 0.29
100,000 train/test, rows read 56.81 μs 4,454.57 μs 78.42

Stratifying costs 136.77 μs at 10,000 rows and 1,489.37 μs at 100,000 — 2.6× and 2.1× the plain fold cut. ML.NET has no row to compare it against: CrossValidationSplit and TrainTestSplit never stratify (dotnet/machinelearning#4396, open since 2019).

Allocation, per operation: 273.94 KB against ML.NET's 149.07 KB at 10,000 rows for the fold cut — this returns every index of every fold, where ML.NET returns views that allocate again on each read, so the two columns count different things and the larger one is the one holding the answer.

Against scikit-learn 1.9.0 through compare-splitters, one run of each side, milliseconds per split, best of five:

n operation Lodestar scikit-learn, wall / cpu ratio, wall
10,000 KFold 0.050 ms 0.057 / 0.057 ms 1.14
10,000 StratifiedKFold 0.135 ms 0.445 / 0.445 ms 3.30
10,000 train/test 0.003 ms 0.073 / 0.073 ms 25.70
100,000 KFold 0.798 ms 2.145 / 2.145 ms 2.69
100,000 StratifiedKFold 1.657 ms 5.718 / 5.717 ms 3.45
100,000 train/test 0.149 ms 0.194 / 0.194 ms 1.30
1,000,000 KFold 12.273 ms 14.919 / 14.917 ms 1.22
1,000,000 StratifiedKFold 20.507 ms 48.187 / 48.178 ms 2.35
1,000,000 train/test 0.517 ms 1.323 / 1.323 ms 2.56

The million-row rows move about ±20% run to run on this machine — KFold there read 10.0 ms once and 12.0 to 12.4 ms in the four runs after, on unchanged code. The ratios below 100,000 rows are stable to the third decimal across every run.

TrainTest writes the two index halves straight out: an identity read comes out ascending already, so neither half is sorted and no order array is filled.

The seeded, grouped and time-ordered splitters against scikit-learn (issue #1157)

Method: bench/README.md, the same compare-splitters harness as above with eight operations added. Machine: the AMD Ryzen 7 8700G named above, 2026-09-25 09:35 to 09:43 UTC, one run of each side under the repository's machine lock, scikit-learn 1.9.1 on numpy 2.5.3 against .NET 10.0.12. Milliseconds per split, best of five; seed 1157 on both sides; five folds, three repeats, groups of 100 rows. No .NET library offers any of these, so scikit-learn is the only incumbent.

n operation Lodestar scikit-learn, wall / cpu ratio, cpu
10,000 KFold, seeded 0.096 ms 0.384 / 0.384 ms 3.98
10,000 StratifiedKFold, seeded 0.146 ms 0.830 / 0.830 ms 5.68
10,000 train/test, seeded 0.042 ms 0.209 / 0.209 ms 5.00
10,000 train/test, stratified 0.401 ms 1.754 / 1.754 ms 4.37
10,000 GroupKFold 0.073 ms 0.273 / 0.273 ms 3.75
10,000 StratifiedGroupKFold 0.131 ms 13.605 / 13.604 ms 103.75
10,000 RepeatedKFold 0.600 ms 1.077 / 1.077 ms 1.79
100,000 KFold, seeded 2.372 ms 5.149 / 5.149 ms 2.04
100,000 StratifiedKFold, seeded 3.049 ms 8.921 / 8.919 ms 2.73
100,000 train/test, seeded 1.383 ms 1.089 / 1.089 ms 0.76
100,000 train/test, stratified 4.942 ms 18.467 / 18.464 ms 3.67
100,000 GroupKFold 0.811 ms 4.666 / 4.666 ms 5.16
100,000 StratifiedGroupKFold 1.665 ms 139.613 / 139.602 ms 76.41
100,000 RepeatedKFold 6.916 ms 15.019 / 15.015 ms 2.12
1,000,000 KFold, seeded 27.502 ms 42.268 / 42.267 ms 1.46
1,000,000 StratifiedKFold, seeded 33.369 ms 73.994 / 73.987 ms 2.12
1,000,000 train/test, seeded 14.748 ms 13.843 / 13.842 ms 0.91
1,000,000 train/test, stratified 60.056 ms 190.253 / 190.238 ms 3.15
1,000,000 GroupKFold 11.437 ms 34.160 / 34.153 ms 2.74
1,000,000 StratifiedGroupKFold 16.800 ms 1,400.889 / 1,400.807 ms 80.00
1,000,000 RepeatedKFold 74.085 ms 125.877 / 125.847 ms 1.66

One row is behind: the seeded train/test split past 10,000 rows, at 0.76× and 0.91×. It is one permutation and two index lists, so what it prices is the generator: about 14 ns a draw here against numpy's 10 ns in C. The same draws make the seeded folds, which are ahead because the folds do more work around them.

TimeSeries has no row: both sides describe each block rather than fill it — numpy views of one arange, and a range here — so the harness times two constant-time constructions, 0.0002 ms against 0.2 ms at a million rows, and the ratio says nothing about a caller's cost.

What moved while this was measured, each on the harness above: the seeded train/test split sorted both halves, 56.8 ms at a million rows, and reads them out of a mask now, 14.7 ms; the time series copied each block, 2.0 ms, and describes it now; and the classes were numbered by sorting every label, which put GroupKFold at 37.4 ms against scikit-learn's 32.3, and are hashed first now, 11.4 ms.

The scalers against ML.NET's normalizers (issue #763)

Full method, and why fixZero: false is passed: bench/README.md. Machine: the AMD Ryzen 7 8700G named above, on 2026-09-16. BenchmarkDotNet 0.14.0, one invocation per iteration, five warmups and twenty iterations. Ten features per row; ML.NET's estimator is lazy, so it appears both as a fit and as a fit whose values are read back.

rows operation Lodestar ML.NET 5.0.0 ML.NET / Lodestar
1,000 min-max, fit + transform 68.91 μs 225.28 μs (fit only) 3.27
1,000 min-max, values read 68.91 μs 1,208.41 μs 17.54
1,000 robust, values read 1,189.85 μs 3,521.85 μs 2.96
20,000 min-max, fit + transform 1,106.62 μs 3,614.47 μs (fit only) 3.27
20,000 min-max, values read 1,106.62 μs 9,674.44 μs 8.74
20,000 robust, values read 10,430.85 μs 15,245.13 μs 1.46

MaxAbsScaler costs 65.21 μs and 1,116.54 μs at the two sizes and has no ML.NET row: its normalizers offer mean-variance, min-max, log-mean-variance, robust scaling, binning and L_p norm, and none of them is "divide by the largest absolute value" — NormalizeMinMax(fixZero: true) comes closest and is a different transform.

Both sides scale the same column to the same values, checked row by row to 1e-6 before either was timed — single precision, which is what ML.NET's pipeline carries.

Where the cost is. RobustScaler is about nine times MinMaxScaler here and allocates twice as much: a percentile has to order its column, so it sorts each feature once where the other two take a single pass. numpy.percentile partitions rather than sorting, which is the same asymptotic work with smaller constants and is where to look if that row ever needs to be cheaper. Allocation, at 20,000 rows: 1,563.96 KB for min-max against ML.NET's 1,133.80 KB, and 3,126.26 KB for robust against 4,821.33 KB.

The encoders and the imputer against ML.NET (issue #764)

Full method, and the shape that had to be corrected first: bench/README.md. Machine: the AMD Ryzen 7 8700G named above, on 2026-09-16. BenchmarkDotNet 0.14.0, one invocation per iteration, five warmups and twenty iterations. Twenty categories; ML.NET's estimator is lazy, so it appears as a fit and as a fit whose values are read back.

rows operation Lodestar ML.NET 5.0.0 ML.NET / Lodestar
1,000 one-hot, fit + transform 136.37 μs 195.66 μs (fit only) 1.43
1,000 one-hot, values read 136.37 μs 728.92 μs 5.35
1,000 impute, values read 84.09 μs 650.84 μs 7.74
20,000 one-hot, fit + transform 3,672.62 μs 2,083.19 μs (fit only) 0.57
20,000 one-hot, values read 3,672.62 μs 5,384.35 μs 1.47
20,000 impute, values read 925.39 μs 4,670.33 μs 5.05

Encoders.Ordinal costs 133.73 μs and 2,875.13 μs at the two sizes — about three quarters of the one-hot encoding, at a tenth of the allocation (16.70 KB against 165.24 KB at 1,000 rows), since it produces one column rather than one per category. ML.NET's MapValueToKey is its counterpart and is not measured here: it maps to a key type inside the pipeline rather than to a number a caller holds.

The correction, stated because the first table was wrong. OneHotEncoding takes one named column where this package's encoders take a matrix of however many features, so the first run encoded four features here against one there — four times the work for the same row, reported as 2.8× slower at 20,000. One column on both sides is the comparison above. ReplaceMissingValues does take a vector column, so the imputer rows compare four features against four.

Allocation is where the two differ most on the read: 165.24 KB against 413.76 KB at 1,000 rows, and 3,282.43 KB against 1,212.64 KB at 20,000 — this package materialises every encoded column as a double, where ML.NET's cursor yields rows one at a time and never holds the matrix.

The feature transformers against ML.NET and scikit-learn (issue #1122)

Full method and what each row does and does not compare: bench/README.md. Machine: the AMD Ryzen 7 8700G named above, on 2026-09-23. BenchmarkDotNet 0.14.0, one invocation per iteration, five warmups and twenty iterations. Four features; ML.NET's estimators are lazy, so each appears as a fit whose rows are read.

Only two of the seven have an incumbent in .NET at all. NormalizeLpNorm scales a row to unit norm as Normalizer does; NormalizeBinning cuts a feature into bins but emits a position in [0, 1] rather than the bin, so the two price the same traversal and not the same answer.

rows operation Lodestar ML.NET 5.0.0 ML.NET / Lodestar
1,000 unit-norm rows, values read 28.01 μs 717.09 μs 25.60
1,000 five bins, fit + transform, values read 566.79 μs 1,525.96 μs 2.69
20,000 unit-norm rows, values read 313.21 μs 4,816.59 μs 15.38
20,000 five bins, fit + transform, values read 8,657.46 μs 18,112.77 μs 2.09

The other five have nothing to race. Their own numbers at 20,000 rows and four features, so the shape of the cost is on the record: PolynomialFeatures.Transform 950.72 μs, QuantileTransformer.Fit 7,173.56 μs and its transform 10,806.04 μs, PowerTransformer.Fit 32,350.17 μs against a transform of 1,085.94 μs, and KnnImputer.Transform 38,939.98 μs over its own fixed 2,000 rows. PowerTransformer's fit is many times its transform — it maximises a log-likelihood by Brent's method per feature — and KnnImputer is the one member here that is quadratic in the rows, which is why it is pinned and why the type itself refuses past 100 million distance terms.

Against scikit-learn 1.9.1 and numpy 2.5.3 through compare-transformers, one run of each side, milliseconds per operation, best of five, on the same machine on 2026-10-10 under the repository's machine lock. Ratios above 1 mean Lodestar is faster; several are below it, and that is the finding.

n operation Lodestar scikit-learn, wall / cpu ratio, wall ratio, cpu
10,000 Normalizer 0.195 ms 0.204 / 0.203 ms 1.04 1.04
10,000 PolynomialFeatures 0.560 ms 0.493 / 0.493 ms 0.88 0.88
10,000 KBinsDiscretizer, fit 0.979 ms 1.156 / 1.156 ms 1.18 1.18
10,000 KBinsDiscretizer, transform 0.815 ms 2.734 / 2.733 ms 3.35 3.35
10,000 QuantileTransformer, fit 1.042 ms 2.368 / 2.367 ms 2.27 2.28
10,000 QuantileTransformer, transform 1.138 ms 0.997 / 0.997 ms 0.88 0.87
10,000 PowerTransformer, fit 4.457 ms 38.864 / 38.855 ms 8.72 8.56
10,000 PowerTransformer, transform 0.972 ms 0.464 / 0.464 ms 0.48 0.48
10,000 KnnImputer, transform 4.070 ms 20.073 / 240.324 ms 4.93 59.06
10,000 LabelEncoder 0.968 ms 0.459 / 0.459 ms 0.47 0.47
100,000 Normalizer 1.131 ms 1.107 / 1.107 ms 0.98 0.75
100,000 PolynomialFeatures 3.586 ms 4.663 / 4.662 ms 1.30 1.08
100,000 KBinsDiscretizer, fit 9.820 ms 4.842 / 4.842 ms 0.49 0.46
100,000 KBinsDiscretizer, transform 4.292 ms 10.809 / 10.807 ms 2.52 2.14
100,000 QuantileTransformer, fit 10.103 ms 11.789 / 11.788 ms 1.17 1.10
100,000 QuantileTransformer, transform 10.421 ms 9.705 / 9.636 ms 0.93 0.90
100,000 PowerTransformer, fit 47.071 ms 296.342 / 295.848 ms 6.30 5.99
100,000 PowerTransformer, transform 8.554 ms 3.269 / 3.269 ms 0.38 0.37
100,000 KnnImputer, transform 4.027 ms 17.388 / 207.921 ms 4.32 51.64
100,000 LabelEncoder 12.174 ms 4.882 / 4.876 ms 0.40 0.39

Where this package wins, it wins on the work rather than on the loop. The discretizer's transform is a binary search per value against a numpy.digitize that builds an index array, and the power fit runs scipy's own searches — brent bracketed from (-2, 2) for Box-Cox, fminbound for Yeo-Johnson — compiled rather than driven from Python, its variance taken directly where it holds and in scipy's logarithms only where a power overflows or cancels. KnnImputer is ahead on both clocks: the nearest donors are kept in a bounded heap per cell, where the reference computes and sorts every pairwise distance on every core.

Where it loses, it loses to vectorised C, and the losses are where a single array operation does the whole job: PolynomialFeatures at 10,000 rows multiplies column by column there, LabelEncoder is one numpy.unique, and the quantile map is one numpy.interp. PowerTransformer's transform is the widest loss, 0.38× at 100,000 rows: it is scipy's expm1(λ · log1p(x)) / λ per value, with log1p and expm1 written here, where numpy runs the same per element in vectorised C — the form the reference's values come from (#1227).

KBinsDiscretizer's fit at 100,000 rows sorts each column with Array.Sort against numpy's introsort over a contiguous buffer, and 0.49× is about the constant factor that costs.

Sparse one-hot encoding against scikit-learn (issue #1161)

Full method: bench/README.md. The incumbent for the dense encoding is ML.NET's OneHotEncoding, measured above; neither it nor any other .NET encoder groups infrequent categories or returns scikit-learn's sparse layout, so the reference, scikit-learn 1.9.1 on numpy 2.5.3, is the one compared here. Machine: AMD Ryzen 7 8700G w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores, .NET 10.0.12, under the repository's machine lock, 2026-09-25 15:42 to 15:43 UTC, one core of sixteen taken by an unrelated process throughout. Milliseconds for a fit and the sparse transform of the fitted rows, best of five: three integer features with skewed counts, up to 997 categories each.

rows encoding Lodestar scikit-learn, wall / cpu ratio, cpu
10,000 every category 1.26 ms 2.39 / 2.39 ms 1.89
10,000 min_frequency=5, max_categories=200 1.43 ms 2.96 / 2.96 ms 2.07
100,000 every category 12.3 ms 21.2 / 21.2 ms 1.67
100,000 min_frequency=5, max_categories=200 12.2 ms 24.7 / 24.7 ms 1.99

How it reads. Ahead on every row, by 1.7× to 2.1×; grouping the tail costs scikit-learn a fifth more and this package nothing measurable, since the grouping is one pass over the counts the fit already takes.