Build a Customer Churn Pipeline

Build, track, serve, and containerize a customer churn model.

Introduction

30 Second Summary

Retention teams often have more at-risk customers than they can contact at once. They need a reliable way to decide who needs attention first.

In this project, you will build a local customer churn pipeline with scikit-learn. It will turn raw customer records into repeatable churn probabilities that another process can use.

What You'll Build

You will run the finished inference script to see a raw customer record become a churn probability plus a risk label.

By the end of this project, you'll have:

  • Generate repeatable customer data with numerical fields, categorical fields, missing values, plus a churn target.
  • Compare model quality using a numeric-only baseline, five-fold cross-validation, holdout ROC AUC, plus threshold recall.
  • Score unseen customers from raw business fields using one saved artifact that keeps preprocessing tied to prediction.
  • Secret Mission: Rank three customers by churn probability while handling the unseen central region.

Are there any prerequisites?

You need a Windows computer. You also need permission to install or update Python packages.

Before We Start

This is your moment to commit to building a reusable local churn-risk pipeline. Keeping preprocessing with the trained model ensures that later serving applies the same transformations to every set of raw customer features.

Prepare a Reproducible Windows Environment

A saved churn model can only be reproduced when its Python interpreter stays compatible with its direct dependencies. Unrelated projects can otherwise change the packages underneath it.

Visual Studio Code will connect this project to a local virtual environment. Your NVIDIA GPU stays unused because this scikit-learn workflow does not need GPU setup.

In this step, get ready to:
  • Confirm that Python 3.11 or newer is installed.
  • Prepare the Visual Studio Code workspace with the Microsoft Python extension.
  • Build an isolated environment from the pinned dependency file.
Confirm Python 3.11 or newer

Windows PowerShell gives you a direct view of the installed interpreter. The pinned pandas and scikit-learn releases require Python 3.11 or newer.

  • Press the Windows key to open Windows search.
  • Type PowerShell and press Enter.
  • Reveal the installed Python version by running this command:
py -3 --version

What Does This Command Do?

The Python launcher checks the default Python 3 interpreter registered on Windows. Its output gives you the version needed to choose the correct path below.

✔️ I see version 3.11 or higher

Your interpreter meets the minimum requirement. Keep this PowerShell window available until you open the project workspace.

ⓧ I see an older version

The installed interpreter is too old for the pinned pandas and scikit-learn releases. Install the current stable Python 3.15.1 release before creating the project environment.

  • Visit the official Python downloads page for Windows.
  • Download the Windows installer for Python 3.15.1.
  • Run the downloaded installer.
  • Approve the Windows permission request if it appears.
  • Complete the Python installation.
  • Restart Visual Studio Code if it was open during installation.

A fresh PowerShell session reads the newly installed interpreter from Windows.

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell and press Enter.
  • Check the updated Python version by running this command:
py -3 --version

What Should the New Check Show?

The fresh session should report Python 3.15.1. This version satisfies the project requirement.

ⓧ Command not found

Windows cannot currently find the Python launcher. Installing Python 3.15.1 provides the supported interpreter for this project.

  • Visit the official Python downloads page for Windows.
  • Download the Windows installer for Python 3.15.1.
  • Run the downloaded installer.
  • Approve the Windows permission request if it appears.
  • Complete the Python installation.

The launcher becomes available after you start a fresh PowerShell session.

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell and press Enter.
  • Verify the Python installation by running this command:
py -3 --version

What Should the Installation Check Show?

PowerShell should now report Python 3.15.1. The launcher is ready to create the project environment.

Still Cannot Check Python?

Close every open PowerShell window after the installation. A terminal that stayed open can retain the old system configuration.

Ask for targeted help with diagnosing the Windows Python launcher.

Prepare the VS Code workspace

A Visual Studio Code workspace keeps the dependency file beside the environment it describes. Opening one folder also gives the Python extension a clear place to discover .venv.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter.
  • Select File in the top menu.
  • Select Open Folder.
  • Create a new folder on your Desktop named customer-churn-pipeline.
  • Select customer-churn-pipeline in the folder picker.

You should see customer-churn-pipeline at the top of the Explorer sidebar. This confirms that VS Code is using the intended project workspace.

  • Select the Extensions view in the Activity Bar.
  • Enter Python in the extension search field.
  • Select the Python extension published by Microsoft.
  • Select Install if the extension is not already installed.

The extension page should show that Python support is available. VS Code can now discover project interpreters.

  • Select New File in the Explorer toolbar.
  • Name the file requirements.txt.

You should see requirements.txt in the Explorer sidebar. The empty file should also be open in the editor.

The dependency file pins pandas, scikit-learn, and skops to tested versions. Exact pins keep later model loading aligned with training.

  • Fill requirements.txt by pasting these dependency pins:
pandas==3.1.0
scikit-learn==1.9.1
skops==0.16.0

What Do These Pins Control?

  • The pandas==3.1.0 pin provides the tables used for training data and new customer records.
  • The scikit-learn==1.9.1 pin provides preprocessing, evaluation, and classification tools.
  • The skops==0.16.0 pin provides the model persistence workflow used later.
  • Save requirements.txt by pressing Ctrl+S.
  • Confirm that the editor contains exactly three dependency lines.

Dependency File Looks Different?

Check that every package name uses lowercase letters. Confirm that each version uses two equals signs.

Ask for help with checking the dependency pins.

✔️ Awesome, I've got everything!

Great. Make sure requirements.txt is saved before you create the virtual environment.

ⓧ I'd like to double check the full code

pandas==3.1.0
scikit-learn==1.9.1
skops==0.16.0

What Should Match?

Compare all three lines with your file. The package names, equals signs, and version numbers should match exactly.

Build the isolated ML environment

The .venv directory stores a project-local Python interpreter and its packages. Activating it keeps every installation inside this workspace.

  • Select Terminal in the top menu.
  • Select New Terminal.
  • Choose PowerShell from the terminal profile menu if another shell opens.
  • Create .venv by running this command:
