Skip to content

Near-duplicate dedup: MinHash, LSH, union-find, and decontamination

Moduledata.04 · build · Python · Pass 3 · 4 to 5 h
You buildpython/corpus/minhash.py: words, shingles, jaccard, shingle_hashes, perm_params, minhash, estimate, LSH, UnionFind, signatures (process-parallel), clusters, near_dedup (a stage), protected_ngrams, contaminated, decontaminate (a stage)
Contractcourse/contracts/py/corpus/minhash.pyi · the shard column it feeds: formats/corpus-shard.md
Testscourse/tests/data.04/ (what they check: section 4); fixtures course/fixtures/data.04/ (planted near-duplicate clusters, borderline pairs, protected eval sets)
Needsdata.02 Doc · data.03 exact dedup runs first · M06.3 PCG32 and FNV-1a (or --ref-deps) · reading: S-M06b (Jaccard and the S-curve by hand)
Used bydata.05 scrubs what survives and keeps its cluster tag · data.06 turns the tag into the minhash_cluster column · later: data.09 runs this stage inside CorpusBuild
MilestoneMS-corpus
Optional depthBroder, On the Resemblance and Containment of Documents (1997); Leskovec, Rajaraman, and Ullman, Mining of Massive Datasets, chapter 3 (free); Lee et al., Deduplicating Training Data Makes Language Models Better (free); Brown et al., Language Models are Few-Shot Learners, appendix C (13-gram contamination, free)
  • The share of equal entries in two MinHash signatures is an unbiased estimate of the Jaccard similarity of their shingle sets (test_estimator_is_unbiased).
  • Banding the signature into bb bands of rr rows turns that estimate into an S-curve: pairs above about (1/b)1/r(1/b)^{1/r} become candidates, pairs below rarely do, and no pair is compared to all the others (test_lsh_s_curve).
  • Candidates are confirmed by their estimate and merged by union-find, whose root is the smallest id, so the clusters and the survivor never depend on input order or worker count (test_invariant_to_input_order, test_invariant_to_worker_count).
  • Decontamination drops every document that shares a word 13-gram with a protected eval or validation text; that is what makes your benchmark numbers mean something (test_decontaminate_fixture).
Terminal window
ol start data.04 # stubs minhash.py into python/corpus/
ol tests data.04 # the course test catalog
ol check data.04 # exit code is the verdict
ol check data.04 --ref-deps # only if data.02, data.03, or M06.3 is not passing yet
ol tdd red data.04 # rung R4: write your property tests first (section 4)
ol mutate data.04 # how many planted bugs your tests catch
ol diff data.04 # after passing: your code against the reference

After data.03 your corpus has no exact copies left, but it is still full of almost-copies: the same story reposted with one word changed, a page scraped twice with a different footer, a template filled with other names. Exact hashes see two different documents. A model trained on them memorizes the repeated text and spends its capacity on it, and, worse, some of those almost-copies are your evaluation stories: train on them and your validation loss in C1 measures recall, not learning. Today nothing in python/corpus/ can tell that two texts are 95% the same without comparing every pair, which at a million documents is half a trillion comparisons. This module finds near duplicates in roughly linear time and removes anything that overlaps your protected eval and validation sets.

SymbolMeaningType / shape
kkshingle length in words (default 5)int
S(d)S(d)the set of word kk-shingles of document ddset[str]
J(A,B)=∣A∩B∣/∣A∪B∣J(A, B) = \lvert A \cap B \rvert / \lvert A \cup B \rvertJaccard similarity of two sets, in [0,1][0, 1]float
P=231−1P = 2^{31} - 1a prime (a Mersenne prime)int
x(s)=fnv1a64(s) mod Px(s) = \mathrm{fnv1a64}(s) \bmod Pthe integer a shingle hashes toint in [0,P)[0, P)
hi(x)=(aix+bi) mod Ph_i(x) = (a_i x + b_i) \bmod Pthe ii-th hash function, 1≤ai<P1 \le a_i < P, 0≤bi<P0 \le b_i < Pint
nnnumber of hash functions (num_perm, default 128)int
sigi(A)=min⁡s∈Ahi(x(s))\mathrm{sig}_i(A) = \min_{s \in A} h_i(x(s))entry ii of the MinHash signatureuint64[n]
J^=1n∑i[sigi(A)=sigi(B)]\hat J = \frac{1}{n}\sum_i [\mathrm{sig}_i(A) = \mathrm{sig}_i(B)]the estimatefloat
b,rb, rbands and rows per band, b⋅r=nb \cdot r = n (16 and 8)int
ssthe true similarity of a pairfloat
p(s)=1−(1−sr)bp(s) = 1 - (1 - s^r)^bprobability that a pair at similarity ss becomes a candidatefloat
ttthe confirmation threshold on J^\hat J (0.8)float

