Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions flaml/automl/automl.py
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,7 @@ def fit(
mlflow_logging=None,
fit_kwargs_by_estimator=None,
mlflow_exp_name=None,
resampler=None,
**fit_kwargs,
):
"""Find a model for a given task.
Expand Down Expand Up @@ -2163,6 +2164,14 @@ def cv_score_agg_func(val_loss_folds, log_metrics_folds):
}
```

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
distribution. Not compatible with `sample_weight` (resampling breaks the
1-to-1 row alignment with weights); passing both raises `ValueError`. Off
by default. See issue #1200 for the design discussion and benchmarks.
**fit_kwargs: Other key word arguments to pass to fit() function of
the searched learners, such as sample_weight. Below are a few examples of
estimator-specific parameters:
Expand Down Expand Up @@ -2336,6 +2345,19 @@ def cv_score_agg_func(val_loss_folds, log_metrics_folds):
self._state.resources_per_trial = {"cpu": n_jobs} if n_jobs > 0 else {"cpu": 1}
self._state.free_mem_ratio = self._settings.get("free_mem_ratio") if free_mem_ratio is None else free_mem_ratio
self._state.task = task
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
self._state.log_training_metric = log_training_metric

self._state.fit_kwargs = fit_kwargs
Expand Down
8 changes: 8 additions & 0 deletions flaml/automl/ml.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,14 @@ def get_val_loss(
# fit_kwargs['groups_val'] = groups_val
# fit_kwargs['X_val'] = X_val
# fit_kwargs['y_val'] = y_val
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)
estimator.fit(X_train, y_train, budget=budget, free_mem_ratio=free_mem_ratio, **fit_kwargs)
val_loss, metric_for_logging, pred_time, _ = _eval_estimator(
config,
Expand Down
161 changes: 161 additions & 0 deletions test/automl/test_resampler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Tests for the `resampler=` kwarg on `AutoML.fit` (issue #1200).

Covers:
- The kwarg is respected: passing a resampler changes the model chosen by the
search compared to the same fit with `resampler=None` on a severely
imbalanced dataset. This is a proxy assertion that the fold-level training
data is actually being resampled inside `get_val_loss`.
- The validation partition is left at the raw class distribution: the model
trained inside the cross-validation loop is still scored against the
original (imbalanced) validation folds.
- Passing both `resampler` and `sample_weight` raises `ValueError`.
- Passing a resampler that doesn't expose `fit_resample` raises `TypeError`.
- `resampler=None` (the default) is a no-op — model output is unchanged
relative to omitting the kwarg entirely.
"""

import numpy as np
import pandas as pd
import pytest
from sklearn.datasets import make_classification

from flaml import AutoML


def _imbalanced_dataset(seed: int = 0):
X, y = make_classification(
n_samples=800,
n_features=10,
n_informative=6,
weights=[0.94, 0.06],
class_sep=0.7,
flip_y=0.02,
random_state=seed,
)
return pd.DataFrame(X, columns=[f"f{i}" for i in range(X.shape[1])]), pd.Series(y, name="target")


def _fit_settings():
return {
"task": "classification",
"metric": "f1",
"estimator_list": ["lgbm"],
"eval_method": "cv",
"n_splits": 3,
"max_iter": 6,
"time_budget": -1,
"verbose": 0,
"n_jobs": 1,
}


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"
)


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"
)


def test_resampler_with_sample_weight_raises():
imblearn = pytest.importorskip("imblearn.over_sampling")
SMOTE = imblearn.SMOTE

X, y = _imbalanced_dataset(seed=2)
sample_weight = np.where(y == 1, 5.0, 1.0)

automl = AutoML()
with pytest.raises(ValueError, match="Cannot combine 'resampler' with 'sample_weight'"):
automl.fit(
X_train=X,
y_train=y,
resampler=SMOTE(random_state=2),
sample_weight=sample_weight,
**_fit_settings(),
)


def test_resampler_without_fit_resample_raises():
"""A resampler that doesn't expose the imblearn `fit_resample` protocol
should be rejected up-front at fit() time, not silently on the first fold."""
X, y = _imbalanced_dataset(seed=3)

class NotAResampler:
pass

automl = AutoML()
with pytest.raises(TypeError, match="fit_resample"):
automl.fit(
X_train=X,
y_train=y,
resampler=NotAResampler(),
**_fit_settings(),
)


def test_resampler_none_is_default_and_noop():
"""Explicit `resampler=None` must behave identically to omitting the kwarg."""
X, y = _imbalanced_dataset(seed=4)

a = AutoML()
a.fit(X_train=X, y_train=y, seed=7, **_fit_settings())

b = AutoML()
b.fit(X_train=X, y_train=y, resampler=None, seed=7, **_fit_settings())

assert a.best_config == b.best_config
assert a.best_estimator == b.best_estimator
assert np.array_equal(a.predict(X), b.predict(X))
Loading