python -m venv .venv

What Does This Command Do?

The environment module creates an isolated interpreter inside .venv. Packages installed after activation stay separate from global Python projects.

  • Confirm that .venv appears in the Explorer sidebar.

The Environment Folder Is Missing?

Confirm that the terminal path ends with your project folder. Check that the Python version command reported 3.11 or newer.

Ask for help with creating the Windows virtual environment.

  • Activate the new environment by running this command:
.venv\Scripts\Activate.ps1

What Does Activation Change?

Activation points the current PowerShell session at the interpreter inside .venv. Future Python and pip commands now use the isolated environment.

✔️ I see (.venv) in PowerShell

The terminal prompt begins with (.venv). Your isolated interpreter is active.

ⓧ PowerShell blocks the activation script

The CurrentUser scope makes this policy change less risky by confining it to your Windows account.

  • Allow locally created activation scripts for your Windows account by running this command:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

What Does This Fallback Change?

The command changes the execution policy for the current Windows user. PowerShell can then run the activation script created by Python.

  • Close the blocked terminal.
  • Select New Terminal from the Terminal menu.
  • Activate .venv again by running this command:
.venv\Scripts\Activate.ps1

What Should the Retry Change?

The reopened terminal reads the updated execution policy. Its prompt should begin with (.venv) after activation.

  • Install the pinned project dependencies by running this command:
python -m pip install -r requirements.txt

What Does This Installation Do?

The command asks the active environment's Python interpreter to run pip. Pip reads every exact package version from requirements.txt.

The resulting environment matches the direct dependencies used to train and reload the churn pipeline.

The terminal should finish with successful installation messages for the three pinned packages. This confirms that the dependency set is compatible with your interpreter.

Package Installation Failed?

Confirm that the prompt begins with (.venv). Check your internet connection before retrying the installation.

Ask for targeted help with diagnosing the pinned package installation.

  • Press Ctrl+Shift+P to open the Command Palette.
  • Enter Python: Select Interpreter.
  • Select the interpreter located inside .venv.

The VS Code status bar should identify .venv as the selected environment. Editor tools and new terminals now use the same interpreter.

Before you run the final check, do you expect every package import to complete or stop at a missing dependency?

  • Verify the complete ML environment by running this command in the activated PowerShell terminal:
python -c "import pandas; import sklearn; import skops; print('ML environment ready')"

What Does This Check Prove?

The command imports pandas, scikit-learn, and skops from the active interpreter. It prints a readiness message only after every import succeeds.

PowerShell should print ML environment ready.

That is the foundation secured: your activated environment can import every pinned ML package.

The Readiness Message Did Not Print?

Confirm that the PowerShell prompt begins with (.venv). Rerun the dependency installation if the output mentions a missing package.

Ask for help with debugging the final import check.

Your reproducible Windows workspace is ready. Next, you will generate customer data and expose the blind spot of a numeric-only churn model.

Build the Numeric-Only Baseline

Your activated PowerShell environment is ready. You can now use scikit-learn to build the first measurable model in your churn workflow.

This baseline uses tenure, charges, and support history. It leaves contract, region, and autopay data outside the model so the output can reveal what those numbers capture on their own.

In this step, get ready to:
  • Generate a deterministic dataset with 2,500 mixed-type customer records.
  • Train a numeric-only pipeline with missing-value handling and feature scaling.
  • Measure holdout ROC AUC while surfacing the excluded categorical fields.
Generate the customer dataset

A deterministic generator produces the same customer records on every run. The resulting pandas DataFrame includes numerical fields, categorical fields, missing values, and a churn target.

  • In the VS Code Explorer sidebar, right-click the open project folder.
  • Choose the option for creating a new file.
  • Name the file generate_data.py.
  • Define the generator settings by pasting this first code section into generate_data.py:
from math import exp
from pathlib import Path
from random import Random

import pandas as pd

RANDOM_SEED = 42
N_CUSTOMERS = 2500
DATA_PATH = Path("data/churn_customers.csv")


def main() -> None:
    rng = Random(RANDOM_SEED)
    rows: list[dict[str, object]] = []

    contract_effects = {
        "month_to_month": 2.2,
        "one_year": -0.4,
        "two_year": -0.9,
    }
    region_effects = {
        "north": 0.7,
        "south": 0.2,
        "east": 0.0,
        "west": -0.5,
    }

What Does This Code Do?

  • The seeded random generator makes each run reproducible.
  • The rows list collects one dictionary for each customer.
  • The contract and region dictionaries assign different churn effects to each category.
  • The output path keeps generated data inside the data folder.
  • Save generate_data.py.
  • Confirm that generate_data.py appears beside requirements.txt in the Explorer sidebar.

Cannot Find the Generator File?

Confirm that you created generate_data.py inside the project folder that contains requirements.txt.

Ask for help with locating the new generator file.

Each generated customer receives numerical account activity plus categorical business context. The formula converts those fields into a churn probability before sampling the final target.

  • Continue main() below the region_effects dictionary by pasting this code:
    for customer_number in range(1, N_CUSTOMERS + 1):
        tenure_months = rng.randint(1, 72)
        monthly_charges = round(
            max(20.0, min(130.0, rng.gauss(75.0, 22.0))), 2
        )
        support_tickets = min(6, int(rng.expovariate(0.9)))
        contract_type = rng.choices(
            ["month_to_month", "one_year", "two_year"],
            weights=[0.55, 0.25, 0.20],
            k=1,
        )[0]
        region = rng.choice(["north", "south", "east", "west"])
        autopay = rng.choice(["yes", "no"])

        logit = (
            -2.5
            + contract_effects[contract_type]
            + (1.3 if autopay == "no" else -0.2)
            + region_effects[region]
            + 0.45 * support_tickets
            + 0.006 * (monthly_charges - 75.0)
            - 0.015 * tenure_months
        )
        probability = 1.0 / (1.0 + exp(-logit))
        churn = int(rng.random() < probability)

