Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 120m read

DL S046 · Building a Pretraining Dataset (SlimPajama-Style)

Curate a 1B-token dataset from the open web. Part of the 'Deep Learning & LLMs From Scratch' 80-session self-study series.

🧠SoftwareM08 · Tokenization + data + scaling laws· Session 046 of 130 120 min

🎯 Curate a 1B-token pretraining dataset from the open web that a small foundation model can actually learn from.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 46 / 80 · Module M08 · ~2 hours

The story we're starting with

Here's the uncomfortable truth about LLM pretraining that nobody puts on the marketing slide: the model is 20% of the work. The data is 80%. You can copy nanoGPT line-for-line, spin up an A100 for a weekend, and get garbage — because you fed it garbage. Or you can take a merely OK architecture, feed it a clean, deduped, well-mixed 1B tokens, and get a small model that writes coherent English on a single-GPU budget. The difference isn't the transformer. It's the corpus.

This is the session where we stop worshipping architecture and start doing the janitor work. We're going to build a real pretraining dataset from scratch — Common Crawl → text extraction → language ID → quality filter → dedup → tokenize → shard on disk. By the end you'll have a ~/data/pretrain/*.bin you can point Session 053's training script at.

The numbers to keep in your head as we go: Chinchilla says ~20 tokens per parameter is compute-optimal. So a 100M-param model wants ~2B tokens, and our 1B target is on the light-but-still-teachable side for that scale. A raw Common Crawl WARC dump is ~60 TB per month. After extraction, language filter, quality filter, and dedup, that becomes ~200 GB of usable English text — a 300× shrink. Almost all "data engineering for LLMs" is engineering that shrink pipeline.

You will be able to
  • Explain, in one paragraph, what a WARC file is and why raw Common Crawl is unusable as pretraining data.
  • Build a text-extraction + language-ID pipeline for a WARC shard using trafilatura + fastText, in under 60 lines.
  • Apply the C4/RefinedWeb heuristic quality filters (repetition, symbol ratio, mean word length, curly-brace density) and know why each one exists.
  • Estimate the disk, RAM, and wall-clock cost of building a 1B-token English dataset from scratch on a single 16-vCPU machine.
  • Shard a tokenized corpus into fixed-size `.bin` files ready for a training DataLoader.

Prerequisites

  • S044 (BPE) and S045 (SentencePiece) — we'll tokenize with a trained tokenizer at the end.
  • S016 (Datasets/DataLoaders) — you should already know why we want fixed-size shards on disk instead of streaming raw JSON.


1 · What is Common Crawl, actually?

Common Crawl is a non-profit that has been crawling the public web since 2008 and publishing the raw HTML on S3 for free. Each monthly crawl (CC-MAIN-YYYY-WW) produces:

  • ~3 billion webpages
  • ~60 TB of raw WARC files (Web ARChive format — a container of HTTP request/response pairs)
  • ~15 TB of WET files (plain-text extraction, produced by CC using a generic extractor)
  • ~5 TB of WAT files (metadata: links, headers, no body)

A single WARC file is ~1 GB gzipped and holds ~30–50k pages. Every crawl is split into ~80k WARC files. The index is on S3 at s3://commoncrawl/crawl-data/CC-MAIN-2024-30/....

Why you'll never train on raw WARCs directly:

  1. Boilerplate. Nav bars, cookie banners, footers, ads — often 80% of the bytes of a page.
  2. HTML noise. <script>, <style>, inline CSS, base64-encoded images.
  3. Duplicates. The same news article reprinted on 50 sites. The same Wikipedia stub scraped 40 times.
  4. Language sludge. ~55% of the web is English. The rest is 100+ other languages.
  5. Low-quality text. Auto-generated pages, SEO word salad, spam farms.
  6. Toxic / PII content. You don't want your model memorizing SSNs and slurs.

Every "clean" pretraining corpus you've heard of — C4, The Pile, SlimPajama, RedPajama, RefinedWeb, FineWeb — is the output of a filtering pipeline that starts from Common Crawl (or a subset of it) and cuts 90–99% of the raw text.

The analogy
🌍 Real world
💻 Code world

The point: we're not building "a webscraper". We're building a series of cheap, parallel filters that each throw away 30–70% of what's left. Doing the filters in the right order is the whole art.


2 · The pipeline in one picture

Here's what we'll build. Read left to right.

Rough survival rates on English CC:

StageInput sizeOutput size% kept
Raw WARC (1 file)1.0 GB gz
After extraction250 MB text250 MB25% of WARC bytes
After language filter (English)250 MB140 MB55%
After URL/NSFW blocklist140 MB130 MB92%
After quality heuristics130 MB55 MB42%
After exact dedup55 MB35 MB64%
After near dedup35 MB20 MB57%
After tokenize (1 byte ≈ 0.25 tokens)20 MB text~5M tokens

So one WARC file (1 GB gzipped input) yields ~5M usable tokens. To hit our 1B-token target we process ~200 WARC files — about 200 GB of downloads. That's a weekend of aws s3 cp on a $0.20/hr machine. Very doable.


3 · Grabbing WARCs and extracting text

3.1 Downloading one shard

Common Crawl is on a requester-pays-nothing S3 bucket. You need AWS credentials only for signing, not for billing.

# One WARC — pick any file from the paths index
aws s3 cp --no-sign-request \
  s3://commoncrawl/crawl-data/CC-MAIN-2024-30/segments/1720763514315.11/warc/\
CC-MAIN-20240719173234-20240719203234-00000.warc.gz \
  ~/data/warc/shard-00000.warc.gz

For a full crawl you download the warc.paths.gz index for that dump (~1 MB), then iterate:

curl -s https://data.commoncrawl.org/crawl-data/CC-MAIN-2024-30/warc.paths.gz \
  | gunzip | head -200 > shard.list
# Now loop through shard.list and download in parallel
cat shard.list | xargs -P 8 -I{} \
  aws s3 cp --no-sign-request s3://commoncrawl/{} ~/data/warc/

Cost check. S3 egress from us-east-1 is free within region. If you run this on a t3.xlarge in us-east-1 (0.17/hrondemand)youpay 0.17/hr on-demand) you pay ~4 for the compute of a full 200-shard pull.

3.2 Parsing WARC records with warcio

# extract_warc.py — reads one WARC, yields (url, html) pairs
from warcio.archiveiterator import ArchiveIterator
 
def iter_warc(path):
    with open(path, "rb") as f:
        for rec in ArchiveIterator(f):
            if rec.rec_type != "response":
                continue
            # Only process HTML
            ctype = rec.http_headers.get_header("Content-Type", "")
            if "text/html" not in ctype.lower():
                continue
            url = rec.rec_headers.get_header("WARC-Target-URI")
            html = rec.content_stream().read()
            yield url, html

15 lines. Every WARC has request, response, and metadata records — we keep only response HTMLs. About 60% of WARC records are responses; the rest are HTTP requests and metadata.

3.3 Extracting the actual article text

Naive BeautifulSoup.get_text() gives you every scrap of nav-bar and JS-generated garbage. The community-standard tool is trafilatura — a well-benchmarked article-extraction library that beats readability.js and boilerpipe on most eval sets.

import trafilatura
 
def extract_text(html: bytes) -> str | None:
    # favor_precision drops borderline content — good for pretraining
    txt = trafilatura.extract(
        html,
        favor_precision=True,
        include_comments=False,
        include_tables=False,
        deduplicate=True,
    )
    return txt  # None if extraction failed / page too short

Chain it:

for url, html in iter_warc("shard-00000.warc.gz"):
    text = extract_text(html)
    if text and len(text) > 200:
        print(json.dumps({"url": url, "text": text}))

This one loop, single-threaded, runs at ~2500 pages/sec on a modern laptop and produces newline-delimited JSON (ndjson) — the format the rest of our pipeline expects.

Try itExtract one WARC file end-to-end and count survivors

Download one WARC shard (any file under s3://commoncrawl/crawl-data/CC-MAIN-2024-30/segments/.../warc/) and run the iter_warc + extract_text loop from §3.2–§3.3. Print two numbers: how many records were response HTMLs, and how many extractions returned text longer than 200 chars. Then flip favor_precision=True to False and re-run — you should see 20–40% more docs survive but with noticeably more nav-bar sludge. That trade-off is the whole game.

💡 Hint · Use `favor_precision=False` on a second run and diff the counts.

4 · Language identification with fastText

Facebook's lid.176 model classifies text into 176 languages, ships as a 130 MB binary, and runs at ~50k predictions/sec on CPU. It is the standard for language ID in every LLM pipeline.

wget https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.bin
pip install fasttext
import fasttext
 
_model = fasttext.load_model("lid.176.bin")
 
def lang_id(text: str) -> tuple[str, float]:
    # fastText wants a single line — no newlines
    sample = text.replace("\n", " ")[:1000]
    labels, probs = _model.predict(sample, k=1)
    lang = labels[0].removeprefix("__label__")
    return lang, float(probs[0])

Filter to English with a confidence floor:

lang, conf = lang_id(doc["text"])
if lang != "en" or conf < 0.65:
    continue

Why 0.65 and not 0.90? Empirically: at 0.90 you throw away code-heavy pages (mixed English/code trips the classifier), forum posts with lots of names, and technical docs. At <0.5 you start letting in low-quality mixed-language slop. 0.65 is the sweet spot that C4 and RefinedWeb converged on.


5 · Quality heuristics — the Gopher rules

This is where "make it clean" gets real. DeepMind's Gopher paper published a checklist that everyone since has copied (with tweaks). Here are the core seven, and why each exists:

The Gopher-style quality filters

    Implementation is boring loops. Here's the meaty ones:

    STOPWORDS = {"the", "of", "a", "and", "in", "to", "is", "for", "on", "with"}
     
    def stopword_count(text: str) -> int:
        words = text.lower().split()
        return sum(1 for w in words if w in STOPWORDS)
     
    def symbol_ratio(text: str) -> float:
        words = text.split()
        if not words:
            return 1.0
        n_sym = sum(1 for w in words if w in ("#", "-", "…", "...", "•"))
        return n_sym / len(words)

    The repetition penalty is the interesting one:

    from collections import Counter
     
    def top_ngram_fraction(text: str, n: int = 5) -> float:
        words = text.split()
        if len(words) < n:
            return 0.0
        grams = [" ".join(words[i:i+n]) for i in range(len(words) - n + 1)]
        counts = Counter(grams)
        top_gram, top_count = counts.most_common(1)[0]
        total_chars = sum(len(g) for g in grams)
        return (top_count * len(top_gram)) / total_chars

    top_ngram_fraction(text, n=5) > 0.15 and you throw the doc out. This one filter alone catches "make money fast make money fast make money fast..." spam farms that dodge every other check.

    The full check:

    def passes_gopher(text: str) -> bool:
        words = text.split()
        n = len(words)
        if not (50 <= n <= 100_000):
            return False
        mean_wlen = sum(len(w) for w in words) / n
        if not (3 <= mean_wlen <= 10):
            return False
        if symbol_ratio(text) >= 0.1:
            return False
        if stopword_count(text) < 2:
            return False
        if top_ngram_fraction(text, 5) > 0.15:
            return False
        return True

    30 lines. Kills ~55% of what survived language ID. Feels harsh; it is exactly the right amount of harsh.

    5.1 The C4 curly-brace rule (weirdest, most effective filter of all time)

    C4 famously dropped any line that contained {. The reasoning: HTML/JS/template garbage on the web is saturated with curly braces; real English prose almost never contains them. That one rule strips 5–10% of remaining docs and is one of the highest signal-to-effort filters in the entire pipeline.

    def has_curly_lines(text: str, max_fraction: float = 0.05) -> bool:
        lines = text.splitlines()
        with_curly = sum(1 for l in lines if "\{" in l or "\}" in l)
        return with_curly / max(len(lines), 1) > max_fraction

    (Escape the braces in your prose or MDX will scream — see the MDX gotcha at the bottom.)


    6 · Deduplication — where 40% of your corpus really lives

    If you skip dedup you will train on the same Wikipedia article 30 times, the same GitHub license 500 times, and the same Terms of Service boilerplate 5000 times. Your model will memorize instead of generalize. Perplexity will look great on training and cratering on eval. This isn't a small effect.

    6.1 Exact dedup — 5 lines that shrink your corpus 30%

    seen = set()
    def is_dup(text: str) -> bool:
        h = hash(text)          # or use blake3 for cross-process consistency
        if h in seen:
            return True
        seen.add(h)
        return False

    That's exact document dedup. RAM is your enemy — hashing 100M docs costs ~800 MB of Python set. For bigger corpora you swap set for a bloom filter or a sorted-file merge.

    6.2 Line-level exact dedup

    Boilerplate lives at the line level ("© 2024 CompanyName · All rights reserved."). Run a second pass at line granularity:

    # Two-pass on disk. Pass 1: hash every line, count.
    # Pass 2: for each doc, drop lines whose hash-count > K (e.g., 100).

    C4 dropped any line seen in more than one document. That's aggressive; RefinedWeb kept it at "≥10 across corpus".

    6.3 Near-dedup with MinHash + LSH (Session preview — full impl in S049)

    MinHash gives you a probability-of-Jaccard-similarity fingerprint per doc; LSH lets you find candidate pairs in sub-quadratic time. This is what catches "the same news article on 40 sites" — bodies differ in nav bars but the article text is 95% Jaccard-identical.

    We'll implement MinHash + LSH from scratch in S049. For today, just know: after MinHash dedup at threshold 0.8, expect another 30–40% shrink.

    War story The GitHub license apocalypse

    My first pretraining run: 400M tokens, no dedup. Loss curve looked amazing — dropped to 2.1 by step 5k, way better than the baseline. I got excited. Then I sampled from the model. The first three completions all started with "MIT License Copyright (c) 2019 ...". The model had memorized the MIT license so hard it became its favorite way to start any generation.

    I re-ran with exact + line dedup. The corpus shrank to 260M tokens. Loss plateaued at 2.6 — worse by the metric but the model actually spoke English.

    Lesson: loss curves lie when your dataset is duplicated. Always dedup, and always sample from your model before you trust the loss.


    7 · URL blocklists and PII

    The lightest possible filter but you should have it.

    • URL blocklist: UT1 blocklist (~450k adult/spam domains) covers 95% of what you don't want. Load once into a set of hostnames, reject on match.
    • PII scrub: simple regex for email addresses, phone numbers, credit cards. For a real production run use Microsoft Presidio (fancier NER) but the regex catches the 80% for free.
    import re
    EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
    PHONE_RE = re.compile(r"\+?\d[\d\s\-()]{8,}\d")
     
    def scrub_pii(text: str) -> str:
        text = EMAIL_RE.sub("[EMAIL]", text)
        text = PHONE_RE.sub("[PHONE]", text)
        return text

    This is deliberately blunt. Real PII detection is a whole field. For a personal pretraining run, this is enough to keep the worst leakage out of your weights.


    8 · Tokenize and shard

    You trained a tokenizer in S045. Load it and tokenize every surviving doc:

    from transformers import AutoTokenizer
    tok = AutoTokenizer.from_pretrained("./my-bpe-tokenizer")  # your S045 output
    EOD = tok.eos_token_id                                     # end-of-doc separator
     
    def tokenize_doc(text: str) -> list[int]:
        return tok.encode(text) + [EOD]

    Write to fixed-size .bin shards — this is what nanoGPT-style trainers expect:

    import numpy as np
    SHARD_TOKENS = 100_000_000    # 100M tokens per shard
     
    buf, shard_idx = [], 0
    for text in stream_docs():
        buf.extend(tokenize_doc(text))
        if len(buf) >= SHARD_TOKENS:
            arr = np.array(buf[:SHARD_TOKENS], dtype=np.uint16)
            arr.tofile(f"~/data/pretrain/shard-{shard_idx:04d}.bin")
            buf = buf[SHARD_TOKENS:]
            shard_idx += 1

    np.uint16 gives you up to vocab_size 65535, which is plenty for a 32k or 50k BPE vocab and cuts disk usage 2× vs int32. For a 100k+ vocab (Llama 3, Qwen), bump to uint32.

    A 1B-token corpus at uint16 is 2 GB on disk. Not big.


    9 · Putting it all together — the driver script

    # build_dataset.py — the full pipeline as one streaming iterator
    def process_shard(warc_path: str) -> list[dict]:
        out = []
        for url, html in iter_warc(warc_path):
            text = extract_text(html)
            if not text or len(text) < 200:
                continue
            lang, conf = lang_id(text)
            if lang != "en" or conf < 0.65:
                continue
            if is_url_blocked(url):
                continue
            if not passes_gopher(text):
                continue
            if has_curly_lines(text):
                continue
            text = scrub_pii(text)
            out.append({"url": url, "text": text})
        return out

    Run in parallel with multiprocessing.Pool — one process per WARC, 8–16 workers on a 16-vCPU box. Wall clock for 200 WARCs on a c7i.4xlarge (0.72/hr): 4hours.Totalcostofthecorpus: 0.72/hr): ~4 hours. Total cost of the corpus: **~3 in EC2 + 0inbandwidth=0 in bandwidth = 3 for 1B tokens**.

    Feed the surviving stream into the dedup + tokenize + shard stage and you're done.


    10 · Diagram — where every byte goes

    Raw WARC After extract After lang After Gopher After dedup After tokenize 1 GB gz 250 MB text 140 MB text 55 MB text 20 MB text ~5M tokens 100% 25% 14% 5.5% 2.0% 1.4% of WARC

    Every filter is a settling tank. By the time text becomes tokens, ~98.6% of the original download is on the cutting-room floor. That is normal, correct, and by design.


    11 · War stories

    War story The Latin sludge

    I built my first pipeline without a stop-word count check. My English corpus turned out to be ~2% Lorem Ipsum. The classifier says Lorem Ipsum is Latin, correctly — but 0.6 confidence, which was below my threshold — so it fell through to my "unknown language → keep as English" fallback. Adding the stop-word check killed it instantly (Lorem Ipsum has none of the/of/a/and).

    Lesson: never have a "keep as English" fallback. Uncertain language ⇒ drop. Rebuilding a corpus is cheap; retraining is not.

    War story The recursive Wikipedia

    I discovered mid-run that ~15% of my "web scrape" was actually mirrors of Wikipedia — sites that scrape Wikipedia and reprint it with different templates. My model was seeing Wikipedia articles 5–8 times each, which sounds fine ("more is more!") until I noticed my perplexity on the held-out slice of Wikipedia was suspiciously low. The eval set had leaked into training via mirrors.

    Lesson: if you're going to eval on Wikipedia (as most LM eval harnesses do), explicitly URL-blocklist the top-500 known Wikipedia mirror domains, not just wikipedia.org itself.

    War story The tokenizer that ate 30% of my corpus

    I trained my BPE tokenizer (S044) on a sample of my corpus taken before the Gopher filters. So the vocab was tuned to a distribution that included lots of the garbage I was about to filter out. Post-filter, my tokens-per-byte ratio was way worse than expected — the tokenizer wasted vocab slots on tokens that never appeared in the clean data. I effectively lost ~30% of my token budget to unused merges.

    Lesson: always train your tokenizer on the final distribution, not the raw one. Filter first, then sample for tokenizer training, then tokenize the whole corpus.


    12 · What we did NOT do (and where to go for it)

    • Model-based quality filtering. Fancier corpora (RefinedWeb v2, DCLM, FineWeb-Edu) train a small classifier (fastText on labeled "high quality vs low quality") and use it as a scoring gate. Adds ~5 points to downstream eval scores. We cover this in S049.
    • PDF / arXiv / Wikipedia mixing. We built a pure-web corpus. Real pretraining datasets (SlimPajama, RedPajama) mix web + books + arxiv + wiki + code in specific ratios. That's next session's job (S048).
    • Toxicity classification. Simple keyword filters miss coded speech; classifiers exist (Perspective API, Detoxify). Beyond scope.
    • Legal. Common Crawl is a research corpus. Commercially-licensed models often use much stricter licensing filters. Beyond scope.
    Common misconception
    ✗ What most people think

    "Building a pretraining corpus is mostly about volume. Common Crawl is petabytes of text, so the job is a scale and engineering problem — download as much as I can, strip the HTML, and let the model sort out the quality. More tokens is more signal, and filtering just throws away data I paid to collect."

    ✓ What is actually true

    The overwhelming majority of raw Common Crawl is discarded before it ever reaches a model, and the discarding is where nearly all the value is created. Boilerplate, navigation chrome, machine-generated spam, SEO filler, near-duplicate reposts of the same article across thousands of domains — these dominate the raw bytes. The pipeline is a sequence of filters, and each one is doing something more valuable than the download that preceded it. Deduplication in particular is not a hygiene step but a quality step: duplicated text is effectively repeated training on a small number of documents, which pushes the model toward memorisation and away from generalisation, and it corrupts your evaluation because held-out sets end up containing text the model already saw. Volume is the input to the process, not its objective.

    Why the myth is so sticky

    Because volume is the part you can see, measure, and report, and quality is not. Corpus size in tokens is a single number that goes in a table and can be compared across projects; "we removed the boilerplate well" has no comparable metric. The belief is also directly reinforced by the scaling literature, which everyone reads as "more tokens lowers loss" — and that is true, but the scaling curves were fitted on filtered corpora, so the token counts in those laws already assume the cleaning happened. Reading them as a licence to skip filtering is using the conclusion to invalidate its own precondition. And there is an economic pull underneath: you spent real money and hours on the download, so throwing away most of it feels like waste rather than like the product. The reframe that fixes this is to stop counting bytes retained and start asking what each byte teaches. A thousand copies of the same cookie banner is one fact repeated a thousand times, and it is a fact you did not want.

    Prove it to yourself

    Measure the survival rate through your own pipeline and look at what dies:

    stages = ['raw_warc', 'text_extracted', 'lang_filtered',
              'quality_filtered', 'deduped']
    counts = {s: 0 for s in stages}
    
    # Instrument the driver: increment at each stage, do not just
    # log the final total.
    prev = None
    for s in stages:
        n = counts[s]
        if prev:
            print(f'{s:20s} {n:>10,}  kept {n/prev:.1%}')
        prev = n
    
    # Then read 20 documents that each stage REJECTED.
    # If the quality filter is discarding things you would have
    # wanted, your thresholds are wrong. If dedup is removing very
    # little, your dedup is broken -- web text is far more
    # redundant than it appears.
    From first principles
    Start with the question

    Why does deduplication have to be approximate? Exact duplicate detection is trivial — hash every document and compare — so reaching for MinHash and LSH looks like unnecessary sophistication. It is forced, and the reason is worth deriving.

    1. 1
      The duplicates that actually harm a corpus are almost never byte-identical. The same article syndicated across sites differs in its header, its advertisement slots, its timestamp, and its trailing boilerplate, while the body text — the part the model learns from — is the same.
      forced by · every publishing system wraps content in its own chrome, so identical content arrives wearing different packaging
    2. 2
      An exact hash is maximally sensitive to exactly those irrelevant differences. Change one byte and the hash is completely unrelated, so exact hashing detects the duplicates that matter least and misses the ones that matter most.
      forced by · cryptographic hashes are designed for avalanche behaviour — that property is the whole point, and here it is precisely wrong
    3. 3
      So you need a similarity measure over document content, and the natural one is Jaccard similarity over shingles: represent each document as its set of overlapping n-grams and ask what fraction of the union the two documents share. Boilerplate differences perturb this slightly; genuinely different documents score near zero.
      forced by · shingle overlap measures shared content rather than shared bytes, so it degrades gracefully under the edits you want to ignore
    4. 4
      But similarity is inherently pairwise, and pairwise comparison over a corpus is quadratic in the number of documents. At web scale that is not a matter of needing a bigger machine — it is not finishable, so exhaustive comparison is off the table regardless of budget.
      forced by · the number of pairs grows as the square of the corpus, and pretraining corpora are counted in the hundreds of millions of documents
    5. 5
      MinHash resolves this by making the similarity estimable from a small fixed-size signature: the probability that two sets share the same minimum hash value under a random permutation is exactly their Jaccard similarity, so a few hundred such minima estimate it. LSH then turns estimation into retrieval by banding those signatures so that similar documents collide in the same bucket and dissimilar ones almost never do.
      forced by · bucketing converts a quadratic all-pairs search into a near-linear pass in which each document is only compared against the handful of candidates that hashed alongside it
    ⇒ Therefore

    Therefore approximation is not a compromise you accept for speed — it is the only formulation in which the problem is both correct (catching near-duplicates, which are the ones that matter) and tractable (sub-quadratic). Exact hashing fails the first requirement and exhaustive similarity fails the second.

    And note what this predicts, which you should go verify on your own data. Because MinHash is a probabilistic estimator, its errors are tunable and directional: more permutations tighten the estimate, and the band-and-row configuration of the LSH stage sets a threshold curve that determines your false-positive and false-negative balance. That means dedup aggressiveness is a dial you choose, not a property of the algorithm — and the two directions fail differently. Too aggressive and you delete legitimately distinct documents that merely share a template, quietly shrinking the diversity you were trying to protect. Too lax and near-duplicates survive, inflating your token count with repetition and leaking training text into your held-out split. The second failure is far more dangerous because it makes your metrics look better, which is exactly the direction in which nobody investigates. So the check is: sample from what dedup removed and from what it kept at the boundary, and read both by hand before trusting the threshold.

    Mental modelA funnel where the filters are the product

    The pipeline is a funnel, and the wide end is worthless. Petabytes of WARC files go in; HTML extraction strips the chrome; language identification keeps what you can evaluate; quality heuristics remove the machine-generated and the malformed; deduplication removes the repetition; and what emerges at the narrow end is a small fraction of what entered. Every stage is subtractive, and the stages you would be tempted to skip are the ones doing the most work.

    The thing to hold onto is that the corpus is defined by what it excludes. Two teams can start from the identical Common Crawl snapshot and end with corpora that train visibly different models, and the entire difference lives in threshold choices nobody writes down. So the filters are not preprocessing before the real work — they are the dataset design, and they deserve the same versioning, review, and evaluation you would give a model architecture.

    • Order matters and it is an economics question: put the cheap, high-rejection filters first so the expensive stages run on far less data. Language ID before quality heuristics before dedup.
    • Instrument every stage's survival rate. A stage whose rejection rate silently changes between runs has changed your dataset without changing your code.
    • Read the rejects, not just the keeps. Rejection samples are the only way to discover that a threshold is eating content you wanted.
    • Dedup before your train/validation split, never after. Splitting first and deduping within splits leaves near-duplicates spanning the boundary, which contaminates evaluation while making the numbers look good.
    • Every threshold is a hyperparameter of the dataset. Version it alongside the corpus, because a corpus without its filter configuration is not reproducible.
    • Near-duplicate is the operative category, not duplicate. If your dedup only catches byte-identical documents it is catching the easy minority and missing the expensive majority.
    🔔 Fires when you see

    Fire this model the moment you see: a corpus described only by its token count · an evaluation score that improved without a corresponding change in method · a plan to skip dedup because the data "looks clean" · train and validation loss that track each other unusually closely · a dataset handed over without its filter configuration · anyone proposing to add data to fix a quality problem.

    The tradeoff

    You have a fixed budget for corpus construction. Do you spend it on collecting more raw web data, on filtering what you already have more carefully, or on acquiring a smaller high-quality curated source?

    Collect more raw web data
    + you gain the cheapest possible way to add tokens, since Common Crawl is free and the marginal cost is bandwidth and compute rather than licensing or human effort; it also broadens coverage into domains and languages your current snapshot underrepresents, which is the one thing filtering can never do — you cannot filter your way to content you never downloaded
    − you pay quality does not improve with volume, it regresses toward the web's mean, which is dominated by boilerplate and machine-generated text; and every additional crawl increases the near-duplicate load against everything already collected, so the marginal unique content per byte falls as you go
    pick when your filtered corpus is smaller than roughly the token count your compute budget can consume, so you are data-bound rather than quality-bound — measure this before assuming it, because the intuition is usually wrong in the other direction
    Filter and dedup more aggressively
    + you gain improves quality without any new acquisition cost, and the effect compounds because a cleaner corpus makes every subsequent training run cheaper per unit of capability; aggressive dedup in particular removes the repetition that was pushing the model toward memorisation, which raises the ceiling rather than just tidying the input
    − you pay shrinks the corpus, and if you are already data-bound that directly costs you capability; heuristic filters also carry systematic biases — length and punctuation rules disproportionately reject some languages, dialects, and technical formats — so aggressive filtering narrows diversity in ways aggregate metrics will not show you
    pick when you have more filtered tokens than your compute budget can consume, or your survival-rate instrumentation shows a stage passing far more than comparable published pipelines report — both mean quality is the binding constraint, not quantity
    Acquire a smaller curated source
    + you gain far higher information density per token — books, papers, and reference text carry sustained argument and long-range structure that web text mostly does not, and those are exactly the capabilities web-only corpora are weakest at; it also gives you a reliable high-quality slice to upweight in a mix, which is leverage disproportionate to its size
    − you pay expensive in money or in legal and licensing effort rather than in compute, and it is small enough that it cannot carry a pretraining run alone; upweighting it too heavily reintroduces the repetition problem you spent the dedup stage solving, so it constrains rather than replaces your mixing decisions
    pick when your evaluations show specific weakness in long-form reasoning or a technical domain, and you have confirmed that weakness is not simply an artefact of a filter rejecting that content from the web data you already hold
    What a senior engineer actually does

    Instrument first, decide second. The survival rate at each stage and the tokens-remaining-after-dedup number tell you which regime you are in, and the answer is genuinely different depending on that. Teams that skip the instrumentation almost always default to collecting more, because it is the option that requires no judgement — and it is the wrong option in exactly the case where quality is already the constraint.

    The durable habit is to treat the filter configuration as part of the model, not part of the tooling. Thresholds get tweaked during a debugging session, nobody records it, and six weeks later two runs are incomparable for reasons no one can reconstruct. Version the config with the corpus, keep a sample of what each stage rejected, and make the survival rates a standing artefact of every build. A corpus you cannot rebuild is a result you cannot defend.


    13 · Retention scaffold

    Recall — answer from memory (no scrolling)

    1. What is a WARC file, and why can't you train an LLM on raw WARCs?

    A WARC is a container of HTTP request/response pairs from a web crawl. Raw WARCs are 60% HTML boilerplate, 45% non-English, and full of duplicates and low-quality pages — you'd waste 98% of your training compute learning nav-bar patterns and spam.

    2. Roughly what fraction of a raw Common Crawl WARC survives to become training tokens?

    About 1–2%. A 1 GB gzipped WARC produces ~5M tokens after extraction, language filter, Gopher rules, and dedup.

    3. Why does C4 drop any line containing a curly brace?

    Because real English prose almost never has { or }, but HTML/JS/template garbage is saturated with them. One rule, ~5–10% additional shrink, near-zero false positive rate on real prose.

    4. What is the danger of skipping deduplication?

    The model memorizes duplicated documents (license text, boilerplate, popular articles) instead of generalizing. Training loss looks great but downstream eval and generation quality collapse.

    5. Why do we tokenize with np.uint16 and not np.int32?

    uint16 supports vocab sizes up to 65535 — enough for typical 32k–50k BPE vocabs — and halves disk footprint vs int32. A 1B-token corpus is 2 GB instead of 4 GB.

    The 2024–2025 pretraining-data revolution

    S046 as-written teaches you the SlimPajama-style pipeline circa 2023. That pipeline is now the floor, not the ceiling. Three datasets released in 2024–2025 changed what "good pretraining data" means, and you should know their names and numbers before you commit to a mix:

    • DataComp-LM (DCLM), Li et al., 2024 (arxiv 2406.11794) — the first benchmark for pretraining data curation. Fixed model architecture (1.4B), fixed compute (28B tokens), only knob = which subset of a 240T-token pool you keep. DCLM-Baseline (3.8T tokens) is a filtered subset of Common Crawl that beat every open pretraining set at matched compute. The filter recipe: fastText classifier trained on OH-2.5+ELI5 as "positives" vs random CC as "negatives". A logistic-regression classifier picked ~10% of raw CC and produced better models than the full corpus. Read this if you read nothing else. https://www.datacomp.ai/dclm/
    • FineWeb / FineWeb-Edu (HuggingFace, 2024) — Penedo et al.'s 15T-token clean CC dump + a 1.3T-token "educational content only" subset scored by a Llama-3-70B judge on a 0–5 scale. FineWeb-Edu doubles benchmark scores per training token compared to raw web. Recipe writeup: https://huggingface.co/spaces/HuggingFaceFW/blogpost-fineweb-v1
    • Nemotron-CC (NVIDIA, 2024) — 6.3T-token corpus using LLM-based rewriting to synthesize higher-quality versions of low-quality docs (turn a listicle into a coherent paragraph). Meaningfully improves reasoning benchmarks vs FineWeb-Edu at matched tokens.
    • RedPajama-V2 (Together, 2023–2024) — 30T-token CC dump with precomputed quality signals (40+ metrics per doc) so you can filter with pandas in an afternoon instead of running your own classifiers. https://github.com/togethercomputer/RedPajama-Data

    The unifying lesson: the frontier of data curation in 2024–2025 shifted from heuristics to learned filters and synthetic rewriting. A fastText classifier trained on a few hundred thousand "good" vs "random" docs replaces dozens of Gopher-style rules and beats them on downstream evals. And you don't need to reinvent this — all four datasets above are on HuggingFace, permissively licensed, ready to datasets.load_dataset(...).

    War story The synthetic-data feedback loop

    Nemotron-CC's LLM-rewriting approach opened a can of worms nobody has closed. If you use Llama 3 to rewrite "bad" docs into "good" ones, then train a new model on that rewritten data, the new model inherits Llama 3's style, quirks, and blind spots. This is model collapse's cousin: not from training on your own outputs, but from training on another model's opinions of what good writing looks like. In 2025 nobody has a clean answer. The current best practice: mix rewritten and raw data 30/70, never above 50/50, and keep a held-out human-written eval set that's never touched by any rewriter. Shumailov et al. (2024), The Curse of Recursion, Nature 631, is the canonical warning.

    Stretch

    Download one WARC file (~1 GB gzipped) from the latest CC dump, run the full pipeline end-to-end (extract → langid → Gopher → exact dedup → tokenize), and report: number of docs in, number of docs out, number of tokens, wall clock, and peak RAM. Sanity check: you should see ~5M tokens survive. If you see 100k or 20M, something is misconfigured.

    In your own words

    Explain, in one sentence, why data-cleaning matters more than architecture for pretraining a small model:


    Spaced-review pointer

    • S016 (Datasets/DataLoaders) — the .bin shard format is exactly what you'll mmap in the training loop.
    • S044 (BPE) and S045 (SentencePiece) — you're using the tokenizer you trained there.

    Next-session teaser

    Right — you have 1B tokens. But how many should you have? Session 047 (Chinchilla) tells us the exact ratio of parameters to tokens that spends compute optimally. Spoiler: for a fixed budget, you want ~20 tokens per parameter — and most published models before 2022 were massively under-trained.

    What to bring back tomorrow — sticky note

    • Diagram: the 6-stage funnel (WARC → extract → langid → quality → dedup → tokens).
    • Equation: 1 GB WARC ≈ 5M tokens. Scale from there.
    • Snippet: passes_gopher(text) — 30 lines that decide if a doc lives or dies.

    Session recall · click to reveal
    ★ = stretch question

    Previous: ← DL S045 · Next: DL S047 →