#!/usr/bin/env python3 """ Deep + Smart cleaning for the 1B English corpus. Two-phase pipeline: PHASE 1 — Deep Clean (text-level): - Unicode normalization (NFKC) - HTML entity & tag stripping - URL / email / file-path removal - Control character removal - Smart quote / dash / ellipsis normalization - Fix spacing, punctuation, sentence boundaries - Strip code blocks, import lines - Fix repeated words, characters, punctuation PHASE 2 — Smart Filter (document-level): - Remove docs with non-English scripts (CJK, Arabic, Cyrillic, Devanagari) - Remove docs under 50 words - Remove very repetitive docs (unique word ratio < 0.22) - Remove docs with heavy residual code (5+ code patterns) - Strip boilerplate (cookies, subscribe, social, login, nav, copyright) - Strip number-only table lines - Cross-document deduplication (blake2b fingerprints) Input: Base/data/english_1b_raw/ (parquet files from build_english_1b.py) Output: Base/data/english_1b_clean/ (cleaned parquet files + CLEANING_REPORT.txt) Usage: python Base/scripts/clean_english_1b.py python Base/scripts/clean_english_1b.py --input_dir Base/data/english_1b_raw --output_dir Base/data/english_1b_clean """ import argparse import hashlib import json import multiprocessing as mp import re import time import unicodedata from collections import Counter from functools import partial from pathlib import Path import pyarrow as pa import pyarrow.parquet as pq # ─── Constants ──────────────────────────────────────────────────────────────── TOKENS_PER_WORD = 1.3 FLUSH_DOCS = 5_000 # ─── Deep Clean Patterns ───────────────────────────────────────────────────── CTRL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\ufeff\ufffd]") HTML_ENTITIES = [ ("&", "&"), ("<", "<"), (">", ">"), (""", '"'), ("'", "'"), ("'", "'"), (" ", " "), ("—", " - "), ("–", "-"), ("…", "..."), ("«", '"'), ("»", '"'), ("•", "- "), ("·", " "), ("©", "(c)"), ("®", "(R)"), ("™", "(TM)"), ("°", " degrees"), ] RE_URL = re.compile(r"https?://\S+|www\.\S+", re.I) RE_EMAIL = re.compile(r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b") RE_FILE_PATH = re.compile(r"(?:[A-Z]:\\|/(?:home|usr|var|etc|opt)/)\S+") RE_HTML_TAG = re.compile(r"]*)?\s*/?>") RE_HTML_COMMENT = re.compile(r"", re.DOTALL) RE_CODE_BLOCK = re.compile(r"```[\s\S]*?```") RE_IMPORT_LINE = re.compile( r"^(?:import |from \S+ import |#include |using namespace |require\(|" r"const \w+ = require|module\.exports|export default).*$", re.M, ) RE_REPEATED_LINE = re.compile(r"^(.{20,})\n(?:\1\n?)+", re.M) RE_REPEATED_PUNCT = re.compile(r"([!?.])\1{3,}") RE_REPEATED_CHAR = re.compile(r"(.)\1{5,}") RE_REPEATED_WORD = re.compile(r"\b(\w+)(?:\s+\1){2,}\b", re.I) RE_MULTI_NL = re.compile(r"\n{4,}") RE_MULTI_SP = re.compile(r"[ \t]{2,}") RE_TRAIL_SP = re.compile(r"[ \t]+$", re.M) RE_NO_SP_PERIOD = re.compile(r"([.!?])([A-Z])") RE_SP_PUNCT = re.compile(r"\s+([.,;:!?])") RE_DBL_PERIOD = re.compile(r"\.{2}(?!\.)") RE_ORPHAN_PAREN = re.compile(r"\(\s*\)|^\s*\)\s*$|^\s*\(\s*$", re.M) # ─── Smart Filter Patterns ─────────────────────────────────────────────────── RE_CJK = re.compile(r"[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]{3,}") RE_ARABIC = re.compile(r"[\u0600-\u06ff]{5,}") RE_CYRILLIC = re.compile(r"[\u0400-\u04ff]{5,}") RE_DEVANAGARI = re.compile(r"[\u0900-\u097f]{5,}") RE_RESIDUAL_CODE = re.compile( r"(function\s*\(|var\s+\w+\s*=|console\.log|document\.get|" r"if\s*\(\s*\w+\s*[!=]==|\.addEventListener|" r"def \w+\(|class \w+:|self\.\w+|print\(|" r"\.forEach|\.map\(|\.filter\(|async\s+function)", re.I, ) # Boilerplate line patterns to STRIP (not remove doc, just strip these lines) BOILERPLATE_PATTERNS = [ re.compile(r"^.*(?:cookie|cookies)\s+(?:policy|consent|notice|preferences|settings).*$", re.I | re.M), re.compile(r"^.*(?:subscribe|sign\s*up\s+(?:for|to)\s+(?:our|the)\s+newsletter|unsubscribe|opt[\s-]*out).*$", re.I | re.M), re.compile(r"^.*(?:you\s+won'?t\s+believe|click\s+here|read\s+more\s*\.{0,3}$|share\s+this|trending\s+now|sponsored\s+content|advertisement).*$", re.I | re.M), re.compile(r"^.*(?:follow\s+us\s+on|share\s+on\s+(?:facebook|twitter|linkedin|instagram)|like\s+us\s+on|tweet\s+this).*$", re.I | re.M), re.compile(r"^.*(?:skip\s+to\s+(?:main\s+)?content|back\s+to\s+top|previous\s+article|next\s+article|related\s+(?:articles|posts)).*$", re.I | re.M), re.compile(r"^.*(?:log\s*in\s+to\s+(?:your|an)\s+account|create\s+(?:a\s+)?(?:free\s+)?account|forgot\s+(?:your\s+)?password|already\s+(?:a\s+)?member).*$", re.I | re.M), re.compile(r"^.*(?:leave\s+a\s+(?:comment|reply)|post\s+a\s+comment|\d+\s+comments?$|logged\s+in\s+as).*$", re.I | re.M), re.compile(r"^.*(?:all\s+rights\s+reserved|\(c\)\s*\d{4}|copyright\s+\d{4}).*$", re.I | re.M), ] RE_NUMBER_TABLE = re.compile(r"^[\d\s,.\-+/%$]+$", re.M) # ─── Stats tracker ──────────────────────────────────────────────────────────── class Stats: def __init__(self): self.total_input = 0 self.total_output = 0 self.tokens_in = 0 self.tokens_out = 0 # Deep clean self.ctrl_removed = 0 self.html_entities = 0 self.html_tags = 0 self.urls_removed = 0 self.emails_removed = 0 self.code_blocks = 0 self.import_lines = 0 self.repeated_lines = 0 self.repeated_punct = 0 self.repeated_words = 0 self.punct_fixed = 0 self.smart_quotes = 0 self.orphan_parens = 0 # Smart filter self.dropped_short = 0 self.dropped_nonenglish = 0 self.dropped_repetitive = 0 self.dropped_code = 0 self.dropped_lowquality = 0 self.dropped_dup = 0 self.boilerplate_stripped = 0 self.number_tables_stripped = 0 # ─── Phase 1: Deep Clean ───────────────────────────────────────────────────── def deep_clean(text: str, st: Stats) -> str: """Thorough text-level cleaning.""" if not text or len(text.strip()) < 30: return "" # Unicode normalization text = unicodedata.normalize("NFKC", text) # Control characters new = CTRL_CHARS.sub("", text) if new != text: st.ctrl_removed += len(text) - len(new) text = new # HTML entities for old, repl in HTML_ENTITIES: if old in text: c = text.count(old) st.html_entities += c text = text.replace(old, repl) # HTML tags new = RE_HTML_TAG.sub("", text) if new != text: st.html_tags += 1 text = new # HTML comments text = RE_HTML_COMMENT.sub("", text) # Code blocks new = RE_CODE_BLOCK.sub("", text) if new != text: st.code_blocks += 1 text = new # Import / require lines new = RE_IMPORT_LINE.sub("", text) if new != text: st.import_lines += 1 text = new # URLs new = RE_URL.sub("", text) if new != text: st.urls_removed += 1 text = new # Emails new = RE_EMAIL.sub("", text) if new != text: st.emails_removed += 1 text = new # File paths text = RE_FILE_PATH.sub("", text) # Repeated lines new = RE_REPEATED_LINE.sub(r"\1", text) if new != text: st.repeated_lines += 1 text = new # Repeated punctuation (!!!! -> !) new = RE_REPEATED_PUNCT.sub(r"\1", text) if new != text: st.repeated_punct += 1 text = new # Repeated characters (aaaaaa -> aa) new = RE_REPEATED_CHAR.sub(r"\1\1", text) if new != text: text = new # Repeated words (the the the -> the) new = RE_REPEATED_WORD.sub(r"\1", text) if new != text: st.repeated_words += 1 text = new # Smart quotes & dashes -> ASCII changes = [ ("\u2018", "'"), ("\u2019", "'"), ("\u201c", '"'), ("\u201d", '"'), ("\u2013", "-"), ("\u2014", " - "), ("\u2026", "..."), ("\u00a0", " "), ("\u200b", ""), ("\u200d", ""), ("\u200c", ""), ("\ufeff", ""), ] for old, repl in changes: if old in text: st.smart_quotes += 1 text = text.replace(old, repl) # Fix double periods new = RE_DBL_PERIOD.sub(".", text) if new != text: st.punct_fixed += 1 text = new # Fix missing space after sentence end new = RE_NO_SP_PERIOD.sub(r"\1 \2", text) if new != text: st.punct_fixed += 1 text = new # Fix space before punctuation new = RE_SP_PUNCT.sub(r"\1", text) if new != text: st.punct_fixed += 1 text = new # Orphan parentheses new = RE_ORPHAN_PAREN.sub("", text) if new != text: st.orphan_parens += 1 text = new # Collapse whitespace text = RE_MULTI_SP.sub(" ", text) text = RE_MULTI_NL.sub("\n\n\n", text) text = RE_TRAIL_SP.sub("", text) # Strip each line text = "\n".join(line.strip() for line in text.splitlines()) return text.strip() # ─── Phase 2: Smart Filter ─────────────────────────────────────────────────── def smart_filter(text: str, st: Stats) -> tuple[str | None, str | None]: """Document-level quality filter. Returns (cleaned_text, reason_if_dropped).""" words = text.split() wc = len(words) if wc < 50: st.dropped_short += 1 return None, "short" # Non-English script check scripts = [] if RE_CJK.search(text): scripts.append("CJK") if RE_ARABIC.search(text): scripts.append("Arabic") if RE_CYRILLIC.search(text): scripts.append("Cyrillic") if RE_DEVANAGARI.search(text): scripts.append("Devanagari") if scripts: st.dropped_nonenglish += 1 return None, f"non-english:{','.join(scripts)}" # Alphabetic ratio alpha = sum(c.isalpha() for c in text) if alpha / max(len(text), 1) < 0.60: st.dropped_lowquality += 1 return None, "low_alpha" # Repetitive content if wc > 50: unique_ratio = len(set(w.lower() for w in words)) / wc if unique_ratio < 0.22: st.dropped_repetitive += 1 return None, f"repetitive:{unique_ratio:.3f}" # Residual code detection code_hits = len(RE_RESIDUAL_CODE.findall(text)) if code_hits >= 5: st.dropped_code += 1 return None, f"code:{code_hits}" # Strip boilerplate lines cleaned = text for pat in BOILERPLATE_PATTERNS: new = pat.sub("", cleaned) if new != cleaned: st.boilerplate_stripped += 1 cleaned = new # Strip pure number table lines new = RE_NUMBER_TABLE.sub("", cleaned) if new != cleaned: st.number_tables_stripped += 1 cleaned = new # Re-collapse whitespace after stripping cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip() # Re-check length after stripping if len(cleaned.split()) < 40: st.dropped_short += 1 return None, "short_after_strip" return cleaned, None # ─── Deduplication ──────────────────────────────────────────────────────────── def fingerprint(text: str) -> int: norm = " ".join(text.lower().split())[:8192] return int.from_bytes( hashlib.blake2b(norm.encode(), digest_size=8).digest(), byteorder="little", signed=False, ) # ─── Parallel worker (top-level for pickle) ────────────────────────────────── def _clean_one(raw_text: str) -> dict: """Clean a single document. Returns dict with result + local stats. Must be a top-level function for multiprocessing. """ local = { "text": None, "fp": None, "drop_reason": None, "tokens_in": 0, "tokens_out": 0, # deep clean counts "ctrl_removed": 0, "html_entities": 0, "html_tags": 0, "urls_removed": 0, "emails_removed": 0, "code_blocks": 0, "import_lines": 0, "repeated_lines": 0, "repeated_punct": 0, "repeated_words": 0, "punct_fixed": 0, "smart_quotes": 0, "orphan_parens": 0, # smart filter counts "dropped_short": 0, "dropped_nonenglish": 0, "dropped_repetitive": 0, "dropped_code": 0, "dropped_lowquality": 0, "boilerplate_stripped": 0, "number_tables_stripped": 0, } est_in = max(1, int(len((raw_text or "").split()) * TOKENS_PER_WORD)) local["tokens_in"] = est_in # Use a throwaway Stats for counting st = Stats() cleaned = deep_clean(raw_text or "", st) # Copy stats for k in ["ctrl_removed", "html_entities", "html_tags", "urls_removed", "emails_removed", "code_blocks", "import_lines", "repeated_lines", "repeated_punct", "repeated_words", "punct_fixed", "smart_quotes", "orphan_parens"]: local[k] = getattr(st, k) if not cleaned or len(cleaned) < 50: local["drop_reason"] = "short" local["dropped_short"] = 1 return local final, reason = smart_filter(cleaned, st) for k in ["dropped_short", "dropped_nonenglish", "dropped_repetitive", "dropped_code", "dropped_lowquality", "boilerplate_stripped", "number_tables_stripped"]: local[k] = getattr(st, k) if final is None: local["drop_reason"] = reason return local local["text"] = final local["fp"] = fingerprint(final) local["tokens_out"] = max(1, int(len(final.split()) * TOKENS_PER_WORD)) return local # ─── Main pipeline ──────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="Deep + Smart clean English 1B corpus") parser.add_argument("--input_dir", default="Base/data/english_1b_raw") parser.add_argument("--output_dir", default="Base/data/english_1b_clean") parser.add_argument("--workers", type=int, default=0, help="Number of parallel workers (0 = all CPUs)") args = parser.parse_args() in_dir = Path(args.input_dir) out_dir = Path(args.output_dir) n_workers = args.workers or mp.cpu_count() if not in_dir.exists(): print(f"ERROR: Input directory not found: {in_dir}") print("Run build_english_1b.py first!") return out_dir.mkdir(parents=True, exist_ok=True) parquet_files = sorted(in_dir.glob("*.parquet")) if not parquet_files: print(f"ERROR: No parquet files in {in_dir}") return print("=" * 72) print(" DEEP + SMART CLEAN — English 1B Corpus ** PARALLEL MODE **") print("=" * 72) print(f" Input: {in_dir} ({len(parquet_files)} files)") print(f" Output: {out_dir}") print(f" Workers: {n_workers} (CPU cores: {mp.cpu_count()})") print("=" * 72) st = Stats() seen_fps: set[int] = set() out_buf: dict[str, list] = {} out_idx: Counter = Counter() t0 = time.time() def flush_buf(source: str): bucket = out_buf.get(source) if not bucket: return i = out_idx[source] path = out_dir / f"{source}_{i:04d}.parquet" pq.write_table(pa.table({"text": bucket}), str(path)) out_idx[source] += 1 out_buf[source] = [] # Use a persistent pool for all files pool = mp.Pool(n_workers) try: for pf in parquet_files: source = pf.stem.rsplit("_", 1)[0] print(f"\n Processing: {pf.name}") table = pq.read_table(str(pf), columns=["text"]) texts = table.column("text").to_pylist() del table # Parallel clean: map all docs across workers results = pool.map(_clean_one, texts, chunksize=256) file_kept = 0 for r in results: st.total_input += 1 st.tokens_in += r["tokens_in"] # Merge deep clean stats st.ctrl_removed += r["ctrl_removed"] st.html_entities += r["html_entities"] st.html_tags += r["html_tags"] st.urls_removed += r["urls_removed"] st.emails_removed += r["emails_removed"] st.code_blocks += r["code_blocks"] st.import_lines += r["import_lines"] st.repeated_lines += r["repeated_lines"] st.repeated_punct += r["repeated_punct"] st.repeated_words += r["repeated_words"] st.punct_fixed += r["punct_fixed"] st.smart_quotes += r["smart_quotes"] st.orphan_parens += r["orphan_parens"] # Merge smart filter stats st.dropped_short += r["dropped_short"] st.dropped_nonenglish += r["dropped_nonenglish"] st.dropped_repetitive += r["dropped_repetitive"] st.dropped_code += r["dropped_code"] st.dropped_lowquality += r["dropped_lowquality"] st.boilerplate_stripped += r["boilerplate_stripped"] st.number_tables_stripped += r["number_tables_stripped"] if r["text"] is None: continue # Dedup (must be serial) fpi = r["fp"] if fpi in seen_fps: st.dropped_dup += 1 continue seen_fps.add(fpi) # Accept st.total_output += 1 st.tokens_out += r["tokens_out"] bucket = out_buf.setdefault(source, []) bucket.append(r["text"]) file_kept += 1 if len(bucket) >= FLUSH_DOCS: flush_buf(source) elapsed = time.time() - t0 rate = st.total_input / max(elapsed, 1) print(f" kept={file_kept:,} / {len(texts):,} " f"total_out={st.total_output:,} " f"~{st.tokens_out:,} tokens " f"elapsed={elapsed:.0f}s " f"rate={rate:,.0f} docs/s") finally: pool.close() pool.join() elapsed = time.time() - t0 print(f" kept={file_kept:,} / {len(texts):,} " f"total_out={st.total_output:,} " f"~{st.tokens_out:,} tokens " f"elapsed={elapsed:.0f}s") # Flush remaining for source in list(out_buf): flush_buf(source) elapsed = time.time() - t0 # ── Report ──────────────────────────────────────────────────────────── kept_pct = st.total_output / max(st.total_input, 1) * 100 tok_pct = st.tokens_out / max(st.tokens_in, 1) * 100 lines = [ "=" * 72, " CLEANING REPORT — English 1B Corpus", "=" * 72, "", f"Input: {in_dir}", f"Output: {out_dir}", f"Files in: {len(parquet_files)}", f"Files out: {sum(out_idx.values())}", f"Elapsed: {elapsed:.0f}s ({elapsed/60:.1f} min)", "", "─── Document Stats ───", f" Total input: {st.total_input:,}", f" Total output: {st.total_output:,}", f" Kept: {kept_pct:.1f}%", f" Unique FPs: {len(seen_fps):,}", "", "─── Token Estimates ───", f" Tokens in: {st.tokens_in:,}", f" Tokens out: {st.tokens_out:,}", f" Retained: {tok_pct:.1f}%", "", "─── Deep Clean Stats ───", f" Control chars removed: {st.ctrl_removed:,}", f" HTML entities fixed: {st.html_entities:,}", f" HTML tags stripped: {st.html_tags:,}", f" URLs removed: {st.urls_removed:,}", f" Emails removed: {st.emails_removed:,}", f" Code blocks removed: {st.code_blocks:,}", f" Import lines removed: {st.import_lines:,}", f" Repeated lines fixed: {st.repeated_lines:,}", f" Repeated punct fixed: {st.repeated_punct:,}", f" Repeated words fixed: {st.repeated_words:,}", f" Punctuation fixed: {st.punct_fixed:,}", f" Smart quotes fixed: {st.smart_quotes:,}", f" Orphan parens removed: {st.orphan_parens:,}", "", "─── Smart Filter Stats ───", f" Dropped (too short): {st.dropped_short:,}", f" Dropped (non-English): {st.dropped_nonenglish:,}", f" Dropped (repetitive): {st.dropped_repetitive:,}", f" Dropped (code): {st.dropped_code:,}", f" Dropped (low quality): {st.dropped_lowquality:,}", f" Dropped (duplicate): {st.dropped_dup:,}", f" Boilerplate stripped: {st.boilerplate_stripped:,}", f" Number tables stripped: {st.number_tables_stripped:,}", "", "Next: python Base/scripts/prepare_litdata.py \\", " --filtered_dir Base/data/english_1b_clean \\", " --output_dir Base/data/litdata_english_1b \\", " --label ENGLISH_1B", "", "=" * 72, ] report = "\n".join(lines) print("\n" + report) (out_dir / "CLEANING_REPORT.txt").write_text(report, encoding="utf-8") (out_dir / "clean_stats.json").write_text(json.dumps({ "docs_in": st.total_input, "docs_out": st.total_output, "tokens_in": st.tokens_in, "tokens_out": st.tokens_out, "dropped": { "short": st.dropped_short, "non_english": st.dropped_nonenglish, "repetitive": st.dropped_repetitive, "code": st.dropped_code, "low_quality": st.dropped_lowquality, "duplicate": st.dropped_dup, }, }, indent=2), encoding="utf-8") print(f"\nCleaning complete. {st.total_output:,} docs, ~{st.tokens_out:,} tokens retained.") if __name__ == "__main__": main()