Skip to content

hugonissar/GA4-Ecommerce-BQML-Purchase-Propensity

Repository files navigation

GA4 Ecommerce BQML Purchase Propensity

A self-hosted BigQuery ML pipeline that predicts purchase propensity from GA4 events and pushes the result back to GA4 as a user property via the Measurement Protocol. Built for Google Ads remarketing. Consent-aware, cost-capped, and production-hardened.

License: MIT Python 3.12+ Cloud Functions BigQuery ML GA4


📚 Related writeups

Long-form posts on this project at hugonissar.github.io:

Or read the project overview.

📖 What this is

A complete end-to-end purchase propensity pipeline for Google Analytics 4. A BigQuery ML boosted-tree classifier with hyperparameter tuning trains weekly on your GA4 export data and predicts which users are most likely to purchase in the next 30 days. A daily Cloud Function (06:00 local time) scores users active in the last 24 hours and pushes their propensity bucket (low / mid / high) back to GA4 as a user property via the Measurement Protocol. GA4 audiences built on that user property sync to Google Ads for remarketing.

Two files, one model, one Cloud Function. The whole thing is 431 lines of Python and 179 lines of SQL.

💬 Use case

"I want to retarget users who are likely to buy but haven't yet, and stop wasting ad spend on recent customers."

Plug it into your GA4 property, point Google Ads at the resulting audiences, and your high-propensity users get a personalized remarketing push within a day of their last visit. mid users get a softer touch. low users don't get spent on.

🏆 Why this design

  1. Consent-gated at the push step. Only users with ads_personalization_consent = 'GRANTED' are scored and pushed to GA4 for ad targeting. Non-consented users never enter the GA4 audience flow or reach Google Ads. Consent Mode v2 is a hard prerequisite. Note: the weekly training query reads all user events to maximize positive examples — see Consent model below for the rationale and how to switch to consent-only training if your privacy posture requires it.

  2. Cost-capped. Every BigQuery query in the Cloud Function has maximum_bytes_billed set (default 10 GB). A misconfigured query can't burn money — BigQuery aborts the job before charging.

  3. Recent buyers excluded. The model doesn't train on users who bought in the feature window, and the scoring SQL excludes anyone who purchased in the last 30 days. You don't pay Google Ads to retarget people who already converted.

  4. Full GA4 ecommerce funnel. The model uses all 10 standard ecommerce events (view_item_listselect_itemview_itemadd_to_wishlistadd_to_cartview_cartremove_from_cartbegin_checkoutadd_shipping_infoadd_payment_info), plus 5 derived funnel ratios. The strongest predictors in ecommerce models — cart_to_checkout_ratio, checkout_to_payment_ratio — are computed and exposed to the model.

  5. Session-level features. Beyond simple event counts, the model sees session depth, engagement rate, and per-session engagement time. A user with 1 deep session looks different from a user with 10 bounced sessions.

  6. Hyperparameter tuning built in. Training uses BQML's HP tuning to sweep learn_rate, max_tree_depth, subsample, and l2_reg across 20 trials, optimizing for ROC AUC on a 20% holdout. No manual tuning needed.

  7. Recency-weighted engagement. Engagement is captured at three time horizons (total, 30-day, 7-day) so the model can learn that recent activity matters more than activity 6 months ago.

  8. Production-hardened. Retries with exponential backoff, structured logging, 1% sampling against GA4's debug endpoint to catch silent payload errors, automatic model freshness checks, and a failure-rate threshold that triggers ERROR logs for Cloud Monitoring alerts.

  9. Timezone-aware. Both the training SQL and the scoring SQL use the same IANA timezone (set via GA4_TIMEZONE env var) so all date-based features are consistent between training and inference.

  10. Two service accounts, least privilege. Training and scoring run as separate service accounts with the minimum IAM grants needed. No editor, no owner, no project-wide writes.

  11. No external dependencies. No Vertex AI, no AutoML, no third-party SaaS. Just BigQuery ML, Cloud Functions, Cloud Scheduler, and the GA4 Measurement Protocol — all native Google services.

🏗️ Architecture at a glance

┌──────────────┐    daily + streaming    ┌──────────────────────┐
│     GA4      │ ──────── export ──────▶ │      BigQuery        │
│   property   │                         │  events_*            │
└──────────────┘                         │  events_intraday_*   │
                                         └──────────┬───────────┘
                                                    │
                            ┌───────────────────────┴────────────────────────┐
                            ▼                                                ▼
                ┌─────────────────────────┐                    ┌──────────────────────────┐
                │  Scheduled query        │                    │   Cloud Function         │
                │  Sunday 00:01           │                    │   Daily 06:00 (0 6 * * *)│
                │  training.sql           │                    │   main.py                │
                │                         │                    │                          │
                │  CREATE OR REPLACE      │                    │   1. Check model age     │
                │  MODEL purchase_        │                    │   2. Query active users  │
                │  propensity             │                    │   3. ML.PREDICT scores   │
                │  + HP tuning            │                    │   4. POST to MP endpoint │
                └────────────┬────────────┘                    └────────────┬─────────────┘
                             │                                              │
                             ▼                                              │
                ┌─────────────────────────┐                                 │
                │  ga4_ml.purchase_       │ ◀──────  ML.PREDICT  ───────────┤
                │  propensity (BQML)      │                                 │
                └─────────────────────────┘                                 │
                                                                            ▼
                                                          ┌──────────────────────────────┐
                                                          │  GA4 Measurement Protocol    │
                                                          │  /mp/collect                 │
                                                          │  • user property write       │
                                                          │  • propensity_sync event     │
                                                          └────────────────┬─────────────┘
                                                                           ▼
                                                          ┌──────────────────────────────┐
                                                          │  GA4 audiences               │
                                                          │  → Google Ads remarketing    │
                                                          └──────────────────────────────┘

🧠 Model design

Algorithm: BigQuery ML BOOSTED_TREE_CLASSIFIER (XGBoost under the hood), with hyperparameter tuning over 20 trials.

Target: binary will_convert — did the user complete a purchase event in the next 30 days?

Training window: 180 days of features (210 to 31 days ago) + 30 days of labels (30 to 1 day ago). Non-overlapping by design — features can't leak the label.

Scoring window: last 30 days of features for any user active in the last 24 hours (run at 06:00 daily).

Features (~30 total):

Category Features
Engagement total_engagement_seconds, engagement_last_30d_seconds, engagement_last_7d_seconds
Sessions sessions_total, engaged_sessions, engaged_session_rate, avg_session_depth, avg_session_engagement_seconds, max_session_engagement_seconds
Funnel events view_item_list_count, select_item_count, view_item_count, add_to_wishlist_count, add_to_cart_count, view_cart_count, remove_from_cart_count, begin_checkout_count, add_shipping_info_count, add_payment_info_count
Activity page_view_count, total_events, days_since_last_visit
Categoricals device_category, os, country, traffic_medium, traffic_source
Funnel ratios list_to_view_ratio, view_to_cart_ratio, cart_to_checkout_ratio, checkout_to_payment_ratio, cart_abandon_ratio

Output buckets:

  • low — predicted probability < 0.40
  • mid — 0.40 ≤ p < 0.70
  • high — p ≥ 0.70

Thresholds are deliberately fixed (not percentile-based) so bucket meaning stays stable across retrains. Revisit them after the first ML.EVALUATE if the distribution is heavily skewed.

🔏 Consent model

Consent enforcement happens at the scoring/push step, not at training time. This is a deliberate design choice — here's what that means in practice and why:

Stage Filters by consent? Why
Weekly training (training.sql) ❌ No Reads all GA4 events to maximize positive examples
Daily scoring (main.py) ✅ Yes Only users with ads_personalization_consent = 'GRANTED' are queried
MP push to GA4 ✅ Yes (inherited) Only consented users ever reach the push step
GA4 audiences ✅ Yes (inherited) Non-consented users never receive a purchase_propensity user property
Google Ads remarketing ✅ Yes (inherited) Audiences only contain consented users

Why train on all users? A high-intent user behaves the same regardless of their consent choice. Filtering training data to consented users only shrinks the positive set by 30–40% (typical EEA consent rates), which materially hurts model quality on a small training set. Consent gating at the push step is what legal frameworks (GDPR, ePrivacy, CCPA) actually require — the moment data is used for ad targeting is the moment consent legally binds, and that's the push step.

When this might not be enough. Some organizations apply a stricter "no non-consented data in any downstream system" policy as a matter of internal policy, not strict legal requirement. Some EEA jurisdictions interpret ePrivacy as requiring consent for all analytics processing beyond strictly necessary. In those cases, you should switch to consent-only training.

To switch to consent-only training, add this CTE to training.sql and intersect it with feats:

consented_users AS (
  SELECT DISTINCT user_pseudo_id
  FROM `GCP_PROJECT.GA4_EXPORT_DATASET.events_*`, windows
  WHERE _TABLE_SUFFIX BETWEEN feat_start AND feat_end
    AND (SELECT value.string_value FROM UNNEST(event_params)
         WHERE key = 'ads_personalization_consent') = 'GRANTED'
)

Then add AND user_pseudo_id IN (SELECT user_pseudo_id FROM consented_users) to the feats CTE's WHERE clause. The scoring SQL doesn't need changing — it already filters by consent.

☁️ Required GCP services

Enable these APIs in your project:

Service Purpose Required
BigQuery (bigquery.googleapis.com) Stores GA4 export, runs the model Yes
BigQuery Data Transfer (bigquerydatatransfer.googleapis.com) Powers scheduled queries Yes
Cloud Functions (cloudfunctions.googleapis.com) Hosts the daily scoring function Yes
Cloud Run (run.googleapis.com) Underlying runtime for 2nd-gen Cloud Functions Yes
Cloud Scheduler (cloudscheduler.googleapis.com) Triggers the function daily Yes
IAM (iam.googleapis.com) Service accounts + role bindings Yes
Cloud Logging Captures function logs Auto
Cloud Monitoring Captures metrics + alerts Auto

Quick enable:

gcloud services enable \
  bigquery.googleapis.com \
  bigquerydatatransfer.googleapis.com \
  cloudfunctions.googleapis.com \
  run.googleapis.com \
  cloudscheduler.googleapis.com \
  iam.googleapis.com

📊 What's required in GA4

Before deploying anything in GCP, configure these in your GA4 property:

  1. BigQuery export enabled. Admin → Product links → BigQuery Links. Turn on both daily export and streaming export — the function reads events_intraday_*, which only exists with streaming on.
  2. Standard ecommerce events tracked. The model uses view_item_list, select_item, view_item, add_to_wishlist, add_to_cart, view_cart, remove_from_cart, begin_checkout, add_shipping_info, add_payment_info, and purchase. Missing events become zero counts — the model still works but loses signal. Implementing the full GA4 recommended ecommerce events is strongly recommended.
  3. Custom user property registered. Admin → Custom definitions → Custom dimensions. Scope: User, user property name: purchase_propensity, display name: Purchase propensity.
  4. Custom event metrics registered. Same screen, scope: Event:
    • Custom metric propensity_score (unit: standard)
    • Custom dimension propensity_bucket
  5. Measurement Protocol API secret. Admin → Data Streams → [your web stream] → Measurement Protocol API secrets → Create. Save the value; it's the GA4_API_SECRET env var.
  6. Google signals enabled. Admin → Data collection. Required for cross-device + ads remarketing.
  7. Google Ads link enabled. Admin → Product links → Google Ads. Toggle Enable Personalized Advertising.
  8. Consent Mode v2 on the site. Required. The pipeline filters to ads_personalization_consent = 'GRANTED', which is only populated when Consent Mode v2 is live.
  9. Audiences defined. Admin → Audiences → New audience → Condition: User property purchase_propensity exactly matches high (repeat for mid). Membership duration: max (540 days).

🔐 Service account permissions (least privilege)

Two service accounts: one for the weekly training, one for the daily scoring. Don't reuse a single account — different jobs, different blast radius.

PROJECT_ID="your-project"

# Service account 1: scheduled query trainer
gcloud iam service-accounts create propensity-trainer \
  --display-name="Purchase Propensity Trainer"

# Service account 2: Cloud Function scorer
gcloud iam service-accounts create propensity-cf \
  --display-name="Purchase Propensity Cloud Function"

🔑 Required IAM roles

Trainer (propensity-trainer@PROJECT.iam.gserviceaccount.com):

Role Scope Why
roles/bigquery.jobUser Project Submit query jobs
roles/bigquery.dataViewer GA4 export dataset Read GA4 events
roles/bigquery.dataEditor ga4_ml dataset Create / replace the model

Cloud Function (propensity-cf@PROJECT.iam.gserviceaccount.com):

Role Scope Why
roles/bigquery.jobUser Project Submit query jobs
roles/bigquery.dataViewer GA4 export dataset Read GA4 events
roles/bigquery.dataViewer ga4_ml dataset Read the model for inference
roles/logging.logWriter Project Write structured logs

Grant commands

# Trainer
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:propensity-trainer@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/bigquery.jobUser"

bq add-iam-policy-binding \
  --member="serviceAccount:propensity-trainer@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/bigquery.dataViewer" \
  "${PROJECT_ID}:analytics_XXXXXXXXX"

bq add-iam-policy-binding \
  --member="serviceAccount:propensity-trainer@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/bigquery.dataEditor" \
  "${PROJECT_ID}:ga4_ml"

# Cloud Function
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:propensity-cf@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/bigquery.jobUser"

bq add-iam-policy-binding \
  --member="serviceAccount:propensity-cf@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/bigquery.dataViewer" \
  "${PROJECT_ID}:analytics_XXXXXXXXX"

bq add-iam-policy-binding \
  --member="serviceAccount:propensity-cf@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/bigquery.dataViewer" \
  "${PROJECT_ID}:ga4_ml"

gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:propensity-cf@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/logging.logWriter"

That's the entire IAM footprint. Do not grant bigquery.user, bigquery.admin, editor, or owner — none are needed and all grant strictly more than necessary.

👥 User IAM (roles for people, not services)

Group Roles Why
Marketing analysts roles/bigquery.user on GA4 + ga4_ml, viewer in GA4 UI Inspect data, no writes
ML / data engineers roles/bigquery.dataEditor on ga4_ml, roles/bigquery.user on GA4 export Iterate on the model
Cloud / DevOps roles/cloudfunctions.developer, roles/cloudscheduler.admin, roles/iam.serviceAccountUser Deploy and manage schedules
GA4 admins GA4 Editor role (set inside GA4, not GCP) Manage audiences and links
Project owners roles/resourcemanager.projectIamAdmin + roles/viewer Manage IAM without blanket Owner

🚀 Quick start

1. Create the ML dataset

bq mk --dataset --location=EU "${PROJECT_ID}:ga4_ml"

2. Train the model once manually

Open BigQuery Studio, paste training.sql, replace the three placeholders (GCP_PROJECT, GA4_EXPORT_DATASET, ML_DATASET), and run it. With HP tuning enabled the first run takes ~10–20 minutes. Confirm the model builds and ML.EVALUATE returns a reasonable AUC (>0.70).

SELECT * FROM ML.EVALUATE(MODEL `your-project.ga4_ml.purchase_propensity`);

3. Schedule the weekly training

In BigQuery Studio: click ScheduleCreate new scheduled query.

  • Name: propensity-model-training
  • Schedule: custom cron 1 0 * * 0 (every Sunday at 00:01)
  • Destination table: leave empty (the SQL creates the model)
  • Service account: propensity-trainer@PROJECT.iam.gserviceaccount.com
  • Location: same region as your GA4 export dataset

4. Deploy the Cloud Function

gcloud functions deploy propensity-sync \
  --gen2 \
  --runtime=python312 \
  --region=europe-north1 \
  --source=. \
  --entry-point=run \
  --trigger-http \
  --no-allow-unauthenticated \
  --service-account=propensity-cf@${PROJECT_ID}.iam.gserviceaccount.com \
  --timeout=540s \
  --memory=2Gi \
  --set-env-vars="\
GCP_PROJECT=${PROJECT_ID},\
GA4_EXPORT_DATASET=analytics_XXXXXXXXX,\
ML_DATASET=ga4_ml,\
GA4_MEASUREMENT_ID=G-XXXXXXXXXX,\
GA4_API_SECRET=your-mp-api-secret,\
GA4_TIMEZONE=Europe/Stockholm"

5. Schedule the daily run

gcloud scheduler jobs create http propensity-sync-daily \
  --location=europe-north1 \
  --schedule="0 6 * * *" \
  --time-zone="Europe/Stockholm" \
  --uri="https://europe-north1-${PROJECT_ID}.cloudfunctions.net/propensity-sync" \
  --http-method=POST \
  --oidc-service-account-email=propensity-cf@${PROJECT_ID}.iam.gserviceaccount.com \
  --oidc-token-audience="https://europe-north1-${PROJECT_ID}.cloudfunctions.net/propensity-sync"

The --time-zone flag ensures 06:00 means local time, not UTC. Match it to your GA4_TIMEZONE env var.

6. Verify

  • Cloud Function logs should show Sync complete: N ok, 0 failed after the next 06:00 run.
  • GA4 DebugView should show propensity_sync events with the purchase_propensity user property attached.
  • Google Ads audience manager should show your propensity audiences within 24-48h of first members qualifying.

⚙️ Configuration reference

All Cloud Function configuration is via environment variables. Required vars abort startup if missing; everything else has a default.

Variable Required Default Description
GCP_PROJECT Yes The GCP project ID
GA4_EXPORT_DATASET Yes GA4 BigQuery export dataset, e.g. analytics_123456789
ML_DATASET Yes Dataset holding the BQML model, e.g. ga4_ml
GA4_MEASUREMENT_ID Yes GA4 measurement ID, e.g. G-XXXXXXXXXX
GA4_API_SECRET Yes GA4 Measurement Protocol API secret
GA4_TIMEZONE No UTC IANA timezone matching GA4 property, e.g. Europe/Stockholm
MAX_BYTES_BILLED No 10737418240 BQ query cost cap in bytes (10 GB)
MODEL_STALE_DAYS No 10 Warn if model is older than N days
DEBUG_VALIDATE_RATE No 0.01 Fraction of payloads validated via GA4 debug endpoint

🛡️ Security model

See SECURITY.md for the full threat model. Summary of controls:

  1. Consent gating at the push step. Non-consented users are filtered out by the scoring SQL before any propensity bucket is pushed to GA4, so they never enter the GA4 audience flow or reach Google Ads. Training data is not filtered by consent — see Consent model above for the full rationale and how to switch to consent-only training.
  2. Recent-buyer exclusion. Buyers in the last 30 days are excluded from both training and scoring — protects against retargeting recent customers and against the model learning a "frequent buyers buy again" trivial pattern.
  3. Hard BQ scan ceiling. maximum_bytes_billed aborts any query that would scan more than MAX_BYTES_BILLED bytes.
  4. No secrets in code. All credentials (Measurement Protocol API secret, project ID) come from env vars set at deploy time.
  5. Least-privilege IAM. Trainer and scorer service accounts each get the minimum bindings needed.
  6. Failure-rate alerting. A failure rate above 10% logs at ERROR severity for Cloud Monitoring alert policies.
  7. Model freshness monitoring. A stale model (older than MODEL_STALE_DAYS) logs at WARNING severity so a silently failing training job gets caught.
  8. Validation sampling. 1% of MP payloads are sent to the debug endpoint, where validation errors are returned as JSON and logged.

💻 Local development

git clone https://github.com/hugonissar/ga4-ecommerce-bqml-purchase-propensity.git
cd ga4-ecommerce-bqml-purchase-propensity

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

# Authenticate to GCP
gcloud auth application-default login

# Set required env vars
export GCP_PROJECT=your-project
export GA4_EXPORT_DATASET=analytics_XXXXXXXXX
export ML_DATASET=ga4_ml
export GA4_MEASUREMENT_ID=G-XXXXXXXXXX
export GA4_API_SECRET=your-mp-api-secret
export GA4_TIMEZONE=Europe/Stockholm

# Run locally with the Functions Framework
pip install functions-framework
functions-framework --target=run --port=8080

# In another shell, trigger it
curl -X POST http://localhost:8080

🛠️ Operations

🔍 Check model performance after each training

SELECT * FROM ML.EVALUATE(MODEL `your-project.ga4_ml.purchase_propensity`);

Watch roc_auc. >0.80 is solid, 0.70-0.80 is acceptable, <0.70 means too few positives or feature issues.

📊 Inspect feature importance

SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL `your-project.ga4_ml.purchase_propensity`)
ORDER BY attribution DESC;

For most ecommerce sites the top 3 are usually checkout_to_payment_ratio, add_payment_info_count, and cart_to_checkout_ratio. If country or os rank top and you only operate in one country, consider dropping them next iteration.

🎛️ Inspect chosen hyperparameters

SELECT * FROM ML.TRIAL_INFO(MODEL `your-project.ga4_ml.purchase_propensity`)
ORDER BY eval_metrics.roc_auc DESC;

Shows which hyperparameter combinations BQML tried and how each performed. Useful for understanding whether HP tuning is hitting search-space boundaries — if the best learn_rate is always 0.30 (the max), widen the range in training.sql.

📜 Tail function logs

gcloud functions logs read propensity-sync \
  --gen2 \
  --region=europe-north1 \
  --limit=50

🚨 Recommended Cloud Monitoring alerts

Alert Condition Trigger window
Function execution failures execution_count where status != "ok" > 0 24h
Stale model Log match: Model is N days old (threshold: N) 48h
High failure rate Log match: High failure rate: N/N 24h
No users scored Log match: No active consented users to sync 48h

