A decoder-only, GPT-style Transformer language model built from scratch in PyTorch and trained at the character level on the Tiny Shakespeare dataset. Every part of the architecture is written by hand: self-attention heads, multi-head attention, the feed-forward network, residual Transformer blocks, and the language-model head. PyTorch supplies only the basic layers (nn.Linear, nn.Embedding, nn.LayerNorm).
π Read the full write-up on Medium: How I Trained My First Transformer from Scratch
- Highlights
- Repository Structure
- Getting Started
- Implementation Details
- Hyperparameters
- Parameter Count
- Results
- Possible Improvements
- Acknowledgements
- Architecture: Decoder-only Transformer (GPT-style) with causal masked self-attention
- Tokenization: Character-level, 65-token vocabulary
- Size: ~0.21M parameters (208,193)
- Depth: 4 Transformer blocks Γ 4 attention heads, 64-dim embeddings
- Training: 10,000 iterations with AdamW
- Result: Loss drops from 4.25 β 2.09 (validation), and the model generates text that looks like Shakespeare
Transformer_from_scratch/
βββ Transformer_from_scratch.ipynb # Full implementation: data, model, training, generation
βββ README.md
The dataset (input.txt) is downloaded by the notebook itself. It is not stored in the repo.
- Python 3
- PyTorch
- Jupyter Notebook / JupyterLab (or Google Colab)
pip install torchA CUDA GPU is used automatically if one is available. Otherwise the code falls back to CPU, and the model is small enough to train there too.
- Open
Transformer_from_scratch.ipynb. - Run all cells from top to bottom. The notebook will:
- download the Tiny Shakespeare dataset,
- build the vocabulary and the train/val split,
- define and train the model,
- print the train/val loss every 100 steps,
- generate 2,000 characters of new text.
Note: The dataset is downloaded with
!wget. Ifwgetisn't installed on your machine (common on macOS), download the file manually instead:curl -o input.txt https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt
The model is trained on Tiny Shakespeare, a single 1.1 MB text file (1,115,394 characters) containing a collection of Shakespeare's plays.
The model uses a simple character-level tokenizer:
- The vocabulary is every unique character in the dataset, sorted: 65 characters (letters, digits, punctuation, space and newline).
stoimaps each character to an integer anditosmaps it back.encode(str) -> list[int]anddecode(list[int]) -> strconvert between text and token IDs.
The whole dataset is then encoded into a single torch.long tensor.
- The first 90% of the tokens are used for training.
- The remaining 10% are used for validation.
The split is sequential (not shuffled), so the validation set is a continuous piece of text the model never trains on.
get_batch(split) samples batch_size random starting positions and returns:
x: input chunks of lengthblock_size, with shape(B, T)y: the same chunks shifted one character to the right, with shape(B, T)
So every chunk gives block_size training examples at once. For each position t, the model sees the context x[:t+1] and has to predict the next character y[t]:
when input is [24] the target: 43
when input is [24, 43] the target: 58
when input is [24, 43, 58] the target: 5
...
Input token IDs (B, T)
β
ββββΊ Token Embedding (vocab_size β n_embd)
ββββΊ Position Embedding (block_size β n_embd)
βΌ
tok_emb + pos_emb (B, T, C)
β
βΌ
βββββββββββββββββββββββββββββββββ
β Transformer Block Γ n_layer β
β β
β x = x + MultiHeadAttn(LN(x)) β
β x = x + FeedForward(LN(x)) β
βββββββββββββββββββββββββββββββββ
β
βΌ
Final LayerNorm (ln_f)
β
βΌ
Linear LM Head (n_embd β vocab_size)
β
βΌ
Logits (B, T, vocab_size)
Shape notation: B = batch size, T = sequence length (time), C = embedding dimension (n_embd).
-
Three bias-free linear projections turn each token into a key, query, and value of size
head_size. -
Attention scores use scaled dot-product attention:
$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V$$ where
d_k = head_size. Scaling by1/βd_kkeeps the variance of the scores under control, so the softmax doesn't become too peaked when training starts. -
A causal mask is applied before the softmax: a lower-triangular matrix (
tril, stored withregister_buffer) sets future positions to-inf, so each token can only attend to itself and earlier tokens. -
Dropout is applied to the attention weights, and then they are used to take a weighted sum of the values.
- Runs
n_headindependentHeads in parallel, each withhead_size = n_embd // n_head. - Concatenates their outputs along the last dimension, so the result is back to size
n_embd. - Applies an output linear projection followed by dropout.
Linear(n_embd β 4Β·n_embd) β ReLU β Linear(4Β·n_embd β n_embd) β Dropout- It is applied to each token on its own. Attention is where tokens communicate; this layer is where each token computes on what it gathered.
- Combines communication (attention) and computation (feed-forward).
- Uses residual connections around both sub-layers:
x = x + sublayer(x). - Uses pre-LayerNorm: LayerNorm is applied before each sub-layer, not after as in the original Attention Is All You Need paper. This makes training more stable.
- Token embedding and learned positional embedding. The two are added together.
n_layerTransformer blocks stacked withnn.Sequential.- A final LayerNorm, then a linear head that projects to vocabulary logits.
- When
targetsare passed in, the logits are reshaped to(BΒ·T, C)and cross-entropy loss is computed against the next-character targets.
Since this is a decoder-only model (like GPT), there is no encoder and no cross-attention. Only masked self-attention is used.
- Optimizer: AdamW, learning rate
1e-3, with no learning-rate schedule. - Loop: For
max_iters = 10,000steps: sample a training batch β forward pass βzero_gradβbackwardβoptimizer.step(). - Evaluation: Every
eval_interval = 100steps (and at the last step),estimate_loss()averages the loss overeval_iters = 200batches for both the train and val splits. It runs under@torch.no_grad()and switches the model betweeneval()andtrain()modes.
generate(idx, max_new_tokens) generates text autoregressively:
- Crop the context to the last
block_sizetokens, since that is the longest context the position embeddings support. - Run the model and keep only the logits for the last time step.
- Apply softmax to get a probability distribution over the next character.
- Sample the next token with
torch.multinomial. - Append the new token to the sequence and repeat.
Generation starts from a single token with ID 0 (the newline character \n), and the notebook generates 2,000 new characters.
The first cell defines the hyperparameters. However, batch_size and block_size are set again in the data-exploration cells (to 4 and 8) before the model is built. So the recorded training run actually used the values in the "Used in run" column:
| Hyperparameter | Set in first cell | Used in run | Description |
|---|---|---|---|
batch_size |
16 | 4 | Independent sequences processed in parallel |
block_size |
32 | 8 | Maximum context length (in characters) |
max_iters |
10,000 | 10,000 | Total training iterations |
eval_interval |
100 | 100 | Evaluate every N iterations |
eval_iters |
200 | 200 | Batches averaged per loss estimate |
learning_rate |
1e-3 | 1e-3 | AdamW learning rate |
n_embd |
64 | 64 | Embedding dimension |
n_head |
4 | 4 | Attention heads per block (head_size = 16) |
n_layer |
4 | 4 | Number of Transformer blocks |
dropout |
0.0 | 0.0 | Dropout rate |
To train with the larger values (batch_size = 16, block_size = 32), remove or change the reassignment in the batching cells so that the first cell's values are the ones used.
Total: 208,193 parameters (~0.21M)
| Component | Parameters |
|---|---|
| Token embedding (65 Γ 64) | 4,160 |
| Position embedding (8 Γ 64) | 512 |
| 4 Γ Transformer block (49,792 each) | 199,168 |
| Β Β β’ 4 heads Γ Q/K/V (64 Γ 16, no bias) | 12,288 |
| Β Β β’ Attention output projection | 4,160 |
| Β Β β’ Feed-forward (64 β 256 β 64) | 33,088 |
| Β Β β’ 2 Γ LayerNorm | 256 |
| Final LayerNorm | 128 |
| LM head (64 β 65) | 4,225 |
| Step | Train Loss | Val Loss |
|---|---|---|
| 0 | 4.2503 | 4.2551 |
| 1000 | 2.4142 | 2.4251 |
| 2000 | 2.2798 | 2.2797 |
| 5000 | 2.1210 | 2.1951 |
| 9999 | 2.0162 | 2.0908 |
The starting loss of ~4.25 is close to the loss you'd expect from uniform random guessing over 65 characters, which is βln(1/65) β 4.17. That confirms the model starts out with no knowledge. The train and val losses stay close together for the whole run, so the model is not overfitting badly.
An excerpt from the 2,000 characters the trained model generated:
DETENWICHANAABELLA:
The this my depity:
Augh are plewty.
SIBENSIRY ISABE:
And whom
Is would thake only so what exicks to they, rive weeshirn sto-lite lix thuse known,
Turt f so;
Angreen love,
I know ofter:
Firse?
The model has learned the structure of a play: character names in capitals followed by a colon, line breaks, dialogue-like rhythm and punctuation. It has also learned many real English words. With only 0.21M parameters and an 8-character context, it cannot yet keep meaning coherent. Because generation uses random sampling, your output will be different.
- Use the larger
batch_size/block_sizefrom the first cell, or go bigger, so the model sees more context. - Scale up
n_embd,n_headandn_layer, and turn ondropout(e.g. 0.2) to regularize the larger model. - Add a learning-rate warmup and cosine decay schedule.
- Switch to a subword tokenizer (e.g. BPE) instead of characters.
- Add temperature and top-k sampling to
generate(). - Use PyTorch's fused
F.scaled_dot_product_attentionfor speed.
- Andrej Karpathy's Let's build GPT: from scratch, in code, spelled out lecture and nanoGPT, which this implementation follows.
- The Tiny Shakespeare dataset from karpathy/char-rnn.
- Vaswani et al., Attention Is All You Need (2017).