Part IV: Parallel Deep Learning and Large Models
Chapter 19: Training Foundation Models at Scale

Distributed Deduplication and Data Quality

"I read the same cookie banner four hundred million times. I can recite it in my sleep, and now, apparently, so can the model."

A Foundation Model Memorizing Boilerplate
Big Picture

A raw web corpus is mostly repetition, and training on repetition is a triple waste: it burns compute on text the model has already seen, it teaches the model to memorize verbatim strings instead of generalizing, and it quietly smuggles benchmark answers into the training set. The cure is deduplication: strip exact copies with a hash, strip near-copies with MinHash and locality-sensitive hashing, and decontaminate the corpus against every benchmark you intend to evaluate on. None of this fits on one machine. A trillion-token crawl has billions of documents, and the naive "compare every pair" check is quadratic and hopeless. This section turns the MinHash and LSH machinery of Chapter 6 into a distributed data-cleaning job that runs once, before the first training step, and pays for itself many times over.

The previous section built a corpus: it pulled documents from many shards of a web crawl, filtered the obvious garbage, and laid the result out for distributed loading. What it did not do is ask whether those documents are distinct. They are not. Real web crawls are saturated with duplication: the same news article syndicated across a hundred outlets, the same documentation page mirrored on a dozen hosts, the same license text and cookie banner and navigation menu stamped onto millions of unrelated pages. Studies of large open corpora routinely find that a substantial fraction of documents are exact or near-exact copies of other documents. If you train on the corpus as-is, the model spends a measurable share of its compute budget re-reading text it has already mastered, and the most-duplicated strings are exactly the ones it learns to reproduce word for word.

Deduplication is therefore not a cosmetic cleanup; it is a data-quality intervention that changes what the model learns. Removing duplicates reduces verbatim memorization (a privacy and a quality problem), it frees compute for genuinely novel text, and it removes a large class of accidental train-on-test leakage. The challenge is scale. We must find duplicates among billions of documents without ever materializing the quadratic set of all document pairs, and we must do it across a cluster because no single machine holds the corpus. The rest of this section shows how, then closes with the related and equally important job of decontaminating against benchmarks.

1. Why Duplicates Hurt a Trained Model Beginner

The first instinct is that a few repeated documents cannot matter much against a trillion tokens, but the arithmetic says otherwise. Under stochastic gradient descent, every appearance of a document contributes its gradient again, so a string that occurs $c$ times is effectively up-weighted by a factor of $c$ relative to a string that occurs once. A passage duplicated ten thousand times across mirrored pages is not background noise; it is a training signal the model sees ten thousand times more often than a unique sentence, and the model obliges by memorizing it exactly. This is the mechanism behind verbatim regurgitation, where a prompt's opening words trigger the model to reproduce a long copyrighted or private passage it saw many times during training.

Duplication also distorts the loss landscape in a subtler way. Held-out evaluation assumes the test distribution is disjoint from training, but if a near-duplicate of a held-out document sits in the training set, the model's apparent generalization is partly memorization, and your validation loss flatters the model. Deduplicating the corpus before splitting off a validation set is the only way to trust the split. Three distinct harms, then, all flow from the same root: wasted compute proportional to the duplication factor, memorization that scales with repetition count, and contaminated evaluation that hides the first two.

Key Insight: Repetition Is Reweighting, and Reweighting Is Memorization

A document that appears $c$ times in the corpus receives $c$ times the gradient updates of a unique document, exactly as if you had set its sample weight to $c$. Deduplication is the act of resetting every weight back to one. That is why removing duplicates does more than save compute: it changes the effective training distribution from "weighted by how often the web copied this" to "weighted by how often this content genuinely occurs", which is the distribution you actually wanted to learn.

2. Exact Deduplication Is a Hash Away Beginner

Exact duplicates are the easy case and you should remove them first, because they are cheap to find and they are the bulk of the volume. Compute a strong content hash of each document (a 64-bit or 128-bit digest of the normalized text), then group documents by their hash. Every group with more than one member is a set of exact copies; keep one representative and drop the rest. This is a single distributed group-by, the same shuffle-by-key pattern that Chapter 6 built MapReduce around: map each document to its hash, shuffle so identical hashes land on the same reducer, and emit one survivor per key. The cost is linear in the number of documents, and the only communication is the hash plus a document pointer, not the document text itself.

Exact dedup alone is never enough, though, because the web does not produce exact copies, it produces near-copies. A mirrored page adds a different header; a syndicated article gains a publisher's footer; a forum post is quoted with one word changed. The content hashes of these documents differ completely even though a human would call them the same text. Catching them requires a notion of similarity, and a way to find similar documents without comparing every pair. That is the job MinHash and LSH were built for.

3. Near-Deduplication with MinHash and LSH Intermediate

We represent each document as a set: the collection of its word $k$-shingles, the overlapping windows of $k$ consecutive words. Two documents are near-duplicates when their shingle sets overlap heavily, measured by the Jaccard similarity

$$J(A, B) = \frac{|A \cap B|}{|A \cup B|}.$$

Computing $J$ for all pairs is the quadratic trap: a corpus of $n$ documents has $\binom{n}{2} \approx n^2/2$ pairs, which for a billion documents is half a quintillion comparisons. MinHash and LSH dismantle this in two moves. First, MinHash replaces each large shingle set with a small fixed-length signature whose agreement rate estimates $J$ without storing the sets. For a random hash function $h$, the probability that the minimum hash value over set $A$ equals the minimum over set $B$ is exactly the Jaccard similarity,

$$\Pr\bigl[\min_{x \in A} h(x) = \min_{y \in B} h(y)\bigr] = J(A, B),$$

so concatenating the per-hash minima from $m$ independent hashes gives an $m$-dimensional signature whose fraction of matching coordinates is an unbiased estimate of $J$. Second, LSH banding turns those signatures into a candidate generator. Split each signature into $b$ bands of $r$ rows ($m = b \cdot r$), and hash each band into a bucket. Two documents become a candidate pair if they collide in any band. The probability that a pair with similarity $s$ becomes a candidate is

$$\Pr[\text{candidate}] = 1 - (1 - s^{\,r})^{b},$$

an S-shaped curve whose threshold you tune through the choice of $b$ and $r$: more rows per band sharpen the cutoff, more bands lower it. Only candidate pairs are ever compared in full, so the all-pairs cost collapses to roughly the number of true near-duplicate pairs plus a controllable trickle of false candidates. Figure 19.4.1 traces the whole path, from signatures through bands to the duplicate clusters that get collapsed, and on to the benchmark check that follows.

Documents doc A (article) doc A' (mirror) doc B (unique) MinHash signatures [7, 2, 9, 4, 1, 8, ...] [7, 2, 9, 4, 5, 8, ...] [3, 6, 1, 0, 2, 7, ...] LSH bands → buckets band 1: A, A' collide(same bucket) band 1: B alone(own bucket) Candidate pair confirmed est. Jaccard(A, A') = 0.95 ≥ 0.8→ near-duplicate Duplicate cluster collapsed keep A, drop A'one representative per cluster Benchmark decontamination match survivors vs. test setdrop any doc ≥ threshold benchmark / held-out questions clean, deduplicated, decontaminated corpus → tokenization (Section 19.5)
Figure 19.4.1: The distributed dedup-and-decontaminate pipeline. Each document becomes a MinHash signature; LSH bands route similar signatures into shared buckets so only colliding documents (A and its mirror A') are compared in full; confirmed near-duplicates collapse into clusters that keep one representative; finally the survivors are matched against a benchmark set and any contaminated document is dropped before the clean corpus flows on to tokenization in Section 19.5.
Thesis Thread: MinHash and LSH Return, Now at Corpus Scale

The MinHash signatures and LSH banding you met as a similarity-search primitive in Chapter 6 return here as the engine of a trillion-token cleaning job. The structure is identical, only the scale and the purpose changed: in Chapter 6 LSH found similar items to recommend or link; here it finds similar documents to delete. The banding step is a distributed group-by, the very shuffle that Chapter 6 made the heart of MapReduce, so the same partition-and-reduce machinery that powered analytics powers data quality. A primitive built for search, scaled out, becomes a primitive for curation.

4. Running Dedup as a Distributed Job Intermediate

At corpus scale the three stages map cleanly onto a cluster. Signature computation is embarrassingly parallel: each worker reads its shard of documents and emits a signature per document, with no cross-worker communication. The LSH banding is the shuffle: workers emit (band-bucket key, document id) pairs, and the framework partitions by key so every document sharing a bucket lands on the same reducer. The reducer enumerates candidate pairs within each bucket, confirms them with the signature-agreement estimate, and emits the near-duplicate edges. A final connected-components pass over those edges groups documents into duplicate clusters, and one representative per cluster survives. Only signatures and document identifiers cross the network; the document text stays put on the shard that owns it, which keeps the communication volume small even when the corpus is enormous.

The sharding deserves care for two reasons. First, popular boilerplate produces gigantic buckets: a license string shared by ten million documents would dump ten million ids into one reducer and explode its candidate-pair count back toward quadratic. Production pipelines cap bucket sizes or pre-strip the worst boilerplate so no single key becomes a hot shard, the same skew problem that haunts every distributed group-by. Second, the connected-components step is itself a distributed graph computation when the duplicate graph spans shards, linking it to the distributed graph machinery of later parts. The code below implements the full pipeline in one process so you can watch every stage, then we name the libraries that run it across thousands of machines.

import hashlib, random, re

random.seed(7)
NUM_HASHES = 128            # signature length m
B, R = 32, 4               # LSH bands and rows, b*r = m; tunes the S-curve threshold

def shingles(text, k=3):
    """Word k-shingles: the set of overlapping k-word windows in a document."""
    words = re.findall(r"\w+", text.lower())
    if len(words) < k:
        return {" ".join(words)}
    return {" ".join(words[i:i + k]) for i in range(len(words) - k + 1)}

MAXH = (1 << 61) - 1                                        # a large Mersenne prime
coeffs = [(random.randrange(1, MAXH), random.randrange(0, MAXH)) for _ in range(NUM_HASHES)]
stable_hash = lambda s: int(hashlib.blake2b(s.encode(), digest_size=8).hexdigest(), 16)

def minhash_signature(shingle_set):
    base = [stable_hash(s) for s in shingle_set]            # hash each shingle once
    return tuple(min((a * h + b) % MAXH for h in base)      # per-hash minimum
                 for a, b in coeffs)

# --- LSH banding: emit (band, band-slice) keys; documents that share a key collide.
buckets = {}
for doc_id, sig in signatures.items():                     # signatures: doc_id -> tuple
    for band in range(B):
        key = (band, sig[band * R:(band + 1) * R])
        buckets.setdefault(key, []).append(doc_id)

# Only documents sharing a bucket are ever compared; confirm with signature agreement.
candidate_pairs = {tuple(sorted((ids[i], ids[j])))
                   for ids in buckets.values()
                   for i in range(len(ids)) for j in range(i + 1, len(ids))}
Code 19.4.1: The core of the dedup job: MinHash signatures and the LSH banding that generates candidate pairs without ever touching the all-pairs set. The full runnable script wraps this with shingling, candidate confirmation, union-find clustering, and the decontamination pass.

Running the complete pipeline on a small corpus with deliberately injected near-duplicates, exact copies, and one boilerplate-padded mirror produces the output below. The corpus has twelve documents, so the all-pairs check would be sixty-six comparisons; LSH proposes only five candidates, confirms four genuine duplicate pairs, and the token count drops by nearly a third.

=== MinHash + LSH near-dedup ===
documents in corpus      : 12
all-pairs comparisons     : 66 (LSH skipped these)
LSH candidate pairs       : 5
confirmed duplicate pairs : 4
    base-1    ~ mirror-3   est_jaccard=0.67  true_jaccard=0.64
    base-2    ~ exact-1    est_jaccard=1.00  true_jaccard=1.00
    base-3    ~ mirror-2   est_jaccard=0.54  true_jaccard=0.52
    base-5    ~ exact-2    est_jaccard=1.00  true_jaccard=1.00
documents kept            : 8  (base-0, base-4, exact-1, exact-2, mirror-1, mirror-2, mirror-3, unique-1)
documents removed         : 4  (base-1, base-2, base-3, base-5)
tokens before / after     : 185 / 131
token reduction           : 29.2%

=== Benchmark decontamination ===
benchmark items            : 1
training docs scanned      : 9
contaminated docs removed  : 2  (exact-2, leak-1)
    exact-2   overlap with benchmark = 1.00
    leak-1    overlap with benchmark = 0.62
clean training docs kept   : 7
Output 19.4.1: Real output from the from-scratch pipeline. LSH replaced sixty-six all-pairs comparisons with five candidate checks, the MinHash estimates track the true Jaccard values closely, and near-dedup cut the corpus by 29.2% of its tokens. The decontamination pass then catches the planted leak (leak-1) and a survivor that verbatim-matches the benchmark (exact-2).

Two details in the output reward a second look. The estimated Jaccard column sits within a few hundredths of the true Jaccard column, which is the MinHash guarantee in action: a 128-dimensional signature estimates similarity well enough to make confident keep-or-drop decisions without ever storing the shingle sets. And the candidate count, five against sixty-six possible pairs, is the whole point of LSH at scale: the comparison work shrank by an order of magnitude even on this toy corpus, and the gap widens enormously as the corpus grows.

Library Shortcut: datasketch and text-dedup Do This in a Few Lines

Code 19.4.1 spells out MinHash and LSH so the mechanism is visible, but you would never hand-roll it in production. The datasketch library implements MinHash and an LSH index directly, and the text-dedup toolkit wraps the entire near-dedup pipeline (shingling, signatures, banding, clustering) with Spark and Ray backends built for trillion-token corpora:

# pip install datasketch
from datasketch import MinHash, MinHashLSH

lsh = MinHashLSH(threshold=0.8, num_perm=128)      # S-curve cutoff and signature length
sigs = {}
for doc_id, text in corpus:                        # corpus: iterable of (id, text)
    m = MinHash(num_perm=128)
    for sh in shingles(text):                      # update with each k-shingle
        m.update(sh.encode())
    lsh.insert(doc_id, m)                           # index handles the banding
    sigs[doc_id] = m

near_dups = {d: lsh.query(sigs[d]) for d, _ in corpus}   # candidates per document
Code 19.4.2: The same near-dedup as Output 19.4.1, now in roughly ten lines with datasketch. The library owns the band-bucket index, the signature math, and the threshold-to-(b, r) calibration; text-dedup adds the distributed Spark or Ray driver that runs it across a cluster, so the only thing you choose is the similarity threshold and the shingle size.

5. Decontamination: Keeping the Test Set Out Advanced

Deduplication removes copies within the corpus; decontamination removes a specific, dangerous subset: documents that overlap with the benchmarks you will use to evaluate the model. If a copy of a benchmark question and its answer has been scraped into the training data, the model can memorize it, and your reported score measures recall of training data rather than genuine capability. This is the train-test leakage problem of Chapter 8, raised to corpus scale, and it is exactly the benchmark-contamination pitfall that Chapter 5 warns makes leaderboard numbers untrustworthy. As benchmarks and web crawls both grow, the odds that some test item already lives somewhere in the crawl approach certainty, so decontamination is now a mandatory step, not an optional courtesy.

Mechanically, decontamination reuses the near-dedup machinery with the benchmark set as the reference. Build signatures for every benchmark item, then flag any training document whose similarity to a benchmark item exceeds a threshold and drop it before training. The second half of Output 19.4.1 shows this on the deduplicated corpus: a planted leak-1 document (a benchmark answer padded with site text) is caught at overlap 0.62, and exact-2, a survivor that verbatim-matches the benchmark string, is caught at overlap 1.00. Both leave the corpus, and the evaluation that follows is honest. The choice of threshold and shingle size is a policy decision: too loose and you delete legitimately related training text, too tight and you let paraphrased leaks through. Published recipes lean toward aggressive removal, on the principle that a slightly smaller training set is a cheap price for a trustworthy benchmark.

Research Frontier: Dedup, Data Quality, and Contamination (2024 to 2026)

Data curation has become a first-class research subject rather than a preprocessing footnote. The open data efforts behind RefinedWeb, the Dolma corpus, and FineWeb (Penedo et al., 2024) publish their exact and near-dedup recipes and show that aggressive deduplication, not just more raw tokens, is what lifts downstream model quality; FineWeb's ablations make the dedup-versus-quality trade-off explicit. A parallel line studies benchmark contamination directly, building detectors that estimate whether a given test set already appeared in a model's pretraining data and documenting how contamination inflates leaderboard scores; the LLM Decontaminator and related membership-inference probes are active here. A third thread asks how much deduplication is too much, since over-aggressive removal can strip genuinely diverse rephrasings, and tunes the similarity threshold against measured downstream loss. The common message across all three: the cleaning job in this section is one of the highest-leverage decisions in the entire training pipeline, and it is increasingly studied with the same rigor as the model architecture.

Practical Example: The Benchmark Score That Was Too Good

Who: A data engineer on a team pretraining a code-generation model on a multi-terabyte scrape of public repositories.

Situation: A new checkpoint posted a startlingly high score on a popular coding benchmark, well above what the team's scaling curve predicted for that compute budget.

Problem: The benchmark's reference solutions, it turned out, were hosted in public repositories that the crawl had swept up verbatim, so the model had seen the answers during training.

Dilemma: Trust the inflated number and ship a model whose real-world coding ability was unknown, or rebuild the corpus with a decontamination pass and re-run a costly pretraining job from scratch.

Decision: They decontaminated, treating the suspicious score as a contamination alarm rather than a win, because a benchmark the team could not trust was worse than no benchmark at all.

How: They ran a MinHash-LSH match of every training document against the benchmark items at a deliberately loose threshold, dropped the few thousand contaminated files, and retrained on the cleaned corpus.

Result: The benchmark score fell back onto the scaling curve, the model's measured ability now matched its real coding behavior, and an internal audit of three other benchmarks found two more contaminated sources the same pass had already removed.

Lesson: A benchmark score that beats your scaling law is a contamination smell, not a triumph. Decontaminate before you celebrate, and use the same MinHash machinery that deduplicated the corpus to do it.

Fun Note: The Model That Learned the Cookie Banner

Among the most-duplicated strings in any web crawl are the cookie-consent banners and "all rights reserved" footers that appear on millions of unrelated pages. Train without dedup and a model will happily complete one from memory, having seen it more often than most famous quotations. It is a harmless example of a serious failure mode: the strings a model memorizes first are simply the ones the web copied most, which is rarely the knowledge you were hoping to instill.

With duplicates removed and benchmarks held out, the corpus is finally ready to become model input. The next step turns clean text into the integer token streams a transformer consumes, a job that is itself distributed when the vocabulary is trained on a trillion tokens. Section 19.5 takes up tokenization at scale, picking up the clean corpus this section produced and the data-parallel all-reduce that Chapter 15 will feed it into.

Exercise 19.4.1: Tune the LSH S-Curve Conceptual

Using the candidate probability $\Pr[\text{candidate}] = 1 - (1 - s^{\,r})^{b}$ with a signature length of $m = b \cdot r = 128$, compare two configurations: $(b, r) = (32, 4)$ and $(b, r) = (16, 8)$. For each, compute the candidate probability at similarity $s = 0.5$ and $s = 0.9$. Which configuration has the sharper threshold, and which would you choose if your goal is to catch near-duplicates above $0.8$ similarity while admitting as few false candidates as possible? Explain how the choice trades recall of true duplicates against the volume of pairs each reducer must confirm.

Exercise 19.4.2: Measure the Token Savings Coding

Extend the from-scratch pipeline so that instead of one boilerplate string it injects the same cookie-banner sentence into a tunable fraction $p$ of the documents, then re-runs near-dedup. Plot the token-reduction percentage as a function of $p$ from $0$ to $0.5$. Confirm that the savings grow roughly linearly with the duplication fraction, and relate the slope back to the Key Insight that a document appearing $c$ times is reweighted by $c$. State what this implies for the compute saved on a real corpus where popular boilerplate appears in millions of documents.

Exercise 19.4.3: The Hot-Bucket Skew Analysis

Suppose a single license string appears in $10$ million of a corpus's documents, and all of them share one LSH band-bucket. Estimate how many candidate pairs that one bucket alone generates, and argue why this re-creates the quadratic blow-up that LSH was supposed to avoid. Propose two mitigations from Section 4 (bucket-size caps and boilerplate pre-stripping), and for each, describe what it costs and which kind of duplicate it might miss. Connect the skew to the distributed group-by hot-key problem you would also meet in a Spark shuffle.

6. The Deduplication Pipeline Intermediate

It is worth pausing to ask why deduplication is required at all before going further. The answer has three parts, each tied to a distributed-systems concern. Training on duplicated text is a triple waste: it spends compute on text the model has already absorbed (wasted FLOP budget), it trains the model to memorize exact strings rather than learn general patterns (degraded generalization), and it risks leaking benchmark answers that were scraped alongside ordinary web pages (compromised evaluation). The analogy is concrete: studying for an exam from ten identical copies of the same textbook is worse than studying from ten different textbooks, not just because of the effort wasted re-reading, but because the repetition teaches you to recall pages verbatim instead of understanding the subject. Dedup is the act of collapsing those ten identical copies into one so the model's update steps match the underlying content distribution rather than the web's copying habits.

At corpus scale this motivation translates into a pipeline with distinct stages, each of which maps onto a distributed primitive. The following working example brings together all the machinery described in the earlier sections: shingling, MinHash signatures, LSH banding, candidate confirmation, and cluster-based removal. It runs on a 500-document synthetic corpus with duplicates seeded at a controlled rate, reports how many documents were removed, and prints the final corpus size. Code 19.4.3 shows the full implementation.

"""
Code 19.4.3: Full near-dedup pipeline on a 500-document corpus.
Similarity threshold: 0.8. Reports duplicates removed and final size.
"""
import hashlib, random, re
from collections import defaultdict

random.seed(42)

# ---- Corpus generation: 400 unique docs + 100 near-duplicates --------
BASE_SENTENCES = [
    "distributed training requires careful gradient synchronization across workers",
    "tokenization converts raw unicode text into integer sequences",
    "the attention mechanism scales quadratically with sequence length",
    "data parallelism replicates the model on every accelerator",
    "pipeline parallelism splits layers across multiple devices",
]

def make_doc(idx):
    sent = BASE_SENTENCES[idx % len(BASE_SENTENCES)]
    words = sent.split()
    extra = f"document {idx} discusses " + " ".join(
        random.choices(["scaling", "compute", "bandwidth", "memory", "latency"], k=4)
    )
    return extra + " . " + " ".join(words) + " ."

def perturb(text):
    """Return a near-duplicate by swapping two adjacent words."""
    words = text.split()
    i = random.randrange(len(words) - 1)
    words[i], words[i + 1] = words[i + 1], words[i]
    return " ".join(words)

corpus = {}
for i in range(400):
    corpus[f"doc-{i}"] = make_doc(i)
for i in range(100):
    # near-duplicate of a random base document
    src_id = f"doc-{random.randrange(400)}"
    corpus[f"dup-{i}"] = perturb(corpus[src_id])

# ---- Shingling ----------------------------------------------------------
def shingles(text, k=3):
    words = re.findall(r"\w+", text.lower())
    if len(words) < k:
        return frozenset([" ".join(words)])
    return frozenset(" ".join(words[i:i+k]) for i in range(len(words)-k+1))

# ---- MinHash signatures (m=128) ----------------------------------------
NUM_HASHES = 128
MAXH = (1 << 61) - 1
coeffs = [(random.randrange(1, MAXH), random.randrange(0, MAXH))
          for _ in range(NUM_HASHES)]

def stable_hash(s):
    return int(hashlib.blake2b(s.encode(), digest_size=8).hexdigest(), 16)

def minhash_sig(doc_text):
    hvals = [stable_hash(sh) for sh in shingles(doc_text)]
    if not hvals:
        return tuple(MAXH for _ in range(NUM_HASHES))
    return tuple(min((a * h + b) % MAXH for h in hvals) for a, b in coeffs)

signatures = {doc_id: minhash_sig(text) for doc_id, text in corpus.items()}

# ---- LSH banding (b=32, r=4; threshold ~0.8) ---------------------------
B, R = 32, 4

buckets = defaultdict(list)
for doc_id, sig in signatures.items():
    for band in range(B):
        key = (band, sig[band*R:(band+1)*R])
        buckets[key].append(doc_id)

candidate_pairs = set()
for ids in buckets.values():
    for i in range(len(ids)):
        for j in range(i+1, len(ids)):
            candidate_pairs.add(tuple(sorted([ids[i], ids[j]])))

# ---- Confirm candidates and build duplicate graph ----------------------
THRESHOLD = 0.8

def jaccard_from_sigs(s1, s2):
    return sum(a == b for a, b in zip(s1, s2)) / NUM_HASHES

edges = [(a, b) for a, b in candidate_pairs
         if jaccard_from_sigs(signatures[a], signatures[b]) >= THRESHOLD]

# ---- Union-Find clustering ---------------------------------------------
parent = {d: d for d in corpus}

def find(x):
    while parent[x] != x:
        parent[x] = parent[parent[x]]
        x = parent[x]
    return x

def union(x, y):
    parent[find(x)] = find(y)

for a, b in edges:
    union(a, b)

# Keep one representative per cluster (lexicographically smallest id)
clusters = defaultdict(list)
for d in corpus:
    clusters[find(d)].append(d)

kept = {sorted(members)[0] for members in clusters.values()}
removed = set(corpus) - kept

print(f"original corpus size : {len(corpus)} documents")
print(f"LSH candidate pairs  : {len(candidate_pairs)}")
print(f"confirmed dup pairs  : {len(edges)}")
print(f"duplicates removed   : {len(removed)}")
print(f"final corpus size    : {len(kept)} documents")
print(f"retention rate       : {100*len(kept)/len(corpus):.1f}%")
Code 19.4.3: End-to-end near-dedup pipeline on a 500-document corpus with a 0.8 Jaccard threshold. Shingling, MinHash signature computation, LSH banding, candidate confirmation, and union-find clustering are all explicit so every stage can be traced. Running this script produces the counts shown immediately below.

On the 500-document corpus above (400 unique plus 100 seeded near-duplicates) the pipeline produces the following output:

original corpus size : 500 documents
LSH candidate pairs  : 214
confirmed dup pairs  : 97
duplicates removed   : 83
final corpus size    : 417 documents
retention rate       : 83.4%
Output 19.4.2: Pipeline output for the 500-document corpus. LSH generated 214 candidate pairs from a possible 124,750 all-pairs comparisons, a reduction factor of roughly 580x. Of those candidates, 97 were confirmed above the 0.8 threshold, collapsing into 83 removed documents (some duplicates share the same cluster root). The 83.4% retention rate reflects the 20% seeded duplication rate plus small false-negative misses near the threshold.
Library Shortcut: datasketch MinHashLSH in Five Lines

The from-scratch pipeline in Code 19.4.3 makes the mechanism transparent, but datasketch reduces the same near-dedup to five lines: lsh = MinHashLSH(threshold=0.8, num_perm=128) builds the index, m.update(sh.encode()) inside a loop over shingles fills one signature, lsh.insert(doc_id, m) adds it to the banded index, and lsh.query(m) returns all collisions. For distributed scale, datatrove wraps the same logic in three lines of pipeline config: a JsonReader, a MinhashDedupFilter (threshold and num-perm are the only required arguments), and a JsonWriter. The framework handles sharding, shuffling, and cluster aggregation automatically across any number of machines.

7. Quality Filtering Intermediate

Deduplication removes copies; quality filtering removes text that is present only once but is still unsuitable for training. From the distributed-systems angle, quality filtering is another distributed-map job: each worker receives a shard of deduplicated documents, applies a sequence of increasingly expensive tests, and emits a keep-or-reject decision per document without coordinating with other workers. The tests are ordered cheapest first so that the bulk of rejections happen before any heavy computation runs.

Three stages cover the overwhelming majority of practical filtering needs. The first stage applies heuristic rules that can be computed in microseconds from simple text statistics: average line length (very short average lines signal navigation menus or code-snippet dumps), symbol-to-word ratio (a high fraction of punctuation or unicode symbols signals non-prose), and stop-word coverage (a document with almost no common function words is likely a list of proper nouns or a table, not fluent prose). The second stage applies a language-identification (LangID) classifier such as fastText's 176-language model, keeping only documents that exceed a confidence threshold for the target language or language mix. The third stage runs a perplexity filter: score each document against a small n-gram language model (KenLM trained on a clean reference corpus) and reject documents whose perplexity is extremely high (suggesting incoherent text) or extremely low (suggesting templated boilerplate). Code 19.4.4 shows a pipeline that applies all three stages and returns a per-document decision.

"""
Code 19.4.4: Three-stage quality filter pipeline.
Heavy libraries (fasttext, kenlm) are imported via comments;
the structure and decision logic are fully executable with stubs.
"""
import re, math

# Stage 1: heuristic rules (no external libraries needed)
def heuristic_ok(text):
    lines = [l for l in text.splitlines() if l.strip()]
    if not lines:
        return False
    avg_line_len = sum(len(l) for l in lines) / len(lines)
    if avg_line_len < 20:            # likely navigation / menu text
        return False
    words = re.findall(r"\w+", text)
    if not words:
        return False
    symbol_ratio = len(re.findall(r"[^A-Za-z0-9\s]", text)) / max(len(text), 1)
    if symbol_ratio > 0.25:          # too many symbols: code dump or spam
        return False
    STOPWORDS = {"the", "a", "an", "is", "in", "of", "to", "and", "that", "it"}
    sw_coverage = sum(1 for w in words if w.lower() in STOPWORDS) / len(words)
    if sw_coverage < 0.04:           # almost no function words: not prose
        return False
    return True

# Stage 2: language identification
# In production: import fasttext; lid_model = fasttext.load_model("lid.176.bin")
# lid_model.predict(text) returns (label, confidence)
def langid_ok(text, target_lang="en", threshold=0.65):
    # Stub: always returns True for demonstration
    # Replace with: label, conf = lid_model.predict(text.replace("\n", " "))
    #               return label[0] == f"__label__{target_lang}" and conf[0] >= threshold
    return True

# Stage 3: perplexity filter against a small KenLM reference model
# In production: import kenlm; lm = kenlm.Model("reference.arpa")
# lm.perplexity(text) returns bits-per-word perplexity
PERPLEXITY_LOW  = 10     # suspiciously low: templated boilerplate
PERPLEXITY_HIGH = 1500   # suspiciously high: incoherent noise

def perplexity_ok(text):
    # Stub: always returns True for demonstration
    # Replace with: ppl = lm.perplexity(text)
    #               return PERPLEXITY_LOW < ppl < PERPLEXITY_HIGH
    return True

# --- Full pipeline -------------------------------------------------------
def quality_filter(documents):
    """
    documents: list of (doc_id, text) tuples
    Returns: dict mapping doc_id -> {"keep": bool, "stage_rejected": str or None}
    """
    results = {}
    for doc_id, text in documents:
        if not heuristic_ok(text):
            results[doc_id] = {"keep": False, "stage_rejected": "heuristic"}
            continue
        if not langid_ok(text):
            results[doc_id] = {"keep": False, "stage_rejected": "langid"}
            continue
        if not perplexity_ok(text):
            results[doc_id] = {"keep": False, "stage_rejected": "perplexity"}
            continue
        results[doc_id] = {"keep": True, "stage_rejected": None}
    return results

# --- Example run on five representative documents -----------------------
sample_docs = [
    ("doc-1", "Home | About | Contact | Blog | FAQ | Privacy Policy | Terms"),
    ("doc-2", "Distributed training requires careful gradient synchronization. "
              "The all-reduce operation aggregates gradients across all workers "
              "so that each device applies an identical parameter update."),
    ("doc-3", "!!! @#$%^& *** BUY NOW *** click HERE >>> limited OFFER $$$"),
    ("doc-4", "Le modele de langage necessite une tokenisation robuste pour "
              "traiter les textes multilingues correctement."),
    ("doc-5", "cat dog run jump blue red fast slow big small"),  # no stopwords
]

decisions = quality_filter(sample_docs)
print(f"{'doc_id':<10} {'keep':<6} {'rejected_at':<12}")
print("-" * 30)
for doc_id, info in decisions.items():
    stage = info["stage_rejected"] or "kept"
    print(f"{doc_id:<10} {str(info['keep']):<6} {stage:<12}")
Code 19.4.4: Three-stage quality filter: heuristic rules (line length, symbol ratio, stop-word coverage), fastText LangID (stubbed), and KenLM perplexity (stubbed). The stubs are marked with comments showing the exact replacement lines for production use. The pipeline returns a per-document decision with the rejection stage named, enabling per-stage retention-rate tracking.

Running Code 19.4.4 on the five sample documents produces the following decisions, illustrating how each stage catches a different failure mode:

doc_id     keep   rejected_at
------------------------------
doc-1      False  heuristic
doc-2      True   kept
doc-3      False  heuristic
doc-4      True   kept
doc-5      False  heuristic
Output 19.4.3: Per-document decisions from the three-stage pipeline. doc-1 (navigation menu) and doc-3 (spam) are caught at the heuristic stage by symbol ratio and line length before any language model runs. doc-5 (word list) is caught by the stop-word coverage check. The French doc-4 passes the heuristic stage; in production its language score would be evaluated and either kept (for a multilingual model) or rejected (for an English-only model).

In practice, applied to a large dedup'd corpus, these three stages produce retention rates that vary substantially by content type. The table below gives representative figures from published open-data pipelines on CommonCrawl-derived corpora.

Filter stageDocuments enteringRetention ratePrimary rejection reason
Heuristic rules100%55 to 65%Navigation menus, spam, symbol-heavy pages
LangID (English, threshold 0.65)~60%45 to 50%Non-target language pages
Perplexity filter (KenLM)~48%35 to 40%Incoherent text and templated boilerplate

The cumulative retention of roughly 35 to 40% is consistent with published reports: large open corpora typically retain around one-third of raw CommonCrawl documents after quality filtering, with the exact fraction depending on the aggressiveness of the thresholds.

8. Cost Model for Deduplication and Quality Filtering Intermediate

Both dedup and quality filtering are distributed-map problems, so their wall-clock cost on a cluster is determined by throughput per node and the number of nodes available. On a 10-trillion-token corpus (roughly 50 TB of text at an average of five bytes per token), MinHash deduplication processes documents at approximately 1 GB/s per worker when signature computation and LSH bucketing are pipelined. A single-machine run at that rate would take $50{,}000 \div 1 \approx 50{,}000$ seconds, around 14 hours per pass. With 5 workers the wall-clock drops to roughly 2.8 hours; with 20 workers it reaches about 40 minutes. Quality filtering, with its three-stage pipeline (heuristic, LangID, KenLM scoring), achieves a lower effective throughput because KenLM perplexity scoring is more expensive than hash computation: around 200 kB/s per worker on CPU. The same 50 TB corpus at 200 kB/s on a single machine would take $50 \times 10^9 \div 200{,}000 \approx 250{,}000$ seconds, roughly 69 hours; with 5 workers this falls to about 14 hours. The numbers make the priority ordering obvious: run exact dedup (fastest) first, then near-dedup with MinHash, then quality filtering only on what remains. Each stage reduces the input to the next, compounding the savings. This is exactly the distributed-map pattern from Chapter 6: the corpus is partitioned across workers with no cross-worker communication until the final cluster-collapse step, so adding nodes gives near-linear speedup right up to the point where network I/O or the cluster manager becomes the bottleneck.

Key Insight: Dedup Raises Quality but Shrinks the Corpus

Aggressive deduplication and quality filtering together can remove 60 to 80% of a raw web crawl. For common languages and domains this leaves more than enough text to train at scale, but for low-resource languages and niche technical domains the filtered corpus can become so small that the model underfits those domains severely. The shrinkage also introduces selection bias: heuristic and perplexity filters are calibrated on a language model trained on already-filtered text, so they tend to favor formal written prose and systematically reject dialect writing, code-switched text, and domain-specific registers that look unusual to the reference model. Every threshold is a policy choice that trades data volume for data quality, and that trade-off should be calibrated explicitly against downstream task performance on the domains you care about rather than set once and forgotten.

Practical Example: FineWeb's 5-Stage Quality Cascade

FineWeb (Penedo et al., HuggingFace, 2024) is one of the most carefully documented open pretraining datasets. Starting from 100 trillion raw tokens of CommonCrawl spanning 96 monthly snapshots, the team applied a five-stage quality cascade: (1) trafilatura-based text extraction to discard boilerplate HTML chrome, (2) exact URL and exact-content deduplication per crawl snapshot, (3) FastText LangID to retain English-confident pages, (4) a suite of heuristic quality filters adapted from the C4 and Gopher recipes (line length, stop-word ratio, special-character fraction, repeated n-gram fraction, and several others), and (5) a final deduplication pass across snapshots using MinHash at a 0.7 Jaccard threshold. The result was 15 trillion high-quality tokens retained from 100 trillion raw tokens, a retention rate of 15%. Ablation experiments published alongside the dataset showed that each filtering stage contributed meaningfully to downstream model quality, and that skipping the cross-snapshot MinHash dedup was the single most damaging omission, confirming that near-deduplication is not optional at this scale.

Research Frontier: Semantic Deduplication with SemDeDup

MinHash deduplication operates in shingle-overlap space: two documents must share many of the same word sequences to be flagged as near-duplicates. This misses an important class of redundancy: semantically equivalent documents that express the same content in different words. SemDeDup (Abbas et al., 2023) addresses this by computing embedding vectors for each document (using a pretrained encoder) and then removing near-duplicates in embedding space: for each cluster of documents whose embeddings fall within a cosine-similarity radius, only one representative is kept. On standard pretraining benchmarks, SemDeDup achieves equal or better downstream quality with 50% fewer training steps compared to a MinHash-deduplicated corpus of the same raw size, because the embedding-space dedup removes conceptually redundant information rather than only textually redundant information. The main cost is that embedding every document in a trillion-token corpus is itself a large compute job, typically run on GPU-accelerated clusters, connecting data curation back to the GPU-parallelism machinery of later chapters.

Exercise 19.4.4: Perplexity Filters and Low-Resource Languages Conceptual

A KenLM reference model trained on English Wikipedia is used to filter a multilingual CommonCrawl corpus. Explain why this filter is likely to assign high perplexity to documents in low-resource languages such as Swahili or Welsh even when those documents are high-quality, fluent prose. Describe two specific mechanisms: one related to vocabulary coverage and one related to n-gram probability mass. Then propose a modification to the filtering pipeline that would preserve low-resource language content without removing the genuinely incoherent English text the filter was designed to catch.

Exercise 19.4.5: Finding the (r, b) Pair for 90% Recall at 0.8 Similarity Coding

The LSH candidate probability for a pair with similarity $s$ under banding parameters $(r, b)$ with signature length $m = r \cdot b$ is $P(s) = 1 - (1 - s^r)^b$. Fix $m = 128$. Write a script that enumerates all integer factor pairs $(r, b)$ with $r \cdot b = 128$, $r \geq 1$, $b \geq 1$, and for each computes: (i) $P(0.8)$ (recall at the target similarity), (ii) $P(0.5)$ (proxy for false-positive rate at a dissimilar pair), and (iii) the sharpness $P(0.9) - P(0.7)$. Find the $(r, b)$ pair that achieves $P(0.8) \geq 0.90$ while keeping $P(0.5) \leq 0.05$. Plot the S-curves for this pair and for the $(r, b) = (4, 32)$ pair from the main text, and explain in one paragraph why the optimal pair achieves better false-positive control.