Vishwas1 commited on
Commit
170658b
·
verified ·
1 Parent(s): 1b0f14e

Add CPU-trained tiny character LLM

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
README.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: pytorch
4
+ tags:
5
+ - tinyllm
6
+ - character-language-model
7
+ - gpt
8
+ - cpu
9
+ - educational
10
+ - pytorch
11
+ pipeline_tag: text-generation
12
+ ---
13
+
14
+ # tinyllm-cpu-char
15
+
16
+ A tiny CPU-trained character-level GPT-style language model created as an educational end-to-end LLM experiment.
17
+
18
+ This is **not** a general chatbot. It is a deliberately small model trained on a tiny toy corpus to demonstrate the full loop:
19
+
20
+ 1. build a vocabulary
21
+ 2. train a causal transformer
22
+ 3. save a checkpoint
23
+ 4. generate text from learned character patterns
24
+
25
+ ## Model details
26
+
27
+ - Architecture: tiny GPT-style causal transformer
28
+ - Tokenization: character-level
29
+ - Parameters: 106,688
30
+ - Training device: CPU
31
+ - Training steps: 3,000
32
+ - Dataset: tiny included toy corpus, `data/tiny_corpus.txt`
33
+ - Checkpoint: `checkpoints/tinyllm_overfit_3k.pt`
34
+
35
+ Final training run:
36
+
37
+ ```text
38
+ step 0: train 3.6967, val 3.6921
39
+ step 3000: train 0.1903, val 3.7094
40
+ ```
41
+
42
+ The rising validation loss is expected here: this checkpoint intentionally overfits the tiny corpus to prove the training loop works.
43
+
44
+ ## Quick inference
45
+
46
+ ```bash
47
+ pip install torch huggingface_hub
48
+ python hf_infer.py --repo-id YOUR_USERNAME/tinyllm-cpu-char --prompt "A seed" --tokens 300
49
+ ```
50
+
51
+ For local inference after cloning:
52
+
53
+ ```bash
54
+ python sample.py --ckpt checkpoints/tinyllm_overfit_3k.pt --prompt "The little machine" --tokens 300
55
+ ```
56
+
57
+ Example output after overfitting:
58
+
59
+ ```text
60
+ A seed does not pretend to be a forest.
61
+ A sed shows that a forest is posssible.
62
+
63
+ The student asked: can a tiny model think?
64
+ The teacher answered: first let it predict...
65
+ ```
66
+
67
+ ## Training
68
+
69
+ ```bash
70
+ python train.py --steps 3000 --eval-interval 500 --eval-iters 10 \
71
+ --batch-size 16 --n-embd 64 --block-size 64 \
72
+ --out checkpoints/tinyllm_overfit_3k.pt
73
+ ```
74
+
75
+ ## Limitations
76
+
77
+ - Character-level only.
78
+ - Trained on a tiny toy corpus.
79
+ - Generates memorized/stylized snippets, not reliable knowledge.
80
+ - Intended for learning and experimentation.
checkpoints/tinyllm_overfit_3k.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "params": 106688,
3
+ "chars": [
4
+ "\n",
5
+ " ",
6
+ ",",
7
+ ".",
8
+ ":",
9
+ "?",
10
+ "A",
11
+ "C",
12
+ "E",
13
+ "I",
14
+ "P",
15
+ "T",
16
+ "U",
17
+ "W",
18
+ "a",
19
+ "b",
20
+ "c",
21
+ "d",
22
+ "e",
23
+ "f",
24
+ "g",
25
+ "h",
26
+ "i",
27
+ "k",
28
+ "l",
29
+ "m",
30
+ "n",
31
+ "o",
32
+ "p",
33
+ "q",
34
+ "r",
35
+ "s",
36
+ "t",
37
+ "u",
38
+ "v",
39
+ "w",
40
+ "x",
41
+ "y",
42
+ "z"
43
+ ],
44
+ "last_loss": {
45
+ "train": 0.19029191136360168,
46
+ "val": 3.7094032764434814
47
+ }
48
+ }
checkpoints/tinyllm_overfit_3k.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:068f3a2580674876837e22022acf895f2bc67cd0f3d30a3782802373a0e01b0d
3
+ size 438789
data/tiny_corpus.txt ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ The little machine listened to the rain and learned one letter at a time.
2
+ A thought began as a spark, then became a pattern, then became a voice.
3
+ The teacher said: do not fear small beginnings. A tiny model can still teach us the shape of learning.
4
+
5
+ In the quiet workshop, tokens walked in a line.
6
+ Each token looked back at the tokens before it and asked, what should come next?
7
+ The answer was never magic. It was counting, guessing, correcting, and trying again.
8
+
9
+ A fox told a crow: wisdom is not the size of the library, but the care of the attention.
10
+ A crow replied: even a small mind can remember a melody if it hears the song often enough.
11
+ The river laughed, because the river had trained every stone by repeating its lesson.
12
+
13
+ We build tinyllm to understand language models from the inside.
14
+ We train on CPU because patience is part of the experiment.
15
+ We start with characters because characters are honest: small marks, simple rules, many possibilities.
16
+
17
+ The model begins with random noise.
18
+ Then loss falls a little.
19
+ Then letters become syllables.
20
+ Then syllables become words.
21
+ Then words begin to imitate the books that fed them.
22
+
23
+ This is not a giant assistant.
24
+ This is a seed.
25
+ A seed does not pretend to be a forest.
26
+ A seed shows that a forest is possible.
27
+
28
+ The student asked: can a tiny model think?
29
+ The teacher answered: first let it predict. Then let us study what prediction teaches.
30
+
31
+ Again the little machine listened.
32
+ Again the optimizer stepped.
33
+ Again the text became less strange.
34
+ And in the warm hum of the CPU, the tiny language model began.
hf_infer.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run inference from a local clone or directly from a Hugging Face repo."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ import torch
9
+ from huggingface_hub import hf_hub_download
10
+
11
+ from model import TinyGPT, TinyGPTConfig
12
+
13
+
14
+ def resolve_ckpt(args) -> Path:
15
+ if args.ckpt:
16
+ return Path(args.ckpt)
17
+ if args.repo_id:
18
+ return Path(
19
+ hf_hub_download(
20
+ repo_id=args.repo_id,
21
+ filename=args.filename,
22
+ revision=args.revision,
23
+ )
24
+ )
25
+ local = Path(args.filename)
26
+ if local.exists():
27
+ return local
28
+ raise SystemExit("Provide --ckpt for local checkpoint or --repo-id for Hugging Face download.")
29
+
30
+
31
+ def main():
32
+ p = argparse.ArgumentParser()
33
+ p.add_argument("--repo-id", help="Hugging Face repo id, e.g. username/tinyllm-cpu-char")
34
+ p.add_argument("--revision", default="main")
35
+ p.add_argument("--filename", default="checkpoints/tinyllm_overfit_3k.pt")
36
+ p.add_argument("--ckpt", help="Local checkpoint path")
37
+ p.add_argument("--prompt", default="The little machine")
38
+ p.add_argument("--tokens", type=int, default=300)
39
+ p.add_argument("--temperature", type=float, default=0.7)
40
+ p.add_argument("--top-k", type=int, default=10)
41
+ args = p.parse_args()
42
+
43
+ ckpt = torch.load(resolve_ckpt(args), map_location="cpu")
44
+ cfg = TinyGPTConfig(**ckpt["config"])
45
+ model = TinyGPT(cfg)
46
+ model.load_state_dict(ckpt["model_state"])
47
+ model.eval()
48
+
49
+ stoi = ckpt["stoi"]
50
+ itos = {int(k): v for k, v in ckpt["itos"].items()}
51
+ prompt = "".join(ch for ch in args.prompt if ch in stoi) or "\n"
52
+ idx = torch.tensor([[stoi[ch] for ch in prompt]], dtype=torch.long)
53
+ out = model.generate(idx, max_new_tokens=args.tokens, temperature=args.temperature, top_k=args.top_k)
54
+ print("".join(itos[int(i)] for i in out[0]))
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
model.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A deliberately tiny GPT-style language model for CPU experiments."""
2
+ from __future__ import annotations
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+ from torch.nn import functional as F
7
+
8
+
9
+ class TinyGPTConfig:
10
+ def __init__(
11
+ self,
12
+ vocab_size: int,
13
+ block_size: int = 64,
14
+ n_layer: int = 2,
15
+ n_head: int = 2,
16
+ n_embd: int = 64,
17
+ dropout: float = 0.1,
18
+ ):
19
+ self.vocab_size = vocab_size
20
+ self.block_size = block_size
21
+ self.n_layer = n_layer
22
+ self.n_head = n_head
23
+ self.n_embd = n_embd
24
+ self.dropout = dropout
25
+
26
+
27
+ class CausalSelfAttention(nn.Module):
28
+ def __init__(self, cfg: TinyGPTConfig):
29
+ super().__init__()
30
+ assert cfg.n_embd % cfg.n_head == 0
31
+ self.n_head = cfg.n_head
32
+ self.head_dim = cfg.n_embd // cfg.n_head
33
+ self.qkv = nn.Linear(cfg.n_embd, 3 * cfg.n_embd)
34
+ self.proj = nn.Linear(cfg.n_embd, cfg.n_embd)
35
+ self.dropout = nn.Dropout(cfg.dropout)
36
+ self.register_buffer(
37
+ "mask",
38
+ torch.tril(torch.ones(cfg.block_size, cfg.block_size)).view(1, 1, cfg.block_size, cfg.block_size),
39
+ persistent=False,
40
+ )
41
+
42
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
43
+ b, t, c = x.shape
44
+ q, k, v = self.qkv(x).split(c, dim=2)
45
+ q = q.view(b, t, self.n_head, self.head_dim).transpose(1, 2)
46
+ k = k.view(b, t, self.n_head, self.head_dim).transpose(1, 2)
47
+ v = v.view(b, t, self.n_head, self.head_dim).transpose(1, 2)
48
+
49
+ att = (q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5)
50
+ att = att.masked_fill(self.mask[:, :, :t, :t] == 0, float("-inf"))
51
+ att = F.softmax(att, dim=-1)
52
+ att = self.dropout(att)
53
+ y = att @ v
54
+ y = y.transpose(1, 2).contiguous().view(b, t, c)
55
+ return self.dropout(self.proj(y))
56
+
57
+
58
+ class Block(nn.Module):
59
+ def __init__(self, cfg: TinyGPTConfig):
60
+ super().__init__()
61
+ self.ln1 = nn.LayerNorm(cfg.n_embd)
62
+ self.attn = CausalSelfAttention(cfg)
63
+ self.ln2 = nn.LayerNorm(cfg.n_embd)
64
+ self.mlp = nn.Sequential(
65
+ nn.Linear(cfg.n_embd, 4 * cfg.n_embd),
66
+ nn.GELU(),
67
+ nn.Linear(4 * cfg.n_embd, cfg.n_embd),
68
+ nn.Dropout(cfg.dropout),
69
+ )
70
+
71
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
72
+ x = x + self.attn(self.ln1(x))
73
+ x = x + self.mlp(self.ln2(x))
74
+ return x
75
+
76
+
77
+ class TinyGPT(nn.Module):
78
+ def __init__(self, cfg: TinyGPTConfig):
79
+ super().__init__()
80
+ self.cfg = cfg
81
+ self.token_embedding = nn.Embedding(cfg.vocab_size, cfg.n_embd)
82
+ self.position_embedding = nn.Embedding(cfg.block_size, cfg.n_embd)
83
+ self.drop = nn.Dropout(cfg.dropout)
84
+ self.blocks = nn.Sequential(*[Block(cfg) for _ in range(cfg.n_layer)])
85
+ self.ln_f = nn.LayerNorm(cfg.n_embd)
86
+ self.head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
87
+
88
+ # Weight tying: common in GPT-style LMs.
89
+ self.head.weight = self.token_embedding.weight
90
+ self.apply(self._init_weights)
91
+
92
+ def _init_weights(self, module: nn.Module) -> None:
93
+ if isinstance(module, nn.Linear):
94
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
95
+ if module.bias is not None:
96
+ nn.init.zeros_(module.bias)
97
+ elif isinstance(module, nn.Embedding):
98
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
99
+
100
+ def forward(self, idx: torch.Tensor, targets: torch.Tensor | None = None):
101
+ b, t = idx.shape
102
+ if t > self.cfg.block_size:
103
+ raise ValueError(f"sequence length {t} > block_size {self.cfg.block_size}")
104
+ pos = torch.arange(0, t, device=idx.device)
105
+ x = self.token_embedding(idx) + self.position_embedding(pos)[None, :, :]
106
+ x = self.drop(x)
107
+ x = self.blocks(x)
108
+ x = self.ln_f(x)
109
+ logits = self.head(x)
110
+ loss = None
111
+ if targets is not None:
112
+ loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
113
+ return logits, loss
114
+
115
+ @torch.no_grad()
116
+ def generate(self, idx: torch.Tensor, max_new_tokens: int, temperature: float = 0.8, top_k: int | None = None):
117
+ for _ in range(max_new_tokens):
118
+ idx_cond = idx[:, -self.cfg.block_size :]
119
+ logits, _ = self(idx_cond)
120
+ logits = logits[:, -1, :] / max(temperature, 1e-6)
121
+ if top_k is not None:
122
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
123
+ logits[logits < v[:, [-1]]] = -float("inf")
124
+ probs = F.softmax(logits, dim=-1)
125
+ next_idx = torch.multinomial(probs, num_samples=1)
126
+ idx = torch.cat((idx, next_idx), dim=1)
127
+ return idx
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ torch>=2.0
2
+ huggingface_hub>=0.23
sample.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate text from a tinyllm checkpoint."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ import torch
9
+
10
+ from model import TinyGPT, TinyGPTConfig
11
+
12
+
13
+ def main():
14
+ p = argparse.ArgumentParser()
15
+ p.add_argument("--ckpt", default="checkpoints/tinyllm.pt")
16
+ p.add_argument("--prompt", default="The")
17
+ p.add_argument("--tokens", type=int, default=300)
18
+ p.add_argument("--temperature", type=float, default=0.8)
19
+ p.add_argument("--top-k", type=int, default=20)
20
+ args = p.parse_args()
21
+
22
+ ckpt = torch.load(Path(args.ckpt), map_location="cpu")
23
+ cfg = TinyGPTConfig(**ckpt["config"])
24
+ model = TinyGPT(cfg)
25
+ model.load_state_dict(ckpt["model_state"])
26
+ model.eval()
27
+
28
+ stoi = ckpt["stoi"]
29
+ itos = {int(k): v for k, v in ckpt["itos"].items()}
30
+ safe_prompt = "".join(ch for ch in args.prompt if ch in stoi) or "\n"
31
+ idx = torch.tensor([[stoi[ch] for ch in safe_prompt]], dtype=torch.long)
32
+ out = model.generate(idx, max_new_tokens=args.tokens, temperature=args.temperature, top_k=args.top_k)
33
+ text = "".join(itos[int(i)] for i in out[0])
34
+ print(text)
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
train.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train a tiny character-level GPT on CPU.
3
+
4
+ This is intentionally small and educational, not production-grade.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+
13
+ import torch
14
+
15
+ from model import TinyGPT, TinyGPTConfig
16
+
17
+
18
+ def build_vocab(text: str):
19
+ chars = sorted(set(text))
20
+ stoi = {ch: i for i, ch in enumerate(chars)}
21
+ itos = {i: ch for ch, i in stoi.items()}
22
+ return chars, stoi, itos
23
+
24
+
25
+ def encode(text: str, stoi: dict[str, int]):
26
+ return [stoi[ch] for ch in text]
27
+
28
+
29
+ def get_batch(data: torch.Tensor, block_size: int, batch_size: int, device: str):
30
+ ix = torch.randint(len(data) - block_size - 1, (batch_size,))
31
+ x = torch.stack([data[i : i + block_size] for i in ix]).to(device)
32
+ y = torch.stack([data[i + 1 : i + block_size + 1] for i in ix]).to(device)
33
+ return x, y
34
+
35
+
36
+ @torch.no_grad()
37
+ def estimate_loss(model, train_data, val_data, block_size, batch_size, eval_iters, device):
38
+ out = {}
39
+ model.eval()
40
+ for split, data in [("train", train_data), ("val", val_data)]:
41
+ losses = torch.zeros(eval_iters)
42
+ for k in range(eval_iters):
43
+ xb, yb = get_batch(data, block_size, batch_size, device)
44
+ _, loss = model(xb, yb)
45
+ losses[k] = loss.item()
46
+ out[split] = losses.mean().item()
47
+ model.train()
48
+ return out
49
+
50
+
51
+ def main():
52
+ p = argparse.ArgumentParser()
53
+ p.add_argument("--data", default="data/tiny_corpus.txt")
54
+ p.add_argument("--out", default="checkpoints/tinyllm.pt")
55
+ p.add_argument("--steps", type=int, default=500)
56
+ p.add_argument("--batch-size", type=int, default=16)
57
+ p.add_argument("--block-size", type=int, default=64)
58
+ p.add_argument("--n-layer", type=int, default=2)
59
+ p.add_argument("--n-head", type=int, default=2)
60
+ p.add_argument("--n-embd", type=int, default=64)
61
+ p.add_argument("--lr", type=float, default=3e-4)
62
+ p.add_argument("--eval-interval", type=int, default=100)
63
+ p.add_argument("--eval-iters", type=int, default=10)
64
+ p.add_argument("--seed", type=int, default=1337)
65
+ args = p.parse_args()
66
+
67
+ torch.manual_seed(args.seed)
68
+ device = "cpu"
69
+
70
+ data_path = Path(args.data)
71
+ text = data_path.read_text(encoding="utf-8")
72
+ if len(text) < args.block_size + 2:
73
+ raise SystemExit("Dataset is too small for the chosen block size.")
74
+
75
+ chars, stoi, itos = build_vocab(text)
76
+ encoded = torch.tensor(encode(text, stoi), dtype=torch.long)
77
+ n = int(0.9 * len(encoded))
78
+ train_data = encoded[:n]
79
+ val_data = encoded[n:] if len(encoded[n:]) > args.block_size + 1 else encoded[:n]
80
+
81
+ cfg = TinyGPTConfig(
82
+ vocab_size=len(chars),
83
+ block_size=args.block_size,
84
+ n_layer=args.n_layer,
85
+ n_head=args.n_head,
86
+ n_embd=args.n_embd,
87
+ dropout=0.1,
88
+ )
89
+ model = TinyGPT(cfg).to(device)
90
+ params = sum(p.numel() for p in model.parameters())
91
+ print(f"chars={len(chars)} tokens={len(encoded)} params={params:,} device={device}")
92
+
93
+ optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr)
94
+ start = time.time()
95
+ last_loss = None
96
+ for step in range(args.steps + 1):
97
+ if step % args.eval_interval == 0 or step == args.steps:
98
+ losses = estimate_loss(model, train_data, val_data, args.block_size, args.batch_size, args.eval_iters, device)
99
+ print(f"step {step:5d}: train {losses['train']:.4f}, val {losses['val']:.4f}")
100
+ last_loss = losses
101
+
102
+ xb, yb = get_batch(train_data, args.block_size, args.batch_size, device)
103
+ _, loss = model(xb, yb)
104
+ optimizer.zero_grad(set_to_none=True)
105
+ loss.backward()
106
+ optimizer.step()
107
+
108
+ out_path = Path(args.out)
109
+ out_path.parent.mkdir(parents=True, exist_ok=True)
110
+ ckpt = {
111
+ "model_state": model.state_dict(),
112
+ "config": cfg.__dict__,
113
+ "stoi": stoi,
114
+ "itos": {str(k): v for k, v in itos.items()},
115
+ "train_args": vars(args),
116
+ "last_loss": last_loss,
117
+ }
118
+ torch.save(ckpt, out_path)
119
+ meta_path = out_path.with_suffix(".json")
120
+ meta_path.write_text(json.dumps({"params": params, "chars": chars, "last_loss": last_loss}, indent=2), encoding="utf-8")
121
+ print(f"saved {out_path} and {meta_path} in {time.time() - start:.1f}s")
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()