LanguageintermediateGPU~35 minColab

Fit Your Own Scaling Law

قِس قانون التوسّع بنفسك

A scaling law is a measurement, not a constant

You can afford one model at a you have never trained at. How big should it be, and how much data should it see? The literature offers a famous answer: about 20 training tokens per parameter. But that number was measured, on someone else's models, data and tokenizer. In this notebook you measure your own. You train a few dozen tiny byte-level language models, fit power laws to the best of them, and commit to a model size and a for a budget 4× beyond anything you trained. Then you train at that budget and see how far off you were. On this setup the law comes out as N ∝ C^0.715, close to the older paper's exponent rather than the newer one's. And the best split is not a constant: it falls from 166 bytes per parameter at the smallest budget to 28 at the held-out one.

The goal

Predict the best model size and its loss at a budget 4× larger than any fitted point. Then train at that budget, measure, and land within the declared error.

Colab opens a read-only copy. Save a copy to Drive to keep your edits.

The notebook needs a keyboard — best opened on a desktop.

The papers behind this

In 2020, Kaplan and colleagues measured how a language model's loss falls as you add parameters, data and compute. Each relation was a , and their advice was to spend most of a growing budget on bigger models. In 2022, the Chinchilla paper ran the measurement again, more carefully, and reached a different answer: grow parameters and training tokens together, at about 20 tokens per parameter.

Neither result was derived from first principles. Both came from training many models, plotting the results, and fitting curves. That means you can do the same thing on a single GPU. The numbers you get will be your own, and that is the point of this notebook.

Check the code hash and that a GPU is attached. The grid needs one.

import azimuth_nb as azimuth

env = azimuth.setup(SLUG, lang=LANG, profile=PROFILE)
Workshop code
Fit Your Own Scaling Law
Tesla T4 · 14.6 GB · 12.7 GB RAM · PyTorch 2.11.0+cu130
profile: free
assets:
  · tinystories-v2-head.txt — 46,875 KB, verified
  · NotoNaskhArabic-Regular.ttf — 242 KB, verified
ready · seed=17, corpus_bytes=48000000, val_bytes=1000000, context=128, batch=16, lr=0.006, lr_width=32, init_std=0.02, layers=2, heads=2, width_min=8, width_max=224, width_step=4, budget_min=1000000000000, budget_factor=2, n_budgets=4, ratio_min=3, ratio_max=450, n_ratios=7, min_tokens=2000000, holdout_factor=4
code · 115a4e6bdc671638

The models read raw bytes, so the vocabulary is 256 symbols and there is no tokenizer to train or argue about. Every loss is measured in nats per byte on text the models never trained on.

The first number to note is the unigram baseline. It is the loss of a model that ignores context and predicts each byte from its overall frequency. Every trained model should beat it by a wide margin. If one does not, it learned nothing, and a curve fitted through it would be a curve through noise.

The baseline lands near 3 nats per byte. Remember that number; every trained model should end well below it.

import math

import numpy as np
import torch


def tr(en, ar):
    """Pick the printed string for the notebook's language."""
    return ar if env.lang == "ar" else en


cfg = env.cfg
device = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(cfg["seed"])

raw = np.frombuffer(env.assets["tinystories-v2-head.txt"].read_bytes(), dtype=np.uint8)
raw = raw[: int(cfg["corpus_bytes"])]
n_val = int(cfg["val_bytes"])
val_bytes, train_bytes = raw[:n_val], raw[n_val:]

# The zero-skill reference: predict every byte from its overall frequency.
counts = np.bincount(train_bytes, minlength=256).astype(np.float64)
probs = counts[counts > 0] / counts.sum()
unigram_loss = round(float(-(probs * np.log(probs)).sum()), 4)

# Cut the training bytes into non-overlapping windows and shuffle them ONCE.
# Every run reads a prefix of this same order, so no run sees a byte twice and
# all runs at all sizes see the same data in the same sequence.
ctx = int(cfg["context"])
n_chunks = len(train_bytes) // (ctx + 1)
train_chunks = (
    torch.from_numpy(train_bytes[: n_chunks * (ctx + 1)].copy()).view(n_chunks, ctx + 1).to(device)
)
order = torch.randperm(n_chunks, generator=torch.Generator().manual_seed(cfg["seed"])).to(device)
max_train_tokens = n_chunks * ctx

n_val_chunks = len(val_bytes) // (ctx + 1)
val_chunks = (
    torch.from_numpy(val_bytes[: n_val_chunks * (ctx + 1)].copy())
    .view(n_val_chunks, ctx + 1)
    .to(device)
)

print(
    tr(
        f"device {device} · train {len(train_bytes) / 1e6:.1f} MB · val {len(val_bytes) / 1e6:.2f} MB",
        f"الجهاز {device} · بيانات التدريب {len(train_bytes) / 1e6:.1f} ميغابايت · التحقق {len(val_bytes) / 1e6:.2f} ميغابايت",
    )
)
print(
    tr(
        f"unigram baseline: {unigram_loss:.3f} nats/byte",
        f"خط الأساس الأحادي: {unigram_loss:.3f} نات لكل بايت",
    )
)
print(train_bytes[:300].tobytes().decode("utf-8", errors="replace"))
Workshop code
device cuda · train 47.0 MB · val 1.00 MB
unigram baseline: 3.086 nats/byte
fixed the mailbox! I made it bigger for more letters." Lily was happy. Now she could send her letter to Max. They both walked home, smiling and laughing.
<|endoftext|>
Once upon a time, there was a bossy giant. He lived in a big house on a hill. All the people in the town were scared of him. One day

A compares models of different sizes, so we need a family of them. Ours has one knob. Every model has the same depth, set by 2 in the profile, and only the width changes. That takes the size from a few thousand to over a million. With the depth fixed, two models differ in size and in nothing else.

Each model is listed with two parameter counts: the total, as Chinchilla counted, and the count without the embedding tables, as Kaplan counted. The last column shows how much of each model the embedding tables take up. For now we use the total. The difference returns in an exercise.

Look at the last column. In the narrowest models the embedding tables are most of the parameters; in the widest they are a small part.

import torch.nn as nn
import torch.nn.functional as F

n_layer, n_head = int(cfg["layers"]), int(cfg["heads"])


class Block(nn.Module):
    def __init__(self, d):
        super().__init__()
        self.ln1 = nn.LayerNorm(d)
        self.qkv = nn.Linear(d, 3 * d)
        self.proj = nn.Linear(d, d)
        self.ln2 = nn.LayerNorm(d)
        self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))

    def forward(self, x):
        b, t, d = x.shape
        q, k, v = self.qkv(self.ln1(x)).split(d, dim=2)
        q, k, v = (z.view(b, t, n_head, d // n_head).transpose(1, 2) for z in (q, k, v))
        y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
        x = x + self.proj(y.transpose(1, 2).reshape(b, t, d))
        return x + self.mlp(self.ln2(x))


class TinyLM(nn.Module):
    """A byte-level GPT. The output layer shares weights with the input embedding."""

    def __init__(self, width, init_std):
        super().__init__()
        self.tok = nn.Embedding(256, width)
        self.pos = nn.Embedding(ctx, width)
        self.blocks = nn.ModuleList(Block(width) for _ in range(n_layer))
        self.ln_f = nn.LayerNorm(width)
        # The token table is also the output layer, so its scale sets the size
        # of the very first predictions. Small values start every run near
        # "all 256 bytes equally likely", which is where an untrained model
        # should start.
        nn.init.normal_(self.tok.weight, std=init_std)
        nn.init.normal_(self.pos.weight, std=init_std)

    def forward(self, idx):
        x = self.tok(idx) + self.pos(torch.arange(idx.shape[1], device=idx.device))
        for blk in self.blocks:
            x = blk(x)
        return self.ln_f(x) @ self.tok.weight.T


def count_params(width):
    """Two bookkeeping conventions for the same network."""
    block = 12 * width * width + 13 * width  # attention + MLP + norms
    embedding = 256 * width + ctx * width  # token + position tables
    non_embedding = n_layer * block + 2 * width  # + the final norm
    return {"total": non_embedding + embedding, "non_embedding": non_embedding}


# One family, one knob. Depth is fixed and only the width changes, so "model
# size" is a single number and no run differs from another in shape.
widths = list(range(cfg["width_min"], cfg["width_max"] + 1, cfg["width_step"]))
sizes = {w: count_params(w) for w in widths}

# The formula must agree with the network itself.
for w in (widths[0], widths[-1]):
    real = sum(p.numel() for p in TinyLM(w, cfg["init_std"]).parameters())
    assert real == sizes[w]["total"], (w, real, sizes[w])

print(
    tr(
        f"{len(widths)} models, {n_layer} layers each, "
        f"from {sizes[widths[0]]['total']:,} to {sizes[widths[-1]]['total']:,} parameters",
        f"{len(widths)} نموذجاً، في كلٍّ منها {n_layer} طبقات، "
        f"من {sizes[widths[0]]['total']:,} إلى {sizes[widths[-1]]['total']:,} معاملاً",
    )
)
print(
    tr(
        " width   params (total)   non-embedding   embedding share",
        " العرض   المعاملات (الكلّ)   دون التضمين   حصّة التضمين",
    )
)
for w in widths[:: max(1, len(widths) // 8)]:
    n = sizes[w]
    share = 1 - n["non_embedding"] / n["total"]
    print(f"{w:>6}   {n['total']:>14,}   {n['non_embedding']:>13,}   {share:>14.0%}")
Workshop code
55 models, 2 layers each, from 4,832 to 1,296,512 parameters
 width   params (total)   non-embedding   embedding share
     8            4,832           1,760              64%
    32           37,760          25,472              33%
    56           98,336          76,832              22%
    80          186,560         155,840              16%
   104          302,432         262,496              13%
   128          445,952         396,800              11%
   152          617,120         558,752               9%
   176          815,936         748,352               8%
   200        1,042,400         965,600               7%
   224        1,296,512       1,210,496               7%

Training a transformer with N parameters on D tokens costs about C ≈ 6·N·D floating-point operations: two per parameter per token for the forward pass, four for the backward pass. That approximation is what lets us trade N against D at a fixed compute budget.

A scaling law compares short runs with long ones, and narrow models with wide ones. So the trainer has to treat them all fairly, and three details in it exist for that reason.

First, each run's learning-rate schedule is stretched to that run's own length. The Chinchilla authors point to one schedule shared by all runs as a reason the earlier law favored large models: a loss read part-way through a long schedule makes short training look worse than it is.

Second, the embedding table starts with small values. Every run then begins from a neutral guess, and does not spend its first steps undoing a bad one.

Third, wider models get a smaller learning rate. With one rate for every size, either the narrow models or the wide ones would be trained badly.

These are three simple rules, and none is tuned for an individual run. The law you measure belongs to these rules as much as to the models. An exercise near the end breaks the second rule on purpose, to show what it does.

One small run before the grid. Its loss should already be under the unigram baseline.

import time


def train_run(width, tokens, init_std=None):
    """Train one model on exactly `tokens` fresh tokens; return its validation loss.

    The cosine schedule is stretched to this run's own length. That detail is the
    one the Chinchilla paper singles out: a schedule set for a longer run leaves
    a shorter run undertrained, and the scaling law inherits the error.

    The seed depends only on the width, so the same model starts from the same
    weights at every budget.
    """
    batch = int(cfg["batch"])
    steps = max(1, round(tokens / (batch * ctx)))
    assert steps * batch <= n_chunks, "run would repeat data; lower the budget"
    torch.manual_seed(cfg["seed"] + width)
    model = TinyLM(width, cfg["init_std"] if init_std is None else init_std).to(device)
    # Wider layers take smaller steps: the peak learning rate falls with the
    # square root of the width. With one rate for every size, either the narrow
    # models or the wide ones would be trained badly, and each valley would lean
    # toward whichever side the rate happened to suit.
    peak_lr = cfg["lr"] * math.sqrt(cfg["lr_width"] / width)
    opt = torch.optim.AdamW(model.parameters(), lr=peak_lr, betas=(0.9, 0.95), weight_decay=0.1)
    warmup = max(1, int(0.05 * steps))

    def lr_at(step):
        if step < warmup:
            return (step + 1) / warmup
        progress = (step - warmup) / max(1, steps - warmup)
        return 0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * progress))

    sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_at)
    model.train()
    for step in range(steps):
        rows = train_chunks[order[step * batch : (step + 1) * batch]].long()
        x, y = rows[:, :-1], rows[:, 1:]
        loss = F.cross_entropy(model(x).view(-1, 256), y.reshape(-1))
        opt.zero_grad(set_to_none=True)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
        sched.step()
    return evaluate(model), steps * batch * ctx


@torch.no_grad()
def evaluate(model):
    model.eval()
    total, n = 0.0, 0
    for i in range(0, len(val_chunks), 256):
        rows = val_chunks[i : i + 256].long()
        logits = model(rows[:, :-1])
        total += F.cross_entropy(
            logits.reshape(-1, 256), rows[:, 1:].reshape(-1), reduction="sum"
        ).item()
        n += rows[:, 1:].numel()
    return total / n


# One short run to make sure the loop learns before the grid spends real time.
t0 = time.time()
probe_loss, _ = train_run(widths[len(widths) // 4], int(cfg["min_tokens"]))
probe_loss = round(probe_loss, 4)
print(
    tr(
        f"probe run: loss {probe_loss:.3f} nats/byte (unigram {unigram_loss:.3f}) in {time.time() - t0:.1f}s",
        f"تشغيل تجريبي: الخسارة {probe_loss:.3f} نات لكل بايت (الأحادي {unigram_loss:.3f}) خلال {time.time() - t0:.1f} ث",
    )
)
Workshop code
probe run: loss 1.774 nats/byte (unigram 3.086) in 16.9s

Here is the core of the method. Pick a compute budget C. Train several models that all spend exactly C, from a large model on few tokens to a small model on many. Plot their losses against size. The result is an , and its lowest point is the best size for that budget. Repeat for several budgets.

We choose the splits by their tokens-per-parameter ratio, D / N, and spread those ratios widely on purpose. We do not know in advance where the best split lies, and the method only works if each budget's best run has worse runs on both sides of it.

Each row is one budget. As the budget grows, the whole row shifts toward larger models.

# Compute budgets, spaced by a constant factor. Within a budget C, each run
# splits the same compute differently: N parameters trained on D = C / (6 N)
# tokens. The splits are chosen by tokens-per-parameter ratio, D / N, over a
# wide range, because we do not know in advance where the best split lies.
budgets = [cfg["budget_min"] * cfg["budget_factor"] ** i for i in range(cfg["n_budgets"])]
ratios = np.geomspace(cfg["ratio_min"], cfg["ratio_max"], cfg["n_ratios"])


def affordable(C, w):
    """A split is allowed only if the run gets at least `min_tokens`, and no
    more tokens than the corpus holds, so no run repeats data."""
    return cfg["min_tokens"] <= C / (6 * sizes[w]["total"]) <= max_train_tokens


def plan_budget(C):
    """The widths to train at budget C: the family member nearest each target ratio."""
    chosen = set()
    for ratio in ratios:
        target = math.sqrt(C / (6 * ratio))
        w = min(widths, key=lambda w: abs(math.log(sizes[w]["total"] / target)))
        if affordable(C, w):
            chosen.add(w)
    return sorted(chosen)


def run_record(C, width, loss, tokens):
    n = sizes[width]
    return {
        "C": C,
        "width": width,
        "N": n["total"],
        "N_ne": n["non_embedding"],
        "D": tokens,
        "loss": loss,
    }


plan = [(C, w) for C in budgets for w in plan_budget(C)]
assert all(sum(C_ == C for C_, _ in plan) >= 3 for C in budgets), "a budget has too few sizes"
planned_tokens = sum(C / (6 * sizes[w]["total"]) for C, w in plan)
print(
    tr(
        f"{len(plan)} runs across {len(budgets)} budgets · {planned_tokens / 1e6:.0f}M tokens in total",
        f"{len(plan)} تشغيلاً على {len(budgets)} ميزانيات · {planned_tokens / 1e6:.0f} مليون رمز إجمالاً",
    )
)
for C in budgets:
    shown = ", ".join(f"{sizes[w]['total'] / 1e3:.0f}k" for C_, w in plan if C_ == C)
    print(f"  C = {C:.1e} FLOPs  →  N = {shown}")
Workshop code
21 runs across 4 budgets · 165M tokens in total
  C = 1.0e+12 FLOPs  →  N = 18k, 30k, 46k, 65k
  C = 2.0e+12 FLOPs  →  N = 30k, 38k, 65k, 98k, 139k
  C = 4.0e+12 FLOPs  →  N = 38k, 55k, 86k, 139k, 204k, 302k
  C = 8.0e+12 FLOPs  →  N = 55k, 86k, 125k, 187k, 281k, 446k

The longest cell. Read the table one budget at a time: as N grows, the loss falls and then rises again. Keep an eye on the steps column.

runs = []
t_grid = time.time()
for i, (C, w) in enumerate(plan):
    loss, tokens = train_run(w, C / (6 * sizes[w]["total"]))
    runs.append(run_record(C, w, loss, tokens))
    if i == 0 or (i + 1) % 5 == 0 or i + 1 == len(plan):
        done = sum(r["D"] for r in runs) / planned_tokens
        eta = max(0.0, (time.time() - t_grid) / done * (1 - done) / 60)
        print(
            tr(
                f"  {i + 1}/{len(plan)} runs · ~{eta:.0f} min left",
                f"  {i + 1}/{len(plan)} تشغيلاً · بقي نحو {eta:.0f} دقيقة",
            )
        )


def print_runs(rows):
    print(
        tr(
            "   budget C     params N     tokens D    steps     D/N    loss",
            "   الميزانية C   المعاملات N   الرموز D   الخطوات     D/N    الخسارة",
        )
    )
    for r in rows:
        steps = r["D"] // (cfg["batch"] * ctx)
        print(
            f"  {r['C']:.1e}  {r['N']:>10,}  {r['D'] / 1e6:>8.2f}M  {steps:>7,}  {r['D'] / r['N']:>6.0f}  {r['loss']:.4f}"
        )


print()
print_runs(runs)

n_runs = len(runs)
grid_minutes = round((time.time() - t_grid) / 60, 1)
worst_margin = round(min(unigram_loss - r["loss"] for r in runs), 3)
Workshop code
  1/21 runs · ~7 min left
  5/21 runs · ~6 min left
  10/21 runs · ~4 min left
  15/21 runs · ~3 min left
  20/21 runs · ~0 min left
  21/21 runs · ~0 min left

   budget C     params N     tokens D    steps     D/N    loss
  1.0e+12      17,840      9.34M    4,562     524  1.4915
  1.0e+12      30,352      5.49M    2,681     181  1.3980
  1.0e+12      45,936      3.63M    1,772      79  1.4365
  1.0e+12      64,592      2.58M    1,260      40  1.6986
  2.0e+12      30,352     10.98M    5,362     362  1.3216
  2.0e+12      37,760      8.83M    4,310     234  1.2852
  2.0e+12      64,592      5.16M    2,520      80  1.2537
  2.0e+12      98,336      3.39M    1,655      34  1.2700
  2.0e+12     138,992      2.40M    1,171      17  1.5905
  4.0e+12      37,760     17.66M    8,621     468  1.2221
  4.0e+12      54,880     12.15M    5,932     221  1.1801
  4.0e+12      86,320      7.72M    3,771      89  1.1193
  4.0e+12     138,992      4.80M    2,342      35  1.1427
  4.0e+12     203,952      3.27M    1,596      16  1.2178
  4.0e+12     302,432      2.20M    1,076       7  1.4549
  8.0e+12      54,880     24.30M   11,863     443  1.1301
  8.0e+12      86,320     15.45M    7,542     179  1.0668
  8.0e+12     124,672     10.69M    5,222      86  1.0331
  8.0e+12     186,560      7.15M    3,490      38  1.0380
  8.0e+12     281,200      4.74M    2,315      17  1.0896
  8.0e+12     445,952      2.99M    1,460       7  1.2783

Plot setup only. In the Arabic notebook it also loads a font that has Arabic glyphs.

import matplotlib.pyplot as plt
from matplotlib import font_manager
from matplotlib.ticker import FuncFormatter, LogLocator, NullFormatter

if env.lang == "ar":
    # matplotlib draws Arabic as isolated letters in logical order. Reshape the
    # letters into their joined forms and reorder for display, and use a font
    # that actually contains Arabic glyphs (the default DejaVu does not).
    import arabic_reshaper
    from bidi.algorithm import get_display

    font_path = env.assets["NotoNaskhArabic-Regular.ttf"]
    font_manager.fontManager.addfont(str(font_path))
    arabic_font = font_manager.FontProperties(fname=str(font_path)).get_name()
    # Naskh has no Latin glyphs; DejaVu fills in digits, "C", "N" and symbols.
    plt.rcParams["font.family"] = [arabic_font, "DejaVu Sans"]
    plt.rcParams["mathtext.fontset"] = "dejavusans"
    plt.rcParams["axes.unicode_minus"] = False


def lab(en, ar):
    """A figure label in the notebook's language, ready for matplotlib."""
    if env.lang != "ar":
        return en
    return get_display(arabic_reshaper.reshape(ar))


def log_axis(axis, sizes=False):
    """Log ticks as plain text that renders in either font: 3e12, or 50k for sizes."""
    if sizes:
        axis.set_major_locator(LogLocator(base=10, subs=(1, 2, 5)))
        axis.set_major_formatter(FuncFormatter(lambda v, _: f"{v / 1e3:g}k"))
    else:
        axis.set_major_formatter(
            FuncFormatter(lambda v, _: f"{v:.0e}".replace("e+0", "e").replace("e+", "e"))
        )
    axis.set_minor_formatter(NullFormatter())


BLUE, ORANGE = "#3b5bdb", "#e8590c"
budget_colors = plt.cm.viridis(np.linspace(0.1, 0.85, len(budgets)))
print(tr("figure text ready", "نصوص الرسوم جاهزة"))
Workshop code
figure text ready

Each colour is one budget. The stars mark the bottom of each valley. Watch them move right as compute grows.

def find_optimum(C, group, count="total"):
    """Locate the best model size among runs that all spent the same compute C.

    Take the lowest-loss run and its two neighbours, and pass a parabola in
    log N through those three points; its vertex is the optimum. The optimum
    counts as measured only if the best run has a neighbour on both sides, so
    the minimum is bracketed.

    `count` chooses which parameter count goes on the x-axis. The training runs
    are the same either way; only the bookkeeping changes.
    """
    key = "N" if count == "total" else "N_ne"
    group = sorted(group, key=lambda r: r[key])
    best = int(np.argmin([r["loss"] for r in group]))
    lo = max(0, min(best - 1, len(group) - 3))
    window = group[lo : lo + 3]
    x = np.log([r[key] for r in window])
    y = np.array([r["loss"] for r in window])
    a2, a1, a0 = np.polyfit(x, y, 2)
    curved = bool(a2 > 0)
    x_star = -a1 / (2 * a2) if curved else float("nan")
    interior = bool(0 < best < len(group) - 1 and curved and x.min() < x_star < x.max())
    if not interior:
        # No bracketed minimum: report the best run we actually trained.
        x_star = math.log(group[best][key])
    # Tokens at the optimum: D = C / (6 N_total). Under the non-embedding
    # convention, map N_ne back to N_total across the fitted runs first.
    n_total_opt = math.exp(np.interp(x_star, x, np.log([r["N"] for r in window])))
    d_opt = C / (6 * n_total_opt)
    return {
        "C": C,
        "C_count": 6 * math.exp(x_star) * d_opt,
        "N_opt": math.exp(x_star),
        "D_opt": d_opt,
        "L_opt": float(np.polyval((a2, a1, a0), x_star)) if interior else group[best]["loss"],
        "interior": interior,
        "fit": (a2, a1, a0),
        "group": group,
        "window": (x.min(), x.max()),
    }


def isoflop_optima(runs, count="total"):
    return [find_optimum(C, [r for r in runs if r["C"] == C], count) for C in budgets]


def draw_valley(ax, o, color, label):
    xs = np.array([r["N"] for r in o["group"]])
    ax.scatter(xs, [r["loss"] for r in o["group"]], color=color, s=28, zorder=3)
    grid_x = np.linspace(o["window"][0] - 0.1, o["window"][1] + 0.1, 100)
    ax.plot(np.exp(grid_x), np.polyval(o["fit"], grid_x), color=color, lw=1.4, label=label)
    if o["interior"]:
        ax.scatter(
            [o["N_opt"]],
            [o["L_opt"]],
            marker="*",
            s=170,
            color=color,
            edgecolor="black",
            linewidth=0.6,
            zorder=4,
        )


optima = isoflop_optima(runs)
bracketed_share = round(sum(o["interior"] for o in optima) / len(budgets), 2)

fig, ax = plt.subplots(figsize=(7.5, 4.6))
for o, color in zip(optima, budget_colors):
    draw_valley(ax, o, color, f"C = {o['C']:.0e}")
ax.set_xscale("log")
log_axis(ax.xaxis, sizes=True)
ax.set_xlabel(lab("parameters N (log scale)", "عدد المعاملات N (مقياس لوغاريتمي)"))
ax.set_ylabel(lab("validation loss (nats/byte)", "خسارة التحقق (نات لكل بايت)"))
ax.set_title(
    lab(
        "IsoFLOP curves: same compute, different splits",
        "منحنيات الحوسبة الثابتة: الحوسبة نفسها بتقسيمات مختلفة",
    )
)
ax.legend(fontsize=8, frameon=False)
plt.tight_layout()
plt.show()


def print_optimum(o):
    flag = "" if o["interior"] else tr("  ← not bracketed", "  ← غير محصور")
    print(
        f"C = {o['C']:.1e}:  N_opt ≈ {o['N_opt'] / 1e3:,.0f}k   D_opt ≈ {o['D_opt'] / 1e6:.1f}M   "
        f"D/N ≈ {o['D_opt'] / o['N_opt']:,.0f}   L ≈ {o['L_opt']:.4f}{flag}"
    )


for o in optima:
    print_optimum(o)
Workshop code
C = 1.0e+12:  N_opt ≈ 32k   D_opt ≈ 5.3M   D/N ≈ 166   L ≈ 1.3975
C = 2.0e+12:  N_opt ≈ 66k   D_opt ≈ 5.1M   D/N ≈ 77   L ≈ 1.2537
C = 4.0e+12:  N_opt ≈ 97k   D_opt ≈ 6.9M   D/N ≈ 71   L ≈ 1.1168
C = 8.0e+12:  N_opt ≈ 146k   D_opt ≈ 9.2M   D/N ≈ 63   L ≈ 1.0298

Each curve is a valley. Left of the bottom, models are too small: they read plenty of data but had no room to use it. Right of the bottom, models are too large for the budget: they had the capacity, but the compute ran out after too few tokens. At this scale, too few tokens means too few optimizer steps. Look at the steps column in the table: the runs on the right wall got little more than a thousand. The star is the balance between the two.

A star only counts if it sits between real runs. A parabola can always be extended past the last point, but its vertex out there is a guess, not a measurement. That is what the bracketing check guards.

Three fits through the stars: best size, best data, and best loss, each against compute.

def power_fit(xs, ys):
    """Fit y = k · x^p in log space. Returns (p, k, r2)."""
    lx, ly = np.log(xs), np.log(ys)
    p, logk = np.polyfit(lx, ly, 1)
    resid = ly - (p * lx + logk)
    r2 = 1 - (resid**2).sum() / ((ly - ly.mean()) ** 2).sum()
    return float(p), float(math.exp(logk)), float(r2)


def loss_fit(cs, ls, floor=None):
    """Fit L(C) = E + A · C^(-alpha). Pass `floor` to fix E instead of fitting it.

    E is found by scanning: for each candidate floor, the rest is a straight line
    in log space, so the scan is exact and needs no optimizer.
    """
    cs, ls = np.asarray(cs), np.asarray(ls)
    floors = np.linspace(0.0, ls.min() * 0.995, 400) if floor is None else np.array([floor])
    best = None
    for e in floors:
        slope, logA = np.polyfit(np.log(cs), np.log(ls - e), 1)
        sse = ((e + np.exp(logA) * cs**slope - ls) ** 2).sum()
        if best is None or sse < best[0]:
            best = (sse, float(e), float(math.exp(logA)), float(-slope))
    return best[1], best[2], best[3]  # E, A, alpha


def frontier_points(optima):
    """The optima a frontier may be fitted to, and whether there are enough."""
    good = [o for o in optima if o["interior"]]
    return (good, True) if len(good) >= 3 else (optima, False)


good, enough_interior = frontier_points(optima)
if not enough_interior:
    # An optimum at the edge of the sampled sizes is a guess, not a measurement.
    # Fall back so the cell still runs, but the fit check will fail.
    print(
        tr(
            "⚠ fewer than three budgets have a bracketed optimum; widen the ratio range",
            "⚠ أقلّ من ثلاث ميزانيات لها حجم أمثل محصور بين تجربتين؛ وسّع مدى النِّسب",
        )
    )
C_fit = np.array([o["C"] for o in good])
L_fit = np.array([o["L_opt"] for o in good])
exponent_a, k_N, frontier_r2 = power_fit(C_fit, [o["N_opt"] for o in good])
exponent_b, k_D, _ = power_fit(C_fit, [o["D_opt"] for o in good])
fitted_E, fit_A, alpha_c = loss_fit(C_fit, L_fit)

a_measured, b_measured, E_frontier = round(exponent_a, 3), round(exponent_b, 3), round(fitted_E, 3)
frontier_r2 = round(frontier_r2, 3) if enough_interior else 0.0
ratio_first = round(good[0]["D_opt"] / good[0]["N_opt"])
ratio_last = round(good[-1]["D_opt"] / good[-1]["N_opt"])
tokens_first_m = round(good[0]["D_opt"] / 1e6, 1)
tokens_last_m = round(good[-1]["D_opt"] / 1e6, 1)

print(
    tr(
        f"N_opt ∝ C^{exponent_a:.3f}   (R² {frontier_r2:.3f})\n"
        f"D_opt ∝ C^{exponent_b:.3f}\n"
        f"L_opt(C) = {fitted_E:.3f} + {fit_A:.3g} · C^-{alpha_c:.3f}\n"
        f"tokens per parameter at the best split: {ratio_first} at the smallest fitted budget, "
        f"{ratio_last} at the largest",
        f"N_opt ∝ C^{exponent_a:.3f}   (R² {frontier_r2:.3f})\n"
        f"D_opt ∝ C^{exponent_b:.3f}\n"
        f"L_opt(C) = {fitted_E:.3f} + {fit_A:.3g} · C^-{alpha_c:.3f}\n"
        f"عدد الرموز لكل معامل عند أفضل تقسيم: {ratio_first} عند أصغر ميزانية، "
        f"و{ratio_last} عند أكبرها",
    )
)
print(
    tr(
        "For comparison: Kaplan et al. reported a ≈ 0.73. Chinchilla reported a ≈ 0.50 "
        "and roughly 20 tokens per parameter.",
        "للمقارنة: نشر Kaplan وزملاؤه a ≈ 0.73. ونشرت ورقة Chinchilla a ≈ 0.50، "
        "ونحو 20 رمزاً لكل معامل.",
    )
)
Workshop code
N_opt ∝ C^0.715   (R² 0.974)
D_opt ∝ C^0.285
L_opt(C) = 0.637 + 5.58e+03 · C^-0.322
tokens per parameter at the best split: 166 at the smallest fitted budget, 63 at the largest
For comparison: Kaplan et al. reported a ≈ 0.73. Chinchilla reported a ≈ 0.50 and roughly 20 tokens per parameter.

The stars lie close to a straight line in log space: N_opt ∝ C^a, with a = 0.715. Kaplan reported 0.73 and Chinchilla 0.50. Yours sits at the Kaplan end: when the budget doubles, most of the increase goes into parameters.

The second fit shows the other side of the same fact. The best run's token count barely moves: D_opt ∝ C^b, with b = 0.285. It goes from 5.300 million tokens at the smallest budget to 9.200 million at the largest, while the compute doubles several times over. At these budgets a few million tokens is a few thousand optimizer steps. A run seems to need about that many before its size can pay off. Once it has them, extra compute is better spent on parameters.

So tokens per parameter is not a constant here. At the best split it falls from 166 at the smallest budget to 63 at the largest. Chinchilla's 20 was a measurement of their models, their tokenizer and their data, at budgets many orders of magnitude above ours. It does not carry over to this setup, and our numbers would not carry over to theirs. One difference is the unit itself: a byte carries much less information than a tokenizer's token, which usually spans several characters.

The third fit is the loss frontier, L_opt(C) = E + A·C^(−α). It shows : each doubling of compute removes less loss than the one before. It also has a floor, E = 0.637 nats per byte: the level this family of models approaches but would not cross, however large the budget. That floor is the , as these few points see it.

A law that only describes the points it was fitted to is . The reason to fit one is : choosing a model at a budget you have not tried yet. So the next cell commits, in writing, to a model size and a loss for a budget 4× larger than any fitted point. Only then does the following cell train anything at that budget.

Nothing trains here. Note the two predicted numbers before you run the next cell.

# Commit to two numbers BEFORE training: the budget is beyond every fitted point.
C_holdout = budgets[-1] * cfg["holdout_factor"]
N_pred = k_N * C_holdout**exponent_a
L_pred = fitted_E + fit_A * C_holdout ** (-alpha_c)

# The model we would actually build: the family member nearest the predicted size.
holdout_widths = plan_budget(C_holdout)
committed_width = min(
    (w for w in widths if affordable(C_holdout, w)),
    key=lambda w: abs(math.log(sizes[w]["total"] / N_pred)),
)
holdout_widths = sorted({*holdout_widths, committed_width})

predicted_size_k = round(N_pred / 1e3)
predicted_loss = round(L_pred, 4)
committed_params = sizes[committed_width]["total"]
committed_tokens_m = round(C_holdout / (6 * committed_params) / 1e6, 1)

print(
    tr(
        f"held-out budget {C_holdout:.1e} FLOPs ({cfg['holdout_factor']}× the largest fitted budget)\n"
        f"predicted best size  N ≈ {predicted_size_k:,}k → we commit to width {committed_width}: "
        f"{committed_params:,} params on {committed_tokens_m:.1f}M tokens\n"
        f"predicted best loss  {predicted_loss:.4f} nats/byte",
        f"الميزانية المحجوزة {C_holdout:.1e} FLOPs (أي {cfg['holdout_factor']} أضعاف أكبر ميزانية في التوفيق)\n"
        f"أفضل حجم متوقَّع  N ≈ {predicted_size_k:,}k ← نلتزم بالعرض {committed_width}: "
        f"{committed_params:,} معاملاً على {committed_tokens_m:.1f} مليون رمز\n"
        f"أفضل خسارة متوقَّعة  {predicted_loss:.4f} نات لكل بايت",
    )
)
Workshop code
held-out budget 3.2e+13 FLOPs (4× the largest fitted budget)
predicted best size  N ≈ 418k → we commit to width 124: 420,112 params on 12.7M tokens
predicted best loss  0.8865 nats/byte

The left panel is the new budget's own valley. The two straight blue lines are the numbers you committed to. See how close the valley's bottom comes to each.

# Train the committed model AND its neighbours at the held-out budget, so the
# budget gets a valley of its own. That is what lets us say whether a miss came
# from choosing the wrong size or from the loss curve itself.
t0 = time.time()
holdout_runs = []
for w in holdout_widths:
    loss, tokens = train_run(w, C_holdout / (6 * sizes[w]["total"]))
    holdout_runs.append(run_record(C_holdout, w, loss, tokens))
holdout_minutes = round((time.time() - t0) / 60, 1)
peak_vram_gb = 0.0
if device == "cuda":
    peak_vram_gb = round(torch.cuda.max_memory_allocated() / 1e9, 2)

print_runs(holdout_runs)
held = find_optimum(C_holdout, holdout_runs)
print()
print_optimum(held)

committed_loss = round(next(r["loss"] for r in holdout_runs if r["width"] == committed_width), 4)
best_holdout_loss = round(min(r["loss"] for r in holdout_runs), 4)
measured_size_k = round(held["N_opt"] / 1e3)
holdout_bracketed = held["interior"]
holdout_ratio = round(held["D_opt"] / held["N_opt"])

# Split the miss in two. `size_regret` is what choosing our size cost, compared
# with the best size we tried. `prediction_error_pct` is how far the predicted
# frontier sits from the best loss this budget actually reached.
size_regret = round(committed_loss - best_holdout_loss, 4)
signed_error_pct = round(100 * (L_pred - best_holdout_loss) / best_holdout_loss, 2)
prediction_error_pct = abs(signed_error_pct)
size_ratio = round(N_pred / held["N_opt"], 2)

print(
    tr(
        f"\nsize:  predicted {predicted_size_k:,}k   measured {measured_size_k:,}k   "
        f"(predicted / measured = {size_ratio:.2f})\n"
        f"loss:  predicted {predicted_loss:.4f}   best measured {best_holdout_loss:.4f}   "
        f"(error {signed_error_pct:+.2f}%)\n"
        f"cost of our size choice: {size_regret:.4f} nats/byte   ({holdout_minutes:.1f} min)",
        f"\nالحجم:  المتوقَّع {predicted_size_k:,}k   المقيس {measured_size_k:,}k   "
        f"(المتوقَّع / المقيس = {size_ratio:.2f})\n"
        f"الخسارة:  المتوقَّعة {predicted_loss:.4f}   أفضل خسارة مقيسة {best_holdout_loss:.4f}   "
        f"(الخطأ {signed_error_pct:+.2f}%)\n"
        f"كلفة اختيارنا للحجم: {size_regret:.4f} نات لكل بايت   ({holdout_minutes:.1f} دقيقة)",
    )
)
if device == "cuda":
    print(
        tr(
            f"peak GPU memory {peak_vram_gb:.2f} GB",
            f"ذروة ذاكرة المعالج الرسومي {peak_vram_gb:.2f} غيغابايت",
        )
    )

fig, (ax0, ax1, ax2) = plt.subplots(1, 3, figsize=(13, 4))
cs = np.geomspace(C_fit.min() / 1.5, C_holdout * 1.5, 100)

draw_valley(ax0, held, ORANGE, lab("held-out budget", "الميزانية المحجوزة"))
ax0.axvline(N_pred, color=BLUE, lw=1.2, ls="--", label=lab("predicted size", "الحجم المتوقَّع"))
ax0.axhline(L_pred, color=BLUE, lw=1.2, ls=":", label=lab("predicted loss", "الخسارة المتوقَّعة"))
ax0.set_xscale("log")
log_axis(ax0.xaxis, sizes=True)
ax0.set_xlabel(lab("parameters N", "عدد المعاملات N"))
ax0.set_ylabel(lab("loss (nats/byte)", "الخسارة (نات لكل بايت)"))
ax0.legend(fontsize=8, frameon=False)

ax1.scatter(
    C_fit,
    [o["N_opt"] for o in good],
    color=BLUE,
    zorder=3,
    label=lab("fitted optima", "أفضل الأحجام في التوفيق"),
)
ax1.plot(cs, k_N * cs**exponent_a, color=BLUE, lw=1.2, ls="--", label=f"N ∝ C^{exponent_a:.2f}")
ax1.scatter(
    [C_holdout],
    [held["N_opt"]],
    marker="D",
    color=ORANGE,
    zorder=4,
    label=lab("measured", "المقيس"),
)
ax1.set_xscale("log")
ax1.set_yscale("log")
log_axis(ax1.xaxis)
log_axis(ax1.yaxis, sizes=True)
ax1.set_xlabel(lab("compute C (FLOPs)", "الحوسبة C بوحدات FLOPs"))
ax1.set_ylabel(lab("best size N", "أفضل حجم N"))
ax1.legend(fontsize=8, frameon=False)

ax2.scatter(C_fit, L_fit, color=BLUE, zorder=3, label=lab("fitted optima", "نقاط التوفيق"))
ax2.plot(
    cs,
    fitted_E + fit_A * cs ** (-alpha_c),
    color=BLUE,
    lw=1.2,
    ls="--",
    label=lab("extrapolated frontier", "الحدّ المُستقرَأ"),
)
ax2.scatter(
    [C_holdout],
    [best_holdout_loss],
    marker="D",
    color=ORANGE,
    zorder=4,
    label=lab("measured", "المقيس"),
)
ax2.set_xscale("log")
log_axis(ax2.xaxis)
ax2.set_ylim(bottom=0)
ax2.set_xlabel(lab("compute C (FLOPs)", "الحوسبة C بوحدات FLOPs"))
ax2.set_ylabel(lab("best loss (nats/byte)", "أفضل خسارة (نات لكل بايت)"))
ax2.legend(fontsize=8, frameon=False)
fig.suptitle(
    lab(
        "Fitted on small budgets, tested on a larger one",
        "توفيق على ميزانيات صغيرة، واختبار على ميزانية أكبر",
    )
)
plt.tight_layout()
plt.show()
Workshop code
   budget C     params N     tokens D    steps     D/N    loss
  3.2e+13     169,936     31.38M   15,324     185  0.9371
  3.2e+13     260,736     20.46M    9,988      78  0.9093
  3.2e+13     370,736     14.39M    7,024      39  0.9055
  3.2e+13     420,112     12.70M    6,199      30  0.9012
  3.2e+13     586,672      9.09M    4,439      15  0.9173
  3.2e+13     888,352      6.00M    2,931       7  0.9642
  3.2e+13   1,296,512      4.11M    2,009       3  1.0303

C = 3.2e+13:  N_opt ≈ 434k   D_opt ≈ 12.3M   D/N ≈ 28   L ≈ 0.9010

size:  predicted 418k   measured 434k   (predicted / measured = 0.96)
loss:  predicted 0.8865   best measured 0.9012   (error -1.63%)
cost of our size choice: 0.0000 nats/byte   (4.4 min)
peak GPU memory 0.56 GB

Start with the size. The fit predicted a best size near 418k parameters. The held-out valley's own bottom is near 434k, so the prediction was 0.960 times the measured optimum. The model we committed to scored 0.901, and the best model trained at this budget scored 0.901. The gap between them, 0 nats per byte, is what our choice of size cost.

Look at the left panel to see why that cost stays small. The valley is wide and nearly flat near its bottom, so a size that is somewhat off loses very little. This is also why a rule of thumb for the ratio can be useful even when it is not exact.

Now the loss. The fit predicted 0.886 nats per byte, and the best measured loss is 0.901: an error of -1.630%. A negative sign means the prediction was optimistic.

This is the same reasoning that sized Chinchilla before it was trained, only at a much smaller scale. Train small models, find the bottom of each valley, fit the frontier, extrapolate, and then check the extrapolation against a budget you did not fit.

Chinchilla's Approach 3 uses every run at once instead of only the stars. It fits one surface, L(N, D) = E + A/N^α + B/D^β, and reads the allocation exponent off it as a = β / (α + β). If both methods describe the same reality, they should roughly agree. In the paper they did: 0.49 from the IsoFLOP method and 0.46 from the surface. The surface also makes its own prediction for the held-out budget, so we can score it the same way.

Compare the two exponents, then the two predictions for the held-out budget.

# Chinchilla's third approach: fit one surface L(N, D) = E + A/N^alpha + B/D^beta
# to every fitted run at once, then read the allocation exponent off it.
def fit_surface(rows):
    """Scan the two exponents; solve E, A and B exactly for each pair.

    For fixed alpha and beta the surface is linear in E, A and B, so those three
    come from least squares (on relative error, kept non-negative). Scanning the
    exponents on a grid then finds the best pair with no optimizer to mislead us.
    """
    N = np.array([r["N"] for r in rows], dtype=float)
    D = np.array([r["D"] for r in rows], dtype=float)
    L = np.array([r["loss"] for r in rows])
    ones = np.ones_like(L)
    best = None
    for alpha in np.arange(0.05, 3.0001, 0.025):
        for beta in np.arange(0.05, 3.0001, 0.025):
            X = np.stack([ones, N**-alpha, D**-beta], axis=1) / L[:, None]
            # Drop a term whenever the unconstrained solve would make it negative.
            for cols in ([0, 1, 2], [1, 2], [0, 1], [0, 2]):
                coef = np.linalg.lstsq(X[:, cols], ones, rcond=None)[0]
                if (coef >= 0).all():
                    sse = ((X[:, cols] @ coef - 1) ** 2).sum()
                    if best is None or sse < best[0]:
                        full = np.zeros(3)
                        full[cols] = coef
                        best = (sse, float(alpha), float(beta), full)
                    break
    sse, alpha, beta, (E, A, B) = best
    return {"E": float(E), "A": float(A), "B": float(B), "alpha": alpha, "beta": beta}


def surface_loss(s, N, D):
    return s["E"] + s["A"] / N ** s["alpha"] + s["B"] / D ** s["beta"]


surface = fit_surface(runs)
param_exponent_a = round(surface["beta"] / (surface["alpha"] + surface["beta"]), 3)
param_E = round(surface["E"], 3)

# The surface makes its own prediction for the held-out budget: walk along
# D = C / (6 N) and take the lowest point.
n_grid = np.geomspace(sizes[widths[0]]["total"], sizes[widths[-1]]["total"], 400)
along = surface_loss(surface, n_grid, C_holdout / (6 * n_grid))
surface_size_k = round(float(n_grid[along.argmin()]) / 1e3)
surface_pred = round(float(along.min()), 4)
surface_error_pct = round(100 * (surface_pred - best_holdout_loss) / best_holdout_loss, 2)

print(
    tr(
        f"L(N, D) = {surface['E']:.3f} + {surface['A']:.3g}/N^{surface['alpha']:.3f} "
        f"+ {surface['B']:.3g}/D^{surface['beta']:.3f}\n"
        f"allocation exponent: surface a = β/(α+β) = {param_exponent_a:.3f}"
        f"   ·   IsoFLOP a = {exponent_a:.3f}\n"
        f"irreducible loss E:  surface {param_E:.3f}   ·   frontier {fitted_E:.3f}\n"
        f"held-out budget:     surface predicts N ≈ {surface_size_k:,}k, loss {surface_pred:.4f}"
        f" (error {surface_error_pct:+.2f}%)\n"
        f"                     frontier predicted N ≈ {predicted_size_k:,}k, loss {predicted_loss:.4f}"
        f" (error {signed_error_pct:+.2f}%)\n"
        f"                     measured N ≈ {measured_size_k:,}k, loss {best_holdout_loss:.4f}",
        f"L(N, D) = {surface['E']:.3f} + {surface['A']:.3g}/N^{surface['alpha']:.3f} "
        f"+ {surface['B']:.3g}/D^{surface['beta']:.3f}\n"
        f"أُسّ التوزيع: من السطح a = β/(α+β) = {param_exponent_a:.3f}"
        f"   ·   بطريقة الحوسبة الثابتة a = {exponent_a:.3f}\n"
        f"الخسارة غير القابلة للاختزال E:  من السطح {param_E:.3f}   ·   من الحدّ {fitted_E:.3f}\n"
        f"الميزانية المحجوزة:  يتوقّع السطح N ≈ {surface_size_k:,}k، وخسارة {surface_pred:.4f}"
        f" (الخطأ {surface_error_pct:+.2f}%)\n"
        f"                     وتوقّع الحدّ N ≈ {predicted_size_k:,}k، وخسارة {predicted_loss:.4f}"
        f" (الخطأ {signed_error_pct:+.2f}%)\n"
        f"                     والمقيس N ≈ {measured_size_k:,}k، وخسارة {best_holdout_loss:.4f}",
    )
)
Workshop code
L(N, D) = 0.689 + 78.1/N^0.475 + 1.88e+10/D^1.650
allocation exponent: surface a = β/(α+β) = 0.776   ·   IsoFLOP a = 0.715
irreducible loss E:  surface 0.689   ·   frontier 0.637
held-out budget:     surface predicts N ≈ 486k, loss 0.8894 (error -1.31%)
                     frontier predicted N ≈ 418k, loss 0.8865 (error -1.63%)
                     measured N ≈ 434k, loss 0.9012

The surface gives a = 0.776 and a floor E = 0.689. The IsoFLOP method gave a = 0.715 and E = 0.637. For the held-out budget, the surface predicted a best size of 486k parameters and a loss of 0.889, an error of -1.310%.

The two methods read the same runs, so a gap between them cannot be noise in the training. It comes from what each one assumes. The surface assumes one formula holds for every run, including the runs on the steep right wall of each valley. The IsoFLOP method looks only at the three runs around each bottom, and assumes nothing about the rest.

Here the two agree on the main points. Both give an exponent well above 0.5. Both put the floor at a similar level. And both predict a loss for the held-out budget that is close to the measured one. When two methods with different assumptions reach the same answer, that answer deserves more trust than either fit alone.

Exercise

No model predicts text perfectly, so the real frontier has a floor: the irreducible loss. The fit had to estimate that floor from a handful of points. This cell fixes the floor by hand and refits the rest. It starts at zero, which is a pure power law that keeps falling forever. Before you run it, predict: will it place the held-out loss higher or lower than the fit with a floor did? Then raise E_FLOOR in steps. Watch two numbers: how well the curve still fits the points it was fitted to, and how close it lands at the held-out budget.

# YOUR TURN — the floor you cannot see.
#
# No model predicts text perfectly, so the true loss curve has a floor E > 0.
# The fit above had to guess E from a handful of points. This cell fixes the
# floor by hand and refits the rest. It starts at zero: a pure power law, which
# keeps falling forever. Before you run it, guess: will that place the held-out
# loss higher or lower than the fit with a floor did? Then raise E_FLOOR in
# steps, and watch two numbers: how well the curve still fits the points it was
# fitted to, and how close it lands at the held-out budget.
E_FLOOR = 0.0

_, A_try, alpha_try = loss_fit(C_fit, L_fit, floor=E_FLOOR)
floor_pred = round(E_FLOOR + A_try * C_holdout ** (-alpha_try), 4)
floor_error_pct = round(100 * (floor_pred - best_holdout_loss) / best_holdout_loss, 2)


def worst_fit_error(E, A, alpha):
    """Largest relative error of a frontier curve on the points it was fitted to."""
    return round(100 * float(np.abs((E + A * C_fit ** (-alpha)) / L_fit - 1).max()), 2)


fitted_in_range_pct = worst_fit_error(fitted_E, fit_A, alpha_c)
in_range_error_pct = worst_fit_error(E_FLOOR, A_try, alpha_try)
print(
    tr(
        f"fitted floor E = {fitted_E:.3f}:  on the fitted points, off by at most {fitted_in_range_pct:.2f}%\n"
        f"    held-out prediction {predicted_loss:.4f}  → error {signed_error_pct:+.2f}%\n"
        f"floor fixed at {E_FLOOR}:  on the fitted points, off by at most {in_range_error_pct:.2f}%\n"
        f"    held-out prediction {floor_pred:.4f}  → error {floor_error_pct:+.2f}%",
        f"الأرضية المُوفَّقة E = {fitted_E:.3f}:  أكبر خطأ على نقاط التوفيق {fitted_in_range_pct:.2f}%\n"
        f"    التوقّع للميزانية المحجوزة {predicted_loss:.4f}  ← الخطأ {signed_error_pct:+.2f}%\n"
        f"الأرضية مثبّتة عند {E_FLOOR}:  أكبر خطأ على نقاط التوفيق {in_range_error_pct:.2f}%\n"
        f"    التوقّع للميزانية المحجوزة {floor_pred:.4f}  ← الخطأ {floor_error_pct:+.2f}%",
    )
)
Workshop code

A hint is available in the notebook — env.hint(1)

Exercise

This cell refits the frontier using the non-embedding parameter count. Nothing is retrained; only the number written next to each model changes. Before you run it, guess whether the exponent a goes up or down. Then compare it with Kaplan's reported 0.73 and Chinchilla's 0.50.

# YOUR TURN — the bookkeeping.
#
# Kaplan et al. counted parameters WITHOUT the embedding tables; Chinchilla
# counted all of them. This cell refits the frontier with the Kaplan count,
# from the very same training runs. Nothing is retrained. Only the number you
# write next to each model changes. Set COUNT = "total" to get the original back.
COUNT = "non_embedding"

recount, _ = frontier_points(isoflop_optima(runs, count=COUNT))
count_exponent_a, _, count_r2 = power_fit(
    [o["C_count"] for o in recount], [o["N_opt"] for o in recount]
)
count_exponent_a = round(count_exponent_a, 3)
count_ratio_last = round(recount[-1]["D_opt"] / recount[-1]["N_opt"])
print(
    tr(
        f"count={COUNT}:  N_opt ∝ C^{count_exponent_a:.3f}  (R² {count_r2:.3f})   "
        f"tokens/param at the largest budget {count_ratio_last}\n"
        f"count=total:  N_opt ∝ C^{exponent_a:.3f}   tokens/param {ratio_last}",
        f"count={COUNT}:  N_opt ∝ C^{count_exponent_a:.3f}  (R² {count_r2:.3f})   "
        f"رموز لكل معامل عند أكبر ميزانية {count_ratio_last}\n"
        f"count=total:  N_opt ∝ C^{exponent_a:.3f}   رموز لكل معامل {ratio_last}",
    )
)
Workshop code

A hint is available in the notebook — env.hint(2)

Both exercises refit the same runs, and both change the answer.

Without a floor, the prediction for the held-out budget is off by -7.860%, against -1.630% with the fitted floor. Yet on the points it was fitted to, the pure power law is never off by more than 1.320%. Inside the fitted range the two curves are hard to tell apart. At a budget 4× further out, they are not. A fit can only be judged by a test outside its range.

Counting parameters like Kaplan moves the exponent from 0.715 to 0.748, with no retraining. The direction makes sense. Embedding tables are a larger share of a small model than of a large one, so removing them shrinks the small models most, and size then appears to grow faster with compute. A later analysis attributes much of the gap between the two papers to this: the earlier law counted non-embedding parameters, at model sizes where embeddings are a large share.

Exercise

PyTorch's default embedding initialisation is much larger than ours. Because the token table is also the output layer, the default starts every run with confident, wrong predictions, and the first steps go to undoing them. This cell re-measures the smallest budget's valley with that default, using the same models and the same seeds. Before you run it, predict which way the best size moves. The cell trains a few more models, so it takes a minute or two.

# YOUR TURN — the handicap.
#
# PyTorch's default embedding init has std 1. Because our token table is also
# the output layer, that starts every run with confident, wrong predictions,
# and the first few hundred steps go to undoing them. A long run can absorb
# that cost; a short run cannot. This cell re-measures the smallest budget's
# valley with INIT_STD = 1.0, using the same sizes and the same seeds as the
# grid. Before you run it, guess which way the best size moves. Set INIT_STD
# back to cfg["init_std"] to recover the grid's own valley.
INIT_STD = 1.0

C_small = budgets[0]
handicap_runs = []
for w in plan_budget(C_small):
    loss, tokens = train_run(w, C_small / (6 * sizes[w]["total"]), init_std=INIT_STD)
    handicap_runs.append(run_record(C_small, w, loss, tokens))
handicapped = find_optimum(C_small, handicap_runs)
fair = optima[0]

print_runs(handicap_runs)
fair_size_k, handicap_size_k = round(fair["N_opt"] / 1e3), round(handicapped["N_opt"] / 1e3)
fair_ratio, handicap_ratio = (
    round(fair["D_opt"] / fair["N_opt"]),
    round(handicapped["D_opt"] / handicapped["N_opt"]),
)
handicap_cost = round(handicapped["L_opt"] - fair["L_opt"], 4)
print(
    tr(
        f"\nbudget {C_small:.1e}, init std {cfg['init_std']}:  best size {fair_size_k}k   "
        f"tokens/param {fair_ratio}   loss {fair['L_opt']:.4f}\n"
        f"budget {C_small:.1e}, init std {INIT_STD}:  best size {handicap_size_k}k   "
        f"tokens/param {handicap_ratio}   loss {handicapped['L_opt']:.4f}",
        f"\nالميزانية {C_small:.1e}، انحراف التهيئة {cfg['init_std']}:  أفضل حجم {fair_size_k}k   "
        f"رموز لكل معامل {fair_ratio}   الخسارة {fair['L_opt']:.4f}\n"
        f"الميزانية {C_small:.1e}، انحراف التهيئة {INIT_STD}:  أفضل حجم {handicap_size_k}k   "
        f"رموز لكل معامل {handicap_ratio}   الخسارة {handicapped['L_opt']:.4f}",
    )
)

fig, ax = plt.subplots(figsize=(7.5, 4.2))
draw_valley(ax, fair, BLUE, lab(f"init std {cfg['init_std']}", f"انحراف التهيئة {cfg['init_std']}"))
draw_valley(ax, handicapped, ORANGE, lab(f"init std {INIT_STD}", f"انحراف التهيئة {INIT_STD}"))
ax.set_xscale("log")
log_axis(ax.xaxis, sizes=True)
ax.set_xlabel(lab("parameters N (log scale)", "عدد المعاملات N (مقياس لوغاريتمي)"))
ax.set_ylabel(lab("validation loss (nats/byte)", "خسارة التحقق (نات لكل بايت)"))
ax.set_title(
    lab(
        "Same budget, same sizes, different starting point",
        "الميزانية نفسها والأحجام نفسها، ونقطة بداية مختلفة",
    )
)
ax.legend(fontsize=8, frameon=False)
plt.tight_layout()
plt.show()
Workshop code

A hint is available in the notebook — env.hint(3)

With the default initialisation, the best size at the smallest budget moved from 32k to 25k parameters, and the best loss rose by 0.114 nats per byte. Nothing about the models or the data changed.

The handicap is the same for every run: some steps wasted at the start. But a short run loses a larger share of its training to it than a long one does. At a fixed budget, the short runs are the large models. So the large models suffer most, and the bottom of the valley slides toward smaller ones.

This is the kind of detail that separates the two papers. The Chinchilla authors argue that the earlier setup made training on fewer tokens look worse than it was, and that this bias shaped the earlier law. A scaling law is a property of a whole measurement setup: the models, the data, the tokenizer, the optimizer, the initialisation and the bookkeeping. Change one of them and you should measure again.

The first two checks make sure the grid measured something. The last two check what you did with it.

learned_ok = env.check("runs-learned", worst_margin)
bracketed_ok = env.check("optima-bracketed", bracketed_share)
frontier_ok = env.check("frontier-fit", frontier_r2)
prediction_ok = env.check("prediction-error", prediction_error_pct)
Workshop code
✓ Smallest gain over the unigram baseline across all grid runs, in nats per byte: 1.387 (needs ≥ 0.8)
✓ Share of fitted budgets whose best model size has a smaller and a larger run on either side: 1 (needs ≥ 0.75)
✓ How well a power law describes best size against compute (coefficient of determination, in log space): 0.974 (needs ≥ 0.9)
✓ Gap between the predicted loss and the best loss measured at the held-out budget, as a percentage: 1.63 (needs ≤ 10)
receipt = env.receipt()
Workshop code
Workshop complete.

Completion code: AZ-██████████
Paste it on the workshop's page on Azimuth to record it.

What should stay with you is the procedure rather than any one exponent. Fix the compute, vary the split, find the bottom of each valley, fit a line through the bottoms, and test the line on a budget you did not fit. The same procedure was used to plan models with billions of parameters.

On this setup it gave N ∝ C^0.715, and 28 bytes per parameter at the held-out budget. Chinchilla found 0.50, and 20 tokens per parameter. Neither pair of numbers is wrong. Each is what a measured law looks like for one setup, and the measurement is something you can now repeat yourself.

Last verified: 2026-10-02 · Tesla T4 · PyTorch 2.11.0+cu130 · Python 3.13.15 · 093279c

Terms in this workshop