Module 7: Prompt Evaluation
6. A/B Testing Prompts
Description
Comparing prompt versions with robust statistical metrics. Sample size calculation, statistical significance, and effect size. Metrics to track: accuracy, latency, cost, user satisfaction. Practical implementation and production patterns.
What Is A/B Testing for Prompts?
A/B testing is the method for determining, with statistical certainty, which of two prompt versions produces better results.
Without A/B testing:
"Prompt B looks better" → Deploy → 50% chance of making production worse
With A/B testing:
Prompt B has 94.2% accuracy vs 91.8% for A (p=0.02, significant improvement) → Deploy with confidence
When to Run an A/B Test
| Situation | A/B Testing | Alternative |
|---|---|---|
| Major prompt change | Yes | N/A |
| New model (gpt-4o-mini → gpt-4o) | Yes | N/A |
| Few-shot vs zero-shot | Yes | N/A |
| Typo fix in a prompt | No | Regression test |
| Reordering the instructions | Yes (it can matter) | N/A |
| New task with no baseline | No | Create the baseline first |
Essential Statistical Concepts
The A/B Test Hypothesis
H₀ (Null): There is no difference between prompt A and prompt B
H₁ (Alternative): Prompt B is better than prompt A
To reject H₀ we need:
- p-value < 0.05 (95% confidence)
- A sufficient effect size (a real improvement, not a trivial one)
- An adequate sample size to detect the expected improvement
Types of Error
| Error | Description | Consequence |
|---|---|---|
| Type I (α) | You declare B better when it isn't (false positive) | Deploying a prompt that doesn't improve anything |
| Type II (β) | You miss that B is better when it actually is (false negative) | Not deploying a real improvement |
| Power | 1-β = probability of detecting a real improvement | You want ≥ 80% |
Sample Size Calculation
Sample size is the most ignored part of A/B testing — and it's what invalidates most tests.
from scipy import stats
import math
def calculate_sample_size(
baseline_rate: float,
min_expected_improvement: float,
alpha: float = 0.05,
power: float = 0.80
) -> dict:
"""
Calculates the sample size needed per variant.
baseline_rate: Current proportion (e.g. 0.91 = 91% accuracy)
min_expected_improvement: The improvement you want to detect (e.g. 0.03 = 3%)
alpha: Significance level (typical: 0.05)
power: Test power (typical: 0.80)
Returns: n per variant
"""
p1 = baseline_rate
p2 = baseline_rate + min_expected_improvement
# Z-scores
z_alpha = stats.norm.ppf(1 - alpha / 2) # Two-tailed
z_beta = stats.norm.ppf(power)
# Pooled proportion
p_pool = (p1 + p2) / 2
# Sample size formula for proportions
numerator = (z_alpha * math.sqrt(2 * p_pool * (1 - p_pool)) +
z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
denominator = (p2 - p1) ** 2
n = math.ceil(numerator / denominator)
return {
"n_per_variant": n,
"total_n": n * 2,
"baseline": f"{p1:.1%}",
"target": f"{p2:.1%}",
"improvement_to_detect": f"{min_expected_improvement:.1%}",
"alpha": alpha,
"power": power
}
# Examples:
print("Detect a 3% improvement:")
r = calculate_sample_size(baseline_rate=0.91, min_expected_improvement=0.03)
print(f" n per variant: {r['n_per_variant']}")
print(f" total n: {r['total_n']}")
print("\nDetect a 1% improvement:")
r = calculate_sample_size(baseline_rate=0.91, min_expected_improvement=0.01)
print(f" n per variant: {r['n_per_variant']}")
# Typical output:
# Detect a 3% improvement: n=~350 per variant (700 total)
# Detect a 1% improvement: n=~3000 per variant (6000 total)
Implementing the A/B Test
import random
import time
from openai import OpenAI
from scipy import stats
from dataclasses import dataclass, field
from typing import Callable
client = OpenAI()
@dataclass
class ABTestResult:
"""Full result of an A/B test."""
prompt_a_name: str
prompt_b_name: str
n_a: int
n_b: int
# Accuracy metrics
scores_a: list[float] = field(default_factory=list)
scores_b: list[float] = field(default_factory=list)
# Performance metrics
latencies_a: list[float] = field(default_factory=list)
latencies_b: list[float] = field(default_factory=list)
# Cost metrics
tokens_a: list[int] = field(default_factory=list)
tokens_b: list[int] = field(default_factory=list)
@property
def mean_a(self) -> float:
return sum(self.scores_a) / len(self.scores_a) if self.scores_a else 0.0
@property
def mean_b(self) -> float:
return sum(self.scores_b) / len(self.scores_b) if self.scores_b else 0.0
@property
def relative_improvement(self) -> float:
if self.mean_a == 0:
return 0.0
return (self.mean_b - self.mean_a) / self.mean_a
def statistical_test(self) -> dict:
"""Applies a t-test to determine statistical significance."""
if len(self.scores_a) < 2 or len(self.scores_b) < 2:
return {"error": "Not enough data for a statistical test"}
t_stat, p_value = stats.ttest_ind(self.scores_a, self.scores_b)
return {
"t_statistic": t_stat,
"p_value": p_value,
"significant": p_value < 0.05,
"confidence": f"{(1 - p_value) * 100:.1f}%"
}
def winner(self) -> str:
"""Determines the winner based on statistical significance."""
test = self.statistical_test()
if "error" in test:
return "INCONCLUSIVE"
if not test["significant"]:
return "TIE (not significant)"
return "B" if self.mean_b > self.mean_a else "A"
def summary(self) -> dict:
"""Full summary of the A/B test."""
test_stats = self.statistical_test()
return {
"winner": self.winner(),
"mean_a": self.mean_a,
"mean_b": self.mean_b,
"absolute_improvement": self.mean_b - self.mean_a,
"relative_improvement": self.relative_improvement,
"n_a": self.n_a,
"n_b": self.n_b,
"p_value": test_stats.get("p_value"),
"significant": test_stats.get("significant"),
"latency_p50_a": sorted(self.latencies_a)[len(self.latencies_a)//2] if self.latencies_a else None,
"latency_p50_b": sorted(self.latencies_b)[len(self.latencies_b)//2] if self.latencies_b else None,
"avg_tokens_a": sum(self.tokens_a)/len(self.tokens_a) if self.tokens_a else None,
"avg_tokens_b": sum(self.tokens_b)/len(self.tokens_b) if self.tokens_b else None,
}
def ab_test_offline(
prompt_a: str,
prompt_b: str,
golden_set: list[dict],
evaluator: Callable | None = None,
n_per_variant: int | None = None
) -> ABTestResult:
"""
Runs an offline A/B test using the golden set.
evaluator: function(expected, actual) -> float (0.0-1.0)
If None, uses exact match.
n_per_variant: Maximum number of examples per variant.
If None, uses the whole golden set for both.
"""
if evaluator is None:
def evaluator(expected, actual):
return 1.0 if str(expected).strip().lower() == str(actual).strip().lower() else 0.0
# Select the sample
if n_per_variant and len(golden_set) > n_per_variant:
sample = random.sample(golden_set, min(len(golden_set), n_per_variant * 2))
sample_a = sample[:n_per_variant]
sample_b = sample[n_per_variant:]
else:
# If the golden set is small, use every example for both
sample_a = golden_set
sample_b = golden_set
result = ABTestResult(
prompt_a_name="Prompt A",
prompt_b_name="Prompt B",
n_a=len(sample_a),
n_b=len(sample_b)
)
# Evaluate Prompt A
print(f"Evaluating Prompt A ({len(sample_a)} examples)...")
for example in sample_a:
start = time.time()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt_a.format(input=example["input"])}],
temperature=0
)
latency = (time.time() - start) * 1000
output = response.choices[0].message.content.strip()
score = evaluator(example["expected_output"], output)
result.scores_a.append(score)
result.latencies_a.append(latency)
result.tokens_a.append(response.usage.total_tokens)
# Evaluate Prompt B
print(f"Evaluating Prompt B ({len(sample_b)} examples)...")
for example in sample_b:
start = time.time()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt_b.format(input=example["input"])}],
temperature=0
)
latency = (time.time() - start) * 1000
output = response.choices[0].message.content.strip()
score = evaluator(example["expected_output"], output)
result.scores_b.append(score)
result.latencies_b.append(latency)
result.tokens_b.append(response.usage.total_tokens)
return result
Multiple Metrics in A/B Testing
In production, you rarely decide based on a single metric. You need a multi-metric framework:
def ab_test_multimetric(
prompt_a: str,
prompt_b: str,
golden_set: list[dict],
metrics: list[str] = None
) -> dict:
"""
A/B test evaluating multiple dimensions at the same time.
metrics: List of metrics to evaluate.
Options: "accuracy", "faithfulness", "format", "latency", "cost"
"""
if metrics is None:
metrics = ["accuracy", "format", "latency", "cost"]
results_a = {m: [] for m in metrics}
results_b = {m: [] for m in metrics}
for example in golden_set:
for prompt, results in [(prompt_a, results_a), (prompt_b, results_b)]:
start = time.time()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt.format(input=example["input"])}],
temperature=0
)
latency_ms = (time.time() - start) * 1000
output = response.choices[0].message.content.strip()
# Compute each metric
if "accuracy" in metrics:
is_correct = output.lower() == str(example["expected_output"]).lower()
results["accuracy"].append(1.0 if is_correct else 0.0)
if "format" in metrics:
import json
try:
json.loads(output)
format_ok = 1.0
except:
format_ok = 0.0 if example.get("requires_json") else 1.0
results["format"].append(format_ok)
if "latency" in metrics:
results["latency"].append(latency_ms)
if "cost" in metrics:
# GPT-4o-mini: $0.15/1M input + $0.60/1M output
cost = (response.usage.prompt_tokens * 0.15 / 1e6 +
response.usage.completion_tokens * 0.60 / 1e6)
results["cost"].append(cost)
# Consolidate the results per metric
comparison = {}
for metric in metrics:
vals_a = results_a[metric]
vals_b = results_b[metric]
mean_a = sum(vals_a) / len(vals_a)
mean_b = sum(vals_b) / len(vals_b)
# Statistical test
if len(vals_a) >= 2:
_, p_value = stats.ttest_ind(vals_a, vals_b)
else:
p_value = 1.0
# For latency and cost, "lower is better"; for the rest, "higher is better"
if metric in ["latency", "cost"]:
winner = "B" if mean_b < mean_a else "A"
else:
winner = "B" if mean_b > mean_a else "A"
comparison[metric] = {
"mean_a": mean_a,
"mean_b": mean_b,
"delta": mean_b - mean_a,
"p_value": p_value,
"significant": p_value < 0.05,
"winner": winner if p_value < 0.05 else "TIE"
}
# Determine the overall winner
wins_a = sum(1 for m in comparison.values() if m["winner"] == "A")
wins_b = sum(1 for m in comparison.values() if m["winner"] == "B")
if wins_b > wins_a:
overall_winner = "B"
elif wins_a > wins_b:
overall_winner = "A"
else:
overall_winner = "TIE"
return {
"winner": overall_winner,
"metrics": comparison,
"wins": {"A": wins_a, "B": wins_b}
}
A/B Testing in Production (Online)
Offline A/B testing is the most practical option for prompts, but in production you can also run online A/B testing:
import random
from datetime import datetime
class ABRouter:
"""
Router that splits traffic between two versions of a prompt.
Use it for A/B testing in production (online).
"""
def __init__(
self,
prompt_a: str,
prompt_b: str,
split: float = 0.5,
experiment_id: str = "exp_001"
):
self.prompt_a = prompt_a
self.prompt_b = prompt_b
self.split = split
self.experiment_id = experiment_id
# Result storage
self.results: list[dict] = []
def get_variant(self, user_id: str | None = None) -> tuple[str, str]:
"""
Determines which variant to use.
If user_id is provided, it hashes for consistency (same user = same variant).
Returns: (prompt, variant_name)
"""
if user_id:
# Deterministic hashing: the same user always sees the same variant
import hashlib
hash_val = int(hashlib.md5(f"{self.experiment_id}:{user_id}".encode()).hexdigest(), 16)
use_b = (hash_val % 100) < (self.split * 100)
else:
use_b = random.random() < self.split
if use_b:
return self.prompt_b, "B"
else:
return self.prompt_a, "A"
def log_result(
self,
variant: str,
input_text: str,
output: str,
expected: str | None = None,
user_feedback: float | None = None
) -> None:
"""Records the result of a call for later analysis."""
self.results.append({
"timestamp": datetime.now().isoformat(),
"variant": variant,
"input": input_text[:100],
"output": output[:200],
"correct": None if expected is None else (output.strip().lower() == str(expected).strip().lower()),
"user_feedback": user_feedback
})
def analyze(self) -> dict:
"""Analyzes the accumulated results."""
from collections import defaultdict
by_variant = defaultdict(list)
for r in self.results:
if r["correct"] is not None:
by_variant[r["variant"]].append(r["correct"])
if not by_variant:
return {"error": "No results to analyze"}
stats_by_variant = {}
for variant, correct in by_variant.items():
stats_by_variant[variant] = {
"n": len(correct),
"accuracy": sum(correct) / len(correct)
}
# Statistical test if there's data from both variants
summary = {"variants": stats_by_variant}
if "A" in by_variant and "B" in by_variant:
_, p_value = stats.ttest_ind(by_variant["A"], by_variant["B"])
summary["p_value"] = p_value
summary["significant"] = p_value < 0.05
acc_a = stats_by_variant["A"]["accuracy"]
acc_b = stats_by_variant["B"]["accuracy"]
summary["winner"] = "B" if acc_b > acc_a and p_value < 0.05 else ("A" if acc_a > acc_b and p_value < 0.05 else "TIE")
return summary
def should_stop(self, max_n: int = 1000) -> bool:
"""Checks whether the test should stop (enough N reached)."""
total_n = len(self.results)
return total_n >= max_n
# Usage in a FastAPI API:
"""
router = ABRouter(prompt_a=PROMPT_V1, prompt_b=PROMPT_V2, split=0.5)
@app.post("/classify")
async def classify(request: ClassifyRequest):
prompt, variant = router.get_variant(user_id=request.user_id)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt.format(input=request.text)}],
temperature=0
)
output = response.choices[0].message.content
router.log_result(variant, request.text, output)
return {"result": output, "variant": variant}
"""
Interpreting the Results
def interpret_ab_test(result: dict) -> str:
"""
Generates a natural-language interpretation of the A/B test.
Useful for reporting to the team or in pull requests.
"""
winner = result.get("winner", "INCONCLUSIVE")
mean_a = result.get("mean_a", 0)
mean_b = result.get("mean_b", 0)
p_value = result.get("p_value")
n_a = result.get("n_a", 0)
n_b = result.get("n_b", 0)
if winner == "TIE (not significant)":
interpretation = f"""
## A/B Test Result
**Conclusion: There is no statistically significant difference**
- Prompt A accuracy: {mean_a:.2%} (n={n_a})
- Prompt B accuracy: {mean_b:.2%} (n={n_b})
- p-value: {p_value:.3f} (> 0.05 — not significant)
**Recommendation:** Keep Prompt A. Prompt B doesn't show enough improvement.
If you expect a larger improvement, consider growing the golden set.
"""
elif winner == "B":
delta = mean_b - mean_a
interpretation = f"""
## A/B Test Result
**Conclusion: Prompt B is BETTER ✅**
- Prompt A accuracy: {mean_a:.2%} (n={n_a})
- Prompt B accuracy: {mean_b:.2%} (n={n_b})
- Improvement: {delta:+.2%} ({delta/mean_a*100:+.1f}% relative)
- p-value: {p_value:.3f} (< 0.05 — significant)
**Recommendation:** Ship Prompt B. The improvement is statistically significant.
"""
elif winner == "A":
delta = mean_b - mean_a
interpretation = f"""
## A/B Test Result
**Conclusion: Prompt A is BETTER (Prompt B regressed) ⚠️**
- Prompt A accuracy: {mean_a:.2%} (n={n_a})
- Prompt B accuracy: {mean_b:.2%} (n={n_b})
- Change: {delta:+.2%}
- p-value: {p_value:.3f}
**Recommendation:** Don't deploy Prompt B. Review the changes you made.
"""
else:
interpretation = "## A/B Test Result\n**Inconclusive** — Not enough data."
return interpretation.strip()
# Full usage example:
def full_ab_test_example():
from openai import OpenAI
client = OpenAI()
# Minimal golden set for the demo
golden_set = [
{"id": "1", "input": "I love this product", "expected_output": "POSITIVE"},
{"id": "2", "input": "Terrible, completely useless", "expected_output": "NEGATIVE"},
{"id": "3", "input": "The package arrived today", "expected_output": "NEUTRAL"},
# ... more examples in real production
]
PROMPT_A = "Classify as POSITIVE, NEGATIVE or NEUTRAL: {input}. Only the category."
PROMPT_B = """You are an expert sentiment classifier.
Classify the following text as POSITIVE, NEGATIVE, or NEUTRAL.
Consider the overall sentiment, not just individual words.
Answer ONLY with the category.
Text: {input}
Category:"""
result = ab_test_offline(PROMPT_A, PROMPT_B, golden_set)
summary = result.summary()
print(interpret_ab_test(summary))
return result
Common Mistakes in A/B Testing
Mistake 1: Peeking (Looking Too Early)
Problem: You run the test with 50 examples, see that B is winning,
then stop the test and declare B the winner.
Why it's bad: With a small N, random variation can be large.
You declare a winner when there isn't enough evidence yet.
Solution: Define N before the test and DON'T look until you reach it.
def test_with_fixed_n(prompt_a, prompt_b, golden_set, min_n=200):
"""
Test that doesn't allow 'peeking' — it only reports once min_n is reached.
"""
if len(golden_set) < min_n:
raise ValueError(
f"The golden set has {len(golden_set)} examples, "
f"it needs at least {min_n} for this test. "
f"Add more examples or lower your min_expected_improvement."
)
# Run with a fixed N
result = ab_test_offline(prompt_a, prompt_b, golden_set, n_per_variant=min_n)
return result
Mistake 2: Multiple Comparisons
Problem: You test prompts A, B, C, D, E... against the baseline.
With 5 comparisons, the probability of a false positive goes up.
Bonferroni correction:
corrected_alpha = 0.05 / n_comparisons
def bonferroni_correction(
results: list[dict],
initial_alpha: float = 0.05
) -> list[dict]:
"""Applies the Bonferroni correction for multiple comparisons."""
n = len(results)
corrected_alpha = initial_alpha / n
for r in results:
if "p_value" in r:
r["corrected_significant"] = r["p_value"] < corrected_alpha
r["corrected_alpha"] = corrected_alpha
return results
Mistake 3: Sample Contamination
Problem: You use the same examples to evaluate A and B,
and the results correlate artificially.
Solution: For small golden sets (< 200 examples),
using the same examples is acceptable but mention it.
For large golden sets, split them randomly.
Troubleshooting
Problem 1: Non-significant result even though you expected an improvement
Symptom: p-value = 0.34, you can't conclude anything.
Most common cause: The golden set is too small.
Solution:
# Calculate how many more you need
r = calculate_sample_size(baseline_rate=0.91, min_expected_improvement=0.03)
print(f"You need {r['n_per_variant']} per variant")
# If you have 50 and need 350, the test is invalid — don't draw conclusions
Problem 2: A wins when you expected B
Symptom: The "improved" prompt B has lower accuracy.
Cause: The change introduced an unexpected regression.
Action:
# 1. Look at the cases where B failed but A didn't
def analyze_b_failures(prompt_a, prompt_b, golden_set):
b_failures_a_passes = []
for ex in golden_set:
out_a = run_prompt(prompt_a, ex["input"])[0]
out_b = run_prompt(prompt_b, ex["input"])[0]
correct_a = out_a.lower() == str(ex["expected_output"]).lower()
correct_b = out_b.lower() == str(ex["expected_output"]).lower()
if correct_a and not correct_b:
b_failures_a_passes.append({
"id": ex["id"],
"input": ex["input"],
"expected": ex["expected_output"],
"output_a": out_a,
"output_b": out_b
})
return b_failures_a_passes
# Look for patterns in the failures → understand what prompt B broke
Problem 3: A tie across metrics (B wins accuracy, A wins cost)
Symptom: B has better accuracy (+3%) but also costs more (+20%).
Action: Decide according to business priorities:
def decide_winner_with_weights(
metric_comparison: dict,
weights: dict[str, float]
) -> str:
"""
Determines the winner taking multiple weighted metrics into account.
weights: {"accuracy": 0.6, "cost": 0.3, "latency": 0.1}
"""
assert abs(sum(weights.values()) - 1.0) < 0.001
score_a = 0.0
score_b = 0.0
for metric, weight in weights.items():
if metric not in metric_comparison:
continue
m = metric_comparison[metric]
if not m.get("significant"):
# A tie, it doesn't count for either side
continue
winner = m.get("winner")
if winner == "A":
score_a += weight
elif winner == "B":
score_b += weight
if score_b > score_a:
return f"B (weighted score: {score_b:.2f} vs {score_a:.2f})"
elif score_a > score_b:
return f"A (weighted score: {score_a:.2f} vs {score_b:.2f})"
else:
return f"TIE ({score_a:.2f} each)"
# Example:
# accuracy is crucial, cost matters, latency is secondary
weights = {"accuracy": 0.6, "cost": 0.3, "latency": 0.1}
winner = decide_winner_with_weights(metric_comparison, weights)
Exercises
Exercise 1: Calculate the sample size for your case
For a classifier with a current accuracy of 88%, calculate how many examples you need to detect a 5% improvement with 80% power and 95% confidence.
See solution
from scipy import stats
import math
def calculate_sample_size(baseline_rate, improvement, alpha=0.05, power=0.80):
p1 = baseline_rate
p2 = baseline_rate + improvement
z_alpha = stats.norm.ppf(1 - alpha / 2)
z_beta = stats.norm.ppf(power)
p_pool = (p1 + p2) / 2
n = math.ceil(
(z_alpha * math.sqrt(2 * p_pool * (1 - p_pool)) +
z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
/ (p2 - p1) ** 2
)
return n
n = calculate_sample_size(baseline_rate=0.88, improvement=0.05)
print(f"You need {n} examples PER variant ({n*2} total)")
# Typical result: ~248 per variant (496 total)
# For cost context:
cost_per_request = 0.001 # $0.001 per example (approximate with gpt-4o-mini)
total_cost = n * 2 * cost_per_request
print(f"Estimated test cost: ${total_cost:.2f}")
Exercise 2: A/B test with interpretation
Write the full code to compare two versions of a summarization prompt and report the winner with statistical justification.
See solution
from openai import OpenAI
from scipy import stats
client = OpenAI()
PROMPT_A = "Summarize in 1 sentence: {input}"
PROMPT_B = """Summarize the following text in exactly one sentence.
Include the most important point.
Use no more than 30 words.
Text: {input}
Summary:"""
texts = [
"The central bank announced today a 0.25-point hike in interest rates, the third in a row this year, aiming to fight inflation that reached 8.2% last quarter.",
"Apple unveiled the new iPhone 17 with significant camera improvements, including a 200-megapixel sensor and 8K recording, available next month with prices starting at $999.",
]
answers_a = []
answers_b = []
for text in texts:
resp_a = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": PROMPT_A.format(input=text)}],
temperature=0
).choices[0].message.content.strip()
resp_b = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": PROMPT_B.format(input=text)}],
temperature=0
).choices[0].message.content.strip()
answers_a.append(resp_a)
answers_b.append(resp_b)
print(f"A: {resp_a[:80]}...")
print(f"B: {resp_b[:80]}...")
print()
# For evaluation: measure length as a proxy (shorter = more concise)
lengths_a = [len(r.split()) for r in answers_a]
lengths_b = [len(r.split()) for r in answers_b]
print(f"Average words A: {sum(lengths_a)/len(lengths_a):.1f}")
print(f"Average words B: {sum(lengths_b)/len(lengths_b):.1f}")
print("(Demo with a small n — in production use a golden set with N>=200)")
Exercise 3: Spot the peeking problem
Explain with code why the following A/B test has a peeking problem and how to fix it:
# Code with the problem
for i, example in enumerate(golden_set):
# evaluate A and B...
if i == 20: # We check with 20 examples
if mean_b > mean_a + 0.05:
print("B wins! Deploy.")
break
See solution
# The problem: with 20 examples, the p-value is usually > 0.05
# A 5% difference can be pure statistical noise
# By stopping early, the "winner" can be a false positive
# THE CORRECT SOLUTION:
from scipy import stats
def correct_ab_test(prompt_a, prompt_b, golden_set, min_n=200):
"""Test without peeking — a fixed N decided BEFORE the test."""
# 1. Calculate the required N BEFORE starting
required_n = calculate_sample_size(baseline_rate=0.90, improvement=0.03)
if len(golden_set) < required_n:
raise ValueError(
f"Insufficient golden set: it has {len(golden_set)}, it needs {required_n}. "
"Add more examples or accept lower precision."
)
# 2. Run ALL the examples without looking at intermediate results
scores_a, scores_b = [], []
import random
sample = random.sample(golden_set, min(required_n * 2, len(golden_set)))
for ex in sample[:required_n]: # Prompt A
out = run_prompt(prompt_a, ex["input"])[0]
scores_a.append(1.0 if out.lower() == str(ex["expected_output"]).lower() else 0.0)
for ex in sample[required_n:2*required_n]: # Prompt B
out = run_prompt(prompt_b, ex["input"])[0]
scores_b.append(1.0 if out.lower() == str(ex["expected_output"]).lower() else 0.0)
# 3. Evaluate ONLY ONCE, at the end
mean_a = sum(scores_a) / len(scores_a)
mean_b = sum(scores_b) / len(scores_b)
_, p_value = stats.ttest_ind(scores_a, scores_b)
winner = "B" if mean_b > mean_a and p_value < 0.05 else ("A" if mean_a > mean_b and p_value < 0.05 else "TIE")
print(f"A: {mean_a:.2%}, B: {mean_b:.2%}, p={p_value:.3f}, Winner: {winner}")
return winner
Summary
- A/B testing: The rigorous method for comparing two prompt versions with statistical certainty
- Sample size: Calculate it BEFORE the test — with an insufficient N, the results are invalid
- p-value < 0.05: The standard threshold for statistical significance
- Effect size: Not just "is it significant" but "how much does it improve?"
- Multiple metrics: Accuracy + latency + cost — decide with weights based on priorities
- Peeking: Don't stop the test before reaching the minimum N
- Online A/B: ABRouter for testing in production with real users
Additional resources
- Evan Miller A/B Test Calculator — Sample size calculator
- scipy.stats — Statistical tests in Python
- A/B Testing Statistics Guide — Comprehensive guide
- Stats for A/B Testing — Common mistakes
- Statsmodels — Advanced statistics library for Python