LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnML Algorithms & EvaluationExperiment Design and A/B Testing
📊MediumEvaluation & Benchmarks

Experiment Design and A/B Testing

Design a trustworthy online experiment for an incident-assistant change: randomize incidents, measure useful outcomes, quantify uncertainty, and reject false wins.

36 min read
Learning path
Step 43 of 196 in the full curriculum
Decoding AlgorithmsCausal Inference and Counterfactuals

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

An on-call engineer stares at a dashboard showing a critical payment service at 7% error rate. The service owner acknowledged the page two minutes ago. The incident assistant suggests an immediate canary rollback in clean prose. That reply can still cite the wrong runbook, take ten seconds to answer, or send the engineer to a human page five minutes later. Should the team ship a query rewrite?

In Decoding Algorithms, you controlled how retrieved runbook passages turned into an answer and logged the decoding policy. Here we focus on an incident assistant and ask a product question: does rewriting its retrieval query help engineers resolve outages? Keep generation and model weights fixed so the experiment isolates that retrieval change.

An A/B test, also called a randomized controlled experiment, assigns incoming incidents randomly to either the existing system (control) or the new system (treatment) and compares their outcomes. A retrieval update earns a production rollout only when rigorous measurement proves it helps without breaking on-call operations.

That measurement supports one of three clear decisions: ship the rewrite, keep investigating, or roll it back.

Incident question hashed by incident id into a sticky arm: control sends the raw query, treatment sends a rewritten retrieval query, and both arms share the same pinned greedy decoder before logging resolution and guardrails.
Only the retrieval query changes. Control sends the raw incident question; treatment rewrites it. Both arms share the pinned greedy decoder from the previous chapter, then log resolution and guardrails.

Test one change at a time

Start with the incoming request that both arms must answer:

The canary is at 7% errors and the owner acked. Can we roll it back?

Your existing pipeline retrieves runbook passages directly from the raw question and generates an answer. The control path keeps that exact behavior.

A candidate treatment first rewrites the request into a retrieval-focused query: canary rollback error-rate policy owner acknowledgement. It can't invent a rollback threshold that wasn't in the request or trusted context. It then sends the retrieved evidence through the same prompt, model, and decoder settings as control. Any measured difference belongs squarely to the rewrite policy, including its latency overhead and network errors.

ComponentControlTreatmentWhy it matters
Query sent to retrieverOriginal incident messageRewritten retrieval queryThis is the one intentional change.
Runbook index snapshotrunbooks-2026-05-01runbooks-2026-05-01A different index would mix two changes together.
Generator and promptSame pinned versionSame pinned versionGeneration changes can't masquerade as retrieval gains.
Decoder receiptselection=greedy, same output schemaselection=greedy, same output schemaDecoder policy doesn't differ by arm.

Write a hypothesis that can fail:

Query rewriting will increase the resolved-incident rate while keeping grounded-answer audit failures, p95 latency, and human pages inside their budgets.

Here, p95 latency represents the 95th percentile of first-request completion times, recorded once per enrolled incident. It protects the slower tail rather than hiding it inside an average. Predeclare how timeouts enter the metric; silently dropping unfinished requests could make a failing treatment look faster.

Lock the decision rules before results tempt you to change them:

The brief specifies an MDE, or minimum detectable effect: the smallest lift the planned enrollment can detect with target statistical power. Keep that planning target separate from the later launch threshold.

Brief fieldLocked choice
Enrollment momentFirst submitted question on an eligible incident
Randomization unitIncident ID
Primary metricResolved incident rate
Resolved definitionUsable-answer mark by 10 minutes after enrollment, with no human page through that deadline
DenominatorAll enrolled incidents, analyzed in their assigned arm, after the outcome window matures
Guardrail: evidenceUpper end of a two-sided approximate 95% interval for grounding-failure increase must stay below +0.5 percentage points
Guardrail: speedp95 latency must not increase by more than 150 ms
Guardrail: operationsHuman-page rate must not increase by more than 0.5 percentage points
MDE for power planning+2.0 percentage points in resolution rate
Launch rule for resolutionApproximate 95% interval lower bound above zero and observed lift of at least +2.0 percentage points
Analysis ruleOne fixed-horizon look after planned enrollment and all 10-minute windows mature

The grounding rule asks whether the data rule out a regression as large as its budget. That's a non-inferiority question, which we'll calculate near the end. Speed and human pages remain point-estimate screens in this compact example, not statistical assurances of safety. A deployable brief needs uncertainty rules for those guardrails too; p95 uncertainty requires request-level data, not just the two reported percentiles.

Random assignment supports causal conclusions only when assignment, instrumentation, and stopping rules stay trustworthy. Practical online experiment systems make this pre-launch contract explicit.[1]Reference 1Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing.https://experimentguide.com/

Assign an incident once

Why assign by incident rather than by message? An engineer might ask a follow-up after seeing the first answer. If the first message uses treatment and the follow-up uses control, the second outcome depends partly on the first answer. The unit has received both policies, so the contrast is no longer clean.

In statistical causal inference, this principle is formalized as the Stable Unit Treatment Value Assumption (SUTVA). SUTVA requires two conditions:

  1. No interference between units: Unit iii's outcome depends only on unit iii's assigned treatment, unaffected by the assignment of unit jjj.
  2. No hidden variations of treatment: The treatment policy assigned to unit iii is identical regardless of how unit iii received it.

When you randomize individual chat messages instead of the entire incident, SUTVA breaks immediately. The LLM's multi-turn conversation memory carries context across turns, exposing a single incident to a mixture of both variants.

SUTVA can fail across incidents through two main paths:

  • Behavioral spillover: An on-call engineer sees a treatment recommendation that clarifies a tricky canary rollback procedure, and then uses that learned knowledge 20 minutes later on a control incident.
  • Shared compute contention: If the treatment's query rewriter makes extra LLM inference calls or triggers heavy vector database scans, it can exhaust shared GPU worker pools, increasing latency for control incidents hosted on the same infrastructure.

When incidents cluster naturally inside engineering teams, randomize at the cluster level. Intracluster correlation inflates outcome variance by the design effect:

DEFF=1+(m−1)ρICC\text{DEFF} = 1 + (m - 1)\rho_{\text{ICC}}DEFF=1+(m−1)ρICC​

Here mmm represents average cluster size and ρICC\rho_{\text{ICC}}ρICC​ is the intracluster correlation coefficient. More chat messages within one team can't replace independently randomized teams.[2]Reference 2Why Tenant-Randomized A/B Test is Challenging and Tenant-Pairing May Not Workhttps://www.microsoft.com/en-us/research/articles/why-tenant-randomized-a-b-test-is-challenging-and-tenant-pairing-may-not-work/

A stable hash gives each enrolled incident the same arm on every request. Include an experiment name and version in the salt so a later experiment can get a fresh assignment. Lock the salt before outcomes arrive, keep incident IDs immutable, and don't let callers choose IDs to obtain a preferred arm. Hashing approximates random allocation here; it doesn't force exactly equal arm sizes.

stable-assignment.py
1import hashlib 2 3EXPERIMENT = "incident-query-rewrite:v1" 4 5def arm_for(incident_id: str) -> str: 6 payload = f"{EXPERIMENT}:{incident_id}".encode() 7 bucket = int.from_bytes(hashlib.sha256(payload).digest()[:8], "big") % 100 8 return "treatment" if bucket < 50 else "control" 9 10incidents = ["inc-014", "inc-014", "inc-001", "inc-021"] 11for incident in incidents: 12 print(incident, arm_for(incident)) 13 14assert arm_for("inc-014") == arm_for("inc-014") 15print("repeat assignment is stable")
Stable incident assignment
1inc-014 control 2inc-014 control 3inc-001 treatment 4inc-021 treatment 5repeat assignment is stable

If the same incident appears twice in the output, the assigned arm must match twice. In a service, persist the experiment version and arm on each event as well. Hashing makes the choice deterministic; logging makes that choice auditable.

Why should one incident keep the same experiment assignment across all follow-ups?

Answer

Switching variants between messages contaminates the treatment contrast and gives one incident's later behavior exposure to both policies. Assign the stable experimental unit once.

Define an outcome from events

