A Beginner's Guide, From First Principles

What does it actually mean
to pretrain a language model?

Not a summary — a from-scratch explanation. We'll build up the concepts one at a time, then show exactly how each one played out when training slm-125m, a 125.8M-parameter model built from nothing.

0
parameters, from random init
0
tokens trained on
$15.54
total cost, every phase
Start with the big picture
Part 1 · Step 01

The Big Picture

A language model is a function that predicts the next token. That's the entire mechanical premise — everything else is detail.

Before training, slm-125m has 125,847,552 parameters, and every one of them is a small random number. It has never seen a sentence. It doesn't know English exists, let alone what a legal opinion or an SEC filing looks like.

Pretraining is the process of fixing that — by showing the model an enormous amount of real text and, at every single position in that text, asking it to guess what comes next. It's wrong, almost every time, at first. The gap between its guess and the truth becomes a signal that nudges its parameters. Repeat that billions of times and something that started as noise becomes a model that writes fluent, stylistically-correct (if occasionally fabricated) legal and financial prose.

i

This is different from fine-tuning, which you may have seen elsewhere — fine-tuning starts from an already-pretrained model and adjusts it with a smaller, more targeted dataset (often using LoRA adapters to update only a fraction of the weights). slm-125m has no starting point. Every one of its 125.8M parameters is trained, from scratch, on this project's own corpus. There's no LoRA here — there's nothing to adapt yet.

Part 1 · Step 02

Tokenization — Text Becomes Numbers

Neural networks don't read letters. Before any of this works, text has to become a sequence of integers.

A tokenizer splits text into chunks — not always whole words, sometimes word-pieces or even single characters — and maps each chunk to an integer ID. slm-125m uses a vocabulary of exactly 16,384 possible chunks, trained specifically on this project's own legal/financial/web corpus (more on how that training works in Part 2).

T
Tokenizing "The plaintiff shall bear the burden."
TOKENS — watch them appear
<bos> The Ġplain tiff Ġshall Ġbear Ġthe Ġburden . <eos>
TOKEN IDS (out of 16,384 possible)
1 412 2891 7734 1205 3390 201 9012 18 2
Text tokens
Special (begin/end)

Note the "Ġ" — that's how byte-level tokenizers commonly represent "a space precedes this chunk." Ten tokens for a nine-word sentence is typical; the real tokenizer in this project averages close to one token per word for its trained domains, which we'll measure precisely in Part 2.

Part 1 · Step 03

The Shift-by-One

Here's the entire trick behind next-token prediction: the same sequence is used as both the input and the answer key, offset by one position.

At every position, the model sees all tokens up to and including that position, and must predict the next one. There's no separate "question" and "answer" dataset — one sentence, read once, generates a training signal at every single token.

The Shift-by-One Mechanism
POS
INPUT
↓   predicts   ↓
TARGET

At position 6 ("...shall bear the"), the model has seen "<bos> The plaintiff shall bear the" and must predict "burden". At position 7, it's seen one token more and must predict ".". This is exactly what happens 1,024 times per training window, for every one of the 1,991,282 windows in the training set.

Part 1 · Step 04

Every Token Trains — No Masking Needed

If you've read about fine-tuning a chat model, you've likely seen "loss masking" — hiding the user's turn so the model only learns to predict the assistant's reply. Pretraining doesn't need that.

In instruction fine-tuning, a training example has structure: a system prompt, a user turn, an assistant turn. You only want the model to learn to generate the assistant part, so every other token's loss gets set to -100 — PyTorch's "ignore this" signal.

slm-125m's training data has no such structure. It's raw continuous text — court opinions, SEC filings, web pages — with no roles, no turns, nothing to mask. Every single token contributes to the loss:

All positions trained — nothing masked
TOKENS
<bos>
The
plain-
tiff
shall
bear
the
burden
.
<eos>
Trained — loss computed at every position
i

This is one reason continued pretraining (what this project does) needs far more tokens than instruction fine-tuning typically does — but also why it's conceptually simpler. There's no dataset-formatting decision to make about "what counts as the target." Everything is the target.