⚠️ Limitations

  • Cold starts. Each invocation is effectively cold because the function only runs once a day. For a daily batch job this is fine — the first BQ query takes a few seconds longer but adds no meaningful latency. Don't set --min-instances=1 for a daily run; it just wastes money.
  • Same-day buyer gap. The 30-day recent-buyer exclusion uses the daily export tables, which finalize ~24h after each day. A user who bought 2 hours ago is in events_intraday_* but not yet in events_*. The function will not exclude that user this run.
  • Small-traffic floor. Google Ads typically needs 1,000 users in an audience before it serves ads to it. At ~1,000 visitors/week, your high bucket may sit below that threshold for weeks. Consider broadening to high + mid for activation.
  • HP tuning training cost. With 20 trials and 4 parallel, weekly training takes ~10-20 minutes and scans a 180-day feature window. For very large GA4 properties (>10M users/month) you may want to reduce num_trials to 10.
  • No streaming results. The daily run materializes all scored users in memory. For >500k active users/day, swap to chunked processing or upgrade memory.
  • One model, one property. This pipeline is scoped to one GA4 property and one propensity model. For multi-property setups, deploy multiple functions with different env vars.

❓Frequently asked questions

How much traffic do I need? Realistic operating points (assuming a 3% conversion rate):
Workable: 1,000+ weekly visitors / 30+ weekly purchases — usable for ranking, expect noise.
Comfortable: ~3,300 weekly visitors / ~100 weekly purchases — stable AUC, predictable buckets.
Ideal: ~8,000 weekly visitors / ~250 weekly purchases — model is reliable for production use.
Robust/production-grade: ~16,000 weekly visitors / ~500 weekly purchases — model is statistically robust and safe to fully automate against ad spend.

Why three buckets and not four? Three thresholds are more interpretable, and small-traffic sites don't have enough positives per bucket for four to be meaningful. Add a fourth once you cross ~500 conversions/week.

Why exclude recent buyers? Two reasons. First, retargeting people who just bought wastes ad spend. Second, in training, frequent buyers dominate the positive class — the model learns "frequent buyers buy again" instead of "high-intent prospects buy." You don't need ML to target recent buyers anyway — create a rule-based audience in GA4 (purchase event in last 30 days) and run retention/upsell campaigns against it separately. The propensity model handles prospecting; a SQL-simple audience handles retention.

Are begin_checkout and add_payment_info really not data leakage? No, they aren't. Data leakage means using post-prediction-time information at training time. Here, feature events happen in the 180-day feature window (210 to 31 days ago) and the label is whether the user purchases in a future 30-day window (30 to 1 day ago). The windows are non-overlapping. add_payment_info happening 60 days ago is a legitimate, strong predictor of a purchase happening 10 days ago — and is exactly the kind of signal you want the model to learn.

Why use Measurement Protocol instead of writing audiences directly? The Google Ads API audience push requires more permissions, more setup, and doesn't keep GA4 reporting in sync. Writing back as a GA4 user property keeps everything in one place: GA4 reports, GA4 audiences, Google Ads remarketing all read from the same source.

Can I use this without Consent Mode v2? Technically yes (remove the consent filter from the SQL), but then you can't legally push the data to Google Ads for ad targeting in jurisdictions that require explicit consent. The pipeline is designed consent-first because that's the constraint that actually matters in production.

Does this work with Firebase / app properties? With minor changes to the SQL — user_pseudo_id exists in app exports too, but the consent param keys differ. Easy to adapt; review the GA4 BigQuery schema.

How is this different from GA4's built-in predictive audiences? GA4 has out-of-the-box predictive audiences (purchase probability, churn probability) but they require ≥1,000 returning users with purchases / ≥1,000 returning users without purchases in the past 28 days — a threshold many small sites don't meet. This pipeline gives you the same capability at lower traffic levels, with explicit control over features, thresholds, and bucket boundaries.

🤝 Contributing

See CONTRIBUTING.md. PRs welcome. Issues with reproductions get prioritized.

📄 License

MIT. See LICENSE.


Keywords: GA4 propensity model, BigQuery ML purchase propensity, GA4 Measurement Protocol, GA4 user property automation, Google Ads remarketing automation, GA4 BQML, propensity score Google Analytics, GA4 audiences BigQuery, Cloud Functions GA4, GA4 consent mode v2 pipeline.

About

A self-hosted BigQuery ML pipeline that predicts purchase propensity from GA4 events and pushes the result back to GA4 as a user property via the Measurement Protocol. Built for Google Ads remarketing. Consent-aware, cost-capped, and production-hardened.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages