Engineering Report

Customer Propensity to Purchase

A distributed, orchestrated machine learning pipeline

PySpark · Apache Airflow 3 · Great Expectations · XGBoost · MLflow · Docker

7 / 7
DAG tasks green
0.935
Validation AUC
42.4M
Events ingested
9.24M
Session features
8
Defects fixed
Contents
  1. Executive summary
  2. Project overview
  3. Architecture and design
  4. Findings: defects fixed
  5. Data findings
  6. Model and results
  7. Predictions and behaviour
  8. Recommendations

Executive Summary


This report documents the commissioning of a customer propensity to purchase pipeline, rebuilt from a single node pandas and XGBoost workflow onto a distributed, orchestrated architecture. PySpark performs extract, transform and load and feature engineering, Apache Airflow 3 orchestrates the workflow as a directed acyclic graph, Great Expectations Core acts as a data quality gate, and XGBoost with MLflow handles training, evaluation and the model registry. The entire stack runs in Docker on a single Apple Silicon workstation.

The codebase was delivered as a scaffold that had never executed against a live Docker daemon. Bringing it to a first successful run surfaced eight distinct defects spanning infrastructure, orchestration, distributed compute, data quality and the machine learning framework layer. Every defect was root caused from primary evidence and corrected at source rather than worked around. The pipeline now completes end to end on the full 42.4 million event month, producing a trained model registered for production.

Headline outcome

Starting from a machine with no Docker, no Java and no Python data stack, the pipeline was taken to a verified green run on the full month of REES46 data. The deployed model separates buyers from non buyers with a validation AUC of 0.935, and is registered in MLflow as propensity-model version 1 under the production alias.

1. Project Overview


The goal is the orchestration and distributed processing layer, not a new model. The brief is explicit that the modelling approach and XGBoost hyperparameters should stay close to a standard pandas pipeline. The claim being supported is migrated to a distributed, orchestrated architecture, not built a better model. A clean, demonstrable, green pipeline run is therefore the primary deliverable, and model quality is treated as a gate to pass.

The source is the REES46 e-commerce behavior dataset from Kaggle, processed one monthly file at a time. The October 2019 file was used: a 5.3 GB CSV containing 42,448,764 behavioural events. Each row is a single event (view, cart, purchase, or remove from cart) carrying a timestamp, product and category identifiers, a brand, a price, a user id and a session id.

Technology stack

LayerComponentVersionRole
OrchestrationApache Airflow3.2.2DAG scheduling, Task Execution API, web UI
Distributed computePySpark4.1.2CSV to Parquet, feature engineering in local[*] mode
RuntimeOpenJDK / Python17 / 3.13JVM for Spark, Python for tasks
Data qualityGreat Expectations Core1.18.1Semantic expectations on the feature table
ModellingXGBoost3.3.0Gradient boosted classifier
ML lifecycleMLflow3.14.0Tracking, metrics, model registry, aliases
Metadata DBPostgreSQL16Airflow metadata backend

2. Architecture and Design


The workflow is a strict linear chain of seven tasks. A failure at any stage stops the run and prevents bad data or a weak model from progressing.

ingest→ validate→ transform→ quality_gate→ train→ evaluate→ deploy
#TaskEngineResponsibility
1ingestPySparkRead raw CSV, stage as Parquet with an explicit schema
2validatePySparkStructural checks: required columns, non empty, allowed event types
3transformPySparkAggregate events to one row per session, build features and target
4quality_gateGX CoreSemantic checks: uniqueness, nulls, value ranges on features
5trainXGBoostBridge to pandas, train classifier, log to MLflow
6evaluateMLflowRead logged AUC, gate on a 0.90 threshold
7deployMLflowRegister model version, point the production alias at it
Design principle: never pass data through XCom

Airflow XCom is backed by the metadata database and is not built for bulk data. Every task writes its output to disk and passes only a small string (a file path or an MLflow run id) to the next task.

Deliberate scope decisions

3. Findings: Defects Identified and Resolved


Eight defects were identified and corrected, each root caused from primary evidence (container logs, supervisor signals, tracebacks) and fixed at source. They are ordered roughly by the sequence in which they blocked the pipeline.

1. DAG import path not simplified for Airflow 3Low

Cause. The DAG used a try and except fallback import. The airflow.sdk path is the canonical TaskFlow import for 3.2.2.

Fix. Imported directly from airflow.sdk, verified by listing the DAG with no import errors.

2. JAVA_HOME hardcoded to amd64 on an arm64 hostCritical

Symptom. The ingest task was killed within seconds. A Spark smoke test reported JAVA_GATEWAY_EXITED, the Java gateway process exited before sending its port number.

Cause. The Dockerfile set JAVA_HOME to the amd64 path, but on Apple Silicon the JDK installs to the arm64 path. PySpark launches the JVM via $JAVA_HOME/bin/java, which did not exist.

Fix. An architecture aware symlink created at build time with dpkg --print-architecture, pointing a stable default-java path at the correct JDK.

3. Missing Task Execution API URL and shared JWT secretCritical

Symptom. Every task was terminated by the supervisor with SIGKILL about nine seconds in, after repeated Connection refused retries.

Cause. Airflow 3 runs all tasks through the Task Execution API. The task defaulted to localhost:8080, where no api-server listens inside the scheduler container, so it could not call home and was killed. A shared JWT secret was also missing.

Fix. Added EXECUTION_API_SERVER_URL pointing at the api-server service and a shared JWT_SECRET across all services, mirroring the official Airflow 3.2.2 compose.

