A proof-of-concept, end-to-end pipeline for training a small GPT-style language model on a cleaned subset of English Wikipedia. Built to understand and explain the full lifecycle behind modern LLMs: dataset → cleaning → tokenization → training → checkpointing/logging → text generation → evaluation.
Results: a 15M-parameter model trained on 519M tokens on a single consumer GPU. Validation perplexity fell from 137 to 63 over 7 epochs, about 5.4x lower than an earlier version of the model before a key attention bug was fixed.
I built this as a "from scratch" LLM proof of concept to prove I could, and to develop the same kind of practical understanding I previously built through a graduate computer vision project. With LLMs being so prominent right now, I wanted to be able to describe them clearly, from the training pipeline up.
Perplexity is e raised to the cross-entropy loss. It roughly measures how many tokens the model is choosing between at each step, so lower is better. A model guessing at random over this 50,000-token vocabulary scores 50,000.
| Epoch | Train loss | Val loss | Val perplexity |
|---|---|---|---|
| 1 | 5.61 | 4.92 | 137 |
| 4 | 4.53 | 4.29 | 73 |
| 7 | 4.32 | 4.14 | 63 |
Validation loss is still falling at epoch 7, with no sign of overfitting. Gains are shrinking each epoch, which is expected for a model this size: most of its parameters sit in the embedding and output layers, with only about 2.4M in the transformer layers themselves.
Training loss sits above validation loss for two reasons. Training loss is averaged across the whole epoch, including the weaker early batches, while validation is measured at the end. Dropout is also active during training and off during evaluation.
An earlier version of the model plateaued at a validation perplexity of about 338 after 18 epochs.
The cause was a dimension mix-up: nn.TransformerEncoderLayer defaults to batch_first=False, but
the model passed tensors shaped (batch, sequence, embedding). PyTorch read the batch dimension as
the sequence, so attention compared tokens across different samples instead of within each sequence.
There was also no causal mask.
The fix was setting batch_first=True and adding a causal mask so each token attends only to
earlier tokens in its own sequence. A controlled test confirmed it: on a synthetic copy task that is
only solvable with working attention, the old model scored at chance level and the fixed model
scored 100%. Retrained from scratch, the fixed model passed the old plateau within its first epoch.
wikipedia_llm.ipynb: main notebook (data prep, tokenization, model, training, generation, charts)tokenizer/: Byte-Level BPE artifacts (vocab.json,merges.txt)tokenizer_hf/: exported Hugging Face tokenizer format (reloadable viafrom_pretrained)training_log.csv: training and validation loss per epoch for the fixed modeltraining_log_before_fix.csv: loss history from the model before the attention fixtraining_perplexity.png,before_after_fix.png: result charts generated by the notebookenvironment.yml,requirements.txt: environment setup for conda or pip
Model checkpoints (checkpoint.pt) and the encoded dataset (train.pt) are excluded from the repo
because of their size. The notebook regenerates both.
- Dataset ingestion (Hugging Face Datasets) and custom cleaning
- Byte-Level BPE tokenizer (loaded from
tokenizer/, exported totokenizer_hf/) - Next-token prediction dataset built from a long token stream and sliced into fixed windows
- Causal transformer with batch-first attention
- Crash-safe checkpointing and resume: checkpoints are written to a temporary file and swapped in only once complete
- Consistent validation: a fixed random seed keeps the same validation set across training sessions
- Durable CSV logging (
training_log.csvwith flush + fsync) - Text generation with temperature and top-k sampling
- Result charts: perplexity by epoch and a before-and-after comparison
- Checkpointing (save + resume) with protection against interrupted saves
- Training metric logging to CSV (durable writes)
- Data cleaning and preprocessing
- Dataset slicing into fixed-length sequences for next-token prediction
- Sampling-based text generation helper
- Diagnosis and fix of the attention bug, plus the test that verified it
- Perplexity and comparison charts
- Hugging Face Datasets: dataset access (Wikipedia snapshot)
- PyTorch: model and training primitives, plus the AdamW optimizer
- tokenizers / transformers: Byte-Level BPE tokenizer +
PreTrainedTokenizerFastwrapper - matplotlib: result charts
The notebook loads a subset of English Wikipedia:
- dataset:
wikimedia/wikipedia - config:
20231101.en - split:
train[:10%]
Then it:
- removes wiki-style headings (e.g.,
== Heading ==) - removes bracketed content (often citations)
- normalizes whitespace
- filters out short entries (keeps text over 200 characters)
This leaves about 625K articles.
A Byte-Level BPE tokenizer with a 50,000-token vocabulary is loaded from:
tokenizer/vocab.jsontokenizer/merges.txt
It is then wrapped as a Hugging Face fast tokenizer and saved to tokenizer_hf/.
Each article is tokenized, followed by an end-of-text token, and everything is concatenated into one
stream of 519M tokens saved to train.pt. Later sessions load this file instead of re-encoding. The
stream is sliced into fixed-length blocks:
block_size = 1024- inputs
x = ids[start:end] - targets
y = ids[start+1:end+1]
- token + positional embeddings
- 4
nn.TransformerEncoderLayerblocks withbatch_first=Trueand a causal mask - layer norm + linear head to vocabulary logits
Default config:
n_embd = 128n_layer = 4nhead = 4block_size = 1024- about 15.3M parameters
AdamW(lr=3e-4)andCrossEntropyLoss- batch size 10, one epoch per run
- 90/10 train/validation split with a fixed seed
- optimizer state restored from the checkpoint on resume
- checkpoint saved before each log entry, so the log never records an epoch without a checkpoint behind it
Each epoch takes roughly an hour on an RTX 5070 Ti.
With conda (recommended, since it also installs the CUDA libraries):
conda env create -f environment.yml
conda activate llmOr with pip:
python -m venv .venv
source .venv/bin/activate # macOS/Linux
pip install -r requirements.txtpython -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_arch_list())"The first value should be True, and the list should include your GPU's architecture (for example,
sm_120 for RTX 50-series cards).
Open wikipedia_llm.ipynb, set TRAINING_MODE = True, and run all cells. Each run trains one
epoch. To continue training, run the checkpoint cell and then the training cell again.
This project was developed on an RTX 5070 Ti early in the Blackwell lifecycle. The model code was rarely the problem. The blockers were driver, CUDA, and PyTorch support catching up.
- Check the build, not just the version. PyTorch can report CUDA as available yet ship without
kernels for your GPU.
torch.cuda.get_arch_list()shows which architectures a build supports. Blackwell needssm_120, which requires a build made with CUDA 12.8 or newer. - Conda-forge builds vary. Pinning an older CUDA version can make conda fall back to a build
without Blackwell support. Requesting a current CUDA build (for example,
"pytorch=*=cuda129*") avoids it. - WSL memory limits. WSL gets half of system RAM by default, and encoding 519M tokens needs more
than that. Raising the limit in
.wslconfig(for example,memory=24GBon a 32 GB machine) stopped the kernel from being killed.
Key lesson: for brand-new GPU architectures, verify the full stack (driver, CUDA, framework build) before launching long runs.
Build a proof-of-concept LLM end-to-end to prove I could, and to develop the same kind of practical understanding I previously built through a graduate computer vision project. With LLMs being so prominent right now, I wanted to be able to explain them clearly from the training pipeline up.
I implemented the practical training infrastructure, including crash-safe checkpointing and durable logging, plus the data cleaning workflow and evaluation charts. Hugging Face provided the raw dataset access, and PyTorch provided the training primitives and AdamW optimizer.
The hardest part was getting training working on an NVIDIA Blackwell GPU early in its lifecycle,
when support across the driver, CUDA, and PyTorch stack was still catching up. PyTorch could report
CUDA as available yet ship without kernels for the card, so I learned to check
torch.cuda.get_arch_list() for sm_120 rather than trusting the version number. I resolved it by
aligning the NVIDIA driver, CUDA version, and a PyTorch build compiled with Blackwell support, then
validating GPU operations before launching multi-hour training runs.
First, I'd move the training code out of the notebook into a command-line script, so a run could start with one command and settings like epochs and batch size could change without editing code. Then I'd train on more of Wikipedia. The model only saw 10% of it, and validation loss was still falling at epoch 7, so more data is the clearest path to better results.

