783 lines
27 KiB
Python
783 lines
27 KiB
Python
import itertools
|
|
import typing
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import sklearn
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
from hypothesis.extra.numpy import arrays
|
|
from hypothesis.strategies import composite
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.multiclass import OneVsRestClassifier
|
|
|
|
from cleanlab import multilabel_classification as ml_classification
|
|
from cleanlab.internal import multilabel_scorer as ml_scorer
|
|
from cleanlab.internal.multilabel_utils import get_onehot_num_classes, onehot2int, stack_complement
|
|
from cleanlab.multilabel_classification import filter
|
|
from cleanlab.multilabel_classification.dataset import (
|
|
common_multilabel_issues,
|
|
multilabel_health_summary,
|
|
overall_multilabel_health_score,
|
|
rank_classes_by_multilabel_quality,
|
|
)
|
|
from cleanlab.multilabel_classification.rank import get_label_quality_scores_per_class
|
|
|
|
|
|
@pytest.fixture
|
|
def labels():
|
|
return np.array(
|
|
[
|
|
[1, 0, 0],
|
|
[0, 1, 0],
|
|
[0, 0, 1],
|
|
[1, 1, 0],
|
|
[1, 0, 1],
|
|
[0, 1, 1],
|
|
[1, 1, 1],
|
|
[0, 0, 0],
|
|
[1, 0, 1],
|
|
[0, 1, 0],
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def pred_probs_gold(labels):
|
|
pred_probs = np.array(
|
|
[
|
|
[0.203, 0.465, 0.612],
|
|
[0.802, 0.596, 0.43],
|
|
[0.776, 0.649, 0.391],
|
|
[0.201, 0.439, 0.633],
|
|
[0.203, 0.443, 0.584],
|
|
[0.814, 0.572, 0.332],
|
|
[0.201, 0.388, 0.544],
|
|
[0.778, 0.646, 0.392],
|
|
[0.796, 0.611, 0.387],
|
|
[0.199, 0.381, 0.58],
|
|
]
|
|
)
|
|
assert pred_probs.shape == labels.shape
|
|
return pred_probs
|
|
|
|
|
|
@pytest.fixture
|
|
def pred_probs():
|
|
return np.array(
|
|
[
|
|
[0.9, 0.1, 0.2],
|
|
[0.5, 0.6, 0.4],
|
|
[0.75, 0.80, 0.85],
|
|
[0.9, 0.85, 0.2],
|
|
[0.9, 0.1, 0.85],
|
|
[0.5, 0.6, 0.85],
|
|
[0.9, 0.85, 0.85],
|
|
[0.8, 0.4, 0.2],
|
|
[0.9, 0.1, 0.85],
|
|
[0.15, 0.95, 0.05],
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def pred_probs_multilabel():
|
|
return np.array(
|
|
[
|
|
[0.9, 0.1, 0.0, 0.4, 0.1],
|
|
[0.7, 0.8, 0.2, 0.3, 0.1],
|
|
[0.9, 0.8, 0.4, 0.2, 0.1],
|
|
[0.1, 0.1, 0.8, 0.3, 0.1],
|
|
[0.4, 0.5, 0.1, 0.1, 0.1],
|
|
[0.1, 0.1, 0.2, 0.1, 0.1],
|
|
[0.8, 0.1, 0.2, 0.1, 0.1],
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def labels_multilabel():
|
|
return [[0], [0, 1], [0, 1], [2], [0, 2, 3], [], []]
|
|
|
|
|
|
@pytest.fixture
|
|
def data_multilabel(num_classes=5):
|
|
labels = []
|
|
pred_probs = []
|
|
for i in range(0, 100):
|
|
q = [0.1] * num_classes
|
|
pos = i % num_classes
|
|
labels.append([pos])
|
|
if i > 90:
|
|
pos = (pos + 2) % num_classes
|
|
q[pos] = 0.9
|
|
pred_probs.append(q)
|
|
return labels, np.array(pred_probs)
|
|
|
|
|
|
@pytest.fixture
|
|
def cv():
|
|
return sklearn.model_selection.StratifiedKFold(
|
|
n_splits=2,
|
|
shuffle=True,
|
|
random_state=42,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def dummy_features(labels):
|
|
np.random.seed(42)
|
|
return np.random.rand(labels.shape[0], 2)
|
|
|
|
|
|
def test_public_label_quality_scores(labels, pred_probs):
|
|
formatted_labels = onehot2int(labels)
|
|
assert isinstance(formatted_labels, list)
|
|
scores1 = ml_classification.get_label_quality_scores(formatted_labels, pred_probs)
|
|
assert len(scores1) == len(labels)
|
|
assert (scores1 >= 0).all() and (scores1 <= 1).all()
|
|
scores2 = ml_classification.get_label_quality_scores(
|
|
formatted_labels, pred_probs, method="confidence_weighted_entropy"
|
|
)
|
|
assert not np.isclose(scores1, scores2).all()
|
|
scores3 = ml_classification.get_label_quality_scores(
|
|
formatted_labels, pred_probs, adjust_pred_probs=True
|
|
)
|
|
assert not np.isclose(scores1, scores3).all()
|
|
scores4 = ml_classification.get_label_quality_scores(
|
|
formatted_labels,
|
|
pred_probs,
|
|
method="normalized_margin",
|
|
adjust_pred_probs=True,
|
|
aggregator_kwargs={"method": "exponential_moving_average"},
|
|
)
|
|
assert not np.isclose(scores1, scores4).all()
|
|
scores5 = ml_classification.get_label_quality_scores(
|
|
formatted_labels,
|
|
pred_probs,
|
|
method="normalized_margin",
|
|
adjust_pred_probs=True,
|
|
aggregator_kwargs={"method": "softmin"},
|
|
)
|
|
assert not np.isclose(scores4, scores5).all()
|
|
scores6 = ml_classification.get_label_quality_scores(
|
|
formatted_labels,
|
|
pred_probs,
|
|
method="normalized_margin",
|
|
adjust_pred_probs=True,
|
|
aggregator_kwargs={"method": "softmin", "temperature": 0.002},
|
|
)
|
|
assert not np.isclose(scores5, scores6).all()
|
|
scores7 = ml_classification.get_label_quality_scores(
|
|
formatted_labels,
|
|
pred_probs,
|
|
method="normalized_margin",
|
|
adjust_pred_probs=True,
|
|
aggregator_kwargs={"method": np.min},
|
|
)
|
|
assert np.isclose(scores6, scores7, rtol=1e-3).all()
|
|
|
|
with pytest.raises(ValueError) as e:
|
|
_ = ml_classification.get_label_quality_scores(
|
|
formatted_labels, pred_probs, method="badchoice"
|
|
)
|
|
assert "Invalid method name: badchoice" in str(e.value)
|
|
|
|
with pytest.raises(ValueError) as e:
|
|
_ = ml_classification.get_label_quality_scores(
|
|
formatted_labels, pred_probs, aggregator_kwargs={"method": "invalid"}
|
|
)
|
|
assert "Invalid aggregation method specified: 'invalid'" in str(e.value)
|
|
|
|
|
|
class TestAggregator:
|
|
"""Test the Aggregator class."""
|
|
|
|
@pytest.fixture
|
|
def base_scores(self):
|
|
return np.array([[0.6, 0.3, 0.7, 0.1, 0.9]])
|
|
|
|
@pytest.mark.parametrize(
|
|
"method",
|
|
[np.min, np.max, np.mean, np.median, "exponential_moving_average", "softmin"],
|
|
ids=lambda x: x.__name__ if callable(x) else str(x),
|
|
)
|
|
def test_aggregator_callable(self, method):
|
|
aggregator = ml_scorer.Aggregator(method=method)
|
|
assert callable(aggregator.method), "Aggregator should store a callable method"
|
|
assert callable(aggregator), "Aggregator should be callable"
|
|
|
|
@pytest.mark.parametrize(
|
|
"method,expected_score",
|
|
[
|
|
(np.min, 0.1),
|
|
(np.max, 0.9),
|
|
(np.mean, 0.52),
|
|
(np.median, 0.6),
|
|
("exponential_moving_average", 0.436),
|
|
("softmin", 0.128),
|
|
],
|
|
ids=["min", "max", "mean", "median", "exponential_moving_average", "softmin"],
|
|
)
|
|
def test_aggregator_score(self, base_scores, method, expected_score):
|
|
aggregator = ml_scorer.Aggregator(method=method)
|
|
scores = aggregator(base_scores)
|
|
assert np.isclose(scores, np.array([expected_score]), rtol=1e-3).all()
|
|
assert scores.shape == (1,)
|
|
|
|
def test_invalid_method(self):
|
|
with pytest.raises(ValueError) as e:
|
|
_ = ml_scorer.Aggregator(method="invalid_method")
|
|
assert "Invalid aggregation method specified: 'invalid_method'" in str(
|
|
e.value
|
|
), "String constructor has limited options"
|
|
|
|
with pytest.raises(TypeError) as e:
|
|
_ = ml_scorer.Aggregator(method=1)
|
|
assert "Expected callable method" in str(e.value), "Non-callable methods are not valid"
|
|
|
|
def test_invalid_score(self, base_scores):
|
|
aggregator = ml_scorer.Aggregator(method=np.min)
|
|
with pytest.raises(ValueError) as e:
|
|
_ = aggregator(base_scores[0])
|
|
assert "Expected 2D array" in str(e.value), "Aggregator expects 2D array"
|
|
|
|
|
|
class TestMultilabelScorer:
|
|
"""Test the MultilabelScorer class."""
|
|
|
|
@pytest.fixture
|
|
def docs_labels(self):
|
|
return np.array([[0, 1, 0], [1, 0, 1]])
|
|
|
|
@pytest.fixture
|
|
def docs_pred_probs(self):
|
|
return np.array([[0.1, 0.9, 0.7], [0.4, 0.1, 0.6]])
|
|
|
|
@pytest.fixture
|
|
def default_scorer(self):
|
|
return ml_scorer.MultilabelScorer()
|
|
|
|
@pytest.mark.parametrize(
|
|
"base_scorer", [scorer for scorer in ml_scorer.ClassLabelScorer], ids=lambda x: x.name
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"aggregator", [np.min, np.max, np.mean, "exponential_moving_average", "softmin"]
|
|
)
|
|
@pytest.mark.parametrize("strict", [True, False], ids=["strict", ""])
|
|
def test_call(self, base_scorer, aggregator, strict, labels, pred_probs):
|
|
scorer = ml_scorer.MultilabelScorer(base_scorer, aggregator, strict=strict)
|
|
assert callable(scorer)
|
|
|
|
test_scores = scorer(labels, pred_probs)
|
|
assert isinstance(test_scores, np.ndarray)
|
|
assert test_scores.shape == (labels.shape[0],)
|
|
|
|
# Test base_scorer_kwargs
|
|
base_scorer_kwargs = {"adjust_pred_probs": True}
|
|
if scorer.base_scorer is not ml_scorer.ClassLabelScorer.CONFIDENCE_WEIGHTED_ENTROPY:
|
|
test_scores = scorer(labels, pred_probs, base_scorer_kwargs=base_scorer_kwargs)
|
|
assert isinstance(test_scores, np.ndarray)
|
|
assert test_scores.shape == (labels.shape[0],)
|
|
else:
|
|
with pytest.raises(ValueError) as e:
|
|
scorer(labels, pred_probs, base_scorer_kwargs=base_scorer_kwargs)
|
|
assert "adjust_pred_probs is not currently supported for" in str(e)
|
|
|
|
@pytest.mark.parametrize(
|
|
"base_scorer", [scorer for scorer in ml_scorer.ClassLabelScorer], ids=lambda x: x.name
|
|
)
|
|
def test_aggregate_kwargs(self, base_scorer):
|
|
"""Make sure the instatiated aggregator kwargs can be overridden.
|
|
I.e. switching from a forgetting-factor 1.0 to 0.5.
|
|
"""
|
|
class_label_quality_scores = np.array([[0.9, 0.9, 0.3], [0.4, 0.9, 0.6]])
|
|
aggregator = ml_scorer.Aggregator(ml_scorer.exponential_moving_average, alpha=1.0)
|
|
scorer = ml_scorer.MultilabelScorer(
|
|
base_scorer=base_scorer,
|
|
aggregator=aggregator,
|
|
)
|
|
scores = scorer.aggregate(class_label_quality_scores)
|
|
assert np.allclose(scores, np.array([0.3, 0.4]))
|
|
# Use different alpha, should change scores
|
|
new_scores = scorer.aggregate(class_label_quality_scores, alpha=0.0)
|
|
assert np.allclose(new_scores, np.array([0.9, 0.9]))
|
|
|
|
def test_get_class_label_quality_scores(self, default_scorer, docs_labels, docs_pred_probs):
|
|
"""Test the get_class_label_quality_scores method."""
|
|
class_label_quality_scores = default_scorer.get_class_label_quality_scores(
|
|
docs_labels, docs_pred_probs
|
|
)
|
|
assert np.allclose(class_label_quality_scores, np.array([[0.9, 0.9, 0.3], [0.4, 0.9, 0.6]]))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"method", ["self_confidence", "normalized_margin", "confidence_weighted_entropy"]
|
|
)
|
|
def test_class_label_scorer_from_str(method):
|
|
for m in (method, method.upper()):
|
|
scorer = ml_scorer.ClassLabelScorer.from_str(m)
|
|
assert callable(scorer)
|
|
with pytest.raises(ValueError):
|
|
ml_scorer.ClassLabelScorer.from_str(m.replace("_", "-"))
|
|
|
|
|
|
@pytest.fixture
|
|
def scorer():
|
|
return ml_scorer.MultilabelScorer(
|
|
base_scorer=ml_scorer.ClassLabelScorer.SELF_CONFIDENCE,
|
|
aggregator=np.min,
|
|
)
|
|
|
|
|
|
def test_is_multilabel(labels):
|
|
assert ml_scorer._is_multilabel(labels)
|
|
assert not ml_scorer._is_multilabel(labels[:, 0])
|
|
|
|
|
|
@pytest.mark.parametrize("class_names", [None, ["Apple", "Cat", "Dog", "Peach", "Bird"]])
|
|
def test_common_multilabel_issues(class_names, pred_probs_multilabel, labels_multilabel):
|
|
df = common_multilabel_issues(
|
|
labels=labels_multilabel, pred_probs=pred_probs_multilabel, class_names=class_names
|
|
)
|
|
expected_issue_probabilities = [
|
|
0.14285714285714285,
|
|
0.14285714285714285,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
0.0,
|
|
]
|
|
assert len(df) == 10
|
|
assert np.isclose(np.array(expected_issue_probabilities), df["Issue Probability"]).all()
|
|
if class_names:
|
|
expected_res = [
|
|
"Apple",
|
|
"Dog",
|
|
"Apple",
|
|
"Cat",
|
|
"Cat",
|
|
"Dog",
|
|
"Peach",
|
|
"Peach",
|
|
"Bird",
|
|
"Bird",
|
|
]
|
|
assert list(df["Class Name"]) == expected_res
|
|
else:
|
|
assert "Class Name" not in df.columns
|
|
|
|
|
|
def test_multilabel_find_label_issues(data_multilabel):
|
|
labels, pred_probs = data_multilabel
|
|
issues = filter.find_label_issues(
|
|
labels=labels, pred_probs=pred_probs, return_indices_ranked_by="self_confidence"
|
|
)
|
|
issues_lm = filter.find_label_issues(
|
|
labels, pred_probs, low_memory=True, return_indices_ranked_by="self_confidence"
|
|
)
|
|
intersection = len(list(set(issues).intersection(set(issues_lm))))
|
|
union = len(set(issues)) + len(set(issues_lm)) - intersection
|
|
assert float(intersection) / union > 0.95
|
|
# Check with return_indices_ranked_by=None
|
|
issues_mask = filter.find_label_issues(labels=labels, pred_probs=pred_probs)
|
|
issues_lm_mask = filter.find_label_issues(labels, pred_probs, low_memory=True)
|
|
issues_from_mask = np.where(issues_mask)[0]
|
|
issues_lm_from_mask = np.where(issues_lm_mask)[0]
|
|
intersection = len(list(set(issues_from_mask).intersection(set(issues_lm_from_mask))))
|
|
union = len(set(issues_from_mask)) + len(set(issues_lm_from_mask)) - intersection
|
|
assert float(intersection) / union > 0.95
|
|
# Check with low_memory=True, unused parameters rank_by_kwargs and n_jobs
|
|
rank_by_kwargs = {"adjust_pred_probs": None}
|
|
issues_lm2 = filter.find_label_issues(
|
|
labels,
|
|
pred_probs,
|
|
low_memory=True,
|
|
return_indices_ranked_by="self_confidence",
|
|
rank_by_kwargs=rank_by_kwargs,
|
|
n_jobs=1,
|
|
)
|
|
np.testing.assert_array_equal(issues_lm2, issues_lm)
|
|
|
|
|
|
@pytest.mark.parametrize("min_examples_per_class", [10, 90])
|
|
def test_multilabel_min_examples_per_class(data_multilabel, min_examples_per_class):
|
|
labels, pred_probs = data_multilabel
|
|
issues = filter.find_label_issues(
|
|
labels=labels, pred_probs=pred_probs, min_examples_per_class=min_examples_per_class
|
|
)
|
|
if min_examples_per_class == 10:
|
|
assert sum(issues) == 9
|
|
else:
|
|
assert sum(issues) == 0
|
|
|
|
|
|
@pytest.mark.parametrize("num_to_remove_per_class", [None, [1, 1, 0, 0, 2], [1, 1, 0, 0, 1]])
|
|
def test_multilabel_num_to_remove_per_class(data_multilabel, num_to_remove_per_class):
|
|
labels, pred_probs = data_multilabel
|
|
|
|
issues = filter.find_label_issues(
|
|
labels=labels, pred_probs=pred_probs, num_to_remove_per_class=num_to_remove_per_class
|
|
)
|
|
num_issues = sum(issues)
|
|
if num_to_remove_per_class is None:
|
|
assert num_issues == 9
|
|
else:
|
|
assert num_issues == sum(num_to_remove_per_class)
|
|
|
|
|
|
@pytest.mark.parametrize("class_names", [None, ["Apple", "Cat", "Dog", "Peach", "Bird"]])
|
|
def test_rank_classes_by_multilabel_quality(pred_probs_multilabel, labels_multilabel, class_names):
|
|
df_ranked = rank_classes_by_multilabel_quality(
|
|
pred_probs=pred_probs_multilabel, labels=labels_multilabel, class_names=class_names
|
|
)
|
|
expected_Label_Issues = [1, 0, 0, 0, 0]
|
|
|
|
expected_Label_Noise = [0.14285714285714285, 0.0, 0.0, 0.0, 0.0]
|
|
|
|
expected_Label_Quality_Score = [0.8571428571428572, 1.0, 1.0, 1.0, 1.0]
|
|
|
|
expected_Inverse_Label_Issues = [0, 1, 0, 0, 0]
|
|
|
|
expected_Inverse_Label_Noise = [0.0, 0.14285714285714285, 0.0, 0.0, 0.0]
|
|
assert list(df_ranked["Label Issues"]) == expected_Label_Issues
|
|
|
|
assert np.isclose(np.array(expected_Label_Noise), df_ranked["Label Noise"]).all()
|
|
assert np.isclose(
|
|
np.array(expected_Label_Quality_Score), df_ranked["Label Quality Score"]
|
|
).all()
|
|
assert list(df_ranked["Inverse Label Issues"]) == expected_Inverse_Label_Issues
|
|
assert np.isclose(
|
|
np.array(expected_Inverse_Label_Noise), df_ranked["Inverse Label Noise"]
|
|
).all()
|
|
if class_names:
|
|
expected_res = [
|
|
"Dog",
|
|
"Apple",
|
|
"Cat",
|
|
"Peach",
|
|
"Bird",
|
|
]
|
|
assert list(df_ranked["Class Name"]) == expected_res
|
|
else:
|
|
assert "Class Name" not in df_ranked.columns
|
|
|
|
|
|
def test_overall_multilabel_health_score(data_multilabel):
|
|
labels, pred_probs = data_multilabel
|
|
overall_label_health_score = overall_multilabel_health_score(
|
|
pred_probs=pred_probs, labels=labels
|
|
)
|
|
assert np.isclose(overall_label_health_score, 0.91)
|
|
|
|
|
|
def test_get_class_label_quality_scores():
|
|
pred_probs = np.array(
|
|
[
|
|
[0.9, 0.1, 0.0, 0.4, 0.1],
|
|
[0.7, 0.8, 0.2, 0.3, 0.1],
|
|
[0.9, 0.8, 0.4, 0.2, 0.1],
|
|
[0.1, 0.1, 0.8, 0.3, 0.1],
|
|
[0.4, 0.5, 0.1, 0.1, 0.1],
|
|
[0.1, 0.1, 0.2, 0.1, 0.1],
|
|
[0.8, 0.1, 0.2, 0.1, 0.1],
|
|
]
|
|
)
|
|
labels = [[0], [0, 1], [0, 1], [2], [0, 2, 3], [], []]
|
|
scores = get_label_quality_scores_per_class(pred_probs=pred_probs, labels=labels)
|
|
expected_res = [
|
|
[0.9, 0.9, 1.0, 0.6, 0.9],
|
|
[0.7, 0.8, 0.8, 0.7, 0.9],
|
|
[0.9, 0.8, 0.6, 0.8, 0.9],
|
|
[0.9, 0.9, 0.8, 0.7, 0.9],
|
|
[0.4, 0.5, 0.1, 0.1, 0.9],
|
|
[0.9, 0.9, 0.8, 0.9, 0.9],
|
|
[0.2, 0.9, 0.8, 0.9, 0.9],
|
|
]
|
|
assert np.isclose(scores, np.array(expected_res)).all()
|
|
|
|
|
|
def test_health_summary_multilabel(pred_probs_multilabel, labels_multilabel):
|
|
health_summary_multilabel = multilabel_health_summary(
|
|
pred_probs=pred_probs_multilabel, labels=labels_multilabel
|
|
)
|
|
expected_keys = [
|
|
"classes_by_multilabel_quality",
|
|
"common_multilabel_issues",
|
|
"overall_multilabel_health_score",
|
|
]
|
|
assert sorted(health_summary_multilabel.keys()) == expected_keys
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"input",
|
|
[
|
|
[[0], [1, 2], [0, 2]],
|
|
[["a", "b"], ["b"]],
|
|
np.array([[[0, 1], [0, 1]], [[1, 1], [0, 0]]]),
|
|
1,
|
|
],
|
|
ids=["lists of ids", "lists of strings", "3d array", "scalar"],
|
|
)
|
|
def test_is_multilabel_is_false(input):
|
|
assert not ml_scorer._is_multilabel(input)
|
|
|
|
|
|
def test_stack_complement():
|
|
# Toy example
|
|
pred_probs_class = np.array([0.1, 0.9, 0.3, 0.8])
|
|
pred_probs_extended = stack_complement(pred_probs_class)
|
|
pred_probs_expected = np.array(
|
|
[
|
|
[0.9, 0.1],
|
|
[0.1, 0.9],
|
|
[0.7, 0.3],
|
|
[0.2, 0.8],
|
|
]
|
|
)
|
|
assert np.isclose(pred_probs_extended, pred_probs_expected).all()
|
|
|
|
# Check preservation of probabilities
|
|
pred_probs_class = np.random.rand(100)
|
|
pred_probs_extended = stack_complement(pred_probs_class)
|
|
assert np.sum(pred_probs_extended, axis=1).all() == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"pred_probs_test",
|
|
(None, "pred_probs"),
|
|
ids=["Without probabilities", "With probabilities"],
|
|
)
|
|
def test_get_onehot_num_classes(labels, pred_probs_test, request):
|
|
pred_probs_test = (
|
|
request.getfixturevalue(pred_probs_test)
|
|
if isinstance(pred_probs_test, str)
|
|
else pred_probs_test
|
|
)
|
|
labels_list = [np.nonzero(x)[0].tolist() for x in labels]
|
|
_, num_classes = get_onehot_num_classes(labels_list, pred_probs_test)
|
|
assert num_classes == 3
|
|
|
|
|
|
def test_get_label_quality_scores_output(labels, pred_probs, scorer):
|
|
# Check that the function returns a dictionary with the correct keys.
|
|
scores = ml_scorer.get_label_quality_scores(labels, pred_probs, method=scorer)
|
|
assert isinstance(scores, np.ndarray)
|
|
assert scores.shape == (labels.shape[0],)
|
|
assert np.all(scores >= 0) and np.all(scores <= 1)
|
|
assert np.all(np.isfinite(scores))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"given_labels,expected",
|
|
[
|
|
(
|
|
"labels",
|
|
np.full((3, 2), 0.5),
|
|
),
|
|
(np.array([[0, 1], [0, 0], [1, 1]]), np.array([[2 / 3, 1 / 3], [1 / 3, 2 / 3]])),
|
|
(np.array([[0, 1], [0, 0], [0, 1], [0, 1]]), np.array([[4 / 4, 0 / 4], [1 / 4, 3 / 4]])),
|
|
(
|
|
np.array([[0, 1, 0, 0, 0, 0, 0, 0, 0]]),
|
|
np.array([[1, 0] if i != 1 else [0, 1] for i in range(9)]),
|
|
),
|
|
],
|
|
ids=[
|
|
"default",
|
|
"Missing class assignment configuration",
|
|
"Missing class",
|
|
"Handle more than 8 classes",
|
|
],
|
|
)
|
|
def test_multilabel_py(given_labels, expected, request):
|
|
given_labels = (
|
|
request.getfixturevalue(given_labels) if isinstance(given_labels, str) else given_labels
|
|
)
|
|
py = ml_scorer.multilabel_py(given_labels)
|
|
assert isinstance(py, np.ndarray)
|
|
assert py.shape == (given_labels.shape[1], 2)
|
|
assert np.isclose(py, expected).all()
|
|
|
|
|
|
@pytest.mark.parametrize("K", [2, 3, 4], ids=["K=2", "K=3", "K=4"])
|
|
def test_get_split_generator(cv, K):
|
|
all_configurations = np.array(list(itertools.product([0, 1], repeat=K)))
|
|
given_labels = np.repeat(all_configurations, 2, axis=0)
|
|
|
|
split_generator = ml_scorer._get_split_generator(given_labels, cv)
|
|
assert isinstance(split_generator, typing.Generator)
|
|
|
|
train, test = next(split_generator)
|
|
for split in (train, test):
|
|
assert isinstance(split, np.ndarray)
|
|
assert np.isin(split, np.arange(given_labels.shape[0])).all()
|
|
|
|
# Test that the label distribution is relatively equal among the splits.
|
|
train_labels, test_labels = given_labels[train], given_labels[test]
|
|
_, train_counts = np.unique(train_labels, axis=0, return_counts=True)
|
|
_, test_counts = np.unique(test_labels, axis=0, return_counts=True)
|
|
# cv.get_n_splits() is 2, so we expect 1/2 of the labels in each split.
|
|
assert np.all(train_counts == 1)
|
|
assert np.all(test_counts == 1)
|
|
|
|
|
|
# Test split_generator with rare/missing multilabel configurations
|
|
@pytest.mark.parametrize("K", [2, 3, 4], ids=["K=2", "K=3", "K=4"])
|
|
def test_get_split_generator_rare_configurations(cv, K):
|
|
all_configurations = np.array(list(itertools.product([0, 1], repeat=K)))
|
|
given_labels = np.repeat(all_configurations, 2, axis=0)
|
|
|
|
# Remove one configuration
|
|
given_labels = given_labels[~np.all(given_labels == all_configurations[0], axis=1)]
|
|
|
|
split_generator = ml_scorer._get_split_generator(given_labels, cv)
|
|
train, test = next(split_generator)
|
|
train_labels, test_labels = given_labels[train], given_labels[test]
|
|
|
|
# Test that the label distribution is relatively equal among the splits.
|
|
_, train_counts = np.unique(train_labels, axis=0, return_counts=True)
|
|
_, test_counts = np.unique(test_labels, axis=0, return_counts=True)
|
|
# cv.get_n_splits() is 2, so we expect 1/2 of the labels in each split.
|
|
assert np.all(train_counts == 1)
|
|
assert np.all(test_counts == 1)
|
|
assert len(train_counts) == len(test_counts) == len(all_configurations) - 1
|
|
|
|
# Remove one instance from labels
|
|
given_labels = given_labels[1:, :]
|
|
|
|
split_generator = ml_scorer._get_split_generator(given_labels, cv)
|
|
train, test = next(split_generator)
|
|
train_labels, test_labels = given_labels[train], given_labels[test]
|
|
|
|
# Test that the label distribution is relatively equal among the splits.
|
|
_, train_counts = np.unique(train_labels, axis=0, return_counts=True)
|
|
_, test_counts = np.unique(test_labels, axis=0, return_counts=True)
|
|
# cv.get_n_splits() is 2, so we expect 1/2 of the labels in each split,
|
|
# except for the class with one fewer instances.
|
|
assert len(train_counts) != len(test_counts)
|
|
|
|
|
|
def test_get_cross_validated_multilabel_pred_probs(dummy_features, labels, cv, pred_probs_gold):
|
|
clf = OneVsRestClassifier(LogisticRegression(random_state=0))
|
|
pred_probs = ml_scorer.get_cross_validated_multilabel_pred_probs(
|
|
dummy_features,
|
|
labels,
|
|
clf=clf,
|
|
cv=cv,
|
|
)
|
|
assert isinstance(pred_probs, np.ndarray)
|
|
assert pred_probs.shape == labels.shape
|
|
assert np.all(pred_probs >= 0) and np.all(pred_probs <= 1)
|
|
assert np.all(np.isfinite(pred_probs))
|
|
|
|
# Gold master test - Ensure output is consistent
|
|
assert dummy_features.shape == (10, 2)
|
|
assert np.allclose(pred_probs, pred_probs_gold, atol=5e-4)
|
|
|
|
|
|
class TestExponentialMovingAverage:
|
|
"""Test the ml_scorer.expontential_moving_average function."""
|
|
|
|
@pytest.mark.parametrize("alpha", [0.5, None])
|
|
def test_valid_alpha(self, alpha):
|
|
# Test valid alpha values
|
|
for x, expected_ema in zip(
|
|
[
|
|
np.ones(5).reshape(1, -1),
|
|
np.array([[0.1, 0.2, 0.3]]),
|
|
np.array([x / 10 for x in range(1, 7)]).reshape(2, 3),
|
|
],
|
|
[1, 0.175, np.array([0.175, 0.475])],
|
|
):
|
|
ema = ml_scorer.exponential_moving_average(x, alpha=alpha)
|
|
assert np.allclose(ema, expected_ema, atol=1e-4)
|
|
|
|
@pytest.mark.parametrize(
|
|
"alpha,expected_ema",
|
|
[[0, 0.3], [1, 0.1]],
|
|
ids=["alpha=0", "alpha=1"],
|
|
)
|
|
def test_alpha_boundary(self, alpha, expected_ema):
|
|
# alpha = 0(1) should return the largest(smallest) value
|
|
X = np.array([[0.1, 0.2, 0.3]])
|
|
ema = ml_scorer.exponential_moving_average(X, alpha=alpha)
|
|
assert np.allclose(ema, expected_ema, atol=1e-4)
|
|
|
|
def test_invalid_alpha(self):
|
|
# Test that the exponential moving average raises an error
|
|
# when alpha is not in the interval [0, 1].
|
|
partial_error_msg = r"alpha must be in the interval \[0, 1\]"
|
|
for alpha in [-0.5, 1.5]:
|
|
with pytest.raises(ValueError, match=partial_error_msg):
|
|
ml_scorer.exponential_moving_average(np.ones(5).reshape(1, -1), alpha=alpha)
|
|
|
|
|
|
def flip_labels(label, flip_prob):
|
|
"""Flips binary labels with a given probability."""
|
|
rand_flip = np.random.choice(
|
|
[0, 1], size=label.shape, replace=True, p=[1 - flip_prob, flip_prob]
|
|
)
|
|
return np.abs(label - rand_flip).astype(int)
|
|
|
|
|
|
@composite
|
|
def cleanlab_data_strategy(draw):
|
|
num_classes = draw(st.integers(min_value=2, max_value=3))
|
|
num_samples = draw(st.integers(min_value=10, max_value=50))
|
|
|
|
# Generate true labels as one-hot encoded vectors for multi-label
|
|
true_labels = draw(
|
|
arrays(dtype=np.int8, shape=(num_samples, num_classes), elements=st.integers(0, 1))
|
|
)
|
|
|
|
# Generate noise matrix for multi-label and flip those values
|
|
flip_prob = 0.2
|
|
noisy_labels = flip_labels(true_labels, flip_prob)
|
|
|
|
# Multilabel find_issues raises a ValueError if all values are the same
|
|
# To avoid that we flip the first two values if all values are equal.
|
|
for i in range(noisy_labels.shape[1]):
|
|
if np.all(noisy_labels[:, i] == noisy_labels[0, i]):
|
|
noisy_labels[:2, i] = 1 - noisy_labels[:2, i]
|
|
|
|
# Generate predicted probabilities for each class for each sample
|
|
pred_probs = draw(
|
|
arrays(
|
|
dtype=np.float32,
|
|
shape=(num_samples, num_classes),
|
|
elements=st.floats(min_value=0, max_value=1, width=32), # Specify width here
|
|
)
|
|
)
|
|
|
|
for i in range(num_samples):
|
|
for j in range(num_classes):
|
|
if draw(st.floats(min_value=0, max_value=1)) < 0.1:
|
|
# Set some probability values to exactly 0.5
|
|
pred_probs[i][j] = 0.5
|
|
return true_labels, noisy_labels, np.array(pred_probs)
|
|
|
|
|
|
class TestMultiLabel:
|
|
@given(cleanlab_data_strategy())
|
|
@settings(deadline=20000)
|
|
def test_find_label_issues(self, data):
|
|
true_labels, noisy_labels, pred_probs = data
|
|
noisy_labels_list = onehot2int(noisy_labels)
|
|
is_issue = filter.find_label_issues(
|
|
labels=noisy_labels_list, pred_probs=np.array(pred_probs), n_jobs=1
|
|
)
|
|
threshold = 0.5
|
|
predicted_labels = (pred_probs >= threshold).astype(int)
|
|
|
|
# Check if predicted labels are the same as noisy labels for each example
|
|
labels_match = np.all(predicted_labels == noisy_labels, axis=1)
|
|
|
|
# For any example flagged as having an issue, there should be at least one label mismatch
|
|
assert not np.any(
|
|
is_issue & labels_match
|
|
), "Examples with issues must have at least one label mismatch."
|