Skip to content

About

Bayesian A/B testing pipeline on GCP - MovieLens 25M with closed-form Bayesian + multivariate CUPED + Cloud Run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

ExperimentIQ

A warehouse-native Bayesian A/B testing pipeline on GCP

Live dashboard


ExperimentIQ dashboard

A production-style experimentation pipeline that runs frequentist and Bayesian analysis with CUPED variance reduction, heterogeneous treatment effect detection, trust checks, and automated guardrail flagging. It is built end to end on Google Cloud (BigQuery, Cloud Storage, Cloud Run, Looker Studio) on real MovieLens 25M data, with a known treatment effect simulated on the variant arm so every estimate can be checked against the truth.


The business question

"We've built a new recommender (Variant B). Should we ship it?"

This is the most common A/B question at any recommender-driven company. The answer is rarely a clean yes or no. The value of an experimentation platform is in producing the right kind of nuance: the probability that B is better, the expected loss if we are wrong, segment-level effects, and guardrail health.

This project demonstrates that full loop at portfolio scale.


TL;DR: what the pipeline found

Metric Raw lift CUPED-adjusted P(B>A) Decision
Watch time (primary) +13.0% +9.1% 0.85 Ship to light users
Sessions (guardrail) +13.1% +10.0% 0.70 Inconclusive (yellow)

Recommendation produced by the pipeline:

PROCEED WITH CAUTION. Primary lift +9.1% (P(B>A)=0.85). Guardrail metric inconclusive (+10.0%, CI crosses zero). Consider a longer experiment or a segmented rollout. Segment analysis shows light users have stronger evidence than heavy users.


Why this experiment is interesting

The dataset is deliberately constructed so the raw aggregate lift is misleading. Pre-period covariates show A and B were balanced before assignment, but a chance imbalance in experiment-period activity inflates the raw lift to +13%. The true treatment effect coded into the data is +7% on watch time (with an extra +3% for light users) and -2% on sessions.

The pipeline's job is to recover that signal from noisy data. It does so through:

  • Multivariate CUPED with four pre-period covariates, which pulls the lift estimate from +13% down to +9%, closer to the true +7%
  • Bayesian inference that produces a calibrated P(B>A) of 0.85, even though the frequentist test fails to reject the null at alpha = 0.05
  • Segment-level analysis that surfaces the heterogeneity (low-activity users +23%, mid +10%, high +4%; light users +11%, heavy users +8%)
  • Guardrail flagging that catches the unstable sessions metric and returns a yellow status with a segmented recommendation

Real experiments are often underpowered. The skill that matters is making good decisions under uncertainty, not waiting for p < 0.05 to feel certain.


Design principles

Warehouse-native. All metric computation happens in BigQuery. Raw events are transformed into a star schema and aggregated metric tables inside the warehouse; the analysis job reads only those aggregated tables and writes results back to BigQuery. Metrics are defined once, in SQL, where the data lives.

Trust before results. A lift estimate is only reported alongside the checks that say whether it can be believed.

Decisions, not p-values. The output is a ship / hold / segment recommendation with the evidence behind it, written for a non-technical reader.


Trust checks

Every run reports these before the results are read:

Check What it guards against Result
Sample ratio mismatch (SRM) Broken assignment or logging Pass (chi-square = 0.205)
Pre-period balance Arms that differed before treatment Pass (balanced on pre-period covariates)
Guardrail metrics Shipping a win that hurts another metric Sessions flagged yellow
Outlier control A few heavy users dominating arm means p99 winsorization applied

Architecture

MovieLens 25M
      │
      ▼
GCS bucket (raw CSVs)
      │
      ▼
BigQuery raw layer (4 tables)
      │
      ▼
BigQuery staging (cohort, assignment, daily metrics)
      │
      ▼
BigQuery analytics (star schema: 3 dims + 2 facts)
      │
      ▼
Cloud Run job (Python: frequentist + CUPED + Bayesian + segments)
      │
      ▼
analytics.experiment_results
      │
      ▼
Looker Studio dashboard (public)

Components

Component Purpose Tech
Synthetic experiment build Real MovieLens behavior as control; injects a known counterfactual treatment effect (lift, novelty, heterogeneity, guardrail regression) into the variant arm BigQuery SQL
Star schema dim_users, dim_movies, dim_experiments, fact_assignments, fact_daily_metrics BigQuery
Analysis library Welch's t-test, univariate and multivariate CUPED, closed-form Bayesian, segment-level HTE Python (scipy, pandas, numpy)
Cloud Run job Containerized pipeline runs end to end in about 10s and writes structured results to BigQuery Docker, Artifact Registry, Cloud Run
Test suite 14 tests covering math correctness against scipy, CUPED variance reduction, and Bayesian probability recovery pytest
Dashboard Single-page executive dashboard with traffic-light decision logic, segment breakdowns, and a methodology table Looker Studio

Technical decisions worth calling out

Closed-form Bayesian instead of MCMC. For Normal-Normal A/B tests with weak priors, the posterior over the difference in means is analytically Student-t. The closed-form implementation runs in 5ms and matches PyMC sampling numerically (which took 23 minutes during development). The PyMC version is kept as bayesian_mcmc.py for reference. Closed-form posteriors are the practical choice for normal metrics on large-scale platforms, where thousands of metrics are evaluated per run.

