sarathi-balakrishnan commited on
Commit
8fde2c1
Β·
verified Β·
1 Parent(s): c6a9515

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. README.md +152 -0
  2. config.json +169 -0
  3. inference.py +194 -0
  4. model.safetensors +3 -0
  5. tiny_gpt.py +702 -0
README.md ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - text-generation
7
+ - sql
8
+ - educational
9
+ - from-scratch
10
+ - interpretability
11
+ - tiny
12
+ pipeline_tag: text-generation
13
+ library_name: pytorch
14
+ ---
15
+
16
+ # Tiny SQL GPT
17
+
18
+ **A 841,216-parameter decoder-only transformer, trained from random weights on a laptop CPU in
19
+ five minutes. 100% of the SQL it generates executes against a real database.**
20
+
21
+ No pretrained weights. No `transformers` model classes. No API keys. The full architecture is
22
+ ~200 readable lines of PyTorch.
23
+
24
+ This model exists to be **understood**, not deployed. Its vocabulary is 155 tokens, which is the
25
+ point: small enough that you can print the *entire* probability distribution at every generation
26
+ step, something no frontier model demo can do.
27
+
28
+ - **Code, evaluation harness and write-up:** https://github.com/sarathi-aiml/tiny-sql-gpt
29
+ - **Plain-English explainer (no maths):** [`EXPLAIN.md`](https://github.com/sarathi-aiml/tiny-sql-gpt/blob/main/EXPLAIN.md)
30
+
31
+ ---
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ pip install torch safetensors huggingface_hub
37
+ ```
38
+
39
+ ```python
40
+ from inference import TinySQLGPT # inference.py + tiny_gpt.py from the repo
41
+
42
+ model = TinySQLGPT.from_pretrained("sarathi-balakrishnan/tiny-sql-gpt")
43
+
44
+ print(model.generate())
45
+ # SELECT segment , AVG ( qty ) FROM sales WHERE product = 'cog' GROUP BY segment ;
46
+
47
+ print(model.generate(prompt="SELECT region ,"))
48
+
49
+ # The whole distribution, all 155 tokens, not a top-k truncation
50
+ for token, p in model.next_token_probs(
51
+ "SELECT region , SUM ( qty ) FROM sales GROUP BY", top=5):
52
+ print(f"{token:<12} {p:.3f}")
53
+ # region 0.995
54
+ # segment 0.001
55
+ # quarter 0.001
56
+ ```
57
+
58
+ ---
59
+
60
+ ## What it does
61
+
62
+ Generates SQL over a fixed three-table schema (`sales`, `customers`, `orders`) using 14 query
63
+ shapes: `SELECT`, `WHERE`, `AND`, `GROUP BY`, `ORDER BY`, `LIMIT`, and the aggregates
64
+ `COUNT`/`SUM`/`AVG`/`MAX`/`MIN`.
65
+
66
+ It is **not** a text-to-SQL model. It does not take a natural-language question. It generates
67
+ SQL unconditionally, or continues a SQL prefix you give it.
68
+
69
+ ## Results
70
+
71
+ 500 generated queries, executed against a real SQLite database. Seeded, so you get these exact
72
+ numbers.
73
+
74
+ | metric | Tiny SQL GPT | bigram baseline |
75
+ |---|---:|---:|
76
+ | executes | **100.0%** | 4.4% |
77
+ | `GROUP BY` agrees with `SELECT` | **100.0%** | 3.4% |
78
+ | novel (not in training set) | 13.4% | 98.6% |
79
+ | validation loss | 0.663 | n/a |
80
+
81
+ 100% is a real measurement, but read it against the task: 14 query shapes, 3 tables, 155 tokens.
82
+ A model that saturates *this* is proof the training loop works, not a text-to-SQL system.
83
+
84
+ ### Scaling
85
+
86
+ Same architecture and data at four sizes, all trained on one laptop:
87
+
88
+ | model | params | executes | `GROUP BY` agrees |
89
+ |---|---:|---:|---:|
90
+ | nano | 24,736 | 99.6% | **41.4%** |
91
+ | micro | 124,032 | 99.8% | **100.0%** |
92
+ | **tiny** (this model) | **841,216** | **100.0%** | **100.0%** |
93
+ | small | 4,834,816 | 100.0% | 100.0% |
94
+
95
+ Syntax is nearly free: 24K parameters writes SQL that runs. The long-range dependency costs ~5x
96
+ more, and appears as a phase transition between 24K and 125K. Above that, nothing improves: all
97
+ four converge to ~0.66 validation loss, which is the entropy of the data generator, not a limit
98
+ of the models.
99
+
100
+ ---
101
+
102
+ ## Why it's interesting
103
+
104
+ The training data contains a deliberately planted long-range dependency: **the column after
105
+ `GROUP BY` is always the column that appeared first in `SELECT`**, roughly 8 tokens earlier.
106
+ Getting it right requires looking back, which is what attention is for.
107
+
108
+ **An attention head learned it.** Probing all 16 heads, layer 1 head 1 places **92.5% of its
109
+ attention** on the `SELECT` column, 11.1x above uniform. Nobody designed or labelled that head.
110
+
111
+ **And you can watch it hallucinate.** Three `(table, column)` pairs were held out from the
112
+ `GROUP BY` position during training. The columns appear elsewhere, just never there. The model
113
+ gets **0 out of 3**, confidently substituting a familiar column instead:
114
+
115
+ ```
116
+ asked for: ... GROUP BY -> "channel" (never seen in this position)
117
+ it answered: ... GROUP BY -> "carrier" (familiar, confident, wrong)
118
+ ```
119
+
120
+ A control prompt shows the copy circuit *is* firing (2.6x to 13.9x lift), it simply loses to a prior
121
+ against tokens never seen in that slot. **Attention identifies the right source token; the output
122
+ prior overrules it.** That is hallucination, in a model small enough to point at the exact cause.
123
+
124
+ ---
125
+
126
+ ## Training
127
+
128
+ | | |
129
+ |---|---|
130
+ | data | 100,000 generated SQL queries (16,941 unique), 1.3M tokens, seed 1337 |
131
+ | architecture | decoder-only, 4 layers, 4 heads, 128 embedding, 64-token context |
132
+ | tokenizer | word-level, 155 tokens |
133
+ | optimizer | AdamW, lr 1e-3, cosine schedule, weight decay 0.01, grad clip 1.0 |
134
+ | steps | 3,000 Β· batch 64 Β· ~5 minutes on a laptop CPU |
135
+ | final loss | train 0.657 Β· val 0.663 |
136
+
137
+ Training data is **generated, not scraped**, from a grammar in the repo. No licensing questions,
138
+ and a learner can read the entire source of the training set.
139
+
140
+ ## Limitations
141
+
142
+ - Not text-to-SQL. No natural-language input.
143
+ - One fixed three-table schema. It knows no other tables or columns.
144
+ - 64-token context. Longer queries are truncated.
145
+ - Does not generalize to column names it never saw in a given syntactic position (see above,
146
+ that failure is the point, and it is measured rather than hidden).
147
+ - Generated SQL is syntactically valid, not semantically meaningful. It will happily write
148
+ `WHERE age > 1500`.
149
+
150
+ ## License
151
+
152
+ MIT.
config.json ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "tiny-sql-gpt",
3
+ "cfg": {
4
+ "name": "tiny",
5
+ "vocab_size": 155,
6
+ "block_size": 64,
7
+ "n_layer": 4,
8
+ "n_head": 4,
9
+ "n_embd": 128,
10
+ "dropout": 0.0
11
+ },
12
+ "itos": [
13
+ "'ads'",
14
+ "'affiliate'",
15
+ "'air'",
16
+ "'annual'",
17
+ "'atlanta'",
18
+ "'austin'",
19
+ "'backorder'",
20
+ "'basic'",
21
+ "'boston'",
22
+ "'bronze'",
23
+ "'business'",
24
+ "'cancelled'",
25
+ "'central'",
26
+ "'chicago'",
27
+ "'closed'",
28
+ "'coastal'",
29
+ "'cog'",
30
+ "'courier'",
31
+ "'critical'",
32
+ "'custom'",
33
+ "'deferred'",
34
+ "'denver'",
35
+ "'dhl'",
36
+ "'direct'",
37
+ "'doohickey'",
38
+ "'draft'",
39
+ "'east'",
40
+ "'education'",
41
+ "'email'",
42
+ "'enterprise'",
43
+ "'escalated'",
44
+ "'events'",
45
+ "'fedex'",
46
+ "'free'",
47
+ "'freight'",
48
+ "'fy'",
49
+ "'gadget'",
50
+ "'gizmo'",
51
+ "'gold'",
52
+ "'government'",
53
+ "'h1'",
54
+ "'h2'",
55
+ "'high'",
56
+ "'inland'",
57
+ "'kiosk'",
58
+ "'legacy'",
59
+ "'lever'",
60
+ "'local'",
61
+ "'low'",
62
+ "'medium'",
63
+ "'mobile'",
64
+ "'monthly'",
65
+ "'north'",
66
+ "'northeast'",
67
+ "'online'",
68
+ "'open'",
69
+ "'organic'",
70
+ "'outbound'",
71
+ "'partner'",
72
+ "'pending'",
73
+ "'phoenix'",
74
+ "'phone'",
75
+ "'platinum'",
76
+ "'portland'",
77
+ "'premium'",
78
+ "'pro'",
79
+ "'q1'",
80
+ "'q2'",
81
+ "'q3'",
82
+ "'q4'",
83
+ "'referral'",
84
+ "'reseller'",
85
+ "'retail'",
86
+ "'returned'",
87
+ "'routine'",
88
+ "'search'",
89
+ "'seattle'",
90
+ "'shipped'",
91
+ "'silver'",
92
+ "'smb'",
93
+ "'social'",
94
+ "'south'",
95
+ "'sprocket'",
96
+ "'starter'",
97
+ "'store'",
98
+ "'team'",
99
+ "'trial'",
100
+ "'ups'",
101
+ "'urgent'",
102
+ "'usps'",
103
+ "'valve'",
104
+ "'web'",
105
+ "'west'",
106
+ "'wholesale'",
107
+ "'widget'",
108
+ "'ytd'",
109
+ "(",
110
+ ")",
111
+ "*",
112
+ ",",
113
+ "1",
114
+ "10",
115
+ "100",
116
+ "1000",
117
+ "1500",
118
+ "20",
119
+ "200",
120
+ "2000",
121
+ "25",
122
+ "250",
123
+ "3",
124
+ "5",
125
+ "50",
126
+ "500",
127
+ "750",
128
+ ";",
129
+ "<s>",
130
+ "=",
131
+ ">",
132
+ "AND",
133
+ "AVG",
134
+ "BY",
135
+ "COUNT",
136
+ "DESC",
137
+ "FROM",
138
+ "GROUP",
139
+ "LIMIT",
140
+ "MAX",
141
+ "MIN",
142
+ "ORDER",
143
+ "SELECT",
144
+ "SUM",
145
+ "WHERE",
146
+ "age",
147
+ "carrier",
148
+ "channel",
149
+ "city",
150
+ "customers",
151
+ "day",
152
+ "items",
153
+ "orders",
154
+ "plan",
155
+ "price",
156
+ "priority",
157
+ "product",
158
+ "qty",
159
+ "quarter",
160
+ "region",
161
+ "sales",
162
+ "segment",
163
+ "source",
164
+ "spend",
165
+ "status",
166
+ "tier",
167
+ "total"
168
+ ]
169
+ }
inference.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference only. No training, no evaluation harness.
3
+
4
+ This is the file to read if you just want to run the model, and the file the
5
+ Hugging Face repo is built around.
6
+
7
+ python inference.py # write 5 queries
8
+ python inference.py --prompt "SELECT region ," # complete a prompt
9
+ python inference.py --n 10 --temperature 1.2 # sample more, wilder
10
+ python inference.py --run # execute them for real
11
+ python inference.py --probs "SELECT region , SUM ( qty ) FROM sales GROUP BY"
12
+
13
+ python inference.py --export hf/tiny-sql-gpt # package for the Hub
14
+
15
+ Programmatic use:
16
+
17
+ from inference import TinySQLGPT
18
+ m = TinySQLGPT.from_pretrained("checkpoints/tiny.pt")
19
+ print(m.generate())
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+
26
+ import torch
27
+ from torch.nn import functional as F
28
+
29
+ from tiny_gpt import BOS, Config, Tokenizer, TinyGPT, HERE
30
+
31
+ DEFAULT_CKPT = os.path.join(HERE, "checkpoints", "tiny.pt")
32
+
33
+
34
+ class TinySQLGPT:
35
+ """A trained model plus its tokenizer. Nothing else."""
36
+
37
+ def __init__(self, model, tok, device="cpu"):
38
+ self.model, self.tok, self.device = model, tok, device
39
+
40
+ # ── loading ──────────────────────────────────────────────────────────────
41
+
42
+ @classmethod
43
+ def from_pretrained(cls, path=DEFAULT_CKPT, device="cpu"):
44
+ """Accepts a .pt checkpoint, a directory in Hub layout, or a repo id."""
45
+ if not os.path.exists(path) and "/" in path and not path.endswith(".pt"):
46
+ from huggingface_hub import snapshot_download # optional dependency
47
+ path = snapshot_download(repo_id=path)
48
+
49
+ if os.path.isdir(path):
50
+ return cls._from_dir(path, device)
51
+ return cls._from_ckpt(path, device)
52
+
53
+ @classmethod
54
+ def _from_ckpt(cls, path, device):
55
+ if not os.path.exists(path):
56
+ raise SystemExit(f"No checkpoint at {path}. Run: python tiny_gpt.py --train")
57
+ ck = torch.load(path, map_location=device, weights_only=False)
58
+ return cls._build(ck["cfg"], ck["state_dict"], ck["itos"], device)
59
+
60
+ @classmethod
61
+ def _from_dir(cls, path, device):
62
+ """Hub layout: config.json + weights, no pickled Python objects."""
63
+ with open(os.path.join(path, "config.json")) as f:
64
+ meta = json.load(f)
65
+ safe = os.path.join(path, "model.safetensors")
66
+ if os.path.exists(safe):
67
+ from safetensors.torch import load_file
68
+ state = load_file(safe)
69
+ else:
70
+ state = torch.load(os.path.join(path, "pytorch_model.bin"),
71
+ map_location=device, weights_only=True)
72
+ return cls._build(meta["cfg"], state, meta["itos"], device)
73
+
74
+ @classmethod
75
+ def _build(cls, cfg_dict, state, itos, device):
76
+ model = TinyGPT(Config(**cfg_dict)).to(device)
77
+ model.load_state_dict(state)
78
+ model.eval()
79
+ tok = Tokenizer.__new__(Tokenizer)
80
+ tok.itos = itos
81
+ tok.stoi = {s: i for i, s in enumerate(itos)}
82
+ return cls(model, tok, device)
83
+
84
+ # ── generation ───────────────────────────────────────────────────────────
85
+
86
+ def _ids(self, prompt):
87
+ ids = [self.tok.stoi[BOS]]
88
+ if prompt:
89
+ ids += self.tok.encode(prompt)
90
+ return torch.tensor([ids], dtype=torch.long, device=self.device)
91
+
92
+ def generate(self, prompt="", temperature=0.8, top_k=None, max_new_tokens=None):
93
+ """Return one SQL query as a string."""
94
+ idx = self._ids(prompt)
95
+ room = self.model.cfg.block_size - idx.shape[1]
96
+ out = self.model.generate(
97
+ idx, max_new_tokens=max_new_tokens or room,
98
+ temperature=temperature, top_k=top_k, stop=self.tok.stoi[";"])
99
+ return self.tok.decode(out[0, 1:].tolist())
100
+
101
+ def generate_many(self, n=5, **kw):
102
+ return [self.generate(**kw) for _ in range(n)]
103
+
104
+ def next_token_probs(self, prompt, top=10):
105
+ """What the model thinks comes next, as (token, probability) pairs.
106
+
107
+ The whole distribution is only 155 wide, so `top=None` really does
108
+ return all of it, the thing you cannot do with a frontier model.
109
+ """
110
+ with torch.no_grad():
111
+ logits, _ = self.model(self._ids(prompt))
112
+ probs = F.softmax(logits[0, -1], dim=-1)
113
+ k = top or len(self.tok)
114
+ vals, idx = torch.topk(probs, min(k, len(self.tok)))
115
+ return [(self.tok.itos[i], float(p)) for p, i in zip(vals, idx)]
116
+
117
+ @property
118
+ def n_params(self):
119
+ return self.model.n_params()
120
+
121
+ # ── export ─────────────────────────────────────────────────────��─────────
122
+
123
+ def export(self, outdir):
124
+ """Write a Hugging Face style folder: config.json + weights.
125
+
126
+ Deliberately avoids a pickled checkpoint. Nobody should have to run
127
+ torch.load(weights_only=False) on a stranger's file.
128
+ """
129
+ os.makedirs(outdir, exist_ok=True)
130
+ with open(os.path.join(outdir, "config.json"), "w") as f:
131
+ json.dump({
132
+ "model_type": "tiny-sql-gpt",
133
+ "cfg": {k: v for k, v in vars(self.model.cfg).items()},
134
+ "itos": self.tok.itos,
135
+ }, f, indent=2)
136
+ state = {k: v.contiguous() for k, v in self.model.state_dict().items()}
137
+ try:
138
+ from safetensors.torch import save_file
139
+ save_file(state, os.path.join(outdir, "model.safetensors"))
140
+ wrote = "model.safetensors"
141
+ except ImportError:
142
+ torch.save(state, os.path.join(outdir, "pytorch_model.bin"))
143
+ wrote = "pytorch_model.bin"
144
+ return wrote
145
+
146
+
147
+ def main():
148
+ ap = argparse.ArgumentParser(description="Tiny SQL GPT inference")
149
+ ap.add_argument("--model", default=DEFAULT_CKPT,
150
+ help=".pt file, Hub-layout directory, or HF repo id")
151
+ ap.add_argument("--prompt", default="", help="text to continue")
152
+ ap.add_argument("--n", type=int, default=5)
153
+ ap.add_argument("--temperature", type=float, default=0.8)
154
+ ap.add_argument("--top-k", type=int, default=None)
155
+ ap.add_argument("--run", action="store_true",
156
+ help="execute each query against SQLite and report")
157
+ ap.add_argument("--probs", metavar="PROMPT",
158
+ help="show the next-token distribution for a prompt")
159
+ ap.add_argument("--export", metavar="DIR", help="write a Hub-ready folder")
160
+ ap.add_argument("--device", default="cpu")
161
+ args = ap.parse_args()
162
+
163
+ m = TinySQLGPT.from_pretrained(args.model, args.device)
164
+
165
+ if args.export:
166
+ wrote = m.export(args.export)
167
+ print(f"exported to {args.export}/ (config.json + {wrote})")
168
+ return
169
+
170
+ if args.probs:
171
+ print(f"context: {args.probs}\n")
172
+ for t, p in m.next_token_probs(args.probs):
173
+ print(f" {t:<12} {p:6.3f} {'β–ˆ' * int(round(p * 30))}")
174
+ return
175
+
176
+ queries = m.generate_many(args.n, prompt=args.prompt,
177
+ temperature=args.temperature, top_k=args.top_k)
178
+ if not args.run:
179
+ print("\n".join(queries))
180
+ return
181
+
182
+ import evaluate # only needed for --run
183
+ conn = evaluate.build_db()
184
+ ok = 0
185
+ for q in queries:
186
+ good = evaluate.executes(conn, q)
187
+ ok += good
188
+ print(f" [{'OK ' if good else 'FAIL'}] {q}")
189
+ print(f"\n{ok}/{len(queries)} executed against a real database "
190
+ f"({m.n_params:,} parameters)")
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:391b98a20c44468827aa4d97ece5750da7834310c8b318ce467f17c294caff25
3
+ size 3435328
tiny_gpt.py ADDED
@@ -0,0 +1,702 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tiny SQL GPT: a ~1M parameter transformer, built from random weights,
3
+ trained on a laptop, that writes SQL you can actually run.
4
+
5
+ No pretrained weights. No HuggingFace model classes. Just PyTorch tensors.
6
+
7
+ Read this file top to bottom and you have seen the whole path:
8
+ Β§1 schema what the SQL is about
9
+ Β§2 data we GENERATE the training set (nothing scraped)
10
+ Β§3 tokenizer text -> integers
11
+ Β§4 model embeddings -> causal attention -> MLP -> logits
12
+ Β§5 bigram the dumb baseline that makes the GPT number mean something
13
+ Β§6 train next-token prediction + backprop
14
+ Β§7 generate sampling, temperature, top-k
15
+ Β§8 explain print the internals: mask, softmax, attention
16
+ Β§9 cli
17
+
18
+ python tiny_gpt.py --data # build the dataset
19
+ python tiny_gpt.py --train # train the ~1M param model (~3 min CPU)
20
+ python tiny_gpt.py --generate 10 # write some SQL
21
+ python tiny_gpt.py --explain # open the black box
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import math
27
+ import os
28
+ import random
29
+ from dataclasses import dataclass, asdict
30
+
31
+ import torch
32
+ import torch.nn as nn
33
+ from torch.nn import functional as F
34
+
35
+ HERE = os.path.dirname(os.path.abspath(__file__))
36
+ DATA_DIR = os.path.join(HERE, "data")
37
+ CKPT_DIR = os.path.join(HERE, "checkpoints")
38
+ SEED = 1337
39
+
40
+ # Set True to keep attention matrices around for the interpretability probe (Β§8).
41
+ # Off during training because [B, H, T, T] per layer is a lot of wasted memory.
42
+ SAVE_ATTN = False
43
+
44
+
45
+ # ─────────────────────────────────────────────────────────────────────────────
46
+ # Β§1 SCHEMA: three small tables. Deliberately generic so anyone can read it.
47
+ # ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ # Four categorical columns per table is deliberate. With only one groupable
50
+ # column per table the model could learn a shortcut ("orders -> GROUP BY status")
51
+ # instead of the actual rule ("copy the SELECT column"). Four columns makes the
52
+ # shortcut useless, so the held-out test below measures real generalization.
53
+ SCHEMA = {
54
+ "sales": {"cat": ["region", "product", "segment", "quarter"],
55
+ "num": ["qty", "price", "day"]},
56
+ "customers": {"cat": ["city", "tier", "source", "plan"],
57
+ "num": ["age", "spend"]},
58
+ "orders": {"cat": ["status", "channel", "priority", "carrier"],
59
+ "num": ["total", "items"]},
60
+ }
61
+
62
+ def _q(*words):
63
+ return [f"'{w}'" for w in words]
64
+
65
+
66
+ VALUES = {
67
+ "region": _q("north", "south", "east", "west",
68
+ "central", "coastal", "inland", "northeast"),
69
+ "product": _q("widget", "gadget", "gizmo", "doohickey",
70
+ "sprocket", "cog", "lever", "valve"),
71
+ "city": _q("austin", "denver", "boston", "seattle",
72
+ "chicago", "portland", "atlanta", "phoenix"),
73
+ "tier": _q("gold", "silver", "bronze", "platinum",
74
+ "basic", "premium", "trial", "legacy"),
75
+ "status": _q("open", "shipped", "closed", "pending",
76
+ "cancelled", "returned", "draft", "backorder"),
77
+ "channel": _q("web", "store", "phone", "partner",
78
+ "kiosk", "mobile", "email", "reseller"),
79
+ "segment": _q("retail", "wholesale", "online", "direct",
80
+ "enterprise", "smb", "government", "education"),
81
+ "quarter": _q("q1", "q2", "q3", "q4", "h1", "h2", "fy", "ytd"),
82
+ "source": _q("ads", "referral", "organic", "outbound",
83
+ "social", "events", "affiliate", "search"),
84
+ "plan": _q("monthly", "annual", "free", "team",
85
+ "business", "starter", "pro", "custom"),
86
+ "priority": _q("low", "medium", "high", "urgent",
87
+ "critical", "routine", "deferred", "escalated"),
88
+ "carrier": _q("ups", "fedex", "dhl", "usps",
89
+ "freight", "local", "courier", "air"),
90
+ }
91
+
92
+ AGGS = ["SUM", "AVG", "MAX", "MIN"]
93
+ THRESHOLDS = ["10", "50", "100", "200", "250", "500", "750", "1000", "1500", "2000"]
94
+ LIMITS = ["1", "3", "5", "10", "20", "25", "50", "100"]
95
+
96
+ # THE GENERALIZATION TEST.
97
+ # These (table, column) pairs NEVER appear in a GROUP BY during training.
98
+ # The columns themselves do appear elsewhere (SELECT, WHERE), so they have
99
+ # embeddings. The model just never saw them grouped.
100
+ #
101
+ # At eval we prompt "SELECT channel , COUNT ( * ) FROM orders GROUP BY" and ask:
102
+ # does it say `channel`? If yes, it learned the RULE, not the pairs.
103
+ HELD_OUT_GROUPBY = [("orders", "channel"), ("sales", "product"), ("customers", "tier")]
104
+
105
+
106
+ # ─────────────────────────────────────────────────────────���───────────────────
107
+ # Β§2 DATA: we generate every training example from a grammar we control.
108
+ #
109
+ # Why generated and not scraped:
110
+ # - no licensing questions
111
+ # - a learner can read the ENTIRE source of the training data (it's right here)
112
+ # - we can inject the long-range dependency on purpose (GROUP BY agreement)
113
+ # - reproducible from a seed
114
+ # - we know the exact training set, so we can MEASURE memorization
115
+ # ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ def _table_cols(t):
118
+ return SCHEMA[t]["cat"] + SCHEMA[t]["num"]
119
+
120
+
121
+ def make_query(rng, allow_heldout=False):
122
+ """Emit one space-separated SQL query. Shape picked at random."""
123
+ t = rng.choice(list(SCHEMA))
124
+ cats, nums = SCHEMA[t]["cat"], SCHEMA[t]["num"]
125
+ cat, cat2 = rng.choice(cats), rng.choice(cats)
126
+ num, num2 = rng.choice(nums), rng.choice(nums)
127
+ agg = rng.choice(AGGS)
128
+ val = rng.choice(VALUES[cat2])
129
+ lim = rng.choice(LIMITS)
130
+ thr, thr2 = rng.choice(THRESHOLDS), rng.choice(THRESHOLDS)
131
+
132
+ # For GROUP BY shapes, pick a grouping column that is NOT a held-out
133
+ # (table, column) pair. This is what creates the unseen test cases.
134
+ choices = [c for c in cats if allow_heldout or (t, c) not in HELD_OUT_GROUPBY]
135
+ g = rng.choice(choices) if choices else None
136
+
137
+ shape = rng.randint(0, 13)
138
+
139
+ if shape == 0:
140
+ return f"SELECT {cat} FROM {t} ;"
141
+ if shape == 1:
142
+ return f"SELECT * FROM {t} LIMIT {lim} ;"
143
+ if shape == 2:
144
+ return f"SELECT {cat} FROM {t} WHERE {cat2} = {val} ;"
145
+ if shape == 3:
146
+ return f"SELECT {num} FROM {t} WHERE {num} > {thr} ;"
147
+ if shape == 4:
148
+ return f"SELECT COUNT ( * ) FROM {t} WHERE {cat2} = {val} ;"
149
+ if shape == 5:
150
+ return f"SELECT {cat} FROM {t} ORDER BY {num} DESC LIMIT {lim} ;"
151
+ if shape == 6:
152
+ return f"SELECT {agg} ( {num} ) FROM {t} ;"
153
+ if shape == 7:
154
+ return f"SELECT {cat} , {num} FROM {t} WHERE {cat2} = {val} ;"
155
+ if shape == 8:
156
+ return (f"SELECT {cat} FROM {t} WHERE {cat2} = {val} "
157
+ f"AND {num} > {thr} ;")
158
+ if shape == 9:
159
+ return f"SELECT {agg} ( {num} ) FROM {t} WHERE {num2} > {thr} ;"
160
+
161
+ if g is None: # every column of this table is held out
162
+ return f"SELECT {cat} FROM {t} ;"
163
+
164
+ if shape == 10:
165
+ return f"SELECT {g} , COUNT ( * ) FROM {t} GROUP BY {g} ;"
166
+ if shape == 11:
167
+ return f"SELECT {g} , {agg} ( {num} ) FROM {t} GROUP BY {g} ;"
168
+ if shape == 12:
169
+ return (f"SELECT {g} , {agg} ( {num} ) FROM {t} "
170
+ f"WHERE {cat2} = {val} GROUP BY {g} ;")
171
+ # The big one: WHERE + AND + GROUP BY + ORDER BY + LIMIT. Long enough that
172
+ # keeping GROUP BY agreeing with SELECT is a genuine long-range dependency.
173
+ return (f"SELECT {g} , {agg} ( {num} ) FROM {t} "
174
+ f"WHERE {cat2} = {val} AND {num2} > {thr2} "
175
+ f"GROUP BY {g} ORDER BY {agg} ( {num} ) DESC LIMIT {lim} ;")
176
+
177
+
178
+ def build_dataset(n=100_000, seed=SEED, out=None):
179
+ rng = random.Random(seed)
180
+ queries = [make_query(rng) for _ in range(n)]
181
+ os.makedirs(DATA_DIR, exist_ok=True)
182
+ out = out or os.path.join(DATA_DIR, "queries.txt")
183
+ with open(out, "w") as f:
184
+ f.write("\n".join(queries))
185
+ manifest = {
186
+ "seed": seed,
187
+ "n_queries": n,
188
+ "unique_queries": len(set(queries)),
189
+ "held_out_groupby": [list(p) for p in HELD_OUT_GROUPBY],
190
+ "schema": SCHEMA,
191
+ }
192
+ with open(os.path.join(DATA_DIR, "manifest.json"), "w") as f:
193
+ json.dump(manifest, f, indent=2)
194
+ return queries, manifest
195
+
196
+
197
+ def load_queries():
198
+ path = os.path.join(DATA_DIR, "queries.txt")
199
+ if not os.path.exists(path):
200
+ raise SystemExit("No dataset. Run: python tiny_gpt.py --data")
201
+ with open(path) as f:
202
+ return f.read().splitlines()
203
+
204
+
205
+ # ─────────────────────────────────────────────────────────────────────────────
206
+ # Β§3 TOKENIZER: text becomes integers. That is the whole job.
207
+ #
208
+ # Word-level, because SQL is already emitted space-separated. ~110 tokens total,
209
+ # which is the point: a vocabulary this small means we can print the ENTIRE
210
+ # probability distribution at every step (Β§8). No frontier model can do that.
211
+ # ─────────────────────────────────────────────────────────────────────────────
212
+
213
+ BOS = "<s>"
214
+
215
+
216
+ class Tokenizer:
217
+ def __init__(self, queries):
218
+ vocab = {BOS}
219
+ for q in queries:
220
+ vocab.update(q.split())
221
+ self.itos = sorted(vocab)
222
+ self.stoi = {s: i for i, s in enumerate(self.itos)}
223
+
224
+ def __len__(self):
225
+ return len(self.itos)
226
+
227
+ def encode(self, text):
228
+ return [self.stoi[t] for t in text.split()]
229
+
230
+ def decode(self, ids):
231
+ return " ".join(self.itos[i] for i in ids)
232
+
233
+
234
+ def build_corpus(queries, tok):
235
+ """One long stream of token ids: <s> q1 ; <s> q2 ; ... trained on windows."""
236
+ ids = []
237
+ bos = tok.stoi[BOS]
238
+ for q in queries:
239
+ ids.append(bos)
240
+ ids.extend(tok.encode(q))
241
+ return torch.tensor(ids, dtype=torch.long)
242
+
243
+
244
+ # ─────────────────────────────────────────────────────────────────────────────
245
+ # Β§4 MODEL: a decoder-only transformer. This is the part people pretend
246
+ # to understand. It is about 90 lines.
247
+ # ─────────────────────────────────────────────────────────────────────────────
248
+
249
+ @dataclass
250
+ class Config:
251
+ name: str = "tiny"
252
+ vocab_size: int = 0
253
+ block_size: int = 64 # context window, in tokens
254
+ n_layer: int = 4
255
+ n_head: int = 4
256
+ n_embd: int = 128
257
+ dropout: float = 0.0
258
+
259
+
260
+ # The four rungs of the scaling ladder (Β§4b of PLAN.md). Same data, same code.
261
+ SIZES = {
262
+ "nano": dict(n_layer=1, n_head=2, n_embd=32),
263
+ "micro": dict(n_layer=2, n_head=4, n_embd=64),
264
+ "tiny": dict(n_layer=4, n_head=4, n_embd=128),
265
+ "small": dict(n_layer=6, n_head=8, n_embd=256),
266
+ # ABLATION, not a rung on the ladder. Same parameter count as `micro`
267
+ # (~125K) but ONE layer instead of two. nano -> micro improved depth AND
268
+ # width at once; this separates them.
269
+ #
270
+ # RESULT: flat scores 100.0% GROUP BY agreement, identical to micro.
271
+ # The hypothesis that this dependency needs two layers to compose is
272
+ # WRONG for this task. At matched parameters, depth buys nothing; the
273
+ # nano -> micro jump was capacity. One head can attend from "after
274
+ # GROUP BY" to "after SELECT" using position and syntax alone, with no
275
+ # previous-token head to compose with. (Copying arbitrary *novel* bigrams
276
+ # (true induction) is a harder job and is the case that needs 2 layers.)
277
+ "flat": dict(n_layer=1, n_head=4, n_embd=88),
278
+ }
279
+
280
+ LADDER = ["nano", "micro", "tiny", "small"] # the scaling curve, minus ablations
281
+
282
+
283
+ class CausalSelfAttention(nn.Module):
284
+ """Every token looks back at earlier tokens and decides what matters.
285
+
286
+ The causal mask is what makes this a *language* model: position t may
287
+ attend to 0..t, never to the future. Without it the model would cheat by
288
+ reading the answer it is being asked to predict.
289
+ """
290
+
291
+ def __init__(self, cfg):
292
+ super().__init__()
293
+ assert cfg.n_embd % cfg.n_head == 0
294
+ self.n_head = cfg.n_head
295
+ self.head_dim = cfg.n_embd // cfg.n_head
296
+ self.qkv = nn.Linear(cfg.n_embd, 3 * cfg.n_embd) # q, k, v in one matmul
297
+ self.proj = nn.Linear(cfg.n_embd, cfg.n_embd)
298
+ self.drop = nn.Dropout(cfg.dropout)
299
+ # lower-triangular ones: mask[i][j] == 1 means "i may look at j"
300
+ self.register_buffer(
301
+ "mask", torch.tril(torch.ones(cfg.block_size, cfg.block_size))
302
+ )
303
+ self.att_cache = None
304
+
305
+ def forward(self, x):
306
+ B, T, C = x.shape
307
+ q, k, v = self.qkv(x).split(C, dim=2)
308
+ # [B, T, C] -> [B, n_head, T, head_dim]
309
+ q = q.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
310
+ k = k.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
311
+ v = v.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
312
+
313
+ att = (q @ k.transpose(-2, -1)) / math.sqrt(self.head_dim) # [B,H,T,T]
314
+ att = att.masked_fill(self.mask[:T, :T] == 0, float("-inf"))
315
+ att = F.softmax(att, dim=-1)
316
+ if SAVE_ATTN:
317
+ self.att_cache = att.detach()
318
+ att = self.drop(att)
319
+
320
+ y = att @ v # [B,H,T,head_dim]
321
+ y = y.transpose(1, 2).contiguous().view(B, T, C) # merge heads back
322
+ return self.drop(self.proj(y))
323
+
324
+
325
+ class Block(nn.Module):
326
+ """LayerNorm -> attention -> residual, then LayerNorm -> MLP -> residual.
327
+
328
+ The residual (+x) is why deep networks train at all: gradients get a
329
+ clean path back to the input.
330
+ """
331
+
332
+ def __init__(self, cfg):
333
+ super().__init__()
334
+ self.ln1 = nn.LayerNorm(cfg.n_embd)
335
+ self.attn = CausalSelfAttention(cfg)
336
+ self.ln2 = nn.LayerNorm(cfg.n_embd)
337
+ self.mlp = nn.Sequential(
338
+ nn.Linear(cfg.n_embd, 4 * cfg.n_embd),
339
+ nn.GELU(),
340
+ nn.Linear(4 * cfg.n_embd, cfg.n_embd),
341
+ nn.Dropout(cfg.dropout),
342
+ )
343
+
344
+ def forward(self, x):
345
+ x = x + self.attn(self.ln1(x))
346
+ x = x + self.mlp(self.ln2(x))
347
+ return x
348
+
349
+
350
+ class TinyGPT(nn.Module):
351
+ def __init__(self, cfg):
352
+ super().__init__()
353
+ self.cfg = cfg
354
+ self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd) # what the token is
355
+ self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd) # where it sits
356
+ self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layer)])
357
+ self.ln_f = nn.LayerNorm(cfg.n_embd)
358
+ self.lm_head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
359
+ self.apply(self._init)
360
+
361
+ @staticmethod
362
+ def _init(m):
363
+ if isinstance(m, (nn.Linear, nn.Embedding)):
364
+ nn.init.normal_(m.weight, mean=0.0, std=0.02)
365
+ if isinstance(m, nn.Linear) and m.bias is not None:
366
+ nn.init.zeros_(m.bias)
367
+
368
+ def n_params(self):
369
+ return sum(p.numel() for p in self.parameters())
370
+
371
+ def forward(self, idx, targets=None):
372
+ B, T = idx.shape
373
+ assert T <= self.cfg.block_size, f"context is {self.cfg.block_size}, got {T}"
374
+ pos = torch.arange(T, device=idx.device)
375
+ x = self.tok_emb(idx) + self.pos_emb(pos) # [B, T, n_embd]
376
+ for blk in self.blocks:
377
+ x = blk(x)
378
+ logits = self.lm_head(self.ln_f(x)) # [B, T, vocab_size]
379
+
380
+ loss = None
381
+ if targets is not None:
382
+ # Predict token t+1 from tokens 0..t, at every position at once.
383
+ loss = F.cross_entropy(
384
+ logits.view(-1, logits.size(-1)), targets.reshape(-1)
385
+ )
386
+ return logits, loss
387
+
388
+ @torch.no_grad()
389
+ def generate(self, idx, max_new_tokens, temperature=0.8, top_k=None, stop=None):
390
+ for _ in range(max_new_tokens):
391
+ idx_cond = idx[:, -self.cfg.block_size:] # context window: hard limit
392
+ logits, _ = self(idx_cond)
393
+ logits = logits[:, -1, :] / max(temperature, 1e-6)
394
+ if top_k is not None:
395
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
396
+ logits[logits < v[:, [-1]]] = float("-inf")
397
+ probs = F.softmax(logits, dim=-1)
398
+ nxt = torch.multinomial(probs, num_samples=1)
399
+ idx = torch.cat((idx, nxt), dim=1)
400
+ if stop is not None and (nxt == stop).all():
401
+ break
402
+ return idx
403
+
404
+
405
+ # ─────────────────────────────────────────────────────────────────────────────
406
+ # Β§5 BIGRAM BASELINE: "what token usually follows this one", no attention.
407
+ # Its job is to be bad. A number is only meaningful next to a baseline.
408
+ # ─────────────────────────────────────────────────────────────────────────────
409
+
410
+ class Bigram:
411
+ def __init__(self, vocab_size):
412
+ self.counts = torch.ones(vocab_size, vocab_size) # +1 smoothing
413
+
414
+ def fit(self, ids):
415
+ for a, b in zip(ids[:-1].tolist(), ids[1:].tolist()):
416
+ self.counts[a, b] += 1
417
+ return self
418
+
419
+ def generate(self, start, max_new_tokens, stop=None):
420
+ out = [start]
421
+ for _ in range(max_new_tokens):
422
+ probs = self.counts[out[-1]] / self.counts[out[-1]].sum()
423
+ nxt = int(torch.multinomial(probs, 1))
424
+ out.append(nxt)
425
+ if nxt == stop:
426
+ break
427
+ return out
428
+
429
+
430
+ # ─────────────────────────────────────────────────────────────────────────────
431
+ # Β§6 TRAIN: sample random windows, predict the next token, backpropagate.
432
+ # ─────────────────────────────────────────────────────────────────────────────
433
+
434
+ def get_batch(data, block_size, batch_size, device):
435
+ ix = torch.randint(len(data) - block_size - 1, (batch_size,))
436
+ x = torch.stack([data[i:i + block_size] for i in ix])
437
+ y = torch.stack([data[i + 1:i + 1 + block_size] for i in ix]) # shifted by one
438
+ return x.to(device), y.to(device)
439
+
440
+
441
+ @torch.no_grad()
442
+ def estimate_loss(model, splits, cfg, batch_size, device, iters=50):
443
+ model.eval()
444
+ out = {}
445
+ for name, data in splits.items():
446
+ losses = torch.zeros(iters)
447
+ for k in range(iters):
448
+ x, y = get_batch(data, cfg.block_size, batch_size, device)
449
+ _, loss = model(x, y)
450
+ losses[k] = loss.item()
451
+ out[name] = losses.mean().item()
452
+ model.train()
453
+ return out
454
+
455
+
456
+ def train(cfg, splits, steps=3000, batch_size=64, lr=1e-3, device="cpu",
457
+ log_every=500, quiet=False):
458
+ torch.manual_seed(SEED)
459
+ model = TinyGPT(cfg).to(device)
460
+ opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
461
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=steps)
462
+ history = []
463
+
464
+ if not quiet:
465
+ print(f"[{cfg.name}] {model.n_params():,} params "
466
+ f"(L{cfg.n_layer} H{cfg.n_head} E{cfg.n_embd}) on {device}")
467
+
468
+ for step in range(steps + 1):
469
+ if step % log_every == 0 or step == steps:
470
+ losses = estimate_loss(model, splits, cfg, batch_size, device)
471
+ history.append({"step": step, **losses})
472
+ if not quiet:
473
+ print(f" step {step:5d} train {losses['train']:.4f} "
474
+ f"val {losses['val']:.4f}")
475
+ x, y = get_batch(splits["train"], cfg.block_size, batch_size, device)
476
+ _, loss = model(x, y)
477
+ opt.zero_grad(set_to_none=True)
478
+ loss.backward()
479
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
480
+ opt.step()
481
+ sched.step()
482
+
483
+ return model, history
484
+
485
+
486
+ def make_splits(corpus, frac=0.9):
487
+ n = int(frac * len(corpus))
488
+ return {"train": corpus[:n], "val": corpus[n:]}
489
+
490
+
491
+ def save_ckpt(model, tok, history, path):
492
+ os.makedirs(os.path.dirname(path), exist_ok=True)
493
+ torch.save({
494
+ "cfg": asdict(model.cfg),
495
+ "state_dict": model.state_dict(),
496
+ "itos": tok.itos,
497
+ "history": history,
498
+ }, path)
499
+
500
+
501
+ def load_ckpt(path, device="cpu"):
502
+ ck = torch.load(path, map_location=device, weights_only=False)
503
+ cfg = Config(**ck["cfg"])
504
+ model = TinyGPT(cfg).to(device)
505
+ model.load_state_dict(ck["state_dict"])
506
+ model.eval()
507
+ tok = Tokenizer.__new__(Tokenizer)
508
+ tok.itos = ck["itos"]
509
+ tok.stoi = {s: i for i, s in enumerate(tok.itos)}
510
+ return model, tok, ck.get("history", [])
511
+
512
+
513
+ # ─────────────────────────────────────────────────────────────────────────────
514
+ # Β§7 GENERATE
515
+ # ─────────────────────────────────────────────────────────────────────────────
516
+
517
+ def sample_queries(model, tok, n=10, temperature=0.8, top_k=None, device="cpu"):
518
+ bos, semi = tok.stoi[BOS], tok.stoi[";"]
519
+ out = []
520
+ for _ in range(n):
521
+ idx = torch.tensor([[bos]], dtype=torch.long, device=device)
522
+ idx = model.generate(idx, max_new_tokens=model.cfg.block_size - 1,
523
+ temperature=temperature, top_k=top_k, stop=semi)
524
+ out.append(tok.decode(idx[0, 1:].tolist()))
525
+ return out
526
+
527
+
528
+ # ─────────────────────────────────────────────────────────────────────────────
529
+ # Β§8 EXPLAIN: open the black box. This is the teaching payload.
530
+ # ─────────────────────────────────────────────────────────────────────────────
531
+
532
+ def bar(p, width=28):
533
+ return "β–ˆ" * int(round(p * width))
534
+
535
+
536
+ def explain(model, tok, device="cpu"):
537
+ prompt = "SELECT region , SUM ( qty ) FROM sales GROUP BY"
538
+ ids = [tok.stoi[BOS]] + tok.encode(prompt)
539
+
540
+ print("\n" + "=" * 68)
541
+ print("1. VOCABULARY: the model's entire universe")
542
+ print("=" * 68)
543
+ print(f"vocab size : {len(tok)} tokens")
544
+ print(f"sample : {', '.join(tok.itos[:14])} ...")
545
+ if model:
546
+ print(f"parameters : {model.n_params():,}")
547
+ print(f"context : {model.cfg.block_size} tokens "
548
+ f"(L{model.cfg.n_layer} H{model.cfg.n_head} E{model.cfg.n_embd})")
549
+
550
+ print("\n" + "=" * 68)
551
+ print("2. TOKENS: text is not words to a model, it is integers")
552
+ print("=" * 68)
553
+ print(f"text : {prompt}")
554
+ print(f"tokens : {ids[1:]}")
555
+ print(f"count : {len(ids)} tokens (including <s>)")
556
+
557
+ print("\n" + "=" * 68)
558
+ print("3. CAUSAL MASK: why it cannot see the future")
559
+ print("=" * 68)
560
+ k = min(8, len(ids))
561
+ names = [tok.itos[i] for i in ids[:k]]
562
+ w = max(len(s) for s in names) + 1
563
+ print(" " * w + "".join(f"{s[:5]:>6}" for s in names))
564
+ for i, s in enumerate(names):
565
+ row = "".join(f"{' 1' if j <= i else ' .':>6}" for j in range(k))
566
+ print(f"{s:>{w}}" + row)
567
+ print("\n 1 = may attend . = masked out (the future)")
568
+
569
+ if model is None:
570
+ print("\n(no checkpoint found, run --train for sections 4 and 5)\n")
571
+ return
572
+
573
+ x = torch.tensor([ids], dtype=torch.long, device=device)
574
+ global SAVE_ATTN
575
+ SAVE_ATTN = True
576
+ with torch.no_grad():
577
+ logits, _ = model(x)
578
+ SAVE_ATTN = False
579
+
580
+ print("\n" + "=" * 68)
581
+ print("4. THE FULL DISTRIBUTION: the model outputs probabilities, not answers")
582
+ print("=" * 68)
583
+ print("Same model, same softmax, two positions. Confidence is not a")
584
+ print("property of the model, it is a property of the context.\n")
585
+
586
+ for label, ctx in [
587
+ ("CONSTRAINED: only one column can legally follow", prompt),
588
+ ("OPEN: any table column could come next", "SELECT"),
589
+ ]:
590
+ cids = torch.tensor([[tok.stoi[BOS]] + tok.encode(ctx)],
591
+ dtype=torch.long, device=device)
592
+ with torch.no_grad():
593
+ cl, _ = model(cids)
594
+ row = cl[0, -1]
595
+ probs = F.softmax(row, dim=-1)
596
+ print(f" context: ...{ctx[-46:]}")
597
+ print(f" {label}")
598
+ top = torch.topk(probs, 6)
599
+ for p, i in zip(top.values.tolist(), top.indices.tolist()):
600
+ print(f" {tok.itos[i]:<12} {p:6.3f} {bar(p)}")
601
+ print(f" {'(other ' + str(len(tok) - 6) + ')':<12} "
602
+ f"{1 - top.values.sum().item():6.3f}")
603
+ print(" temperature reshapes this distribution, nothing else:")
604
+ for t in (0.2, 1.0, 2.0):
605
+ pt = F.softmax(row / t, dim=-1)
606
+ tt = torch.topk(pt, 3)
607
+ line = " ".join(f"{tok.itos[i]}:{p:.2f}"
608
+ for p, i in zip(tt.values.tolist(), tt.indices.tolist()))
609
+ print(f" T={t:<4} {line}")
610
+ print()
611
+
612
+ print("\n" + "=" * 68)
613
+ print("5. ATTENTION: what the last token actually looked at")
614
+ print("=" * 68)
615
+ print(f"query: {prompt}")
616
+ print("position of the final token: predicting the GROUP BY column\n")
617
+ for li, blk in enumerate(model.blocks):
618
+ att = blk.attn.att_cache
619
+ if att is None:
620
+ continue
621
+ for h in range(att.shape[1]):
622
+ row = att[0, h, -1, :]
623
+ top = torch.topk(row, 3)
624
+ parts = " ".join(
625
+ f"{tok.itos[ids[i]]}({p:.2f})"
626
+ for p, i in zip(top.values.tolist(), top.indices.tolist())
627
+ )
628
+ print(f" L{li}H{h} -> {parts}")
629
+ print()
630
+
631
+
632
+ # ─────────────────────────────────────────────────────────────────────────────
633
+ # Β§9 CLI
634
+ # ─────────────────────────────────────────────────────────────────────────────
635
+
636
+ def pick_device(name):
637
+ if name != "auto":
638
+ return name
639
+ return "cuda" if torch.cuda.is_available() else "cpu"
640
+
641
+
642
+ def main():
643
+ ap = argparse.ArgumentParser(description="Tiny SQL GPT")
644
+ ap.add_argument("--data", action="store_true", help="generate the dataset")
645
+ ap.add_argument("--n", type=int, default=100_000, help="dataset size")
646
+ ap.add_argument("--train", action="store_true", help="train a model")
647
+ ap.add_argument("--size", default="tiny", choices=list(SIZES))
648
+ ap.add_argument("--steps", type=int, default=3000)
649
+ ap.add_argument("--generate", type=int, metavar="N", help="sample N queries")
650
+ ap.add_argument("--temperature", type=float, default=0.8)
651
+ ap.add_argument("--explain", action="store_true", help="open the black box")
652
+ ap.add_argument("--device", default="auto")
653
+ args = ap.parse_args()
654
+
655
+ device = pick_device(args.device)
656
+ ckpt = os.path.join(CKPT_DIR, f"{args.size}.pt")
657
+
658
+ if args.data:
659
+ queries, man = build_dataset(args.n)
660
+ print(f"wrote {man['n_queries']:,} queries "
661
+ f"({man['unique_queries']:,} unique) to data/queries.txt")
662
+ print(f"held out from GROUP BY: {HELD_OUT_GROUPBY}")
663
+ print("\nsamples:")
664
+ for q in queries[:5]:
665
+ print(f" {q}")
666
+ return
667
+
668
+ if args.train:
669
+ queries = load_queries()
670
+ tok = Tokenizer(queries)
671
+ corpus = build_corpus(queries, tok)
672
+ print(f"{len(queries):,} queries {len(corpus):,} tokens "
673
+ f"vocab {len(tok)}")
674
+ cfg = Config(name=args.size, vocab_size=len(tok), **SIZES[args.size])
675
+ model, hist = train(cfg, make_splits(corpus), steps=args.steps,
676
+ device=device)
677
+ save_ckpt(model, tok, hist, ckpt)
678
+ print(f"saved {ckpt}")
679
+ print("\nsamples:")
680
+ for q in sample_queries(model, tok, 5, device=device):
681
+ print(f" {q}")
682
+ return
683
+
684
+ if not os.path.exists(ckpt):
685
+ raise SystemExit(f"No checkpoint at {ckpt}. Run: python tiny_gpt.py --train")
686
+ model, tok, _ = load_ckpt(ckpt, device)
687
+
688
+ if args.generate:
689
+ for q in sample_queries(model, tok, args.generate,
690
+ temperature=args.temperature, device=device):
691
+ print(q)
692
+ return
693
+
694
+ if args.explain:
695
+ explain(model, tok, device)
696
+ return
697
+
698
+ ap.print_help()
699
+
700
+
701
+ if __name__ == "__main__":
702
+ main()