tiny-llm / src /tinyllm /data.py
CoolDip00's picture
tiny-llm 75e4579: 6.5b step 5: 5a Space created, 5b secrets added
d656a30 verified
Raw History Blame Contribute Delete
5.36 kB
"""Dataset loading and text cleanup (D-008, D-009, D-018).
Every step that reads TinyStories (tokenizer training, tokenization) goes
through `load_clean`, so they all see exactly the same text.
"""
from pathlib import Path
import numpy as np
import torch
from datasets import Dataset, load_dataset
DATASET = "roneneldan/TinyStories"
DATA_DIR = Path(__file__).resolve().parents[2] / "data" # git-ignored
TOKEN_DTYPE = np.uint16 # vocab 8192 < 65536 (D-009)
# D-018: TinyStories has UTF-8 punctuation that was decoded as Windows-1252
# ("mojibake"), e.g. “ -> "“". Map it back to plain ASCII.
# Order matters: the 3-character sequences must run before the bare "â€".
_REPLACEMENTS: list[tuple[str, str]] = [
("“", '"'), # “ left double quote
("â€\x9d", '"'), # ” right double quote (when the 3rd byte survived)
("’", "'"), # ’ apostrophe / right single quote
("‘", "'"), # ‘ left single quote
("–", "-"), # – en dash
("—", "-"), # — em dash
("…", "..."), # … ellipsis
("â€", '"'), # ” right double quote whose 3rd byte (0x9D) was lost
("Â\xa0", " "), # no-break space
("Â\xad", ""), # soft hyphen
# Real (correctly encoded) typographic punctuation -> ASCII.
("“", '"'), ("”", '"'), ("‘", "'"), ("’", "'"),
("–", "-"), ("—", "-"), ("…", "..."), ("\xa0", " "), ("\xad", ""),
]
def clean_text(text: str) -> str | None:
"""Return cleaned ASCII text, or None if the story should be dropped.
Dropped: empty stories, and stories that still contain non-ASCII
characters after the replacements (rare leftovers such as 'é').
"""
if not text.isascii():
for bad, good in _REPLACEMENTS:
if bad in text:
text = text.replace(bad, good)
text = text.strip()
if not text or not text.isascii():
return None
return text
def _clean_batch(batch: dict) -> dict:
return {"text": [clean_text(t) or "" for t in batch["text"]]}
def _keep_batch(batch: dict) -> list[bool]:
return [t != "" for t in batch["text"]]
def load_clean(split: str, num_proc: int = 4) -> Dataset:
"""Load a TinyStories split ('train' or 'validation') with D-018 cleanup applied.
`datasets` caches the result, so only the first call does the work.
"""
ds = load_dataset(DATASET, split=split)
# With split=... (and no streaming) this is always a Dataset. The check narrows
# the declared Union return type for type checkers, and fails loudly otherwise.
assert isinstance(ds, Dataset), f"expected Dataset, got {type(ds).__name__}"
ds = ds.map(_clean_batch, batched=True, num_proc=num_proc, desc=f"clean {split}")
return ds.filter(_keep_batch, batched=True, num_proc=num_proc, desc=f"drop {split}")
def bin_path(split: str) -> Path:
"""'train' -> data/train.bin, 'validation' -> data/val.bin"""
return DATA_DIR / f"{'val' if split == 'validation' else split}.bin"
def write_tokens(split: str, tok, eos_id: int, batch_size: int = 10_000) -> tuple[int, int]:
"""Encode a cleaned split into one flat uint16 file: story EOS story EOS ...
Streams batch by batch, so RAM use stays small. Writes to a .tmp file and
renames at the end, so a crash never leaves a half-written .bin behind.
Returns (n_stories, n_tokens).
"""
ds = load_clean(split)
out = bin_path(split)
out.parent.mkdir(exist_ok=True)
tmp = out.with_suffix(".tmp")
n_tokens = 0
with open(tmp, "wb") as f:
for i in range(0, len(ds), batch_size):
encs = tok.encode_batch(ds[i: i + batch_size]["text"]) # Rust, multi-threaded
ids = np.fromiter(
(t for e in encs for t in (*e.ids, eos_id)), dtype=np.int64
)
assert ids.max() < np.iinfo(TOKEN_DTYPE).max
ids.astype(TOKEN_DTYPE).tofile(f)
n_tokens += len(ids)
tmp.replace(out)
return len(ds), n_tokens
def load_tokens(split: str) -> np.memmap:
"""Memory-map a .bin file: no RAM cost, the OS pages it in on demand."""
return np.memmap(bin_path(split), dtype=TOKEN_DTYPE, mode="r")
def get_batch(
tokens: np.ndarray,
batch_size: int,
seq_len: int,
rng: np.random.Generator,
device: str | torch.device = "cpu",
) -> tuple[torch.Tensor, torch.Tensor]:
"""Random training windows: x = tokens[s : s+T], y = tokens[s+1 : s+T+1].
Windows start anywhere and cross story boundaries; EOS tells the model where a
story ends. Sampling with replacement from a seeded `rng` makes runs reproducible,
and saving `rng.bit_generator.state` in a checkpoint makes resume exact.
On CUDA the batch goes through pinned memory so the copy can overlap with compute.
"""
if len(tokens) < seq_len + 1:
raise ValueError(f"need at least {seq_len + 1} tokens, got {len(tokens)}")
starts = rng.integers(0, len(tokens) - seq_len, size=batch_size) # last valid start: len-T-1
idx = starts[:, None] + np.arange(seq_len + 1) # (B, T+1)
chunk = torch.from_numpy(tokens[idx].astype(np.int64)) # gather, then widen for embedding
if torch.device(device).type == "cuda":
chunk = chunk.pin_memory().to(device, non_blocking=True)
return chunk[:, :-1], chunk[:, 1:]