Your organization will run 100 A/B tests next year. In every single one, treatment outperforms control in the sample. But the p-value never quite crosses 0.05 — it lands at 0.06, 0.07, 0.08. Close, but not there.
Your vaunted stats expert calls it every time: “Not significant. Keep control.”
Do you abide? Or will you push back?
100 tests. 100 times, the data points toward the treatment. 100 times, you shipped nothing. The cost of that policy — across a year, across a product — how much revenue have you sacrificed in the name of statistical purity?
Why Are We AB Testing?
The purpose of an A/B test, in industry, is straightforward: find the variant that performs better and ship it. More clicks, more conversions, more revenue.
Meanwhile, Significance Testing was designed with science in mind — a field where false positives are costly and reproducibility depends on tight error control. α caps the false positive rate; β limits false negatives.
But neither tells you whether acting on the result is worth it, or the potential magnitude of an error.
Mindset Shift: Value-Focused Testing
This paper proposes a mindset shift. Rather than obsess over statistical significance in the search for low p-values, design experiments to maximize value.
Instead of asking “is this result statistically significant?”, ask “at what point does continuing this test cost more than it returns?”
The threshold that answers that question is the Optimal Stopping Rule.
Computing the Optimal Stopping Rule
Instead of fixing α, β, and a single — often unrealistic — Minimum Detectable Effect, we set three inputs:
Max Test N — the absolute maximum number of users allowed into the test
Post-Test N — the audience that sees the winner after the test ends
Treatment Effects — drawn from a realistic distribution, not a single fixed point
With those inputs fixed, we simulate 100,000 experiments — each drawing a different treatment effect, running the test, and evaluating every possible stopping threshold. We pick the one that maximizes average long-run value across all simulations.
Why Use Simulations?
Simulations give us something no real experiment can: a ground truth to judge against. In practice, we never know the true effect — except in our simulations, where we encode it. Each draw is different, modeling the full range of experiments a team might actually run: large effects, small effects, null effects, and genuinely positive treatments that look like losers in the sample by chance. We measure which stopping rule performs best across all of it — without knowing the true effect in advance, just as in a real experiment.
Define Experiment Parameters
These parameters define your experiment — baseline conversion, expected treatment effects, test traffic, and post-test audience — and directly determine the optimal stopping threshold. Click any to learn more.
The pre-experiment conversion rate. Sets the variance of your test statistic
and determines how much room for improvement exists.
Test Traffic & Split
Total users in the experiment and their control/treatment split.
The first 7 days are burn-in — no stopping decisions. The experiment runs at most 20 days.
Post-Test Traffic
The population exposed to the winner after the decision is made. More post-test traffic means more value at stake — potentially justifying a more aggressive stopping rule.
The realistic lift you expect comes from a distribution of possible effects — not a single number. Here, experiments have an equal chance of being good or bad.
How the Simulation Works
The same simulated data is evaluated under 50 different stopping rules — Pr(Best) thresholds from 0.50 to 0.99. For each:
Burn-in: No stopping before day min_test_days = 7
Daily check: If either variant’s Pr(Best) crosses the threshold, the experiment stops.
Rollout: The winning variant is deployed to all remaining test users and the post-test audience.
Score: Record the long-run value produced.
The threshold with the highest average score across all simulations is the Optimal Stopping Rule.
Metric
Description
Projected Mean
Average conversion rate across all users: both arms during the test, plus the winner deployed to the full post-test audience.
Marginal Events vs No Test
Incremental conversions above the baseline of never running an experiment.
Correct Decision Rate
How often the strategy deploys the correct variant. Covers the full distribution of effects — beneficial and harmful — not just the single MDE statistical power is designed for.
Avg Trials (Days)
Total user-experiment observations before stopping, with calendar duration in parentheses. Shorter experiments compound value by freeing up traffic faster.
Optimization Curve
We swept every Pr(Best) threshold from 0.50 to 0.99. For each, we applied that stopping rule to all 100,000 experiments — each drawing a different true effect, known only to us — and recorded the average long-run value produced across test traffic and the full post-test rollout. The curve plots the average across all simulations. The peak is the threshold that maximizes expected value.
Change the scenario — notice whether the peaks shift or hold.
Given our experiment design, the curve peaks at Pr(Best) = 0.81 — the threshold that balances speed and precision. Stop sooner and you trade accuracy for time. Wait longer and the extra certainty doesn't pay for itself.
Post-Test N is the audience that sees the winning variant after the experiment ends — the size of the real-world rollout. It matters because the value of an experiment isn't just what happens during the test: it's the cumulative impact of deploying the winner to every user who follows. A wrong call during a test with a small rollout is a minor cost; the same wrong call with a million-person rollout is a much larger one. As Post-Test N grows, the stakes of each stopping decision rise — and the optimal threshold rises with it, demanding stronger evidence before committing.
Change the scenario — notice whether the peaks shift or hold.
Optimal Stopping vs Significance: A Comparison
The same 100,000 simulated experiments — evaluated under Optimal Stopping and Fixed Sample Significance Testing. Neither strategy had access to the true effect we encoded. They only saw what any real analyst sees: the data as it accumulated.
Optimal Stopping returns 2.0× as many marginal events as Significance Testing (α = 0.05) and takes half the time.
Against Fixed Sample (α = 0.50), Optimal Stopping finishes 10 days sooner, producing 114 more marginal events.
Both strategies ship the same winner — so any difference in value is attributable entirely to early stopping.
Why It Works
The Optimal Stopping algorithm adapts to the signal it sees:
Small effects — the value gap between variants is narrow, so the cost of a wrong call is low. The algorithm rolls out the winner quickly, sacrificing some accuracy for speed.
Large positive effects — a clear winning condition emerges early. The algorithm stops, exploits the finding, and captures value before a Fixed Sample test would even reach significance.
Large negative effects — harm is detectable fast. The algorithm cuts traffic to the losing variant before it does meaningful damage.
Conclusion
Stopping rules are arbitrary without context. Fixed Sample Significance Testing borrows a threshold designed for science — not for maximizing revenue. It runs longer than necessary, defaults to control when the data already points elsewhere, and treats a threshold with no connection to long-run value as the decision criterion.
The simulations show the cost. Compared to Fixed Sample Significance Testing (α = 0.05), Optimal Stopping ships the right variant more often, in fewer days, with more conversions over the full rollout window. The right stopping rule is derived from your experiment design — baseline conversion, audience size, effect distribution, and rollout size. It maximizes expected value, not statistical certainty.
Extras: The Optimization Toolkit
⚡ 100,000 simulated experiments × 20 days each × two recipes — 2.5s
To run your own optimization, swap in your experiment parameters — baseline conversion, Max Test N, Post-Test N, effect distribution — and the pipeline handles the rest.
The whole thing is fully vectorized. Every simulation, every day, every threshold is computed as a single set of array operations — no Python for loops anywhere. Under the hood, simulationengine uses Polars for columnar data transformations and NumPy for the numerical sweep.
Four steps: simulate 100\,000 experiments, compute daily running stats, score each potential stopping decision, then sweep every Pr(Best) threshold to find the one that maximizes long-run value.
create_simulation_df generates a DataFrame of daily binomial draws for one or more fixed-rate conditions. Each row is one day of one simulation.
import numpy as np
import polars as pl
def create_simulation_df(
number_test_days,
number_simulations,
condition_parameters,
seed=None,
):
if seed is not None:
np.random.seed(seed)
# trials_per_day is the same for every day in the experiment
n = np.array(
[c["trials"] // number_test_days for c in condition_parameters]
)[:, None]
p = np.array([c["mean"] for c in condition_parameters])[:, None]
rep = number_test_days * number_simulations
n_mat = np.repeat(n, rep, axis=1)
p_mat = np.repeat(p, rep, axis=1)
# One binomial draw per (condition, day, simulation)
samples = np.random.binomial(n=n_mat, p=p_mat)
return pl.DataFrame(
{
"simulation": np.arange(rep) // number_test_days + 1,
"day": np.arange(rep) % number_test_days + 1,
**{
f"condition_{i}_value": samples[i]
for i in range(len(condition_parameters))
},
**{
f"condition_{i}_trials": n_mat[i]
for i in range(len(condition_parameters))
},
**{
f"condition_{i}_true_mean": np.full(
rep, condition_parameters[i]["mean"]
)
for i in range(len(condition_parameters))
},
}
)
add_random_effect_condition appends a treatment arm where the true effect varies across simulations but is fixed within one. The effect is drawn from an ITC (Index-to-Control) distribution: ITC = 100 → no lift; ITC ~ N(100, 1) → effects centered at parity with modest spread.
import numpy as np
import polars as pl
def add_random_effect_condition(df, random_effect_parameters, seed=None):
if seed is not None:
np.random.seed(seed)
n_simulations = int(df["simulation"].max())
n_days = int(df["day"].max())
# One ITC draw per simulation — the unknown true effect for that experiment
itc_samples = (
np.random.normal(
random_effect_parameters["itc_mean"],
random_effect_parameters["itc_se"],
n_simulations,
)
/ 100
)
# treatment_mean = (itc / 100) x control_mean
control_mean = float(df["condition_0_true_mean"][0])
treatment_means = itc_samples * control_mean
# Daily binomial draws; treatment_mean is constant across days within a simulation
trials_per_day = random_effect_parameters["trials"] // n_days
events = np.random.binomial(
n=trials_per_day,
p=treatment_means[:, None],
size=(n_simulations, n_days),
)
# Reshape to tall format to match the existing DataFrame
next_condition = (
max(
int(c.split("_")[1])
for c in df.columns
if c.startswith("condition_") and c.split("_")[1].isdigit()
)
+ 1
)
return df.with_columns(
[
pl.Series(
f"condition_{next_condition}_value",
events.flatten(),
),
pl.Series(
f"condition_{next_condition}_trials",
np.full(n_simulations * n_days, trials_per_day),
),
pl.Series(
f"condition_{next_condition}_true_mean",
np.repeat(treatment_means, n_days),
),
]
)
add_cumulative_columns converts daily event counts into running totals grouped by simulation. Each condition gets a cumulative event count, trial count, and conversion rate. Cross-condition totals and remaining trials are derived in a single vectorized pass — no loops.
import polars as pl
def add_cumulative_columns(df):
value_cols = [col for col in df.columns if col.endswith("_value")]
trials_cols = [col for col in df.columns if col.endswith("_trials")]
# Running totals per condition, grouped by simulation
cumsum_exprs = [
pl.col(col).cum_sum().over("simulation").alias(f"{col}_cumsum")
for col in value_cols + trials_cols
]
df = df.with_columns(cumsum_exprs)
# Cross-condition totals
value_cumsums = [f"{c}_cumsum" for c in value_cols]
trials_cumsums = [f"{c}_cumsum" for c in trials_cols]
df = df.with_columns([
pl.sum_horizontal(value_cumsums).alias("total_value_cumsum"),
pl.sum_horizontal(trials_cumsums).alias("total_trials_cumsum"),
])
# Trials remaining in the experiment window
df = df.with_columns([
(pl.col("total_trials_cumsum").max().over("simulation")
- pl.col("total_trials_cumsum")).alias("total_remaining_test_trials")
])
# Cumulative conversion rate per condition
mean_exprs = [
(pl.col(f"{c}_cumsum") / pl.col(f"{c.replace('_value','_trials')}_cumsum"))
.alias(f"{c.replace('_value','')}_mean_cumsum")
for c in value_cols
if f"{c.replace('_value','_trials')}" in trials_cols
]
return df.with_columns(mean_exprs)
compute_cumulative_std_rates applies the binomial standard deviation formula sqrt(p * (1 - p)) to each condition's running conversion rate. Note: this is the std of the proportion, not the standard error — divide by sqrt(n) to get SE.
import polars as pl
def compute_cumulative_std_rates(df):
mean_cumsum_cols = [col for col in df.columns if col.endswith("_mean_cumsum")]
std_exprs = [
(pl.col(col) * (1 - pl.col(col)))
.sqrt()
.alias(col.replace("_mean_cumsum", "_std_cumsum"))
for col in mean_cumsum_cols
]
return df.with_columns(std_exprs)
compute_delta_stats computes two things for each treatment condition vs control: the standard error (SE = std / sqrt(n)) and the delta statistics (delta_mean = mean_i - mean_0, delta_se = sqrt(SE_i² + SE_0²)). These are the direct inputs to z-scores and p-values — this function does not compute those itself.
import polars as pl
def compute_delta_stats(df):
mean_cols = [c for c in df.columns if c.endswith("_mean_cumsum")]
# SE = std / sqrt(trials) for every condition
se_exprs = [
(pl.col(f"condition_{i}_std_cumsum") / pl.col(f"condition_{i}_trials_cumsum").sqrt())
.alias(f"condition_{i}_se_cumsum")
for i in [int(c.split("_")[1]) for c in mean_cols if c.startswith("condition_")]
if f"condition_{i}_std_cumsum" in df.columns
]
df = df.with_columns(se_exprs)
# Delta mean and delta SE for each treatment vs condition_0 (control)
treatment_nums = [
int(c.split("_")[1]) for c in mean_cols
if c.startswith("condition_") and int(c.split("_")[1]) > 0
]
delta_exprs = []
for i in treatment_nums:
delta_exprs += [
(pl.col(f"condition_{i}_mean_cumsum") - pl.col("condition_0_mean_cumsum"))
.alias(f"condition_{i}_delta_mean"),
(pl.col(f"condition_{i}_se_cumsum").pow(2) + pl.col("condition_0_se_cumsum").pow(2))
.sqrt()
.alias(f"condition_{i}_delta_se"),
]
return df.with_columns(delta_exprs)
compute_test_stats converts delta statistics into probabilities and expected values using the normal CDF. For each treatment: Pr(treatment > control) = Φ(delta_mean / delta_se). Expected gain and loss use truncated normal formulas. These are the columns the stopping rule reads — it stops when Pr(Best) crosses the threshold α.
import numpy as np
import polars as pl
from scipy.stats import norm
def compute_test_stats(df):
delta_mean_cols = [c for c in df.columns if c.endswith("_delta_mean")]
condition_nums = [int(c.split("_")[1]) for c in delta_mean_cols]
pr_cols = []
for i in condition_nums:
z = df[f"condition_{i}_delta_mean"].to_numpy() / df[f"condition_{i}_delta_se"].to_numpy()
pr = norm.cdf(z)
df = df.with_columns([
pl.lit(pr).alias(f"condition_{i}_pr_positive_diff"),
pl.lit(1 - pr).alias(f"condition_{i}_pr_lowest_diff"),
])
pr_cols.append(f"condition_{i}_pr_positive_diff")
# Expected gain and loss (truncated normal)
pdf_z = norm.pdf(z)
df = df.with_columns([
pl.lit(np.abs(pr * (df[f"condition_{i}_delta_mean"].to_numpy() + df[f"condition_{i}_delta_se"].to_numpy() * pdf_z / np.maximum(pr, 1e-10)))).alias(f"condition_{i}_expected_gain"),
pl.lit(np.abs((1-pr) * (-df[f"condition_{i}_delta_mean"].to_numpy() + df[f"condition_{i}_delta_se"].to_numpy() * pdf_z / np.maximum(1-pr, 1e-10)))).alias(f"condition_{i}_expected_loss"),
])
# Control: Pr(best) = 1 - max(Pr(treatment > control))
if pr_cols:
df = df.with_columns([
(1 - pl.max_horizontal(pr_cols)).alias("condition_0_pr_highest_diff"),
pl.min_horizontal(pr_cols).alias("condition_0_pr_lowest_diff"),
])
return df
For each condition, draws from Binomial(total_remaining_test_trials, true_mean) — one random realization of the value you'd capture by stopping now and deploying that condition to remaining test users.
import numpy as np
import polars as pl
def calculate_remaining_test_potential(df, seed=None):
if not isinstance(df, pl.DataFrame):
raise TypeError(f"df must be a polars DataFrame, got {type(df)}")
if "total_remaining_test_trials" not in df.columns:
raise ValueError("df must contain 'total_remaining_test_trials' column")
if seed is not None:
np.random.seed(seed)
true_mean_cols = [col for col in df.columns if col.endswith("_true_mean")]
if not true_mean_cols:
raise ValueError("df must contain at least one column ending with '_true_mean'")
potential_exprs = []
for mean_col in true_mean_cols:
condition_prefix = mean_col.replace("_true_mean", "")
n = df["total_remaining_test_trials"].to_numpy().astype(int)
p = np.clip(df[mean_col].to_numpy(), 0.0, 1.0)
draws = np.random.binomial(n=n, p=p)
potential_exprs.append(
pl.lit(draws).alias(f"{condition_prefix}_test_potential_value")
)
return df.with_columns(potential_exprs)
Draws from Binomial(post_test_n, true_mean) for each condition — one random realization of the value you'd capture by launching that condition to the post-test population. This is where stopping decisions have the most leverage.
import numpy as np
import polars as pl
def calculate_launch_potential(df, post_test_n, seed=None):
if not isinstance(df, pl.DataFrame):
raise TypeError(f"df must be a polars DataFrame, got {type(df)}")
if not isinstance(post_test_n, (int, np.integer)):
raise TypeError(f"post_test_n must be an integer, got {type(post_test_n)}")
if post_test_n < 0:
raise ValueError(f"post_test_n must be non-negative, got {post_test_n}")
if seed is not None:
np.random.seed(seed)
true_mean_cols = [col for col in df.columns if col.endswith("_true_mean")]
if not true_mean_cols:
raise ValueError("df must contain at least one column ending with '_true_mean'")
potential_exprs = []
for mean_col in true_mean_cols:
condition_prefix = mean_col.replace("_true_mean", "")
p = np.clip(df[mean_col].to_numpy(), 0.0, 1.0)
draws = np.random.binomial(n=post_test_n, p=p)
potential_exprs.append(
pl.lit(draws).alias(f"{condition_prefix}_launch_potential_value")
)
return df.with_columns(potential_exprs)
total_potential = total_value_cumsum + test_potential + launch_potential. The cumulative observed value is shared across all conditions; only the forward-looking components differ. The condition with the highest total potential is the one to deploy.
import polars as pl
def calculate_total_potential(df):
if not isinstance(df, pl.DataFrame):
raise TypeError(f"df must be a polars DataFrame, got {type(df)}")
if "total_value_cumsum" not in df.columns:
raise ValueError("df must contain 'total_value_cumsum' column")
test_potential_cols = [
col for col in df.columns if col.endswith("_test_potential_value")
]
if not test_potential_cols:
raise ValueError("df must contain at least one column ending with '_test_potential_value'")
total_exprs = []
for test_col in test_potential_cols:
condition_prefix = test_col.replace("_test_potential_value", "")
launch_col = f"{condition_prefix}_launch_potential_value"
if launch_col not in df.columns:
raise ValueError(f"Missing required column: {launch_col}")
total_exprs.append(
(pl.col("total_value_cumsum") + pl.col(test_col) + pl.col(launch_col))
.alias(f"{condition_prefix}_total_potential_value")
)
return df.with_columns(total_exprs)
The core of the optimization loop. Rather than iterating over thresholds in Python, it stacks all Pr(Best) columns into a 3D NumPy array (n_sims × n_days × n_conditions), finds the first crossing day per simulation per threshold in a single broadcast, then aggregates value, trial count, and decision accuracy across all thresholds simultaneously.
import numpy as np
import polars as pl
def evaluate_probability_thresholds(
df_prepped,
threshold_values,
metric_direction_improvement="increase",
pre_sorted=False,
select_best_at_end=False,
):
if not isinstance(df_prepped, pl.DataFrame):
raise TypeError(f"df_prepped must be a polars DataFrame, got {type(df_prepped)}")
if metric_direction_improvement not in ["increase", "decrease"]:
raise ValueError(f"metric_direction_improvement must be 'increase' or 'decrease'")
if not pre_sorted:
df_prepped = df_prepped.sort(["simulation", "day"])
n_sims = df_prepped["simulation"].n_unique()
n_days = df_prepped.height // n_sims
def _col2d(name):
return df_prepped[name].to_numpy().reshape(n_sims, n_days)
# Select probability columns based on metric direction
if metric_direction_improvement == "increase":
treatment_prob_cols = [
c for c in df_prepped.columns
if c.endswith("_pr_positive_diff") and not c.startswith("condition_0_")
]
control_prob_col = "condition_0_pr_highest_diff"
else:
treatment_prob_cols = [
c for c in df_prepped.columns
if c.endswith("_pr_lowest_diff") and not c.startswith("condition_0_")
]
control_prob_col = "condition_0_pr_lowest_diff"
all_prob_cols = [control_prob_col] + treatment_prob_cols
condition_nums = np.array([int(c.split("_")[1]) for c in all_prob_cols])
# Stack: (n_sims, n_days, n_conditions)
prob_stack = np.stack([_col2d(c) for c in all_prob_cols], axis=2)
max_prob = prob_stack.max(axis=2) # (n_sims, n_days)
best_cond = condition_nums[prob_stack.argmax(axis=2)]
# Cumulative max — first day cummax >= threshold is the stopping day
cummax = np.maximum.accumulate(max_prob, axis=1)
thresholds_arr = np.array(sorted(threshold_values))
# First-crossing index, clamped to last day for never-crossed sims
raw_stop = (cummax[:, :, np.newaxis] < thresholds_arr).sum(axis=1) # (n_sims, n_thresholds)
any_crossed = raw_stop < n_days
stop_idx = np.minimum(raw_stop, n_days - 1)
row = np.arange(n_sims)[:, np.newaxis]
best_cond_at_stop = best_cond[row, stop_idx]
winning_cond = np.where(any_crossed | select_best_at_end, best_cond_at_stop, 0)
# Winning condition's total potential value at stopping day
pot_cols = sorted(
[c for c in df_prepped.columns
if c.endswith("_total_potential_value") and c.startswith("condition_") and c.split("_")[1].isdigit()],
key=lambda c: int(c.split("_")[1]),
)
pot_stack = np.stack([_col2d(c) for c in pot_cols], axis=2)
win_pot_at_stop = pot_stack[row, stop_idx, winning_cond]
total_at_stop = _col2d("total_trials_cumsum")[row, stop_idx]
true_cond_at_stop = _col2d("true_best_condition")[row, stop_idx]
return pl.DataFrame({
"threshold": thresholds_arr,
"mean_potential_value": win_pot_at_stop.mean(axis=0),
"mean_trials": total_at_stop.mean(axis=0),
"median_trials": np.median(total_at_stop, axis=0),
"p80_trials": np.percentile(total_at_stop, 80, axis=0),
"match_rate": (winning_cond == true_cond_at_stop).mean(axis=0),
})
Acknowledgements
The idea of designing experiments for value rather than significance is inspired by Test & Roll: Profit-Maximizing A/B Tests by Ron Berman and Elea McDonnell Feit. Their core insight: for tactical experiments, the cost of a wrong call on a small effect is negligible — the cost of oversizing the test to satisfy α is not. This paper extends that spirit by making the stopping rule, not the sample size, the optimization target.
My colleague Joseph Powers, PhD introduced me to the idea that A/B testing is an optimization problem and fundamentally changed how I approach experimentation. He showed me the power of simulation studies for building real intuition — not just running tests. A generous mentor and an invaluable teammate.
Source Code
---title: "AB Testing is an Optimization Problem"author: - "Russ Zaliznyak" - "Joseph Powers, PhD"date: "2026-06-17"subtitle: "Significance Testing is costing you revenue."image: "assets/optimization.png"categories: [AB Testing, Bayesian, Optimization, Simulation, Experimentation]open-graph: description: "Significance Testing is costing you revenue!"page-layout: fulljupyter: py311execute: echo: falsecss: wider.css---# AB Testing Nightmare: 100 Close CallsYour organization will run 100 A/B tests next year. In every single one, treatment outperforms control in the sample. But the p-value never quite crosses 0.05 — it lands at 0.06, 0.07, 0.08. Close, but not there.Your vaunted stats expert calls it every time: *"Not significant. Keep control."*Do you abide? Or will you push back?100 tests. 100 times, the data points toward the treatment. 100 times, you shipped nothing. The cost of that policy — across a year, across a product — how much revenue have you sacrificed in the name of statistical purity?# Why Are We AB Testing?The purpose of an A/B test, in industry, is straightforward: find the variant that performs better and ship it. More clicks, more conversions, more revenue.Meanwhile, Significance Testing was designed with science in mind — a field where false positives are costly and reproducibility depends on tight error control. α caps the false positive rate; β limits false negatives.But neither tells you whether acting on the result is worth it, or the potential magnitude of an error.## Mindset Shift: Value-Focused TestingThis paper proposes a mindset shift. Rather than obsess over statistical significance in the search for low p-values, design experiments to maximize value.Instead of asking "is this result statistically significant?", ask "at what point does continuing this test cost more than it returns?"The threshold that answers that question is the **Optimal Stopping Rule**.## Computing the Optimal Stopping RuleInstead of fixing α, β, and a single — often unrealistic — Minimum Detectable Effect, we set three inputs:- **`Max Test N`** — the absolute maximum number of users allowed into the test- **`Post-Test N`** — the audience that sees the winner after the test ends- **`Treatment Effects`** — drawn from a realistic distribution, not a single fixed pointWith those inputs fixed, we simulate 100,000 experiments — each drawing a different treatment effect, running the test, and evaluating every possible stopping threshold. We pick the one that maximizes average long-run value across all simulations.# Why Use Simulations?Simulations give us something no real experiment can: a ground truth to judge against. In practice, we never know the true effect — except in our simulations, where we encode it. Each draw is different, modeling the full range of experiments a team might actually run: large effects, small effects, null effects, and genuinely positive treatments that look like losers in the sample by chance. We measure which stopping rule performs best across all of it — without knowing the true effect in advance, just as in a real experiment.## Define Experiment ParametersThese parameters define your experiment — baseline conversion, expected treatment effects, test traffic, and post-test audience — and directly determine the optimal stopping threshold. Click any to learn more.```{=html}<div class="stopping-factors-ui mt-3 mb-4"> <!-- Buttons with inputs always visible below each --> <div class="factor-columns"> <div class="factor-col"> <button class="btn factor-btn active w-100" data-target="baseline"> Baseline </button> <div class="factor-inputs"> <label class="param-label" style="text-align:center; display:block;">Conversion</label> <input type="text" class="form-control" value="60%" disabled> </div> </div> <div class="factor-col"> <button class="btn factor-btn w-100" data-target="effects"> Expected Effects </button> <div class="factor-inputs" style="padding: 0.15rem 0.5rem;"> <div id="itc-density-label" style="font-size:0.72rem; color:#555; text-align:center; line-height:1.2; margin-bottom:1px;"></div> <div id="itc-density-chart" style="height:63px; width:100%; margin-top:0;"></div> </div> </div> <div class="factor-col"> <button class="btn factor-btn w-100" data-target="traffic"> Test Traffic & Split </button> <div class="factor-inputs"> <div class="d-flex gap-2"> <div class="flex-fill"> <label class="param-label" style="text-align:center; display:block;">Traffic</label> <input type="text" class="form-control" value="100,000" disabled> </div> <div class="flex-fill"> <label class="param-label" style="text-align:center; display:block;">Split</label> <input type="text" class="form-control" value="50 / 50" disabled> </div> <div class="flex-fill"> <label class="param-label" style="text-align:center; display:block;">Min / Max Days</label> <input type="text" class="form-control" value="7 / 20" disabled> </div> </div> </div> </div> <div class="factor-col"> <button class="btn factor-btn w-100" data-target="posttest"> Post-Test Traffic </button> <div class="factor-inputs"> <label class="param-label" style="text-align:center; display:block;">Post-Test Traffic</label> <input type="text" class="form-control" value="100,000" disabled> </div> </div> </div> <!-- Definition box --> <div id="factor-display" style="display:none; margin-top:0.75rem;"> <div class="factor-panel" id="panel-baseline"> The pre-experiment conversion rate. Sets the variance of your test statistic and determines how much room for improvement exists. </div> <div class="factor-panel d-none" id="panel-traffic" style="border: 1.5px dashed #90b8e0; border-radius:6px; background:#f0f6fd; padding:0.6rem 1rem; font-size:0.9rem; color:#333;"> <div style="font-size:0.9rem; font-weight:600; color:#1e46a0; margin-bottom:0.3rem;">Test Traffic & Split</div> Total users in the experiment and their control/treatment split. The first 7 days are burn-in — no stopping decisions. The experiment runs at most 20 days. </div> <div class="factor-panel d-none" id="panel-posttest" style="border: 1.5px dashed #90b8e0; border-radius:6px; background:#f0f6fd; padding:0.6rem 1rem; font-size:0.9rem; color:#333;"> <div style="font-size:0.9rem; font-weight:600; color:#1e46a0; margin-bottom:0.3rem;">Post-Test Traffic</div> The population exposed to the winner after the decision is made. More post-test traffic means more value at stake — potentially justifying a more aggressive stopping rule. </div> <div class="factor-panel d-none" id="panel-effects"> The realistic lift you expect comes from a <em>distribution</em> of possible effects — not a single number. Here, experiments have an equal chance of being good or bad. </div> </div> <div class="mt-3"> </div></div><script>document.addEventListener('DOMContentLoaded', function() { const simCollapse = document.getElementById('sim-collapse'); const simHeader = document.getElementById('sim-accordion-header'); const simBtn = document.getElementById('sim-accordion-btn'); simCollapse.addEventListener('show.bs.collapse', () => { simHeader.style.borderBottom = 'none'; simHeader.style.borderRadius = '6px 6px 0 0'; simBtn.querySelector('span:first-child').textContent = 'Hide Random Walks of Control'; }); simCollapse.addEventListener('hide.bs.collapse', () => { simHeader.style.borderBottom = '1.5px dashed #90b8e0'; simHeader.style.borderRadius = '6px'; simBtn.querySelector('span:first-child').textContent = 'Show Random Walks of Control'; });});</script><script>(function renderItcDensity() { const el = document.getElementById('itc-density-chart'); if (!el) return; if (typeof Plotly === 'undefined' || !window.__itcDensity) { setTimeout(renderItcDensity, 200); return; } const d = window.__itcDensity; const lbl = document.getElementById('itc-density-label'); if (lbl) lbl.innerHTML = `N(μ = ${Math.round(d.mean)}, σ = ${d.std.toFixed(1)})`; Plotly.newPlot(el, [ { x: d.xL, y: d.yL, customdata: d.sfL, fill: 'tozeroy', mode: 'lines', name: '< 100', line: { color: '#e45c3a', width: 1.5 }, fillcolor: 'rgba(228,92,58,0.25)', hovertemplate: 'Pr( ITC > %{x:.2f} ) = %{customdata:.2f}%<extra></extra>', }, { x: d.xR, y: d.yR, customdata: d.sfR, fill: 'tozeroy', mode: 'lines', name: '> 100', line: { color: '#1e46a0', width: 1.5 }, fillcolor: 'rgba(30,70,160,0.2)', hovertemplate: 'Pr( ITC > %{x:.2f} ) = %{customdata:.2f}%<extra></extra>', }, { x: [d.mean], y: [0], mode: 'markers', name: 'avg', marker: { color: '#1e46a0', size: 7, symbol: 'circle', line: { color: 'white', width: 1.5 } }, hovertemplate: 'Avg ITC: %{x:.3f}<extra></extra>', }, ], { margin: { t: 0, b: 14, l: 2, r: 2 }, paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)', xaxis: { title: { text: 'Index-to-Control', font: { size: 10 } }, showticklabels: false, showgrid: false, zeroline: false, fixedrange: true }, yaxis: { visible: false, fixedrange: true }, showlegend: false, shapes: [{ type: 'line', x0: 100, x1: 100, y0: 0, y1: 1, yref: 'paper', line: { color: '#555', width: 1, dash: 'dot' } }], hoverlabel: { bgcolor: '#111', font: { color: '#fff', size: 16 } }, }, { displayModeBar: false, responsive: true });})();</script><script>document.querySelectorAll('.factor-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.factor-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.factor-panel').forEach(p => p.classList.add('d-none')); btn.classList.add('active'); document.getElementById('panel-' + btn.dataset.target).classList.remove('d-none'); const target = btn.dataset.target; const simWrap = document.getElementById('sim-accordion-wrap'); const treatmentWrap = document.getElementById('treatment-accordion-wrap'); const factorDisplay = document.getElementById('factor-display'); if (simWrap) simWrap.style.display = (target === 'baseline') ? '' : 'none'; if (treatmentWrap) treatmentWrap.style.display = (target === 'effects') ? '' : 'none'; if (factorDisplay) factorDisplay.style.display = (target === 'baseline' || target === 'effects') ? 'none' : ''; });});</script>``````{python}#| echo: false#| code-fold: true#| code-summary: "Experiment Assumptions"import time as _time_moduleimport simulationengine as se_t_total_start = _time_module.time()# ── Experiment Assumptions ─────────────────────────────────────────CONTROL_MEAN =0.60# pre-experiment conversion rateMAX_TEST_N =100_000# total users in the experimentSPLIT =0.50# treatment allocation (50/50)MIN_TEST_DAYS =7# mandatory burn-in before stopping decisionsNUMBER_TEST_DAYS =20# maximum experiment durationPOST_TEST_N =100_000# users exposed to winner once MAX_TEST_N is reachedNUMBER_SIMULATIONS =100_000# simulations to runSEED =42ITC_MEAN =100# index-to-control meanITC_SE =1# index-to-control standard errordf = se.create_simulation_df( number_test_days=NUMBER_TEST_DAYS, number_simulations=NUMBER_SIMULATIONS, seed=SEED, condition_parameters=[ {"mean": CONTROL_MEAN,"trials": int(MAX_TEST_N * SPLIT), }, ],)random_effect_parameters_0 = {"itc_mean": ITC_MEAN,"itc_se": ITC_SE,"trials": int(MAX_TEST_N * SPLIT),}df = se.add_random_effect_condition(df, random_effect_parameters_0)df = se.add_cumulative_columns(df)df = se.compute_cumulative_std_rates(df)df = se.compute_delta_stats(df)df = se.compute_test_stats(df)df = se.calculate_remaining_test_potential(df)df = se.calculate_launch_potential(df, post_test_n=POST_TEST_N)df = se.calculate_total_potential(df)``````{=html}<div class="mt-3 mb-2" id="sim-accordion-wrap"> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0;" id="sim-accordion-header"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" id="sim-accordion-btn" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; min-height:0; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#sim-collapse"> <span style="flex:1;">Show Random Walks of Control</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Each simulation is a different realization of the same baseline, but different noise. This is the randomness inherent in experimentation.</span> </button> </div> </div> <div id="sim-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff;">``````{python}#| code-fold: true#| code-summary: "Simulation Charts"import jsonimport plotly.graph_objects as goimport numpy as npimport polars as plfrom plotly.subplots import make_subplotsfrom IPython.display import display as ipy_display# ── Time series data (first 100 sims, cumulative conversion rate) ─N_SIMS =100plot_df = ( df.filter(pl.col("simulation") <= N_SIMS) .sort(["simulation", "day"]) .rename({"condition_0_mean_cumsum": "conversion_rate"}))# ── Serialize all 100 sims for JS slider ─────────────────────────sim_data = {}for sim inrange(1, N_SIMS +1): sim_df = plot_df.filter(plot_df["simulation"] == sim) sim_data[str(sim)] = {"x": sim_df["day"].to_list(),"y": [round(v, 6) for v in sim_df["conversion_rate"].to_list()], }# ── Pre-compute histogram bins for all 10k sims × 20 days ────────all_df = ( df.sort(["simulation", "day"]) .rename({"condition_0_mean_cumsum": "conversion_rate"}))hist_data = {}global_min =float(all_df["conversion_rate"].min())global_max =float(all_df["conversion_rate"].max())for day inrange(1, NUMBER_TEST_DAYS +1): rates = all_df.filter(all_df["day"] == day)["conversion_rate"].to_numpy() counts, edges = np.histogram(rates, bins=50) bin_centers = [float((edges[i] + edges[i+1]) /2) for i inrange(len(counts))] hist_data[str(day)] = {"x": bin_centers, "y": counts.tolist()}# ── Subplot: time series (left) + histogram (right) ──────────────fig = make_subplots( cols=2, column_widths=[0.5, 0.5], horizontal_spacing=0.08,)# Manually add left-aligned titlesfig.add_annotation( text="Each simulation takes its own path!", xref="paper", yref="paper", x=0.0, y=1.07, xanchor="left", yanchor="bottom", showarrow=False, font=dict(size=13),)fig.add_annotation( text="Control Estimates Improve Over Time (Day = 1)", xref="paper", yref="paper", x=0.54, y=1.07, xanchor="left", yanchor="bottom", showarrow=False, font=dict(size=13),)# Single trace — JS swaps x/y on slider change (trace index 0)s1 = plot_df.filter(plot_df["simulation"] ==1)fig.add_trace(go.Scatter( x=s1["day"].to_list(), y=s1["conversion_rate"].to_list(), mode="lines", line=dict(color="rgba(15, 70, 160, 0.9)", width=3), opacity=1.0, showlegend=False, name="Sim 1", hovertemplate="%{fullData.name} · %{y:.2%}<extra></extra>",), col=1, row=1)fig.add_hline( y=CONTROL_MEAN, line_dash="dash", line_color="rgba(20, 60, 160, 0.8)", annotation_text=f"True mean ({CONTROL_MEAN:.0%})", annotation_position="bottom right", col=1, row=1,)d1 = hist_data["1"]fig.add_trace(go.Bar( x=d1["x"], y=d1["y"], marker_color="rgba(15, 70, 160, 0.55)", showlegend=False, hovertemplate="Rate: %{x:.2%}<br>Count: %{y}<extra></extra>",), col=2, row=1)fig.add_vline( x=CONTROL_MEAN, line_dash="dash", line_color="rgba(20, 60, 160, 0.8)", annotation_text=f"{CONTROL_MEAN:.0%}", annotation_position="top right", col=2, row=1,)fig.update_layout( template="plotly_white", height=300, autosize=True, margin=dict(t=45, b=30, l=50, r=20), hovermode="closest", hoverlabel=dict(bgcolor="white", font_size=16, namelength=0),)fig.update_xaxes(tickformat="d", title_text="Day", col=1, row=1)fig.update_xaxes(tickformat=".1%", title_text="Conversion Rate",range=[global_min, global_max], col=2, row=1)ts_min =float(plot_df["conversion_rate"].min())ts_max =float(plot_df["conversion_rate"].max())fig.update_yaxes(tickformat=".1%", title_text="Conversion Rate",range=[ts_min, ts_max], col=1, row=1)fig.update_yaxes(title_text="", showticklabels=False, col=2, row=1)fig``````{python}#| output: asisprint(f'<script>window.__histData = {json.dumps(hist_data)}; window.__simData = {json.dumps(sim_data)};</script>')``````{=html}<div style="margin: 0.25rem 0 0.5rem 0; display: flex; gap: 2rem; flex-wrap: wrap;"> <div style="display:flex; align-items:center; gap:0.75rem; flex:1; min-width:200px;"> <label style="font-size:0.85rem; font-weight:600; white-space:nowrap;"> Simulation: <span id="sim-label">1</span> </label> <input id="sim-slider" type="range" min="1" max="100" step="1" value="1" style="flex:1; accent-color:#1e46a0;"> </div> <div style="display:flex; align-items:center; gap:0.75rem; flex:1; min-width:200px;"> <label style="font-size:0.85rem; font-weight:600; white-space:nowrap;"> Day: <span id="day-label">1</span> </label> <input id="day-slider" type="range" min="1" max="20" step="1" value="1" style="flex:1; accent-color:#1e46a0;"> </div></div><script>(function() { function attach() { const plot = document.querySelector('#sim-collapse .plotly-graph-div'); if (!plot || !plot._fullData) { setTimeout(attach, 300); return; } Plotly.Plots.resize(plot); const simSlider = document.getElementById('sim-slider'); const simLabel = document.getElementById('sim-label'); const daySlider = document.getElementById('day-slider'); const dayLabel = document.getElementById('day-label'); function showSim(idx) { const simNum = idx + 1; const d = window.__simData[String(simNum)]; Plotly.restyle(plot, { x: [d.x], y: [d.y], name: [`Sim ${simNum}`] }, [0]); } simSlider.addEventListener('input', function() { simLabel.textContent = this.value; showSim(parseInt(this.value) - 1); const tSlider = document.getElementById('t-sim-slider'); const tLabel = document.getElementById('t-sim-label'); if (tSlider && tSlider.value !== this.value) { tSlider.value = this.value; if (tLabel) tLabel.textContent = this.value; tSlider.dispatchEvent(new Event('input')); } const pvSlider = document.getElementById('pval-sim-slider'); const pvLabel = document.getElementById('pval-sim-label'); if (pvSlider && pvSlider.value !== this.value) { pvSlider.value = this.value; if (pvLabel) pvLabel.textContent = this.value; pvSlider.dispatchEvent(new Event('input')); } }); daySlider.addEventListener('input', function() { const day = this.value; dayLabel.textContent = day; const d = window.__histData[day]; Plotly.restyle(plot, { x: [d.x], y: [d.y] }, [1]); Plotly.relayout(plot, { 'annotations[1].text': `Control Estimates Improve Over Time (Day = ${day})` }); }); showSim(0); } const collapse = document.getElementById('sim-collapse'); if (collapse) { collapse.addEventListener('shown.bs.collapse', attach); } else { attach(); }})();</script></div></div>``````{=html}<div class="mt-3 mb-2" id="treatment-accordion-wrap" style="display:none;"> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0;" id="treatment-accordion-header"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" id="treatment-accordion-btn" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; min-height:0; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#treatment-collapse"> <span style="flex:1;">Show Random Walks of Treatment</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Each simulation of treatment is encoded with a different true effect. Sometimes harmful, sometimes beneficial, often marginal. In practice, the true effect is never known.</span> </button> </div> </div> <div id="treatment-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff;">``````{python}# ── Treatment time series (first 100 sims) ───────────────────────treat_plot_df = ( df.filter(pl.col("simulation") <= N_SIMS) .sort(["simulation", "day"]) .rename({"condition_1_mean_cumsum": "conversion_rate"}))treat_true_mean =float(df["condition_1_true_mean"].mean())treat_sim_data = {}for sim inrange(1, N_SIMS +1): sim_df = treat_plot_df.filter(treat_plot_df["simulation"] == sim) treat_sim_data[str(sim)] = {"x": sim_df["day"].to_list(),"y": [round(v, 6) for v in sim_df["conversion_rate"].to_list()],"true_mean": round(float(sim_df["condition_1_true_mean"].mean()), 6), }# ── ITC histogram (last day, one value per simulation) ────────────itc_df = ( df.filter(pl.col("day") == NUMBER_TEST_DAYS) .with_columns( (100* pl.col("condition_1_true_mean") / pl.col("condition_0_true_mean")).alias("itc") ) .sort("simulation"))itc_values = itc_df["itc"].to_numpy()itc_counts, itc_edges = np.histogram(itc_values, bins=50)itc_centers = [float((itc_edges[i] + itc_edges[i+1]) /2) for i inrange(len(itc_counts))]itc_hist = {"x": itc_centers, "y": itc_counts.tolist()}itc_max_count =int(itc_counts.max())# ── KDE density + survival function for ITC mini-chart ───────────from scipy.stats import gaussian_kde as _kde, norm as _norm_kde_fn = _kde(itc_values)_x_dense = np.linspace(float(itc_values.min()), float(itc_values.max()), 400)_y_dense = _kde_fn(_x_dense)_itc_mean =float(itc_values.mean())_itc_std =float(itc_values.std())_sf = [round(float(_norm.sf(x, _itc_mean, _itc_std)) *100, 2) for x in _x_dense]_split = _x_dense <=100itc_density = {"xL": _x_dense[_split].tolist(), "yL": _y_dense[_split].tolist(),"sfL": [_sf[i] for i inrange(len(_x_dense)) if _split[i]],"xR": _x_dense[~_split].tolist(), "yR": _y_dense[~_split].tolist(),"sfR": [_sf[i] for i inrange(len(_x_dense)) ifnot _split[i]],"mean": _itc_mean,"std": _itc_std,}itc_max_dev =max(abs(float(itc_values.max()) -100), abs(100-float(itc_values.min())))itc_range = [100- itc_max_dev *1.1, 100+ itc_max_dev *1.1]treat_itc_per_sim = {}for sim inrange(1, N_SIMS +1): rows = itc_df.filter(pl.col("simulation") == sim)["itc"] treat_itc_per_sim[str(sim)] =round(float(rows[0]), 4)# ── Subplot figure ────────────────────────────────────────────────t_fig = make_subplots(cols=2, column_widths=[0.5, 0.5], horizontal_spacing=0.08)ts1 = treat_plot_df.filter(treat_plot_df["simulation"] ==1)s1_true_mean =round(float(ts1["condition_1_true_mean"].mean()), 6)t_fig.add_annotation( text="Each simulation has a different true mean!", xref="paper", yref="paper", x=0.0, y=1.07, xanchor="left", yanchor="bottom", showarrow=False, font=dict(size=13),)t_fig.add_annotation( text="Distribution of True Treatment Effects (ITC)", xref="paper", yref="paper", x=0.54, y=1.07, xanchor="left", yanchor="bottom", showarrow=False, font=dict(size=13),)# annotation[2]: per-sim true effect label on time series linet_fig.add_annotation( x=NUMBER_TEST_DAYS, y=s1_true_mean, xref="x1", yref="y1", text=f"True mean ({s1_true_mean:.2%})", showarrow=False, xanchor="right", yanchor="top", font=dict(size=12, color="rgba(20, 60, 160, 0.8)"),)ts1 = treat_plot_df.filter(treat_plot_df["simulation"] ==1)t_fig.add_trace(go.Scatter( x=ts1["day"].to_list(), y=ts1["conversion_rate"].to_list(), mode="lines", line=dict(color="rgba(15, 70, 160, 0.9)", width=3), opacity=1.0, showlegend=False, name="Sim 1", hovertemplate="%{fullData.name} · %{y:.2%}<extra></extra>",), col=1, row=1)# Trace 2: per-sim true mean line on time seriest_fig.add_trace(go.Scatter( x=[1, NUMBER_TEST_DAYS], y=[s1_true_mean, s1_true_mean], mode="lines", line=dict(color="rgba(20, 60, 160, 0.8)", width=1.5, dash="dash"), showlegend=False, hoverinfo="skip", name="True Mean",), col=1, row=1)# Trace 2: ITC histogram (fixed across all sims)t_fig.add_trace(go.Bar( x=itc_hist["x"], y=itc_hist["y"], marker_color="rgba(15, 70, 160, 0.45)", showlegend=False, hovertemplate="ITC: %{x:.1f}<br>Count: %{y}<extra></extra>",), col=2, row=1)# annotation[3]: ITC value label above the green dots1_itc = treat_itc_per_sim["1"]s1_color ="green"if s1_itc >=100else"red"t_fig.add_annotation( x=s1_itc, y=itc_max_count *0.1, xref="x2", yref="y2", text=f"True Effect: {s1_itc:.2f}", showarrow=True, arrowhead=2, arrowsize=2, arrowwidth=2, arrowcolor=s1_color, ax=0, ay=-40, font=dict(size=14, color=s1_color), bgcolor="white", bordercolor=s1_color, borderwidth=1,)t_fig.add_trace(go.Scatter( x=[s1_itc], y=[0], mode="markers", marker=dict(color=s1_color, size=12, symbol="circle"), showlegend=False, hoverinfo="skip", name="Selected Sim",), col=2, row=1)t_fig.update_layout( template="plotly_white", height=300, autosize=True, margin=dict(t=45, b=30, l=50, r=20), hovermode="closest", hoverlabel=dict(bgcolor="white", font_size=16, namelength=0),)t_fig.update_xaxes(tickformat="d", title_text="Day", col=1, row=1)t_fig.update_xaxes(tickformat=".1f", title_text="Index-to-Control (ITC)",range=itc_range, col=2, row=1)t_treat_min =float(treat_plot_df["conversion_rate"].min())t_treat_max =float(treat_plot_df["conversion_rate"].max())t_fig.update_yaxes(tickformat=".1%", title_text="Conversion Rate",range=[t_treat_min, t_treat_max], col=1, row=1)t_fig.update_yaxes(title_text="", showticklabels=False, col=2, row=1)t_fig``````{python}#| output: asisprint(f'<script>window.__treatSimData = {json.dumps(treat_sim_data)}; window.__treatItcPerSim = {json.dumps(treat_itc_per_sim)}; window.__itcDensity = {json.dumps(itc_density)};</script>')``````{=html}<div style="margin: 0.25rem 0 0.5rem 0; display: flex; gap: 2rem; flex-wrap: wrap;"> <div style="display:flex; align-items:center; gap:0.75rem; flex:1; min-width:200px;"> <label style="font-size:0.85rem; font-weight:600; white-space:nowrap;"> Simulation: <span id="t-sim-label">1</span> </label> <input id="t-sim-slider" type="range" min="1" max="100" step="1" value="1" style="flex:1; accent-color:#1e46a0;"> </div></div><script>(function() { function attachTreatment() { const tplot = document.querySelector('#treatment-collapse .plotly-graph-div'); if (!tplot || !tplot._fullData) { setTimeout(attachTreatment, 300); return; } Plotly.Plots.resize(tplot); const simSlider = document.getElementById('t-sim-slider'); const simLabel = document.getElementById('t-sim-label'); function showSim(idx) { const simNum = idx + 1; const d = window.__treatSimData[String(simNum)]; const itc = window.__treatItcPerSim[String(simNum)]; const pct = (d.true_mean * 100).toFixed(2) + '%'; const color = itc >= 100 ? 'green' : 'red'; Plotly.restyle(tplot, { x: [d.x], y: [d.y], name: [`Sim ${simNum}`] }, [0]); Plotly.restyle(tplot, { y: [[d.true_mean, d.true_mean]] }, [1]); Plotly.restyle(tplot, { x: [[itc]], 'marker.color': [color] }, [3]); Plotly.relayout(tplot, { 'annotations[2].y': d.true_mean, 'annotations[2].text': `True mean (${pct})`, 'annotations[3].x': itc, 'annotations[3].text': `True Effect: ${itc.toFixed(2)}`, 'annotations[3].font.color': color, 'annotations[3].arrowcolor': color, 'annotations[3].bordercolor': color, }); } simSlider.addEventListener('input', function() { simLabel.textContent = this.value; showSim(parseInt(this.value) - 1); const cSlider = document.getElementById('sim-slider'); const cLabel = document.getElementById('sim-label'); if (cSlider && cSlider.value !== this.value) { cSlider.value = this.value; if (cLabel) cLabel.textContent = this.value; cSlider.dispatchEvent(new Event('input')); } const pvSlider = document.getElementById('pval-sim-slider'); const pvLabel = document.getElementById('pval-sim-label'); if (pvSlider && pvSlider.value !== this.value) { pvSlider.value = this.value; if (pvLabel) pvLabel.textContent = this.value; pvSlider.dispatchEvent(new Event('input')); } }); showSim(0); } const tc = document.getElementById('treatment-collapse'); if (tc) { tc.addEventListener('shown.bs.collapse', attachTreatment); } else { attachTreatment(); }})();</script></div></div>``````{=html}<script>document.addEventListener('DOMContentLoaded', function() { const tc = document.getElementById('treatment-collapse'); const th = document.getElementById('treatment-accordion-header'); const tb = document.getElementById('treatment-accordion-btn'); tc.addEventListener('show.bs.collapse', () => { th.style.borderBottom = 'none'; th.style.borderRadius = '6px 6px 0 0'; tb.querySelector('span:first-child').textContent = 'Hide Random Walks of Treatment'; }); tc.addEventListener('hide.bs.collapse', () => { th.style.borderBottom = '1.5px dashed #90b8e0'; th.style.borderRadius = '6px'; tb.querySelector('span:first-child').textContent = 'Show Random Walks of Treatment'; });});</script>``````{=html}<div class="mt-3 mb-2" id="pvalue-accordion-wrap" style="display:none;"> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0;" id="pvalue-accordion-header"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" id="pvalue-accordion-btn" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; min-height:0; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#pvalue-collapse"> <span style="flex:1;">Show Random Walks of Probabilities</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">See how Pr(Best) evolves for each condition over time — the signal the stopping rule acts on.</span> </button> </div> </div> <div id="pvalue-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff;">``````{python}#| echo: falses1_pv_df = df.filter(pl.col("simulation") ==1).sort("day")pv_fig = go.Figure()pv_fig.add_trace(go.Scatter( x=s1_pv_df["day"].to_list(), y=[round(v, 6) for v in s1_pv_df["condition_1_pr_positive_diff"].to_list()], mode="lines", name="Treatment", line=dict(color="#e45c3a", width=2), hovertemplate="Pr(Best) = %{y:.3f}<extra></extra>", hoverlabel=dict(bgcolor="#c03a1e", font_color="white", bordercolor="#c03a1e", font_size=16),))pv_fig.add_trace(go.Scatter( x=s1_pv_df["day"].to_list(), y=[round(v, 6) for v in s1_pv_df["condition_0_pr_highest_diff"].to_list()], mode="lines", name="Control", line=dict(color="#1e46a0", width=2), hovertemplate="Pr(Best) = %{y:.3f}<extra></extra>", hoverlabel=dict(font_size=16),))pv_fig.update_layout( height=320, yaxis=dict(range=[0, 1.05], title="Pr(Best)", tickformat=".0%"), xaxis=dict(title="Day"), legend=dict(orientation="h", yanchor="bottom", y=1.01, xanchor="left", x=0), margin=dict(t=40, b=40, l=60, r=80), paper_bgcolor="white", plot_bgcolor="white",)pv_fig.update_yaxes(gridcolor="#eeeeee")pv_fig.update_xaxes(gridcolor="#eeeeee")pv_fig``````{python}#| output: asispval_sim_data = {}for sim inrange(1, N_SIMS +1): sim_df = df.filter(pl.col("simulation") == sim).sort("day") pval_sim_data[str(sim)] = {"x": sim_df["day"].to_list(),"treat": [round(v, 6) for v in sim_df["condition_1_pr_positive_diff"].to_list()],"control": [round(v, 6) for v in sim_df["condition_0_pr_highest_diff"].to_list()], }print(f'<script>window.__pvalSimData = {json.dumps(pval_sim_data)};</script>')``````{=html}<div style="margin: 0.25rem 0 0.5rem 0; display: flex; gap: 2rem; flex-wrap: wrap;"> <div style="display:flex; align-items:center; gap:0.75rem; flex:1; min-width:200px;"> <label style="font-size:0.85rem; font-weight:600; white-space:nowrap;"> Simulation: <span id="pval-sim-label">1</span> </label> <input id="pval-sim-slider" type="range" min="1" max="100" step="1" value="1" style="flex:1; accent-color:#1e46a0;"> </div></div><script>(function() { function attachPval() { const pvplot = document.querySelector('#pvalue-collapse .plotly-graph-div'); if (!pvplot || !pvplot._fullData) { setTimeout(attachPval, 300); return; } Plotly.Plots.resize(pvplot); const slider = document.getElementById('pval-sim-slider'); const label = document.getElementById('pval-sim-label'); function showSim(idx) { const simNum = idx + 1; const d = window.__pvalSimData[String(simNum)]; Plotly.restyle(pvplot, { x: [d.x], y: [d.treat] }, [0]); Plotly.restyle(pvplot, { x: [d.x], y: [d.control] }, [1]); } slider.addEventListener('input', function() { label.textContent = this.value; showSim(parseInt(this.value) - 1); const cSlider = document.getElementById('sim-slider'); const cLabel = document.getElementById('sim-label'); if (cSlider && cSlider.value !== this.value) { cSlider.value = this.value; if (cLabel) cLabel.textContent = this.value; cSlider.dispatchEvent(new Event('input')); } const tSlider = document.getElementById('t-sim-slider'); const tLabel = document.getElementById('t-sim-label'); if (tSlider && tSlider.value !== this.value) { tSlider.value = this.value; if (tLabel) tLabel.textContent = this.value; tSlider.dispatchEvent(new Event('input')); } }); showSim(0); } const pc2 = document.getElementById('pvalue-collapse'); if (pc2) { pc2.addEventListener('shown.bs.collapse', attachPval); const ph = document.getElementById('pvalue-accordion-header'); const pb2 = document.getElementById('pvalue-accordion-btn'); pc2.addEventListener('show.bs.collapse', () => { ph.style.borderBottom = 'none'; ph.style.borderRadius = '6px 6px 0 0'; pb2.querySelector('span:first-child').textContent = 'Hide Random Walks of Probabilities'; }); pc2.addEventListener('hide.bs.collapse', () => { ph.style.borderBottom = '1.5px dashed #90b8e0'; ph.style.borderRadius = '6px'; pb2.querySelector('span:first-child').textContent = 'Show Random Walks of Probabilities'; }); } else { attachPval(); }})();</script></div></div>```## How the Simulation WorksThe same simulated data is evaluated under 50 different stopping rules — Pr(Best) thresholds from 0.50 to 0.99. For each:1. **Burn-in:** No stopping before day `min_test_days = 7`2. **Daily check:** If either variant's Pr(Best) crosses the threshold, the experiment stops.3. **Rollout:** The winning variant is deployed to all remaining test users and the post-test audience.4. **Score:** Record the long-run value produced.The threshold with the highest average score across all simulations is the Optimal Stopping Rule.```{=html}<div class="mt-2 mb-3" id="simstats-accordion-wrap"> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0;" id="simstats-accordion-header"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button px-3 shadow-none" id="simstats-accordion-btn" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; min-height:0; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#simstats-collapse"> <span style="flex:1;">Simulation Statistics Definitions <span id="simstats-info-icon" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="Definitions for the metrics shown in the results table below." style="cursor:help; display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px; border-radius:50%; border:1px solid #1e46a0; color:#1e46a0; font-size:0.65rem; font-weight:700; vertical-align:middle; margin-left:5px;">i</span> </span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> </button> </div> </div> <div id="simstats-collapse" class="collapse show" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff;"> <table style="width:100%; font-size:0.9rem; border-collapse:collapse;"> <colgroup> <col style="width:22%;"> <col style="width:78%;"> </colgroup> <thead> <tr> <th style="padding:6px 12px; border-bottom:2px solid #ddd; text-align:left; color:#555;">Metric</th> <th style="padding:6px 12px; border-bottom:2px solid #ddd; text-align:left; color:#555;">Description</th> </tr> </thead> <tbody> <tr> <td style="padding:6px 12px; border-bottom:1px solid #eee; font-weight:600; vertical-align:top;">Projected Mean</td> <td style="padding:6px 12px; border-bottom:1px solid #eee; color:#444;">Average conversion rate across all users: both arms during the test, plus the winner deployed to the full post-test audience.</td> </tr> <tr> <td style="padding:6px 12px; border-bottom:1px solid #eee; font-weight:600; vertical-align:top;">Marginal Events vs No Test</td> <td style="padding:6px 12px; border-bottom:1px solid #eee; color:#444;">Incremental conversions above the baseline of never running an experiment.</td> </tr> <tr> <td style="padding:6px 12px; border-bottom:1px solid #eee; font-weight:600; vertical-align:top;">Correct Decision Rate</td> <td style="padding:6px 12px; border-bottom:1px solid #eee; color:#444;">How often the strategy deploys the correct variant. Covers the full distribution of effects — beneficial and harmful — not just the single MDE statistical power is designed for.</td> </tr> <tr> <td style="padding:6px 12px; font-weight:600; vertical-align:top;">Avg Trials (Days)</td> <td style="padding:6px 12px; color:#444;">Total user-experiment observations before stopping, with calendar duration in parentheses. Shorter experiments compound value by freeing up traffic faster.</td> </tr> </tbody> </table> </div></div><script>(function() { var _tip = document.getElementById('simstats-info-icon'); if (_tip && typeof bootstrap !== 'undefined') { new bootstrap.Tooltip(_tip); } var _btn = document.getElementById('simstats-accordion-btn'); var _chevron = _btn ? _btn.querySelector('.accordion-chevron') : null; var _collapse = document.getElementById('simstats-collapse'); if (_collapse) { _collapse.addEventListener('show.bs.collapse', function() { document.getElementById('simstats-accordion-header').style.borderRadius = '6px 6px 0 0'; if (_chevron) _chevron.style.transform = 'rotate(180deg)'; }); _collapse.addEventListener('hide.bs.collapse', function() { document.getElementById('simstats-accordion-header').style.borderRadius = '6px'; if (_chevron) _chevron.style.transform = ''; }); }})();</script>```## Optimization CurveWe swept every Pr(Best) threshold from 0.50 to 0.99. For each, we applied that stopping rule to all `{python} f"{NUMBER_SIMULATIONS:,}"` experiments — each drawing a different true effect, known only to us — and recorded the average long-run value produced across test traffic and the full post-test rollout. The curve plots the average across all simulations. The peak is the threshold that maximizes expected value.```{python}#| echo: falseimport timeimport plotly.express as pxfrom plotly.subplots import make_subplotsfrom simulationengine import prepare_stopping_decision_columns, evaluate_probability_thresholdsp_thresholds = np.arange(0.5, 1.00, 0.01) # probabilities, not p-valuesnormalizer = df["total_trials_cumsum"].max() + POST_TEST_NDAILY_N = MAX_TEST_N / NUMBER_TEST_DAYS # users per day across all conditionsdf_prepped = prepare_stopping_decision_columns( df.filter(pl.col("day") >= MIN_TEST_DAYS), metric_direction_improvement="increase",)df_prepped = df_prepped.sort(["simulation", "day"])df_prepped_fixed = prepare_stopping_decision_columns( df.filter(pl.col("day") >= NUMBER_TEST_DAYS), metric_direction_improvement="increase",)df_prepped_fixed = df_prepped_fixed.sort(["simulation", "day"])def run_loop(select_best_at_end, prepped_df=None):if prepped_df isNone: prepped_df = df_prepped r = evaluate_probability_thresholds( prepped_df, threshold_values=p_thresholds, metric_direction_improvement="increase", pre_sorted=True, select_best_at_end=select_best_at_end, ) outcomes = r["mean_potential_value"].to_list() trials = r["mean_trials"].to_list() med_trials = r["median_trials"].to_list() rate = r["match_rate"].to_list() avg_days = (r["mean_trials"] / DAILY_N).to_list()return outcomes, trials, med_trials, rate, avg_daysdef run_loop_df(df_p, select_best_at_end): r = evaluate_probability_thresholds( df_p, threshold_values=p_thresholds, metric_direction_improvement="increase", pre_sorted=True, select_best_at_end=select_best_at_end, ) outcomes = r["mean_potential_value"].to_list() trials = r["mean_trials"].to_list() med_trials = r["median_trials"].to_list() rate = r["match_rate"].to_list() avg_days = (r["mean_trials"] / DAILY_N).to_list()return outcomes, trials, med_trials, rate, avg_days_t0 = time.time()all_potential_outcomes, total_trials, median_trials, match_rate, all_avg_days = run_loop(True)ctrl_potential_outcomes, ctrl_trials, ctrl_med_trials, ctrl_match_rate, ctrl_avg_days = run_loop(False)fixed_potential_outcomes, fixed_trials, fixed_med_trials, fixed_match_rate, fixed_avg_days = run_loop(False, df_prepped_fixed)_runtime = _time_module.time() - _t_total_start_peak_idx =int(np.argmax(all_potential_outcomes))_opt_pr =float(p_thresholds[_peak_idx])def make_curve_fig(outcomes, trials, med_trials, rate, title, y_fmt=".3%", round_peak=False): y = np.array(outcomes) / normalizer x = p_thresholds peak =int(np.argmax(np.round(y *100, 4)) if round_peak else np.argmax(y)) marginal = (y - CONTROL_MEAN) * (POST_TEST_N + MAX_TEST_N) cd = np.column_stack([np.array(trials).astype(int), np.array(rate), np.array(med_trials), marginal]) hover_tmpl = (f"Pr(Best): %{{x:.2f}}<br>"f"Projected Mean: %{{y:{y_fmt}}}<br>""Marginal Events: %{customdata[3]:,.0f}<br>""Avg Trials: %{customdata[0]:,}<br>""Median Trials: %{customdata[2]:,}<br>""Correct Decision Rate: %{customdata[1]:.2%}<br>""<extra></extra>" ) f = px.line(x=x, y=y) f.add_scatter( x=[x[peak]], y=[y[peak]], mode="markers", marker=dict(symbol="square", size=14, color="#3a6fd8"), customdata=[[int(trials[peak]), rate[peak], int(med_trials[peak]), marginal[peak]]], hovertemplate=hover_tmpl, showlegend=False, ) f.update_traces(selector=dict(mode="lines"), customdata=cd, hovertemplate=hover_tmpl) f.update_layout(title_text=title, hoverlabel=dict(font_size=16, font_color="white")) f.update_xaxes(title_text="Pr(Best)", tickformat=".2f") f.update_yaxes(title_text="Projected Mean @ Year-End", tickformat=y_fmt)return fdef make_overlay_fig_n(o1, t1, m1, r1, o2, t2, m2, r2, o3, t3, m3, r3, norm):"""Optimal Stopping only: x = avg total trials, y = projected mean.""" _pal = ['#1f77b4'] _labels = ['Optimal Stopping'] _rp = [False] _x_pvals = p_thresholds fig = go.Figure()for _outcomes, _trials, _marginal, _rate, _col, _lbl, _round inzip( [o1], [t1], [m1], [r1], _pal, _labels, _rp ): _y = np.array(_outcomes) / norm _x = np.array(_trials) peak =int(np.argmax(np.round(_y *100, 4)) if _round else np.argmax(_y)) hover_tmpl = ("Avg Trials: %{x:,}<br>""Projected Mean: %{y:.3%}<br>""Pr(Best): %{customdata[0]:.2f}<br>""Marginal Events: %{customdata[1]:,.0f}<br>""Correct Decision Rate: %{customdata[2]:.2%}<br>""<extra></extra>" ) _cd = np.column_stack([_x_pvals, np.array(_marginal), np.array(_rate)]) fig.add_trace(go.Scatter( x=_x, y=_y, mode='lines+markers', name=_lbl, line=dict(color=_col, width=2), marker=dict(color=_col, size=4), customdata=_cd, hovertemplate=hover_tmpl, )) fig.add_trace(go.Scatter( x=[_x[peak]], y=[_y[peak]], mode='markers', showlegend=False, marker=dict(symbol='square', size=10, color=_col), customdata=[[_x_pvals[peak], _marginal[peak], _rate[peak]]], hovertemplate=hover_tmpl, )) fig.update_layout( showlegend=True, legend=dict( orientation="h", yanchor="top", y=-0.2, xanchor="center", x=0.5, font=dict(size=10), ), height=300, hoverlabel=dict(font_size=16, font_color="white"), )return figdef make_triple_curve_fig(o1, t1, m1, r1, o2, t2, m2, r2, o3, t3, m3, r3, norm, baseline=None, total_n=None, y_fmt=".3%"):"""Optimal Stopping only: x = p-value threshold, y = projected mean.""" _pal = ['#1f77b4'] _labels = ['Optimal Stopping'] _rp = [False] x = p_thresholds marginal_hover ="Marginal Events: %{customdata[3]:,.0f}<br>"if (baseline isnotNoneand total_n isnotNone) else"" fig = go.Figure()for _o, _t, _m, _r, _col, _lbl, _round inzip( [o1], [t1], [m1], [r1], _pal, _labels, _rp ): y = np.array(_o) / norm peak =int(np.argmax(np.round(y *100, 4)) if _round else np.argmax(y)) marginal = (y - baseline) * total_n if (baseline isnotNoneand total_n isnotNone) else np.zeros(len(y)) cd = np.column_stack([np.array(_t).astype(int), np.array(_r), np.array(_m), marginal]) hover_tmpl = (f"Pr(Best): %{{x:.2f}}<br>"f"Projected Mean: %{{y:{y_fmt}}}<br>"f"{marginal_hover}""Avg Trials: %{customdata[0]:,}<br>""Correct Decision Rate: %{customdata[1]:.2%}<br>""<extra></extra>" ) fig.add_trace(go.Scatter( x=x, y=y, mode='lines', name=_lbl, showlegend=False, line=dict(color=_col, width=2), customdata=cd, hovertemplate=hover_tmpl, )) fig.add_trace(go.Scatter( x=[x[peak]], y=[y[peak]], mode='markers', showlegend=False, marker=dict(symbol='square', size=10, color=_col), customdata=[[int(_t[peak]), _r[peak], int(_m[peak]), marginal[peak]]], hovertemplate=hover_tmpl, )) fig.update_xaxes(title_text="Pr(Best)", tickformat=".2f") fig.update_yaxes(title_text="Projected Mean @ Year-End", tickformat=y_fmt) fig.update_layout( showlegend=True, legend=dict(orientation="h", yanchor="top", y=-0.18, xanchor="center", x=0.5, font=dict(size=10)), height=350, hoverlabel=dict(font_size=16, font_color="white"), )return figdef make_scenario_row(o1, t1, m1, r1, o2, t2, m2, r2, o3, t3, m3, r3, norm, baseline, total_n): fig = make_triple_curve_fig(o1, t1, m1, r1, o2, t2, m2, r2, o3, t3, m3, r3, norm, baseline, total_n) fig.update_xaxes(title_text="Pr(Best)", tickformat=".2f") fig.update_yaxes(title_text="Projected Mean @ Year-End", tickformat=".3%") fig.update_layout( showlegend=True, legend=dict(orientation="h", yanchor="top", y=-0.18, xanchor="center", x=0.5, font=dict(size=10)), height=380, hoverlabel=dict(font_size=16, font_color="white"), )return figdef make_scenario_stats(o1, d1, t1, r1, o2, d2, t2, r2, o3, d3, t3, r3, norm, baseline, total_n):"""Returns a list of plain dicts (no HTML) — table is built in JS to avoid Quarto postprocessing."""def _peak(outcomes, avg_days, trials, rate, round_peak=False): y = np.array(outcomes) / norm x = p_thresholds peak =int(np.argmax(np.round(y *100, 4)) if round_peak else np.argmax(y))returnfloat(x[peak]), float((y[peak] - baseline) * total_n), float(avg_days[peak]), float(y[peak]), float(np.array(rate)[peak]), float(np.array(trials)[peak]) _pal = ['#1f77b4', '#2ca02c'] _lbls = ['Optimal Stopping', 'Fixed Sample'] _data = [_peak(o1, d1, t1, r1), _peak(o3, d3, t3, r3, round_peak=True)] rows = [ {"label": lbl, "color": c, "alpha": a, "marginal": m, "avg_days": d,"proj_mean": pm, "best_rate": br, "avg_trials": at, "is_ref": False}for (a, m, d, pm, br, at), c, lbl inzip(_data, _pal, _lbls) ] y3 = np.array(o3) / norm# Reference row: Fixed Sample at the conventional α = 0.05 (Pr(Best) = 0.95) idx_05 =int(np.argmin(np.abs((1- p_thresholds) -0.05))) rows.append({"label": "Fixed Sample","color": "#2ca02c","alpha": float(p_thresholds[idx_05]),"marginal": float((y3[idx_05] - baseline) * total_n),"avg_days": float(np.array(d3)[idx_05]),"proj_mean": float(y3[idx_05]),"best_rate": float(np.array(r3)[idx_05]),"avg_trials": float(np.array(t3)[idx_05]),"is_ref": True,"is_ref_50": False, })# Reference row: Fixed Sample at α = 0.50 (Pr(Best) = 0.50) — lenient benchmark idx_50 =int(np.argmin(np.abs(p_thresholds -0.50))) rows.append({"label": "Fixed Sample","color": "#2ca02c","alpha": float(p_thresholds[idx_50]),"marginal": float((y3[idx_50] - baseline) * total_n),"avg_days": float(np.array(d3)[idx_50]),"proj_mean": float(y3[idx_50]),"best_rate": float(np.array(r3)[idx_50]),"avg_trials": float(np.array(t3)[idx_50]),"is_ref": False,"is_ref_50": True, })return rowsrow1 = make_scenario_row( all_potential_outcomes, total_trials, median_trials, match_rate, ctrl_potential_outcomes, ctrl_trials, ctrl_med_trials, ctrl_match_rate, fixed_potential_outcomes, fixed_trials, fixed_med_trials, fixed_match_rate, normalizer, CONTROL_MEAN, POST_TEST_N + MAX_TEST_N,)from IPython.display import display, HTMLimport plotly.io as pio_stats_base = make_scenario_stats( all_potential_outcomes, all_avg_days, total_trials, match_rate, ctrl_potential_outcomes, ctrl_avg_days, ctrl_trials, ctrl_match_rate, fixed_potential_outcomes, fixed_avg_days, fixed_trials, fixed_match_rate, normalizer, CONTROL_MEAN, POST_TEST_N + MAX_TEST_N,)display(HTML(f"""<script>window.__wif_scenarios = window.__wif_scenarios || {{}};window.__wif_scenarios['400k_s1_cr60'] = {{ row1: {pio.to_json(row1)}, stats: {json.dumps(_stats_base)}}};</script><!-- scenario selector (hidden for now) --><div style="display:none;"><div style="display:flex; flex-direction:column; gap:0.6rem; margin-bottom:0.75rem; border: 1.5px dashed #90b8e0; border-radius:6px; padding:0.5rem 1rem;"> <div style="font-size:1.05rem; color:#444; font-style:italic;">Change the scenario — notice whether the peaks shift or hold.</div> <div style="display:flex; gap:2rem; align-items:flex-start; flex-wrap:wrap;"> <div style="display:flex; flex-direction:column; gap:0.25rem;"> <label style="font-size:0.85rem; color:#555; font-weight:600;">Baseline Conversion</label> <select id="wif-cr" class="form-select form-select-sm" style="width:auto; font-size:0.85rem;"> <option value="cr60" selected>60%</option> </select> </div> <div style="display:flex; flex-direction:column; gap:0.25rem;"> <label style="font-size:0.85rem; color:#555; font-weight:600;">Post-Test N</label> <select id="wif-ptn" class="form-select form-select-sm" style="width:auto; font-size:0.85rem;"> <option value="50k">50,000</option> <option value="100k" selected>100,000</option> <option value="150k">150,000</option> <option value="200k">200,000</option> <option value="300k">300,000</option> <option value="400k">400,000</option> <option value="600k">600,000</option> <option value="800k">800,000</option> <option value="1200k">1,200,000</option> <option value="1600k">1,600,000</option> </select> </div> <div style="display:flex; flex-direction:column; gap:0.25rem;"> <label style="font-size:0.85rem; color:#555; font-weight:600;">Expected Effects <span id="sigma-info-icon" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="Bigger swings result in faster, more accurate experiments — large effects are easier to detect, so the stopping rule fires sooner and picks the right winner more often." style="cursor:help; display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px; border-radius:50%; border:1px solid #888; color:#888; font-size:0.65rem; font-weight:700; vertical-align:middle; margin-left:3px;">i</span> </label> <select id="wif-sigma" class="form-select form-select-sm" style="width:auto; font-size:0.85rem;"> <option value="s1" selected>ITC ~ N(100, 1)</option> </select> </div> </div></div></div><div id="wif-row1" style="min-height:380px;"></div>"""))display(HTML(f"""<div style="margin-top:0.5rem; padding:0.75rem 1.25rem; background:#e8f0fe; border-left:4px solid #1e46a0; border-radius:4px; font-size:0.95rem; color:#1a1a2e; line-height:1.6;"> Given our experiment design, the curve peaks at <strong>Pr(Best) = {_opt_pr:.2f}</strong> — the threshold that balances speed and precision. Stop sooner and you trade accuracy for time. Wait longer and the extra certainty doesn't pay for itself.</div>"""))display(HTML(f"""<script>(function() {{}})();</script><script>(function initWif() {{ if (typeof Plotly === 'undefined') {{ setTimeout(initWif, 200); return; }} function renderScenario(key) {{ var s = window.__wif_scenarios && window.__wif_scenarios[key]; if (!s) {{ setTimeout(function() {{ renderScenario(key); }}, 200); return; }} var r1 = s.row1; r1.layout.height = 380; Plotly.react('wif-row1', r1.data, r1.layout); var opt = s.stats.find(function(d) {{ return !d.is_ref && !d.is_ref_50 && d.label === 'Optimal Stopping'; }}); var ref50 = s.stats.find(function(d) {{ return d.is_ref_50; }}); var ref = s.stats.find(function(d) {{ return d.is_ref; }}); var _tdl = 'style="padding:6px 12px;color:#444;font-weight:600;border-bottom:1px solid #eee;text-align:left;white-space:nowrap;"'; var _tdo = 'style="padding:6px 12px;text-align:center;font-weight:700;border-bottom:1px solid #eee;"'; var _tdr = 'style="padding:6px 12px;text-align:center;font-weight:700;border-bottom:1px solid #eee;"'; var _thl = 'style="padding:6px 12px;font-weight:700;border-bottom:2px solid #ccc;text-align:left;white-space:nowrap;"'; var _thc = 'style="padding:6px 12px;font-weight:700;border-bottom:2px solid #ccc;text-align:center;white-space:nowrap;"'; var rows = [ ['Projected Mean', opt ? (opt.proj_mean * 100).toFixed(3) + '%' : '—', ref ? (ref.proj_mean * 100).toFixed(3) + '%' : '—', ref50 ? (ref50.proj_mean * 100).toFixed(3) + '%' : '—'], ['Marginal Events vs No Test', opt ? Math.round(opt.marginal).toLocaleString() : '—', ref ? Math.round(ref.marginal).toLocaleString() : '—', ref50 ? Math.round(ref50.marginal).toLocaleString() : '—'], ['Correct Decision Rate', opt ? (opt.best_rate * 100).toFixed(1) + '%' : '—', ref ? (ref.best_rate * 100).toFixed(1) + '%' : '—', ref50 ? (ref50.best_rate * 100).toFixed(1) + '%' : '—'], ['Avg Trials (Days)', opt ? Math.round(opt.avg_trials).toLocaleString() + ' (' + opt.avg_days.toFixed(0) + ' days)' : '—', ref ? Math.round(ref.avg_trials).toLocaleString() + ' (' + ref.avg_days.toFixed(0) + ' days)' : '—', ref50 ? Math.round(ref50.avg_trials).toLocaleString() + ' (' + ref50.avg_days.toFixed(0) + ' days)' : '—'], ].map(function(r) {{ return '<tr>' + '<td ' + _tdl + '>' + r[0] + '</td>' + '<td ' + _tdo + '>' + r[1] + '</td>' + '<td ' + _tdr + '>' + r[2] + '</td>' + '<td ' + _tdr + '>' + r[3] + '</td>' + '</tr>';}}).join(''); var optColor = opt ? opt.color : '#1f77b4'; var refColor = ref ? ref.color : '#2ca02c'; document.getElementById('wif-stats1').innerHTML = '<' + 'table style="width:100%;font-size:0.85rem;border-collapse:collapse;margin-top:0.4rem;">' + '<thead><tr>' + '<th ' + _thl + '></th>' + '<th ' + _thc + ' style="color:' + optColor + ';">Optimal Stopping</th>' + '<th ' + _thc + ' style="color:' + refColor + ';">Fixed Sample (α = 0.05)</th>' + '<th ' + _thc + ' style="color:' + refColor + ';">Fixed Sample (α = 0.50)</th>' + '</tr></thead><tbody>' + rows + '</tbody></' + 'table>';}} function getKey() {{ return document.getElementById('wif-ptn').value + '_' + document.getElementById('wif-sigma').value + '_' + document.getElementById('wif-cr').value;}} renderScenario('100k_s1_cr60'); document.getElementById('wif-ptn').addEventListener('change', function() {{ renderScenario(getKey()); }}); document.getElementById('wif-sigma').addEventListener('change', function() {{ renderScenario(getKey()); }}); document.getElementById('wif-cr').addEventListener('change', function() {{ renderScenario(getKey()); }}); var _tip = document.getElementById('sigma-info-icon'); if (_tip && typeof bootstrap !== 'undefined') {{ new bootstrap.Tooltip(_tip); }}}})();</script>"""))``````{python}#| echo: false# Additional baseline conversion rates: 5%, 30%, and 65%def _peak_stats_cr(outcomes, avg_days_list, norm, cr_val, ptn_val, round_peak=False): y = np.array(outcomes) / norm x = p_thresholds peak =int(np.argmax(np.round(y *100, 4)) if round_peak else np.argmax(y)) opt_pr_best = x[peak] marginal_events = (y[peak] - cr_val) * (ptn_val + MAX_TEST_N) avg_d = avg_days_list[peak]returnf"""<div style="text-align:center; font-size:0.9rem; color:#333; margin-top:0.25rem;"> Pr(Best): <strong>{opt_pr_best:.2f}</strong> | Marginal Events vs No Test: <strong>{marginal_events:,.0f}</strong> | Avg(Days): <strong>{avg_d:.1f}</strong> </div>"""_cr_combos = [(0.60, 'cr60')]_sigma_combos = [(ITC_SE, 's1')]_ptn_combos = [(50_000, '50k'), (100_000, '100k'), (150_000, '150k'), (200_000, '200k'), (300_000, '300k'), (400_000, '400k'), (600_000, '600k'), (800_000, '800k'), (1_200_000, '1200k'), (1_600_000, '1600k')]for _cr_val, _cr_key in _cr_combos:for _itc_se_val, _sigma_key in _sigma_combos: _n_sims_loop = NUMBER_SIMULATIONS _df_tmp = se.create_simulation_df( number_test_days=NUMBER_TEST_DAYS, number_simulations=_n_sims_loop, seed=SEED, condition_parameters=[{"mean": _cr_val, "trials": int(MAX_TEST_N * SPLIT)}], ) _params_tmp = {"itc_mean": ITC_MEAN, "itc_se": _itc_se_val, "trials": int(MAX_TEST_N * SPLIT)} _df_tmp = se.add_random_effect_condition(_df_tmp, _params_tmp) _df_tmp = se.add_cumulative_columns(_df_tmp) _df_tmp = se.compute_cumulative_std_rates(_df_tmp) _df_tmp = se.compute_delta_stats(_df_tmp) _df_tmp = se.compute_test_stats(_df_tmp) _df_tmp = se.calculate_remaining_test_potential(_df_tmp)for _ptn_val, _ptn_key in _ptn_combos: _df_ptn = se.calculate_launch_potential(_df_tmp, post_test_n=_ptn_val) _df_ptn = se.calculate_total_potential(_df_ptn) _norm = _df_ptn["total_trials_cumsum"].max() + _ptn_val _df_prepped_cr = prepare_stopping_decision_columns( _df_ptn.filter(pl.col("day") >= MIN_TEST_DAYS), metric_direction_improvement="increase", ).sort(["simulation", "day"]) _df_fixed_cr = prepare_stopping_decision_columns( _df_ptn.filter(pl.col("day") >= NUMBER_TEST_DAYS), metric_direction_improvement="increase", ).sort(["simulation", "day"]) _o1, _t1, _m1, _r1, _d1 = run_loop_df(_df_prepped_cr, True) _o2, _t2, _m2, _r2, _d2 = run_loop_df(_df_prepped_cr, False) _o3, _t3, _m3, _r3, _d3 = run_loop_df(_df_fixed_cr, False) _row_cr = make_scenario_row( _o1, _t1, _m1, _r1, _o2, _t2, _m2, _r2, _o3, _t3, _m3, _r3, _norm, _cr_val, _ptn_val + MAX_TEST_N, ) _stats_cr = make_scenario_stats( _o1, _d1, _t1, _r1, _o2, _d2, _t2, _r2, _o3, _d3, _t3, _r3, _norm, _cr_val, _ptn_val + MAX_TEST_N, ) _key =f"{_ptn_key}_{_sigma_key}_{_cr_key}" display(HTML(f"""<script>window.__wif_scenarios = window.__wif_scenarios || {{}};window.__wif_scenarios['{_key}'] = {{ row1: {pio.to_json(_row_cr)}, stats: {json.dumps(_stats_cr)}}};</script>"""))``````{=html}<div class="mt-5 mb-5" id="ptn-accordion-wrap"> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0;" id="ptn-accordion-header"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" id="ptn-accordion-btn" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; min-height:0; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#ptn-collapse"> <span style="flex:1;">Importance of Post-Test N <span id="ptn-info-icon" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="The larger the post-test audience, the more costly a wrong call. The stopping threshold rises to match — making early stops harder to earn as the stakes grow." style="cursor:help; display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px; border-radius:50%; border:1px solid #1e46a0; color:#1e46a0; font-size:0.65rem; font-weight:700; vertical-align:middle; margin-left:5px;">i</span> </span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> </button> </div> </div> <div id="ptn-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff;"><p style="font-size:1.05rem; color:#444; margin-bottom:1rem; line-height:1.6;">Post-Test N is the audience that sees the winning variant after the experiment ends — the size of the real-world rollout. It matters because the value of an experiment isn't just what happens during the test: it's the cumulative impact of deploying the winner to every user who follows. A wrong call during a test with a small rollout is a minor cost; the same wrong call with a million-person rollout is a much larger one. As Post-Test N grows, the stakes of each stopping decision rise — and the optimal threshold rises with it, demanding stronger evidence before committing.</p><div style="margin-bottom:1.5rem;"> <!-- scenario selector (hidden for now) --> <div style="display:none;"> <div style="display:flex; flex-direction:column; gap:0.6rem; margin-bottom:0.75rem; border: 1.5px dashed #90b8e0; border-radius:6px; padding:0.5rem 1rem;"> <div style="font-size:1.05rem; color:#444; font-style:italic;">Change the scenario — notice whether the peaks shift or hold.</div> <div style="display:flex; gap:2rem; align-items:flex-start; flex-wrap:wrap;"> <div style="display:flex; flex-direction:column; gap:0.25rem;"> <label style="font-size:0.85rem; color:#555; font-weight:600;">Baseline Conversion</label> <select id="ptn-cr" class="form-select form-select-sm" style="width:auto; font-size:0.85rem;"> <option value="cr60" selected>60%</option> </select> </div> <div style="display:flex; flex-direction:column; gap:0.25rem;"> <label style="font-size:0.85rem; color:#555; font-weight:function">Expected Effects</label> <select id="ptn-sigma" class="form-select form-select-sm" style="width:auto; font-size:0.85rem;"> <option value="s1" selected>ITC ~ N(100, 1)</option> </select> </div> <div style="display:flex; flex-direction:column; gap:0.25rem;"> <label style="font-size:0.85rem; color:#555; font-weight:600;">Metric</label> <select id="ptn-metric" class="form-select form-select-sm" style="width:auto; font-size:0.85rem;"> <option value="alpha" selected>Optimal Pr(Best)</option> <option value="days">Avg Days</option> <option value="marginal">Marginal Events</option> </select> </div> </div> </div> </div> <div id="ptn-alpha-chart"></div> <div id="ptn-takeaways-box" style="margin-top:0.75rem; padding:0.65rem 1rem; background:#f8f9fa; border-radius:6px; border-left:3px solid #3a6fd8; font-size:0.9rem; color:#333; line-height:1.75;"> <ul id="ptn-takeaways" style="margin:0; padding-left:1.2rem;"></ul> </div></div><script>(function () { var PTN_VALS = [50000, 100000, 150000, 200000, 300000, 400000, 600000, 800000, 1200000, 1600000]; var PTN_KEYS = ['50k', '100k', '150k', '200k', '300k', '400k', '600k', '800k', '1200k', '1600k']; var PTN_LABELS = ['50k', '100k', '150k', '200k', '300k', '400k', '600k', '800k', '1200k', '1600k']; var PTN_TICK_LBLS = ['50k', '', '', '200k', '300k', '400k', '600k', '800k', '1200k', '1600k']; var PAL = ['#1f77b4']; var STRATS = ['Optimal Stopping']; function getStat(ptn_key, sigma_key, cr_key, label, isRef) { var key = ptn_key + '_' + sigma_key + '_' + cr_key; var sc = window.__wif_scenarios && window.__wif_scenarios[key]; if (!sc) return null; return sc.stats.find(function (r) { return r.label === label && r.is_ref === isRef; }) || null; } function buildChart(cr_key, sigma_key, metric) { var traces = []; var isAlpha = (metric === 'alpha'); var isDays = (metric === 'days'); STRATS.forEach(function (lbl, i) { var vals = [], ht = []; PTN_KEYS.forEach(function (pk, j) { var s = getStat(pk, sigma_key, cr_key, lbl, false); if (s) { vals.push(isDays ? s.avg_days : (isAlpha ? s.alpha : s.marginal)); ht.push(lbl + '<br>Post-Test N: ' + PTN_LABELS[j] + '<br>Optimal Pr(Best): ' + s.alpha.toFixed(2) + '<br>Avg Days: ' + s.avg_days.toFixed(1) + '<br>Marginal Events: ' + Math.round(s.marginal).toLocaleString() + '<extra></extra>'); } }); traces.push({ x: isBar ? PTN_LABELS : PTN_VALS, y: vals, mode: 'lines+markers', name: lbl, visible: (!isAlpha && !isDays) || (lbl === 'Optimal Stopping') ? true : 'legendonly', line: { color: PAL[i], width: 2 }, marker: { symbol: 'square', size: 9, color: PAL[i] }, hovertemplate: ht }); }); var annotations = [], extraRight = 55; if (isAlpha) { } else if (isDays) { traces.push({ x: PTN_VALS, y: [20, 20, 20, 20, 20, 20, 20], mode: 'lines', name: 'Max test days (20)', showlegend: false, line: { color: '#aaa', width: 1.5, dash: 'dot' }, hovertemplate: 'Max test days = 20<extra></extra>' }); annotations.push({ x: PTN_VALS[PTN_VALS.length - 1], y: 20, xref: 'x', yref: 'y', text: 'Day 20', showarrow: false, font: { color: '#999', size: 11 }, xanchor: 'left', yanchor: 'bottom', xshift: 6 }); } else { // marginal: Fixed@0.05 reference bar var ref_m = [], ref_ht = []; PTN_KEYS.forEach(function (pk, j) { var s = getStat(pk, sigma_key, cr_key, 'Fixed Sample', true); ref_m.push(s ? s.marginal : null); ref_ht.push(s ? 'Fixed @ Pr(Best)=0.95<br>Post-Test N: ' + PTN_LABELS[j] + '<br>Marginal Events: ' + Math.round(s.marginal).toLocaleString() + '<extra></extra>' : '<extra></extra>'); }); traces.push({ x: PTN_LABELS, y: ref_m, type: 'bar', name: 'Fixed @ Pr(Best)=0.95', marker: { color: 'rgba(44,160,44,0.35)', line: { color: '#2ca02c', width: 1.5 } }, hovertemplate: ref_ht }); } var isBar = (metric === 'marginal'); // re-cast strategy traces as bars for marginal view if (isBar) { traces = traces.map(function (t) { if (t.type === 'bar') return t; var barName = t.name === 'Fixed Sample' ? 'Fixed Sample @ Pr(Best) = 0.50' : t.name; return Object.assign({}, t, { type: 'bar', x: PTN_LABELS, name: barName, marker: { color: t.line.color } }); }); } Plotly.react('ptn-alpha-chart', traces, { barmode: isBar ? 'group' : undefined, xaxis: isBar ? { title: { text: 'Post-Test N', font: { size: 12 }, standoff: 4 }, ticktext: PTN_TICK_LBLS, tickvals: PTN_LABELS, showgrid: false } : { tickvals: PTN_VALS, ticktext: PTN_TICK_LBLS, title: { text: 'Post-Test N', font: { size: 12 }, standoff: 4 }, showgrid: true, gridcolor: '#eee' }, yaxis: { title: { text: isAlpha ? 'Optimal Pr(Best)' : (isDays ? 'Avg Days to Stop' : 'Marginal Events vs No Test'), font: { size: 12 } }, tickformat: isAlpha ? '.2f' : (isDays ? '.1f' : ',.0f'), range: isAlpha ? [0.50, 1.02] : (isDays ? [0, 22] : undefined), showgrid: true, gridcolor: '#eee' }, annotations: annotations, legend: { orientation: 'h', x: 0.5, xanchor: 'center', y: -0.22, font: { size: 11 }, title: { text: '— click to show/hide —', font: { size: 11, color: '#555' }, side: 'top' }, bordercolor: '#999', borderwidth: 1.5, bgcolor: 'white' }, margin: { t: 20, b: 60, l: isAlpha ? 55 : (isDays ? 55 : 75), r: (isAlpha || isDays) ? 55 : 15 }, height: 320, paper_bgcolor: 'white', plot_bgcolor: 'white', hoverlabel: { font: { size: 16, color: 'white' } } }, { responsive: true, displayModeBar: false }); } var BULLETS = { alpha: [], marginal: [], days: [] }; function updateTakeaways(metric) { var ul = document.getElementById('ptn-takeaways'); var box = document.getElementById('ptn-takeaways-box'); if (!ul) return; var bullets = BULLETS[metric] || []; ul.innerHTML = bullets.map(function (b) { return '<li>' + b + '</li>'; }).join(''); if (box) box.style.display = bullets.length ? '' : 'none'; } function update() { var metric = document.getElementById('ptn-metric').value; buildChart( document.getElementById('ptn-cr').value, document.getElementById('ptn-sigma').value, metric ); updateTakeaways(metric); } ['ptn-cr', 'ptn-sigma', 'ptn-metric'].forEach(function (id) { document.getElementById(id).addEventListener('change', update); }); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', update); } else { setTimeout(update, 0); }})();// Sync Baseline Conversion and Expected Effects dropdowns between Results and Importance sections(function () { var _syncing = false; function syncTo(srcId, dstId) { var src = document.getElementById(srcId); var dst = document.getElementById(dstId); if (!src || !dst) return; src.addEventListener('change', function () { if (_syncing || dst.value === src.value) return; _syncing = true; dst.value = src.value; dst.dispatchEvent(new Event('change')); _syncing = false; }); } syncTo('wif-cr', 'ptn-cr'); syncTo('ptn-cr', 'wif-cr'); syncTo('wif-sigma', 'ptn-sigma'); syncTo('ptn-sigma', 'wif-sigma'); var _ptnTip = document.getElementById('ptn-info-icon'); if (_ptnTip && typeof bootstrap !== 'undefined') { new bootstrap.Tooltip(_ptnTip); } var _ptnBtn = document.getElementById('ptn-accordion-btn'); var _ptnChevron = _ptnBtn ? _ptnBtn.querySelector('.accordion-chevron') : null; var _ptnCollapse = document.getElementById('ptn-collapse'); if (_ptnCollapse) { _ptnCollapse.addEventListener('show.bs.collapse', function() { document.getElementById('ptn-accordion-header').style.borderRadius = '6px 6px 0 0'; if (_ptnChevron) _ptnChevron.style.transform = 'rotate(180deg)'; }); _ptnCollapse.addEventListener('hide.bs.collapse', function() { document.getElementById('ptn-accordion-header').style.borderRadius = '6px'; if (_ptnChevron) _ptnChevron.style.transform = ''; }); }})();</script>``````{=html} </div><!-- /ptn-collapse --></div><!-- /ptn-accordion-wrap -->```# Optimal Stopping vs Significance: A ComparisonThe same 100,000 simulated experiments — evaluated under Optimal Stopping and Fixed Sample Significance Testing. Neither strategy had access to the true effect we encoded. They only saw what any real analyst sees: the data as it accumulated.<div id="wif-stats1" style="margin-top:0.25rem;"></div>```{python}#| echo: false_opt = _stats_base[0]_ref05 = _stats_base[2]_ref50 = _stats_base[3]_marginal_ratio = _opt['marginal'] / _ref05['marginal']_day_ratio = _ref05['avg_days'] / _opt['avg_days']display(HTML(f"""<div style="margin-top:0.75rem; padding:0.75rem 1.25rem; background:#e8f0fe; border-left:4px solid #1e46a0; border-radius:4px; font-size:0.95rem; color:#1a1a2e; line-height:1.6;"> <ul style="margin:0; padding-left:1.25rem;"> <li>Optimal Stopping returns <strong>{_marginal_ratio:.1f}× as many marginal events</strong> as Significance Testing (α = 0.05) and takes <strong>half the time</strong>.</li> <li>Against Fixed Sample (α = 0.50), Optimal Stopping finishes <strong>{_ref50['avg_days'] - _opt['avg_days']:.0f} days sooner</strong>, producing <strong>{_opt['marginal'] - _ref50['marginal']:,.0f} more marginal events</strong>. <ul style="margin:0.25rem 0 0 0;"><li>Both strategies ship the same winner — so any difference in value is attributable entirely to early stopping.</li></ul> </li> </ul></div>"""))```## Why It WorksThe Optimal Stopping algorithm adapts to the signal it sees:- **Small effects** — the value gap between variants is narrow, so the cost of a wrong call is low. The algorithm rolls out the winner quickly, sacrificing some accuracy for speed.- **Large positive effects** — a clear winning condition emerges early. The algorithm stops, exploits the finding, and captures value before a Fixed Sample test would even reach significance.- **Large negative effects** — harm is detectable fast. The algorithm cuts traffic to the losing variant before it does meaningful damage.# ConclusionStopping rules are arbitrary without context. Fixed Sample Significance Testing borrows a threshold designed for science — not for maximizing revenue. It runs longer than necessary, defaults to control when the data already points elsewhere, and treats a threshold with no connection to long-run value as the decision criterion.The simulations show the cost. Compared to Fixed Sample Significance Testing (α = 0.05), Optimal Stopping ships the right variant more often, in fewer days, with more conversions over the full rollout window. **The right stopping rule is derived from your experiment design — baseline conversion, audience size, effect distribution, and rollout size. It maximizes expected value, not statistical certainty.**# Extras: The Optimization Toolkit```{python}#| echo: falsefrom IPython.display import display, HTML as _HTMLdisplay(_HTML(f'<div style="font-size:1.4rem; font-weight:700; color:#1e46a0; margin-bottom:1rem;">⚡ {NUMBER_SIMULATIONS:,} simulated experiments × 20 days each × two recipes — {_runtime:.1f}s</div>'))```To run your own optimization, swap in your experiment parameters — baseline conversion, Max Test N, Post-Test N, effect distribution — and the pipeline handles the rest.The whole thing is fully vectorized. Every simulation, every day, every threshold is computed as a single set of array operations — no Python for loops anywhere. Under the hood, `simulationengine` uses [Polars](https://pola.rs/) for columnar data transformations and NumPy for the numerical sweep.```{=html}<div class="mt-3 mb-2"> <!-- full pipeline standalone accordion --> <div style="border:1.5px dashed #90b8e0; border-radius:6px; overflow:hidden; margin-bottom:0.75rem;"> <div style="background:#f5f5f5;"> <button id="full-pipeline-btn" onclick="(function(){var el=document.getElementById('code-full-pipeline-collapse');var btn=document.getElementById('full-pipeline-chevron');var open=el.style.display==='none'||el.style.display==='';el.style.display=open?'block':'none';btn.style.transform=open?'rotate(180deg)':'rotate(0deg)';})()" style="background:none; border:none; width:100%; display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1rem; cursor:pointer;"> <div style="display:flex; flex-direction:column; align-items:flex-start;"> <span style="font-size:0.9rem; font-weight:600; color:#444;">Full Simulation Pipeline</span> <span style="font-size:0.8rem; font-weight:400; font-style:italic; color:#777;">The exact code that generated the optimization curves — from parameters to Pr(Best) sweep.</span> </div> <span id="full-pipeline-chevron" style="font-size:1.2rem; color:#888; transition:transform 0.2s; flex-shrink:0; margin-left:0.75rem;">⌄</span> </button> </div> </div> <div id="code-full-pipeline-collapse" style="display:none; border:1.5px dashed #90b8e0; border-top:none; border-radius:0 0 6px 6px; padding:1rem; background:#fff; margin-top:-0.75rem; margin-bottom:0.75rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;">Four steps: simulate `{python} f"{NUMBER_SIMULATIONS:,}"` experiments, compute daily running stats, score each potential stopping decision, then sweep every Pr(Best) threshold to find the one that maximizes long-run value.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import simulationengine as seimport numpy as npimport polars as pl# ── Parameters ────────────────────────────────────────────────────────CONTROL_MEAN = 0.60 # baseline conversion rateMAX_TEST_N = 100_000 # total users in the experimentSPLIT = 0.50 # 50/50 control/treatment splitMIN_TEST_DAYS = 7 # burn-in: no stopping before day 7NUMBER_TEST_DAYS = 20 # max experiment durationPOST_TEST_N = 100_000 # post-test audience sizeNUMBER_SIMULATIONS = 100_000SEED = 42ITC_MEAN = 100 # index-to-control mean (100 = no lift)ITC_SE = 1 # spread of treatment effects# ── Step 1: Simulate experiments ──────────────────────────────────────df = se.create_simulation_df( number_test_days=NUMBER_TEST_DAYS, number_simulations=NUMBER_SIMULATIONS, seed=SEED, condition_parameters=[{"mean": CONTROL_MEAN, "trials": int(MAX_TEST_N * SPLIT)}],)df = se.add_random_effect_condition(df, { "itc_mean": ITC_MEAN, "itc_se": ITC_SE, "trials": int(MAX_TEST_N * SPLIT),})# ── Step 2: Compute running stats ─────────────────────────────────────df = se.add_cumulative_columns(df)df = se.compute_cumulative_std_rates(df)df = se.compute_delta_stats(df)df = se.compute_test_stats(df)# ── Step 3: Score each stopping decision ──────────────────────────────df = se.calculate_remaining_test_potential(df)df = se.calculate_launch_potential(df, post_test_n=POST_TEST_N)df = se.calculate_total_potential(df)# ── Step 4: Find the optimal Pr(Best) threshold ───────────────────────p_thresholds = np.arange(0.5, 1.00, 0.01)df_prepped = se.prepare_stopping_decision_columns( df.filter(pl.col("day") >= MIN_TEST_DAYS), metric_direction_improvement="increase",).sort(["simulation", "day"])results = se.evaluate_probability_thresholds( df_prepped, threshold_values=p_thresholds, metric_direction_improvement="increase", pre_sorted=True, select_best_at_end=True,)optimal_threshold = p_thresholds[results["mean_potential_value"].arg_max()]print(f"Optimal Pr(Best) threshold: {optimal_threshold:.2f}")</code></pre> </div> <!-- uber toggle --> <div style="border:1.5px dashed #90b8e0; border-radius:6px; overflow:hidden;"> <div style="background:#f5f5f5;"> <button id="uber-code-btn" onclick="(function(){var el=document.getElementById('uber-code-collapse');var btn=document.getElementById('uber-code-chevron');var open=el.style.display==='none'||el.style.display==='';el.style.display=open?'block':'none';btn.style.transform=open?'rotate(180deg)':'rotate(0deg)';})()" style="background:none; border:none; width:100%; display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1rem; cursor:pointer;"> <div style="display:flex; flex-direction:column; align-items:flex-start;"> <span style="font-size:0.9rem; font-weight:600; color:#444;">Simulation Pipeline Code</span> <span style="font-size:0.8rem; font-weight:400; font-style:italic; color:#777;">Ten individual functions — each a reusable building block of the pipeline above.</span> </div> <span id="uber-code-chevron" style="font-size:1.2rem; color:#888; transition:transform 0.2s; flex-shrink:0; margin-left:0.75rem;">⌄</span> </button> </div> <div id="uber-code-collapse" style="display:none; padding:0.75rem;"> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-control-collapse"> <span style="flex:1;">Simulating the Control</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Each simulation of control is an independent binomial process — same true rate, but different random outcome.</span> </button> </div> </div> <div id="code-control-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;"><code>create_simulation_df</code> generates a DataFrame of daily binomial draws for one or more fixed-rate conditions. Each row is one day of one simulation.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import numpy as npimport polars as pldef create_simulation_df( number_test_days, number_simulations, condition_parameters, seed=None,): if seed is not None: np.random.seed(seed) # trials_per_day is the same for every day in the experiment n = np.array( [c["trials"] // number_test_days for c in condition_parameters] )[:, None] p = np.array([c["mean"] for c in condition_parameters])[:, None] rep = number_test_days * number_simulations n_mat = np.repeat(n, rep, axis=1) p_mat = np.repeat(p, rep, axis=1) # One binomial draw per (condition, day, simulation) samples = np.random.binomial(n=n_mat, p=p_mat) return pl.DataFrame( { "simulation": np.arange(rep) // number_test_days + 1, "day": np.arange(rep) % number_test_days + 1, **{ f"condition_{i}_value": samples[i] for i in range(len(condition_parameters)) }, **{ f"condition_{i}_trials": n_mat[i] for i in range(len(condition_parameters)) }, **{ f"condition_{i}_true_mean": np.full( rep, condition_parameters[i]["mean"] ) for i in range(len(condition_parameters)) }, } )</code></pre> </div> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-treatment-collapse"> <span style="flex:1;">Simulating the Treatment</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Each simulation of treatment is encoded with a different true effect — sometimes harmful, sometimes beneficial, often marginal.</span> </button> </div> </div> <div id="code-treatment-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;"><code>add_random_effect_condition</code> appends a treatment arm where the true effect varies across simulations but is fixed within one. The effect is drawn from an ITC (Index-to-Control) distribution: ITC = 100 → no lift; ITC ~ N(100, 1) → effects centered at parity with modest spread.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import numpy as npimport polars as pldef add_random_effect_condition(df, random_effect_parameters, seed=None): if seed is not None: np.random.seed(seed) n_simulations = int(df["simulation"].max()) n_days = int(df["day"].max()) # One ITC draw per simulation — the unknown true effect for that experiment itc_samples = ( np.random.normal( random_effect_parameters["itc_mean"], random_effect_parameters["itc_se"], n_simulations, ) / 100 ) # treatment_mean = (itc / 100) x control_mean control_mean = float(df["condition_0_true_mean"][0]) treatment_means = itc_samples * control_mean # Daily binomial draws; treatment_mean is constant across days within a simulation trials_per_day = random_effect_parameters["trials"] // n_days events = np.random.binomial( n=trials_per_day, p=treatment_means[:, None], size=(n_simulations, n_days), ) # Reshape to tall format to match the existing DataFrame next_condition = ( max( int(c.split("_")[1]) for c in df.columns if c.startswith("condition_") and c.split("_")[1].isdigit() ) + 1 ) return df.with_columns( [ pl.Series( f"condition_{next_condition}_value", events.flatten(), ), pl.Series( f"condition_{next_condition}_trials", np.full(n_simulations * n_days, trials_per_day), ), pl.Series( f"condition_{next_condition}_true_mean", np.repeat(treatment_means, n_days), ), ] )</code></pre> </div> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-cumulative-collapse"> <span style="flex:1;">Computing Cumulative Stats</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Converts daily draws into running totals — the input to every stopping rule.</span> </button> </div> </div> <div id="code-cumulative-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;"><code>add_cumulative_columns</code> converts daily event counts into running totals grouped by simulation. Each condition gets a cumulative event count, trial count, and conversion rate. Cross-condition totals and remaining trials are derived in a single vectorized pass — no loops.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import polars as pldef add_cumulative_columns(df): value_cols = [col for col in df.columns if col.endswith("_value")] trials_cols = [col for col in df.columns if col.endswith("_trials")] # Running totals per condition, grouped by simulation cumsum_exprs = [ pl.col(col).cum_sum().over("simulation").alias(f"{col}_cumsum") for col in value_cols + trials_cols ] df = df.with_columns(cumsum_exprs) # Cross-condition totals value_cumsums = [f"{c}_cumsum" for c in value_cols] trials_cumsums = [f"{c}_cumsum" for c in trials_cols] df = df.with_columns([ pl.sum_horizontal(value_cumsums).alias("total_value_cumsum"), pl.sum_horizontal(trials_cumsums).alias("total_trials_cumsum"), ]) # Trials remaining in the experiment window df = df.with_columns([ (pl.col("total_trials_cumsum").max().over("simulation") - pl.col("total_trials_cumsum")).alias("total_remaining_test_trials") ]) # Cumulative conversion rate per condition mean_exprs = [ (pl.col(f"{c}_cumsum") / pl.col(f"{c.replace('_value','_trials')}_cumsum")) .alias(f"{c.replace('_value','')}_mean_cumsum") for c in value_cols if f"{c.replace('_value','_trials')}" in trials_cols ] return df.with_columns(mean_exprs)</code></pre> </div> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-std-collapse"> <span style="flex:1;">Cumulative Standard Deviation</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Binomial std of the running conversion rate — input to z-scores and confidence intervals.</span> </button> </div> </div> <div id="code-std-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;"><code>compute_cumulative_std_rates</code> applies the binomial standard deviation formula <code>sqrt(p * (1 - p))</code> to each condition's running conversion rate. Note: this is the <em>std of the proportion</em>, not the standard error — divide by <code>sqrt(n)</code> to get SE.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import polars as pldef compute_cumulative_std_rates(df): mean_cumsum_cols = [col for col in df.columns if col.endswith("_mean_cumsum")] std_exprs = [ (pl.col(col) * (1 - pl.col(col))) .sqrt() .alias(col.replace("_mean_cumsum", "_std_cumsum")) for col in mean_cumsum_cols ] return df.with_columns(std_exprs)</code></pre> </div> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-delta-collapse"> <span style="flex:1;">Computing Delta Statistics</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Treatment effect size and combined standard error — inputs to z-scores and p-values.</span> </button> </div> </div> <div id="code-delta-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;"><code>compute_delta_stats</code> computes two things for each treatment condition vs control: the standard error (<code>SE = std / sqrt(n)</code>) and the delta statistics (<code>delta_mean = mean_i - mean_0</code>, <code>delta_se = sqrt(SE_i² + SE_0²)</code>). These are the direct inputs to z-scores and p-values — this function does not compute those itself.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import polars as pldef compute_delta_stats(df): mean_cols = [c for c in df.columns if c.endswith("_mean_cumsum")] # SE = std / sqrt(trials) for every condition se_exprs = [ (pl.col(f"condition_{i}_std_cumsum") / pl.col(f"condition_{i}_trials_cumsum").sqrt()) .alias(f"condition_{i}_se_cumsum") for i in [int(c.split("_")[1]) for c in mean_cols if c.startswith("condition_")] if f"condition_{i}_std_cumsum" in df.columns ] df = df.with_columns(se_exprs) # Delta mean and delta SE for each treatment vs condition_0 (control) treatment_nums = [ int(c.split("_")[1]) for c in mean_cols if c.startswith("condition_") and int(c.split("_")[1]) > 0 ] delta_exprs = [] for i in treatment_nums: delta_exprs += [ (pl.col(f"condition_{i}_mean_cumsum") - pl.col("condition_0_mean_cumsum")) .alias(f"condition_{i}_delta_mean"), (pl.col(f"condition_{i}_se_cumsum").pow(2) + pl.col("condition_0_se_cumsum").pow(2)) .sqrt() .alias(f"condition_{i}_delta_se"), ] return df.with_columns(delta_exprs)</code></pre> </div> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-teststats-collapse"> <span style="flex:1;">Computing Test Statistics</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Converts delta stats into Pr(Best) and expected gain/loss — the signal the stopping rule acts on.</span> </button> </div> </div> <div id="code-teststats-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;"><code>compute_test_stats</code> converts delta statistics into probabilities and expected values using the normal CDF. For each treatment: <code>Pr(treatment > control) = Φ(delta_mean / delta_se)</code>. Expected gain and loss use truncated normal formulas. These are the columns the stopping rule reads — it stops when <code>Pr(Best)</code> crosses the threshold α.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import numpy as npimport polars as plfrom scipy.stats import normdef compute_test_stats(df): delta_mean_cols = [c for c in df.columns if c.endswith("_delta_mean")] condition_nums = [int(c.split("_")[1]) for c in delta_mean_cols] pr_cols = [] for i in condition_nums: z = df[f"condition_{i}_delta_mean"].to_numpy() / df[f"condition_{i}_delta_se"].to_numpy() pr = norm.cdf(z) df = df.with_columns([ pl.lit(pr).alias(f"condition_{i}_pr_positive_diff"), pl.lit(1 - pr).alias(f"condition_{i}_pr_lowest_diff"), ]) pr_cols.append(f"condition_{i}_pr_positive_diff") # Expected gain and loss (truncated normal) pdf_z = norm.pdf(z) df = df.with_columns([ pl.lit(np.abs(pr * (df[f"condition_{i}_delta_mean"].to_numpy() + df[f"condition_{i}_delta_se"].to_numpy() * pdf_z / np.maximum(pr, 1e-10)))).alias(f"condition_{i}_expected_gain"), pl.lit(np.abs((1-pr) * (-df[f"condition_{i}_delta_mean"].to_numpy() + df[f"condition_{i}_delta_se"].to_numpy() * pdf_z / np.maximum(1-pr, 1e-10)))).alias(f"condition_{i}_expected_loss"), ]) # Control: Pr(best) = 1 - max(Pr(treatment > control)) if pr_cols: df = df.with_columns([ (1 - pl.max_horizontal(pr_cols)).alias("condition_0_pr_highest_diff"), pl.min_horizontal(pr_cols).alias("condition_0_pr_lowest_diff"), ]) return df</code></pre> </div><!-- /code-teststats-collapse --> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-remaining-collapse"> <span style="flex:1;">Remaining Test Potential</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Simulates conversions from deploying each condition to remaining test users — the opportunity cost of continuing.</span> </button> </div> </div> <div id="code-remaining-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;">For each condition, draws from <code>Binomial(total_remaining_test_trials, true_mean)</code> — one random realization of the value you'd capture by stopping now and deploying that condition to remaining test users.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import numpy as npimport polars as pldef calculate_remaining_test_potential(df, seed=None): if not isinstance(df, pl.DataFrame): raise TypeError(f"df must be a polars DataFrame, got {type(df)}") if "total_remaining_test_trials" not in df.columns: raise ValueError("df must contain 'total_remaining_test_trials' column") if seed is not None: np.random.seed(seed) true_mean_cols = [col for col in df.columns if col.endswith("_true_mean")] if not true_mean_cols: raise ValueError("df must contain at least one column ending with '_true_mean'") potential_exprs = [] for mean_col in true_mean_cols: condition_prefix = mean_col.replace("_true_mean", "") n = df["total_remaining_test_trials"].to_numpy().astype(int) p = np.clip(df[mean_col].to_numpy(), 0.0, 1.0) draws = np.random.binomial(n=n, p=p) potential_exprs.append( pl.lit(draws).alias(f"{condition_prefix}_test_potential_value") ) return df.with_columns(potential_exprs)</code></pre> </div> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-launch-collapse"> <span style="flex:1;">Launch Potential</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Simulates post-test conversions for each condition deployed to the full post-test audience.</span> </button> </div> </div> <div id="code-launch-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;">Draws from <code>Binomial(post_test_n, true_mean)</code> for each condition — one random realization of the value you'd capture by launching that condition to the post-test population. This is where stopping decisions have the most leverage.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import numpy as npimport polars as pldef calculate_launch_potential(df, post_test_n, seed=None): if not isinstance(df, pl.DataFrame): raise TypeError(f"df must be a polars DataFrame, got {type(df)}") if not isinstance(post_test_n, (int, np.integer)): raise TypeError(f"post_test_n must be an integer, got {type(post_test_n)}") if post_test_n < 0: raise ValueError(f"post_test_n must be non-negative, got {post_test_n}") if seed is not None: np.random.seed(seed) true_mean_cols = [col for col in df.columns if col.endswith("_true_mean")] if not true_mean_cols: raise ValueError("df must contain at least one column ending with '_true_mean'") potential_exprs = [] for mean_col in true_mean_cols: condition_prefix = mean_col.replace("_true_mean", "") p = np.clip(df[mean_col].to_numpy(), 0.0, 1.0) draws = np.random.binomial(n=post_test_n, p=p) potential_exprs.append( pl.lit(draws).alias(f"{condition_prefix}_launch_potential_value") ) return df.with_columns(potential_exprs)</code></pre> </div> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-total-collapse"> <span style="flex:1;">Total Potential</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Sums observed value, remaining test potential, and launch potential — the complete value score for each condition.</span> </button> </div> </div> <div id="code-total-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;"><code>total_potential = total_value_cumsum + test_potential + launch_potential</code>. The cumulative observed value is shared across all conditions; only the forward-looking components differ. The condition with the highest total potential is the one to deploy.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import polars as pldef calculate_total_potential(df): if not isinstance(df, pl.DataFrame): raise TypeError(f"df must be a polars DataFrame, got {type(df)}") if "total_value_cumsum" not in df.columns: raise ValueError("df must contain 'total_value_cumsum' column") test_potential_cols = [ col for col in df.columns if col.endswith("_test_potential_value") ] if not test_potential_cols: raise ValueError("df must contain at least one column ending with '_test_potential_value'") total_exprs = [] for test_col in test_potential_cols: condition_prefix = test_col.replace("_test_potential_value", "") launch_col = f"{condition_prefix}_launch_potential_value" if launch_col not in df.columns: raise ValueError(f"Missing required column: {launch_col}") total_exprs.append( (pl.col("total_value_cumsum") + pl.col(test_col) + pl.col(launch_col)) .alias(f"{condition_prefix}_total_potential_value") ) return df.with_columns(total_exprs)</code></pre> </div> <div class="accordion-item" style="border: 1.5px dashed #90b8e0; border-radius: 6px; background: #f0f6fd; margin-bottom: 0.5rem;"> <div class="accordion-header" style="margin:0;"> <button class="accordion-button collapsed px-3 shadow-none" style="font-size:0.9rem; font-weight:600; color:#1e46a0; background:transparent; height:auto; width:100%; display:flex; align-items:center; padding-top:0.5rem; padding-bottom:0.5rem; flex-wrap:wrap;" type="button" data-bs-toggle="collapse" data-bs-target="#code-evalprob-collapse"> <span style="flex:1;">Evaluating Probability Thresholds</span> <span class="accordion-chevron" style="flex-shrink:0; margin-left:0.75rem; font-size:1.8rem; line-height:1; transition:transform 0.2s;">⌄</span> <span style="width:100%; font-size:0.85rem; font-weight:400; font-style:italic; color:#555; margin-top:0.2rem;">Sweeps all Pr(Best) thresholds at once using NumPy broadcasting — produces the value curves shown in the graphs above.</span> </button> </div> </div> <div id="code-evalprob-collapse" class="collapse" style="border: 1.5px dashed #90b8e0; border-top: none; border-radius: 0 0 6px 6px; padding: 1rem; background: #fff; margin-bottom: 0.5rem;"> <p style="font-size:0.9rem; margin-bottom:0.75rem;">The core of the optimization loop. Rather than iterating over thresholds in Python, it stacks all Pr(Best) columns into a 3D NumPy array <code>(n_sims × n_days × n_conditions)</code>, finds the first crossing day per simulation per threshold in a single broadcast, then aggregates value, trial count, and decision accuracy across all thresholds simultaneously.</p> <pre style="background:#f8f8f8; border-radius:4px; padding:1rem; font-size:0.8rem; overflow-x:auto;"><code>import numpy as npimport polars as pldef evaluate_probability_thresholds( df_prepped, threshold_values, metric_direction_improvement="increase", pre_sorted=False, select_best_at_end=False,): if not isinstance(df_prepped, pl.DataFrame): raise TypeError(f"df_prepped must be a polars DataFrame, got {type(df_prepped)}") if metric_direction_improvement not in ["increase", "decrease"]: raise ValueError(f"metric_direction_improvement must be 'increase' or 'decrease'") if not pre_sorted: df_prepped = df_prepped.sort(["simulation", "day"]) n_sims = df_prepped["simulation"].n_unique() n_days = df_prepped.height // n_sims def _col2d(name): return df_prepped[name].to_numpy().reshape(n_sims, n_days) # Select probability columns based on metric direction if metric_direction_improvement == "increase": treatment_prob_cols = [ c for c in df_prepped.columns if c.endswith("_pr_positive_diff") and not c.startswith("condition_0_") ] control_prob_col = "condition_0_pr_highest_diff" else: treatment_prob_cols = [ c for c in df_prepped.columns if c.endswith("_pr_lowest_diff") and not c.startswith("condition_0_") ] control_prob_col = "condition_0_pr_lowest_diff" all_prob_cols = [control_prob_col] + treatment_prob_cols condition_nums = np.array([int(c.split("_")[1]) for c in all_prob_cols]) # Stack: (n_sims, n_days, n_conditions) prob_stack = np.stack([_col2d(c) for c in all_prob_cols], axis=2) max_prob = prob_stack.max(axis=2) # (n_sims, n_days) best_cond = condition_nums[prob_stack.argmax(axis=2)] # Cumulative max — first day cummax >= threshold is the stopping day cummax = np.maximum.accumulate(max_prob, axis=1) thresholds_arr = np.array(sorted(threshold_values)) # First-crossing index, clamped to last day for never-crossed sims raw_stop = (cummax[:, :, np.newaxis] < thresholds_arr).sum(axis=1) # (n_sims, n_thresholds) any_crossed = raw_stop < n_days stop_idx = np.minimum(raw_stop, n_days - 1) row = np.arange(n_sims)[:, np.newaxis] best_cond_at_stop = best_cond[row, stop_idx] winning_cond = np.where(any_crossed | select_best_at_end, best_cond_at_stop, 0) # Winning condition's total potential value at stopping day pot_cols = sorted( [c for c in df_prepped.columns if c.endswith("_total_potential_value") and c.startswith("condition_") and c.split("_")[1].isdigit()], key=lambda c: int(c.split("_")[1]), ) pot_stack = np.stack([_col2d(c) for c in pot_cols], axis=2) win_pot_at_stop = pot_stack[row, stop_idx, winning_cond] total_at_stop = _col2d("total_trials_cumsum")[row, stop_idx] true_cond_at_stop = _col2d("true_best_condition")[row, stop_idx] return pl.DataFrame({ "threshold": thresholds_arr, "mean_potential_value": win_pot_at_stop.mean(axis=0), "mean_trials": total_at_stop.mean(axis=0), "median_trials": np.median(total_at_stop, axis=0), "p80_trials": np.percentile(total_at_stop, 80, axis=0), "match_rate": (winning_cond == true_cond_at_stop).mean(axis=0), })</code></pre> </div> </div><!-- /uber-code-collapse --> </div><!-- /uber dashed border --></div>```# Acknowledgements1. The idea of designing experiments for value rather than significance is inspired by [Test & Roll: Profit-Maximizing A/B Tests](https://ron-berman.com/2020/01/26/test-and-roll/) by Ron Berman and Elea McDonnell Feit. Their core insight: for tactical experiments, the cost of a wrong call on a small effect is negligible — the cost of oversizing the test to satisfy α is not. This paper extends that spirit by making the stopping rule, not the sample size, the optimization target.2. My colleague [Joseph Powers, PhD](https://www.linkedin.com/in/joseph-t-powers/) introduced me to the idea that A/B testing is an optimization problem and fundamentally changed how I approach experimentation. He showed me the power of simulation studies for building real intuition — not just running tests. A generous mentor and an invaluable teammate.