The assistant shouldn't get credit merely because it produced an answer. Resolution requires a usable-answer mark by ten minutes after enrollment and no human page through that deadline. A missing mark counts as unresolved under this definition; broken telemetry is a measurement problem to investigate, not evidence of success.

Keep incidents whose treatment rewrite timed out in the treatment denominator. This intention-to-treat (ITT) comparison estimates the effect of assignment to the rewrite policy, failures included. Comparing only successful rewrites would select incidents using a treatment-affected event and answer a different, biased question. If 5% of treatment rewrites fail with timeouts, dropping them creates survivor bias by retaining only easier queries.

Wait until every enrolled incident has its full follow-up window. A nine-minute-old incident with no page isn't yet a ten-minute success. The rows below are already matured incident summaries, not individual chat messages; solved means a usable mark arrived within the locked window.

The next lab turns raw product events into that metric. It also records two guardrails: whether an audit says the answer is grounded in the retrieved runbook, and the response latency in milliseconds. Read the event rows before looking at the aggregate counts: which page timing should disqualify a usable answer?

define-resolution.py
1incident_outcomes = [ 2 {"arm": "control", "solved": True, "escalation_after_min": None, "grounded": True, "latency_ms": 1080}, 3 {"arm": "control", "solved": True, "escalation_after_min": 4, "grounded": True, "latency_ms": 1200}, 4 {"arm": "control", "solved": False, "escalation_after_min": 2, "grounded": False, "latency_ms": 980}, 5 {"arm": "treatment", "solved": True, "escalation_after_min": None, "grounded": True, "latency_ms": 1120}, 6 {"arm": "treatment", "solved": True, "escalation_after_min": 18, "grounded": True, "latency_ms": 1240}, 7 {"arm": "treatment", "solved": True, "escalation_after_min": 6, "grounded": True, "latency_ms": 1280}, 8] 9# Each fixture incident has already been observed for 20 minutes. 10incident_outcomes = [{**row, "observed_minutes": 20} for row in incident_outcomes] 11 12def resolved(row: dict) -> bool | None: 13 if row["observed_minutes"] < 10: 14 return None # Pending is not an unresolved outcome. 15 escalation_too_soon = row["escalation_after_min"] is not None and row["escalation_after_min"] <= 10 16 return row["solved"] and not escalation_too_soon 17 18for arm in ("control", "treatment"): 19 rows = [row for row in incident_outcomes if row["arm"] == arm] 20 assert all(resolved(row) is not None for row in rows) 21 successes = sum(resolved(row) for row in rows) 22 grounded = sum(row["grounded"] for row in rows) 23 print(f"{arm}: resolved={successes}/{len(rows)} grounded={grounded}/{len(rows)}") 24 25assert resolved({**incident_outcomes[0], "observed_minutes": 9}) is None 26assert not resolved({**incident_outcomes[0], "escalation_after_min": 10}) 27print("pending window is not scored; a page at exactly 10 minutes disqualifies")
Outcome definition on event rows
1control: resolved=1/3 grounded=2/3 2treatment: resolved=2/3 grounded=3/3 3pending window is not scored; a page at exactly 10 minutes disqualifies

The small rows teach the definition, not the launch result. The treatment row with escalation_after_min=18 still counts as resolved because its page arrives outside the locked ten-minute window. That makes the metric contract inspectable and deterministic.

If later pages matter, choose a longer window or track them separately before launch. The metric also needs enough incidents to distinguish an actual lift from chance variation. The next step is to turn these event-level decisions into an effect estimate.

Diagram showing Enrolled incident keep assigned arm, Wait for full 10-minute window, Usable mark by deadline?, and Resolved = 0.
Enrolled incident keep assigned arm, Wait for full 10-minute window, Usable mark by deadline?, and Resolved = 0.

Read lift from counts

After the planned window, start with a compact teaching fixture of 2,000 incidents per arm. The counts are small enough to check by hand. Before reading the table, predict which arm has the higher resolution rate and what could still make that apparent win misleading.

This fixture isn't powered for the locked MDE of +2.0 percentage points. The planning lab later shows you need about 9,246 incidents per arm at 80% power for that MDE.

Keep the roles separate: use the 2,000-row tables to practice lift, intervals, and launch gates; use the powered enrollment estimate when you design a real test for the brief.

VariantIncidentsResolvedResolution ratep95 latencyHuman pagesGrounded audit failures
Control: raw query2,00076038.0%1180 ms31022
Treatment: rewritten query2,00084042.0%1260 ms30623

Before subtracting the rates, name the target quantity. The estimand is the average effect of assignment to the rewrite policy on resolution among eligible incidents, with each incident weighted once. It includes rewrite failures and follow-up behavior. Random assignment lets us estimate that target by subtracting the two assigned-arm rates, under the no-interference and measurement assumptions already stated.

For the grounding counts below, assume every enrolled incident's answer receives the same blinded audit. If only a sample is audited, use the audited denominator and a predeclared sampling design rather than dividing audit failures by all incidents.

The absolute lift is the treatment rate minus the control rate. It answers a practical question: how many additional incidents resolve per 100 enrolled incidents?

Δ^=p^treatment−p^control=8402000−7602000=0.04\widehat{\Delta} = \hat{p}_{treatment} - \hat{p}_{control} = \frac{840}{2000} - \frac{760}{2000} = 0.04Δ=p^​treatment​−p^​control​=2000840​−2000760​=0.04

The treatment resolves 4.0 additional incidents per 100 enrolled incidents. That's a 4.0 percentage point lift.

The relative lift compares that absolute change with the original baseline. It answers a different question: how large is the change compared with the control rate?

0.040.38≈0.105\frac{0.04}{0.38} \approx 0.1050.380.04​≈0.105

That's a 10.5 percent relative lift. Name which one you report; saying "up 10.5 points" would be wrong because points describe the absolute difference here.

compute-lift.py
1control_resolved, control_n = 760, 2000 2treatment_resolved, treatment_n = 840, 2000 3 4p_control = control_resolved / control_n 5p_treatment = treatment_resolved / treatment_n 6absolute_lift = p_treatment - p_control 7relative_lift = absolute_lift / p_control 8 9print(f"control rate: {p_control:.1%}") 10print(f"treatment rate: {p_treatment:.1%}") 11print(f"absolute lift: {absolute_lift * 100:.1f} percentage points") 12print(f"relative lift: {relative_lift:.1%}")
Absolute and relative lift
1control rate: 38.0% 2treatment rate: 42.0% 3absolute lift: 4.0 percentage points 4relative lift: 10.5%

Treatment resolves 840 of 2,000 incidents and control resolves 760 of 2,000. What's the clearest user-facing interpretation of absolute lift?

Answer

The rewrite resolved 4.0 additional incidents per 100 enrolled incidents: 42.0% instead of 38.0%.

Put uncertainty around the lift

If you reran the experiment with different incidents, the counts wouldn't be identical. The observed lift is one draw from a larger process, so it needs a measure of how much it could move.

In Hypothesis Tests, Intervals, and pass@k you put a confidence interval around a paired lift. Here the two arms contain independent incidents, so the standard error adds their two Bernoulli variances. For reasonably large binary-outcome arms, a normal-approximation interval is a useful first calculation.

For each arm, p^\hat{p}p^​ is its observed resolution rate and nnn is its number of enrolled incidents. The estimated standard error of the difference is:

SE(Δ^)=p^control(1−p^control)ncontrol+p^treatment(1−p^treatment)ntreatmentSE(\widehat{\Delta}) = \sqrt{ \frac{\hat{p}_{control}(1 - \hat{p}_{control})}{n_{control}} + \frac{\hat{p}_{treatment}(1 - \hat{p}_{treatment})}{n_{treatment}} }SE(Δ)=ncontrol​p^​control​(1−p^​control​)​+ntreatment​p^​treatment​(1−p^​treatment​)​​

A rough 95 percent interval is Δ^±1.96×SE\widehat{\Delta} \pm 1.96 \times SEΔ±1.96×SE. The number 1.96 is the standard-normal cutoff that leaves about 2.5 percent in each tail.

Across repeated experiments satisfying these assumptions, this procedure aims to cover the true effect about 95% of the time. The particular interval we calculate isn't a 95% probability statement about a fixed true effect, and it doesn't cover a different population automatically.

resolution-lift-interval.py
1from math import sqrt 2 3control_resolved, control_n = 760, 2000 4treatment_resolved, treatment_n = 840, 2000 5 6p_control = control_resolved / control_n 7p_treatment = treatment_resolved / treatment_n 8diff = p_treatment - p_control 9se = sqrt( 10 p_control * (1 - p_control) / control_n 11 + p_treatment * (1 - p_treatment) / treatment_n 12) 13low = diff - 1.96 * se 14high = diff + 1.96 * se 15 16print(f"estimated lift: {diff * 100:.1f} pp") 17print(f"standard error: {se * 100:.2f} pp") 18print(f"approximate 95% interval: [{low * 100:.1f}, {high * 100:.1f}] pp")
Approximate lift interval
1estimated lift: 4.0 pp 2standard error: 1.55 pp 3approximate 95% interval: [1.0, 7.0] pp

The estimate is +4.0 points and this approximation gives an interval of about +1.0 to +7.0 points. Because the lower bound is above zero, this planned analysis gives evidence for a positive treatment effect, assuming assignment and instrumentation are sound.

It doesn't prove every future rollout will gain four points. The interval answers how much the estimate could vary; it still leaves the planning question open: was 2,000 per arm enough to detect the +2.0 point win you said you cared about?

For rare outcomes, heavily clustered incidents, many simultaneous comparisons, or business-critical launches, choose the inference method with a statistician or a mature experimentation platform before running the test.

More rows aren't always more evidence

Suppose a logging join copies every incident outcome onto four message rows. Both arm rates stay unchanged, so the lift still looks right. But treating those copies as independent observations divides the estimated standard error by two. No new incidents were enrolled; the extra precision is fictional.

check-analysis-unit.py
1from math import sqrt 2 3p_c, p_t, n_incidents = 0.38, 0.42, 2000 4lift = p_t - p_c 5for copies in (1, 4): 6 assumed_n = n_incidents * copies 7 se = sqrt((p_c * (1 - p_c) + p_t * (1 - p_t)) / assumed_n) 8 low, high = lift - 1.96 * se, lift + 1.96 * se 9 label = "unique incidents" if copies == 1 else "duplicated message rows (WRONG)" 10 print(f"{label}: SE={se * 100:.2f} pp, CI=[{low * 100:.1f}, {high * 100:.1f}] pp")
Duplicate rows create false precision
1unique incidents: SE=1.55 pp, CI=[1.0, 7.0] pp 2duplicated message rows (WRONG): SE=0.77 pp, CI=[2.5, 5.5] pp

Duplicating rows changes the denominator in the formula, not the amount of independent evidence. Aggregate once per randomized incident before computing this interval.

Real within-team correlation is usually less extreme than identical copies, but the same independence issue applies. If teams are randomized, use a team-aware estimator and uncertainty calculation, such as cluster-robust inference or resampling whole teams. Choosing a cluster method doesn't itself fix interference between clusters. The assignment unit, estimand, and analysis must agree.[2]Reference 2Why Tenant-Randomized A/B Test is Challenging and Tenant-Pairing May Not Workhttps://www.microsoft.com/en-us/research/articles/why-tenant-randomized-a-b-test-is-challenging-and-tenant-pairing-may-not-work/

Plan for a meaningful win

A test with too little traffic may return "uncertain" even when the treatment helps. Before launching, declare a minimum detectable effect (MDE): a true effect size at which you want a specified probability of rejecting the no-effect null. Smaller effects can still be detected, and an effect at the MDE can still be missed.

Power planning balances three competing parameters:

  1. False positive rate (Type I error, α\alphaα): The budget for false alarms when there's truly zero effect. Standard practice sets α=0.05\alpha = 0.05α=0.05 two-sided (z1−α/2≈1.96z_{1-\alpha/2} \approx 1.96z1−α/2​≈1.96).
  2. False negative rate (Type II error, β\betaβ): The risk of missing a real effect of size MDE. Standard practice sets β=0.20\beta = 0.20β=0.20.
  3. Statistical power (1−β1 - \beta1−β): The probability of successfully rejecting the null hypothesis when the true lift equals the MDE. Standard practice targets 80% (z1−β≈0.8416z_{1-\beta} \approx 0.8416z1−β​≈0.8416).

Hold the observed rates at 38 and 42 percent, then watch what sample size does to the interval:

interval-width-by-traffic.py
1from math import sqrt 2 3p_control = 0.38 4p_treatment = 0.42 5lift = p_treatment - p_control 6 7for n in (200, 500, 2000): 8 se = sqrt(p_control * (1 - p_control) / n + p_treatment * (1 - p_treatment) / n) 9 low = lift - 1.96 * se 10 high = lift + 1.96 * se 11 print(f"{n:>4} per arm: [{low * 100:>4.1f}, {high * 100:>4.1f}] pp")
Traffic narrows uncertainty
1200 per arm: [-5.6, 13.6] pp 2 500 per arm: [-2.1, 10.1] pp 32000 per arm: [ 1.0, 7.0] pp

With 200 or 500 incidents per arm, a good-looking +4.0 point estimate is still compatible with no improvement. At 2,000 per arm, this observed +4.0 pp effect's interval finally excludes zero in the teaching fixture.

That isn't the same as being powered to detect the locked design MDE of +2.0 pp, which needs about 9,246 incidents per arm below. More enrollment narrows uncertainty; it doesn't make treatment better by itself.

The sample size required per arm follows an inverse square relationship with the effect size δ\deltaδ:

n≈2σ2(z1−α/2+z1−β)2δ2n \approx \frac{2\sigma^2(z_{1-\alpha/2} + z_{1-\beta})^2}{\delta^2}n≈δ22σ2(z1−α/2​+z1−β​)2​

For binary proportions near baseline rate ppp, outcome variance is σ2=p(1−p)\sigma^2 = p(1 - p)σ2=p(1−p):

n≈2p(1−p)(z1−α/2+zpower)2δ2n \approx \frac{2p(1-p)(z_{1-\alpha/2} + z_{power})^2}{\delta^2}n≈δ22p(1−p)(z1−α/2​+zpower​)2​

Notice the δ2\delta^2δ2 in the denominator: halving the MDE from +2.0 pp to +1.0 pp quadruples the required sample size.

plan-sample-size.py
1from math import ceil 2from statistics import NormalDist 3 4baseline_rate = 0.38 5mde = 0.02 6alpha = 0.05 7target_power = 0.80 8 9normal = NormalDist() 10z_alpha = normal.inv_cdf(1 - alpha / 2) 11z_power = normal.inv_cdf(target_power) 12n_per_arm = ceil( 13 2 * baseline_rate * (1 - baseline_rate) * (z_alpha + z_power) ** 2 / mde**2 14) 15 16print(f"MDE: {mde * 100:.1f} pp") 17print(f"target power: {target_power:.0%}") 18print(f"planning estimate: {n_per_arm:,} incidents per arm")
Planning enrollment from MDE
1MDE: 2.0 pp 2target power: 80% 3planning estimate: 9,246 incidents per arm

This formula is for planning, not post-result storytelling. Actual platform planning may account for unequal allocation, repeated incidents, variance reduction, multiple metrics, or sequential analysis.

For comparison, the fuller independent-proportions calculation at baseline 0.38, true lift 0.02, equal allocation, and two-sided alpha 0.05 gives about 79.6% power at 9,246 per arm and rounds up to 9,336 per arm for 80%. That small difference explains why the shortcut is useful for sizing the project but shouldn't be mistaken for the final analysis specification.[3]Reference 3power_proportions_2indephttps://www.statsmodels.org/stable/generated/statsmodels.stats.proportion.power_proportions_2indep.html

An 80% power target isn't an 80% chance of satisfying the whole launch gate. If the true lift is exactly +2.0 points, a roughly symmetric estimate falls above the +2.0-point observed-lift threshold only about half the time. Requiring guardrails to pass can reduce launch probability further. Power the actual decision you intend to make, not just its easiest condition.

Use the estimate to decide how much traffic to enroll before the first outcome arrives. Predeclare a minimum calendar duration too when weekdays, on-call rotations, or novelty effects matter. Hitting the sample count during one unusual shift doesn't establish performance across those conditions.