4. Timestamp parsing blind to the UTC suffixHigh

Cause. Real timestamps are formatted "2019-10-01 00:00:00 UTC" while fixtures use ISO. to_timestamp with no format returns null on the UTC text, which would null session duration and cause training to drop every row.

Fix. Parse both formats explicitly with a coalesce, so a format mismatch can never silently null the timestamps.

5. Spark driver heap too small for the shuffleHigh

Symptom. transform failed after about twenty seconds with py4j errors as the Spark JVM died mid shuffle.

Cause. In local[*] mode the driver JVM is also the executor, and the default 1 GB heap cannot shuffle 42 million rows through the groupBy and countDistinct aggregations.

Fix. Configured spark.driver.memory to 4 GB, which fits inside the 7.8 GB Docker VM alongside the Airflow processes.

6. Session key not unique, null sessions presentHigh

Symptom. quality_gate failed: the uniqueness and not null expectations on the session key did not hold.

Cause. The aggregation grouped by session and user id together, emitting duplicate session rows for the ~350 REES46 sessions carrying more than one user id, plus two null sessions. The gate correctly caught both.

Fix. Aligned the code with its stated contract: drop null sessions, group by session alone, keep a single representative user id. The gate now passes honestly rather than being weakened.

7. MLflow 3 file store maintenance mode and removed stage APIHigh

Cause. MLflow 3 raises by default on the file store and removed the old model stage API. Both would have failed the last two tasks.

Fix. Set MLFLOW_ALLOW_FILE_STORE and rewrote deploy to register the model and point a production alias with set_registered_model_alias.

8. Task parameter named run_id, a reserved context keyHigh

Symptom. evaluate failed with "the key run_id in args is reserved", appearing only once every upstream task was finally green.

Cause. Airflow injects run_id as a reserved task context key. A TaskFlow parameter of the same name collided during keyword resolution and raised before the function body ran.

Fix. Renamed the parameter to train_run_id and documented the constraint.

4. Data Findings


The month of raw events was aggregated to one row per user session. The Great Expectations gate is not decoration: on the real data it caught two null and 350 duplicate session keys, resolved at the feature engineering layer.

StageRowsNote
Raw events (CSV)42,448,764view, cart, purchase, remove_from_cart
Session features (pre clean)9,244,772included 2 null and 350 duplicate session keys
Session features (modelled)9,244,421unique, non null sessions used for training

Class balance and buyer profile

Purchase is a rare event: 6.81 percent of sessions are buyers against 93.19 percent non buyers, which is why training applies scale_pos_weight. Comparing mean feature values shows where the signal lives. Buyers add to cart far more often and, notably, spend less time per session, consistent with decisive, intent driven visits.

FeatureBuyers (mean)Non buyers (mean)Read
cart_count0.7920.050about 16x higher for buyers
intent_score3.1691.727strong separation
engagement_score6.4695.183buyers more engaged
view_count5.5614.327buyers view slightly more
session_duration_seconds658.71069.9buyers spend less time, decisive

5. Model and Results


The model is an XGBoost classifier kept deliberately close to a standard pandas configuration: 200 estimators, max depth 6, learning rate 0.1, positive class up weighted to counter the 6.81 percent base rate, evaluated by AUC on an 80 / 20 stratified split.

Result

Validation AUC 0.935, comfortably above the 0.90 deployment gate. The model is registered as propensity-model version 1 with the production alias.

Feature importance (by gain)

RankFeatureGain
1cart_count15,541
2session_duration_seconds12,747
3view_count5,743
4distinct_brands_viewed4,021
5engagement_score1,116

6. Predictions and Model Behaviour


The registered production model was loaded and used to score real sessions. The table contrasts known buyers with known non buyers. Aggregated over the whole month, the mean predicted value is 0.86 for actual buyers against 0.16 for non buyers, a clear separation consistent with the 0.935 AUC.

Sessionviewscartsintentscoreactual
b1e867633011.00buyer
500e05421131.00buyer
e3d7ff2717031.00non buyer (false positive)
7de68edd4030.00buyer (false negative)
664c1a7a1010.00non buyer

Operating point

On a 500,000 session sample, at the deployed decision threshold: recall 0.861, precision 0.283, F1 0.426. The high recall and modest precision are the expected shape for rare event propensity, the model catches most buyers at the cost of a number of false positives.

An important caveat surfaced during prediction analysis

The deployed model returns class labels (0 or 1), not calibrated probabilities, which is why every score above is exactly 0.00 or 1.00 and precision and recall do not move with the threshold. The genuine ranking metric, AUC 0.935, comes from predict_proba at training time. For a true, tunable propensity score, deploy should expose predict_proba. This is captured as a recommendation rather than presented as a probability.

7. Recommendations and Future Work


PriorityRecommendationRationale
HighExpose predict_proba in deploy and add a predict taskTurn the binary label into a tunable propensity score and a visible predictions output
MediumAdd an MLflow UI service to the compose stackBrowse runs, metrics and the registry visually
MediumAdd task level retries to the DAGSelf heal transient supervisor or store hiccups
MediumCalibrate the classifier and tune the decision thresholdLift precision at the chosen operating point
LowParameterise the month and process multiple filesScale beyond a single October snapshot

Closing assessment

The rebuild achieves its stated objective. A pandas scale workflow now runs as a distributed, orchestrated, quality gated pipeline on tens of millions of events, with training, evaluation and registry handled through an industry standard ML lifecycle tool. The defects encountered were characteristic of moving a scaffold onto real infrastructure, and all were resolved at source and documented.

Download the full PDF report