What Is an Experiment?
An experiment is a controlled comparison. You hold everything constant โ the dataset, the evaluators, the harness โ and change exactly one thing (a prompt, a model, a retrieval setting) to isolate its effect.
Then you measure whether the difference is real or noise. LLM outputs vary, and eval sets are finite, so a 2% score bump might be pure chance. Statistical testing tells you whether to ship.
This layer sits on top of everything below it: it consumes golden sets, coverage, reproducible replays, and rubric scores to turn "I think this prompt is better" into "this prompt is better, p = 0.01".
"Change one thing, measure against a fixed baseline, and decide with statistical confidence."
- A controlled lab trial
- A product A/B test
- A dependency upgrade benchmark
Why Experiments Matter
Signal vs. Noise
Statistical tests separate a genuine improvement from random variation in a small sample.
Isolate Variables
Change one thing at a time so you know exactly what caused a score to move.
Auditable History
A logged trail of experiments explains why the system is configured the way it is.
Justify Trade-offs
Quantify quality-vs-cost-vs-latency so model choices are defensible, not intuition.
Prevent Regressions
A ship gate on the baseline stops "improvements" that quietly break other cases.
Compounding Learning
Each experiment sharpens intuition and feeds the next hypothesis.
Anatomy of an Experiment
A good experiment record is a pre-registered plan: hypothesis, the single variable, the fixed dataset, and the metric that decides success โ all written before you run it.
id: exp_017
hypothesis: >
Adding the refund window to the system prompt reduces
hallucinated_fact failures without hurting tone.
variable: system_prompt # the ONE thing that changes
control: prompts/v3.txt
treatment: prompts/v4.txt
dataset: golden/support.jsonl # fixed across both arms
primary_metric: accuracy # decides ship / no-ship
guardrail_metrics: [tone, cost_usd, p95_latency_ms]
decision_rule: "ship if accuracy +โฅ3% at p < 0.05 and no guardrail regresses"
Hypothesis
A specific, falsifiable prediction โ written first to avoid rationalizing the result.
Single Variable
Exactly one change between control and treatment, so any effect is attributable.
Primary Metric
One metric decides the outcome; declaring it up front prevents cherry-picking.
Guardrails
Metrics that must not regress โ a win on accuracy can't come at the cost of safety or latency.
How Experiments Work
| Metric | Control (v3) | Treatment (v4) | ฮ | Verdict |
|---|---|---|---|---|
| Accuracy (primary) | 0.78 | 0.86 | +0.08 | Significant (p=0.01) |
| Tone | 0.91 | 0.90 | โ0.01 | No change (n.s.) |
| Cost / 1k | $1.20 | $1.22 | +$0.02 | Within guardrail |
Decision: Ship v4 โ accuracy up significantly, no guardrail regressed.
Step-by-Step Implementation
Define the experiment
Model an experiment as two arms over one fixed dataset with a declared primary metric.
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
@dataclass
class Arm:
"""One side of a comparison (control or treatment).
Args:
name: Human-readable label, e.g. "v3" or "gpt-4o".
agent: Callable mapping an input to the arm's output.
"""
name: str
agent: Callable[[str], str]
@dataclass
class Experiment:
"""A controlled comparison between two arms on a shared dataset."""
id: str
control: Arm
treatment: Arm
dataset: list
metric: Callable[[str, object], float]
primary: str = "score"
Run both arms on the same data
Score each example under both arms so the comparison is paired โ the most sensitive design.
from .models import Experiment
def run_experiment(exp: Experiment) -> dict:
"""Evaluate control and treatment over the same examples.
Args:
exp: The experiment definition to execute.
Returns:
Paired per-example scores for each arm, ready for a paired test.
"""
control_scores, treatment_scores = [], []
for example in exp.dataset:
control_scores.append(exp.metric(exp.control.agent(example.input), example))
treatment_scores.append(exp.metric(exp.treatment.agent(example.input), example))
return {
"control": control_scores,
"treatment": treatment_scores,
"n": len(exp.dataset),
}
Test for significance
A paired t-test tells you whether the mean difference is unlikely to be chance.
from statistics import mean
from scipy import stats
def compare(control: list[float], treatment: list[float], alpha: float = 0.05) -> dict:
"""Run a paired significance test between two arms.
Args:
control: Per-example scores for the control arm.
treatment: Per-example scores for the treatment arm.
alpha: Significance threshold for declaring a real effect.
Returns:
A dict with the mean delta, p-value, and a ``significant`` flag.
Raises:
ValueError: If the two arms have different lengths.
"""
if len(control) != len(treatment):
raise ValueError("Paired test requires equal-length arms")
result = stats.ttest_rel(treatment, control)
delta = mean(treatment) - mean(control)
return {
"delta": round(delta, 4),
"p_value": round(float(result.pvalue), 4),
"significant": bool(result.pvalue < alpha and delta > 0),
}
Apply the decision rule
Automate ship/no-ship so the decision follows the pre-registered rule, not enthusiasm.
def decide(primary: dict, guardrails: dict[str, dict], min_delta: float = 0.03) -> str:
"""Return a ship decision from primary and guardrail test results.
Args:
primary: Significance result for the primary metric.
guardrails: Significance results keyed by guardrail metric name.
min_delta: Minimum improvement required on the primary metric.
Returns:
One of ``"ship"``, ``"hold"``, or ``"reject"``.
"""
regressed = [m for m, r in guardrails.items() if r["delta"] < -min_delta and r["significant"]]
if regressed:
return "reject"
if primary["significant"] and primary["delta"] >= min_delta:
return "ship"
return "hold"
Use Cases
Prompt A/B Tests
Prove a reworded system prompt actually improves accuracy before rolling it out.
Model Bake-off
Compare a cheaper model against the incumbent on quality, cost, and latency together.
Retriever Comparison
Test chunk size or embedding model changes on frozen replay fixtures.
Trade-off Analysis
Quantify how much quality you lose by halving token spend โ then decide deliberately.
Best Practices & Pitfalls
Do
- Pre-register. Write the hypothesis and primary metric before running.
- Change one variable. Isolate cause; batch changes make results uninterpretable.
- Use paired tests. Score both arms on identical examples for maximum sensitivity.
- Log everything. Version prompts, datasets, and results for a reproducible trail.
Avoid
- Chasing noise. A +1% bump on 30 examples is almost certainly random.
- p-hacking. Don't test ten metrics and report only the one that "won".
- Ignoring guardrails. An accuracy win that tanks safety or latency isn't a win.
- Moving the dataset. Changing the eval set between arms invalidates the comparison.
FAQ
How many examples do I need?
It depends on effect size and variance, but small sets (under ~30) rarely detect anything but huge differences. Run a power analysis, or grow your golden set until effects are detectable.
Which statistical test should I use?
For paired continuous scores, a paired t-test or Wilcoxon signed-rank works well. For pass/fail proportions, use McNemar's test. Bootstrap confidence intervals are a robust general option.
How does this use the layers below?
Experiments run on golden sets (L1) with good coverage (L2), replayed deterministically (L4), scored by rubrics (L5), with error analysis (L3) explaining why an arm won. It's the capstone of the stack.