How Are Customers Generated?

  • The loop generates exactly N_CUSTOMERS customer records.
  • The numerical fields vary across realistic bounded ranges.
  • The categorical fields create business context for contract type, region, and autopay status.
  • The probability formula converts all six predictive fields into a binary churn target.
  • Save generate_data.py.
  • Check that every line from tenure_months through churn is indented beneath the for loop.

Does the Loop Indentation Look Uneven?

Keep the customer field assignments one indentation level inside the loop. Keep the lines inside each multiline expression one additional level deeper.

Ask for help with checking the generator indentation.

The final generator section introduces missing values in two fields. It also writes the completed records to data/churn_customers.csv.

  • Complete main() below the churn assignment by pasting this code:
        rows.append(
            {
                "customer_id": f"CUST-{customer_number:04d}",
                "tenure_months": tenure_months,
                "monthly_charges": (
                    None if rng.random() < 0.04 else monthly_charges
                ),
                "support_tickets": support_tickets,
                "contract_type": contract_type,
                "region": None if rng.random() < 0.03 else region,
                "autopay": autopay,
                "churn": churn,
            }
        )

    DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
    customer_data = pd.DataFrame(rows)
    customer_data.to_csv(DATA_PATH, index=False)

    print(f"Generated {len(customer_data):,} customer records")
    print(f"Saved dataset: {DATA_PATH}")
    print("Sample records:")
    print(*rows[:3], sep="\n")


if __name__ == "__main__":
    main()

What Does This Code Add?

  • Each dictionary stores one customer identifier plus the model fields and churn target.
  • A small share of monthly_charges and region values become missing.
  • The directory creation keeps the CSV write safe when the data folder is absent.
  • The final guard runs main() when you execute the file directly.
  • Save generate_data.py.

Before you run the generator, how many customer records do you expect it to report?

  • Generate the dataset from the activated PowerShell terminal by running this command:
python generate_data.py

What Does This Command Do?

The command runs the generator with the interpreter selected from your activated .venv. The script creates the data folder before saving the CSV.

You should see Generated 2,500 customer records followed by the dataset path and three sample records. The Explorer sidebar should now show data/churn_customers.csv.

Did the Dataset Generation Fail?

Confirm that the PowerShell prompt still shows the activated .venv environment. Check the indentation around rows.append() if the terminal reports a syntax problem.

Ask for help with diagnosing the generator run.

✔️ Awesome, I've got everything!

Your generator now produces the deterministic customer dataset used by the baseline.

ⓧ I'd like to double check the full code

from math import exp
from pathlib import Path
from random import Random

import pandas as pd

RANDOM_SEED = 42
N_CUSTOMERS = 2500
DATA_PATH = Path("data/churn_customers.csv")


def main() -> None:
    rng = Random(RANDOM_SEED)
    rows: list[dict[str, object]] = []

    contract_effects = {
        "month_to_month": 2.2,
        "one_year": -0.4,
        "two_year": -0.9,
    }
    region_effects = {
        "north": 0.7,
        "south": 0.2,
        "east": 0.0,
        "west": -0.5,
    }

    for customer_number in range(1, N_CUSTOMERS + 1):
        tenure_months = rng.randint(1, 72)
        monthly_charges = round(
            max(20.0, min(130.0, rng.gauss(75.0, 22.0))), 2
        )
        support_tickets = min(6, int(rng.expovariate(0.9)))
        contract_type = rng.choices(
            ["month_to_month", "one_year", "two_year"],
            weights=[0.55, 0.25, 0.20],
            k=1,
        )[0]
        region = rng.choice(["north", "south", "east", "west"])
        autopay = rng.choice(["yes", "no"])

        logit = (
            -2.5
            + contract_effects[contract_type]
            + (1.3 if autopay == "no" else -0.2)
            + region_effects[region]
            + 0.45 * support_tickets
            + 0.006 * (monthly_charges - 75.0)
            - 0.015 * tenure_months
        )
        probability = 1.0 / (1.0 + exp(-logit))
        churn = int(rng.random() < probability)

        rows.append(
            {
                "customer_id": f"CUST-{customer_number:04d}",
                "tenure_months": tenure_months,
                "monthly_charges": (
                    None if rng.random() < 0.04 else monthly_charges
                ),
                "support_tickets": support_tickets,
                "contract_type": contract_type,
                "region": None if rng.random() < 0.03 else region,
                "autopay": autopay,
                "churn": churn,
            }
        )

    DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
    customer_data = pd.DataFrame(rows)
    customer_data.to_csv(DATA_PATH, index=False)

    print(f"Generated {len(customer_data):,} customer records")
    print(f"Saved dataset: {DATA_PATH}")
    print("Sample records:")
    print(*rows[:3], sep="\n")


if __name__ == "__main__":
    main()

How to Use This Reference

This reference shows the complete generator after the three code sections are combined. Its output path and field names feed directly into the baseline.

Build the numerical pipeline

A Pipeline keeps missing-value handling, scaling, and classification in one fitted workflow. The baseline limits that workflow to three numerical features.

Why Use a Pipeline?

A pipeline applies the same ordered transformations during fitting and prediction. This keeps the model tied to the preprocessing it expects.

The pattern also gives later evaluation one estimator to split, fit, and score.

  • In the VS Code Explorer sidebar, right-click the open project folder.
  • Choose the option for creating a new file.
  • Name the file baseline.py.
  • Define the baseline features and pipeline by pasting this code into baseline.py:
from pathlib import Path

import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

DATA_PATH = Path("data/churn_customers.csv")
RANDOM_STATE = 42
NUMERIC_FEATURES = [
    "tenure_months",
    "monthly_charges",
    "support_tickets",
]
IGNORED_FEATURES = ["contract_type", "region", "autopay"]


def build_baseline_pipeline() -> Pipeline:
    return Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler()),
            ("classifier", LogisticRegression(max_iter=1000)),
        ]
    )

