Data Pipeline: Download, Clean & Enrich¶
This notebook walks through the three-step pipeline that transforms raw Kaggle data into the enriched parquet file consumed by all downstream analysis.
Steps:
- Understand the raw dataset schema and its limitations
- Clean by removing mismatched assignments and deduplicating users
- Enrich with synthetic behavioral columns (device, country, revenue, etc.) for portfolio depth
Every decision is documented with its business rationale. Reproducibility is guaranteed via
seed=42 throughout.
import matplotlib
matplotlib.use('Agg')
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
import warnings
warnings.filterwarnings('ignore')
plt.style.use('seaborn-v0_8-whitegrid')
plt.rcParams['figure.figsize'] = (10, 5)
Step 1: Understanding the Raw Data¶
The original Kaggle dataset (ab_data.csv) contains roughly 294,000 rows, one per
user-experiment event. The experiment ran for 22 days in January 2017 and tests a new
e-commerce landing page design against the existing one.
The five raw columns are:
| Column | Type | Description |
|---|---|---|
user_id |
int | Unique user identifier (may repeat — duplicates exist) |
timestamp |
datetime | Event time |
group |
str | Experiment assignment: control or treatment |
landing_page |
str | Page actually served: old_page or new_page |
converted |
int | Binary outcome: 1 = user converted, 0 = did not |
Critical design contract: A user assigned to control must see old_page;
treatment must see new_page. Violations of this contract indicate logging errors
or experiment leakage and must be removed before any inference.
df_enriched = pd.read_parquet('../data/processed/ab_data_enriched.parquet')
# Show a sample of core columns — mirrors the raw schema before enrichment
raw_cols = ['user_id', 'timestamp', 'group', 'landing_page', 'converted']
df_enriched[raw_cols].head(10)
print(f"Total rows: {len(df_enriched):,}")
print(f"Columns: {list(df_enriched.columns)}")
print(f"\nData types:\n{df_enriched.dtypes}")
Step 2: Cleaning Decision — Removing Mismatched Rows¶
In the raw data, ~3,893 rows violate the assignment contract:
controlusers who were servednew_page(~1,965 rows)treatmentusers who were servedold_page(~1,928 rows)
Why we remove them, not recode them:
- Causal contamination: A control user who saw the new page received the treatment. Their outcome cannot be attributed to either variant cleanly.
- Bias in both directions: Leaving them inflates variance and distorts both conversion rate estimates — the direction of the bias is not predictable without knowing why the mismatch occurred.
- Minimal information loss: ~3,893 rows = 1.3% of the dataset. Removing them has negligible impact on statistical power.
Alternative considered: Reassigning mismatched rows to the variant they actually saw. This was rejected because it changes the intent-to-treat (ITT) estimand to an as-treated estimand — a valid choice, but not what the experiment was designed to measure.
# Simulate mismatch detection on the enriched data
# (The pipeline already removed them; we show what the detection rule looks like)
mismatches_control = df_enriched[
(df_enriched['group'] == 'control') & (df_enriched['landing_page'] == 'new_page')
]
mismatches_treatment = df_enriched[
(df_enriched['group'] == 'treatment') & (df_enriched['landing_page'] == 'old_page')
]
print(f"Remaining mismatches in clean data: {len(mismatches_control) + len(mismatches_treatment)}")
print("(All mismatches were removed in the cleaning step)")
print(f"\nFinal dataset shape: {df_enriched.shape}")
print(f"Control group: {(df_enriched['group']=='control').sum():,} users")
print(f"Treatment group: {(df_enriched['group']=='treatment').sum():,} users")
Step 3: Cleaning Decision — Deduplication¶
Some user_id values appear more than once in the raw data (e.g., a user who visited on
multiple days). When the same user has conflicting converted values across records, we
must choose a deduplication strategy.
Decision: keep the first record per user_id (sorted by timestamp).
Rationale:
- We are measuring first-exposure conversion — did the user convert the first time they saw the page? This matches the business question: "Does the new design convert visitors?"
- Alternative (keep last record) would conflate returning behavior with first-impression response, biasing results toward users with more sessions.
- Alternative (keep
converted=1if any record is 1) would overcount conversions, inflating both groups' rates and potentially masking the differential effect.
The conservative first-record approach is the most defensible for an intent-to-treat analysis.
Step 4: Synthetic Enrichment (seed=42)¶
The original 5-column dataset is sufficient for a basic statistical test, but a portfolio project needs to demonstrate segmentation analysis, Simpson's Paradox detection, and multi-dimensional breakdowns. We synthetically added:
| Column | Type | Description |
|---|---|---|
device_type |
str | mobile, desktop, tablet |
browser |
str | chrome, firefox, safari, edge |
country |
str | US, UK, DE, FR, BR |
revenue |
float | Revenue per user (0 if not converted, positive otherwise) |
session_duration_sec |
float | Time on page (seconds) |
pages_viewed |
int | Pages visited in session |
user_segment |
str | new_user, returning_user |
traffic_source |
str | organic, paid, social, email |
Why seed=42? Reproducibility. Every pipeline run generates identical synthetic values,
so analysis results are deterministic across environments and reviewers.
Key design choice — heterogeneous treatment effects were deliberately engineered:
- Mobile users have a stronger positive treatment effect than desktop users
- New users respond better to the redesigned page than returning users
- This creates a realistic Simpson's Paradox scenario: the aggregate lift (+2.4%) is primarily driven by new mobile users, while returning desktop users see minimal benefit
This design choice adds analytical depth without distorting the core frequentist result,
which is computed solely from the original converted column.
synthetic_cols = [
'device_type', 'browser', 'country', 'revenue',
'session_duration_sec', 'user_segment', 'traffic_source'
]
print("Synthetic column distributions:\n")
for col in ['device_type', 'browser', 'country', 'user_segment', 'traffic_source']:
print(f"{col}:")
print(df_enriched[col].value_counts().to_string())
print()
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for ax, group in zip(axes, ['control', 'treatment']):
data = df_enriched[df_enriched['group'] == group]['revenue']
ax.hist(data[data > 0], bins=50, alpha=0.7,
color='steelblue' if group == 'control' else 'coral')
ax.set_title(f'Revenue Distribution — {group.capitalize()}')
ax.set_xlabel('Revenue ($)')
ax.set_ylabel('Count')
ax.axvline(data.mean(), color='red', linestyle='--', label=f'Mean: ${data.mean():.2f}')
ax.legend()
plt.tight_layout()
plt.savefig('/tmp/revenue_dist.png', dpi=100, bbox_inches='tight')
plt.show()
print(f"Control mean revenue: ${df_enriched[df_enriched['group']=='control']['revenue'].mean():.4f}")
print(f"Treatment mean revenue: ${df_enriched[df_enriched['group']=='treatment']['revenue'].mean():.4f}")
Final Dataset Summary¶
The pipeline produces a single enriched parquet file with the following characteristics:
- 290,584 users after mismatch removal and deduplication (~1.3% removed)
- 13 columns: 5 original + 8 synthetic enrichment columns
- Balanced groups: ~145K control, ~145K treatment (SRM test will verify this in notebook 02)
- Date range: 22-day window in January 2017
- Conversion rates: ~12.0% control, ~12.3% treatment (a +2.4% relative lift)
The enriched file is the single source of truth for all downstream analysis notebooks and the FastAPI backend that powers the interactive dashboard.
summary = {
'Total users': len(df_enriched),
'Control users': int((df_enriched['group'] == 'control').sum()),
'Treatment users': int((df_enriched['group'] == 'treatment').sum()),
'Overall conversion rate': df_enriched['converted'].mean(),
'Control conversion rate': df_enriched[df_enriched['group'] == 'control']['converted'].mean(),
'Treatment conversion rate': df_enriched[df_enriched['group'] == 'treatment']['converted'].mean(),
'Date range': f"{pd.to_datetime(df_enriched['timestamp']).min().date()} to {pd.to_datetime(df_enriched['timestamp']).max().date()}",
}
for k, v in summary.items():
if isinstance(v, float):
if 'rate' in k.lower():
print(f"{k}: {v:.4%}")
else:
print(f"{k}: {v:.4f}")
elif isinstance(v, (int, np.integer)):
print(f"{k}: {v:,}")
else:
print(f"{k}: {v}")