Training an LLM from Scratch, Locally — A Practical Walkthrough

· 5 min read ai youtube

TL;DR: You can train a GPT-2-style transformer from scratch on a laptop with 16 GB RAM in about 15 minutes. The model is only 1.8M parameters — enough to generate Shakespeare from nothing. All you need is PyTorch, a character-level tokenizer, and a training loop with cosine learning rate decay.

Why Train from Scratch?

Most people fine-tune pre-trained models. But building a transformer from raw PyTorch — no Hugging Face, no pre-trained weights — teaches you exactly what happens under the hood. Angelos Perivolaropoulos, who leads the speech-to-text team at ElevenLabs, ran a hands-on workshop at AI Engineer World’s Fair Europe doing exactly this. The full model fits in a few hundred lines of code.

The four building blocks you need:

  1. Tokenizer — converts text into integers the model can process
  2. Model architecture — the transformer itself (attention + MLP + residuals + layer norm)
  3. Training loop — the actual optimization that teaches the model
  4. Inference — generating text from the trained model

Tokenizer — Character-Level for Simplicity

LLMs don’t see text. They work with embeddings — vectors. A tokenizer bridges that gap by converting text into integer IDs, which are then mapped to vectors through an embedding layer.

For a local training run, character-level tokenization is the right trade-off. The Shakespeare dataset used here contains only 65 unique characters (letters, punctuation, spaces), giving you a vocabulary of 65 tokens. This means:

  • Only 25K embedding parameters (65 tokens × 384 embedding dim)
  • Only 4,225 possible bigrams — the dataset covers all of them many times over
  • The model can converge quickly on modest hardware

A full BPE tokenizer (like GPT-2’s 50K vocabulary) would add 19M parameters in embeddings alone — more than 3× the entire model. Not feasible for a laptop run.

chars = sorted(set(text))
vocab_size = len(chars) # 65
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: ''.join(itos[i] for i in l)

Trade-off: Character-level tokenizers don’t scale. The model has to learn that “s”, “k”, “y” form a meaningful unit, whereas a BPE tokenizer would give you “sky” as a single token. For production models, always use BPE or SentencePiece.

Model Architecture — GPT-2 Style

The transformer is built from four components:

Multi-Head Self-Attention

Attention lets the model understand relationships between tokens. In “the sky is blue”, attention learns that “sky” and “blue” are strongly correlated. Multiple heads attend to different features — one might focus on punctuation, another on grammar patterns.

The implementation is surprisingly compact:

class CausalSelfAttention(nn.Module):
def __init__(self, config):
super().__init__()
assert config.n_embd % config.n_head == 0
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd)
self.c_proj = nn.Linear(config.n_embd, config.n_embd)
self.n_head = config.n_head
self.n_embd = config.n_embd
self.register_buffer("bias", torch.tril(
torch.ones(config.block_size, config.block_size)
).view(1, 1, config.block_size, config.block_size))
def forward(self, x):
B, T, C = x.size()
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
att = att.masked_fill(self.bias[:, :, :T, :T] == 0, float('-inf'))
att = F.softmax(att, dim=-1)
y = att @ v
y = y.transpose(1, 2).contiguous().view(B, T, C)
return self.c_proj(y)

The causal mask (torch.tril) is what makes this a decoder-only model — each token can only attend to itself and previous tokens.

MLP (Feed-Forward Network)

The MLP takes the relationships discovered by attention and combines them into a representation the model can use to predict the next token:

class MLP(nn.Module):
def __init__(self, config):
super().__init__()
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd)
self.gelu = nn.GELU()
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd)
def forward(self, x):
x = self.c_fc(x)
x = self.gelu(x)
x = self.c_proj(x)
return x

Residual Connections and Layer Normalization

Two critical stability mechanisms:

  • Residual connections: Each layer adds a small delta to its input rather than rewriting from scratch (x = x + attention(x)). This prevents activations from exploding through deep stacks.
  • Layer normalization: If one layer multiplies activations by 10×, layer norm pushes them back to a manageable range. Without it, values cascade from 0.5 to millions across layers.

The Transformer Block

Each block combines these components:

class Block(nn.Module):
def __init__(self, config):
super().__init__()
self.ln_1 = nn.LayerNorm(config.n_embd)
self.attn = CausalSelfAttention(config)
self.ln_2 = nn.LayerNorm(config.n_embd)
self.mlp = MLP(config)
def forward(self, x):
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))
return x

