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.
- semantic_dedup/__init__.py +36 -0
- semantic_dedup/__main__.py +7 -0
- semantic_dedup/_core.py +495 -0
- semantic_dedup/_io.py +180 -0
- semantic_dedup/_lexicon.py +199 -0
- semantic_dedup/_minhash.py +147 -0
- semantic_dedup/_text.py +193 -0
- semantic_dedup/_vectors.py +406 -0
- semantic_dedup/cli.py +114 -0
- semantic_dedup-0.1.0.dist-info/METADATA +170 -0
- semantic_dedup-0.1.0.dist-info/RECORD +14 -0
- semantic_dedup-0.1.0.dist-info/WHEEL +4 -0
- semantic_dedup-0.1.0.dist-info/entry_points.txt +2 -0
- semantic_dedup-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
semantic_dedup/_core.py
ADDED
|
@@ -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)
|