Mar 01, 2026 a/b testing statistics tea-tasting python
Disclosure: I am also the author of tea-tasting.
This article compares four Python packages that are relevant to A/B test analysis: tea-tasting, Pingouin, statsmodels, and SciPy. It does not try to pick a universal winner. Instead, it clarifies what each package does well for common experimentation tasks and how much manual work is needed to produce production-style A/B test outputs.
It assumes familiarity with A/B testing basics, including randomization, p-values, and confidence intervals.
A/B test setting and analysis requirements #A/B tests in a nutshell #An A/B test compares two (or more) variants of a product change by randomly assigning experimental units to variants and measuring outcomes. In online experiments, the randomization unit is usually the user, and the standard assumption is that units are independent.
A typical workflow is:
Good references for this mindset include books and papers by Ron Kohavi and Alex Deng, especially on trustworthy experimentation, delta-method metrics, and CUPED.
Typical metric types and tests #The table below summarizes common A/B test metric families and the tests usually applied.
| Metric type | Examples | Typical test |
|---|---|---|
| Average | Average revenue per user, average orders per user | Welch's t-test (unequal-variance two-sample t-test) |
| Ratio of averages | Average revenue per order, average orders per session | Welch's t-test with variance from the delta method |
| Proportion | Proportion of users with at least one order | Asymptotic tests (z-test, G-test, Pearson's chi-squared) or exact tests (Boschloo, Barnard, Fisher) |
For each metric, analysts usually want the same core fields:
This output format is what makes A/B test analysis convenient for repeated use across many experiments.
A/B testing specifics #Some details matter a lot in real experimentation workflows.
With that context, the examples below use the same synthetic experiment dataset generated with tea-tasting. They intentionally do not use variance reduction (no CUPED), so the package comparisons stay focused on baseline analysis.
import tea_tasting as tt
data = tt.make_users_data(return_type="pandas", rng=42, n_users=5_000)
data["has_order"] = (data["orders"] > 0).astype(int)
control = data[data["variant"] == 0]
treatment = data[data["variant"] == 1]
print(data.head(3).to_string(index=False))Output:
user variant sessions orders revenue has_order
0 1 1 1 5.77 1
1 0 3 0 0.00 0
2 1 1 0 0.00 0The metrics used in the examples are:
orders_per_user: average orders per user.users_with_orders: proportion of users with at least one order.revenue_per_user: average revenue per user.revenue_per_order: average revenue per order (ratio of averages).All package examples below reuse data, control, and treatment from this setup.
tea-tasting is a package specifically designed for A/B test analysis. It targets experimentation workflows directly, with metrics, relative effects, CUPED, power analysis, and concise experiment-style outputs.
Best for: teams that want an A/B-testing-first workflow with minimal glue code.
This is the most compact example among the four packages because it provides A/B-specific metric classes and a high-level Experiment API.
experiment = tt.Experiment(
orders_per_user=tt.Mean("orders"),
users_with_orders=tt.Proportion("has_order", correction=False),
revenue_per_user=tt.Mean("revenue"),
revenue_per_order=tt.RatioOfMeans("revenue", "orders"),
)
result = experiment.analyze(data)
print(result)Output:
metric control treatment rel_effect_size rel_effect_size_ci pvalue
orders_per_user 0.511 0.556 8.8% [-0.74%, 19%] 0.0718
users_with_orders 0.334 0.352 5.4% [-2.4%, 14%] 0.181
revenue_per_user 5.06 5.64 11% [0.22%, 24%] 0.0455
revenue_per_order 9.91 10.2 2.5% [-3.0%, 8.3%] 0.389A/B testing specifics:
Experiment.solve_power and metric parameters for effect size, relative effect size, and sample size).tt.adjust_fdr and tt.adjust_fwer) for experiment results, including FDR and FWER procedures.Pingouin is a user-friendly statistical package focused on convenient inferential statistics in pandas-centric workflows. It is strong for common tests and effect sizes, but it is not an A/B-specific framework.
Best for: quick pandas-based analyses of standard statistical tests.
Pingouin has a convenient t-test interface and a contingency-table chi-squared helper. It does not provide a built-in ratio-of-averages test with delta-method variance, so the revenue_per_order example is omitted.
import pingouin as pg
orders_test = pg.ttest(
treatment["orders"],
control["orders"],
correction=True,
).iloc[0]
print(
"orders_per_user: "
f"control={control['orders'].mean():.3f} "
f"treatment={treatment['orders'].mean():.3f} "
f"effect_size="
f"{treatment['orders'].mean() - control['orders'].mean():.3f} "
f"effect_size_ci={orders_test['CI95']} "
f"pvalue={orders_test['p_val']:.4f}"
)Output:
orders_per_user: control=0.511 treatment=0.556 effect_size=0.045 effect_size_ci=[-0. 0.09] pvalue=0.0718_, _, tests = pg.chi2_independence(
data,
x="variant",
y="has_order",
correction=False,
)
pearson = tests.loc[tests["test"] == "pearson"].iloc[0]
print(
"users_with_orders: "
f"control={control['has_order'].mean():.3f} "
f"treatment={treatment['has_order'].mean():.3f} "
f"effect_size="
f"{treatment['has_order'].mean() - control['has_order'].mean():.3f} "
f"pvalue={pearson['pval']:.4f}"
)Output:
users_with_orders: control=0.334 treatment=0.352 effect_size=0.018 pvalue=0.1811Notes:
revenue_per_user uses the same pg.ttest(...) pattern as orders_per_user.revenue_per_order (ratio of averages) requires manual derivation if you want a statistically correct delta-method analysis.A/B testing specifics:
power_ttest, power_ttest2n, power_chi2), but not an A/B-specific multi-metric workflow.pg.multicomp (for example, Bonferroni, Holm, and FDR methods), but integration into an A/B reporting workflow is manual.statsmodels is a broad statistical modeling library with strong hypothesis testing, power analysis, and confidence interval utilities. It is less opinionated than an experimentation-specific package, which is a strength if you want building blocks and explicit control.
Best for: analysts who want mature statistical building blocks and are comfortable assembling a workflow.
The example below uses Welch-style t-tests for two average metrics and a risk-ratio test/CI for the proportion metric. revenue_per_order is omitted because there is no built-in A/B-style ratio-of-averages delta-method helper.
from statsmodels.stats.proportion import (
confint_proportions_2indep,
test_proportions_2indep,
)
from statsmodels.stats.weightstats import CompareMeans, DescrStatsW
def welch_summary(treatment_series, control_series):
cm = CompareMeans(
DescrStatsW(treatment_series),
DescrStatsW(control_series),
)
_, pvalue, _ = cm.ttest_ind(usevar="unequal")
ci_low, ci_high = cm.tconfint_diff(usevar="unequal")
return (
control_series.mean(),
treatment_series.mean(),
treatment_series.mean() - control_series.mean(),
ci_low,
ci_high,
pvalue,
)
for metric in ["orders", "revenue"]:
ctrl, trt, effect, ci_low, ci_high, pvalue = welch_summary(
treatment[metric],
control[metric],
)
print(
f"{metric}_per_user: "
f"control={ctrl:.3f} treatment={trt:.3f} effect_size={effect:.3f} "
f"effect_size_ci=[{ci_low:.3f}, {ci_high:.3f}] pvalue={pvalue:.4f}"
)Output:
orders_per_user: control=0.511 treatment=0.556 effect_size=0.045 effect_size_ci=[-0.004, 0.094] pvalue=0.0718
revenue_per_user: control=5.062 treatment=5.641 effect_size=0.579 effect_size_ci=[0.012, 1.146] pvalue=0.0455count1 = int(treatment["has_order"].sum())
nobs1 = len(treatment)
count0 = int(control["has_order"].sum())
nobs0 = len(control)
prop_test = test_proportions_2indep(
count1=count1,
nobs1=nobs1,
count2=count0,
nobs2=nobs0,
compare="ratio",
method="log",
)
prop_ci = confint_proportions_2indep(
count1=count1,
nobs1=nobs1,
count2=count0,
nobs2=nobs0,
compare="ratio",
method="log",
)
print(
"users_with_orders: "
f"control={control['has_order'].mean():.3f} "
f"treatment={treatment['has_order'].mean():.3f} "
f"rel_effect_size={prop_test.ratio - 1:.3f} "
f"rel_effect_size_ci=[{prop_ci[0] - 1:.3f}, {prop_ci[1] - 1:.3f}] "
f"pvalue={prop_test.pvalue:.4f}"
)Output:
users_with_orders: control=0.334 treatment=0.352 rel_effect_size=0.054 rel_effect_size_ci=[-0.024, 0.138] pvalue=0.1811A/B testing specifics:
TTestIndPower, NormalIndPower, GofChisquarePower, and more).statsmodels.stats.multitest, including multipletests and fdrcorrection).SciPy is a foundational scientific computing and statistics package used directly or indirectly by many higher-level libraries, including the others in this comparison. It provides robust hypothesis tests and exact tests, but it does not provide a high-level A/B testing workflow.
Best for: low-level building blocks and custom A/B analysis code.
This snippet shows a Welch t-test for orders_per_user and a Pearson chi-squared test for users_with_orders. Exact tests such as Fisher, Barnard, and Boschloo are also available in SciPy.
import numpy as np
from scipy import stats
orders_test = stats.ttest_ind(
treatment["orders"],
control["orders"],
equal_var=False,
)
orders_ci = orders_test.confidence_interval()
print(
"orders_per_user: "
f"control={control['orders'].mean():.3f} "
f"treatment={treatment['orders'].mean():.3f} "
f"effect_size="
f"{treatment['orders'].mean() - control['orders'].mean():.3f} "
f"effect_size_ci=[{orders_ci.low:.3f}, {orders_ci.high:.3f}] "
f"pvalue={orders_test.pvalue:.4f}"
)Output:
orders_per_user: control=0.511 treatment=0.556 effect_size=0.045 effect_size_ci=[-0.004, 0.094] pvalue=0.0718contingency = np.array(
[
[(control["has_order"] == 0).sum(), (control["has_order"] == 1).sum()],
[
(treatment["has_order"] == 0).sum(),
(treatment["has_order"] == 1).sum(),
],
]
)
chi2_res = stats.contingency.chi2_contingency(contingency, correction=False)
print(
"users_with_orders: "
f"control={control['has_order'].mean():.3f} "
f"treatment={treatment['has_order'].mean():.3f} "
f"effect_size="
f"{treatment['has_order'].mean() - control['has_order'].mean():.3f} "
f"pvalue={chi2_res.pvalue:.4f}"
)Output:
users_with_orders: control=0.334 treatment=0.352 effect_size=0.018 pvalue=0.1811Notes:
revenue_per_user uses the same stats.ttest_ind(..., equal_var=False) pattern as orders_per_user.revenue_per_order (ratio of averages) requires manual delta-method implementation.A/B testing specifics:
scipy.stats.false_discovery_control for BH/BY FDR adjustment, but broader multiple-comparison correction workflows are more limited than in statsmodels.ttest_ind_from_stats and chi-squared/exact tests on contingency tables), but not an A/B-specific aggregate workflow.SciPy underpins much of the Python statistics ecosystem. In principle, all the capabilities discussed here can be implemented with NumPy + SciPy plus custom code. The practical question is convenience and code verbosity. For that reason, the table below uses three labels:
built-in: directly supported in a way that fits common A/B analysis tasks.partial: some built-in support exists, but not as a complete or ergonomic A/B workflow.manual: possible, but requires custom implementation/glue code.| Feature | tea-tasting | Pingouin | statsmodels | SciPy |
|---|---|---|---|---|
| Power analysis to estimate required number of observations | built-in | built-in | built-in | manual |
| Welch's t-test or Student's t-test for analysis of averages | built-in | built-in | built-in | built-in |
| Welch's t-test with delta method for analysis of ratios of averages | built-in | manual | manual | manual |
| Two-sample proportion z-test, G-test, or Pearson's chi-squared test | built-in | built-in | built-in | built-in |
| Relative effect size confidence intervals | built-in | manual | partial | manual |
| Variance reduction with CUPED for analysis of averages and ratios of averages | built-in | manual | manual | manual |
| Multiple hypothesis testing correction (FWER/FDR p-value adjustment) | built-in | built-in | built-in | partial |
| Working with aggregated statistics instead of granular data | built-in | manual | partial | partial |
The four packages sit at different levels of abstraction.
If you run many experiments with multiple metrics and need consistent outputs, the main differentiator is not just statistical correctness. It is how much A/B-specific workflow a package gives you out of the box.
Inclusion criteria for the comparison #For transparency, here are the minimum criteria I used to decide which packages to include.
Scope note: This comparison focuses on frequentist A/B testing workflows. Bayesian-first experimentation frameworks are out of scope.
Excluded notable packages (and why):
Note: The maintenance and documentation notes in this section are assessed as of March 1, 2026.
Resources #The comparison above is based on the public documentation and APIs of the packages as of March 1, 2026. Current stable versions on PyPI at the time of writing:
A/B testing and statistics references:
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Comparing Python Type Checkers: Typing Spec Conformance | 0 | 10 | 21-03-2026 |
| 2 | Python Type Checker Comparison: Empty Container Inference | 0 | 10 | 02-03-2026 |
| 3 | PyPy: A new benchmark runner for PyPy | 0 | 7.35 | 20-06-2026 |
| 4 | A (biased) Pure Python Performance Comparison | 0 | 5.23 | 01-01-2026 |
| 5 | syrupy: The Sweeter pytest Snapshot Plugin | 0 | 10 | 03-04-2026 |
| 6 | flake8-lazy: Detect Lazy-Importable Modules in Python 3.15+ | 0 | 52.86 | 01-06-2026 |
| 7 | pytest-tia: Run Only the Tests Your Git Diff Actually Affects | 0 | 20 | 12-07-2026 |
| 8 | pytrendy: Trend Detection in Time Series Data | 0 | 10 | 20-06-2026 |
| 9 | cnsplots: Python Data Visualization for Complex Datasets | 0 | 10 | 10-08-2026 |
| 10 | What Every Python Developer Should Know About the CPython ABI | 0 | 10 | 19-07-2026 |