Customer churn prediction

更新时间:
复制 MD 格式

This tutorial walks through a complete in-database machine learning workflow using the pgml extension on AnalyticDB for PostgreSQL V7.0. You will train a customer churn classification model on E-commerce behavioral data, tune it with grid search, and run both real-time and batch inference — all in SQL, without moving data to an external ML platform.

Prerequisites

Before you begin, ensure that you have:

  • An AnalyticDB for PostgreSQL V7.0 instance running kernel version V7.1.1.0 or later

  • The instance configured in elastic storage mode

  • The pgml extension installed on the instance

If the pgml extension is installed, a schema named pgml appears in your schema list. If it is not installed, submit a ticket for installation assistance. After installation, restart the instance. To uninstall pgml, submit a ticket.

How it works

The pgml extension brings AI/ML directly into the database. It loads models into PostgreSQL backend processes and exposes training, fine-tuning, and inference as user-defined functions (UDFs). Trained models are stored in heap tables — no separate high-availability setup required. By colocating compute with storage, pgml eliminates data transfer overhead and simplifies operations.

image

Workflow overview

The full workflow runs in four stages:

-- Stage 1: Import data
COPY raw_data_table FROM '/path/to/dataset.csv' DELIMITER ',' CSV HEADER;

-- Stage 2: Create a training view with feature engineering
CREATE OR REPLACE VIEW train_data_view AS SELECT ...;

-- Stage 3: Train and select the best model
SELECT * FROM pgml.train('Customer Churn Prediction Project', ...);

-- Stage 4: Run inference
SELECT pgml.predict('Customer Churn Prediction Project', (...)) FROM predict_data_view;

The sections below walk through each stage in detail.

Import data

Dataset

This tutorial uses the Ecommerce Customer Churn Analysis and Prediction dataset from Kaggle. It contains historical customer behavior records with churn labels — input for building a retention prediction model.

The dataset has 20 fields:

FieldDescription
CustomerIDUnique customer ID
ChurnChurn label (prediction target)
TenureHow long the customer has used the service
PreferredLoginDeviceCustomer's preferred login device
CityTierCity tier where the customer lives
WarehouseToHomeDistance from warehouse to customer's home
PreferredPaymentModeCustomer's preferred payment method
GenderCustomer's gender
HourSpendOnAppHours spent on the mobile app or website
NumberOfDeviceRegisteredTotal registered devices
PreferedOrderCatPreferred order category in the last month
SatisfactionScoreCustomer satisfaction score
MaritalStatusMarital status
NumberOfAddressTotal addresses added
ComplainWhether a complaint was raised in the last month
OrderAmountHikeFromlastYearYear-over-year order amount growth rate
CouponUsedCoupons used in the last month
OrderCountOrders placed in the last month
DaySinceLastOrderDays since the most recent order
CashbackAmountCashback received in the last month

Create the table and import data

  1. Create the raw data table:

    CREATE TABLE raw_data_table (
        CustomerID INTEGER,
        Churn INTEGER,
        Tenure FLOAT,
        PreferredLoginDevice TEXT,
        CityTier INTEGER,
        WarehouseToHome FLOAT,
        PreferredPaymentMode TEXT,
        Gender TEXT,
        HourSpendOnApp FLOAT,
        NumberOfDeviceRegistered INTEGER,
        PreferedOrderCat TEXT,
        SatisfactionScore INTEGER,
        MaritalStatus TEXT,
        NumberOfAddress INTEGER,
        Complain INTEGER,
        OrderAmountHikeFromlastYear FLOAT,
        CouponUsed FLOAT,
        OrderCount FLOAT,
        DaySinceLastOrder FLOAT,
        CashbackAmount FLOAT
    );
  2. Download the dataset and import it. Replace /path/to/dataset with the actual file path:

    COPY raw_data_table FROM '/path/to/dataset.csv' DELIMITER ',' CSV HEADER;
Use the psql tool to import data. If you use another SDK, import with the COPY or INSERT statement.

Analyze data

Before training, check for null values to determine preprocessing strategies.

Check null counts

Run the following query to count null values across all columns:

DO $$
DECLARE
    r RECORD;
    SQL TEXT := '';
BEGIN
    FOR r IN
        SELECT column_name
        FROM information_schema.columns
        WHERE table_name = 'raw_data_table'
    LOOP
        SQL := SQL ||
            'SELECT ''' || r.column_name || ''' AS column_name, COUNT(*) FILTER (WHERE ' || r.column_name || ' IS NULL) AS null_count FROM raw_data_table UNION ALL ';
    END LOOP;

    SQL := LEFT(SQL, length(SQL) - 11);

    FOR r IN EXECUTE SQL LOOP
        RAISE NOTICE 'Column: %, Null Count: %', r.column_name, r.null_count;
    END LOOP;
END $$;

Sample result:

NOTICE:  Column: customerid, Null Count: 0
NOTICE:  Column: churn, Null Count: 0
NOTICE:  Column: tenure, Null Count: 264
NOTICE:  Column: preferredlogindevice, Null Count: 0
NOTICE:  Column: citytier, Null Count: 0
NOTICE:  Column: warehousetohome, Null Count: 251
NOTICE:  Column: preferredpaymentmode, Null Count: 0
NOTICE:  Column: gender, Null Count: 0
NOTICE:  Column: hourspendonapp, Null Count: 255
NOTICE:  Column: numberofdeviceregistered, Null Count: 0
NOTICE:  Column: preferedordercat, Null Count: 0
NOTICE:  Column: satisfactionscore, Null Count: 0
NOTICE:  Column: maritalstatus, Null Count: 0
NOTICE:  Column: numberofaddress, Null Count: 0
NOTICE:  Column: complain, Null Count: 0
NOTICE:  Column: orderamounthikefromlastyear, Null Count: 265
NOTICE:  Column: couponused, Null Count: 256
NOTICE:  Column: ordercount, Null Count: 258
NOTICE:  Column: daysincelastorder, Null Count: 307
NOTICE:  Column: cashbackamount, Null Count: 0

Inspect column distributions

For columns with null values, inspect the data distribution to choose an imputation strategy. The following helper function computes distinct count, min, max, mean, and median for any column:

CREATE OR REPLACE FUNCTION print_column_statistics(table_name TEXT, column_name TEXT)
RETURNS VOID AS $$
DECLARE
    SQL TEXT;
    distinct_count INTEGER;
    min_value NUMERIC;
    max_value NUMERIC;
    avg_value NUMERIC;
    median_value NUMERIC;
    r RECORD;
BEGIN
    SQL := 'SELECT
                COUNT(DISTINCT ' || column_name || ') AS distinct_count,
                MIN(' || column_name || ') AS min_value,
                MAX(' || column_name || ') AS max_value,
                AVG(' || column_name || ') AS avg_value,
                PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ' || column_name || ') AS median_value
            FROM ' || table_name;

    EXECUTE SQL INTO r;

    distinct_count := r.distinct_count;
    min_value := r.min_value;
    max_value := r.max_value;
    avg_value := r.avg_value;
    median_value := r.median_value;

    RAISE NOTICE 'Distinct Count: %', distinct_count;

    IF distinct_count < 40 THEN
        SQL := 'SELECT ' || column_name || ' AS col, COUNT(*) AS count FROM ' || table_name ||
               ' GROUP BY ' || column_name || ' ORDER BY count DESC';

        FOR r IN EXECUTE SQL LOOP
            RAISE NOTICE '%: %', r.col, r.count;
        END LOOP;
    END IF;

    RAISE NOTICE 'Min Value: %, Max Value: %, Avg Value: %, Median Value: %',
                 min_value, max_value, avg_value, median_value;
END;
$$ LANGUAGE plpgsql;

Example — inspect the tenure column:

SELECT print_column_statistics('raw_data_table', 'tenure');

Sample result:

NOTICE:  Distinct Count: 36
NOTICE:  1: 690
NOTICE:  0: 508
NOTICE:  <NULL>: 264
NOTICE:  8: 263
...
NOTICE:  Min Value: 0, Max Value: 61, Avg Value: 10.1898993663809, Median Value: 9

Train the model

Preprocess data

Seven columns contain null values. The following table summarizes the imputation strategy for each, based on the distribution analysis:

ColumnStrategyReason
TenureMedianPositively skewed distribution
WarehouseToHomeMedianExtreme outliers; median centralizes distribution
HourSpendOnAppMeanSymmetric distribution
OrderAmountHikeFromLastYearMeanStable distribution
CouponUsedZeroNull means no coupon used
OrderCountZeroNull means no orders placed
DaySinceLastOrderMaxNull indicates a long inactive period

These strategies map directly to the preprocess parameter in pgml.train():

{
  "tenure": {"impute": "median"},
  "warehousetohome": {"impute": "median"},
  "hourspendonapp": {"impute": "mean"},
  "orderamounthikefromlastyear": {"impute": "mean"},
  "couponused": {"impute": "zero"},
  "ordercount": {"impute": "zero"},
  "daysincelastorder": {"impute": "max"}
}
CityTier and Complain are stored as INTEGER but represent categorical labels. Cast them to TEXT and apply one-hot encoding during training.

Create a training view

Create a view that applies type casts without modifying the raw table. This keeps the original data intact and lets you iterate on features by recreating the view.

CREATE OR REPLACE VIEW train_data_view AS
SELECT
  Churn::TEXT,
  Tenure,
  PreferredLoginDevice,
  CityTier::TEXT,
  WarehouseToHome,
  PreferredPaymentMode,
  Gender,
  HourSpendOnApp,
  NumberOfDeviceRegistered,
  PreferedOrderCat,
  SatisfactionScore,
  MaritalStatus,
  NumberOfAddress,
  Complain::TEXT,
  OrderAmountHikeFromlastYear,
  CouponUsed,
  OrderCount,
  DaySinceLastOrder,
  CashbackAmount
FROM
  raw_data_table;

Apply feature engineering

Feature engineering derives additional signals from existing columns. The following four derived features capture per-order behavior patterns:

FeatureFormulaWhat it captures
AvgCashbkPerOrderCashbackAmount / OrderCountAverage cashback per order
AvgHourSpendPerOrderHourSpendOnApp / OrderCountAverage browse time per order
CouponUsedPerOrderCouponUsed / OrderCountCoupon usage rate per order
LogCashbackAmountlog(1 + CashbackAmount)Log-transformed cashback amount

Recreate the view to include these features:

CREATE OR REPLACE VIEW train_data_view AS
SELECT
  Churn::TEXT,
  Tenure,
  PreferredLoginDevice,
  CityTier::TEXT,
  WarehouseToHome,
  PreferredPaymentMode,
  Gender,
  HourSpendOnApp,
  NumberOfDeviceRegistered,
  PreferedOrderCat,
  SatisfactionScore,
  MaritalStatus,
  NumberOfAddress,
  Complain::TEXT,
  OrderAmountHikeFromlastYear,
  CouponUsed,
  OrderCount,
  DaySinceLastOrder,
  CashbackAmount,
  CashbackAmount/OrderCount AS AvgCashbkPerOrder,
  HourSpendOnApp/OrderCount AS AvgHourSpendPerOrder,
  CouponUsed/OrderCount AS CouponUsedPerOrder,
  log(1+CashbackAmount) AS LogCashbackAmount
FROM
  raw_data_table;

Select an algorithm

Use pgml.train() to fit multiple algorithms and compare their F1 scores. All examples use the same project name, task, data source, and preprocessing parameters — only the algorithm value changes between runs.

`pgml.train()` key parameters:

ParameterDescriptionExample
project_nameIdentifies the project; reused across training runs'Customer Churn Prediction Project'
taskML task type'classification'
relation_nameSource table or view'train_data_view'
y_column_namePrediction target column'churn'
preprocessImputation and encoding config (JSON)'{"tenure": {"impute": "median"}, ...}'
algorithmAlgorithm to fit'xgboost', 'bagging'
runtimeExecution runtime'python' (required)
test_sizeFraction held out for evaluation0.2
searchHyperparameter search method'grid'
search_paramsSearch space (JSON)'{"max_depth": [4, 6, 8, 16], ...}'
search_argsSearch settings, e.g., cross-validation folds'{"cv": 5}'
hyperparamsFixed hyperparameters (JSON)'{"nthread": 16, "alpha": 0}'

Fit the XGBoost model:

SELECT * FROM pgml.train(
    project_name => 'Customer Churn Prediction Project',
    task => 'classification',
    relation_name => 'train_data_view',
    y_column_name => 'churn',
    preprocess => '{
            "tenure": {"impute": "median"},
            "warehousetohome": {"impute": "median"},
            "hourspendonapp": {"impute": "mean"},
            "orderamounthikefromlastyear": {"impute": "mean"},
            "couponused": {"impute": "zero"},
            "ordercount": {"impute": "zero"},
            "daysincelastorder": {"impute": "max"},
            "avgcashbkperorder": {"impute": "zero"},
            "avghourspendperorder": {"impute": "zero"},
            "couponusedperorder": {"impute": "zero"},
            "logcashbackamount": {"impute": "min"}
        }',
    algorithm => 'xgboost',
    runtime => 'python',
    test_size => 0.2
);
-- {"f1": 0.9543147, "precision": 0.96907216, "recall": 0.94, "accuracy": 0.9840142, ...}

Fit the bagging model for comparison:

SELECT * FROM pgml.train(
    project_name => 'Customer Churn Prediction Project',
    task => 'classification',
    relation_name => 'train_data_view',
    y_column_name => 'churn',
    preprocess => '{
            "tenure": {"impute": "median"},
            "warehousetohome": {"impute": "median"},
            "hourspendonapp": {"impute": "mean"},
            "orderamounthikefromlastyear": {"impute": "mean"},
            "couponused": {"impute": "zero"},
            "ordercount": {"impute": "zero"},
            "daysincelastorder": {"impute": "max"},
            "avgcashbkperorder": {"impute": "zero"},
            "avghourspendperorder": {"impute": "zero"},
            "couponusedperorder": {"impute": "zero"},
            "logcashbackamount": {"impute": "min"}
        }',
    algorithm => 'bagging',
    runtime => 'python',
    test_size => 0.2
);
-- {"f1": 0.9270833, "precision": 0.96216214, "recall": 0.89447236}

XGBoost achieves a higher F1 score (0.9543 vs. 0.9271), so this tutorial proceeds with XGBoost for hyperparameter tuning. To compare other algorithms, replace the algorithm value. For the full list of supported algorithms, see the pgml.algorithm enumeration type table in Use machine learning.

Tune hyperparameters

Run a grid search with 5-fold cross-validation to find the optimal XGBoost hyperparameters. The search explores the following space:

HyperparameterSearch valuesWhat it controls
max_depth4, 6, 8, 16Maximum tree depth; higher values capture more interactions but risk overfitting
n_estimators100, 200, 300, 400, 500, 1000, 2000Number of trees; more trees improve performance at higher compute cost
eta0.05, 0.1, 0.2Learning rate; lower values are more stable but require more estimators
SELECT * FROM pgml.train(
    project_name => 'Customer Churn Prediction Project',
    task => 'classification',
    relation_name => 'train_data_view',
    y_column_name => 'churn',
    preprocess => '{
            "tenure": {"impute": "median"},
            "warehousetohome": {"impute": "median"},
            "hourspendonapp": {"impute": "mean"},
            "orderamounthikefromlastyear": {"impute": "mean"},
            "couponused": {"impute": "zero"},
            "ordercount": {"impute": "zero"},
            "daysincelastorder": {"impute": "max"},
            "avgcashbkperorder": {"impute": "zero"},
            "avghourspendperorder": {"impute": "zero"},
            "couponusedperorder": {"impute": "zero"},
            "logcashbackamount": {"impute": "min"}
        }',
    algorithm => 'xgboost',
    search_args => '{ "cv": 5 }',
    SEARCH => 'grid',
    search_params => '{
        "max_depth": [4, 6, 8, 16],
        "n_estimators": [100, 200, 300, 400, 500, 1000, 2000],
        "eta": [0.05, 0.1, 0.2]
    }',
    hyperparams => '{
        "nthread": 16,
        "alpha": 0,
        "lambda": 1
    }',
    runtime => 'python',
    test_size => 0.2
);

Sample result:

INFO:  Best Hyperparams: {
  "alpha": 0,
  "lambda": 1,
  "nthread": 16,
  "eta": 0.1,
  "max_depth": 6,
  "n_estimators": 1000
}
INFO:  Best f1 Metrics: Number(0.9874088168144226)

The search identifies {"eta": 0.1, "max_depth": 6, "n_estimators": 1000} as the best configuration, achieving an F1 score of 0.9874 on the held-out validation set. The "cv": 5 setting means each configuration is evaluated on 5 different data splits, which makes the score more reliable than a single train/test split.

Train on full data with optimal hyperparameters

Use the best hyperparameters from the grid search to train a final model on the full dataset:

SELECT * FROM pgml.train(
    project_name => 'Customer Churn Prediction Project',
    task => 'classification',
    relation_name => 'train_data_view',
    y_column_name => 'churn',
    preprocess => '{
            "tenure": {"impute": "median"},
            "warehousetohome": {"impute": "median"},
            "hourspendonapp": {"impute": "mean"},
            "orderamounthikefromlastyear": {"impute": "mean"},
            "couponused": {"impute": "zero"},
            "ordercount": {"impute": "zero"},
            "daysincelastorder": {"impute": "max"},
            "avgcashbkperorder": {"impute": "zero"},
            "avghourspendperorder": {"impute": "zero"},
            "couponusedperorder": {"impute": "zero"},
            "logcashbackamount": {"impute": "min"}
        }',
    algorithm => 'xgboost',
    hyperparams => '{
        "max_depth": 6,
        "n_estimators": 1000,
        "eta": 0.1,
        "nthread": 16,
        "alpha": 0,
        "lambda": 1
    }',
    runtime => 'python',
    test_size => 0.2
);

Sample result:

INFO:  Training Model { id: 170, task: classification, algorithm: xgboost, runtime: python }
INFO:  Hyperparameter searches: 1, cross validation folds: 1
INFO:  Hyperparams: {
  "eta": 0.1,
  "alpha": 0,
  "lambda": 1,
  "nthread": 16,
  "max_depth": 6,
  "n_estimators": 1000
}
INFO:  Metrics: {"roc_auc": 0.9751001, "log_loss": 0.19821791, "f1": 0.99258476, "precision": 0.9936373, "recall": 0.9915344, "accuracy": 0.9875666, "mcc": 0.95414394, "fit_time": 0.9980099, "score_time": 0.0085158}
INFO:  Comparing to deployed model f1: Some(0.9874088168144226)
INFO:  Deploying model id: 170
                  project              |      task      | algorithm | deployed
-----------------------------------+----------------+-----------+----------
 Customer Churn Prediction Project | classification | xgboost   | t

The final model achieves an F1 score of 0.9926 on the test set, an improvement over the cross-validated score of 0.9874 from the grid search run.

Deploy the model

By default, pgml automatically deploys the model with the highest F1 score within a project (for classification tasks). To check which model is currently deployed:

SELECT d.id, d.project_id, d.model_id, p.name, p.task FROM pgml.deployments d
JOIN pgml.projects p on d.project_id = p.id;

Sample result:

 id | project_id | model_id |               name                |      task
----+------------+----------+-----------------------------------+----------------
 61 |          2 |      170 | Customer Churn Prediction Project | classification

To deploy a specific model instead of the best-scoring one, see the "Deployment" section of Use machine learning.

Run inference

Real-time inference

Real-time inference returns a prediction immediately for a single input record. Use it when a data analyst or application needs an instant response based on a customer's behavioral profile.

SELECT pgml.predict('Customer Churn Prediction Project',
( 4, 'Mobile Phone'::TEXT, 3, 6,
'Debit Card'::TEXT, 'Female'::TEXT, 3, 3,
'Laptop & Accessory'::TEXT, 2,
'Single'::TEXT, 9 ,
'1'::TEXT, 11, 1, 1, 5, 159.93,
159.93, 3, 1, 2.206637011283536
));

Sample result:

 predict
---------
       0
(1 row)

A result of 0 means the model predicts this customer will not churn.

Batch inference

Batch inference processes many records in a single query. Use it when you need to score a large customer segment and throughput matters more than response latency.

First, create a prediction view that applies the same feature engineering used during training:

CREATE OR REPLACE VIEW predict_data_view AS
SELECT
  CustomerID,
  Churn::TEXT,
  Tenure,
  PreferredLoginDevice,
  CityTier::TEXT,
  WarehouseToHome,
  PreferredPaymentMode,
  Gender,
  HourSpendOnApp,
  NumberOfDeviceRegistered,
  PreferedOrderCat,
  SatisfactionScore,
  MaritalStatus,
  NumberOfAddress,
  Complain::TEXT,
  OrderAmountHikeFromlastYear,
  CouponUsed,
  OrderCount,
  DaySinceLastOrder,
  CashbackAmount,
  CashbackAmount/OrderCount AS AvgCashbkPerOrder,
  HourSpendOnApp/OrderCount AS AvgHourSpendPerOrder,
  CouponUsed/OrderCount AS CouponUsedPerOrder,
  log(1+CashbackAmount) AS LogCashbackAmount
FROM
  raw_data_table;

Then run predictions across all rows:

SELECT CustomerID, pgml.predict('Customer Churn Prediction Project', (
  "tenure",
  "preferredlogindevice",
  "citytier",
  "warehousetohome",
  "preferredpaymentmode",
  "gender",
  "hourspendonapp",
  "numberofdeviceregistered",
  "preferedordercat",
  "satisfactionscore",
  "maritalstatus",
  "numberofaddress",
  "complain",
  "orderamounthikefromlastyear",
  "couponused",
  "ordercount",
  "daysincelastorder",
  "cashbackamount",
  "avgcashbkperorder",
  "avghourspendperorder",
  "couponusedperorder",
  "logcashbackamount"
)) FROM predict_data_view LIMIT 20;

Sample result:

 customerid | predict
------------+---------
      50005 |       0
      50009 |       0
      50012 |       0
      50013 |       0
      50019 |       0
      50020 |       0
      50022 |       0
      50023 |       0
      50026 |       0
      50031 |       1
      50039 |       1
      50040 |       0
      50043 |       1
      50045 |       1
      50047 |       0
      50048 |       1
      50050 |       1
      50051 |       1
      50052 |       1
      50053 |       0
(20 rows)

Customers with a prediction of 1 are identified as likely to churn. Use this output to prioritize retention campaigns or targeted offers.

What's next

  • Use machine learning — full pgml.train() API reference, supported algorithms, and deployment options