All Lab projects

E-Commerce Analytics Lab

From raw e-commerce data to a business decision using BigQuery, Python, and Tableau.

PythonSQLBigQuerypandasPlotly.jsStatsmodelsTableau

The Question

How can an e-commerce business identify the customers, channels, and products responsible for its performance — and quantify where meaningful opportunities exist? This project treats itself as the analytics function supporting an e-commerce company: establish a performance baseline, find where value is being created or lost, test whether apparent patterns are real, and size what closing a real gap would actually be worth.

Architecture

TheLook eCommerce
BigQuery
SQL Data Models
Python → Plotly.js
Tableau → Executive BI
Business Findings

Executive Findings

Growth Is Volume-Driven, Not Economics-Driven

AOV and margin sit essentially flat across 8 years while order volume compounds, powered by both accelerating new-customer acquisition and a growing repeat-purchase base — plus real holiday seasonality once a partial-year confound is removed.

Purchase Frequency, Not Demographics, Is the Real Value Driver

The customer value ladder — Never ($0) → One-time ($85) → Repeat ($192) → Frequent ($342) — is purely behavioral. Gender, age, geography, and channel are statistically flat across every tier.

No Factor Predicts Who Becomes a Repeat Buyer — Timing Does

Gender, age, country, channel, first-order size, spend, and return status all come back null, jointly and individually. What actually differentiates outcomes is timing: the gap between purchases shrinks from a median 260 days (1st→2nd order) to 169 days (3rd→4th), and 41% of eventual repeaters take over a year to return. That's why the campaign below is broad and sustained, not narrowly targeted or a short post-purchase drip.

Discovered Optimization Opportunity

The recommended opportunity: retarget one-time buyers into repeat buyers. The population is real and fully identified — 29,721 one-time buyers who purchased in the last 24 months and haven't repeated yet — sized against a real historical baseline (27.6% natural 24-month repeat rate), not an assumption of zero. This is a scenario, not a forecast. The full analytical path behind it — performance baseline, acquisition, segmentation, the conversion funnel, statistical testing — is below, collapsed by default.

How: A Broad, Sustained Retargeting Campaign

Email (for the identified base) or paid retargeting ads, sent to the full one-time-buyer list rather than a narrowed segment — an exhaustive check found no factor (demographics, channel, first-order size, first-order return status) predicts who's more likely to come back, so targeting narrowly would only shrink reach for no accuracy gain. Run as a sustained 12-24 month lifecycle program, not a short post-purchase drip: only about 26% of eventual repeaters return within 90 days, and 41% take over a year, so a short win-back window would structurally miss most of the population it's meant to reach.

Incremental repeaters
Incremental revenue
Incremental profit

Computed directly in your browser from the live target-list size and the measured per-customer economics ($106.81 revenue / $55.51 profit per converted customer) — no server round trip.

The second purchase is the hard threshold — customers accelerate once past it.

Why the campaign needs a 12-24 month horizon, not a 90-day drip.

Supporting Analysis

01 · Performance Explorer

Revenue, orders, AOV, and margin over time — switch the metric with the dropdown on the chart.

Revenue seasonality, complete years only (2020-2025) — December peaks.

Order share by day of week — a modest weekend bump.

The order mix shifting from almost-entirely-new (2019) toward a growing repeat share (2026), at unchanged per-order economics throughout.

02 · Acquisition Quality

Search dominates on raw volume, but every quality metric — conversion, AOV, revenue per user, repeat rate — sits nearly flat across all five channels. Formally tested, not just eyeballed: chi-square p=0.578, Cramér's V = 0.005.

Volume — Search wins purely on scale.

Quality — switch metrics with the dropdown. Every one is flat.

03 · Customer/Segment Explorer

The value ladder is purely a frequency effect — darker bars are higher-frequency tiers, not a different kind of customer.

Return behavior barely moves revenue within a frequency tier.

Return behavior is bimodal — customers return everything or nothing.

