first main run setup ready to begin
Browse files- layer_0.py +133 -0
- layer_1.py +105 -0
- layer_11.py +29 -0
- layer_2.py +40 -0
- notio.py +75 -0
- train_seq.py +253 -0
layer_0.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Layer 0: the binary input.
|
| 2 |
+
|
| 3 |
+
No parameters. Owns the u8 id streams (train/val) and turns them into
|
| 4 |
+
model-ready batches: x (B,T) and y (B,T) int64 tensors, where y is the
|
| 5 |
+
same stream shifted by one id (next-token target).
|
| 6 |
+
|
| 7 |
+
Independence: imports only numpy/torch. Knows nothing about layer 1+.
|
| 8 |
+
"""
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class Layer0Config:
|
| 19 |
+
block_size: int = 1024 # T: context length handed to layer 1
|
| 20 |
+
batch_size: int = 32 # B
|
| 21 |
+
chunk_size: int = 65536 # shuffle granularity, in ids
|
| 22 |
+
seed: Optional[int] = 1337
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Layer0:
|
| 26 |
+
"""Read-only view of the binary; chunk-shuffle sampling."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, cfg, train_path="data/train.bin", val_path="data/val.bin"):
|
| 29 |
+
self.cfg = cfg
|
| 30 |
+
# memmaps: fork-inheritable later, no copies in this process now
|
| 31 |
+
self.train = np.memmap(train_path, dtype=np.uint8, mode="r")
|
| 32 |
+
self.val = np.memmap(val_path, dtype=np.uint8, mode="r")
|
| 33 |
+
self.n_train = int(self.train.size)
|
| 34 |
+
self.n_val = int(self.val.size)
|
| 35 |
+
self.n_chunks = self.n_train // cfg.chunk_size # tail partial chunk dropped
|
| 36 |
+
self.rng = np.random.default_rng(cfg.seed)
|
| 37 |
+
|
| 38 |
+
def sample_batch(self):
|
| 39 |
+
"""One training batch: random chunk, random offset, x and y shifted by 1."""
|
| 40 |
+
B, T, C = self.cfg.batch_size, self.cfg.block_size, self.cfg.chunk_size
|
| 41 |
+
xs = np.empty((B, T + 1), dtype=np.uint8)
|
| 42 |
+
for b in range(B):
|
| 43 |
+
c = int(self.rng.integers(0, self.n_chunks))
|
| 44 |
+
o = int(self.rng.integers(0, C - T)) # keeps y inside the chunk
|
| 45 |
+
base = c * C + o
|
| 46 |
+
xs[b] = self.train[base: base + T + 1]
|
| 47 |
+
x = torch.tensor(xs[:, :T], dtype=torch.long)
|
| 48 |
+
y = torch.tensor(xs[:, 1:], dtype=torch.long)
|
| 49 |
+
return x, y
|
| 50 |
+
|
| 51 |
+
def val_block(self, i):
|
| 52 |
+
"""The i-th contiguous block of the val stream (sequential, no shuffle)."""
|
| 53 |
+
T = self.cfg.block_size
|
| 54 |
+
base = i * T
|
| 55 |
+
assert base + T < self.n_val, f"val block {i} out of range"
|
| 56 |
+
seg = self.val[base: base + T + 1]
|
| 57 |
+
return (torch.tensor(seg[:T], dtype=torch.long),
|
| 58 |
+
torch.tensor(seg[1:], dtype=torch.long))
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def n_val_blocks(self):
|
| 62 |
+
# blocks i with base+T+1 <= n_val -> i <= (n_val - T - 1) // T
|
| 63 |
+
return (self.n_val - self.cfg.block_size - 1) // self.cfg.block_size + 1
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def steps_per_epoch(self):
|
| 67 |
+
return self.n_train // (self.cfg.batch_size * self.cfg.block_size)
|
| 68 |
+
|
| 69 |
+
# ---- stateful truncated BPTT mode: sequential windows over contiguous tapes ----
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def tape_len(self):
|
| 73 |
+
"""Contiguous ids per row, truncated to whole windows."""
|
| 74 |
+
return (self.n_train // self.cfg.batch_size) // self.cfg.block_size * self.cfg.block_size
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def n_seq_windows(self):
|
| 78 |
+
return self.tape_len // self.cfg.block_size
|
| 79 |
+
|
| 80 |
+
def seq_windows(self):
|
| 81 |
+
"""Yield (x, y) for each T-window, all B rows advancing in lockstep.
|
| 82 |
+
|
| 83 |
+
Row b walks tape b = train[b*tape_len : (b+1)*tape_len] in order.
|
| 84 |
+
Tapes are contiguous in the original stream, so the caller may carry
|
| 85 |
+
RNN state across windows and reset only at sweep end (the one real
|
| 86 |
+
discontinuity). Deterministic: no RNG.
|
| 87 |
+
"""
|
| 88 |
+
B, T = self.cfg.batch_size, self.cfg.block_size
|
| 89 |
+
rows = np.arange(B, dtype=np.int64) * self.tape_len
|
| 90 |
+
cols = np.arange(T + 1, dtype=np.int64)
|
| 91 |
+
idx = rows[:, None] + cols[None, :] # (B, T+1) base indices, built once
|
| 92 |
+
for w in range(self.n_seq_windows):
|
| 93 |
+
off = (idx + w * T).ravel() # per-window offsets
|
| 94 |
+
xs = self.train[off].reshape(B, T + 1) # one C-level gather, no python loop
|
| 95 |
+
yield (torch.tensor(xs[:, :T], dtype=torch.long),
|
| 96 |
+
torch.tensor(xs[:, 1:], dtype=torch.long))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _self_test():
|
| 100 |
+
PROJ = Path(__file__).resolve().parents[1]
|
| 101 |
+
import sys
|
| 102 |
+
sys.path.insert(0, str(PROJ / "scripts"))
|
| 103 |
+
import tokenizer as tk
|
| 104 |
+
tk.load_vocab()
|
| 105 |
+
|
| 106 |
+
cfg = Layer0Config(batch_size=8, block_size=256)
|
| 107 |
+
l0 = Layer0(cfg)
|
| 108 |
+
x, y = l0.sample_batch()
|
| 109 |
+
|
| 110 |
+
assert x.shape == (8, 256) and y.shape == (8, 256), "batch shape"
|
| 111 |
+
assert int(x.min()) >= 0 and int(x.max()) <= 108, "ids out of vocab"
|
| 112 |
+
assert torch.equal(y[:, :255], x[:, 1:]), "shift law violated"
|
| 113 |
+
|
| 114 |
+
v0, _ = l0.val_block(0)
|
| 115 |
+
assert torch.equal(v0, torch.tensor(l0.val[:256].astype(np.int64))), "val walk"
|
| 116 |
+
|
| 117 |
+
def render(row):
|
| 118 |
+
out = bytes(row.numpy().astype(np.uint8)).translate(tk.TABLE_DEC)
|
| 119 |
+
for s, t in tk.SENT_DEC:
|
| 120 |
+
out = out.replace(s, t)
|
| 121 |
+
return tk.display(out).decode()
|
| 122 |
+
|
| 123 |
+
print("x[0] (model input):")
|
| 124 |
+
print(render(x[0]))
|
| 125 |
+
print("y[0] (model target):")
|
| 126 |
+
print(render(y[0]))
|
| 127 |
+
print(f"n_train={l0.n_train:,} n_chunks={l0.n_chunks:,} "
|
| 128 |
+
f"steps/epoch={l0.steps_per_epoch:,} n_val_blocks={l0.n_val_blocks:,}")
|
| 129 |
+
print("layer0 self-test: PASS")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
if __name__ == "__main__":
|
| 133 |
+
_self_test()
|
layer_1.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Layer 1: the embedding - id -> vector.
|
| 2 |
+
|
| 3 |
+
wte: token table (vocab_size, d_model) "what"
|
| 4 |
+
wpe: position table (block_size, d_model) "where"
|
| 5 |
+
x = wte(ids) + wpe(pos), then optional dropout.
|
| 6 |
+
|
| 7 |
+
Independence: imports only torch. Knows nothing about layer 0 (data)
|
| 8 |
+
or anything above it. Exposes tie_lm_head for the (later) output layer.
|
| 9 |
+
"""
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class Layer1Config:
|
| 18 |
+
vocab_size: int = 109
|
| 19 |
+
block_size: int = 1024
|
| 20 |
+
d_model: int = 512
|
| 21 |
+
init_std: float = 0.02
|
| 22 |
+
dropout: float = 0.0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Layer1(nn.Module):
|
| 26 |
+
def __init__(self, cfg: Layer1Config):
|
| 27 |
+
super().__init__()
|
| 28 |
+
self.cfg = cfg
|
| 29 |
+
self.wte = nn.Embedding(cfg.vocab_size, cfg.d_model)
|
| 30 |
+
self.wpe = nn.Embedding(cfg.block_size, cfg.d_model)
|
| 31 |
+
self.drop = nn.Dropout(cfg.dropout)
|
| 32 |
+
self.init_weights()
|
| 33 |
+
|
| 34 |
+
def init_weights(self):
|
| 35 |
+
nn.init.normal_(self.wte.weight, mean=0.0, std=self.cfg.init_std)
|
| 36 |
+
nn.init.normal_(self.wpe.weight, mean=0.0, std=self.cfg.init_std)
|
| 37 |
+
|
| 38 |
+
def forward(self, ids, pos_offset=0):
|
| 39 |
+
"""ids: (B, T) long -> (B, T, d_model). pos_offset: position embedding
|
| 40 |
+
start, used by stateful generation so wpe advances with the stream."""
|
| 41 |
+
B, T = ids.shape
|
| 42 |
+
assert pos_offset + T <= self.cfg.block_size, \
|
| 43 |
+
f"pos_offset+T={pos_offset + T} exceeds block_size {self.cfg.block_size}"
|
| 44 |
+
pos = torch.arange(T, device=ids.device) + pos_offset
|
| 45 |
+
return self.drop(self.wte(ids) + self.wpe(pos))
|
| 46 |
+
|
| 47 |
+
def tie_lm_head(self, head):
|
| 48 |
+
"""Share wte with the (later) output head: head.weight IS wte.weight."""
|
| 49 |
+
head.weight = self.wte.weight
|
| 50 |
+
return head
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def n_params(self):
|
| 54 |
+
return sum(p.numel() for p in self.parameters())
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _self_test():
|
| 58 |
+
torch.manual_seed(0)
|
| 59 |
+
cfg = Layer1Config()
|
| 60 |
+
l1 = Layer1(cfg)
|
| 61 |
+
B, T = 4, 16
|
| 62 |
+
ids = torch.randint(0, cfg.vocab_size, (B, T))
|
| 63 |
+
x = l1(ids)
|
| 64 |
+
|
| 65 |
+
assert x.shape == (B, T, cfg.d_model), "embed shape"
|
| 66 |
+
|
| 67 |
+
# same id everywhere -> the wte part is constant across positions
|
| 68 |
+
same = torch.full((1, T), 42, dtype=torch.long)
|
| 69 |
+
xs = l1(same)
|
| 70 |
+
for p in range(T - 1):
|
| 71 |
+
d = (xs[0, p] - l1.wpe.weight[p]) - (xs[0, p + 1] - l1.wpe.weight[p + 1])
|
| 72 |
+
assert d.abs().max().item() < 1e-6, "wte part varies with position"
|
| 73 |
+
|
| 74 |
+
# same position, different ids -> the wpe part is constant
|
| 75 |
+
for b in range(B - 1):
|
| 76 |
+
d = (x[b, 0] - l1.wte(ids[b, 0])) - (x[b + 1, 0] - l1.wte(ids[b + 1, 0]))
|
| 77 |
+
assert d.abs().max().item() < 1e-6, "wpe part varies with token"
|
| 78 |
+
|
| 79 |
+
# gradients flow through both tables
|
| 80 |
+
x.sum().backward()
|
| 81 |
+
assert l1.wte.weight.grad is not None and l1.wte.weight.grad.abs().sum().item() > 0
|
| 82 |
+
assert l1.wpe.weight.grad is not None and l1.wpe.weight.grad.abs().sum().item() > 0
|
| 83 |
+
|
| 84 |
+
# param count: 109*256 + 256*256
|
| 85 |
+
assert l1.n_params == cfg.vocab_size * cfg.d_model + cfg.block_size * cfg.d_model == 93440
|
| 86 |
+
|
| 87 |
+
# weight tying is a true share, not a copy
|
| 88 |
+
head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
|
| 89 |
+
l1.tie_lm_head(head)
|
| 90 |
+
assert head.weight is l1.wte.weight, "tie is not a shared tensor"
|
| 91 |
+
|
| 92 |
+
# block_size guard
|
| 93 |
+
try:
|
| 94 |
+
l1(torch.zeros((1, cfg.block_size + 1), dtype=torch.long))
|
| 95 |
+
raise SystemExit("expected assertion for T > block_size")
|
| 96 |
+
except AssertionError:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
print(f"wte {tuple(l1.wte.weight.shape)} wpe {tuple(l1.wpe.weight.shape)} "
|
| 100 |
+
f"params {l1.n_params:,}")
|
| 101 |
+
print("layer1 self-test: PASS")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
_self_test()
|
layer_11.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Layer 11: the output head.
|
| 2 |
+
|
| 3 |
+
ln_f + lm_head over the vocab. Supports weight tying with layer 1's wte.
|
| 4 |
+
The 12-layer stack: layer 0 + layer 1 + 9x layer 2 + this.
|
| 5 |
+
"""
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class Layer11Config:
|
| 13 |
+
vocab_size: int = 109
|
| 14 |
+
d_model: int = 512
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Layer11(nn.Module):
|
| 18 |
+
def __init__(self, cfg: Layer11Config):
|
| 19 |
+
super().__init__()
|
| 20 |
+
self.cfg = cfg
|
| 21 |
+
self.ln_f = nn.LayerNorm(cfg.d_model)
|
| 22 |
+
self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False)
|
| 23 |
+
|
| 24 |
+
def tie_to(self, layer1):
|
| 25 |
+
"""Share layer 1's wte: head.weight IS wte.weight (0 extra params)."""
|
| 26 |
+
self.lm_head.weight = layer1.wte.weight
|
| 27 |
+
|
| 28 |
+
def forward(self, x):
|
| 29 |
+
return self.lm_head(self.ln_f(x))
|
layer_2.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Layer 2: one recurrent block (pre-LN + residual, LSTM or GRU).
|
| 2 |
+
|
| 3 |
+
The 12-layer stack instantiates this 9 times: layers 2..10.
|
| 4 |
+
Causal by construction - no mask needed; memory is O(T), not O(T^2).
|
| 5 |
+
Hidden state resets each forward (windowed training); stateful sampling
|
| 6 |
+
can pass an initial state later.
|
| 7 |
+
|
| 8 |
+
Independence: imports only torch. Knows nothing about other layers.
|
| 9 |
+
"""
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class Layer2Config:
|
| 17 |
+
d_model: int = 512
|
| 18 |
+
rnn_type: str = "gru" # "lstm" | "gru"
|
| 19 |
+
dropout: float = 0.0
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Layer2(nn.Module):
|
| 23 |
+
def __init__(self, cfg: Layer2Config):
|
| 24 |
+
super().__init__()
|
| 25 |
+
self.cfg = cfg
|
| 26 |
+
self.ln = nn.LayerNorm(cfg.d_model)
|
| 27 |
+
if cfg.rnn_type == "lstm":
|
| 28 |
+
self.rnn = nn.LSTM(cfg.d_model, cfg.d_model, batch_first=True)
|
| 29 |
+
elif cfg.rnn_type == "gru":
|
| 30 |
+
self.rnn = nn.GRU(cfg.d_model, cfg.d_model, batch_first=True)
|
| 31 |
+
else:
|
| 32 |
+
raise ValueError(f"unknown rnn_type: {cfg.rnn_type}")
|
| 33 |
+
|
| 34 |
+
def forward(self, x, state=None):
|
| 35 |
+
out, state = self.rnn(self.ln(x), state)
|
| 36 |
+
return x + out, state
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def n_params(self):
|
| 40 |
+
return sum(p.numel() for p in self.parameters())
|
notio.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The assembler: layer 0 + layer 1 + 9x layer 2 + layer 11 = 12 layers.
|
| 2 |
+
|
| 3 |
+
The only file that knows the whole stack. forward(ids) -> logits (B, T, vocab).
|
| 4 |
+
"""
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
|
| 9 |
+
from .layer_0 import Layer0, Layer0Config
|
| 10 |
+
from .layer_1 import Layer1, Layer1Config
|
| 11 |
+
from .layer_2 import Layer2, Layer2Config
|
| 12 |
+
from .layer_11 import Layer11, Layer11Config
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class NotioConfig:
|
| 17 |
+
layer0: Layer0Config = field(default_factory=Layer0Config)
|
| 18 |
+
layer1: Layer1Config = field(default_factory=Layer1Config)
|
| 19 |
+
block: Layer2Config = field(default_factory=Layer2Config)
|
| 20 |
+
head: Layer11Config = field(default_factory=Layer11Config)
|
| 21 |
+
n_blocks: int = 2 # layers 2..10; + head = layer 11 -> 12 layers total
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class Notio(nn.Module):
|
| 25 |
+
def __init__(self, cfg: NotioConfig):
|
| 26 |
+
super().__init__()
|
| 27 |
+
self.cfg = cfg
|
| 28 |
+
# cross-layer contracts (the one place they can be checked)
|
| 29 |
+
assert cfg.layer1.block_size >= cfg.layer0.block_size, "layer 1 capacity must be >= layer 0 sequence length"
|
| 30 |
+
assert cfg.layer1.d_model == cfg.block.d_model == cfg.head.d_model, "d_model must match everywhere"
|
| 31 |
+
assert cfg.layer1.vocab_size == cfg.head.vocab_size, "vocab_size must match layer 1 and head"
|
| 32 |
+
|
| 33 |
+
self.layer0 = Layer0(cfg.layer0) # data (not an nn.Module)
|
| 34 |
+
self.layer1 = Layer1(cfg.layer1)
|
| 35 |
+
self.blocks = nn.ModuleList([Layer2(cfg.block) for _ in range(cfg.n_blocks)])
|
| 36 |
+
self.head = Layer11(cfg.head)
|
| 37 |
+
self.tie_head()
|
| 38 |
+
self.init_weights()
|
| 39 |
+
|
| 40 |
+
def init_weights(self):
|
| 41 |
+
for module in self.modules():
|
| 42 |
+
if isinstance(module, nn.Linear):
|
| 43 |
+
nn.init.normal_(module.weight, mean=0.0, std=self.cfg.layer1.init_std)
|
| 44 |
+
if module.bias is not None:
|
| 45 |
+
nn.init.zeros_(module.bias)
|
| 46 |
+
# residual-path projections get the GPT-2 depth scaling
|
| 47 |
+
for name, p in self.named_parameters():
|
| 48 |
+
if name.endswith("c_proj.weight"):
|
| 49 |
+
nn.init.normal_(p, mean=0.0,
|
| 50 |
+
std=self.cfg.layer1.init_std / (2 * self.cfg.n_blocks) ** 0.5)
|
| 51 |
+
|
| 52 |
+
def tie_head(self):
|
| 53 |
+
self.head.tie_to(self.layer1)
|
| 54 |
+
|
| 55 |
+
def forward(self, ids, states=None, pos_offset=0):
|
| 56 |
+
"""ids: (B, T) long -> (logits (B, T, vocab), states list).
|
| 57 |
+
|
| 58 |
+
states: per-block recurrent state from the previous window, or None
|
| 59 |
+
for zeros. The caller decides when to detach/reset (truncated BPTT).
|
| 60 |
+
pos_offset: position embedding offset for stateful generation.
|
| 61 |
+
"""
|
| 62 |
+
x = self.layer1(ids, pos_offset)
|
| 63 |
+
new_states = []
|
| 64 |
+
for i, block in enumerate(self.blocks):
|
| 65 |
+
st = None if states is None else states[i]
|
| 66 |
+
x, st = block(x, st)
|
| 67 |
+
new_states.append(st)
|
| 68 |
+
return self.head(x), new_states
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def n_params(self):
|
| 72 |
+
return sum(p.numel() for p in self.parameters())
|
| 73 |
+
|
| 74 |
+
def sample_batch(self):
|
| 75 |
+
return self.layer0.sample_batch()
|
train_seq.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Stateful truncated BPTT training loop for the RNN stack.
|
| 3 |
+
|
| 4 |
+
- layer_0.seq_windows(): B contiguous tapes walked in order
|
| 5 |
+
- RNN state carried across windows but detached at every boundary:
|
| 6 |
+
gradients are truncated to one window, while information flows forward
|
| 7 |
+
through the carried state (the k1 == k2 == T form of truncated BPTT)
|
| 8 |
+
- state reset to zeros only at sweep boundaries (the one real discontinuity)
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python3 src/train_seq.py # train (defaults: 1 epoch, cpu/cuda auto)
|
| 12 |
+
python3 src/train_seq.py --check # prove truncation + statefulness
|
| 13 |
+
python3 src/train_seq.py --epochs 3 --lr 3e-4 --save out.pt
|
| 14 |
+
"""
|
| 15 |
+
import argparse
|
| 16 |
+
import math
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
from itertools import islice
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn.functional as F
|
| 25 |
+
|
| 26 |
+
PROJ = Path(__file__).resolve().parents[1]
|
| 27 |
+
sys.path.insert(0, str(PROJ))
|
| 28 |
+
sys.path.insert(0, str(PROJ / "scripts"))
|
| 29 |
+
import tokenizer as tk
|
| 30 |
+
from model import Notio, NotioConfig
|
| 31 |
+
|
| 32 |
+
tk.load_vocab() # also asserts vocab.txt == vocab.bin at train start
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def detach_states(states):
|
| 36 |
+
"""Detach every hidden/cell tensor: no gradient flows across windows.
|
| 37 |
+
Handles LSTM (h, c) tuples and GRU bare tensors."""
|
| 38 |
+
out = []
|
| 39 |
+
for st in states:
|
| 40 |
+
out.append(tuple(s.detach() for s in st) if isinstance(st, tuple) else st.detach())
|
| 41 |
+
return out
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def ids_to_display(ids):
|
| 45 |
+
"""id tensor -> human text via the runtime decoder (tags dropped/mapped)."""
|
| 46 |
+
out = bytes(ids.cpu().numpy().astype(np.uint8)).translate(tk.TABLE_DEC)
|
| 47 |
+
for sent, tok in tk.SENT_DEC:
|
| 48 |
+
out = out.replace(sent, tok)
|
| 49 |
+
return tk.display(out).decode()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@torch.no_grad()
|
| 53 |
+
def generate(m, device, max_tokens=256, temperature=0.9, top_k=0):
|
| 54 |
+
"""Sample one story: <bos> prompt, carry RNN state, stop at <eos>."""
|
| 55 |
+
m.eval()
|
| 56 |
+
ids = torch.tensor([[1]], dtype=torch.long, device=device) # <bos>
|
| 57 |
+
states = None
|
| 58 |
+
pos = 0
|
| 59 |
+
for _ in range(max_tokens):
|
| 60 |
+
logits, states = m(ids[:, -1:], states, pos_offset=pos)
|
| 61 |
+
states = detach_states(states)
|
| 62 |
+
pos += 1
|
| 63 |
+
logits = logits[:, -1, :] / max(temperature, 1e-6)
|
| 64 |
+
if top_k > 0:
|
| 65 |
+
v, _ = torch.topk(logits, top_k)
|
| 66 |
+
logits = torch.where(logits < v[:, -1:],
|
| 67 |
+
torch.full_like(logits, -float("inf")), logits)
|
| 68 |
+
probs = F.softmax(logits, dim=-1)
|
| 69 |
+
nxt = torch.multinomial(probs, 1)
|
| 70 |
+
ids = torch.cat([ids, nxt], dim=1)
|
| 71 |
+
if nxt.item() == 2: # <eos>
|
| 72 |
+
break
|
| 73 |
+
m.train()
|
| 74 |
+
return ids
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def sample_and_print(m, device, n=2):
|
| 78 |
+
for i in range(n):
|
| 79 |
+
ids = generate(m, device)
|
| 80 |
+
print(f"--- sample {i} ({ids.numel()} ids) ---")
|
| 81 |
+
print(ids_to_display(ids))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@torch.no_grad()
|
| 85 |
+
def val_loss(m, device, n_blocks=20):
|
| 86 |
+
"""Mean next-token loss over the first n_blocks contiguous val blocks,
|
| 87 |
+
carrying state across them (mirrors training)."""
|
| 88 |
+
m.eval()
|
| 89 |
+
states = None
|
| 90 |
+
total = 0.0
|
| 91 |
+
n = min(n_blocks, m.layer0.n_val_blocks)
|
| 92 |
+
for i in range(n):
|
| 93 |
+
x, y = m.layer0.val_block(i)
|
| 94 |
+
x, y = x.unsqueeze(0).to(device), y.unsqueeze(0).to(device)
|
| 95 |
+
logits, states = m(x, states)
|
| 96 |
+
total += F.cross_entropy(logits.view(-1, m.cfg.head.vocab_size), y.view(-1)).item()
|
| 97 |
+
states = detach_states(states)
|
| 98 |
+
m.train()
|
| 99 |
+
return total / n
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def lr_at(step, lr, warmup, total, min_lr):
|
| 103 |
+
"""Linear warmup, then cosine decay to min_lr over [warmup, total)."""
|
| 104 |
+
if step < warmup:
|
| 105 |
+
return lr * step / max(warmup, 1)
|
| 106 |
+
if step >= total:
|
| 107 |
+
return min_lr
|
| 108 |
+
prog = (step - warmup) / max(total - warmup, 1)
|
| 109 |
+
return min_lr + 0.5 * (lr - min_lr) * (1 + math.cos(math.pi * prog))
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def check():
|
| 113 |
+
torch.manual_seed(0)
|
| 114 |
+
m = Notio(NotioConfig())
|
| 115 |
+
m.train()
|
| 116 |
+
it = m.layer0.seq_windows()
|
| 117 |
+
x1, y1 = next(it)
|
| 118 |
+
x2, y2 = next(it)
|
| 119 |
+
|
| 120 |
+
# 1) statefulness: carried state changes the second window's output
|
| 121 |
+
_, st = m(x1)
|
| 122 |
+
logits_carry, _ = m(x2, st)
|
| 123 |
+
logits_zero, _ = m(x2)
|
| 124 |
+
assert not torch.equal(logits_carry, logits_zero), "state is not used"
|
| 125 |
+
|
| 126 |
+
# 2) truncation: detach cuts the graph back to window 1.
|
| 127 |
+
# (values still flow - that is the point of carrying state)
|
| 128 |
+
V = m.cfg.head.vocab_size
|
| 129 |
+
assert st[0][0].grad_fn is not None, "state should carry history to window 1"
|
| 130 |
+
st_d = detach_states(st)
|
| 131 |
+
assert st_d[0][0].grad_fn is None, "detached state should be a leaf"
|
| 132 |
+
logits_d, _ = m(x2, st_d)
|
| 133 |
+
assert torch.equal(logits_carry, logits_d), "detach changed the forward pass"
|
| 134 |
+
|
| 135 |
+
def pgrad(states_):
|
| 136 |
+
m.zero_grad()
|
| 137 |
+
logits, _ = m(x2, states_)
|
| 138 |
+
F.cross_entropy(logits.view(-1, V), y2.view(-1)).backward()
|
| 139 |
+
return sum(p.grad.detach().abs().sum() for p in m.parameters() if p.grad is not None)
|
| 140 |
+
|
| 141 |
+
gd = pgrad(st_d) # detached: graph stops at the carried state
|
| 142 |
+
gu = pgrad(st) # undetached: gradient also flows back through window 1
|
| 143 |
+
assert torch.equal(gd, gu) is False, "detach did not truncate the gradient path"
|
| 144 |
+
|
| 145 |
+
# 3) tape continuity: window w+1 continues window w inside a tape
|
| 146 |
+
assert torch.equal(x2[:, 0], y1[:, -1]), "tapes are not contiguous"
|
| 147 |
+
|
| 148 |
+
# 4) a few real optimizer steps with carried+detached state
|
| 149 |
+
opt = torch.optim.AdamW(m.parameters(), lr=1e-3)
|
| 150 |
+
states = None
|
| 151 |
+
it2 = m.layer0.seq_windows()
|
| 152 |
+
t0 = time.time()
|
| 153 |
+
for step in range(3):
|
| 154 |
+
x, y = next(it2)
|
| 155 |
+
logits, states = m(x, states)
|
| 156 |
+
loss = F.cross_entropy(logits.view(-1, m.cfg.head.vocab_size), y.view(-1))
|
| 157 |
+
opt.zero_grad()
|
| 158 |
+
loss.backward()
|
| 159 |
+
opt.step()
|
| 160 |
+
states = detach_states(states)
|
| 161 |
+
print(f"step {step}: loss {loss.item():.3f}")
|
| 162 |
+
toks = 3 * x.numel()
|
| 163 |
+
print(f"truncation: True | statefulness: True | tape continuity: True")
|
| 164 |
+
print(f"{toks / (time.time() - t0):,.0f} tokens/s (CPU, {m.n_params:,} params, "
|
| 165 |
+
f"T={m.cfg.layer0.block_size}, B={m.cfg.layer0.batch_size}, d={m.cfg.layer1.d_model}, "
|
| 166 |
+
f"n_blocks={m.cfg.n_blocks})")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def main(device, epochs, lr, log_every, save, max_steps, save_every,
|
| 170 |
+
warmup, min_lr, resume, val_blocks):
|
| 171 |
+
torch.backends.cudnn.benchmark = True
|
| 172 |
+
if resume:
|
| 173 |
+
ck = torch.load(resume, map_location=device, weights_only=False) # our own file, contains NotioConfig
|
| 174 |
+
m = Notio(ck["cfg"])
|
| 175 |
+
m.load_state_dict(ck["state_dict"])
|
| 176 |
+
m.to(device)
|
| 177 |
+
start = ck.get("step", 0)
|
| 178 |
+
print(f"resumed {resume} @ step {start} "
|
| 179 |
+
f"(loss {ck.get('loss', float('nan')):.3f}, "
|
| 180 |
+
f"val loss {ck.get('val_loss', float('nan')):.3f})")
|
| 181 |
+
else:
|
| 182 |
+
m = Notio(NotioConfig())
|
| 183 |
+
m.to(device)
|
| 184 |
+
start = 0
|
| 185 |
+
m.train()
|
| 186 |
+
opt = torch.optim.AdamW(m.parameters(), lr=lr)
|
| 187 |
+
total = max_steps if max_steps else epochs * m.layer0.n_seq_windows
|
| 188 |
+
states = None
|
| 189 |
+
step = start
|
| 190 |
+
t0 = time.time()
|
| 191 |
+
it = islice(m.layer0.seq_windows(), start, None) # continue the sweep position
|
| 192 |
+
for epoch in range(epochs if max_steps is None else 1):
|
| 193 |
+
src = islice(it, max_steps) if max_steps is not None else it
|
| 194 |
+
for x, y in src:
|
| 195 |
+
new_lr = lr_at(step, lr, warmup, total, min_lr)
|
| 196 |
+
for g in opt.param_groups:
|
| 197 |
+
g["lr"] = new_lr
|
| 198 |
+
x, y = x.to(device), y.to(device)
|
| 199 |
+
logits, states = m(x, states)
|
| 200 |
+
loss = F.cross_entropy(logits.view(-1, m.cfg.head.vocab_size), y.view(-1))
|
| 201 |
+
opt.zero_grad()
|
| 202 |
+
loss.backward()
|
| 203 |
+
torch.nn.utils.clip_grad_norm_(m.parameters(), 1.0)
|
| 204 |
+
opt.step()
|
| 205 |
+
states = detach_states(states) # truncate gradients at window length T
|
| 206 |
+
step += 1
|
| 207 |
+
if step % log_every == 0:
|
| 208 |
+
tok = step * x.numel()
|
| 209 |
+
print(f"epoch {epoch} step {step}: loss {loss.item():.3f} | "
|
| 210 |
+
f"{tok / (time.time() - t0):,.0f} tok/s | lr {new_lr:.2e}")
|
| 211 |
+
if step % save_every == 0:
|
| 212 |
+
vl = val_loss(m, device, val_blocks)
|
| 213 |
+
print(f"val loss @ step {step}: {vl:.3f}")
|
| 214 |
+
sample_and_print(m, device)
|
| 215 |
+
if save:
|
| 216 |
+
torch.save({"step": step, "loss": loss.item(), "val_loss": vl,
|
| 217 |
+
"lr": new_lr, "window": step, "cfg": m.cfg,
|
| 218 |
+
"state_dict": m.state_dict()}, save)
|
| 219 |
+
print(f"checkpoint saved: {save} @ step {step}")
|
| 220 |
+
states = None # sweep ended: reset at the real discontinuity
|
| 221 |
+
if max_steps is not None:
|
| 222 |
+
break
|
| 223 |
+
it = m.layer0.seq_windows()
|
| 224 |
+
total_tok = (step - start) * m.cfg.layer0.batch_size * m.cfg.layer0.block_size
|
| 225 |
+
vl = val_loss(m, device, val_blocks)
|
| 226 |
+
print(f"done: {step} steps | {total_tok / (time.time() - t0):,.0f} tok/s on {device} "
|
| 227 |
+
f"| final loss {loss.item():.3f} | val loss {vl:.3f}")
|
| 228 |
+
if save:
|
| 229 |
+
torch.save({"step": step, "loss": loss.item(), "val_loss": vl, "lr": new_lr,
|
| 230 |
+
"window": step, "cfg": m.cfg, "state_dict": m.state_dict()}, save)
|
| 231 |
+
print(f"saved {save}")
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
ap = argparse.ArgumentParser()
|
| 236 |
+
ap.add_argument("--check", action="store_true", help="prove truncation + statefulness, then exit")
|
| 237 |
+
ap.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 238 |
+
ap.add_argument("--epochs", type=int, default=1)
|
| 239 |
+
ap.add_argument("--lr", type=float, default=4e-4)
|
| 240 |
+
ap.add_argument("--log-every", type=int, default=20)
|
| 241 |
+
ap.add_argument("--max-steps", type=int, default=None, help="benchmark: train this many steps then stop")
|
| 242 |
+
ap.add_argument("--save", default=None, help="checkpoint path (saved every --save-every steps + at end)")
|
| 243 |
+
ap.add_argument("--save-every", type=int, default=500)
|
| 244 |
+
ap.add_argument("--warmup", type=int, default=500)
|
| 245 |
+
ap.add_argument("--min-lr", type=float, default=4e-5)
|
| 246 |
+
ap.add_argument("--resume", default=None, help="checkpoint to resume from (continues sweep position)")
|
| 247 |
+
ap.add_argument("--val-blocks", type=int, default=20)
|
| 248 |
+
a = ap.parse_args()
|
| 249 |
+
if a.check:
|
| 250 |
+
check()
|
| 251 |
+
else:
|
| 252 |
+
main(a.device, a.epochs, a.lr, a.log_every, a.save, a.max_steps, a.save_every,
|
| 253 |
+
a.warmup, a.min_lr, a.resume, a.val_blocks)
|