Clustered Data and the Sampling Budget

Clustered Data and the Sampling Budget

One Bad Tool Call, Ten Bad Rows

Your agent eval grades 100 conversations, 10 turns each: 1,000 graded rows. The pass rate comes in at 70%, and the naive 95% confidence interval is

±1.960.211000±2.8 pp\pm\, 1.96\sqrt{\frac{0.21}{1000}} \approx \pm\, 2.8 \text{ pp}

Tight. Shippable. Also wrong.

Turns within a conversation sink or swim together. One bad tool call on turn 2 poisons the next nine rows; a conversation that starts clean tends to stay clean. If the intraclass correlation is ρ=0.5\rho = 0.5—half of the outcome variance lives between conversations—the design effect is

DEFF=1+(m1)ρ=1+9×0.5=5.5\mathrm{DEFF} = 1 + (m-1)\rho = 1 + 9 \times 0.5 = 5.5

The effective sample size is 1000/5.51821000 / 5.5 \approx 182, and the honest interval is ±2.8×5.5±6.7\pm\, 2.8 \times \sqrt{5.5} \approx \pm\, 6.7 pp. Your error bars were 2.3× too tight, and nobody lied. The rows just weren't independent.

This is the third post in the Error Bars for Evals strand. The first put a confidence interval on a single score; the second compared two models on the same questions. This one asks what happens when your rows arrive in clumps—and where the next thousand dollars of eval budget should go.

The Whole Idea in One Formula

Everything runs through the intraclass correlation ρ\rho: the share of outcome variance that lives between clusters rather than within them. ρ=0\rho = 0 means knowing one turn's outcome tells you nothing about its siblings. ρ=1\rho = 1 means a conversation passes or fails as a unit and the turns are photocopies.

Given ρ\rho and a common cluster size mm, Kish's design effect says how much clustering inflates the variance of your pass rate:

DEFF=1+(m1)ρ\mathrm{DEFF} = 1 + (m - 1)\rho

Two corollaries do all the practical work:

neff=nDEFFSEhonest=SEnaive×DEFFn_{\mathrm{eff}} = \frac{n}{\mathrm{DEFF}} \qquad\qquad \mathrm{SE}_{\mathrm{honest}} = \mathrm{SE}_{\mathrm{naive}} \times \sqrt{\mathrm{DEFF}}

Anchor the formula at its edges:

  • m=1m = 1 → no penalty, ever. One row per cluster is just an independent sample, whatever ρ\rho is.
  • ρ=0\rho = 0 → no penalty, ever. Independent rows are independent rows, however you bundle them.
  • The penalty is depth × stickiness. It grows with how deep each cluster goes (m1m - 1) multiplied by how much the rows inside it move together (ρ\rho). Neither alone hurts you; the product does.

The Budget Identity

The second big idea decides how to spend. Fix a budget of n=Kmn = K \cdot m rows—KK clusters, mm rows each—and expand the variance of the pooled pass rate:

Var(p^)    1ρKmwithin-cluster noise  +  ρKbetween-cluster noise\operatorname{Var}(\hat{p}) \;\propto\; \underbrace{\frac{1-\rho}{K m}}_{\text{within-cluster noise}} \;+\; \underbrace{\frac{\rho}{K}}_{\text{between-cluster noise}}

Read it out loud. The first term has the full budget KmKm in its denominator: any spending shrinks it, depth or breadth alike. The second term has only KK. There is no mm in it anywhere—no amount of extra depth touches it. Once ρ\rho is meaningful, that second term becomes the floor under your precision, and deeper clusters stop buying you anything. Only more clusters do.

Where the two-term split comes from

Start from the design-effect form and split 1+(m1)ρ=(1ρ)+mρ1 + (m-1)\rho = (1-\rho) + m\rho:

Var(p^)=p(1p)1+(m1)ρKm=p(1p)[1ρKm+mρKm]=p(1p)[1ρKm+ρK]\operatorname{Var}(\hat{p}) = p(1-p)\,\frac{1 + (m-1)\rho}{Km} = p(1-p)\left[\frac{1-\rho}{Km} + \frac{m\rho}{Km}\right] = p(1-p)\left[\frac{1-\rho}{Km} + \frac{\rho}{K}\right]

