KuralGPT · a tiny GPT, built by hand and explained as it's built

"A leads letters; the Ancient Lord leads the world" — Kural 1. The first verse of the corpus is about the first letter — and above, a machine's one skill: producing the next character. This page teaches how.

Building a language model from scratch on the திருக்குறள்

1,330 two-line verses, ~2,000 years old — turned into a working GPT, stage by stage. Every number, chart, and demo on this page comes from the real corpus, tokenizer, and trained weights of this project.

New here? Read the story version first — the whole journey with zero jargon, explained like you're five →

✓ 0 Corpus ✓ 1 Tokenizer ✓ 2 Model ✓ 3 Training ✓ 4 Generation
Chapter 0 · அகரம் — where letters begin

The big picture: an LLM is a next-token guesser

Strip away everything and a language model does exactly one thing: given text so far, predict what comes next. Then it does that again. And again. Everything else — the transformer, attention, billions of parameters — exists to make that one guess good.

Play the model's game yourself

Below is the start of a real kural, cut off mid-verse. Your job is the LLM's job: guess the next character. Notice what you use to decide — letter patterns, grammar, meaning. Training a model means making it acquire those same instincts, purely from data.

The journey we're on

  1. Corpus — curate the text the model will learn from. Done, explained below.
  2. Tokenizer — turn text into numbers (and discover why Tamil makes this fun). Done, explained below.
  3. Model — build a decoder-only transformer (the GPT architecture) in PyTorch.
  4. Training — the loop: predict, measure error (loss), nudge weights, repeat.
  5. Generation — sample from the trained model: temperature, top-k, and creativity.
Why the Thirukkural? It's tiny (~83k characters vs GPT-3's ~300 billion tokens), so we can train on a laptop in minutes and watch phenomena like overfitting happen — the same problems frontier labs fight, at a scale we can fully inspect. And its rigid form (two lines, 7 metrical feet) gives us a crisp test: did the model learn the form?
நூ
Chapter 1 · Stage 0 · நூல் — the book

The corpus: everything the model will ever know

A model's knowledge is bounded by its training data. Ours is the complete Thirukkural: 133 chapters (அதிகாரம்) of 10 kurals each, spanning three books — Virtue (அறம்), Wealth (பொருள்), Love (இன்பம்).

Explore the raw material

This is the actual training text. The "model view" is exactly the string the model sees — no chapter titles, no numbers, no translations. Just verse, and a blank line between verses. Any structure the model ends up knowing, it learned from patterns in this stream.

The character inventory — our future vocabulary

Every distinct Unicode codepoint in the corpus. There are only — this becomes the char-level tokenizer's entire vocabulary in Stage 1. The single most common "character" is (puḷḷi, the dot that silences a consonant's inherent vowel) — a hint that Tamil text in Unicode is built from pieces smaller than what your eye reads as one letter.

Bar length = occurrences in the full corpus (83k chars). ␣ = space, ⏎ = newline.

One glyph ≠ one codepoint

What reads as one Tamil letter is often two codepoints: base consonant + combining vowel sign. கு (ku) = (ka) +  ு (vowel sign u). Our char-level model predicts codepoints, so it must learn Tamil orthography — which combinations are legal — from scratch. Type anything Tamil to see its decomposition:

Train / validation split — the honesty mechanism

Our corpus is so small the model could eventually memorize it. A model that recites is not a model that learned. So we hide the 10th kural of every chapter (133 verses, exactly 10%) from training. If the model gets good at predicting those, it learned the style; if it only gets good on training verses, it's just memorizing. Watching these two numbers diverge during Stage 3 will be one of the best lessons of this whole project.

1,197 training kurals 133 validation kurals (every 10th — hidden from the model)
Chapter 2 · Stage 1 · எழுத்து — letters into numbers

The tokenizer: turning text into numbers

Neural networks eat numbers, not letters. A tokenizer is a reversible mapping between text and integer sequences — and the choice of mapping shapes everything downstream.

The design space

  • Character-level — one token per codepoint. Tiny vocabulary ( here), nothing is ever out-of-vocabulary, but sequences are long and the model wastes capacity re-learning spelling.
  • Word-level — one token per word. Huge vocabulary, and any unseen word breaks it. Essentially abandoned.
  • Subword (BPE) — the middle path used by GPT, Llama, Claude: frequent strings become single tokens, rare strings decompose into pieces. We built it from scratch.