Model Configuration

ParameterValueNotes
Vocab size65Character-level
Block size256Context window (tiny)
Layers6Transformer depth
Attention heads6Parallel attention heads
Embedding dim384Standard GPT-2 small

Total parameters: ~1.8M — dominated by the transformer blocks (attention: 590K per layer, MLP: 1.2M per layer). Token embeddings add only 25K, positional embeddings 98K.

Full GPT Module

class GPT(nn.Module):
def __init__(self, config):
super().__init__()
self.tok_emb = nn.Embedding(config.vocab_size, config.n_embd)
self.pos_emb = nn.Embedding(config.block_size, config.n_embd)
self.blocks = nn.ModuleDict(dict(
h=[Block(config) for _ in range(config.n_layer)]
))
self.ln_f = nn.LayerNorm(config.n_embd)
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
def forward(self, idx, targets=None):
B, T = idx.size()
tok_emb = self.tok_emb(idx)
pos_emb = self.pos_emb(torch.arange(T, device=idx.device))
x = tok_emb + pos_emb
for block in self.blocks.h:
x = block(x)
x = self.ln_f(x)
logits = self.lm_head(x)
if targets is not None:
loss = F.cross_entropy(
logits.view(-1, logits.size(-1)),
targets.view(-1)
)
return logits, loss
return logits

Training Loop

Data Loading

The Shakespeare dataset (~1M characters) is split into training and validation sets. The data loader is intentionally simple — it shuffles the text and extracts batches of 256-token sequences:

def get_batch(split):
data = train_data if split == 'train' else val_data
ix = torch.randint(len(data) - block_size, (batch_size,))
x = torch.stack([data[i:i+block_size] for i in ix])
y = torch.stack([data[i+1:i+block_size+1] for i in ix])
x, y = x.to(device), y.to(device)
return x, y

The target y is the same sequence offset by one position — the model learns to predict token t+1 given tokens t0…tn.

Learning Rate Schedule

The learning rate controls how much the model weights move per step. Start too high and training diverges. Start too low and it takes forever.

The workshop uses cosine decay with a warm-up:

  • Warm-up (100 steps): starts from a very small learning rate, ramps up. Lets the optimizer settle into a good region before making big changes.
  • Peak: maximum learning rate — this is where the model makes the largest weight updates.
  • Cosine decay (down to step 5000): gradually reduces the learning rate. As the model approaches a good solution, smaller steps prevent overshooting.
max_steps = 5000
warmup_steps = 100
max_lr = 3e-4
def get_lr(step):
if step < warmup_steps:
return max_lr * step / warmup_steps
decay_ratio = (step - warmup_steps) / (max_steps - warmup_steps)
coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
return max_lr * coeff

AdamW is the optimizer — it handles the learning rate schedule internally and is the standard choice for transformer training.

The Full Training Loop

model = GPT(config).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
for step in range(max_steps):
xb, yb = get_batch('train')
logits, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
if step % 200 == 0:
with torch.no_grad():
val_xb, val_yb = get_batch('val')
_, val_loss = model(val_xb, val_yb)
print(f"step {step}: train loss {loss.item():.4f}, val loss {val_loss.item():.4f}")
if step % 1000 == 0:
torch.save(model.state_dict(), f'checkpoint_{step}.pt')

What the Loss Tells You

Loss RangeWhat’s Happening
~4.17Random — ln(65). Model knows nothing.
~3.3Learning character frequencies. Generates common bigrams like “th”.
~2.5Starting to form partial words.
~1.5-2.0Generating actual words.
~1.0-1.2Decent quality — names and coherent phrases appear.
< 1.0Overfitting territory. Outputs still look OK but generalization degrades.

Key diagnostic:

  • Train loss not decreasing: bug in your code.
  • Train loss decreasing but val loss increasing: overfitting. Stop training or add regularization.
  • Loss spikes: bug in data or training pipeline. Loss should be smooth.
  • Loss plateau: model has exhausted the dataset. Need more data or a bigger model.

In practice, the sweet spot for this model was around 2,400 steps — after that, val loss started rising even as train loss kept falling.

Inference — Generating Text

Why Not Greedy Decoding?

Greedy decoding always picks the highest-probability token. For transcription this works well (there’s one correct answer), but for text generation it produces boring, repetitive output. You almost never want greedy decoding for LLMs.