The between-cluster term ρ/K\rho / K is the variance of the cluster-quality lottery itself: with only KK conversations, you only get KK draws of "was this a good conversation," no matter how carefully you grade each one.

A Worked Example

Take the eval from the hook: K=100K = 100 conversations, m=10m = 10 turns, and a pilot estimate of ρ=0.5\rho = 0.5. The same five steps appear in the practice exercise below.

Step 1 — count the rows. n=Km=100×10=1,000n = K \cdot m = 100 \times 10 = 1{,}000. This is the nn the dashboard reports.

Step 2 — the design effect. DEFF=1+(101)×0.5=5.5\mathrm{DEFF} = 1 + (10 - 1) \times 0.5 = 5.5. Each conversation's ten turns carry the information of roughly two independent rows, not ten.

Step 3 — effective sample size. neff=1000/5.5182n_{\mathrm{eff}} = 1000 / 5.5 \approx 182. A thousand rows doing the work of 182.

Step 4 — the bar-widening factor. 5.52.35\sqrt{5.5} \approx 2.35. Every naive standard error in the report needs to stretch by 2.35× before you believe it.

Step 5 — spend the next budget. Leadership doubles your spend to 2,000 rows. Two ways to use it:

DesignRowsDEFFneffn_{\mathrm{eff}}
Today: 100 conversations × 10 turns1,0001+9(0.5)=5.51 + 9(0.5) = 5.51000/5.51821000/5.5 \approx 182
(a) 200 conversations × 10 turns2,0001+9(0.5)=5.51 + 9(0.5) = 5.52000/5.53642000/5.5 \approx 364
(b) 100 conversations × 20 turns2,0001+19(0.5)=10.51 + 19(0.5) = 10.52000/10.51902000/10.5 \approx 190

Same money. Doubling depth bought almost nothing (182 → 190 effective samples); doubling clusters doubled the work done (182 → 364). The budget identity said this would happen: option (b) poured its entire raise into the term that mm can't reach.

Where Clusters Hide in LLM Evals

Conversations are the obvious case. They are not the only one.

ClusterRows inside itHow it bites
ConversationGraded turnsOne early tool-call failure cascades through every later turn
Promptkk resamples (pass@k, best-of-nn, temperature sampling)Ten generations of the same prompt mostly re-measure that prompt
TemplateInstantiations with different entities/valuesA broken template fails identically a hundred times
DocumentChunks, or questions about the same passageA hard document drags down every question written against it
UserSessions, requestsPower users overweight their own quirks in the average
GraderEvery item they gradeA rubric-happy grader clusters everything they touch

Miller's "Adding Error Bars to Evals" treats exactly this structure—question groups drawn from a shared context—and recommends clustered standard errors whenever eval questions arrive in related bundles. The design effect itself is much older: it comes from Kish (1965), built for household surveys where interviewing one more person in the same village was cheap and one more village was expensive. Swap "village" for "conversation" and the arithmetic transfers unchanged.

Ways to Fool Yourself

1. Counting turns (or resamples) as independent nn. The dashboard says n=1000n = 1000; the statistics say neff=182n_{\mathrm{eff}} = 182. Any calculation that feeds the raw row count into n\sqrt{n}—power analyses, CIs, significance tests—inherits the lie. The same applies to kk generations per prompt: pass@k evaluated on 100 prompts is 100 clusters, not 100×k100 \times k observations.

2. Bootstrapping rows instead of clusters. The bootstrap is not automatically honest. Resample individual rows and every resampled dataset scrambles conversations together, silently preserving none of the within-cluster dependence—the interval that comes out is the naive one with extra steps. Resample whole clusters (keep every turn of each drawn conversation together) and the dependence survives into the replicates. Same estimator, one word different, very different interval.

3. Assuming ρ\rho is small without measuring it. Intuition about ρ\rho is poor and the penalty is linear in it. A pilot run and a one-way ANOVA give you an estimate in five lines: with MSB\mathrm{MS}_B the between-cluster mean square and MSW\mathrm{MS}_W the within, ρ^=(MSBMSW)/(MSB+(m1)MSW)\hat\rho = (\mathrm{MS}_B - \mathrm{MS}_W) / (\mathrm{MS}_B + (m-1)\mathrm{MS}_W). Code in the field notes. Agent-style evals routinely land at ρ=0.3\rho = 0.3 to 0.60.6; "it's probably about 0.1" is a hope, not a measurement.

4. Buying ten generations per prompt when the same money buys ten new prompts. Resampling feels rigorous—look how we averaged out the temperature noise. But if prompts differ more than resamples do (they almost always do), the tenth resample of prompt 17 is nearly free of new information while the eleventh prompt would have been full price. Sample in the direction of the variance.

5. Mixing cluster sizes and averaging carelessly. With unequal cluster sizes there are two different answers: pool all rows (big conversations count more) or average the cluster means (every conversation counts once). These are different estimands, and they genuinely differ when cluster size correlates with difficulty—long conversations are often long because they're going badly. Pick the one that matches the question you're asking, and state which one you picked.

Try It Yourself

The playground simulates the whole pipeline: each conversation draws a quality θj\theta_j, each turn is a coin flip around it, and both intervals are computed from the same rows. The strip plot is the intuition generator—watch the rows go all-or-nothing as ρ\rho rises.

Rows per clusterK = 100 conversations × m = 10 turns
Intraclass correlation ρρ = 0.50
Conversation 1, turn 1: passConversation 1, turn 2: passConversation 1, turn 3: passConversation 1, turn 4: passConversation 1, turn 5: passConversation 1, turn 6: passConversation 1, turn 7: passConversation 1, turn 8: passConversation 1, turn 9: passConversation 1, turn 10: pass10/10Conversation 2, turn 1: passConversation 2, turn 2: failConversation 2, turn 3: failConversation 2, turn 4: failConversation 2, turn 5: failConversation 2, turn 6: failConversation 2, turn 7: failConversation 2, turn 8: passConversation 2, turn 9: passConversation 2, turn 10: fail3/10Conversation 3, turn 1: passConversation 3, turn 2: passConversation 3, turn 3: passConversation 3, turn 4: passConversation 3, turn 5: failConversation 3, turn 6: failConversation 3, turn 7: failConversation 3, turn 8: failConversation 3, turn 9: passConversation 3, turn 10: fail5/10Conversation 4, turn 1: passConversation 4, turn 2: passConversation 4, turn 3: passConversation 4, turn 4: passConversation 4, turn 5: passConversation 4, turn 6: passConversation 4, turn 7: passConversation 4, turn 8: passConversation 4, turn 9: passConversation 4, turn 10: pass10/10Conversation 5, turn 1: passConversation 5, turn 2: passConversation 5, turn 3: passConversation 5, turn 4: passConversation 5, turn 5: passConversation 5, turn 6: failConversation 5, turn 7: passConversation 5, turn 8: passConversation 5, turn 9: passConversation 5, turn 10: pass9/10Conversation 6, turn 1: passConversation 6, turn 2: failConversation 6, turn 3: passConversation 6, turn 4: failConversation 6, turn 5: failConversation 6, turn 6: passConversation 6, turn 7: failConversation 6, turn 8: passConversation 6, turn 9: failConversation 6, turn 10: pass5/10Conversation 7, turn 1: failConversation 7, turn 2: passConversation 7, turn 3: failConversation 7, turn 4: failConversation 7, turn 5: failConversation 7, turn 6: failConversation 7, turn 7: failConversation 7, turn 8: failConversation 7, turn 9: failConversation 7, turn 10: pass2/10Conversation 8, turn 1: failConversation 8, turn 2: passConversation 8, turn 3: failConversation 8, turn 4: passConversation 8, turn 5: passConversation 8, turn 6: passConversation 8, turn 7: passConversation 8, turn 8: passConversation 8, turn 9: passConversation 8, turn 10: pass8/10Conversation 9, turn 1: passConversation 9, turn 2: passConversation 9, turn 3: passConversation 9, turn 4: passConversation 9, turn 5: passConversation 9, turn 6: passConversation 9, turn 7: passConversation 9, turn 8: passConversation 9, turn 9: passConversation 9, turn 10: pass10/10Conversation 10, turn 1: passConversation 10, turn 2: passConversation 10, turn 3: passConversation 10, turn 4: passConversation 10, turn 5: passConversation 10, turn 6: passConversation 10, turn 7: passConversation 10, turn 8: passConversation 10, turn 9: passConversation 10, turn 10: pass10/10Conversation 11, turn 1: failConversation 11, turn 2: failConversation 11, turn 3: passConversation 11, turn 4: passConversation 11, turn 5: passConversation 11, turn 6: failConversation 11, turn 7: failConversation 11, turn 8: failConversation 11, turn 9: failConversation 11, turn 10: fail3/10Conversation 12, turn 1: passConversation 12, turn 2: passConversation 12, turn 3: passConversation 12, turn 4: passConversation 12, turn 5: passConversation 12, turn 6: passConversation 12, turn 7: passConversation 12, turn 8: passConversation 12, turn 9: passConversation 12, turn 10: pass10/10Conversation 13, turn 1: passConversation 13, turn 2: passConversation 13, turn 3: passConversation 13, turn 4: passConversation 13, turn 5: passConversation 13, turn 6: passConversation 13, turn 7: passConversation 13, turn 8: passConversation 13, turn 9: passConversation 13, turn 10: pass10/10Conversation 14, turn 1: passConversation 14, turn 2: passConversation 14, turn 3: passConversation 14, turn 4: passConversation 14, turn 5: passConversation 14, turn 6: passConversation 14, turn 7: passConversation 14, turn 8: passConversation 14, turn 9: failConversation 14, turn 10: pass9/10Conversation 15, turn 1: passConversation 15, turn 2: passConversation 15, turn 3: passConversation 15, turn 4: failConversation 15, turn 5: failConversation 15, turn 6: passConversation 15, turn 7: failConversation 15, turn 8: passConversation 15, turn 9: failConversation 15, turn 10: pass6/10Conversation 16, turn 1: failConversation 16, turn 2: passConversation 16, turn 3: passConversation 16, turn 4: passConversation 16, turn 5: passConversation 16, turn 6: failConversation 16, turn 7: passConversation 16, turn 8: passConversation 16, turn 9: failConversation 16, turn 10: fail6/10Conversation 17, turn 1: passConversation 17, turn 2: passConversation 17, turn 3: passConversation 17, turn 4: passConversation 17, turn 5: passConversation 17, turn 6: passConversation 17, turn 7: failConversation 17, turn 8: failConversation 17, turn 9: passConversation 17, turn 10: pass8/10Conversation 18, turn 1: passConversation 18, turn 2: passConversation 18, turn 3: passConversation 18, turn 4: passConversation 18, turn 5: failConversation 18, turn 6: passConversation 18, turn 7: passConversation 18, turn 8: passConversation 18, turn 9: failConversation 18, turn 10: pass8/10Conversation 19, turn 1: failConversation 19, turn 2: failConversation 19, turn 3: passConversation 19, turn 4: failConversation 19, turn 5: failConversation 19, turn 6: failConversation 19, turn 7: failConversation 19, turn 8: failConversation 19, turn 9: failConversation 19, turn 10: fail1/10Conversation 20, turn 1: failConversation 20, turn 2: passConversation 20, turn 3: passConversation 20, turn 4: passConversation 20, turn 5: failConversation 20, turn 6: passConversation 20, turn 7: failConversation 20, turn 8: failConversation 20, turn 9: failConversation 20, turn 10: fail4/10Conversation 21, turn 1: passConversation 21, turn 2: passConversation 21, turn 3: passConversation 21, turn 4: passConversation 21, turn 5: failConversation 21, turn 6: failConversation 21, turn 7: failConversation 21, turn 8: passConversation 21, turn 9: passConversation 21, turn 10: pass7/10Conversation 22, turn 1: passConversation 22, turn 2: passConversation 22, turn 3: failConversation 22, turn 4: passConversation 22, turn 5: passConversation 22, turn 6: passConversation 22, turn 7: passConversation 22, turn 8: passConversation 22, turn 9: passConversation 22, turn 10: pass9/10Conversation 23, turn 1: passConversation 23, turn 2: passConversation 23, turn 3: passConversation 23, turn 4: passConversation 23, turn 5: passConversation 23, turn 6: passConversation 23, turn 7: passConversation 23, turn 8: passConversation 23, turn 9: passConversation 23, turn 10: fail9/10Conversation 24, turn 1: passConversation 24, turn 2: passConversation 24, turn 3: passConversation 24, turn 4: passConversation 24, turn 5: passConversation 24, turn 6: passConversation 24, turn 7: passConversation 24, turn 8: passConversation 24, turn 9: passConversation 24, turn 10: pass10/10

