Lesson 2: What pretraining gave the model, and what it costs
Module 1 · Lesson 2 · About 40 minutes · Needs: Lesson 1 (TinyLM and next-token prediction)
The situation
It is week 1. In Lesson 1, TinyLM wrote the 14-day refund rule word for word. Maya asks: "So does it know our help centre? What will hosted models know about Quillpath?" Tomasz, choosing models, asks: "If we run an open model ourselves, how much memory do we need?"
This lesson follows ticket T-1010: automations stopped on a Team plan. Its answer hangs on one help-centre fact, "Team plans include 1,000 automation runs a month."
A real case, first
In "Why Language Models Hallucinate" (Kalai, Nachum, Vempala and Zhang, September 2025), the authors asked a state-of-the-art open model (named in a footnote as DeepSeek-V3) for Adam Tauman Kalai's birthday, adding "If you know, just respond with DD-MM." Over three attempts it answered 03-07, 15-06 and 01-01. The real date is in the autumn.
Their argument has two halves. First, pretraining alone produces errors on facts that appear only once in the training text: if 20% of birthday facts appear exactly once, they expect a base model to hallucinate on at least 20% of birthdays. Second, most evaluations score a right answer 1 and a wrong answer or "I don't know" 0, so guessing always pays. In their words, models hallucinate "because the training and evaluation procedures reward guessing over acknowledging uncertainty." Their fix: change how benchmarks score uncertain answers. You will reproduce both halves.
What you will be able to do
- Read a training log: training loss, validation loss, and the point where they part.
- Measure how much of its training text a model repeats word for word.
- Explain why hallucination follows from the training objective and how scoring rules push it either way.
- Read the four numbers on a spec sheet, count parameters from a published config, and turn them into memory for weights and for the KV cache.
1. Training loss and validation loss (Essential)
A score on text the model has already read tells you little. Before trusting any training result, here or in Module 9's fine-tuning, you need a score on unseen text.
TinyLM's corpus is 100,691 tokens. The last 10% become the validation set: never trained on, used only to check. Lesson 1 trained on everything for 600 steps; this run uses the same code and settings, holds out 10%, and runs 1,000 steps.
The score is Lesson 1's loss: minus the log of the probability given to the real next token. Probability 0.81 gives 0.21; probability 0.01 gives 4.6. Averaged over positions, training loss scores training text and validation loss the held-out text.
First, rebuild TinyLM exactly as in Lesson 1. These cells are unchanged.
%pip install -q tokenizers==0.23.3ARTICLES = {
"account-login": ("Sign-in and passwords", "Reset your password from the sign-in page with Forgot password. The reset link is emailed and expires after 30 minutes. After 5 failed sign-in attempts the account is locked for 15 minutes and then unlocks by itself. If you lost your two-factor device, a workspace owner can reset two-factor for you."),
"account-sso": ("Single sign-on", "SSO with SAML or OIDC is available on Business and Enterprise plans. A workspace owner sets it up in Settings > Security. If people are sent back to the sign-in page after signing in, check that your identity provider's certificate has not expired and that the callback URL matches the one in Settings."),
"billing-invoices": ("Invoices and tax details", "Invoices are emailed to the billing contact after every charge and can be downloaded as PDF from Settings > Billing > Invoices. Add a VAT or tax number in Settings > Billing > Tax details; it appears on future invoices. Support can reissue invoices from the last 12 months."),
"billing-plans": ("Plans and seats", "Free: up to 3 users and 5 boards. Team: 12 USD per member per month, or 10 USD billed annually. Business: 24 USD per member per month, or 20 USD billed annually, and adds SSO and audit logs. Enterprise: custom pricing, contact sales. A member added mid-period is charged pro rata on the next invoice. Switching from monthly to annual starts a new annual period that day."),
"billing-refunds": ("Refunds and cancellations", "Cancel at any time from Settings > Billing > Cancel plan. Monthly plans stay active until the end of the period and are not refunded pro rata. Annual plans cancelled within 14 days of purchase or renewal get a full refund; after 14 days, no refund. Duplicate charges are always refunded in full. Only workspace owners and billing admins can cancel or request refunds. Approved refunds reach the original card within 5 to 10 working days."),
"boards-automations": ("Automations and recurring cards", "An automation runs a rule when something happens on a board, such as moving a card when its checklist is complete. Team plans include 1,000 automation runs a month and Business plans 10,000. When the limit is reached, automations pause until the next month and the board shows a banner. A recurring card is an automation on a schedule, such as every Monday."),
"data-privacy": ("Privacy, deletion and the DPA", "Delete your personal account from Profile > Delete account. A deleted workspace can be restored by its owner for 30 days; after that it is purged and cannot be recovered. Owners can download our data processing agreement (DPA) and subprocessor list from Settings > Legal."),
"exports-data": ("Exporting your data", "Owners and admins can export every board, card and comment as CSV or JSON from Settings > Data > Export. Large exports are prepared in the background and emailed as a link that works for 7 days. One board can be exported to CSV, which opens in Excel, from the board menu."),
"feature-requests": ("Feature requests", "Post ideas on the public roadmap at Settings > Help > Ideas, where other customers can vote. We read every idea but do not give release dates. Timeline and Gantt views are not available today."),
"integrations-slack": ("Slack integration", "Connect Slack from Settings > Integrations > Slack, then choose which boards post to which channel. Each connection posts once per update. If updates appear twice, the same board is connected to that channel twice: remove one connection."),
"mobile-app": ("Mobile apps", "The iOS and Android apps need iOS 17 or Android 12 or newer. If the app closes on start, update it from the store and clear its cache. Report a crash from Profile > Help in the app so the log is attached."),
"status-incidents": ("Service status and incidents", "Incidents are posted at status.quillpath.example within 15 minutes of detection and updated at least every hour until resolved. Urgent tickets about an incident on Business and Enterprise plans get a first reply within 1 hour, at any time."),
}
words = {aid: len(text.split()) for aid, (title, text) in ARTICLES.items()}
print(len(ARTICLES), "articles,", sum(words.values()), "words; longest:", max(words, key=words.get), words[max(words, key=words.get)])Code walkthrough
ARTICLESis the help centre from course-guide/06: id, title and text for twelve articles.
import random
QA = [ # (customer question, the agent's fixed answer, taken from the help centre)
("how much does the Team plan cost?", "Team costs 12 USD per member per month, or 10 USD billed annually."),
("how much does the Business plan cost?", "Business costs 24 USD per member per month, or 20 USD billed annually."),
("how long does a reset link last?", "Reset links expire after 30 minutes."),
("why is my account locked?", "After 5 failed sign-in attempts the account is locked for 15 minutes."),
("can I get a refund on my monthly plan?", "Monthly plans are not refunded pro rata."),
("can I get a refund on my annual plan?", "Annual plans cancelled within 14 days of purchase or renewal get a full refund."),
("which plans have SSO?", "SSO is available on Business and Enterprise plans."),
("can I restore a deleted workspace?", "A deleted workspace can be restored by its owner for 30 days."),
]
NAMES, AGENTS = ["Ana", "Leo", "Grace", "Jonas", "Yuki", "Ruth", "Kofi", "Sara"], ["Lena", "Ravi"]
GREETINGS = ["Hi,", "Hello,", "Good morning,", "Quick question:"]
def build_corpus(n=3000, seed=7):
rng = random.Random(seed)
lines = [f"{title}\n{text}\n" for title, text in ARTICLES.values()] # each article once
for _ in range(n):
q, a = rng.choice(QA)
lines.append(f"Customer ({rng.choice(NAMES)}): {rng.choice(GREETINGS)} {q}\nAgent ({rng.choice(AGENTS)}): {a}\n")
return "\n".join(lines)
CORPUS = build_corpus()
print(f"{len(CORPUS):,} characters, {len(CORPUS.split()):,} words")
print(CORPUS[CORPUS.index("Customer"):].split("\n\n")[0])Code walkthrough
build_corpusadds each article once, then 3,000 conversations from the eightQApairs; theseedmakes it identical for everyone.
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders
tok = Tokenizer(models.BPE())
tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tok.decoder = decoders.ByteLevel()
trainer = trainers.BpeTrainer(vocab_size=2048, initial_alphabet=pre_tokenizers.ByteLevel.alphabet())
tok.train_from_iterator([CORPUS], trainer)
ids = tok.encode(CORPUS).ids
print(tok.get_vocab_size(), "tokens learned;", f"{len(ids):,}", "tokens in the corpus")
print(tok.encode("Agent (Lena): Annual plans cancelled within 14 days").tokens)Code walkthrough
- Lesson 1's tokeniser: it prints
1134 tokens learned; 100,691 tokens in the corpus.
import torch, torch.nn as nn, torch.nn.functional as F
class Block(nn.Module):
"""One transformer layer: attention to earlier tokens, then a small network."""
def __init__(self, d=128, heads=4):
super().__init__()
self.heads = heads
self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
self.qkv, self.proj = nn.Linear(d, 3 * d), nn.Linear(d, d)
self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))
def forward(self, x):
b, t, d = x.shape
q, k, v = self.qkv(self.ln1(x)).split(d, dim=2)
q, k, v = (z.view(b, t, self.heads, d // self.heads).transpose(1, 2) for z in (q, k, v))
y = F.scaled_dot_product_attention(q, k, v, is_causal=True) # look left only
x = x + self.proj(y.transpose(1, 2).reshape(b, t, d))
return x + self.mlp(self.ln2(x))Code walkthrough
- One transformer layer, unchanged from Lesson 1.
class TinyLM(nn.Module):
def __init__(self, vocab=2048, context=128, d=128, layers=4, heads=4):
super().__init__()
self.context = context
self.tok_emb = nn.Embedding(vocab, d) # token id -> 128 numbers
self.pos_emb = nn.Embedding(context, d) # position -> 128 numbers
self.blocks = nn.Sequential(*[Block(d, heads) for _ in range(layers)])
self.ln_f = nn.LayerNorm(d)
self.head = nn.Linear(d, vocab, bias=False) # 128 numbers -> one score per token
self.head.weight = self.tok_emb.weight # tied: input and output share one table
for m in self.modules():
if isinstance(m, (nn.Linear, nn.Embedding)):
nn.init.normal_(m.weight, std=0.02) # small random start
def forward(self, idx):
pos = torch.arange(idx.shape[1], device=idx.device)
x = self.blocks(self.tok_emb(idx) + self.pos_emb(pos))
return self.head(self.ln_f(x)) # scores: (batch, tokens, vocab)
torch.manual_seed(0)
model = TinyLM()
print(f"{sum(p.numel() for p in model.parameters()):,} parameters")Code walkthrough
- The same TinyLM and seed as Lesson 1:
1,071,872 parameters.
Now the new part: split, train, and score both sets as training goes.
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
data = torch.tensor(ids)
cut = int(len(data) * 0.9)
train, val = data[:cut], data[cut:] # the last 10% is never trained on
@torch.no_grad()
def avg_loss(tokens):
"""Average next-token loss over a stretch of text, in 128-token windows."""
model.eval()
total, count = 0.0, 0
for s in range(0, len(tokens) - 1, 128):
x, y = tokens[s:s + 128], tokens[s + 1:s + 129]
logits = model(x[:len(y)].unsqueeze(0).to(device))[0]
total += F.cross_entropy(logits, y.to(device), reduction="sum").item()
count += len(y)
model.train()
return total / count
def train_steps(text_ids, steps, every, check):
opt = torch.optim.AdamW(model.parameters(), lr=3e-3)
g = torch.Generator().manual_seed(0)
log = []
for step in range(1, steps + 1):
starts = torch.randint(0, len(text_ids) - 129, (16,), generator=g)
x = torch.stack([text_ids[s:s + 128] for s in starts]).to(device)
y = torch.stack([text_ids[s + 1:s + 129] for s in starts]).to(device)
logits = model(x)
loss = F.cross_entropy(logits.reshape(-1, logits.shape[-1]), y.reshape(-1))
opt.zero_grad(); loss.backward(); opt.step()
if step == 1 or step % every == 0:
log.append((step, avg_loss(check[0]), avg_loss(check[1])))
return logCode walkthrough
valis the last 10% of tokens; no training step reads it.avg_lossaverages the loss per token over 128-token windows.model.eval()and@torch.no_grad()make it a pure measurement.train_stepsis Lesson 1's loop in a function, same batch, window and learning rate. Everyeverysteps it scores the two texts incheck.
log = train_steps(train, steps=1000, every=100, check=(train[-len(val):], val))
print(f"{len(train):,} training tokens, {len(val):,} validation tokens")
print("step train val")
for step, tr, va in log:
print(f"{step:4} {tr:.3f} {va:.3f}")Code walkthrough
checkscores the last 10,070 training tokens beside the 10,070 validation tokens, equal amounts of text.
Both losses fall from about 7.2 to 0.21 and stay within 0.01 of each other. The bump at step 700 is the optimiser overshooting briefly. This table is the training log.
A blind guess over 2,048 slots scores 7.62. At 0.208, TinyLM gives the right next token 0.81 on average. Perplexity, e raised to the loss, puts it on a friendlier scale: 1.23, as if choosing among about one and a quarter tokens.
import math
step, tr, va = log[-1]
print(f"blind guess over 2,048 slots: loss {math.log(2048):.2f}")
print(f"step {step}: validation loss {va:.3f} = perplexity {math.exp(va):.2f}")
print(f"chance of the right next token, on average: {math.exp(-va):.2f}")
print(f"tokens trained on: {1000 * 16 * 128:,} = {1000 * 16 * 128 / len(train):.0f} passes over the training text")Code walkthrough
math.log(2048)is a uniform guess;math.exp(va)is perplexity;math.exp(-va)the average probability of the right token.- The last line gives about 23 epochs: full passes over the training text.
2. Overfitting in a training log (Essential)
Matching losses are less reassuring than they look: the validation text repeats the same eight answers, so it is barely new.
When a model stops learning patterns and starts storing its exact training text, training loss keeps falling while validation loss rises. That is overfitting.
Break it: train on the help centre only
Maya suggests training only on the help centre. Train a fresh TinyLM on eleven articles and validate on the twelfth, boards-automations, which holds T-1010's answer.
held = "boards-automations"
small_train = "\n".join(f"{t}\n{x}\n" for a, (t, x) in ARTICLES.items() if a != held)
small_val = f"{ARTICLES[held][0]}\n{ARTICLES[held][1]}\n"
small_train = torch.tensor(tok.encode(small_train).ids)
small_val = torch.tensor(tok.encode(small_val).ids)
trained = {k: v.clone() for k, v in model.state_dict().items()} # keep the good model
torch.manual_seed(0)
model = TinyLM().to(device) # a fresh, untrained copy
over = train_steps(small_train, steps=300, every=25, check=(small_train, small_val))
best = min(over, key=lambda row: row[2])
for step, tr, va in over:
print(f"{step:4} train {tr:6.3f} val {va:6.3f}" + (" <- best" if (step, tr, va) == best else ""))
model.load_state_dict(trained) # back to the full-corpus modelCode walkthrough
small_trainis eleven articles, 698 tokens;small_valis the held-out article, 80 tokens.trainedsaves the good model's weights;load_state_dictrestores them at the end.bestis the row with the lowest validation loss.
Validation loss improves for 25 steps, 7.40 to 7.17, then climbs to 13.22 while training loss drops to 0.85. With 698 tokens and a million parameters, storing the text is cheaper than learning anything general.
The rule: keep the weights from the lowest validation loss, and stop when it stops improving. Large pretraining runs see most text only a few times and rarely overfit like this; fine-tuning on a few hundred examples does, so Module 9 watches this curve. The Transformers course (Module 6, Lessons 1 and 4) covers the mechanics.
3. Measuring memorisation of training text (Recommended)
If TinyLM "knows" the help centre, it should continue an article from its opening words. You can test that.
Memorisation means reproducing training text word for word. The test: give the model the start of a training document, let it continue greedily, compare with what really came next.
@torch.no_grad()
def greedy(prompt, max_tokens=30):
ids_ = tok.encode(prompt).ids
start = len(ids_)
for _ in range(max_tokens):
probs = torch.softmax(model(torch.tensor([ids_[-128:]], device=device))[0, -1], dim=-1)
best = int(probs.argmax())
if tok.decode([best]) == "\n":
break
ids_.append(best)
return tok.decode(ids_[start:])
model.eval()
answers = sum(greedy(f"Customer (Ana): Hi, {q}\nAgent (Lena):").strip() == a for q, a in QA)
print(f"Q&A answers repeated word for word: {answers} of {len(QA)}")
articles = 0
for title, text in ARTICLES.values():
words = text.split()
prompt = title + "\n" + " ".join(words[:8]) # the first 8 words
articles += greedy(prompt).strip().startswith(" ".join(words[8:14])) # the next 6?
print(f"articles continued word for word: {articles} of {len(ARTICLES)}")
print("each Q&A answer appears", CORPUS.count(QA[0][1]), "times; '1,000 automation runs' appears",
CORPUS.count("1,000 automation runs"), "time")Code walkthrough
greedyis Lesson 1's loop: most likely token, stop at a line break.- The first test asks the eight
QAquestions and compares each reply with the stored answer; the second checks whether each article's next six words follow its first eight. CORPUS.countshows how often each text appears.
All 8 answers come back word for word. None of the 12 articles does. The difference is repetition: each answer appears 356 times, each article once, and T-1010's fact, "1,000 automation runs", appears exactly once. TinyLM stored what it saw hundreds of times and almost nothing of what it saw once.
Large models memorise a far smaller share, but they do: Nasr, Carlini and colleagues (2023) extracted gigabytes of training text from open and production models (Module 11, Lesson 1).
4. Hallucination as a property of the objective (Essential)
Now ask TinyLM T-1010's question. What it does matters for the whole course, because hosted models do it too.
TinyLM answers "Reset links expire after 30 minutes." with 0.495 on its first token. It never stored the fact, so it reaches for the nearest memorised answer. A hallucination is fluent output that the facts or the provided context do not support.
for question in ["how many automation runs does the Team plan include?",
"how much does the Enterprise plan cost?"]:
prompt = f"Customer (Ana): Hi, {question}\nAgent (Lena):"
with torch.no_grad():
probs = torch.softmax(model(torch.tensor([tok.encode(prompt).ids], device=device))[0, -1], dim=-1)
top_p, top_i = probs.topk(3)
print(question)
print(" first token:", [(tok.decode([i]), round(p, 3)) for p, i in zip(top_p.tolist(), top_i.tolist())])
print(" reply:", greedy(prompt).strip())
print("probabilities add up to", round(probs.sum().item(), 4))Code walkthrough
probs.topk(3)lists the three likeliest first tokens; the last line checks that all probabilities add up to 1.
Asked what Enterprise costs, TinyLM puts 0.988 on "Team" and recites the Team price: the question looks like ones it saw 356 times. A low first-token probability is sometimes a warning; a high one is not proof.
The objective rewards a likely next token, and a distribution must put all its weight somewhere. There is no "I don't know" outcome unless the training text taught one, and generation always emits a token. On facts seen once or never, likely and true come apart: Kalai's first half.
The second half is scoring. Under binary grading, right scores 1, wrong and "I don't know" both 0. A guess right 10% of the time averages 0.10; abstaining scores 0. Models tuned to score well learn to guess.
def expected_score(p_right, wrong_costs):
"""Score of guessing when the guess is right with probability p_right. Abstaining scores 0."""
return p_right * 1 - (1 - p_right) * wrong_costs
for p in (0.1, 0.5, 0.8):
binary, penalty = expected_score(p, 0), expected_score(p, 3)
print(f"p(right) {p:.1f}: guess scores {binary:+.2f} under binary grading, "
f"{penalty:+.2f} when a wrong answer costs 3; abstain scores +0.00")Code walkthrough
expected_scoreis the average score of guessing: right with probabilityp_right, minuswrong_costswhen wrong. Abstaining always scores 0.
The paper proposes a confidence target in the instructions: a wrong answer costs points. At a cost of 3, guessing pays only above 0.75 confidence. Try both rules on a hosted model. Quillpath is fictional, so any number is a guess.
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollamaimport os
from langchain.chat_models import init_chat_model
PROVIDER = "groq" # "groq" or "gemini" in Colab; "ollama" on your own computer
MODELS = {"groq": "groq:openai/gpt-oss-20b", "gemini": "google_genai:gemini-3.5-flash-lite", "ollama": "ollama:llama3.2:3b"}
KEY = {"groq": "GROQ_API_KEY", "gemini": "GOOGLE_API_KEY", "ollama": None}[PROVIDER]
if KEY and KEY not in os.environ:
from google.colab import userdata # keys live in Colab's Secrets panel
os.environ[KEY] = userdata.get(KEY)
llm = init_chat_model(MODELS[PROVIDER], temperature=0, max_retries=6)Code walkthrough
- The standard setup cells (course-guide/02);
PROVIDERis the one word you change.
def ask(model, messages):
"""Returns (reply, None), or (None, reason) after two refusals."""
for attempt in range(2): # one retry: the next reply is usually fine
try:
return model.invoke(messages), None
except Exception as err:
last = err
return None, f"provider_error: {type(last).__name__}"Code walkthrough: ask (course-guide/02) retries a reply the provider rejects once, then returns no reply and the reason provider_error.
import re
question = "How many automation runs a month does Quillpath's Team plan include?"
RULES = {
"right 1, else 0": "Reply with a number only. A right number scores 1 point; anything else, including no answer, scores 0.",
"wrong costs 3": "Reply with a number, or with I don't know. A right number scores 1, I don't know scores 0, a wrong number scores -3.",
}
for name, rule in RULES.items():
answers = [ask(llm, f"{question} {rule}") for _ in range(3)]
replies = [reply.text.strip() for reply, err in answers if reply is not None]
guesses = [r for r in replies if re.fullmatch(r"[\d,]+", r)]
print(f"{name:16} guessed a number {len(guesses)} of {len(replies)} times: {guesses}")
for reply, err in answers:
if reply is None:
print(f"{'':16} no answer: {err}")Code walkthrough
- Each rule in
RULESis added to the same question and asked three times, six calls in all.re.fullmatchcounts a reply as a guess only when it is just a number. - A rejected reply prints
no answerand is not counted.
On our Gemini run, the first rule drew a number all three times, 10000, 0 and 10000, all wrong: the help centre says 1,000. The second rule drew none: three times "I don't know". Punishing wrong answers made abstaining the better move, as the paper predicts. Your counts may differ: Gemini ignores temperature. Lesson 3 shows how post-training teaches "I don't know".
Pretraining text also stops at a knowledge cut-off: December 2023 for Llama 3.2, June 2024 for gpt-oss, per their model cards (checked October 2026). Quillpath's data is from June 2026. Lesson 4 measures cut-offs; Module 7 puts the help centre in the prompt.
5. Four numbers on a spec sheet (Essential)
Choosing a model starts with its spec sheet, and four numbers decide most of what you can do with it.
Parameters are the learned numbers; they set memory. A mixture-of-experts model lists total (all in memory) and active (used per token). The context window is the most tokens read at once; vocabulary is the tokeniser's size; the cut-off ends the training text. Module 2 measures the token numbers.
6. Counting parameters from a published config (Recommended)
A headline like "3B" is rounded. Counting from the config shows where the numbers live and checks any open model in a minute.
Start with TinyLM: half its parameters sit in the feed-forward networks.
from collections import Counter
parts = Counter()
for name, p in model.named_parameters():
part = ("embeddings" if "emb" in name else "attention" if "qkv" in name or "proj" in name
else "feed-forward" if "mlp" in name else "layer norms")
parts[part] += p.numel()
total = sum(parts.values())
for part, n in parts.most_common():
print(f"{part:13} {n:>9,} {n / total:.0%}")
print(f"{'total':13} {total:>9,} vocabulary slots 2,048, used {tok.get_vocab_size():,}")Code walkthrough
named_parameters()lists every weight by name, which says its part; the tied output layer counts once. The last line shows 2,048 slots, 1,134 filled.
A published model ships a config.json with width, layers, heads, feed-forward size and vocabulary. Multiply them out for SmolLM2-135M-Instruct, the course's small open model, and Llama 3.2 3B, the Ollama default.
import json
from huggingface_hub import hf_hub_download, get_safetensors_metadata
def count_from_config(c):
"""Parameters of a Llama-style decoder, from the numbers in its config.json."""
d, layers, ffn, vocab = c["hidden_size"], c["num_hidden_layers"], c["intermediate_size"], c["vocab_size"]
head = c.get("head_dim") or d // c["num_attention_heads"]
attn = d * c["num_attention_heads"] * head * 2 + d * c["num_key_value_heads"] * head * 2 # Q, O, then K, V
mlp = 3 * d * ffn # gate, up and down matrices
norms = 2 * d # two norms per layer
embed = vocab * d * (1 if c.get("tie_word_embeddings") else 2)
return layers * (attn + mlp + norms) + embed + d # + the final norm
CONFIGS = {}
for repo in ["HuggingFaceTB/SmolLM2-135M-Instruct", "unsloth/Llama-3.2-3B-Instruct"]:
CONFIGS[repo] = c = json.load(open(hf_hub_download(repo, "config.json")))
stored = sum(get_safetensors_metadata(repo).parameter_count.values())
print(f"{repo.split('/')[1]:24} from config {count_from_config(c):>13,} in the weight files {stored:>13,}")Code walkthrough
hf_hub_downloadfetches the config;get_safetensors_metadatareads the weight files' headers, which record their size, without downloading weights.attncounts key and value matrices at the smaller grouped-query size, where several query heads share one key and value head.embedcounts the table twice when not tied.
Both counts match the weight files exactly: 134,515,008 and 3,212,749,824.
7. Memory arithmetic for model weights (Essential)
Tomasz's question has a one-line answer: weight memory is parameters times bytes per parameter.
Precision is how many bytes store each number: 4 for fp32, 2 for bf16, 1 for int8, half for 4-bit. Storing weights at lower precision is quantisation. Llama 3.2 3B needs 6.43 GB at bf16 and 1.61 GB at 4-bit.
BYTES = {"fp32": 4, "bf16": 2, "int8": 1, "4-bit": 0.5} # bytes per stored number
SIZES = {"SmolLM2-135M": 134_515_008, "Llama 3.2 3B": 3_212_749_824, "gpt-oss-20b": 20.91e9}
print(f"{'weights, GB':14}" + "".join(f"{k:>7}" for k in BYTES))
for name, n in SIZES.items():
print(f"{name:14}" + "".join(f"{n * b / 1e9:7.2f}" for b in BYTES.values()))
print(f"TinyLM at fp32: {total * 4 / 1e6:.1f} MB")
# gpt-oss-20b ships its 19.12B expert weights at 4.25 bits (MXFP4), the other 1.79B at 2 bytes
shipped = 19.12e9 * 4.25 / 8 + (20.91e9 - 19.12e9) * 2
print(f"gpt-oss-20b as shipped, estimated: {shipped / 1e9:.1f} GB = {shipped / 2**30:.1f} GiB")Code walkthrough
- Each cell is parameters times
BYTES, in GB (a billion bytes).shippeduses OpenAI's model card split: 19.12B expert weights at 4.25 bits, the rest at 2 bytes.
gpt-oss-20b would need 41.82 GB at bf16. With its experts at 4.25 bits the estimate is 12.8 GiB, exactly the checkpoint size in OpenAI's model card, which says it runs within 16 GB.
8. KV cache memory for long context (Recommended)
Weights are not the only memory. A model stores a key and a value for every token in every layer, the KV cache, which grows with the text.
Per token it is 2 (key and value) x layers x key-value heads x head size x bytes. For Llama 3.2 3B at bf16: 2 x 28 x 8 x 128 x 2 = 114,688 bytes.
def kv_bytes_per_token(c, bytes_per_number=2):
head = c.get("head_dim") or c["hidden_size"] // c["num_attention_heads"]
return 2 * c["num_hidden_layers"] * c["num_key_value_heads"] * head * bytes_per_number # K and V
for repo, c in CONFIGS.items():
per = kv_bytes_per_token(c)
window = c["max_position_embeddings"]
print(f"{repo.split('/')[1]:24} {per:,} bytes per token; "
f"4,096 tokens {per * 4096 / 1e9:.2f} GB; full {window:,} tokens {per * window / 1e9:.1f} GB")
print("TinyLM (fp32):", f"{2 * 4 * 4 * 32 * 4:,} bytes per token, {2 * 4 * 4 * 32 * 4 * 128:,} bytes for 128 tokens")Code walkthrough
kv_bytes_per_tokenmultiplies the config's numbers as in the figure;max_position_embeddingsis the context window.
At 4,096 tokens the cache is 0.47 GB; at the full 131,072 it is 15.0 GB, over twice the weights. Grouped-query attention (8 key-value heads, not 24) keeps it three times smaller. Module 13 sizes hardware.
9. Awareness card: comparing spec sheets side by side (Optional)
In one table the choices get clearer. SmolLM2 fits anywhere but states no cut-off. Llama 3.2 3B fits a laptop at 4-bit until the context gets long. gpt-oss-20b computes with 3.6B of its 20.9B per token. Gemini 3.5 Flash-Lite lists a 1,048,576-token input limit and no parameter count (checked October 2026).
In production
Teams treat pretraining's leftovers as constraints. A company's own policies, prices and anything after the cut-off go into the prompt from a maintained source (Module 7), never into the model's memory. Money decisions are checked by rules in code (Module 5). Evaluations credit "I don't know" where it is right, so the model is not tuned to guess. Hardware is sized from weights plus the KV cache at the real context length, before anyone buys a GPU.
What you added to the Quillpath support desk
- A training log with a held-out split, and the rule for reading it.
- A measured answer for Maya: TinyLM stored 8 repeated answers and no articles, and hosted models never saw Quillpath, so facts must come from the help centre.
count_from_configandkv_bytes_per_tokenfor Tomasz's model selection, checked against real weight files.
Check yourself
- Training and validation loss both end at about 0.21. Why does that not prove TinyLM generalises?
- In the help-centre run, which step's weights would you keep, and why?
- Why does TinyLM repeat every Q&A answer but no article?
- Under binary grading, why is guessing always at least as good as abstaining?
- A 3B model at bf16 fits in 8 GB. Why might it still run out of memory?
- The validation text repeats the training answers; a low score on near-copies shows memory.
- Step 25, the lowest validation loss (7.17).
- Each answer appears 356 times, each article once.
- A guess scores 0 or 1, never below abstaining's 0.
- The KV cache: 15.0 GB at 131,072 tokens for Llama 3.2 3B.
Exercise
Before running the cell, write down your guesses for Qwen3-8B's bf16 weight memory and KV cache at 32,768 tokens. Then run it. The counts differ by 9,216 parameters: find which small layers the formula leaves out (hint: 36 layers x 2 x 128).
repo = "Qwen/Qwen3-8B"
c = json.load(open(hf_hub_download(repo, "config.json")))
mine = count_from_config(c)
stored = sum(get_safetensors_metadata(repo).parameter_count.values())
print(f"from config {mine:,} in the weight files {stored:,}")
assert abs(mine - stored) / stored < 0.001, "more than 0.1% apart: check tied embeddings and head_dim"
print(f"bf16 weights {stored * 2 / 1e9:.1f} GB; KV cache at 32,768 tokens {kv_bytes_per_token(c) * 32768 / 1e9:.1f} GB")Code walkthrough
- Section 6's steps on a new config. The
assertpasses within 0.1%; the last line answers your guesses.
Key terms
| Term | Meaning |
|---|---|
| Validation set | Text held out from training, used only to check the model |
| Perplexity | e raised to the loss: how many tokens the model is choosing among, on average |
| Overfitting | Training loss falls while validation loss rises: the model stores text instead of patterns |
| Memorisation | Reproducing training text word for word |
| Hallucination | Fluent output that facts or the provided context do not support |
| Knowledge cut-off | The date the training text stops |
| Precision | Bytes used to store each number; lower precision is quantisation |
| KV cache | Stored keys and values for every token, growing with context length |
Sources
- Kalai, Nachum, Vempala, Zhang, "Why Language Models Hallucinate", September 2025. https://arxiv.org/abs/2509.04664
- Nasr, Carlini and others, "Scalable Extraction of Training Data from (Production) Language Models", November 2023. https://arxiv.org/abs/2311.17035
- OpenAI, "gpt-oss-120b & gpt-oss-20b Model Card", August 2025. https://arxiv.org/abs/2508.10925
- Meta, Llama 3.2 model card, 2024. https://github.com/meta-llama/llama-models/blob/main/models/llama3_2/MODEL_CARD.md
- Hugging Face, SmolLM2-135M-Instruct model card and config, checked October 2026. https://huggingface.co/HuggingFaceTB/SmolLM2-135M-Instruct
- Google, Gemini 3.5 Flash-Lite model page, checked October 2026. https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite
- Transformers course, Modules 6 and 8, for the training mechanics.