Step 7 — Clean your data 🧹¶
Goal: turn a pile of scraped text into a training corpus you trust — and a recipe someone else could run and get the same corpus.
Why this step matters¶
Small models are extremely sensitive to bad data. A large model trained on trillions of tokens can absorb some noise. Yours cannot. Every bad document is a larger fraction of what your model believes.
Digitised ancient texts have a particular problem: OCR errors. A scanner reading a hundred-year-old Devanagari print will make mistakes, and those mistakes become things your model confidently learns.
But the failure that actually ruins projects is quieter than either of those. The same verse appears across dozens of sources, with small differences in punctuation, transliteration, or attached commentary. Exact-duplicate removal catches none of it. Your model memorises instead of learning, your validation loss looks fine because the duplicates are on both sides of the split, and you find out in Step 10 — or worse, you don’t.
You already know what that looks like. It is experiment 5 from Step 2.
The shape of this chapter 🗺️¶
Six stages in a pipe. The order is not decorative — get it wrong and each stage quietly undoes the one before it:
Two ordering rules to internalise:
Normalize before deduplicating, or two identical documents with different Unicode spellings survive as “different”.
Deduplicate before splitting, or the same verse lands on both sides of your train/test line and your Step 10 numbers become fiction.
1 · Normalize, and detect the language 🧼¶
Same NFC rule as everywhere else in this book, plus whitespace collapsing:
def normalize(text):
text = unicodedata.normalize("NFC", text)
text = regex.sub(r"[ \t]+", " ", text)
return "\n".join(ln.strip() for ln in text.splitlines()).strip()Then language detection, which deserves a warning. Off-the-shelf detectors are poor at telling apart closely related South Asian languages, and they fall apart completely on code-mixed text.
2 · Filter for quality, and always say why 🚦¶
Start crude. Crude filters catch most of the damage:
def quality_report(text, min_chars=100):
"""Return (keep?, reason). Always return the reason."""
if len(text) < min_chars:
return False, "too short"
if devanagari_ratio(text) < 0.60:
return False, f"devanagari only {devanagari_ratio(text):.0%} (OCR failure?)"
if repetition_ratio(text) > 0.30:
return False, f"{repetition_ratio(text):.0%} repeated lines (headers/footers?)"
return True, "ok"Two of those three are catching real, specific, common failures:
The Devanagari ratio catches OCR failures and legacy-font extractions — the
Ÿ‚Ÿàdgarbage from Step 6. It also catches wrong-language documents for free.The repetition ratio catches page headers, running footers, and scrapers that looped:
def repetition_ratio(text):
lines = [ln for ln in text.splitlines() if ln.strip()]
return 1 - (len(set(lines)) / len(lines))You can go further later — filtering by perplexity from a reference model, where anything the reference finds wildly surprising is usually broken text rather than interesting text. Do not start there. Start with the three rules above and read what they dropped.
3 · Exact duplicates — cheap, so do it first 🔁¶
def content_hash(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()Hash every document, drop repeats. Fast, exact, and it shrinks the input to the expensive stage that follows.
4 · Near-duplicates — the stage that actually matters 🔑¶
This is the one people skip, because exact dedup already ran and felt like the job was done. They are not the same thing, and the second one matters far more for Sanskrit.
MinHash is implemented in the file from scratch, in about thirty lines, for the same reason BPE was in Step 3: it is short enough to read, and once you have read it you can reason about the threshold instead of guessing at it.
a) Shingles — the unit of comparison¶
def shingles(text, k=5):
"""Character k-grams."""
s = regex.sub(r"\s+", " ", text)
return {s[i:i + k] for i in range(max(len(s) - k + 1, 1))}Character shingles, not word shingles. Word shingles are the usual default and they are wrong here: Sanskrit word boundaries move around under sandhi, so the same passage can have completely different “words” and nearly identical characters. Compare the characters.
b) MinHash — a signature instead of a set¶
def minhash(text, num_perm=64, k=5):
sh = shingles(text, k)
sig = []
for i in range(num_perm):
salt = str(i).encode()
sig.append(min(int(hashlib.blake2b(salt + g.encode(), digest_size=8).hexdigest(), 16)
for g in sh))
return sig
def jaccard_estimate(a, b):
return sum(1 for x, y in zip(a, b) if x == y) / len(a)The trick is worth stating plainly: for each of 64 “permutations” we keep only the minimum hash. Two documents with similar shingle sets are likely to have the same minimum, so the fraction of matching signature slots estimates the Jaccard similarity of the original sets — an O(64) comparison instead of comparing two full sets of thousands of shingles.
c) LSH banding — so it finishes this century¶
Even at 64 numbers per document, comparing all pairs is quadratic. Banding fixes that:
rows = num_perm // bands
buckets = defaultdict(list)
for idx, d in enumerate(docs):
for b in range(bands):
band = tuple(d["_sig"][b * rows:(b + 1) * rows])
buckets[(b, band)].append(idx) # only compare docs sharing a bandSplit each signature into 16 bands of 4, and only compare documents that share an identical band. Similar documents almost always collide in at least one band; dissimilar ones almost never do.
d) Which copy do you keep?¶
# Keep the longer document — it usually has the commentary attached.
keep, drop = (i, j) if len(docs[i]["text"]) >= len(docs[j]["text"]) else (j, i)A one-line policy, and worth thinking about for your corpus. For classical texts the longer version usually carries commentary you want. For scraped news, the longer version is usually the one with the navigation menu attached, and you want the shorter one. Decide deliberately.
5 · Contamination — and the metric change 🕵️¶
Remove anything that appears in the test sets you plan to evaluate on. Do this now, before training. If you do it after, your Step 10 numbers are a lie and you will not know.
Here is the subtlety that catches almost everyone:
def contamination(docs, eval_texts, threshold=0.8):
hits = []
for n, ev in enumerate(eval_texts):
ev_sh = shingles(ev)
for d in docs:
overlap = len(ev_sh & shingles(d["text"])) / len(ev_sh) # containment
if overlap >= threshold:
hits.append((d["id"], n, overlap))
return hitsNote the denominator: the size of the eval shingle set, not the union. That single change is the difference between finding your leaks and not.
6 · Split by source, never by line ✂️¶
def split_by_source(docs, ratios=(0.9, 0.05, 0.05)):
by_source = defaultdict(list)
for d in docs:
by_source[d["source"]].append(d)
out = {"train": [], "val": [], "test": []}
for source, group in sorted(by_source.items()):
r = int(hashlib.sha256(source.encode()).hexdigest(), 16) % 1000 / 1000
name = "train" if r < ratios[0] else ("val" if r < ratios[0] + ratios[1] else "test")
out[name].extend(group)
return outSplit by line and the same verse appears on both sides — your validation loss then measures memorisation and calls it generalisation. Assign whole sources to splits, and hash the source name so the assignment is deterministic: re-running the pipeline gives you the same split, which is what makes results comparable across runs.
This is also the payoff for the one-folder-per-source layout from Step 6.
What you should see ▶️¶
python clean_corpus.pyloaded 7 documents
=== quality filter ===
KEEP gita-1 ok
KEEP gita-2 ok
KEEP gita-3 ok
KEEP subhashita-1 ok
DROP manuscript-ocr devanagari only 0% (OCR failure or wrong language?)
DROP loop-scrape 95% repeated lines (headers/footers?)
DROP fragment too short
-> 4/7 survive
=== exact dedup ===
DROP gita-2 byte-identical to gita-1
-> 3 remain
=== near dedup (MinHash, threshold 0.7) ===
DROP gita-1 ~77% similar to gita-3
-> 2 remain
=== contamination check against the eval set ===
LEAK subhashita-1 ~100% similar to eval item 0
-> 1 remain after removing leaks
=== split by source ===
train 1 docs sources=['sacred-texts']
val 0 docs sources=[]
test 0 docs sources=[]
wrote corpus/train.txt, val.txt, test.txt and manifest.json (187 chars kept)Seven documents in. One document out. Read the stages:
Three died on quality: an OCR failure that extracted to zero Devanagari, a scraper that looped the same line twenty times, and a fragment.
One died on exact dedup — the same Gita text collected from two sites.
One died on near dedup at 77% similarity: same verses, different punctuation, plus a commentary line. Exact hashing would never have caught it. Note also which copy survived —
gita-3, the longer one with the commentary.One was leaking your evaluation set into training at 100% containment. It looked like an ordinary training document, because it was one.
7 · Two data sources worth considering 🔀¶
Transliteration — nearly free extra data¶
Sanskrit is written in several scripts: Devanagari, Grantha, Telugu, Kannada, and Roman (IAST). Converting between them is mechanical and reliable.
This can meaningfully increase your usable data, and it also teaches your model that the same text can wear different clothes. The same trick works between Urdu script and Hindi Devanagari, since the spoken languages are very close.
Synthetic data — carefully¶
You can generate text with a large model, or translate text into Sanskrit. Two warnings:
It copies the teacher’s mistakes into your language. If a large model produces slightly wrong Sanskrit — and the introduction noted that they mostly do — and you train on it, your model learns the wrong Sanskrit as truth. Worse, it learns it fluently.
Check the licence. Many model licences restrict using their output to train competing models. Read the actual text before you build on it.
8 · Fix OCR errors, and stop early ✏️¶
For scanned text, expect:
Confused similar-looking characters
Lost or wrongly placed diacritics and vowel signs
Broken conjunct letters
Page numbers, headers, and footnotes mixed into the body text
Build a list of the most common errors in your sources and fix them with rules. Perfect is not the goal — reducing the top ten error patterns gets you most of the benefit, and the eleventh through hundredth will cost you a month.
The same applies to personal information: names, phone numbers, addresses, and identifiers. Less of an issue for classical texts, a real issue for scraped Urdu web data.
Where people usually get stuck¶
Skipping near-duplicate removal because exact-duplicate removal already ran. They are not the same thing, and the second one matters far more for Sanskrit.
Trusting a MinHash threshold they never calibrated. 0.7 is a starting guess,
not an answer. Run with --near-threshold 0.5 and 0.9 on your own corpus and
read the pairs it reports. If it is dropping things you wanted, raise it. If the
“nothing found” line appears, be suspicious rather than pleased.
Using Jaccard for contamination. Covered in stage 5, and it is the single most expensive mistake in this chapter because the failure is silent and only surfaces as inexplicably good Step 10 numbers.
Splitting by line. Or by document without grouping sources. Either way the same verse ends up on both sides.
Cleaning without a manifest. Six months from now someone will ask “where did this line come from?” and you will have no answer, and no legal basis for the release you were planning in Step 25.
You are ready to move on when¶
You have a clean corpus, a documented recipe someone else could run to reproduce it exactly, a manifest recording every surviving document’s source, and a train/validation/test split made at the source level with more than one source on each side.
A good test: delete your output folder and rebuild it from the raw data with one command. If you cannot, you have a corpus but not a recipe.