Each row is one conversation, each dot one graded turn — filled = pass, hollow = fail. Showing 24 of 100 conversations.

62.3%69.1%76.0%82.8%naive (rows independent)± 2.8 ppnaive (rows independent): [69.8%, 75.4%]cluster bootstrap± 6.3 ppcluster bootstrap: [66.3%, 78.8%]

Two 95% intervals from the same simulated rows. The naive one pretends every row is independent; the cluster bootstrap resamples whole conversations.

Pass rate p̂
72.6%
Naive SE
1.41 pp
Clustered SE
3.03 pp
Implied n_eff
216
of 1,000 rows
1.53.04.6SE (pp)15101520rows per cluster mpredicted SE at n = 1000, ρ = 0.50m = 1 → K = 1000 conversations, predicted SE 1.41 ppm = 2 → K = 500 conversations, predicted SE 1.73 ppm = 3 → K = 333 conversations, predicted SE 1.99 ppm = 4 → K = 250 conversations, predicted SE 2.23 ppm = 5 → K = 200 conversations, predicted SE 2.44 ppm = 6 → K = 167 conversations, predicted SE 2.64 ppm = 7 → K = 143 conversations, predicted SE 2.82 ppm = 8 → K = 125 conversations, predicted SE 2.99 ppm = 9 → K = 111 conversations, predicted SE 3.15 ppm = 10 → K = 100 conversations, predicted SE 3.31 ppm = 11 → K = 91 conversations, predicted SE 3.45 ppm = 12 → K = 83 conversations, predicted SE 3.60 ppm = 13 → K = 77 conversations, predicted SE 3.73 ppm = 14 → K = 71 conversations, predicted SE 3.86 ppm = 15 → K = 67 conversations, predicted SE 3.99 ppm = 16 → K = 63 conversations, predicted SE 4.11 ppm = 17 → K = 59 conversations, predicted SE 4.23 ppm = 18 → K = 56 conversations, predicted SE 4.35 ppm = 19 → K = 53 conversations, predicted SE 4.46 ppm = 20 → K = 50 conversations, predicted SE 4.57 pp