Tokenizer playground

Type any Tamil text (or pick a kural) and see how each tokenizer slices it. Alternating colors mark token boundaries; the small number under each chip is the token's integer id — the actual input to the neural network.

Watch BPE learn — merge by merge

BPE training is greedy compression: count every adjacent pair of tokens in the corpus, fuse the most frequent pair into a new token, repeat. Drag the slider to replay training. Watch the sample verse fuse from isolated codepoints into syllables (ன், ும்) then word-parts (ெல்லா) — nobody told it Tamil has syllables; frequency statistics discovered them.

tokens, char-level (corpus)
tokens, BPE-512 (corpus)
compression ratio
Why compression matters: a transformer's context window is measured in tokens and attention cost grows with sequence length². Fewer tokens for the same text = longer effective memory and cheaper training. This is why every production LLM uses subword tokenization.
Chapter 3 · Stage 2 · கவனம் — attention

The transformer: communicate, then compute

The model (in kuralgpt/stage2_model.py) is a stack of identical blocks, each doing two things: attention lets tokens exchange information with earlier tokens; the MLP lets each token process what it gathered. Everything else is plumbing to keep training stable.

The data's journey, top to bottom

token ids (B, T)
the tokenizer's integers — e.g. அ→5, க→16
token embedding + position embedding (B, T, 128)
each id becomes a learned 128-number vector; a second learned vector says where it sits. Without positions, attention couldn't tell "அக" from "கஅ" — it's a bag of tokens otherwise.
↓ ×4 blocks
causal self-attention (B, 4 heads, T, T)
every token emits a query ("what am I looking for?"), a key ("what do I contain?") and a value ("what I'll contribute"). Attention weight = softmax(query·key) — a learned, content-based weighted average over earlier positions only. 4 heads = 4 independent conversations in parallel.
MLP 128 → 512 → 128
per-token thinking: expand 4×, GELU nonlinearity, contract. Attention moves information; this processes it. ~⅔ of all parameters live here.
linear head (B, T, 47)
one score (logit) per vocabulary character, at every position — softmax turns them into "probability of each possible next character". The head reuses (is tied to) the embedding matrix.
Residual connections: each sub-layer is applied as x = x + f(norm(x)) — the original signal always flows through untouched, layers only learn refinements. This is what makes deep stacks trainable.

The causal mask — why the model can't cheat

Training asks the model to predict character t+1 at every position t simultaneously, in one forward pass. That only works if position t provably can't see the future. One triangular mask does it: attention scores to later positions are set to −∞ before the softmax, which turns them into exactly zero weight. Hover any character:

We verified this empirically in stage2_model.py: changing the character at position 100 left predictions at positions 0–99 bit-for-bit identical, and changed them from 100 on. A one-line test that catches a whole class of bugs.

Where do 815,744 parameters live?

Our model's exact size, computed live from the same formulas as the PyTorch code. Drag the sliders — watch where parameters go as you scale. (Head count is absent on purpose: heads split the same matrices, they don't add parameters.)

Our first real bug: initialization

Before training, a correctly-wired model should be exactly as good as uniform guessing: cross-entropy = ln(47) = 3.85. A number we can predict before running — a free correctness test. Our first run printed 81.4.

