Build a Customer Churn Prediction API
Train and serve a validated churn prediction API with FastAPI.
Introduction
30 Second Summary
Retention teams often learn that a customer was unhappy only after the cancellation arrives. A reassuring dashboard can make that warning easier to miss when it rewards the wrong number.
In this project, you will build a local machine learning API that turns a customer profile into a churn prediction with a probability. You'll uncover how high accuracy can hide missed at-risk customers before training a recall-aware model that catches more of them.
What You'll Build
Picture a browser demo where a high-risk customer returns a visibly larger churn probability than a low-risk customer.
By the end of this project, you'll have:
- A reproducible model report you can run to expose a high-accuracy baseline with zero recall for churned customers.
- A balanced prediction pipeline that improves recall across repeatable training runs. Its saved model loads directly into the service.
- A portfolio-ready API demo you can run in the browser to compare low-risk and high-risk scores. A concise README explains the metric trap plus service design.
- Secret Mission: Extend the service with batch churn scoring so one validated request can score a list of customers.
Are there any prerequisites?
No prior machine learning or API experience is required. The guide covers every setup step for a project that runs locally on your Mac at no cost.
Before We Start
Before the hands-on work begins, commit to who this churn service helps and which decision it supports. This purpose keeps the project focused on useful outreach instead of a reassuring accuracy number.
Set Up the Local ML Workspace
The churn score will eventually guide retention outreach. Its code needs a stable home before you build the model or API.
A reproducible environment keeps Python versions and package versions consistent. This prevents hidden setup differences from changing how your project behaves.
In this step, get ready to:
- Install Visual Studio Code with terminal access.
- Create an isolated Python 3.13 virtual environment with uv.
- Pin the project dependencies in configuration files.
Install Visual Studio Code and its terminal command
Visual Studio Code gives you one place to manage every project file. Its code command lets macOS Terminal open the current folder directly in the editor.
The first check reveals whether the editor already has terminal access. A missing command leads to the setup path that matches your Mac.
- Press Cmd+Space to open Spotlight.
- Type Terminal and press Enter to open macOS Terminal.
- Check whether the code command is available by running:
code --version
What does this check prove?
The command asks Visual Studio Code to print its installed version. A version number confirms that Terminal can find the editor.
✔️ I see a version number
Great. Visual Studio Code already has terminal access on your Mac.
ⓧ Visual Studio Code opens but code is missing
This setting is tucked inside the Command Palette, so it is easy to miss.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code and press Enter to open it.
- Press Cmd+Shift+P to open the Command Palette.
- Type shell command into the Command Palette.
- Select Shell Command: Install 'code' command in PATH.
The terminal command is now installed. A fresh Terminal session picks up the updated path.
- Return to Terminal from earlier.
- Click Terminal in the menu bar.
- Click Quit Terminal.
- Press Cmd+Space to open Spotlight.
- Type Terminal and press Enter to reopen it.
- Confirm terminal access by running:
code --version
What confirms success?
The output now includes a Visual Studio Code version. Terminal can use the editor command in later project steps.
ⓧ Visual Studio Code is not installed
- Open the official Visual Studio Code download page in Safari.
- Download the Universal .dmg build for macOS.
- Open the downloaded .dmg file.
- Drag Visual Studio Code.app into the Applications folder.
Visual Studio Code is now installed in the standard macOS application location.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code and press Enter to open it.
- Press Cmd+Shift+P to open the Command Palette.
- Type shell command into the Command Palette.
- Select Shell Command: Install 'code' command in PATH.
The fiddly editor setup is complete. A fresh Terminal session can now discover the command.
- Return to Terminal from earlier.
- Click Terminal in the menu bar.
- Click Quit Terminal.
- Press Cmd+Space to open Spotlight.
- Type Terminal and press Enter to reopen it.
- Confirm the installation by running:
code --version
What confirms success?
The output includes a Visual Studio Code version. This proves that the app and its terminal command are ready.
Still missing the code command?
Quit every Terminal window after installing the command. A Terminal session that stayed open can keep the old path.
Run Shell Command: Install 'code' command in PATH again if the first attempt did not complete.
Help me make the Visual Studio Code command available in macOS Terminal.
Create an isolated Python workspace
uv manages the Python interpreter and the packages used inside this project. The pinned 0.13.1 release keeps those environment commands reproducible.
Start with a version check. The result determines whether you can continue or need the pinned installer.
- Check your uv version by running:
uv --version
What does this check reveal?
The command prints the uv release that Terminal currently finds. This project uses uv 0.13.1.
✔️ I see version 0.13.1
Your pinned uv tool is ready to create the project environment.
ⓧ I see an older version
- Install uv 0.13.1 by running the pinned installer:
curl -LsSf https://astral.sh/uv/0.13.1/install.sh | sh
What does this installer do?
The command downloads the official installer for uv 0.13.1. Pinning the installer URL prevents a later release from changing this project setup.
- Click Terminal in the menu bar.
- Click Quit Terminal.
- Press Cmd+Space to open Spotlight.
- Type Terminal and press Enter to reopen it.
- Verify the pinned release by running:
uv --version
What confirms the update?
The output reads uv 0.13.1. Your terminal now uses the project release.
ⓧ Command not found
- Install uv 0.13.1 by running the pinned installer:
curl -LsSf https://astral.sh/uv/0.13.1/install.sh | sh
What does this installer do?
The command downloads the official uv 0.13.1 installer. The installer makes the uv command available to your shell.
- Click Terminal in the menu bar.
- Click Quit Terminal.
- Press Cmd+Space to open Spotlight.
- Type Terminal and press Enter to reopen it.
- Verify the installation by running:
uv --version
What confirms the installation?
The output reads uv 0.13.1. Terminal can now use uv.
Still unable to run uv?
Reopen Terminal after the installer finishes. The new session loads the path added by the installer.
Check that you copied the pinned installer command exactly.
Help me diagnose why uv is unavailable in macOS Terminal.
The project folder keeps every local artifact together. Its .venv environment isolates Python 3.13 and the packages used by this churn service.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The command moves Terminal to the Desktop inside your home folder. Your project folder will be easy to find there.
- Create the churn-prediction-api folder by running these commands:
mkdir churn-prediction-api
cd churn-prediction-api
What do these commands do?
- The first command creates the churn-prediction-api folder on your Desktop.
- The second command moves Terminal into that folder.
- Confirm your current folder by running:
pwd
What does this check prove?
The command prints your current folder path. The path should end with /Desktop/churn-prediction-api.
Your project now has a dedicated location on the Desktop.
- Create the Python 3.13 virtual environment by running:
uv venv --python 3.13
What does this command create?
uv creates a .venv environment inside churn-prediction-api. It downloads a compatible Python 3.13 interpreter when one is unavailable.
- Activate the new environment by running:
source .venv/bin/activate
Why activate the environment?
Activation points Python and package commands at the local .venv environment. Packages installed next stay isolated from other projects on your Mac.
The terminal prompt now shows the environment name. That visible change confirms the project environment is active.
- Check the active Python version by running:
python --version
What does this check reveal?
The command prints the Python interpreter used by the active environment. This project requires Python 3.13.
✔️ I see Python 3.13
Your isolated Python environment is active with the required release.
ⓧ I see an older version
An older version means the shell is still pointing outside the new environment.
- Activate the project environment again by running these commands:
source .venv/bin/activate
python --version
What should change?
The first command activates .venv. The second command should print Python 3.13.
ⓧ Command not found
A missing Python command means the shell has not activated the local environment.
- Activate the project environment by running these commands:
source .venv/bin/activate
python --version
What should change?
Activation makes the environment's Python command available. The version check should print Python 3.13.
About folder trust
Visual Studio Code may show a trust prompt when it opens the folder. This folder is safe to approve because you created it locally.
- Open the current project folder in Visual Studio Code by running:
code .
What does this command open?
The period represents the current churn-prediction-api folder. Visual Studio Code opens that folder as your workspace.
- Approve the folder if Visual Studio Code shows a trust prompt.
You will see churn-prediction-api in the Explorer sidebar. Your editor and activated terminal now point to the same project.
Pin dependencies and verify the workspace
A pinned dependency file gives every installation the same releases of scikit-learn, FastAPI, and Pydantic. This keeps model training and API behavior consistent.
- Select the new-file icon at the top of the Visual Studio Code Explorer sidebar.
- Name the file requirements.txt.
You will see requirements.txt in the Explorer sidebar. The editor opens the empty file for your dependency pins.
- Fill requirements.txt by pasting the exact pins below:
scikit-learn==1.9.1
fastapi[standard]==0.148.0
pydantic==2.14.1
What do these pins control?
- The scikit-learn pin provides the model training tools used later.
- The FastAPI pin includes the standard development command used to serve the API.
- The Pydantic pin provides the validated request models used by the prediction endpoint.
- Save requirements.txt by pressing Cmd+S.
- Confirm the saved file by locating requirements.txt in the Explorer sidebar.
Dependency pins look different?
Check each package name for spelling. Keep one package pin on each line.
Make sure the file is named requirements.txt without an extra file extension.
Help me compare my requirements file with the expected dependency pins.
✔️ Awesome, I've got everything!
Great. Double-check that you saved requirements.txt.
ⓧ I'd like to double check the full code
scikit-learn==1.9.1
fastapi[standard]==0.148.0
pydantic==2.14.1
What should match?
Your file should contain these three lines in this order. Each line uses the exact pinned release from the project.
The .gitignore file tells Git which generated local files to leave out of version history. This protects the environment and model artifact from accidental tracking.
- Select the new-file icon at the top of the Visual Studio Code Explorer sidebar.
- Name the file .gitignore.
You will see .gitignore in the Explorer sidebar. Visual Studio Code opens the empty file for its ignore rules.
- Fill .gitignore by pasting these rules:
.venv/
__pycache__/
*.pyc
model.pkl
What do these rules protect?
- The .venv/ rule excludes the local environment and installed packages.
- The __pycache__/ and *.pyc rules exclude generated Python cache files.
- The model.pkl rule excludes the locally generated model artifact.
- Save .gitignore by pressing Cmd+S.
- Confirm the saved file by locating .gitignore in the Explorer sidebar.
Cannot see .gitignore?
Check that the filename starts with a period. The complete filename is .gitignore.
Look in the Explorer sidebar inside churn-prediction-api.
Help me check why my .gitignore file is missing from the Visual Studio Code Explorer.
✔️ Awesome, I've got everything!
Great. Double-check that you saved .gitignore.
ⓧ I'd like to double check the full code
.venv/
__pycache__/
*.pyc
model.pkl
What should match?
Your file should contain these four rules in this order. Each rule matches a local artifact created by the project.
- Switch back to macOS Terminal from earlier.
- Install the pinned dependencies from requirements.txt by running:
uv pip install -r requirements.txt
What does this command install?
uv reads every pin from requirements.txt. It installs those packages into the active .venv environment.
The terminal prints installation progress followed by a successful completion. Your local environment now contains the machine learning and API packages.
Dependency installation failed?
Check that your terminal prompt shows the active environment. Run the activation command again if the environment name is missing.
Compare requirements.txt with the full-code tab if uv reports a package or version problem.
Help me diagnose a uv dependency installation failure in this project.
Before you run the final check, which four parts of the workspace do you expect the terminal to confirm?
- Verify the editor command, uv release, Python release, and installed packages by running:
code --version && uv --version && python --version && uv pip list
What does the final check cover?
- The first check confirms that Terminal can find Visual Studio Code.
- The second check confirms uv 0.13.1.
- The third check confirms Python 3.13 inside the active environment.
- The final check lists the packages installed in .venv.
You will see a Visual Studio Code version followed by uv 0.13.1 and Python 3.13. The package list includes scikit-learn 1.9.1, FastAPI 0.148.0, and Pydantic 2.14.1.
That is the setup work complete. Next, you will launch the smallest working version of your churn API.
Launch the Smallest Working API
Your reproducible workspace is ready. The next goal is a live service shell you can test before a model enters the picture.
FastAPI turns a function into a browser-accessible API route. A health endpoint proves the service shell works before model training adds more moving parts.
In this step, get ready to:
- Create the named FastAPI application.
- Expose a root health endpoint.
- Verify the live response through Safari.
Create the API health endpoint
A health endpoint gives callers a quick way to confirm that the service is available. This first version returns the same small response for every request.
- Create main.py inside churn-prediction-api with the new-file control at the top of the Visual Studio Code file sidebar.
You should see main.py listed beside requirements.txt in the file sidebar.
- Paste the following code into main.py:
from fastapi import FastAPI
app = FastAPI(title="Customer Churn Prediction API")
@app.get("/")
def health() -> dict[str, str]:
return {"status": "api is running"}
What does this code do?
- The app variable stores the FastAPI application that the development server loads.
- The @app.get("/") decorator connects the root GET route to health().
- The health() function returns a stable service-status response.
- Press Command-S to save main.py.
✔️ Awesome, I've got everything!
Your main.py file now contains the named application. It also contains the root health endpoint.
ⓧ I'd like to double check the full code
from fastapi import FastAPI
app = FastAPI(title="Customer Churn Prediction API")
@app.get("/")
def health() -> dict[str, str]:
return {"status": "api is running"}
Start the development server
The development server loads your application whenever it starts. It keeps the API available while you test it from another application.
Before you start the server, what local address do you expect it to report?
- Switch back to the activated macOS Terminal from earlier.
- Start the FastAPI development server by running:
fastapi dev
What does this command do?
The fastapi dev command reads main.py automatically.
The command starts the local server at http://127.0.0.1:8000.
You'll see the terminal report the local server address. The running process keeps this terminal occupied.
- Leave the server running for the browser check.
Development Server Did Not Start?
- Confirm the .venv name still appears in the terminal prompt.
- Confirm main.py is saved inside churn-prediction-api.
Help me diagnose why my FastAPI development server is not starting.
Verify the API in Safari
Safari gives you a direct view of the response returned by the root route. The browser should display the response as JSON.
Before you load the route, what status message do you expect the browser to show?
- Press Command-Space bar to open Spotlight.
- Enter Safari in the search field.
- Press Return to open the selected result.
- Enter http://127.0.0.1:8000/ in the Safari address bar.
- Press Return to load the route.
You'll see {"status":"api is running"} in the browser.
That first live response confirms your API shell can answer browser requests.
Browser Cannot Reach the API?
- Confirm the Safari address is http://127.0.0.1:8000/.
- Confirm the server process is still running in the terminal from earlier.
Help me find why Safari cannot reach my local FastAPI health endpoint.
FastAPI also generates interactive documentation from your application routes. This page confirms that the framework discovered the root health endpoint.
Before you load the documentation, which route do you expect FastAPI to list?
- Enter http://127.0.0.1:8000/docs in the Safari address bar.
- Press Return to load the documentation.
- Confirm the page lists the GET / health route.
You'll see Customer Churn Prediction API on the interactive documentation page. You'll also see the GET / route listed below it.
- Switch back to the terminal from earlier.
- Press Control+C to stop the development server.
You'll see the terminal prompt return. This confirms the local development server has stopped.
Your API shell is proven. Next, you'll create a dataset that exposes how accuracy can hide the customers you care about.
Expose the Accuracy Trap
Your FastAPI health endpoint proved that the local service shell can answer a request. An ML service also needs evidence that its chosen metric supports the retention team.
Customer churn creates an imbalanced classification problem because far fewer customers leave than stay. A high accuracy score can hide how many at-risk customers the model catches.
This step creates deterministic customer data. You will judge a naive baseline with accuracy first. You will also calculate recall.
In this step, get ready to:
- Create a repeatable synthetic customer dataset with six business-readable features.
- Split the dataset while preserving its class distribution.
- Run a majority-class baseline to expose the difference between accuracy and recall.
Generate deterministic customer data
Synthetic data keeps this experiment local. Deterministic formulas create the same customer profiles every time you run the script.
- Use the new-file icon at the top of VS Code's file sidebar.
- Enter train.py as the file name.
- Add the model imports plus the shared feature metadata by pasting this code into train.py:
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, recall_score
from sklearn.model_selection import train_test_split
FEATURES = [
"tenure_months",
"monthly_charges",
"support_calls",
"contract_months",
"late_payments",
"usage_score",
]
MODEL_PATH = "model.pkl"
What Does This Setup Define?
- The scikit-learn imports provide the baseline classifier plus the two evaluation metrics.
- FEATURES keeps the six customer values in a stable training order.
- MODEL_PATH names the model artifact used later in the project.
- Save train.py.
You should now see train.py beside the existing project files in VS Code's file sidebar.
Imports Showing Red Underlines?
Confirm that the .venv environment from earlier is still activated in the terminal. The pinned packages inside that environment provide these imports.
Help me diagnose unresolved scikit-learn imports in train.py.
- Add the first region of build_dataset() below MODEL_PATH by pasting this code:
def build_dataset(sample_count: int = 1200) -> tuple[list[list[float]], list[int]]:
rows: list[list[float]] = []
labels: list[int] = []
contract_options = [1, 12, 24]
for customer_id in range(sample_count):
tenure_months = (customer_id * 37) % 72 + 1
monthly_charges = round(
20 + ((customer_id * 29) % 100) + (customer_id % 10) / 10,
2,
)
support_calls = (customer_id * 5) % 9
contract_months = contract_options[(customer_id * 7) % 3]
late_payments = (customer_id * 11) % 7
usage_score = float((customer_id * 13) % 101)
How Are the Customer Profiles Built?
- sample_count controls how many customer profiles the function generates.
- rows holds the feature values. labels holds the churn outcomes.
- customer_id drives every formula so the same ID always produces the same profile.
- contract_options limits contract lengths to business-readable values.
- Complete build_dataset() by pasting this connected region directly below the usage_score line:
churn_score = (
-0.045 * tenure_months
+ 0.035 * monthly_charges
+ 0.7 * support_calls
- 0.12 * contract_months
+ 0.9 * late_payments
- 0.025 * usage_score
)
churned = int(churn_score >= 6.0)
rows.append(
[
tenure_months,
monthly_charges,
support_calls,
contract_months,
late_payments,
usage_score,
]
)
labels.append(churned)
if len(set(labels)) != 2:
raise RuntimeError("Synthetic data must contain both churn classes.")
return rows, labels
How Is Churn Assigned?
- churn_score combines the six customer signals into a transparent rule.
- churned becomes 1 when the score reaches the threshold.
- rows.append() preserves the feature order declared in FEATURES.
- len(set(labels)) guards against a dataset containing only one class.
- Save train.py.
You should see the complete build_dataset() function without red syntax underlines.
Seeing a Syntax Underline?
Check that churn_score remains indented inside the for customer_id loop. Check that the final class guard lines up with the loop itself.
Help me fix the indentation in build_dataset().
Run the majority-class baseline
A baseline model gives you a minimum standard for comparison. DummyClassifier(strategy="most_frequent") always predicts the most common training label.
The split uses stratification to preserve the churn proportion across training data plus test data. This makes the metric comparison repeatable.
- Add main() below build_dataset() by pasting this code:
def main() -> None:
rows, labels = build_dataset()
X_train, X_test, y_train, y_test = train_test_split(
rows,
labels,
test_size=0.25,
random_state=42,
stratify=labels,
)
baseline = DummyClassifier(strategy="most_frequent")
baseline.fit(X_train, y_train)
baseline_predictions = baseline.predict(X_test)
print(f"Positive class share: {sum(y_test) / len(y_test):.3f}")
print(f"Baseline accuracy: {accuracy_score(y_test, baseline_predictions):.3f}")
print(
"Baseline recall: "
f"{recall_score(y_test, baseline_predictions, zero_division=0):.3f}"
)
if __name__ == "__main__":
main()
What Does This Code Do?
- train_test_split() separates the generated profiles into training data plus test data.
- stratify=labels keeps the churn proportion consistent across the split.
- baseline.fit() identifies the most frequent training label.
- accuracy_score() measures the share of all predictions that were correct.
- recall_score() measures the share of churned customers the baseline found.
- Save train.py.
✔️ Awesome, I've got everything!
Your train.py now contains the deterministic dataset builder plus the baseline evaluation.
ⓧ I'd like to double check the full code
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, recall_score
from sklearn.model_selection import train_test_split
FEATURES = [
"tenure_months",
"monthly_charges",
"support_calls",
"contract_months",
"late_payments",
"usage_score",
]
MODEL_PATH = "model.pkl"
def build_dataset(sample_count: int = 1200) -> tuple[list[list[float]], list[int]]:
rows: list[list[float]] = []
labels: list[int] = []
contract_options = [1, 12, 24]
for customer_id in range(sample_count):
tenure_months = (customer_id * 37) % 72 + 1
monthly_charges = round(
20 + ((customer_id * 29) % 100) + (customer_id % 10) / 10,
2,
)
support_calls = (customer_id * 5) % 9
contract_months = contract_options[(customer_id * 7) % 3]
late_payments = (customer_id * 11) % 7
usage_score = float((customer_id * 13) % 101)
churn_score = (
-0.045 * tenure_months
+ 0.035 * monthly_charges
+ 0.7 * support_calls
- 0.12 * contract_months
+ 0.9 * late_payments
- 0.025 * usage_score
)
churned = int(churn_score >= 6.0)
rows.append(
[
tenure_months,
monthly_charges,
support_calls,
contract_months,
late_payments,
usage_score,
]
)
labels.append(churned)
if len(set(labels)) != 2:
raise RuntimeError("Synthetic data must contain both churn classes.")
return rows, labels
def main() -> None:
rows, labels = build_dataset()
X_train, X_test, y_train, y_test = train_test_split(
rows,
labels,
test_size=0.25,
random_state=42,
stratify=labels,
)
baseline = DummyClassifier(strategy="most_frequent")
baseline.fit(X_train, y_train)
baseline_predictions = baseline.predict(X_test)
print(f"Positive class share: {sum(y_test) / len(y_test):.3f}")
print(f"Baseline accuracy: {accuracy_score(y_test, baseline_predictions):.3f}")
print(
"Baseline recall: "
f"{recall_score(y_test, baseline_predictions, zero_division=0):.3f}"
)
if __name__ == "__main__":
main()
Before you run the baseline, which metric do you expect to look more reassuring?
- Test the baseline from the activated terminal by running this command:
python train.py
What Should You See?
You will see the positive class share followed by the baseline accuracy. You will also see Baseline recall: 0.000.
You found the trap: the accuracy looks reassuring while the baseline misses every churned customer. Recall exposes the failure that matters to the retention team.
Baseline Script Did Not Run?
Confirm that the terminal is still inside churn-prediction-api. Confirm that .venv remains activated.
Help me diagnose why python train.py does not print the baseline metrics.
Your baseline now proves why one reassuring metric cannot protect the business goal. Next, you will train a recall-aware pipeline that can identify at-risk customers.
Train and Save a Recall-Aware Pipeline
The baseline exposed the real risk in this service. In this imbalanced classification problem, a high accuracy score still leaves every at-risk customer unseen.
Class weighting gives churn cases more influence during fitting. A scikit-learn pipeline keeps scaling tied to classification. The same preprocessing then applies every time the model handles data.
In this step, get ready to:
- Build a scaled classifier with balanced class weights.
- Compare the balanced model with the baseline using accuracy and recall.
- Save the fitted pipeline as a trusted local model artifact.
Build the balanced pipeline
Feature values such as monthly charges and support calls use different numeric ranges. Scaling puts them on comparable ranges before logistic regression learns their relationship with churn.
- In train.py, replace the current import group with the code below:
import pickle
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
What do these imports add?
- StandardScaler prepares each feature for the classifier by scaling its values.
- LogisticRegression provides the classifier that learns which profiles resemble churn cases.
- make_pipeline keeps scaling attached to the classifier during fitting and prediction.
- pickle provides the model serialization used later in this step.
- In train.py, find baseline_predictions = baseline.predict(X_test).
- Paste the following block directly below that line:
model = make_pipeline(
StandardScaler(),
LogisticRegression(class_weight="balanced", max_iter=1000),
)
model.fit(X_train, y_train)
model_predictions = model.predict(X_test)
What does this code do?
- StandardScaler learns scaling values from the training features.
- LogisticRegression uses balanced class weights so the minority churn class has more influence during fitting.
- model_predictions holds the balanced model's decisions for the untouched test set.
- Save train.py.
- Test the pipeline by running this command:
python train.py
What should you see?
You should see the three baseline metrics again. The terminal should return to its prompt after fitting the new pipeline.
Does the script stop during training?
- Compare the import names with the first snippet in this substep.
- Check that the pipeline block sits inside main() directly below baseline_predictions.
Help me debug the balanced pipeline.
Compare accuracy and recall
Accuracy still describes overall correctness. Recall answers the business-critical question: how many actual churn cases did the model identify?
- In train.py, find the baseline recall block ending with f"{recall_score(y_test, baseline_predictions, zero_division=0):.3f}".
- Paste the balanced metric calculations directly below that block:
print(f"Balanced accuracy: {accuracy_score(y_test, model_predictions):.3f}")
print(
"Balanced recall: "
f"{recall_score(y_test, model_predictions, zero_division=0):.3f}"
)
Why print both metrics?
- accuracy_score shows how often the model predicts either class correctly.
- recall_score focuses on the positive churn class that the retention team needs to find.
- Keeping both metrics visible prevents an attractive accuracy score from hiding missed churners.
- Save train.py.
- Before you run the script, ask yourself whether the balanced model will find more churners than the baseline.
- Compare the two models by running this command:
python train.py
What should you see?
You should still see Baseline recall: 0.000. The balanced recall should now be higher than the baseline recall.
That is the metric trap fixed. Your model now identifies churn cases that the baseline ignored.
Is balanced recall missing or unchanged?
- Check that both balanced metric lines use model_predictions.
- Confirm that class_weight="balanced" appears inside LogisticRegression.
Help me compare the baseline and balanced metrics.
Save the trusted model artifact
Retraining for every API request would waste time and could produce an unstable service contract. Model persistence lets the API load one fitted pipeline with its required feature order.
- In train.py, find the balanced recall block ending with f"{recall_score(y_test, model_predictions, zero_division=0):.3f}".
- Paste the following artifact code directly below that block:
artifact = {"model": model, "features": FEATURES}
with open(MODEL_PATH, "wb") as model_file:
pickle.dump(artifact, model_file, protocol=pickle.HIGHEST_PROTOCOL)
print(f"Saved trusted local model to {MODEL_PATH}")
What does this code save?
- artifact stores the fitted pipeline under the model key.
- The features key preserves the six feature names in their training order.
- pickle.dump writes both values to model.pkl using Python's highest available pickle protocol.
Why keep the artifact trusted?
A pickle file can execute code when Python loads it. This project only loads the model.pkl file generated by your own training script.
- Save train.py.
- Before you rerun the script, ask yourself which terminal message will prove that serialization completed.
- Generate the trusted model artifact by running this command:
python train.py
What should you see?
You should see Saved trusted local model to model.pkl after the metrics. This confirms that the fitted pipeline was serialized.
- Confirm that model.pkl appears beside train.py in the Visual Studio Code file list.
Is model.pkl missing?
- Check that the artifact block remains inside main().
- Confirm that MODEL_PATH still contains model.pkl.
- Rerun the training script after saving train.py.
Help me find why model.pkl was not created.
✔️ Awesome, I've got everything!
Your balanced pipeline is trained. Your trusted local model artifact is ready for the API.
ⓧ I'd like to double check the full code
- Compare your train.py file with this complete version:
import pickle
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
FEATURES = [
"tenure_months",
"monthly_charges",
"support_calls",
"contract_months",
"late_payments",
"usage_score",
]
MODEL_PATH = "model.pkl"
def build_dataset(sample_count: int = 1200) -> tuple[list[list[float]], list[int]]:
rows: list[list[float]] = []
labels: list[int] = []
contract_options = [1, 12, 24]
for customer_id in range(sample_count):
tenure_months = (customer_id * 37) % 72 + 1
monthly_charges = round(
20 + ((customer_id * 29) % 100) + (customer_id % 10) / 10,
2,
)
support_calls = (customer_id * 5) % 9
contract_months = contract_options[(customer_id * 7) % 3]
late_payments = (customer_id * 11) % 7
usage_score = float((customer_id * 13) % 101)
churn_score = (
-0.045 * tenure_months
+ 0.035 * monthly_charges
+ 0.7 * support_calls
- 0.12 * contract_months
+ 0.9 * late_payments
- 0.025 * usage_score
)
churned = int(churn_score >= 6.0)
rows.append(
[
tenure_months,
monthly_charges,
support_calls,
contract_months,
late_payments,
usage_score,
]
)
labels.append(churned)
if len(set(labels)) != 2:
raise RuntimeError("Synthetic data must contain both churn classes.")
return rows, labels
def main() -> None:
rows, labels = build_dataset()
X_train, X_test, y_train, y_test = train_test_split(
rows,
labels,
test_size=0.25,
random_state=42,
stratify=labels,
)
baseline = DummyClassifier(strategy="most_frequent")
baseline.fit(X_train, y_train)
baseline_predictions = baseline.predict(X_test)
model = make_pipeline(
StandardScaler(),
LogisticRegression(class_weight="balanced", max_iter=1000),
)
model.fit(X_train, y_train)
model_predictions = model.predict(X_test)
print(f"Positive class share: {sum(y_test) / len(y_test):.3f}")
print(f"Baseline accuracy: {accuracy_score(y_test, baseline_predictions):.3f}")
print(
"Baseline recall: "
f"{recall_score(y_test, baseline_predictions, zero_division=0):.3f}"
)
print(f"Balanced accuracy: {accuracy_score(y_test, model_predictions):.3f}")
print(
"Balanced recall: "
f"{recall_score(y_test, model_predictions, zero_division=0):.3f}"
)
artifact = {"model": model, "features": FEATURES}
with open(MODEL_PATH, "wb") as model_file:
pickle.dump(artifact, model_file, protocol=pickle.HIGHEST_PROTOCOL)
print(f"Saved trusted local model to {MODEL_PATH}")
if __name__ == "__main__":
main()
How to use this reference
Check the import group first. Next, compare the pipeline placement, balanced metric calculations, artifact dictionary, and save block.
You turned zero baseline recall into a model that identifies at-risk customers. Next, you will load the trusted artifact into your FastAPI service to return validated churn predictions.
Serve Validated Churn Predictions
Your balanced scikit-learn pipeline now catches more at-risk customers than the baseline. The fitted model still needs a checked service boundary before another application can use it.
This step turns model.pkl into a local REST API with FastAPI. Pydantic checks each customer profile before the model receives it.
In this step, get ready to:
- Load the trusted local model through the API.
- Validate customer profiles before scoring them.
- Compare predictions in the browser and document the service.
Load the trusted model and define inputs
The API loads the fitted pipeline once when it starts. Each request can then reuse the same model without retraining it.
Why only load your own model?
Python pickle files can run code while they load. Use only the model.pkl artifact generated by your own train.py script.
- Return to Visual Studio Code with the churn-prediction-api folder open.
- Select all the existing code in main.py.
- Replace the temporary API shell by pasting this service bootstrap:
import pickle
from fastapi import FastAPI
from pydantic import BaseModel, Field
MODEL_PATH = "model.pkl"
with open(MODEL_PATH, "rb") as model_file:
artifact = pickle.load(model_file)
MODEL = artifact["model"]
FEATURES = artifact["features"]
app = FastAPI(title="Customer Churn Prediction API")
@app.get("/")
def health() -> dict[str, str]:
return {"status": "api is running"}
What does this code do?
- The MODEL_PATH value points to the trusted artifact created during training.
- The MODEL value holds the fitted preprocessing pipeline.
- The FEATURES value preserves the feature order used during training.
- The health() endpoint keeps the existing service check available.
- Save main.py.
- Return to the terminal with the activated .venv environment.
- Start the development server by running this command:
fastapi dev
What does this command do?
The command reads main.py automatically. It starts the local server at http://127.0.0.1:8000.
The server keeps this terminal busy while it runs. It reloads the application after saved code changes.
- Press Cmd+Space to open the macOS search bar.
- Type Safari into the search bar.
- Press Enter to open Safari.
- Enter http://127.0.0.1:8000/ in the address bar.
You should see {"status":"api is running"}. This proves the service can load the trusted model artifact during startup.
Did the server stop during startup?
Confirm that model.pkl appears beside main.py in the churn-prediction-api folder. Rerun train.py if the model artifact is missing.
Check that the terminal prompt showed (.venv) before the server started.
Help me diagnose why my FastAPI service cannot load my locally generated model artifact.
A request schema gives every input a name plus an allowed range. Invalid values stop at the service boundary.
- In main.py find the blank space above @app.get("/").
- Define the six validated customer fields by pasting this class into that space:
class CustomerFeatures(BaseModel):
tenure_months: int = Field(ge=1, le=72)
monthly_charges: float = Field(ge=20, le=120)
support_calls: int = Field(ge=0, le=8)
contract_months: int = Field(ge=1, le=24)
late_payments: int = Field(ge=0, le=6)
usage_score: float = Field(ge=0, le=100)
How does the schema protect the model?
- The CustomerFeatures class defines the shape of one customer request.
- Each Field sets the lowest accepted value with ge.
- Each Field sets the highest accepted value with le.
- FastAPI uses this schema to generate the request editor in its interactive documentation.
- Save main.py.
- Return to the server terminal to confirm that the application completes its reload without exiting.
The running server now accepts the schema definition. The endpoint that uses it comes next.
Did the reload fail?
Check that every field remains indented inside CustomerFeatures. Confirm that the class sits above @app.get("/").
Help me fix my CustomerFeatures schema so the FastAPI development server reloads successfully.
Add the scoring contract
The scoring function converts a validated customer into the exact feature order stored with the model. It returns a stable response containing a decision plus a rounded probability.
- In main.py find the blank space below CustomerFeatures.
- Add the reusable scoring function by pasting this code above @app.get("/"):
def score_customer(customer: CustomerFeatures) -> dict[str, bool | float]:
row = [[getattr(customer, feature) for feature in FEATURES]]
will_churn = bool(MODEL.predict(row)[0])
churn_probability = float(MODEL.predict_proba(row)[0][1])
return {
"will_churn": will_churn,
"churn_probability": round(churn_probability, 3),
}
What does this code do?
- The feature loop reads values in the order recorded inside FEATURES.
- The predict() call produces the churn decision.
- The predict_proba() call produces the positive-class probability.
- The response rounds churn_probability to three decimal places.
- Save main.py.
- Return to the server terminal to confirm that the application completes another reload without exiting.
- Refresh http://127.0.0.1:8000/ in Safari.
You should still see the health response. This confirms the scoring function loads without breaking the existing endpoint.
Did the health check stop working?
Check that score_customer() sits above the endpoint decorators. Confirm that each response key uses matching quotation marks.
Help me find the syntax or placement problem in my score_customer function.
The prediction route connects the validated request body to score_customer(). The function signature also becomes part of the generated documentation.
- In main.py locate the end of health().
- Expose the prediction route by pasting this endpoint below health():
@app.post("/predict")
def predict(customer: CustomerFeatures) -> dict[str, bool | float]:
return score_customer(customer)
How does the endpoint work?
- The decorator exposes POST /predict to callers.
- The CustomerFeatures annotation validates the request body.
- The endpoint delegates model inference to score_customer().
- Save main.py.
- Enter http://127.0.0.1:8000/docs in Safari.
You should see separate operations for GET / plus POST /predict. The new prediction operation is the visible service contract.
Is the prediction operation missing?
Confirm that @app.post("/predict") starts at the left edge of main.py. Save the file before refreshing the documentation.
Help me find why POST /predict is missing from my FastAPI interactive documentation.
Use these tabs to confirm the complete service file before testing it.
✔️ Awesome, I've got everything!
Great. Save main.py before moving on to the browser tests.
ⓧ I'd like to double check the full code
import pickle
from fastapi import FastAPI
from pydantic import import BaseModel, Field
Secret mission
Add Batch Churn Scoring
Retention teams often score a group of customers before planning outreach. Extend your API with validated batch scoring so one request returns predictions for a mixed list of customer profiles.
Clean Up Your Resources
Clean Up Your Resources
Everything you built runs locally on your Mac with no ongoing service costs. Choose whether to keep it ready for more work, pause it for later, or delete it.
Resources you used:
- The local churn-prediction-api folder containing .venv, the pinned Python packages, train.py, main.py, README.md, requirements.txt, .gitignore, and model.pkl.
Keep everything running
No action needed. Choose this if you plan to refine the model or continue developing the API.
- Retain the complete churn-prediction-api folder so the source files, virtual environment, installed packages, and trained model remain available.
Pause - I'll come back to this later
Your development server is already stopped. Deactivate the current environment before closing your tools.
- Deactivate the current environment by running this command:
deactivate
What does deactivate do?
The deactivate command exits the active virtual environment. Your project files and installed packages remain inside .venv.
- Confirm that your Terminal prompt no longer shows the environment name.
- Close Visual Studio Code.
Your source files and locally generated model remain ready inside churn-prediction-api.
Delete - I don't want to use this again
Moving the project folder to Trash removes your code, environment, installed project packages, and locally generated model from their current location. Visual Studio Code, uv, and managed Python stay installed for your next project.
- Deactivate the current environment by running this command:
deactivate
Why deactivate first?
The deactivate command releases the active virtual environment before you remove its files.
- Close Visual Studio Code.
- In Finder, locate the churn-prediction-api folder.
- Drag the churn-prediction-api folder to Trash.
- Confirm that churn-prediction-api no longer appears in its original Finder location.
Nice Work!
Nice Work!
Outstanding work! You built an end-to-end machine learning service for customer churn. It exposes a misleading metric before serving validated predictions from a persisted model.
You learned how to:
- Expose the accuracy trap with a deterministic churn dataset. Use a majority-class baseline to show how strong accuracy can hide zero recall.
- Build a recall-aware pipeline with scikit-learn. Compare its recall with the baseline. Persist the trusted fitted artifact in model.pkl.
- Serve the trusted model through FastAPI as a validated prediction API. Reject out-of-range inputs with a constrained Pydantic schema. Demonstrate contrasting churn scores through interactive API documentation. Explain the service decisions in README.md.
- Secret Mission: Extend the service with POST /predict/batch. Reuse score_customer() so one validated request returns predictions for multiple customers.
Ready to quiz yourself?