Files
rzen 9fd56b6063 Add degradation experiments: one-parameter-at-a-time ablations
Controlled study of how the model degrades. Four variants, each = v1 with a
single field replaced (dataclasses.replace), so differences are attributable:
- small: capacity down (2L/64d, ~0.38M params)
- short: training time down (max_iters 20k->2k)
- ctx:   context window down (256->64)
- data:  data quantity down (max_train_tokens->1M; new TrainConfig knob + train.py slice)

- scripts/compare.py: sample same prompt across all trained configs with a
  shared seed, print side by side, write reports/compare.md
- tests/test_configs.py: enforces one-parameter-at-a-time (only intended fields
  differ from v1) + small param count (3 tests). Full suite: 29 passing.
2026-07-12 12:58:48 -04:00

207 lines
9.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# experiment-llm
Training a small language model completely from scratch on a MacBook Pro (M1):
own tokenizer, own transformer implementation, own training loop. No
pretrained weights, no model libraries, no cloud compute.
The goal of **iteration 1** is *completeness and clarity*, not model quality:
a small model (~45M parameters) taken through the entire pipeline end to end,
with every component written to be read and understood.
## Principles
- **From scratch.** The tokenizer, model, training loop, and sampler are
implemented in this repo. Dependencies are limited to PyTorch (tensor ops +
autograd + MPS backend) and NumPy. No HuggingFace libraries, no nanoGPT
imports — reference implementations are for reading, not importing.
- **Self-sufficient.** After a one-time dataset download, everything runs
offline on the local machine. No API calls, no cloud GPUs, no external
evaluation services.
- **Clarity over speed.** Straightforward code beats clever code. Every phase
produces an inspectable artifact (tokenizer vocab, token dumps, loss curves,
sample outputs).
- **Config-driven.** One experiment = one config. Model size, context length,
and training schedule live in a config file, so iteration 2 is a config
change, not a rewrite.
## Setup
Requires Python 3.13 (Homebrew) on Apple Silicon.
```sh
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python scripts/check_mps.py # verify the Apple GPU is usable
python scripts/download_data.py # one-time, ~2.2 GB into data/raw/
```
After the download, everything runs offline.
## Why TinyStories
General web text is hopeless at this scale — a 5M-parameter model trained on
it produces word salad. [TinyStories](https://arxiv.org/abs/2305.07759)
(Eldan & Li, 2023) is a ~2GB corpus of synthetic children's stories with a
deliberately restricted vocabulary, built precisely so that very small models
can learn coherent English. It is the smallest known regime where a
from-scratch model produces output that *makes sense* — grammatical sentences,
consistent characters, simple plots. Our v1 model sits below the paper's
coherence sweet spot (~30M params); some incoherence is expected and fine.
## Pipeline
```
raw text ──► tokenizer training ──► tokenized corpus ──► pretraining ──► sampling / eval
(phase 1) (phase 2) (phase 2) (phase 3) (phase 4)
```
### Phase 0 — Setup
- Standard `venv` + `pip` environment; dependencies (`torch`, `numpy`) pinned
to exact versions in `requirements.txt`.
- One-time download of TinyStoriesV2 (plain `.txt` files fetched over HTTPS
into `data/raw/`, which is gitignored). This is the only network access in
the whole project.
- Deliverable: `scripts/download_data.py`, pinned environment, smoke test that
MPS is available.
### Phase 1 — Tokenizer
- Byte-pair encoding implemented from scratch: trainer, encoder, decoder.
- Trained on a subsample of the corpus (BPE training is quadratic-ish in pure
Python; a ~50MB sample is plenty for a 4k vocab on this data).
- Vocab size 4,096 — TinyStories' restricted vocabulary doesn't need more,
and a small vocab keeps the embedding table (and the model) small.
- Deliverables: `src/tokenizer.py`, saved vocab/merges file, round-trip tests
(`decode(encode(s)) == s`), a short report on compression ratio
(bytes per token).
### Phase 2 — Data pipeline
- Encode the full corpus once with the trained tokenizer into flat binary
token files (`data/tokens/train.bin`, `val.bin`) — memory-mapped at training
time, so the 2GB corpus never has to fit in RAM as Python objects.
- Batching: random crops of `context_length + 1` tokens; inputs/targets are
shifted views of the same crop.
- Deliverables: `src/dataset.py`, `scripts/encode_corpus.py`, token counts
logged for train/val.
### Phase 3 — Model
- Decoder-only transformer, GPT-style, implemented module by module:
token embeddings, causal self-attention, MLP block, RMSNorm, residual
stream, weight-tied output head.
- v1 configuration (~4.3M params):
| knob | value |
|---|---|
| layers | 4 |
| d_model | 256 |
| heads | 4 |
| context length | 256 |
| vocab | 4,096 |
| positions | learned embeddings (v1 simplicity; RoPE is an iteration-2 candidate) |
- Deliverables: `src/model.py`, a parameter-count printout that matches the
hand-computed estimate, a forward-pass shape test.
### Phase 4 — Training
- Plain training loop: AdamW, cosine LR schedule with warmup, gradient
clipping, periodic validation loss, checkpointing (resumable).
- Runs on MPS; expected wall-clock for v1 is a few hours for ~100200M tokens
(roughly Chinchilla-optimal for this size — we don't need to see the whole
corpus).
- Loss curve logged to a plain CSV (`reports/loss-v1.csv`) and rendered as an
ASCII chart by `scripts/plot_loss.py` — no plotting dependency; the chart is
also saved to `reports/loss-v1.txt` as a committable artifact.
- Deliverables: `src/train.py`, `configs/v1.py`, `scripts/plot_loss.py`,
checkpoints in `checkpoints/` (gitignored), loss log + chart in `reports/`.
Commands:
```sh
python src/train.py --config v1 --overfit # sanity check (loss -> ~0)
python src/train.py --config v1 # real run (resumable: --resume)
python scripts/plot_loss.py --config v1 # ASCII loss curve
```
### Phase 5 — Sampling & evaluation
- Autoregressive sampler with temperature and top-k.
- Evaluation is fully local:
- validation loss / perplexity,
- a fixed prompt suite (story openings) sampled at every checkpoint so
progress is visible qualitatively over training,
- a sample gallery in `reports/` for the final model.
- Deliverables: `src/sample.py`, `reports/samples-v1.md`.
## Project layout
```
experiment-llm/
├── README.md
├── requirements.txt
├── configs/ # one file per experiment (v1, v2, ...)
├── scripts/ # one-shot entry points: download, encode, plots
├── artifacts/ # trained tokenizer.json (small, committed)
├── src/
│ ├── tokenizer.py # BPE: train / encode / decode
│ ├── dataset.py # memmapped token files, batch sampling
│ ├── model.py # the transformer
│ ├── train.py # training loop
│ └── sample.py # generation
├── tests/ # round-trip + shape + overfit-one-batch tests
├── data/ # gitignored: raw/ (txt), tokens/ (bin)
├── checkpoints/ # gitignored
└── reports/ # loss curves, sample galleries (committed)
```
## Definition of done (iteration 1)
- [x] Environment reproducible with `pip install -r requirements.txt`; dataset downloaded and hashed
- [x] BPE tokenizer trains, round-trips, and is saved to disk
- [x] Corpus encoded to binary token files
- [x] Model builds; parameter count matches the estimate
- [x] Overfit-one-batch sanity check passes (loss → ~0 on a single batch)
- [x] Full training run completes on MPS with checkpoints + loss curve
(v1: 20k steps, ~58 min on M1, val loss 1.52 / perplexity ~4.6)
- [x] Sampler produces stories from prompts; gallery committed to `reports/`
(v1: grammatical, story-arc coherent; plot logic wobbles as expected)
- [x] Every phase re-runnable from a clean clone with documented commands
## Degradation experiments (iteration 1.5)
A controlled study of *how* a model gets worse: start from the v1 baseline and
change exactly **one** variable per run, so any difference in the output is
attributable to that variable. Each variant config is literally `v1` with one
field replaced (see `configs/*.py`); `tests/test_configs.py` enforces that only
the intended field changes.
| config | one change from v1 | isolates | expected failure mode |
|---|---|---|---|
| `v1` | — (baseline) | — | grammatical, coherent story arcs |
| `small` | 2L/64d, ~0.38M params | capacity | generic, repetitive, forgets rare words |
| `short` | `max_iters` 20k→2k | training time | half-learned grammar, weaker structure |
| `ctx` | `context_length` 256→64 | long-range coherence | fine locally, plot threads break sooner |
| `data` | `max_train_tokens`→1M | data quantity | overfits: memorizes, novel prompts collapse |
Run each (they all train faster than v1; `short` is ~minutes):
```sh
python src/train.py --config small # then short, ctx, data
python scripts/plot_loss.py --config small
```
Then compare generated text across every trained variant, side by side (same
prompt, same RNG seed, so differences reflect the model):
```sh
python scripts/compare.py # default prompt, all configs
python scripts/compare.py --suite # every suite prompt -> reports/compare.md
python scripts/compare.py --configs v1,small # a subset
```
## Iteration 2 candidates (out of scope for now)
- Scale toward the coherence sweet spot (~1530M params, longer training)
- RoPE positions, SwiGLU MLP, other modern architecture upgrades
- Instruction tuning on TinyStories-Instruct (pretrain → SFT arc)
- MLX port for faster training on Apple Silicon