download.py
Fetches the raw corpus from Hugging Face.
36 lines
· Explained in chapter 9
· Open as text
"""Fetch the raw corpus from Hugging Face into raw/ (about 6.5 GB).
python download.py --out raw
"""
import argparse
import os
import urllib.request
HF = 'https://huggingface.co/datasets'
FILES = {
'TinyStoriesV2-GPT4-train.txt': f'{HF}/roneneldan/TinyStories/resolve/main/TinyStoriesV2-GPT4-train.txt',
'TinyStoriesV2-GPT4-valid.txt': f'{HF}/roneneldan/TinyStories/resolve/main/TinyStoriesV2-GPT4-valid.txt',
'soda-train.parquet': f'{HF}/allenai/soda/resolve/main/train.parquet',
'everyday-train.parquet': f'{HF}/HuggingFaceTB/everyday-conversations-llama3.1-2k/resolve/main/data/train_sft-00000-of-00001.parquet',
**{f'simplestories-{i}.parquet': f'{HF}/SimpleStories/SimpleStories/resolve/main/data/train-0000{i}-of-00007.parquet'
for i in range(7)},
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--out', default='raw')
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
for name, url in FILES.items():
path = os.path.join(args.out, name)
if os.path.exists(path):
print('have', name)
continue
print('get ', name, flush=True)
urllib.request.urlretrieve(url, path + '.part')
os.rename(path + '.part', path)
if __name__ == '__main__':
main()
tokenizer.py
Byte-level BPE: training, encoding and decoding.
132 lines
· Explained in chapter 6
· Open as text
"""Byte-level BPE: turn text into a list of integers and back.
Training starts from the 256 possible bytes and repeatedly glues together the
pair of neighbouring tokens that occurs most often. Every glued pair becomes a
new token. Encoding replays the same merges in the same order.
"""
import json
from collections import Counter, defaultdict
import regex as re
# Split text into chunks first, so a merge never crosses a word boundary:
# contractions, words with their leading space, single digits, punctuation, spaces.
SPLIT = re.compile(r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")
SPECIALS = ['<|endoftext|>', '<|user|>', '<|assistant|>', '<|end|>']
class Tokenizer:
def __init__(self, merges=(), specials=SPECIALS):
self.merges = {tuple(pair): 256 + i for i, pair in enumerate(merges)}
self.vocab = {i: bytes([i]) for i in range(256)}
for (a, b), idx in self.merges.items():
self.vocab[idx] = self.vocab[a] + self.vocab[b]
first_special = 256 + len(self.merges)
self.specials = {s: first_special + i for i, s in enumerate(specials)}
for s, idx in self.specials.items():
self.vocab[idx] = s.encode()
self.special_split = re.compile('(' + '|'.join(re.escape(s) for s in specials) + ')')
self.cache = {}
@property
def vocab_size(self):
return len(self.vocab)
def __getitem__(self, special):
return self.specials[special]
# ---- training -------------------------------------------------------
@classmethod
def train(cls, texts, vocab_size, specials=SPECIALS, verbose=False):
n_merges = vocab_size - 256 - len(specials)
words = Counter()
for text in texts:
for chunk in SPLIT.findall(text):
words[chunk] += 1
# every distinct chunk is a list of token ids, counted once with its frequency
seqs = [list(w.encode()) for w in words]
freqs = list(words.values())
pairs = Counter()
where = defaultdict(set) # pair -> indices of chunks that contain it
for i, seq in enumerate(seqs):
for pair in zip(seq, seq[1:]):
pairs[pair] += freqs[i]
where[pair].add(i)
merges = []
for step in range(n_merges):
if not pairs:
break
best = max(pairs, key=pairs.get)
new_id = 256 + step
merges.append(best)
for i in list(where[best]):
seq, f = seqs[i], freqs[i]
for pair in zip(seq, seq[1:]): # forget this chunk's old pairs
pairs[pair] -= f
if pairs[pair] <= 0:
del pairs[pair]
where[pair].discard(i)
seq = merge(seq, best, new_id)
seqs[i] = seq
for pair in zip(seq, seq[1:]): # and count the new ones
pairs[pair] += f
where[pair].add(i)
if verbose and step % 500 == 0:
tok = cls(merges).vocab[new_id]
print(f'merge {step:5d}: {tok!r}')
return cls(merges, specials)
# ---- encoding -------------------------------------------------------
def encode_chunk(self, chunk):
ids = self.cache.get(chunk)
if ids is not None:
return ids
ids = list(chunk.encode())
while len(ids) > 1:
# the pair that was learned earliest gets merged first
pair = min(zip(ids, ids[1:]), key=lambda p: self.merges.get(p, float('inf')))
if pair not in self.merges:
break
ids = merge(ids, pair, self.merges[pair])
if len(self.cache) < 500_000:
self.cache[chunk] = ids
return ids
def encode(self, text, allow_special=True):
out = []
parts = self.special_split.split(text) if allow_special else [text]
for part in parts:
if part in self.specials and allow_special:
out.append(self.specials[part])
continue
for chunk in SPLIT.findall(part):
out.extend(self.encode_chunk(chunk))
return out
def decode(self, ids):
return b''.join(self.vocab[i] for i in ids).decode('utf-8', errors='replace')
# ---- saving ---------------------------------------------------------
def save(self, path):
with open(path, 'w') as f:
json.dump({'merges': [list(p) for p in self.merges], 'specials': list(self.specials)}, f)
@classmethod
def load(cls, path):
with open(path) as f:
data = json.load(f)
return cls(data['merges'], data['specials'])
def merge(ids, pair, new_id):
"""Replace every occurrence of `pair` in `ids` with `new_id`."""
out, i = [], 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
out.append(new_id)
i += 2
else:
out.append(ids[i])
i += 1
return out
prepare.py
Cleans and deduplicates the texts, trains the tokenizer and turns the corpus into files of token ids.
149 lines
· Explained in chapter 9
· Open as text
"""Build the pre-training corpus: clean, deduplicate, train the tokenizer, encode.
Sources (all English, all openly licensed):
- TinyStories V2 (GPT-4 part) - short stories in a 3-4 year old's vocabulary
- SimpleStories - simple stories with many topics, themes and styles
- SODA - everyday two-person dialogues with a short narrative
Output: data/train.bin and data/val.bin (uint16 token ids, every document starts
with <|endoftext|>), data/tokenizer.json and data/corpus_stats.json.
"""
import argparse
import glob
import hashlib
import json
import os
import random
import time
from multiprocessing import Pool
import numpy as np
import pyarrow.parquet as pq
from tokenizer import Tokenizer
REPLACE = str.maketrans({'‘': "'", '’': "'", '“': '"', '”': '"', '–': '-',
'—': ' - ', '…': '...', ' ': ' ', '\r': ''})
def clean(text):
"""Normalise quotes and dashes, drop stray spaces; None if the text should go."""
text = text.translate(REPLACE).strip()
lines = [' '.join(line.split()) for line in text.split('\n')]
text = '\n'.join(lines).strip()
while '\n\n\n' in text:
text = text.replace('\n\n\n', '\n\n')
if len(text) < 80:
return None
if sum(ord(c) > 126 for c in text) > 0.002 * len(text): # other scripts, mojibake
return None
return text
def tinystories(raw):
with open(f'{raw}/TinyStoriesV2-GPT4-train.txt', encoding='utf-8') as f:
for story in f.read().split('<|endoftext|>'):
yield story
def simplestories(raw):
for path in sorted(glob.glob(f'{raw}/simplestories-[0-9].parquet')):
yield from pq.read_table(path, columns=['story']).column('story').to_pylist()
def soda(raw):
table = pq.read_table(f'{raw}/soda-train.parquet', columns=['narrative', 'dialogue', 'speakers'])
for narrative, lines, speakers in zip(*(table.column(c).to_pylist() for c in table.column_names)):
if len(speakers) != len(lines):
continue
dialogue = '\n'.join(f'{who}: {line}' for who, line in zip(speakers, lines))
yield f'{narrative}\n\n{dialogue}'
SOURCES = {'tinystories': tinystories, 'simplestories': simplestories, 'soda': soda}
_tok = None
def _init(path):
global _tok
_tok = Tokenizer.load(path)
def _encode(texts):
eot = _tok['<|endoftext|>']
out = []
for t in texts:
out.append(eot)
out.extend(_tok.encode(t, allow_special=False))
return np.array(out, dtype=np.uint16)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--raw', default='raw')
ap.add_argument('--out', default='data')
ap.add_argument('--vocab', type=int, default=8192)
ap.add_argument('--tok-docs', type=int, default=40_000, help='documents per source for BPE training')
ap.add_argument('--val-frac', type=float, default=0.005)
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
rng = random.Random(1337)
# 1. clean + exact deduplication
docs, stats = {}, {}
for name, read in SOURCES.items():
seen, kept, raw_count = set(), [], 0
for text in read(args.raw):
raw_count += 1
text = clean(text)
if text is None:
continue
h = hashlib.md5(text.lower().encode()).digest()
if h in seen:
continue
seen.add(h)
kept.append(text)
rng.shuffle(kept)
docs[name] = kept
stats[name] = {'raw': raw_count, 'kept': len(kept), 'chars': sum(map(len, kept))}
print(f'{name}: {raw_count} -> {len(kept)} documents', flush=True)
# 2. tokenizer on a balanced sample
tok_path = f'{args.out}/tokenizer.json'
if not os.path.exists(tok_path):
t0 = time.time()
sample = [d for name in docs for d in docs[name][:args.tok_docs]]
tok = Tokenizer.train(sample, args.vocab, verbose=True)
tok.save(tok_path)
print(f'tokenizer: {tok.vocab_size} tokens in {time.time() - t0:.0f}s', flush=True)
# 3. encode in parallel; a small slice of every source goes to validation
tok = Tokenizer.load(tok_path)
eot = tok['<|endoftext|>']
train, val = [], []
with Pool(max(1, os.cpu_count() - 2), initializer=_init, initargs=(tok_path,)) as pool:
for name, texts in docs.items():
n_val = int(len(texts) * args.val_frac)
for split, part in ((val, texts[:n_val]), (train, texts[n_val:])):
chunks = [part[i:i + 2000] for i in range(0, len(part), 2000)]
split.append(np.concatenate(pool.map(_encode, chunks)))
stats[name]['tokens'] = int(len(train[-1]) + len(val[-1]))
print(f'{name}: {stats[name]["tokens"] / 1e6:.1f}M tokens', flush=True)
# shuffle whole runs of documents between sources, so any stretch of the file is a fair mix
blocks = []
for arr in train:
starts = np.flatnonzero(arr == eot)
cuts = starts[np.searchsorted(starts, np.arange(1_000_000, len(arr), 1_000_000))]
blocks.extend(np.split(arr, np.unique(cuts)))
rng.shuffle(blocks)
np.concatenate(blocks).tofile(f'{args.out}/train.bin')
np.concatenate(val).tofile(f'{args.out}/val.bin')
stats['train_tokens'] = int(sum(len(b) for b in blocks))
stats['val_tokens'] = int(sum(len(v) for v in val))
with open(f'{args.out}/corpus_stats.json', 'w') as f:
json.dump(stats, f, indent=1)
if __name__ == '__main__':
main()
model.py
The model itself: RMSNorm, RoPE, attention with QK-norm, SwiGLU, tied embeddings.
174 lines
· Explained in chapter 8
· Open as text
"""Sprout: a tiny GPT, all of it in one file.
A decoder-only transformer in the style of today's open models:
- pre-norm blocks with RMSNorm,
- rotary position embeddings (RoPE),
- multi-head causal self-attention with QK-norm,
- a SwiGLU feed-forward layer,
- input and output embeddings shared (weight tying),
- no biases anywhere.
"""
import math
from dataclasses import dataclass, asdict
import torch
import torch.nn as nn
import torch.nn.functional as F
@dataclass
class Config:
vocab_size: int = 8192 # how many different tokens the model knows
context: int = 512 # how many tokens it can look back at
n_layer: int = 8 # transformer blocks stacked on top of each other
n_head: int = 8 # attention heads per block
d_model: int = 512 # width of the residual stream
d_ff: int = 1408 # hidden width of the feed-forward layer
rope_base: float = 10000.0
def to_dict(self):
return asdict(self)
class RMSNorm(nn.Module):
"""Rescale a vector to unit root-mean-square, then apply a learned gain."""
def __init__(self, dim, eps=1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def forward(self, x):
return F.rms_norm(x.float(), (x.size(-1),), self.weight.float(), self.eps).type_as(x)
def rope_tables(head_dim, context, base):
"""cos/sin for every position and every frequency, shape (context, head_dim/2)."""
inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2).float() / head_dim))
angles = torch.outer(torch.arange(context).float(), inv_freq)
return angles.cos(), angles.sin()
def apply_rope(x, cos, sin):
"""Rotate pairs (x[i], x[i + d/2]) by a position-dependent angle.
x: (batch, heads, time, head_dim); cos/sin: (time, head_dim/2).
"""
d = x.size(-1) // 2
x1, x2 = x[..., :d], x[..., d:]
return torch.cat((x1 * cos - x2 * sin, x1 * sin + x2 * cos), dim=-1)
class Attention(nn.Module):
def __init__(self, cfg):
super().__init__()
self.n_head = cfg.n_head
self.head_dim = cfg.d_model // cfg.n_head
self.qkv = nn.Linear(cfg.d_model, 3 * cfg.d_model, bias=False)
self.proj = nn.Linear(cfg.d_model, cfg.d_model, bias=False)
self.q_norm = RMSNorm(self.head_dim)
self.k_norm = RMSNorm(self.head_dim)
def forward(self, x, cos, sin):
B, T, C = x.shape
q, k, v = self.qkv(x).split(C, dim=-1)
# (B, T, C) -> (B, heads, T, head_dim)
q = q.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
k = k.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
v = v.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
q, k = self.q_norm(q), self.k_norm(k)
q, k = apply_rope(q, cos, sin), apply_rope(k, cos, sin)
# softmax(q·k / sqrt(d)) · v, every token sees only itself and the past
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
y = y.transpose(1, 2).contiguous().view(B, T, C)
return self.proj(y)
class FeedForward(nn.Module):
"""SwiGLU: silu(x·W1) ⊙ (x·W3), projected back with W2."""
def __init__(self, cfg):
super().__init__()
self.w1 = nn.Linear(cfg.d_model, cfg.d_ff, bias=False)
self.w3 = nn.Linear(cfg.d_model, cfg.d_ff, bias=False)
self.w2 = nn.Linear(cfg.d_ff, cfg.d_model, bias=False)
def forward(self, x):
return self.w2(F.silu(self.w1(x)) * self.w3(x))
class Block(nn.Module):
def __init__(self, cfg):
super().__init__()
self.norm1 = RMSNorm(cfg.d_model)
self.attn = Attention(cfg)
self.norm2 = RMSNorm(cfg.d_model)
self.ffn = FeedForward(cfg)
def forward(self, x, cos, sin):
x = x + self.attn(self.norm1(x), cos, sin) # tokens exchange information
x = x + self.ffn(self.norm2(x)) # each token thinks on its own
return x
class GPT(nn.Module):
def __init__(self, cfg):
super().__init__()
self.cfg = cfg
self.embed = nn.Embedding(cfg.vocab_size, cfg.d_model)
self.blocks = nn.ModuleList(Block(cfg) for _ in range(cfg.n_layer))
self.norm = RMSNorm(cfg.d_model)
self.head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
self.head.weight = self.embed.weight # weight tying
cos, sin = rope_tables(cfg.d_model // cfg.n_head, cfg.context, cfg.rope_base)
self.register_buffer('cos', cos, persistent=False)
self.register_buffer('sin', sin, persistent=False)
self.apply(self._init)
# residual projections start small so every block begins close to "do nothing"
for name, p in self.named_parameters():
if name.endswith('proj.weight') or name.endswith('w2.weight'):
nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * cfg.n_layer))
@staticmethod
def _init(m):
if isinstance(m, (nn.Linear, nn.Embedding)):
nn.init.normal_(m.weight, mean=0.0, std=0.02)
def num_params(self):
return sum(p.numel() for p in self.parameters())
def forward(self, idx, targets=None, loss_mask=None):
T = idx.size(1)
x = self.embed(idx)
cos, sin = self.cos[:T], self.sin[:T]
for block in self.blocks:
x = block(x, cos, sin)
logits = self.head(self.norm(x))
if targets is None:
return logits, None
loss = F.cross_entropy(logits.float().view(-1, logits.size(-1)), targets.reshape(-1), reduction='none')
if loss_mask is None:
return logits, loss.mean()
mask = loss_mask.reshape(-1).float()
return logits, (loss * mask).sum() / mask.sum().clamp(min=1)
@torch.no_grad()
def generate(self, idx, max_new, temperature=0.8, top_k=None, top_p=0.95, stop=None):
for _ in range(max_new):
logits, _ = self(idx[:, -self.cfg.context:])
logits = logits[:, -1, :].float() / max(temperature, 1e-5)
if top_k:
kth = torch.topk(logits, top_k).values[:, -1, None]
logits[logits < kth] = -float('inf')
probs = F.softmax(logits, dim=-1)
if top_p and top_p < 1.0:
sorted_p, order = probs.sort(descending=True)
drop = sorted_p.cumsum(-1) - sorted_p > top_p
sorted_p[drop] = 0
probs = torch.zeros_like(probs).scatter(-1, order, sorted_p)
probs /= probs.sum(-1, keepdim=True)
nxt = torch.multinomial(probs, 1)
idx = torch.cat((idx, nxt), dim=1)
if stop is not None and nxt.item() in stop:
break
return idx
muon.py
The Muon optimiser for the hidden matrices.
63 lines
· Explained in chapter 10
· Open as text
"""Muon: momentum, then orthogonalise the update of every weight matrix.
For a matrix W, plain SGD adds -lr * G. Muon replaces the momentum-averaged G
by the closest orthogonal matrix U·Vᵀ (from G = U·S·Vᵀ): every direction gets
the same step size, so rare but useful directions are not drowned out by a
few dominant ones. Five Newton–Schulz iterations approximate U·Vᵀ cheaply.
Only for 2-D hidden weights. Embeddings and norm gains stay with AdamW.
After Keller Jordan, https://kellerjordan.github.io/posts/muon/
"""
from collections import defaultdict
import torch
def orthogonalize(G, steps=5):
"""G: (..., rows, cols) -> the nearest semi-orthogonal matrices, batched."""
a, b, c = 3.4445, -4.7750, 2.0315 # tuned for fast convergence
X = G.bfloat16()
tall = X.size(-2) > X.size(-1)
if tall:
X = X.mT
X = X / (X.norm(dim=(-2, -1), keepdim=True) + 1e-7)
for _ in range(steps):
A = X @ X.mT
X = a * X + (b * A + c * A @ A) @ X
if tall:
X = X.mT
return X.to(G.dtype)
class Muon(torch.optim.Optimizer):
def __init__(self, params, lr=0.02, momentum=0.95, weight_decay=0.0, split=None):
# split: {param: n} treats a fused weight (like q, k, v) as n separate matrices
self.split = split or {}
super().__init__(params, dict(lr=lr, momentum=momentum, weight_decay=weight_decay))
@torch.no_grad()
def step(self):
for group in self.param_groups:
# matrices of the same shape are orthogonalised together, in one batch
by_shape = defaultdict(list)
for p in group['params']:
if p.grad is None:
continue
state = self.state[p]
if 'momentum' not in state:
state['momentum'] = torch.zeros_like(p)
buf = state['momentum']
buf.lerp_(p.grad, 1 - group['momentum'])
g = p.grad.lerp(buf, group['momentum']) # Nesterov look-ahead
n = self.split.get(p, 1)
by_shape[(p.size(0) // n, p.size(1))].append((p, g.view(n, -1, p.size(1))))
for (rows, cols), items in by_shape.items():
updates = orthogonalize(torch.cat([g for _, g in items]))
scale = max(1.0, rows / cols) ** 0.5
i = 0
for p, g in items:
u = updates[i:i + g.size(0)].reshape_as(p)
i += g.size(0)
if group['weight_decay']:
p.mul_(1 - group['lr'] * group['weight_decay'])
p.add_(u, alpha=-group['lr'] * scale)
train.py
The pre-training loop: batches, bf16, torch.compile, the learning-rate schedule, samples along the way.
211 lines
· Explained in chapter 10
· Open as text
"""Pre-train Sprout: show it text, ask for the next token, nudge the weights.
python train.py --out runs/base --tokens 400e6
Writes runs/<name>/log.jsonl (loss, learning rate, samples as it learns)
and checkpoints ckpt_<step>.pt + ckpt_final.pt.
"""
import argparse
import json
import math
import os
import time
import numpy as np
import torch
from model import GPT, Config
from muon import Muon
from tokenizer import Tokenizer
PROMPTS = ['Once upon a time', 'Lily looked at the sky and said', 'Tom: Hi! How are you today?\nAnna:',
'The best thing about summer is']
def get_device():
if torch.cuda.is_available():
return 'cuda'
if torch.backends.mps.is_available():
return 'mps'
return 'cpu'
class Batches:
"""Random windows of context+1 tokens from a flat file of token ids."""
def __init__(self, path, device, seed=0):
self.data = np.memmap(path, dtype=np.uint16, mode='r')
self.device = device
self.rng = np.random.default_rng(seed)
def get(self, batch, context):
starts = self.rng.integers(0, len(self.data) - context - 1, batch)
rows = np.stack([self.data[s:s + context + 1] for s in starts]).astype(np.int64)
rows = torch.from_numpy(rows).to(self.device, non_blocking=True)
return rows[:, :-1], rows[:, 1:]
def lr_factor(step, total, warmup, cooldown):
"""Warm up, hold, then decay linearly to zero over the last `cooldown` share."""
if step < warmup:
return (step + 1) / warmup
decay_start = total * (1 - cooldown)
if step < decay_start:
return 1.0
return max(0.0, (total - step) / (total - decay_start))
def context_at(step, total, context, warm):
"""Sequence-length warm-up: short windows first, full length later."""
if not warm:
return context
if step < 0.1 * total:
return max(64, context // 4)
if step < 0.3 * total:
return context // 2
return context
def make_optimizers(model, args):
matrices = [p for n, p in model.named_parameters() if p.ndim == 2 and 'embed' not in n]
others = [p for n, p in model.named_parameters() if not (p.ndim == 2 and 'embed' not in n)]
embed = [p for p in others if p.ndim == 2]
gains = [p for p in others if p.ndim < 2]
adam_groups = [dict(params=embed, lr=args.lr_embed, weight_decay=0.0),
dict(params=gains, lr=args.lr_embed, weight_decay=0.0)]
if args.optim == 'muon':
split = {blk.attn.qkv.weight: 3 for blk in model.blocks}
muon = Muon(matrices, lr=args.lr, weight_decay=args.wd, split=split)
adam = torch.optim.AdamW(adam_groups, betas=(0.9, 0.95), eps=1e-10)
return [muon, adam]
adam_groups.append(dict(params=matrices, lr=args.lr, weight_decay=args.wd))
return [torch.optim.AdamW(adam_groups, betas=(0.9, 0.95), eps=1e-10)]
@torch.no_grad()
def evaluate(model, val, args, ctx):
model.eval()
rng_state = val.rng
val.rng = np.random.default_rng(1234) # the same batches every time
losses = []
for _ in range(args.eval_batches):
x, y = val.get(args.batch_tokens // args.context, args.context)
with ctx:
_, loss = model(x, y)
losses.append(loss.item())
val.rng = rng_state
model.train()
return sum(losses) / len(losses)
@torch.no_grad()
def samples(model, tok, device, n_tokens=80):
model.eval()
out = []
g = torch.Generator(device='cpu').manual_seed(7)
for prompt in PROMPTS:
torch.manual_seed(int(torch.randint(0, 2**31, (1,), generator=g)))
idx = torch.tensor([[tok['<|endoftext|>']] + tok.encode(prompt)], device=device)
ids = model.generate(idx, n_tokens, temperature=0.8, top_p=0.95, stop={tok['<|endoftext|>']})
out.append(tok.decode([i for i in ids[0, 1:].tolist() if i != tok['<|endoftext|>']]))
model.train()
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--data', default='data')
ap.add_argument('--out', default='runs/base')
ap.add_argument('--d-model', type=int, default=384)
ap.add_argument('--n-layer', type=int, default=8)
ap.add_argument('--n-head', type=int, default=6)
ap.add_argument('--d-ff', type=int, default=1024)
ap.add_argument('--context', type=int, default=512)
ap.add_argument('--batch-tokens', type=int, default=32768)
ap.add_argument('--tokens', type=float, default=400e6, help='how many tokens to train on')
ap.add_argument('--optim', choices=['muon', 'adamw'], default='muon')
ap.add_argument('--lr', type=float, default=0.02, help='Muon lr (or AdamW lr for matrices)')
ap.add_argument('--lr-embed', type=float, default=0.006)
ap.add_argument('--wd', type=float, default=0.0)
ap.add_argument('--warmup', type=int, default=200)
ap.add_argument('--cooldown', type=float, default=0.3)
ap.add_argument('--seq-warmup', action='store_true')
ap.add_argument('--eval-every', type=int, default=250)
ap.add_argument('--eval-batches', type=int, default=20)
ap.add_argument('--sample-every', type=int, default=500)
ap.add_argument('--sample-at', default='', help='extra steps with samples, e.g. 25,50,100')
ap.add_argument('--save-at', default='', help='extra checkpoint steps, e.g. 100,1000,5000')
ap.add_argument('--no-compile', action='store_true')
ap.add_argument('--seed', type=int, default=0)
ap.add_argument('--device', default='', help='cuda / mps / cpu (default: the best available)')
ap.add_argument('--threads', type=int, default=0, help='CPU threads (0 = default)')
args = ap.parse_args()
torch.manual_seed(args.seed)
if args.threads:
torch.set_num_threads(args.threads)
device = args.device or get_device()
os.makedirs(args.out, exist_ok=True)
tok = Tokenizer.load(f'{args.data}/tokenizer.json')
cfg = Config(vocab_size=tok.vocab_size, context=args.context, n_layer=args.n_layer,
n_head=args.n_head, d_model=args.d_model, d_ff=args.d_ff)
model = GPT(cfg).to(device)
print(f'{model.num_params() / 1e6:.2f}M parameters on {device}')
train_model = model if args.no_compile else torch.compile(model)
opts = make_optimizers(model, args)
for opt in opts:
for g in opt.param_groups:
g['base_lr'] = g['lr']
ctx = torch.autocast(device_type=device, dtype=torch.bfloat16) if device != 'cpu' else torch.autocast('cpu', enabled=False)
train = Batches(f'{args.data}/train.bin', device, seed=args.seed)
val = Batches(f'{args.data}/val.bin', device)
steps = int(args.tokens // args.batch_tokens)
save_at = {int(s) for s in args.save_at.split(',') if s}
sample_at = {int(s) for s in args.sample_at.split(',') if s}
log = open(f'{args.out}/log.jsonl', 'a')
json.dump({'config': cfg.to_dict(), 'args': vars(args), 'steps': steps, 'params': model.num_params()}, log)
log.write('\n')
t0, seen = time.time(), 0
for step in range(steps + 1):
last = step == steps
if step % args.eval_every == 0 or step in sample_at or last:
vl = evaluate(train_model, val, args, ctx)
rec = {'step': step, 'tokens': seen, 'val': round(vl, 4), 'time': round(time.time() - t0, 1)}
if step % args.sample_every == 0 or step in sample_at or last:
rec['samples'] = samples(model, tok, device)
print(json.dumps(rec), flush=True)
log.write(json.dumps(rec) + '\n')
log.flush()
if step in save_at or last:
name = 'final' if last else step
torch.save({'config': cfg.to_dict(), 'model': model.state_dict(), 'step': step, 'tokens': seen},
f'{args.out}/ckpt_{name}.pt')
if last:
break
T = context_at(step, steps, args.context, args.seq_warmup)
x, y = train.get(args.batch_tokens // T, T)
with ctx:
_, loss = train_model(x, y)
loss.backward()
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
f = lr_factor(step, steps, args.warmup, args.cooldown)
for opt in opts:
for g in opt.param_groups:
g['lr'] = g['base_lr'] * f
opt.step()
opt.zero_grad(set_to_none=True)
seen += x.numel()
if step % 20 == 0:
rec = {'step': step, 'loss': round(loss.item(), 4), 'lr': round(f, 4), 'norm': round(norm.item(), 3),
'tokens': seen, 'tok_s': round(seen / (time.time() - t0)), 'T': T}
log.write(json.dumps(rec) + '\n')
if step % 100 == 0:
print(json.dumps(rec), flush=True)
log.close()
if __name__ == '__main__':
main()
build_chats.py
Collects the conversations for chat fine-tuning.
122 lines
· Explained in chapter 12
· Open as text
"""Collect the conversations Sprout learns to chat from -> data/chats.jsonl
- sft/*.jsonl conversations written for Sprout's level
- everyday-conversations (HF) short everyday chats with an assistant
- SimpleStories "tell me a story about ..." requests, built from its labels
- TinyStories "continue this story: ..." requests
"""
import argparse
import glob
import json
import random
import re
import pyarrow.parquet as pq
from prepare import REPLACE
def tidy(text):
text = text.translate(REPLACE).strip()
return '\n'.join(' '.join(line.split()) for line in text.split('\n'))
def conversation(messages, source):
msgs = [{'role': m['role'], 'content': tidy(m['content'])} for m in messages if m['role'] in ('user', 'assistant')]
ok = msgs and msgs[0]['role'] == 'user' and msgs[-1]['role'] == 'assistant' and all(m['content'] for m in msgs)
ok = ok and all(a['role'] != b['role'] for a, b in zip(msgs, msgs[1:]))
return {'messages': msgs, 'source': source} if ok else None
STORY_ASKS = [
'Tell me a story about {subject}.', 'Can you write a story about {subject}?', 'story about {subject} pls',
'I want a short story about {subject}.', 'Tell me a bedtime story about {subject}.',
'Write a story where the hero is {subject}.', 'Could you tell me a story about {subject}, please?',
'Make up a story about {subject}!',
]
# "Once upon a time, there was a little fox named ..." -> "a little fox"
SUBJECT = re.compile(r"\bthere (?:was|lived) (a|an) ([a-z]+(?: [a-z]+){0,3}?)(?= named| who| that| in | with| called|,|\.)")
def story_requests(raw, n, rng, per_noun=120):
"""Ask for a story about exactly the hero the story is about, so request and answer agree.
(A first version asked for SimpleStories' abstract topic labels like "bygone eras"; the stories
only loosely matched them, and the model learned that a story need not fit the request.)
"""
with open(f'{raw}/TinyStoriesV2-GPT4-train.txt', encoding='utf-8') as f:
text = f.read(150_000_000)
per = {}
out = []
stories = text.split('<|endoftext|>')[1:-1]
rng.shuffle(stories)
for s in stories:
s = tidy(s)
m = SUBJECT.search(s[:220])
if not m or len(s.split()) > 170 or not 1 <= len(m.group(2).split()) <= 3:
continue
noun = m.group(2).split()[-1]
if per.get(noun, 0) >= per_noun:
continue
per[noun] = per.get(noun, 0) + 1
ask = rng.choice(STORY_ASKS).format(subject=f'{m.group(1)} {m.group(2)}')
out.append(conversation([{'role': 'user', 'content': ask}, {'role': 'assistant', 'content': s}], 'story'))
if len(out) >= n:
break
return out
def continuations(raw, n, rng):
with open(f'{raw}/TinyStoriesV2-GPT4-valid.txt', encoding='utf-8') as f:
stories = [s.strip() for s in f.read().split('<|endoftext|>') if 300 < len(s.strip()) < 1200]
out = []
for s in rng.sample(stories, min(n, len(stories))):
sentences = re.split(r'(?<=[.!?])\s+', tidy(s))
if len(sentences) < 4:
continue
k = rng.choice([1, 2])
start, rest = ' '.join(sentences[:k]), ' '.join(sentences[k:])
ask = rng.choice(['Continue this story: {s}', 'Can you finish this story? "{s}"', 'What happens next? {s}',
'Please continue: {s}'])
out.append(conversation([{'role': 'user', 'content': ask.format(s=start)},
{'role': 'assistant', 'content': rest}], 'continue'))
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--raw', default='raw')
ap.add_argument('--written', default='sft')
ap.add_argument('--out', default='data/chats.jsonl')
ap.add_argument('--stories', type=int, default=2500)
ap.add_argument('--continues', type=int, default=800)
ap.add_argument('--everyday', type=int, default=1200)
ap.add_argument('--written-repeat', type=int, default=2)
ap.add_argument('--self-repeat', type=int, default=4, help='conversations about Sprout itself (files 08-*)')
args = ap.parse_args()
rng = random.Random(7)
written = []
for path in sorted(glob.glob(f'{args.written}/*.jsonl')):
repeat = args.self_repeat if path.split('/')[-1].startswith('08-') else args.written_repeat
for line in open(path):
if line.strip():
written += [conversation(json.loads(line)['messages'], 'written')] * repeat
everyday = [conversation(m, 'everyday')
for m in pq.read_table(f'{args.raw}/everyday-train.parquet').column('messages').to_pylist()]
parts = {
'written': [c for c in written if c],
'everyday': rng.sample([c for c in everyday if c], args.everyday),
'story': [c for c in story_requests(args.raw, args.stories, rng) if c],
'continue': [c for c in continuations(args.raw, args.continues, rng) if c],
}
chats = [c for part in parts.values() for c in part]
rng.shuffle(chats)
with open(args.out, 'w') as f:
for c in chats:
f.write(json.dumps(c) + '\n')
print({k: len(v) for k, v in parts.items()}, '->', len(chats))
if __name__ == '__main__':
main()
sft.py
Chat fine-tuning: the loss counts only the assistant's words.
154 lines
· Explained in chapter 12
· Open as text
"""Teach the base model to chat: supervised fine-tuning on conversations.
python sft.py --base runs/base/ckpt_final.pt --chats data/chats.jsonl --out runs/chat
A conversation becomes one line of tokens:
<|endoftext|><|user|>hi!<|end|><|assistant|>Hello! How are you?<|end|><|user|>...
The loss is counted only on the assistant's words and its closing <|end|>, so the
model learns to answer, not to imitate the user.
"""
import argparse
import json
import os
import random
import time
import torch
from model import GPT, Config
from muon import Muon
from tokenizer import Tokenizer
from train import get_device, lr_factor
def render(tok, messages):
"""Token ids and a 0/1 mask saying which positions the model should learn to predict."""
ids, mask = [tok['<|endoftext|>']], [0]
for m in messages:
role = tok['<|user|>'] if m['role'] == 'user' else tok['<|assistant|>']
body = tok.encode(m['content'].strip(), allow_special=False) + [tok['<|end|>']]
ids += [role] + body
learn = 1 if m['role'] == 'assistant' else 0
mask += [0] + [learn] * len(body)
return ids, mask
def pack(examples, context):
"""Glue conversations into rows of exactly context+1 tokens (the rest is padding)."""
rows, cur_ids, cur_mask = [], [], []
for ids, mask in examples:
if len(ids) > context + 1:
continue
if len(cur_ids) + len(ids) > context + 1:
rows.append((cur_ids, cur_mask))
cur_ids, cur_mask = [], []
cur_ids += ids
cur_mask += mask
if cur_ids:
rows.append((cur_ids, cur_mask))
x = torch.zeros(len(rows), context + 1, dtype=torch.long)
m = torch.zeros(len(rows), context + 1, dtype=torch.long)
for i, (ids, mask) in enumerate(rows):
x[i, :len(ids)] = torch.tensor(ids)
m[i, :len(mask)] = torch.tensor(mask)
return x, m
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--base', required=True)
ap.add_argument('--chats', default='data/chats.jsonl')
ap.add_argument('--data', default='data')
ap.add_argument('--out', default='runs/chat')
ap.add_argument('--epochs', type=float, default=3)
ap.add_argument('--batch', type=int, default=32)
ap.add_argument('--lr', type=float, default=0.004)
ap.add_argument('--lr-embed', type=float, default=0.0008)
ap.add_argument('--val-frac', type=float, default=0.03)
ap.add_argument('--seed', type=int, default=0)
args = ap.parse_args()
torch.manual_seed(args.seed)
device = get_device()
os.makedirs(args.out, exist_ok=True)
tok = Tokenizer.load(f'{args.data}/tokenizer.json')
ck = torch.load(args.base, map_location='cpu')
cfg = Config(**ck['config'])
model = GPT(cfg)
model.load_state_dict(ck['model'])
model.to(device)
chats = [json.loads(line) for line in open(args.chats)]
# some conversations appear several times on purpose; hold out whole conversations,
# every copy of them, so validation text is really never trained on
key = lambda c: json.dumps(c['messages'], sort_keys=True)
unique = sorted({key(c) for c in chats})
random.Random(args.seed).shuffle(unique)
held_out = set(unique[:int(len(unique) * args.val_frac)])
val = list({key(c): c for c in chats if key(c) in held_out}.values())
train = [c for c in chats if key(c) not in held_out]
random.Random(args.seed).shuffle(train)
val_x, val_m = pack([render(tok, c['messages']) for c in val], cfg.context)
train_x, train_m = pack([render(tok, c['messages']) for c in train], cfg.context)
print(f'{len(chats)} conversations -> {len(train_x)} train rows, {len(val_x)} val rows')
matrices = [p for n, p in model.named_parameters() if p.ndim == 2 and 'embed' not in n]
rest = [p for n, p in model.named_parameters() if not (p.ndim == 2 and 'embed' not in n)]
muon = Muon(matrices, lr=args.lr, split={b.attn.qkv.weight: 3 for b in model.blocks})
adam = torch.optim.AdamW(rest, lr=args.lr_embed, betas=(0.9, 0.95), weight_decay=0.0)
opts = [muon, adam]
for opt in opts:
for g in opt.param_groups:
g['base_lr'] = g['lr']
ctx = torch.autocast(device_type=device, dtype=torch.bfloat16)
@torch.no_grad()
def evaluate():
model.eval()
losses = []
for i in range(0, len(val_x), args.batch):
x, m = val_x[i:i + args.batch].to(device), val_m[i:i + args.batch].to(device)
with ctx:
_, loss = model(x[:, :-1], x[:, 1:], loss_mask=m[:, 1:])
losses.append(loss.item())
model.train()
return sum(losses) / len(losses)
steps = int(args.epochs * len(train_x) / args.batch)
log = open(f'{args.out}/log.jsonl', 'w')
log.write(json.dumps({'config': cfg.to_dict(), 'args': vars(args), 'params': model.num_params(), 'steps': steps,
'conversations': len(chats), 'train_rows': len(train_x)}) + '\n')
t0 = time.time()
order = torch.randperm(len(train_x))
pos = 0
for step in range(steps + 1):
if step % 50 == 0 or step == steps:
rec = {'step': step, 'val': round(evaluate(), 4), 'time': round(time.time() - t0, 1)}
print(json.dumps(rec), flush=True)
log.write(json.dumps(rec) + '\n')
log.flush()
if step == steps:
break
if pos + args.batch > len(order):
order, pos = torch.randperm(len(train_x)), 0
idx = order[pos:pos + args.batch]
pos += args.batch
x, m = train_x[idx].to(device), train_m[idx].to(device)
with ctx:
_, loss = model(x[:, :-1], x[:, 1:], loss_mask=m[:, 1:])
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
f = lr_factor(step, steps, warmup=20, cooldown=0.5)
for opt in opts:
for g in opt.param_groups:
g['lr'] = g['base_lr'] * f
opt.step()
opt.zero_grad(set_to_none=True)
if step % 10 == 0:
log.write(json.dumps({'step': step, 'loss': round(loss.item(), 4), 'lr': round(f, 4)}) + '\n')
torch.save({'config': cfg.to_dict(), 'model': model.state_dict(), 'step': steps}, f'{args.out}/ckpt_final.pt')
log.close()
if __name__ == '__main__':
main()
export.py
Packs the weights as int8 for the browser engine.
83 lines
· Explained in chapter 11
· Open as text
"""Pack a checkpoint for the browser: int8 weights with one scale per row.
python export.py runs/chat/ckpt_final.pt ../../public/llm/models/sprout-chat.bin
File layout: b'SPRT', uint32 version, uint32 header length, JSON header,
then the tensors, each aligned to 16 bytes. A matrix W (rows x cols) is stored
as int8 q plus float32 scales s so that W[r] ≈ q[r] * s[r].
"""
import argparse
import json
import struct
import numpy as np
import torch
MAGIC, VERSION = b'SPRT', 1
def quantize_rows(w):
scale = np.abs(w).max(axis=1) / 127.0
scale[scale == 0] = 1.0
q = np.clip(np.round(w / scale[:, None]), -127, 127).astype(np.int8)
return q, scale.astype(np.float32)
def pack(state, config, meta, path, quantize=True):
tensors, blobs, offset = [], [], 0
def add(name, arr, dtype):
nonlocal offset
data = arr.astype(dtype).tobytes()
pad = (-len(data)) % 16
tensors.append({'name': name, 'dtype': np.dtype(dtype).name, 'shape': list(arr.shape), 'offset': offset})
blobs.append(data + b'\0' * pad)
offset += len(data) + pad
for name, t in state.items():
if name == 'head.weight': # tied to embed.weight
continue
w = t.detach().float().cpu().numpy()
if quantize and w.ndim == 2:
q, s = quantize_rows(w)
add(name, q, np.int8)
add(name + '.scale', s, np.float32)
else:
add(name, w, np.float32)
header = json.dumps({'config': config, 'meta': meta, 'tensors': tensors}).encode()
header += b' ' * ((-(12 + len(header))) % 16)
with open(path, 'wb') as f:
f.write(MAGIC + struct.pack('<II', VERSION, len(header)) + header)
for b in blobs:
f.write(b)
return 12 + len(header) + offset
def dequantized(state):
"""The weights exactly as the browser will see them, to measure the damage."""
out = {}
for name, t in state.items():
w = t.detach().float().cpu()
if w.ndim == 2 and name != 'head.weight':
q, s = quantize_rows(w.numpy())
w = torch.from_numpy(q.astype(np.float32) * s[:, None])
out[name] = w
out['head.weight'] = out['embed.weight']
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('ckpt')
ap.add_argument('out')
ap.add_argument('--name', default='')
ap.add_argument('--float', action='store_true', help='keep float32 (tiny models)')
args = ap.parse_args()
ck = torch.load(args.ckpt, map_location='cpu')
meta = {'name': args.name, 'step': ck.get('step'), 'tokens': ck.get('tokens'), 'kind': 'gpt'}
size = pack(ck['model'], ck['config'], meta, args.out, quantize=not args.float)
print(f'{args.out}: {size / 1e6:.1f} MB')
if __name__ == '__main__':
main()
chat.py
Talk to the model in the terminal.
54 lines
· Explained in chapter 12
· Open as text
"""Talk to Sprout in the terminal.
python chat.py runs/chat/ckpt_final.pt # the chat model
python chat.py runs/base/ckpt_final.pt --base # the base model just continues your text
"""
import argparse
import torch
from model import GPT, Config
from tokenizer import Tokenizer
from train import get_device
def main():
ap = argparse.ArgumentParser()
ap.add_argument('ckpt')
ap.add_argument('--data', default='data')
ap.add_argument('--base', action='store_true', help='plain continuation, no chat template')
ap.add_argument('--temperature', type=float, default=0.7)
ap.add_argument('--top-p', type=float, default=0.9)
ap.add_argument('--max', type=int, default=200)
args = ap.parse_args()
device = get_device()
tok = Tokenizer.load(f'{args.data}/tokenizer.json')
ck = torch.load(args.ckpt, map_location='cpu')
model = GPT(Config(**ck['config']))
model.load_state_dict(ck['model'])
model.to(device).eval()
eot, user, assistant, end = (tok[s] for s in ('<|endoftext|>', '<|user|>', '<|assistant|>', '<|end|>'))
history = [eot]
print('Sprout is listening. Empty line to quit.')
while True:
text = input('you> ').strip()
if not text:
break
if args.base:
ids = [eot] + tok.encode(text, allow_special=False)
else:
history += [user] + tok.encode(text, allow_special=False) + [end, assistant]
ids = history[-(model.cfg.context - args.max):]
out = model.generate(torch.tensor([ids], device=device), args.max, temperature=args.temperature,
top_p=args.top_p, stop={end, eot})
new = out[0, len(ids):].tolist()
reply = [i for i in new if i not in (end, eot)]
print('sprout>', (text + ' ' if args.base else '') + tok.decode(reply).strip())
if not args.base:
history += reply + [end]
if __name__ == '__main__':
main()
lora.py
A LoRA style adapter, merged back into the weights.
141 lines
· Explained in chapter 13
· Open as text
"""LoRA: teach the chat model a new style by training two thin matrices per layer.
python lora.py --base runs/chat/ckpt_final.pt --chats sft/style/pirate.jsonl --out runs/pirate
A frozen weight W gets a detour: y = W x + (alpha / r) · B (A x), A: r × in, B: out × r.
B starts at zero, so at step 0 the model is exactly the chat model. Only A and B learn —
with rank 8 that is about 2.4 % of Sprout's parameters. At the end B·A is merged into W,
and the result is an ordinary Sprout checkpoint again.
"""
import argparse
import json
import math
import os
import random
import time
import torch
import torch.nn as nn
from model import GPT, Config
from sft import pack, render
from tokenizer import Tokenizer
from train import get_device, lr_factor
class LoRALinear(nn.Module):
def __init__(self, base, rank=8, alpha=16):
super().__init__()
self.base = base
self.A = nn.Parameter(torch.randn(rank, base.in_features) / math.sqrt(base.in_features))
self.B = nn.Parameter(torch.zeros(base.out_features, rank))
self.scale = alpha / rank
def forward(self, x):
return self.base(x) + (x @ self.A.T @ self.B.T) * self.scale
def merge(self):
self.base.weight.data += (self.B @ self.A).to(self.base.weight.dtype) * self.scale
return self.base
TARGETS = [('attn', 'qkv'), ('attn', 'proj'), ('ffn', 'w1'), ('ffn', 'w2'), ('ffn', 'w3')]
def add_lora(model, rank, alpha):
"""Freeze everything, then wrap every projection in the blocks with a LoRA detour."""
for p in model.parameters():
p.requires_grad_(False)
for block in model.blocks:
for part, name in TARGETS:
parent = getattr(block, part)
setattr(parent, name, LoRALinear(getattr(parent, name), rank, alpha))
return [p for p in model.parameters() if p.requires_grad]
def merge_lora(model):
for block in model.blocks:
for part, name in TARGETS:
parent = getattr(block, part)
layer = getattr(parent, name)
if isinstance(layer, LoRALinear):
setattr(parent, name, layer.merge())
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--base', required=True)
ap.add_argument('--chats', required=True)
ap.add_argument('--data', default='data')
ap.add_argument('--out', required=True)
ap.add_argument('--rank', type=int, default=8)
ap.add_argument('--alpha', type=float, default=16)
ap.add_argument('--epochs', type=float, default=9)
ap.add_argument('--batch', type=int, default=16)
ap.add_argument('--lr', type=float, default=3e-3)
ap.add_argument('--seed', type=int, default=0)
args = ap.parse_args()
torch.manual_seed(args.seed)
device = get_device()
os.makedirs(args.out, exist_ok=True)
tok = Tokenizer.load(f'{args.data}/tokenizer.json')
ck = torch.load(args.base, map_location='cpu')
cfg = Config(**ck['config'])
model = GPT(cfg)
model.load_state_dict(ck['model'])
trainable = add_lora(model, args.rank, args.alpha)
model.to(device)
n_train = sum(p.numel() for p in trainable)
print(f'LoRA rank {args.rank}: {n_train / 1e3:.0f}k trainable of {model.num_params() / 1e6:.2f}M')
chats = [json.loads(line) for line in open(args.chats)]
random.Random(args.seed).shuffle(chats)
n_val = max(8, len(chats) // 20)
val_x, val_m = pack([render(tok, c['messages']) for c in chats[:n_val]], cfg.context)
train_x, train_m = pack([render(tok, c['messages']) for c in chats[n_val:]], cfg.context)
opt = torch.optim.AdamW(trainable, lr=args.lr, weight_decay=0.0)
ctx = torch.autocast(device_type=device, dtype=torch.bfloat16)
steps = max(1, int(args.epochs * len(train_x) / args.batch))
log = open(f'{args.out}/log.jsonl', 'w')
log.write(json.dumps({'config': cfg.to_dict(), 'args': vars(args), 'params': model.num_params() - n_train,
'trainable': n_train, 'steps': steps, 'conversations': len(chats)}) + '\n')
@torch.no_grad()
def evaluate():
model.eval()
x, m = val_x.to(device), val_m.to(device)
with ctx:
_, loss = model(x[:, :-1], x[:, 1:], loss_mask=m[:, 1:])
model.train()
return loss.item()
t0 = time.time()
for step in range(steps + 1):
if step % 10 == 0 or step == steps:
rec = {'step': step, 'val': round(evaluate(), 4), 'time': round(time.time() - t0, 1)}
print(json.dumps(rec), flush=True)
log.write(json.dumps(rec) + '\n')
if step == steps:
break
idx = torch.randint(0, len(train_x), (args.batch,))
x, m = train_x[idx].to(device), train_m[idx].to(device)
with ctx:
_, loss = model(x[:, :-1], x[:, 1:], loss_mask=m[:, 1:])
loss.backward()
for g in opt.param_groups:
g['lr'] = args.lr * lr_factor(step, steps, warmup=10, cooldown=0.5)
opt.step()
opt.zero_grad(set_to_none=True)
log.write(json.dumps({'step': step, 'loss': round(loss.item(), 4)}) + '\n')
log.close()
adapter = {n: p.detach().cpu() for n, p in model.named_parameters() if p.requires_grad}
torch.save({'rank': args.rank, 'alpha': args.alpha, 'adapter': adapter}, f'{args.out}/adapter.pt')
merge_lora(model)
torch.save({'config': cfg.to_dict(), 'model': model.state_dict(), 'step': steps}, f'{args.out}/ckpt_final.pt')
if __name__ == '__main__':
main()
snapshots.py
The small models of the first chapters: the bigram, networks on letters and tokens.
148 lines
· Explained in chapter 1
· Open as text
"""The small models the course shows along the way (the "Sprout now" cards).
python snapshots.py bigram # letter bigram: counts -> probabilities
python snapshots.py mlp3 # 3 letters -> next letter (embeddings + hidden layer)
python snapshots.py mlp8 # 8 letters -> next letter (two hidden layers)
python snapshots.py emb2d # the same idea with 2-D embeddings, to draw them
python snapshots.py mlptok # 8 BPE tokens -> next token
Writes browser files into --out (weights in the same SPRT format as export.py).
"""
import argparse
import json
import math
import os
import time
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from export import pack
# id 0 = start/end of a text, then '\n' and the 95 printable ASCII characters (as chars.js)
ALPHABET = '\n' + ''.join(chr(i) for i in range(32, 127))
STOI = {c: i + 1 for i, c in enumerate(ALPHABET)}
V = len(ALPHABET) + 1
def char_corpus(data, n_chars):
"""A slice of the pre-training text, as letters (decoded from the token file)."""
from tokenizer import Tokenizer
tok = Tokenizer.load(f'{data}/tokenizer.json')
ids = np.memmap(f'{data}/train.bin', dtype=np.uint16, mode='r')[: n_chars // 3]
eot = tok['<|endoftext|>']
text = tok.decode([int(i) for i in ids]).replace('<|endoftext|>', '\0')
out = np.array([0 if c == '\0' else STOI.get(c, -1) for c in text], dtype=np.int64)
return out[out >= 0]
def bigram(args):
ids = char_corpus(args.data, args.chars)
counts = np.zeros((V, V), dtype=np.int64)
np.add.at(counts, (ids[:-1], ids[1:]), 1)
probs = (counts + 1) / (counts + 1).sum(1, keepdims=True)
logits = np.log(probs).astype(np.float32)
state = {'embed.weight': torch.from_numpy(logits)}
cfg = {'vocab_size': V, 'context': 1}
pack(state, cfg, {'kind': 'mlp', 'name': 'bigram-char', 'chars': int(len(ids))}, f'{args.out}/models/bigram-char.bin', quantize=False)
with open(f'{args.out}/data/bigram-counts.json', 'w') as f:
json.dump({'alphabet': ALPHABET, 'counts': counts.tolist(), 'chars': int(len(ids))}, f, separators=(',', ':'))
nll = -np.log(probs[ids[:-1], ids[1:]]).mean()
print(f'bigram: {len(ids)} letters, loss {nll:.4f} nats/letter')
class MLP(nn.Module):
def __init__(self, vocab, context, d_embed, hidden):
super().__init__()
self.context = context
self.embed = nn.Embedding(vocab, d_embed)
dims = [context * d_embed] + hidden
self.hidden = nn.ModuleList(nn.Linear(a, b) for a, b in zip(dims, dims[1:]))
self.out = nn.Linear(dims[-1], vocab)
def forward(self, x): # x: (batch, context)
h = self.embed(x).flatten(1)
for layer in self.hidden:
h = torch.tanh(layer(h))
return self.out(h)
def windows(ids, context, pad=0):
"""(context letters, next letter) pairs; the window restarts at every text start."""
return torch.from_numpy(np.lib.stride_tricks.sliding_window_view(np.concatenate([np.full(context, pad), ids]), context + 1).copy())
def train_mlp(name, ids, vocab, context, d_embed, hidden, args, lr=3e-3, steps=None, batch=512):
torch.manual_seed(0)
data = windows(ids, context)
n_val = len(data) // 50
val, tr = data[:n_val], data[n_val:]
model = MLP(vocab, context, d_embed, hidden)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
steps = steps or args.steps
t0 = time.time()
log = []
for step in range(steps + 1):
if step % 500 == 0 or step == steps:
with torch.no_grad():
idx = torch.randint(0, len(val), (8192,), generator=torch.Generator().manual_seed(0))
vb = val[idx]
vl = F.cross_entropy(model(vb[:, :-1]), vb[:, -1]).item()
log.append({'step': step, 'val': round(vl, 4)})
print(f'{name} step {step} val {vl:.4f} ({time.time() - t0:.0f}s)', flush=True)
if step == steps:
break
b = tr[torch.randint(0, len(tr), (batch,))]
loss = F.cross_entropy(model(b[:, :-1]), b[:, -1])
opt.zero_grad(set_to_none=True)
loss.backward()
opt.step()
for g in opt.param_groups:
g['lr'] = lr * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * step / steps)))
return model, log
def export_mlp(model, name, cfg, args, log):
state = {k: v for k, v in model.state_dict().items()}
size = pack(state, cfg, {'kind': 'mlp', 'name': name, 'log': log}, f'{args.out}/models/{name}.bin', quantize=True)
print(f'{name}: {sum(p.numel() for p in model.parameters()) / 1e3:.0f}k params, {size / 1e3:.0f} KB')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('what', choices=['bigram', 'mlp3', 'mlp8', 'emb2d', 'mlptok'])
ap.add_argument('--data', default='data')
ap.add_argument('--out', default='../../public/llm')
ap.add_argument('--chars', type=int, default=30_000_000)
ap.add_argument('--steps', type=int, default=20000)
ap.add_argument('--threads', type=int, default=4)
args = ap.parse_args()
torch.set_num_threads(args.threads)
os.makedirs(f'{args.out}/models', exist_ok=True)
os.makedirs(f'{args.out}/data', exist_ok=True)
if args.what == 'bigram':
bigram(args)
elif args.what in ('mlp3', 'mlp8', 'emb2d'):
ids = char_corpus(args.data, args.chars)
spec = {'mlp3': (3, 16, [256]), 'mlp8': (8, 24, [512, 512]), 'emb2d': (3, 2, [128])}[args.what]
context, d_embed, hidden = spec
name = {'mlp3': 'mlp-char-3', 'mlp8': 'mlp-char-8', 'emb2d': 'emb2d'}[args.what]
model, log = train_mlp(name, ids, V, context, d_embed, hidden, args)
if args.what == 'emb2d':
emb = model.embed.weight.detach().numpy()
with open(f'{args.out}/data/emb2d.json', 'w') as f:
json.dump({'alphabet': ALPHABET, 'xy': emb.round(4).tolist(), 'log': log}, f, separators=(',', ':'))
cfg = {'vocab_size': V, 'context': context, 'd_embed': d_embed, 'hidden': hidden}
export_mlp(model, name, cfg, args, log)
elif args.what == 'mlptok':
ids = np.memmap(f'{args.data}/train.bin', dtype=np.uint16, mode='r')[:40_000_000].astype(np.int64)
model, log = train_mlp('mlp-token', ids, 8192, 8, 48, [384], args, lr=2e-3, batch=512)
cfg = {'vocab_size': 8192, 'context': 8, 'd_embed': 48, 'hidden': [384]}
export_mlp(model, 'mlp-token', cfg, args, log)
if __name__ == '__main__':
main()
course_data.py
The JSON files the course's widgets draw from.
110 lines
· Open as text
"""Small JSON files the course widgets draw from (public/llm/data/*.json).
python course_data.py --work ~/claude-projects/tinyllm-work --out ../../public/llm/data
"""
import argparse
import collections
import glob
import json
import os
import random
import numpy as np
from prepare import SOURCES, clean
from tokenizer import Tokenizer
def corpus(work, out, tok):
stats = json.load(open(f'{work}/data/corpus_stats.json'))
rng = random.Random(3)
samples = {}
lengths = {}
for name, read in SOURCES.items():
docs, n = [], 0
for text in read(f'{work}/raw'):
n += 1
if n % 997 == 0:
t = clean(text)
if t:
docs.append(t)
if len(docs) >= 400:
break
lens = [len(tok.encode(d, allow_special=False)) for d in docs]
lengths[name] = np.histogram(lens, bins=20, range=(0, 800))[0].tolist()
samples[name] = [d[:1400] for d in rng.sample(docs, 24)]
ids = np.memmap(f'{work}/data/val.bin', dtype=np.uint16, mode='r')
counts = collections.Counter(ids.tolist())
top = [{'id': int(i), 'text': tok.decode([int(i)]), 'n': int(c)} for i, c in counts.most_common(400)]
ranks = sorted(counts.values(), reverse=True)
zipf = [[r + 1, int(ranks[r])] for r in sorted({int(x) for x in np.unique(np.geomspace(1, len(ranks), 60).astype(int) - 1)})]
json.dump({'stats': stats, 'samples': samples, 'lengths': lengths, 'length_bins': 40, 'top': top, 'zipf': zipf,
'val_tokens': int(len(ids))}, open(f'{out}/corpus.json', 'w'), separators=(',', ':'))
def train_log(work, out, run='base', name='train-log'):
path = f'{work}/runs/{run}/log.jsonl'
if not os.path.exists(path):
return
lines = [json.loads(l) for l in open(path) if l.strip()]
head = lines[0]
train = [{k: r[k] for k in ('step', 'loss', 'lr', 'tokens', 'T', 'norm') if k in r} for r in lines[1:] if 'loss' in r]
evals = [{k: r[k] for k in ('step', 'val', 'tokens', 'time', 'samples') if k in r} for r in lines[1:] if 'val' in r]
extra = {k: head[k] for k in ('train_rows', 'conversations', 'trainable') if k in head}
json.dump({'config': head.get('config'), 'args': head.get('args'), 'params': head.get('params'), 'steps': head.get('steps'),
**extra, 'train': train, 'evals': evals}, open(f'{out}/{name}.json', 'w'), separators=(',', ':'))
def lora_logs(work, out):
res = {}
for style in ('pirate', 'poet'):
path = f'{work}/runs/{style}/log.jsonl'
if not os.path.exists(path):
continue
lines = [json.loads(l) for l in open(path) if l.strip()]
head = lines[0]
evals = [r for r in lines[1:] if 'val' in r]
res[style] = {'rank': head['args']['rank'], 'alpha': head['args']['alpha'], 'trainable': head['trainable'],
'params': head['params'], 'steps': head['steps'], 'conversations': head.get('conversations'),
'minutes': round((evals[-1]['time'] if evals else 0) / 60, 1),
'train': [{'step': r['step'], 'loss': r['loss']} for r in lines[1:] if 'loss' in r],
'evals': [{'step': r['step'], 'val': r['val']} for r in evals]}
if res:
json.dump(res, open(f'{out}/train-log-lora.json', 'w'), separators=(',', ':'))
def chats(work, out):
path = f'{work}/data/chats.jsonl'
if not os.path.exists(path):
return
rows = [json.loads(l) for l in open(path)]
rng = random.Random(5)
by = collections.defaultdict(list)
for r in rows:
by[r['source']].append(r)
sample = {k: rng.sample(v, min(12, len(v))) for k, v in by.items()}
json.dump({'counts': {k: len(v) for k, v in by.items()}, 'samples': sample}, open(f'{out}/chats.json', 'w'), separators=(',', ':'))
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--work', default='.')
ap.add_argument('--out', default='../../public/llm/data')
ap.add_argument('--only', default='')
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
tok = Tokenizer.load(f'{args.work}/data/tokenizer.json')
todo = args.only.split(',') if args.only else ['corpus', 'log', 'chats']
if 'corpus' in todo:
corpus(args.work, args.out, tok)
if 'log' in todo:
train_log(args.work, args.out)
for run in ('gpt1', 'gpt4', 'chat'):
train_log(args.work, args.out, run, f'train-log-{run}')
lora_logs(args.work, args.out)
if 'chats' in todo:
chats(args.work, args.out)
if __name__ == '__main__':
main()