The cause: PyTorch initializes embeddings from N(0,1), and since our output head is tied to the embedding matrix, initial logits came out ~10 standard deviations wide. Softmax saturated; the model was born pathologically overconfident, and its "poetry" was அஅஅஅஅஅ… forever. The fix (GPT-2's): initialize every weight with std 0.02. After that, loss = 3.874 ≈ ln(47), and the untrained babble is honest uniform noise:

அீஉமரஞிபழீீஏ யநஊேீஉ
ூஎநைூ ஆவூைஉமஐத.நரபஃஐொஎூசவஆறஉயசஒநஈவாைஙுதழொ

Note the illegal Tamil — vowel signs after vowels (அீ), floating combining marks. This is our baseline. The first visible sign of learning in Stage 3 will be the model discovering orthography and refusing to write these.

Chapter 4 · Stage 3 · பயிற்சி — practice

Training: 815,744 dials, turned by calculus

Training never tells the model any rules. It only repeats one procedure: show a random snippet, ask for next-character predictions everywhere at once, measure the average wrongness (loss), and let backpropagation compute — for every one of the 815,744 weights — which direction would have made the guess slightly better. Then nudge, and repeat.

The whole loop is five lines

for step in range(3000):
    x, y = get_batch()          # 64 random 128-char windows; y = x shifted by one
    logits, loss = model(x, y)  # cross-entropy: -ln P(true next char), averaged
    loss.backward()             # backprop: a gradient for every weight
    optimizer.step()            # AdamW nudges each weight against its gradient

The refinements around it (in kuralgpt/stage3_train.py) are all stability engineering: learning-rate warmup (small steps while weights are still random) then cosine decay, gradient clipping (cap any single step's size), and weight decay + dropout — our defences against memorising a 75 KB corpus.

One number to internalize: eloss is the "perplexity" — the effective number of characters the model is choosing between. Untrained: e3.85 = 47, i.e. clueless. Watch it collapse below.

The loss curves — and the moment memorisation begins

Both curves start at 3.85 (uniform guessing). Train loss is measured on verses the model studies; val loss on the 133 kurals it has never seen. Watch the gap: the curves fall together at first (everything learned is general), then train keeps dropping while val flattens — that growing wedge is memorisation, capacity spent photocopying the textbook instead of learning the subject. Our dropout and weight decay are why the wedge grows slowly instead of exploding.

view as table

Watch it learn to write

At milestones during training we froze the model and asked it to write (temperature 0.8, seeded with a newline — "start a fresh verse"). Drag through training time:


    

Now train one yourself — live, in this tab

Everything this chapter described, happening in front of you: a smaller sibling of our model (2 layers, 64-wide, 107,200 parameters) trains on the same 1,197 kurals — and this time backpropagation is hand-written. PyTorch computed our gradients in Stage 3; here every backward formula — through the softmax, the attention weights, the layernorms — is spelled out in site/train.js and gradient-checked against numerical derivatives. PyTorch is nowhere in the room.

step 0 · loss ·
— press Start, then watch this box every ~25 steps —

Single-threaded JavaScript in a Web Worker manages ~1 step/second — your GPU did ~30/second in Stage 3, on a model 8× larger. Same math, honest speed difference. A few minutes gets the loss from 3.85 (clueless) to ~2: watch spaces, then word-endings, then verse shape emerge in the samples.

Chapter 5 · Stage 4 · செய்யுள் — verse

Generation: choosing words from a cloud of maybes

The trained model doesn't output text — it outputs, at every step, a probability for each of the 47 characters. Generation is the art of choosing from that cloud, one character at a time, feeding each choice back in. The knobs below aren't post-processing tricks; they are the entire difference between dull, dazzling and deranged.

The live poet — the trained model, running in this tab

Nothing below is a recording. The best checkpoint's 815,744 weights were exported to weights_char.bin (3.3 MB) and the forward pass — attention, KV cache and all — is ~200 lines of plain JavaScript (site/kural-engine.js), verified to reproduce PyTorch's outputs to within 0.00005. No server, no GPU, no libraries: your browser is doing the inference.


  

The sampling lab — real logits, live knobs

These are the actual output scores from our best checkpoint (step 2,650) for a few contexts. Your browser re-applies temperature (divide scores by t, then softmax) and top-k (keep only the k best, renormalise) in real time — the same math the generator runs:

Same model, same seed — different knobs

Every sample below starts from the identical random seed; only the decoding strategy differs. The text differences are 100% knob.


    

Look inside: what attention actually attends to

These are the real attention weights from inside the trained model, reading kural #1. Pick a layer and head, then hover any character: the shading on earlier characters shows how much this head consults each of them at that moment. (Rows always sum to 1 — attention is a budget.)

layer
head

Different heads learn different jobs — some watch the immediately preceding character (spelling), some the start of the word, some the same position in the previous line (the verse's parallel structure). Nobody assigned these roles.

The anthology — six verses that never existed

Complete verses sampled at temperature 0.75 from the best checkpoint. Not one of these appears in the Thirukkural — every line is invented, in Valluvar's form and voice:

But do they mean anything? An honest translation

The ultimate test: translate them. Verdict — no. Every verse is built from real Tamil morphemes sitting in grammatically correct slots, yet none composes into a proposition. Where they fail is as instructive as where they succeed:

பசிற்கும் அல்வாழ் உயிர் படைத்துண்டின்
புகழ்துகை யானைப் பெறின்.
≈ "For hunger(?), un-living life, if created-and-eaten / fame-?? — if one obtains an elephant."
உயிர் (life), யானை (elephant!) and the verse-final பெறின் ("if one obtains") are genuine — the last is a signature Valluvar ending. But nothing predicates anything; the elephant arrives from nowhere.
ஏதுளாத் தாயினும் சான்றோர் உயிர்
காதலை அளிவின் தெறின்.
≈ "Even the ?-mother, the noble ones' life / if love's grace(?) is destroyed."
சான்றோர் (the noble), உயிர், காதல் genuinely co-occur in real kurals — the model learned the thematic cluster without learning what to claim about it.
என்மை என்னும் எனைத்தொன்றால் என்னும்
அன்பா துயிர்கண் கண்.
≈ "The thing called my-ness(?), by however-much-one-thing, so-called / love… life-eye. Eye."
Semantic word salad — with flawless மோனை alliteration, and the doubled என்னும் ("that which is called…") is a real rhetorical device of Valluvar's. The cadence of profundity, none of the content.
நுணங்கிுற் கொள்ளுக்க நில்லை எய்துணர்
பண்புடைந்த தாமத்து வறு.
≈ untranslatable.
Two treasures: நுணங்கிுற் hides கிு — an orthographically illegal cluster, a rare survivor of step-0 illiteracy. And பண்புடைந்த is one character from the real பண்புடைய ("having character") — but உடைந்த means shattered: one codepoint, opposite meaning.
எத்தற்க துணையாகச் சான்றோ செய்வற்கான்
புல்லது எற்றார் பவர்.
≈ "For-what(?), as a companion, the noble(truncated) for-doing(?) / that-which-is-base, those-who-strike."
Note துணையாகச் சான்… — the sandhi (the joining ச்) is correct. Case endings and particles all in the right slots: a sentence-shaped container with nothing inside.
காமமம் கரிப்பினும் கண்ணும் என்பொருள்
நெஞ்சத்து நீங்கி நடும்.
≈ "Even if desire scorches(?), even the eye, my-substance / departs from the heart and plants(?)."
The best of the six. Desire (காமம்), eyes and the heart (நெஞ்சம்) are exactly the co-occurring vocabulary of the real காமத்துப்பால் (Book of Love). It almost gestures at "even if desire burns, it leaves the heart" — then dissolves.

For contrast, a real kural (#129) — an argument compressed into seven metrical feet:

தீயினாற் சுட்டபுண் உள்ளாறும் ஆறாதே
நாவினாற் சுட்ட வடு.
"A wound burned by fire heals within; a scar burned by the tongue never heals."
The ladder of language, as our model climbed it: verse structure ✓ · orthography ✓ (one slip in six verses) · morphology ✓ · sandhi ✓ · prosody (மோனை) ✓ · thematic word-clusters ✓ · proposition ✗ · meaning ✗. That's not a bug — it's the ceiling of 815,744 parameters and 75 KB of characters. Meaning is what scale buys: with enough data and capacity, predicting the next token well forces a model to represent what words refer to. You've just seen which rungs come first, more clearly than most people who work with LLMs ever do.

What you built — and the road from here to ChatGPT

End to end: 1,330 ancient verses → a 47-character vocabulary → an 815,744-parameter transformer → 102 seconds of gradient descent → a machine that composes passable kural form. What it does not have is meaning: it has never seen a word's definition, only character statistics. Everything it "knows" is pattern.

  • Scale is the road from here to fluency: more data, wider and deeper models, BPE tokens instead of characters — same five-line training loop.
  • Instruction tuning then teaches a fluent model to follow requests (fine-tune on prompt→response pairs), and RLHF/RLAIF tunes it toward answers people prefer. That's the full distance from KuralGPT to a chat assistant — and you now know every load-bearing idea in it.

Everything on this page is computed from this repo's actual artifacts — data/corpus.txt, data/tokenizer_bpe.json — exported by kuralgpt/export_site.py. Corpus dataset: tk120404/thirukkural (GitHub).