Skip to content

Commit e5db672

Browse files
committed
Fix anomaly detection validation and metrics
1 parent 6f072ee commit e5db672

5 files changed

Lines changed: 170 additions & 21 deletions

File tree

‎flaml/automl/automl.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2513,11 +2513,7 @@ def cv_score_agg_func(val_loss_folds, log_metrics_folds):
25132513
groups_val,
25142514
groups,
25152515
)
2516-
unlabeled_anomaly = (
2517-
task.is_anomaly_detection()
2518-
and self._y_train_all is None
2519-
and self._state.y_val is None
2520-
)
2516+
unlabeled_anomaly = task.is_anomaly_detection() and self._y_train_all is None and self._state.y_val is None
25212517

25222518
self._search_states = {} # key: estimator name; value: SearchState
25232519
self._random = np.random.RandomState(RANDOM_SEED)
@@ -2603,6 +2599,12 @@ def cv_score_agg_func(val_loss_folds, log_metrics_folds):
26032599
# Validate metric parameter before processing
26042600
self._validate_metric_parameter(metric, allow_auto=True)
26052601

2602+
if task.is_anomaly_detection() and callable(metric):
2603+
raise ValueError(
2604+
"Callable metrics are not currently supported for anomaly_detection. "
2605+
"Use the built-in 'ap' or 'roc_auc' metric."
2606+
)
2607+
26062608
metric = task.default_metric(metric)
26072609

26082610
if task.is_anomaly_detection() and isinstance(metric, str) and metric not in ["ap", "roc_auc"]:
@@ -2707,6 +2709,22 @@ def is_to_reverse_metric(metric, task):
27072709
elif max_iter is None:
27082710
# set to a large number
27092711
max_iter = 1000000
2712+
2713+
if task.is_anomaly_detection() and max_iter > 1:
2714+
from flaml.automl.ml import normalize_anomaly_labels
2715+
2716+
evaluation_labels = self._state.y_val if eval_method == "holdout" else self._y_train_all
2717+
if evaluation_labels is None:
2718+
raise ValueError(
2719+
"Hyperparameter search for anomaly_detection requires labeled "
2720+
"evaluation data containing both normal and anomaly samples."
2721+
)
2722+
normalized_labels = normalize_anomaly_labels(evaluation_labels)
2723+
if set(np.unique(normalized_labels)) != {0, 1}:
2724+
raise ValueError(
2725+
"Hyperparameter search for anomaly_detection requires evaluation "
2726+
"labels containing both normal and anomaly classes."
2727+
)
27102728
self._state.retrain_final = (
27112729
retrain_full is True
27122730
and eval_method == "holdout"

‎flaml/automl/data.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,11 @@ def fit_transform(self, X: Union[DataFrame, np.ndarray], y, task: Union[str, "Ta
394394
self._drop = drop
395395
if y is None:
396396
self.label_transformer = None
397+
elif task.is_anomaly_detection() and not pd.api.types.is_numeric_dtype(y):
398+
raise ValueError(
399+
"Anomaly detection labels must be numeric and use either "
400+
"{0, 1} with 1=anomaly or {-1, 1} with -1=anomaly."
401+
)
397402
elif task.is_classification() or not pd.api.types.is_numeric_dtype(y) and not task.is_nlg():
398403
if not task.is_token_classification():
399404
from sklearn.preprocessing import LabelEncoder

‎flaml/automl/ml.py‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,7 @@ def normalize_anomaly_labels(y):
308308
return y
309309

310310
raise ValueError(
311-
"For anomaly detection with AP/ROC-AUC, labels must use "
312-
"{0, 1} with 1=anomaly or {-1, 1} with -1=anomaly."
311+
"For anomaly detection with AP/ROC-AUC, labels must use " "{0, 1} with 1=anomaly or {-1, 1} with -1=anomaly."
313312
)
314313

315314

@@ -636,12 +635,17 @@ def _eval_estimator(
636635

637636
pred_time = (time.time() - pred_start) / num_val_rows
638637

639-
try:
640-
y_val_for_metric = (
641-
normalize_anomaly_labels(y_val)
642-
if task.is_anomaly_detection() and eval_metric in ["ap", "roc_auc"]
643-
else y_val
644-
)
638+
if task.is_anomaly_detection() and eval_metric in ["ap", "roc_auc"]:
639+
y_val_for_metric = normalize_anomaly_labels(y_val)
640+
if set(np.unique(y_val_for_metric)) != {0, 1}:
641+
raise ValueError(
642+
"Anomaly detection evaluation with AP/ROC-AUC requires "
643+
"both normal and anomaly labels after normalization."
644+
)
645+
else:
646+
y_val_for_metric = y_val
647+
648+
try:
645649
val_loss = metric_loss_score(
646650
eval_metric,
647651
y_processed_predict=val_pred_y,
@@ -655,7 +659,7 @@ def _eval_estimator(
655659
val_loss = np.inf
656660
logger.warning(f"ValueError {e} happened in `metric_loss_score`, set `val_loss` to `np.inf`")
657661
metric_for_logging = {"pred_time": pred_time}
658-
if log_training_metric:
662+
if log_training_metric and y_train is not None:
659663
# For time series forecasting, X_train may be a sampled dataset whose
660664
# test partition can be empty. Use the training partition from X_val
661665
# (which is the dataset used to define y_train above) to keep shapes

‎flaml/automl/task/generic_task.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ def validate_data(
122122
)
123123
assert X_train_all.size != 0, "Input data must not be empty."
124124

125+
if self.is_anomaly_detection():
126+
for label_name, labels in (("y_train", y_train_all), ("y_val", y_val)):
127+
if labels is not None and not pd.api.types.is_numeric_dtype(labels):
128+
raise ValueError(
129+
f"{label_name} for anomaly_detection must be numeric and use either "
130+
"{0, 1} with 1=anomaly or {-1, 1} with -1=anomaly."
131+
)
132+
125133
if y_train_all is not None:
126134
assert isinstance(
127135
y_train_all, (np.ndarray, pd.Series, psSeries)
@@ -272,9 +280,7 @@ def validate_data(
272280
state.groups = groups
273281

274282
automl.data_size_full = (
275-
len(automl._y_train_all)
276-
if automl._y_train_all is not None
277-
else automl._X_train_all.shape[0]
283+
len(automl._y_train_all) if automl._y_train_all is not None else automl._X_train_all.shape[0]
278284
)
279285

280286
@staticmethod
@@ -1342,10 +1348,7 @@ def default_estimator_list(self, estimator_list: List[str], is_spark_dataframe:
13421348
"Use 'isolation_forest' or a compatible custom learner."
13431349
)
13441350
elif "isolation_forest" in estimator_list:
1345-
raise ValueError(
1346-
"Built-in estimator 'isolation_forest' only supports "
1347-
"the anomaly_detection task."
1348-
)
1351+
raise ValueError("Built-in estimator 'isolation_forest' only supports " "the anomaly_detection task.")
13491352
n_estimators = len(estimator_list)
13501353
if is_spark_dataframe:
13511354
# For spark dataframe, only estimators ending with '_spark' are supported

‎test/automl/test_anomaly_detection.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def test_anomaly_detection_rejects_unsupported_builtin_estimator():
1717
estimator_list=["lgbm"],
1818
time_budget=1,
1919
)
20+
21+
2022
def test_isolation_forest_rejects_non_anomaly_task():
2123
automl = AutoML()
2224

@@ -32,6 +34,7 @@ def test_isolation_forest_rejects_non_anomaly_task():
3234
time_budget=1,
3335
)
3436

37+
3538
def test_automl_anomaly_detection_e2e():
3639
X_normal, _ = make_blobs(
3740
n_samples=100,
@@ -181,3 +184,119 @@ def test_normalize_anomaly_labels_single_class():
181184
normalize_anomaly_labels(np.array([-1, 1, 1])),
182185
np.array([1, 0, 0]),
183186
)
187+
188+
189+
def test_anomaly_detection_hpo_rejects_single_class_evaluation_labels():
190+
rng = np.random.RandomState(42)
191+
X_train = rng.randn(80, 2)
192+
X_val = rng.randn(20, 2)
193+
194+
with pytest.raises(
195+
ValueError,
196+
match="requires evaluation labels containing both normal and anomaly classes",
197+
):
198+
AutoML().fit(
199+
X_train=X_train,
200+
y_train=np.zeros(len(X_train)),
201+
X_val=X_val,
202+
y_val=np.zeros(len(X_val)),
203+
task="anomaly_detection",
204+
estimator_list=["isolation_forest"],
205+
metric="roc_auc",
206+
max_iter=2,
207+
)
208+
209+
210+
def test_anomaly_detection_x_only_hpo_with_training_metric_logging():
211+
X_normal, _ = make_blobs(
212+
n_samples=100,
213+
centers=1,
214+
cluster_std=0.5,
215+
random_state=42,
216+
)
217+
rng = np.random.RandomState(42)
218+
X_anomaly = rng.uniform(low=6, high=8, size=(20, 2))
219+
220+
X_val = np.vstack([X_normal[:20], X_anomaly])
221+
y_val = np.array([0] * 20 + [1] * 20)
222+
223+
automl = AutoML()
224+
automl.fit(
225+
X_train=X_normal,
226+
X_val=X_val,
227+
y_val=y_val,
228+
task="anomaly_detection",
229+
estimator_list=["isolation_forest"],
230+
metric="roc_auc",
231+
max_iter=2,
232+
log_training_metric=True,
233+
)
234+
235+
assert automl.best_estimator == "isolation_forest"
236+
assert automl.score(X_val, y_val) > 0.9
237+
238+
239+
@pytest.mark.parametrize(
240+
"label_location",
241+
["train", "validation"],
242+
)
243+
def test_anomaly_detection_rejects_nonnumeric_labels(label_location):
244+
rng = np.random.RandomState(42)
245+
X_train = rng.randn(40, 2)
246+
247+
if label_location == "train":
248+
with pytest.raises(
249+
ValueError,
250+
match="y_train for anomaly_detection must be numeric",
251+
):
252+
AutoML().fit(
253+
X_train=X_train,
254+
y_train=np.array(["normal"] * 40),
255+
task="anomaly_detection",
256+
estimator_list=["isolation_forest"],
257+
max_iter=1,
258+
)
259+
else:
260+
X_val = rng.randn(20, 2)
261+
y_val = np.array(["normal"] * 10 + ["anomaly"] * 10)
262+
263+
with pytest.raises(
264+
ValueError,
265+
match="y_val for anomaly_detection must be numeric",
266+
):
267+
AutoML().fit(
268+
X_train=X_train,
269+
X_val=X_val,
270+
y_val=y_val,
271+
task="anomaly_detection",
272+
estimator_list=["isolation_forest"],
273+
max_iter=2,
274+
)
275+
276+
277+
def test_anomaly_detection_rejects_callable_metric():
278+
X = np.random.RandomState(42).randn(50, 2)
279+
280+
def custom_metric(
281+
X_val,
282+
y_val,
283+
estimator,
284+
labels,
285+
X_train,
286+
y_train,
287+
*args,
288+
**kwargs,
289+
):
290+
return 0.0, {}
291+
292+
with pytest.raises(
293+
ValueError,
294+
match="Callable metrics are not currently supported for anomaly_detection",
295+
):
296+
AutoML().fit(
297+
X_train=X,
298+
task="anomaly_detection",
299+
estimator_list=["isolation_forest"],
300+
metric=custom_metric,
301+
max_iter=1,
302+
)

0 commit comments

Comments
 (0)