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.
Long-form posts on this project at hugonissar.github.io:
- How to build a GA4 purchase propensity model with BigQuery ML (without Vertex AI) — end-to-end walkthrough of the design.
Or read the project overview.
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.
"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.
-
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. -
Cost-capped. Every BigQuery query in the Cloud Function has
maximum_bytes_billedset (default 10 GB). A misconfigured query can't burn money — BigQuery aborts the job before charging. -
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.
-
Full GA4 ecommerce funnel. The model uses all 10 standard ecommerce events (
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), 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. -
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.
-
Hyperparameter tuning built in. Training uses BQML's HP tuning to sweep
learn_rate,max_tree_depth,subsample, andl2_regacross 20 trials, optimizing for ROC AUC on a 20% holdout. No manual tuning needed. -
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.
-
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
ERRORlogs for Cloud Monitoring alerts. -
Timezone-aware. Both the training SQL and the scoring SQL use the same IANA timezone (set via
GA4_TIMEZONEenv var) so all date-based features are consistent between training and inference. -
Two service accounts, least privilege. Training and scoring run as separate service accounts with the minimum IAM grants needed. No
editor, noowner, no project-wide writes. -
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.
┌──────────────┐ 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 │
└──────────────────────────────┘
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.40mid— 0.40 ≤ p < 0.70high— 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 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.
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.comBefore deploying anything in GCP, configure these in your GA4 property:
- 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. - 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, andpurchase. Missing events become zero counts — the model still works but loses signal. Implementing the full GA4 recommended ecommerce events is strongly recommended. - Custom user property registered. Admin → Custom definitions → Custom dimensions. Scope: User, user property name:
purchase_propensity, display name:Purchase propensity. - Custom event metrics registered. Same screen, scope: Event:
- Custom metric
propensity_score(unit: standard) - Custom dimension
propensity_bucket
- Custom metric
- Measurement Protocol API secret. Admin → Data Streams → [your web stream] → Measurement Protocol API secrets → Create. Save the value; it's the
GA4_API_SECRETenv var. - Google signals enabled. Admin → Data collection. Required for cross-device + ads remarketing.
- Google Ads link enabled. Admin → Product links → Google Ads. Toggle Enable Personalized Advertising.
- 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. - Audiences defined. Admin → Audiences → New audience → Condition:
User property purchase_propensity exactly matches high(repeat formid). Membership duration: max (540 days).
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"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 |
# 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.
| 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 |
bq mk --dataset --location=EU "${PROJECT_ID}:ga4_ml"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`);In BigQuery Studio: click Schedule → Create 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
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"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.
- Cloud Function logs should show
Sync complete: N ok, 0 failedafter the next 06:00 run. - GA4 DebugView should show
propensity_syncevents with thepurchase_propensityuser property attached. - Google Ads audience manager should show your propensity audiences within 24-48h of first members qualifying.
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 |
See SECURITY.md for the full threat model. Summary of controls:
- 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.
- 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.
- Hard BQ scan ceiling.
maximum_bytes_billedaborts any query that would scan more thanMAX_BYTES_BILLEDbytes. - No secrets in code. All credentials (Measurement Protocol API secret, project ID) come from env vars set at deploy time.
- Least-privilege IAM. Trainer and scorer service accounts each get the minimum bindings needed.
- Failure-rate alerting. A failure rate above 10% logs at
ERRORseverity for Cloud Monitoring alert policies. - Model freshness monitoring. A stale model (older than
MODEL_STALE_DAYS) logs atWARNINGseverity so a silently failing training job gets caught. - Validation sampling. 1% of MP payloads are sent to the debug endpoint, where validation errors are returned as JSON and logged.
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:8080SELECT * 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.
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.
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.
gcloud functions logs read propensity-sync \
--gen2 \
--region=europe-north1 \
--limit=50| 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 |
- 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=1for 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 inevents_*. 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
highbucket may sit below that threshold for weeks. Consider broadening tohigh + midfor 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_trialsto 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.
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.
See CONTRIBUTING.md. PRs welcome. Issues with reproductions get prioritized.
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.