---
title: "Module 24: Pretraining Data"
format:
html:
code-fold: false
toc: true
ipynb: default
jupyter: python3
---
{{< include ../_diagram-lib.qmd >}}
{{< include ../_components/step-control.qmd >}}
## Introduction
Every module so far has been about the **model** — how it tokenizes
([m03](../m03_tokenization/lesson.qmd)), attends ([m05](../m05_attention/lesson.qmd)),
trains ([m07](../m07_training/lesson.qmd)), and scales
([m23](../m23_distributed/lesson.qmd)). But the corpus has always arrived
pre-cleaned, as if by magic. In practice **the data is the lever**: given a fixed
architecture and compute budget, the single biggest thing you control is *what
goes in*. **Pretraining data curation** is the pipeline that turns trillions of
raw, filthy web tokens into a training set worth learning from.
That pipeline is a funnel with three stages, and this module builds each from
scratch:
- **Quality filtering** — cheap, deterministic heuristics that throw out documents
that are too short, too repetitive, symbol-spammy, or missing the function words
real language always has.
- **Deduplication** — the web is full of near-copies, and duplicated data makes
models memorize instead of generalize. Exact dedup is a hash set; **near**-dedup
needs **MinHash**, a sketch whose collision rate *equals* set overlap.
- **Data mixing** — a corpus is many domains of unequal size. How much to sample
from each is a temperature dial you will drive yourself.
Why it matters for LLMs:
- **Garbage in, garbage out — measurably.** Deduplicating training data alone
lowers perplexity and cuts memorization (Lee et al. 2021); quality filtering is
why Gopher, Llama, and every frontier model spend more engineering on data than
on the model.
- **It's cheap and parallel.** Every function here is a pure map over documents —
no gradients, no GPUs — so it runs at corpus scale on CPUs.
- **The estimator is exact.** MinHash isn't a heuristic: the fraction of matching
signature positions is an *unbiased* estimate of the Jaccard similarity, with a
variance you control by making the signature longer.
### What You'll Learn
After this module, you can:
- Implement the **Gopher/C4 quality heuristics** as pure functions and read a
per-document pass/fail report.
- Explain why **duplicated data hurts**, and build **exact** and **near**-dedup.
- Derive the **MinHash** property $P[\text{min-hash equal}] = J(A,B)$ and use it to
estimate document overlap; scale it with **LSH**.
- Compute **temperature-weighted mixing** $p_i \propto n_i^\alpha$ and the
**effective epochs** each domain is seen.
### Prerequisites
This module requires familiarity with:
- [Module 03: Tokenization](../m03_tokenization/lesson.qmd) — what a training
corpus *is*, and how text becomes tokens once it's clean.
- [Module 07: Training](../m07_training/lesson.qmd) — where this data goes, and why
the token budget (m07's scaling laws) makes *quality per token* matter.
## Intuition: The Data Funnel
Start with **Common Crawl**: petabytes of raw HTML, most of it junk — SEO spam,
navigation bars, boilerplate, machine translation, and endless near-duplicates.
You cannot train on it directly. Each stage of the funnel is a filter that a
document must survive:
```
raw web ─▶ language ID ─▶ quality filters ─▶ dedup ─▶ domain mixing ─▶ training set
(trillions) (drop junk) (drop copies) (reweight) (what m07 sees)
```
The stages are cheap and *composable*: each is a map from documents to a keep/drop
decision (or a weight). The art is in the thresholds — filter too little and you
train on spam; filter too much and you strip exactly the rare, high-quality text
you wanted. By the end of this lesson you will drive that whole funnel over a toy
corpus and watch it shrink and clean itself.
::: {.callout-note}
## Key Insight
Data curation is not preprocessing you do once and forget — it *is* the model's
inductive bias about what language looks like. A filter that removes all code
removes the model's ability to write code. Every threshold is a curriculum choice.
:::
## The Math & Code: Quality Filtering
The first funnel stage is a set of **heuristics** from the Gopher paper (Rae et
al. 2021) and C4 (Raffel et al. 2020). None of them look at meaning; they exploit
cheap statistical signatures of junk. The real thresholds Gopher uses:
| Heuristic | Keep the document if… | Catches |
|-----------|-----------------------|---------|
| **word count** | between 50 and 100,000 words | stubs, dumps |
| **mean word length** | between 3 and 10 characters | menus, base64/URL soup |
| **symbol-to-word ratio** | $\le 0.1$ (`#`, ellipses) | hashtag walls, snippets |
| **stop words** | $\ge 2$ of *{the, be, to, of, and, that, have, with}* | keyword spam, tables |
| **duplicate lines** | $< 30\%$ of lines are repeats | boilerplate footers |
Each is a pure function in `pretraining_data.py`. They are exact and checkable:
```{python}
import sys
sys.path.insert(0, ".")
from pretraining_data import (
mean_word_length, symbol_to_word_ratio, stop_word_count,
fraction_duplicate_lines, quality_report, CORPUS, DEMO_FILTERS,
)
spam = CORPUS[1]["text"] # a hashtag wall
print("text:", spam)
print("mean word length:", round(mean_word_length(spam), 2))
print("symbol/word ratio:", round(symbol_to_word_ratio(spam), 2))
print("stop words:", stop_word_count(spam))
```
`quality_report` runs every heuristic and returns which ones a document passes —
so you see *why* it was rejected, not just that it was:
```{python}
for name, ok in quality_report(spam, DEMO_FILTERS).items():
print(f" {name:16} {'PASS' if ok else 'FAIL'}")
```
The hashtag wall fails three ways at once: its "words" are mostly single `#`
characters (mean length too low), its symbol ratio is $0.7$ (seven times the
limit), and it has no stop words. That triple failure is the fingerprint of
non-language.
::: {.callout-warning}
## These are thresholds, not truths
The 50-word minimum, the $0.1$ symbol ratio — none are laws. They were tuned on
English web text and *will* mis-fire: a valid haiku is too short, a code file is
symbol-heavy, a non-English document has different stop words. Production
pipelines pair these heuristics with a learned quality classifier and per-language
thresholds. Filter conservatively; you can't un-drop a document.
:::
### Walk a document through the filters
Step through the five heuristics applied to the symbol-spam document. Each row
shows the measured value against the rule, and whether it passes (green) or the
document dies there (red).
```{python}
#| echo: false
#| output: false
from pretraining_data import word_count
_doc = CORPUS[1]["text"]
_f = DEMO_FILTERS
_rows = [
{"name": "word count", "value": word_count(_doc),
"rule": f"{int(_f['min_words'])}–{int(_f['max_words'])} words",
"pass": _f["min_words"] <= word_count(_doc) <= _f["max_words"]},
{"name": "mean word length", "value": round(mean_word_length(_doc), 2),
"rule": f"{_f['min_mean_word_length']}–{_f['max_mean_word_length']} chars",
"pass": _f["min_mean_word_length"] <= mean_word_length(_doc) <= _f["max_mean_word_length"]},
{"name": "symbol / word ratio", "value": round(symbol_to_word_ratio(_doc), 2),
"rule": f"≤ {_f['max_symbol_ratio']}",
"pass": symbol_to_word_ratio(_doc) <= _f["max_symbol_ratio"]},
{"name": "stop words", "value": stop_word_count(_doc),
"rule": f"≥ {int(_f['min_stop_words'])}",
"pass": stop_word_count(_doc) >= _f["min_stop_words"]},
{"name": "duplicate lines", "value": round(fraction_duplicate_lines(_doc), 2),
"rule": f"≤ {_f['max_duplicate_line_fraction']}",
"pass": fraction_duplicate_lines(_doc) <= _f["max_duplicate_line_fraction"]},
]
ojs_define(filterRows = _rows, filterText = _doc)
```
```{ojs}
//| echo: false
viewof qStep = stepControl({min: 0, max: 4, value: 0, label: "Heuristic"})
```
```{ojs}
//| echo: false
filterDiagram = {
const theme = diagramTheme;
const width = 720, height = 360;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
svg.append("text").attr("x", 30).attr("y", 34)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text("document under test:");
svg.append("text").attr("x", 30).attr("y", 54)
.attr("font-size", 11).attr("fill", theme.nodeText)
.text(filterText.length > 60 ? filterText.slice(0, 60) + "…" : filterText);
const rows = filterRows;
const x0 = 30, y0 = 90, rowH = 46, w = width - 60;
rows.forEach((r, i) => {
const y = y0 + i * rowH;
const active = i === qStep;
const col = r.pass ? theme.success : theme.error;
svg.append("rect").attr("x", x0).attr("y", y).attr("width", w).attr("height", rowH - 8)
.attr("rx", 6)
.attr("fill", active ? col : theme.nodeFill)
.attr("opacity", active ? 0.9 : 0.35)
.attr("stroke", active ? col : theme.nodeStroke)
.attr("stroke-width", active ? 3 : 1);
svg.append("text").attr("x", x0 + 16).attr("y", y + 24)
.attr("font-size", 13).attr("font-weight", active ? 700 : 400)
.attr("fill", active ? theme.bgOpaque : theme.nodeText).text(r.name);
svg.append("text").attr("x", x0 + 300).attr("y", y + 24)
.attr("font-size", 12).attr("fill", active ? theme.bgOpaque : theme.edgeStroke)
.text(`measured ${r.value}`);
svg.append("text").attr("x", x0 + 470).attr("y", y + 24)
.attr("font-size", 12).attr("fill", active ? theme.bgOpaque : theme.edgeStroke)
.text(`rule ${r.rule}`);
svg.append("text").attr("x", x0 + w - 16).attr("y", y + 24).attr("text-anchor", "end")
.attr("font-size", 13).attr("font-weight", 700)
.attr("fill", active ? theme.bgOpaque : col).text(r.pass ? "PASS" : "FAIL");
});
const anyFail = rows.some(r => !r.pass);
svg.append("text").attr("x", x0).attr("y", height - 16)
.attr("font-size", 12).attr("font-weight", 700)
.attr("fill", anyFail ? theme.error : theme.success)
.text(anyFail ? "verdict: REJECTED (fails at least one heuristic)" : "verdict: kept");
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Step to "symbol / word ratio".** The measured $0.7$ dwarfs the $0.1$ rule —
one heuristic alone would have caught this.
2. **Note "word count" passes.** A junk document can clear *some* filters; you need
the whole battery. This is why quality filtering is a conjunction, not a vote.
:::
## The Math & Code: Deduplication
Near-duplicate documents are everywhere on the web — reposts, templated pages,
scraped mirrors. Training on them is actively harmful: the model spends capacity
**memorizing** repeated spans instead of learning general structure, and
duplicated text inflates benchmark contamination. Lee et al. (2021) showed that
deduplicating the training set lowers perplexity and cuts verbatim memorization.
**Exact** dedup is easy — normalize whitespace and case, hash, keep the first of
each:
```{python}
from pretraining_data import exact_dedup, normalize_text
docs = ["The Transformer", "the transformer", "a different doc"]
print("normalized:", [normalize_text(d) for d in docs])
print("kept indices:", exact_dedup(docs)) # the second is a normalized copy
```
But exact dedup misses the *near*-copy — the same article with one word changed
hashes to something completely different. For that we need a similarity that is
cheap to estimate over millions of documents.
### Shingles and Jaccard
Represent a document as its set of **shingles** — overlapping word $k$-grams. 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|}
$$
```{python}
from pretraining_data import shingles, jaccard
a = shingles("the cat sat on the mat", k=3)
b = shingles("the cat sat on the rug", k=3)
print("shingles A:", sorted(a))
print("Jaccard(A, B):", jaccard(a, b))
```
Exact Jaccard is correct but expensive: comparing every pair of $N$ documents is
$O(N^2)$ set operations, hopeless at web scale. **MinHash** makes each comparison
$O(1)$ over a tiny fixed-size sketch.
### MinHash: the estimator that *is* the similarity
Pick a random hash function $h$ and, for a set $A$, keep only the **minimum** hash
value over its shingles, $\min_{x \in A} h(x)$. The magic property (Broder, 1997):
$$
P\big[\, \min_{x \in A} h(x) = \min_{x \in B} h(x) \,\big] \;=\; J(A, B)
$$
The two minima coincide exactly when the overall-minimum element lies in the
intersection — which happens with probability $|A\cap B| / |A \cup B|$. So repeat
with $m$ independent hash functions to get an $m$-value **signature**, and the
**fraction of matching positions is an unbiased estimate of the Jaccard
similarity** — with variance shrinking as $1/m$. A short signature (say 128 ints)
replaces the whole document.
```{python}
from pretraining_data import minhash_signature, estimated_jaccard
sig_a = minhash_signature(a, num_perm=128, seed=0)
sig_b = minhash_signature(b, num_perm=128, seed=0)
print("signature length:", len(sig_a))
print("estimated Jaccard:", estimated_jaccard(sig_a, sig_b))
print("true Jaccard: ", jaccard(a, b))
```
### Watch the estimate converge
Two documents with a genuine partial overlap (the clean web doc and its
near-duplicate from the toy corpus, true $J \approx 0.51$). Slide the signature
length: with few hash functions the estimate is noisy; as $m$ grows it locks onto
the true value — the $1/m$ variance made visible.
```{python}
#| echo: false
#| output: false
from pretraining_data import demonstrate_minhash
ojs_define(minhashDemo = demonstrate_minhash())
```
```{ojs}
//| echo: false
viewof mhIdx = stepControl({min: 0, max: minhashDemo.curve.length - 1, value: 3, label: "signature length"})
```
```{ojs}
//| echo: false
minhashDiagram = {
const theme = diagramTheme;
const width = 720, height = 380;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const curve = minhashDemo.curve;
const trueJ = minhashDemo.true_jaccard;
const m = 60, x0 = m + 20, y0 = 40, plotW = width - x0 - 40, plotH = height - y0 - 70;
const xs = curve.map(d => Math.log2(d.num_perm));
const xMin = Math.min(...xs), xMax = Math.max(...xs);
const xScale = v => x0 + (Math.log2(v) - xMin) / (xMax - xMin) * plotW;
const yScale = v => y0 + (1 - v) * plotH;
// axes
svg.append("line").attr("x1", x0).attr("y1", y0).attr("x2", x0).attr("y2", y0 + plotH)
.attr("stroke", theme.edgeStroke);
svg.append("line").attr("x1", x0).attr("y1", y0 + plotH).attr("x2", x0 + plotW).attr("y2", y0 + plotH)
.attr("stroke", theme.edgeStroke);
[0, 0.25, 0.5, 0.75, 1].forEach(t => {
svg.append("text").attr("x", x0 - 10).attr("y", yScale(t) + 4).attr("text-anchor", "end")
.attr("font-size", 10).attr("fill", theme.edgeStroke).text(t.toFixed(2));
});
// true-Jaccard line
svg.append("line").attr("x1", x0).attr("y1", yScale(trueJ)).attr("x2", x0 + plotW).attr("y2", yScale(trueJ))
.attr("stroke", theme.success).attr("stroke-width", 1.5).attr("stroke-dasharray", "5 4");
svg.append("text").attr("x", x0 + plotW).attr("y", yScale(trueJ) - 6).attr("text-anchor", "end")
.attr("font-size", 11).attr("fill", theme.success).text(`true J = ${trueJ.toFixed(3)}`);
// estimate path
const line = d3.line().x(d => xScale(d.num_perm)).y(d => yScale(d.estimate));
svg.append("path").datum(curve).attr("fill", "none")
.attr("stroke", theme.highlight).attr("stroke-width", 2).attr("d", line);
curve.forEach((d, i) => {
const active = i === mhIdx;
svg.append("circle").attr("cx", xScale(d.num_perm)).attr("cy", yScale(d.estimate))
.attr("r", active ? 7 : 3.5)
.attr("fill", active ? theme.highlight : theme.nodeFill)
.attr("stroke", theme.highlight).attr("stroke-width", active ? 3 : 1.5);
svg.append("text").attr("x", xScale(d.num_perm)).attr("y", y0 + plotH + 18)
.attr("text-anchor", "middle").attr("font-size", 9).attr("fill", theme.edgeStroke)
.text(d.num_perm);
});
const cur = curve[mhIdx];
svg.append("text").attr("x", x0).attr("y", 24)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`m = ${cur.num_perm} hashes → estimate ${cur.estimate.toFixed(3)} (error ${Math.abs(cur.estimate - trueJ).toFixed(3)})`);
svg.append("text").attr("x", x0).attr("y", height - 14)
.attr("font-size", 11).attr("fill", theme.nodeText)
.text("signature length m (log scale) — variance falls as 1/m");
return svg.node();
}
```
### Scaling with LSH
MinHash makes each *comparison* cheap, but you still can't compare all $N^2$
pairs. **Locality-Sensitive Hashing** finds the candidate near-duplicates without
looking at most pairs: split the length-$m$ signature into $b$ **bands** of $r$
rows ($m = b \cdot r$), and hash each band. Two documents become a **candidate**
if they collide in *any* band. A pair with similarity $s$ survives with
probability
$$
P(\text{candidate}) = 1 - (1 - s^{\,r})^{b}
$$
an **S-curve**: near-0 below a tunable threshold, near-1 above it. `near_dedup`
ties it together — LSH proposes candidates, MinHash scores them, and the later
document of any near-duplicate pair is dropped:
```{python}
from pretraining_data import near_dedup
corpus_texts = [d["text"] for d in CORPUS]
kept = near_dedup(corpus_texts, threshold=0.4, num_perm=128, bands=32, rows=4)
print("kept indices:", kept)
print("dropped:", [i for i in range(len(corpus_texts)) if i not in kept])
```
The exact duplicate (index 3) *and* the near-duplicate (index 4) are both gone —
the near-copy that exact-dedup could never catch.
::: {.callout-warning}
## Dedup against the eval set, or you'll fool yourself
The most dangerous duplicates are the ones shared between your **training** data
and your **evaluation** benchmarks. If a test question leaks into pretraining, the
model "solves" it by memorization and your scores are fiction
([m17](../m17_evaluation/lesson.qmd) calls this contamination). Always dedup the
training corpus *against* the eval sets, not just within itself.
:::
## The Math & Code: Data Mixing
After filtering and dedup you have a clean corpus — but it is **imbalanced**. Web
text dwarfs curated sources (books, code, Wikipedia). Train in natural proportion
and the model barely sees the high-quality tail; train uniformly and you repeat
tiny domains until it memorizes them. The standard lever is **temperature-weighted
sampling**: give domain $i$ (with $n_i$ tokens) a sampling weight
$$
p_i \;=\; \frac{n_i^{\,\alpha}}{\sum_j n_j^{\,\alpha}}
$$
$\alpha = 1$ samples in natural proportion; $\alpha = 0$ is uniform over domains
(maximal upsampling of small ones); $0 < \alpha < 1$ interpolates.
```{python}
from pretraining_data import mixing_weights, effective_epochs
sizes = [800, 150, 40, 10] # web, books, code, wiki (billions of tokens)
print("alpha=1.0 (natural): ", [round(w, 3) for w in mixing_weights(sizes, 1.0)])
print("alpha=0.5 (tempered):", [round(w, 3) for w in mixing_weights(sizes, 0.5)])
print("alpha=0.0 (uniform): ", [round(w, 3) for w in mixing_weights(sizes, 0.0)])
```
The catch: upsampling a small domain means **repeating** it. `effective_epochs`
tells you how many times each domain is seen for a given token budget — and
repetition past a few epochs is where memorization creeps back in.
```{python}
eps = effective_epochs(sizes, target_tokens=1000, alpha=0.5)
for n, e in zip(["web", "books", "code", "wiki"], eps):
print(f" {n:6} seen {e:.2f}×")
```
At $\alpha = 0.5$ the 10B-token wiki domain is repeated $6.3\times$ to lift its
share — a real risk you are trading against diversity.
### Drive the mixing temperature
Slide $\alpha$ from proportional (right) to uniform (left) and watch the domain
shares reshape. The number under each bar is its **effective epochs** — when it
climbs past a few, you're re-showing the same tokens.
```{python}
#| echo: false
#| output: false
from pretraining_data import demonstrate_mixing
ojs_define(mixDemo = demonstrate_mixing())
```
```{ojs}
//| echo: false
viewof mixAlpha = Inputs.range([0, 1], {value: 1.0, step: 0.05, label: "α (temperature)"})
```
```{ojs}
//| echo: false
mixingDiagram = {
const theme = diagramTheme;
const width = 720, height = 360;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const sizes = mixDemo.sizes, labels = mixDemo.labels;
const total = sizes.reduce((a, b) => a + b, 0);
const target = mixDemo.target_tokens;
// recompute weights + epochs live from alpha (continuous)
const wpow = sizes.map(n => Math.pow(n, mixAlpha));
const wsum = wpow.reduce((a, b) => a + b, 0);
const weights = wpow.map(w => w / wsum);
const epochs = weights.map((w, i) => target * w / sizes[i]);
const colors = [theme.accent, theme.info, theme.highlight, theme.success];
const x0 = 70, y0 = 60, barW = 110, gap = 40, maxH = 190;
labels.forEach((lab, i) => {
const x = x0 + i * (barW + gap);
const h = weights[i] * maxH / Math.max(...weights);
svg.append("rect").attr("x", x).attr("y", y0 + (maxH - h)).attr("width", barW).attr("height", h)
.attr("rx", 6).attr("fill", colors[i % colors.length]).attr("opacity", 0.9);
svg.append("text").attr("x", x + barW / 2).attr("y", y0 + (maxH - h) - 8)
.attr("text-anchor", "middle").attr("font-size", 13).attr("font-weight", 700)
.attr("fill", theme.nodeText).text(`${(weights[i] * 100).toFixed(1)}%`);
svg.append("text").attr("x", x + barW / 2).attr("y", y0 + maxH + 20)
.attr("text-anchor", "middle").attr("font-size", 12).attr("fill", theme.nodeText)
.text(lab);
svg.append("text").attr("x", x + barW / 2).attr("y", y0 + maxH + 38)
.attr("text-anchor", "middle").attr("font-size", 10).attr("fill", theme.edgeStroke)
.text(`${sizes[i]}B raw`);
const ep = epochs[i];
svg.append("text").attr("x", x + barW / 2).attr("y", y0 + maxH + 56)
.attr("text-anchor", "middle").attr("font-size", 11).attr("font-weight", 700)
.attr("fill", ep > 3 ? theme.error : theme.edgeStroke)
.text(`${ep.toFixed(1)}× epochs`);
});
svg.append("text").attr("x", x0).attr("y", 34)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`α = ${mixAlpha.toFixed(2)} · ${mixAlpha >= 0.98 ? "natural proportion" : mixAlpha <= 0.02 ? "uniform over domains" : "tempered upsampling"}`);
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Drag α to 0.** Every domain gets an equal 25% — but wiki is now repeated many
times over (its epoch count turns red). Uniform mixing trades diversity for
memorization risk.
2. **Drag α to 1.** Web swamps everything at 80%; the curated domains barely
register. The truth is in between — most recipes use $\alpha \approx 0.3$–$0.7$.
:::
## Interactive Exploration
Now run the **whole funnel** over the toy corpus. Toggle each stage on or off and
watch the corpus shrink and clean. Each document is a tile, colored by the stage
that drops it (or green if it survives to training). This is
`demonstrate_pipeline` in `pretraining_data.py`, bridged live.
```{python}
#| echo: false
#| output: false
from pretraining_data import demonstrate_pipeline
ojs_define(pipeDemo = demonstrate_pipeline())
```
```{ojs}
//| echo: false
viewof pipeStages = Inputs.checkbox(
["quality", "exact-dedup", "near-dedup"],
{value: ["quality", "exact-dedup", "near-dedup"], label: "stages enabled"}
)
```
```{ojs}
//| echo: false
pipelineDiagram = {
const theme = diagramTheme;
const width = 720, height = 440;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const enabled = new Set(pipeStages);
const docs = pipeDemo.docs;
// a doc survives iff the stage that would drop it is disabled (or it's "kept")
const survives = d => d.dropped_at === "kept" || !enabled.has(d.dropped_at);
const stageColor = {quality: theme.error, "exact-dedup": theme.info, "near-dedup": theme.highlight};
const stageNames = ["raw", "quality", "exact-dedup", "near-dedup"];
// funnel bar per stage: count of docs surviving up to and including it
const survivorsAfter = name => {
const order = stageNames.indexOf(name);
return docs.filter(d => {
if (d.dropped_at === "kept") return true;
const dropOrder = stageNames.indexOf(d.dropped_at);
// dropped only if its stage is enabled AND at/before this point
return !(enabled.has(d.dropped_at) && dropOrder <= order);
}).length;
};
const x0 = 40, y0 = 50, barMaxW = 300;
stageNames.forEach((name, i) => {
const y = y0 + i * 44;
const count = survivorsAfter(name);
const on = name === "raw" || enabled.has(name);
const w = barMaxW * count / docs.length;
svg.append("rect").attr("x", x0).attr("y", y).attr("width", w).attr("height", 30)
.attr("rx", 5).attr("fill", on ? theme.success : theme.nodeFill).attr("opacity", on ? 0.85 : 0.4);
svg.append("text").attr("x", x0 - 10).attr("y", y + 20).attr("text-anchor", "end")
.attr("font-size", 11).attr("fill", theme.nodeText).text(name);
svg.append("text").attr("x", x0 + w + 8).attr("y", y + 20)
.attr("font-size", 12).attr("font-weight", 700).attr("fill", theme.nodeText)
.text(`${count} docs`);
});
// doc tiles
const gx = 400, gy = y0, tile = 60, tgap = 12, perRow = 4;
svg.append("text").attr("x", gx).attr("y", gy - 12)
.attr("font-size", 11).attr("fill", theme.edgeStroke).text("documents");
docs.forEach((d, i) => {
const col = i % perRow, rowi = Math.floor(i / perRow);
const x = gx + col * (tile + tgap), y = gy + rowi * (tile + tgap);
const alive = survives(d);
const fill = alive ? theme.success : (stageColor[d.dropped_at] || theme.nodeFill);
svg.append("rect").attr("x", x).attr("y", y).attr("width", tile).attr("height", tile)
.attr("rx", 6).attr("fill", fill).attr("opacity", alive ? 0.9 : 0.7)
.attr("stroke", theme.nodeStroke).attr("stroke-width", 1);
svg.append("text").attr("x", x + tile / 2).attr("y", y + tile / 2 - 2)
.attr("text-anchor", "middle").attr("font-size", 10).attr("font-weight", 700)
.attr("fill", theme.bgOpaque).text(d.kind.split("-")[0]);
svg.append("text").attr("x", x + tile / 2).attr("y", y + tile / 2 + 12)
.attr("text-anchor", "middle").attr("font-size", 8).attr("fill", theme.bgOpaque).text(d.domain);
});
const kept = docs.filter(survives).length;
svg.append("text").attr("x", x0).attr("y", 30)
.attr("font-size", 13).attr("font-weight", 700).attr("fill", theme.highlight)
.text(`${kept} of ${docs.length} documents reach training · ${((1 - kept / docs.length) * 100).toFixed(0)}% removed`);
svg.append("text").attr("x", x0).attr("y", height - 16)
.attr("font-size", 10).attr("fill", theme.edgeStroke)
.text("green = kept · red = quality · blue = exact-dup · orange = near-dup");
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Turn off every stage.** All 7 documents pass through — including the hashtag
wall and both copies. This is training on raw web.
2. **Enable only "quality".** The spam, the stub, and the boilerplate vanish, but
both copies of the clean doc survive — filtering can't see duplication.
3. **Enable "exact-dedup" then "near-dedup".** The identical copy dies first; only
near-dedup catches the reworded near-copy. Two clean documents remain.
:::
## Common Pitfalls
::: {.callout-warning}
## Over-filtering strips the tail you wanted
Aggressive heuristics disproportionately remove exactly the rare, high-value text
(technical prose, code, non-English) that makes a model capable. The symbol filter
that kills spam also kills LaTeX and source code. Measure what a filter removes
before trusting it — sample the rejects and read them.
:::
::: {.callout-warning}
## MinHash estimates, it does not certify
The signature gives an *estimate* of Jaccard with $1/m$ variance. At a threshold,
some true duplicates slip through and some distinct docs get dropped. Pick $m$ and
the LSH band/row split for the precision/recall you can tolerate; there is no
setting that is exact and cheap at once.
:::
::: {.callout-warning}
## Upsampling is repetition in disguise
A high mixing weight on a small domain means many epochs over the same tokens.
Past a handful of repeats the model memorizes rather than generalizes — the same
failure duplication caused. `effective_epochs` is the number to watch, not the
sampling weight.
:::
::: {.callout-warning}
## Curation choices are silent and compounding
Every threshold is a curriculum decision the model can never override. Drop all
code and it can't program; over-weight forums and it learns their tone. Unlike a
loss curve, a bad data mix leaves no error message — only a weaker model.
:::
## Exercises
### Exercise 1: A custom quality filter
Add a heuristic that rejects documents whose fraction of numeric tokens exceeds a
threshold (catches tables and data dumps). Wire it into a report.
```{python}
import re
from pretraining_data import CORPUS
def numeric_fraction(text: str) -> float:
# Your implementation here: fraction of whitespace tokens that are numbers.
words = text.split()
if not words:
return 0.0
nums = sum(1 for w in words if re.fullmatch(r"\d[\d,.]*", w))
return nums / len(words)
print("clean doc:", round(numeric_fraction(CORPUS[0]["text"]), 3))
print("digits: ", round(numeric_fraction("2024 2025 42 3.14 100 7 99 5"), 3))
```
### Exercise 2: Tune the LSH band/row split
For a fixed signature length $m = b \cdot r$, the split $(b, r)$ sets the S-curve's
threshold. Plot the collision probability at a target similarity for a few splits
and pick the one whose curve rises near your dedup threshold.
```{python}
from pretraining_data import lsh_collision_probability
m = 128
for bands, rows in [(64, 2), (32, 4), (16, 8)]:
assert bands * rows == m
at_08 = lsh_collision_probability(0.8, bands, rows)
at_04 = lsh_collision_probability(0.4, bands, rows)
print(f"b={bands:2} r={rows}: P(0.8)={at_08:.3f} P(0.4)={at_04:.3f}")
# More rows per band → sharper, higher threshold. Which split best separates
# 0.8 (duplicate) from 0.4 (distinct)?
```
### Exercise 3: Back out α from a target epoch count
Given domain sizes and a token budget, find the temperature $\alpha$ that shows
your smallest domain a target number of epochs — a common way to *set* the mix.
```{python}
from pretraining_data import effective_epochs
sizes = [800, 150, 40, 10]
target_tokens = 1000
def epochs_of_smallest(alpha):
return effective_epochs(sizes, target_tokens, alpha)[-1]
# Your implementation here: scan alpha for the value giving ~2 epochs on wiki.
best = min([a / 100 for a in range(0, 101)],
key=lambda a: abs(epochs_of_smallest(a) - 2.0))
print(f"alpha ≈ {best:.2f} → wiki seen {epochs_of_smallest(best):.2f}×")
```
## Summary
Key takeaways:
1. **Data curation is the lever.** Given fixed compute and architecture, filtering,
deduplication, and mixing determine model quality more than any single modeling
trick — and they run cheaply on CPUs as pure maps over documents.
2. **Quality filters are cheap heuristics, applied as a conjunction.** Word count,
mean word length, symbol ratio, stop words, and line repetition each catch a
fingerprint of junk; a document must clear all of them. The thresholds are tuned
guesses, not truths.
3. **MinHash turns similarity into a sketch.** Because $P[\text{min-hash equal}] =
J(A,B)$, the fraction of matching signature positions is an *unbiased* Jaccard
estimate with $1/m$ variance, and **LSH** ($1-(1-s^r)^b$) finds candidates
without the $O(N^2)$ scan. Near-dedup catches the copies exact hashing misses.
4. **Mixing is a temperature dial with a repetition cost.** $p_i \propto n_i^\alpha$
interpolates from natural ($\alpha{=}1$) to uniform ($\alpha{=}0$); upsampling a
small domain repeats it, so watch **effective epochs**, not just the weight.
5. **Every threshold is a silent curriculum choice.** Curation shapes what the
model can ever learn, leaves no error message when wrong, and must be measured —
including *against* the eval sets to avoid contamination.
## What's Next
You can now build the model (m01–m06), feed it a **curated corpus** (this module),
train it (m07), scale that training (m23), align it (m12), and serve it (m08–m09).
The data frontier from here: a learned **quality classifier** (a small model
scoring documents), **synthetic data** and self-distillation, **data curriculum**
(easy-to-hard ordering), and **decontamination** at scale against every benchmark.
### Going Deeper
**Core Papers:**
- [Scaling Language Models: Methods, Analysis & Insights from Training Gopher](https://arxiv.org/abs/2112.11446) — Rae et al. (2021): the MassiveText quality-filter heuristics (word count, mean word length, symbol ratio, stop words, repetition) built here.
- [Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer (C4)](https://arxiv.org/abs/1910.10683) — Raffel et al. (2020): the C4 cleaning pipeline for Common Crawl.
- [Deduplicating Training Data Makes Language Models Better](https://arxiv.org/abs/2107.06499) — Lee et al. (2021): why exact + near dedup lowers perplexity and cuts memorization.
- [On the Resemblance and Containment of Documents](https://ieeexplore.ieee.org/document/666900) — Broder (1997): MinHash and the $P[\text{min equal}] = J$ property.
- [The Pile: An 800GB Dataset of Diverse Text for Language Modeling](https://arxiv.org/abs/2101.00027) — Gao et al. (2020): domain mixing and diversity in a curated corpus.
**Practical Resources:**
- [Mining of Massive Datasets, Ch. 3 (Finding Similar Items)](http://www.mmds.org/) — Leskovec, Rajaraman & Ullman: shingling, MinHash, and LSH in depth.
- [The RefinedWeb Dataset for Falcon LLM](https://arxiv.org/abs/2306.01116) — Penedo et al. (2023): that well-filtered web data alone can match curated corpora.