Omarrran's picture
Add Transformers-compatible ks_byte_lm SpaceByte release
45121cf verified
Raw
History Blame Contribute Delete
10.8 kB
"""Offline data preparation: raw corpus -> normalized -> packed byte shards.
Run ONCE per (dataset, normalization-policy). Idempotent: if the shards and
meta.json already exist and `cfg.rebuild_data` is False, it is a no-op.
Output artifacts in `cfg.data_dir`:
train.bin / val.bin / test.bin uint16 token streams (0..255 bytes, 256=BOS,
257=EOS); each document is BOS + utf8 + EOS.
meta.json vocab/specials, per-split token counts,
bytes_per_word (for word-ppl), a frozen copy
of the normalization policy, and provenance.
Source selection:
* cfg.local_text_file set -> read that UTF-8 file (newline-delimited docs).
* else -> stream cfg.hf_dataset via `datasets`.
Splitting is deterministic and content-hash based (like ks_diacritizer), so the
same row always lands in the same split regardless of order or machine.
"""
from __future__ import annotations
import hashlib
import json
import os
from typing import Dict, Iterator, List, Optional, Tuple
import numpy as np
from .config import BOS_ID, EOS_ID, VOCAB_SIZE, ByteLMConfig
from .logging_utils import get_logger
from .metrics import ks_ratio as compute_ks_ratio
from .normalize import LMNormalizer, NormConfig, run_guards
SPLITS = ("train", "val", "test")
# --------------------------- source iteration ------------------------------- #
def _split_long_text(text: str, max_chars: int) -> Iterator[str]:
"""Yield document-sized pieces from HF rows that may be whole-corpus blobs.
The published corpus currently arrives as a few very large strings rather
than millions of rows. We split first on Kashmiri/Urdu sentence punctuation
and then, for pathological long sentences, on word boundaries. This mirrors
the local newline-doc path while keeping every emitted document under the
normal `max_chars` guard.
"""
text = text.strip()
if not text:
return
if len(text) <= max_chars:
yield text
return
buf: List[str] = []
cur = ""
boundaries = {"۔", "؟", "!", "."}
for ch in text:
cur += ch
if ch in boundaries:
sent = cur.strip()
cur = ""
if not sent:
continue
if sum(len(x) + 1 for x in buf) + len(sent) > max_chars and buf:
yield " ".join(buf).strip()
buf = []
if len(sent) <= max_chars:
buf.append(sent)
else:
words = sent.split()
chunk = []
n = 0
for w in words:
if chunk and n + 1 + len(w) > max_chars:
yield " ".join(chunk)
chunk, n = [], 0
chunk.append(w)
n += len(w) + 1
if chunk:
yield " ".join(chunk)
tail = cur.strip()
if tail:
if sum(len(x) + 1 for x in buf) + len(tail) > max_chars and buf:
yield " ".join(buf).strip()
buf = []
buf.append(tail)
if buf:
yield " ".join(buf).strip()
def _pick_text_column(columns: List[str], sample_row: dict) -> str:
"""Heuristic: the column whose sample value is the longest string."""
best, best_len = None, -1
for c in columns:
v = sample_row.get(c)
if isinstance(v, str) and len(v) > best_len:
best, best_len = c, len(v)
if best is None:
raise ValueError(f"no string column found among {columns}")
return best
def _iter_raw_docs(cfg: ByteLMConfig, logger) -> Iterator[Tuple[str, Optional[float]]]:
"""Yield (raw_text, ks_ratio_or_None) documents from the configured source."""
if cfg.local_text_file:
logger.info(f"reading local text file: {cfg.local_text_file}")
if not os.path.exists(cfg.local_text_file):
raise FileNotFoundError(cfg.local_text_file)
with open(cfg.local_text_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
yield line, None
return
try:
from datasets import load_dataset
except ImportError as e:
raise ImportError(
"`datasets` is required for HF loading. `pip install datasets`, "
"or set cfg.local_text_file to a local .txt corpus."
) from e
logger.info(f"loading HF dataset: {cfg.hf_dataset} (rev={cfg.hf_revision})")
ds = load_dataset(cfg.hf_dataset, revision=cfg.hf_revision)
split_name = "train" if "train" in ds else list(ds.keys())[0]
ds = ds[split_name]
text_col = cfg.text_col
if text_col == "auto":
text_col = _pick_text_column(ds.column_names, ds[0])
logger.info(f"auto-detected text column: {text_col!r}")
has_ksr = "ks_ratio" in ds.column_names
for row in ds:
text = row.get(text_col)
if isinstance(text, str):
# Some HF exports pack many newline-delimited documents into a few
# giant rows/files. Treat those lines like the local .txt path so
# max_chars filtering does not drop the whole corpus as one row.
for line in text.splitlines():
for doc in _split_long_text(line, cfg.max_chars):
yield doc, (float(row["ks_ratio"]) if has_ksr else None)
# ------------------------------ encoding ------------------------------------ #
def _encode_doc(text: str) -> np.ndarray:
"""utf-8 bytes wrapped with BOS/EOS, as uint16."""
raw = text.encode("utf-8")
arr = np.empty(len(raw) + 2, dtype=np.uint16)
arr[0] = BOS_ID
arr[1:-1] = np.frombuffer(raw, dtype=np.uint8)
arr[-1] = EOS_ID
return arr
def _split_of(text: str, cfg: ByteLMConfig) -> str:
"""Deterministic content-hash split assignment."""
h = hashlib.sha1(f"{cfg.split_seed}:{text}".encode("utf-8")).digest()
frac = int.from_bytes(h[:8], "big") / float(1 << 64)
if frac < cfg.val_frac:
return "val"
if frac < cfg.val_frac + cfg.test_frac:
return "test"
return "train"
# ------------------------------ entrypoint ---------------------------------- #
def _meta_path(cfg: ByteLMConfig) -> str:
return os.path.join(cfg.data_dir, "meta.json")
def shards_exist(cfg: ByteLMConfig) -> bool:
if not os.path.exists(_meta_path(cfg)):
return False
return all(os.path.exists(os.path.join(cfg.data_dir, f"{s}.bin")) for s in SPLITS)
def load_meta(cfg: ByteLMConfig) -> dict:
with open(_meta_path(cfg), "r", encoding="utf-8") as f:
return json.load(f)
def prepare_data(cfg: ByteLMConfig, logger=None) -> dict:
"""Build (or reuse) the packed byte shards. Returns the meta dict."""
logger = logger or get_logger("ksbyte.data_prep", cfg.run_dir)
cfg.validate()
if shards_exist(cfg) and not cfg.rebuild_data:
meta = load_meta(cfg)
logger.info(f"data shards already present in {cfg.data_dir} "
f"(train={meta['counts']['train']:,} tokens) — skipping prep")
return meta
# Guard the vendored normalizer BEFORE touching any text.
guard_report = run_guards(strict=True)
logger.info(f"normalizer guard OK ({guard_report['protected_count']} protected letters; "
f"tatweel->{guard_report['tatweel_maps_to']}, zwnj(raw)->{guard_report['zwnj_maps_to']})")
normalizer = LMNormalizer(NormConfig(
zwnj_policy=cfg.zwnj_policy,
digit_policy=cfg.digit_policy,
remove_diacritics=cfg.remove_diacritics,
))
os.makedirs(cfg.data_dir, exist_ok=True)
buffers: Dict[str, List[np.ndarray]] = {s: [] for s in SPLITS}
seen_hashes: set = set()
stats = {"raw": 0, "kept": 0, "dropped_short": 0, "dropped_ksr": 0,
"dropped_dup": 0, "content_bytes": 0, "words": 0}
for raw_text, ksr in _iter_raw_docs(cfg, logger):
stats["raw"] += 1
text = normalizer(raw_text)
if not (cfg.min_chars <= len(text) <= cfg.max_chars):
stats["dropped_short"] += 1
continue
ratio = ksr if ksr is not None else compute_ks_ratio(text)
if not cfg.keep_mixed_script and ratio < cfg.min_ks_ratio:
stats["dropped_ksr"] += 1
continue
if cfg.dedup:
hh = hashlib.sha1(text.encode("utf-8")).digest()
if hh in seen_hashes:
stats["dropped_dup"] += 1
continue
seen_hashes.add(hh)
split = _split_of(text, cfg)
buffers[split].append(_encode_doc(text))
stats["kept"] += 1
stats["content_bytes"] += len(text.encode("utf-8"))
stats["words"] += len(text.split())
if stats["raw"] % 50_000 == 0:
logger.info(f" processed {stats['raw']:,} raw docs (kept {stats['kept']:,})")
counts = {}
for split in SPLITS:
arrs = buffers[split]
stream = (np.concatenate(arrs) if arrs else np.empty(0, dtype=np.uint16))
path = os.path.join(cfg.data_dir, f"{split}.bin")
stream.tofile(path)
counts[split] = int(stream.size)
logger.info(f" wrote {split}.bin: {stream.size:,} tokens -> {path}")
if counts["train"] == 0:
raise RuntimeError("train split is empty after preparation — check filters/source")
bytes_per_word = (stats["content_bytes"] / stats["words"]) if stats["words"] else float("nan")
meta = {
"vocab_size": VOCAB_SIZE,
"bos_id": BOS_ID,
"eos_id": EOS_ID,
"counts": counts,
"bytes_per_word": bytes_per_word,
"stats": stats,
"normalization": {"zwnj_policy": cfg.zwnj_policy,
"digit_policy": cfg.digit_policy,
"remove_diacritics": cfg.remove_diacritics},
"source": (cfg.local_text_file or cfg.hf_dataset),
"dtype": "uint16",
}
with open(_meta_path(cfg), "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
logger.info(f"data prep done: kept {stats['kept']:,}/{stats['raw']:,} docs, "
f"bytes/word={bytes_per_word:.2f}, meta -> {_meta_path(cfg)}")
return meta
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser(description="Prepare ks_byte_lm data shards")
p.add_argument("--local_text_file", default=None)
p.add_argument("--data_dir", default="data")
p.add_argument("--rebuild", action="store_true")
args = p.parse_args()
cfg = ByteLMConfig().merge({
"local_text_file": args.local_text_file,
"data_dir": args.data_dir,
"rebuild_data": args.rebuild,
})
prepare_data(cfg)