Repository navigation
[Data][PreProc][4/4] Enable aggregation-based preprocessors by default; migrate remaining scalers - #66092
Conversation
|
This pull request has been automatically marked as stale because it has not had You can always ask for help on our discussion forum or Ray's public slack channel. If you'd like to keep this open, just leave any comment, and the stale label will be removed. |
a614cb7 to
98cb876
Compare
714ed45 to
90e7ea4
Compare
e2b15b8 to
062b3e7
Compare
062b3e7 to
606b7eb
Compare
…ggregation (#66088) ## Why are these changes needed? Encoder preprocessors (`OrdinalEncoder`, `OneHotEncoder`, `MultiHotEncoder`, `LabelEncoder`, `Categorizer`) currently fit through `compute_unique_value_indices`: a `map_batches` that emits per-block value counters, followed by a loop **on the driver** that merges them into global counts. `SimpleImputer(strategy="most_frequent")` has the same shape via `_get_most_frequent_values`. That driver-side merge is a scalability bottleneck, and because it is registered as a *callable stat*, it can never be batched with other statistics into a single `Dataset.aggregate()` query. This PR moves those fits onto the stat computation plan as real distributed aggregations: - The five encoders register `Unique` (or `TopKUnique` when `max_categories` is set) with the same `unique_values({col})` alias the transform code reads. - `SimpleImputer("most_frequent")` registers `TopKUnique(k=1, ignore_nulls=True)`. It returns the most frequent non-null value, breaks frequency ties by picking the smallest value, and yields no value for an all-null column, which gives the same results as before. It uses the vectorized column-wise merge, so no driver-side argmax helper is needed. - `Unique` (and `TopKUnique`) fall back to collecting values in Python when the vectorized path can't handle a column's type: types without an Arrow `unique`/`value_counts` kernel, such as tensor columns, and list-typed columns when the optional `polars` package isn't installed. Lists and arrays become tuples, so the encoders produce the same categories as before. `HASH` list encoding still requires `polars`, since a Python fallback can't reproduce polars' hashes. Behavior is preserved: `stats_` key names, the "contains null values … consider imputing" errors, list-column encoding (flatten vs whole-list), and global cross-partition top-k semantics for `max_categories` are all unchanged, which the existing suites verify. Each fit is now a single `Dataset.aggregate()` query per preprocessor with no driver-side merge. The legacy helpers `compute_unique_value_indices` and `_get_most_frequent_values` are removed; nothing uses them. Behavior notes for reviewers: - `ds.aggregate(Unique(...))` on a tensor column, or on a list column without `polars` installed, now works instead of raising. This comes from the fallback above and benefits any `Unique` user, not only the encoders. - Whole-list categories stay whole lists when merged from several partial aggregates, with or without `polars` (see the new tests below). - With `max_categories`, a null tied with a real value at the cut-off no longer raises "contains null values": nulls now rank last, whereas the old path broke ties by insertion order. Otherwise it raises in the same cases as before. - Pre-existing and unchanged: a block that holds *only* null rows of a list column is typed `null` rather than `list`, so its nulls count as a value and the fit raises. This depends on how rows are split into blocks, as it did before this PR; I'll file a follow-up. ## Related issue number N/A. Not a duplicate: searched open PRs/issues for encoder/imputer fit work; no overlap found (#66012 covers GPU transforms, not fitting). Part 2 of 4 of the preprocessor fitting series (RFC: #66090). Part 1 (`TopKUnique`, #66087) has merged; parts 3 and 4 (#66091, #66092) build on this. ## Checks - [x] I've signed off every commit (DCO). - [x] I've run pre-commit hooks — all hooks pass. - [x] Testing (macOS / Python 3.12, source build): - `pytest python/ray/data/tests/preprocessors/ --ignore=.../test_torch.py` (torch not installed) — 411 passed, 5 skipped, 0 failed (clean master at the same base: 392 passed). Includes `test_preprocessors_arrow_backed.py`. Env matches CI pins (pandas 2.3.3, pyarrow 23.0.1, cloudpickle 3.1.1). - `pytest python/ray/data/tests/test_aggregations.py` — 62 passed (clean master: 57). - New tests: encoders/imputer assert they register plan aggregators (no callable stats); most_frequent never imputes null (including `None`, `NaN`, `pd.NA` and float32 `NaN`, even when they outnumber every real value); deterministic tie-break; most_frequent picks the value from counts summed across blocks; whole-list categories (`OrdinalEncoder(encode_lists=False)`, `OneHotEncoder`) stay whole lists when merged from several partial aggregates; encoders fit tensor columns (whole arrays, flattened arrays, `max_categories`); whole-list encoding with and without `polars`; `Unique` on tensor columns and on list columns without `polars`; `HASH` mode still requires `polars`; a null row in a list column adds no categories. AI assistance was used to draft this change (Claude Code); every line has been reviewed by the submitter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Abhishek Verma <abhishek.verma@anyscale.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
378121e to
351b3c9
Compare
…aining scalers Flips DataContext.enable_aggregation_based_preprocessors to default on, so Chain.fit() defers and batches member statistics computation (one dataset scan per dependency level instead of one per member). Opt out with RAY_DATA_ENABLE_AGGREGATION_BASED_PREPROCESSORS=0 or the DataContext setting. Also moves the last eagerly-fitting scalers onto the stat computation plan so they benefit from batching inside chains: - MinMaxScaler: Min/Max via add_aggregator. - MaxAbsScaler: AbsMax via add_aggregator. - RobustScaler: one single-quantile ApproximateQuantile sketch per statistic (low_quantile/median/high_quantile), keeping the existing stats_ keys. This builds three sketches per column instead of one queried at three points - a deliberate trade of some sketch-update work for unchanged public statistics keys. All stats_ key layouts are unchanged. The KBins discretizers keep their eager fit (they post-process inside _fit_execute); chains containing them, or any preprocessor without _supports_deferred_fit, continue to fit sequentially as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Abhishek Verma <abhishek.verma@anyscale.com>
351b3c9 to
8be0706
Compare
There was a problem hiding this comment.
Code Review
This pull request enables aggregation-based preprocessors by default and implements deferred fitting support for MinMaxScaler, MaxAbsScaler, and RobustScaler by refactoring their _fit methods to register aggregators with the stat computation plan. Additionally, the test suite is updated to verify that multiple scalers in a chain can defer fitting and execute in a single aggregation query. The review feedback correctly identifies a potential late-binding issue in RobustScaler._fit, where self._quantile_precision is captured from the outer scope of a deferred lambda, and suggests binding it as a default argument to ensure safety during serialization or mutation.
| self._stat_computation_plan.add_aggregator( | ||
| aggregator_fn=lambda col, q=quantile, name=stat_name: ( | ||
| ApproximateQuantile( | ||
| on=col, | ||
| quantiles=[q], | ||
| quantile_precision=self._quantile_precision, | ||
| alias_name=f"{name}({col})", | ||
| ) | ||
| ), | ||
| post_process_fn=unpack_quantile, | ||
| columns=self._columns, | ||
| ) |
There was a problem hiding this comment.
In RobustScaler._fit, the lambda function for aggregator_fn captures self._quantile_precision from the outer scope. Since this lambda is executed deferredly during statistics materialization (which happens later during transform), any potential mutation or serialization/deserialization of the preprocessor instance could lead to unexpected behavior or lookup issues. Capturing self._quantile_precision as a default argument (e.g., prec=self._quantile_precision) at definition time ensures that the value is safely bound when _fit is called, matching the behavior of q=quantile and name=stat_name.
| self._stat_computation_plan.add_aggregator( | |
| aggregator_fn=lambda col, q=quantile, name=stat_name: ( | |
| ApproximateQuantile( | |
| on=col, | |
| quantiles=[q], | |
| quantile_precision=self._quantile_precision, | |
| alias_name=f"{name}({col})", | |
| ) | |
| ), | |
| post_process_fn=unpack_quantile, | |
| columns=self._columns, | |
| ) | |
| self._stat_computation_plan.add_aggregator( | |
| aggregator_fn=lambda col, q=quantile, name=stat_name, prec=self._quantile_precision: ( | |
| ApproximateQuantile( | |
| on=col, | |
| quantiles=[q], | |
| quantile_precision=prec, | |
| alias_name=f"{name}({col})", | |
| ) | |
| ), | |
| post_process_fn=unpack_quantile, | |
| columns=self._columns, | |
| ) |
Summary
Turns on deferred, batched
Chainfitting by default (added behind a flag in #66091), and moves the three remaining scalers onto aggregation-based fitting so they batch inside chains too.Before
DataContext.enable_aggregation_based_preprocessorsdefaulted toFalse, soChain.fit()fitted members one by one: one full dataset scan per fittable member.MinMaxScaler,MaxAbsScalerandRobustScalerran their ownDataset.aggregate()inside_fit, so any chain containing them fell back to the sequential path.After
True:Chain.fit()registers members' aggregations and computes them at the firsttransform()(ortransform_batch()/ serialization), one scan per dependency level. Opt out withRAY_DATA_ENABLE_AGGREGATION_BASED_PREPROCESSORS=0or theDataContextsetting. The flip is isolated in this PR so it's trivially revertible.MinMaxScaler(Min/Max),MaxAbsScaler(AbsMax) andRobustScalerregister aggregators on the stat computation plan and support deferred fit.RobustScalernow builds one single-quantileApproximateQuantilesketch per statistic (low_quantile,median,high_quantile) instead of one sketch queried at three points. That's a little more sketch work, in exchange for keeping everystats_key unchanged.stats_keys and values are unchanged. KBins discretizers keep their eager fit (they post-process in_fit_execute); chains containing them, or any member without_supports_deferred_fit, still fit sequentially.flowchart TD subgraph Before subgraph BS[Subsystem: preprocessors] subgraph BC["Component: Chain.fit() (flag default off)"] B1[MinMaxScaler._fit: own aggregate scan] --> B2[MaxAbsScaler._fit: own aggregate scan] B2 --> B3[RobustScaler._fit: own aggregate scan] B3 --> B4[StandardScaler._fit: own aggregate scan] end end end subgraph After subgraph AS[Subsystem: preprocessors] subgraph AC["Component: Chain.fit() (flag default on)"] A1[MinMax / MaxAbs / Robust / Standard: register aggregators on plan] A1 --> A2["first transform(): one Dataset.aggregate() per dependency level"] end end end classDef removed fill:#ffcdd2,stroke:#c62828,color:#000 classDef added fill:#c8e6c9,stroke:#2e7d32,color:#000 classDef changed fill:#fff9c4,stroke:#f9a825,color:#000 class B1,B2,B3,B4 removed class A1 changed class A2 addedSubsystems
Preprocessors (
preprocessors/scaler.py, default incontext.py); computes statistics through Shuffle / aggregation (Dataset.aggregate) with no changes there.Related issue
RFC: #66090. Final PR of the stack #66087 → #66088 → #66091 (all merged). Not a duplicate: the only open PR touching this flag is this one.
Testing
macOS / Python 3.12, env pinned to CI versions (pandas 2.3.3, pyarrow 23.0.1, cloudpickle 3.1.1, pytest 7.4.4):
pytest python/ray/data/tests/preprocessors/test_chain.py python/ray/data/tests/preprocessors/test_scaler.py: 64 passed, including a new test that a chain of MinMax/MaxAbs/Robust/Standard scalers on disjoint columns fits in a single aggregation query with the exact expectedstats_. Existingtest_scaler.pyassertions pass unmodified.pytest python/ray/data/tests/preprocessors/ --ignore=python/ray/data/tests/preprocessors/test_torch.py(torch not installed): 442 passed, 5 skipped, 0 failed (master: 441 passed).pytest python/ray/data/tests/test_aggregations.py: 62 passed.AI assistance was used to draft this change (Claude Code); every line has been reviewed by the submitter.
🤖 Generated with Claude Code