eval-toolkit 0.27.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eval_toolkit/__init__.py +238 -0
- eval_toolkit/__main__.py +156 -0
- eval_toolkit/_version.py +5 -0
- eval_toolkit/analysis.py +196 -0
- eval_toolkit/artifacts.py +376 -0
- eval_toolkit/bootstrap.py +1344 -0
- eval_toolkit/calibration.py +1143 -0
- eval_toolkit/claims.py +670 -0
- eval_toolkit/config.py +112 -0
- eval_toolkit/docs.py +305 -0
- eval_toolkit/evidence.py +90 -0
- eval_toolkit/harness.py +1193 -0
- eval_toolkit/leakage.py +1052 -0
- eval_toolkit/loaders.py +424 -0
- eval_toolkit/manifest.py +622 -0
- eval_toolkit/metrics.py +1720 -0
- eval_toolkit/operating_points.py +192 -0
- eval_toolkit/paths.py +125 -0
- eval_toolkit/plotting.py +991 -0
- eval_toolkit/protocols.py +98 -0
- eval_toolkit/provenance.py +255 -0
- eval_toolkit/py.typed +0 -0
- eval_toolkit/schemas/manifest.v1.json +155 -0
- eval_toolkit/schemas/manifest.v2.json +186 -0
- eval_toolkit/schemas/manifest.v3.json +186 -0
- eval_toolkit/schemas/results.v1.json +87 -0
- eval_toolkit/schemas/results_full.v1.json +83 -0
- eval_toolkit/seeds.py +119 -0
- eval_toolkit/splits.py +520 -0
- eval_toolkit/text_dedup.py +1403 -0
- eval_toolkit/thresholds.py +819 -0
- eval_toolkit-0.27.1.dist-info/METADATA +314 -0
- eval_toolkit-0.27.1.dist-info/RECORD +36 -0
- eval_toolkit-0.27.1.dist-info/WHEEL +4 -0
- eval_toolkit-0.27.1.dist-info/entry_points.txt +2 -0
- eval_toolkit-0.27.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
"""Text deduplication: pluggable similarity strategies + near-dup orchestrators.
|
|
2
|
+
|
|
3
|
+
Pure helpers:
|
|
4
|
+
|
|
5
|
+
- :func:`sha256_text` — canonical SHA-256 of (optionally normalized) text
|
|
6
|
+
- :func:`normalize_text_for_dedup` — Unicode/whitespace normalization
|
|
7
|
+
|
|
8
|
+
Similarity strategies (Protocol + reference implementations):
|
|
9
|
+
|
|
10
|
+
- :class:`SimilarityStrategy` — Protocol; ``pairs_within`` + ``pairs_across``
|
|
11
|
+
- :class:`TfidfCosineStrategy` — default lexical near-dedup (TF-IDF + cosine).
|
|
12
|
+
Recommended default. Scales to 100K+ texts (sklearn sparse + ANN k-NN).
|
|
13
|
+
- :class:`ExactNormalizedHashStrategy` — SHA-256-bucket exact-paraphrase dedup.
|
|
14
|
+
O(n) hashing; trivially scales to millions of texts.
|
|
15
|
+
- :class:`EmbeddingCosineStrategy` — cosine on caller-supplied embeddings.
|
|
16
|
+
Scale follows the embedder + sklearn k-NN; recommended for semantic
|
|
17
|
+
near-dedup when lexical signal is insufficient.
|
|
18
|
+
- :class:`JaccardNgramStrategy` — set-based n-gram Jaccard. **Diagnostic /
|
|
19
|
+
small-corpus only**: brute-force pairwise (O(n²) memory + compute). Stalls
|
|
20
|
+
on corpora above ~1K texts. Prefer :class:`TfidfCosineStrategy` or
|
|
21
|
+
:class:`EmbeddingCosineStrategy` (with a MinHash/LSH-backed embedder) at
|
|
22
|
+
any production scale.
|
|
23
|
+
|
|
24
|
+
Orchestrators (strategy-agnostic):
|
|
25
|
+
|
|
26
|
+
- :func:`near_dedup` — forward-scan greedy near-deduplication
|
|
27
|
+
- :func:`cross_dedup` — drop eval rows near-duplicate to any train row
|
|
28
|
+
- :class:`DedupReport` — frozen audit-trail of which rows were dropped and why
|
|
29
|
+
|
|
30
|
+
Notes
|
|
31
|
+
-----
|
|
32
|
+
|
|
33
|
+
**NFC normalization asymmetry across strategies.** Only
|
|
34
|
+
:class:`ExactNormalizedHashStrategy` applies Unicode NFC normalization
|
|
35
|
+
before similarity (via :func:`normalize_text_for_dedup`). The TF-IDF,
|
|
36
|
+
embedding, and Jaccard strategies treat composed and decomposed accents
|
|
37
|
+
as different inputs by default. If your corpus mixes NFC and NFD forms
|
|
38
|
+
(common when concatenating data from different OSes), normalize once at
|
|
39
|
+
load time with :func:`normalize_text_for_dedup` and then call any
|
|
40
|
+
strategy.
|
|
41
|
+
|
|
42
|
+
References
|
|
43
|
+
----------
|
|
44
|
+
.. [1] Lee, K., et al. "Deduplicating training data makes language models
|
|
45
|
+
better." ACL 2022. (NearDup pipeline; modern authority on
|
|
46
|
+
dedup-and-LM-quality.)
|
|
47
|
+
.. [2] Penedo, G., et al. "The RefinedWeb dataset for Falcon LLM."
|
|
48
|
+
NeurIPS Datasets & Benchmarks, 2023.
|
|
49
|
+
.. [3] Unicode Standard Annex #15 (Unicode Normalization Forms).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import hashlib
|
|
55
|
+
import unicodedata
|
|
56
|
+
from collections.abc import Callable, Sequence
|
|
57
|
+
from dataclasses import dataclass
|
|
58
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
59
|
+
|
|
60
|
+
import numpy as np
|
|
61
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
62
|
+
from sklearn.neighbors import NearestNeighbors
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"DEFAULT_DEDUP_THRESHOLD",
|
|
66
|
+
"DedupReport",
|
|
67
|
+
"EmbeddingCosineStrategy",
|
|
68
|
+
"ExactNormalizedHashStrategy",
|
|
69
|
+
"JaccardNgramStrategy",
|
|
70
|
+
"MinHashLSHStrategy",
|
|
71
|
+
"SimilarityAuditFinding",
|
|
72
|
+
"SimilarityAuditReport",
|
|
73
|
+
"SimilarityStrategy",
|
|
74
|
+
"TfidfCosineStrategy",
|
|
75
|
+
"audit_source_label_similarity",
|
|
76
|
+
"cross_dedup",
|
|
77
|
+
"cross_dedup_pairs",
|
|
78
|
+
"near_dedup",
|
|
79
|
+
"normalize_text_for_dedup",
|
|
80
|
+
"sha256_text",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
DEFAULT_DEDUP_THRESHOLD = 0.9
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class DedupReport:
|
|
88
|
+
"""Outcome of a near-dedup pass.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
kept_indices : list[int]
|
|
93
|
+
Sorted positions in the input ``texts`` retained after dedup.
|
|
94
|
+
dropped_pairs : list[tuple[int, int, float]]
|
|
95
|
+
Triples ``(dropped_idx, kept_idx, similarity)`` for audit.
|
|
96
|
+
threshold : float
|
|
97
|
+
Cosine-similarity threshold the dedup pass used.
|
|
98
|
+
n_input : int
|
|
99
|
+
Length of the input list (so kept + dropped invariant is checkable).
|
|
100
|
+
|
|
101
|
+
Examples
|
|
102
|
+
--------
|
|
103
|
+
>>> report = DedupReport(
|
|
104
|
+
... kept_indices=[0, 2],
|
|
105
|
+
... dropped_pairs=[(1, 0, 0.95)],
|
|
106
|
+
... threshold=0.9,
|
|
107
|
+
... n_input=3,
|
|
108
|
+
... )
|
|
109
|
+
>>> report.n_kept + report.n_dropped == report.n_input
|
|
110
|
+
True
|
|
111
|
+
|
|
112
|
+
Notes
|
|
113
|
+
-----
|
|
114
|
+
The ``kept_indices`` and the first element of each ``dropped_pairs``
|
|
115
|
+
triple form a disjoint partition of ``range(n_input)``. Callers can use
|
|
116
|
+
this property to reconstruct the dropped-set without re-running dedup.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
kept_indices: list[int]
|
|
120
|
+
dropped_pairs: list[tuple[int, int, float]]
|
|
121
|
+
threshold: float
|
|
122
|
+
n_input: int
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def n_kept(self) -> int:
|
|
126
|
+
"""Number of input rows retained after deduplication."""
|
|
127
|
+
return len(self.kept_indices)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def n_dropped(self) -> int:
|
|
131
|
+
"""Number of input rows dropped as near-duplicates."""
|
|
132
|
+
return len(self.dropped_pairs)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
SimilarityRelation = Literal[
|
|
136
|
+
"unspecified",
|
|
137
|
+
"within_source",
|
|
138
|
+
"cross_source",
|
|
139
|
+
"same_label",
|
|
140
|
+
"cross_label",
|
|
141
|
+
"within_source_same_label",
|
|
142
|
+
"within_source_cross_label",
|
|
143
|
+
"cross_source_same_label",
|
|
144
|
+
"cross_source_cross_label",
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class SimilarityAuditFinding:
|
|
150
|
+
"""One high-similarity pair found during a non-dropping audit."""
|
|
151
|
+
|
|
152
|
+
left_index: int
|
|
153
|
+
right_index: int
|
|
154
|
+
similarity: float
|
|
155
|
+
relation: SimilarityRelation = "unspecified"
|
|
156
|
+
left_source: str | None = None
|
|
157
|
+
right_source: str | None = None
|
|
158
|
+
left_label: object | None = None
|
|
159
|
+
right_label: object | None = None
|
|
160
|
+
|
|
161
|
+
def to_dict(self) -> dict[str, object]:
|
|
162
|
+
"""JSON-serializable representation."""
|
|
163
|
+
out: dict[str, object] = {
|
|
164
|
+
"left_index": self.left_index,
|
|
165
|
+
"right_index": self.right_index,
|
|
166
|
+
"similarity": self.similarity,
|
|
167
|
+
"relation": self.relation,
|
|
168
|
+
}
|
|
169
|
+
if self.left_source is not None:
|
|
170
|
+
out["left_source"] = self.left_source
|
|
171
|
+
if self.right_source is not None:
|
|
172
|
+
out["right_source"] = self.right_source
|
|
173
|
+
if self.left_label is not None:
|
|
174
|
+
out["left_label"] = self.left_label
|
|
175
|
+
if self.right_label is not None:
|
|
176
|
+
out["right_label"] = self.right_label
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True, slots=True)
|
|
181
|
+
class SimilarityAuditReport:
|
|
182
|
+
"""Non-dropping source/label-aware similarity audit report."""
|
|
183
|
+
|
|
184
|
+
findings: list[SimilarityAuditFinding]
|
|
185
|
+
threshold: float
|
|
186
|
+
n_input: int
|
|
187
|
+
strategy: str
|
|
188
|
+
k_neighbors: int
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def n_findings(self) -> int:
|
|
192
|
+
"""Number of high-similarity pairs in the report."""
|
|
193
|
+
return len(self.findings)
|
|
194
|
+
|
|
195
|
+
def to_dict(self) -> dict[str, object]:
|
|
196
|
+
"""JSON-serializable representation."""
|
|
197
|
+
return {
|
|
198
|
+
"findings": [finding.to_dict() for finding in self.findings],
|
|
199
|
+
"threshold": self.threshold,
|
|
200
|
+
"n_input": self.n_input,
|
|
201
|
+
"strategy": self.strategy,
|
|
202
|
+
"k_neighbors": self.k_neighbors,
|
|
203
|
+
"n_findings": self.n_findings,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def normalize_text_for_dedup(text: str) -> str:
|
|
208
|
+
"""Canonical text normalization for hashing and deduplication.
|
|
209
|
+
|
|
210
|
+
Applies:
|
|
211
|
+
|
|
212
|
+
1. Unicode NFC normalization (composes accent characters)
|
|
213
|
+
2. Lowercase
|
|
214
|
+
3. Collapse runs of whitespace to single spaces
|
|
215
|
+
4. Strip leading/trailing whitespace
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
text : str
|
|
220
|
+
|
|
221
|
+
Returns
|
|
222
|
+
-------
|
|
223
|
+
str
|
|
224
|
+
Normalized text.
|
|
225
|
+
|
|
226
|
+
Raises
|
|
227
|
+
------
|
|
228
|
+
TypeError
|
|
229
|
+
If ``text`` is not a ``str``.
|
|
230
|
+
|
|
231
|
+
Examples
|
|
232
|
+
--------
|
|
233
|
+
>>> normalize_text_for_dedup("Hello World")
|
|
234
|
+
'hello world'
|
|
235
|
+
>>> normalize_text_for_dedup(" Foo\\tBar\\n")
|
|
236
|
+
'foo bar'
|
|
237
|
+
>>> normalize_text_for_dedup(normalize_text_for_dedup("X")) == normalize_text_for_dedup("X")
|
|
238
|
+
True
|
|
239
|
+
"""
|
|
240
|
+
if not isinstance(text, str):
|
|
241
|
+
raise TypeError(f"text must be str, got {type(text).__name__}")
|
|
242
|
+
nfc = unicodedata.normalize("NFC", text)
|
|
243
|
+
lowered = nfc.lower()
|
|
244
|
+
collapsed = " ".join(lowered.split())
|
|
245
|
+
return collapsed
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def sha256_text(text: str, *, normalize: bool = True) -> str:
|
|
249
|
+
"""SHA-256 hex digest of (optionally normalized) text.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
text : str
|
|
254
|
+
normalize : bool, optional
|
|
255
|
+
If True (default), apply :func:`normalize_text_for_dedup` first.
|
|
256
|
+
Set False to hash the raw bytes.
|
|
257
|
+
|
|
258
|
+
Returns
|
|
259
|
+
-------
|
|
260
|
+
str
|
|
261
|
+
64-character hex digest.
|
|
262
|
+
|
|
263
|
+
Examples
|
|
264
|
+
--------
|
|
265
|
+
>>> sha256_text("Hello World") == sha256_text("hello world")
|
|
266
|
+
True
|
|
267
|
+
>>> len(sha256_text("foo"))
|
|
268
|
+
64
|
|
269
|
+
>>> sha256_text("foo", normalize=False) != sha256_text("FOO", normalize=False)
|
|
270
|
+
True
|
|
271
|
+
|
|
272
|
+
Raises
|
|
273
|
+
------
|
|
274
|
+
TypeError
|
|
275
|
+
If ``text`` is not a ``str`` (or, when ``normalize=True``, on the
|
|
276
|
+
re-raised :class:`TypeError` from :func:`normalize_text_for_dedup`).
|
|
277
|
+
"""
|
|
278
|
+
if not isinstance(text, str):
|
|
279
|
+
raise TypeError(f"text must be str, got {type(text).__name__}")
|
|
280
|
+
canonical = normalize_text_for_dedup(text) if normalize else text
|
|
281
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# SimilarityStrategy Protocol + reference implementations
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@runtime_checkable
|
|
290
|
+
class SimilarityStrategy(Protocol):
|
|
291
|
+
"""Pluggable similarity backend for :func:`near_dedup` / :func:`cross_dedup`.
|
|
292
|
+
|
|
293
|
+
Strategies own vectorization plus nearest-neighbor lookup. Similarities
|
|
294
|
+
are floats; higher values mean more similar. Each strategy chooses its
|
|
295
|
+
own scale (e.g. cosine in ``[-1, 1]``, Jaccard in ``[0, 1]``, hash
|
|
296
|
+
collision in ``{0.0, 1.0}``); threshold semantics live in the orchestrators
|
|
297
|
+
(``near_dedup`` / ``cross_dedup``), not the strategy itself.
|
|
298
|
+
|
|
299
|
+
The shape mirrors the existing :class:`eval_toolkit.harness.Scorer`
|
|
300
|
+
Protocol: structural typing, no ABC inheritance, runtime-checkable so
|
|
301
|
+
``isinstance(obj, SimilarityStrategy)`` returns True for any object that
|
|
302
|
+
exposes the two methods.
|
|
303
|
+
|
|
304
|
+
Notes
|
|
305
|
+
-----
|
|
306
|
+
Both methods return aligned ``(similarities, indices)`` arrays of shape
|
|
307
|
+
``(n_query, k_eff)`` where ``k_eff = min(k, n_reference)``. ``indices[i]``
|
|
308
|
+
are positions into the reference list; for ``pairs_within`` the reference
|
|
309
|
+
list IS the input list, so ``indices[i, 0] == i`` is conventional (each
|
|
310
|
+
text is its own most-similar neighbor).
|
|
311
|
+
|
|
312
|
+
See Also
|
|
313
|
+
--------
|
|
314
|
+
eval_toolkit.harness.Scorer : analogous Protocol pattern for predict_proba.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
def pairs_within(
|
|
318
|
+
self, texts: Sequence[str], k: int
|
|
319
|
+
) -> tuple[np.ndarray, np.ndarray]: # pragma: no cover
|
|
320
|
+
"""k-NN of each text against the same list."""
|
|
321
|
+
...
|
|
322
|
+
|
|
323
|
+
def pairs_across(
|
|
324
|
+
self,
|
|
325
|
+
query_texts: Sequence[str],
|
|
326
|
+
reference_texts: Sequence[str],
|
|
327
|
+
k: int,
|
|
328
|
+
) -> tuple[np.ndarray, np.ndarray]: # pragma: no cover
|
|
329
|
+
"""k-NN of each query against ``reference_texts``."""
|
|
330
|
+
...
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True, slots=True)
|
|
334
|
+
class TfidfCosineStrategy:
|
|
335
|
+
"""TF-IDF (n-gram) cosine similarity — the default lexical near-dedup.
|
|
336
|
+
|
|
337
|
+
Wraps :class:`sklearn.feature_extraction.text.TfidfVectorizer` plus
|
|
338
|
+
:class:`sklearn.neighbors.NearestNeighbors`. With default parameters,
|
|
339
|
+
behavior is bit-for-bit equivalent to the inline implementation shipped
|
|
340
|
+
in eval-toolkit v0.1.0.
|
|
341
|
+
|
|
342
|
+
Parameters
|
|
343
|
+
----------
|
|
344
|
+
ngram_range : tuple[int, int], optional
|
|
345
|
+
``(min_n, max_n)`` n-gram range. Default ``(1, 3)``.
|
|
346
|
+
min_df : int, optional
|
|
347
|
+
Minimum document frequency for a term. Default ``1``.
|
|
348
|
+
lowercase : bool, optional
|
|
349
|
+
Lowercase before vectorizing. Default ``True``.
|
|
350
|
+
|
|
351
|
+
Examples
|
|
352
|
+
--------
|
|
353
|
+
>>> strat = TfidfCosineStrategy()
|
|
354
|
+
>>> sims, idx = strat.pairs_within(
|
|
355
|
+
... ["the quick fox", "the quick fox!", "lorem ipsum"], k=2
|
|
356
|
+
... )
|
|
357
|
+
>>> sims.shape, idx.shape
|
|
358
|
+
((3, 2), (3, 2))
|
|
359
|
+
>>> bool(idx[0, 0] == 0) # self at slot 0
|
|
360
|
+
True
|
|
361
|
+
|
|
362
|
+
Notes
|
|
363
|
+
-----
|
|
364
|
+
TF-IDF cosine is a fast lexical signal — it will miss paraphrase
|
|
365
|
+
duplicates that share no n-grams. For semantic-paraphrase dedup, see
|
|
366
|
+
:class:`EmbeddingCosineStrategy`.
|
|
367
|
+
|
|
368
|
+
Scaling: scales to ~100K+ texts on a single machine via sklearn's
|
|
369
|
+
sparse vectorizer + approximate nearest neighbors. This is the
|
|
370
|
+
recommended default for any non-trivial corpus.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
ngram_range: tuple[int, int] = (1, 3)
|
|
374
|
+
min_df: int = 1
|
|
375
|
+
lowercase: bool = True
|
|
376
|
+
|
|
377
|
+
def _vectorizer(self) -> TfidfVectorizer:
|
|
378
|
+
"""Construct a fresh TfidfVectorizer with the strategy's config."""
|
|
379
|
+
return TfidfVectorizer(
|
|
380
|
+
ngram_range=self.ngram_range,
|
|
381
|
+
min_df=self.min_df,
|
|
382
|
+
lowercase=self.lowercase,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
|
|
386
|
+
"""k-NN within ``texts`` via TF-IDF + cosine NearestNeighbors."""
|
|
387
|
+
n = len(texts)
|
|
388
|
+
if n == 0:
|
|
389
|
+
return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
|
|
390
|
+
vec = self._vectorizer()
|
|
391
|
+
tfidf = vec.fit_transform(list(texts))
|
|
392
|
+
k_eff = min(k, n)
|
|
393
|
+
nn = NearestNeighbors(n_neighbors=k_eff, metric="cosine").fit(tfidf)
|
|
394
|
+
distances, indices = nn.kneighbors(tfidf)
|
|
395
|
+
return 1.0 - distances, indices.astype(np.int64)
|
|
396
|
+
|
|
397
|
+
def pairs_across(
|
|
398
|
+
self,
|
|
399
|
+
query_texts: Sequence[str],
|
|
400
|
+
reference_texts: Sequence[str],
|
|
401
|
+
k: int,
|
|
402
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
403
|
+
"""k-NN of ``query_texts`` against ``reference_texts``."""
|
|
404
|
+
if not reference_texts or not query_texts:
|
|
405
|
+
return (
|
|
406
|
+
np.zeros((len(query_texts), 0), dtype=np.float64),
|
|
407
|
+
np.zeros((len(query_texts), 0), dtype=np.int64),
|
|
408
|
+
)
|
|
409
|
+
vec = self._vectorizer()
|
|
410
|
+
# Fit on union so vocabulary covers both sides; matches v0.1.0 behavior.
|
|
411
|
+
vec.fit(list(reference_texts) + list(query_texts))
|
|
412
|
+
tfidf_ref = vec.transform(list(reference_texts))
|
|
413
|
+
tfidf_query = vec.transform(list(query_texts))
|
|
414
|
+
k_eff = min(k, len(reference_texts))
|
|
415
|
+
nn = NearestNeighbors(n_neighbors=k_eff, metric="cosine").fit(tfidf_ref)
|
|
416
|
+
distances, indices = nn.kneighbors(tfidf_query)
|
|
417
|
+
return 1.0 - distances, indices.astype(np.int64)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@dataclass(frozen=True, slots=True)
|
|
421
|
+
class ExactNormalizedHashStrategy:
|
|
422
|
+
"""SHA-256 hash-bucket dedup; similarities are exactly ``{0.0, 1.0}``.
|
|
423
|
+
|
|
424
|
+
Reuses :func:`normalize_text_for_dedup` + :func:`sha256_text`. Two texts
|
|
425
|
+
are similar iff they share a hash bucket — useful when the operational
|
|
426
|
+
sense of "leakage" is exact paraphrase (after Unicode/whitespace/case
|
|
427
|
+
normalization) rather than near-paraphrase.
|
|
428
|
+
|
|
429
|
+
Parameters
|
|
430
|
+
----------
|
|
431
|
+
normalize : bool, optional
|
|
432
|
+
If ``True`` (default), apply :func:`normalize_text_for_dedup` before
|
|
433
|
+
hashing. Set ``False`` to bucket on raw bytes only.
|
|
434
|
+
|
|
435
|
+
Examples
|
|
436
|
+
--------
|
|
437
|
+
>>> strat = ExactNormalizedHashStrategy()
|
|
438
|
+
>>> sims, idx = strat.pairs_within(["foo", "FOO", "bar"], k=2)
|
|
439
|
+
>>> # Texts 0 and 1 collide after lowercasing; text 2 isolated.
|
|
440
|
+
>>> bool(sims[0, 0] == 1.0 and idx[0, 0] == 0) # self at slot 0
|
|
441
|
+
True
|
|
442
|
+
>>> bool(sims[0, 1] == 1.0 and idx[0, 1] == 1) # collision with text 1
|
|
443
|
+
True
|
|
444
|
+
>>> bool(sims[2, 1] == 0.0) # text 2 has no collision in the corpus
|
|
445
|
+
True
|
|
446
|
+
|
|
447
|
+
Notes
|
|
448
|
+
-----
|
|
449
|
+
Threshold ``t`` semantics for this strategy: any ``t`` in ``(0, 1]``
|
|
450
|
+
catches all collisions (since collisions are sim=1.0); a threshold
|
|
451
|
+
``> 1.0`` catches nothing. The default ``DEFAULT_DEDUP_THRESHOLD = 0.9``
|
|
452
|
+
works as expected.
|
|
453
|
+
|
|
454
|
+
Scaling: O(n) hashing + O(n) bucket lookup — trivially scales to
|
|
455
|
+
millions of texts. Use this strategy when "leakage" means *exact match
|
|
456
|
+
after normalization*, not paraphrase. For lexical near-paraphrase use
|
|
457
|
+
:class:`TfidfCosineStrategy`; for semantic near-paraphrase use
|
|
458
|
+
:class:`EmbeddingCosineStrategy`.
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
normalize: bool = True
|
|
462
|
+
|
|
463
|
+
def _hash(self, text: str) -> str:
|
|
464
|
+
"""SHA-256 of (optionally normalized) text."""
|
|
465
|
+
return sha256_text(text, normalize=self.normalize)
|
|
466
|
+
|
|
467
|
+
@staticmethod
|
|
468
|
+
def _build_neighbors(
|
|
469
|
+
n: int,
|
|
470
|
+
k_eff: int,
|
|
471
|
+
bucket_for_query: list[list[int]],
|
|
472
|
+
ref_hashes: Sequence[str],
|
|
473
|
+
query_hashes: Sequence[str],
|
|
474
|
+
n_ref: int,
|
|
475
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
476
|
+
"""Pack each query's collision list (then arbitrary fillers) into (n, k)."""
|
|
477
|
+
sims = np.zeros((n, k_eff), dtype=np.float64)
|
|
478
|
+
indices = np.zeros((n, k_eff), dtype=np.int64)
|
|
479
|
+
ref_index_set = set(range(n_ref))
|
|
480
|
+
for i in range(n):
|
|
481
|
+
collisions = bucket_for_query[i]
|
|
482
|
+
non_collisions = sorted(ref_index_set - set(collisions))
|
|
483
|
+
ranked = collisions + non_collisions
|
|
484
|
+
chosen = ranked[:k_eff]
|
|
485
|
+
for slot, j in enumerate(chosen):
|
|
486
|
+
indices[i, slot] = j
|
|
487
|
+
sims[i, slot] = 1.0 if ref_hashes[j] == query_hashes[i] else 0.0
|
|
488
|
+
return sims, indices
|
|
489
|
+
|
|
490
|
+
def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
|
|
491
|
+
n = len(texts)
|
|
492
|
+
if n == 0:
|
|
493
|
+
return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
|
|
494
|
+
hashes = [self._hash(t) for t in texts]
|
|
495
|
+
bucket: dict[str, list[int]] = {}
|
|
496
|
+
for i, h in enumerate(hashes):
|
|
497
|
+
bucket.setdefault(h, []).append(i)
|
|
498
|
+
# Self at slot 0; then other collisions; then arbitrary fillers.
|
|
499
|
+
bucket_for_query = [[i] + [j for j in bucket[hashes[i]] if j != i] for i in range(n)]
|
|
500
|
+
k_eff = min(k, n)
|
|
501
|
+
return self._build_neighbors(n, k_eff, bucket_for_query, hashes, hashes, n)
|
|
502
|
+
|
|
503
|
+
def pairs_across(
|
|
504
|
+
self,
|
|
505
|
+
query_texts: Sequence[str],
|
|
506
|
+
reference_texts: Sequence[str],
|
|
507
|
+
k: int,
|
|
508
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
509
|
+
n_q, n_r = len(query_texts), len(reference_texts)
|
|
510
|
+
if n_q == 0 or n_r == 0:
|
|
511
|
+
return (
|
|
512
|
+
np.zeros((n_q, 0), dtype=np.float64),
|
|
513
|
+
np.zeros((n_q, 0), dtype=np.int64),
|
|
514
|
+
)
|
|
515
|
+
ref_hashes = [self._hash(t) for t in reference_texts]
|
|
516
|
+
q_hashes = [self._hash(t) for t in query_texts]
|
|
517
|
+
ref_bucket: dict[str, list[int]] = {}
|
|
518
|
+
for i, h in enumerate(ref_hashes):
|
|
519
|
+
ref_bucket.setdefault(h, []).append(i)
|
|
520
|
+
bucket_for_query = [list(ref_bucket.get(qh, [])) for qh in q_hashes]
|
|
521
|
+
k_eff = min(k, n_r)
|
|
522
|
+
return self._build_neighbors(n_q, k_eff, bucket_for_query, ref_hashes, q_hashes, n_r)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@dataclass(frozen=True, slots=True)
|
|
526
|
+
class EmbeddingCosineStrategy:
|
|
527
|
+
"""Cosine similarity on caller-supplied embeddings.
|
|
528
|
+
|
|
529
|
+
The toolkit owns cosine + k-NN; the caller owns the embedder. This keeps
|
|
530
|
+
the toolkit dep-free of any specific embedding library
|
|
531
|
+
(sentence-transformers, OpenAI, local model, etc.) while still offering
|
|
532
|
+
a turnkey strategy class.
|
|
533
|
+
|
|
534
|
+
Parameters
|
|
535
|
+
----------
|
|
536
|
+
embedder : Callable[[Sequence[str]], np.ndarray]
|
|
537
|
+
Maps a sequence of texts to a 2-D array of shape ``(n, d)``. Caller
|
|
538
|
+
is responsible for batching, GPU placement, model loading, etc.
|
|
539
|
+
|
|
540
|
+
Examples
|
|
541
|
+
--------
|
|
542
|
+
>>> import numpy as np
|
|
543
|
+
>>> def stub_embedder(ts):
|
|
544
|
+
... # One-hot per text index → behaves like exact-match on identity.
|
|
545
|
+
... return np.eye(len(ts))
|
|
546
|
+
>>> strat = EmbeddingCosineStrategy(stub_embedder)
|
|
547
|
+
>>> sims, idx = strat.pairs_within(["a", "b", "c"], k=3)
|
|
548
|
+
>>> bool(sims.shape == (3, 3) and idx.shape == (3, 3))
|
|
549
|
+
True
|
|
550
|
+
>>> bool(idx[0, 0] == 0) # self at slot 0
|
|
551
|
+
True
|
|
552
|
+
|
|
553
|
+
Notes
|
|
554
|
+
-----
|
|
555
|
+
The embedder is called once per ``pairs_*`` invocation. Persistent
|
|
556
|
+
embedding caches are out of scope — callers should wrap their embedder
|
|
557
|
+
in any cache they need.
|
|
558
|
+
|
|
559
|
+
Scaling: dominated by the embedder. With sentence-transformers on GPU,
|
|
560
|
+
100K+ texts is routine; with OpenAI / remote-API embedders, throughput
|
|
561
|
+
is API-limited but k-NN itself remains fast (sklearn cosine NN). This
|
|
562
|
+
is the recommended strategy whenever lexical TF-IDF signal is
|
|
563
|
+
insufficient (paraphrase, multilingual, semantic-near-duplicate).
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
embedder: Callable[[Sequence[str]], np.ndarray]
|
|
567
|
+
|
|
568
|
+
def _embed(self, texts: Sequence[str], name: str) -> np.ndarray:
|
|
569
|
+
"""Coerce embedder output to a 2-D float64 ndarray + validate shape."""
|
|
570
|
+
emb = np.asarray(self.embedder(texts), dtype=np.float64)
|
|
571
|
+
if emb.ndim != 2:
|
|
572
|
+
raise ValueError(f"embedder must return 2-D array; {name} got shape {emb.shape}")
|
|
573
|
+
if emb.shape[0] != len(texts):
|
|
574
|
+
raise ValueError(
|
|
575
|
+
f"embedder must return one row per text; {name} got shape "
|
|
576
|
+
f"{emb.shape} for {len(texts)} texts"
|
|
577
|
+
)
|
|
578
|
+
return emb
|
|
579
|
+
|
|
580
|
+
def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
|
|
581
|
+
n = len(texts)
|
|
582
|
+
if n == 0:
|
|
583
|
+
return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
|
|
584
|
+
emb = self._embed(texts, "texts")
|
|
585
|
+
k_eff = min(k, n)
|
|
586
|
+
nn = NearestNeighbors(n_neighbors=k_eff, metric="cosine").fit(emb)
|
|
587
|
+
distances, indices = nn.kneighbors(emb)
|
|
588
|
+
return 1.0 - distances, indices.astype(np.int64)
|
|
589
|
+
|
|
590
|
+
def pairs_across(
|
|
591
|
+
self,
|
|
592
|
+
query_texts: Sequence[str],
|
|
593
|
+
reference_texts: Sequence[str],
|
|
594
|
+
k: int,
|
|
595
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
596
|
+
"""Top-k cosine-similar reference rows per query row.
|
|
597
|
+
|
|
598
|
+
Raises
|
|
599
|
+
------
|
|
600
|
+
ValueError
|
|
601
|
+
If the embedder returns inconsistent feature dimensions for
|
|
602
|
+
``reference_texts`` vs ``query_texts`` (would silently
|
|
603
|
+
mis-align cosine; better to fail loudly).
|
|
604
|
+
"""
|
|
605
|
+
n_q, n_r = len(query_texts), len(reference_texts)
|
|
606
|
+
if n_q == 0 or n_r == 0:
|
|
607
|
+
return (
|
|
608
|
+
np.zeros((n_q, 0), dtype=np.float64),
|
|
609
|
+
np.zeros((n_q, 0), dtype=np.int64),
|
|
610
|
+
)
|
|
611
|
+
ref_emb = self._embed(reference_texts, "reference_texts")
|
|
612
|
+
query_emb = self._embed(query_texts, "query_texts")
|
|
613
|
+
# Cross-call dimension consistency — buggy embedders that return
|
|
614
|
+
# different `d` for query vs reference would silently mis-align cosine.
|
|
615
|
+
if ref_emb.shape[1] != query_emb.shape[1]:
|
|
616
|
+
raise ValueError(
|
|
617
|
+
f"embedder returned inconsistent feature dimensions: "
|
|
618
|
+
f"reference_texts has d={ref_emb.shape[1]}, "
|
|
619
|
+
f"query_texts has d={query_emb.shape[1]}"
|
|
620
|
+
)
|
|
621
|
+
k_eff = min(k, n_r)
|
|
622
|
+
nn = NearestNeighbors(n_neighbors=k_eff, metric="cosine").fit(ref_emb)
|
|
623
|
+
distances, indices = nn.kneighbors(query_emb)
|
|
624
|
+
return 1.0 - distances, indices.astype(np.int64)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@dataclass(frozen=True, slots=True)
|
|
628
|
+
class JaccardNgramStrategy:
|
|
629
|
+
"""Set-based n-gram Jaccard similarity — **diagnostic / small-corpus only**.
|
|
630
|
+
|
|
631
|
+
.. warning::
|
|
632
|
+
|
|
633
|
+
Brute-force pairwise — O(n²) in the corpus size for both memory and
|
|
634
|
+
compute. Empirical scaling on a single core: ~1K texts in under a
|
|
635
|
+
second; ~10K texts ≈ tens of seconds; ~100K texts will exhaust
|
|
636
|
+
memory and is effectively unusable. **Do not use this strategy at
|
|
637
|
+
production scale.** Prefer :class:`TfidfCosineStrategy` (default,
|
|
638
|
+
sparse + ANN k-NN, scales to 100K+) or :class:`EmbeddingCosineStrategy`
|
|
639
|
+
backed by a MinHash/LSH or sentence-transformer embedder.
|
|
640
|
+
|
|
641
|
+
When this strategy is the right tool:
|
|
642
|
+
|
|
643
|
+
- **Tiny diagnostic corpora** (< ~500 texts) where you want a clean
|
|
644
|
+
mathematical match-counting interpretation of similarity.
|
|
645
|
+
- **Token-order-invariant fingerprints** where lexical TF-IDF cosine
|
|
646
|
+
over-weights position (e.g., SQL fragments, CLI-flag strings,
|
|
647
|
+
shell-command normalization, JSON-key bag-of-words).
|
|
648
|
+
- **Reference / sanity-check** implementation against which you want to
|
|
649
|
+
validate a faster MinHash/LSH approximation.
|
|
650
|
+
|
|
651
|
+
Parameters
|
|
652
|
+
----------
|
|
653
|
+
n : int, optional
|
|
654
|
+
N-gram size. Default ``3``. Must be ``≥ 1``.
|
|
655
|
+
analyzer : {'char', 'word'}, optional
|
|
656
|
+
N-gram unit. Default ``'char'``.
|
|
657
|
+
|
|
658
|
+
Examples
|
|
659
|
+
--------
|
|
660
|
+
>>> strat = JaccardNgramStrategy(n=2, analyzer='char')
|
|
661
|
+
>>> # bigrams("abc") = {ab, bc}; bigrams("abd") = {ab, bd};
|
|
662
|
+
>>> # ∩ = {ab}; ∪ = {ab, bc, bd} → J = 1/3.
|
|
663
|
+
>>> sims, idx = strat.pairs_within(["abc", "abd"], k=2)
|
|
664
|
+
>>> bool(abs(float(sims[0, 1]) - 1 / 3) < 1e-9)
|
|
665
|
+
True
|
|
666
|
+
|
|
667
|
+
Notes
|
|
668
|
+
-----
|
|
669
|
+
Jaccard on *sets* of n-grams (order-invariant) gives a strict-match
|
|
670
|
+
similarity in ``[0, 1]``: identical n-gram sets map to 1.0, disjoint
|
|
671
|
+
n-gram sets to 0.0. For approximate Jaccard at production scale, see
|
|
672
|
+
MinHash + LSH (Broder 1997, Indyk & Motwani 1998); the toolkit does not
|
|
673
|
+
ship a MinHash strategy in v0.2 — wrap your preferred MinHash library in
|
|
674
|
+
:class:`EmbeddingCosineStrategy` if you need it.
|
|
675
|
+
|
|
676
|
+
References
|
|
677
|
+
----------
|
|
678
|
+
.. [1] Broder, A. "On the resemblance and containment of documents."
|
|
679
|
+
Compression and Complexity of Sequences, 1997.
|
|
680
|
+
.. [2] Indyk, P. & Motwani, R. "Approximate nearest neighbors: Towards
|
|
681
|
+
removing the curse of dimensionality." STOC 1998.
|
|
682
|
+
"""
|
|
683
|
+
|
|
684
|
+
n: int = 3
|
|
685
|
+
analyzer: Literal["char", "word"] = "char"
|
|
686
|
+
|
|
687
|
+
def __post_init__(self) -> None:
|
|
688
|
+
"""Validate constructor arguments (frozen dataclass invariants)."""
|
|
689
|
+
if self.n < 1:
|
|
690
|
+
raise ValueError(f"n must be >= 1, got {self.n}")
|
|
691
|
+
if self.analyzer not in ("char", "word"):
|
|
692
|
+
raise ValueError(f"analyzer must be 'char' or 'word', got {self.analyzer!r}")
|
|
693
|
+
|
|
694
|
+
def _ngrams(self, text: str) -> set[str]:
|
|
695
|
+
"""N-gram set for ``text`` per the configured analyzer."""
|
|
696
|
+
if self.analyzer == "char":
|
|
697
|
+
if len(text) < self.n:
|
|
698
|
+
return {text} if text else set()
|
|
699
|
+
return {text[i : i + self.n] for i in range(len(text) - self.n + 1)}
|
|
700
|
+
tokens = text.split()
|
|
701
|
+
if len(tokens) < self.n:
|
|
702
|
+
return {" ".join(tokens)} if tokens else set()
|
|
703
|
+
return {" ".join(tokens[i : i + self.n]) for i in range(len(tokens) - self.n + 1)}
|
|
704
|
+
|
|
705
|
+
@staticmethod
|
|
706
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
707
|
+
"""Jaccard similarity ``|a ∩ b| / |a ∪ b|``; both empty → 1.0."""
|
|
708
|
+
if not a and not b:
|
|
709
|
+
return 1.0
|
|
710
|
+
union = a | b
|
|
711
|
+
if not union:
|
|
712
|
+
return 0.0
|
|
713
|
+
return len(a & b) / len(union)
|
|
714
|
+
|
|
715
|
+
def _pairwise(
|
|
716
|
+
self,
|
|
717
|
+
queries: Sequence[set[str]],
|
|
718
|
+
references: Sequence[set[str]],
|
|
719
|
+
) -> np.ndarray:
|
|
720
|
+
"""Brute-force Jaccard matrix of shape ``(len(queries), len(references))``."""
|
|
721
|
+
m = np.zeros((len(queries), len(references)), dtype=np.float64)
|
|
722
|
+
for i, qs in enumerate(queries):
|
|
723
|
+
for j, rs in enumerate(references):
|
|
724
|
+
m[i, j] = self._jaccard(qs, rs)
|
|
725
|
+
return m
|
|
726
|
+
|
|
727
|
+
@staticmethod
|
|
728
|
+
def _topk(matrix: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]:
|
|
729
|
+
"""Top-k by descending similarity per row; stable on ties."""
|
|
730
|
+
order = np.argsort(-matrix, axis=1, kind="stable")[:, :k]
|
|
731
|
+
sims = np.take_along_axis(matrix, order, axis=1)
|
|
732
|
+
return sims.astype(np.float64), order.astype(np.int64)
|
|
733
|
+
|
|
734
|
+
def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
|
|
735
|
+
n = len(texts)
|
|
736
|
+
if n == 0:
|
|
737
|
+
return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
|
|
738
|
+
ngs = [self._ngrams(t) for t in texts]
|
|
739
|
+
k_eff = min(k, n)
|
|
740
|
+
return self._topk(self._pairwise(ngs, ngs), k_eff)
|
|
741
|
+
|
|
742
|
+
def pairs_across(
|
|
743
|
+
self,
|
|
744
|
+
query_texts: Sequence[str],
|
|
745
|
+
reference_texts: Sequence[str],
|
|
746
|
+
k: int,
|
|
747
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
748
|
+
n_q, n_r = len(query_texts), len(reference_texts)
|
|
749
|
+
if n_q == 0 or n_r == 0:
|
|
750
|
+
return (
|
|
751
|
+
np.zeros((n_q, 0), dtype=np.float64),
|
|
752
|
+
np.zeros((n_q, 0), dtype=np.int64),
|
|
753
|
+
)
|
|
754
|
+
q_ngs = [self._ngrams(t) for t in query_texts]
|
|
755
|
+
r_ngs = [self._ngrams(t) for t in reference_texts]
|
|
756
|
+
k_eff = min(k, n_r)
|
|
757
|
+
return self._topk(self._pairwise(q_ngs, r_ngs), k_eff)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# ---------------------------------------------------------------------------
|
|
761
|
+
# MinHash + LSH: production-scale approximate Jaccard
|
|
762
|
+
# ---------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# Mersenne prime > 2**32 — used for the universal hash family in MinHash.
|
|
766
|
+
_MINHASH_PRIME: int = (1 << 61) - 1
|
|
767
|
+
_MINHASH_MAX_HASH: int = (1 << 32) - 1
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@dataclass(frozen=True, slots=True)
|
|
771
|
+
class MinHashLSHStrategy:
|
|
772
|
+
r"""Approximate Jaccard via MinHash + LSH banding (Broder 1997, Indyk-Motwani 1998).
|
|
773
|
+
|
|
774
|
+
Production-scale alternative to :class:`JaccardNgramStrategy`. Computes
|
|
775
|
+
MinHash signatures of length ``num_perm`` for each text using a
|
|
776
|
+
universal hash family, then Locality-Sensitive Hashing (LSH) with
|
|
777
|
+
``bands × rows = num_perm`` partitions the signature space so any two
|
|
778
|
+
texts with high Jaccard similarity probably collide in at least one
|
|
779
|
+
band. For each query, we expand its candidate set from the LSH
|
|
780
|
+
buckets, compute exact Jaccard on the surviving candidates, and
|
|
781
|
+
return the top-``k`` by similarity.
|
|
782
|
+
|
|
783
|
+
.. note::
|
|
784
|
+
|
|
785
|
+
This is an *approximate* Jaccard. Two texts with true Jaccard ≥
|
|
786
|
+
threshold are *probably* (not certainly) discovered as
|
|
787
|
+
candidates; the false-negative probability decreases with the
|
|
788
|
+
``num_perm`` × bands tuning. See Indyk & Motwani 1998 for the
|
|
789
|
+
analysis of the LSH band-curve.
|
|
790
|
+
|
|
791
|
+
Parameters
|
|
792
|
+
----------
|
|
793
|
+
n : int, optional
|
|
794
|
+
Character n-gram size for shingling. Default ``3``.
|
|
795
|
+
num_perm : int, optional
|
|
796
|
+
Number of MinHash permutations / signature length. Default
|
|
797
|
+
``128``. Larger num_perm → more accurate Jaccard estimate, more
|
|
798
|
+
compute. ``num_perm`` must equal ``bands × rows_per_band``.
|
|
799
|
+
bands : int, optional
|
|
800
|
+
Number of LSH bands. Default ``16``. With default ``num_perm=128``
|
|
801
|
+
this gives ``rows_per_band = 8``. The LSH probability of two
|
|
802
|
+
texts with true Jaccard :math:`s` colliding in at least one band
|
|
803
|
+
is :math:`1 - (1 - s^r)^b`. Tune ``(bands, rows_per_band)`` to
|
|
804
|
+
the threshold you care about; e.g. ``(20, 5)`` flips around
|
|
805
|
+
:math:`s = 0.5`, ``(16, 8)`` flips around :math:`s ≈ 0.74`.
|
|
806
|
+
seed : int, optional
|
|
807
|
+
Deterministic seed for the universal-hash coefficients. Default
|
|
808
|
+
``42``.
|
|
809
|
+
|
|
810
|
+
Examples
|
|
811
|
+
--------
|
|
812
|
+
>>> import numpy as np
|
|
813
|
+
>>> strat = MinHashLSHStrategy(n=2, num_perm=64, bands=16, seed=0)
|
|
814
|
+
>>> texts = ["the quick brown fox", "the quick brown fox!", "lorem ipsum"]
|
|
815
|
+
>>> sims, idx = strat.pairs_within(texts, k=2)
|
|
816
|
+
>>> sims.shape, idx.shape
|
|
817
|
+
((3, 2), (3, 2))
|
|
818
|
+
|
|
819
|
+
Notes
|
|
820
|
+
-----
|
|
821
|
+
Scaling: ``num_perm × n_texts`` MinHash work for signatures, then
|
|
822
|
+
LSH bucketing is O(bands · n_texts) hashing + O(candidates per query)
|
|
823
|
+
Jaccard recomputation. For ``n_texts = 100K`` with default params,
|
|
824
|
+
this is dramatically faster than :class:`JaccardNgramStrategy`'s
|
|
825
|
+
O(n²) brute-force.
|
|
826
|
+
|
|
827
|
+
The "fillers" returned for queries with fewer than ``k`` candidates
|
|
828
|
+
are filled in with arbitrary indices and similarity ``0.0``;
|
|
829
|
+
downstream callers (``near_dedup``, ``cross_dedup``) only act on
|
|
830
|
+
similarities ``≥ threshold`` so the fillers are correctly ignored.
|
|
831
|
+
|
|
832
|
+
References
|
|
833
|
+
----------
|
|
834
|
+
.. [1] Broder, A. "On the resemblance and containment of documents."
|
|
835
|
+
Compression and Complexity of Sequences, 1997.
|
|
836
|
+
.. [2] Indyk, P. & Motwani, R. "Approximate nearest neighbors:
|
|
837
|
+
Towards removing the curse of dimensionality." STOC 1998.
|
|
838
|
+
"""
|
|
839
|
+
|
|
840
|
+
n: int = 3
|
|
841
|
+
num_perm: int = 128
|
|
842
|
+
bands: int = 16
|
|
843
|
+
seed: int = 42
|
|
844
|
+
|
|
845
|
+
def __post_init__(self) -> None:
|
|
846
|
+
"""Validate constructor arguments."""
|
|
847
|
+
if self.n < 1:
|
|
848
|
+
raise ValueError(f"n must be ≥ 1, got {self.n}")
|
|
849
|
+
if self.num_perm < 8:
|
|
850
|
+
raise ValueError(f"num_perm must be ≥ 8, got {self.num_perm}")
|
|
851
|
+
if self.bands < 1 or self.bands > self.num_perm:
|
|
852
|
+
raise ValueError(f"bands must be in [1, num_perm={self.num_perm}], got {self.bands}")
|
|
853
|
+
if self.num_perm % self.bands != 0:
|
|
854
|
+
raise ValueError(
|
|
855
|
+
f"num_perm ({self.num_perm}) must be divisible by bands "
|
|
856
|
+
f"({self.bands}) for evenly-sized rows"
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
@property
|
|
860
|
+
def rows_per_band(self) -> int:
|
|
861
|
+
"""Rows per LSH band; equals ``num_perm // bands``."""
|
|
862
|
+
return self.num_perm // self.bands
|
|
863
|
+
|
|
864
|
+
def _hash_coefs(self) -> tuple[np.ndarray, np.ndarray]:
|
|
865
|
+
"""Build (a, b) coefficients for the universal hash family.
|
|
866
|
+
|
|
867
|
+
The hash is :math:`h_i(x) = ((a_i x + b_i) \\mod p) \\mod 2^{32}`,
|
|
868
|
+
a 4-universal hash family per Carter & Wegman 1979.
|
|
869
|
+
"""
|
|
870
|
+
rng = np.random.default_rng(self.seed)
|
|
871
|
+
a = rng.integers(1, _MINHASH_PRIME, size=self.num_perm, dtype=np.int64)
|
|
872
|
+
b = rng.integers(0, _MINHASH_PRIME, size=self.num_perm, dtype=np.int64)
|
|
873
|
+
return a, b
|
|
874
|
+
|
|
875
|
+
def _shingles(self, text: str) -> set[str]:
|
|
876
|
+
"""Char n-gram set for ``text`` (matches JaccardNgramStrategy.char-mode)."""
|
|
877
|
+
if len(text) < self.n:
|
|
878
|
+
return {text} if text else set()
|
|
879
|
+
return {text[i : i + self.n] for i in range(len(text) - self.n + 1)}
|
|
880
|
+
|
|
881
|
+
def _signature(self, shingle_set: set[str], a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
882
|
+
"""MinHash signature of length ``num_perm`` for one shingle set."""
|
|
883
|
+
if not shingle_set:
|
|
884
|
+
return np.full(self.num_perm, _MINHASH_MAX_HASH, dtype=np.int64)
|
|
885
|
+
# Hash each shingle to a 32-bit integer (deterministic via SHA-256).
|
|
886
|
+
shingle_hashes = np.fromiter(
|
|
887
|
+
(
|
|
888
|
+
int.from_bytes(
|
|
889
|
+
hashlib.sha256(s.encode("utf-8")).digest()[:4],
|
|
890
|
+
"big",
|
|
891
|
+
)
|
|
892
|
+
for s in shingle_set
|
|
893
|
+
),
|
|
894
|
+
dtype=np.int64,
|
|
895
|
+
count=len(shingle_set),
|
|
896
|
+
)
|
|
897
|
+
# For each of the num_perm permutations, take the min over the shingle hashes.
|
|
898
|
+
# h_i(x) = ((a_i * x + b_i) mod prime) mod 2^32 — universal hash.
|
|
899
|
+
permuted = (
|
|
900
|
+
(np.outer(shingle_hashes, a) + b[np.newaxis, :])
|
|
901
|
+
% _MINHASH_PRIME
|
|
902
|
+
% (_MINHASH_MAX_HASH + 1)
|
|
903
|
+
)
|
|
904
|
+
sig: np.ndarray = permuted.min(axis=0).astype(np.int64)
|
|
905
|
+
return sig
|
|
906
|
+
|
|
907
|
+
def _signatures_for(self, texts: Sequence[str]) -> np.ndarray:
|
|
908
|
+
"""Stack of MinHash signatures of shape ``(len(texts), num_perm)``."""
|
|
909
|
+
a, b = self._hash_coefs()
|
|
910
|
+
n = len(texts)
|
|
911
|
+
sigs = np.zeros((n, self.num_perm), dtype=np.int64)
|
|
912
|
+
for i, t in enumerate(texts):
|
|
913
|
+
sigs[i] = self._signature(self._shingles(t), a, b)
|
|
914
|
+
return sigs
|
|
915
|
+
|
|
916
|
+
def _build_lsh_index(self, sigs: np.ndarray) -> list[dict[bytes, list[int]]]:
|
|
917
|
+
"""Bucket signatures into ``self.bands`` band-keyed dicts."""
|
|
918
|
+
rows = self.rows_per_band
|
|
919
|
+
indices: list[dict[bytes, list[int]]] = [{} for _ in range(self.bands)]
|
|
920
|
+
for i in range(sigs.shape[0]):
|
|
921
|
+
for band in range(self.bands):
|
|
922
|
+
start = band * rows
|
|
923
|
+
key = sigs[i, start : start + rows].tobytes()
|
|
924
|
+
indices[band].setdefault(key, []).append(i)
|
|
925
|
+
return indices
|
|
926
|
+
|
|
927
|
+
def _query_lsh(
|
|
928
|
+
self,
|
|
929
|
+
sigs_query: np.ndarray,
|
|
930
|
+
sigs_ref: np.ndarray,
|
|
931
|
+
ref_index: list[dict[bytes, list[int]]],
|
|
932
|
+
) -> list[set[int]]:
|
|
933
|
+
"""Per-query candidate set: ref indices sharing ≥ 1 band hash."""
|
|
934
|
+
rows = self.rows_per_band
|
|
935
|
+
candidates: list[set[int]] = [set() for _ in range(sigs_query.shape[0])]
|
|
936
|
+
for i in range(sigs_query.shape[0]):
|
|
937
|
+
for band in range(self.bands):
|
|
938
|
+
start = band * rows
|
|
939
|
+
key = sigs_query[i, start : start + rows].tobytes()
|
|
940
|
+
if key in ref_index[band]:
|
|
941
|
+
candidates[i].update(ref_index[band][key])
|
|
942
|
+
return candidates
|
|
943
|
+
|
|
944
|
+
@staticmethod
|
|
945
|
+
def _exact_jaccard(a: set[str], b: set[str]) -> float:
|
|
946
|
+
"""Standard Jaccard; matches :meth:`JaccardNgramStrategy._jaccard`."""
|
|
947
|
+
if not a and not b:
|
|
948
|
+
return 1.0
|
|
949
|
+
union = a | b
|
|
950
|
+
if not union:
|
|
951
|
+
return 0.0
|
|
952
|
+
return len(a & b) / len(union)
|
|
953
|
+
|
|
954
|
+
def pairs_within(self, texts: Sequence[str], k: int) -> tuple[np.ndarray, np.ndarray]:
|
|
955
|
+
n = len(texts)
|
|
956
|
+
if n == 0:
|
|
957
|
+
return np.zeros((0, 0), dtype=np.float64), np.zeros((0, 0), dtype=np.int64)
|
|
958
|
+
sigs = self._signatures_for(texts)
|
|
959
|
+
index = self._build_lsh_index(sigs)
|
|
960
|
+
shingles_per_text = [self._shingles(t) for t in texts]
|
|
961
|
+
return self._compute_topk(
|
|
962
|
+
list(range(n)),
|
|
963
|
+
shingles_per_text,
|
|
964
|
+
shingles_per_text,
|
|
965
|
+
self._query_lsh(sigs, sigs, index),
|
|
966
|
+
k_eff=min(k, n),
|
|
967
|
+
n_ref=n,
|
|
968
|
+
include_self=True,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
def pairs_across(
|
|
972
|
+
self,
|
|
973
|
+
query_texts: Sequence[str],
|
|
974
|
+
reference_texts: Sequence[str],
|
|
975
|
+
k: int,
|
|
976
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
977
|
+
n_q, n_r = len(query_texts), len(reference_texts)
|
|
978
|
+
if n_q == 0 or n_r == 0:
|
|
979
|
+
return (
|
|
980
|
+
np.zeros((n_q, 0), dtype=np.float64),
|
|
981
|
+
np.zeros((n_q, 0), dtype=np.int64),
|
|
982
|
+
)
|
|
983
|
+
sigs_ref = self._signatures_for(reference_texts)
|
|
984
|
+
sigs_query = self._signatures_for(query_texts)
|
|
985
|
+
index = self._build_lsh_index(sigs_ref)
|
|
986
|
+
ref_shingles = [self._shingles(t) for t in reference_texts]
|
|
987
|
+
query_shingles = [self._shingles(t) for t in query_texts]
|
|
988
|
+
return self._compute_topk(
|
|
989
|
+
list(range(n_q)),
|
|
990
|
+
query_shingles,
|
|
991
|
+
ref_shingles,
|
|
992
|
+
self._query_lsh(sigs_query, sigs_ref, index),
|
|
993
|
+
k_eff=min(k, n_r),
|
|
994
|
+
n_ref=n_r,
|
|
995
|
+
include_self=False,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
def _compute_topk(
|
|
999
|
+
self,
|
|
1000
|
+
query_idx: list[int],
|
|
1001
|
+
query_shingles: Sequence[set[str]],
|
|
1002
|
+
ref_shingles: Sequence[set[str]],
|
|
1003
|
+
candidate_sets: list[set[int]],
|
|
1004
|
+
k_eff: int,
|
|
1005
|
+
n_ref: int,
|
|
1006
|
+
include_self: bool,
|
|
1007
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
1008
|
+
"""Compute exact Jaccard on LSH candidates and return top-k per query."""
|
|
1009
|
+
n_q = len(query_idx)
|
|
1010
|
+
sims = np.zeros((n_q, k_eff), dtype=np.float64)
|
|
1011
|
+
indices = np.zeros((n_q, k_eff), dtype=np.int64)
|
|
1012
|
+
for i, qi in enumerate(query_idx):
|
|
1013
|
+
cands = candidate_sets[i]
|
|
1014
|
+
if include_self:
|
|
1015
|
+
cands = cands | {qi}
|
|
1016
|
+
scored: list[tuple[float, int]] = [
|
|
1017
|
+
(self._exact_jaccard(query_shingles[i], ref_shingles[j]), j) for j in cands
|
|
1018
|
+
]
|
|
1019
|
+
# Stable descending sort: highest similarity first; on ties, lower index first.
|
|
1020
|
+
scored.sort(key=lambda t: (-t[0], t[1]))
|
|
1021
|
+
chosen = scored[:k_eff]
|
|
1022
|
+
for slot, (sim, j) in enumerate(chosen):
|
|
1023
|
+
sims[i, slot] = sim
|
|
1024
|
+
indices[i, slot] = j
|
|
1025
|
+
# Pad with arbitrary fillers if fewer than k_eff candidates.
|
|
1026
|
+
if len(chosen) < k_eff:
|
|
1027
|
+
seen = {j for _, j in chosen}
|
|
1028
|
+
fillers = [j for j in range(n_ref) if j not in seen]
|
|
1029
|
+
for slot in range(len(chosen), k_eff):
|
|
1030
|
+
if not fillers:
|
|
1031
|
+
break
|
|
1032
|
+
indices[i, slot] = fillers.pop(0)
|
|
1033
|
+
# sims[i, slot] stays 0.0 — filler.
|
|
1034
|
+
return sims, indices
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
# ---------------------------------------------------------------------------
|
|
1038
|
+
# Orchestrators
|
|
1039
|
+
# ---------------------------------------------------------------------------
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def near_dedup(
|
|
1043
|
+
texts: list[str],
|
|
1044
|
+
threshold: float = DEFAULT_DEDUP_THRESHOLD,
|
|
1045
|
+
k_neighbors: int = 20,
|
|
1046
|
+
*,
|
|
1047
|
+
strategy: SimilarityStrategy | None = None,
|
|
1048
|
+
) -> DedupReport:
|
|
1049
|
+
"""Greedy near-deduplication via a pluggable similarity strategy.
|
|
1050
|
+
|
|
1051
|
+
Forward-scan: for each kept text ``i``, drop all ``j > i`` with
|
|
1052
|
+
``similarity(i, j) >= threshold`` per the active strategy. Default
|
|
1053
|
+
strategy is :class:`TfidfCosineStrategy` with literal v0.1.0 defaults
|
|
1054
|
+
(preserved bit-for-bit; existing callers keep working unchanged).
|
|
1055
|
+
|
|
1056
|
+
Parameters
|
|
1057
|
+
----------
|
|
1058
|
+
texts : list[str]
|
|
1059
|
+
threshold : float, optional
|
|
1060
|
+
Similarity threshold in (0, 1). Default 0.9. Strategy-specific scale:
|
|
1061
|
+
for cosine-style strategies (TF-IDF, embedding) similarities are in
|
|
1062
|
+
[-1, 1]; for hash-bucket dedup similarities are in {0.0, 1.0}; for
|
|
1063
|
+
Jaccard in [0, 1].
|
|
1064
|
+
k_neighbors : int, optional
|
|
1065
|
+
Maximum neighbors to consider per query. Default 20.
|
|
1066
|
+
strategy : SimilarityStrategy or None, optional
|
|
1067
|
+
Pluggable similarity backend. ``None`` (default) instantiates
|
|
1068
|
+
:class:`TfidfCosineStrategy` with constructor defaults.
|
|
1069
|
+
|
|
1070
|
+
Returns
|
|
1071
|
+
-------
|
|
1072
|
+
DedupReport
|
|
1073
|
+
|
|
1074
|
+
Raises
|
|
1075
|
+
------
|
|
1076
|
+
TypeError
|
|
1077
|
+
If ``texts`` is not a list.
|
|
1078
|
+
ValueError
|
|
1079
|
+
If ``threshold`` is outside (0, 1).
|
|
1080
|
+
|
|
1081
|
+
Examples
|
|
1082
|
+
--------
|
|
1083
|
+
Default strategy (TF-IDF cosine):
|
|
1084
|
+
|
|
1085
|
+
>>> texts = ["the quick brown fox", "the quick brown fox!", "lorem ipsum"]
|
|
1086
|
+
>>> report = near_dedup(texts, threshold=0.8)
|
|
1087
|
+
>>> report.n_kept >= 2 # at most one near-duplicate dropped
|
|
1088
|
+
True
|
|
1089
|
+
>>> set(report.kept_indices) | {p[0] for p in report.dropped_pairs} == set(range(3))
|
|
1090
|
+
True
|
|
1091
|
+
|
|
1092
|
+
Custom strategy (exact-match-after-normalization):
|
|
1093
|
+
|
|
1094
|
+
>>> report_exact = near_dedup(
|
|
1095
|
+
... ["foo", "FOO", "bar"], threshold=0.5,
|
|
1096
|
+
... strategy=ExactNormalizedHashStrategy(),
|
|
1097
|
+
... )
|
|
1098
|
+
>>> report_exact.n_kept # "foo"/"FOO" collide; "bar" isolated
|
|
1099
|
+
2
|
|
1100
|
+
|
|
1101
|
+
Notes
|
|
1102
|
+
-----
|
|
1103
|
+
The orchestrator is strategy-agnostic: it dispatches to
|
|
1104
|
+
``strategy.pairs_within(texts, k_neighbors)`` and applies the threshold
|
|
1105
|
+
plus forward-scan greedy drop logic. Different "senses of leakage"
|
|
1106
|
+
(lexical, semantic, exact, n-gram-set) are encoded by swapping the
|
|
1107
|
+
strategy.
|
|
1108
|
+
|
|
1109
|
+
**Order dependence**: forward-scan greedy is order-dependent — for any
|
|
1110
|
+
cluster of near-duplicates, the *first* occurrence is kept and later
|
|
1111
|
+
occurrences are dropped. To make dedup reproducible across re-runs
|
|
1112
|
+
that may permute the input, **sort inputs by a canonical key** (URL,
|
|
1113
|
+
document id, primary key) before calling. This is the canonical
|
|
1114
|
+
approach in modern dedup pipelines (Lee et al. 2022 ACL "NearDup";
|
|
1115
|
+
Penedo et al. 2023 RefinedWeb; Penedo et al. 2025 FineWeb2).
|
|
1116
|
+
|
|
1117
|
+
References
|
|
1118
|
+
----------
|
|
1119
|
+
.. [1] Lee, K., et al. "Deduplicating training data makes language
|
|
1120
|
+
models better." ACL 2022.
|
|
1121
|
+
"""
|
|
1122
|
+
if not isinstance(texts, list):
|
|
1123
|
+
raise TypeError(f"texts must be a list, got {type(texts).__name__}")
|
|
1124
|
+
n = len(texts)
|
|
1125
|
+
if n == 0:
|
|
1126
|
+
return DedupReport([], [], threshold, 0)
|
|
1127
|
+
if not 0.0 < threshold < 1.0:
|
|
1128
|
+
raise ValueError(f"threshold must be in (0, 1), got {threshold}")
|
|
1129
|
+
|
|
1130
|
+
active_strategy: SimilarityStrategy = (
|
|
1131
|
+
strategy if strategy is not None else TfidfCosineStrategy()
|
|
1132
|
+
)
|
|
1133
|
+
similarities, indices = active_strategy.pairs_within(texts, k_neighbors)
|
|
1134
|
+
|
|
1135
|
+
kept_mask = np.ones(n, dtype=bool)
|
|
1136
|
+
dropped: list[tuple[int, int, float]] = []
|
|
1137
|
+
|
|
1138
|
+
for i in range(n):
|
|
1139
|
+
if not kept_mask[i]:
|
|
1140
|
+
continue
|
|
1141
|
+
for sim, j in zip(similarities[i], indices[i], strict=True):
|
|
1142
|
+
j_int = int(j)
|
|
1143
|
+
if j_int == i or j_int < i:
|
|
1144
|
+
continue
|
|
1145
|
+
if float(sim) >= threshold and kept_mask[j_int]:
|
|
1146
|
+
kept_mask[j_int] = False
|
|
1147
|
+
dropped.append((j_int, i, float(sim)))
|
|
1148
|
+
|
|
1149
|
+
kept_indices = np.where(kept_mask)[0].tolist()
|
|
1150
|
+
return DedupReport(kept_indices, dropped, threshold, n)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def audit_source_label_similarity(
|
|
1154
|
+
texts: Sequence[str],
|
|
1155
|
+
*,
|
|
1156
|
+
sources: Sequence[str] | None = None,
|
|
1157
|
+
labels: Sequence[object] | None = None,
|
|
1158
|
+
threshold: float = DEFAULT_DEDUP_THRESHOLD,
|
|
1159
|
+
k_neighbors: int = 20,
|
|
1160
|
+
strategy: SimilarityStrategy | None = None,
|
|
1161
|
+
include_within_source: bool = True,
|
|
1162
|
+
include_cross_source: bool = True,
|
|
1163
|
+
include_same_label: bool = True,
|
|
1164
|
+
include_cross_label: bool = True,
|
|
1165
|
+
) -> SimilarityAuditReport:
|
|
1166
|
+
"""Report high-similarity pairs without dropping rows.
|
|
1167
|
+
|
|
1168
|
+
This is the evidence-first complement to :func:`near_dedup`. It uses the
|
|
1169
|
+
same pluggable similarity strategies but returns pair findings annotated
|
|
1170
|
+
with source/label relationships so consumers can decide whether duplicates
|
|
1171
|
+
are leakage, benign repeated labels, or label conflicts.
|
|
1172
|
+
|
|
1173
|
+
Raises
|
|
1174
|
+
------
|
|
1175
|
+
ValueError
|
|
1176
|
+
If ``threshold`` is not in (0, 1]; if ``k_neighbors < 1``; or if
|
|
1177
|
+
``sources``/``labels`` is supplied but does not have the same
|
|
1178
|
+
length as ``texts``.
|
|
1179
|
+
"""
|
|
1180
|
+
text_list = list(texts)
|
|
1181
|
+
n = len(text_list)
|
|
1182
|
+
if not 0.0 < threshold <= 1.0:
|
|
1183
|
+
raise ValueError(f"threshold must be in (0, 1], got {threshold}")
|
|
1184
|
+
if k_neighbors < 1:
|
|
1185
|
+
raise ValueError(f"k_neighbors must be >= 1, got {k_neighbors}")
|
|
1186
|
+
if sources is not None and len(sources) != n:
|
|
1187
|
+
raise ValueError("sources must have the same length as texts")
|
|
1188
|
+
if labels is not None and len(labels) != n:
|
|
1189
|
+
raise ValueError("labels must have the same length as texts")
|
|
1190
|
+
if n == 0:
|
|
1191
|
+
return SimilarityAuditReport([], threshold, 0, "none", k_neighbors)
|
|
1192
|
+
|
|
1193
|
+
source_list = list(sources) if sources is not None else None
|
|
1194
|
+
label_list = list(labels) if labels is not None else None
|
|
1195
|
+
active_strategy: SimilarityStrategy = (
|
|
1196
|
+
strategy if strategy is not None else TfidfCosineStrategy()
|
|
1197
|
+
)
|
|
1198
|
+
similarities, indices = active_strategy.pairs_within(text_list, k_neighbors)
|
|
1199
|
+
|
|
1200
|
+
findings: list[SimilarityAuditFinding] = []
|
|
1201
|
+
seen_pairs: set[tuple[int, int]] = set()
|
|
1202
|
+
for i in range(n):
|
|
1203
|
+
for sim, j in zip(similarities[i], indices[i], strict=True):
|
|
1204
|
+
j_int = int(j)
|
|
1205
|
+
if j_int == i:
|
|
1206
|
+
continue
|
|
1207
|
+
left, right = sorted((i, j_int))
|
|
1208
|
+
pair = (left, right)
|
|
1209
|
+
if pair in seen_pairs:
|
|
1210
|
+
continue
|
|
1211
|
+
seen_pairs.add(pair)
|
|
1212
|
+
similarity = float(sim)
|
|
1213
|
+
if similarity < threshold:
|
|
1214
|
+
continue
|
|
1215
|
+
if not _audit_pair_allowed(
|
|
1216
|
+
left,
|
|
1217
|
+
right,
|
|
1218
|
+
sources=source_list,
|
|
1219
|
+
labels=label_list,
|
|
1220
|
+
include_within_source=include_within_source,
|
|
1221
|
+
include_cross_source=include_cross_source,
|
|
1222
|
+
include_same_label=include_same_label,
|
|
1223
|
+
include_cross_label=include_cross_label,
|
|
1224
|
+
):
|
|
1225
|
+
continue
|
|
1226
|
+
findings.append(
|
|
1227
|
+
SimilarityAuditFinding(
|
|
1228
|
+
left_index=left,
|
|
1229
|
+
right_index=right,
|
|
1230
|
+
similarity=similarity,
|
|
1231
|
+
relation=_similarity_relation(left, right, source_list, label_list),
|
|
1232
|
+
left_source=source_list[left] if source_list is not None else None,
|
|
1233
|
+
right_source=source_list[right] if source_list is not None else None,
|
|
1234
|
+
left_label=label_list[left] if label_list is not None else None,
|
|
1235
|
+
right_label=label_list[right] if label_list is not None else None,
|
|
1236
|
+
)
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
findings.sort(
|
|
1240
|
+
key=lambda finding: (-finding.similarity, finding.left_index, finding.right_index)
|
|
1241
|
+
)
|
|
1242
|
+
return SimilarityAuditReport(
|
|
1243
|
+
findings=findings,
|
|
1244
|
+
threshold=threshold,
|
|
1245
|
+
n_input=n,
|
|
1246
|
+
strategy=type(active_strategy).__name__,
|
|
1247
|
+
k_neighbors=k_neighbors,
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _audit_pair_allowed(
|
|
1252
|
+
left: int,
|
|
1253
|
+
right: int,
|
|
1254
|
+
*,
|
|
1255
|
+
sources: Sequence[str] | None,
|
|
1256
|
+
labels: Sequence[object] | None,
|
|
1257
|
+
include_within_source: bool,
|
|
1258
|
+
include_cross_source: bool,
|
|
1259
|
+
include_same_label: bool,
|
|
1260
|
+
include_cross_label: bool,
|
|
1261
|
+
) -> bool:
|
|
1262
|
+
"""Return whether a candidate pair should be retained by audit filters."""
|
|
1263
|
+
if sources is not None:
|
|
1264
|
+
same_source = sources[left] == sources[right]
|
|
1265
|
+
if same_source and not include_within_source:
|
|
1266
|
+
return False
|
|
1267
|
+
if not same_source and not include_cross_source:
|
|
1268
|
+
return False
|
|
1269
|
+
if labels is not None:
|
|
1270
|
+
same_label = labels[left] == labels[right]
|
|
1271
|
+
if same_label and not include_same_label:
|
|
1272
|
+
return False
|
|
1273
|
+
if not same_label and not include_cross_label:
|
|
1274
|
+
return False
|
|
1275
|
+
return True
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _similarity_relation(
|
|
1279
|
+
left: int,
|
|
1280
|
+
right: int,
|
|
1281
|
+
sources: Sequence[str] | None,
|
|
1282
|
+
labels: Sequence[object] | None,
|
|
1283
|
+
) -> SimilarityRelation:
|
|
1284
|
+
"""Classify the source/label relationship for an audit finding."""
|
|
1285
|
+
if sources is None and labels is None:
|
|
1286
|
+
return "unspecified"
|
|
1287
|
+
same_source = sources[left] == sources[right] if sources is not None else None
|
|
1288
|
+
same_label = labels[left] == labels[right] if labels is not None else None
|
|
1289
|
+
if same_source is None:
|
|
1290
|
+
return "same_label" if same_label else "cross_label"
|
|
1291
|
+
if same_label is None:
|
|
1292
|
+
return "within_source" if same_source else "cross_source"
|
|
1293
|
+
if same_source and same_label:
|
|
1294
|
+
return "within_source_same_label"
|
|
1295
|
+
if same_source and not same_label:
|
|
1296
|
+
return "within_source_cross_label"
|
|
1297
|
+
if not same_source and same_label:
|
|
1298
|
+
return "cross_source_same_label"
|
|
1299
|
+
return "cross_source_cross_label"
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def cross_dedup_pairs(
|
|
1303
|
+
train_texts: list[str],
|
|
1304
|
+
eval_texts: list[str],
|
|
1305
|
+
threshold: float = DEFAULT_DEDUP_THRESHOLD,
|
|
1306
|
+
k_neighbors: int = 20,
|
|
1307
|
+
*,
|
|
1308
|
+
strategy: SimilarityStrategy | None = None,
|
|
1309
|
+
) -> list[tuple[int, int, float]]:
|
|
1310
|
+
"""Return all (eval_idx, train_idx, similarity) tuples at or above threshold.
|
|
1311
|
+
|
|
1312
|
+
Companion to :func:`cross_dedup` that exposes the matched train neighbor
|
|
1313
|
+
indices instead of only the eval-side keep set. Used by
|
|
1314
|
+
:class:`eval_toolkit.leakage.CrossSplitLeakageCheck` in
|
|
1315
|
+
``label_aware`` mode (v0.17.0) to split near-duplicate hits into
|
|
1316
|
+
same-label and cross-label findings.
|
|
1317
|
+
|
|
1318
|
+
Parameters mirror :func:`cross_dedup`. Returns ``[]`` when either side
|
|
1319
|
+
is empty.
|
|
1320
|
+
|
|
1321
|
+
Raises
|
|
1322
|
+
------
|
|
1323
|
+
ValueError
|
|
1324
|
+
If ``threshold`` is not in (0, 1).
|
|
1325
|
+
"""
|
|
1326
|
+
if not 0.0 < threshold < 1.0:
|
|
1327
|
+
raise ValueError(f"threshold must be in (0, 1), got {threshold}")
|
|
1328
|
+
if not train_texts or not eval_texts:
|
|
1329
|
+
return []
|
|
1330
|
+
active_strategy: SimilarityStrategy = (
|
|
1331
|
+
strategy if strategy is not None else TfidfCosineStrategy()
|
|
1332
|
+
)
|
|
1333
|
+
similarities, indices = active_strategy.pairs_across(eval_texts, train_texts, k_neighbors)
|
|
1334
|
+
if similarities.size == 0:
|
|
1335
|
+
return []
|
|
1336
|
+
out: list[tuple[int, int, float]] = []
|
|
1337
|
+
for eval_idx in range(similarities.shape[0]):
|
|
1338
|
+
for col in range(similarities.shape[1]):
|
|
1339
|
+
sim = float(similarities[eval_idx, col])
|
|
1340
|
+
if sim >= threshold:
|
|
1341
|
+
out.append((eval_idx, int(indices[eval_idx, col]), sim))
|
|
1342
|
+
return out
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def cross_dedup(
|
|
1346
|
+
train_texts: list[str],
|
|
1347
|
+
eval_texts: list[str],
|
|
1348
|
+
threshold: float = DEFAULT_DEDUP_THRESHOLD,
|
|
1349
|
+
k_neighbors: int = 20,
|
|
1350
|
+
*,
|
|
1351
|
+
strategy: SimilarityStrategy | None = None,
|
|
1352
|
+
) -> list[int]:
|
|
1353
|
+
"""Return eval indices to KEEP (drop those near-duplicate to any train text).
|
|
1354
|
+
|
|
1355
|
+
Used to scrub OOD eval slices of any train-pool leakage before reporting
|
|
1356
|
+
OOD metrics. The notion of "near-duplicate" is owned by ``strategy``.
|
|
1357
|
+
|
|
1358
|
+
Parameters
|
|
1359
|
+
----------
|
|
1360
|
+
train_texts, eval_texts : list[str]
|
|
1361
|
+
threshold : float, optional
|
|
1362
|
+
Similarity threshold in (0, 1). Default 0.9.
|
|
1363
|
+
k_neighbors : int, optional
|
|
1364
|
+
Maximum neighbors to consider per eval text. Default 20.
|
|
1365
|
+
strategy : SimilarityStrategy or None, optional
|
|
1366
|
+
Pluggable similarity backend. ``None`` (default) instantiates
|
|
1367
|
+
:class:`TfidfCosineStrategy` with constructor defaults.
|
|
1368
|
+
|
|
1369
|
+
Returns
|
|
1370
|
+
-------
|
|
1371
|
+
list[int]
|
|
1372
|
+
Indices into ``eval_texts`` of rows that are NOT near-duplicate to
|
|
1373
|
+
any train text. Order preserved.
|
|
1374
|
+
|
|
1375
|
+
Examples
|
|
1376
|
+
--------
|
|
1377
|
+
>>> train = ["the quick brown fox", "lorem ipsum dolor sit amet"]
|
|
1378
|
+
>>> eval_set = ["the quick brown fox!", "completely different text"]
|
|
1379
|
+
>>> kept = cross_dedup(train, eval_set, threshold=0.8)
|
|
1380
|
+
>>> 1 in kept # second eval text has no train match
|
|
1381
|
+
True
|
|
1382
|
+
|
|
1383
|
+
Raises
|
|
1384
|
+
------
|
|
1385
|
+
ValueError
|
|
1386
|
+
If ``threshold`` is not in (0, 1).
|
|
1387
|
+
"""
|
|
1388
|
+
if not 0.0 < threshold < 1.0:
|
|
1389
|
+
raise ValueError(f"threshold must be in (0, 1), got {threshold}")
|
|
1390
|
+
if not train_texts:
|
|
1391
|
+
return list(range(len(eval_texts)))
|
|
1392
|
+
if not eval_texts:
|
|
1393
|
+
return []
|
|
1394
|
+
|
|
1395
|
+
active_strategy: SimilarityStrategy = (
|
|
1396
|
+
strategy if strategy is not None else TfidfCosineStrategy()
|
|
1397
|
+
)
|
|
1398
|
+
similarities, _indices = active_strategy.pairs_across(eval_texts, train_texts, k_neighbors)
|
|
1399
|
+
if similarities.size == 0:
|
|
1400
|
+
return list(range(len(eval_texts)))
|
|
1401
|
+
max_sim_per_eval = similarities.max(axis=1)
|
|
1402
|
+
keep_mask = max_sim_per_eval < threshold
|
|
1403
|
+
return [int(i) for i in np.where(keep_mask)[0]]
|