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
9 changes: 9 additions & 0 deletions doc/whats_new/v0.15.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ Changelog
Bug fixes
.........

- Fix a bug in :func:`~imblearn.metrics.sensitivity_specificity_support` where
the specificity was computed incorrectly when ``sample_weight`` was provided
(the true-negative count mixed the raw sample count with weighted sums, which
could make the specificity exceed 1). This also affects
:func:`~imblearn.metrics.specificity_score`,
:func:`~imblearn.metrics.geometric_mean_score` and
:func:`~imblearn.metrics.classification_report_imbalanced`, which rely on it.
:issue:`1180` by :user:`Imran Ahamed <immu4989>`.

Enhancements
............

Expand Down
11 changes: 10 additions & 1 deletion imblearn/metrics/_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,16 @@ def sensitivity_specificity_support(
true_sum = np.bincount(y_true, weights=sample_weight, minlength=len(labels))

# Compute the true negative
tn_sum = y_true.size - (pred_sum + true_sum - tp_sum)
# ``pred_sum``, ``true_sum`` and ``tp_sum`` are weighted sums when
# ``sample_weight`` is provided, so the population size must be the total
# weight rather than the raw number of samples. Using ``y_true.size``
# here mixes counts with weighted sums and can make ``tn_sum`` negative
# (and the resulting specificity exceed 1).
if sample_weight is not None:
total = np.sum(sample_weight)
else:
total = y_true.size
tn_sum = total - (pred_sum + true_sum - tp_sum)

# Retain only selected labels
indices = np.searchsorted(sorted_labels, labels[:n_labels])
Expand Down
87 changes: 86 additions & 1 deletion imblearn/metrics/tests/test_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ def test_geometric_mean_average(y_true, y_pred, average, expected_gmean):
[0, 1, 1, 0, 0, 1],
[1, 2, 1, 1, 2, 1],
"weighted",
0.333,
0.609,
),
],
)
Expand Down Expand Up @@ -548,3 +548,88 @@ def test_macro_averaged_mean_absolute_error_sample_weight():
)

assert ma_mae_unit_weights == pytest.approx(ma_mae_no_weights)


def test_sensitivity_specificity_support_sample_weight():
# Non-regression test for #1180: with sample_weight the true-negative count
# must use the total weight, not the raw sample count. Otherwise the
# specificity is wrong and can exceed 1.
y_true = [0, 0, 1, 1]
y_pred = [0, 1, 1, 1]
sample_weight = [1.0, 2.0, 3.0, 4.0]

_, spe, _ = sensitivity_specificity_support(
y_true, y_pred, sample_weight=sample_weight, average=None
)
# class 1 as positive: TN=1 (sample 0), FP=2 (sample 1) -> 1/(1+2)=1/3.
assert_allclose(spe, [1.0, 1.0 / 3.0], rtol=R_TOL)
# a specificity is a rate and must never exceed 1.
assert np.all(spe <= 1.0)

# integer weights equal to repetition counts must match repeated samples.
y_true_w = [0, 1, 1]
y_pred_w = [1, 1, 0]
weights = [2, 1, 3]
y_true_rep = [0, 0, 1, 1, 1, 1]
y_pred_rep = [1, 1, 1, 0, 0, 0]
_, spe_w, _ = sensitivity_specificity_support(
y_true_w, y_pred_w, sample_weight=weights, average=None
)
_, spe_rep, _ = sensitivity_specificity_support(
y_true_rep, y_pred_rep, average=None
)
assert_allclose(spe_w, spe_rep, rtol=R_TOL)


def _repeat_samples(y_true, y_pred, sample_weight):
"""Expand each sample ``i`` into ``sample_weight[i]`` identical copies."""
y_true_rep, y_pred_rep = [], []
for yt, yp, weight in zip(y_true, y_pred, sample_weight):
y_true_rep.extend([yt] * weight)
y_pred_rep.extend([yp] * weight)
return y_true_rep, y_pred_rep


@pytest.mark.parametrize("average", [None, "macro", "weighted", "micro"])
@pytest.mark.parametrize(
"metric",
[
lambda *a, **k: sensitivity_specificity_support(*a, **k)[0],
lambda *a, **k: sensitivity_specificity_support(*a, **k)[1],
sensitivity_score,
specificity_score,
geometric_mean_score,
make_index_balanced_accuracy()(specificity_score),
make_index_balanced_accuracy()(geometric_mean_score),
],
ids=[
"sensitivity",
"specificity",
"sensitivity_score",
"specificity_score",
"geometric_mean_score",
"iba_specificity",
"iba_geometric_mean",
],
)
def test_metrics_sample_weight_repeat_equivalence(metric, average):
# Non-regression test for #1180: integer ``sample_weight`` must give the
# same result as physically repeating each sample that many times. This
# exercises the whole chain of metrics that delegate to
# ``sensitivity_specificity_support`` (where the specificity bug lived),
# not just that function in isolation.
y_true = [0, 0, 1, 1, 2, 2, 0, 1, 2]
y_pred = [0, 1, 1, 2, 2, 0, 0, 1, 1]
sample_weight = [1, 2, 3, 1, 2, 1, 2, 1, 3]
y_true_rep, y_pred_rep = _repeat_samples(y_true, y_pred, sample_weight)

weighted = np.atleast_1d(
metric(y_true, y_pred, average=average, sample_weight=sample_weight)
).astype(float)
repeated = np.atleast_1d(
metric(y_true_rep, y_pred_rep, average=average)
).astype(float)

assert_allclose(weighted, repeated, rtol=R_TOL)
# sensitivities, specificities and their geometric mean are rates in [0, 1].
assert np.all((weighted >= 0.0) & (weighted <= 1.0))
Loading