Shingles. Lower-case the text and split it into word runs (re.findall(r"\w+", text.lower()), so punctuation and case never matter). A kk-shingle is kk consecutive words joined by one space; “the cat sat on the mat” has the 3-shingles “the cat sat”, “cat sat on”, “sat on the”, “on the mat”. Shingles turn “how similar are these texts?” into “how much do these sets overlap?”. A text shorter than kk words is one shingle (all its words), so a three-word line can still match its copy; a text with no words has no shingles and is nobody’s near duplicate.

Jaccard similarity. J(A,B)J(A, B) is the size of the overlap divided by the size of the union: 1 for equal sets, 0 for disjoint ones. One changed word in a 150-word story changes at most kk shingles, so JJ stays above 0.9; two unrelated stories share almost none.

MinHash. Pick a random hash function hh and look at the shingle of A∪BA \cup B with the smallest hash. It is equally likely to be any element of the union, and the minimum of AA equals the minimum of BB exactly when that element is in both. So

Pr⁡[min⁡Ah=min⁡Bh]=∣A∩B∣∣A∪B∣=J(A,B).\Pr[\min_{A} h = \min_{B} h] = \frac{\lvert A \cap B \rvert}{\lvert A \cup B \rvert} = J(A, B).

With nn independent hash functions, each equal entry is a coin that lands heads with probability JJ, and J^\hat J is the fraction of heads: an unbiased estimate with standard deviation J(1−J)/n\sqrt{J(1-J)/n}, about 0.035 at J=0.8J = 0.8 and n=128n = 128. The signature has nn numbers whatever the document’s length.

The hash family, made exact. A random permutation of all shingles is too expensive, so each hih_i is a Carter-Wegman universal hash (aix+bi) mod P(a_i x + b_i) \bmod P over x(s)x(s), the FNV-1a hash of the shingle’s UTF-8 bytes reduced mod PP. The parameters come from your PCG32 (M06.3): rng = PCG32(seed), then for i=0,1,…i = 0, 1, \dots draw $a_i = 1 + $ rng.below(P - 1) and $b_i = $ rng.below(P), interleaved. ai≠0a_i \ne 0, because a=0a = 0 maps every shingle to bb and that entry always agrees. Because ai,x<231a_i, x < 2^{31}, the product aix+bi<263a_i x + b_i < 2^{63} fits in uint64, so numpy computes every entry exactly and every implementation (Python, the Go port in data.09, yours) produces the same signature for the same seed. FNV-1a runs byte by byte, but every shingle runs the same steps, so you can loop over byte positions with all shingles at once: numpy’s uint64 multiply wraps modulo 2642^{64}, which is exactly FNV’s arithmetic.

LSH banding. Comparing every pair of signatures is still quadratic. Cut each signature into bb bands of rr consecutive rows and hash each band, with its band index, into a bucket: two documents become candidates when they agree on all rr rows of at least one band. A band agrees with probability srs^r, so it disagrees with probability 1−sr1 - s^r, all bb bands disagree with probability (1−sr)b(1 - s^r)^b, and

p(s)=1−(1−sr)b.p(s) = 1 - (1 - s^r)^b.

That is an S-curve: near 0 for small ss, near 1 for large ss, steepest near (1/b)1/r(1/b)^{1/r}. With b=16,r=8b = 16, r = 8 the steep part is around 0.7, below the 0.8 we want, so true near duplicates almost never slip through and the false candidates are filtered next.

Confirm, then union-find. A candidate pair is kept when J^≥t\hat J \ge t. Near-duplication is not transitive (x∼yx \sim y and y∼zy \sim z do not imply x∼zx \sim z), but a cluster should hold the whole chain, so confirmed pairs are merged with a union-find (disjoint-set forest): find(x) follows parent pointers to the root, union(x, y) points one root at the other. Choose the root as the smallest id (string order) and the result is the same whatever order the pairs arrive in. near_dedup tags every document with its root (meta["minhash_cluster"]) and, with drop=True, keeps only the roots.

Process-parallel, worker-invariant. Signatures are independent per document, so signatures(..., workers=N) splits the texts into NN contiguous chunks and computes them on a ProcessPoolExecutor. Every chunk uses the same (num_perm, seed) and the chunks come back in order, so the array is identical for every NN. Use the spawn start method (the same on every OS), and remember that spawn re-imports your __main__: the CLI’s entry needs if __name__ == "__main__":.

Decontamination. A protected text is any eval or validation example you will report a number on. Its word nn-grams (n=13n = 13, the GPT-3 rule) form a set; a training document is contaminated when any of its word 13-grams is in that set. Thirteen words are long enough that a shared run is almost never a coincidence and short enough to catch a copied paragraph with edits around it. In .jsonl protected files every string value counts except the keys case_id, id, tags, and scorer_args (they are labels, not text the model is tested on). A protected text shorter than 13 words contributes no 13-gram: if your eval items are that short, lower nn for that suite.

Shingles and Jaccard. AA = “the cat sat on the mat today”, BB = “the cat sat on the mat again”, k=3k = 3:

3-shingles
AAthe cat sat · cat sat on · sat on the · on the mat · the mat today
BBthe cat sat · cat sat on · sat on the · on the mat · the mat again

Four shared, six distinct in total: J=4/6=0.667J = 4/6 = 0.667.

MinHash with three toy hash functions. Give the six distinct shingles ids 11 to 66 in the order above, with “the mat today” =5= 5 and “the mat again” =6= 6, so A={1,2,3,4,5}A = \{1,2,3,4,5\} and B={1,2,3,4,6}B = \{1,2,3,4,6\}. Use P=7P = 7:

xx123456min over AAmin over BBequal?
h1=(2x+1) mod 7h_1 = (2x + 1) \bmod 735024600yes
h2=(3x+2) mod 7h_2 = (3x + 2) \bmod 751403600yes
h3=(5x+3) mod 7h_3 = (5x + 3) \bmod 716420501no

J^=2/3\hat J = 2/3. The third function’s minimum over AA landed on shingle 5, which BB lacks, so the entries differ. (Three functions give a noisy estimate; 128 give a standard deviation near 0.04.)

The S-curve. b=16b = 16, r=8r = 8:

  • threshold (1/16)1/8=2−4/8=2−1/2=0.7071(1/16)^{1/8} = 2^{-4/8} = 2^{-1/2} = 0.7071;
  • at s=0.8s = 0.8: 0.88=0.167770.8^8 = 0.16777, 1−0.16777=0.832231 - 0.16777 = 0.83223, 0.8322316=0.052950.83223^{16} = 0.05295, so p=0.9470p = 0.9470;
  • at s=0.5s = 0.5: 0.58=0.00390630.5^8 = 0.0039063, 0.996093816=0.939300.9960938^{16} = 0.93930, so p=0.0607p = 0.0607.

A pair at 0.8 is a candidate 95 times in 100; a pair at 0.5 about 6 times in 100, and then its estimate (near 0.5) fails the 0.8 check.

Union-find on a chain. Confirmed pairs (y,z)(y, z) then (x,y)(x, y). union(y, z): roots yy, zz, the smaller is yy, so parent[z] = y. union(x, y): roots xx and yy, the smaller is xx, so parent[y] = x. Now find(z) follows z→y→xz \to y \to x: one cluster {x,y,z}\{x, y, z\} with root xx, and near_dedup keeps only xx.

Decontamination. A protected story contains “Tom found a shiny red shell by the river and carried it home to show his sister.” A training document says ”… and then TOM FOUND A SHINY, RED shell by the river and carried it home to show …” Its words lower-cased are the same run, so the 13-gram “tom found a shiny red shell by the river and carried it home” is shared and the document is dropped. Had it copied only 12 of those words, it would stay.

These numbers are the first test, test_hand_example; the toy MinHash is the idea behind test_signature_is_the_spec, which checks the real formula with P=231−1P = 2^{31} - 1, and the chain is test_chain_is_one_cluster.

# python/corpus/minhash.py (the full contract is contracts/py/corpus/minhash.pyi)
MERSENNE31: int # 2**31 - 1
def words(text: str) -> list[str]
def shingles(text: str, k: int = 5) -> set[str]
def jaccard(a: AbstractSet[str], b: AbstractSet[str]) -> float
def shingle_hashes(shingles: Iterable[str]) -> NDArray # uint64 [n]
def perm_params(num_perm: int, seed: int) -> tuple[NDArray, NDArray]
def minhash(shingles: Iterable[str], num_perm: int = 128, seed: int = 0) -> NDArray
def estimate(sig_a: NDArray, sig_b: NDArray) -> float
class LSH: # __init__(bands=16, rows=8), threshold, probability(s), insert(key, sig), candidates()
class UnionFind: # find(x), union(x, y), groups()
def signatures(texts, *, num_perm=128, seed=0, k=5, workers=1) -> NDArray # [len(texts), num_perm]
def clusters(docs: Mapping[str, str], *, num_perm=128, bands=16, threshold=0.8, k=5, seed=0, workers=1) -> list[set[str]]
def near_dedup(docs: Iterable[Doc], *, ..., drop: bool = True) -> Iterator[Doc]
def protected_ngrams(paths: Sequence[Path], n: int = 13) -> set[str]
def contaminated(text: str, grams: AbstractSet[str], n: int = 13) -> bool
def decontaminate(docs: Iterable[Doc], protected: Sequence[Path], n: int = 13) -> Iterator[Doc]

near_dedup must see every document before it can cluster, so it reads its whole input (unlike the streaming stages of data.02); decontaminate streams. Both are stages: compose them after exact_dedup.

TestKINDChecksWhy it matters downstream
test_hand_exampleunitthe section 3 shingles, J=4/6J = 4/6, threshold 0.7071, p(0.8)=0.9470p(0.8) = 0.9470, p(0.5)=0.0607p(0.5) = 0.0607you and the tests agree on the definitions
test_signature_is_the_specunitsignatures recomputed from FNV-1a and interleaved PCG32 drawsevery run, resumed or ported, clusters alike
test_words_and_short_textsboundarycase and punctuation ignored; fewer than kk words is one shingleshort documents still match their copies
test_empty_texts_are_nobodys_duplicateboundarywordless texts: all-PP signature, J=0J = 0, no clusterempty pages do not collapse into one survivor
test_estimator_is_unbiasedstatisticalmean of J^\hat J over 40 fresh pairs at J=1/3J = 1/3 within ∣z∣<3.29\lvert z \rvert < 3.29the estimate means what it says
test_lsh_s_curvestatisticalcandidate rates at 13 similarities fit p(s)p(s) (chi-square), 50% point within 0.05 of (1/b)1/r(1/b)^{1/r}the threshold you configure is the one you get
test_bands_do_not_collide_with_each_otherboundaryequal rows in different bands are not a candidatefalse candidates cost time and, unconfirmed, data
test_candidates_are_sorted_unique_pairsunitone pair per key pair, smaller first, sorted; bad inserts raisedeterministic union order
test_union_find_root_is_the_smallest_memberpropertyevery permutation of the unions gives the same groups and rootsthe survivor is stable
test_fixture_clusters_match_plantedgoldenplanted clusters found exactly for 3 seeds; borderline pairs stay apartthe confirm step does its job
test_chain_is_one_clusterunitx∼y∼zx \sim y \sim z, x≁zx \not\sim z: one cluster, keep xxclusters are transitive
test_invariant_to_worker_countproperty1 and 3 workers give identical signatures and clustersMS-corpus compares hashes across worker counts
test_invariant_to_input_orderpropertyforward and reversed input keep the same documentsstages upstream may reorder
test_near_dedup_tags_keeps_order_and_metaunitminhash_cluster tags, drop flag, order and meta kept, repeated ids raisedata.06 builds its column from the tag
test_decontaminate_fixturegolden13-word spans anywhere (upper-cased, punctuated) contaminate; 12 words, ids, and tags do notbenchmark numbers stay honest
test_span_at_the_very_endboundarythe last window of a document is checkedan off-by-one hides the commonest copy
test_protected_filesboundaryJSON Lines string values minus id-like keys; plain files whole; bad lines raisethe right text is protected
test_decontaminate_streamspropertyreads 5 of an endless stream without running aheadthe stage runs on any corpus size
test_after_exact_dedupregressionafter data.03, near dedup drops exactly the planted near duplicatesthe two dedup counts add up in the manifest

Your tests (rung R4, properties). Write them under python/tests/data-04-minhash/ before the code (ol tdd red data.04): the hand example; the signature formula; for any 1 to k−1k-1 words, one shingle; wordless texts never cluster; the estimate is unbiased over fresh pairs; equal values in different bands, or in only r−1r - 1 rows of a band, are no candidate; union-find roots are the minimum of each group for any union sequence (Hypothesis); a chain is one cluster; candidates below the threshold are not merged; workers do not change signatures; tags, order, and meta survive near_dedup; a protected 13-gram anywhere (any case, any punctuation) contaminates and 12 words do not; id-like keys are not protected; decontaminate streams. ol mutate data.04 grades them: 0.80 of the mutants, and every Pitfall below.

PitfallSymptomCaught by
1. returning no shingles for a text shorter than kkshort documents never match their copiestest_words_and_short_texts (mutant s07)
2. indexing empty signaturesevery wordless document lands in one bucket and all but one are droppedtest_empty_texts_are_nobodys_duplicate (mutant s08)
3. a bucket key without the band indexequal rows in different bands make false candidatestest_bands_do_not_collide_with_each_other (mutant s01)
4. union by size, or by argument orderthe kept document depends on which pair came firsttest_union_find_root_is_the_smallest_member (mutant s09)
5. “drop it if it resembles a kept document” instead of union-finda chain keeps both endstest_chain_is_one_cluster (mutant s12)
6. a seed per worker chunk, or chunks collected as they finishthe output hash changes with --workerstest_invariant_to_worker_count (mutants s13, s14)
7. range(len(w) - n) instead of range(len(w) - n + 1)a document ending with the protected span passestest_span_at_the_very_end (mutant s18)
8. comparing signature values as sets instead of position by positionbiased estimates, missed clusterstest_estimator_is_unbiased (mutant s11)
9. Python’s hash() for shinglessalted per process: signatures change between runs and workerstest_signature_is_the_spec (mutant s04)
10. merging every LSH candidate without the estimate checkborderline pairs (0.5 to 0.6) merge a quarter of the timetest_fixture_clusters_match_planted (mutant s10)
11. substring matching on raw text for contaminationpunctuation and case changes hide copiestest_decontaminate_fixture (mutant s17)
12. treating case_id, tags, or scorer_args as protected textlabels, not eval text, start dropping documentstest_protected_files (mutant s19)
13. list(docs) in decontaminatethe stage holds the whole corpus in memorytest_decontaminate_streams (mutant s20)
14. no if __name__ == "__main__": in your CLIwith --workers 4, each spawned worker re-runs the CLI and the pool dies with “bootstrapping phase”the MS-corpus step corpus-run-w4 (your entry point, not a unit)
DirectionModuleHow it uses this
Backdata.02Doc and the Stage shape these two stages share
Backdata.03exact dedup runs first, so near dedup counts only near copies
BackM06.3PCG32.below draws the hash parameters; FNV-1a hashes the shingles
BackS-M06bJaccard, the S-curve, and the threshold worked on paper
Forwarddata.05the PII scrub runs on the survivors and keeps minhash_cluster
Forwarddata.06minhash_cluster becomes the smallest row index of each cluster; near_dropped and decontaminated go into the manifest
Forwarddata.09CorpusBuild runs this stage as the dedup_near activity, with an output hash that must not depend on the worker count

If you skip this module, ol check data.05 stops with data.05 needs data.04: build it, or rerun with --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
signatures + LSHdatatrove MinhashDedupSignature, MinhashDedupBucketssignatures written to disk per shard, buckets sorted and merged across machines, 14 bands of 8 in FineWebdatatrove src/datatrove/pipeline/dedup/minhash.py
UnionFinddatatrove MinhashDedupClusterunion-find over billions of pairs streamed from bucket filessame file
clusters confirm stepDolma’s dedup, text-dedupsuffix arrays for exact substring dedup, Bloom filters for paragraph dedupallenai/dolma, ChenghaoMou/text-dedup
decontaminateGPT-3 and Llama decontamination13-gram overlap with per-benchmark reports; Llama 2’s token-level contamination scoresBrown et al. 2020 appendix C; Touvron et al. 2023 appendix A.6