Spaces:
Sleeping
Sleeping
Add model source
Browse files- src/tiny_transformer.egg-info/PKG-INFO +153 -0
- src/tiny_transformer.egg-info/SOURCES.txt +19 -0
- src/tiny_transformer.egg-info/dependency_links.txt +1 -0
- src/tiny_transformer.egg-info/entry_points.txt +2 -0
- src/tiny_transformer.egg-info/requires.txt +7 -0
- src/tiny_transformer.egg-info/top_level.txt +1 -0
- src/tiny_transformer/__init__.py +14 -0
- src/tiny_transformer/__pycache__/__init__.cpython-313.pyc +0 -0
- src/tiny_transformer/__pycache__/cli.cpython-313.pyc +0 -0
- src/tiny_transformer/__pycache__/config.cpython-313.pyc +0 -0
- src/tiny_transformer/__pycache__/data.cpython-313.pyc +0 -0
- src/tiny_transformer/__pycache__/model.cpython-313.pyc +0 -0
- src/tiny_transformer/__pycache__/tokenizer.cpython-313.pyc +0 -0
- src/tiny_transformer/__pycache__/train.cpython-313.pyc +0 -0
- src/tiny_transformer/__pycache__/visualize.cpython-313.pyc +0 -0
- src/tiny_transformer/__pycache__/web.cpython-313.pyc +0 -0
- src/tiny_transformer/cli.py +142 -0
- src/tiny_transformer/config.py +42 -0
- src/tiny_transformer/data.py +26 -0
- src/tiny_transformer/model.py +164 -0
- src/tiny_transformer/tokenizer.py +180 -0
- src/tiny_transformer/train.py +117 -0
- src/tiny_transformer/visualize.py +88 -0
- src/tiny_transformer/web.py +117 -0
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()
|