singhpratech commited on
Commit
ef48c9a
·
verified ·
1 Parent(s): 00fbfe5

Upload tiny_llm_from_scratch.ipynb with huggingface_hub

Browse files
Files changed (1) hide show
  1. tiny_llm_from_scratch.ipynb +769 -0
tiny_llm_from_scratch.ipynb ADDED
@@ -0,0 +1,769 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "e2785f32",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Tiny Storyteller: building a language model from scratch\n",
9
+ "\n",
10
+ "**Author:** singhpratech, with Claude. **Runs on:** Google Colab (free T4 GPU), or a Mac with Apple Silicon, or CPU.\n",
11
+ "\n",
12
+ "## Purpose\n",
13
+ "Learn how a language model (an \"LLM\") is really built, by building one from nothing.\n",
14
+ "No pretrained weights, no fine-tuning, no magic. We start with raw PyTorch tensors,\n",
15
+ "build the simplest possible model, then add one idea at a time until we have a small\n",
16
+ "GPT-style transformer that we train ourselves.\n",
17
+ "\n",
18
+ "## Use case\n",
19
+ "A **bedtime story starter** for young kids. Give it the first few words, like\n",
20
+ "*\"Once upon a time, a little fox\"*, and it continues with a short, simple story.\n",
21
+ "\n",
22
+ "## The road map\n",
23
+ "| Part | What we build | Why |\n",
24
+ "|---|---|---|\n",
25
+ "| 1 | Tensors and gradients | The two things every neural net is made of |\n",
26
+ "| 2 | Data: TinyStories | Simple English stories, made for small models |\n",
27
+ "| 3 | A bigram model | The simplest language model that can \"write\" |\n",
28
+ "| 4 | A real tokenizer (BPE) | Words instead of letters |\n",
29
+ "| 5 | Attention, by hand | The idea that makes transformers work |\n",
30
+ "| 6 | A tiny GPT | Stack the pieces into a full model |\n",
31
+ "| 7 | Training | Teach it with a few minutes on a GPU |\n",
32
+ "| 8 | Generate stories | Try the use case |\n",
33
+ "| 9 | Publish on Hugging Face | Save and share the model |\n",
34
+ "\n",
35
+ "Run every cell top to bottom. Each cell is small on purpose."
36
+ ]
37
+ },
38
+ {
39
+ "cell_type": "markdown",
40
+ "id": "5a109db5",
41
+ "metadata": {},
42
+ "source": [
43
+ "## 0. Setup\n",
44
+ "On Colab, go to **Runtime > Change runtime type > T4 GPU** first. Then run this cell."
45
+ ]
46
+ },
47
+ {
48
+ "cell_type": "code",
49
+ "execution_count": null,
50
+ "id": "00b4393c",
51
+ "metadata": {},
52
+ "outputs": [],
53
+ "source": [
54
+ "%pip install -q torch transformers datasets tokenizers huggingface_hub"
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "code",
59
+ "execution_count": null,
60
+ "id": "e97e5f1c",
61
+ "metadata": {},
62
+ "outputs": [],
63
+ "source": [
64
+ "import math, time, json, os\n",
65
+ "import torch\n",
66
+ "import torch.nn as nn\n",
67
+ "import torch.nn.functional as F\n",
68
+ "\n",
69
+ "# Pick the best device available: NVIDIA GPU (Colab), Apple GPU (Mac), or CPU.\n",
70
+ "device = \"cuda\" if torch.cuda.is_available() else \"mps\" if torch.backends.mps.is_available() else \"cpu\"\n",
71
+ "torch.manual_seed(42)\n",
72
+ "print(\"PyTorch\", torch.__version__, \"| device:\", device)"
73
+ ]
74
+ },
75
+ {
76
+ "cell_type": "markdown",
77
+ "id": "5eecb952",
78
+ "metadata": {},
79
+ "source": [
80
+ "## 1. Tensors and gradients: the two building blocks\n",
81
+ "\n",
82
+ "A **tensor** is just a grid of numbers with a shape. A vector is 1-D, a matrix is 2-D,\n",
83
+ "a batch of sentences is 3-D. Everything in a neural network (the input text, the weights,\n",
84
+ "the output probabilities) is a tensor."
85
+ ]
86
+ },
87
+ {
88
+ "cell_type": "code",
89
+ "execution_count": null,
90
+ "id": "3f77d7e0",
91
+ "metadata": {},
92
+ "outputs": [],
93
+ "source": [
94
+ "x = torch.tensor([[1., 2., 3.],\n",
95
+ " [4., 5., 6.]]) # shape (2, 3): 2 rows, 3 columns\n",
96
+ "print(\"shape:\", x.shape)\n",
97
+ "print(\"x @ x.T =\\n\", x @ x.T) # matrix multiply: the core operation of every layer\n",
98
+ "print(\"x * 2 =\\n\", x * 2) # elementwise ops just work"
99
+ ]
100
+ },
101
+ {
102
+ "cell_type": "markdown",
103
+ "id": "2054c860",
104
+ "metadata": {},
105
+ "source": [
106
+ "A **gradient** tells us how much the loss would change if we nudged a weight.\n",
107
+ "PyTorch computes gradients for us with *autograd*: we mark a tensor with\n",
108
+ "`requires_grad=True`, do some math, call `.backward()`, and the gradient appears in `.grad`.\n",
109
+ "\n",
110
+ "Training a model is only this, repeated millions of times: compute a loss, get the gradients,\n",
111
+ "move every weight a tiny step against its gradient."
112
+ ]
113
+ },
114
+ {
115
+ "cell_type": "code",
116
+ "execution_count": null,
117
+ "id": "0d5918ad",
118
+ "metadata": {},
119
+ "outputs": [],
120
+ "source": [
121
+ "w = torch.tensor([1.0, -1.0, 0.5], requires_grad=True) # a \"weight\" we want to learn\n",
122
+ "loss = ((x @ w) ** 2).sum() # some loss that depends on w\n",
123
+ "loss.backward() # autograd fills in w.grad\n",
124
+ "print(\"loss:\", loss.item())\n",
125
+ "print(\"d(loss)/d(w):\", w.grad)\n",
126
+ "\n",
127
+ "# One step of gradient descent: move w a little bit against the gradient.\n",
128
+ "with torch.no_grad():\n",
129
+ " w -= 0.01 * w.grad\n",
130
+ "print(\"w after one step:\", w)"
131
+ ]
132
+ },
133
+ {
134
+ "cell_type": "markdown",
135
+ "id": "4c3d59ca",
136
+ "metadata": {},
137
+ "source": [
138
+ "## 2. Data: TinyStories\n",
139
+ "\n",
140
+ "[TinyStories](https://huggingface.co/datasets/roneneldan/TinyStories) is a public dataset of\n",
141
+ "short children's stories written in very simple English. It was created specifically to show\n",
142
+ "that *small* models can learn to write coherent text if the language is simple enough.\n",
143
+ "\n",
144
+ "We stream a slice of it so the download stays small."
145
+ ]
146
+ },
147
+ {
148
+ "cell_type": "code",
149
+ "execution_count": null,
150
+ "id": "6af6bd64",
151
+ "metadata": {},
152
+ "outputs": [],
153
+ "source": [
154
+ "from datasets import load_dataset\n",
155
+ "\n",
156
+ "N_STORIES = 20_000 # enough for a tiny model; raise it later if you want a better one\n",
157
+ "\n",
158
+ "stream = load_dataset(\"roneneldan/TinyStories\", split=\"train\", streaming=True)\n",
159
+ "stories = [row[\"text\"].strip() for row in stream.take(N_STORIES)]\n",
160
+ "\n",
161
+ "print(f\"{len(stories):,} stories, {sum(len(s) for s in stories)/1e6:.1f}M characters\")\n",
162
+ "print(\"-\" * 60)\n",
163
+ "print(stories[0][:500])"
164
+ ]
165
+ },
166
+ {
167
+ "cell_type": "markdown",
168
+ "id": "ea6277bf",
169
+ "metadata": {},
170
+ "source": [
171
+ "## 3. The simplest language model: a bigram\n",
172
+ "\n",
173
+ "A language model answers one question: **given what came before, what comes next?**\n",
174
+ "\n",
175
+ "The simplest version looks only at the *last character* and keeps a table of\n",
176
+ "\"after character A, how likely is character B\". That is a **bigram model**.\n",
177
+ "It is just one `nn.Embedding` table of size *vocab x vocab*.\n",
178
+ "\n",
179
+ "First we need a vocabulary. For now, one token = one character."
180
+ ]
181
+ },
182
+ {
183
+ "cell_type": "code",
184
+ "execution_count": null,
185
+ "id": "9a29bbc9",
186
+ "metadata": {},
187
+ "outputs": [],
188
+ "source": [
189
+ "text = \"\\n\".join(stories)\n",
190
+ "chars = sorted(set(text))\n",
191
+ "char_vocab = len(chars)\n",
192
+ "stoi = {c: i for i, c in enumerate(chars)} # string -> int\n",
193
+ "itos = {i: c for c, i in stoi.items()} # int -> string\n",
194
+ "encode_c = lambda s: [stoi[c] for c in s]\n",
195
+ "decode_c = lambda ids: \"\".join(itos[i] for i in ids)\n",
196
+ "\n",
197
+ "print(\"character vocab size:\", char_vocab)\n",
198
+ "print(\"encode('hello') ->\", encode_c(\"hello\"))\n",
199
+ "print(\"decode back ->\", decode_c(encode_c(\"hello\")))"
200
+ ]
201
+ },
202
+ {
203
+ "cell_type": "code",
204
+ "execution_count": null,
205
+ "id": "9990f156",
206
+ "metadata": {},
207
+ "outputs": [],
208
+ "source": [
209
+ "class Bigram(nn.Module):\n",
210
+ " def __init__(self, vocab):\n",
211
+ " super().__init__()\n",
212
+ " self.table = nn.Embedding(vocab, vocab) # row i = scores for \"what follows token i\"\n",
213
+ "\n",
214
+ " def forward(self, idx, targets=None):\n",
215
+ " logits = self.table(idx) # (B, T) -> (B, T, vocab)\n",
216
+ " loss = None\n",
217
+ " if targets is not None:\n",
218
+ " # cross-entropy compares predicted scores with the true next token\n",
219
+ " loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))\n",
220
+ " return logits, loss\n",
221
+ "\n",
222
+ " @torch.no_grad()\n",
223
+ " def generate(self, idx, max_new_tokens):\n",
224
+ " for _ in range(max_new_tokens):\n",
225
+ " logits, _ = self(idx[:, -1:]) # only the last token matters\n",
226
+ " probs = F.softmax(logits[:, -1, :], dim=-1)\n",
227
+ " idx = torch.cat([idx, torch.multinomial(probs, 1)], dim=1)\n",
228
+ " return idx\n",
229
+ "\n",
230
+ "char_data = torch.tensor(encode_c(text[:2_000_000]), dtype=torch.long)\n",
231
+ "\n",
232
+ "def get_char_batch(batch_size=64, block_size=8):\n",
233
+ " ix = torch.randint(len(char_data) - block_size - 1, (batch_size,))\n",
234
+ " x = torch.stack([char_data[i:i+block_size] for i in ix])\n",
235
+ " y = torch.stack([char_data[i+1:i+block_size+1] for i in ix]) # the \"next character\" of each position\n",
236
+ " return x.to(device), y.to(device)\n",
237
+ "\n",
238
+ "bigram = Bigram(char_vocab).to(device)\n",
239
+ "opt = torch.optim.AdamW(bigram.parameters(), lr=1e-2)\n",
240
+ "\n",
241
+ "for step in range(2000):\n",
242
+ " xb, yb = get_char_batch()\n",
243
+ " _, loss = bigram(xb, yb)\n",
244
+ " opt.zero_grad(); loss.backward(); opt.step()\n",
245
+ " if step % 500 == 0:\n",
246
+ " print(f\"step {step:4d} loss {loss.item():.3f}\")\n",
247
+ "\n",
248
+ "start = torch.tensor([encode_c(\"T\")], device=device)\n",
249
+ "print(\"\\nBigram writes:\\n\", decode_c(bigram.generate(start, 300)[0].tolist()))"
250
+ ]
251
+ },
252
+ {
253
+ "cell_type": "markdown",
254
+ "id": "658f7e72",
255
+ "metadata": {},
256
+ "source": [
257
+ "It learned *something* (the loss dropped, and it produces word-ish shapes with spaces),\n",
258
+ "but it is gibberish. It only sees one character back. To write a story, the model needs\n",
259
+ "two upgrades: **better tokens** and a way to **look further back**. That is the rest of the notebook."
260
+ ]
261
+ },
262
+ {
263
+ "cell_type": "markdown",
264
+ "id": "763829c4",
265
+ "metadata": {},
266
+ "source": [
267
+ "## 4. A real tokenizer: byte-pair encoding (BPE)\n",
268
+ "\n",
269
+ "Characters are too small; the model wastes effort learning to spell. Whole words are too\n",
270
+ "many (and unknown words break). BPE is the middle ground: it starts from characters and\n",
271
+ "repeatedly merges the most common pairs, so frequent words become single tokens and rare\n",
272
+ "words split into pieces. Every big model (GPT, Llama, Qwen) uses some form of this.\n",
273
+ "\n",
274
+ "We train our own tokenizer on the stories, with a small vocabulary of 4,096 tokens."
275
+ ]
276
+ },
277
+ {
278
+ "cell_type": "code",
279
+ "execution_count": null,
280
+ "id": "8bcb2397",
281
+ "metadata": {},
282
+ "outputs": [],
283
+ "source": [
284
+ "from tokenizers import ByteLevelBPETokenizer\n",
285
+ "from transformers import PreTrainedTokenizerFast\n",
286
+ "\n",
287
+ "VOCAB_SIZE = 4096\n",
288
+ "EOS = \"<|endoftext|>\" # special token that marks where one story ends\n",
289
+ "\n",
290
+ "bpe = ByteLevelBPETokenizer()\n",
291
+ "bpe.train_from_iterator(stories, vocab_size=VOCAB_SIZE, min_frequency=2, special_tokens=[EOS])\n",
292
+ "\n",
293
+ "tokenizer = PreTrainedTokenizerFast(tokenizer_object=bpe, bos_token=EOS, eos_token=EOS, pad_token=EOS)\n",
294
+ "vocab_size = len(tokenizer)\n",
295
+ "\n",
296
+ "sample = \"Once upon a time, a little fox found a shiny red ball.\"\n",
297
+ "ids = tokenizer(sample)[\"input_ids\"]\n",
298
+ "print(\"vocab size:\", vocab_size)\n",
299
+ "print(f\"{len(sample)} characters -> {len(ids)} tokens\")\n",
300
+ "print(tokenizer.convert_ids_to_tokens(ids)) # 'Ġ' means 'there is a space before this piece'"
301
+ ]
302
+ },
303
+ {
304
+ "cell_type": "code",
305
+ "execution_count": null,
306
+ "id": "23cb929f",
307
+ "metadata": {},
308
+ "outputs": [],
309
+ "source": [
310
+ "# Turn all stories into one long stream of token ids, with EOS between stories.\n",
311
+ "ids = tokenizer(EOS.join(stories) + EOS)[\"input_ids\"]\n",
312
+ "data = torch.tensor(ids, dtype=torch.long)\n",
313
+ "n = int(0.99 * len(data))\n",
314
+ "train_data, val_data = data[:n], data[n:]\n",
315
+ "print(f\"total tokens: {len(data)/1e6:.2f}M | train {len(train_data)/1e6:.2f}M | val {len(val_data)/1e3:.0f}K\")"
316
+ ]
317
+ },
318
+ {
319
+ "cell_type": "markdown",
320
+ "id": "d2e9b068",
321
+ "metadata": {},
322
+ "source": [
323
+ "## 5. Attention, by hand\n",
324
+ "\n",
325
+ "This is the idea that made modern LLMs possible. Each token gets to **look at the tokens\n",
326
+ "before it** and decide how much to pay attention to each one.\n",
327
+ "\n",
328
+ "Every token produces three vectors:\n",
329
+ "- a **query** (\"what am I looking for?\"),\n",
330
+ "- a **key** (\"what do I contain?\"),\n",
331
+ "- a **value** (\"what do I pass on if someone attends to me?\").\n",
332
+ "\n",
333
+ "A token's output is a weighted average of the *values* of earlier tokens, where the weights\n",
334
+ "come from how well its *query* matches their *keys*. The weights are made with a softmax,\n",
335
+ "and a triangular mask stops tokens from peeking at the future (we are predicting it!)."
336
+ ]
337
+ },
338
+ {
339
+ "cell_type": "code",
340
+ "execution_count": null,
341
+ "id": "c68c6f5c",
342
+ "metadata": {},
343
+ "outputs": [],
344
+ "source": [
345
+ "B, T, C = 1, 4, 8 # batch, time (tokens), channels (features)\n",
346
+ "x_demo = torch.randn(B, T, C)\n",
347
+ "head = 4\n",
348
+ "q = nn.Linear(C, head, bias=False)(x_demo)\n",
349
+ "k = nn.Linear(C, head, bias=False)(x_demo)\n",
350
+ "v = nn.Linear(C, head, bias=False)(x_demo)\n",
351
+ "\n",
352
+ "scores = q @ k.transpose(-2, -1) / math.sqrt(head) # (B, T, T): how much token i matches token j\n",
353
+ "mask = torch.tril(torch.ones(T, T)) # 1 = allowed to look, 0 = future\n",
354
+ "scores = scores.masked_fill(mask == 0, float(\"-inf\")) # future positions get -inf -> 0 after softmax\n",
355
+ "weights = F.softmax(scores, dim=-1)\n",
356
+ "out = weights @ v # (B, T, head)\n",
357
+ "\n",
358
+ "torch.set_printoptions(precision=2, sci_mode=False)\n",
359
+ "print(\"attention weights (row = token, column = who it looks at):\\n\", weights[0])\n",
360
+ "print(\"\\nRow 1 only looks at tokens 0-1, row 3 can see all four. Each row sums to 1.\")"
361
+ ]
362
+ },
363
+ {
364
+ "cell_type": "markdown",
365
+ "id": "0da003d3",
366
+ "metadata": {},
367
+ "source": [
368
+ "## 6. A tiny GPT\n",
369
+ "\n",
370
+ "Now the full model. It is the same attention from above, used many times:\n",
371
+ "\n",
372
+ "1. **Token embedding**: each token id becomes a vector of `n_embd` numbers.\n",
373
+ "2. **Position embedding**: each position (0, 1, 2, ...) also gets a vector, so the model knows word order.\n",
374
+ "3. **Blocks** (repeated `n_layer` times). Each block has:\n",
375
+ " - multi-head attention (several attention \"heads\" in parallel, each looking for different things),\n",
376
+ " - a feed-forward network (a small MLP applied to every token on its own),\n",
377
+ " - layer norm and residual connections, which keep training stable.\n",
378
+ "4. **Final layer norm + output head**: turn each vector into scores over the vocabulary.\n",
379
+ "\n",
380
+ "We also write a `generate` method: predict the next token, append it, repeat."
381
+ ]
382
+ },
383
+ {
384
+ "cell_type": "code",
385
+ "execution_count": null,
386
+ "id": "b00876c0",
387
+ "metadata": {},
388
+ "outputs": [],
389
+ "source": [
390
+ "class Config:\n",
391
+ " vocab_size = vocab_size\n",
392
+ " block_size = 256 # how many tokens the model can see at once\n",
393
+ " n_embd = 256 # size of each token's vector\n",
394
+ " n_head = 4 # attention heads per block\n",
395
+ " n_layer = 6 # number of blocks\n",
396
+ " dropout = 0.0 # tiny model + lots of data -> no dropout needed\n",
397
+ "\n",
398
+ "cfg = Config()\n",
399
+ "\n",
400
+ "class CausalSelfAttention(nn.Module):\n",
401
+ " def __init__(self, cfg):\n",
402
+ " super().__init__()\n",
403
+ " self.n_head = cfg.n_head\n",
404
+ " self.qkv = nn.Linear(cfg.n_embd, 3 * cfg.n_embd) # query, key, value for all heads at once\n",
405
+ " self.proj = nn.Linear(cfg.n_embd, cfg.n_embd)\n",
406
+ " self.drop = nn.Dropout(cfg.dropout)\n",
407
+ "\n",
408
+ " def forward(self, x):\n",
409
+ " B, T, C = x.shape\n",
410
+ " q, k, v = self.qkv(x).split(C, dim=2)\n",
411
+ " # reshape so each head works on its own slice: (B, n_head, T, head_size)\n",
412
+ " q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)\n",
413
+ " k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)\n",
414
+ " v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)\n",
415
+ " # exactly the masked softmax(QK^T/sqrt(d))V from Part 5, done by a fast built-in kernel\n",
416
+ " y = F.scaled_dot_product_attention(q, k, v, is_causal=True)\n",
417
+ " y = y.transpose(1, 2).contiguous().view(B, T, C) # merge the heads back together\n",
418
+ " return self.drop(self.proj(y))\n",
419
+ "\n",
420
+ "class FeedForward(nn.Module):\n",
421
+ " def __init__(self, cfg):\n",
422
+ " super().__init__()\n",
423
+ " self.net = nn.Sequential(\n",
424
+ " nn.Linear(cfg.n_embd, 4 * cfg.n_embd),\n",
425
+ " nn.GELU(),\n",
426
+ " nn.Linear(4 * cfg.n_embd, cfg.n_embd),\n",
427
+ " nn.Dropout(cfg.dropout),\n",
428
+ " )\n",
429
+ " def forward(self, x):\n",
430
+ " return self.net(x)\n",
431
+ "\n",
432
+ "class Block(nn.Module):\n",
433
+ " def __init__(self, cfg):\n",
434
+ " super().__init__()\n",
435
+ " self.ln1 = nn.LayerNorm(cfg.n_embd)\n",
436
+ " self.attn = CausalSelfAttention(cfg)\n",
437
+ " self.ln2 = nn.LayerNorm(cfg.n_embd)\n",
438
+ " self.ff = FeedForward(cfg)\n",
439
+ " def forward(self, x):\n",
440
+ " x = x + self.attn(self.ln1(x)) # \"x +\" is the residual connection\n",
441
+ " x = x + self.ff(self.ln2(x))\n",
442
+ " return x\n",
443
+ "\n",
444
+ "class GPT(nn.Module):\n",
445
+ " def __init__(self, cfg):\n",
446
+ " super().__init__()\n",
447
+ " self.cfg = cfg\n",
448
+ " self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)\n",
449
+ " self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)\n",
450
+ " self.blocks = nn.Sequential(*[Block(cfg) for _ in range(cfg.n_layer)])\n",
451
+ " self.ln_f = nn.LayerNorm(cfg.n_embd)\n",
452
+ " self.head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)\n",
453
+ " self.head.weight = self.tok_emb.weight # weight tying: same matrix in and out, saves parameters\n",
454
+ " self.apply(self._init)\n",
455
+ "\n",
456
+ " def _init(self, m):\n",
457
+ " if isinstance(m, (nn.Linear, nn.Embedding)):\n",
458
+ " nn.init.normal_(m.weight, mean=0.0, std=0.02)\n",
459
+ " if isinstance(m, nn.Linear) and m.bias is not None:\n",
460
+ " nn.init.zeros_(m.bias)\n",
461
+ "\n",
462
+ " def forward(self, idx, targets=None):\n",
463
+ " B, T = idx.shape\n",
464
+ " pos = torch.arange(T, device=idx.device)\n",
465
+ " x = self.tok_emb(idx) + self.pos_emb(pos) # (B, T, n_embd)\n",
466
+ " x = self.ln_f(self.blocks(x))\n",
467
+ " logits = self.head(x) # (B, T, vocab_size)\n",
468
+ " loss = None\n",
469
+ " if targets is not None:\n",
470
+ " loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))\n",
471
+ " return logits, loss\n",
472
+ "\n",
473
+ " @torch.no_grad()\n",
474
+ " def generate(self, idx, max_new_tokens, temperature=0.8, top_k=40):\n",
475
+ " for _ in range(max_new_tokens):\n",
476
+ " idx_cond = idx[:, -self.cfg.block_size:] # never feed more than block_size tokens\n",
477
+ " logits, _ = self(idx_cond)\n",
478
+ " logits = logits[:, -1, :] / temperature # lower temperature = safer, more boring\n",
479
+ " if top_k is not None:\n",
480
+ " v, _ = torch.topk(logits, top_k)\n",
481
+ " logits[logits < v[:, [-1]]] = float(\"-inf\") # keep only the k most likely tokens\n",
482
+ " probs = F.softmax(logits, dim=-1)\n",
483
+ " next_id = torch.multinomial(probs, 1)\n",
484
+ " idx = torch.cat([idx, next_id], dim=1)\n",
485
+ " if next_id.item() == tokenizer.eos_token_id: # the model decided the story is over\n",
486
+ " break\n",
487
+ " return idx\n",
488
+ "\n",
489
+ "model = GPT(cfg).to(device)\n",
490
+ "n_params = sum(p.numel() for p in model.parameters())\n",
491
+ "print(f\"GPT with {cfg.n_layer} blocks, {cfg.n_head} heads, {cfg.n_embd}-dim vectors -> {n_params/1e6:.1f}M parameters\")\n",
492
+ "print(\"(for scale: GPT-2 small is 124M, Llama-3 8B is 8,000M)\")"
493
+ ]
494
+ },
495
+ {
496
+ "cell_type": "markdown",
497
+ "id": "9c446a37",
498
+ "metadata": {},
499
+ "source": [
500
+ "## 7. Training\n",
501
+ "\n",
502
+ "The loop is the same one we used for the bigram, just bigger:\n",
503
+ "1. grab a random batch of token windows, and the same windows shifted by one (the targets),\n",
504
+ "2. run the model, get the loss,\n",
505
+ "3. backward, step the optimizer.\n",
506
+ "\n",
507
+ "Two small extras that every real training run uses:\n",
508
+ "- a **learning-rate schedule** (warm up, then cosine decay) so the start is gentle and the end is precise,\n",
509
+ "- **mixed precision** on NVIDIA GPUs, which is roughly 2x faster.\n",
510
+ "\n",
511
+ "About 1,500 steps takes a few minutes on a T4 or an M-series Mac. Watch the loss fall."
512
+ ]
513
+ },
514
+ {
515
+ "cell_type": "code",
516
+ "execution_count": null,
517
+ "id": "222e1c51",
518
+ "metadata": {},
519
+ "outputs": [],
520
+ "source": [
521
+ "BATCH_SIZE = 32\n",
522
+ "MAX_STEPS = 1500\n",
523
+ "EVAL_EVERY = 250\n",
524
+ "LR = 1e-3\n",
525
+ "WARMUP = 100\n",
526
+ "\n",
527
+ "def get_batch(split):\n",
528
+ " d = train_data if split == \"train\" else val_data\n",
529
+ " ix = torch.randint(len(d) - cfg.block_size - 1, (BATCH_SIZE,))\n",
530
+ " x = torch.stack([d[i:i+cfg.block_size] for i in ix])\n",
531
+ " y = torch.stack([d[i+1:i+cfg.block_size+1] for i in ix])\n",
532
+ " return x.to(device), y.to(device)\n",
533
+ "\n",
534
+ "@torch.no_grad()\n",
535
+ "def estimate_loss(iters=20):\n",
536
+ " model.eval()\n",
537
+ " out = {}\n",
538
+ " for split in (\"train\", \"val\"):\n",
539
+ " losses = torch.zeros(iters)\n",
540
+ " for i in range(iters):\n",
541
+ " xb, yb = get_batch(split)\n",
542
+ " _, loss = model(xb, yb)\n",
543
+ " losses[i] = loss.item()\n",
544
+ " out[split] = losses.mean().item()\n",
545
+ " model.train()\n",
546
+ " return out\n",
547
+ "\n",
548
+ "def lr_at(step):\n",
549
+ " if step < WARMUP:\n",
550
+ " return LR * (step + 1) / WARMUP\n",
551
+ " progress = (step - WARMUP) / max(1, MAX_STEPS - WARMUP)\n",
552
+ " return 0.1 * LR + 0.9 * LR * 0.5 * (1 + math.cos(math.pi * progress))\n",
553
+ "\n",
554
+ "optimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=0.1, betas=(0.9, 0.95))\n",
555
+ "use_amp = device == \"cuda\"\n",
556
+ "scaler = torch.amp.GradScaler(enabled=use_amp)\n",
557
+ "\n",
558
+ "history = []\n",
559
+ "t0 = time.time()\n",
560
+ "model.train()\n",
561
+ "for step in range(MAX_STEPS + 1):\n",
562
+ " for g in optimizer.param_groups:\n",
563
+ " g[\"lr\"] = lr_at(step)\n",
564
+ " if step % EVAL_EVERY == 0 or step == MAX_STEPS:\n",
565
+ " losses = estimate_loss()\n",
566
+ " history.append((step, losses[\"train\"], losses[\"val\"]))\n",
567
+ " print(f\"step {step:5d} | train loss {losses['train']:.3f} | val loss {losses['val']:.3f} \"\n",
568
+ " f\"| perplexity {math.exp(losses['val']):6.1f} | {(time.time()-t0)/60:4.1f} min\")\n",
569
+ " if step == MAX_STEPS:\n",
570
+ " break\n",
571
+ " xb, yb = get_batch(\"train\")\n",
572
+ " with torch.autocast(device_type=\"cuda\", dtype=torch.float16, enabled=use_amp):\n",
573
+ " _, loss = model(xb, yb)\n",
574
+ " optimizer.zero_grad(set_to_none=True)\n",
575
+ " scaler.scale(loss).backward()\n",
576
+ " scaler.unscale_(optimizer)\n",
577
+ " torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # stops rare huge gradients from wrecking training\n",
578
+ " scaler.step(optimizer)\n",
579
+ " scaler.update()"
580
+ ]
581
+ },
582
+ {
583
+ "cell_type": "markdown",
584
+ "id": "4dee51b4",
585
+ "metadata": {},
586
+ "source": [
587
+ "**How to read the numbers.** The loss is the average \"surprise\" per token. *Perplexity* is\n",
588
+ "`exp(loss)`: roughly, \"between how many tokens is the model guessing\". A random guess over\n",
589
+ "4,096 tokens would be perplexity 4,096. Anything under 10 on this data means real sentences."
590
+ ]
591
+ },
592
+ {
593
+ "cell_type": "markdown",
594
+ "id": "284708da",
595
+ "metadata": {},
596
+ "source": [
597
+ "## 8. Generate stories: the use case\n",
598
+ "\n",
599
+ "Give the model the first few words of a story and let it continue.\n",
600
+ "Run it a few times; each run is a fresh story. Try your own prompts."
601
+ ]
602
+ },
603
+ {
604
+ "cell_type": "code",
605
+ "execution_count": null,
606
+ "id": "ce19e9d9",
607
+ "metadata": {},
608
+ "outputs": [],
609
+ "source": [
610
+ "def tell_story(prompt, max_new_tokens=200, temperature=0.8):\n",
611
+ " model.eval()\n",
612
+ " idx = torch.tensor([tokenizer(prompt)[\"input_ids\"]], device=device)\n",
613
+ " out = model.generate(idx, max_new_tokens, temperature=temperature)\n",
614
+ " return tokenizer.decode(out[0].tolist(), skip_special_tokens=True)\n",
615
+ "\n",
616
+ "for prompt in [\"Once upon a time, a little fox\", \"Lily had a red balloon.\", \"One day, Tom went to the park\"]:\n",
617
+ " print(tell_story(prompt))\n",
618
+ " print(\"-\" * 60)"
619
+ ]
620
+ },
621
+ {
622
+ "cell_type": "markdown",
623
+ "id": "a3a6cfad",
624
+ "metadata": {},
625
+ "source": [
626
+ "## 9. Save and publish on the Hugging Face Hub\n",
627
+ "\n",
628
+ "A model is just its **weights** (the tensors we trained), its **config** (the shape), and its\n",
629
+ "**tokenizer**. We save all three to a folder, write a short model card, and upload the folder\n",
630
+ "as a repo under your account.\n",
631
+ "\n",
632
+ "You need a Hugging Face token with *write* permission (Settings > Access Tokens).\n",
633
+ "On Colab, `login()` will ask for it. Locally, put `HF_TOKEN=...` in your environment."
634
+ ]
635
+ },
636
+ {
637
+ "cell_type": "code",
638
+ "execution_count": null,
639
+ "id": "e5fc0838",
640
+ "metadata": {},
641
+ "outputs": [],
642
+ "source": [
643
+ "REPO_NAME = \"tiny-storyteller\" # the model will live at huggingface.co/<you>/tiny-storyteller\n",
644
+ "PUSH_TO_HUB = True # set False to only save locally\n",
645
+ "\n",
646
+ "save_dir = REPO_NAME\n",
647
+ "os.makedirs(save_dir, exist_ok=True)\n",
648
+ "torch.save(model.state_dict(), f\"{save_dir}/model.pt\")\n",
649
+ "json.dump({k: getattr(cfg, k) for k in [\"vocab_size\", \"block_size\", \"n_embd\", \"n_head\", \"n_layer\", \"dropout\"]},\n",
650
+ " open(f\"{save_dir}/config.json\", \"w\"), indent=2)\n",
651
+ "tokenizer.save_pretrained(save_dir)\n",
652
+ "\n",
653
+ "final_val = history[-1][2]\n",
654
+ "card = f'''---\n",
655
+ "license: mit\n",
656
+ "language: en\n",
657
+ "datasets: [roneneldan/TinyStories]\n",
658
+ "tags: [tiny, from-scratch, pytorch, educational, text-generation]\n",
659
+ "---\n",
660
+ "# Tiny Storyteller\n",
661
+ "\n",
662
+ "A {n_params/1e6:.1f}M-parameter GPT-style language model trained **from scratch** (random init, no pretrained weights)\n",
663
+ "on {N_STORIES:,} TinyStories, as a learning project. Use case: a bedtime story starter for young kids.\n",
664
+ "\n",
665
+ "- Architecture: {cfg.n_layer} transformer blocks, {cfg.n_head} heads, {cfg.n_embd}-dim embeddings, {cfg.block_size}-token context\n",
666
+ "- Tokenizer: byte-level BPE, {vocab_size:,} tokens, trained on the same data\n",
667
+ "- Training: {MAX_STEPS} steps, batch {BATCH_SIZE}, AdamW, cosine schedule\n",
668
+ "- Final validation loss {final_val:.3f} (perplexity {math.exp(final_val):.1f})\n",
669
+ "\n",
670
+ "The full build, explained step by step, is in `tiny_llm_from_scratch.ipynb` in this repo.\n",
671
+ "Load with plain PyTorch: `GPT(cfg).load_state_dict(torch.load(\"model.pt\"))`.\n",
672
+ "'''\n",
673
+ "open(f\"{save_dir}/README.md\", \"w\").write(card)\n",
674
+ "print(\"saved:\", sorted(os.listdir(save_dir)))"
675
+ ]
676
+ },
677
+ {
678
+ "cell_type": "code",
679
+ "execution_count": null,
680
+ "id": "7e1ae430",
681
+ "metadata": {},
682
+ "outputs": [],
683
+ "source": [
684
+ "if PUSH_TO_HUB:\n",
685
+ " from huggingface_hub import HfApi, login, whoami\n",
686
+ " if not os.environ.get(\"HF_TOKEN\"):\n",
687
+ " login() # on Colab this opens a token prompt\n",
688
+ " api = HfApi()\n",
689
+ " user = whoami()[\"name\"]\n",
690
+ " repo_id = f\"{user}/{REPO_NAME}\"\n",
691
+ " api.create_repo(repo_id, repo_type=\"model\", private=True, exist_ok=True) # private by default; flip to False to share publicly\n",
692
+ " api.upload_folder(folder_path=save_dir, repo_id=repo_id, repo_type=\"model\")\n",
693
+ " nb = \"tiny_llm_from_scratch.ipynb\"\n",
694
+ " if os.path.exists(nb):\n",
695
+ " api.upload_file(path_or_fileobj=nb, path_in_repo=nb, repo_id=repo_id)\n",
696
+ " print(\"published at https://huggingface.co/\" + repo_id)"
697
+ ]
698
+ },
699
+ {
700
+ "cell_type": "markdown",
701
+ "id": "34f8cabf",
702
+ "metadata": {},
703
+ "source": [
704
+ "## Loading it back later\n",
705
+ "Anyone (including you, on another machine) can rebuild the model from the Hub with the\n",
706
+ "`GPT` class from Part 6 and these few lines:"
707
+ ]
708
+ },
709
+ {
710
+ "cell_type": "code",
711
+ "execution_count": null,
712
+ "id": "c5e86850",
713
+ "metadata": {},
714
+ "outputs": [],
715
+ "source": [
716
+ "from huggingface_hub import hf_hub_download\n",
717
+ "\n",
718
+ "def load_from_hub(repo_id):\n",
719
+ " c = json.load(open(hf_hub_download(repo_id, \"config.json\")))\n",
720
+ " class C: pass\n",
721
+ " for k, v in c.items(): setattr(C, k, v)\n",
722
+ " m = GPT(C()).to(device)\n",
723
+ " m.load_state_dict(torch.load(hf_hub_download(repo_id, \"model.pt\"), map_location=device))\n",
724
+ " return m.eval()\n",
725
+ "\n",
726
+ "# Example (uncomment after publishing):\n",
727
+ "# model = load_from_hub(\"singhpratech/tiny-storyteller\")\n",
728
+ "# print(tell_story(\"Once upon a time, a little fox\"))"
729
+ ]
730
+ },
731
+ {
732
+ "cell_type": "markdown",
733
+ "id": "c0f44cd1",
734
+ "metadata": {},
735
+ "source": [
736
+ "## What we built, in one paragraph\n",
737
+ "Text became numbers through a tokenizer we trained ourselves. A tiny transformer turned\n",
738
+ "those numbers into vectors, let each token attend to the ones before it, and predicted the\n",
739
+ "next token. We measured how wrong it was with cross-entropy, used autograd to get gradients,\n",
740
+ "and nudged every weight with AdamW, thousands of times. That is the entire recipe. Big\n",
741
+ "models use the same recipe with more data, more layers, and more GPUs.\n",
742
+ "\n",
743
+ "## Ideas to try next\n",
744
+ "- Raise `N_STORIES` and `MAX_STEPS` for a noticeably better storyteller.\n",
745
+ "- Change `n_layer` or `n_embd` and watch how parameters, speed and loss trade off.\n",
746
+ "- Lower `temperature` for safer stories, raise it for stranger ones.\n",
747
+ "- Replace TinyStories with your own text (recipes, poems, your journal) and the same code\n",
748
+ " learns to write in that style."
749
+ ]
750
+ }
751
+ ],
752
+ "metadata": {
753
+ "accelerator": "GPU",
754
+ "colab": {
755
+ "gpuType": "T4",
756
+ "provenance": []
757
+ },
758
+ "kernelspec": {
759
+ "display_name": "Python 3",
760
+ "language": "python",
761
+ "name": "python3"
762
+ },
763
+ "language_info": {
764
+ "name": "python"
765
+ }
766
+ },
767
+ "nbformat": 4,
768
+ "nbformat_minor": 5
769
+ }