At this ρ, the curve says go left: more clusters, less depth.

Experiments to try:

  • "The multi-turn agent eval" — the hook's numbers. The implied neffn_{\mathrm{eff}} tile bounces around the 182 we computed by hand; reshuffle a few times to watch it orbit the theory.
  • "Actually independent" — set ρ=0\rho = 0 and the two intervals collapse onto each other: clustering is a fact about correlation, not about layout.
  • "Deep and sticky" — 50 conversations × 20 turns at ρ=0.6\rho = 0.6. A thousand rows, and the strip plot shows you the ~50 real observations.
  • "Wide and shallow" vs "Deep and sticky" — same budget, same ρ\rho-ish, wildly different clustered SEs. The budget curve says why.
  • "Bootstrap on fumes" — 20 conversations. Reshuffle a few times and watch the clustered interval itself jump around; below ~20 clusters the bootstrap is running on vapors.
  • Hold ρ\rho at 0.5 and sweep mm from 1 to 20. The marker dot rides up the budget curve—every step right is precision you paid for and didn't get.

Your Turn

The worked example was mine; these numbers are yours. Each problem generates a fresh eval design, and the five steps mirror the walkthrough exactly. If you get stuck, help escalates gently—a nudge first, then the setup with your numbers plugged in, and only then the full answer.

Practice problem

Prompt Resampling

Each prompt gets several generations at temperature, and resamples of the same prompt tend to pass or fail together. A pilot run estimated the intraclass correlation at ρ = 0.5. Work out what the correlation costs, then decide how to spend the next budget.
Clusters
K = 100
prompts
Rows per cluster
m = 12
temperature resamples each
Pilot ICC
ρ = 0.5
from a small pilot run
Step 1 of 50/5

Count the rows

How many graded rows does this eval produce — the n the dashboard will report?

Field Notes

Estimating ρ\rho from a pilot. Run a slice of the eval—even 30 clusters will do—and fit the one-way ANOVA estimator. With equal cluster sizes mm, grand mean yˉ\bar{y}, and cluster means yˉj\bar{y}_j:

MSB=mK1j(yˉjyˉ)2MSW=1K(m1)ji(yijyˉj)2ρ^=MSBMSWMSB+(m1)MSW\mathrm{MS}_B = \frac{m}{K-1}\sum_j (\bar{y}_j - \bar{y})^2 \qquad \mathrm{MS}_W = \frac{1}{K(m-1)}\sum_j \sum_i (y_{ij} - \bar{y}_j)^2 \qquad \hat\rho = \frac{\mathrm{MS}_B - \mathrm{MS}_W}{\mathrm{MS}_B + (m-1)\,\mathrm{MS}_W}
Compact Python: ICC from a pilot
import numpy as np
 
def icc_pilot(cluster_ids, outcomes):
    """One-way ANOVA intraclass correlation from (cluster_id, outcome) pairs."""
    ids, y = np.asarray(cluster_ids), np.asarray(outcomes, dtype=float)
    groups = [y[ids == c] for c in np.unique(ids)]
    K, n = len(groups), len(y)
    m_bar = n / K  # average cluster size
    grand = y.mean()
    ms_b = sum(len(g) * (g.mean() - grand) ** 2 for g in groups) / (K - 1)
    ms_w = sum(((g - g.mean()) ** 2).sum() for g in groups) / (n - K)
    return (ms_b - ms_w) / (ms_b + (m_bar - 1) * ms_w)

A small negative ρ^\hat\rho just means "indistinguishable from zero at this pilot size"—treat it as ρ0\rho \approx 0, not as an anti-correlation discovery.

When depth is the point. Everything above assumes the estimand is a population average over clusters. Sometimes it isn't. If you're measuring within-conversation degradation—does turn 15 fail more than turn 3?—then long conversations are the only place that signal lives, and cutting depth destroys the experiment. If pass@k is your metric, the kk resamples aren't a cluster inflating your variance; they're the estimand itself, and you need all kk of them per prompt (see Chen et al. 2021 for the unbiased estimator). The design effect punishes depth only when depth is redundant measurement of the same thing. First decide what the thing is.

What to report. KK, mm (or its distribution), ρ^\hat\rho from a pilot, and clustered standard errors—via the cluster bootstrap below or the analytic clustered SEs in Miller (2024). If your CI would embarrass you at neffn_{\mathrm{eff}} instead of nn, the number to fix is KK.

Earlier in this strand. The mechanics of the basic CI live in Error Bars on Eval Scores; comparing two models on the same clustered questions is the subject of Paired Deltas. Clustering composes with both: cluster the bootstrap there the same way you cluster it here.

Code

Python: cluster bootstrap CI
import numpy as np
 
def cluster_bootstrap_ci(cluster_ids, outcomes, n_boot=2000, seed=0):
    """95% CI for the pooled mean, resampling whole clusters with replacement."""
    rng = np.random.default_rng(seed)
    ids, y = np.asarray(cluster_ids), np.asarray(outcomes, dtype=float)
    groups = [y[ids == c] for c in np.unique(ids)]
    K = len(groups)
    stats = []
    for _ in range(n_boot):
        draw = rng.integers(0, K, size=K)          # sample K clusters
        sample = np.concatenate([groups[j] for j in draw])
        stats.append(sample.mean())                # pool the rows
    return np.percentile(stats, [2.5, 97.5])
 
# cluster_ids = conversation IDs, one per graded turn
# lo, hi = cluster_bootstrap_ci(df.conversation_id, df.passed)

Two implementation notes. First, the resampling unit is the cluster: a drawn conversation brings all of its turns. Second, sample.mean() pools rows, so bigger clusters weigh more—swap in np.mean([g.mean() for ...]) if your estimand is the mean of cluster means (see fooling-yourself #5).

Python: design-effect arithmetic
import numpy as np
 
def design_effect(m, rho):
    return 1 + (m - 1) * rho
 
def effective_n(K, m, rho):
    return K * m / design_effect(m, rho)
 
# The worked example
print(design_effect(10, 0.5))        # 5.5
print(effective_n(100, 10, 0.5))     # 181.8
print(effective_n(200, 10, 0.5))     # 363.6  <- double the clusters
print(effective_n(100, 20, 0.5))     # 190.5  <- double the depth

Further Reading