None of that math is trustworthy if the two arms didn't actually receive the traffic you planned.

Check traffic before outcomes

A sample ratio mismatch (SRM) means observed assignment counts disagree suspiciously with the intended split. If the design says 50/50 but your data has 2,350 treatment incidents and 1,650 control incidents, pause before reading resolution lift. Assignment code, event logging, eligibility filters, or bot removal may differ by arm.

This lab computes a chi-square diagnostic for a planned 50/50 allocation. A one-degree-of-freedom value above 10.83 is a deliberately strict alert threshold corresponding to a very small tail probability, about 0.1 percent.

The 0.001 threshold is this lab's choice, not a universal standard. Microsoft's documented platform uses an even stricter 0.0005 threshold. Such a check diagnoses allocation or data-quality problems; it isn't a test of whether treatment helps.[4]Reference 4Diagnosing Sample Ratio Mismatch in A/B Testinghttps://www.microsoft.com/en-us/research/articles/diagnosing-sample-ratio-mismatch-in-a-b-testing/

check-assignment-split.py
1observed = {"control": 1650, "treatment": 2350} 2expected_each = sum(observed.values()) / 2 3 4chi_square = sum( 5 (count - expected_each) ** 2 / expected_each 6 for count in observed.values() 7) 8alert_threshold = 10.83 9 10print("planned split: 50% / 50%") 11print(f"observed: control={observed['control']}, treatment={observed['treatment']}") 12print(f"chi-square statistic: {chi_square:.1f}") 13print("pause analysis for SRM investigation:", chi_square > alert_threshold)
Sample ratio integrity check
1planned split: 50% / 50% 2observed: control=1650, treatment=2350 3chi-square statistic: 122.5 4pause analysis for SRM investigation: True

An SRM alert doesn't identify the bug. It says your comparison hasn't earned trust yet. Stop and diagnose the event path before making a lift claim.

If the first look at lift looks unbelievable, treat instrumentation as the leading hypothesis until you can replay the events.[1]Reference 1Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing.https://experimentguide.com/

Check distinct enrolled incident IDs, not message counts. Treatment could legitimately change the number of follow-ups, so expecting a 50/50 split of messages would test the wrong denominator. A clean SRM result is necessary evidence about traffic, not proof that every outcome field is correct.

Don't stop the first time noise looks good

Suppose treatment truly has no effect: both arms resolve 40 percent of incidents. One planned two-sided 5% test should falsely detect a difference about 5% of the time. That includes apparent benefits and apparent harms. It isn't a 5% rate of positive wins alone. Predict what happens when the same cutoff is checked 20 times and the first crossing ends the test.

Inspecting the ordinary interval after every batch gives noise many chances to cross the threshold. By the Law of the Iterated Logarithm, the cumulative path of zero-effect Brownian noise will cross any fixed significance boundary if observed indefinitely. The stopping policy, not the treatment, changes this false-alarm rate.

This A/A simulation, with two identical arms, uses 5,000 repeat experiments, 20 looks, and a fixed seed. Each look adds 100 new incidents per arm by drawing a binomial count, which is the same as 100 independent Bernoulli trials at 40%.

It tests this exact stopping policy, not a universal percentage for every experiment design.

simulate-peeking.py
1import random 2 3rng = random.Random(11) 4trials = 5_000 5looks = 20 6batch_size = 100 7true_rate = 0.40 8 9planned_alarms = 0 10peek_alarms = 0 11 12for _ in range(trials): 13 control_successes = 0 14 treatment_successes = 0 15 crossed_early = False 16 17 for look in range(1, looks + 1): 18 control_successes += rng.binomialvariate(batch_size, true_rate) 19 treatment_successes += rng.binomialvariate(batch_size, true_rate) 20 n = look * batch_size 21 p_control = control_successes / n 22 p_treatment = treatment_successes / n 23 se = (p_control * (1 - p_control) / n + p_treatment * (1 - p_treatment) / n) ** 0.5 24 significant = se > 0 and abs(p_treatment - p_control) / se > 1.96 25 crossed_early = crossed_early or significant 26 if look == looks: 27 planned_alarms += significant 28 29 peek_alarms += crossed_early 30 31print(f"planned final look, either direction: {planned_alarms / trials:.1%}") 32print(f"stop at first crossing, either direction: {peek_alarms / trials:.1%}")
Peeking simulation
1planned final look, either direction: 4.6% 2stop at first crossing, either direction: 24.9%
Seeded simulation of 5,000 zero-effect A/A tests: one planned look yields a 4.6% two-sided false-alarm rate; 5 batch looks inflate it to 14.2%; 10 batch looks reach 19.3%; and checking after each of 20 batches with early stopping inflates false alarms to 24.9%. Both apparent benefits and harms count.
Both arms have the same true resolution rate. Checking fixed-horizon confidence intervals after every batch and stopping on the first crossing inflates the false-alarm rate from 4.6% to 24.9% across 20 looks.

Ordinary fixed-horizon intervals aren't valid for a decision triggered by continuous monitoring. Repeated observation changes the statistical procedure.

Repeated looks aren't the only way to multiply chances. Trying many variants, metrics, or subgroups and reporting whichever interval excludes zero also needs multiplicity control. Predeclare the family of claims and a method such as Bonferroni, or label new subgroup discoveries exploratory and confirm them with fresh data.[5]Reference 5NIST/SEMATECH e-Handbook: Bonferroni's Methodhttps://www.itl.nist.gov/div898/handbook/prc/section4/prc473.htm

Why can't a fixed-horizon experiment stop the first time an ordinary 95% interval excludes zero?

Answer

Repeated unplanned looks give random noise more chances to cross the threshold. Use the planned final look or a sequential method designed for repeated monitoring.

Sequential testing and continuous monitoring

Product teams want dashboards that update in real time and allow early stopping when a treatment is winning convincingly or hurting users. Two principled mathematical frameworks make this possible without inflating false alarms:

Alpha spending functions

Group sequential designs allocate the total false-alarm budget α\alphaα across intermediate looks using an alpha spending function α(t)\alpha(t)α(t), where t=n/Nmaxt = n / N_{\text{max}}t=n/Nmax​ represents the information fraction (0≤t≤10 \le t \le 10≤t≤1).

Lan and DeMets introduced flexible spending functions that preserve the overall Type I error regardless of exact look timings. A popular choice is the O'Brien-Fleming spending boundary:

α(t)≈2−2Φ(zα/2t)\alpha(t) \approx 2 - 2\Phi\left(\frac{z_{\alpha/2}}{\sqrt{t}}\right)α(t)≈2−2Φ(t​zα/2​​)

Early in the test (small ttt), the boundary requires extreme evidence (for example, ∣z∣>4.0|z| > 4.0∣z∣>4.0 or p<0.0001p < 0.0001p<0.0001) to stop early. As the test approaches the maximum planned sample size (t→1t \to 1t→1), the required critical value relaxes toward the standard 1.96 cutoff. This preserves almost the entire nominal power for the final analysis while providing an emergency brake against catastrophic treatment failures.

Mixture sequential probability ratio tests (mSPRT)

When teams demand true continuous monitoring on every event, the mixture Sequential Probability Ratio Test (mSPRT) provides always-valid p-values and confidence sequences.[6]Reference 6Peeking at A/B Tests: Why it matters, and what to do about it.https://doi.org/10.1145/3097983.3097992

Under mSPRT, you construct a test statistic Λn\Lambda_nΛn​ by integrating the likelihood ratio across a mixing distribution H(θ)H(\theta)H(θ) over possible effect sizes:

Λn=∫∏i=1nfθ(Yi)f0(Yi)dH(θ)\Lambda_n = \int \prod_{i=1}^n \frac{f_\theta(Y_i)}{f_0(Y_i)} dH(\theta)Λn​=∫i=1∏n​f0​(Yi​)fθ​(Yi​)​dH(θ)

Under the null hypothesis H0H_0H0​, Λn\Lambda_nΛn​ is a non-negative martingale with expectation equal to 1 for all nnn. By Ville's inequality:

P(sup⁡n≥1Λn≥1α)≤α\mathbb{P}\left(\sup_{n \ge 1} \Lambda_n \ge \frac{1}{\alpha}\right) \le \alphaP(n≥1sup​Λn​≥α1​)≤α