How Does the Baseline Pipeline Work?

  • The feature lists make the baseline boundary explicit.
  • The imputer replaces missing numerical values with the training median.
  • The scaler puts the numerical columns onto comparable scales.
  • The classifier estimates churn probabilities from the transformed values.
  • Save baseline.py.
  • Confirm that baseline.py appears beside generate_data.py in the Explorer sidebar.

Cannot Find the Baseline File?

Confirm that baseline.py sits in the same project folder as generate_data.py and requirements.txt.

Ask for help with checking the baseline file location.

The evaluation uses a stratified split so the training and holdout sets preserve the target balance. Positive-class probabilities feed the ROC AUC calculation.

  • Complete baseline.py below build_baseline_pipeline() by pasting this code:
def main() -> None:
    customer_data = pd.read_csv(DATA_PATH)
    X = customer_data[NUMERIC_FEATURES]
    y = customer_data["churn"]

    X_train, X_test, y_train, y_test = train_test_split(
        X,
        y,
        test_size=0.20,
        random_state=RANDOM_STATE,
        stratify=y,
    )

    baseline = build_baseline_pipeline()
    baseline.fit(X_train, y_train)
    probabilities = baseline.predict_proba(X_test)[:, 1]
    baseline_auc = roc_auc_score(y_test, probabilities)

    print(f"Numeric-only holdout ROC AUC: {baseline_auc:.3f}")
    print(f"Ignored categorical fields: {', '.join(IGNORED_FEATURES)}")
    print("Obstacle: the baseline cannot use contract, region, or autopay signal.")


if __name__ == "__main__":
    main()

How Is the Baseline Evaluated?

  • The script reads the generated CSV and selects the three numerical features.
  • The fixed random state makes the stratified split repeatable.
  • The fitted pipeline returns positive-class churn probabilities for the untouched holdout rows.
  • The final output reports the ranking metric plus the features excluded from this model.
  • Save baseline.py.

Before you run the baseline, do you think the three numerical fields capture all the useful churn signal?

  • Run the numeric-only model from the activated PowerShell terminal with this command:
python baseline.py

What Does This Command Test?

The command loads the generated dataset before fitting the numerical pipeline. It evaluates positive-class probabilities against the held-out churn labels.

You should see a numeric-only holdout ROC AUC followed by contract_type, region, and autopay as ignored fields. The final obstacle message confirms that the running model cannot use their signal.

This Shortfall Is Intentional

The model runs successfully while leaving three useful business fields unused. You now have a visible capability gap that a stronger pipeline can address.

Did the Baseline Run Fail?

Confirm that data/churn_customers.csv exists. Check that every name in NUMERIC_FEATURES matches the generated CSV columns.

Ask for help with diagnosing the baseline run.

✔️ Awesome, I've got everything!

Your numerical imputer, scaler, classifier, split, and ROC AUC evaluation now live in one baseline script.

ⓧ I'd like to double check the full code

from pathlib import Path

import pandas as pd
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

DATA_PATH = Path("data/churn_customers.csv")
RANDOM_STATE = 42
NUMERIC_FEATURES = [
    "tenure_months",
    "monthly_charges",
    "support_tickets",
]
IGNORED_FEATURES = ["contract_type", "region", "autopay"]


def build_baseline_pipeline() -> Pipeline:
    return Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler()),
            ("classifier", LogisticRegression(max_iter=1000)),
        ]
    )


def main() -> None:
    customer_data = pd.read_csv(DATA_PATH)
    X = customer_data[NUMERIC_FEATURES]
    y = customer_data["churn"]

    X_train, X_test, y_train, y_test = train_test_split(
        X,
        y,
        test_size=0.20,
        random_state=RANDOM_STATE,
        stratify=y,
    )

    baseline = build_baseline_pipeline()
    baseline.fit(X_train, y_train)
    probabilities = baseline.predict_proba(X_test)[:, 1]
    baseline_auc = roc_auc_score(y_test, probabilities)

    print(f"Numeric-only holdout ROC AUC: {baseline_auc:.3f}")
    print(f"Ignored categorical fields: {', '.join(IGNORED_FEATURES)}")
    print("Obstacle: the baseline cannot use contract, region, or autopay signal.")


if __name__ == "__main__":
    main()

How to Use This Reference

This reference shows the complete baseline after both code sections are combined. Its constants and function names match the generated dataset.

Verify the complete baseline workflow

The final check rebuilds the deterministic CSV before fitting the baseline again. This proves the workflow can start from the generator and finish with a measurable model result.

Before you run the complete workflow, which three signals should confirm that both scripts succeeded?

  • Run the generator and baseline together from the activated PowerShell terminal with this command:
python generate_data.py; python baseline.py

What Do These Commands Prove?

  • The first command regenerates the same 2,500 customer records.
  • The second command reads the saved CSV and evaluates the numeric-only pipeline.
  • The combined output links deterministic data creation to repeatable baseline evaluation.

You should see Generated 2,500 customer records, a numeric-only holdout ROC AUC, and the three ignored categorical fields. Metric decimals can vary slightly across platforms.

That is your first real model run complete. You now have a repeatable baseline with a clearly observed limitation.

Did the Combined Check Stop Early?

Read the first terminal error to identify which script stopped. Confirm that the PowerShell prompt points to the project folder containing both Python files.

Ask for help with diagnosing the combined workflow.

Your baseline now gives the project a measurable starting point. Next, you will recover the unused business context with a full mixed-data pipeline.

Recover the Missing Signal with a Full Pipeline

Your numeric-only baseline now produces a holdout score. Its result proves that the training loop works.

Contract type, region, and autopay still contain predictive business context that the baseline cannot use. This step brings those fields into one cross-validated pipeline with type-specific preprocessing inside each training fold.

In this step, get ready to:
  • Build type-specific preprocessing for numerical and categorical fields.
  • Evaluate churn probabilities with stratified cross-validation.
  • Save the fitted workflow as a reusable skops artifact.
Build type-specific preprocessing

Numerical fields need missing-value imputation plus scaling. Categorical fields need missing-value imputation plus encoding.

A machine learning pipeline keeps those transformations attached to the classifier. The same raw fields follow the same path during training and later inference.

Why Keep Preprocessing Inside the Pipeline?

Cross-validation must learn preprocessing values from each training fold. Keeping preprocessing inside the pipeline prevents information from validation rows influencing those fitted transformations.

This structure reduces data leakage. It also creates one fitted object that accepts raw business fields.

  • Create train_model.py beside baseline.py from the VS Code Explorer sidebar.
  • Paste the imports plus project constants into train_model.py using this code:
from pathlib import Path

import pandas as pd
import skops.io as sio
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import (
    StratifiedKFold,
    cross_val_score,
    train_test_split,
)
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

from baseline import NUMERIC_FEATURES, build_baseline_pipeline

DATA_PATH = Path("data/churn_customers.csv")
MODEL_PATH = Path("artifacts/churn_pipeline.skops")
RANDOM_STATE = 42
CATEGORICAL_FEATURES = ["contract_type", "region", "autopay"]
MODEL_FEATURES = NUMERIC_FEATURES + CATEGORICAL_FEATURES

What Does This Setup Do?

  • The scikit-learn imports provide type-specific preprocessing tools plus the evaluation utilities used later.
  • The import from baseline.py reuses the existing numerical feature list plus the baseline builder.
  • The path constants identify the existing dataset plus the future fitted artifact.
  • The feature lists combine three numerical fields with three categorical fields.
  • Save train_model.py.
  • Confirm that train_model.py appears beside baseline.py in the VS Code Explorer sidebar.

Cannot Resolve the Imports?

Confirm that VS Code still uses the interpreter inside .venv. Check that train_model.py sits in the same folder as baseline.py.

Ask for targeted help with diagnosing the unresolved training imports.

The full preprocessing builder starts with one branch for each field type. Each branch handles missing values before applying its type-specific transformation.

  • Add the numerical plus categorical sub-pipelines below the constants in train_model.py using this code:
def build_full_pipeline() -> Pipeline:
    numeric_pipeline = Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler()),
        ]
    )
    categorical_pipeline = Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="most_frequent")),
            ("onehot", OneHotEncoder(handle_unknown="ignore")),
        ]
    )

How Do the Two Branches Differ?

  • The numerical branch fills missing values with each field's median.
  • The numerical branch scales values before classification.
  • The categorical branch fills missing values with the most frequent category.
  • The encoder tolerates categories that were absent during training.
  • Save train_model.py.
  • Expand build_full_pipeline in the VS Code Outline panel to confirm that the function is recognized.

Function Missing from the Outline?

Check that def build_full_pipeline() -> Pipeline: begins at the left edge. Keep every line inside the function indented by four spaces.

Ask for help with fixing the preprocessing function structure.

A ColumnTransformer routes each selected column group through its matching branch. The outer pipeline sends the combined transformed output into logistic regression.

  • Place the transformer plus classifier directly below the closing parenthesis for categorical_pipeline using this code:
    preprocessor = ColumnTransformer(
        transformers=[
            ("numeric", numeric_pipeline, NUMERIC_FEATURES),
            ("categorical", categorical_pipeline, CATEGORICAL_FEATURES),
        ]
    )
    return Pipeline(
        steps=[
            ("preprocessor", preprocessor),
            ("classifier", LogisticRegression(max_iter=1000)),
        ]
    )

What Does the Outer Pipeline Do?

  • The transformer sends numerical columns through numeric_pipeline.
  • The transformer sends categorical columns through categorical_pipeline.
  • The outer pipeline fits preprocessing plus classification as one workflow.
  • Save train_model.py.
  • Check the VS Code Problems panel to confirm that the completed builder has no syntax or indentation errors.

Seeing an Indentation Error?

Keep preprocessor plus the returned pipeline inside build_full_pipeline(). Their first lines align with numeric_pipeline.

Ask for help with aligning the full pipeline builder.

Evaluate the full pipeline

A ranking metric shows whether higher churn probabilities tend to reach actual churners first. ROC AUC measures that ranking quality without locking the model to one decision threshold.

Shuffled five-fold cross-validation repeats the evaluation across five stratified partitions. The untouched holdout remains available for one final comparison.

Threshold recall counts how many actual churners receive a positive prediction at a chosen cutoff. This helper makes the later operational comparison explicit.

  • Add recall_at_threshold() below build_full_pipeline() using this code:
def recall_at_threshold(
    actual_values,
    probabilities,
    threshold: float,
) -> float:
    actual_labels = [int(value) for value in actual_values]
    predicted_labels = [int(value >= threshold) for value in probabilities]
    true_positives = sum(
        actual == 1 and predicted == 1
        for actual, predicted in zip(actual_labels, predicted_labels)
    )
    positive_count = sum(actual_labels)
    return true_positives / positive_count

How Is Recall Calculated?

  • The helper converts actual target values into integer labels.
  • Each probability becomes a positive prediction when it reaches the supplied threshold.
  • The function divides correctly identified churners by the total number of actual churners.
  • Save train_model.py.
  • Confirm that recall_at_threshold appears as a separate function in the VS Code Outline panel.

Recall Helper Nested Inside the Builder?

Align def recall_at_threshold( with def build_full_pipeline(). Both function definitions begin at the left edge.

Ask for help with separating the two function definitions.

The training entry point reads all six model features. A stratified split preserves the target balance while reserving the holdout rows.

  • Add the opening of main() below the recall helper using this code:
def main() -> None:
    customer_data = pd.read_csv(DATA_PATH)
    X = customer_data[MODEL_FEATURES]
    y = customer_data["churn"]

    X_train, X_test, y_train, y_test = train_test_split(
        X,
        y,
        test_size=0.20,
        random_state=RANDOM_STATE,
        stratify=y,
    )

What Does the Split Protect?

  • The feature table includes every numerical plus categorical field used by the full pipeline.
  • The target contains the observed churn labels.
  • The fixed random state makes the split repeatable.
  • Stratification preserves the churn proportion across the training plus holdout sets.
  • Save train_model.py.
  • Confirm that main appears below both helper functions in the VS Code Outline panel.

Main Function Not Recognized?

Place def main() -> None: at the left edge. Keep the data loading plus split indented inside it.

Ask for help with correcting the main function structure.

The baseline and full pipeline use the same training plus holdout rows. This makes their ROC AUC comparison meaningful.

  • Add the baseline comparison plus cross-validation directly below the split using this code:
    baseline = build_baseline_pipeline()
    baseline.fit(X_train[NUMERIC_FEATURES], y_train)
    baseline_probabilities = baseline.predict_proba(
        X_test[NUMERIC_FEATURES]
    )[:, 1]
    baseline_auc = roc_auc_score(y_test, baseline_probabilities)

    full_pipeline = build_full_pipeline()
    cross_validation = StratifiedKFold(
        n_splits=5,
        shuffle=True,
        random_state=RANDOM_STATE,
    )
    cv_scores = cross_val_score(
        full_pipeline,
        X_train,
        y_train,
        cv=cross_validation,
        scoring="roc_auc",
    )

How Does This Compare the Models?

  • The baseline receives only the three numerical columns.
  • Its positive-class probabilities produce the reference holdout ROC AUC.
  • The full pipeline enters shuffled five-fold evaluation on the training set.
  • Every fold fits its preprocessing steps using only that fold's training rows.
  • Save train_model.py.
  • Check the VS Code Problems panel to confirm that the baseline plus cross-validation block has no syntax errors.

Cross-Validation Block Underlined?

Keep this block indented inside main(). Confirm that X_train plus y_train match the names created by the split.

Ask for help with diagnosing the cross-validation block.

Cross-validation estimates performance across the training partitions. The full pipeline still needs one fit on all training rows before the untouched holdout can score it.

  • Add the holdout evaluation plus threshold calculations below cv_scores using this code:
    full_pipeline.fit(X_train, y_train)
    holdout_probabilities = full_pipeline.predict_proba(X_test)[:, 1]
    holdout_auc = roc_auc_score(y_test, holdout_probabilities)

    recall_default = recall_at_threshold(
        y_test, holdout_probabilities, threshold=0.50
    )
    recall_retention = recall_at_threshold(
        y_test, holdout_probabilities, threshold=0.35
    )

    MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
    sio.dump(full_pipeline, MODEL_PATH)

What Gets Fitted and Saved?

  • The full pipeline fits every preprocessing branch plus the classifier on all training rows.
  • Positive-class probabilities produce the untouched holdout ROC AUC.
  • The recall helper compares the default cutoff with the lower retention cutoff.
  • The dump operation saves the fitted preprocessing plus classifier together.
  • Save train_model.py.
  • Check the VS Code Problems panel to confirm that the completed training logic has no syntax errors.

Holdout Logic Showing Errors?

Confirm that this block remains inside main(). Check that both threshold values use decimal notation.

Ask for help with diagnosing the holdout plus persistence logic.

Compare thresholds and save the artifact

A threshold converts a probability into an operational decision. Lowering the cutoff to 0.35 usually identifies more actual churners for a retention workflow.

The saved skops artifact contains the fitted transformer plus classifier. The next step can load one object instead of rebuilding preprocessing by hand.

  • Finish main() with the evaluation output plus script entry point using this code:
    print(f"Baseline holdout ROC AUC: {baseline_auc:.3f}")
    print(
        "Full pipeline 5-fold ROC AUC: "
        f"{cv_scores.mean():.3f} +/- {cv_scores.std():.3f}"
    )
    print(f"Full pipeline holdout ROC AUC: {holdout_auc:.3f}")
    print(f"ROC AUC improvement: {holdout_auc - baseline_auc:+.3f}")
    print(f"Recall at threshold 0.50: {recall_default:.3f}")
    print(f"Recall at threshold 0.35: {recall_retention:.3f}")
    print(f"Saved model: {MODEL_PATH}")


if __name__ == "__main__":
    main()

What Will the Script Report?

  • The first comparison shows how the numeric-only baseline ranks the holdout customers.
  • The cross-validation line reports the five-fold mean plus spread.
  • The holdout lines show the full pipeline's ranking quality plus improvement.
  • The final lines compare recall cutoffs plus confirm the saved artifact path.
  • Save train_model.py.
  • Confirm that the VS Code Problems panel contains no syntax or indentation errors.

Script Entry Point Underlined?

Keep the print statements indented inside main(). Place if __name__ == "__main__": at the left edge.

Ask for help with fixing the final output plus entry point.

✔️ Awesome, I've got everything!

Great. Double-check that train_model.py is saved before running it.

ⓧ I'd like to double check the full code

from pathlib import Path

import pandas as pd
import skops.io as sio
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import (
    StratifiedKFold,
    cross_val_score,
    train_test_split,
)
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

from baseline import NUMERIC_FEATURES, build_baseline_pipeline

DATA_PATH = Path("data/churn_customers.csv")
MODEL_PATH = Path("artifacts/churn_pipeline.skops")
RANDOM_STATE = 42
CATEGORICAL_FEATURES = ["contract_type", "region", "autopay"]
MODEL_FEATURES = NUMERIC_FEATURES + CATEGORICAL_FEATURES


def build_full_pipeline() -> Pipeline:
    numeric_pipeline = Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler()),
        ]
    )
    categorical_pipeline = Pipeline(
        steps=[
            ("imputer", SimpleImputer(strategy="most_frequent")),
            ("onehot", OneHotEncoder(handle_unknown="ignore")),
        ]
    )
    preprocessor = ColumnTransformer(
        transformers=[
            ("numeric", numeric_pipeline, NUMERIC_FEATURES),
            ("categorical", categorical_pipeline, CATEGORICAL_FEATURES),
        ]
    )
    return Pipeline(
        steps=[
            ("preprocessor", preprocessor),
            ("classifier", LogisticRegression(max_iter=1000)),
        ]
    )


