axay28 commited on
Commit
c407ed8
·
verified ·
1 Parent(s): 90492d1

Add model source

Browse files
src/tiny_transformer.egg-info/PKG-INFO ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: tiny-transformer
3
+ Version: 0.1.0
4
+ Summary: A compact GPT-style Transformer built from scratch in PyTorch.
5
+ Author: Akshay Mulgund
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy>=2.0
9
+ Requires-Dist: torch>=2.2
10
+ Requires-Dist: tqdm>=4.66
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == "dev"
13
+ Requires-Dist: ruff>=0.5; extra == "dev"
14
+
15
+ # Tiny Transformer
16
+
17
+ A compact GPT-style language model built from scratch in PyTorch. This repo is designed to show the fundamentals recruiters actually care about: clean architecture, readable math, reproducible training, tests, and an end-to-end demo path from raw text to generated tokens.
18
+
19
+ ## What Makes This Worth Looking At
20
+
21
+ - Implements a decoder-only Transformer without Hugging Face or high-level training frameworks.
22
+ - Includes causal self-attention, multi-head attention, residual blocks, layer norm, embeddings, generation, and checkpointing.
23
+ - Ships with character and byte-pair encoding tokenizers so the model can train on any plain-text file.
24
+ - Keeps the code small enough to understand in one sitting, but structured like production Python.
25
+ - Includes smoke tests for masking, shapes, tokenization, attention export, and generation behavior.
26
+
27
+ ## Quickstart
28
+
29
+ ```bash
30
+ python3 -m venv .venv
31
+ source .venv/bin/activate
32
+ python -m pip install -e ".[dev]"
33
+ ```
34
+
35
+ Train on the included sample text:
36
+
37
+ ```bash
38
+ tiny-transformer train --data data/tiny_shakespeare_excerpt.txt --steps 300 --device cpu
39
+ ```
40
+
41
+ Use the optional BPE tokenizer, gradient accumulation, and mixed precision when you want a stronger local run:
42
+
43
+ ```bash
44
+ tiny-transformer train \
45
+ --data data/tiny_shakespeare_excerpt.txt \
46
+ --tokenizer bpe \
47
+ --bpe-vocab-size 128 \
48
+ --grad-accum-steps 4 \
49
+ --amp \
50
+ --device mps
51
+ ```
52
+
53
+ Generate text from a checkpoint:
54
+
55
+ ```bash
56
+ tiny-transformer generate --checkpoint runs/tiny-transformer.pt --prompt "To be" --max-new-tokens 160
57
+ ```
58
+
59
+ Export an attention heatmap:
60
+
61
+ ```bash
62
+ tiny-transformer attention --checkpoint runs/tiny-transformer.pt --prompt "To be" --output runs/attention.svg
63
+ ```
64
+
65
+ Launch the local playground:
66
+
67
+ ```bash
68
+ tiny-transformer serve --checkpoint runs/tiny-transformer.pt
69
+ ```
70
+
71
+ Deploy the hosted playground:
72
+
73
+ ```bash
74
+ pip install huggingface_hub
75
+ huggingface-cli login
76
+ huggingface-cli repo create tiny-transformer --type space --space_sdk gradio
77
+ git remote add space https://huggingface.co/spaces/axay28/tiny-transformer
78
+ git push space main
79
+ ```
80
+
81
+ Run tests:
82
+
83
+ ```bash
84
+ pytest
85
+ ```
86
+
87
+ ## Project Layout
88
+
89
+ ```text
90
+ src/tiny_transformer/
91
+ cli.py Command line interface for training and generation
92
+ config.py Model and training configuration
93
+ data.py Text dataset and batching utilities
94
+ model.py GPT-style Transformer implementation
95
+ tokenizer.py Character-level tokenizer
96
+ train.py Training loop, evaluation, checkpointing
97
+ visualize.py Attention heatmap export
98
+ web.py Local generation playground
99
+ tests/ Unit and smoke tests
100
+ data/ Tiny sample corpus
101
+ ```
102
+
103
+ ## Architecture
104
+
105
+ The model is intentionally small, but it follows the same structure as larger decoder-only LLMs:
106
+
107
+ 1. Token and positional embeddings convert IDs into vectors.
108
+ 2. Each Transformer block applies pre-norm causal self-attention.
109
+ 3. Feed-forward layers expand and compress the hidden dimension.
110
+ 4. Residual connections preserve gradient flow.
111
+ 5. A tied-size language modeling head predicts the next token.
112
+
113
+ The attention mask is causal, so each position can only attend to itself and previous positions.
114
+
115
+ ```mermaid
116
+ flowchart LR
117
+ A["Raw text corpus"] --> B["Char or BPE tokenizer"]
118
+ B --> C["Token IDs"]
119
+ C --> D["Contiguous train/val batches"]
120
+ D --> E["Token + position embeddings"]
121
+ E --> F1["LayerNorm"]
122
+ F1 --> F2["Masked multi-head self-attention"]
123
+ F2 --> F3["Residual add"]
124
+ F3 --> F4["LayerNorm"]
125
+ F4 --> F5["Feed-forward MLP"]
126
+ F5 --> F6["Residual add"]
127
+ F6 --> F7["Repeat for N layers"]
128
+ F7 --> G["Final layer norm"]
129
+ G --> H["Language modeling head"]
130
+ H --> I["Next-token logits"]
131
+ I --> J["Cross-entropy loss during training"]
132
+ I --> K["Top-k sampling during generation"]
133
+ F2 --> L["Attention heatmap export"]
134
+ K --> M["Local web playground"]
135
+ ```
136
+
137
+ ## Example Configuration
138
+
139
+ The CLI defaults train quickly on CPU. For the included tiny corpus, the command uses a
140
+ 32-token context window; for larger text files, 128 tokens is a good next step:
141
+
142
+ ```python
143
+ ModelConfig(
144
+ vocab_size=128,
145
+ block_size=128,
146
+ n_layer=4,
147
+ n_head=4,
148
+ n_embd=128,
149
+ dropout=0.1,
150
+ )
151
+ ```
152
+
153
+ Increase `n_layer`, `n_head`, and `n_embd` for a stronger demo once the training loop is validated.
src/tiny_transformer.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ src/tiny_transformer/__init__.py
4
+ src/tiny_transformer/cli.py
5
+ src/tiny_transformer/config.py
6
+ src/tiny_transformer/data.py
7
+ src/tiny_transformer/model.py
8
+ src/tiny_transformer/tokenizer.py
9
+ src/tiny_transformer/train.py
10
+ src/tiny_transformer/visualize.py
11
+ src/tiny_transformer/web.py
12
+ src/tiny_transformer.egg-info/PKG-INFO
13
+ src/tiny_transformer.egg-info/SOURCES.txt
14
+ src/tiny_transformer.egg-info/dependency_links.txt
15
+ src/tiny_transformer.egg-info/entry_points.txt
16
+ src/tiny_transformer.egg-info/requires.txt
17
+ src/tiny_transformer.egg-info/top_level.txt
18
+ tests/test_model.py
19
+ tests/test_tokenizer.py
src/tiny_transformer.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
src/tiny_transformer.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ tiny-transformer = tiny_transformer.cli:main
src/tiny_transformer.egg-info/requires.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ numpy>=2.0
2
+ torch>=2.2
3
+ tqdm>=4.66
4
+
5
+ [dev]
6
+ pytest>=8.0
7
+ ruff>=0.5
src/tiny_transformer.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ tiny_transformer
src/tiny_transformer/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny Transformer: a minimal GPT-style language model."""
2
+
3
+ from tiny_transformer.config import ModelConfig, TrainConfig
4
+ from tiny_transformer.tokenizer import BytePairTokenizer, CharTokenizer
5
+
6
+ __all__ = ["BytePairTokenizer", "CharTokenizer", "ModelConfig", "TinyTransformer", "TrainConfig"]
7
+
8
+
9
+ def __getattr__(name: str):
10
+ if name == "TinyTransformer":
11
+ from tiny_transformer.model import TinyTransformer
12
+
13
+ return TinyTransformer
14
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
src/tiny_transformer/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (839 Bytes). View file
 
src/tiny_transformer/__pycache__/cli.cpython-313.pyc ADDED
Binary file (8.03 kB). View file
 
src/tiny_transformer/__pycache__/config.cpython-313.pyc ADDED
Binary file (2.59 kB). View file
 
src/tiny_transformer/__pycache__/data.cpython-313.pyc ADDED
Binary file (2.32 kB). View file
 
src/tiny_transformer/__pycache__/model.cpython-313.pyc ADDED
Binary file (12.1 kB). View file
 
src/tiny_transformer/__pycache__/tokenizer.cpython-313.pyc ADDED
Binary file (11.6 kB). View file
 
src/tiny_transformer/__pycache__/train.cpython-313.pyc ADDED
Binary file (6.78 kB). View file
 
src/tiny_transformer/__pycache__/visualize.cpython-313.pyc ADDED
Binary file (4.8 kB). View file
 
src/tiny_transformer/__pycache__/web.cpython-313.pyc ADDED
Binary file (7.16 kB). View file
 
src/tiny_transformer/cli.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import torch
7
+
8
+ from tiny_transformer.config import ModelConfig, TrainConfig
9
+ from tiny_transformer.train import load_checkpoint, train_from_text
10
+ from tiny_transformer.visualize import save_attention_heatmap
11
+ from tiny_transformer.web import serve_playground
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(description="Train and sample a tiny GPT-style Transformer.")
16
+ subparsers = parser.add_subparsers(dest="command", required=True)
17
+
18
+ train = subparsers.add_parser("train", help="Train a model on a plain-text corpus.")
19
+ train.add_argument("--data", required=True, help="Path to a UTF-8 text file.")
20
+ train.add_argument("--output", default="runs/tiny-transformer.pt", help="Checkpoint path.")
21
+ train.add_argument("--device", default="cpu", help="Device such as cpu, cuda, or mps.")
22
+ train.add_argument("--steps", type=int, default=1_000)
23
+ train.add_argument("--batch-size", type=int, default=32)
24
+ train.add_argument("--block-size", type=int, default=32)
25
+ train.add_argument("--layers", type=int, default=4)
26
+ train.add_argument("--heads", type=int, default=4)
27
+ train.add_argument("--embedding", type=int, default=128)
28
+ train.add_argument("--dropout", type=float, default=0.1)
29
+ train.add_argument("--learning-rate", type=float, default=3e-4)
30
+ train.add_argument("--tokenizer", choices=["char", "bpe"], default="char")
31
+ train.add_argument("--bpe-vocab-size", type=int, default=256)
32
+ train.add_argument("--grad-accum-steps", type=int, default=1)
33
+ train.add_argument("--amp", action="store_true", help="Use mixed precision on CUDA or MPS.")
34
+
35
+ generate = subparsers.add_parser("generate", help="Generate text from a trained checkpoint.")
36
+ generate.add_argument("--checkpoint", required=True, help="Path to a model checkpoint.")
37
+ generate.add_argument("--prompt", default="\n", help="Prompt text.")
38
+ generate.add_argument("--device", default="cpu")
39
+ generate.add_argument("--max-new-tokens", type=int, default=200)
40
+ generate.add_argument("--temperature", type=float, default=0.8)
41
+ generate.add_argument("--top-k", type=int, default=20)
42
+
43
+ attention = subparsers.add_parser("attention", help="Export an attention heatmap SVG.")
44
+ attention.add_argument("--checkpoint", required=True, help="Path to a model checkpoint.")
45
+ attention.add_argument("--prompt", required=True, help="Prompt text to inspect.")
46
+ attention.add_argument("--output", default="runs/attention.svg", help="SVG output path.")
47
+ attention.add_argument("--device", default="cpu")
48
+ attention.add_argument("--layer", type=int, default=-1, help="Layer index to visualize.")
49
+ attention.add_argument("--head", type=int, default=0, help="Attention head index to visualize.")
50
+
51
+ serve = subparsers.add_parser("serve", help="Launch a local text-generation playground.")
52
+ serve.add_argument("--checkpoint", required=True, help="Path to a model checkpoint.")
53
+ serve.add_argument("--host", default="127.0.0.1")
54
+ serve.add_argument("--port", type=int, default=8000)
55
+ serve.add_argument("--device", default="cpu")
56
+
57
+ return parser
58
+
59
+
60
+ def train_command(args: argparse.Namespace) -> None:
61
+ text = Path(args.data).read_text(encoding="utf-8")
62
+ train_config = TrainConfig(
63
+ batch_size=args.batch_size,
64
+ learning_rate=args.learning_rate,
65
+ max_steps=args.steps,
66
+ grad_accum_steps=args.grad_accum_steps,
67
+ use_amp=args.amp,
68
+ output_path=args.output,
69
+ )
70
+ model_config = ModelConfig(
71
+ vocab_size=1,
72
+ block_size=args.block_size,
73
+ n_layer=args.layers,
74
+ n_head=args.heads,
75
+ n_embd=args.embedding,
76
+ dropout=args.dropout,
77
+ )
78
+ train_from_text(
79
+ text,
80
+ model_config=model_config,
81
+ train_config=train_config,
82
+ device=args.device,
83
+ tokenizer_name=args.tokenizer,
84
+ bpe_vocab_size=args.bpe_vocab_size,
85
+ )
86
+ print(f"Saved checkpoint to {args.output}")
87
+
88
+
89
+ def generate_command(args: argparse.Namespace) -> None:
90
+ model, tokenizer = load_checkpoint(args.checkpoint, device=args.device)
91
+ encoded = tokenizer.encode(args.prompt)
92
+ idx = torch.tensor([encoded], dtype=torch.long, device=args.device)
93
+ out = model.generate(
94
+ idx,
95
+ max_new_tokens=args.max_new_tokens,
96
+ temperature=args.temperature,
97
+ top_k=args.top_k,
98
+ )
99
+ print(tokenizer.decode(out[0].tolist()))
100
+
101
+
102
+ def attention_command(args: argparse.Namespace) -> None:
103
+ model, tokenizer = load_checkpoint(args.checkpoint, device=args.device)
104
+ encoded = tokenizer.encode(args.prompt)
105
+ idx = torch.tensor([encoded], dtype=torch.long, device=args.device)
106
+ save_attention_heatmap(
107
+ model=model,
108
+ tokenizer=tokenizer,
109
+ idx=idx,
110
+ output_path=args.output,
111
+ layer=args.layer,
112
+ head=args.head,
113
+ )
114
+ print(f"Saved attention heatmap to {args.output}")
115
+
116
+
117
+ def serve_command(args: argparse.Namespace) -> None:
118
+ serve_playground(
119
+ checkpoint=args.checkpoint,
120
+ host=args.host,
121
+ port=args.port,
122
+ device=args.device,
123
+ )
124
+
125
+
126
+ def main() -> None:
127
+ parser = build_parser()
128
+ args = parser.parse_args()
129
+ if args.command == "train":
130
+ train_command(args)
131
+ elif args.command == "generate":
132
+ generate_command(args)
133
+ elif args.command == "attention":
134
+ attention_command(args)
135
+ elif args.command == "serve":
136
+ serve_command(args)
137
+ else:
138
+ parser.error(f"Unknown command: {args.command}")
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
src/tiny_transformer/config.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class ModelConfig:
8
+ vocab_size: int
9
+ block_size: int = 128
10
+ n_layer: int = 4
11
+ n_head: int = 4
12
+ n_embd: int = 128
13
+ dropout: float = 0.1
14
+
15
+ def __post_init__(self) -> None:
16
+ if self.n_embd % self.n_head != 0:
17
+ raise ValueError("n_embd must be divisible by n_head")
18
+ if self.vocab_size <= 0:
19
+ raise ValueError("vocab_size must be positive")
20
+
21
+ def to_dict(self) -> dict[str, int | float]:
22
+ return asdict(self)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class TrainConfig:
27
+ batch_size: int = 32
28
+ learning_rate: float = 3e-4
29
+ max_steps: int = 1_000
30
+ eval_interval: int = 100
31
+ eval_batches: int = 20
32
+ grad_accum_steps: int = 1
33
+ use_amp: bool = False
34
+ seed: int = 1337
35
+ output_path: str = "runs/tiny-transformer.pt"
36
+
37
+ def __post_init__(self) -> None:
38
+ if self.grad_accum_steps <= 0:
39
+ raise ValueError("grad_accum_steps must be positive")
40
+
41
+ def to_dict(self) -> dict[str, bool | int | float | str]:
42
+ return asdict(self)
src/tiny_transformer/data.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+
5
+
6
+ class TextDataset:
7
+ def __init__(self, token_ids: list[int], block_size: int, device: str) -> None:
8
+ if len(token_ids) <= block_size:
9
+ raise ValueError("Dataset must contain more tokens than block_size")
10
+ self.data = torch.tensor(token_ids, dtype=torch.long, device=device)
11
+ self.block_size = block_size
12
+ self.device = device
13
+
14
+ def get_batch(self, batch_size: int) -> tuple[torch.Tensor, torch.Tensor]:
15
+ max_start = len(self.data) - self.block_size
16
+ starts = torch.randint(0, max_start, (batch_size,), device=self.device)
17
+ x = torch.stack([self.data[start : start + self.block_size] for start in starts])
18
+ y = torch.stack([self.data[start + 1 : start + self.block_size + 1] for start in starts])
19
+ return x, y
20
+
21
+
22
+ def split_tokens(token_ids: list[int], train_fraction: float = 0.9) -> tuple[list[int], list[int]]:
23
+ if not 0.0 < train_fraction < 1.0:
24
+ raise ValueError("train_fraction must be between 0 and 1")
25
+ split_idx = int(len(token_ids) * train_fraction)
26
+ return token_ids[:split_idx], token_ids[split_idx:]
src/tiny_transformer/model.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import torch
6
+ from torch import nn
7
+ from torch.nn import functional as F
8
+
9
+ from tiny_transformer.config import ModelConfig
10
+
11
+
12
+ class CausalSelfAttention(nn.Module):
13
+ def __init__(self, config: ModelConfig) -> None:
14
+ super().__init__()
15
+ self.n_head = config.n_head
16
+ self.head_dim = config.n_embd // config.n_head
17
+ self.qkv = nn.Linear(config.n_embd, 3 * config.n_embd)
18
+ self.proj = nn.Linear(config.n_embd, config.n_embd)
19
+ self.attn_dropout = nn.Dropout(config.dropout)
20
+ self.resid_dropout = nn.Dropout(config.dropout)
21
+ mask = torch.tril(torch.ones(config.block_size, config.block_size))
22
+ self.register_buffer("causal_mask", mask.view(1, 1, config.block_size, config.block_size))
23
+
24
+ def forward(
25
+ self, x: torch.Tensor, return_attention: bool = False
26
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
27
+ batch, seq_len, channels = x.shape
28
+ qkv = self.qkv(x)
29
+ query, key, value = qkv.split(channels, dim=2)
30
+
31
+ query = query.view(batch, seq_len, self.n_head, self.head_dim).transpose(1, 2)
32
+ key = key.view(batch, seq_len, self.n_head, self.head_dim).transpose(1, 2)
33
+ value = value.view(batch, seq_len, self.n_head, self.head_dim).transpose(1, 2)
34
+
35
+ scores = query @ key.transpose(-2, -1) / math.sqrt(self.head_dim)
36
+ scores = scores.masked_fill(self.causal_mask[:, :, :seq_len, :seq_len] == 0, float("-inf"))
37
+ weights = F.softmax(scores, dim=-1)
38
+ weights = self.attn_dropout(weights)
39
+ out = weights @ value
40
+ out = out.transpose(1, 2).contiguous().view(batch, seq_len, channels)
41
+ out = self.resid_dropout(self.proj(out))
42
+ if return_attention:
43
+ return out, weights
44
+ return out
45
+
46
+
47
+ class FeedForward(nn.Module):
48
+ def __init__(self, config: ModelConfig) -> None:
49
+ super().__init__()
50
+ self.net = nn.Sequential(
51
+ nn.Linear(config.n_embd, 4 * config.n_embd),
52
+ nn.GELU(),
53
+ nn.Linear(4 * config.n_embd, config.n_embd),
54
+ nn.Dropout(config.dropout),
55
+ )
56
+
57
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
58
+ return self.net(x)
59
+
60
+
61
+ class TransformerBlock(nn.Module):
62
+ def __init__(self, config: ModelConfig) -> None:
63
+ super().__init__()
64
+ self.ln_1 = nn.LayerNorm(config.n_embd)
65
+ self.attn = CausalSelfAttention(config)
66
+ self.ln_2 = nn.LayerNorm(config.n_embd)
67
+ self.ffwd = FeedForward(config)
68
+
69
+ def forward(
70
+ self, x: torch.Tensor, return_attention: bool = False
71
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
72
+ if return_attention:
73
+ attn_out, weights = self.attn(self.ln_1(x), return_attention=True)
74
+ x = x + attn_out
75
+ x = x + self.ffwd(self.ln_2(x))
76
+ return x, weights
77
+
78
+ x = x + self.attn(self.ln_1(x))
79
+ x = x + self.ffwd(self.ln_2(x))
80
+ return x
81
+
82
+
83
+ class TinyTransformer(nn.Module):
84
+ def __init__(self, config: ModelConfig) -> None:
85
+ super().__init__()
86
+ self.config = config
87
+ self.token_embedding = nn.Embedding(config.vocab_size, config.n_embd)
88
+ self.position_embedding = nn.Embedding(config.block_size, config.n_embd)
89
+ self.dropout = nn.Dropout(config.dropout)
90
+ self.blocks = nn.ModuleList([TransformerBlock(config) for _ in range(config.n_layer)])
91
+ self.ln_f = nn.LayerNorm(config.n_embd)
92
+ self.lm_head = nn.Linear(config.n_embd, config.vocab_size)
93
+ self.apply(self._init_weights)
94
+
95
+ def _init_weights(self, module: nn.Module) -> None:
96
+ if isinstance(module, nn.Linear):
97
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
98
+ if module.bias is not None:
99
+ nn.init.zeros_(module.bias)
100
+ elif isinstance(module, nn.Embedding):
101
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
102
+
103
+ def forward(
104
+ self, idx: torch.Tensor, targets: torch.Tensor | None = None
105
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
106
+ logits, loss, _ = self._forward(idx, targets, capture_attention=False)
107
+ return logits, loss
108
+
109
+ def _forward(
110
+ self,
111
+ idx: torch.Tensor,
112
+ targets: torch.Tensor | None = None,
113
+ capture_attention: bool = False,
114
+ ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor]]:
115
+ batch, seq_len = idx.shape
116
+ if seq_len > self.config.block_size:
117
+ raise ValueError("Sequence length exceeds block_size")
118
+
119
+ attentions: list[torch.Tensor] = []
120
+ positions = torch.arange(seq_len, device=idx.device)
121
+ x = self.token_embedding(idx) + self.position_embedding(positions)
122
+ x = self.dropout(x)
123
+ for block in self.blocks:
124
+ if capture_attention:
125
+ x, weights = block(x, return_attention=True)
126
+ attentions.append(weights)
127
+ else:
128
+ x = block(x)
129
+ x = self.ln_f(x)
130
+ logits = self.lm_head(x)
131
+
132
+ loss = None
133
+ if targets is not None:
134
+ loss = F.cross_entropy(logits.view(batch * seq_len, -1), targets.view(batch * seq_len))
135
+ return logits, loss, attentions
136
+
137
+ @torch.no_grad()
138
+ def attention_maps(self, idx: torch.Tensor) -> list[torch.Tensor]:
139
+ self.eval()
140
+ _, _, attentions = self._forward(idx, capture_attention=True)
141
+ return attentions
142
+
143
+ @torch.no_grad()
144
+ def generate(
145
+ self,
146
+ idx: torch.Tensor,
147
+ max_new_tokens: int,
148
+ temperature: float = 1.0,
149
+ top_k: int | None = None,
150
+ ) -> torch.Tensor:
151
+ if temperature <= 0:
152
+ raise ValueError("temperature must be positive")
153
+
154
+ for _ in range(max_new_tokens):
155
+ idx_cond = idx[:, -self.config.block_size :]
156
+ logits, _ = self(idx_cond)
157
+ logits = logits[:, -1, :] / temperature
158
+ if top_k is not None:
159
+ values, _ = torch.topk(logits, min(top_k, logits.size(-1)))
160
+ logits[logits < values[:, [-1]]] = -float("inf")
161
+ probs = F.softmax(logits, dim=-1)
162
+ next_idx = torch.multinomial(probs, num_samples=1)
163
+ idx = torch.cat((idx, next_idx), dim=1)
164
+ return idx
src/tiny_transformer/tokenizer.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from dataclasses import dataclass
5
+ from typing import Protocol
6
+
7
+
8
+ class Tokenizer(Protocol):
9
+ @property
10
+ def vocab_size(self) -> int: ...
11
+
12
+ def encode(self, text: str) -> list[int]: ...
13
+
14
+ def decode(self, ids: list[int]) -> str: ...
15
+
16
+ def id_to_token(self, idx: int) -> str: ...
17
+
18
+ def to_dict(self) -> dict[str, object]: ...
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class CharTokenizer:
23
+ stoi: dict[str, int]
24
+ itos: dict[int, str]
25
+
26
+ @classmethod
27
+ def train(cls, text: str) -> "CharTokenizer":
28
+ if not text:
29
+ raise ValueError("Cannot train a tokenizer on empty text")
30
+ chars = sorted(set(text))
31
+ stoi = {char: idx for idx, char in enumerate(chars)}
32
+ itos = {idx: char for char, idx in stoi.items()}
33
+ return cls(stoi=stoi, itos=itos)
34
+
35
+ @property
36
+ def vocab_size(self) -> int:
37
+ return len(self.stoi)
38
+
39
+ def encode(self, text: str) -> list[int]:
40
+ try:
41
+ return [self.stoi[char] for char in text]
42
+ except KeyError as exc:
43
+ char = exc.args[0]
44
+ raise ValueError(f"Character {char!r} is not in the tokenizer vocabulary") from exc
45
+
46
+ def decode(self, ids: list[int]) -> str:
47
+ try:
48
+ return "".join(self.itos[idx] for idx in ids)
49
+ except KeyError as exc:
50
+ idx = exc.args[0]
51
+ raise ValueError(f"Token id {idx!r} is not in the tokenizer vocabulary") from exc
52
+
53
+ def id_to_token(self, idx: int) -> str:
54
+ try:
55
+ return self.itos[idx]
56
+ except KeyError as exc:
57
+ raise ValueError(f"Token id {idx!r} is not in the tokenizer vocabulary") from exc
58
+
59
+ def to_dict(self) -> dict[str, object]:
60
+ return {"type": "char", "stoi": self.stoi, "itos": self.itos}
61
+
62
+ @classmethod
63
+ def from_dict(cls, payload: dict[str, dict[str, int] | dict[int | str, str]]) -> "CharTokenizer":
64
+ stoi = {str(char): int(idx) for char, idx in payload["stoi"].items()}
65
+ itos = {int(idx): str(char) for idx, char in payload["itos"].items()}
66
+ return cls(stoi=stoi, itos=itos)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class BytePairTokenizer:
71
+ stoi: dict[str, int]
72
+ itos: dict[int, str]
73
+ merges: list[tuple[str, str]]
74
+
75
+ @classmethod
76
+ def train(cls, text: str, vocab_size: int = 256) -> "BytePairTokenizer":
77
+ if not text:
78
+ raise ValueError("Cannot train a tokenizer on empty text")
79
+ if vocab_size <= 0:
80
+ raise ValueError("vocab_size must be positive")
81
+
82
+ base_vocab = sorted(set(text))
83
+ sequences = [[char for char in text]]
84
+ merges: list[tuple[str, str]] = []
85
+ vocab = set(base_vocab)
86
+
87
+ while len(vocab) < vocab_size:
88
+ pair_counts = _count_pairs(sequences)
89
+ if not pair_counts:
90
+ break
91
+ pair, count = pair_counts.most_common(1)[0]
92
+ merged = "".join(pair)
93
+ if count < 2 or merged in vocab:
94
+ break
95
+ sequences = [_merge_pair(sequence, pair, merged) for sequence in sequences]
96
+ merges.append(pair)
97
+ vocab.add(merged)
98
+
99
+ tokens = sorted(vocab, key=lambda token: (len(token), token))
100
+ stoi = {token: idx for idx, token in enumerate(tokens)}
101
+ itos = {idx: token for token, idx in stoi.items()}
102
+ return cls(stoi=stoi, itos=itos, merges=merges)
103
+
104
+ @property
105
+ def vocab_size(self) -> int:
106
+ return len(self.stoi)
107
+
108
+ def encode(self, text: str) -> list[int]:
109
+ unknown = sorted(set(text) - {token for token in self.stoi if len(token) == 1})
110
+ if unknown:
111
+ raise ValueError(f"Characters {unknown!r} are not in the tokenizer vocabulary")
112
+
113
+ pieces = list(text)
114
+ for left, right in self.merges:
115
+ pieces = _merge_pair(pieces, (left, right), left + right)
116
+ return [self.stoi[piece] for piece in pieces]
117
+
118
+ def decode(self, ids: list[int]) -> str:
119
+ try:
120
+ return "".join(self.itos[idx] for idx in ids)
121
+ except KeyError as exc:
122
+ idx = exc.args[0]
123
+ raise ValueError(f"Token id {idx!r} is not in the tokenizer vocabulary") from exc
124
+
125
+ def id_to_token(self, idx: int) -> str:
126
+ try:
127
+ return self.itos[idx]
128
+ except KeyError as exc:
129
+ raise ValueError(f"Token id {idx!r} is not in the tokenizer vocabulary") from exc
130
+
131
+ def to_dict(self) -> dict[str, object]:
132
+ return {
133
+ "type": "bpe",
134
+ "stoi": self.stoi,
135
+ "itos": self.itos,
136
+ "merges": self.merges,
137
+ }
138
+
139
+ @classmethod
140
+ def from_dict(cls, payload: dict[str, object]) -> "BytePairTokenizer":
141
+ raw_stoi = payload["stoi"]
142
+ raw_itos = payload["itos"]
143
+ raw_merges = payload["merges"]
144
+ if not isinstance(raw_stoi, dict) or not isinstance(raw_itos, dict):
145
+ raise ValueError("Invalid BPE tokenizer payload")
146
+ if not isinstance(raw_merges, list):
147
+ raise ValueError("Invalid BPE merge payload")
148
+ stoi = {str(token): int(idx) for token, idx in raw_stoi.items()}
149
+ itos = {int(idx): str(token) for idx, token in raw_itos.items()}
150
+ merges = [(str(left), str(right)) for left, right in raw_merges]
151
+ return cls(stoi=stoi, itos=itos, merges=merges)
152
+
153
+
154
+ def tokenizer_from_dict(payload: dict[str, object]) -> Tokenizer:
155
+ tokenizer_type = payload.get("type", "char")
156
+ if tokenizer_type == "char":
157
+ return CharTokenizer.from_dict(payload) # type: ignore[arg-type]
158
+ if tokenizer_type == "bpe":
159
+ return BytePairTokenizer.from_dict(payload)
160
+ raise ValueError(f"Unknown tokenizer type: {tokenizer_type!r}")
161
+
162
+
163
+ def _count_pairs(sequences: list[list[str]]) -> Counter[tuple[str, str]]:
164
+ counts: Counter[tuple[str, str]] = Counter()
165
+ for sequence in sequences:
166
+ counts.update(zip(sequence, sequence[1:]))
167
+ return counts
168
+
169
+
170
+ def _merge_pair(sequence: list[str], pair: tuple[str, str], merged: str) -> list[str]:
171
+ out: list[str] = []
172
+ idx = 0
173
+ while idx < len(sequence):
174
+ if idx < len(sequence) - 1 and (sequence[idx], sequence[idx + 1]) == pair:
175
+ out.append(merged)
176
+ idx += 2
177
+ else:
178
+ out.append(sequence[idx])
179
+ idx += 1
180
+ return out
src/tiny_transformer/train.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import torch
6
+ from tqdm import trange
7
+
8
+ from tiny_transformer.config import ModelConfig, TrainConfig
9
+ from tiny_transformer.data import TextDataset, split_tokens
10
+ from tiny_transformer.model import TinyTransformer
11
+ from tiny_transformer.tokenizer import BytePairTokenizer, CharTokenizer, Tokenizer, tokenizer_from_dict
12
+
13
+
14
+ @torch.no_grad()
15
+ def estimate_loss(
16
+ model: TinyTransformer,
17
+ train_data: TextDataset,
18
+ val_data: TextDataset,
19
+ batch_size: int,
20
+ eval_batches: int,
21
+ ) -> dict[str, float]:
22
+ model.eval()
23
+ losses: dict[str, float] = {}
24
+ for split, dataset in {"train": train_data, "val": val_data}.items():
25
+ split_losses = []
26
+ for _ in range(eval_batches):
27
+ x, y = dataset.get_batch(batch_size)
28
+ _, loss = model(x, y)
29
+ if loss is None:
30
+ raise RuntimeError("Expected a loss during evaluation")
31
+ split_losses.append(loss.item())
32
+ losses[split] = sum(split_losses) / len(split_losses)
33
+ model.train()
34
+ return losses
35
+
36
+
37
+ def train_from_text(
38
+ text: str,
39
+ model_config: ModelConfig | None = None,
40
+ train_config: TrainConfig | None = None,
41
+ device: str = "cpu",
42
+ tokenizer_name: str = "char",
43
+ bpe_vocab_size: int = 256,
44
+ ) -> TinyTransformer:
45
+ train_config = train_config or TrainConfig()
46
+ torch.manual_seed(train_config.seed)
47
+
48
+ tokenizer = train_tokenizer(text, tokenizer_name, bpe_vocab_size)
49
+ token_ids = tokenizer.encode(text)
50
+ train_ids, val_ids = split_tokens(token_ids)
51
+
52
+ if model_config is None:
53
+ model_config = ModelConfig(vocab_size=tokenizer.vocab_size)
54
+ else:
55
+ model_config = ModelConfig(**{**model_config.to_dict(), "vocab_size": tokenizer.vocab_size})
56
+
57
+ train_data = TextDataset(train_ids, model_config.block_size, device)
58
+ val_data = TextDataset(val_ids, model_config.block_size, device)
59
+ model = TinyTransformer(model_config).to(device)
60
+ optimizer = torch.optim.AdamW(model.parameters(), lr=train_config.learning_rate)
61
+ device_type = "cuda" if device.startswith("cuda") else "mps" if device == "mps" else "cpu"
62
+ amp_enabled = train_config.use_amp and device_type in {"cuda", "mps"}
63
+ scaler = torch.amp.GradScaler("cuda", enabled=amp_enabled and device_type == "cuda")
64
+
65
+ progress = trange(train_config.max_steps, desc="training", leave=True)
66
+ for step in progress:
67
+ if step % train_config.eval_interval == 0 or step == train_config.max_steps - 1:
68
+ losses = estimate_loss(
69
+ model, train_data, val_data, train_config.batch_size, train_config.eval_batches
70
+ )
71
+ progress.set_postfix(train=f"{losses['train']:.3f}", val=f"{losses['val']:.3f}")
72
+
73
+ optimizer.zero_grad(set_to_none=True)
74
+ for _ in range(train_config.grad_accum_steps):
75
+ x, y = train_data.get_batch(train_config.batch_size)
76
+ with torch.autocast(device_type=device_type, enabled=amp_enabled):
77
+ _, loss = model(x, y)
78
+ if loss is None:
79
+ raise RuntimeError("Expected a loss during training")
80
+ loss = loss / train_config.grad_accum_steps
81
+ scaler.scale(loss).backward()
82
+ scaler.step(optimizer)
83
+ scaler.update()
84
+
85
+ save_checkpoint(model, tokenizer, train_config.output_path)
86
+ return model
87
+
88
+
89
+ def train_tokenizer(text: str, tokenizer_name: str, bpe_vocab_size: int) -> Tokenizer:
90
+ if tokenizer_name == "char":
91
+ return CharTokenizer.train(text)
92
+ if tokenizer_name == "bpe":
93
+ return BytePairTokenizer.train(text, vocab_size=bpe_vocab_size)
94
+ raise ValueError("tokenizer_name must be 'char' or 'bpe'")
95
+
96
+
97
+ def save_checkpoint(model: TinyTransformer, tokenizer: Tokenizer, path: str) -> None:
98
+ output = Path(path)
99
+ output.parent.mkdir(parents=True, exist_ok=True)
100
+ torch.save(
101
+ {
102
+ "model_config": model.config.to_dict(),
103
+ "model_state": model.state_dict(),
104
+ "tokenizer": tokenizer.to_dict(),
105
+ },
106
+ output,
107
+ )
108
+
109
+
110
+ def load_checkpoint(path: str, device: str = "cpu") -> tuple[TinyTransformer, Tokenizer]:
111
+ payload = torch.load(path, map_location=device)
112
+ tokenizer = tokenizer_from_dict(payload["tokenizer"])
113
+ config = ModelConfig(**payload["model_config"])
114
+ model = TinyTransformer(config).to(device)
115
+ model.load_state_dict(payload["model_state"])
116
+ model.eval()
117
+ return model, tokenizer
src/tiny_transformer/visualize.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from html import escape
4
+ from pathlib import Path
5
+
6
+ import torch
7
+
8
+ from tiny_transformer.model import TinyTransformer
9
+ from tiny_transformer.tokenizer import Tokenizer
10
+
11
+
12
+ @torch.no_grad()
13
+ def save_attention_heatmap(
14
+ model: TinyTransformer,
15
+ tokenizer: Tokenizer,
16
+ idx: torch.Tensor,
17
+ output_path: str,
18
+ layer: int = -1,
19
+ head: int = 0,
20
+ ) -> None:
21
+ attentions = model.attention_maps(idx)
22
+ if not attentions:
23
+ raise ValueError("Model did not return attention maps")
24
+
25
+ selected = attentions[layer][0]
26
+ if head < 0 or head >= selected.shape[0]:
27
+ raise ValueError(f"head must be between 0 and {selected.shape[0] - 1}")
28
+
29
+ weights = selected[head].detach().cpu()
30
+ token_ids = idx[0].detach().cpu().tolist()
31
+ labels = [_display_token(tokenizer.id_to_token(token_id)) for token_id in token_ids]
32
+ svg = _attention_svg(weights, labels, layer=layer, head=head)
33
+
34
+ output = Path(output_path)
35
+ output.parent.mkdir(parents=True, exist_ok=True)
36
+ output.write_text(svg, encoding="utf-8")
37
+
38
+
39
+ def _attention_svg(weights: torch.Tensor, labels: list[str], layer: int, head: int) -> str:
40
+ cell = 24
41
+ margin_left = 120
42
+ margin_top = 96
43
+ size = len(labels)
44
+ width = margin_left + size * cell + 24
45
+ height = margin_top + size * cell + 40
46
+ max_weight = max(float(weights.max()), 1e-9)
47
+
48
+ cells = []
49
+ for row in range(size):
50
+ for col in range(size):
51
+ value = float(weights[row, col]) / max_weight
52
+ color = 255 - int(value * 210)
53
+ cells.append(
54
+ f'<rect x="{margin_left + col * cell}" y="{margin_top + row * cell}" '
55
+ f'width="{cell}" height="{cell}" fill="rgb({color},{color},255)">'
56
+ f"<title>{escape(labels[row])} attends to {escape(labels[col])}: "
57
+ f"{float(weights[row, col]):.3f}</title></rect>"
58
+ )
59
+
60
+ x_labels = [
61
+ f'<text x="{margin_left + idx * cell + 12}" y="{margin_top - 10}" '
62
+ f'transform="rotate(-45 {margin_left + idx * cell + 12},{margin_top - 10})">'
63
+ f"{escape(label)}</text>"
64
+ for idx, label in enumerate(labels)
65
+ ]
66
+ y_labels = [
67
+ f'<text x="{margin_left - 8}" y="{margin_top + idx * cell + 16}" text-anchor="end">'
68
+ f"{escape(label)}</text>"
69
+ for idx, label in enumerate(labels)
70
+ ]
71
+
72
+ return "\n".join(
73
+ [
74
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
75
+ f'viewBox="0 0 {width} {height}">',
76
+ "<style>text{font:12px system-ui,sans-serif} rect{stroke:#fff;stroke-width:1}</style>",
77
+ f'<text x="16" y="28" style="font-size:18px;font-weight:700">'
78
+ f"Attention heatmap: layer {layer}, head {head}</text>",
79
+ *x_labels,
80
+ *y_labels,
81
+ *cells,
82
+ "</svg>",
83
+ ]
84
+ )
85
+
86
+
87
+ def _display_token(token: str) -> str:
88
+ return token.replace("\n", "\\n").replace("\t", "\\t").replace(" ", "space")
src/tiny_transformer/web.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+
6
+ import torch
7
+
8
+ from tiny_transformer.train import load_checkpoint
9
+
10
+
11
+ HTML = """<!doctype html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <title>Tiny Transformer Playground</title>
17
+ <style>
18
+ body { margin: 0; font: 16px system-ui, sans-serif; background: #f7f7f4; color: #1b1b1b; }
19
+ main { max-width: 920px; margin: 0 auto; padding: 32px 20px; }
20
+ h1 { margin: 0 0 18px; font-size: 32px; }
21
+ textarea, pre { box-sizing: border-box; width: 100%; border: 1px solid #c8c8c2;
22
+ border-radius: 8px; padding: 14px; background: white; color: #1b1b1b; }
23
+ textarea { min-height: 110px; resize: vertical; }
24
+ pre { min-height: 220px; white-space: pre-wrap; line-height: 1.45; }
25
+ .controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 14px 0; }
26
+ label { display: grid; gap: 4px; font-size: 13px; }
27
+ input { width: 86px; padding: 8px; border: 1px solid #c8c8c2; border-radius: 6px; }
28
+ button { border: 0; border-radius: 8px; padding: 10px 16px; color: white;
29
+ background: #1f5f5b; font-weight: 700; cursor: pointer; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <main>
34
+ <h1>Tiny Transformer Playground</h1>
35
+ <textarea id="prompt">To be</textarea>
36
+ <div class="controls">
37
+ <label>New tokens <input id="tokens" type="number" min="1" max="500" value="120"></label>
38
+ <label>Temperature <input id="temperature" type="number" min="0.1" step="0.1" value="0.8"></label>
39
+ <label>Top-k <input id="topk" type="number" min="1" value="20"></label>
40
+ <button id="generate">Generate</button>
41
+ </div>
42
+ <pre id="output"></pre>
43
+ </main>
44
+ <script>
45
+ const button = document.getElementById("generate");
46
+ button.addEventListener("click", async () => {
47
+ button.disabled = true;
48
+ document.getElementById("output").textContent = "Generating...";
49
+ const response = await fetch("/api/generate", {
50
+ method: "POST",
51
+ headers: {"Content-Type": "application/json"},
52
+ body: JSON.stringify({
53
+ prompt: document.getElementById("prompt").value,
54
+ max_new_tokens: Number(document.getElementById("tokens").value),
55
+ temperature: Number(document.getElementById("temperature").value),
56
+ top_k: Number(document.getElementById("topk").value)
57
+ })
58
+ });
59
+ const payload = await response.json();
60
+ document.getElementById("output").textContent = payload.text || payload.error;
61
+ button.disabled = false;
62
+ });
63
+ </script>
64
+ </body>
65
+ </html>
66
+ """
67
+
68
+
69
+ def serve_playground(checkpoint: str, host: str, port: int, device: str) -> None:
70
+ model, tokenizer = load_checkpoint(checkpoint, device=device)
71
+
72
+ class Handler(BaseHTTPRequestHandler):
73
+ def do_GET(self) -> None:
74
+ if self.path != "/":
75
+ self.send_error(404)
76
+ return
77
+ self._send(200, HTML.encode("utf-8"), "text/html; charset=utf-8")
78
+
79
+ def do_POST(self) -> None:
80
+ if self.path != "/api/generate":
81
+ self.send_error(404)
82
+ return
83
+ length = int(self.headers.get("content-length", "0"))
84
+ payload = json.loads(self.rfile.read(length) or b"{}")
85
+ try:
86
+ prompt = str(payload.get("prompt", ""))
87
+ idx = torch.tensor([tokenizer.encode(prompt)], dtype=torch.long, device=device)
88
+ out = model.generate(
89
+ idx,
90
+ max_new_tokens=int(payload.get("max_new_tokens", 120)),
91
+ temperature=float(payload.get("temperature", 0.8)),
92
+ top_k=int(payload.get("top_k", 20)),
93
+ )
94
+ body = json.dumps({"text": tokenizer.decode(out[0].tolist())}).encode("utf-8")
95
+ self._send(200, body, "application/json")
96
+ except Exception as exc:
97
+ body = json.dumps({"error": str(exc)}).encode("utf-8")
98
+ self._send(400, body, "application/json")
99
+
100
+ def log_message(self, format: str, *args: object) -> None:
101
+ return
102
+
103
+ def _send(self, status: int, body: bytes, content_type: str) -> None:
104
+ self.send_response(status)
105
+ self.send_header("Content-Type", content_type)
106
+ self.send_header("Content-Length", str(len(body)))
107
+ self.end_headers()
108
+ self.wfile.write(body)
109
+
110
+ server = ThreadingHTTPServer((host, port), Handler)
111
+ print(f"Serving playground at http://{host}:{port}")
112
+ try:
113
+ server.serve_forever()
114
+ except KeyboardInterrupt:
115
+ print("\nStopped playground server.")
116
+ finally:
117
+ server.server_close()