Part 1 · Step 05

Cross-Entropy Loss

At every trained position, the model outputs a probability across all 16,384 possible next tokens. Loss measures how much probability it put on the token that was actually correct.

The formula is simple: loss = −log(P(correct token)). High confidence in the right answer → loss near zero. Confidently wrong → loss shoots up. Here's what the pattern looks like, illustratively, at three positions in our example sentence, before any training has happened:

POSITION 4 — Predict "shall"
Model sees: <bos> The plaintiff → should output shall
"is"
0.09
"shall" ✓
0.05
"was"
0.07
everything else
0.79
Loss = −log(0.05) =3.00
POSITION 6 — Predict "burden"
Model sees: ...shall bear the → should output burden
"same"
0.06
"burden" ✓
0.03
"case"
0.04
everything else
0.87
Loss = −log(0.03) =3.51
POSITION 8 — Predict "."
Model sees: ...bear the burden → should output .
"of"
0.22
"for"
0.08
"." ✓
0.04
everything else
0.66
Loss = −log(0.04) =3.22
!

The specific probabilities above are illustrative — reconstructing the exact softmax output at a single position from a live run isn't practical for a static page. But the overall pattern is exactly what happened, measured for real, averaged across the entire training and validation sets:

CheckpointAvg. loss (nats)PerplexityWhat it means
Step 0 (random init)9.8706≈19,412= ln(16,384) almost exactly — confirms correct initialization, measured before training began
Step 3,889 (final)2.326 (val)≈10.2real, measured on held-out data the model never trained on

That drop — 9.87 to 2.33 — is the entire story of training, compressed into two numbers. Everything from here on is about how that happened.

Part 1 · Step 06

The Complete Loop

Every one of the 3,889 training steps in this project did exactly this, on a fresh 524,288-token batch each time.

01

Take a batch of real text

524,288 tokens, sampled from the 2.04B-token training corpus, already packed into 1,024-token windows.

02

Shift by one position

Input = tokens[0..N-1], target = tokens[1..N] — the same data, offset.

03

Forward pass → logits

At every position, the model outputs a probability distribution over all 16,384 vocabulary tokens.

04

Cross-entropy loss

Average of −log(P(correct)) across every position — no masking, everything counts.

05

Backpropagation

Gradients flow to every one of the 125.8M parameters — full pretraining, not adapter-based.

06

AdamW optimizer step

Every parameter nudges slightly in the direction that reduces loss, scaled by the current learning rate.

Repeat — 3,889 times

Each cycle ≈2.6 seconds on the GPU used here. The whole run: 2.85 hours.

i

Part 1 complete. You now know what pretraining mechanically does. Part 2 gets specific: exactly what data slm-125m trained on, exactly how its tokenizer was built, exactly how big it is and why, and exactly what happened when this ran for real.

End of Part 1 · Beginning of Part 2

Now the specifics.
How slm-125m was actually built.

Part 1 covered what pretraining does, in general. Part 2 is this project, specifically — the real dataset, the real tokenizer, the real architecture, and the real numbers from the real training run.

2.04B real tokens 16,384-vocab custom BPE 125.8M params, full pretrain → Part 2 starts here
Part 2 · Chapter 01 — The Data Problem

Three sources.
Only one has enough text.

The plan was 70% US case law, 20% SEC filings, 10% web text. Measuring the real token yield first ruled that out before a dollar was spent.

case law avail.
0.81B
0.81B tok
SEC avail.
1.16B
1.16B tok
web avail.
11.67B available
11.67B tok
case law used
1.0B budget
1.0B cap
SEC used
1.3B budget
1.3B cap
web used
0.5B budget
0.5B cap

Case law and SEC filings together only contain about 2 billion clean tokens, total. There is no version of this project where legal text is 70% of a 10-billion-token corpus — the source material doesn't physically contain that much.