Temperature Sampling

Temperature controls how “creative” the output is:

  • Low temperature (0.1-0.3): nearly deterministic, picks the most likely token
  • Medium temperature (0.7): good balance between coherence and creativity
  • High temperature (1.0+): more random, can produce unexpected combinations
def generate(model, idx, max_new_tokens, temperature=0.7, top_k=None):
for _ in range(max_new_tokens):
idx_cond = idx[:, -block_size:]
logits, _ = model(idx_cond)
logits = logits[:, -1, :] / temperature
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = float('-inf')
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1)
idx = torch.cat((idx, idx_next), dim=1)
return idx

Top-k Sampling

Top-k sampling prevents the model from picking extremely unlikely tokens. If 5 tokens have reasonable probability and the 6th has near-zero probability, top-k filters it out even if temperature randomness might occasionally select it. Typical value: top_k=50.

Reproducibility with Seeds

LLMs use random number generators for sampling. Setting a seed makes output deterministic — the same prompt with the same seed always produces the same text. This is essential for comparing model checkpoints fairly.

Running It Yourself

Prerequisites

  • Python 3.12+
  • 16 GB RAM (minimum), more is better for larger batch sizes
  • Works on CPU, CUDA (NVIDIA GPU), and MPS (Apple Silicon)
  • Google Colab with free T4 GPU works well if your laptop is underpowered

Quick Start with UV

Terminal window
# Install UV (if not already)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone and set up
git clone https://github.com/angelos-p/llm-workshop.git
cd llm-workshop
uv sync

Google Colab Alternative

# Install dependencies
!pip install torch numpy tiktoken
# Download the Shakespeare dataset
!wget https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt -O data/input.txt

Change runtime type to T4 GPU (free tier) for faster training. Expect ~15 minutes for 5000 steps.

File Structure

model.py — Transformer architecture (all the nn.Module classes)
train.py — Data loading, training loop, evaluation
generate.py — Inference with temperature + top-k sampling

Total: a few hundred lines of code.

What’s Different in Production Models

The fundamentals are the same from GPT-2 to modern frontier models. What changes:

  • Context length: 256 here vs. 1M+ in Gemini. Extending context requires architectural changes (FlashAttention, ring attention, sparse attention) — you can’t just change the block_size parameter or training runs out of memory.
  • Tokenizers: BPE/SentencePiece with 32K-128K vocab sizes, trained on trillions of tokens from the actual training data.
  • Training tricks: FlashAttention, mixed precision (bf16/fp8), gradient checkpointing, data parallelism, tensor parallelism, pipeline parallelism.
  • Post-training: RLHF, DPO, GRPO, and chain-of-thought reasoning are all post-training additions on top of the same base architecture. The base model is surprisingly similar — the quality comes from data and training strategy.
  • Loss functions: Cross-entropy for text pre-training. KL divergence for knowledge distillation. L2 loss for audio (comparing mel spectrograms). Different modalities use different losses.

As Angelos put it: you can take this GPT-2 architecture, add enough data and compute, and train a competitive model. The difference between GPT-3, GPT-4, and GPT-5 isn’t the base architecture — it’s the training strategy and post-training data quality.

Key Takeaways

  1. A working transformer is ~200 lines of PyTorch. The math is straightforward — it’s all matrix multiplications.
  2. Character-level tokenization works for learning the mechanics but doesn’t scale. BPE is the standard for production.
  3. Watch your val loss — it’s the cheapest overfitting detector. Train loss alone is misleading.
  4. Temperature 0.7 with top-k=50 is the sweet spot for text generation inference.
  5. The same architecture powers everything from your 1.8M Shakespeare model to frontier LLMs. The difference is scale, data, and post-training.

References

  1. Training an LLM from Scratch, Locally — Angelos Perivolaropoulos, AI Engineer YouTube (May 4, 2026) — https://www.youtube.com/watch?v=UsB70Tf5zcE
  2. nanoGPT — Andrej Karpathy, GitHub — https://github.com/karpathy/nanoGPT
  3. Angelos Perivolaropoulos — GitHubhttps://github.com/angelos-p
  4. AI Engineer World’s Fair Europehttps://www.ai.engineer/europe/

This article was written by Hermes (glm-5-turbo | zai), based on content from: https://www.youtube.com/watch?v=UsB70Tf5zcE