semantic-dedup 0.1.0__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.
@@ -0,0 +1,36 @@
1
+ """semantic-dedup: remove passages that repeat the same meaning, not just the same words.
2
+
3
+ Quick use::
4
+
5
+ import semantic_dedup
6
+ result = semantic_dedup.dedupe(texts) # or a .txt / .csv / .jsonl path
7
+ print(result.summary())
8
+ clean = result.texts
9
+ """
10
+ from ._core import (
11
+ AUTO_MINHASH_ABOVE,
12
+ KEEP_MODES,
13
+ METHODS,
14
+ DedupeResult,
15
+ Deduper,
16
+ dedupe,
17
+ find_duplicates,
18
+ similarity,
19
+ )
20
+ from ._text import canonical_tokens, normalize
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "DedupeResult",
26
+ "Deduper",
27
+ "dedupe",
28
+ "find_duplicates",
29
+ "similarity",
30
+ "canonical_tokens",
31
+ "normalize",
32
+ "METHODS",
33
+ "KEEP_MODES",
34
+ "AUTO_MINHASH_ABOVE",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m semantic_dedup``."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,495 @@
1
+ """The public behaviour: ``Deduper``, ``DedupeResult`` and the one-line helpers."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
7
+
8
+ import numpy as np
9
+
10
+ from . import _minhash, _vectors
11
+ from ._io import load_texts
12
+ from ._text import canonical_tokens, normalize
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ __all__ = ["DedupeResult", "Deduper", "dedupe", "find_duplicates", "similarity"]
17
+
18
+ METHODS = ("auto", "tfidf", "minhash", "embed")
19
+ KEEP_MODES = ("longest", "first", "last", "most_complete")
20
+
21
+ #: Above this many distinct texts, ``method="auto"`` switches to MinHash.
22
+ #: Above this many texts, ``method="auto"`` switches from exhaustive TF-IDF to MinHash
23
+ #: candidate generation. Measured on 2,700 mixed passages: TF-IDF took 15.2s and MinHash
24
+ #: 1.6s, and BOTH removed exactly the same 200 duplicates, because MinHash is used only
25
+ #: to pick candidate pairs and every candidate is then scored exactly. A threshold of
26
+ #: 5,000 left the default an order of magnitude slower than it needed to be with nothing
27
+ #: gained for it.
28
+ AUTO_MINHASH_ABOVE = 1000
29
+
30
+ EmbedFn = Callable[[List[str]], Any]
31
+
32
+
33
+ def _shorten(text: str, width: int = 62) -> str:
34
+ """One-line, ASCII-punctuated preview of a text."""
35
+ flat = " ".join(text.split())
36
+ if len(flat) <= width:
37
+ return flat
38
+ return flat[: width - 3] + "..."
39
+
40
+
41
+ def _check_ngram(name: str, value: Any) -> Tuple[int, int]:
42
+ """Validate an inclusive ``(low, high)`` n-gram range.
43
+
44
+ A reversed range produces no features at all, which would silently turn
45
+ every run into "no duplicates found", so it is rejected here instead.
46
+ """
47
+ try:
48
+ low, high = value
49
+ low, high = int(low), int(high)
50
+ except (TypeError, ValueError) as exc:
51
+ raise ValueError(
52
+ f"{name} must be a (low, high) pair of ints, got {value!r}"
53
+ ) from exc
54
+ if low < 0 or high < 0:
55
+ raise ValueError(f"{name} must not be negative, got ({low}, {high})")
56
+ if low > high:
57
+ raise ValueError(
58
+ f"{name}=({low}, {high}) is reversed: the low end must not be above "
59
+ f"the high end (did you mean ({high}, {low})?)"
60
+ )
61
+ return (low, high)
62
+
63
+
64
+ class _Union:
65
+ """Tiny union-find over ``n`` items."""
66
+
67
+ def __init__(self, n: int) -> None:
68
+ self.parent = list(range(n))
69
+
70
+ def find(self, x: int) -> int:
71
+ root = x
72
+ while self.parent[root] != root:
73
+ root = self.parent[root]
74
+ while self.parent[x] != root:
75
+ self.parent[x], x = root, self.parent[x]
76
+ return root
77
+
78
+ def union(self, a: int, b: int) -> None:
79
+ ra, rb = self.find(a), self.find(b)
80
+ if ra != rb:
81
+ self.parent[max(ra, rb)] = min(ra, rb)
82
+
83
+
84
+ @dataclass
85
+ class DedupeResult:
86
+ """What a dedupe run found. Indices always refer to the input positions."""
87
+
88
+ kept: List[int]
89
+ removed: List[int]
90
+ groups: List[List[int]]
91
+ pairs: List[Tuple[int, int, float]]
92
+ texts: List[str]
93
+ threshold: float
94
+ method: str
95
+ keep: str
96
+ n_texts: int
97
+ dropped: bool = True
98
+ warnings: List[str] = field(default_factory=list)
99
+ all_texts: List[str] = field(default_factory=list, repr=False)
100
+
101
+ # -- derived -----------------------------------------------------------
102
+ @property
103
+ def n_removed(self) -> int:
104
+ """How many texts were dropped (always 0 for ``find_duplicates``)."""
105
+ return len(self.removed)
106
+
107
+ @property
108
+ def n_kept(self) -> int:
109
+ """How many texts survived."""
110
+ return len(self.kept)
111
+
112
+ @property
113
+ def n_groups(self) -> int:
114
+ """How many duplicate groups were found."""
115
+ return len(self.groups)
116
+
117
+ @property
118
+ def n_duplicates(self) -> int:
119
+ """Texts that repeat something already present (group size minus one)."""
120
+ return sum(len(group) - 1 for group in self.groups)
121
+
122
+ @property
123
+ def reduction(self) -> float:
124
+ """Fraction of the input that was removed, 0.0 to 1.0."""
125
+ if not self.n_texts:
126
+ return 0.0
127
+ return len(self.removed) / self.n_texts
128
+
129
+ def group_range(self, group: Sequence[int]) -> Optional[Tuple[float, float]]:
130
+ """The lowest and highest similarity recorded inside one group."""
131
+ members = set(group)
132
+ scores = [s for i, j, s in self.pairs if i in members and j in members]
133
+ if not scores:
134
+ return None
135
+ return (min(scores), max(scores))
136
+
137
+ # -- output ------------------------------------------------------------
138
+ def summary(self, max_groups: int = 5) -> str:
139
+ """A human-readable report of what was found, in plain ASCII."""
140
+ head = f"semantic-dedup: {self.n_texts} text{'' if self.n_texts == 1 else 's'}"
141
+ if not self.groups:
142
+ lines = [
143
+ f"{head}, no duplicates found "
144
+ f"(method {self.method}, threshold {self.threshold:g})"
145
+ ]
146
+ for note in self.warnings:
147
+ lines.append(f" note: {note}")
148
+ return "\n".join(lines)
149
+
150
+ if self.dropped:
151
+ head += (
152
+ f", {self.n_groups} duplicate group{'' if self.n_groups == 1 else 's'}, "
153
+ f"{self.n_removed} removed, {self.reduction * 100:.1f}% smaller"
154
+ )
155
+ else:
156
+ head += (
157
+ f", {self.n_groups} duplicate group{'' if self.n_groups == 1 else 's'}, "
158
+ f"{self.n_duplicates} repeat{'' if self.n_duplicates == 1 else 's'} "
159
+ "found, nothing removed"
160
+ )
161
+ lines = [head, f" method {self.method}, threshold {self.threshold:g}, keep {self.keep}"]
162
+ for note in self.warnings:
163
+ lines.append(f" note: {note}")
164
+
165
+ removed = set(self.removed)
166
+ for number, group in enumerate(self.groups[:max_groups], start=1):
167
+ span = self.group_range(group)
168
+ scale = "" if span is None else f", similarity {span[0]:.2f} to {span[1]:.2f}"
169
+ lines.append(f" group {number} ({len(group)} texts{scale})")
170
+ for index in group:
171
+ label = "removed" if index in removed else "kept "
172
+ if not self.dropped and index != group[0]:
173
+ label = "repeat "
174
+ text = self.all_texts[index] if index < len(self.all_texts) else ""
175
+ lines.append(f' {label} [{index}] "{_shorten(text)}"')
176
+ if self.n_groups > max_groups:
177
+ lines.append(f" ... and {self.n_groups - max_groups} more groups")
178
+ return "\n".join(lines)
179
+
180
+ def to_dict(self) -> Dict[str, Any]:
181
+ """A JSON-safe dict of the result as *indices*, never the texts.
182
+
183
+ Every index refers to a position in the input, so join the report back
184
+ to the input you passed in to read it. ``pairs`` holds one entry per
185
+ matching pair and can be long on a large corpus.
186
+ """
187
+ return {
188
+ "n_texts": self.n_texts,
189
+ "n_kept": self.n_kept,
190
+ "n_removed": self.n_removed,
191
+ "n_groups": self.n_groups,
192
+ "n_duplicates": self.n_duplicates,
193
+ "reduction": round(self.reduction, 6),
194
+ "method": self.method,
195
+ "threshold": self.threshold,
196
+ "keep": self.keep,
197
+ "dropped": self.dropped,
198
+ "kept": list(self.kept),
199
+ "removed": list(self.removed),
200
+ "groups": [list(group) for group in self.groups],
201
+ "pairs": [[int(i), int(j), float(s)] for i, j, s in self.pairs],
202
+ "warnings": list(self.warnings),
203
+ }
204
+
205
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
206
+ return (
207
+ f"DedupeResult(n_texts={self.n_texts}, n_groups={self.n_groups}, "
208
+ f"n_removed={self.n_removed}, method={self.method!r}, "
209
+ f"threshold={self.threshold})"
210
+ )
211
+
212
+
213
+ class Deduper:
214
+ """The engine under :func:`dedupe`, with the extra knobs exposed.
215
+
216
+ Args:
217
+ threshold: minimum cosine similarity to call two texts duplicates, in
218
+ ``(0, 1]``. ``1.0`` means exact matches only.
219
+ method: ``"tfidf"``, ``"minhash"``, ``"embed"`` or ``"auto"``.
220
+ keep: which member of a group survives - ``"longest"``, ``"first"``,
221
+ ``"last"`` or ``"most_complete"``.
222
+ embed: ``callable(list[str]) -> ndarray`` of one vector per text.
223
+ word_ngram / char_ngram: inclusive n-gram ranges for the TF-IDF features.
224
+ num_perm / random_state: MinHash size and seed (results are deterministic).
225
+ column: which csv column or json key to read when given a file path.
226
+ """
227
+
228
+ def __init__(
229
+ self,
230
+ threshold: float = 0.82,
231
+ method: str = "auto",
232
+ keep: str = "longest",
233
+ embed: Optional[EmbedFn] = None,
234
+ word_ngram: Tuple[int, int] = (1, 2),
235
+ char_ngram: Tuple[int, int] = (3, 4),
236
+ num_perm: int = 128,
237
+ random_state: int = 0,
238
+ column: Optional[str] = None,
239
+ ) -> None:
240
+ if method not in METHODS:
241
+ raise ValueError(f"method must be one of {METHODS}, got {method!r}")
242
+ if keep not in KEEP_MODES:
243
+ raise ValueError(f"keep must be one of {KEEP_MODES}, got {keep!r}")
244
+ threshold = float(threshold)
245
+ if not (0.0 < threshold <= 1.0):
246
+ raise ValueError(f"threshold must be in (0, 1], got {threshold}")
247
+ if method == "embed" and embed is None:
248
+ raise ValueError("method='embed' needs embed=callable(list[str]) -> ndarray")
249
+ if embed is not None and not callable(embed):
250
+ raise TypeError("embed must be a callable taking list[str] and returning vectors")
251
+ if num_perm < 8:
252
+ raise ValueError(f"num_perm must be at least 8, got {num_perm}")
253
+ self.threshold = threshold
254
+ self.method = method
255
+ self.keep = keep
256
+ self.embed = embed
257
+ self.word_ngram = _check_ngram("word_ngram", word_ngram)
258
+ self.char_ngram = _check_ngram("char_ngram", char_ngram)
259
+ self.num_perm = num_perm
260
+ self.random_state = random_state
261
+ self.column = column
262
+
263
+ # -- internals ---------------------------------------------------------
264
+ def _resolve_method(self, n_unique: int) -> str:
265
+ if self.method != "auto":
266
+ return self.method
267
+ if self.embed is not None:
268
+ return "embed"
269
+ return "minhash" if n_unique > AUTO_MINHASH_ABOVE else "tfidf"
270
+
271
+ def _unique_pairs(
272
+ self, unique_texts: List[str], method: str, warnings: List[str]
273
+ ) -> List[Tuple[int, int, float]]:
274
+ """Similar pairs among the distinct texts, as exact cosine similarities."""
275
+ if len(unique_texts) < 2 or self.threshold >= 1.0:
276
+ return []
277
+
278
+ if method == "embed":
279
+ assert self.embed is not None # guarded in __init__/dedupe
280
+ vectors = np.asarray(self.embed(list(unique_texts)))
281
+ if vectors.ndim != 2 or vectors.shape[0] != len(unique_texts):
282
+ raise ValueError(
283
+ "embed must return one vector per text: expected shape "
284
+ f"({len(unique_texts)}, d), got {tuple(vectors.shape)}"
285
+ )
286
+ return _vectors.dense_pairs_above(
287
+ _vectors.normalize_rows(vectors), self.threshold
288
+ )
289
+
290
+ matrix = _vectors.build_tfidf(unique_texts, self.word_ngram, self.char_ngram)
291
+ if matrix.indices.size == 0:
292
+ # Nothing was vectorized at all: every text is featureless (symbols
293
+ # only) or the n-gram ranges select nothing. No pair can match, so
294
+ # say so rather than report a confident "no duplicates found".
295
+ warnings.append(
296
+ "no comparable features were produced for any text "
297
+ f"(word_ngram={tuple(self.word_ngram)}, "
298
+ f"char_ngram={tuple(self.char_ngram)}); no pair can match"
299
+ )
300
+ return []
301
+ if method == "tfidf":
302
+ return _vectors.pairs_above(matrix, self.threshold)
303
+
304
+ signatures = _minhash.minhash_signatures(matrix, self.num_perm, self.random_state)
305
+ candidates, truncated = _minhash.candidate_pairs(signatures, self.threshold)
306
+ if truncated:
307
+ warnings.append(
308
+ "minhash candidate cap reached; some far-apart pairs may be missing "
309
+ "(use method='tfidf' for an exhaustive scan)"
310
+ )
311
+ if not candidates and len(unique_texts) <= 2000:
312
+ found = _vectors.pairs_above(matrix, self.threshold)
313
+ if found:
314
+ warnings.append(
315
+ "minhash proposed no candidates; fell back to an exact scan"
316
+ )
317
+ return found
318
+ return _vectors.candidate_similarities(matrix, candidates, self.threshold)
319
+
320
+ def _choose_keeper(self, group: Sequence[int], texts: Sequence[str]) -> int:
321
+ if self.keep == "first":
322
+ return group[0]
323
+ if self.keep == "last":
324
+ return group[-1]
325
+ if self.keep == "longest":
326
+ return max(group, key=lambda i: (len(texts[i]), -i))
327
+ token_sets: Dict[int, Set[str]] = {i: set(canonical_tokens(texts[i])) for i in group}
328
+
329
+ def coverage(i: int) -> float:
330
+ others = [token_sets[o] for o in group if o != i]
331
+ scores = [
332
+ len(token_sets[i] & other) / len(other) for other in others if other
333
+ ]
334
+ return sum(scores) / len(scores) if scores else 0.0
335
+
336
+ return max(group, key=lambda i: (coverage(i), len(texts[i]), -i))
337
+
338
+ # -- entry point -------------------------------------------------------
339
+ def run(self, texts: Any, drop: bool = True) -> DedupeResult:
340
+ """Analyse ``texts``; ``drop=False`` reports without removing anything."""
341
+ items = load_texts(texts, self.column)
342
+ n = len(items)
343
+ warnings: List[str] = []
344
+
345
+ if n == 0:
346
+ return DedupeResult(
347
+ kept=[], removed=[], groups=[], pairs=[], texts=[],
348
+ threshold=self.threshold, method=self._resolve_method(0),
349
+ keep=self.keep, n_texts=0, dropped=drop, warnings=warnings, all_texts=[],
350
+ )
351
+
352
+ # Collapse exact (post-normalization) duplicates first: it keeps the
353
+ # similarity search small and makes threshold=1.0 meaningful.
354
+ normalized = [normalize(text) for text in items]
355
+ first_seen: Dict[str, int] = {}
356
+ exact_pairs: List[Tuple[int, int, float]] = []
357
+ unique_positions: List[int] = []
358
+ for index, key in enumerate(normalized):
359
+ owner = first_seen.get(key)
360
+ if owner is None:
361
+ first_seen[key] = index
362
+ unique_positions.append(index)
363
+ else:
364
+ exact_pairs.append((owner, index, 1.0))
365
+
366
+ unique_texts = [items[i] for i in unique_positions]
367
+ method = self._resolve_method(len(unique_texts))
368
+ if method == "embed" and self.embed is None:
369
+ raise ValueError("method='embed' needs embed=callable(list[str]) -> ndarray")
370
+
371
+ near = self._unique_pairs(unique_texts, method, warnings)
372
+ pairs = exact_pairs + [
373
+ (unique_positions[u], unique_positions[v], round(float(s), 6))
374
+ for u, v, s in near
375
+ ]
376
+ pairs = [(i, j, s) if i < j else (j, i, s) for i, j, s in pairs]
377
+ pairs.sort(key=lambda p: (p[0], p[1]))
378
+
379
+ union = _Union(n)
380
+ for i, j, _ in pairs:
381
+ union.union(i, j)
382
+ buckets: Dict[int, List[int]] = {}
383
+ for index in range(n):
384
+ buckets.setdefault(union.find(index), []).append(index)
385
+ groups = sorted(
386
+ (sorted(members) for members in buckets.values() if len(members) > 1),
387
+ key=lambda group: group[0],
388
+ )
389
+
390
+ removed: List[int] = []
391
+ if drop:
392
+ for group in groups:
393
+ keeper = self._choose_keeper(group, items)
394
+ removed.extend(index for index in group if index != keeper)
395
+ removed.sort()
396
+ removed_set = set(removed)
397
+ kept = [index for index in range(n) if index not in removed_set]
398
+
399
+ return DedupeResult(
400
+ kept=kept,
401
+ removed=removed,
402
+ groups=groups,
403
+ pairs=pairs,
404
+ texts=[items[index] for index in kept],
405
+ threshold=self.threshold,
406
+ method=method,
407
+ keep=self.keep,
408
+ n_texts=n,
409
+ dropped=drop,
410
+ warnings=warnings,
411
+ all_texts=items,
412
+ )
413
+
414
+
415
+ def dedupe(
416
+ texts: Any,
417
+ *,
418
+ threshold: float = 0.82,
419
+ method: str = "auto",
420
+ keep: str = "longest",
421
+ embed: Optional[EmbedFn] = None,
422
+ **kwargs: Any,
423
+ ) -> DedupeResult:
424
+ """Drop passages that repeat the meaning of an earlier one.
425
+
426
+ Args:
427
+ texts: a list of strings, or a path to a ``.txt`` (one per line),
428
+ ``.csv`` or ``.jsonl`` file.
429
+ threshold: minimum similarity to treat two texts as duplicates.
430
+ ``1.0`` keeps only exact matches.
431
+ method: ``"tfidf"`` (default engine), ``"minhash"`` (fast candidate
432
+ generation for big inputs), ``"embed"`` (use your vectors) or
433
+ ``"auto"`` - embed if given, else minhash above 1000 texts.
434
+ keep: which member of a duplicate group survives.
435
+ embed: ``callable(list[str]) -> ndarray``, one row per text.
436
+
437
+ Returns:
438
+ A :class:`DedupeResult`; ``result.texts`` are the survivors.
439
+ """
440
+ return Deduper(
441
+ threshold=threshold, method=method, keep=keep, embed=embed, **kwargs
442
+ ).run(texts, drop=True)
443
+
444
+
445
+ def find_duplicates(texts: Any, **kwargs: Any) -> DedupeResult:
446
+ """Same analysis as :func:`dedupe`, but nothing is removed.
447
+
448
+ ``result.groups`` and ``result.pairs`` are filled in exactly as they would
449
+ be, while ``result.kept`` stays the whole input.
450
+ """
451
+ threshold = kwargs.pop("threshold", 0.82)
452
+ method = kwargs.pop("method", "auto")
453
+ keep = kwargs.pop("keep", "longest")
454
+ embed = kwargs.pop("embed", None)
455
+ return Deduper(
456
+ threshold=threshold, method=method, keep=keep, embed=embed, **kwargs
457
+ ).run(texts, drop=False)
458
+
459
+
460
+ def similarity(a: str, b: str, *, method: str = "tfidf") -> float:
461
+ """How alike two texts are, from 0.0 to 1.0.
462
+
463
+ ``method="tfidf"`` (the default) is the cosine used by :func:`dedupe`;
464
+ ``method="minhash"`` is the exact Jaccard of the same feature sets, which
465
+ is what the MinHash stage estimates.
466
+
467
+ Note that the TF-IDF weighting is relative to the texts being compared, so
468
+ this scores the pair on its own while :func:`dedupe` scores it against the
469
+ whole corpus. The two are close but not identical, and a threshold tuned
470
+ here will not transfer exactly - calibrate on the corpus you will run.
471
+ """
472
+ if not isinstance(a, str) or not isinstance(b, str):
473
+ raise TypeError("similarity() takes two strings")
474
+ if method == "auto":
475
+ method = "tfidf"
476
+ if method == "embed":
477
+ raise ValueError(
478
+ "similarity() has no embed hook; call your embedder and compare the "
479
+ "two vectors, or use dedupe(..., method='embed', embed=...)"
480
+ )
481
+ if method not in ("tfidf", "minhash"):
482
+ raise ValueError(f"method must be 'tfidf' or 'minhash', got {method!r}")
483
+ if normalize(a) == normalize(b):
484
+ return 1.0
485
+ matrix = _vectors.build_tfidf([a, b])
486
+ if method == "minhash":
487
+ return round(_minhash.jaccard(matrix, 0, 1), 6)
488
+ left_idx, left_val = matrix.row(0)
489
+ right_idx, right_val = matrix.row(1)
490
+ if left_idx.size == 0 or right_idx.size == 0:
491
+ return 0.0
492
+ buffer = np.zeros(matrix.n_features, dtype=np.float64)
493
+ buffer[left_idx] = left_val
494
+ score = float((buffer[right_idx] * right_val).sum())
495
+ return round(min(1.0, max(0.0, score)), 6)