diff --git a/doc/whats_new/v0.15.rst b/doc/whats_new/v0.15.rst index 7ef7284b8..30b944b27 100644 --- a/doc/whats_new/v0.15.rst +++ b/doc/whats_new/v0.15.rst @@ -11,6 +11,14 @@ Changelog Bug fixes ......... +- Fix a bug in :func:`~imblearn.metrics.macro_averaged_mean_absolute_error` + where a ``ValueError`` was raised when a class was present in ``y_pred`` + but absent from ``y_true``. Such classes are now skipped (per-class MAE + is undefined when there are no ground-truth samples), matching the + graceful handling of missing labels in :func:`sklearn.metrics.f1_score` + with ``average='macro'``. + :pr:`1172` by :user:`jbbqqf`. + Enhancements ............ diff --git a/imblearn/metrics/_classification.py b/imblearn/metrics/_classification.py index c7ba87bf2..5c2282a50 100644 --- a/imblearn/metrics/_classification.py +++ b/imblearn/metrics/_classification.py @@ -1127,7 +1127,12 @@ def macro_averaged_mean_absolute_error(y_true, y_pred, *, sample_weight=None): else: sample_weight = np.ones(y_true.shape) check_consistent_length(y_true, y_pred, sample_weight) - labels = unique_labels(y_true, y_pred) + # Iterate only over classes present in y_true: per-class MAE is undefined + # for a class that has zero ground-truth samples (mean_absolute_error + # rejects empty arrays). This matches the metric's documented intent + # ("computes each MAE for each class") and aligns the macro behaviour + # with sklearn's f1_score(average='macro') on labels missing from y_true. + labels = unique_labels(y_true) mae = [] for possible_class in labels: indices = np.flatnonzero(y_true == possible_class) diff --git a/imblearn/metrics/tests/test_classification.py b/imblearn/metrics/tests/test_classification.py index 2a022e271..645d90a77 100644 --- a/imblearn/metrics/tests/test_classification.py +++ b/imblearn/metrics/tests/test_classification.py @@ -548,3 +548,22 @@ def test_macro_averaged_mean_absolute_error_sample_weight(): ) assert ma_mae_unit_weights == pytest.approx(ma_mae_no_weights) + + +def test_macro_averaged_mean_absolute_error_class_only_in_y_pred(): + # Non-regression test for #1094: when a label appears only in y_pred + # (predicted but absent from ground truth), the metric must not raise. + # The per-class MAE for a class with zero ground-truth samples is + # undefined and is therefore excluded from the macro average — this + # matches the docstring intent ("computes each MAE for each class") and + # mirrors the graceful handling of absent labels by sklearn's + # f1_score(average='macro'). + # y_true = [0, 0], y_pred = [0, 1]: only class 0 is in y_true, with + # samples [0, 0] vs predictions [0, 1] -> MAE = (0+1)/2 = 0.5; macro + # over the single ground-truth class = 0.5. Crucially, no exception. + assert macro_averaged_mean_absolute_error([0, 0], [0, 1]) == pytest.approx(0.5) + + # Symmetric sanity check: when a label is missing from y_pred but + # present in y_true, we still compute MAE on each ground-truth slice. + # class 0: |0-0|=0; class 1: |1-0|=1; macro = (0+1)/2 = 0.5. + assert macro_averaged_mean_absolute_error([0, 1], [0, 0]) == pytest.approx(0.5)