def recall_at_threshold(
    actual_values,
    probabilities,
    threshold: float,
) -> float:
    actual_labels = [int(value) for value in actual_values]
    predicted_labels = [int(value >= threshold) for value in probabilities]
    true_positives = sum(
        actual == 1 and predicted == 1
        for actual, predicted in zip(actual_labels, predicted_labels)
    )
    positive_count = sum(actual_labels)
    return true_positives / positive_count


def main() -> None:
    customer_data = pd.read_csv(DATA_PATH)
    X = customer_data[MODEL_FEATURES]
    y = customer_data["churn"]

    X_train, X_test, y_train, y_test = train_test_split(
        X,
        y,
        test_size=0.20,
        random_state=RANDOM_STATE,
        stratify=y,
    )

    baseline = build_baseline_pipeline()
    baseline.fit(X_train[NUMERIC_FEATURES], y_train)
    baseline_probabilities = baseline.predict_proba(
        X_test[NUMERIC_FEATURES]
    )[:, 1]
    baseline_auc = roc_auc_score(y_test, baseline_probabilities)

    full_pipeline = build_full_pipeline()
    cross_validation = StratifiedKFold(
        n_splits=5,
        shuffle=True,
        random_state=RANDOM_STATE,
    )
    cv_scores = cross_val_score(
        full_pipeline,
        X_train,
        y_train,
        cv=cross_validation,
        scoring="roc_auc",
    )

    full_pipeline.fit(X_train, y_train)
    holdout_probabilities = full_pipeline.predict_proba(X_test)[:, 1]
    holdout_auc = roc_auc_score(y_test, holdout_probabilities)

    recall_default = recall_at_threshold(
        y_test, holdout_probabilities, threshold=0.50
    )
    recall_retention = recall_at_threshold(
        y_test, holdout_probabilities, threshold=0.35
    )

    MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
    sio.dump(full_pipeline, MODEL_PATH)

    print(f"Baseline holdout ROC AUC: {baseline_auc:.3f}")
    print(
        "Full pipeline 5-fold ROC AUC: "
        f"{cv_scores.mean():.3f} +/- {cv_scores.std():.3f}"
    )
    print(f"Full pipeline holdout ROC AUC: {holdout_auc:.3f}")
    print(f"ROC AUC improvement: {holdout_auc - baseline_auc:+.3f}")
    print(f"Recall at threshold 0.50: {recall_default:.3f}")
    print(f"Recall at threshold 0.35: {recall_retention:.3f}")
    print(f"Saved model: {MODEL_PATH}")


if __name__ == "__main__":
    main()

Before you run the training workflow, do you expect the full pipeline to recover useful signal from the three categorical fields?

  • Run the completed training workflow in the activated PowerShell terminal with this command:
python train_model.py

What Does This Command Do?

The script rebuilds the numerical baseline on the same split. It evaluates the full pipeline across five training folds.

The script then scores the untouched holdout. It saves the fitted workflow after comparing both recall thresholds.

You'll see the baseline ROC AUC plus the five-fold ROC AUC mean and spread. You'll also see the full-pipeline holdout ROC AUC plus its improvement.

You'll see recall values for thresholds 0.50 plus 0.35. The final line reads Saved model: artifacts\churn_pipeline.skops.

Training Workflow Failed?

Confirm that the activated terminal is in the folder containing train_model.py. Check that data/churn_customers.csv still exists.

Compare train_model.py with the full-code tab if Python reports a syntax error or an undefined name.

Ask for targeted help with diagnosing the training failure.

That is the missing signal recovered. Your numerical plus categorical preprocessing now trains as one evaluated workflow.

Your fitted churn workflow now lives in one reusable artifact. Next, you will load that artifact in a separate process to score raw customer fields.

Load the Artifact and Score a New Customer

Your fitted scikit-learn Pipeline now carries the preprocessing rules with the trained classifier. Training proved that this workflow can evaluate customer data.

A training script alone cannot serve predictions from a separate process. This step proves that predict.py can load the saved workflow from raw customer fields.

In this step, get ready to:
  • Review the saved artifact's reported types before loading it.
  • Represent an unseen customer with the six raw model features.
  • Score the customer with the saved pipeline.
Point to the saved pipeline

A saved skops artifact preserves the fitted preprocessing steps with the classifier. The inference script needs the artifact path plus the operational risk threshold.

  • Use VS Code's file sidebar to create predict.py inside the open project folder.
  • Add the inference preamble by copying this code:
from pathlib import Path

import pandas as pd
import skops.io as sio

MODEL_PATH = Path("artifacts/churn_pipeline.skops")
RISK_THRESHOLD = 0.35

What Does This Preamble Do?

  • The Path import provides a platform-aware path to the saved model.
  • The pandas import makes the raw customer table available to the pipeline.
  • The skops import provides the artifact inspection plus loading functions.
  • The 0.35 threshold matches the retention threshold evaluated during training.
  • Save predict.py.
  • Confirm that predict.py appears beside train_model.py in the file sidebar.

Cannot See predict.py?

Confirm that you created predict.py inside the same open project folder as train_model.py.

Ask for help with finding the inference file.

Build the inference workflow

The artifact inspection reports types that require a trust decision before loading. This project uses the reviewed list because you created the artifact locally in the previous step.

A pandas DataFrame supplies the same six raw features used during training. The loaded pipeline applies its fitted imputation plus encoding rules internally.

  • In predict.py, locate RISK_THRESHOLD = 0.35.
  • Place your cursor on the first empty line below it.
  • Add the complete inference workflow by copying this code:
def main() -> None:
    unknown_types = sio.get_untrusted_types(file=MODEL_PATH)
    print(f"Reviewed untrusted types: {unknown_types}")

    model = sio.load(MODEL_PATH, trusted=unknown_types)
    customer = pd.DataFrame(
        [
            {
                "tenure_months": 5,
                "monthly_charges": 109.50,
                "support_tickets": 3,
                "contract_type": "month_to_month",
                "region": "north",
                "autopay": "no",
            }
        ]
    )

    churn_probability = float(model.predict_proba(customer)[0, 1])
    risk_label = (
        "high" if churn_probability >= RISK_THRESHOLD else "standard"
    )

    print(f"Churn probability: {churn_probability:.3f}")
    print(f"Risk label: {risk_label}")


if __name__ == "__main__":
    main()

What Does This Workflow Do?

  • The sio.get_untrusted_types call inspects the local artifact before loading.
  • The sio.load call loads the artifact with the reviewed type list.
  • The customer DataFrame holds one unseen customer's raw business fields.
  • The model.predict_proba(customer)[0, 1] expression extracts the positive-class churn probability.
  • The RISK_THRESHOLD comparison converts the probability into an operational risk label.
  • Save predict.py.
  • Confirm that the file ends with main() under the if __name__ == "__main__": guard.

✔️ Awesome, I've got everything!

Your saved inference script is ready for the standalone prediction check.

ⓧ I'd like to double check the full code

from pathlib import Path

import pandas as pd
import skops.io as sio

MODEL_PATH = Path("artifacts/churn_pipeline.skops")
RISK_THRESHOLD = 0.35


def main() -> None:
    unknown_types = sio.get_untrusted_types(file=MODEL_PATH)
    print(f"Reviewed untrusted types: {unknown_types}")

    model = sio.load(MODEL_PATH, trusted=unknown_types)
    customer = pd.DataFrame(
        [
            {
                "tenure_months": 5,
                "monthly_charges": 109.50,
                "support_tickets": 3,
                "contract_type": "month_to_month",
                "region": "north",
                "autopay": "no",
            }
        ]
    )

    churn_probability = float(model.predict_proba(customer)[0, 1])
    risk_label = (
        "high" if churn_probability >= RISK_THRESHOLD else "standard"
    )

    print(f"Churn probability: {churn_probability:.3f}")
    print(f"Risk label: {risk_label}")


if __name__ == "__main__":
    main()
Run the standalone prediction

A fresh Python process must recover the complete fitted workflow from artifacts/churn_pipeline.skops. Successful output proves that inference does not depend on the training process remaining active.

Before you run the script, do you expect the saved pipeline to accept all six raw fields without rebuilding preprocessing?

  • Run the standalone inference workflow in the activated PowerShell terminal with this command:
python predict.py

What Does This Command Do?

The command starts predict.py in a separate Python process. The script inspects the local artifact before loading its fitted workflow.

First, you'll see Reviewed untrusted types: followed by the inspected type list. Next, you'll see a churn probability between 0 and 1.

The final line reports either high or standard as the risk label. That closes the deployment gap: your separate inference process now turns raw customer fields into an operational result.

Prediction Did Not Complete?

Confirm that artifacts/churn_pipeline.skops still exists inside the open project folder. Rerun train_model.py if the artifact is missing.

Confirm that the PowerShell prompt still shows the activated .venv environment. Compare the six customer field names with the full code above.

Ask for targeted help with debugging the standalone prediction.

Secret mission

Build a Batch Risk Ranking

Score three customers with the unchanged churn pipeline. Rank them by churn probability while proving that an unseen region still works.

Clean Up Your Resources

Clean Up Your Resources

Your scikit-learn workflow runs entirely on your Windows computer. Keeping its local files creates no ongoing charges.

Resources you used:

  • The local project folder that contains the complete churn workflow.
  • The requirements.txt dependency manifest with pinned package versions.
  • The generate_data.py dataset generator.
  • The baseline.py numeric-only baseline.
  • The train_model.py training workflow.
  • The predict.py batch inference workflow.
  • The generated data/churn_customers.csv dataset.
  • The fitted artifacts/churn_pipeline.skops model artifact.
  • The .venv folder containing the isolated project dependencies.

Keep everything running

No action needed. Choose this if you plan to improve the churn model or prepare it for later serving.

  • Keep the local project folder unchanged for another training run.
  • Keep data/churn_customers.csv for repeatable evaluation.
  • Keep artifacts/churn_pipeline.skops for future inference work.
  • Keep .venv so the pinned environment remains available.

Your fitted pipeline stays ready for more local predictions without creating any ongoing cost.

Pause - I'll come back to this later

Pausing this local project means ending the active editor session. Every project file remains on disk.

  • Switch back to the activated PowerShell terminal in Visual Studio Code.
  • Close the activated PowerShell terminal from the Terminal panel.
  • Close Visual Studio Code.

The virtual environment is no longer active because its terminal session has ended. Every project file remains available for your next session.

Delete - I don't want to use this again

Deleting the project folder is permanent. Your other files on Windows stay untouched.

  • Return to the activated PowerShell terminal in Visual Studio Code.
  • Close the activated terminal from the Terminal panel.
  • Close Visual Studio Code.
  • Open File Explorer from the Windows taskbar.
  • Navigate to the parent folder that contains the project folder from earlier.
  • Select the project folder from earlier.
  • Use File Explorer's deletion control to move the selected project folder to Recycle Bin.
  • Open Recycle Bin from the Windows desktop.
  • Select the deleted project folder.
  • Permanently delete the selected project folder.

The project folder no longer appears in File Explorer or Recycle Bin. This confirms that every listed local resource is removed.

Nice Work!

Nice Work!

You did it! Your production-style customer churn workflow now turns raw customer fields into ranked risk probabilities through a reusable scikit-learn pipeline.

You've learned how to:

  • Generate deterministic customer data with mixed field types plus missing values. Use a numeric-only baseline to reveal ignored categorical signal.
  • Build a leakage-safe workflow that keeps preprocessing inside model evaluation. Measure its probability ranking with stratified cross-validation plus holdout ROC AUC.
  • Save the complete fitted workflow with skops. Review its reported types before loading it in predict.py for raw-customer inference.
  • Secret Mission: Rank three customers by churn probability. Handle the unseen central region without changing or retraining artifacts/churn_pipeline.skops.

Ready to quiz yourself? Test what you can explain from memory.