Repository navigation
feat: opt-in fused estimator fits, one aggregation per dependency layer - #84
Conversation
…rom-row part Estimators whose fit is a single aggregation over the rows now expose it in two parts on BaseEstimator: - can_fit_by_aggregation(dataset): whether the fit is one aggregation. - fit_aggregations(dataset, prefix): helper columns and aggregation expressions (may add columns, never filters, explodes or samples rows). - fit_from_row(row, prefix): builds the fitted transformer from the result row. _fit_by_aggregation(dataset, persist) is the normal fit built from these two parts, so the default behaviour is unchanged (including the persist the standard scalers already did). fit() is split into prepare_fit_dataset / finish_fit so both parts can be driven from outside the estimator. Converted: ConditionalStandardScale (standard and binary), StandardScale, MinMaxScale, Impute (scalar inputs), and StringIndex, OneHotEncode, SharedStringIndex, SharedOneHotEncode (alphabetical string orders, scalar inputs; labels collected with collect_set and ordered on the driver exactly as collect_labels_array orders them). Nested-array scaler inputs, array inputs, frequency orders and SingleFeatureArrayStandardScale keep their own fit. Fitted values are bitwise identical to 3.2.0 across the parameter combinations of every converted estimator.
Adds fuseEstimatorFits to KamaeSparkPipeline (default False). When set, the estimators are grouped into dependency layers (an estimator is in the layer after the deepest estimator it depends on, through any column it reads, including relevanceCol/maskCols). Each layer is fitted in one pass over the data: part one (fit_aggregations) of every estimator that fits by aggregation, a single agg(), then part two (fit_from_row) of each. Nothing is persisted. Estimators that cannot fit by aggregation, or set their own sampleFraction, are fitted normally within their layer. Safeguards: - the pipeline is fitted normally (with a warning) if a layer would give an estimator a different version of a column than the normal fit, i.e. a column rewritten by a stage in the same layer; - an estimator whose helper columns add a row generator (e.g. explode) is fitted on its own, with a warning; - on failure, every estimator is pointed back at its original input columns. Ignored when fitSampleFraction is set. On a 17-estimator pipeline over 25.6M rows the fit went from 2h44m to ~14 min, with identical vocabularies and scaler moments within 1e-14 relative of the normal fit.
georyetti
left a comment
There was a problem hiding this comment.
One nit picky comment on potential collision naming of columns
I really like this addition thanks @ArranBasra !!!
| if own_sample or not estimator.can_fit_by_aggregation(dataset): | ||
| fitted[index] = estimator.fit(dataset) | ||
| continue | ||
| prefix = f"__fused_{index}__" |
There was a problem hiding this comment.
Nit picky comment, can we make this name collision proof? Very rare but this could already exist in the dataframe
There was a problem hiding this comment.
Good point, now using a random tmp prefix like elsewhere in Kamae. If it collides with another column it tries a new random prefix and added collision test.
The helper columns of a fused fit used a fixed prefix (`__fused_<index>__`), which could clash with a column already in the dataset. Use a random prefix (`__fused_<random>_<index>__`, from the same generator as the temporary cast columns) and pick another if any existing column starts with it.
mantasu
left a comment
There was a problem hiding this comment.
Was reviewing this with claude, it caught a couple things:
-
Copied estimators can fit on the wrong cast column. A
.copy()keeps the same tmp_column_suffix. In the shared fused frame, copies reading the same input column with differentinputDtypes overwrite or reuse each other's cast column. Reproduced: adoublecopy got thefloatmean, with no warning. Fix: a fresh suffix per estimator when fusing. -
Label estimators can run out of memory on high-cardinality columns, even with fusion off. With alphabetical orders they now use collect_set, which sends every distinct label to the driver before maxNumLabels is applied. Suggest keeping the old distributed
distinct()+take(n)unless fusing.
Copies of an estimator share its temporary column suffix. In the shared frame of a fused fit, copies reading the same input column with different inputDtypes created the same casted column, so one fitted on the other's cast. Each fused estimator now gets a fresh suffix for the fit, restored afterwards.
F.collect_set gathers every distinct label in one aggregation buffer and sends them to the driver before maxNumLabels is applied, which can run out of memory on high-cardinality columns. The label estimators' normal fit goes back to collect_labels_array (distributed distinct and sort, take(maxNumLabels)), so it is unchanged from before. collect_set is only used when fitting fused, and not for estimators that set maxNumLabels.
|
Thanks Mantas I have addressed both comments.
|
Description
Fitting a pipeline currently runs one or more Spark passes per estimator. Each pass re-reads the input and recomputes the upstream transforms (including any explode), and the standard scalers also persist their whole input frame, which forces every column to be computed and cached for a fit that only reads one of them. On wide data with many estimators the fit is dominated by this repetition.
This PR adds an opt-in
fuseEstimatorFitsoption toKamaeSparkPipelinethat fits every estimator of a dependency layer in one aggregation, one pass over the data per layer, with nothing persisted. Default fit results are unchanged.Two commits, meant to be reviewed separately:
1.
refactor:split estimator fits into an aggregation part and a build-from-row part (no change in fitted values)Estimators whose fit is a single aggregation now expose it in two parts on
BaseEstimator:fit_aggregations(dataset, prefix): the helper columns and aggregation expressions (may add columns, never filters, explodes or samples rows);fit_from_row(row, prefix): builds the fitted transformer from the result row (driver only);can_fit_by_aggregation(dataset): whether the estimator fits this way (defaultFalse).The normal fit (
_fit_by_aggregation) is built from these two parts, keeping the existing persist where estimators already had it. Converted:ConditionalStandardScale(standard and binary),StandardScale,MinMaxScale,Impute(scalar inputs), andStringIndex,OneHotEncode,SharedStringIndex,SharedOneHotEncode(alphabetical string orders, scalar inputs). Nested-array scaler inputs, array inputs, frequency orders andSingleFeatureArrayStandardScalekeep their own fit.The label estimators' normal fit is unchanged (collect_labels_array). When fitted fused, their labels are collected with F.collect_set in the shared aggregation and ordered on the driver exactly as collect_labels_array orders them. Label estimators with maxNumLabels set keep the distributed path even when fusing, since collect_set gathers every distinct label before the limit is applied.
2.
feat:opt-in fused estimator fits (fuseEstimatorFits, defaultFalse)*Col/*Colsparams such asrelevanceColandmaskCols).agg(), then part two of each. Other estimators (or those with their ownsampleFraction) are fitted normally within their layer.fitSampleFractionis set.Results
On a 17-estimator pipeline (9 scalers, 8 label estimators, 1 dependency layer) over 25.6M rows on a 12-node cluster:
fuseEstimatorFits=TrueTesting
skipZeros,nanFillValue, binary scaling, mask tokens,maxNumLabels, nested and multi-column inputs).tests/kamae/spark/pipeline/:test_fused_fit.py: fused vs normal fit per estimator, label ordering vscollect_labels_array, fallbacks, nested-array inputs, the row-generator safeguard;test_fused_fit_layers.py: multi-layer graphs (chain, diamond, nested pipeline, transforms after unrelated estimators), asserting one fused aggregation per layer;test_fused_fit_regressions.py: auxiliary columns as dependencies, masks, empty or fully-masked data, copied estimators sharing a uid, special characters in uids, reset on failure, rewritten columns, and that the normal fit releases its persisted data.Checklists
The new layer / transformer checklists do not apply: no new transformers, estimators or Keras layers are added.