Download sdg/preprocessing/dedupe/test_edge_cases.py from fzzhang/svd-code: direct link, hf CLI and curl.
- Browser
- Download file 19.4 kB
-
https://huggingface.co/fzzhang/svd-code/resolve/main/sdg/preprocessing/dedupe/test_edge_cases.py
- Command line
-
hf download hf://fzzhang/svd-code/sdg/preprocessing/dedupe/test_edge_cases.py
-
curl -L -o test_edge_cases.py https://huggingface.co/fzzhang/svd-code/resolve/main/sdg/preprocessing/dedupe/test_edge_cases.py
19.4 kB
| """Edge cases and limits for MinHash + Semantic deduplicators. | |
| Covers: | |
| - Degenerate inputs (empty strings, whitespace, single char, unicode, emoji) | |
| - Threshold boundaries (1.0, near-zero, just-above-cutoff) | |
| - Shingle/permutation extremes | |
| - Large clusters, many disjoint clusters, mixed | |
| - HNSW vs Flat boundary behavior | |
| - Numerical edge cases for embeddings (zero vector, antipodal, very high dim) | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| ds = pytest.importorskip("datasketch") | |
| faiss = pytest.importorskip("faiss") | |
| import numpy as np | |
| from sdg.preprocessing.dedupe.minhash import MinHashDeduplicator | |
| from sdg.preprocessing.dedupe.semantic import SemanticDeduplicator | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MinHash: degenerate inputs | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_minhash_all_empty_strings_collapse(): | |
| keep = MinHashDeduplicator().dedup(["", "", ""], show_progress=False) | |
| assert len(keep) == 1 | |
| def test_minhash_one_empty_with_distinct_others(): | |
| keep = MinHashDeduplicator().dedup( | |
| ["", "alpha beta gamma delta epsilon zeta", "completely different content here"], | |
| show_progress=False, | |
| ) | |
| # Empty doesn't share shingles with the others -> kept as singleton | |
| assert len(keep) == 3 | |
| def test_minhash_whitespace_only_strings_collapse_with_normalize(): | |
| keep = MinHashDeduplicator(normalize=True).dedup( | |
| [" ", "\t\t", "\n\n", " \t \n "], | |
| show_progress=False, | |
| ) | |
| assert len(keep) == 1 | |
| def test_minhash_single_character_strings(): | |
| """Each char becomes its own short shingle; identical chars collapse.""" | |
| keep = MinHashDeduplicator().dedup(["a", "a", "b"], show_progress=False) | |
| assert len(keep) == 2 | |
| def test_minhash_string_shorter_than_shingle_size(): | |
| """When text is shorter than shingle size, the whole text is a single shingle.""" | |
| keep = MinHashDeduplicator(shingle_size=10).dedup( | |
| ["short text", "short text", "very different brief"], | |
| show_progress=False, | |
| ) | |
| assert len(keep) == 2 | |
| def test_minhash_unicode_cjk(): | |
| text_a = "θ§£ε³ζΉη¨ x εΉ³ζΉ εε» ε ηδΊ ιΆ η θ―¦η» ζ₯ιͺ€ θ§£ι" | |
| text_b = text_a # identical | |
| text_c = "ε εδ½η¨ ζ― ζ€η© ε©η¨ ε θ½ εΆι ζζΊη© η θΏη¨ θ―¦θ§£" | |
| keep = MinHashDeduplicator().dedup([text_a, text_b, text_c], show_progress=False) | |
| assert len(keep) == 2 | |
| def test_minhash_emoji_inputs(): | |
| a = "Solve this π― equation x squared minus four equals zero step by step" | |
| b = "Solve this π― equation x squared minus four equals zero step by step" | |
| c = "Cook the π pasta in salted boiling water for nine to ten minutes" | |
| keep = MinHashDeduplicator().dedup([a, b, c], show_progress=False) | |
| assert len(keep) == 2 | |
| def test_minhash_repeated_token_pattern(): | |
| """Many copies of the same token -> all shingles are identical.""" | |
| a = ("yes " * 50).strip() | |
| b = ("yes " * 100).strip() | |
| c = ("no " * 50).strip() | |
| keep = MinHashDeduplicator(threshold=0.5).dedup([a, b, c], show_progress=False) | |
| # a and b share their only shingle "yes yes yes yes yes" -> Jaccard 1.0 | |
| # c is disjoint from them | |
| assert len(keep) == 2 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MinHash: threshold limits | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_minhash_threshold_1_rejected_by_validator(): | |
| """datasketch's LSH cannot find valid bands at threshold=1.0; we reject early.""" | |
| with pytest.raises(ValueError, match="threshold"): | |
| MinHashDeduplicator(threshold=1.0) | |
| def test_minhash_high_threshold_keeps_near_dups_apart(): | |
| """At a strict threshold (0.95), a clearly-below pair (Jaccard ~0.4) does not collapse.""" | |
| base = " ".join(f"word{i}" for i in range(20)) # 20 tokens, 16 5-grams | |
| a = base | |
| # Append 20 unrelated tokens -> b has 36 5-grams, 16 shared with a, Jaccard 16/36 β 0.44 | |
| b = base + " " + " ".join(f"extra{i}" for i in range(20)) | |
| keep = MinHashDeduplicator(threshold=0.95).dedup([a, b], show_progress=False) | |
| assert len(keep) == 2 | |
| def test_minhash_high_threshold_collapses_exact_duplicates(): | |
| """At a strict threshold (0.95), byte-identical strings still collapse.""" | |
| a = " ".join(f"word{i}" for i in range(20)) | |
| keep = MinHashDeduplicator(threshold=0.95).dedup([a, a, a], show_progress=False) | |
| assert len(keep) == 1 | |
| def test_minhash_low_threshold_aggressive_collapse(): | |
| """At very low threshold, weak similarity is enough to merge.""" | |
| texts = [ | |
| "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu", | |
| "alpha xxxxx yyyyy zzzzz wwwww vvvvv uuuuu ttttt sssss rrrrr qqqqq pppp", # shares only 'alpha' as 1-gram | |
| ] | |
| # threshold 0.05 + 1-word shingles -> very low bar; "alpha" alone may suffice | |
| keep = MinHashDeduplicator(threshold=0.05, shingle_size=1).dedup(texts, show_progress=False) | |
| # Tolerant: result is 1 (collapsed) or 2 (LSH missed). Either is acceptable | |
| # at extreme threshold; just don't crash. | |
| assert len(keep) in {1, 2} | |
| def test_minhash_invalid_threshold_zero_raises(): | |
| with pytest.raises(ValueError): | |
| MinHashDeduplicator(threshold=0.0) | |
| def test_minhash_invalid_threshold_negative_raises(): | |
| with pytest.raises(ValueError): | |
| MinHashDeduplicator(threshold=-0.5) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MinHash: shingle and permutation extremes | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_minhash_shingle_size_one(): | |
| """1-word shingles == bag-of-words Jaccard.""" | |
| a = "the quick brown fox" | |
| b = "fox the brown quick" # same words, different order | |
| keep = MinHashDeduplicator(shingle_size=1, threshold=0.9).dedup([a, b], show_progress=False) | |
| # Same shingle set -> Jaccard 1.0 -> collapse | |
| assert len(keep) == 1 | |
| def test_minhash_shingle_size_one_word_order_matters_with_5gram(): | |
| a = "the quick brown fox jumps over the lazy dog quickly today" | |
| b = "today quickly dog lazy the over jumps fox brown quick the" | |
| keep = MinHashDeduplicator(shingle_size=5, threshold=0.7).dedup([a, b], show_progress=False) | |
| # Word order changes -> different 5-grams -> Jaccard near 0 -> kept apart | |
| assert len(keep) == 2 | |
| def test_minhash_invalid_shingle_size_zero_raises(): | |
| with pytest.raises(ValueError): | |
| MinHashDeduplicator(shingle_size=0) | |
| def test_minhash_low_num_perm_still_works(): | |
| """Low num_perm == noisier similarity estimates but still functional.""" | |
| a = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda" | |
| keep = MinHashDeduplicator(num_perm=16, threshold=0.7).dedup([a, a], show_progress=False) | |
| assert len(keep) == 1 | |
| def test_minhash_high_num_perm_still_works(): | |
| a = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu" | |
| keep = MinHashDeduplicator(num_perm=512, threshold=0.7).dedup([a, a], show_progress=False) | |
| assert len(keep) == 1 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MinHash: cluster topology | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_minhash_large_identical_cluster_collapses_to_one(): | |
| a = "Solve x squared minus four equals zero showing all your work step by step in detail" | |
| keep = MinHashDeduplicator().dedup([a] * 200, show_progress=False) | |
| assert len(keep) == 1 | |
| def test_minhash_many_disjoint_pairs_all_collapse(): | |
| """50 pairs of identical strings -> 50 kept.""" | |
| pairs = [] | |
| for i in range(50): | |
| # each pair shares its own unique vocabulary, no cross-pair overlap | |
| prompt = f"unique prompt vocabulary words alpha{i} beta{i} gamma{i} delta{i} epsilon{i} zeta{i}" | |
| pairs.append(prompt) | |
| pairs.append(prompt) | |
| keep = MinHashDeduplicator().dedup(pairs, show_progress=False) | |
| assert len(keep) == 50 | |
| def test_minhash_chain_of_similar_strings_clusters(): | |
| """Three near-identical strings (each one-word edit from the base) form one cluster. | |
| Pure transitivity (A-B above, B-C above, A-C below) is hard to construct | |
| reliably under MinHash's probabilistic LSH at moderate num_perm. Union-find | |
| behavior on transitivity is independently tested in test_base.py; here we | |
| just verify cluster formation works for chained near-dups. | |
| """ | |
| base = " ".join(f"word{i}" for i in range(40)) # 40 tokens | |
| a = base | |
| b = a.replace("word0 ", "REPLACED_B ", 1) | |
| c = a.replace("word39", "REPLACED_C", 1) | |
| # Pairwise Jaccard ~0.95+, well above threshold. | |
| keep = MinHashDeduplicator(threshold=0.85).dedup([a, b, c], show_progress=False) | |
| assert len(keep) == 1 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MinHash: normalization corner cases | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_minhash_normalize_handles_extreme_whitespace(): | |
| a = "alpha beta gamma delta epsilon zeta eta theta iota kappa" | |
| b = "ALPHA\tBETA GAMMA\n\nDELTA\rEPSILON ZETA ETA\tTHETA IOTA KAPPA" | |
| keep = MinHashDeduplicator(normalize=True, threshold=0.9).dedup([a, b], show_progress=False) | |
| assert len(keep) == 1 | |
| def test_minhash_normalize_off_keeps_case_variants_apart(): | |
| a = "ALPHA BETA GAMMA DELTA EPSILON ZETA ETA THETA IOTA KAPPA" | |
| b = "alpha beta gamma delta epsilon zeta eta theta iota kappa" | |
| keep = MinHashDeduplicator(normalize=False, threshold=0.9).dedup([a, b], show_progress=False) | |
| # Different shingles entirely (case-sensitive) -> Jaccard 0 -> both kept | |
| assert len(keep) == 2 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Semantic: degenerate vectors | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _normalize(v: np.ndarray) -> np.ndarray: | |
| norms = np.linalg.norm(v, axis=1, keepdims=True) | |
| return (v / np.clip(norms, 1e-12, None)).astype(np.float32) | |
| def test_semantic_zero_vector_does_not_crash(): | |
| """A zero vector has undefined direction; clipping keeps it numerically safe.""" | |
| emb = np.zeros((3, 8), dtype=np.float32) | |
| # All-zero inner product is 0 -> below any reasonable threshold -> all kept | |
| keep = SemanticDeduplicator(threshold=0.5).dedup_from_embeddings(emb) | |
| assert len(keep) == 3 | |
| def test_semantic_antipodal_vectors_not_clustered(): | |
| """Cosine = -1 must not be treated as similar.""" | |
| a = np.array([[1.0, 0.0, 0.0]], dtype=np.float32) | |
| b = np.array([[-1.0, 0.0, 0.0]], dtype=np.float32) | |
| emb = np.vstack([a, b]) | |
| keep = SemanticDeduplicator(threshold=0.5).dedup_from_embeddings(emb) | |
| assert keep == [0, 1] | |
| def test_semantic_one_dim_embeddings(): | |
| emb = np.array([[1.0], [1.0], [-1.0]], dtype=np.float32) | |
| keep = SemanticDeduplicator(threshold=0.99).dedup_from_embeddings(emb) | |
| # Two +1 collapse, -1 stays separate | |
| assert len(keep) == 2 | |
| def test_semantic_high_dim_1024(): | |
| rng = np.random.default_rng(0) | |
| emb = _normalize(rng.standard_normal((100, 1024)).astype(np.float32)) | |
| keep = SemanticDeduplicator(threshold=0.95).dedup_from_embeddings(emb) | |
| assert len(keep) == 100 # nearly orthogonal in 1024D | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Semantic: topk extremes | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_semantic_topk_one_misses_cross_matches(): | |
| """topk=1 returns each vector's nearest neighbor = self. j>i is never | |
| satisfied, so even highly similar vectors are not merged. | |
| Uses slightly distinct (cosβ0.996) vectors to avoid the tied-IP nondeterminism | |
| you'd get from two byte-identical embeddings. | |
| """ | |
| emb = _normalize( | |
| np.array( | |
| [[1.0, 0.0], [np.cos(np.radians(5)), np.sin(np.radians(5))]], | |
| dtype=np.float32, | |
| ) | |
| ) | |
| keep = SemanticDeduplicator(threshold=0.95, topk=1).dedup_from_embeddings(emb) | |
| assert len(keep) == 2 | |
| def test_semantic_topk_equal_to_n_full_pairwise(): | |
| rng = np.random.default_rng(1) | |
| base = rng.standard_normal((1, 16)).astype(np.float32) | |
| emb = _normalize(np.vstack([base, base, base])) # all identical | |
| keep = SemanticDeduplicator(threshold=0.99, topk=3).dedup_from_embeddings(emb) | |
| assert len(keep) == 1 | |
| def test_semantic_topk_larger_than_n_clamped_safely(): | |
| emb = _normalize(np.array([[1.0, 0.0], [1.0, 0.0]], dtype=np.float32)) | |
| # topk=100 > n=2 -> internal min() prevents overrun | |
| keep = SemanticDeduplicator(threshold=0.99, topk=100).dedup_from_embeddings(emb) | |
| assert len(keep) == 1 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Semantic: threshold limits | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_semantic_threshold_1_only_exactly_identical(): | |
| a = _normalize(np.array([[1.0, 0.0]], dtype=np.float32)) | |
| b = _normalize(np.array([[np.cos(np.radians(2)), np.sin(np.radians(2))]], dtype=np.float32)) | |
| emb = np.vstack([a, a, b]) | |
| keep = SemanticDeduplicator(threshold=1.0).dedup_from_embeddings(emb) | |
| # a == a collapses (cos == 1), b stays apart (cos β 0.999 < 1.0) | |
| assert len(keep) == 2 | |
| def test_semantic_low_threshold_collapses_many(): | |
| """At cos>=0.5, mildly related vectors collapse together.""" | |
| angles = [0, 10, 20, 30, 40, 50] # all within 60 degrees | |
| emb = _normalize( | |
| np.array( | |
| [[np.cos(np.radians(a)), np.sin(np.radians(a))] for a in angles], | |
| dtype=np.float32, | |
| ) | |
| ) | |
| keep = SemanticDeduplicator(threshold=0.5, topk=6).dedup_from_embeddings(emb) | |
| # All pairwise cos > 0.5 -> all merge transitively | |
| assert len(keep) == 1 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Semantic: HNSW vs Flat boundary | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_semantic_just_below_hnsw_threshold_uses_flat(): | |
| """Boundary check: n < hnsw_threshold -> Flat path; just verify no crash + correctness.""" | |
| rng = np.random.default_rng(7) | |
| emb = _normalize(rng.standard_normal((49, 64)).astype(np.float32)) | |
| dedup = SemanticDeduplicator(threshold=0.99, hnsw_threshold=50) | |
| keep = dedup.dedup_from_embeddings(emb) | |
| assert len(keep) == 49 | |
| def test_semantic_just_above_hnsw_threshold_uses_hnsw(): | |
| rng = np.random.default_rng(7) | |
| emb = _normalize(rng.standard_normal((51, 64)).astype(np.float32)) | |
| dedup = SemanticDeduplicator(threshold=0.99, hnsw_threshold=50) | |
| keep = dedup.dedup_from_embeddings(emb) | |
| assert len(keep) == 51 | |
| def test_semantic_hnsw_path_finds_planted_cluster_among_distractors(): | |
| """HNSW must still detect a planted duplicate cluster despite approximation.""" | |
| rng = np.random.default_rng(99) | |
| distractors = rng.standard_normal((300, 128)).astype(np.float32) | |
| seed_vec = rng.standard_normal((1, 128)).astype(np.float32) | |
| planted = np.repeat(seed_vec, 30, axis=0) | |
| raw = np.concatenate([distractors, planted], axis=0) | |
| emb = _normalize(raw) | |
| dedup = SemanticDeduplicator(threshold=0.95, hnsw_threshold=50, topk=40) | |
| keep = dedup.dedup_from_embeddings(emb) | |
| # 300 distractors + 1 cluster representative | |
| assert len(keep) == 301 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Semantic: representative selection across many clusters | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_semantic_key_fn_picks_max_across_many_clusters(): | |
| """5 clusters of 3 identical vectors each; key_fn picks the longest in each.""" | |
| rng = np.random.default_rng(11) | |
| seeds = rng.standard_normal((5, 32)).astype(np.float32) | |
| raw = np.repeat(seeds, 3, axis=0) # 15 vectors, 5 distinct directions x 3 | |
| emb = _normalize(raw) | |
| # response_lengths designed so that within each cluster of 3 (idx triples | |
| # 0-2, 3-5, ...), the LAST index has the largest length. | |
| response_lengths = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150] | |
| key_fn = lambda i: -response_lengths[i] | |
| dedup = SemanticDeduplicator(threshold=0.99, topk=5) | |
| keep = dedup.dedup_from_embeddings(emb, key_fn=key_fn) | |
| assert keep == [2, 5, 8, 11, 14] | |