codelion's picture
Curate a corpus: clean, calibrate, create, compose on a sample, plus a recipe and runner for your own machines
4f740eb verified
Raw History Blame Contribute Delete
9.68 kB
"""Clean: drop low-information documents, then remove anything that overlaps the benchmarks you will test on.
The prose rules follow the Gopher and C4 heuristics with FineWeb's thresholds. Math and code sources get their own,
looser rules, because LaTeX and source code fail prose checks by design.
"""
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass, field
from .text import ngrams, norm, words
BOILER = re.compile(r"(cookie|javascript|privacy policy|terms of (use|service)|all rights reserved|lorem ipsum|"
r"click here|sign up for our newsletter|accept all|log ?in to (reply|comment)|skip to (main )?content)", re.I)
AUTOGEN = re.compile(r"(auto-?generated|do not edit|generated by (the )?protoc|minified)", re.I)
DEFAULTS = {
"min_words": 50, "max_words": 100_000, "min_mean_word": 3.0, "max_mean_word": 10.0,
"max_symbol_ratio": 0.1, "min_alpha_words": 0.8, "max_ellipsis_lines": 0.3,
"max_dup_lines": 0.3, "max_top2gram": 0.2, "max_dup5gram": 0.15, "max_dup10gram": 0.1, "max_boiler_lines": 0.4,
"strip_boilerplate_lines": True,
# code
"code_max_line": 1000, "code_min_alpha": 0.25,
}
def dup_ngram_chars(ws: list[str], n: int) -> int:
"""Characters inside repeated n-grams, counting each repeat once and never the first copy (Gopher, as
implemented in datatrove). Overlapping windows are skipped so a repeated paragraph is not counted n times."""
seen, dup, i = set(), 0, 0
while i <= len(ws) - n:
g = tuple(ws[i:i + n])
if g in seen:
dup += sum(len(w) for w in g)
i += n
else:
seen.add(g)
i += 1
return dup
@dataclass
class Cleaner:
rules: dict = field(default_factory=lambda: dict(DEFAULTS))
def __post_init__(self):
self.rules = {**DEFAULTS, **(self.rules or {})}
def apply(self, doc: dict) -> tuple[dict | None, str]:
"""Return (document, "") to keep it, possibly with boilerplate lines removed, or (None, reason) to drop it."""
text = doc["text"]
kind = doc.get("kind", "web")
if not text.strip():
return None, "empty"
if kind == "code":
why = self._code(text)
return (None, why) if why else (doc, "")
if self.rules["strip_boilerplate_lines"] and kind in ("web", "educational", "multilingual"):
lines = text.split("\n")
kept = [l for l in lines if not (BOILER.search(l) and len(l.split()) < 25)]
if lines and len(lines) - len(kept) > self.rules["max_boiler_lines"] * len(lines):
return None, "mostly boilerplate"
if len(kept) != len(lines):
text = "\n".join(kept)
doc = {**doc, "text": text}
if kind == "textbook": # PDFs repeat running headers and footers on every page; drop those lines, keep the page
lines = text.split("\n")
short = Counter(l.strip() for l in lines if l.strip() and len(l.split()) <= 12)
running = {l for l, c in short.items() if c >= 3}
if running:
text = "\n".join(l for l in lines if l.strip() not in running)
doc = {**doc, "text": text}
why = self._prose(text, loose=kind in ("math", "textbook", "synthetic"), rep=1.6 if kind == "math" else 1.0)
return (None, why) if why else (doc, "")
def _prose(self, text: str, loose: bool, rep: float = 1.0) -> str:
"""rep scales the repetition caps: worked math repeats symbols and steps by nature."""
r = self.rules
toks = text.split()
n = len(toks)
if n < r["min_words"]:
return "too short"
if n > r["max_words"]:
return "too long"
if not loose:
mean = sum(len(t) for t in toks) / n
if not r["min_mean_word"] <= mean <= r["max_mean_word"]:
return "odd word lengths"
sym = (text.count("#") + text.count("...") + text.count("…")) / n
if sym > r["max_symbol_ratio"]:
return "too many symbols"
alpha = sum(1 for t in toks if any(c.isalpha() for c in t)) / n
if alpha < r["min_alpha_words"]:
return "too little real text"
lines = [l.strip() for l in text.split("\n") if l.strip()]
if lines:
ell = sum(1 for l in lines if l.endswith(("...", "…"))) / len(lines)
if ell > r["max_ellipsis_lines"]:
return "truncated lines"
if len(lines) >= 5:
dup = 1 - len(set(lines)) / len(lines)
if dup > r["max_dup_lines"] * rep:
return "repeated lines"
ws = words(text)
if len(ws) >= 20:
bi = Counter(ngrams(ws, 2))
top = bi.most_common(1)[0]
if top[1] > 2 and top[1] * len(top[0]) / max(1, sum(len(w) + 1 for w in ws)) > r["max_top2gram"] * rep:
return "one phrase repeated"
total = max(1, sum(len(w) for w in ws))
for n, cap in ((5, r["max_dup5gram"]), (10, r["max_dup10gram"])):
if dup_ngram_chars(ws, n) / total > cap * rep:
return "repeated passages"
return ""
def _code(self, text: str) -> str:
r = self.rules
lines = text.split("\n")
if len(text) < 100:
return "too short"
if max(len(l) for l in lines) > r["code_max_line"]:
return "minified or data blob"
if sum(c.isalpha() for c in text) / len(text) < r["code_min_alpha"]:
return "too little real code"
if AUTOGEN.search(text[:600]):
return "auto-generated"
return ""
# ---------------------------------------------------------------- decontamination
# Benchmarks people usually report. Each entry: (hub id, config, split, fields joined to form the test text).
BENCHMARKS = {
"gsm8k": ("openai/gsm8k", "main", "test", ["question"]),
"math500": ("HuggingFaceH4/MATH-500", "default", "test", ["problem"]),
"humaneval": ("openai/openai_humaneval", "openai_humaneval", "test", ["prompt"]),
"mbpp": ("google-research-datasets/mbpp", "full", "test", ["text"]),
"mmlu": ("cais/mmlu", "all", "test", ["question"]),
"arc_challenge": ("allenai/ai2_arc", "ARC-Challenge", "test", ["question"]),
"arc_easy": ("allenai/ai2_arc", "ARC-Easy", "test", ["question"]),
"hellaswag": ("Rowan/hellaswag", "default", "validation", ["ctx"]),
"openbookqa": ("allenai/openbookqa", "main", "test", ["question_stem"]),
"winogrande": ("allenai/winogrande", "winogrande_xl", "validation", ["sentence"]),
"truthfulqa": ("truthfulqa/truthful_qa", "generation", "validation", ["question"]),
"commonsenseqa": ("tau/commonsense_qa", "default", "validation", ["question"]),
}
class Decontaminator:
"""Drop a document when it shares any long n-gram with a held-out test item.
13-grams is the GPT-3 / OLMo convention. Short test items (under n words) are matched as a whole normalized string.
"""
def __init__(self, n: int = 13):
self.n = n
self.grams: dict[str, str] = {}
self.short: dict[str, str] = {}
self.counts: Counter = Counter()
def add(self, name: str, texts: list[str]):
for t in texts:
ws = norm(t).split()
if len(ws) < self.n:
if len(ws) >= 6:
self.short[" ".join(ws)] = name
continue
for g in ngrams(ws, self.n):
self.grams.setdefault(g, name)
def __len__(self):
return len(self.grams) + len(self.short)
def hit(self, text: str) -> str:
if not self:
return ""
nt = norm(text)
ws = nt.split()
for g in ngrams(ws, self.n):
b = self.grams.get(g)
if b:
self.counts[b] += 1
return b
if self.short:
for s, b in self.short.items():
if s in nt:
self.counts[b] += 1
return b
return ""
_BUNDLED: dict | None = None
def bundled_benchmarks() -> dict:
"""The test sets shipped with the package (data/benchmarks.json.gz, built by make_benchmarks.py)."""
global _BUNDLED
if _BUNDLED is None:
import gzip
import json
import os
path = os.path.join(os.path.dirname(__file__), "data", "benchmarks.json.gz")
try:
with gzip.open(path, "rt", encoding="utf-8") as f:
_BUNDLED = json.load(f)
except OSError:
_BUNDLED = {}
return _BUNDLED
def benchmark_texts(key: str, token: str = "") -> list[str]:
"""Bundled copy first; the dataset viewer only when a set is missing from the bundle."""
return bundled_benchmarks().get(key) or load_benchmark_texts(key, token)
def load_benchmark_texts(key: str, token: str = "", limit: int = 20000) -> list[str]:
"""Test items through the dataset viewer API (no download). Test sets are small, so this is enough."""
from .sources import _get
ds, cfg, split, fields = BENCHMARKS[key]
out, off = [], 0
while off < limit:
got = _get("rows", {"dataset": ds, "config": cfg, "split": split, "offset": off, "length": 100}, token)
rows = got.get("rows", [])
for r in rows:
row = r.get("row") or {}
t = " ".join(str(row.get(f, "")) for f in fields).strip()
if t:
out.append(t)
if len(rows) < 100:
break
off += 100
return out