Skip to content

[Data][PreProc][4/4] Enable aggregation-based preprocessors by default; migrate remaining scalers - #66092

Merged
goutamvenkat-anyscale merged 1 commit into
ray-project:masterfrom
abhishekverma-ray:preproc-4-flag-flip
Oct 26, 2026
Merged

goutamvenkat-anyscale merged 1 commit into
ray-project:masterfrom
abhishekverma-ray:preproc-4-flag-flip

Conversation

@abhishekverma-ray

@abhishekverma-ray abhishekverma-ray commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Turns on deferred, batched Chain fitting 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_preprocessors defaulted to False, so Chain.fit() fitted members one by one: one full dataset scan per fittable member.
  • Even with the flag on, MinMaxScaler, MaxAbsScaler and RobustScaler ran their own Dataset.aggregate() inside _fit, so any chain containing them fell back to the sequential path.

After

  • The flag defaults to True: Chain.fit() registers members' aggregations and computes them at the first transform() (or transform_batch() / serialization), one scan per dependency level. Opt out with RAY_DATA_ENABLE_AGGREGATION_BASED_PREPROCESSORS=0 or the DataContext setting. The flip is isolated in this PR so it's trivially revertible.
  • MinMaxScaler (Min/Max), MaxAbsScaler (AbsMax) and RobustScaler register aggregators on the stat computation plan and support deferred fit.
  • RobustScaler now builds one single-quantile ApproximateQuantile sketch 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 every stats_ key unchanged.
  • All 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 added
Loading

Subsystems

Preprocessors (preprocessors/scaler.py, default in context.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 expected stats_. Existing test_scaler.py assertions 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.
  • pre-commit, pyrefly (CI file set) and the API param coverage check pass.

AI assistance was used to draft this change (Claude Code); every line has been reviewed by the submitter.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

This pull request has been automatically marked as stale because it has not had
any activity for 14 days. It will be closed in another 14 days if no further activity occurs.
Thank you for your contributions.

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.

@github-actions github-actions Bot added the stale The issue is stale. It will be closed within 7 days unless there are further conversation label Sep 25, 2026
@github-actions github-actions Bot added unstale A PR that has been marked unstale. It will not get marked stale again if this label is on it. and removed stale The issue is stale. It will be closed within 7 days unless there are further conversation labels Sep 29, 2026
@abhishekverma-ray
abhishekverma-ray force-pushed the preproc-4-flag-flip branch 3 times, most recently from 714ed45 to 90e7ea4 Compare October 5, 2026 06:48
@abhishekverma-ray
abhishekverma-ray force-pushed the preproc-4-flag-flip branch 3 times, most recently from e2b15b8 to 062b3e7 Compare October 9, 2026 10:14
goutamvenkat-anyscale pushed a commit that referenced this pull request Oct 16, 2026
…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>
@abhishekverma-ray
abhishekverma-ray force-pushed the preproc-4-flag-flip branch 4 times, most recently from 378121e to 351b3c9 Compare October 23, 2026 03:13
…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>
@abhishekverma-ray
abhishekverma-ray marked this pull request as ready for review October 26, 2026 05:04
@abhishekverma-ray
abhishekverma-ray requested a review from a team as a code owner October 26, 2026 05:04
@abhishekverma-ray abhishekverma-ray added data Ray Data-related issues go add ONLY when ready to merge, run all tests labels Oct 26, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +651 to +662
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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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,
)

@goutamvenkat-anyscale
goutamvenkat-anyscale merged commit 88f1ffe into ray-project:master Oct 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data Ray Data-related issues go add ONLY when ready to merge, run all tests unstale A PR that has been marked unstale. It will not get marked stale again if this label is on it.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants