SGJM / src /sgjm /training /data.py
adampippert's picture
SGJM 2026.6.5 — code/docs
e51ccda verified
Raw
History Blame Contribute Delete
6.65 kB
from __future__ import annotations
import os
import random
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
TINYSHAKESPEARE_URL = (
"https://raw.githubusercontent.com/karpathy/char-rnn/master/data/"
"tinyshakespeare/input.txt"
)
def _cache_dir() -> Path:
base = os.environ.get("SGJM_CACHE_DIR")
if base:
path = Path(base)
else:
path = Path.home() / ".cache" / "sgjm"
path.mkdir(parents=True, exist_ok=True)
return path
def download_tinyshakespeare(force: bool = False) -> Path:
target = _cache_dir() / "tinyshakespeare.txt"
if target.exists() and not force:
return target
try:
with urllib.request.urlopen(TINYSHAKESPEARE_URL, timeout=30) as resp:
data = resp.read()
except (urllib.error.URLError, TimeoutError) as e:
raise RuntimeError(
f"failed to download tinyshakespeare from {TINYSHAKESPEARE_URL}: {e}. "
"Pre-download it manually and pass --data-path."
) from e
target.write_bytes(data)
return target
def synthetic_corpus(n_bytes: int = 1 << 20, seed: int = 0) -> bytes:
# Deterministic byte stream with both periodic structure and a 2nd-order
# Markov component. Gives the model something learnable but non-trivial
# without pulling external data.
rng = random.Random(seed)
pattern = bytes(rng.randrange(256) for _ in range(96))
period = len(pattern)
out = bytearray(n_bytes)
prev1 = 0
prev2 = 0
for i in range(n_bytes):
mix = (pattern[i % period] ^ ((prev1 * 17 + prev2 * 31 + i * 7) & 0xFF))
out[i] = mix & 0xFF
prev2 = prev1
prev1 = out[i]
return bytes(out)
def _python_stdlib_root() -> Path:
"""Return the root directory of the active Python stdlib."""
import sysconfig
stdlib = sysconfig.get_path("stdlib")
if stdlib and Path(stdlib).is_dir():
return Path(stdlib)
import sys
return Path(sys.prefix) / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}"
_EXCLUDE_PARTS = {"test", "tests", "__pycache__", ".egg-info", "dist-info"}
def _collect_py_files(root: Path) -> list[Path]:
"""Return sorted .py files under root, excluding test and cache directories."""
return sorted(
p for p in root.rglob("*.py")
if not any(part in _EXCLUDE_PARTS for part in p.parts)
)
def _assemble_corpus(py_files: list[Path], n_bytes: int) -> bytes:
sep = b"\n# ---\n"
chunks: list[bytes] = []
total = 0
for p in py_files:
try:
chunk = p.read_bytes()
except OSError:
continue
chunks.append(chunk)
total += len(chunk) + len(sep)
if total >= n_bytes:
break
return sep.join(chunks)[:n_bytes]
def _site_packages_root() -> Path | None:
"""Return the site-packages directory for the active Python installation."""
import sysconfig
sp = sysconfig.get_path("purelib")
if sp and Path(sp).is_dir():
return Path(sp)
return None
def load_python_corpus(
path: str | None = None,
n_bytes: int = 1 << 20,
extended: bool = False,
) -> bytes:
"""Collect .py files into a byte corpus.
If `path` is given: collect from that directory.
If `extended=True` (and no path): collect from Python stdlib + site-packages.
Otherwise: collect from Python stdlib only.
"""
if path:
root = Path(path)
if not root.is_dir():
raise FileNotFoundError(f"Python corpus directory not found: {root}")
py_files = _collect_py_files(root)
if not py_files:
raise ValueError(f"no .py files found under {root}")
return _assemble_corpus(py_files, n_bytes)
roots: list[Path] = [_python_stdlib_root()]
if extended:
sp = _site_packages_root()
if sp:
roots.append(sp)
py_files: list[Path] = []
for r in roots:
if r.is_dir():
py_files.extend(_collect_py_files(r))
if not py_files:
raise ValueError("no .py files found in Python stdlib or site-packages")
# Deduplicate (stdlib and site-packages can overlap)
seen: set[Path] = set()
unique: list[Path] = []
for p in py_files:
if p not in seen:
seen.add(p)
unique.append(p)
return _assemble_corpus(unique, n_bytes)
def load_corpus(
path: str | None = None,
n_bytes: int = 1 << 20,
seed: int = 0,
source: str = "auto",
) -> bytes:
"""Load a byte corpus from `path`, a known source, or synthetic fallback.
source:
"auto" — use path if given, else synthetic
"synthetic" — always synthetic, ignore path
"tinyshakespeare" — download (and cache) Karpathy's tinyshakespeare
"file" — require path; raise if missing
"python" — collect .py files from path (or Python stdlib)
"""
if source == "synthetic":
return synthetic_corpus(n_bytes, seed)
if source == "tinyshakespeare":
target = download_tinyshakespeare()
return target.read_bytes()
if source == "python":
return load_python_corpus(path=path, n_bytes=n_bytes)
if source == "python_extended":
return load_python_corpus(path=path, n_bytes=n_bytes, extended=True)
if source == "file":
if not path or not os.path.exists(path):
raise FileNotFoundError(f"data file not found: {path!r}")
with open(path, "rb") as f:
return f.read()
# auto
if path and os.path.exists(path):
with open(path, "rb") as f:
return f.read()
return synthetic_corpus(n_bytes, seed)
@dataclass
class ByteDataset:
data: bytes
seq_len: int
def __post_init__(self) -> None:
if len(self.data) <= self.seq_len + 1:
raise ValueError(
f"corpus too small ({len(self.data)} bytes) for seq_len={self.seq_len}"
)
def __len__(self) -> int:
return len(self.data) - self.seq_len - 1
def sample(self, rng: random.Random) -> tuple[list[int], list[int]]:
i = rng.randrange(len(self))
chunk = self.data[i : i + self.seq_len + 1]
return list(chunk[:-1]), list(chunk[1:])
def batch(
self,
batch_size: int,
rng: random.Random,
) -> tuple[list[list[int]], list[list[int]]]:
xs: list[list[int]] = []
ys: list[list[int]] = []
for _ in range(batch_size):
x, y = self.sample(rng)
xs.append(x)
ys.append(y)
return xs, ys