Files
2026-07-13 12:49:22 +08:00

565 lines
14 KiB
Python

import requests
import pytest
import hypothesis.extra.numpy as npst
import hypothesis.strategies as st
import io
import numpy as np
from hypothesis import given, settings
from cleanlab.dataset import (
health_summary,
find_overlapping_classes,
rank_classes_by_label_quality,
overall_label_health_score,
)
from cleanlab.count import estimate_joint, num_label_issues, compute_confident_joint
cifar100 = [
"apple",
"aquarium_fish",
"baby",
"bear",
"beaver",
"bed",
"bee",
"beetle",
"bicycle",
"bottle",
"bowl",
"boy",
"bridge",
"bus",
"butterfly",
"camel",
"can",
"castle",
"caterpillar",
"cattle",
"chair",
"chimpanzee",
"clock",
"cloud",
"cockroach",
"couch",
"crab",
"crocodile",
"cup",
"dinosaur",
"dolphin",
"elephant",
"flatfish",
"forest",
"fox",
"girl",
"hamster",
"house",
"kangaroo",
"keyboard",
"lamp",
"lawn_mower",
"leopard",
"lion",
"lizard",
"lobster",
"man",
"maple_tree",
"motorcycle",
"mountain",
"mouse",
"mushroom",
"oak_tree",
"orange",
"orchid",
"otter",
"palm_tree",
"pear",
"pickup_truck",
"pine_tree",
"plain",
"plate",
"poppy",
"porcupine",
"possum",
"rabbit",
"raccoon",
"ray",
"road",
"rocket",
"rose",
"sea",
"seal",
"shark",
"shrew",
"skunk",
"skyscraper",
"snail",
"snake",
"spider",
"squirrel",
"streetcar",
"sunflower",
"sweet_pepper",
"table",
"tank",
"telephone",
"television",
"tiger",
"tractor",
"train",
"trout",
"tulip",
"turtle",
"wardrobe",
"whale",
"willow_tree",
"wolf",
"woman",
"worm",
]
caltech256 = [
"ak47",
"american-flag",
"backpack",
"baseball-bat",
"baseball-glove",
"basketball-hoop",
"bat",
"bathtub",
"bear",
"beer-mug",
"billiards",
"binoculars",
"birdbath",
"blimp",
"bonsai",
"boom-box",
"bowling-ball",
"bowling-pin",
"boxing-glove",
"brain",
"breadmaker",
"buddha",
"bulldozer",
"butterfly",
"cactus",
"cake",
"calculator",
"camel",
"cannon",
"canoe",
"car-tire",
"cartman",
"cd",
"centipede",
"cereal-box",
"chandelier",
"chess-board",
"chimp",
"chopsticks",
"cockroach",
"coffee-mug",
"coffin",
"coin",
"comet",
"computer-keyboard",
"computer-monitor",
"computer-mouse",
"conch",
"cormorant",
"covered-wagon",
"cowboy-hat",
"crab",
"desk-globe",
"diamond-ring",
"dice",
"dog",
"dolphin",
"doorknob",
"drinking-straw",
"duck",
"dumb-bell",
"eiffel-tower",
"electric-guitar",
"elephant",
"elk",
"ewer",
"eyeglasses",
"fern",
"fighter-jet",
"fire-extinguisher",
"fire-hydrant",
"fire-truck",
"fireworks",
"flashlight",
"floppy-disk",
"football-helmet",
"french-horn",
"fried-egg",
"frisbee",
"frog",
"frying-pan",
"galaxy",
"gas-pump",
"giraffe",
"goat",
"golden-gate-bridge",
"goldfish",
"golf-ball",
"goose",
"gorilla",
"grand-piano",
"grapes",
"grasshopper",
"guitar-pick",
"hamburger",
"hammock",
"harmonica",
"harp",
"harpsichord",
"hawksbill",
"head-phones",
"helicopter",
"hibiscus",
"homer-simpson",
"horse",
"horseshoe-crab",
"hot-air-balloon",
"hot-dog",
"hot-tub",
"hourglass",
"house-fly",
"human-skeleton",
"hummingbird",
"ibis",
"ice-cream-cone",
"iguana",
"ipod",
"iris",
"jesus-christ",
"joy-stick",
"kangaroo",
"kayak",
"ketch",
"killer-whale",
"knife",
"ladder",
"laptop",
"lathe",
"leopards",
"license-plate",
"lightbulb",
"light-house",
"lightning",
"llama",
"mailbox",
"mandolin",
"mars",
"mattress",
"megaphone",
"menorah",
"microscope",
"microwave",
"minaret",
"minotaur",
"motorbikes",
"mountain-bike",
"mushroom",
"mussels",
"necktie",
"octopus",
"ostrich",
"owl",
"palm-pilot",
"palm-tree",
"paperclip",
"paper-shredder",
"pci-card",
"penguin",
"people",
"pez-dispenser",
"photocopier",
"picnic-table",
"playing-card",
"porcupine",
"pram",
"praying-mantis",
"pyramid",
"raccoon",
"radio-telescope",
"rainbow",
"refrigerator",
"revolver",
"rifle",
"rotary-phone",
"roulette-wheel",
"saddle",
"saturn",
"school-bus",
"scorpion",
"screwdriver",
"segway",
"self-propelled-lawn-mower",
"sextant",
"sheet-music",
"skateboard",
"skunk",
"skyscraper",
"smokestack",
"snail",
"snake",
"sneaker",
"snowmobile",
"soccer-ball",
"socks",
"soda-can",
"spaghetti",
"speed-boat",
"spider",
"spoon",
"stained-glass",
"starfish",
"steering-wheel",
"stirrups",
"sunflower",
"superman",
"sushi",
"swan",
"swiss-army-knife",
"sword",
"syringe",
"tambourine",
"teapot",
"teddy-bear",
"teepee",
"telephone-box",
"tennis-ball",
"tennis-court",
"tennis-racket",
"theodolite",
"toaster",
"tomato",
"tombstone",
"top-hat",
"touring-bike",
"tower-pisa",
"traffic-light",
"treadmill",
"triceratops",
"tricycle",
"trilobite",
"tripod",
"t-shirt",
"tuning-fork",
"tweezer",
"umbrella",
"unicorn",
"vcr",
"video-projector",
"washing-machine",
"watch",
"waterfall",
"watermelon",
"welding-mask",
"wheelbarrow",
"windmill",
"wine-bottle",
"xylophone",
"yarmulke",
"yo-yo",
"zebra",
"airplanes",
"car-side",
"faces-easy",
"greyhound",
"tennis-shoes",
"toad",
]
imdb = ["Negative", "Positive"]
mnist = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
urls = {
"caltech256": [
"https://github.com/cleanlab/label-errors/raw/5392f6c71473055060be3044becdde1cbc18284d/"
"original_test_labels/caltech256_original_labels.npy",
"https://github.com/cleanlab/label-errors/raw/5392f6c71473055060be3044becdde1cbc18284d"
"/cross_validated_predicted_probabilities/caltech256_pyx.npy",
],
"mnist": [
"https://github.com/cleanlab/label-errors/raw/5392f6c71473055060be3044becdde1cbc18284d"
"/original_test_labels/mnist_test_set_original_labels.npy",
"https://github.com/cleanlab/label-errors/raw/5392f6c71473055060be3044becdde1cbc18284d"
"/cross_validated_predicted_probabilities/mnist_test_set_pyx.npy",
],
"imdb": [
"https://github.com/cleanlab/label-errors/raw"
"/5392f6c71473055060be3044becdde1cbc18284d/original_test_labels"
"/imdb_test_set_original_labels.npy",
"https://github.com/cleanlab/label-errors/raw/5392f6c71473055060be3044becdde1cbc18284d"
"/cross_validated_predicted_probabilities/imdb_test_set_pyx.npy",
],
"cifar100": [
"https://github.com/cleanlab/label-errors/raw/5392f6c71473055060be3044becdde1cbc18284d"
"/original_test_labels/cifar100_test_set_original_labels.npy",
"https://github.com/cleanlab/label-errors/raw/5392f6c71473055060be3044becdde1cbc18284d"
"/cross_validated_predicted_probabilities/cifar100_test_set_pyx.npy",
],
}
def _get_pred_probs_labels_from_labelerrors_datasets(dataset_name):
"""Helper function to load data from the labelerrors.com datasets."""
labels_url, pred_probs_url = urls[dataset_name]
response = requests.get(pred_probs_url)
response.raise_for_status()
pred_probs = np.load(io.BytesIO(response.content), allow_pickle=True)
response = requests.get(labels_url)
response.raise_for_status()
labels = np.load(io.BytesIO(response.content), allow_pickle=True)
return pred_probs, labels
@pytest.mark.parametrize("dataset_name", ["mnist", "caltech256", "cifar100"])
def test_real_datasets(dataset_name):
print("\n" + dataset_name.capitalize() + "\n")
class_names = eval(dataset_name)
pred_probs, labels = _get_pred_probs_labels_from_labelerrors_datasets(dataset_name)
# if this runs without issue no all four datasets, the test passes
_ = health_summary(
pred_probs=pred_probs,
labels=labels,
class_names=class_names,
verbose=dataset_name != "mnist", # test out verbose=False on one of the datasets.
)
@pytest.mark.parametrize("dataset_name", ["mnist"])
def test_multilabel_error(dataset_name):
print("\n" + dataset_name.capitalize() + "\n")
class_names = eval(dataset_name)
pred_probs, labels = _get_pred_probs_labels_from_labelerrors_datasets(dataset_name)
# if this runs without issue no all four datasets, the test passes
with pytest.raises(ValueError) as e:
_ = find_overlapping_classes(labels=labels, pred_probs=pred_probs, multi_label=True)
@pytest.mark.parametrize("asymmetric", [True, False])
@pytest.mark.parametrize("dataset_name", ["mnist", "imdb"])
def test_symmetry_df_size(asymmetric, dataset_name):
pred_probs, labels = _get_pred_probs_labels_from_labelerrors_datasets(dataset_name)
joint = estimate_joint(labels=labels, pred_probs=pred_probs)
num_classes = pred_probs.shape[1]
df = find_overlapping_classes(
joint=joint,
asymmetric=asymmetric,
class_names=eval(dataset_name),
num_examples=len(labels),
)
if asymmetric:
assert len(df) == num_classes**2 - num_classes
else: # symmetric
assert len(df) == (num_classes**2 - num_classes) / 2
# Second test for symmetric
# check that the row, col value returned is actually the sum from the joint.
sum_0_1 = joint[0, 1] + joint[1, 0]
df_0_1 = df[(df["Class Index A"] == 0) & (df["Class Index B"] == 1)]["Joint Probability"]
assert sum_0_1 - df_0_1.values[0] < 1e-8 # Check two floats are equal
@pytest.mark.parametrize("use_num_examples", [True, False])
@pytest.mark.parametrize("use_labels", [True, False])
@pytest.mark.parametrize(
"func", [find_overlapping_classes, rank_classes_by_label_quality, overall_label_health_score]
)
def test_value_error_missing_num_examples_with_joint(use_num_examples, use_labels, func):
dataset_name = "imdb"
pred_probs, labels = _get_pred_probs_labels_from_labelerrors_datasets(dataset_name)
joint = estimate_joint(labels=labels, pred_probs=pred_probs)
if use_num_examples is False and use_labels is False: # can't infer num_examples. Throw error!
with pytest.raises(ValueError) as e:
df = func(
labels=labels if use_labels else None,
joint=joint,
num_examples=len(labels) if use_num_examples else None,
)
else: # at least one of use_num_examples and use_labels must be True. Can infer num_examples.
# If this runs without error, the test passes.
df = func(
labels=labels if use_labels else None,
joint=joint,
num_examples=len(labels) if use_num_examples else None,
)
@pytest.mark.parametrize("dataset_name", ["mnist", "caltech256", "cifar100"])
def test_overall_label_health_score_matched_num_issues(dataset_name):
# Matches num_label_issues
pred_probs, labels = _get_pred_probs_labels_from_labelerrors_datasets(dataset_name)
num_issues = num_label_issues(labels=labels, pred_probs=pred_probs)
score = overall_label_health_score(labels=labels, pred_probs=pred_probs)
assert 1 - num_issues / labels.shape[0] == score
def test_overall_label_health_score_function_calls():
dataset_name = "caltech256"
pred_probs, labels = _get_pred_probs_labels_from_labelerrors_datasets(dataset_name)
score = overall_label_health_score(labels=labels, pred_probs=pred_probs)
confident_joint = compute_confident_joint(labels=labels, pred_probs=pred_probs)
num_examples = len(labels)
score_cj = overall_label_health_score(
labels=None, pred_probs=pred_probs, confident_joint=confident_joint
)
joint = estimate_joint(labels=labels, pred_probs=pred_probs)
score_joint = overall_label_health_score(
labels=None, pred_probs=pred_probs, joint=joint, num_examples=num_examples
)
joint_cj = estimate_joint(labels=labels, pred_probs=pred_probs, confident_joint=confident_joint)
score_joint_cj = overall_label_health_score(
labels=None, pred_probs=pred_probs, joint=joint_cj, num_examples=num_examples
)
assert score_cj != score
assert score_cj == score_joint
assert score_joint_cj == score_joint
confident_joint_strategy = npst.arrays(
np.int32,
shape=npst.array_shapes(min_dims=2, max_dims=2, min_side=2, max_side=10),
elements=st.integers(min_value=0, max_value=int(1e6)),
).filter(lambda arr: arr.shape[0] == arr.shape[1])
@pytest.mark.issue_651
@given(confident_joint=confident_joint_strategy)
@settings(deadline=500)
def test_find_overlapping_classes_with_confident_joint(confident_joint):
# Setup
K = confident_joint.shape[0]
overlapping_classes = find_overlapping_classes(confident_joint=confident_joint)
# Test that the output dataframe has the expected columns
expected_columns = [
"Class Index A",
"Class Index B",
"Num Overlapping Examples",
"Joint Probability",
]
assert set(overlapping_classes.columns) == set(expected_columns)
# Class indices must be valid
assert overlapping_classes["Class Index A"].between(0, K - 1).all()
assert overlapping_classes["Class Index B"].between(0, K - 1).all()
# Overlapping example count should be non-negative integers
assert (overlapping_classes["Num Overlapping Examples"] >= 0).all()
assert overlapping_classes["Num Overlapping Examples"].dtype == int
# Joint probabilities should be between 0 and 1
assert (overlapping_classes["Joint Probability"] >= 0).all()
assert (overlapping_classes["Joint Probability"] <= 1).all()
# Joint probabilities sorted in descending order
if K > 2:
assert (overlapping_classes["Joint Probability"].diff()[1:] <= 0).all()