Demographics don't differentiate value — every cut reads flat. Switch cuts with the dropdown.

04 · Conversion Analysis

Clearly, in order to purchase, users have to be identified. But since identification is already fixed at the very start of the session, it's unlikely to be a login happening there — no login/signup event exists in this dataset's taxonomy at all. No clear information was captured about how identification actually happens, but it's likely tied somehow to the purchasing flow itself rather than a separate checkout-page action.

A caught reasoning error worth stating plainly: an earlier pass treated "anonymous sessions never convert" as a growth opportunity — get more unidentified visitors to convert — as if identification and purchase were separable events with a gap to close between them. They aren't. Identification is fixed before a session's first tracked event and never changes mid-session, and every purchase in this dataset is, by construction, already identified — so this isn't a discovered gap so much as a restatement of how conversion is defined here. There's no unidentified-to-converted lever to pull at the cart or checkout step; any real intervention would have to happen before a session even starts, not during it.

0.00%Anonymous cart → purchase
99.31%Identified cart → purchase

The same near-total separation, shown as a literal outcome comparison.

05 · Statistical Findings

Separating "statistically detectable" from "practically meaningful" — a real null, a technically-significant-but-trivial coefficient, and one real small effect.

FactorTestp-valueEffect sizeVerdict
Acquisition channelChi-square (traffic_source x converted)5.78e-10.0054 (Cramer's V)null
Main conversion model (age+gender+channel+tenure)Logistic regression, LLR test1.86e-10.0001 (pseudo R-squared)null
tenure_days (within main conversion model)Logistic regression coefficient3.92e-21.0000 (odds ratio)significant but meaningless
Gender x purchaser revenueWelch's t-test + bootstrap CI1.05e-640.1268 (Cohen's d)real but small
Repeat-purchase joint model (age+gender+channel+country+first-order size)Logistic regression, LLR test4.84e-10.0001 (pseudo R-squared)null
Repeat-purchase: genderChi-square3.87e-1null
Repeat-purchase: age bandRange across 6 bandsrange 32.8%-33.9%null
Repeat-purchase: countryRange across top 10range 31.2%-35.0%null
Repeat-purchase: channelRange across 5 sourcesrange 31.7%-33.9%null
Repeat-purchase: first-order item countRangerange 33.2%-34.2%null
Repeat-purchase: first-order spend quartileRangerange 32.7%-33.9%null
Repeat-purchase: first-order return statusTwo-group comparison33.3% vs 33.7%null
Repeat-purchase: first-purchase categoryChi-square goodness of fit4.30e-1null

Rows testing the main conversion model, the repeat-purchase joint model, the channel/gender tests, and tenure_days were re-run live against the frozen snapshot for this page. The individual-factor repeat-purchase scan (age band, country, channel, first-order size, etc.) is carried from the already-verified exhaustive null sweep documented in the project's own methodology notes.

Behind the Analysis

Two SQL examples and three Python examples — not the full pipeline, just enough to show the work.

SQL — the return-rate discipline

-- Grain: one row per calendar day. Question 1 (business performance
-- baseline). Depends on fct_orders.

CREATE OR REPLACE TABLE `...marts_20260921.mart_daily_performance` AS
SELECT
  DATE(created_at) AS order_date,
  COUNT(DISTINCT order_id) AS orders,
  COUNT(DISTINCT user_id) AS customers,
  SUM(revenue) AS revenue,
  SUM(profit) AS gross_profit,
  SAFE_DIVIDE(SUM(revenue), COUNT(DISTINCT order_id)) AS aov,
  SAFE_DIVIDE(SUM(profit), SUM(revenue)) AS margin_pct,
  SAFE_DIVIDE(COUNTIF(has_return), COUNT(*)) AS order_return_rate
FROM `...marts_20260921.fct_orders`
WHERE NOT is_cancelled
GROUP BY order_date
ORDER BY order_date;

SQL — the funnel's terminal-outcome logic

-- Grain: one row per session. funnel_outcome takes the furthest real
-- stage reached, with purchase/cancel as the two mutually exclusive
-- terminal outcomes once a session reaches cart -- confirmed from real
-- event sequences, not assumed.

SELECT
  session_id,
  ANY_VALUE(user_id) IS NULL AS is_anonymous,
  LOGICAL_OR(event_type = 'cart') AS reached_cart,
  CASE
    WHEN LOGICAL_OR(event_type = 'purchase') THEN 'purchase'
    WHEN LOGICAL_OR(event_type = 'cancel') THEN 'cancel'
    WHEN LOGICAL_OR(event_type = 'cart') THEN 'abandoned_at_cart'
    WHEN LOGICAL_OR(event_type = 'product') THEN 'abandoned_at_product'
    ELSE 'abandoned_at_home'
  END AS funnel_outcome
FROM `...raw_snapshot_20260921.events`
GROUP BY session_id;

Python — testing the identification hypothesis

# Is identification the real bottleneck, or is checkout friction?
by_anon = query_df("""
    SELECT is_anonymous,
           COUNTIF(reached_cart) AS reached_cart,
           COUNTIF(reached_purchase) AS reached_purchase,
           SAFE_DIVIDE(COUNTIF(reached_purchase),
                       COUNTIF(reached_cart)) AS cart_to_purchase_rate
    FROM `marts_20260921.mart_funnel`
    WHERE reached_cart
    GROUP BY is_anonymous
""")
# identified: 99.31%   anonymous: 0.00% -- zero exceptions, 680,987 sessions

Python — statistically real vs. practically large

# Statistically significant is not the same as practically large.
t, p = stats.ttest_ind(male_rev, female_rev, equal_var=False)
d = (male_rev.mean() - female_rev.mean()) / np.sqrt(
    (male_rev.std() ** 2 + female_rev.std() ** 2) / 2)

rng = np.random.default_rng(42)
diffs = [rng.choice(male_rev.values, len(male_rev), replace=True).mean()
         - rng.choice(female_rev.values, len(female_rev), replace=True).mean()
         for _ in range(2000)]
# p=1.05e-64 (real), Cohen's d=0.127 (small) -- both true at once

Methodology

Dataset, snapshot, and known limitations

Dataset: Google's public bigquery-public-data.thelook_ecommerce — synthetic, not real transactions. The dataset is continuously refreshed, so every table was materialized into a dated snapshot before any analysis — all numbers on this page trace back to that fixed snapshot, not a live query.

Data model: seven raw tables roll up into ten BigQuery marts (three fact tables, two dimensions, five pre-aggregated marts) that Python and Tableau both consume, so neither can invent a slightly different version of "revenue" or "margin."

Statistical methodology: chi-square tests of independence, Kruskal-Wallis, binomial tests, Welch's t-test with bootstrap confidence intervals, Cohen's d alongside every significance test, and logistic regression with likelihood-ratio tests. Every p-value is reported with an effect size or a plain-English magnitude — a significant result without a stated magnitude was treated as an incomplete answer throughout.

Known limitations: this is synthetic data — some patterns (the exactly-one-product-view-per-session funnel, the coin-flip cancel/abandon split) read as generator artifacts, not realistic behavior, and are flagged as such rather than presented as business insight. No causal inference is claimed anywhere — the opportunity simulator above is explicitly scenario analysis (a stated assumption run through arithmetic), not a causal estimate of what a specific campaign would achieve. Return rate (12.06% of orders) is a real, sizable pattern with no identified driver anywhere checked — reported honestly as an open question.

The dataset (Google's public thelook_ecommerce) is synthetic, not a real company's transactions — stated plainly because this project demonstrates analytical methodology, engineering, and communication, not claims about a real business. Every number above traces back to a materialized, dated snapshot so the analysis stays reproducible rather than shifting under a continuously-refreshed public table.