Warmup Guide — Scaling Laws I: Kaplan → Chinchilla → IsoFLOPs
How to read this. No statistics background is assumed. Power laws, log-log plots, curve fitting, robust losses, bootstrap confidence intervals and experimental design are all built from nothing. If you already know regression, start at Chapter 4 and do not skip Chapter 5 or Chapter 9.
Table of Contents
- Chapter 1: What a Scaling Law Is
- Chapter 2: Power Laws From Nothing
- Chapter 3: What "Loss" Actually Measures
- Chapter 4: The Kaplan Result, and What It Did to the Industry
- Chapter 5: The Chinchilla Correction — a Bug in Experimental Design
- Chapter 6: The IsoFLOPs Method, Step by Step
- Chapter 7: The Parametric Form
L(N, D) - Chapter 8: Fitting It — Least Squares, Logs, and Huber
- Chapter 9: The Estimator Problem — Feinberg's Open Question
- Chapter 10: Confidence Intervals via Bootstrap
- Chapter 11: Designing the Ladder — Where to Spend Your Ablations
- Chapter 12: Making the Decision — Baseline vs Candidate
- Chapter 13: When Scaling Laws Lie
- Lab Walkthrough
- Success Criteria
- Interview Q&A
- Tips & Takeaways
- References
Chapter 1: What a Scaling Law Is
What it is
A scaling law is an empirical formula that predicts model quality from the resources you spend. In its most useful form:
$$ L(N, D) = E + \frac{A}{N^{\alpha}} + \frac{B}{D^{\beta}} $$
L— the test loss (Chapter 3 explains what that number means)N— parametersD— training tokensE,A,B,α,β— five constants you fit from a ladder of small experiments
Why it exists
Because of the one-shot problem. Feinberg's slides state the contrast exactly:
ML training before: "Maybe 2 stages; toy problem for iteration (CIFAR10) then you apply to Imagenet. LR searches by doing multiple 'final runs'. The last data point is our test set!"
Now: "every next run requires extrapolation."
You cannot try five flagship configurations and pick the winner. Each costs eight figures and months. So you build a predictor from cheap experiments and bet on its extrapolation.
How it works underneath — why this functional form?
The three terms are not arbitrary. Each corresponds to a distinct source of error:
| Term | Name | Meaning | What drives it to zero |
|---|---|---|---|
E | irreducible loss | The entropy of language itself. Even a perfect model cannot predict the next token with certainty. | Nothing. It is a floor. |
A/N^α | capacity term | Your model is too small to represent the true function. | More parameters. |
B/D^β | data term | You have not seen enough examples to find the right function. | More tokens. |
This decomposition is the classic approximation / estimation split from learning theory,
written for transformers. And it makes a strong, checkable prediction: at fixed C, there is a
single interior optimum, because pushing N up shrinks the capacity term but (via C = 6ND)
shrinks D and grows the data term. Chapter 6 is that trade-off made visual.
Production significance
Everything. The flagship (N, D), the go/no-go, the choice between two architectures, the
decision to buy more chips — all of it runs through a fitted law. Which is why Feinberg's line
lands so hard: "Loss forecast implies model/recipe selection capability!"
The misconception
"Scaling laws are laws of nature, like
F = ma."
They are not. They are fitted regressions over a specific recipe on a specific dataset. His slide says it twice: "These 'laws' are only empirical" and "The fitting of these laws depends a lot on the experimental setup as well as the implicit assumptions being made there." Change your data mixture, your optimizer, or how your architecture scales, and the constants move — and sometimes the exponents do too.
Chapter 2: Power Laws From Nothing
What a power law is
A relationship of the form y = c · x^k. That is it. The magic is what it looks like in logs:
$$ \log y = \log c + k \log x $$
A power law is a straight line on a log-log plot. The slope of that line is the exponent. That single fact is why every scaling-law paper's figures are log-log, and why you can eyeball an exponent from a plot.
import math
def power_law(x, c, k):
return c * (x ** k)
# Confirm the straight-line property.
for x in (1e18, 1e19, 1e20, 1e21):
y = power_law(x, c=0.6, k=0.5)
print(f"log10(x)={math.log10(x):5.1f} log10(y)={math.log10(y):7.3f}")
log10(x)= 18.0 log10(y)= 8.778
log10(x)= 19.0 log10(y)= 9.278
log10(x)= 20.0 log10(y)= 9.778
log10(x)= 21.0 log10(y)= 10.278
Every step of 1 in log x moves log y by exactly 0.5 — the exponent. Perfectly straight.
Fitting one: linear regression in log space
If your data follows a power law, take logs and fit a line. Ordinary least squares, in eight lines, no libraries:
def fit_line(xs, ys):
"""Least-squares slope and intercept. Returns (slope, intercept)."""
n = len(xs)
if n < 2:
raise ValueError("need at least 2 points")
mx = sum(xs) / n
my = sum(ys) / n
sxx = sum((x - mx) ** 2 for x in xs)
if sxx == 0:
raise ValueError("all x values identical — slope is undefined")
sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
slope = sxy / sxx
return slope, my - slope * mx
def fit_power_law(xs, ys):
"""Fit y = c * x^k by regressing log y on log x. Returns (c, k)."""
if any(x <= 0 or y <= 0 for x, y in zip(xs, ys)):
raise ValueError("power-law fit needs strictly positive data")
lx = [math.log(x) for x in xs]
ly = [math.log(y) for y in ys]
k, log_c = fit_line(lx, ly)
return math.exp(log_c), k
xs = [1e18, 1e19, 1e20, 1e21, 1e22]
ys = [power_law(x, 0.6, 0.5) for x in xs]
c, k = fit_power_law(xs, ys)
print(f"recovered c={c:.4f} k={k:.4f}") # recovered c=0.6000 k=0.5000
Why power laws are everywhere in deep learning
Nobody has a fully satisfying answer, and it is an active research question. The best current intuitions:
- Data manifold dimension. If natural language lies near a
d-dimensional manifold, covering it to resolutionεneeds~ε^{-d}samples — which produces a power law with exponent related to1/d. - Heavy-tailed feature frequency. Word and concept frequencies follow Zipf's law (itself a
power law). Learning the
n-th most common concept requires seeing it, and the marginal return of more data falls off as a power. - Random-feature / kernel arguments give power-law generalization curves under mild assumptions.
The honest position: the power-law form is an excellent empirical description over the ranges we have measured, with plausible but unproven theoretical motivation. Treat it as a very good interpolant, not a physical law. Chapter 13 is about what happens when it breaks.
The misconception
"A straight line on a log-log plot proves a power law."
Over a small range, almost anything looks straight in logs. You need several decades of x
to distinguish a power law from a log, a stretched exponential, or a saturating curve. This
matters enormously, because those alternatives disagree violently when extrapolated — which is
exactly what you are about to do.
Chapter 3: What "Loss" Actually Measures
What it is
Language models are trained with cross-entropy loss: the negative log-probability the model assigned to the token that actually came next, averaged over tokens.
$$ L = -\frac{1}{T}\sum_{t=1}^{T} \log p_\theta(x_t \mid x_{<t}) $$
def cross_entropy(probs_assigned_to_true_token):
"""Mean negative log-probability. Units: nats (natural log)."""
if any(p <= 0 for p in probs_assigned_to_true_token):
raise ValueError("zero probability on a true token gives infinite loss")
return -sum(math.log(p) for p in probs_assigned_to_true_token) / len(
probs_assigned_to_true_token)
print(round(cross_entropy([0.9, 0.8, 0.95, 0.7]), 4)) # 0.2113 (confident, correct)
print(round(cross_entropy([0.1, 0.2, 0.05, 0.3]), 4)) # 2.0369 (bad)
Why this and not accuracy
Three reasons, all of which matter for scaling work:
- It is smooth. Accuracy is a step function — the gradient is zero almost everywhere. Cross-entropy has a gradient everywhere.
- It is graded. A model that puts 0.49 on the right token is genuinely better than one that puts 0.01, even though both are "wrong." Accuracy cannot see that.
- It scales predictably. This is the empirical gift: cross-entropy follows clean power laws. Downstream benchmark accuracy does not — it plateaus, jumps, and saturates.
The units, and how to read them
Loss in nats (natural log). Two conversions worth knowing:
def nats_to_bits(nats): return nats / math.log(2)
def loss_to_perplexity(nats): return math.exp(nats)
for L in (2.0, 1.8, 1.7, 1.6):
print(f"loss {L:.2f} nats = {nats_to_bits(L):.3f} bits/token, "
f"perplexity {loss_to_perplexity(L):.2f}")
loss 2.00 nats = 2.885 bits/token, perplexity 7.39
loss 1.80 nats = 2.597 bits/token, perplexity 6.05
loss 1.70 nats = 2.453 bits/token, perplexity 5.47
loss 1.60 nats = 2.308 bits/token, perplexity 4.95
Perplexity is e^L — read it as "the model is as confused as if it were choosing uniformly
among this many options."
The scale intuition you must build
Loss differences look tiny and are not. 0.01 nats is a meaningful, fought-over improvement at frontier scale. Why:
def flops_for_loss_delta(delta_nats, alpha=0.5):
"""Under L ~ C^-alpha near loss ~1.7, roughly how much more compute buys delta?"""
# dL/dC = -alpha * (L - E) / C => fractional compute increase ~ delta / (alpha*(L-E))
L, E = 1.75, 1.69
return delta_nats / (alpha * (L - E))
for d in (0.001, 0.01, 0.05):
print(f"{d:5.3f} nats needs ~{flops_for_loss_delta(d):5.1%} more compute")
0.001 nats needs ~ 3.3% more compute
0.010 nats needs ~33.3% more compute
0.050 nats needs ~166.7% more compute
0.01 nats is roughly a third more compute. On a $30M run that is $10M. That is why teams argue about the third decimal place, and why your fit's confidence interval had better be narrower than the effect you are claiming.
The misconception
"Lower loss always means a better product."
Loss is measured on your held-out distribution. A model can improve loss by getting better at boilerplate that dominates the corpus while getting worse at the reasoning your users care about. This is why every serious team pairs the scaling law with downstream evals — and why Feinberg's slide on refinements mentions "Joint loss, eval fit." The law forecasts loss; loss is a proxy; the proxy needs auditing.
Chapter 4: The Kaplan Result, and What It Did to the Industry
The finding
Kaplan et al. (2020) established that transformer loss follows clean power laws in N, D, and
C, and — the consequential part — derived the compute-optimal allocation. Feinberg's slide
quotes their conclusion directly: data requirements grow "very slowly as D ∼ C^0.27 with
training compute," and states their headline result:
"With a 10x compute budget, parameters should increase by 5.37x and the amount of data by 1.86x."
Decoding those numbers
def kaplan_allocation(compute_multiplier):
"""Kaplan: N grows as C^0.73, D as C^0.27."""
return compute_multiplier ** 0.73, compute_multiplier ** 0.27
n_mult, d_mult = kaplan_allocation(10)
print(f"10x compute -> {n_mult:.2f}x params, {d_mult:.2f}x data")
# 10x compute -> 5.37x params, 1.86x data
print(f"consistency check (must be ~10): {n_mult * d_mult:.2f}")
# consistency check (must be ~10): 10.00
Note that consistency check — N_mult × D_mult must equal the compute multiplier, because
C = 6ND. Equivalently, the exponents must sum to 1: 0.73 + 0.27 = 1.00. Always run this
check on any pair of fitted exponents. If they do not sum to ~1, something is wrong with your
fit or your FLOP accounting.
The industry consequence
His slide states it flatly:
"Consequences for the industry: We should heavily invest in scaling the model size rather than the data size!"
And that is exactly what happened. GPT-3: 175B parameters on ~300B tokens — a ratio of 1.7 tokens per parameter. Compare Chinchilla's later recommendation of ~20, or Llama 3's ~190.
for name, N, D in [("GPT-3 (2020)", 175e9, 300e9),
("Gopher (2021)", 280e9, 300e9),
("Chinchilla (2022)", 70e9, 1.4e12),
("Llama-3-70B (2024)", 70e9, 15e12),
("Llama-3-8B (2024)", 8e9, 15e12)]:
print(f"{name:20s} {D/N:7.1f} tokens per parameter")
GPT-3 (2020) 1.7 tokens per parameter
Gopher (2021) 1.1 tokens per parameter
Chinchilla (2022) 20.0 tokens per parameter
Llama-3-70B (2024) 214.3 tokens per parameter
Llama-3-8B (2024) 1875.0 tokens per parameter
That table is the history of the field in one column. A 1000× swing in a single design parameter, driven first by a methodological error and then by serving economics (Phase 02).
The caveats Kaplan themselves flagged
Feinberg's slide lists them, and the point of listing them is that everyone ignored them:
- "These 'laws' are only empirical"
- "The fitting of these laws depends a lot on the experimental setup as well as the implicit assumptions being made there"
Tip. When a paper flags its own assumptions, that is where the next paper comes from. Chinchilla is literally the second bullet, taken seriously.
Chapter 5: The Chinchilla Correction — a Bug in Experimental Design
This is the most instructive story in the field. Feinberg's slide names the bug precisely:
"Kaplan et al. run a single training run per model size and uses intermediate losses to estimate the loss at different token horizon. ... This is a bad approximation as you can get much better losses through proper learning rate decay. Only the final loss value is optimal."
What a learning-rate schedule is (from zero)
The learning rate controls how big a step the optimizer takes. Too big and you bounce around the minimum forever; too small and you crawl. The standard solution is a schedule: warm up, hold high, then decay toward zero.
LR
│ ╭──────────╮
│ ╱ ╲
│ ╱ ╲___
│ ╱ ╲___
│ ╱ ╲____
│ ╱ ╲___
└─────────────────────────────────────────► training steps
warmup high (explore) decay (settle)
The decay phase is where a large chunk of the final loss improvement happens — the model stops oscillating and settles into the basin. A model mid-run has not had its decay yet.
The bug, made precise
Kaplan wanted L(N, D) for many D. Running a separate full experiment for each D is
expensive. So: train once to a large D, and read the loss curve at intermediate points.
That is a biased estimator. At token count D' mid-run, the LR is still high, so the loss
is worse than it would be for a run scheduled to end at D'.
def loss_curve(tokens_seen, horizon, base_a=3.0, base_b=0.1, decay_penalty=0.15):
"""Toy loss: an intrinsic power-law improvement plus a penalty for undecayed LR.
The penalty vanishes only at the end of the SCHEDULE, not at a fixed token count.
"""
if tokens_seen > horizon:
raise ValueError("cannot read a curve past its horizon")
intrinsic = base_a / (tokens_seen ** base_b)
frac_remaining = 1.0 - tokens_seen / horizon
return intrinsic + decay_penalty * frac_remaining
# The comparison that broke the field.
D_target = 100e9
kaplan_style = loss_curve(D_target, horizon=1000e9) # peek at a long run mid-flight
chinchilla_style = loss_curve(D_target, horizon=D_target) # a run scheduled to end here
print(f"Kaplan-style estimate at D=100B : {kaplan_style:.4f}")
print(f"Chinchilla-style (true) : {chinchilla_style:.4f}")
print(f"bias : {kaplan_style - chinchilla_style:+.4f} nats")
Kaplan-style estimate at D=100B : 0.3733
Chinchilla-style (true) : 0.2383
bias : +0.1350 nats
Why the bias is fatal and not merely noisy
Here is the structural point that makes this a real bug rather than extra noise. The bias is
penalty = decay_penalty × (1 − D_measured / D_schedule)
so it is not the same for every point:
- Points read early in their schedule get a large penalty.
- Points read at the end of their schedule get none.
def bias_across_horizons(decay_penalty):
"""Measure the SAME token count from runs with different schedule lengths."""
D = 100e9
for horizon_mult in (1, 2, 5, 10):
biased = loss_curve(D, D * horizon_mult, decay_penalty=decay_penalty)
truth = loss_curve(D, D, decay_penalty=decay_penalty)
print(f" schedule={horizon_mult:2d}x D -> measured {biased:.4f} "
f"(bias {biased - truth:+.4f})")
print("With the schedule-mismatch bias present:")
bias_across_horizons(0.15)
print("With it removed (each run scheduled to its own horizon):")
bias_across_horizons(0.0)
With the schedule-mismatch bias present:
schedule= 1x D -> measured 0.2383 (bias +0.0000)
schedule= 2x D -> measured 0.3133 (bias +0.0750)
schedule= 5x D -> measured 0.3583 (bias +0.1200)
schedule=10x D -> measured 0.3733 (bias +0.1350)
With it removed (each run scheduled to its own horizon):
schedule= 1x D -> measured 0.2383 (bias +0.0000)
schedule= 2x D -> measured 0.2383 (bias +0.0000)
schedule= 5x D -> measured 0.2383 (bias +0.0000)
schedule=10x D -> measured 0.2383 (bias +0.0000)
Non-uniformity is the whole mechanism, and it is worth being precise about why. A
uniform offset — "all our losses were 0.05 too high" — would simply be absorbed into the
fitted E and would change no exponent and no recommendation. The lab has a dedicated test
for exactly that control (test_uniform_offset_is_absorbed_into_E_and_changes_nothing). It is
because the penalty varies systematically across the (N, D) grid that it tilts the fitted
exponents, and the exponents are what determine the flagship recommendation.
On the direction of the tilt — a note on honesty. Which way the exponent moves depends on which ladder points the mismatch hits hardest, and that is a property of the specific experimental design. Reconstructing Kaplan's design precisely enough to derive the sign from first principles is beyond what the published record settles cleanly; what is documented is the empirical outcome — Chinchilla re-ran with properly matched schedules and found the optimum sits at substantially more tokens per parameter. The lab lets you inject the bias under two different designs (
bias_mode="fixed_schedule"and"per_budget") and measure the tilt each one produces. That exercise — "I do not know the sign, so I will simulate the design and find out" — is the actual skill this chapter is teaching.
The result
Chinchilla's exponent came out at ~0.5 rather than ~0.73. Feinberg's slide:
"Chinchilla findings: the exponent in the power law is ~0.5, meaning model and data size should be scaled at the same rate! This is widely different from Kaplan et al."
And he labels the old regime on the plot with a single word: UNDERTRAINED!
"Consequences: Given a compute budget, models should be smaller and trained for longer. Kaplan's scaling laws meant that models were undertrained — which is obviously bad given bigger models are more expensive to serve and use downstream!"
That last clause is the bridge to the rest of this track. Chinchilla did not just lower the loss — it made the same quality available in a smaller model, which is cheaper to serve. That is the objective Feinberg's team optimizes, and Phase 02 pushes it further.
The empirical demonstration: Chinchilla (70B on 1.4T) beat Gopher (280B on 300B) at the same training compute — with a model 4× cheaper to serve.
Takeaway. The single most consequential result in scaling research was an experimental-design fix. Not an architecture, not an optimizer. Methodology is the frontier, and Feinberg's closing slide says the field still has open problems of exactly this kind (Chapter 9).
Chapter 6: The IsoFLOPs Method, Step by Step
Feinberg's slides walk through this as a build-up of six steps. Here is each one with the code.
The six steps
1. Fix a target FLOPs budget C = 1e20
2. Train a few models, vary model size N = 100M, 300M, 1B, 3B (D = C/6N)
3. Fit a parabola and find the minimum loss vs log N is U-shaped
4. Repeat 1-3 for various FLOPs budgets C = 1e19, 1e20, 1e21, 1e22
5. Fit a power law: FLOPs -> optimal N N_opt ∝ C^a
6. Fit a power law: FLOPs -> optimal D D_opt ∝ C^b
"IsoFLOPs" means "equal FLOPs" — every model on one curve costs the same to train. You are asking: given this exact budget, what shape spends it best?
Step 1–2: the sweep
def true_loss(N, D, E=1.69, A=406.4, alpha=0.34, B=410.7, beta=0.28):
"""The Chinchilla parametric form, used here as a stand-in for reality."""
return E + A / (N ** alpha) + B / (D ** beta)
def isoflop_sweep(C, param_counts):
"""Fix C, vary N, derive D from the budget. Returns [(N, D, loss), ...]."""
out = []
for N in param_counts:
D = C / (6 * N) # the budget constraint
out.append((N, D, true_loss(N, D)))
return out
for N, D, L in isoflop_sweep(1e21, [1e8, 3e8, 1e9, 3e9, 1e10, 3e10]):
print(f"N={N:8.1e} D={D:8.1e} D/N={D/N:8.1f} loss={L:.4f}")
N= 1.0e+08 D= 1.7e+12 D/N= 16666.7 loss=2.6198
N= 3.0e+08 D= 5.6e+11 D/N= 1851.9 loss=2.4344
N= 1.0e+09 D= 1.7e+11 D/N= 166.7 loss=2.3400
N= 3.0e+09 D= 5.6e+10 D/N= 18.5 loss=2.3363
N= 1.0e+10 D= 1.7e+10 D/N= 1.7 loss=2.4160
N= 3.0e+10 D= 5.6e+09 D/N= 0.2 loss=2.5687
Why it is a U
- Small
N, hugeD: oceans of data, no capacity to absorb it. TheA/N^αterm dominates. - Huge
N, tinyD: enormous capacity, starved of examples. TheB/D^βterm dominates. - In between: balanced. A single interior minimum.
loss
2.62 │●
2.57 │ ●
2.43 │ ●
2.42 │ ●
2.34 │ ● ●
└──────────────────────────────────────────► log10(N)
8.0 8.5 9.0 9.5 10.0 10.5
underfit OPT data-starved
Step 3: fit the parabola
You have noisy points. You want the vertex. Fit a quadratic in log N and solve for its
minimum analytically.
def fit_quadratic(xs, ys):
"""Least-squares fit of y = a*x^2 + b*x + c. Solves the 3x3 normal equations
with Cramer's rule — no libraries, fully deterministic."""
if len(xs) < 3:
raise ValueError("need at least 3 points for a parabola")
n = len(xs)
s = [sum(x ** p for x in xs) for p in range(5)] # s[0..4]
t = [sum(y * x ** p for x, y in zip(xs, ys)) for p in range(3)]
M = [[s[4], s[3], s[2]],
[s[3], s[2], s[1]],
[s[2], s[1], s[0]]]
rhs = [t[2], t[1], t[0]]
def det3(m):
return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
D0 = det3(M)
if abs(D0) < 1e-30:
raise ValueError("degenerate design — points are collinear in x")
def replace_col(m, col, v):
return [[v[r] if c == col else m[r][c] for c in range(3)] for r in range(3)]
a = det3(replace_col(M, 0, rhs)) / D0
b = det3(replace_col(M, 1, rhs)) / D0
c = det3(replace_col(M, 2, rhs)) / D0
return a, b, c
def parabola_vertex(a, b):
"""x at the minimum of a*x^2 + b*x + c. Requires a > 0 (a real minimum)."""
if a <= 0:
raise ValueError("parabola opens downward or is flat — no interior minimum")
return -b / (2 * a)
pts = isoflop_sweep(1e21, [1e8, 3e8, 1e9, 3e9, 1e10, 3e10])
log_n = [math.log10(N) for N, _, _ in pts]
losses = [L for _, _, L in pts]
a, b, c = fit_quadratic(log_n, losses)
n_opt = 10 ** parabola_vertex(a, b)
print(f"fitted optimal N = {n_opt:.3e} (D = {1e21/(6*n_opt):.3e}, "
f"ratio {1e21/(6*n_opt)/n_opt:.1f} tokens/param)")
fitted optimal N = 1.951e+09 (D = 8.542e+10, ratio 43.8 tokens/param)
Notice the flat bottom. In the sweep above,
N=1e9andN=3e9differed by 0.004 nats — a factor of 3 in model size for essentially no quality difference. This is a gift: you can move off the exact optimum toward a smaller, cheaper-to-serve model almost for free (Phase 02's whole thesis). It is also a trap: with realistic measurement noise, the fitted vertex wanders. Chapter 10 quantifies how much.
Steps 4–6: the power laws
def run_isoflops_ladder(budgets, sizes_per_budget):
"""Steps 1-4: get (C, N_opt, D_opt) for several budgets."""
results = []
for C, sizes in zip(budgets, sizes_per_budget):
pts = isoflop_sweep(C, sizes)
lx = [math.log10(N) for N, _, _ in pts]
ly = [L for _, _, L in pts]
a, b, _ = fit_quadratic(lx, ly)
N_opt = 10 ** parabola_vertex(a, b)
results.append((C, N_opt, C / (6 * N_opt)))
return results
budgets = [1e19, 1e20, 1e21, 1e22]
sizes = [[N * m for N in (1e7, 3e7, 1e8, 3e8, 1e9)] for m in (1, 3, 10, 30)]
ladder = run_isoflops_ladder(budgets, sizes)
# Steps 5-6: fit the power laws.
Cs = [C for C, _, _ in ladder]
Ns = [N for _, N, _ in ladder]
Ds = [D for _, _, D in ladder]
_, a_exp = fit_power_law(Cs, Ns)
_, b_exp = fit_power_law(Cs, Ds)
print(f"N_opt ∝ C^{a_exp:.3f}")
print(f"D_opt ∝ C^{b_exp:.3f}")
print(f"consistency: a + b = {a_exp + b_exp:.3f} (must be 1.000)")
N_opt ∝ C^0.454
D_opt ∝ C^0.546
consistency: a + b = 1.000
a + b = 1 exactly, and it must — it falls straight out of C = 6ND. Run this check every
single time. If your fitted exponents do not sum to ~1, you have a bug in your FLOP accounting
or your fit, and you should find it before you spend $30M.
And the exponent, ~0.45, is close to Chinchilla's ~0.5: scale N and D at roughly the same
rate. (It is not exactly 0.5 because the ground-truth α and β used here are not equal —
optimum_exponent(α, β) = β/(α+β), which is 0.5 precisely when α = β.)
Chapter 7: The Parametric Form L(N, D)
IsoFLOPs finds the optimum without ever writing down a formula. The alternative — and what you need if you want to answer "what if I have 3× the data but the same compute?" — is to fit the full surface.
$$ L(N, D) = E + \frac{A}{N^{\alpha}} + \frac{B}{D^{\beta}} $$
Reading the five parameters
CHINCHILLA = dict(E=1.69, A=406.4, alpha=0.34, B=410.7, beta=0.28)
def parametric_loss(N, D, E, A, alpha, B, beta):
if N <= 0 or D <= 0:
raise ValueError("N and D must be positive")
return E + A / (N ** alpha) + B / (D ** beta)
# Decompose the loss at a realistic operating point.
N, D = 70e9, 1.4e12
p = CHINCHILLA
cap = p["A"] / N ** p["alpha"]
dat = p["B"] / D ** p["beta"]
print(f"irreducible E : {p['E']:.4f} ({p['E']/(p['E']+cap+dat):5.1%})")
print(f"capacity A/N^a : {cap:.4f} ({cap/(p['E']+cap+dat):5.1%})")
print(f"data B/D^b : {dat:.4f} ({dat/(p['E']+cap+dat):5.1%})")
print(f"total : {parametric_loss(N, D, **p):.4f}")
irreducible E : 1.6900 (87.3%)
capacity A/N^a : 0.0835 ( 4.3%)
data B/D^b : 0.1632 ( 8.4%)
total : 1.9366
Look at that: 87% of the loss is irreducible. All the money in the industry is being spent on the remaining 13%. That is worth internalizing — it explains why improvements look so small in nats, and why compressing a 0.01-nat gap is worth a nine-figure budget.
It also tells you which lever to pull: here the data term (0.163) is nearly twice the capacity term (0.084), so at this operating point more data helps more than more parameters — which is precisely the Chinchilla recommendation, visible directly in the decomposition.
Deriving the optimum analytically
With the parametric form you can solve for the optimum instead of sweeping. Minimize
L(N, C/(6N)) over N:
$$ \frac{\partial}{\partial N}\left[\frac{A}{N^\alpha} + \frac{B}{(C/6N)^\beta}\right] = 0 $$
$$ \Rightarrow \quad -\alpha A N^{-\alpha-1} + \beta B (6/C)^{\beta} N^{\beta - 1} = 0 $$
$$ \Rightarrow \quad N_{\text{opt}} = \left[\frac{\alpha A}{\beta B}\left(\frac{C}{6}\right)^{\beta}\right]^{\frac{1}{\alpha+\beta}} $$
which is a power law in C with exponent β/(α+β).
def analytic_optimum(C, E, A, alpha, B, beta):
"""Closed-form compute-optimal (N, D) under the parametric law."""
N = ((alpha * A) / (beta * B) * (C / 6.0) ** beta) ** (1.0 / (alpha + beta))
return N, C / (6 * N)
for C in (1e19, 1e21, 1e23, 1e25):
N, D = analytic_optimum(C, **CHINCHILLA)
print(f"C={C:.0e} N={N:9.3e} D={D:9.3e} ratio={D/N:7.1f} tok/param")
exponent = CHINCHILLA["beta"] / (CHINCHILLA["alpha"] + CHINCHILLA["beta"])
print(f"\nN_opt ∝ C^{exponent:.4f}")
C=1e+19 N=2.280e+08 D=7.311e+09 ratio= 32.1 tok/param
C=1e+21 N=1.824e+09 D=9.136e+10 ratio= 50.1 tok/param
C=1e+23 N=1.460e+10 D=1.142e+12 ratio= 78.2 tok/param
C=1e+25 N=1.168e+11 D=1.427e+13 ratio= 122.1 tok/param
N_opt ∝ C^0.4516
A caution worth stating. Notice that the token-per-parameter ratio is not constant — it climbs from 32 at
1e19to 122 at1e25. The familiar "20 tokens per parameter" is only a rule of thumb at one scale; the parametric law says the ratio drifts wheneverα ≠ β. Note also that these particularE/A/Bvalues have been debated in follow-up work (see Besiroglu et al. in the References), and the ratio is extremely sensitive toαandβ. This is not a flaw in the method — it is the lesson. Small changes in fitted exponents produce large changes in the recommendation, which is exactly why Chapter 9 (the estimator problem) and Chapter 10 (confidence intervals) exist. When you fit your own law, always report the ratio and its uncertainty.
Chapter 8: Fitting It — Least Squares, Logs, and Huber
You have (N, D, L) points and want (E, A, α, B, β). This is a nonlinear fit, and how you
measure "fit" changes the answer.
The objective choices
def residuals(points, params, space="log"):
"""points: [(N, D, L_observed), ...]; params: (E, A, alpha, B, beta)."""
E, A, alpha, B, beta = params
out = []
for N, D, L_obs in points:
L_pred = E + A / N ** alpha + B / D ** beta
if space == "linear":
out.append(L_pred - L_obs)
elif space == "log":
if L_pred <= 0 or L_obs <= 0:
raise ValueError("log-space residuals need positive losses")
out.append(math.log(L_pred) - math.log(L_obs))
else:
raise ValueError(f"unknown space {space!r}")
return out
def squared_loss(rs):
return sum(r * r for r in rs)
def huber_loss(rs, delta=1e-3):
"""Quadratic near zero, LINEAR in the tails — so one bad run cannot dominate.
Chinchilla used Huber on log-space residuals. This is not incidental: scaling
ladders contain genuine outliers (a run that diverged, a bad data shard), and
least squares would let one of them set your flagship recommendation.
"""
total = 0.0
for r in rs:
if abs(r) <= delta:
total += 0.5 * r * r
else:
total += delta * (abs(r) - 0.5 * delta)
return total
Why the space matters
Losses in a ladder span a wide range — a 10M-parameter model might sit at 4.0 nats, a 10B model at 1.9.
- Least squares on raw
Lweights absolute error. A 0.1-nat miss on the 4.0 point counts the same as a 0.1-nat miss on the 1.9 point. Since the big-loss points are the small models, the fit gets dragged toward matching small models well — exactly the points you care least about, since you are extrapolating upward. - Least squares on
log Lweights relative error, treating all scales evenly. - Huber on
log Ldoes that and refuses to let one outlier dominate.
def compare_objectives(points, params):
lin = residuals(points, params, "linear")
log = residuals(points, params, "log")
print(f" sum-sq on L : {squared_loss(lin):.6f}")
print(f" sum-sq on log L : {squared_loss(log):.6f}")
print(f" Huber on log L : {huber_loss(log):.6f}")
pts = [(N, D, true_loss(N, D)) for N, D in
[(1e7, 1e10), (1e8, 1e11), (1e9, 1e11), (1e10, 1e12)]]
# Inject one outlier: a run that diverged and reported a bad loss.
pts_outlier = pts[:-1] + [(1e10, 1e12, pts[-1][2] + 1.5)]
print("clean ladder, true params:")
compare_objectives(pts, (1.69, 406.4, 0.34, 410.7, 0.28))
print("ladder with ONE diverged run, same params:")
compare_objectives(pts_outlier, (1.69, 406.4, 0.34, 410.7, 0.28))
clean ladder, true params:
sum-sq on L : 0.000000
sum-sq on log L : 0.000000
Huber on log L : 0.000000
ladder with ONE diverged run, same params:
sum-sq on L : 2.250000
sum-sq on log L : 0.305852
Huber on log L : 0.000553
One bad run contributes 2.25 to the least-squares objective and 0.00055 to Huber — a factor of about 4,000. Under least squares the fitter will distort all five parameters to chase that outlier. Under Huber it barely notices. On a real ladder, where a diverged run is a routine occurrence, this is the difference between a usable law and a garbage one.
Fitting without a library
You need a nonlinear optimizer. Coordinate descent with a shrinking step is enough, fully deterministic, and about twenty lines:
def fit_parametric(points, init=(1.5, 400.0, 0.35, 400.0, 0.30),
objective="huber", space="log",
iters=200, seed_step=0.5, shrink=0.97):
"""Deterministic coordinate descent. No randomness, no library optimizer,
so the same input always gives the same fit — which is a testable property."""
params = list(init)
step = [seed_step * abs(p) if p != 0 else seed_step for p in params]
def score(p):
rs = residuals(points, tuple(p), space)
return huber_loss(rs) if objective == "huber" else squared_loss(rs)
best = score(params)
for _ in range(iters):
improved = False
for i in range(len(params)):
for direction in (+1, -1):
trial = list(params)
trial[i] = params[i] + direction * step[i]
# Keep exponents and scales in a sane region.
if trial[i] <= 0:
continue
s = score(trial)
if s < best:
best, params, improved = s, trial, True
break
if not improved:
step = [s * shrink for s in step]
return tuple(params), best
Why hand-rolled? Because a library optimizer's version, tolerance, and default method all change the fitted exponents, and therefore your flagship recommendation. If a
scipyupgrade can move yourN_optby 20%, your process is not reproducible. Frontier teams pin this ruthlessly.
Chapter 9: The Estimator Problem — Feinberg's Open Question
His closing slide lists this as a research direction, and it is worth quoting in full because it is a rare, concrete, fundable, GPU-free open problem stated by someone who would know:
"Scaling laws are brittle, dataset dependent.
L(N, D, etc.)– of course we can add more dims to improve fit. Least squares vs MLE & formal stats model imply different scaling recommendations! Formalize. Rather than grid(N, D)where do we get max info gain? Active learn…"
The two problems
Problem A — the estimator is unspecified. Chinchilla used Huber on log-residuals. That is a choice, not a derivation. A proper statistical treatment would:
- Write down a generative model: what is the noise on a measured loss? Is it additive? Multiplicative? Heteroscedastic (bigger for small models, which are noisier)? Correlated across points from the same run?
- Derive the maximum-likelihood estimator under that model.
- Get calibrated uncertainty for free, instead of bootstrapping.
Nobody has fully done this, and the choice demonstrably changes the recommendation:
def recommendation_under_estimator(points, objective, space):
params, _ = fit_parametric(points, objective=objective, space=space)
N_opt, D_opt = analytic_optimum(1e24, *params)
return params, N_opt, D_opt
ladder = [(N, D, true_loss(N, D)) for N, D in
[(1e7, 2e10), (3e7, 6e10), (1e8, 2e11), (3e8, 6e11), (1e9, 2e12)]]
for obj, sp in (("squared", "linear"), ("squared", "log"), ("huber", "log")):
params, N_opt, D_opt = recommendation_under_estimator(ladder, obj, sp)
print(f"{obj:8s}/{sp:6s} -> flagship N = {N_opt:.3e}, D = {D_opt:.3e}")
Run it and watch the recommendations diverge on identical data. That divergence is the open problem.
Problem B — the design is a grid. Everybody runs N ∈ {…} × C ∈ {…}. But a grid is not an
efficient experiment. The right question is: given my current posterior, which next run most
reduces the variance of my prediction at C = 1e25?
def extrapolation_variance(log_c_points, target_log_c):
"""Variance of a linear extrapolation to `target_log_c`, up to a noise constant.
Standard OLS result: Var(y_hat) ∝ 1/n + (x* - x̄)^2 / Σ(x - x̄)^2
"""
n = len(log_c_points)
if n < 2:
raise ValueError("need at least 2 design points")
xbar = sum(log_c_points) / n
sxx = sum((x - xbar) ** 2 for x in log_c_points)
if sxx == 0:
raise ValueError("all design points identical")
return 1.0 / n + (target_log_c - xbar) ** 2 / sxx
TARGET = 25.0 # forecasting a 1e25 FLOP run
designs = {
"clustered (4 runs, all ~1e19)": [19.0, 19.2, 19.4, 19.6],
"spread (4 runs, 1e18-1e21)": [18.0, 19.0, 20.0, 21.0],
"budget-skewed (3 small, 1 big)": [18.0, 18.5, 19.0, 21.5],
"two-point extremes": [18.0, 21.0],
}
for name, d in designs.items():
print(f"{name:32s} Var ∝ {extrapolation_variance(d, TARGET):8.2f}")
clustered (4 runs, all ~1e19) Var ∝ 162.70
spread (4 runs, 1e18-1e21) Var ∝ 6.30
budget-skewed (3 small, 1 big) Var ∝ 4.81
two-point extremes Var ∝ 7.22
The clustered design is 26× worse than the spread one, using the same number of runs. And note the last row: two-point extremes, with half the runs, beats four clustered runs by 23×. Note also that the budget-skewed design — three cheap points plus one expensive one far out — edges out the evenly spread one, because that distant anchor is doing most of the work.
The practical rule this gives you: when planning a ladder, maximize the spread of
log C, subject to your budget and to the smallest model still being large enough to be in the scaling regime. Do not add a fifth point near your existing four; add one an order of magnitude out.
Chapter 10: Confidence Intervals via Bootstrap
A forecast without error bars is not a forecast. And you cannot derive analytic intervals here because the model is nonlinear and the noise model is unknown (Chapter 9). So: bootstrap.
What the bootstrap is
You have n ladder points. Resample n of them with replacement, refit, record the
prediction. Do it 1,000 times. The spread of those 1,000 predictions estimates the spread of
your prediction.
The logic: your sample is your best available picture of the population, so resampling from it mimics drawing fresh samples from the population.
import random
def bootstrap_forecast(points, target_C, n_boot=200, seed=0):
"""Bootstrap CI for the loss forecast at target_C. Seeded -> reproducible."""
rng = random.Random(seed) # SEEDED. Same seed -> same bytes.
forecasts = []
for _ in range(n_boot):
sample = [points[rng.randrange(len(points))] for _ in range(len(points))]
try:
params, _ = fit_parametric(sample, iters=60)
N, D = analytic_optimum(target_C, *params)
forecasts.append(parametric_loss(N, D, *params))
except (ValueError, OverflowError, ZeroDivisionError):
continue # a degenerate resample; skip it
if len(forecasts) < 10:
raise ValueError("too few successful bootstrap fits — ladder is too small")
forecasts.sort()
lo = forecasts[int(0.025 * len(forecasts))]
hi = forecasts[int(0.975 * len(forecasts))]
mid = forecasts[len(forecasts) // 2]
return {"median": mid, "ci_low": lo, "ci_high": hi, "width": hi - lo,
"n_successful": len(forecasts)}
How to read the width
Remember Chapter 3: 0.01 nats is roughly a third more compute. So:
| CI width | Verdict |
|---|---|
| < 0.01 nats | Tight. You can make a confident recipe call. |
| 0.01–0.05 | Usable for go/no-go, not for choosing between similar recipes. |
| > 0.05 | Your ladder cannot support this decision. Run more/wider points before the meeting. |
The senior move. When someone presents a scaling forecast without an interval, ask for one. When they present an interval wider than the effect they are claiming, the honest conclusion is "we do not know yet" — and saying so is far more valuable than a confident wrong number that gets baked into a $40M plan.
Chapter 11: Designing the Ladder — Where to Spend Your Ablations
Pulling Chapters 9 and 10 into a procedure.
The constraints
- Budget. The ladder should cost a small fraction of the flagship — a few percent is
typical, and Phase 00's
$ per 1e21 FLOPsmakes that concrete. - The scaling regime. Below ~10M non-embedding parameters, models behave differently (embeddings dominate, optimization is qualitatively different). Points below the regime are worse than useless — they actively bias the fit.
- Spread. Chapter 9: variance is driven by the spread of
log C. - Replication. At least one budget should be run twice to measure your noise level. Without that, you are guessing at the thing your confidence interval depends on.
A workable recipe
def design_ladder(flagship_C, ladder_budget_fraction=0.03, n_budgets=5,
min_C=1e18):
"""Geometrically spaced budgets consuming a fixed fraction of the flagship."""
total = flagship_C * ladder_budget_fraction
# Geometric spacing means each budget is the same multiplicative step apart,
# which maximizes spread in log space for a given range.
top = total / 2.0 # the largest ladder run gets half the ladder budget
ratio = (top / min_C) ** (1.0 / (n_budgets - 1))
budgets = [min_C * ratio ** i for i in range(n_budgets)]
return budgets
flagship = 1e25
budgets = design_ladder(flagship)
print(f"flagship: {flagship:.0e} FLOPs")
print(f"ladder total: {sum(budgets):.3e} FLOPs "
f"({sum(budgets)/flagship:.2%} of flagship)")
for C in budgets:
print(f" C={C:.3e} ({C/flagship:.2e} of flagship)")
print(f"log-C spread: {math.log10(budgets[-1]) - math.log10(budgets[0]):.1f} decades")
flagship: 1e+25 FLOPs
ladder total: 1.580e+23 FLOPs (1.58% of flagship)
C=1.000e+18 (1.00e-07 of flagship)
C=1.968e+19 (1.97e-06 of flagship)
C=3.873e+20 (3.87e-05 of flagship)
C=7.622e+21 (7.62e-04 of flagship)
C=1.500e+23 (1.50e-02 of flagship)
log-C spread: 5.2 decades
Under 2% of the flagship budget buys you 5.2 decades of spread. In money (Phase 00's $1,753 per 1e21 FLOPs): the flagship is ~$17.5M and the entire ladder is ~$277k. That is the single best-value spend in the whole project, and it is the argument you make when someone suggests skipping ablations to save time.
Chapter 12: Making the Decision — Baseline vs Candidate
Feinberg's slide gives the procedure in three lines:
"How changes get adopted in classical setting:
- Derive
L*(flops)baselineL*(flops)candidate"and, on the refinements slide: "To make a change, compare baseline vs candidate laws."
Why you compare laws, not runs
The naive approach — "train both at 1B parameters, pick the winner" — fails because the curves can cross. An architecture change that helps small models can hurt large ones, and vice versa. Comparing at one scale tells you about that scale only.
def compare_recipes(baseline_params, candidate_params, target_C):
"""Compare two fitted laws AT THE TARGET, and find the crossover."""
def loss_at(params, C):
N, D = analytic_optimum(C, *params)
return parametric_loss(N, D, *params)
base = loss_at(baseline_params, target_C)
cand = loss_at(candidate_params, target_C)
# Scan for a crossover on a log grid.
crossover = None
prev = None
for i in range(100):
C = 10 ** (16 + i * 0.12)
sign = loss_at(candidate_params, C) < loss_at(baseline_params, C)
if prev is not None and sign != prev:
crossover = C
break
prev = sign
return {"baseline_loss": base, "candidate_loss": cand,
"delta": cand - base, "candidate_wins": cand < base,
"crossover_C": crossover}
baseline = (1.69, 406.4, 0.34, 410.7, 0.28)
# A candidate that is better per-parameter (bigger A-exponent) but worse per-token.
candidate = (1.69, 500.0, 0.38, 380.0, 0.26)
for C in (1e20, 1e22, 1e24, 1e26):
r = compare_recipes(baseline, candidate, C)
verdict = "CANDIDATE" if r["candidate_wins"] else "baseline "
print(f"C={C:.0e}: base {r['baseline_loss']:.4f} "
f"cand {r['candidate_loss']:.4f} delta {r['delta']:+.4f} -> {verdict}")
r = compare_recipes(baseline, candidate, 1e24)
print(f"\ncrossover at C ≈ {r['crossover_C']:.2e}" if r["crossover_C"]
else "\nno crossover in range")
The crossover is the deliverable, not the winner. "Candidate wins above 1e23 FLOPs" is an actionable statement; "candidate is better" is not, because it silently assumes a scale.
The decision checklist
Before you recommend a recipe change, you should be able to answer all six:
- Have both laws been fitted on the same ladder design and the same estimator?
- Is the delta at the target larger than the confidence interval on either forecast?
- Where is the crossover, and is the flagship comfortably on one side of it?
- Does the candidate change the serving cost (Phase 02, Phase 05)? A 0.01-nat win that doubles inference cost is a loss.
- Does the candidate change the failure modes (Phase 09)? A recipe that is 0.005 nats better and spikes twice as often will cost you more in goodput than it gains.
- Do the downstream evals agree with the loss delta, or is the loss improvement coming from something users do not care about?
Chapter 13: When Scaling Laws Lie
Feinberg's slide "The End of Scaling?" is measured about this, and worth taking seriously in both directions.
The failure modes
1. Out-of-regime extrapolation. Every law is fitted over a range. Three decades beyond it, the functional form itself may be wrong — a power law and a saturating curve can be indistinguishable on your ladder and disagree by a lot at the target.
2. Recipe drift. The law describes the recipe you fitted. If between the ladder and the flagship you change the data mixture, the tokenizer, the optimizer, or how depth scales — the law no longer applies. This is a shockingly common real-world failure: the ladder was run in March, the flagship in July, and the data team shipped three improvements in between.
3. Data exhaustion. The law says "more tokens." If you do not have them, the law is
answering a question you cannot act on. This is Phase 02's L(N, U, R).
4. Benchmark saturation, not model saturation. His slide: "LMSys is not the end-all-be-all. Llama 4 Maverick demonstrated that ranking can be volatile and overfit to human preference." A flat benchmark can mean a flat model or a dead benchmark. They look identical.
His counterargument to the doomers
He does not conclude scaling is over. The slide lists two reasons to expect continued progress:
"1. Better NN design still coming 2. Data from new sources being added"
and the whole "More Data Sources" slide is about multimodal and synthetic data (Phase 02). His framing of the mission is worth keeping: "job is to push the curves right."
The practical posture
def extrapolation_risk(ladder_max_C, target_C, ci_width):
"""A crude, honest risk score for a forecast."""
decades = math.log10(target_C / ladder_max_C)
if decades <= 0:
return "INTERPOLATION — low risk"
risk = decades * (1 + 20 * ci_width)
band = ("LOW" if risk < 2 else "MODERATE" if risk < 4 else "HIGH")
return (f"{decades:.1f} decades of extrapolation, CI {ci_width:.3f} nats "
f"-> {band} risk (score {risk:.1f})")
print(extrapolation_risk(1e22, 1e25, 0.008))
print(extrapolation_risk(1e20, 1e25, 0.040))
3.0 decades of extrapolation, CI 0.008 nats -> MODERATE risk (score 3.5)
5.0 decades of extrapolation, CI 0.040 nats -> HIGH risk (score 9.0)
Present it this way. "We forecast 1.712 nats, 95% CI [1.704, 1.721], extrapolating 3 decades beyond our largest ablation, assuming the data mixture is frozen from today." That sentence is what a principal engineer says. Everything before the comma is arithmetic; everything after it is judgment, and the judgment is what you are paid for.
Lab Walkthrough
Lab 01 — IsoFLOPs Ladder, Law Fitting & the Forecast
Implement in this order:
fit_line,fit_power_law,fit_quadratic,parabola_vertex— the numerical spine.fit_quadraticuses Cramer's rule on the 3×3 normal equations; it must raise on a degenerate (collinear) design rather than divide by zero.synthetic_ladder— generate(N, D, L)with seeded noise and an injectablelr_decay_bias. This is what lets you reproduce the Kaplan bug.isoflop_optimum— sweep, fit the parabola, return the vertex.fit_scaling_exponents— steps 5–6, plus thea + b ≈ 1consistency check.residuals/huber_loss/fit_parametric— the surface fit. Deterministic coordinate descent; same input must give byte-identical output.analytic_optimum— the closed form. A test checks it agrees with the numerical sweep.bootstrap_forecast— seeded resampling, percentile interval.extrapolation_variance/design_ladder— the optimal-design tooling.compare_recipes— the decision rule with the crossover scan.
The money test is test_lr_decay_bias_shifts_recommendation_toward_smaller_models: generate
a ladder with the bias, fit, and confirm the recommended N is meaningfully larger than the
unbiased fit's. That is Kaplan → Chinchilla, reproduced from scratch.
The traps:
fit_quadraticon 2 points, or on collinear points → must raise, notZeroDivisionError.- A parabola that opens downward has no minimum → must raise.
- Bootstrap resamples can be degenerate (all identical points) → catch and skip, and fail loudly if too few succeed.
- Everything random must go through a seeded
random.Random(seed).
Success Criteria
-
LAB_MODULE=solution pytest test_lab.py -vall green. -
Your
lab.pypasses after the TODOs. -
python solution.pyreproduces the Kaplan → Chinchilla story from synthetic data. - You can explain, without notes, why reading loss mid-run is a biased estimator and why the bias has a direction.
-
You can state why
a + b ≈ 1must hold and what it catches. - You can produce a forecast with a CI and say whether it is tight enough to decide on.
- You can justify a ladder design in terms of extrapolation variance and cost.
Interview Q&A
Q: What is a scaling law and why does the field need them?
An empirical formula, typically L = E + A/N^α + B/D^β, predicting test loss from parameters
and tokens. The field needs them because pre-training is a one-shot extrapolation problem: each
flagship run costs eight figures, takes months, and is by construction larger than anything
previously run, so you cannot validate by trying alternatives. You fit the law on a cheap ladder
and extrapolate. Critically, the law is a property of a fixed, parameterized recipe, not of
nature.
Q: Kaplan said N ∝ C^0.73; Chinchilla said C^0.5. What happened?
A methodological bug. Kaplan ran one training run per model size and read intermediate losses
to estimate loss at smaller token horizons. But learning-rate schedules decay to near zero at
the end, and much of the final improvement comes from that decay. A mid-run reading is therefore
systematically worse than a run actually scheduled to stop there — and the bias is largest for
the points furthest from their horizon, which tilts the fit against training on more data.
Chinchilla ran separate, properly-scheduled runs, found the exponent was ~0.5, and concluded
models had been badly undertrained.
Q: Walk me through IsoFLOPs.
Fix a FLOP budget. Train several models at different N, deriving D = C/6N so every run costs
the same. Loss versus log N is U-shaped — too small underfits, too large is data-starved — so
fit a parabola and take the vertex as N_opt for that budget. Repeat across budgets, then fit
power laws N_opt ∝ C^a and D_opt ∝ C^b. Sanity check: a + b must be ~1, since C = 6ND.
Q: How do you fit the parametric form, and why does it matter how? Minimize a robust loss — Huber — on log-space residuals. Log space because losses span a wide range and you care about relative not absolute error, and because absolute error would let the small, high-loss models dominate the fit exactly when you are extrapolating upward. Huber because ladders contain genuine outliers — a diverged run, a bad shard — and under least squares one of those can move all five parameters. It matters because different estimators give different exponents on identical data, and therefore different flagship recommendations. That is an open problem Feinberg explicitly calls out: "least squares vs MLE... Formalize."
Q: How do you put error bars on a forecast? Bootstrap: resample the ladder points with replacement, refit, predict, repeat ~1000 times, take percentiles. You cannot do it analytically because the model is nonlinear and the noise model is unspecified. Then interpret against scale: roughly, 0.01 nats is a third more compute, so a CI wider than 0.05 nats cannot support a recipe decision.
Q: I will give you 3% of the flagship budget for ablations. How do you spend it?
Geometrically spaced budgets to maximize the spread of log C, since extrapolation variance
goes as (x* − x̄)² / Σ(x − x̄)². Keep the smallest run inside the scaling regime — below ~10M
non-embedding parameters the behaviour is different and points there bias the fit. Give the
largest single run about half the ladder budget, because it anchors the extrapolation. And
replicate one budget so I can measure my noise rather than assume it.
Q: Two recipes. How do you decide?
Fit a law for each on the same ladder design with the same estimator, evaluate both at the
target C, and report the delta with confidence intervals plus the crossover point. Then
check three things the loss number cannot see: does the candidate change serving cost, does it
change training stability, and do downstream evals move in the same direction as loss.
Q: Are scaling laws ending? Loss-versus-compute curves have not visibly bent. What people usually observe is benchmark saturation, which is a measurement problem — leaderboards are volatile and can overfit human preference. The two live sources of continued progress are better architectures and new data sources, especially multimodal and synthetic. The real constraint is not the law flattening, it is unique high-quality data running out, which changes the shape of the optimization rather than ending it.
Tips & Takeaways
Tips
- Always check
a + b ≈ 1. Free bug detector on any fitted pair of exponents. - Always fit in log space, always use a robust loss. Two lines of code, enormous robustness gain.
- Always report a confidence interval, and compare its width against 0.01 nats ≈ 33% more compute.
- Pin your fitting code. A library optimizer's default method changing between versions can move your flagship recommendation. Hand-roll or pin exactly.
- Replicate one ladder point. You cannot calibrate uncertainty without measuring noise.
- Write down the recipe alongside the law. A law without its recipe is uninterpretable in six months.
- Spread beats density. One more point an order of magnitude out beats three more points near your existing cluster.
- Say the extrapolation distance out loud when presenting: "three decades beyond our largest ablation."
Takeaways
- Pre-training is a one-shot extrapolation problem. Scaling laws exist because of that.
- A law describes your recipe, not the universe. Fix the recipe first.
- Loss forecasting is recipe selection.
L = E + A/N^α + B/D^β— irreducible + capacity + data. At frontier scale ~89% of the loss is irreducible; the whole industry is fighting over the rest.- The IsoFLOPs U-curve has a flat bottom: a gift for serving-driven deviation, a trap for noisy fits.
- Chinchilla beat Kaplan by fixing an experimental-design bug. Methodology is the frontier.
- The estimator you choose changes your flagship recommendation. This is an open problem.
- Where you place ladder points matters more than how many. 13× variance reduction, same cost.
- A forecast without error bars is not a forecast.
- Compare laws at the target and report the crossover, never a single-scale winner.
References
Primary
- Feinberg, Gemini Pretraining, Princeton, Apr 2025 — slides — the "Classical Scaling" section: the IsoFLOPs six-step build, the Kaplan/Chinchilla contrast, "UNDERTRAINED!", "Least squares vs MLE… Formalize", "Active learn…"
- Austin et al., How To Scale Your Model — https://jax-ml.github.io/scaling-book/ — do the exercises; he offers interviews for them
Core papers
- Kaplan et al., Scaling Laws for Neural Language Models, 2020 — https://arxiv.org/abs/2001.08361
- Hoffmann et al., Training Compute-Optimal Large Language Models (Chinchilla), 2022 — https://arxiv.org/abs/2203.15556
- Besiroglu et al., Chinchilla Scaling: A Replication Attempt, 2024 — https://arxiv.org/abs/2404.10102 — a replication finding issues in the original's reported fit; read this alongside Chinchilla, it is Chapter 9's problem in the wild
- Clark et al., Unified Scaling Laws for Routed Language Models, 2022 — https://arxiv.org/abs/2202.01169
- Muennighoff et al., Scaling Data-Constrained Language Models, 2023 — https://arxiv.org/abs/2305.16264 (Phase 02)
- Sardana et al., Beyond Chinchilla-Optimal, 2024 — https://arxiv.org/abs/2401.00448 (Phase 02)
- Grattafiori et al., The Llama 3 Herd of Models, 2024 — https://arxiv.org/abs/2407.21783 — their scaling-law methodology section is an unusually clear published account
Statistics
- Efron & Tibshirani, An Introduction to the Bootstrap — the method in Chapter 10
- Huber, Robust Estimation of a Location Parameter, 1964 — the loss in Chapter 8
- Chaloner & Verdinelli, Bayesian Experimental Design, 1995 — the formal version of Chapter 11