Because Ville's inequality holds uniformly across all sample sizes nnn, the resulting confidence sequences never expire. Product managers can inspect the dashboard at any hour and stop the test whenever the always-valid interval excludes zero, with a mathematical guarantee that false alarms stay capped at α\alphaα.

Reduce noise with pre-experiment data

Power doesn't always require more incidents. CUPED (Controlled-experiment Using Pre-Experiment Data) uses a predictive measurement collected before treatment. For a new incident, that could be a frozen summary of its service's resolution history from before the experiment started. It can't be a prior-period outcome of the same not-yet-created incident.

That signal can remove predictable unit-to-unit variation from the outcome.

Let YYY be the experiment outcome and XXX be a pre-experiment covariate. CUPED forms an adjusted metric:

Yadjusted=Y−θ(X−Xˉ)Y_{adjusted} = Y - \theta(X - \bar{X})Yadjusted​=Y−θ(X−Xˉ)

Because treatment assignment is randomized, the expected value of XXX is identical in both arms: E[Xtreatment−Xcontrol]=0\mathbb{E}[X_{treatment} - X_{control}] = 0E[Xtreatment​−Xcontrol​]=0. Subtracting θ(X−Xˉ)\theta(X - \bar{X})θ(X−Xˉ) leaves the expected treatment difference completely unchanged:

E[Yadj,treatment−Yadj,control]=E[Ytreatment−Ycontrol]\mathbb{E}[Y_{adj, treatment} - Y_{adj, control}] = \mathbb{E}[Y_{treatment} - Y_{control}]E[Yadj,treatment​−Yadj,control​]=E[Ytreatment​−Ycontrol​]

Now compute the variance of the adjusted metric:

Var⁡(Yadjusted)=Var⁡(Y)+θ2Var⁡(X)−2θCov⁡(Y,X)\operatorname{Var}(Y_{adjusted}) = \operatorname{Var}(Y) + \theta^2 \operatorname{Var}(X) - 2\theta \operatorname{Cov}(Y, X)Var(Yadjusted​)=Var(Y)+θ2Var(X)−2θCov(Y,X)

Differentiating with respect to θ\thetaθ and setting to zero yields the optimal coefficient:

θ∗=Cov⁡(Y,X)Var⁡(X)\theta^* = \frac{\operatorname{Cov}(Y, X)}{\operatorname{Var}(X)}θ∗=Var(X)Cov(Y,X)​

Plugging θ∗\theta^*θ∗ back into the variance equation produces the variance reduction formula:

Var⁡(Yadjusted)=Var⁡(Y)(1−Cov⁡(Y,X)2Var⁡(Y)Var⁡(X))=Var⁡(Y)(1−ρ2)\operatorname{Var}(Y_{adjusted}) = \operatorname{Var}(Y) \left(1 - \frac{\operatorname{Cov}(Y, X)^2}{\operatorname{Var}(Y)\operatorname{Var}(X)}\right) = \operatorname{Var}(Y)(1 - \rho^2)Var(Yadjusted​)=Var(Y)(1−Var(Y)Var(X)Cov(Y,X)2​)=Var(Y)(1−ρ2)

Here ρ\rhoρ is the Pearson correlation between XXX and YYY. Under optimal adjustment, outcome variance is multiplied by 1−ρ21-\rho^21−ρ2.

Deng, Xu, Kohavi, and Walker introduced this approach and reported about 50% variance reduction on Bing experiments, enough to cut runtime or traffic roughly in half. Using the same metric from a one-week pre-period reduced queries-per-user variance by more than 45%.[7]Reference 7Improving the Sensitivity of Online Controlled Experiments by Utilizing Pre-Experiment Data (CUPED)https://exp-platform.com/Documents/2013-02-CUPED-ImprovingSensitivityOfControlledExperiments.pdf

This simulation gives each independent unit a historical flag available before treatment, then assigns treatment independently. Treat the flag as a simplified precomputed feature, not an earlier response to the experiment. Predict which estimate should have the smaller standard error.

cuped-for-resolution.py
1import random 2from statistics import covariance, fmean, variance 3 4rng = random.Random(27) 5n = 20_000 6arm = [rng.randrange(2) for _ in range(n)] 7resolved_before = [rng.binomialvariate(1, 0.45) for _ in range(n)] 8resolved_now = [ 9 rng.binomialvariate(1, min(1.0, max(0.0, 0.18 + 0.46 * prior + 0.04 * assigned))) 10 for prior, assigned in zip(resolved_before, arm) 11] 12theta = covariance(resolved_now, resolved_before) / variance(resolved_before) 13mean_before = fmean(resolved_before) 14adjusted = [y - theta * (x - mean_before) for y, x in zip(resolved_now, resolved_before)] 15 16def lift_and_se(outcome: list[float]) -> tuple[float, float]: 17 control = [value for value, assigned in zip(outcome, arm) if assigned == 0] 18 treatment = [value for value, assigned in zip(outcome, arm) if assigned == 1] 19 lift = fmean(treatment) - fmean(control) 20 se = (variance(control) / len(control) + variance(treatment) / len(treatment)) ** 0.5 21 return lift, se 22 23raw_lift, raw_se = lift_and_se(resolved_now) 24cuped_lift, cuped_se = lift_and_se(adjusted) 25variance_reduction = 1 - variance(adjusted) / variance(resolved_now) 26 27print(f"raw estimate: lift={raw_lift * 100:.2f} pp, se={raw_se * 100:.2f} pp") 28print(f"CUPED estimate: lift={cuped_lift * 100:.2f} pp, se={cuped_se * 100:.2f} pp") 29print(f"outcome variance reduced: {variance_reduction:.1%}")
CUPED variance reduction
1raw estimate: lift=4.68 pp, se=0.70 pp 2CUPED estimate: lift=4.39 pp, se=0.61 pp 3outcome variance reduced: 22.1%
CUPED variance reduction on 20,000 incidents: raw resolution lift is +4.68 pp with standard error 0.70 pp (interval +3.31 to +6.05 pp); CUPED-adjusted lift is +4.39 pp with standard error 0.61 pp (interval +3.20 to +5.58 pp). Outcome variance is reduced by 22.1 percent via pre-experiment service history correlation of 0.47, equivalent to a 28.4 percent boost in effective traffic.
CUPED strips predictable baseline variation using pre-experiment service history. Residual variance shrinks by factor 1 − ρ², narrowing the confidence interval from ±1.37 pp to ±1.19 pp without collecting additional traffic.

In a finite sample, the raw and adjusted estimates won't be numerically identical. The code estimates one common coefficient from pooled experiment data and uses a large-sample plug-in standard error. Predeclare the adjustment; for richer fitted models, use an appropriate regression-adjusted or cross-fitted estimator and standard errors rather than searching for the adjustment with the biggest lift.

Adjusted outcomes can fall outside zero and one. That's acceptable: they're numerical variance-reduction scores, not new Bernoulli labels. Report the adjusted lift and its uncertainty, and check whether the standard error actually improves.

Never adjust for a quantity treatment could affect, such as latency measured after the rewrite is enabled. Conditioning on that post-treatment value creates collider bias or blocks the causal pathway, wrecking the validity of your test.

Bandits versus fixed A/B tests

When should you run a traditional fixed-horizon A/B test, and when should you deploy a multi-armed bandit algorithm?

This choice centers on the classic exploration versus exploitation tradeoff:

  • Fixed A/B testing: Enforces equal 50/50 allocation throughout the entire experiment. It prioritizes pure exploration during the trial to maximize statistical power for causal parameter estimation and guardrail verification. Once the launch gate passes, the winning variant receives 100% of traffic (pure exploitation).
  • Multi-armed bandits: Adaptively shift traffic toward whichever variant currently looks best, minimizing cumulative regret R(T)=∑t=1T(μ∗−μat)R(T) = \sum_{t=1}^T (\mu^* - \mu_{a_t})R(T)=∑t=1T​(μ∗−μat​​) while the experiment runs.[8]Reference 8A Tutorial on Thompson Samplinghttps://arxiv.org/abs/1707.02038