fineweb-edu, by contrast, is nearly bottomless — 11.67B tokens in the sampled slice alone. So the actual strategy: take all of both legal sources, cap web text at just enough to round out the mix.

Realized mix after cleaning: case law ~35%, SEC ~42%, web ~23% — about 78% legal text, nowhere near 70/20/10, but the closest legal-first mix this data actually supports.

1
🧹

Clean

Line-level noise filters, boilerplate stripping, repetition and language gates, plus an OCR-garbage gate for scanned case law.

2.68B proxy tokens kept · ~97%
2
🔁

Dedup

Exact-hash duplicates removed, MinHash near-duplicates caught on case law, and a 13-word overlap check strips anything resembling the eval benchmarks.

2.40B tokens · 24k contaminated docs removed
3
🔤

Tokenize

Encoded with the custom 16,384-vocab BPE tokenizer (next chapter), packed into 1,024-token windows, 99/1 train/val split.

2.04B train tokens · 20.6M val
Part 2 · Chapter 02 — Byte-Pair Encoding

A vocabulary,
grown from this corpus.

The tokenizer wasn't downloaded from a larger model — it's trained from scratch, on this project's own cleaned text, so its 16,384 chunks reflect legal and financial language specifically.

The algorithm, one merge at a time

BPE starts with every input broken into raw bytes — the smallest possible unit, guaranteeing anything can be represented. Then, repeatedly:

  1. Scan the whole training corpus for the most frequent adjacent pair of units
  2. Merge that pair into one new unit
  3. Repeat — 16,384 minus the starting alphabet size, times

Common English/legal/financial sequences — "tion", "the", whole short words — collapse into single tokens early. Rare sequences stay as smaller pieces or individual bytes. Nothing is ever unrepresentable; worst case, a token falls all the way back to raw bytes.

