Skip to main content
Version: v1.4.1

Shopify Customer Churn Prediction

Predict which Shopify customers are likely to churn based on their order history and behavior.

Dataset Source: Kaggle - Shopify Sales Dataset for ML & EDA Problem Type: Binary Classification Target Variable: churned — 1 = customer has churned, 0 = active customer Use Case: Identify at-risk customers to enable proactive retention campaigns

Churn Definition: A customer is considered churned if they have been a customer for 180+ days AND have not placed an order in the last 90 days. This avoids mislabeling new customers who simply haven't had time to reorder.

Package Imports

1import pandas as pd
2import numpy as np
3import xplainable as xp
4from xplainable.core.models import XClassifier
5from xplainable.core.optimisation.bayesian import XParamOptimiser
6from sklearn.model_selection import train_test_split
7import requests
8import json
9
10from xplainable_client.client.client import XplainableClient
11from xplainable_client.client.base import XplainableAPIError
12
13from xplainable_preprocessing import PipelineSpec, StepSpec, compile_spec

Instantiate Xplainable Cloud

Initialise the xplainable cloud using an API key from: https://platform.xplainable.io/

This allows you to save and collaborate on models, create deployments, create shareable reports.

1# Initialize Xplainable Cloud client
2client = XplainableClient(
3 api_key="", # Create api key in xplainable cloud - https://platform.xplainable.io/
4 hostname="https://platform.xplainable.io"
5)

Load Shopify Order Data

This dataset contains 60,000 individual order records from a Shopify store spanning January 2023 to June 2025. We'll aggregate these to the customer level and engineer features for churn prediction.

1orders_df = pd.read_csv("shopify_sales_dataset_ml_eda.csv", parse_dates=["order_date"])
2
3print(f"Orders: {len(orders_df):,}")
4print(f"Unique customers: {orders_df['customer_id'].nunique():,}")
5print(f"Date range: {orders_df['order_date'].min().date()} to {orders_df['order_date'].max().date()}")
6orders_df.head()

Feature Engineering

Aggregate order-level data to customer-level features. These map to real Shopify API fields:

  • Direct fields: customer_id, customer_country, orders_count, total_spent
  • Derived from orders: recency, frequency, monetary value, discount behavior, return rate
  • Dominant category: most frequently purchased product_category per customer
1reference_date = orders_df["order_date"].max()
2
3# --- Aggregate to customer level ---
4customers = orders_df.groupby("customer_id").agg(
5 # Direct Shopify fields
6 customer_country=("customer_country", "first"),
7 orders_count=("order_id", "count"),
8 total_spent=("revenue", "sum"),
9
10 # Derived: dates for tenure and recency
11 first_order_date=("order_date", "min"),
12 last_order_date=("order_date", "max"),
13
14 # Derived: monetary
15 avg_order_value=("revenue", "mean"),
16 total_discounts=("discounted_price", lambda x: (orders_df.loc[x.index, "product_price"] * orders_df.loc[x.index, "quantity"] - x * orders_df.loc[x.index, "quantity"]).sum()),
17 avg_discount_pct=("discount_percent", "mean"),
18 total_shipping=("shipping_cost", "sum"),
19 total_profit=("profit", "sum"),
20
21 # Derived: product behavior
22 avg_quantity_per_order=("quantity", "mean"),
23 unique_products=("product_id", "nunique"),
24 unique_categories=("product_category", "nunique"),
25
26 # Derived: satisfaction & returns
27 avg_rating=("rating", "mean"),
28 return_count=("is_returned", "sum"),
29
30 # Derived: channel & payment
31 primary_traffic_source=("traffic_source", lambda x: x.mode().iloc[0]),
32 primary_payment_method=("payment_method", lambda x: x.mode().iloc[0]),
33 primary_product_category=("product_category", lambda x: x.mode().iloc[0]),
34).reset_index()
35
36# Derived: tenure and recency
37customers["days_since_first_order"] = (reference_date - customers["first_order_date"]).dt.days
38customers["days_since_last_order"] = (reference_date - customers["last_order_date"]).dt.days
39
40# Derived: return rate
41customers["return_rate_pct"] = (customers["return_count"] / customers["orders_count"] * 100).round(1)
42
43# Derived: average days between orders
44customers["avg_days_between_orders"] = np.where(
45 customers["orders_count"] > 1,
46 (customers["days_since_first_order"] / (customers["orders_count"] - 1)).round(1),
47 0
48)
49
50# Derived: has used discount
51customers["has_used_discount"] = (customers["avg_discount_pct"] > 0).astype(int)
52
53# Clean up intermediate date columns
54customers.drop(columns=["first_order_date", "last_order_date"], inplace=True)
55
56# Round numeric columns
57for col in ["total_spent", "avg_order_value", "total_discounts", "total_shipping",
58 "total_profit", "avg_quantity_per_order", "avg_rating", "avg_discount_pct"]:
59 customers[col] = customers[col].round(2)
60
61print(f"Customer-level dataset: {customers.shape}")
62customers.head()

Define Churn Label

A customer is churned if:

  1. They have been a customer for 180+ days (sufficient tenure to establish a purchase pattern)
  2. They have not placed an order in the last 90 days

Customers with less than 180 days tenure are excluded — they haven't been around long enough to reliably label.

1# Filter to customers with 180+ days tenure
2df = customers[customers["days_since_first_order"] >= 180].copy()
3
4# Define churn: no order in last 90 days
5df["churned"] = (df["days_since_last_order"] >= 90).astype(int)
6
7print(f"Customers with 180+ day tenure: {len(df):,} (excluded {len(customers) - len(df):,} new customers)")
8print(f"
9Churn distribution:")
10print(df["churned"].value_counts())
11print(f"
12Churn rate: {df['churned'].mean():.1%}")

1. Data Preprocessing

The aggregation step above (orders → customers) is business logic that runs before the persistable pipeline. The pipeline below handles transforms on the already-aggregated customer-level data — this is what gets persisted to Xplainable Cloud and runs at inference time.

We use the new PipelineSpec format, which is JSON-serializable and can be versioned, previewed, and recompiled independently of the fitted state.

1# Define the preprocessing spec (JSON-serializable, persistable)
2preprocessing_spec = PipelineSpec(steps=[
3 StepSpec(
4 id="lowercase_categoricals",
5 type="TextCleanTransformer",
6 columns=["customer_country", "primary_traffic_source",
7 "primary_payment_method", "primary_product_category"],
8 params={"operations": ["lowercase"]},
9 description="Standardize categorical values to lowercase",
10 ),
11 StepSpec(
12 id="drop_leakage_columns",
13 type="DropColumnsTransformer",
14 params={"columns": [
15 "customer_id", # Highly cardinal identifier
16 "days_since_last_order", # Data leakage — directly encodes the churn definition
17 "total_profit", # Data leakage — derived from revenue and costs
18 ]},
19 description="Drop ID, leakage, and redundant columns",
20 ),
21])
22
23# Compile spec into an executable pipeline
24pipeline = compile_spec(preprocessing_spec)
25
26# Fit and transform
27df_transformed = pipeline.fit_transform(df)
28print(f"Transformed shape: {df_transformed.shape}")
29df_transformed.head()

Persist Preprocessor to Xplainable Cloud

The PipelineSpec is JSON-serializable, so we can persist it directly. The API stores both the spec and the fitted pipeline binary, allowing it to be loaded and reused for inference.

1# Persist the preprocessing spec to Xplainable Cloud
2try:
3 preprocessor_id, preprocessor_version_id = client.preprocessing.create_preprocessor(
4 name="Shopify Churn Preprocessing",
5 description="Customer-level feature transforms for Shopify churn prediction. "
6 "Expects pre-aggregated customer data (not raw orders).",
7 spec=preprocessing_spec.model_dump(),
8 sample_df=df,
9 )
10 print(f"Preprocessor created: {preprocessor_id} (version: {preprocessor_version_id})")
11except (XplainableAPIError, ValueError) as e:
12 print(f"Error creating preprocessor: {e}")
13 preprocessor_id, preprocessor_version_id = None, None

Train/Test Split

1X, y = df_transformed.drop(columns=["churned"]), df_transformed["churned"]
2
3X_train, X_test, y_train, y_test = train_test_split(
4 X, y, test_size=0.33, random_state=42
5)
6
7print(f"Train: {X_train.shape[0]:,} samples")
8print(f"Test: {X_test.shape[0]:,} samples")
9print(f"Train churn rate: {y_train.mean():.1%}")
10print(f"Test churn rate: {y_test.mean():.1%}")

2. Model Optimisation

The XParamOptimiser uses Bayesian optimisation to find the best hyperparameters for the XClassifier, balancing accuracy and computational efficiency.

1opt = XParamOptimiser()
2params = opt.optimise(X_train, y_train)

3. Model Training

1model = XClassifier(**params)
2model.fit(X_train, y_train)

4. Model Interpretability and Explainability

The model.explain() method generates an interactive visualisation showing:

  • Feature Importances: Which customer attributes are most predictive of churn
  • Contributions: How specific feature values push predictions toward churn or retention
1model.explain()

5. Model Persisting

Save the trained model to Xplainable Cloud for version tracking, collaboration, and deployment.

1# Persist the trained model
2try:
3 model_id, version_id = client.models.create_model(
4 model=model,
5 model_name="Shopify Customer Churn",
6 model_description="Predicting which Shopify customers are likely to churn based on order history, purchasing behavior, and engagement patterns.",
7 x=X_train,
8 y=y_train
9 )
10 print(f"Model created: {model_id} (version: {version_id})")
11except XplainableAPIError as e:
12 print(f"Error creating model: {e}")
13 model_id, version_id = None, None

6. Model Deployment

Deploy the model to Xplainable's inference endpoint, making it available for real-time churn predictions via API.

1if model_id and version_id:
2 try:
3 deployment_response = client.deployments.deploy(
4 model_version_id=version_id
5 )
6 deployment_id = deployment_response.deployment_id
7 except XplainableAPIError as e:
8 print(f"Error deploying model: {e}")
9 deployment_id = None
10else:
11 deployment_id = None

7. Testing the Deployment

Activate the deployment, generate an API key, and make a test prediction.

1# Activate the deployment
2if deployment_id:
3 try:
4 client.deployments.activate_deployment(deployment_id=deployment_id)
5 except XplainableAPIError as e:
6 print(f"Error activating deployment: {e}")
7else:
8 print("Deployment ID not available")
1# Generate a deployment key
2if deployment_id:
3 try:
4 deploy_key = client.deployments.generate_deploy_key(
5 deployment_id=deployment_id,
6 description="API key for Shopify Churn",
7 days_until_expiry=1
8 )
9 print(f"Deploy key created: {str(deploy_key)}")
10 except XplainableAPIError as e:
11 print(f"Error generating deploy key: {e}")
12 deploy_key = None
13else:
14 deploy_key = None
1# Generate a sample payload from test data
2body = json.loads(X_test.sample(1).to_json(orient="records"))
3print("Sample payload:")
4print(json.dumps(body, indent=2))
1# Make a prediction request
2if deploy_key and body:
3 response = requests.post(
4 url="https://inference.xplainable.io/v1/predict",
5 headers={"api_key": str(deploy_key)},
6 json=body
7 )
8 print("Prediction result:", response.json())
9else:
10 print("Deploy key or body not available for prediction")

8. AI-Generated Report

1if model_id and version_id:
2 report = client.gpt.generate_report(
3 model_id=model_id,
4 version_id=version_id,
5 target_description="Customer churn likelihood (1 = will churn, 0 = will stay)",
6 project_objective="Identify at-risk Shopify customers to enable proactive retention campaigns",
7 max_features=10,
8 temperature=0.7
9 )
10
11 from IPython.display import Markdown, display
12 display(Markdown(report.body))
13else:
14 print("Model not persisted — skipping report generation")

9. Contribution-Driven Retention Optimization

The xplainable model doesn't just predict whether a customer will churn — it explains why via per-feature contribution scores. Crucially, several of these features represent controllable business levers that exist in the dataset:

  • orders_count / unique_products — drive repeat purchases through recommendations
  • total_shipping — offer free shipping to increase engagement
  • total_spent — use discounts to increase basket size
  • unique_categories — cross-category recommendations
  • avg_discount_pct / has_used_discount — incentivize with targeted offers

The model's partition profiles tell us the measured churn reduction when a customer moves from one partition to another. For example, if orders_count at 1 order scores +0.012 (toward churn) and at 3 orders scores -0.048 (toward retention), that's a 6pp churn reduction — derived from the data, not assumed.

We use these counterfactual partition shifts as the lever effects, then calculate the net expected value of each intervention per customer.

Extract Contributions, CLV, and Counterfactual Lever Effects

For each controllable feature, the lever effect is the difference between a customer's current contribution score and the best achievable partition score. This tells us how much churn probability would drop if we successfully moved that customer to the best partition — measured from the data.

1# Get per-feature contributions for every test customer
2contributions = model._transform(X_test)
3contrib_df = pd.DataFrame(contributions, columns=model.columns, index=X_test.index)
4
5# Churn probability = base_value + sum of contributions
6base_value = model.profile['base_value']
7contrib_df['churn_prob'] = (contributions.sum(axis=1) + base_value).clip(0, 1)
8
9# Estimate CLV
10contrib_df['est_orders_per_year'] = np.where(
11 X_test['avg_days_between_orders'] > 0,
12 365 / X_test['avg_days_between_orders'],
13 1
14)
15contrib_df['estimated_clv'] = (X_test['avg_order_value'] * contrib_df['est_orders_per_year']).round(2)
16
17# Define controllable features and find best partition score for each
18controllable_features = [
19 'orders_count', 'unique_products', 'unique_categories',
20 'total_spent', 'total_shipping', 'avg_quantity_per_order',
21 'avg_discount_pct', 'has_used_discount',
22]
23
24profile = model.profile
25best_partition_scores = {}
26for feat in controllable_features:
27 if feat in profile['numeric']:
28 scores = [p['score'] for p in profile['numeric'][feat]
29 if not (isinstance(p.get('lower', 0), float) and np.isnan(p.get('lower', 0)))]
30 if scores:
31 best_partition_scores[feat] = min(scores)
32
33# Compute lever effect per customer per feature:
34# = current contribution - best achievable contribution
35lever_effects = pd.DataFrame(index=X_test.index)
36for feat in controllable_features:
37 if feat in best_partition_scores and feat in contrib_df.columns:
38 lever_effects[feat] = contrib_df[feat] - best_partition_scores[feat]
39
40# For each customer, identify the feature with the biggest improvement potential
41lever_effects['best_lever'] = lever_effects[controllable_features].idxmax(axis=1)
42lever_effects['lever_effect'] = lever_effects[controllable_features].max(axis=1)
43
44print(f"Base churn rate: {base_value:.1%}")
45print(f"
46Best lever per feature (which feature offers most improvement per customer):")
47print(lever_effects['best_lever'].value_counts().to_string())
48print(f"
49Average churn reduction by best lever:")
50summary = lever_effects.groupby('best_lever')['lever_effect'].agg(['count', 'mean'])
51summary.columns = ['customers', 'avg_churn_reduction']
52print(summary.sort_values('avg_churn_reduction', ascending=False).round(4).to_string())

Map Levers to Actions and Costs

Each controllable feature maps to a concrete business action. The lever effect comes from the model (data-driven), the cost is a business input (replace with your actual costs).

1# Map controllable features to business actions
2# Lever effect = from model partitions (data-driven)
3# Cost = business input (replace with your actual costs)
4lever_actions = {
5 "unique_products": {"action": "Product recommendation campaign", "cost": 0.50},
6 "orders_count": {"action": "Repeat purchase incentive", "cost": 3.00},
7 "total_spent": {"action": "Personalized discount (10%)", "cost_pct_aov": 0.10}, # % of AOV
8 "unique_categories": {"action": "Cross-category recommendation", "cost": 0.50},
9 "total_shipping": {"action": "Free shipping offer", "cost": 8.00},
10 "avg_quantity_per_order": {"action": "Bundle / multi-buy incentive", "cost": 3.00},
11 "avg_discount_pct": {"action": "Targeted discount offer", "cost_pct_aov": 0.10},
12 "has_used_discount": {"action": "First-time discount email", "cost": 0.50},
13}
14
15# Combine lever effects with actions and costs
16optimization = lever_effects[['best_lever', 'lever_effect']].copy()
17optimization['churn_prob'] = contrib_df['churn_prob']
18optimization['estimated_clv'] = contrib_df['estimated_clv']
19optimization['avg_order_value'] = X_test['avg_order_value']
20
21# Assign action and cost
22optimization['action'] = optimization['best_lever'].map(
23 lambda f: lever_actions.get(f, {}).get('action', 'General winback')
24)
25optimization['lever_cost'] = optimization.apply(
26 lambda row: (
27 lever_actions.get(row['best_lever'], {}).get('cost', 1.50)
28 if 'cost' in lever_actions.get(row['best_lever'], {})
29 else row['avg_order_value'] * lever_actions.get(row['best_lever'], {}).get('cost_pct_aov', 0.10)
30 ), axis=1
31).round(2)
32
33print("Action assignment:")
34print(optimization.groupby('action').agg(
35 customers=('action', 'count'),
36 avg_churn_prob=('churn_prob', 'mean'),
37 avg_lever_effect=('lever_effect', 'mean'),
38 avg_cost=('lever_cost', 'mean'),
39).sort_values('customers', ascending=False).round(3).to_string())

Expected Value Optimization

The net EV now uses the model-derived lever effect instead of an assumed prevention rate:

Net EV = lever_effect x CLV - lever_cost

Where lever_effect is the counterfactual churn reduction from the model's partition profile — the measured difference between the customer's current partition and the best achievable partition for that feature.

1# Net EV = lever_effect (from model) * CLV - lever_cost (from business)
2optimization['expected_revenue_saved'] = (optimization['lever_effect'] * optimization['estimated_clv']).round(2)
3optimization['net_ev'] = (optimization['expected_revenue_saved'] - optimization['lever_cost']).round(2)
4optimization['roi'] = np.where(
5 optimization['lever_cost'] > 0,
6 (optimization['net_ev'] / optimization['lever_cost']).round(2),
7 0
8)
9
10positive_ev = optimization[optimization['net_ev'] > 0].copy()
11negative_ev = optimization[optimization['net_ev'] <= 0].copy()
12
13print(f"Customers worth intervening on: {len(positive_ev):,} ({len(positive_ev)/len(optimization):.1%})")
14print(f"Customers to skip (negative EV): {len(negative_ev):,} ({len(negative_ev)/len(optimization):.1%})")
15print(f"
16--- Portfolio Summary (positive-EV only) ---")
17print(f"Total intervention cost: ${positive_ev['lever_cost'].sum():>12,.2f}")
18print(f"Total expected revenue saved: ${positive_ev['expected_revenue_saved'].sum():>12,.2f}")
19print(f"Total net EV: ${positive_ev['net_ev'].sum():>12,.2f}")
20if positive_ev['lever_cost'].sum() > 0:
21 print(f"Portfolio ROI: {positive_ev['net_ev'].sum() / positive_ev['lever_cost'].sum():.1f}x")
1# Breakdown by action
2print("Net EV by action (data-driven lever effects):
3")
4action_summary = positive_ev.groupby('action').agg(
5 customers=('action', 'count'),
6 avg_lever_effect=('lever_effect', 'mean'),
7 total_cost=('lever_cost', 'sum'),
8 total_revenue_saved=('expected_revenue_saved', 'sum'),
9 total_net_ev=('net_ev', 'sum'),
10 avg_roi=('roi', 'mean'),
11).sort_values('total_net_ev', ascending=False).round(2)
12
13action_summary

Budget-Constrained Allocation

Rank customers by ROI and allocate a fixed monthly retention budget to the highest-return interventions first.

1# Rank by ROI and apply budget constraints
2portfolio = positive_ev.sort_values('roi', ascending=False).copy()
3portfolio['cumulative_cost'] = portfolio['lever_cost'].cumsum()
4
5budget_levels = [500, 1000, 2500, 5000, 10000, 25000]
6
7budget_analysis = []
8for budget in budget_levels:
9 within = portfolio[portfolio['cumulative_cost'] <= budget]
10 if len(within) > 0:
11 total_cost = within['lever_cost'].sum()
12 budget_analysis.append({
13 'budget': f'${budget:,}',
14 'customers_reached': len(within),
15 'cost_used': round(total_cost, 2),
16 'revenue_saved': round(within['expected_revenue_saved'].sum(), 2),
17 'net_ev': round(within['net_ev'].sum(), 2),
18 'roi': f"{within['net_ev'].sum() / total_cost:.1f}x",
19 })
20
21budget_df = pd.DataFrame(budget_analysis)
22print("Budget Allocation — customers ranked by ROI:
23")
24budget_df

Diminishing Returns Analysis

Each additional dollar of retention spend yields less marginal return. This helps identify the inflection point where additional budget stops being worthwhile.

1# Marginal ROI at each budget tier
2marginal_analysis = []
3prev_cost, prev_ev = 0, 0
4
5for budget in budget_levels:
6 within = portfolio[portfolio['cumulative_cost'] <= budget]
7 if len(within) > 0:
8 total_cost = within['lever_cost'].sum()
9 total_ev = within['net_ev'].sum()
10 marginal_cost = total_cost - prev_cost
11 marginal_ev = total_ev - prev_ev
12 marginal_roi = marginal_ev / marginal_cost if marginal_cost > 0 else 0
13
14 marginal_analysis.append({
15 'tier': f'${prev_cost:,.0f} → ${total_cost:,.0f}',
16 'marginal_spend': round(marginal_cost, 2),
17 'marginal_ev_gained': round(marginal_ev, 2),
18 'marginal_roi': f'{marginal_roi:.1f}x',
19 })
20 prev_cost, prev_ev = total_cost, total_ev
21
22marginal_df = pd.DataFrame(marginal_analysis)
23print("Diminishing Returns by Budget Tier:
24")
25marginal_df
1# Top 10 highest-value customers
2print("Top 10 highest net-EV customers:
3")
4positive_ev.nlargest(10, 'net_ev')[
5 ['churn_prob', 'estimated_clv', 'best_lever', 'lever_effect',
6 'action', 'lever_cost', 'expected_revenue_saved', 'net_ev']
7].round(2)

What's Data-Driven vs What's Assumed

This optimization separates model outputs from business inputs:

From the model (data-driven):

  • Churn probability per customer (base_value + contributions)
  • Per-feature contribution scores explaining why each customer is at risk
  • Counterfactual lever effects: how much churn probability changes if a feature moves from its current partition to the best partition
  • CLV estimates (from actual purchase frequency and AOV)

Business inputs (replace with your actuals):

  • Intervention costs ($0.50 for an email, $3 for loyalty enrollment, etc.)
  • The assumption that moving a feature to a better partition is achievable via the mapped action

The lever effects are the key differentiator. Instead of assuming "a discount reduces churn by 12%", the model tells us: "for this customer, their unique_products contribution is +0.012 (toward churn), and the best partition is -0.086 — so getting them to buy more products could reduce their churn score by 9.8pp." That's measured from the data, not invented.