In Thompson Sampling, each arm maintains a Bayesian posterior over its success probability. For binary resolution, a Beta-Bernoulli conjugate model samples θk∼Beta⁡(αk,βk)\theta_k \sim \operatorname{Beta}(\alpha_k, \beta_k)θk​∼Beta(αk​,βk​) on each request and routes to the arm with the highest draw.

simulate-bandit.py
1import random 2 3rng = random.Random(42) 4p_control = 0.38 5p_treatment = 0.42 6 7alpha_c, beta_c = 1, 1 8alpha_t, beta_t = 1, 1 9n_pulls = 2000 10 11for _ in range(n_pulls): 12 sample_c = rng.betavariate(alpha_c, beta_c) 13 sample_t = rng.betavariate(alpha_t, beta_t) 14 if sample_t > sample_c: 15 reward = 1 if rng.random() < p_treatment else 0 16 alpha_t += reward 17 beta_t += 1 - reward 18 else: 19 reward = 1 if rng.random() < p_control else 0 20 alpha_c += reward 21 beta_c += 1 - reward 22 23pulls_c = alpha_c + beta_c - 2 24pulls_t = alpha_t + beta_t - 2 25mean_c = (alpha_c - 1) / pulls_c 26mean_t = (alpha_t - 1) / pulls_t 27 28print(f"control pulls: {pulls_c} ({pulls_c / n_pulls:.1%}), sample mean: {mean_c:.1%}") 29print(f"treatment pulls: {pulls_t} ({pulls_t / n_pulls:.1%}), sample mean: {mean_t:.1%}") 30print(f"allocation ratio: {pulls_t / pulls_c:.2f}x")
Thompson sampling allocation
1control pulls: 463 (23.2%), sample mean: 38.2% 2treatment pulls: 1537 (76.8%), sample mean: 41.7% 3allocation ratio: 3.32x

The bandit routed 76.8% of incidents to the winning treatment arm, resolving more outages during the test. That efficiency carries an inference penalty:

  1. Adaptive sampling bias: Arms that get lucky early receive more traffic, while arms that encounter early bad luck are starved. The sample mean of the winning variant is biased upward.
  2. Invalid classical inference: Standard two-sample t-tests and confidence intervals collapse because observations aren't independent and identically distributed.
  3. Guardrail blindness: Bandits optimize a single scalar reward. If treatment improves resolution by 4% but quietly triples p95 latency or inflates hallucinations, an unconstrained bandit keeps routing more traffic to the slower, hallucinating model.

Deploy bandits for short-lived opportunities where immediate opportunity cost dominates and long-term scientific learning isn't needed: headline testing, holiday marketing campaigns, or transient routing between external API providers. Use fixed A/B tests for core algorithm changes, model migrations, and architectural updates where guardrails and unconfounded causal conclusions are required.

Interleave pairs to slash prompt noise

In conversational AI and incident management, prompts vary wildly in difficulty. Some incidents describe simple permission lockouts; others describe complex distributed deadlocks. In a standard between-subject A/B test (incident A gets Control, incident B gets Treatment), this prompt difficulty variance adds directly to the standard error of the lift.

Interleaved testing solves this problem by evaluating both models on the exact same input.[9]Reference 9Large-scale Validation and Analysis of Interleaved Search Evaluation.https://doi.org/10.1145/2094072.2094078 In search ranking, interleaving blends the candidate lists into a single combined presentation. In LLM evaluation, both models generate candidate answers for the same prompt, and an engineer or automated evaluator judges them head-to-head (as popularized in Chatbot Arena).

By turning an independent two-sample test into a paired comparison, the shared prompt variance cancels out:

Var⁡(YT−YC)=Var⁡(YT)+Var⁡(YC)−2Cov⁡(YT,YC)\operatorname{Var}(Y_T - Y_C) = \operatorname{Var}(Y_T) + \operatorname{Var}(Y_C) - 2\operatorname{Cov}(Y_T, Y_C)Var(YT​−YC​)=Var(YT​)+Var(YC​)−2Cov(YT​,YC​)

When both models evaluate the same prompt difficulty, their scores are strongly positively correlated. The covariance term 2Cov⁡(YT,YC)2\operatorname{Cov}(Y_T, Y_C)2Cov(YT​,YC​) subtracts out prompt difficulty noise, drastically shrinking the standard error.

compare-interleaving.py
1import random 2from statistics import fmean, variance 3 4rng = random.Random(42) 5n_evals = 1000 6 7# Base difficulty of the query on a 1-10 quality scale 8query_difficulty = [rng.gauss(5.0, 1.8) for _ in range(n_evals)] 9queries_c = [rng.gauss(5.0, 1.8) for _ in range(n_evals)] 10queries_t = [rng.gauss(5.0, 1.8) for _ in range(n_evals)] 11 12# Independent A/B test (two distinct sets of queries) 13scores_c_indep = [q + rng.gauss(0, 0.6) for q in queries_c] 14scores_t_indep = [q + 0.45 + rng.gauss(0, 0.6) for q in queries_t] 15lift_indep = fmean(scores_t_indep) - fmean(scores_c_indep) 16se_indep = (variance(scores_c_indep) / n_evals + variance(scores_t_indep) / n_evals) ** 0.5 17 18# Interleaved paired test (both models evaluated on the same queries) 19scores_c_paired = [q + rng.gauss(0, 0.6) for q in query_difficulty] 20scores_t_paired = [q + 0.45 + rng.gauss(0, 0.6) for q in query_difficulty] 21diffs = [t - c for t, c in zip(scores_t_paired, scores_c_paired)] 22lift_paired = fmean(diffs) 23se_paired = (variance(diffs) / n_evals) ** 0.5 24var_reduction = 1 - (se_paired / se_indep) ** 2 25 26print(f"independent A/B: lift={lift_indep:.3f}, SE={se_indep:.3f}") 27print(f"interleaved test: lift={lift_paired:.3f}, SE={se_paired:.3f}") 28print(f"variance reduction: {var_reduction:.1%}")
Interleaved evaluation variance reduction
1independent A/B: lift=0.570, SE=0.085 2interleaved test: lift=0.443, SE=0.027 3variance reduction: 89.5%

Pairing on the same query slashed outcome variance by 89.5%, shrinking standard error from 0.085 down to 0.027. Chapelle et al. demonstrated that interleaved search evaluation can be 100 to 1,000 times more sensitive than traditional A/B testing.[9]Reference 9Large-scale Validation and Analysis of Interleaved Search Evaluation.https://doi.org/10.1145/2094072.2094078

Treat AI evaluation as a stack

An online experiment answers whether the on-call benefits under live use. It shouldn't be the first time you discover that a treatment emits unsupported rollback claims. Check the treatment in layers, each with a different question:

LayerQuestionExample evidence
Offline regression setDoes treatment still follow the runbook on known cases?Hand-labeled rollback prompts, groundedness and refusal checks
Online primary outcomeDoes it help engineers complete the incident task?Resolution rate
Product guardrailsDid it hurt speed or operations?p95 latency, human-page rate, cost
Integrity receiptDid we compare the promised systems?Assignment split, index snapshot, prompt/model/decoder versions

If you use a large language model as a judge for an offline rubric, treat it as a measured evaluator rather than truth. Pin its model and prompt version, then compare it with human labels on a retained calibration set.

Zheng et al. study LLM judges as approximations to human preference and document position bias, verbosity bias, self-enhancement bias (favoring answers the judge model itself produced), and reasoning limitations.[10]Reference 10Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena.https://arxiv.org/abs/2306.05685

This lab makes the configuration receipt explicit. A comparison with two different decoder versions is rejected before anybody reads its lift.

validate-treatment-receipt.py
1control = { 2 "index": "runbooks-2026-05-01", 3 "generator": "incident-generator-v7", 4 "decoder": {"selection": "greedy", "schema": "incident-answer-v2"}, 5} 6treatment = { 7 "index": "runbooks-2026-05-01", 8 "generator": "incident-generator-v7", 9 "decoder": {"selection": "greedy", "schema": "incident-answer-v2"}, 10 "new_component": "query-rewrite-v1", 11} 12 13locked_fields = ("index", "generator", "decoder") 14 15def validate_receipt(control, treatment): 16 mismatches = [field for field in locked_fields if control[field] != treatment[field]] 17 if mismatches: 18 raise ValueError(f"unplanned changes: {mismatches}") 19 return mismatches 20 21mismatches = validate_receipt(control, treatment) 22 23print("intentional treatment change:", treatment["new_component"]) 24print("unplanned mismatches:", mismatches) 25print("comparison is interpretable:", not mismatches) 26 27bad_treatment = {**treatment, "decoder": {"selection": "greedy", "schema": "incident-answer-v3"}} 28try: 29 validate_receipt(control, bad_treatment) 30except ValueError as error: 31 print("rejected:", error) 32else: 33 raise AssertionError("unplanned decoder change was accepted")
Pinned experiment receipt
1intentional treatment change: query-rewrite-v1 2unplanned mismatches: [] 3comparison is interpretable: True 4rejected: unplanned changes: ['decoder']

Make the launch decision auditable

Now combine the evidence. The brief requires a positive interval, an observed lift of at least +2.0 points, guardrails inside their budgets, and trustworthy traffic.

This last lab prints each condition instead of burying the call in a slide deck. Like the earlier lift tables, it reuses the compact 2,000-incident teaching fixture so you can check every line by hand.

For the locked MDE of +2.0 points, the fuller independent-arm calculation targets about 9,336 incidents per arm before accounting for other design requirements. The smaller fixture below is arithmetic practice, not permission for an unplanned early look. Predict which guardrail could keep a positive lift from shipping.

launch-decision.py
1from math import sqrt 2 3report = { 4 "control": {"n": 2000, "resolved": 760, "p95_latency_ms": 1180, "escalated": 310, "grounding_failures": 22}, 5 "treatment": {"n": 2000, "resolved": 840, "p95_latency_ms": 1260, "escalated": 306, "grounding_failures": 23}, 6 "minimum_ship_lift": 0.02, 7 "srm_alert": False, 8} 9 10if report["srm_alert"]: 11 raise ValueError("Investigate SRM before computing lift") 12 13c = report["control"] 14t = report["treatment"] 15p_c = c["resolved"] / c["n"] 16p_t = t["resolved"] / t["n"] 17lift = p_t - p_c 18se = sqrt(p_c * (1 - p_c) / c["n"] + p_t * (1 - p_t) / t["n"]) 19low = lift - 1.96 * se 20 21latency_delta = t["p95_latency_ms"] - c["p95_latency_ms"] 22escalation_delta = t["escalated"] / t["n"] - c["escalated"] / c["n"] 23grounding_delta = t["grounding_failures"] / t["n"] - c["grounding_failures"] / c["n"] 24 25grounding_control = c["grounding_failures"] / c["n"] 26grounding_treatment = t["grounding_failures"] / t["n"] 27grounding_se = sqrt( 28 grounding_control * (1 - grounding_control) / c["n"] 29 + grounding_treatment * (1 - grounding_treatment) / t["n"] 30) 31grounding_upper = grounding_delta + 1.96 * grounding_se 32 33# Evidence rule was declared in the brief, before outcomes were observed. 34checks = { 35 "traffic_integrity": not report["srm_alert"], 36 "positive_lift_interval": low > 0, 37 "observed_lift_threshold": lift >= report["minimum_ship_lift"], 38 "latency_budget": latency_delta <= 150, 39 "escalation_budget": escalation_delta <= 0.005, 40 "grounding_budget": grounding_delta <= 0.005, 41 "grounding_noninferiority": grounding_upper < 0.005, 42} 43 44for name, passed in checks.items(): 45 print(f"{name}: {'PASS' if passed else 'FAIL'}") 46print( 47 f"lift={lift * 100:.1f} pp, low_end={low * 100:.1f} pp, " 48 f"ship_threshold={report['minimum_ship_lift'] * 100:.1f} pp, latency_delta={latency_delta} ms, " 49 f"grounding_delta={grounding_delta * 100:.2f} pp, grounding_upper={grounding_upper * 100:.2f} pp" 50) 51print("teaching gate:", "PASS" if all(checks.values()) else "HOLD AND INVESTIGATE")
Launch decision report
1traffic_integrity: PASS 2positive_lift_interval: PASS 3observed_lift_threshold: PASS 4latency_budget: PASS 5escalation_budget: PASS 6grounding_budget: PASS 7grounding_noninferiority: FAIL 8lift=4.0 pp, low_end=1.0 pp, ship_threshold=2.0 pp, latency_delta=80 ms, grounding_delta=0.05 pp, grounding_upper=0.70 pp 9teaching gate: HOLD AND INVESTIGATE
Auditable launch scorecard: Resolution lift is +4.0 pp with approximate 95% interval +1.0 to +7.0 pp, clearing the +2.0 pp ship minimum. Grounding failure increase is +0.05 pp with interval -0.60 to +0.70 pp, violating the +0.50 pp non-inferiority budget. Guardrails for p95 latency (+80 ms vs +150 ms budget), human pages (-0.20 pp vs +0.50 pp budget), and traffic split (SRM chi-square = 0.0) all pass. Overall decision is HOLD AND INVESTIGATE due to unresolved grounding risk.
The resolution interval stays above zero and the point estimate clears +2.0 points, but the grounding-failure interval's upper end is +0.70 points against a +0.5-point budget. The gate therefore stays at investigate.

This gate separates evidence from business value. positive_lift_interval asks whether the interval's lower bound is above zero. observed_lift_threshold asks whether the point estimate clears the predeclared +2.0 point bar. Those checks answer different questions.

It doesn't prove the true lift exceeds +2.0 points: the interval begins at +1.0. A more conservative brief could require the lower bound to clear +2.0 points and would keep this result under investigation.

Guardrails can look safe on a point estimate while their uncertainty still crosses the budget. Our brief already declared the grounding rule: the upper endpoint must stay below +0.5 points. Using the upper end of a two-sided 95% interval corresponds to an approximate one-sided 97.5% bound, not a one-sided 95% bound. Keep that choice fixed rather than switching cutoffs after seeing the result.

For grounding failures as a rate, the unpooled SE is:

SE(Δ^g)=g^c(1−g^c)nc+g^t(1−g^t)ntSE(\widehat{\Delta}_g) = \sqrt{ \frac{\hat{g}_{c}(1 - \hat{g}_{c})}{n_{c}} + \frac{\hat{g}_{t}(1 - \hat{g}_{t})}{n_{t}} }SE(Δg​)=nc​g^​c​(1−g^​c​)​+nt​g^​t​(1−g^​t​)​​

With the teaching fixture, grounding rates are 1.10% for control and 1.15% for treatment, so Δ^g=+0.05\widehat{\Delta}_g = +0.05Δg​=+0.05 pp. The unpooled SE is about 0.333 pp, which puts the rough 95% interval at about [−0.60,+0.70][-0.60, +0.70][−0.60,+0.70] pp. Its upper end exceeds the +0.5 pp budget, so grounding_noninferiority fails even though the observed change is small.

For these teaching numbers, the lift and point-estimate checks pass, but the grounding rule keeps the decision at HOLD AND INVESTIGATE. Failing to establish non-inferiority doesn't prove that grounding became worse: the interval includes no change too. We lack enough precision to rule out the unacceptable regression.

The rare-event grounding interval is only a normal approximation with 22 and 23 failures. A real launch should use a preselected method suitable for those counts and audit sampling. Even a passing teaching gate wouldn't resolve the omitted latency and human-page uncertainty rules.

Change treatment latency to 1490 and rerun: resolution still improves, but the speed budget also fails. A treatment doesn't earn launch by winning only its favorite metric.

Practice: break the launch gate

Change one field at a time and predict the gate before rerunning it. These are counterfactual exercises, not permission to edit a live experiment's brief after seeing results:

  1. Change treatment latency from 1260 to 1490. Which check fails, and why doesn't the resolution lift override it?
  2. Restore latency, then change minimum_ship_lift from 0.02 to 0.05. Which check fails even though the interval still excludes zero?
  3. Restore the threshold, then set srm_alert to True. Why should analysis pause before anybody argues about the observed lift?

