VKVlad KulikovPublic / lectures
Modern LLMs20 / 20

Module 20. The complete LLM: from tokens to training and generation

Read the explanations and code in the existing notebooks, or download the files. Code does not run on this site.

Download course files (.zip) 2588.1 KB

The archive preserves the source folder structure, both languages, and supporting files.

M20. The complete LLM: a worked computation

Main module notebook

Download .ipynb module_20.ipynb · 32.8 KB

Show notebook text

M20. The complete LLM: a worked computation

Follow tokens through probabilities, derivatives, weight updates, and generation. This synthetic 50-parameter fixture is designed for inspectable arithmetic, not language competence. Code and data are embedded. No GPU, API, external files, or downloaded weights are required; installation runs only for missing libraries.

First pass: steps 1–3, 6–8, and 10–11. Second pass: add derivatives and optimization in steps 4–5, then routing in step 9. Execute the steps of your chosen route in order before experimenting. Reference checks use the original matrices. Separate experiment cells let you change temperature and top-k in step 8, or inputs and targets in step 11; their results need not match the original reference values.

Python
import importlib.util
import subprocess
import sys
missing = [name for name in ("numpy", "torch", "matplotlib") if importlib.util.find_spec(name) is None]
if missing:
    subprocess.check_call([sys.executable, "-m", "pip", "install", *missing],
                          creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
import numpy as np
import torch
import matplotlib
from IPython import get_ipython
ipython = get_ipython()
if ipython is not None and getattr(ipython, "kernel", None) is not None:
    ipython.run_line_magic("matplotlib", "inline")
import matplotlib.pyplot as plt
torch.set_num_threads(1)
torch.manual_seed(20)
np.set_printoptions(precision=6, suppress=True)
print("NumPy", np.__version__, "PyTorch", torch.__version__, "device: CPU")

1. Define the complete path

The next cell defines functions; it does not train a model. forward returns both outputs and intermediate values. As in the lecture, rows are positions and linear matrices use input-by-output orientation. The separately written torch_forward uses float64. Finite differences will run through NumPy without consulting the PyTorch graph.

Python
"""M20: one complete pre-norm decoder block, with inspectable intermediates.

Rows are token representations; linear weights have shape [input, output].
This synthetic fixture illustrates arithmetic, not language competence.
"""
import numpy as np

EPS = 1e-6
INPUT = np.array([0, 1, 2], dtype=np.int64)
TARGET = np.array([1, 2, 3], dtype=np.int64)
TOKENS = ("BOS", "A", "B", "EOS")


def fixture():
    """Return fresh arrays: vocabulary 4, width 2, one head, FFN width 2."""
    return {
        "E": np.array([[1., 0.], [0., 1.], [1., 1.], [-1., 0.]]),
        "Q": .5 * np.eye(2), "K": .5 * np.eye(2),
        "V": np.diag([.5, 1.]), "O": .25 * np.eye(2),
        "gate": np.array([[.5, -.25], [.25, .5]]),
        "up": np.array([[1., .5], [-.5, 1.]]),
        "down": .25 * np.eye(2),
        "na": np.ones(2), "nf": np.ones(2), "nout": np.ones(2),
        "U": np.array([[0., .5, -.5, .25], [0., -.25, .5, -.5]]),
    }


def rms(x, gain):
    return x / np.sqrt(np.mean(x * x, axis=-1, keepdims=True) + EPS) * gain


def softmax(x):
    e = np.exp(x - np.max(x, axis=-1, keepdims=True))
    return e / e.sum(axis=-1, keepdims=True)


def silu(x):
    return x / (1 + np.exp(-x))


def rope(x, positions):
    """[heads, time, even head_dim]; pair first and second halves."""
    dh = x.shape[-1]
    if dh % 2:
        raise ValueError("RoPE requires an even head dimension")
    angle = np.asarray(positions)[:, None] * 10000. ** (-np.arange(0, dh, 2) / dh)
    angle = np.concatenate([angle, angle], axis=-1)
    rotated = np.concatenate([-x[..., dh // 2:], x[..., :dh // 2]], axis=-1)
    return x * np.cos(angle) + rotated * np.sin(angle)


def forward(w, ids, hq=1, hkv=1, cache=None):
    """A single layer; cache contains already rotated K and unrotated V.

    Cache is valid only for these weights and this uninterrupted prefix.
    Tied embeddings are selected by omitting U from w.
    """
    ids = np.asarray(ids, dtype=np.int64)
    if ids.ndim != 1 or len(ids) == 0:
        raise ValueError("ids must be a nonempty one-dimensional sequence")
    if np.any(ids < 0) or np.any(ids >= len(w["E"])):
        raise ValueError("token id outside the vocabulary")
    h = w["E"][ids]
    t, d = h.shape
    if hq < 1 or hkv < 1 or hq % hkv or d % hq:
        raise ValueError("head counts must divide the query count and model width")
    dh = d // hq
    offset = 0 if cache is None else cache[0].shape[1]
    positions = np.arange(offset, offset + t)
    x = rms(h, w["na"])
    q = rope((x @ w["Q"]).reshape(t, hq, dh).transpose(1, 0, 2), positions)
    k = rope((x @ w["K"]).reshape(t, hkv, dh).transpose(1, 0, 2), positions)
    v = (x @ w["V"]).reshape(t, hkv, dh).transpose(1, 0, 2)
    if cache is not None:
        k = np.concatenate([cache[0], k], axis=1)
        v = np.concatenate([cache[1], v], axis=1)
    kr, vr = (np.repeat(a, hq // hkv, axis=0) for a in (k, v))
    scores = q @ kr.transpose(0, 2, 1) / np.sqrt(dh)
    mask = np.arange(k.shape[1])[None, :] <= positions[:, None]
    a = softmax(np.where(mask[None], scores, -np.inf))
    mixed = (a @ vr).transpose(1, 0, 2).reshape(t, d)
    z = h + mixed @ w["O"]
    n = rms(z, w["nf"])
    gate, up = n @ w["gate"], n @ w["up"]
    f = (silu(gate) * up) @ w["down"]
    h1 = z + f
    final = rms(h1, w["nout"])
    logits = final @ (w["U"] if "U" in w else w["E"].T)
    return dict(H0=h, X=x, Q=q, K=k, V=v, scores=scores, A=a,
                mixed=mixed, Z=z, N=n, gate=gate, up=up, F=f, H1=h1,
                final=final, logits=logits, probabilities=softmax(logits), cache=(k, v))


def cross_entropy(logits, targets, mask=None):
    """Mean over explicitly selected targets; never mean over padding."""
    targets = np.asarray(targets, dtype=np.int64)
    if logits.ndim != 2 or targets.shape != (len(logits),):
        raise ValueError("one target is required for every logit row")
    selected = np.ones(len(targets), dtype=bool) if mask is None else np.asarray(mask, dtype=bool)
    if selected.shape != targets.shape or not selected.any():
        raise ValueError("the target mask must select at least one row")
    if np.any(targets[selected] < 0) or np.any(targets[selected] >= logits.shape[1]):
        raise ValueError("selected target outside the vocabulary")
    x = logits[selected]
    m = x.max(axis=-1)
    return np.mean(m + np.log(np.exp(x - m[:, None]).sum(axis=-1))
                   - x[np.arange(len(x)), targets[selected]])


def finite_gradients(w, ids=INPUT, targets=TARGET, step=1e-5):
    """Central differences for every scalar; restore each weight after use."""
    result = {}
    for name, value in w.items():
        g = np.empty_like(value)
        for index in np.ndindex(value.shape):
            old = value[index]
            try:
                value[index] = old + step
                plus = cross_entropy(forward(w, ids)["logits"], targets)
                value[index] = old - step
                minus = cross_entropy(forward(w, ids)["logits"], targets)
            finally:
                value[index] = old
            g[index] = (plus - minus) / (2 * step)
        result[name] = g
    return result


def adamw_first_step(w, grads, lr=.01, decay=.1, clip=1., eps=1e-8):
    """Zero initial moments, bias correction: m_hat=g, v_hat=g^2.

    Only matrices receive weight decay. Clipping precedes moment updates.
    The first bias-corrected step is independent of beta1 and beta2.
    """
    norm = np.sqrt(sum(np.sum(g * g) for g in grads.values()))
    scale = min(1., clip / max(norm, 1e-300))
    updated = {}
    for name, value in w.items():
        g = grads[name] * scale
        wd = decay if value.ndim == 2 else 0.
        updated[name] = (1 - lr * wd) * value - lr * g / (np.abs(g) + eps)
    return updated, float(norm), float(scale)


def torch_forward(w, ids, hq=1, hkv=1):
    """Independent float64 autograd path; PyTorch is imported only when used."""
    import torch
    import torch.nn.functional as F
    ids = torch.as_tensor(ids, dtype=torch.long)
    h = w["E"][ids]
    t, d = h.shape
    dh = d // hq
    def norm(a, gain):
        return a * torch.rsqrt(a.square().mean(-1, keepdim=True) + EPS) * gain
    def rotate(a):
        freq = 10000. ** (-torch.arange(0, dh, 2, dtype=h.dtype) / dh)
        angle = torch.arange(t, dtype=h.dtype)[:, None] * freq
        angle = torch.cat([angle, angle], dim=-1)
        return a * angle.cos() + torch.cat([-a[..., dh//2:], a[..., :dh//2]], -1) * angle.sin()
    n = norm(h, w["na"])
    q = rotate((n @ w["Q"]).view(t, hq, dh).transpose(0, 1))
    k = rotate((n @ w["K"]).view(t, hkv, dh).transpose(0, 1))
    v = (n @ w["V"]).view(t, hkv, dh).transpose(0, 1)
    k, v = [a.repeat_interleave(hq // hkv, dim=0) for a in (k, v)]
    scores = q @ k.transpose(-2, -1) / dh ** .5
    scores = scores.masked_fill(torch.ones(t, t, dtype=torch.bool).triu(1), -torch.inf)
    mixed = (scores.softmax(-1) @ v).transpose(0, 1).reshape(t, d)
    z = h + mixed @ w["O"]
    n = norm(z, w["nf"])
    h1 = z + (F.silu(n @ w["gate"]) * (n @ w["up"])) @ w["down"]
    return norm(h1, w["nout"]) @ (w["U"] if "U" in w else w["E"].T)


def autograd_gradients(w, ids=INPUT, targets=TARGET):
    import torch
    params = {k: torch.tensor(v, dtype=torch.float64, requires_grad=True) for k, v in w.items()}
    logits = torch_forward(params, ids)
    loss = torch.nn.functional.cross_entropy(logits, torch.tensor(targets, dtype=torch.long))
    loss.backward()
    return {k: v.grad.detach().numpy().copy() for k, v in params.items()}


def sampling_distribution(logits, temperature=1., top_k=None):
    """One row; stable token-index tie rule. Distribution q, not base p."""
    if temperature <= 0 or np.asarray(logits).ndim != 1:
        raise ValueError("use a positive temperature and one logit row")
    z = np.asarray(logits) / temperature
    if top_k is not None:
        if not isinstance(top_k, int) or not 1 <= top_k <= len(z):
            raise ValueError("top_k must be an integer in [1, vocabulary_size]")
        keep = np.argsort(-z, kind="stable")[:top_k]
        z = np.where(np.isin(np.arange(len(z)), keep), z, -np.inf)
    return softmax(z)


def moe_weights(logits, selection_bias, k=2, eps=1e-9):
    """MiniLLM's sigmoid routing: bias changes selection, not mixture weights."""
    p = 1 / (1 + np.exp(-np.asarray(logits)))
    selected = np.argsort(-(p + selection_bias), kind="stable")[:k]
    return selected, p[selected] / (p[selected].sum() + eps)


def architecture_examples():
    """Small exact SSM recurrence and a two-step discrete reverse chain."""
    state, states = 0., []
    for u in (1., 0., -1.):
        state = .5 * state + u
        states.append(state)
    # Four binary sequences: 00, 01, 10, 11. At s=2 both positions are masked.
    first = np.array([.6*.3, .6*.7, .4*.3, .4*.7])
    # At the next refinement each bit is independently kept with probability .8.
    bits = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
    distance = (bits[:, None, :] != bits[None, :, :]).sum(-1)
    kernel = .8 ** (2-distance) * .2 ** distance
    return {"ssm_states": states, "diffusion_first": first.tolist(),
            "diffusion_kernel": kernel.tolist(), "diffusion_final": (first @ kernel).tolist()}


def reference_metrics():
    w = fixture()
    trace = forward(w, INPUT)
    grads = autograd_gradients(w)
    changed, norm, scale = adamw_first_step(w, grads)
    out = {k: v.tolist() for k, v in trace.items() if isinstance(v, np.ndarray)}
    out.update(parameter_count=sum(v.size for v in w.values()),
               loss=float(cross_entropy(trace["logits"], TARGET)),
               target_losses=(-np.log(trace["probabilities"][np.arange(3), TARGET])).tolist(),
               gradient_norm=norm, clip_scale=scale,
               gradients={k: v.tolist() for k, v in grads.items()},
               loss_after_step=float(cross_entropy(forward(changed, INPUT)["logits"], TARGET)),
               probabilities_after_step=forward(changed, INPUT)["probabilities"].tolist(),
               q_temperature_half_top2=sampling_distribution(trace["logits"][-1], .5, 2).tolist(),
               architecture_examples=architecture_examples())
    return out
Python
baseline = fixture()
trace = forward(baseline, INPUT)
assert sum(v.size for v in baseline.values()) == 50
for name in ("H0", "X", "Q", "K", "V", "A", "Z", "N", "gate", "up", "F", "H1", "final"):
    print(name, trace[name].shape, "\n", trace[name])

2. Attention, logits, and probabilities

Every attention row sums to one, with zeros above the diagonal. The final query mixes three value rows; the first query can read only its own. The output probabilities use the order BOS, A, B, EOS.

Python
np.testing.assert_allclose(trace["A"].sum(-1), 1.)
assert np.all(np.triu(trace["A"][0], 1) == 0)
print("logits\n", trace["logits"])
print("p\n", trace["probabilities"])
fig, ax = plt.subplots(figsize=(6, 4))
ax.imshow(trace["A"][0], vmin=0, vmax=1, cmap="Blues")
for i in range(3):
    for j in range(3):
        ax.text(j, i, f"{trace['A'][0,i,j]:.3f}", ha="center", va="center",
                color="white" if trace["A"][0,i,j] > .6 else "#2E3440")
ax.set(xticks=range(3), yticks=range(3), xlabel="Key position", ylabel="Query position", title="Causal attention")
fig.tight_layout()
plt.show()

3. Target shift and loss selection

Inputs [BOS, A, B] predict [A, B, EOS]. First average all three losses, then select only the last target. This changes the learning objective while leaving logits intact. A loss mask does not remove context tokens. Selecting no targets should raise a clear error rather than produce NaN.

Python
loss_before = cross_entropy(trace["logits"], TARGET)
loss_eos = cross_entropy(trace["logits"], TARGET, [False, False, True])
np.testing.assert_allclose(loss_before, 1.1093739242148548, atol=1e-10)
np.testing.assert_allclose(loss_eos, 1.6955297196508368, atol=1e-10)
print("all targets:", loss_before, "EOS only:", loss_eos)
try:
    cross_entropy(trace["logits"], TARGET, [False]*3)
except ValueError as error:
    print(type(error).__name__, str(error))
else:
    raise AssertionError("empty target mask was accepted")

4. Check every derivative

Perturbing a parameter in both directions provides an independent estimate of its derivative. Check all 50 scalars, then remove the independent U and check the tied model's 42 parameters. This also checks accumulation through E's two roles. An excessively small finite-difference step can amplify roundoff; we use 10−510^{-5}.

Python
gradients = autograd_gradients(baseline)
finite = finite_gradients(baseline)
max_error = max(np.max(np.abs(gradients[k] - finite[k])) for k in baseline)
assert max_error < 2e-8
print("50 parameters; max gradient error:", max_error)
tied = fixture()
del tied["U"]
tied_auto, tied_fd = autograd_gradients(tied), finite_gradients(tied)
tied_error = max(np.max(np.abs(tied_auto[k] - tied_fd[k])) for k in tied)
assert tied_error < 2e-8
print("42 tied parameters; max gradient error:", tied_error)
logit_gradient = trace["probabilities"].copy()
logit_gradient[np.arange(len(TARGET)), TARGET] -= 1
logit_gradient /= len(TARGET)
np.testing.assert_allclose(logit_gradient.sum(-1), 0., atol=1e-15)
print("gradient of loss w.r.t. logits:\n", logit_gradient)

5. One AdamW update

Start from zero moments. Matrices receive decay 0.1; normalization gains receive none. The global gradient norm is below the threshold of 1. Compare the first-step formula with an actual optimizer, checking every updated array. The resulting loss is measured on the original training sequence, not on unseen data.

Python
updated, gradient_norm, clip_scale = adamw_first_step(baseline, gradients)
params = {k: torch.tensor(v, dtype=torch.float64, requires_grad=True) for k, v in baseline.items()}
optimizer = torch.optim.AdamW([
    {"params": [p for p in params.values() if p.ndim == 2], "weight_decay": .1},
    {"params": [p for p in params.values() if p.ndim == 1], "weight_decay": 0.}],
    lr=.01, betas=(.9, .95), eps=1e-8)
loss_torch = torch.nn.functional.cross_entropy(torch_forward(params, INPUT), torch.tensor(TARGET))
optimizer.zero_grad()
loss_torch.backward()
for p in params.values():
    p.grad.mul_(clip_scale)
optimizer.step()
for k, p in params.items():
    np.testing.assert_allclose(p.detach().numpy(), updated[k], atol=1e-10)
loss_after = cross_entropy(forward(updated, INPUT)["logits"], TARGET)
np.testing.assert_allclose(loss_after, 1.0848315484318878, atol=1e-10)
print("gradient norm:", gradient_norm, "scale:", clip_scale)
print("loss:", loss_before, "->", loss_after)

6. Repeated updates retain optimizer state

A fresh optimizer now takes 80 consecutive steps. This deliberately overfits one sequence; it tests a training loop, not language understanding. These settings differ from the single-step experiment: the learning rate is 0.03 and weight decay is disabled.

Python
training_params = {k: torch.tensor(v, dtype=torch.float64, requires_grad=True) for k, v in fixture().items()}
training_optimizer = torch.optim.AdamW(list(training_params.values()), lr=.03, weight_decay=0.)
training_losses = []
for step in range(80):
    training_optimizer.zero_grad()
    loss = torch.nn.functional.cross_entropy(torch_forward(training_params, INPUT), torch.tensor(TARGET))
    training_losses.append(loss.item())
    loss.backward()
    torch.nn.utils.clip_grad_norm_(list(training_params.values()), 1.)
    training_optimizer.step()
with torch.no_grad():
    final_training_loss = torch.nn.functional.cross_entropy(torch_forward(training_params, INPUT), torch.tensor(TARGET)).item()
training_losses.append(final_training_loss)
assert np.isfinite(training_losses).all() and final_training_loss < training_losses[0]
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(training_losses, color="#5E81AC")
ax.set(xlabel="Training step", ylabel="Mean loss, nats", title="Fitting one sequence")
fig.tight_layout()
plt.show()
print("training loss:", training_losses[0], "->", final_training_loss)

7. Causality and cached execution

Change the final input and check that earlier logits stay fixed. Then process the original prefix one token at a time, carrying K/V between calls, and check a two-token suffix as well. This cache belongs to the baseline weights; it would be stale after training. The final negative control shows why resetting the RoPE position changes attention scores.

Python
changed_input = forward(baseline, [0, 1, 3])
np.testing.assert_allclose(changed_input["logits"][:2], trace["logits"][:2])
cache, cached_logits = None, []
for token in INPUT:
    current = forward(baseline, [token], cache=cache)
    cached_logits.append(current["logits"])
    cache = current["cache"]
np.testing.assert_allclose(np.concatenate(cached_logits), trace["logits"], atol=1e-12)
prefix = forward(baseline, INPUT[:1])
suffix = forward(baseline, INPUT[1:], cache=prefix["cache"])
np.testing.assert_allclose(suffix["logits"], trace["logits"][1:], atol=1e-12)
wrong_q = rope((trace["X"][2:] @ baseline["Q"])[None], [0])
wrong_scores = wrong_q @ trace["K"].transpose(0, 2, 1) / np.sqrt(2)
offset_error = np.max(np.abs(wrong_scores[0, 0] - trace["scores"][0, 2]))
assert offset_error > .1
print("cache scalars:", sum(a.size for a in cache), "wrong-offset score difference:", offset_error)

8. A decoding distribution and a short generation

First reproduce top-2 at temperature 0.5 for the original model. Then generate greedily from the briefly trained weights. These are distinct experiments: the first matches the lecture table; the second uses the learned checkpoint. A length limit guarantees that the loop ends even if EOS never appears.

Python
q = sampling_distribution(trace["logits"][-1], temperature=.5, top_k=2)
np.testing.assert_allclose(q, [0., .5631163013756344, .4368836986243656, 0.])
print("p:", trace["probabilities"][-1], "q:", q)
trained = {k: p.detach().numpy().copy() for k, p in training_params.items()}
generated = [0]
generation_cache = None
for _ in range(12):
    output = forward(trained, [generated[-1]], cache=generation_cache)
    generation_cache = output["cache"]
    next_token = int(np.argmax(output["logits"][-1]))
    generated.append(next_token)
    if next_token == 3:
        break
print(" ".join(TOKENS[i] for i in generated))

Your experiment: temperature and top-k

Use the original weights and the final prefix [BOS, A, B] again. The next cell reads its logits from trace, which the short training run did not modify. Set experiment_temperature to a positive finite number. For experiment_top_k, choose an integer from 1 to 4, or None to retain the full vocabulary. The checks require valid probabilities summing to one; they do not require the reference distribution from the earlier cell.

Compare temperatures 0.5, 1.0, and 2.0 with top-2. How does the balance between A and B change? Then try temperature 1.0 with None: the selection distribution should match the base model distribution. Explain why top-2 still assigns EOS zero selection probability while its model probability remains positive. Only the selection rule changes in this experiment; weights and logits stay fixed.

Python
experiment_temperature = 1.0
experiment_top_k = 2
if not np.isfinite(experiment_temperature) or experiment_temperature <= 0:
    raise ValueError("Temperature must be positive and finite")
experiment_q = sampling_distribution(
    trace["logits"][-1], temperature=experiment_temperature, top_k=experiment_top_k)
assert np.isfinite(experiment_q).all() and (experiment_q >= 0).all()
np.testing.assert_allclose(experiment_q.sum(), 1., rtol=1e-12, atol=1e-12)
print("Temperature:", experiment_temperature, "top-k:", experiment_top_k)
print("Token   model p    selection q")
for token, model_p, selection_q in zip(TOKENS, trace["probabilities"][-1], experiment_q):
    print(f"{token:<5}   {model_p:.6f}   {selection_q:.6f}")

9. MoE routing separates selection from weighting

Balancing offsets affect expert selection; mixture weights use the original sigmoid scores. This cell reproduces the router calculation in Section 15, not a second trained MoE. A complete sublayer multiplies these weights by the corresponding expert outputs and adds any shared experts.

Python
selected, routing_weights = moe_weights([0., 1., -1.], np.array([0., -.5, .5]), k=2)
assert selected.tolist() == [2, 0]
np.testing.assert_allclose(routing_weights, [.3497554090542189, .6502445909457811])
print("selected:", selected, "weights:", routing_weights)

10. State recurrence and hidden refinement paths

The scalar SSM carries a state across input positions. The second calculation sums paths through a two-step reverse chain over complete binary strings. Its transitions are hand-specified, not learned by a diffusion denoiser. Check the probability of 00 with a scalar sum, then explain what a time step means in each experiment.

Python
examples = architecture_examples()
np.testing.assert_allclose(examples["ssm_states"], [1., .5, -.75])
kernel = np.array(examples["diffusion_kernel"])
first = np.array(examples["diffusion_first"])
np.testing.assert_allclose(kernel.sum(-1), 1.)
final = first @ kernel
np.testing.assert_allclose(final, [.2128, .3472, .1672, .2728])
manual_00 = .18*.64 + .42*.16 + .12*.16 + .28*.04
np.testing.assert_allclose(manual_00, final[0])
print("SSM:", examples["ssm_states"])
print("reverse chain:", first, "->", final, "sum:", final.sum())

11. Your experiment: change an input or a target

Use a nonempty input with IDs from 0 to 3 and one target per input position. For sequence [0, 2, 1, 3], use inputs [0, 2, 1] and targets [2, 1, 3]. You may also test different targets for the same input. Results here are recomputed rather than compared with the lecture's fixed loss.

Before running, predict which rows will change, whether EOS will become more likely, and how loss will move. Explain any difference between your prediction and the output. Do not reuse a cache from another input.

Python
experiment_ids = np.array([0, 2, 1], dtype=np.int64)
experiment_targets = np.array([2, 1, 3], dtype=np.int64)
experiment_weights = fixture()
experiment_trace = forward(experiment_weights, experiment_ids)
experiment_loss = cross_entropy(experiment_trace["logits"], experiment_targets)
experiment_gradients = autograd_gradients(experiment_weights, experiment_ids, experiment_targets)
print("inputs:", experiment_ids, "targets:", experiment_targets)
print("p:\n", experiment_trace["probabilities"])
print("loss:", experiment_loss)
print("gradient norm:", np.sqrt(sum(np.sum(g*g) for g in experiment_gradients.values())))

What these checks establish

We followed lookup through loss and a parameter update, checked every derivative, and tested tied weights, causality, and cache equivalence. We also separated token selection from prediction and reproduced arithmetic for two alternative constructions. This scale validates computational mechanisms; language quality requires data and a separate evaluation such as the one developed in Module 19.

Capstone · Notebook 1. Pretraining

MiniLLM · Capstone

Download .ipynb 01_pretrain.ipynb · 43.2 KB

Show notebook text

Capstone · Notebook 1. Pretraining

Files for this notebook. Download course-materials.zip from the course website and extract the complete archive, preserving the capstone folder. In Colab, upload the same ZIP when the notebook prompts for files.

Modern LLMs course · capstone · edition 2026.10.3

What happens here. We walk the whole road from raw documents to a trained course reference language model: the corpus cleaning pipeline (M9), byte-level BPE from scratch (M2), sharding the token stream, the 20× Chinchilla-style reference budget arithmetic (M9), and the run itself with Muon and a WSD schedule (M9).

Assumed background. Modules 2–9. Module 1 helps but is not required — at the end we read our own model with the same passport approach it teaches.

The Colab contract. The mandatory path runs on CPU and needs no network: by default the corpus is synthetic and generated right here. Downloading real data is a separate cell behind a flag that is off. No number in this notebook depends on a network call.

Profiles. SMOKE (the default) is a short end-to-end mechanism check. FULL uses profile M and the 20× Chinchilla-style reference budget. Runtime is hardware- and software-dependent: auto selects BF16 on capable GPUs, FP16 with gradient scaling on older CUDA devices such as T4, and FP32 on CPU.

On a first pass, run the cells in order and connect the code to the explanations. On a second, change one setting, retain the results, and state which claim the comparison can test.

1. Environment

Python
import importlib
import sys
import subprocess
import zipfile
from pathlib import Path

ARCHIVE_GLOB = "modern-llms-capstone-*.zip"

def _activate_capstone_root(candidate: Path):
    package_init = candidate / "minillm" / "__init__.py"
    if not package_init.exists():
        return None
    root = str(candidate.resolve())
    if root not in sys.path:
        sys.path.insert(0, root)
    return root

def _find_capstone_root(search_root: Path):
    direct = _activate_capstone_root(search_root)
    if direct:
        return direct
    for package_root in (search_root / "capstone" / "minillm" / "en", search_root / "minillm" / "en"):
        found = _activate_capstone_root(package_root)
        if found:
            return found
    for child in sorted(search_root.glob("modern-llms-capstone-*")):
        found = _activate_capstone_root(child)
        if found:
            return found
    return None

def _safe_extract(archive_path: Path, destination: Path):
    destination = destination.resolve()
    with zipfile.ZipFile(archive_path) as archive:
        for member in archive.infolist():
            member_path = Path(member.filename)
            if member_path.is_absolute() or ".." in member_path.parts:
                raise RuntimeError(f"unsafe path in archive: {member.filename}")
        archive.extractall(destination)

def ensure_minillm():
    for candidate in (Path.cwd(), *Path.cwd().parents):
        found = _find_capstone_root(candidate)
        if found:
            return "project package: " + found

    try:
        import minillm  # noqa: F401
        return "minillm is already importable"
    except ImportError:
        pass

    archives = sorted(Path.cwd().glob(ARCHIVE_GLOB)) + sorted(Path.cwd().glob("course-materials.zip"))
    if not archives:
        try:
            from google.colab import files
        except ImportError:
            files = None
        if files is not None:
            print("Upload course-materials.zip from the course notebook page. The directory structure will be restored automatically.")
            files.upload()
            archives = sorted(Path.cwd().glob(ARCHIVE_GLOB)) + sorted(Path.cwd().glob("course-materials.zip"))

    if archives:
        archive_path = archives[-1]
        destination = Path.cwd() / ".capstone_runtime"
        destination.mkdir(exist_ok=True)
        _safe_extract(archive_path, destination)
        root = _find_capstone_root(destination)
        if root:
            return "project archive: " + str(archive_path)
        matches = sorted(destination.glob("*/minillm/__init__.py"))
        if len(matches) != 1:
            raise RuntimeError(
                f"expected one minillm package in {archive_path.name}, "
                f"found {len(matches)}"
            )
        root = _activate_capstone_root(matches[0].parent.parent)
        if root:
            return "project archive: " + str(archive_path)

    raise RuntimeError("minillm was not found. Download course-materials.zip from the course notebook page and either extract it in full or upload the ZIP to the current Colab session.")

print(ensure_minillm())

for package in ("torch", "numpy", "matplotlib", "regex"):
    try:
        importlib.import_module(package)
    except ImportError:
        subprocess.run(
            [sys.executable, "-m", "pip", "install", "-q", package],
            check=True,
        )

import os, torch, numpy as np, hashlib
print("torch", torch.__version__, "| CUDA:", torch.cuda.is_available())
Python
# ------------------------------------------------------------------ run profile
PROFILE = "SMOKE"          # "SMOKE" | "FULL"
USE_REAL_CORPUS = False    # network download of real data — see §3
SEED = 0

torch.manual_seed(SEED)
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
from minillm.precision import resolve_compute_dtype
COMPUTE_DTYPE = resolve_compute_dtype(DEVICE, "auto")

SETTINGS = {
    "SMOKE": dict(docs=8_000, vocab_size=2_048, seq_len=128,
                  model_profile="S", steps=250, micro_batch_size=8, grad_accum=1),
    "FULL":  dict(docs=200_000, vocab_size=16_384, seq_len=512,
                  model_profile="M", steps=18_886, micro_batch_size=16, grad_accum=4),
}[PROFILE]
if os.getenv("CAPSTONE_FAST_VALIDATION") == "1":
    SETTINGS = {**SETTINGS, "docs": min(SETTINGS["docs"], 800),
                "vocab_size": min(SETTINGS["vocab_size"], 512),
                "steps": min(SETTINGS["steps"], 12),
                "micro_batch_size": min(SETTINGS["micro_batch_size"], 2),
                "grad_accum": 1}
print(PROFILE, "on", DEVICE, "with", COMPUTE_DTYPE, "compute ->", SETTINGS)

2. The corpus and its cleaning funnel

The order of pipeline stages is not arbitrary — it runs cheap to expensive (M9). Heuristics cost a pass over a line, dedup costs hashing, a model-based quality classifier costs inference. So the classifier only ever sees what the earlier stages let through, and the pipeline's price is set by those.

The synthetic corpus below is assembled from a slot grammar in two languages and deliberately spoiled: it contains duplicates, near-duplicates, documents that are too short, and symbol junk. That is the raw material a funnel needs — on clean data there is nothing to demonstrate.

Python
import random
from minillm.corpus import FilterConfig, pipeline

RU_SLOTS = dict(
    subject=["языковая модель", "трансформер", "механизм внимания", "токенизатор",
             "оптимизатор", "нормализация", "остаточная связь", "роутер экспертов",
             "кэш ключей", "эмбеддинг", "спекулятивный декод", "квантизация"],
    verb=["предсказывает", "обрабатывает", "сокращает", "ускоряет", "стабилизирует",
          "ограничивает", "восстанавливает", "перераспределяет", "измеряет"],
    obj=["следующий токен", "длинный контекст", "стоимость шага", "объём памяти",
         "масштаб активаций", "разброс градиентов", "поток данных", "качество ответа",
         "нагрузку экспертов", "пропускную способность"],
    tail=["на длинных последовательностях", "при обучении в низкой точности",
          "без потери качества", "за счёт дополнительной матрицы",
          "ценой роста числа параметров", "в фазе декодирования",
          "по сравнению с базовой схемой", "при неизменном бюджете вычислений"],
)
EN_SLOTS = dict(
    subject=["a language model", "the transformer", "the attention mechanism",
             "the tokenizer", "the optimizer", "normalization", "the residual stream",
             "the expert router", "the key cache", "the embedding table",
             "speculative decoding", "quantization"],
    verb=["predicts", "processes", "reduces", "accelerates", "stabilizes", "bounds",
          "restores", "redistributes", "measures"],
    obj=["the next token", "a long context", "the cost per step", "the memory footprint",
         "the scale of activations", "the spread of gradients", "the data stream",
         "the quality of the answer", "the load across experts", "the throughput"],
    tail=["on long sequences", "when training in low precision", "without losing quality",
          "at the price of one more matrix", "at the cost of more parameters",
          "during the decode phase", "compared with the baseline scheme",
          "under an unchanged compute budget"],
)


def sentence(rng: random.Random, slots: dict) -> str:
    parts = [rng.choice(slots["subject"]), rng.choice(slots["verb"]),
             rng.choice(slots["obj"]), rng.choice(slots["tail"])]
    return " ".join(parts).capitalize() + "."


def synthetic_corpus(n_docs: int, seed: int = 0) -> list[str]:
    """A bilingual corpus from a slot grammar, with junk mixed in on purpose.

    This is no pretraining corpus: one grammar, a narrow lexicon. Its job is to
    give the pipeline and the tokenizer reproducible, unambiguously licence-clean
    material where every mechanism is visible. The slots supply combinatorial
    variety — without it near-dup dedup would rightly collapse the whole corpus.
    """
    rng = random.Random(seed)
    docs = []
    for i in range(n_docs):
        slots = RU_SLOTS if i % 2 else EN_SLOTS
        docs.append(" ".join(sentence(rng, slots) for _ in range(rng.randint(3, 6))))
    # Junk for the funnel: exact copies, near-copies, short and symbol-only.
    docs += docs[:n_docs // 20]                                   # exact duplicates
    docs += [d + " Addendum." for d in docs[:n_docs // 40]]       # near-duplicates
    docs += ["ok"] * (n_docs // 40)                               # too short
    docs += ["!@#$%^&*()" * 8] * (n_docs // 40)                   # symbol junk
    rng.shuffle(docs)
    return docs

raw_docs = synthetic_corpus(SETTINGS["docs"], seed=SEED)
print(f"raw documents: {len(raw_docs):,}")
Python
clean_docs, report = pipeline(raw_docs, FilterConfig(min_chars=60),
                              near_dup_threshold=0.85)
print(report)
print(f"\nresult: {len(clean_docs):,} documents, "
      f"{sum(len(d) for d in clean_docs):,} characters")

# Split documents BEFORE tokenizer training: validation contributes neither
# vocabulary entries nor BPE merges. Global cleaning happened before the split,
# so exact and near duplicates cannot straddle train and validation.
split_rng = random.Random(SEED + 17)
split_rng.shuffle(clean_docs)
valid_count = max(1, round(0.05 * len(clean_docs)))
valid_docs = clean_docs[:valid_count]
train_docs = clean_docs[valid_count:]
print(f"document split: train={len(train_docs):,}, valid={len(valid_docs):,}")

Read the funnel as a record of selection. A high rejection rate could reflect dirty input, an overly strict threshold, or both. Inspect retained and rejected examples near each threshold, then compare languages and document types. Here the corruptions were inserted deliberately, so the rejection reasons can be checked against the generator rather than judged from the survival rate alone.

3. Real data (optional)

The mandatory path needs no real data and takes no published number from it. The cell below is enabled by the USE_REAL_CORPUS flag from §1.

Options whose licences we verified:

CorpusLicenceNote
HuggingFaceFW/fineweb-2, subset rus_CyrlODC-By 1.0attribution required; CommonCrawl terms of use also apply
roneneldan/TinyStoriesCDLA-Sharing-1.0synthetic stories with a narrow lexicon; share-alike covers derived datasets, not a trained model

Model size alone does not determine coherence. TinyStories offers a narrower task than broad web text; outcomes also depend on training budget, coverage, and evaluation. Compare held-out loss and actual continuations under a recorded protocol. The optional data path is an extension, not a required download.

Python
if USE_REAL_CORPUS:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "datasets"], check=True)
    from datasets import load_dataset

    DATA_REVISIONS = {
        "fineweb2": "v2.1.1",
        "tinystories": "f54c09fd23315a6f9c86f9dc80f725de7d8f9c64",
    }
    train_target = SETTINGS["docs"] // 2
    eval_target = max(100, SETTINGS["docs"] // 40)

    ru_train = load_dataset("HuggingFaceFW/fineweb-2", name="rus_Cyrl",
                            split="train", streaming=True,
                            revision=DATA_REVISIONS["fineweb2"])
    ru_eval = load_dataset("HuggingFaceFW/fineweb-2", name="rus_Cyrl",
                           split="test", streaming=True,
                           revision=DATA_REVISIONS["fineweb2"])
    en_train = load_dataset("roneneldan/TinyStories", split="train", streaming=True,
                            revision=DATA_REVISIONS["tinystories"])
    en_eval = load_dataset("roneneldan/TinyStories", split="validation", streaming=True,
                           revision=DATA_REVISIONS["tinystories"])

    def take_text(dataset, limit):
        rows = []
        for row in dataset:
            rows.append(row["text"])
            if len(rows) >= limit:
                break
        return rows

    raw_train = take_text(ru_train, train_target) + take_text(en_train, train_target)
    raw_valid = take_text(ru_eval, eval_target) + take_text(en_eval, eval_target)
    train_docs, train_report = pipeline(raw_train, FilterConfig(), near_dup_threshold=0.85)
    valid_docs, valid_report = pipeline(raw_valid, FilterConfig(), near_dup_threshold=0.85)
    train_fingerprints = {hashlib.sha256(d.encode("utf-8")).digest() for d in train_docs}
    valid_docs = [d for d in valid_docs
                  if hashlib.sha256(d.encode("utf-8")).digest() not in train_fingerprints]
    print("train:", train_report)
    print("validation:", valid_report)
    print("revisions:", DATA_REVISIONS)
    print("\nAttribution: FineWeb2 (ODC-By 1.0 + Common Crawl Terms), "
          "TinyStories (CDLA-Sharing-1.0). This is technical documentation, not legal advice.")
else:
    DATA_REVISIONS = {"synthetic": f"generator-v1-seed-{SEED}"}
    print("USE_REAL_CORPUS = False — using the synthetic document-level split")

4. A byte-level BPE tokenizer

The initial alphabet consists of all 256 byte values, so valid UTF-8 text can be encoded without an unknown token. Ordinary Russian letters occupy two bytes before merging, but this does not describe every character in Unicode's Cyrillic blocks. BPE merges can join bytes within a character or span several characters.

Russian data volume matters, as do its diversity, vocabulary size, and preprocessing. We measure tokens per character and bytes per token on separate Russian and English probes. Read these as compression properties of this tokenizer on these examples, not as a direct measure of the language model's eventual quality.

Python
import time
from minillm.tokenizer import BBPETokenizer
from minillm.data import ByteTokenizer
from minillm.chat import CHAT_SPECIALS

# The chat control tokens are reserved in the vocabulary right away, even though
# pretraining does not need them. The reason is exactly the one behind the 1,075
# reserved identifiers in o200k_harmony (M2): adding them later means changing
# vocab_size and rebuilding the embeddings. Notebook 2 picks up this very
# checkpoint and expects to find `<|eot_id|>` inside it.
train_text = "\n".join(train_docs)
t0 = time.time()
tokenizer = BBPETokenizer.train(train_text, vocab_size=SETTINGS["vocab_size"],
                                specials=["<|bos|>", "<|eos|>"] + CHAT_SPECIALS)
print(f"trained in {time.time() - t0:.1f} s: vocabulary {tokenizer.vocab_size}, "
      f"{len(tokenizer.merges)} merges")
if tokenizer.vocab_size < SETTINGS["vocab_size"]:
    print('Available merge pairs ran out before the requested vocabulary size.')

sample = "The tokenizer predicts nothing; it only decides the price."
assert tokenizer.decode(tokenizer.encode(sample)) == sample, "round trip is broken"
print("round trip:", repr(tokenizer.decode(tokenizer.encode(sample))))
Python
probe_rng = random.Random(123)
ru_probe = " ".join(sentence(probe_rng, RU_SLOTS) for _ in range(40))
en_probe = " ".join(sentence(probe_rng, EN_SLOTS) for _ in range(40))
plain = ByteTokenizer()

rows = []
for name, probe in (("RU", ru_probe), ("EN", en_probe)):
    trained = tokenizer.fertility(probe)
    baseline = len(plain.encode(probe)) / len(probe)
    rows.append((name, trained["tokens_per_char"], baseline,
                 trained["bytes_per_token"]))

print(f"{'lang':<5} {'BBPE tok/char':>14} {'byte-level tok/char':>21} {'bytes/token':>12}")
for name, trained, baseline, bpt in rows:
    print(f"{name:<5} {trained:>14.3f} {baseline:>21.3f} {bpt:>12.2f}")
print("\nByte-level costs about two tokens per Cyrillic character — that is the "
      "price of having no merges for a language (M2).")

5. Sharding the token stream

Write the tokens into .bin shards and retain their checksums in index.json. Data volume, not the model profile alone, determines whether the corpus fits in memory. Small inputs let us inspect the reading order and integrity checks before scaling up. In particular, crossing a shard boundary must not discard the next-token transition.

Python
from minillm.data import ShardStream, TokenStream, write_shards

EOS = tokenizer.specials["<|eos|>"]

def encode_documents(documents):
    stream = []
    for doc in documents:
        stream.extend(tokenizer.encode(doc))
        stream.append(EOS)
    return stream

train_token_ids = encode_documents(train_docs)
valid_token_ids = encode_documents(valid_docs)
print(f"train tokens: {len(train_token_ids):,}; validation tokens: {len(valid_token_ids):,}")

shard_dir = Path("data_shards")
index = write_shards(train_token_ids, shard_dir / "train", shard_tokens=250_000)
write_shards(valid_token_ids, shard_dir / "valid", shard_tokens=250_000)

train_stream = ShardStream(shard_dir / "train", seq_len=SETTINGS["seq_len"], verify=True)
valid_stream = ShardStream(shard_dir / "valid", seq_len=SETTINGS["seq_len"], verify=True)
print(f"train: {len(index['shards'])} shards, {index['total_tokens']:,} tokens, "
      f"{len(train_stream):,} windows, checksum {train_stream.checksum()}")
print(f"validation: {len(valid_stream):,} windows — document-separated before tokenizer training")

6. Model config and budget

Before launching anything, three quantities the course insists on computing before the run rather than after the OOM:

  1. parameters — by the formula of M6, checked against the actual model.parameters();

  2. KV cache per token — 2·L·H_kv·d_head·sizeof(dtype) (M5);

  3. the 20× Chinchilla-style reference budget — 20 tokens per parameter (M9), and how many steps that is.

Python
from minillm.config import MiniLLMConfig, kv_bytes_per_token
from minillm.model import MiniLLM, MoELayer
from minillm.train import TrainConfig, memory_report

cfg = MiniLLMConfig.profile(
    SETTINGS["model_profile"],
    vocab_size=tokenizer.vocab_size,
    max_position_embeddings=max(SETTINGS["seq_len"], 256),
)
model = MiniLLM(cfg).to(DEVICE)

analytic = cfg.parameter_report()
assert analytic["total"] == model.num_parameters(), "formula and code disagree"
print(f"parameters: {analytic['total']:,} (formula == actual)")
for key in ("embeddings", "attention", "ffn", "norms"):
    print(f"  {key:<12} {analytic[key]:>12,}  ({analytic[key] / analytic['total']:.1%})")
print(f"  the block costs {analytic['block_over_d2']}·d²")
reference_cache_bpt = kv_bytes_per_token(cfg, dtype_bytes=2)
runtime_cache_bpt = kv_bytes_per_token(cfg, dtype_bytes={"fp32": 4, "bf16": 2, "fp16": 2}[COMPUTE_DTYPE])
print(f"\nKV cache, BF16/FP16 reference: {reference_cache_bpt} bytes/token "
      f"→ {reference_cache_bpt * SETTINGS['seq_len'] / 1024:.1f} KiB per window")
print(f"KV cache at the current compute dtype ({COMPUTE_DTYPE}): {runtime_cache_bpt} bytes/token")
print("persistent training memory:", memory_report(cfg, "adamw"))
Python
tcfg = TrainConfig(
    steps=SETTINGS["steps"],
    seq_len=SETTINGS["seq_len"],
    micro_batch_size=SETTINGS["micro_batch_size"],
    grad_accum=SETTINGS["grad_accum"],
    optimizer="muon",
    compute_dtype="auto",
    gradient_checkpointing=DEVICE.startswith("cuda"),
    seed=SEED,
    device=DEVICE,
    log_every=max(1, SETTINGS["steps"] // 10),
    out_dir="runs/pretrain",
)

chinchilla_tokens = 20 * analytic["total"]
planned_tokens = tcfg.steps * tcfg.tokens_per_step
print(f"planned next-token targets per step: {tcfg.tokens_per_step:,}")
print(f"planned:         {planned_tokens:,} supervised targets over {tcfg.steps:,} steps")
print(f"reference 20×: {chinchilla_tokens:,} tokens "
      f"→ {chinchilla_tokens / tcfg.tokens_per_step:,.0f} steps")
print(f"share of budget: {planned_tokens / chinchilla_tokens:.1%}")
print(f"epochs over the corpus: {planned_tokens / len(train_token_ids):.2f}")

The planned budget counts next-token targets: a seq_len+1 window supplies seq_len targets. Runtime logs distinguish input positions (tokens, including the first context position and PAD) from supervised targets after shifting (supervised_tokens). Throughput, tokens_per_s, uses input positions. Training and validation loss average over supervised targets; gradient accumulation uses one denominator for the whole effective batch.

The budget fraction compares planned tokens with the 20P20P reference. It describes experiment size rather than diagnosing whether training is sufficient. The appropriate budget depends on the data and objective. SMOKE checks the pipeline with a short run; FULL sets a longer teaching schedule.

Epoch count records data reuse. Repeated exposure to a small corpus can encourage memorization, but there is no universal four-epoch boundary. Inspect both training and held-out curves, and remember that shared synthetic templates limit the diversity of both splits.

7. The run

The optimizer is a hybrid: Muon on two-dimensional matrices, AdamW on embeddings, norms and the router (M9). The schedule is WSD — warmup, a plateau, a decay (M9). The plateau is what lets a run be interrupted at any point and finished with a decay tail, without retuning the schedule for a new length.

Python
from minillm.train import train, save_checkpoint

state = train(model, train_stream, tcfg, eval_stream=valid_stream)
print(f"\ndone: {state.step} steps, "
      f"{state.tokens:,} input positions, {state.supervised_tokens:,} targets, "
      f"final loss {state.history[-1]['loss']:.4f}")
Python
import matplotlib.pyplot as plt

steps = [h["step"] for h in state.history]
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

axes[0].plot(steps, [h["loss"] for h in state.history], label="train", color="#5E81AC")
if "eval_loss" in state.history[0]:
    axes[0].plot(steps, [h["eval_loss"] for h in state.history], label="valid",
                 color="#BF616A")
axes[0].axhline(np.log(cfg.vocab_size), ls="--", lw=1, color="#4C566A",
                label="ln(V) — a uniform model")
axes[0].set_xlabel("step"); axes[0].set_ylabel("cross-entropy"); axes[0].legend()
axes[0].set_title("loss")

axes[1].plot(steps, [h["lr_scale"] for h in state.history], color="#A3BE8C")
axes[1].set_xlabel("step"); axes[1].set_ylabel("lr multiplier")
axes[1].set_title("WSD schedule")

axes[2].plot(steps, [h["tokens_per_s"] for h in state.history], color="#D08770")
axes[2].set_xlabel("step"); axes[2].set_ylabel("input positions/s")
axes[2].set_title("throughput")

for ax in axes:
    ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()

bpc = state.history[-1]["loss"] / np.log(2)
print(f"final cross-entropy {state.history[-1]['loss']:.4f} nats "
      f"= {bpc:.4f} bits per token")
print('The curve describes this budget and corpus; a plateau alone does not identify the limiting cause.')

7.1. A sparse twin: total and active parameters

Replace the dense FFN with experts while keeping vocabulary, depth, attention, and positions fixed. Expert width targets a similar active-parameter budget, not exact equality. The standard S and S-MoE profiles have 6,327,552 and 6,593,792 active parameters respectively; the table below recomputes the counts for this run's vocabulary.

Similar counts do not promise equal token latency. Routing, matrix shapes, and output combination also cost work. KV geometry does remain identical when attention is unchanged. Begin by verifying those distinct statements numerically.

Python
dense_cfg = cfg
moe_cfg = MiniLLMConfig(**{**MiniLLMConfig.twin(SETTINGS["model_profile"]).to_dict(),
                           "vocab_size": tokenizer.vocab_size,
                           "max_position_embeddings": cfg.max_position_embeddings})
moe_model = MiniLLM(moe_cfg).to(DEVICE)

rows = [("dense", dense_cfg, model), ("MoE", moe_cfg, moe_model)]
print(f"{'variant':<8} {'total':>14} {'active':>12} {'sparsity':>9} "
      f"{'KV B/tok':>9} {'FLOP/tok':>12}")
for name, c, m in rows:
    assert c.parameter_report()["total"] == m.num_parameters(), "formula diverged"
    total, active = c.parameter_report()["total"], c.active_parameters()
    print(f"{name:<8} {total:>14,} {active:>12,} {total / active:>8.2f}× "
          f"{kv_bytes_per_token(c):>9,} {c.flops_per_token():>12,}")

print(f"\n{moe_cfg.num_local_experts} experts, top-{moe_cfg.num_experts_per_tok}, "
      f"{moe_cfg.num_shared_experts} shared, first layer dense: "
      f"{not moe_cfg.moe_layer(0)}")
print(f"MoE active relative to dense: "
      f"×{moe_cfg.active_parameters() / dense_cfg.active_parameters():.3f}")
print(f"KV cache matches to the byte: "
      f"{kv_bytes_per_token(dense_cfg) == kv_bytes_per_token(moe_cfg)} — "
      f"attention untouched, so the comparison is clean")

The table separates bank capacity from the work of selected experts. Train both models for the same step and token budget, then compare loss, throughput, and assignments. Neither an MoE quality gain nor equal hardware cost is assumed in advance.

Python
moe_state = train(moe_model, train_stream, tcfg, eval_stream=valid_stream)

dense_last, moe_last = state.history[-1], moe_state.history[-1]
print(f"\n{'variant':<8} {'train':>9} {'valid':>9} {'input/s':>9}")
for name, h in (("dense", dense_last), ("MoE", moe_last)):
    print(f"{name:<8} {h['loss']:>9.4f} {h.get('eval_loss', float('nan')):>9.4f} "
          f"{h['tokens_per_s']:>9.0f}")

moe_layer = next(l.ffn for l in moe_model.layers if isinstance(l.ffn, MoELayer))
load = moe_layer.expert_load
print(f"\nexpert load at the last measurement: min {load.min():.3f}, "
      f"max {load.max():.3f}, std {load.std():.3f}")
print(f"a perfectly even load would be "
      f"{moe_cfg.num_experts_per_tok / moe_cfg.num_local_experts:.3f} per expert")
print(f"aux-loss-free bias: max|b| = {moe_layer.expert_bias.abs().max():.4f}")

Interpreting the comparison. Uniform routing would give each of EE experts an average fraction k/Ek/E of tokens. Actual assignments may be uneven, and fewer tokens do not automatically mean fewer optimizer steps. Inspect the load table to see how much data each expert receives.

One short run compares two particular configurations. Parameter counts alone cannot explain a loss gap. Notebook 4 isolates a balancing-rate change and measures dropped assignments, providing additional evidence about routing without assuming its effect on quality.

8. Inspecting the trained model

Generate continuations with greedy decoding and sampling. Prefill processes prompt positions; decode continues using the KV cache. Module 10 explains when compute or memory traffic becomes limiting, but those conditions cannot be assumed for this small CPU run. Record the measured times and inspect the text, separating a working generation path from useful language quality.

Python
from minillm.generate import SamplingConfig, generate

for temperature, top_p in ((0.0, 1.0), (0.8, 0.9)):
    prompt = tokenizer.encode("a language model")
    out, stats = generate(model, prompt, 80,
                          SamplingConfig(temperature=temperature, top_p=top_p, seed=SEED),
                          stop_tokens={EOS})
    label = "greedy" if temperature == 0 else f"T={temperature}, top_p={top_p}"
    print(f"--- {label}\n{tokenizer.decode(out)!r}\n")
print("stats of the last run:", stats.as_dict())

9. A passport for our own model

The course opened by teaching us to read someone else's config.json. Now the model is ours — and the same passport approach reads it with no changes at all. That is the ring closing: the arithmetic of modules 1, 5 and 6 applied to a file we just wrote ourselves.

Python
run_dir = Path("runs/pretrain/final")
save_checkpoint(run_dir, model, state.optimizer, tcfg, state)
tokenizer.save(run_dir / "tokenizer.json")
print("saved:", sorted(p.name for p in run_dir.iterdir()))
Python
import json

def read_passport(path: Path) -> None:
    """A model passport from config.json — the same move as in module 1."""
    c = json.loads((path / "config.json").read_text(encoding="utf-8"))
    heads, kv = c["num_attention_heads"], c["num_key_value_heads"]
    attention = "MHA" if heads == kv else ("MQA" if kv == 1 else f"GQA {heads}/{kv}")
    ffn_ratio = c["intermediate_size"] / c["hidden_size"]
    cache_bytes = {"fp32": 4, "bf16": 2, "fp16": 2}[stats.cache_dtype]
    kv_bytes = 2 * c["num_hidden_layers"] * kv * c["head_dim"] * cache_bytes

    print("Axis I   · size      :", f"d={c['hidden_size']}, L={c['num_hidden_layers']}, "
          f"V={c['vocab_size']}, embeddings "
          f"{'tied' if c['tie_word_embeddings'] else 'untied'}")
    print("Axis II  · context   :", f"{c['max_position_embeddings']} tokens, "
          f"RoPE base {c['rope_theta']:g}, scaling "
          f"{c['rope_scaling'] or 'none'}")
    print("Axis III · attention :", attention,
          f"· head_dim {c['head_dim']} · sinks {'yes' if c['attention_sinks'] else 'no'}"
          f" · window {c['sliding_window'] or 'none'}")
    print("Axis IV  · block     :", f"{c['norm_placement']}-norm, {c['hidden_act']}, "
          f"d_ffn/d = {ffn_ratio:.2f}"
          + (f", clamp {c['swiglu_limit']}" if c["swiglu_limit"] else ""))
    print("Axis V   · sparsity  :", "dense" if not c["num_local_experts"]
          else f"MoE, {c['num_local_experts']} experts, top-{c['num_experts_per_tok']}")
    print("Derived              :", f"KV cache {kv_bytes} B/token in measured {stats.cache_dtype}, "
          f"{kv_bytes * c['max_position_embeddings'] / 1024 ** 2:.1f} MiB at full context")

read_passport(run_dir)

10. Summary and what comes next

We walked the road the course took apart across the course modules: raw documents → cleaning funnel → our own BPE → shards → the course reference model → a run with Muon and WSD → generation → a passport. Every number was computed here; none was taken from the lectures on faith.

The passport computes the training budget relative to the 20P20P reference. That ratio depends on the profile and step count; it is not a quality score. The synthetic corpus lets us trace learning and check the handoffs between stages. Architecture comparisons require a controlled experiment such as those in Notebook 4. Claims about natural language need suitable data and response evaluation in addition to a successful training run.

Next:

  • notebook 2 — post-training: chat template, loss masking over assistant spans, packing and DPO;
  • notebook 3 — inference: prefix caching, speculative decoding with a small draft model, and quantization;
  • notebook 4 — ablations: the course's claims checked by number, plus an honest account of where this scale runs out.

Data attribution. The synthetic corpus of §2 is generated by this notebook's own code. With USE_REAL_CORPUS enabled, the licences of the respective datasets apply: FineWeb-2 under ODC-By 1.0 plus the CommonCrawl terms of use, TinyStories under CDLA-Sharing-1.0.

Capstone · Notebook 2. Post-training: SFT and DPO

MiniLLM · Capstone

Download .ipynb 02_posttrain.ipynb · 32.1 KB

Show notebook text

Capstone · Notebook 2. Post-training: SFT and DPO

Files for this notebook. Download course-materials.zip from the course website and extract the complete archive, preserving the capstone folder. In Colab, upload the same ZIP when the notebook prompts for files.

Modern LLMs course · capstone · edition 2026.10.3

What happens here. The base model from notebook 1 can continue text but cannot hold a conversation. We teach it the format — chat template, loss masking over assistant replies, packing (M13) — and then shift its preferences with DPO (M14).

The central experiment is §5. We deliberately leave the closing <|eot_id|> out of the trainable part of the mask and watch what happens. We measure the changed training objective and both branches’ behavior.

The Colab contract. The notebook is self-contained: if no checkpoint from notebook 1 is nearby, it trains a small base model itself. No network required.

A useful working distinction is that SFT demonstrates desired behaviour while preference or reinforcement learning supplies a different training signal. At this scale we verify the mechanics, not frontier-level answer quality.

On a first pass, run the cells in order and connect the code to the explanations. On a second, change one setting, retain the results, and state which claim the comparison can test.

1. Environment and the base model

Python
import importlib
import sys
import subprocess
import zipfile
from pathlib import Path

ARCHIVE_GLOB = "modern-llms-capstone-*.zip"

def _activate_capstone_root(candidate: Path):
    package_init = candidate / "minillm" / "__init__.py"
    if not package_init.exists():
        return None
    root = str(candidate.resolve())
    if root not in sys.path:
        sys.path.insert(0, root)
    return root

def _find_capstone_root(search_root: Path):
    direct = _activate_capstone_root(search_root)
    if direct:
        return direct
    for package_root in (search_root / "capstone" / "minillm" / "en", search_root / "minillm" / "en"):
        found = _activate_capstone_root(package_root)
        if found:
            return found
    for child in sorted(search_root.glob("modern-llms-capstone-*")):
        found = _activate_capstone_root(child)
        if found:
            return found
    return None

def _safe_extract(archive_path: Path, destination: Path):
    destination = destination.resolve()
    with zipfile.ZipFile(archive_path) as archive:
        for member in archive.infolist():
            member_path = Path(member.filename)
            if member_path.is_absolute() or ".." in member_path.parts:
                raise RuntimeError(f"unsafe path in archive: {member.filename}")
        archive.extractall(destination)

def ensure_minillm():
    for candidate in (Path.cwd(), *Path.cwd().parents):
        found = _find_capstone_root(candidate)
        if found:
            return "project package: " + found

    try:
        import minillm  # noqa: F401
        return "minillm is already importable"
    except ImportError:
        pass

    archives = sorted(Path.cwd().glob(ARCHIVE_GLOB)) + sorted(Path.cwd().glob("course-materials.zip"))
    if not archives:
        try:
            from google.colab import files
        except ImportError:
            files = None
        if files is not None:
            print("Upload course-materials.zip from the course notebook page. The directory structure will be restored automatically.")
            files.upload()
            archives = sorted(Path.cwd().glob(ARCHIVE_GLOB)) + sorted(Path.cwd().glob("course-materials.zip"))

    if archives:
        archive_path = archives[-1]
        destination = Path.cwd() / ".capstone_runtime"
        destination.mkdir(exist_ok=True)
        _safe_extract(archive_path, destination)
        root = _find_capstone_root(destination)
        if root:
            return "project archive: " + str(archive_path)
        matches = sorted(destination.glob("*/minillm/__init__.py"))
        if len(matches) != 1:
            raise RuntimeError(
                f"expected one minillm package in {archive_path.name}, "
                f"found {len(matches)}"
            )
        root = _activate_capstone_root(matches[0].parent.parent)
        if root:
            return "project archive: " + str(archive_path)

    raise RuntimeError("minillm was not found. Download course-materials.zip from the course notebook page and either extract it in full or upload the ZIP to the current Colab session.")

print(ensure_minillm())

for package in ("torch", "numpy", "matplotlib", "regex"):
    try:
        importlib.import_module(package)
    except ImportError:
        subprocess.run(
            [sys.executable, "-m", "pip", "install", "-q", package],
            check=True,
        )

import os, torch, numpy as np, random

# Run profile. SMOKE is sized for CPU and finishes in minutes; FULL only makes
# sense on a GPU and produces noticeably more coherent answers.
PROFILE = "SMOKE"
BUDGET = {
    "SMOKE": dict(base_steps=250, sft_steps=200, sft_batch=4, seq_len=192,
                  dpo_steps=100, dpo_batch=4, hidden=128, layers=3, dialogues=800),
    "FULL":  dict(base_steps=2000, sft_steps=1500, sft_batch=8, seq_len=256,
                  dpo_steps=600, dpo_batch=8, hidden=384, layers=6, dialogues=6000),
}[PROFILE]
if os.getenv("CAPSTONE_FAST_VALIDATION") == "1":
    BUDGET = {**BUDGET, "base_steps": min(BUDGET["base_steps"], 10),
              "sft_steps": min(BUDGET["sft_steps"], 12),
              "dpo_steps": min(BUDGET["dpo_steps"], 8),
              "dialogues": min(BUDGET["dialogues"], 80),
              "sft_batch": min(BUDGET["sft_batch"], 2),
              "dpo_batch": min(BUDGET["dpo_batch"], 2)}
SEED = 0
torch.manual_seed(SEED)
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
print("torch", torch.__version__, "| device:", DEVICE)

The base model. If a checkpoint from notebook 1 sits nearby we load it; otherwise we train a small base model right here. The second path is faster and sufficient for every conclusion in this notebook: what interests us is what the mask teaches, not absolute quality.

Python
from minillm.chat import CHAT_SPECIALS
from minillm.config import MiniLLMConfig
from minillm.data import TokenStream
from minillm.model import MiniLLM
from minillm.tokenizer import BBPETokenizer
from minillm.train import TrainConfig, train

EN_SLOTS = dict(
    subject=["the language model", "the transformer", "the attention mechanism",
             "the tokenizer", "the optimizer", "normalization", "the residual stream",
             "the expert router"],
    verb=["predicts", "processes", "reduces", "accelerates", "stabilizes"],
    obj=["the next token", "a long context", "the cost per step", "the memory footprint",
         "the scale of activations", "the load across experts"],
)

def sentence(rng, slots):
    return " ".join([rng.choice(slots["subject"]), rng.choice(slots["verb"]),
                     rng.choice(slots["obj"])]).capitalize() + "."

rng = random.Random(SEED)
base_text = "\n".join(" ".join(sentence(rng, EN_SLOTS) for _ in range(4))
                      for _ in range(4000))

def usable_checkpoint(path: Path):
    """A checkpoint is usable only if its vocabulary knows the chat control tokens.

    A vocabulary without `<|eot_id|>` would crash the notebook on the very first
    loss mask, and with an unhelpful KeyError at that. Notebook 1 reserves those
    identifiers up front, but a checkpoint may have been built by an older
    version — so we check explicitly.
    """
    if not (path / "config.json").exists():
        return None, None, "no notebook-1 checkpoint nearby"
    tok = BBPETokenizer.load(path / "tokenizer.json")
    missing = [s for s in CHAT_SPECIALS if s not in tok.specials]
    if missing:
        return None, None, f"the checkpoint vocabulary lacks {missing}"
    from minillm.train import load_checkpoint
    model, _, _, _ = load_checkpoint(path, device=DEVICE)
    return model, tok, None

CHECKPOINT = Path("runs/pretrain/final")
base_model, tokenizer, reason = usable_checkpoint(CHECKPOINT)
if base_model is not None:
    print("loaded the checkpoint from notebook 1:", base_model.num_parameters(), "parameters")
else:
    print(f"training a base model ourselves: {reason}")
    tokenizer = BBPETokenizer.train(base_text, vocab_size=1024, specials=CHAT_SPECIALS)
    cfg = MiniLLMConfig.profile("S", vocab_size=tokenizer.vocab_size,
                                hidden_size=BUDGET["hidden"],
                                num_hidden_layers=BUDGET["layers"],
                                num_attention_heads=4, num_key_value_heads=2,
                                head_dim=32, max_position_embeddings=BUDGET["seq_len"])
    base_model = MiniLLM(cfg).to(DEVICE)
    stream = TokenStream.from_text(base_text, tokenizer, seq_len=128)
    print(f"parameters: {base_model.num_parameters():,}")
    train(base_model, stream, TrainConfig(steps=BUDGET["base_steps"], seq_len=128,
                                          micro_batch_size=8, optimizer="muon",
                                          seed=SEED, device=DEVICE, log_every=125))
print("vocabulary:", tokenizer.vocab_size)

2. The instruction dataset

The dialogue generator reuses the pretraining slot grammar, reducing changes in vocabulary and topic. That does not establish that SFT learns format alone. Random slot combinations are teaching fixtures and may be factually nonsensical. For the EOT experiment, both branches receive the same dialogues, isolating the mask change rather than assigning every SFT effect to formatting.

Python
from minillm.chat import ChatTemplate, Message, SFTDataset

QUESTIONS = [
    ("What does {s} do?", "{s} {v} {o}."),
    ("What is {s} used for?", "{s} {v} {o}. That is its main role here."),
    ("How does {s} affect the model?", "{s} {v} {o}, changing the model behaviour."),
]

def make_dialogues(n: int, seed: int = 0) -> list[list[Message]]:
    rng = random.Random(seed)
    out = []
    for _ in range(n):
        question, answer = rng.choice(QUESTIONS)
        s = rng.choice(EN_SLOTS["subject"])
        v, o = rng.choice(EN_SLOTS["verb"]), rng.choice(EN_SLOTS["obj"])
        out.append([
            Message("system", "Answer briefly and directly."),
            Message("user", question.format(s=s)),
            Message("assistant", answer.format(s=s.capitalize(), v=v, o=o)),
        ])
    return out

template = ChatTemplate()
dialogues = make_dialogues(BUDGET["dialogues"], seed=SEED)
print(template.render(dialogues[0], add_generation_prompt=False))

3. The mask: what exactly the model learns

The rule of M13: train on assistant replies and the closing <|eot_id|>; do not train on the system prompt, the user turns or the role headers — those are written by the runtime.

Python
EOT_ID = tokenizer.specials["<|eot_id|>"]
PAD_ID = tokenizer.specials["<|begin_of_text|>"]

ids, mask = template.encode_with_mask(dialogues[0], tokenizer)
print(f"tokens: {len(ids)}, trainable: {sum(mask)} "
      f"({template.trainable_fraction(mask):.1%})\n")

print(f"{'token':<22} {'id':>6}  train")
for token_id, flag in list(zip(ids, mask)):
    piece = tokenizer.decode([token_id]).replace("\n", "\\n")
    print(f"{piece!r:<22} {token_id:>6}  {'YES' if flag else '—'}")
Python
dataset, stats = SFTDataset.from_dialogues(dialogues, tokenizer, template,
                                           seq_len=BUDGET["seq_len"], pad_id=PAD_ID)
print("packing:", stats)
print(f"\nwithout packing this would need {stats['dialogues']} rows of "
      f"{BUDGET['seq_len']} tokens,")
print(f"with packing — {stats['bins']}: a ×{stats['packing_ratio']} saving, "
      f"padding {stats['padding_fraction']:.1%}")

Packing here is a correctness feature as well as an efficiency feature. The dataset returns segment_ids beside ids and labels; the model builds a block-diagonal causal mask and resets RoPE positions inside every dialogue. Changing one packed example therefore cannot alter the logits of its neighbour. <|eot_id|> marks the protocol boundary, but it would not provide this isolation by itself.

4. SFT

We train the same model with the same loop — only the source of batches changes: instead of a token stream it now yields (ids, labels, segment_ids). The −100 labels disable direct loss, while segment IDs build a block-diagonal causal mask and reset positions inside every independently packed dialogue.

SFT and validation use a mean over supervised targets after the causal shift. With gradient accumulation, each microbatch contributes in proportion to its target count. Repartitioning the same examples therefore preserves the chosen loss objective.

Python
import copy

sft_model = copy.deepcopy(base_model)
sft_cfg = TrainConfig(steps=BUDGET["sft_steps"], seq_len=BUDGET["seq_len"],
                      micro_batch_size=BUDGET["sft_batch"], optimizer="muon",
                      seed=SEED, device=DEVICE, log_every=BUDGET["sft_steps"] // 4,
                      out_dir="runs/sft")
sft_state = train(sft_model, dataset, sft_cfg)
Python
from minillm.generate import SamplingConfig, generate

def ask(model, question: str, max_new_tokens: int = 60, temperature: float = 0.0) -> tuple[str, int]:
    ids, _ = template.encode_with_mask(
        [Message("system", "Answer briefly and directly."),
         Message("user", question)], tokenizer, add_generation_prompt=True)
    out, stats = generate(model, ids, max_new_tokens,
                          SamplingConfig(temperature=temperature, seed=SEED),
                          stop_tokens={EOT_ID})
    answer = tokenizer.decode(out[len(ids):])
    return answer, stats.generated_tokens

question = "What does the tokenizer do?"
for label, model in (("before SFT", base_model), ("after SFT", sft_model)):
    answer, n = ask(model, question)
    print(f"--- {label} ({n} tokens)\n{answer!r}\n")

5. The central experiment: the forgotten <|eot_id|>

We repeat SFT with exactly one change: train_eot=False. The data are the same, the sequences are the same — one position of the mask differs per dialogue.

Python
broken_dataset, _ = SFTDataset.from_dialogues(dialogues, tokenizer, template,
                                              seq_len=BUDGET["seq_len"], pad_id=PAD_ID,
                                              train_eot=False)
a_ids, a_mask = template.encode_with_mask(dialogues[0], tokenizer, train_eot=True)
b_ids, b_mask = template.encode_with_mask(dialogues[0], tokenizer, train_eot=False)
diff = [i for i, (x, y) in enumerate(zip(a_mask, b_mask)) if x != y]
print(f"sequences identical: {a_ids == b_ids}")
print(f"masks differ at positions {diff} — that is {tokenizer.decode([a_ids[diff[0]]])!r}")

broken_model = copy.deepcopy(base_model)
train(broken_model, broken_dataset,
      TrainConfig(steps=BUDGET["sft_steps"], seq_len=BUDGET["seq_len"],
                  micro_batch_size=BUDGET["sft_batch"], optimizer="muon", seed=SEED,
                  device=DEVICE, log_every=BUDGET["sft_steps"], out_dir="runs/sft_broken"))
Python
eot_rows = []

print(f"{'model':<16} {'p(<|eot_id|>) after reply':>28} {'reply length':>14}")
for label, model in (("mask correct", sft_model), ("EOT forgotten", broken_model)):
    ids, _ = template.encode_with_mask(dialogues[0], tokenizer)
    prefix = ids[:len(ids) - 1]        # everything but the closing EOT
    with torch.no_grad():
        logits, _ = model(torch.tensor([prefix], device=DEVICE))
        p_eot = torch.softmax(logits[0, -1].float(), dim=-1)[EOT_ID].item()
    _, generated = ask(model, "What does the tokenizer do?", max_new_tokens=120)
    eot_rows.append(dict(label=label, probability=p_eot, generated_tokens=generated))
    print(f"{label:<16} {p_eot:>28.4f} {generated:>14}")

answer, n = ask(broken_model, "What does the tokenizer do?", max_new_tokens=120)
print(f"\nreply from the model with the forgotten EOT ({n} tokens):\n{answer!r}")

# Use one correct mask for both models; the generator is a diagnostic fixture.
from minillm.train import evaluate
check_dialogues = make_dialogues(32, seed=SEED + 991)
check_dataset, _ = SFTDataset.from_dialogues(check_dialogues, tokenizer, template,
    seq_len=BUDGET["seq_len"], pad_id=PAD_ID, train_eot=True)
shared_scores = {name: evaluate(model, check_dataset, sft_cfg, batches=4)
                 for name, model in (("correct_mask", sft_model), ("omitted_eot", broken_model))}
assert sum(a_mask) == sum(b_mask) + len(diff)
print("EOT supervised positions:", sum(a_mask), "vs", sum(b_mask))
print("Shared correct-mask evaluation:", shared_scores)

Removing EOT changes both the summed loss and the number of supervised positions in its denominator. The training losses now refer to different objectives and need not agree. Validation using the same faulty mask can conceal the missing stopping target.

Score both models on one shared dataset with EOT supervision restored, then inspect EOT probability after a specified answer and the length of greedy generation. A token probability and an observed failure to stop are related but distinct measurements. A short run may produce only a weak contrast; report its actual size rather than assuming a dramatic failure.

6. Preference pairs

These pairs are authored by a template: there is no model generation or human annotation in build_pairs. The chosen answer fills a QUESTIONS template with slot values; the rejected answer appends two identical filler sentences. Both terminate with EOT.

The construction makes a preference for brevity inspectable. It lets us observe a DPO update, but it does not represent the diversity of human preferences. Studying policy-generated data would require a separate generation stage with a fixed model revision and independently assessed candidates.

Python
from minillm.preference import (DPOConfig, PreferencePair, freeze_reference, train_dpo)

FILLER = " This matters and should be considered when working with the model."

def build_pairs(n: int, seed: int = 0) -> list[PreferencePair]:
    rng = random.Random(seed)
    pairs = []
    for _ in range(n):
        question, answer = rng.choice(QUESTIONS)
        s = rng.choice(EN_SLOTS["subject"])
        v, o = rng.choice(EN_SLOTS["verb"]), rng.choice(EN_SLOTS["obj"])
        prompt_ids, _ = template.encode_with_mask(
            [Message("user", question.format(s=s))], tokenizer,
            add_generation_prompt=True)
        short = answer.format(s=s.capitalize(), v=v, o=o)
        verbose = short + FILLER * 2
        pairs.append(PreferencePair(
            prompt_ids=prompt_ids,
            chosen_ids=tokenizer.encode(short) + [EOT_ID],
            rejected_ids=tokenizer.encode(verbose) + [EOT_ID],
        ))
    return pairs

pairs = build_pairs(192, seed=SEED)
print(f"pairs: {len(pairs)}")
print("chosen  :", tokenizer.decode(pairs[0].chosen_ids)[:90])
print("rejected:", tokenizer.decode(pairs[0].rejected_ids)[:90])

7. DPO

A frozen SFT copy supplies reference probabilities. DPO compares changes in the two responses' log probabilities relative to that reference. After training, inference needs only the updated policy.

margin is the mean implicit-reward difference; accuracy counts pairs with a positive difference. A zero margin is not counted as success here. Read both alongside loss and individual responses. Neither 0.5 accuracy nor a rapid rise to 1.0 identifies a cause on its own: easy pairs, tie handling, limited learning, leakage, and overfitting require different checks.

Python
policy = copy.deepcopy(sft_model)
reference = freeze_reference(sft_model)
dpo_state = train_dpo(policy, pairs,
                      DPOConfig(beta=0.1, lr=1e-4, steps=BUDGET["dpo_steps"],
                                batch_size=BUDGET["dpo_batch"], seed=SEED,
                                device=DEVICE, log_every=BUDGET["dpo_steps"] // 8),
                      pad_id=PAD_ID, reference=reference)
Python
import matplotlib.pyplot as plt

steps = [h["step"] for h in dpo_state.history]
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
axes[0].plot(steps, [h["loss"] for h in dpo_state.history], color="#5E81AC")
axes[0].axhline(np.log(2), ls="--", lw=1, color="#4C566A", label="ln 2 — the start")
axes[0].set_title("DPO loss"); axes[0].legend()
axes[1].plot(steps, [h["margin"] for h in dpo_state.history], color="#A3BE8C")
axes[1].axhline(0, ls="--", lw=1, color="#4C566A")
axes[1].set_title("margin: difference of implicit rewards")
axes[2].plot(steps, [h["chosen_reward"] for h in dpo_state.history],
             label="chosen", color="#A3BE8C")
axes[2].plot(steps, [h["rejected_reward"] for h in dpo_state.history],
             label="rejected", color="#BF616A")
axes[2].set_title("implicit rewards"); axes[2].legend()
for ax in axes:
    ax.set_xlabel("step"); ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()

last = dpo_state.history[-1]
print(f"margin {dpo_state.history[0]['margin']:+.3f} → {last['margin']:+.3f}, "
      f"accuracy {last['accuracy']:.2f}")

The third plot exposes the two terms behind the margin. DPO can increase their difference without increasing the chosen response's absolute probability. Implicit reward is not a direct utility measurement either. Downward movement in both terms warrants investigation, but does not by itself demonstrate degraded answers.

Length and repeated filler make these pairs easy to distinguish. Check how quickly this run separates them, then reduce the filler while keeping other settings fixed. Claims about quality require held-out pairs and content checks in addition to the training curves.

Python
question = "What is the optimizer used for?"
for label, model in (("after SFT", sft_model), ("after DPO", policy)):
    answer, n = ask(model, question, max_new_tokens=80, temperature=0.7)
    print(f"--- {label} ({n} tokens)\n{answer!r}\n")
print('Two responses are illustrations. Compare several fixed prompts and save outcomes before inferring a behavioral change.')

8. Saving

After DPO we save an inference artifact: weights, config.json, the tokenizer, and JSON stage provenance. This is deliberately not a resumable checkpoint. The DPO weights no longer correspond to the SFT optimizer moments, and presenting those stale moments as resumable state would be a silent error. The full resumable pretraining checkpoint remains in runs/pretrain/final.

Python
from minillm.train import save_model_artifact

out_dir = Path("runs/posttrain/final")
save_model_artifact(out_dir, policy, {
    "stage": "dpo",
    "sft_steps": sft_state.step,
    "dpo_steps": dpo_state.step,
    "note": "inference artifact; DPO optimizer state is intentionally not stored",
})
tokenizer.save(out_dir / "tokenizer.json")
print("saved:", sorted(p.name for p in out_dir.iterdir()))

import json
metrics = {"seed": SEED, "budget": BUDGET, "eot": eot_rows,
           "shared_mask_scores": shared_scores, "dpo_history": dpo_state.history}
(out_dir / "teaching_metrics.json").write_text(
    json.dumps(metrics, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8")

9. What the experiment establishes

The chat template, supervision mask, and packing rules define the SFT task. The EOT experiment changes the mask and compares both branches under a shared scoring rule. DPO uses explicit template preferences; the margin describes those particular pairs.

SFT can teach content as well as format. Preference learning changes behavior according to a signal whose quality must also be examined. The measurements here describe synthetic dialogues and authored pairs. Real-world answer utility and the effects of a separate RL stage require their own experiments and evaluation criteria.

Retain the actual loss, margin, EOT probabilities, and response lengths with their training budget. They describe this run on this corpus. Continue with inference in Notebook 3 and controlled architecture and training comparisons in Notebook 4.

Capstone · Notebook 3. Inference

MiniLLM · Capstone

Download .ipynb 03_inference.ipynb · 30.5 KB

Show notebook text

Capstone · Notebook 3. Inference

Files for this notebook. Download course-materials.zip from the course website and extract the complete archive, preserving the capstone folder. In Colab, upload the same ZIP when the notebook prompts for files.

Modern LLMs course · final practical project

Investigate the trained model in use: time prefill and decode separately, account for KV memory, and check prefix reuse. Then compare approximate weight formats and run speculative decoding. Modules 10 and 11 provide the mechanisms and assumptions against which to read these experiments.

Keep geometric identities, distribution changes, and measured runtime distinct. Quantization is assessed on a broad synthetic vocabulary with several metrics. A harder corpus does not guarantee visible damage, and a low loss does not guarantee its absence.

No network is needed. The notebook uses CPU or an available GPU; runtime depends on the device and budget. Follow the cells in order on a first pass. On a second pass, change one setting and explain which measurement should respond.

1. Environment

Python
import importlib
import sys
import subprocess
import zipfile
from pathlib import Path

ARCHIVE_GLOB = "modern-llms-capstone-*.zip"

def _activate_capstone_root(candidate: Path):
    package_init = candidate / "minillm" / "__init__.py"
    if not package_init.exists():
        return None
    root = str(candidate.resolve())
    if root not in sys.path:
        sys.path.insert(0, root)
    return root

def _find_capstone_root(search_root: Path):
    direct = _activate_capstone_root(search_root)
    if direct:
        return direct
    for package_root in (search_root / "capstone" / "minillm" / "en", search_root / "minillm" / "en"):
        found = _activate_capstone_root(package_root)
        if found:
            return found
    for child in sorted(search_root.glob("modern-llms-capstone-*")):
        found = _activate_capstone_root(child)
        if found:
            return found
    return None

def _safe_extract(archive_path: Path, destination: Path):
    destination = destination.resolve()
    with zipfile.ZipFile(archive_path) as archive:
        for member in archive.infolist():
            member_path = Path(member.filename)
            if member_path.is_absolute() or ".." in member_path.parts:
                raise RuntimeError(f"unsafe path in archive: {member.filename}")
        archive.extractall(destination)

def ensure_minillm():
    for candidate in (Path.cwd(), *Path.cwd().parents):
        found = _find_capstone_root(candidate)
        if found:
            return "project package: " + found

    try:
        import minillm  # noqa: F401
        return "minillm is already importable"
    except ImportError:
        pass

    archives = sorted(Path.cwd().glob(ARCHIVE_GLOB)) + sorted(Path.cwd().glob("course-materials.zip"))
    if not archives:
        try:
            from google.colab import files
        except ImportError:
            files = None
        if files is not None:
            print("Upload course-materials.zip from the course notebook page. The directory structure will be restored automatically.")
            files.upload()
            archives = sorted(Path.cwd().glob(ARCHIVE_GLOB)) + sorted(Path.cwd().glob("course-materials.zip"))

    if archives:
        archive_path = archives[-1]
        destination = Path.cwd() / ".capstone_runtime"
        destination.mkdir(exist_ok=True)
        _safe_extract(archive_path, destination)
        root = _find_capstone_root(destination)
        if root:
            return "project archive: " + str(archive_path)
        matches = sorted(destination.glob("*/minillm/__init__.py"))
        if len(matches) != 1:
            raise RuntimeError(
                f"expected one minillm package in {archive_path.name}, "
                f"found {len(matches)}"
            )
        root = _activate_capstone_root(matches[0].parent.parent)
        if root:
            return "project archive: " + str(archive_path)

    raise RuntimeError("minillm was not found. Download course-materials.zip from the course notebook page and either extract it in full or upload the ZIP to the current Colab session.")

print(ensure_minillm())

for package in ("torch", "numpy", "matplotlib", "regex"):
    try:
        importlib.import_module(package)
    except ImportError:
        subprocess.run(
            [sys.executable, "-m", "pip", "install", "-q", package],
            check=True,
        )

import copy, math, os, random, time
import torch, numpy as np
import matplotlib.pyplot as plt

SEED = 0
torch.manual_seed(SEED)
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
from minillm.precision import autocast_context, model_parameter_dtype, resolve_compute_dtype
COMPUTE_DTYPE = resolve_compute_dtype(DEVICE, "auto")
print("torch", torch.__version__, "| device:", DEVICE, "| compute:", COMPUTE_DTYPE)

1.1. Loading the result of Notebook 2

When the notebooks share a workspace, load runs/posttrain/final and generate with the saved policy. If the artifact is absent, the cell reports that fact. Subsequent comparisons use a separately trained model and common inputs, keeping post-training changes out of the quantization experiment.

Python
from minillm.chat import ChatTemplate, Message
from minillm.generate import SamplingConfig, generate
from minillm.tokenizer import BBPETokenizer
from minillm.train import load_model_artifact

POSTTRAIN_ARTIFACT = Path("runs/posttrain/final")
if (POSTTRAIN_ARTIFACT / "model.pt").exists():
    handoff_model, handoff_meta = load_model_artifact(POSTTRAIN_ARTIFACT, DEVICE)
    handoff_tokenizer = BBPETokenizer.load(POSTTRAIN_ARTIFACT / "tokenizer.json")
    handoff_template = ChatTemplate()
    handoff_ids, _ = handoff_template.encode_with_mask(
        [Message("system", "Answer briefly and directly."),
         Message("user", "What does a tokenizer do?")],
        handoff_tokenizer, add_generation_prompt=True)
    handoff_out, handoff_stats = generate(
        handoff_model, handoff_ids, 40, SamplingConfig(temperature=0.0),
        stop_tokens={handoff_tokenizer.specials["<|eot_id|>"]})
    print("post-training handoff:", handoff_meta)
    print("reply:", repr(handoff_tokenizer.decode(handoff_out[len(handoff_ids):])))
    print("KV bytes:", handoff_stats.kv_bytes)
else:
    print("post-training artifact not found — continuing via the self-contained route")

2. A controlled evaluation corpus

Construct a pseudo-language from random words with unequal frequencies. The generating rule is shared while individual sequences differ. This provides controlled comparison inputs; it does not make memorization of a finite training set impossible.

Separate evaluation data before fitting either the tokenizer or the model. Scores on that split describe prediction changes in this synthetic task. Translation, coding, and answer usefulness require their own evaluations.

Python
from minillm.tokenizer import BBPETokenizer
from minillm.data import TokenStream

def high_entropy_corpus(n_lines: int = 12_000, lexicon: int = 600,
                        seed: int = 0) -> str:
    """A pseudo-language: large lexicon, Zipfian frequencies.

    Unequal frequencies define a controlled prediction task. Loss is measured
    per BPE token, so the entropy of the word distribution is not a direct
    reference value for this metric.
    """
    rng = random.Random(seed)
    alphabet = "abcdefghijklmnopqrstuvwxyz"
    words = ["".join(rng.choice(alphabet) for _ in range(rng.randint(3, 8)))
             for _ in range(lexicon)]
    weights = [1.0 / (i + 1) ** 0.9 for i in range(lexicon)]   # Zipfian tail
    lines = []
    for _ in range(n_lines):
        k = rng.randint(6, 14)
        lines.append(" ".join(rng.choices(words, weights=weights, k=k)) + ".\n")
    return "".join(lines)

corpus = high_entropy_corpus(n_lines=8_000, seed=SEED)
lines = corpus.splitlines(keepends=True)
cut = int(len(lines) * 0.9)
train_text, eval_text = "".join(lines[:cut]), "".join(lines[cut:])
tokenizer = BBPETokenizer.train(train_text, vocab_size=1024, specials=["<|eos|>"])
stream = TokenStream.from_text(train_text, tokenizer, seq_len=128)
eval_stream = TokenStream.from_text(eval_text, tokenizer, seq_len=128)
print(f"corpus {len(corpus):,} characters · vocabulary {tokenizer.vocab_size} · "
      f"{len(stream):,} windows · sha {stream.checksum()}")
print(f"uniform-model reference loss: ln(V) = "
      f"{math.log(tokenizer.vocab_size):.3f}")

3. Two models: target and draft

Speculative decoding needs two models sharing a vocabulary: a large one whose distribution we are obliged to preserve, and a small one that proposes candidates cheaply. We train both on the same data — that makes the draft resemble the target, and the acceptance rate depends directly on that resemblance (M11).

Python
from minillm.config import MiniLLMConfig
from minillm.model import MiniLLM
from minillm.train import TrainConfig, evaluate, train

def build(hidden: int, layers: int, heads: int, kv: int) -> MiniLLM:
    cfg = MiniLLMConfig.profile("S", vocab_size=tokenizer.vocab_size,
                                hidden_size=hidden, num_hidden_layers=layers,
                                num_attention_heads=heads, num_key_value_heads=kv,
                                head_dim=hidden // heads, max_position_embeddings=256)
    return MiniLLM(cfg).to(DEVICE)

# This budget targets CPU execution. Longer training is a separate experiment;
# remeasure both loss and sensitivity to quantization afterwards.
models = {}
for name, (hidden, layers, heads, kv, steps) in {
    "target": (192, 4, 6, 2, 250),
    "draft":  (96, 2, 4, 2, 250),
}.items():
    if os.getenv("CAPSTONE_FAST_VALIDATION") == "1":
        steps = 12
    torch.manual_seed(SEED)
    model = build(hidden, layers, heads, kv)
    print(f"--- {name}: {model.num_parameters():,} parameters")
    train(model, stream, TrainConfig(steps=steps, seq_len=128, micro_batch_size=8,
                                     optimizer="muon", seed=SEED, device=DEVICE, compute_dtype="auto",
                                     log_every=steps // 2))
    models[name] = model

target, draft = models["target"], models["draft"]
eval_cfg = TrainConfig(seq_len=128, micro_batch_size=8, device=DEVICE, compute_dtype="auto")
base_loss = evaluate(target, eval_stream, eval_cfg, batches=(2 if os.getenv("CAPSTONE_FAST_VALIDATION") == "1" else 16))
BASELINE_COMPUTE = resolve_compute_dtype(DEVICE, "auto")
BASELINE_PARAMETER = model_parameter_dtype(target)
BASELINE_LABEL = f"{BASELINE_PARAMETER}/{BASELINE_COMPUTE}"
BASELINE_STORAGE_BITS = 32.0 if BASELINE_PARAMETER == "fp32" else 16.0
print("target eval loss:", base_loss, "uniform reference:", math.log(tokenizer.vocab_size))

4. Prefill and decode: what timing can tell us

Prefill processes prompt positions together, while decode reuses earlier keys and values. Weight traffic can limit dense decoding at small batch sizes; longer prefill often makes better use of arithmetic hardware. Module 10 derives the relevant conditions through arithmetic intensity.

Small matrices, Python overhead, and cache operations also affect this implementation. A latency-versus-length curve cannot by itself identify a hardware bottleneck. Record the device, precision, and lengths, then compare measured phases; diagnosing the limiting resource would require profiling.

Python
from minillm.generate import SamplingConfig, generate

rows = []
for prompt_len in (16, 32, 64, 128):
    prompt = tokenizer.encode(corpus[:4000])[:prompt_len]
    _, stats = generate(target, prompt, 32, SamplingConfig(temperature=0.0))
    rows.append((prompt_len, stats.prefill_seconds * 1000,
                 stats.decode_seconds / stats.generated_tokens * 1000,
                 stats.kv_bytes / 1024))

print(f"{'prompt':>7} {'prefill, ms':>12} {'ms/token decode':>17} {'KV cache, KiB':>15}")
for prompt_len, prefill_ms, decode_ms, kv_kib in rows:
    print(f"{prompt_len:>7} {prefill_ms:>12.2f} {decode_ms:>17.2f} {kv_kib:>15.1f}")

print('Compare measured phases; diagnosing a bottleneck needs repeated measurements and profiling.')

5. The KV cache: arithmetic and three attention schemes

The formula from M5: 2 · L · H_kv · d_head · sizeof(dtype) bytes per token. It is not quoted here but checked against the memory actually allocated.

Python
from minillm.config import gqa_variants, kv_bytes_per_token

variants = gqa_variants(target.cfg)
runtime_bytes = 4 if BASELINE_COMPUTE == "fp32" else 2
print(f"{'scheme':>6} {'H_kv':>5} {('bytes/token ' + BASELINE_COMPUTE):>20} {'at 128K context':>20}")
for name, cfg in variants.items():
    per_token = kv_bytes_per_token(cfg, dtype_bytes=runtime_bytes)
    print(f"{name:>6} {cfg.num_key_value_heads:>5} {per_token:>20,} "
          f"{per_token * 131072 / 1024 ** 3:>18.2f} GiB")

# Checking the formula against the actual cache allocation.
prompt = tokenizer.encode(corpus[:2000])[:48]
cache = target.new_cache()
with torch.no_grad():
    with autocast_context(DEVICE, BASELINE_COMPUTE):
        target(torch.tensor([prompt], device=DEVICE), cache=cache)
actual_bytes = cache.nbytes() // (len(prompt) * 2 * target.cfg.num_hidden_layers * target.cfg.kv_dim)
expected = kv_bytes_per_token(target.cfg, dtype_bytes=actual_bytes) * len(prompt)
print(f"\nactual cache dtype: {str(cache.dtype()).replace('torch.', '')}")
print(f"actual {cache.nbytes():,} B == formula {expected:,} B: {cache.nbytes() == expected}")

assert cache.nbytes() == expected

Prefix caching

Compute a repeated system prefix once and retain its K/V. Clone that cache for each request so one request's suffix cannot enter another's context. The experiment uses greedy decoding with unchanged weights, positions, and masks.

Check token agreement in this run. Logit equivalence is numerical, with a tolerance; rounding can reverse almost tied candidates. Sampling would additionally require aligned randomness. Equal output distributions do not imply identical individual sequences.

Python
system_prompt = tokenizer.encode(corpus[:1200])[:96]
questions = [tokenizer.encode(corpus[5000 + 500 * i:5000 + 500 * i + 200])[:16]
             for i in range(4)]

warm = target.new_cache()
if DEVICE.startswith("cuda"):
    torch.cuda.synchronize()
t0 = time.perf_counter()
with torch.no_grad():
    with autocast_context(DEVICE, BASELINE_COMPUTE):
        target(torch.tensor([system_prompt], device=DEVICE), cache=warm)
if DEVICE.startswith("cuda"):
    torch.cuda.synchronize()
warmup_ms = (time.perf_counter() - t0) * 1000

cold_total = hot_total = 0.0
identical = True
for tail in questions:
    cold, cold_stats = generate(target, system_prompt + tail, 16,
                                SamplingConfig(temperature=0.0))
    hot, hot_stats = generate(target, system_prompt + tail, 16,
                              SamplingConfig(temperature=0.0), cache=warm.clone())
    identical &= cold == hot
    cold_total += cold_stats.prefill_seconds
    hot_total += hot_stats.prefill_seconds

print(f"results identical: {identical}")
print(f"prefill without cache: {cold_total * 1000:6.1f} ms over {len(questions)} requests")
print(f"prefill with cache:    {hot_total * 1000:6.1f} ms (+{warmup_ms:.1f} ms warmup, once)")
print(f"saving over {len(questions)} requests: "
      f"×{cold_total / max(hot_total, 1e-9):.1f} on the prefill phase")

6. Quantization and three kinds of change

Start with storage accounting: a full group of gg four-bit weights and an eight-bit scale costs (4g+8)/g(4g+8)/g bits per weight, or 4.25 at g=32g=32 and 4.50 at g=16g=16. The implementation report also accounts for partial groups and metadata. Its MXFP4/NVFP4 paths are teaching approximations.

Loss scores target-token probabilities; KL measures distributional change relative to the original model; top-1 agreement counts unchanged most-likely tokens. Their sensitivity depends on the inputs and errors. A top-1 change does not directly measure whether a user notices a difference, so the intended task needs a separate check.

Python
from minillm.generate import quantize_model

probe = eval_stream.batch(torch.arange(16)).to(DEVICE)
with torch.no_grad():
    with autocast_context(DEVICE, BASELINE_COMPUTE):
        reference_logits, _ = target(probe)
reference_probs = torch.softmax(reference_logits.float(), dim=-1)
reference_top1 = reference_logits.argmax(-1)

print(f"{'format':>7} {'eff bits/wt':>11} {'compr.':>8} {'loss':>8} {'Δloss':>8} "
      f"{'KL, nats':>9} {'top-1 agree':>12}")
print(f"{BASELINE_LABEL:>7} {BASELINE_STORAGE_BITS:>11.2f} {'×1.00':>8} {base_loss:>8.4f} {0.0:>8.4f} "
      f"{0.0:>9.5f} {1.0:>11.1%}")

quant_rows = []
for method in ("int8", "nvfp4", "mxfp4"):
    clone = copy.deepcopy(target)
    report = quantize_model(clone, method)
    loss = evaluate(clone, eval_stream, eval_cfg, batches=(2 if os.getenv("CAPSTONE_FAST_VALIDATION") == "1" else 16))
    with torch.no_grad():
        with autocast_context(DEVICE, BASELINE_COMPUTE):
            logits, _ = clone(probe)
    probs = torch.softmax(logits.float(), dim=-1)
    kl = torch.sum(reference_probs * (torch.log(reference_probs + 1e-12)
                                      - torch.log(probs + 1e-12)), dim=-1).mean().item()
    agreement = (logits.argmax(-1) == reference_top1).float().mean().item()
    quant_rows.append((method, report, loss, kl, agreement))
    print(f"{method:>7} {report['effective_bits_per_weight']:>11.2f} "
          f"{'×' + format(report['compression_vs_runtime_fp32'], '.2f'):>8} {loss:>8.4f} "
          f"{loss - base_loss:>+8.4f} {kl:>9.5f} {agreement:>11.1%}")
Python
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
names = [BASELINE_LABEL] + [r[0] for r in quant_rows]
colors = ["#4C566A", "#A3BE8C", "#EBCB8B", "#BF616A"]

axes[0].bar(names, [base_loss] + [r[2] for r in quant_rows], color=colors)
axes[0].set_ylim(min([base_loss] + [r[2] for r in quant_rows]) * 0.98, max([base_loss] + [r[2] for r in quant_rows]) * 1.02)
axes[0].set_title("loss (note the clipped axis!)"); axes[0].set_ylabel("nats")

axes[1].bar(names, [0.0] + [r[3] for r in quant_rows], color=colors)
axes[1].set_title("KL(baseline ‖ fake quant)"); axes[1].set_ylabel("nats")

axes[2].bar(names, [1.0] + [r[4] for r in quant_rows], color=colors)
axes[2].set_ylim(0, 1.05); axes[2].set_title("top-1 agreement with baseline")

for ax in axes:
    ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()

Compare all three plots and notice where the loss axis begins. Similar loss bars can coexist with meaningful distributional change. A format ordering in this run is not a universal ranking: weights, grouping, scales, and the task all matter.

This is fake quantization: rounded weights are returned to a working floating-point format. It studies representation error without implementing packed arithmetic. The timings therefore establish no four-bit inference speedup; that requires a compatible storage format and execution kernels.

7. Speculative decoding

The draft proposes k tokens in a row and the target verifies them in one pass. The acceptance rule min(1, p/q), with a fallback draw from the normalized residual max(0, p−q), preserves the target's distribution exactly (M11) — the speedup is free in quality terms.

The key metric is not the acceptance rate itself but how many tokens come out per pass of the large model. Without speculation it equals one by definition.

Python
from minillm.generate import speculative_generate

prompt = tokenizer.encode(corpus[:2000])[:32]
print(f"{'k':>3} {'accept':>8} {'tokens/target pass':>21} {'draft passes':>14}")
lookahead_rows = []
for k in (1, 2, 4, 8):
    _, stats = speculative_generate(target, draft, prompt, 48, lookahead=k,
                                    sampling=SamplingConfig(temperature=1.0, seed=SEED))
    lookahead_rows.append((k, stats.acceptance_rate, stats.tokens_per_target_forward,
                           stats.draft_decode_forwards))
    print(f"{k:>3} {stats.acceptance_rate:>7.1%} "
          f"{stats.tokens_per_target_forward:>21.2f} {stats.draft_decode_forwards:>14}")
Python
# A draft equal to the target: the min(1, p/q) rule must accept everything.
_, twin = speculative_generate(target, copy.deepcopy(target), prompt, 32, lookahead=4,
                               sampling=SamplingConfig(temperature=1.0, seed=SEED))
print(f"draft == target: acceptance {twin.acceptance_rate:.1%}, "
      f"{twin.tokens_per_target_forward:.2f} tokens per pass")

fig, axes = plt.subplots(1, 2, figsize=(11, 4))
ks = [r[0] for r in lookahead_rows]
axes[0].plot(ks, [r[1] for r in lookahead_rows], "o-", color="#5E81AC")
axes[0].set_xlabel("draft depth k"); axes[0].set_ylabel("acceptance rate")
axes[0].set_title("observed acceptance")
axes[1].plot(ks, [r[2] for r in lookahead_rows], "o-", color="#A3BE8C")
axes[1].axhline(1.0, ls="--", lw=1, color="#4C566A", label="no speculation")
axes[1].set_xlabel("draft depth k")
axes[1].set_ylabel("tokens per target pass")
axes[1].set_title("observed yield per verification"); axes[1].legend()
for ax in axes:
    ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()

import json
inference_record = {"seed": SEED, "device": DEVICE,
    "compute_dtype": BASELINE_COMPUTE, "parameter_dtype": BASELINE_PARAMETER,
    "evaluation_sha": eval_stream.checksum(), "base_loss": base_loss,
    "quantization": [dict(method=r[0], report=r[1], loss=r[2], kl=r[3], top1=r[4]) for r in quant_rows],
    "speculative": [dict(k=r[0], acceptance=r[1], tokens_per_verification=r[2], draft_decode_passes=r[3]) for r in lookahead_rows],
    "identical_draft_acceptance": twin.acceptance_rate,
    "prefix_greedy_identical": identical}
Path("runs/inference").mkdir(parents=True, exist_ok=True)
Path("runs/inference/measurements.json").write_text(
    json.dumps(inference_record, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8")

Acceptance rate and tokens per verification pass describe different aspects of the run. Under a constant acceptance probability, longer drafts increase expected emitted tokens with diminishing returns. Real probabilities can depend on position and history. Neither falling acceptance nor a monotone measured yield curve is guaranteed by the algorithm.

Accepted tokens still cost proposal and verification work. Compare the avoided target passes with both models' costs, keeping prefill separate from verification. Module 11 derives the break-even condition under explicit assumptions. This batch-one experiment does not measure server throughput or its workload dependence.

8. Results and their scope

KV size is checked against geometry and actual dtype. Prefix reuse tests previously computed states; quantization compares several kinds of prediction change. Speculation records acceptance and token yield alongside draft work.

The saved report describes particular weights, inputs, and hardware. A small timing sample does not diagnose the bottleneck; fake quantization does not test a packed kernel's speed; pseudo-language does not replace target tasks. Repeat warmed-up timing runs and report variability. Assess quality with held-out items and error analysis.

Notebook 4 connects architecture changes to controlled experiments and builds its final table from the measurements of its own run.

Capstone · Notebook 4. Ablations: from a hypothesis to a measurement

MiniLLM · Capstone

Download .ipynb 04_ablations.ipynb · 42.2 KB

Show notebook text

Capstone · Notebook 4. Ablations: from a hypothesis to a measurement

Files for this notebook. Download course-materials.zip from the course website and extract the complete archive, preserving the capstone folder. In Colab, upload the same ZIP when the notebook prompts for files.

Modern LLMs course · final practical project

The nineteen modules introduced mechanisms together with their assumptions. Here we ask narrower experimental questions: how normalization changes activation scale, how much KV memory GQA saves, what changes when experts replace an FFN, and how a model handles dependency lengths absent from training.

For each comparison, state the factor, controls, and measurement in advance. One conceptual change can require several coordinated config fields. A shared seed makes initialization reproducible, but cannot make every initial tensor identical across different architectures.

On a first pass, run the cells and interpret each table. On a second, select an experiment and repeat it across seeds to test a hypothesis. The final report is assembled from this run's results. Fast validation checks execution; it does not substitute for sufficient training.

1. Common setup

Python
import importlib
import sys
import subprocess
import zipfile
from pathlib import Path

ARCHIVE_GLOB = "modern-llms-capstone-*.zip"

def _activate_capstone_root(candidate: Path):
    package_init = candidate / "minillm" / "__init__.py"
    if not package_init.exists():
        return None
    root = str(candidate.resolve())
    if root not in sys.path:
        sys.path.insert(0, root)
    return root

def _find_capstone_root(search_root: Path):
    direct = _activate_capstone_root(search_root)
    if direct:
        return direct
    for package_root in (search_root / "capstone" / "minillm" / "en", search_root / "minillm" / "en"):
        found = _activate_capstone_root(package_root)
        if found:
            return found
    for child in sorted(search_root.glob("modern-llms-capstone-*")):
        found = _activate_capstone_root(child)
        if found:
            return found
    return None

def _safe_extract(archive_path: Path, destination: Path):
    destination = destination.resolve()
    with zipfile.ZipFile(archive_path) as archive:
        for member in archive.infolist():
            member_path = Path(member.filename)
            if member_path.is_absolute() or ".." in member_path.parts:
                raise RuntimeError(f"unsafe path in archive: {member.filename}")
        archive.extractall(destination)

def ensure_minillm():
    for candidate in (Path.cwd(), *Path.cwd().parents):
        found = _find_capstone_root(candidate)
        if found:
            return "project package: " + found

    try:
        import minillm  # noqa: F401
        return "minillm is already importable"
    except ImportError:
        pass

    archives = sorted(Path.cwd().glob(ARCHIVE_GLOB)) + sorted(Path.cwd().glob("course-materials.zip"))
    if not archives:
        try:
            from google.colab import files
        except ImportError:
            files = None
        if files is not None:
            print("Upload course-materials.zip from the course notebook page. The directory structure will be restored automatically.")
            files.upload()
            archives = sorted(Path.cwd().glob(ARCHIVE_GLOB)) + sorted(Path.cwd().glob("course-materials.zip"))

    if archives:
        archive_path = archives[-1]
        destination = Path.cwd() / ".capstone_runtime"
        destination.mkdir(exist_ok=True)
        _safe_extract(archive_path, destination)
        root = _find_capstone_root(destination)
        if root:
            return "project archive: " + str(archive_path)
        matches = sorted(destination.glob("*/minillm/__init__.py"))
        if len(matches) != 1:
            raise RuntimeError(
                f"expected one minillm package in {archive_path.name}, "
                f"found {len(matches)}"
            )
        root = _activate_capstone_root(matches[0].parent.parent)
        if root:
            return "project archive: " + str(archive_path)

    raise RuntimeError("minillm was not found. Download course-materials.zip from the course notebook page and either extract it in full or upload the ZIP to the current Colab session.")

print(ensure_minillm())

for package in ("torch", "numpy", "matplotlib", "regex"):
    try:
        importlib.import_module(package)
    except ImportError:
        subprocess.run(
            [sys.executable, "-m", "pip", "install", "-q", package],
            check=True,
        )

import math, os, random
import torch, numpy as np
import matplotlib.pyplot as plt

SEED = 0
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
STEPS = 60 if DEVICE == "cpu" else 600
if os.getenv("CAPSTONE_FAST_VALIDATION") == "1":
    STEPS = 5     # on a GPU the contrasts are sharper
print("device:", DEVICE, "| steps per run:", STEPS)
Python
from minillm.config import MiniLLMConfig, kv_bytes_per_token
from minillm.data import TokenStream
from minillm.model import MiniLLM, RMSNorm
from minillm.tokenizer import BBPETokenizer
from minillm.train import TrainConfig, evaluate, train

def high_entropy_corpus(n_lines=8_000, lexicon=600, seed=0) -> str:
    """The same pseudo-language as notebook 3: a Zipfian lexicon leaves models
    the headroom in which architectural variants are distinguishable."""
    rng = random.Random(seed)
    alphabet = "abcdefghijklmnopqrstuvwxyz"
    words = ["".join(rng.choice(alphabet) for _ in range(rng.randint(3, 8)))
             for _ in range(lexicon)]
    weights = [1.0 / (i + 1) ** 0.9 for i in range(lexicon)]
    return "".join(" ".join(rng.choices(words, weights=weights, k=rng.randint(6, 14)))
                   + ".\n" for _ in range(n_lines))

corpus = high_entropy_corpus(seed=SEED)
lines = corpus.splitlines(keepends=True)
a, b = int(len(lines) * .8), int(len(lines) * .9)
train_text, valid_text, test_text = map("".join, (lines[:a], lines[a:b], lines[b:]))
tokenizer = BBPETokenizer.train(train_text, vocab_size=1024, specials=["<|eos|>"])
train_stream = TokenStream.from_text(train_text, tokenizer, 128)
valid_stream = TokenStream.from_text(valid_text, tokenizer, 128)
test_stream = TokenStream.from_text(test_text, tokenizer, 128)
CEILING = math.log(tokenizer.vocab_size)
print(f"vocabulary {tokenizer.vocab_size} · train {len(train_stream):,} windows · "
      f"valid {len(valid_stream):,} windows · uniform reference ln(V) = {CEILING:.3f}")
Python
BASE = dict(vocab_size=tokenizer.vocab_size, hidden_size=128, num_hidden_layers=3,
            num_attention_heads=4, num_key_value_heads=2, head_dim=32,
            max_position_embeddings=256)

def run(label: str, steps: int = None, **overrides):
    """One ablation run: fixed seed, fixed data, fixed schedule."""
    cfg = MiniLLMConfig.profile("S", **{**BASE, **overrides})
    torch.manual_seed(SEED)
    model = MiniLLM(cfg).to(DEVICE)
    tcfg = TrainConfig(steps=steps or STEPS, seq_len=128, micro_batch_size=8,
                       optimizer="muon", seed=SEED, device=DEVICE, compute_dtype="auto", log_every=10_000)
    train(model, train_stream, tcfg)
    loss = evaluate(model, valid_stream, tcfg, batches=8)
    total = cfg.parameter_report()["total"]
    print(f"  {label:<26} parameters {total:>9,} · active "
          f"{cfg.active_parameters():>9,} · valid loss {loss:.4f}")
    return dict(label=label, cfg=cfg, model=model, loss=loss, params=total,
                active=cfg.active_parameters())

2. Norm placement (Module 6)

Compare pre-norm, post-norm, Peri-LN, OLMo2-style normalization, and a no-norm control. These normalize different parts of the computation graph; sharing a scale-related purpose does not make them equivalent.

Start with a separate residual chain h←h+tanh⁡(h)Wh\leftarrow h+\tanh(h)W, using random matrix entries with variance 1/d1/d. This is a nonlinear illustration rather than the lecture's exact linear recurrence. Trace RMS over 36 layers. The bounded tanh changes growth behavior, so exponential growth is not assumed.

Python
torch.manual_seed(1)
d, layers = 256, 36
h0 = torch.randn(1, d)
weights = [torch.randn(d, d) / math.sqrt(d) for _ in range(layers)]
norm = RMSNorm(d, 1e-6)

traces = {}
with torch.no_grad():
    for mode in ("none", "pre", "post"):
        h, trace = h0.clone(), []
        for W in weights:
            if mode == "none":
                h = h + torch.tanh(h) @ W
            elif mode == "pre":
                h = h + torch.tanh(norm(h)) @ W
            else:
                h = norm(h + torch.tanh(h) @ W)
            trace.append(h.pow(2).mean().sqrt().item())
        traces[mode] = trace

plt.figure(figsize=(7, 4))
for mode, color in (("none", "#BF616A"), ("pre", "#5E81AC"), ("post", "#A3BE8C")):
    plt.plot(range(1, layers + 1), traces[mode], label=mode, color=color)
plt.yscale("log"); plt.xlabel("layer"); plt.ylabel("stream rms")
plt.title("Nonlinear residual chain: 36 layers")
plt.legend(); plt.gca().spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()

for mode, trace in traces.items():
    print(f"{mode:>5}: layer 1 = {trace[0]:8.3f} → layer 36 = {trace[-1]:10.3f}")

Read the first and last RMS values for each branch. Normalizing after the addition keeps RMS near one, subject to epsilon; the other branches can accumulate updates. The plot determines the size of that growth.

Next ask the question in a trained three-layer MiniLLM. Its initialization and sublayer function differ from the illustrative chain, so the earlier numbers are not predictions for the model. Compare loss and layerwise RMS under a shared budget and optimizer configuration.

Python
def stream_rms(model: MiniLLM) -> list[float]:
    ids = valid_stream.batch(torch.arange(4)).to(DEVICE)
    values = []
    with torch.no_grad():
        x = model.embed_tokens(ids)
        cos, sin = model.rope(torch.arange(ids.shape[1], device=DEVICE))
        for layer in model.layers:
            x = layer(x, cos, sin)
            values.append(x.float().pow(2).mean().sqrt().item())
    return values

print("norm placement ablation:")
norm_runs = [run(f"norm_placement={p}", norm_placement=p)
             for p in ("pre", "post", "peri", "olmo2", "none")]

print("\nresidual stream rms by layer of the trained models:")
for r in norm_runs:
    r["rms"] = stream_rms(r["model"])
    print(f"  {r['label']:<26} " +
          " → ".join(f"{v:.2f}" for v in r["rms"]))

Loss and residual RMS answer different questions. If a variant has higher loss, inspect its activation and gradient scales before diagnosing an exploding stream. Conversely, modest growth across three layers does not refute a depth-related problem.

Peri-LN normalizes both sides of a sublayer; the OLMo2-style graph normalizes its output before addition. Changing the update scale can change the appropriate learning rate. A shared rate is a useful control, not a complete tuning protocol. This table compares configurations; comparing methods requires per-method tuning and repeated runs.

3. MHA / GQA / MQA (M5)

Here scale is not in the way: the cache saving is a property of geometry rather than of training, and it computes exactly. The open question is different — what the model loses in quality.

Python
print("attention ablation:")
attn_runs = []
for name, kv in (("MHA (H_kv=4)", 4), ("GQA (H_kv=2)", 2), ("MQA (H_kv=1)", 1)):
    r = run(name, num_key_value_heads=kv)
    r["kv_per_token"] = kv_bytes_per_token(r["cfg"])
    attn_runs.append(r)

print(f"\n{'scheme':<14} {'KV, B/token':>12} {'× vs MHA':>9} {'valid loss':>11} {'Δloss':>8}")
mha = attn_runs[0]
for r in attn_runs:
    print(f"{r['label']:<14} {r['kv_per_token']:>12,} "
          f"{mha['kv_per_token'] / r['kv_per_token']:>8.1f}× "
          f"{r['loss']:>11.4f} {r['loss'] - mha['loss']:>+8.4f}")

With length, layer count, and dtype fixed, KV storage changes by H/H_kv. This follows from configuration geometry independently of training quality. The loss difference is empirical: read its sign and magnitude from this run.

A small gap does not establish that reducing KV heads is always free, and a large gap does not rank attention schemes for every budget. Projection counts and optimization trajectories also change. Broader conclusions need repeated runs and suitable tuning.

4. Activations within the GLU family (Module 6)

Compare SwiGLU, GeGLU, and ReGLU. Each uses two input matrices and an output matrix; the gate activation changes. Equal widths give equal parameter counts, which the assertion verifies.

The dff≈8d/3d_{ff}\approx8d/3 rule matches a three-matrix GLU budget to a conventional two-matrix FFN of width 4d4d. We round the width for this implementation and do not implement the two-matrix control. The experiment therefore compares GLU activations, not the value of gating itself.

Python
d = BASE["hidden_size"]
gated_ff = round(8 * d / 3 / 64) * 64          # the 2/3 rule, aligned to 64
print(f"all variants: 3 · {d} · {gated_ff} = {3 * d * gated_ff:,} FFN parameters\n")

print("ablation of φ inside the GLU family:")
ffn_runs = [run(f"{name} (3 matrices)", hidden_act=act, intermediate_size=gated_ff)
            for name, act in (("SwiGLU", "swiglu"), ("GeGLU", "geglu"),
                              ("ReGLU", "relu"))]

assert len({r["params"] for r in ffn_runs}) == 1, "budgets must match"
best = min(ffn_runs, key=lambda r: r["loss"])
spread = max(r["loss"] for r in ffn_runs) - min(r["loss"] for r in ffn_runs)
print(f"\nbudgets match to the parameter: {ffn_runs[0]['params']:,}")
print(f"best on this run — {best['label']}, loss spread {spread:.4f}")

Check parameter equality and the observed loss spread. The code names the lowest-loss variant in this run, but one seed cannot estimate between-run variability. Repeat under several seeds with the same protocol and evaluation inputs before calling the difference stable.

Here hidden_act="relu" selects ReGLU: ReLU does not remove the second input matrix. Configuration names must be read alongside the computation graph. An ungated FFN remains outside this comparison.

5. Dense and MoE at similar active budgets (Module 7)

Keep vocabulary, attention, depth, and positions fixed while replacing the dense FFN with experts. Rounding expert width to a multiple of 64 can leave different active counts. The table reports that ratio alongside total parameters and loss; KV geometry stays shared.

Both branches receive twice the step budget of the other experiments. This matches processed tokens, not wall time or every FLOP. Routing and combining expert outputs add work. We observe this particular model pair, then investigate router load separately.

Python
from minillm.model import MoELayer

MOE_STEPS = STEPS * 2
EXPERTS, TOPK, SHARED = 8, 2, 1
expert_ff = max(64, round(gated_ff / (TOPK + SHARED) / 64) * 64)

print("sparsity ablation (step budget doubled):")
dense = run("dense", steps=MOE_STEPS, intermediate_size=gated_ff)
moe = run(f"MoE {EXPERTS} experts, top-{TOPK}", steps=MOE_STEPS,
          intermediate_size=gated_ff, num_local_experts=EXPERTS,
          num_experts_per_tok=TOPK, num_shared_experts=SHARED,
          moe_intermediate_size=expert_ff, first_k_dense_replace=1)

parity = moe["active"] / dense["active"]
sparsity = moe["params"] / moe["active"]
print(f"\n{'variant':<26} {'total':>12} {'active':>11} {'valid loss':>11}")
for r in (dense, moe):
    print(f"{r['label']:<26} {r['params']:>12,} {r['active']:>11,} {r['loss']:>11.4f}")
print(f"\nMoE active relative to dense: ×{parity:.3f} — active counts; runtime is measured separately")
print(f"capacity: total ×{moe['params'] / dense['params']:.2f}, "
      f"sparsity ×{sparsity:.2f}")
print(f"KV cache matches to the byte: "
      f"{kv_bytes_per_token(dense['cfg']) == kv_bytes_per_token(moe['cfg'])}")
Python
# What about expert load? This is the first thing to look at in an MoE.
def load_table(result, title):
    layers = [l.ffn for l in result["model"].layers if isinstance(l.ffn, MoELayer)]
    print(title)
    print(f"{'layer':>5} {'min':>7} {'max':>7} {'std':>7}  {'max|bias|':>9}")
    for i, layer in enumerate(layers, start=1):
        load = layer.expert_load
        print(f"{i:>5} {load.min():>7.3f} {load.max():>7.3f} {load.std():>7.3f}"
              f"  {layer.expert_bias.abs().max():>9.4f}")
    return max(float(l.expert_load.std()) for l in layers)

ideal = TOPK / EXPERTS
print(f"a perfectly even load would be {ideal:.3f} per expert\n")
std_default = load_table(moe, "default bias step (router_bias_speed = 1e-3):")

Inspect minimum, maximum, and standard deviation of load. This is a snapshot of the last measured batch, not a training-wide average. Concentrated assignments can signal imbalance; one snapshot cannot establish persistent collapse.

Increase the balancing-bias update rate from 10−310^{-3} to 5⋅10−25\cdot10^{-2} with other settings fixed. Compare both load and loss. A narrower load distribution need not improve predictions, because routing also has to learn which experts are useful.

Python
moe_fast = run(f"MoE, balance ×50", steps=MOE_STEPS, intermediate_size=gated_ff,
               num_local_experts=EXPERTS, num_experts_per_tok=TOPK,
               num_shared_experts=SHARED, moe_intermediate_size=expert_ff,
               first_k_dense_replace=1, router_bias_speed=5e-2)
print()
std_fast = load_table(moe_fast, "accelerated bias (router_bias_speed = 5e-2):")
print(f"\nmaximum load spread: {std_default:.3f} → {std_fast:.3f}")
print(f"valid loss: {moe['loss']:.4f} → {moe_fast['loss']:.4f} "
      f"(dense {dense['loss']:.4f})")

Expert capacity: how much is lost silently

An expert's buffer is finite and the router does not know it. capacity_factor sets the headroom above a perfectly even load; whatever does not fit is dropped. The run below is forward-only on the trained model — training is left alone.

Python
probe = valid_stream.batch(torch.arange(8)).to(DEVICE)
print(f"{'capacity_factor':>16} {'ceiling':>9} {'dropped':>11}")
capacity_rows = []
for factor in (None, 2.0, 1.5, 1.25, 1.0, 0.75):
    cfg_c = MiniLLMConfig(**{**moe["cfg"].to_dict(), "capacity_factor": factor})
    probe_model = MiniLLM(cfg_c).to(DEVICE)
    probe_model.load_state_dict(moe["model"].state_dict())
    with torch.no_grad():
        probe_model(probe)
    layer = next(l.ffn for l in probe_model.layers if isinstance(l.ffn, MoELayer))
    cap = cfg_c.expert_capacity(probe.numel())
    capacity_rows.append(dict(factor=factor, capacity=cap, dropped=float(layer.dropped_fraction)))
    print(f"{str(factor):>16} {str(cap):>9} {layer.dropped_fraction.item():>10.1%}")

Total and active counts and KV equality follow from the configs; loss and load come from the experiment. Keep those kinds of evidence separate. If faster balancing narrows load spread without closing the dense-model gap, imbalance has not thereby been ruled out as a contributing cause. The intervention may be incomplete and may change optimization in other ways.

Uniform top-2 routing among eight experts would assign a quarter of tokens to each on average. That is a conditional expectation, not a guaranteed load or a count of optimizer steps. A short budget limits the experiment without automatically explaining every outcome.

The capacity table measures dropped assignments for fixed weights. A limit based on balanced load can overflow under unequal assignments. Dropping an assignment does not remove the whole token: residual paths, attention, and other experts remain. Evaluating its quality cost would require scoring each capacity setting; this table measures dropping itself.

6. RoPE beyond training lengths (Module 4)

A long-range test needs a task containing a long-range dependency. Train a separate model on a random sequence repeated twice. Targets in the second half can be recovered from the first, with half-length controlling dependency distance. Successful copying does not identify a particular induction head; that would require evidence about internal computations.

Training distances range from 12 to 48, and evaluation distances from 48 to 128. First measure accuracy at distance 32. If the model has not learned the in-range task, low out-of-range accuracy cannot confidently diagnose failed extrapolation. Uniform guessing gives 1/64=0.0156251/64=0.015625. Fast validation uses only ten training steps and checks execution.

Sequence length varies across batches, so the budget is measured from actual input positions and supervised targets. The training config's seq_len does not control this generator. The next cell records both counts for each RoPE setting.

Python
from minillm.probes import RepeatTask, chance_level, repeat_accuracy
from minillm.model import RotaryEmbedding

PROBE_V = 64
probe = RepeatTask(vocab_size=PROBE_V, train_range=(12, 48), seed=SEED)
PROBE_BASE = dict(vocab_size=PROBE_V, hidden_size=128, num_hidden_layers=2,
                  num_attention_heads=4, num_key_value_heads=2, head_dim=32,
                  max_position_embeddings=512)
PROBE_STEPS = 1200 if DEVICE == "cpu" else 3000
if os.getenv("CAPSTONE_FAST_VALIDATION") == "1":
    PROBE_STEPS = 10

probe_budgets = {}

def train_probe(theta: float) -> MiniLLM:
    cfg = MiniLLMConfig.profile("S", **{**PROBE_BASE, "rope_theta": theta})
    torch.manual_seed(SEED)
    model = MiniLLM(cfg).to(DEVICE)
    probe_state = train(model, probe, TrainConfig(steps=PROBE_STEPS, seq_len=64,
                                    micro_batch_size=16, optimizer="muon",
                                    seed=SEED, device=DEVICE, log_every=10_000))
    probe_budgets[str(theta)] = {
        "input_positions": probe_state.tokens,
        "supervised_targets": probe_state.supervised_tokens,
    }
    return model

probe_models = {theta: train_probe(theta) for theta in (10_000.0, 500_000.0)}
print(f"chance level: {chance_level(probe):.4f}")
print("measured RoPE budgets:", probe_budgets)
probe_in_range = {str(theta): repeat_accuracy(model, probe, 32, device=DEVICE)
                  for theta, model in probe_models.items()}
for theta, accuracy in probe_in_range.items():
    print("rope_theta:", theta, "in-range accuracy at 32:", accuracy)
Python
LENGTHS = (48, 64, 80, 96, 128)
rope_rows = {f"base {theta:g}": [repeat_accuracy(m, probe, n, device=DEVICE)
                                 for n in LENGTHS]
             for theta, m in probe_models.items()}

# YaRN on top of an already trained model: RoPE has no parameters, so swapping
# the frequency table is enough — the weights stay as they are. That is the
# "stretching without fine-tuning" of M4. The pretraining window is 96 tokens
# (two halves of 48).
for factor in (2.0, 4.0):
    model = probe_models[10_000.0]
    saved = model.rope
    yarn_cfg = MiniLLMConfig.profile("S", **{**PROBE_BASE, "rope_theta": 10_000.0,
        "rope_scaling": {"rope_type": "yarn", "factor": factor,
                         "original_max_position_embeddings": 96}})
    model.rope = RotaryEmbedding(yarn_cfg).to(DEVICE)
    rope_rows[f"10000 + YaRN ×{factor:g}"] = [
        repeat_accuracy(model, probe, n, device=DEVICE) for n in LENGTHS]
    model.rope = saved

print(f"{'variant':<22}" + " ".join(f"{n:>8}" for n in LENGTHS))
print("-" * 64)
for name, values in rope_rows.items():
    print(f"{name:<22}" + " ".join(f"{v:>8.3f}" for v in values))
print("\ntraining used distances 12–48; everything right of 48 is extrapolation")
Python
plt.figure(figsize=(7.5, 4.5))
palette = ("#BF616A", "#D08770", "#5E81AC", "#A3BE8C")
for (name, values), color in zip(rope_rows.items(), palette):
    plt.plot(LENGTHS, values, "o-", label=name, color=color)
plt.axvline(48, ls="--", lw=1, color="#4C566A")
plt.text(49, 0.5, "training boundary", rotation=90, va="center",
         fontsize=9, color="#4C566A")
plt.axhline(chance_level(probe), ls=":", lw=1, color="#4C566A")
plt.text(120, chance_level(probe) + 0.03, "chance level",
         ha="right", fontsize=9, color="#4C566A")
plt.xlabel("dependency distance (half length)")
plt.ylabel("share of correctly restored tokens")
plt.title('Accuracy across dependency distances')
plt.legend(); plt.ylim(-0.03, 1.05)
plt.gca().spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()

Read the graph in two stages. Compare the two RoPE bases inside and beyond the training range. Then compare the original 10,000-base model with YaRN while holding learned weights fixed. The package's YaRN implementation defines the intervention, including any attention scaling specified by that mechanism.

Any long-range gain or short-range loss belongs to this training run and factor. Neither failure of ordinary RoPE nor recovery under YaRN is guaranteed. Retain every point, including in-range accuracy. Selecting a factor from these results turns them into development evidence and requires a fresh test.

7. Muon and AdamW: selection and held-out evaluation

Two default settings do not characterize the full potential of two optimizers. Search two learning rates for each under a common budget. Select on validation loss, then score the selected model on a separate test split kept out of selection.

The split precedes tokenizer training. All partitions share the synthetic generator, so test performance concerns new synthetic lines, not natural language. Repeating across seeds and widening the grid are separate follow-up experiments.

Python
def run_optimizer(optimizer: str, lr: float):
    cfg = MiniLLMConfig.profile("S", **BASE)
    torch.manual_seed(SEED)
    model = MiniLLM(cfg).to(DEVICE)
    tcfg = TrainConfig(steps=STEPS, seq_len=128, micro_batch_size=8,
                       optimizer=optimizer, lr=lr if optimizer == "adamw" else 3e-3,
                       muon_lr=lr if optimizer == "muon" else 0.02,
                       seed=SEED, device=DEVICE, log_every=10_000)
    train(model, train_stream, tcfg)
    return dict(model=model, config=tcfg, valid_loss=evaluate(model, valid_stream, tcfg, batches=8))

grid = {
    "adamw": [3e-3, 1e-2],
    "muon":  [2e-2, 6e-2],
}
optimizer_report = {}
for optimizer, lrs in grid.items():
    runs = {lr: run_optimizer(optimizer, lr) for lr in lrs}
    chosen_lr, selected = min(runs.items(), key=lambda kv: kv[1]["valid_loss"])
    test_loss = evaluate(selected["model"], test_stream, selected["config"], batches=8)
    optimizer_report[optimizer] = dict(selected_lr=chosen_lr,
        validation_grid={str(lr): r["valid_loss"] for lr,r in runs.items()},
        selected_valid_loss=selected["valid_loss"], selected_test_loss=test_loss)
    print(optimizer, optimizer_report[optimizer])

Compare selected learning rates, validation loss, and test loss. The test score did not participate in selection, making it a separate check of that choice. Two grid points do not guarantee good tuning; other optimizer coefficients remain fixed.

The run prints its horizon: 60 CPU steps or 600 GPU steps by default, reduced to 5 in fast validation. A short horizon can favor a quick starter without predicting long-run performance. One seed cannot establish a robust advantage; repeat the selection-and-test procedure to assess it.

8. From measurements to a saved report

Write the settings and numerical results to JSON, reload the file, and construct the summary from that saved record. The table can then be inspected independently of the training cells. Label geometry-derived quantities as calculations and loss, RMS, load, and accuracy as observations.

Some course questions are not tested here, such as packed-quantization speed or RL quality. Those receive neither invented values nor reproduction verdicts. Fast-mode reports explicitly identify their role as execution checks.

Python
CAPSTONE_LANGUAGE = 'en'
"""Embedded into both ablation notebooks; reads only this run's measurements."""
import json
from pathlib import Path

ablation_record = {
    "schema": 2,
    "language": CAPSTONE_LANGUAGE,
    "mode": "fast_execution_check" if os.getenv("CAPSTONE_FAST_VALIDATION") == "1" else "teaching_experiment",
    "seed": SEED, "device": DEVICE, "torch": torch.__version__,
    "steps": STEPS, "moe_steps": MOE_STEPS, "probe_steps": PROBE_STEPS,
    "data": {"train_sha": train_stream.checksum(), "valid_sha": valid_stream.checksum(),
             "test_sha": test_stream.checksum()},
    "chain_rms": traces,
    "norm": [{"label": r["label"], "valid_loss": r["loss"], "rms": r["rms"]} for r in norm_runs],
    "attention": [{"label": r["label"], "valid_loss": r["loss"],
                   "calculated_kv_bytes_per_token_bf16": r["kv_per_token"]} for r in attn_runs],
    "glu": [{"label": r["label"], "valid_loss": r["loss"], "parameters": r["params"]} for r in ffn_runs],
    "moe": {"dense_total": dense["params"], "moe_total": moe["params"],
            "dense_active": dense["active"], "moe_active": moe["active"],
            "active_ratio": parity, "dense_loss": dense["loss"], "moe_loss": moe["loss"],
            "faster_balance_loss": moe_fast["loss"],
            "last_batch_load_std": std_default, "faster_balance_load_std": std_fast},
    "capacity": capacity_rows,
    "rope": {"distances": list(LENGTHS), "accuracy": rope_rows,
             "training_budgets": probe_budgets,
             "in_range_32": probe_in_range, "chance": chance_level(probe)},
    "optimizers": optimizer_report,
    "not_tested": ["ungated FFN", "packed quantization speed", "RL answer quality",
                   "scaling-law optimum", "QK-norm ablation",
                   "speculative distribution test in this notebook", "EOT ablation in this notebook"],
}
report_dir = Path("runs/ablations")
report_dir.mkdir(parents=True, exist_ok=True)
report_path = report_dir / "measurements.json"
report_path.write_text(json.dumps(ablation_record, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8")
saved = json.loads(report_path.read_text(encoding="utf-8"))
assert saved == ablation_record
ru = CAPSTONE_LANGUAGE == "ru"
mode_ru = {"fast_execution_check": "быстрая проверка исполнения", "teaching_experiment": "учебный эксперимент"}
print("Режим:" if ru else "Mode:", mode_ru[saved["mode"]] if ru else saved["mode"])
print("Измерения сохранены:" if ru else "Measurements saved:", report_path)
labels_ru = {"chain_rms": "RMS остаточной цепочки", "norm": "Нормализация",
             "attention": "Внимание", "glu": "Варианты GLU", "moe": "Эксперты",
             "capacity": "Ограничение нагрузки", "rope": "Позиции", "optimizers": "Оптимизаторы"}
for key in ("chain_rms", "norm", "attention", "glu", "moe", "capacity", "rope", "optimizers"):
    print("\n" + (labels_ru[key] if ru else key))
    print(json.dumps(saved[key], ensure_ascii=False, indent=2))
print("\nЗдесь не проверялось:" if ru else "\nNot tested here:")
not_tested_ru = {"ungated FFN": "FFN без гейта",
    "packed quantization speed": "скорость упакованной квантизации",
    "RL answer quality": "качество ответов после RL",
    "scaling-law optimum": "оптимум закона масштабирования",
    "QK-norm ablation": "абляция QK-нормировки",
    "speculative distribution test in this notebook": "распределение спекулятивного семплирования в этой тетради",
    "EOT ablation in this notebook": "абляция EOT в этой тетради"}
for item in saved["not_tested"]:
    print("-", not_tested_ru[item] if ru else item)

9. Continuing the investigation

Choose one observation and pose a follow-up that could change your interpretation. Does the normalization loss gap survive per-mode learning-rate tuning? Does the MoE comparison change with the data distribution? Has the repetition task been learned before extrapolation is judged?

Retain the control, budget, data, and selection rule. Show variability when outcomes depend on the seed, and leave unmeasured quantities absent. A small or missing effect in one run limits that run's conclusion without dismissing the mechanism everywhere.

The project closes the course by connecting formulas, executable code, and evidence. Your report should let another reader distinguish measurements from config-derived calculations and identify the stronger claim that still needs a test.