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

259 lines
9.6 KiB
Python

import numpy as np
import pytest
from hypothesis import HealthCheck, assume, given, settings, strategies as st
from hypothesis.strategies import composite
from hypothesis.extra.numpy import arrays
from cleanlab import Datalab
from cleanlab.datalab.internal.issue_manager.duplicate import NearDuplicateIssueManager
from .conftest import knn_graph_strategy
SEED = 42
@composite
def embeddings_strategy(draw):
shape_strategy = st.tuples(
st.integers(min_value=3, max_value=20), st.integers(min_value=2, max_value=2)
)
element_strategy = st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
)
embeddings = draw(
arrays(
dtype=np.float64,
shape=shape_strategy,
elements=element_strategy,
unique=True,
)
)
return embeddings
class TestNearDuplicateIssueManager:
@pytest.fixture
def embeddings(self, lab):
np.random.seed(SEED)
embeddings_array = 0.5 + 0.1 * np.random.rand(lab.get_info("statistics")["num_examples"], 2)
embeddings_array[4, :] = (
embeddings_array[3, :] + np.random.rand(embeddings_array.shape[1]) * 0.001
)
return {"embedding": embeddings_array}
@pytest.fixture
def issue_manager(self, lab, embeddings, monkeypatch):
mock_data = lab.data.from_dict({**lab.data.to_dict(), **embeddings})
monkeypatch.setattr(lab, "data", mock_data)
return NearDuplicateIssueManager(
datalab=lab,
metric="euclidean",
k=2,
)
def test_init(self, lab, issue_manager):
assert issue_manager.datalab == lab
assert issue_manager.metric == "euclidean"
assert issue_manager.k == 2
assert issue_manager.threshold == 0.13
issue_manager = NearDuplicateIssueManager(
datalab=lab,
threshold=0.1,
)
assert issue_manager.threshold == 0.1
def test_find_issues(self, issue_manager, embeddings):
issue_manager.find_issues(features=embeddings["embedding"])
issues, summary, info = issue_manager.issues, issue_manager.summary, issue_manager.info
expected_issue_mask = np.array([False] * 3 + [True] * 2)
assert np.all(
issues["is_near_duplicate_issue"] == expected_issue_mask
), "Issue mask should be correct"
assert summary["issue_type"][0] == "near_duplicate"
assert summary["score"][0] == pytest.approx(expected=0.4734458, abs=1e-7)
assert (
info.get("near_duplicate_sets", None) is not None
), "Should have sets of near duplicates"
new_issue_manager = NearDuplicateIssueManager(
datalab=issue_manager.datalab,
metric="euclidean",
k=2,
threshold=0.1,
)
new_issue_manager.find_issues(features=embeddings["embedding"])
def test_scores_of_examples_with_issues_are_smaller_than_those_without(
self, issue_manager, embeddings
):
# TODO: Turn this into a property-based test
issue_manager.find_issues(features=embeddings["embedding"])
is_issue = issue_manager.issues["is_near_duplicate_issue"]
scores = issue_manager.issues["near_duplicate_score"]
max_issue_score = np.max(scores[is_issue])
min_non_issue_score = np.min(scores[~is_issue])
assert max_issue_score < min_non_issue_score
def test_report(self, issue_manager, embeddings):
issue_manager.find_issues(features=embeddings["embedding"])
report = issue_manager.report(
issues=issue_manager.issues,
summary=issue_manager.summary,
info=issue_manager.info,
)
assert isinstance(report, str)
assert (
"------------------ near_duplicate issues -------------------\n\n"
"Number of examples with this issue:"
) in report
report = issue_manager.report(
issues=issue_manager.issues,
summary=issue_manager.summary,
info=issue_manager.info,
verbosity=3,
)
assert "Additional Information: " in report
@given(embeddings=embeddings_strategy())
@settings(deadline=800)
def test_near_duplicate_sets(self, embeddings):
data = {"metadata": ["" for _ in range(len(embeddings))]}
lab = Datalab(data)
issue_manager = NearDuplicateIssueManager(
datalab=lab,
metric="euclidean",
k=2,
)
embeddings = np.array(embeddings)
issue_manager.find_issues(features=embeddings)
near_duplicate_sets = issue_manager.info["near_duplicate_sets"]
issues = issue_manager.issues["is_near_duplicate_issue"]
# Test: Near duplicates are symmetric
all_symmetric = all(
i in near_duplicate_sets[j]
for i, near_duplicates in enumerate(near_duplicate_sets)
for j in near_duplicates
)
assert all_symmetric, "Some near duplicate sets are not symmetric"
# Test: Near duplicate sets for issues
all_non_issues_have_empty_near_duplicate_sets = all(
len(near_duplicate_set) == 0
for i, near_duplicate_set in enumerate(near_duplicate_sets)
if not issues[i]
)
assert (
all_non_issues_have_empty_near_duplicate_sets
), "Non-issue examples should not have near duplicate sets"
all_issues_have_non_empty_near_duplicate_sets = all(
len(near_duplicate_set) > 0
for i, near_duplicate_set in enumerate(near_duplicate_sets)
if issues[i]
)
assert (
all_issues_have_non_empty_near_duplicate_sets
), "Issue examples should have near duplicate sets"
def build_issue_manager(
draw, num_samples_strategy, k_neighbors_strategy, with_issues=False, threshold=None
):
"""Create a random knn_graph with the given number of samples and k neighbors.
Run the NearDuplicateIssueManager on the knn_graph and return the issue manager.
A threshold can be provided to control the number of issues for small graphs.
A with_issues flag can be provided to control whether the issue manager should have issues.
"""
if with_issues:
knn_graph = draw(
knn_graph_strategy(num_samples=num_samples_strategy, k_neighbors=k_neighbors_strategy)
)
else:
knn_graph = draw(
knn_graph_strategy(
num_samples=num_samples_strategy, k_neighbors=k_neighbors_strategy, min_distance=0.1
)
)
lab = Datalab(data={})
inputs = {"datalab": lab, "threshold": threshold}
inputs = {k: v for k, v in inputs.items() if v is not None}
issue_manager = NearDuplicateIssueManager(**inputs)
issue_manager.find_issues(knn_graph=knn_graph)
issues = issue_manager.issues["is_near_duplicate_issue"]
if with_issues:
assume(any(issues))
else:
assume(not any(issues))
return issue_manager
@st.composite
def no_issue_issue_manager_strategy(draw):
"""Strategy for generating NearDuplicateIssueManagers with no issues."""
return build_issue_manager(
draw,
st.integers(min_value=10, max_value=50),
st.integers(min_value=2, max_value=5),
with_issues=False,
threshold=0.0001,
)
@st.composite
def issue_manager_with_issues_strategy(draw):
"""Strategy for generating NearDuplicateIssueManagers with issues."""
return build_issue_manager(
draw,
st.integers(min_value=10, max_value=20),
st.integers(min_value=2, max_value=5),
with_issues=True,
threshold=0.9,
)
class TestNearDuplicateSets:
"""Property-based tests properties of near duplicate sets found in a knn graph."""
@pytest.mark.slow
@given(issue_manager=no_issue_issue_manager_strategy())
@settings(
deadline=800, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large]
)
def test_near_duplicate_sets_empty_if_no_issue_next(self, issue_manager):
near_duplicate_sets = issue_manager.info["near_duplicate_sets"]
assert all(len(near_duplicate_set) == 0 for near_duplicate_set in near_duplicate_sets)
@given(issue_manager=issue_manager_with_issues_strategy())
@settings(deadline=800, max_examples=1000, suppress_health_check=[HealthCheck.too_slow])
def test_symmetric_and_flagged_consistency(self, issue_manager):
near_duplicate_sets = issue_manager.info["near_duplicate_sets"]
issues = issue_manager.issues["is_near_duplicate_issue"]
# Test symmetry: If A is in near_duplicate_set of B, then B should be in near_duplicate_set of A.
for i, near_duplicates in enumerate(near_duplicate_sets):
for j in near_duplicates:
assert (
i in near_duplicate_sets[j]
), f"Example {j} is in near_duplicate_set of {i}, but not vice versa"
# Test consistency of flags with near_duplicate_sets
for i, near_duplicate_set in enumerate(near_duplicate_sets):
if issues[i]:
# Near duplicate sets of flagged examples should not be empty
assert (
len(near_duplicate_set) > 0
), f"Near duplicate set of flagged example {i} is empty"
# Check if all examples in the near_duplicate_set of a flagged example are also flagged
flagged_in_set = [issues[j] for j in near_duplicate_set]
assert all(
flagged_in_set
), f"Example {i} is flagged as near_duplicate but some examples in its near_duplicate_set are not flagged"