Expected observations

  1. latency_budget fails because treatment is now 310 ms slower than control, beyond the locked 150 ms budget.
  2. observed_lift_threshold fails because the observed +4.0 point lift is below the new +5.0 point business threshold. Statistical evidence and launch value answer different questions.
  3. The code raises ValueError: Investigate SRM before computing lift. SRM can signal broken assignment, filtering, or logging, so the outcome calculation doesn't run. It isn't merely a red badge added after displaying an untrustworthy lift.

Freeze the experiment receipt

Save the assignment unit and hash rule, metric query and outcome window, planned analysis schedule, sample-ratio-mismatch check, effect estimate with uncertainty, guardrails, and final decision. Those artifacts let a reviewer rerun the gate without reconstructing policy from a dashboard. A trustworthy experiment ends as a reproducible decision record, not a screenshot of a favorable number.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.The intended treatment is query rewriting before retrieval. The receipt shows the same runbook index and generator in both arms, but control uses decoder schema incident-answer-v2 and treatment uses incident-answer-v3. What should the team conclude before reading lift?

Correct answer: The comparison isn't interpretable as a query-rewrite effect because an unplanned decoder change differs by arm.

The experiment changes only query rewriting. If decoder schema also differs by arm, any resolution change could be due to the rewrite, the decoder change, or their interaction. The receipt should reject that comparison before the lift is interpreted.

2.An eligible incident receives a first question and a follow-up five minutes later. Which assignment implementation prevents that incident from receiving both experiment arms?

Correct answer: Hash the experiment version and incident ID, then persist that arm on every experiment event.

Follow-up outcomes can depend on the first answer, so independently randomizing messages would mix experiences. A stable hash of the versioned experiment key and incident identity keeps the incident in one arm, while event persistence makes the assignment auditable.

3.Resolution requires a usable-answer mark and no human page within 10 minutes. Four incidents have these results: A is marked usable with no page, B is marked usable with a page after 18 minutes, C is marked usable with a page after 6 minutes, and D isn't marked usable with no page. What's the resolution rate?

Correct answer: 50%, because incidents A and B meet the locked definition.

A qualifies because it's marked usable and has no page. B also qualifies because its page arrives outside the locked ten-minute window. C is disqualified by the page at six minutes, and D is disqualified because the engineer didn't mark the answer usable.

4.A join copies every incident outcome onto four message rows. Resolution rates stay at 38% and 42%. What happens if the analysis treats those copies as independent incidents?

Correct answer: The lift stays at 4.0 points, but the calculated standard error is wrongly halved.

Duplicating successes and denominators equally preserves both rates. Substituting four times the sample size into the independence formula divides SE by sqrt(4), or two, without adding evidence. Aggregate once per randomized incident.

5.At the planned final look, an experiment reports an absolute lift of +4.0 percentage points with a standard error of 1.55 points. Using lift plus or minus 1.96 times the standard error, which interpretation is correct?

Correct answer: The interval is about +1.0 to +7.0 points, supporting a positive effect if assignment and measurement are sound.

The margin is 1.96 x 1.55, or about 3.0 points, giving 4.0 plus or minus 3.0. The lower bound is above zero, which is evidence of a positive effect under trustworthy execution. It neither guarantees future performance nor proves that the true effect exceeds +2.0 points.

6.A team keeps baseline rate, alpha, and target power the same but lowers the MDE for planning from 2.0 percentage points to 1.0 percentage point. What changes in the plan?

Correct answer: Halving MDE from 2.0 to 1.0 points requires about 4x enrollment per arm; decision rules are unchanged.

In the planning approximation, enrollment is proportional to 1 divided by delta squared. Halving the MDE from 2.0 pp to 1.0 pp therefore requires about four times as many incidents per arm. The lesson also separates MDE for power planning from the business launch threshold.

7.A test planned a 50/50 split but records 1,650 control incidents and 2,350 treatment incidents. Its chi-square statistic is 122.5, far above the predeclared SRM alert threshold of 10.83. What should the team do next?

Correct answer: Pause outcome analysis and diagnose assignment, filtering, eligibility, or event logging before making a claim.

An SRM alert doesn't identify the bug or establish treatment preference. It warns that the observed allocation is inconsistent with the planned randomization, so the comparison hasn't earned trust until the event and assignment paths are understood.

8.A team planned one fixed-horizon analysis, but after launch it checks an ordinary 95% interval after every 100-incident batch and stops the first time the interval excludes zero. What's the problem?

Correct answer: Repeated checks give noise more chances to cross, so the original false-alarm guarantee no longer applies.

A fixed-horizon interval is calibrated for the planned final look. Repeated checks and stopping at the first crossing give noise many chances to look significant. The simulation's 4.6% and 24.9% rates count two-sided false alarms, including apparent harms as well as benefits; they aren't positive-win rates.

9.A predeclared gate requires no SRM alert, a 95% lift interval lower bound above zero, observed lift of at least +2.0 points, and a p95 latency increase no greater than 150 ms. Traffic is trustworthy, lift is +4.0 points with a +1.0 point lower bound, but p95 latency rises from 1180 ms to 1490 ms. Other guardrails pass. What's the decision?

Correct answer: Don't ship because the 310 ms p95 increase exceeds its budget, even though the resolution conditions pass.

The interval and observed lift satisfy the stated resolution rules, and traffic integrity is acceptable. However, treatment is 310 ms slower at p95, exceeding the locked 150 ms budget. A predeclared launch gate requires every condition to pass, so success on the primary metric can't cancel a guardrail failure.

10.A team wants to use CUPED for resolution rate. It has a prior-period resolution flag recorded before assignment that predicts the experiment outcome, plus treatment-period latency that the query rewrite may change. Which adjustment preserves the experiment's causal boundary?

Correct answer: Adjust with the prior-resolution flag using theta = cov(Y, X) / var(X); it can reduce variance without conditioning on a treatment effect.

CUPED uses a predictive covariate measured before treatment, subtracting theta * (X - mean(X)) from the outcome to reduce unit-to-unit variance. Treatment-period latency may be changed by the rewrite, so conditioning on it can bias the comparison. The adjusted lift and its standard error still need to be reported.

10 questions remaining.

Next Step
Continue to Causal Inference and Counterfactuals

Random assignment made the treatment comparison credible. Next you'll examine what changes when assignment isn't random, which variables confound an observed outcome, and how to reason about the result an alternative action might have produced.

PreviousDecoding Algorithms
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing.

Kohavi, R., Tang, D., Xu, Y. · 2020

https://experimentguide.com/

Why Tenant-Randomized A/B Test is Challenging and Tenant-Pairing May Not Work

Microsoft Experimentation Platform · 2020

https://www.microsoft.com/en-us/research/articles/why-tenant-randomized-a-b-test-is-challenging-and-tenant-pairing-may-not-work/

power_proportions_2indep

statsmodels Developers · 2026

https://www.statsmodels.org/stable/generated/statsmodels.stats.proportion.power_proportions_2indep.html

Diagnosing Sample Ratio Mismatch in A/B Testing

Microsoft Experimentation Platform · 2020

https://www.microsoft.com/en-us/research/articles/diagnosing-sample-ratio-mismatch-in-a-b-testing/

NIST/SEMATECH e-Handbook: Bonferroni's Method

NIST/SEMATECH · 2026

https://www.itl.nist.gov/div898/handbook/prc/section4/prc473.htm

Peeking at A/B Tests: Why it matters, and what to do about it.

Johari, R., et al. · 2017 · KDD 2017

https://doi.org/10.1145/3097983.3097992

Improving the Sensitivity of Online Controlled Experiments by Utilizing Pre-Experiment Data (CUPED)

Deng, A., Xu, Y., Kohavi, R., & Walker, T. · 2013

https://exp-platform.com/Documents/2013-02-CUPED-ImprovingSensitivityOfControlledExperiments.pdf

A Tutorial on Thompson Sampling

Daniel Russo, Benjamin Van Roy, Abbas Kazerouni, Ian Osband, Zheng Wen · 2018

https://arxiv.org/abs/1707.02038

Large-scale Validation and Analysis of Interleaved Search Evaluation.

Chapelle, O., et al. · 2012 · ACM TOIS

https://doi.org/10.1145/2094072.2094078

Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena.

Zheng, L., et al. · 2023 · NeurIPS 2023

https://arxiv.org/abs/2306.05685

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.