real output — Phase 3 tokenizer test
>>> tok.encode("The plaintiff shall bear the burden
    of proof by a preponderance
    of the evidence.")
15 tokens

>>> tok.encode("The Company's net revenues
    increased 12% year over year
    pursuant to the agreement.")
16 tokens

>>> tok.decode(tok.encode(text)) == text
True  # lossless roundtrip, every time

Why byte-level

The starting alphabet is raw bytes, not Unicode characters — so any input, in any language, including malformed or unexpected text, always encodes to something. There's no "unknown character" failure mode.

Why 16,384, specifically

A bigger vocabulary means fewer tokens per sentence (more efficient) but a bigger embedding table. At 125.8M total parameters, the embedding table is already 16,384 × 768 = 12.58M params — about 10% of the whole model. A 50,000-token vocab (typical for much larger models) would eat a disproportionate share of this model's capacity. 16,384 is sized for this model, not inherited from a bigger one.

i

Special tokens: <|bos|> <|eos|> <|pad|> <|unk|>, plus three chat-role tokens (<|user|>, <|assistant|>, <|system|>) reserved but unused during this base pretraining run — present in the vocabulary for a possible future fine-tuning stage.

Part 2 · Chapter 03 — Architecture

A plain transformer.
On purpose.

No architectural novelty — a standard LLaMA-style decoder, so every result is attributable to data and training, not a clever model design.

Token Embedding (16,384 × 768)
RMSNorm
Self-Attention · 12 heads, dim 64
RMSNorm
SwiGLU MLP · 768 → 3,072 → 768
↻ repeated 12 times
Final RMSNorm
Output projection (tied to embedding)
Parameters125,847,552
Layers12
Hidden size768
Intermediate (MLP)3,072 · SwiGLU
Attention heads12 · head dim 64
KV heads12 — plain MHA, not GQA
Positional encodingRoPE, θ=10,000
NormalizationRMSNorm, ε=1e-5
EmbeddingsTied input/output
Context length1,024 tokens
i

Tied embeddings means the same 12.58M-parameter matrix is reused for both "token in" and "token out" — saving ~12.6M parameters versus two separate matrices. Small models benefit from this proportionally more than large ones.

Try it — how big would a different shape be?

The real model uses hidden=768, layers=12 (the highlighted cell below). Drag either slider to see how parameter count scales.

// Live parameter calculator — same formula as config.py
Total params
125.8M
Embedding share
10.0%
vs. real model
✓ exact match
Part 2 · Chapter 04 — What's Actually in GPU Memory

No LoRA. No quantization.
Didn't need them.

At 125.8M parameters, full-precision full-parameter training fits comfortably in memory — the tricks that make training a 32B model feasible on one GPU exist to solve a problem this model doesn't have.

ComponentSizeWhat it is
Model weights (fp32)~503 MB125.8M params × 4 bytes. Kept in fp32 as the "master copy"; bf16 is used only transiently during the forward/backward matmuls (autocast).
Gradients~503 MBOne gradient per parameter, same shape and dtype as the weights — every parameter gets one, since this is full pretraining, not adapter-based.
AdamW optimizer state~1,006 MBTwo extra numbers per parameter (first and second moment estimates, "m" and "v") — this is why AdamW costs 2× the model size on top of the weights themselves.
Activations (transient)a few GBIntermediate values from the forward pass, held only until backward pass consumes them. Scales with batch size × sequence length × hidden size × layers.
Fixed, resident
~2 GB
weights + grads + optimizer
H100 available
80 GB
the GPU this ran on
Headroom
huge
this model barely uses the GPU it's on
i

If you've read about QLoRA — 4-bit quantized frozen weights, small trainable LoRA adapters, paged optimizers to survive memory spikes — that machinery exists because a 32-billion-parameter model's full fp32 footprint is ~384GB, more than any single consumer or even datacenter GPU holds. slm-125m's entire footprint is about 2GB. This model would likely train fine on a GPU a fraction the size of an H100 — the H100 was chosen for speed, not because anything smaller couldn't fit it.

Why 1 GPU, not 8

Compute per epoch ≈ 6 × params × tokens = 6 × 125.8M × 2.04B ≈ 1.54 × 10¹⁸ FLOPs. A single H100 finishes that in a few hours. Eight GPUs would mean paying for cross-GPU gradient-sync overhead with almost nothing to parallelize against at this parameter count — the original default config assumed 8×, and the math above is why it was changed to 1×. Measured result: ~2.63 seconds/step steady-state, ~2.85 hours total.

Part 2 · Chapter 05 — Every Setting, Explained

Nothing hardcoded.
Everything in one file.

config.py is the single source of truth — every other file imports from it. Here's every value that mattered, and why it was set that way.

M
ModelConfig
architecture — maps 1:1 to transformers.LlamaConfig
vocab_size16,384sized for a 125M model, not inherited from a bigger one
hidden_size768residual stream width
intermediate_size3,072SwiGLU MLP inner dim, 4× hidden
num_hidden_layers12transformer blocks
num_attention_heads12head dim 64 (768÷12)
num_key_value_heads12= heads → plain MHA, not GQA
max_position_embeddings1,024context length
rope_theta10,000.0RoPE base frequency
rms_norm_eps1e-5normalization stability constant
tie_word_embeddingsTruesaves ~12.6M params
C
CleanConfig
every threshold in the cleaning pipeline
min_line_chars40drop lines shorter than this
max_nonalnum_ratio0.30drop lines that are >30% non-alphanumeric
min_doc_chars600drop documents shorter than this after cleaning
repetition_top_k / max_repetition_ratio10 / 0.50drop if the top 10 most common 4-grams cover >50% of the doc
lang_sample_chars5,000how much text the language-detection gate samples
nonword_ratio_max0.20OCR gate — drop if >20% of words aren't in the dictionary
ocr_min_tokens50minimum words before the OCR gate applies at all
T
TrainConfig
every training hyperparameter
seq_len1,024tokens per training window
micro_batch_size32windows per forward/backward pass
global_batch_tokens524,288= 16 micro-batches accumulated per optimizer step
lr / min_lr6e-4 / 6e-5cosine schedule peak and floor
warmup_tokens200,000,000≈381 steps of linear warmup before peak LR
weight_decay0.1AdamW regularization
beta1 / beta20.9 / 0.95AdamW moment decay rates
grad_clip1.0max gradient norm
ckpt_every_steps500resumable checkpoint cadence
eval_every_steps1,000held-out validation loss check
seed1337data shuffling + weight init

Common errors, hit for real

Training job died mid-run — local terminal disconnected, killing a job tied to .remote()
use .spawn() + --detach
422 "missing query parameter" on the inference endpoint — Pydantic model defined inside the route function, unresolvable under from __future__ import annotations
define request models at module scope
500 error: unexpected model_kwargs — tokenizer returns token_type_ids, which generate() doesn't accept
return_token_type_ids=False
Modal image build fails — pip/apt installs placed after add_local_python_source(...)
installs always come first in the chain
Part 2 · Chapter 06 — The Real Curve

9.87 to 2.33.
195 measured points.

Not illustrative this time — every point below is a real logged value from the actual training run.

Training loss — all 3,889 steps

9.87 → 2.263 nats
Optimizer steps
3,889
1 epoch
Tokens seen
2.04B
~16.2× params — near Chinchilla-optimal ~20×
Wall-clock
2.85hr
1× H100
Total cost
$15.54
every phase, including retries
i

Warmup then decay: linear ramp for ~381 steps (jumping straight to peak LR on random weights destabilizes early training), peak 6e-4, cosine decay to 6e-5 by the final step. This exact schedule is what lr_at() in train.py computes at every step.

Part 2 · Chapter 07 — Modal & Vercel

Where this
actually ran.

Every phase, in order — each one a small, cheap, resumable cloud job, not one long fragile script.

0

Measure

Sample each source, extrapolate real token yield — the number that rules out 70/20/10 before any spend.

1

Clean

16 parallel Modal CPU workers, one per data shard. ~718,000 documents streamed, ~97% kept.

2

Dedup + decontaminate

MinHash near-dup detection, 13-gram overlap check against eval benchmarks. 2.40B tokens remain.

3

Train the tokenizer

Fresh 16,384-vocab BPE trained on the whole cleaned corpus.

4

Tokenize + pack

14 parallel workers encode and pack into 1,024-token windows. 2.04B train / 20.6M val tokens.

5

Pretrain

modal run --detach modal_app.py::pretrain --epochs 1.0 — 1× H100, ~2.85 hours, ~$12–13.

6

Push to Hugging Face

modal run modal_app.py::deploy — uploads checkpoint, tokenizer, and an auto-generated model card.

Serve + deploy the demo

modal deploy modal_app.py for a persistent inference endpoint, vercel deploy --prod for the static site that calls it directly from the browser.

setup — both platforms, from zero
# ── Modal ─────────────────────────────────────────────────
pip install modal
modal token new

# ephemeral — stops when your terminal disconnects
modal run app.py::main

# long-running — survives a dropped connection
modal run --detach app.py::train

# persistent — gets a stable URL, serves forever
modal deploy app.py

# ── Vercel ────────────────────────────────────────────────
npm install -g vercel
vercel link --yes --project my-site
vercel deploy --prod

Why two platforms and not one: Modal has no first-class static-site hosting, and Vercel has no GPU compute. Each does the one thing it's actually built for — the browser-side demo calls the Modal endpoint directly via CORS, so there isn't even a server in between.

Summary

Three ideas.
One trained model.

Shift-by-one
Every token predicts the next

One sequence, offset by one position, generates a training signal at every position. No labeling, no separate answer key — the text is the supervision.

Full pretraining
125.8M parameters, all trained

No LoRA, no quantization, no frozen base — small enough that full fp32 training fits in ~2GB, comfortably inside any modern GPU.

Measured, not assumed
9.87 → 2.326, real numbers

The data mix, the GPU count, and every hyperparameter above trace back to a measurement, not a guess — that's what makes the $15.54 total cost repeatable.

Now go see it work. The weights and a live demo are both one click away.