Multivariate CUPED. Univariate CUPED with pre_watch_time_min alone achieved only 6% variance reduction (rho = 0.25), because the cohort filter compresses the covariate range. Multivariate CUPED with four pre-period covariates reaches 14% (multiple R = 0.38). This is more realistic than the textbook 40% figure: it reflects what production CUPED looks like when covariates are weak. Investigating why the reduction is modest, and reporting it honestly, is part of the project's value.

Stratified randomization on activity decile. Hash-based bucketing within NTILE(10) OVER (ORDER BY pre_n_ratings) keeps pre-period activity balanced across arms.

Winsorization at p99 (pooled). Watch time has a heavy tail. Capping at the pooled p99 stops single users from dominating arm averages without biasing one arm relative to the other.

Intent-to-treat analysis. Per-user totals include zero-activity users. Every assigned user contributes one value, including zero, which matches how production experimentation platforms handle exposure.

See DECISIONS.md for a fuller treatment of these and other tradeoffs.


How to reproduce

You need a GCP project with billing enabled and the bigquery, storage, run, and artifactregistry APIs enabled.

# 1. Setup
git clone https://github.com/Tussar98/experimentiq.git && cd experimentiq
python -m venv .venv && source .venv/bin/activate  # Windows: .venv\Scripts\Activate.ps1
pip install -e .
pip install -r requirements.txt

# 2. Build the warehouse
python scripts/01_download_movielens.py
python scripts/02_upload_to_gcs.py
python scripts/03_load_to_bigquery.py
python scripts/04_build_experiment.py
python scripts/05_build_star_schema.py

# 3. Run the analysis (writes to analytics.experiment_results)
python scripts/06_run_analysis.py

# 4. Or deploy to Cloud Run
docker build -t $IMAGE .
docker push $IMAGE
gcloud run jobs create experimentiq-analysis --image $IMAGE --region us-central1 \
  --service-account $SA_EMAIL \
  --set-env-vars "GCP_PROJECT_ID=$PROJECT_ID" \
  --set-env-vars "GCS_BUCKET=$BUCKET" \
  --set-env-vars "BQ_LOCATION=US"
gcloud run jobs execute experimentiq-analysis --region us-central1 --wait

# 5. Run tests
pytest tests -v

Project structure

experimentiq/
├── src/
│   └── analysis/
│       ├── data.py            # BigQuery loader, ExperimentData container
│       ├── frequentist.py     # Welch's t-test + CIs + power
│       ├── cuped.py           # univariate + multivariate CUPED
│       ├── bayesian.py        # closed-form Bayesian (production)
│       ├── bayesian_mcmc.py   # PyMC reference implementation
│       └── segments.py        # heterogeneous treatment effects
├── scripts/
│   ├── 01_download_movielens.py
│   ├── 02_upload_to_gcs.py
│   ├── 03_load_to_bigquery.py
│   ├── 04_build_experiment.py
│   ├── 05_build_star_schema.py
│   ├── 06_run_analysis.py
│   ├── main.py                # Cloud Run entrypoint
│   └── _run_analysis_entry.py # importable wrapper
├── tests/                     # 14 tests, runs in under 2 seconds
├── Dockerfile
├── requirements-prod.txt
├── pyproject.toml
├── DECISIONS.md
└── README.md

Caveats

This experiment is underpowered. With about 2,400 users and heavy-tailed engagement, the frequentist test fails to reject the null at alpha = 0.05 even though the true effect is real. The Bayesian and segment analyses do the decision-making work. On production traffic of millions of users, the same pipeline would produce a tight interval and a clearly significant result.

CUPED's modest variance reduction (14%) is structural to the cohort. Cohort filtering compresses the range of pre-period covariates and suppresses correlation.

The chance imbalance is the point. A naive frequentist analysis would say "fail to reject, don't ship." A naive aggregate-only Bayesian analysis on raw lift would say "P(B>A) = 0.91, ship." The pipeline does neither: it adjusts for the imbalance, surfaces the heterogeneity, flags the guardrail uncertainty, and produces a segmented recommendation (ship to light users, hold for heavy users).


Roadmap

  • Delta method for ratio metrics (e.g., watch time per session), where the analysis unit differs from the randomization unit
  • Sequential testing with always-valid inference, so experiments can be monitored without inflating false positives
  • Crossover check for users exposed to both variants, added to the trust-check table
  • Metric definitions as config (YAML compiled to BigQuery SQL), so new metrics are defined once without code changes
  • Better CUPED covariates: shorter pre-period windows (7 to 14 days vs 90), per-segment CUPED (high-activity users show rho > 0.4), or learned residual models
  • Holdout analysis to measure the cumulative value of shipped changes

Built with

Python 3.12 · GCP (BigQuery, Cloud Storage, Cloud Run, Artifact Registry) · Docker · scipy · pandas · numpy · pymc · pytest · Looker Studio

About

Bayesian A/B testing pipeline on GCP - MovieLens 25M with closed-form Bayesian + multivariate CUPED + Cloud Run

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages