| """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") |
|
|
|
|
| |
| 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): |
| |
| |
| |
| 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) |
|
|
|
|
| |
| 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" |
|
|
|
|
| |
| 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_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) |
|
|