Build an LLM from scratch
MLP language model
Train a character/token MLP LM
- Tokenization (browse)
- Self-attention (browse)
- Alignment basics — RLHF and DPO (browse)
- Classical ML literacy (browse)
Learning objectives
- Train a character/token MLP LM
- Sample from the model
- Compare qualitatively to the bigram baseline
Beyond counts: learning distributed representations
A bigram model stores one probability vector per vocabulary token — if you have 8,000 tokens, you have 8,000 rows and no sharing between similar contexts. "The cat" and "the dog" do not inform each other because only the immediate predecessor matters and each row is independent.
An MLP language model fixes the first problem by using a fixed context window of the last k tokens and fixes the second by embedding tokens into a shared continuous space. Similar tokens and similar contexts produce similar hidden states; the network generalizes to combinations never seen in training.
This is the same architectural family you used for classification in the foundations module — embeddings, linear layers, nonlinearity, softmax — rearranged for sequential prediction. You are one step away from attention: the MLP LM uses a fixed window; the transformer learns which past tokens to look at.
Callout — same loss, richer function: The training objective is still next-token cross-entropy. You are upgrading the function class from a lookup table to a neural net.
Architecture: context window → logits
Given token IDs [t_{-k+1}, ..., t_{-1}], predict distribution over t_0:
inputs: (batch, context_len) # integer token IDs
embed: (batch, context_len, embed_dim) # Embedding lookup
flatten: (batch, context_len * embed_dim)
mlp: (batch, hidden) → (batch, vocab_size)
softmax: probabilities over vocab
loss: cross_entropy(logits, target_token)For a context length of 8 and embed_dim of 64, the first linear layer sees 512 inputs. Each position's embedding captures semantic hints; the MLP learns which combinations predict the next token.
Character vs token MLP
| Input unit | Context len | Typical use |
|---|---|---|
| Character | 64–256 | Tiny demos, Karpathy-style char-RNN |
| Token (BPE) | 8–32 | Closer to real LMs; shorter sequences |
Character models see more positions but a smaller vocab (~256 bytes). Token models match your BPE pipeline from lesson 3.1. For this project, either works — pick one and stay consistent through lesson 3.5.
Training data: sliding window
From a token sequence [x_1, x_2, ..., x_N], create training examples:
context = [x_1, ..., x_k] → target = x_{k+1}
context = [x_2, ..., x_{k+1}] → target = x_{k+2}
...Every contiguous window is one example. A 10k-token corpus with context 8 yields ~10k training rows — plenty for a tiny MLP on a small vocab.
Split train/val by sequence or by time block, not by random windows from the same document (that leaks adjacent context across splits). For a single file like tiny Shakespeare, a simple 90/10 split on windows is acceptable at this scale.
Training loop (same as lesson 2.5):
for context, target in train_loader:
logits = model(context) # (batch, vocab)
loss = F.cross_entropy(logits, target)
loss.backward()
optimizer.step()Log train and val loss per epoch. Val loss should track train loss early; divergence means overfitting — your MLP may be large relative to corpus size.
Sampling from the MLP LM
Generation mirrors the bigram loop, but context is a tensor of the last k tokens:
@torch.no_grad()
def generate(model, start_ids, max_new_tokens):
context = list(start_ids)
for _ in range(max_new_tokens):
x = torch.tensor([context[-k:]], dtype=torch.long)
logits = model(x)[0]
probs = F.softmax(logits / temperature, dim=-1)
next_id = torch.multinomial(probs, 1).item()
context.append(next_id)
return contextUse model.eval() and torch.no_grad() — no gradients during inference.
Qualitative expectations on a small corpus after modest training:
- Local spelling and short phrases resemble the training text.
- Long-range coherence is weak (fixed window cannot remember beginnings).
- Repeated n-grams may appear (model finds low-loss shortcuts).
Compare samples side-by-side with your bigram model from lesson 3.2. The MLP should show slightly better phrase structure within the context window even if both models ramble globally.
Capacity, overfitting, and hyperparameters
Knobs that matter on CPU-scale training:
| Hyperparameter | Effect |
|---|---|
context_len |
More context = more pattern, larger input layer |
embed_dim |
Richer token representations |
hidden_dim |
MLP capacity; too large → overfit small corpus |
lr |
1e-3 AdamW is a sane start; reduce if loss spikes |
epochs |
Stop when val loss plateaus |
Start small: embed_dim=64, hidden=128, context=8. Scale up only if underfitting.
Callout — qualitative beats benchmark here: Your tiny MLP will not beat GPT. Success is: val loss decreases, samples look more corpus-like than bigram output, and you can explain the architecture in an interview.
Why this model still hits a wall
Fixed context is the bottleneck. No matter how wide the MLP, token at position 100 cannot directly attend to token at position 1 if context_len is 8. Convolutional and recurrent architectures partially address this; self-attention (lesson 3.4) addresses it directly and scales better on parallel hardware.
The MLP LM is pedagogically valuable because you train it with the exact same loss and sampling code you will reuse in the transformer — only the forward method changes.
Engineering problem (staff framing)
A fixed-window MLP LM teaches capacity limits before attention: context is a flattened bag of last-k embeddings.
Diagram — Fixed-window MLP LM
flowchart TD
T1[tok t-k] --> Cat[Concat embeddings]
T2[tok t-1] --> Cat
Cat --> MLP[MLP]
MLP --> Logits[Vocab logits]
Precise definitions & mental model
Context window as hyperparameter; embedding+MLP; no content-based routing across positions.
Tradeoffs — when to use what
Simple & parallel vs cannot learn long-range selectively.
Failure modes (interview + on-call)
k too small → no syntax; k too large → param blowup in concat MLP.
Production & OSS practices
Pedagogical baseline; production uses transformers — keep MLP as unit test of training infra.
Deep dive (FAANG / OSS bar)
Push «mlp-language-model» past tutorial depth: write the interface contract (inputs/outputs/invariants), list three measurable metrics, and name two degrade modes if the happy path fails. Add a short threat note: what an attacker or noisy tool result could do, and which layer catches it (schema, policy, HITL, or eval gate).
flowchart LR
Contract[Interface contract] --> Metrics
Metrics --> Degrade[Degrade modes]
Degrade --> Threat[Threat + control]
Micro-project: Train + sample
In m3/mlp_lm/:
- Implement
MLPLMwith configurablecontext_len,embed_dim,hidden_dim,vocab_size. - Build sliding-window dataset from your tokenized corpus (reuse tokenizer artifacts).
- Train with PyTorch; save
checkpoints/best.ptandhistory.json. - Implement sampling with temperature=1.0; generate ≥200 tokens from 3 prompts.
- Write
COMPARE.md: paste one bigram sample and one MLP sample for the same start context; note differences in local coherence, repetition, and val perplexity if computed.
Commit samples.txt and training script. Document hyperparameters and hardware (CPU ok).
Checklist
- MLP LM trains with decreasing val loss
- Samples generated from saved checkpoint
- COMPARE.md with bigram vs MLP qualitative analysis
- Architecture hyperparameters documented in README
ShipAI delivery model is: