Skip to content

feat: add optional resampler= kwarg to AutoML.fit for per-fold class-imbalance handling (#1200) - #1568

Open
Imran Ahamed (immu4989) wants to merge 7 commits into
microsoft:mainfrom
immu4989:flaml-feature-1200-resampler-kwarg
Open

feat: add optional resampler= kwarg to AutoML.fit for per-fold class-imbalance handling (#1200)#1568
Imran Ahamed (immu4989) wants to merge 7 commits into
microsoft:mainfrom
immu4989:flaml-feature-1200-resampler-kwarg

Conversation

@immu4989

Copy link
Copy Markdown
Contributor

Why are these changes needed?

Implements the design agreed on #1200 (option 3 — ship a minimal resampler= kwarg, off by default). Closes the long-standing feature request from petrosDemetrakopoulos (open since 2023) to plug an imbalanced-learn-style resampler into FLAML's cross-validation loop without leaking synthesized samples into the validation folds.

The benchmark I posted earlier on two synthetic imbalance levels found that per-fold SMOTE doesn't materially outperform the pre-applied SMOTE workaround FLAML currently recommends, so this PR ships the integration as an opt-in convenience rather than a claimed quality improvement. Users who already roll their own per-fold pipelines get a cleaner path; users who don't get zero behavior change.

What the PR does

flaml/automl/automl.pyAutoML.fit:

  • New resampler=None keyword-only argument (any object exposing fit_resample(X, y) -> (X, y) — matches imbalanced-learn's BaseSampler protocol).
  • Validation: raises ValueError if both resampler and sample_weight are passed (resampling breaks the 1-to-1 row alignment with weights; silent behavior in either direction would be a footgun).
  • Validation: raises TypeError if the object doesn't expose fit_resample. Both errors fire at fit() entry, not mid-fold.
  • Attaches the resampler to the task instance (task._resampler = resampler) so the per-fold hook can find it without a signature change downstream.

flaml/automl/ml.pyget_val_loss:

  • Just before estimator.fit, if a resampler is set on task, clones it via sklearn.base.clone (each fold gets an identical starting random_state; the caller-provided instance stays untouched) and calls X_train, y_train = fold_sampler.fit_resample(X_train, y_train).
  • X_val, y_val are left at the raw class distribution — this is the entire point of the request and is asserted by the test_resampler_leaves_validation_untouched test.
  • Single insertion point covers both the CV path (evaluate_model_CVget_val_loss) and the holdout path (compute_estimatorget_val_loss).

test/automl/test_resampler.py — new file, five tests:

  1. test_resampler_changes_chosen_config — passing SMOTE(...) on a 6%-minority dataset picks a different best_config than the same fit without the kwarg (proxy that the per-fold hook actually fires; a no-op would land on the identical config).
  2. test_resampler_leaves_validation_untouched — final best_loss (1 − F1) stays in [0, 1] on the raw imbalanced validation folds; a leak would drive it out of range or below the reachable minimum.
  3. test_resampler_with_sample_weight_raises — expected ValueError.
  4. test_resampler_without_fit_resample_raises — expected TypeError.
  5. test_resampler_none_is_default_and_noopresampler=None produces identical best_config, best_estimator, and predict(X) output as omitting the kwarg entirely; guarantees backward compatibility for existing users.

imbalanced-learn is not added as a FLAML dependency. The tests use pytest.importorskip so they skip cleanly when it's not installed; end-users install it themselves if they want to pass a SMOTE object.

Verified locally

  • New tests: pytest test/automl/test_resampler.py — 5/5 pass.
  • Regression check: pytest test/automl/test_split.py test/automl/test_preprocess_api.py — 18/18 pass.
  • pre-commit run --files flaml/automl/automl.py flaml/automl/ml.py test/automl/test_resampler.py — all hooks pass.

Related issue

Closes #1200.

Checks

Copilot AI 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.

Pull request overview

This PR adds an opt-in resampler= hook to AutoML.fit to support per-fold resampling (e.g., SMOTE-style) inside FLAML’s CV/holdout evaluation loop, avoiding synthesized-sample leakage into validation folds as requested in issue #1200.

Changes:

  • Add resampler=None parameter to AutoML.fit, validate inputs, and store the resampler on the task for downstream access.
  • Apply per-fold resampling in get_val_loss by cloning the provided resampler and calling fit_resample(X_train, y_train) before estimator.fit(...).
  • Add a new test module to exercise the new API and its validation behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
flaml/automl/automl.py Introduces resampler argument and performs early validation / task attachment.
flaml/automl/ml.py Applies the resampler per fold/call (clone + fit_resample) just before estimator training.
test/automl/test_resampler.py Adds tests for the new resampler= behavior and validation.

Comment thread flaml/automl/automl.py
Comment on lines 1845 to 1849
mlflow_logging=None,
fit_kwargs_by_estimator=None,
mlflow_exp_name=None,
resampler=None,
**fit_kwargs,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a40ebc0. The fit signature now places resampler after *, so it can only be passed as a keyword argument.

Comment thread flaml/automl/automl.py Outdated
Comment on lines +2348 to +2360
if resampler is not None:
if "sample_weight" in fit_kwargs:
raise ValueError(
"Cannot combine 'resampler' with 'sample_weight' — resampling breaks "
"the 1-to-1 row alignment with sample weights. Use either resampling "
"or sample weighting, not both."
)
if not hasattr(resampler, "fit_resample"):
raise TypeError(
"'resampler' must expose a fit_resample(X, y) -> (X, y) method "
"(e.g., an imbalanced-learn BaseSampler such as SMOTE)."
)
task._resampler = resampler

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a40ebc0 and ab11996. The effective fit_kwargs_by_estimator settings are resolved before sample-weight validation, fit_resample must be callable, clone compatibility is checked at fit entry, and task._resampler is assigned on every fit so omitted values reset prior state.

Comment thread test/automl/test_resampler.py Outdated
Comment on lines +61 to +63
imblearn = pytest.importorskip("imblearn.over_sampling")
SMOTE = imblearn.SMOTE

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a40ebc0. The core tests now use a local deterministic DuplicateMinorityResampler, so they run without imbalanced-learn. A separate optional integration test still verifies SMOTE when imbalanced-learn is installed.

Comment on lines +84 to +110
def test_resampler_leaves_validation_untouched():
"""Sanity check: the CV validation partitions must retain the raw class
distribution. If SMOTE were leaking into the validation folds, the search
would perceive an artificially balanced eval set and the val_loss reported
by the resampled fit would be systematically better than what the same
model achieves on the raw distribution.

We approximate this by asserting the final CV val_loss for the resampled
fit is not negative (it is 1 - f1, which is bounded in [0, 1] on a raw
imbalanced validation set with a non-trivial model).
"""
imblearn = pytest.importorskip("imblearn.over_sampling")
SMOTE = imblearn.SMOTE

X, y = _imbalanced_dataset(seed=1)
resampled = AutoML()
resampled.fit(
X_train=X,
y_train=y,
resampler=SMOTE(random_state=1, k_neighbors=3),
seed=1,
**_fit_settings(),
)
assert 0.0 <= resampled.best_loss <= 1.0, (
f"best_loss ({resampled.best_loss}) outside expected [0, 1] range for 1-f1 on a "
"raw imbalanced validation fold — validation may have been resampled"
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a40ebc0. The test now uses a custom metric to record each raw validation-fold class rate and asserts that it remains imbalanced, while the deterministic resampler counters verify that training partitions grow.

Comment thread test/automl/test_resampler.py Outdated
Comment on lines +52 to +81
def test_resampler_changes_chosen_config():
"""Passing a resampler should influence the search — the chosen best_config
on an imbalanced dataset with SMOTE will differ from the same fit without.

This is a proxy for verifying the per-fold hook actually fires; if the
hook were a no-op, both fits would land on the same best_config since
everything else about the search is deterministic (same seed, same
estimator, same data).
"""
imblearn = pytest.importorskip("imblearn.over_sampling")
SMOTE = imblearn.SMOTE

X, y = _imbalanced_dataset(seed=0)

baseline = AutoML()
baseline.fit(X_train=X, y_train=y, seed=42, **_fit_settings())

resampled = AutoML()
resampled.fit(
X_train=X,
y_train=y,
resampler=SMOTE(random_state=42, k_neighbors=3),
seed=42,
**_fit_settings(),
)

assert baseline.best_config != resampled.best_config, (
"resampler=SMOTE(...) did not change the chosen best_config vs baseline; "
"the per-fold resampling hook may not be firing"
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a40ebc0. The best-config comparison was removed and replaced with deterministic call-count and input/output-size assertions that directly verify each training-fold resampling hook.

Copilot AI 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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

flaml/automl/automl.py:2175

  • The user-facing Production-Deployment guide still says applying SMOTE upstream is the current recommendation and describes resampler= as a future integration (website/docs/Use-Cases/Production-Deployment.md:229). After this API ships, that guidance is stale and continues directing users toward the leakage-prone workaround; update the guide in this PR.
                by default. See issue #1200 for the design discussion and benchmarks.

flaml/automl/automl.py:2357

  • hasattr accepts a non-callable attribute, so a cloneable object with fit_resample = None passes entry validation and fails mid-fold with an unrelated 'NoneType' object is not callable error. Check callability to preserve the documented fail-fast behavior.
            if not hasattr(resampler, "fit_resample"):

flaml/automl/automl.py:2350

  • This validation checks only the argument passed directly to fit(), but fit_kwargs_by_estimator is resolved from AutoML(...) settings at line 2385. Thus AutoML(fit_kwargs_by_estimator={"lgbm": {"sample_weight": ...}}).fit(..., resampler=...) bypasses the error and reaches estimator fitting with weights shorter than the resampled data. Resolve the effective settings before checking them.

This issue also appears on line 2357 of the same file.

            weight_sources = [fit_kwargs] + list((fit_kwargs_by_estimator or {}).values())
            if any("sample_weight" in kw for kw in weight_sources):

flaml/automl/automl.py:2175

  • The public contract is inaccurate: a plain object exposing fit_resample is rejected unless sklearn.clone-compatible, while the implementation also applies the sampler to holdout evaluation rather than only CV folds. Document both constraints/paths so callers can select a compatible object and understand holdout behavior.

This issue also appears on line 2175 of the same file.

            resampler: object, default=None | An imbalanced-learn-compatible resampler
                (any object exposing `fit_resample(X, y) -> (X, y)`, such as
                `imblearn.over_sampling.SMOTE`). When set, the resampler is cloned and
                applied to each cross-validation fold's training partition before the
                estimator is fitted — validation partitions are left at the raw class

Comment thread flaml/automl/ml.py Outdated
Comment on lines +528 to +535
resampler = getattr(task, "_resampler", None)
if resampler is not None:
from sklearn.base import clone

# Clone per fold/call so each estimator fit sees the same starting
# random_state; the caller-provided resampler stays untouched.
fold_sampler = clone(resampler)
X_train, y_train = fold_sampler.fit_resample(X_train, y_train)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in ab11996. A shared cloning helper now applies the resampler during fold evaluation, final and retrain fitting, and ensemble training. Counter-based regression tests cover the max_iter=1 final-fit path and ensemble training; all 13 focused tests and pre-commit checks pass.

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread flaml/automl/automl.py
Comment on lines +3343 to +3347
X_ensemble_train, y_ensemble_train = _resample_training_data(
self._X_train_all,
self._y_train_all,
self._state.task,
)
- `class_weight` is passed through to the underlying estimator unchanged if your chosen estimator accepts it (e.g., LightGBM, XGBoost sklearn API).

For severe class imbalance, see also [issue #1200](https://github.com/microsoft/FLAML/issues/1200) on adding a `resampler=` integration. The current recommendation is to apply SMOTE (or your resampler of choice) upstream of `AutoML.fit`; see the imbalanced-learn documentation for the canonical pattern.
For severe class imbalance, pass a cloneable imbalanced-learn sampler with `resampler=`:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support of SMOTE with cross-validation

3 participants