Back in Article 0, before any of the hands-on work started, the running example for almost every concept was a subscription business's customer churn model. It was useful precisely because it was hypothetical: no code to write, no data to wrangle, just a clean illustration of what MLOps is for.
Seven articles later, you've actually learned every tool that example was gesturing at. Git and GitHub for versioning code. DVC for versioning data and models alongside it. Docker for packaging the whole thing so it runs the same everywhere. FastAPI and Pydantic for turning a trained model into a validated, documented API.
What you haven't done yet is run all four, back to back, on one real project, without gaps. Each article showed a slice. This one shows the whole chain: git init to a running, containerized, input-validated churn prediction API, on real data, with nothing hidden or simulated.
It also ends somewhere deliberately uncomfortable. Partway through, you'll do the thing every data scientist does: train a few models, tweak some hyperparameters, and try to remember which run was actually the best one. Watching that go wrong, honestly, is what sets up the next two articles on MLflow.
The project
Task: predict whether a telecom customer is going to churn, from their account, service, and billing data.
Dataset: the IBM Telco Customer Churn dataset, the same one Article 0 gestured at without naming. It's real, well documented, and small enough to move quickly: 7,043 customers, 21 columns, hosted on IBM's own GitHub.1 About 26.5% of customers in the dataset churned.
Unlike Heart Disease or Iris from earlier articles, this dataset is mostly categorical, not numeric: gender, contract type, payment method, internet service, and a dozen more Yes/No/service-tier columns, alongside a handful of numeric ones like tenure and monthly charges. That's closer to what a real business dataset actually looks like, and it means this project introduces one genuinely new piece: encoding categorical features properly, inside the same pipeline pattern you already know from Articles 2, 3, 5, and 7.
Project structure, matching the shape you've used since Article 7:
churn_prediction/
├── .gitignore
├── README.md
├── requirements.txt
├── Dockerfile
├── train.py ← trains and saves the final pipeline
├── explore_models.py ← informal experimentation (Part 4)
├── schemas.py ← Pydantic request/response schemas
├── main.py ← the FastAPI app
├── data/
│ └── telco_churn.csv
└── models/
└── model.pkl
Part 1 — Git: the foundation, as always
Nothing new here. Start the way every project in this series has started.
mkdir churn_prediction
cd churn_prediction
git init
Write .gitignore before anything else, the habit from Article 2:
__pycache__/
*.pyc
.venv/
venv/
data/
*.csv
models/
*.pkl
.env
.DS_Store
git add .gitignore
git commit -m "Add .gitignore"
Add a short README and a requirements.txt pinned to what's actually been tested:
scikit-learn==1.8.0
pandas==3.0.2
numpy==2.4.4
joblib==1.5.3
fastapi==0.141.1
uvicorn==0.52.1
pydantic==2.13.4
git add README.md requirements.txt
git commit -m "Add README and dependencies"
Three commits in, nothing surprising. This is exactly the workflow from Article 2.
Part 2 — DVC: version the data before you touch it
dvc init
git add .dvc .dvcignore
git commit -m "Initialize DVC"
Now place telco_churn.csv in data/, and here's a genuine gotcha worth knowing before it bites you.
Run dvc add on the data file before it ever gets swept up by a git add . or git add -A. If Git commits the raw CSV directly first, DVC will refuse to track it afterward, confirmed by actually triggering this exact error while building this project:
ERROR: output 'data/telco_churn.csv' is already tracked by SCM (e.g. Git).
You can remove it from Git, then add to DVC.
To stop tracking from Git:
git rm -r --cached 'data/telco_churn.csv'
This is DVC protecting you from double-tracking the same file two different ways, but it's an easy trap to fall into on a real project, especially once your .gitignore from Part 1 hasn't caught up yet. Do it in the right order and there's no problem at all:
dvc add data/telco_churn.csv
To track the changes with git, run:
git add data/.gitignore data/telco_churn.csv.dvc
git add data/.gitignore data/telco_churn.csv.dvc
git commit -m "Track Telco churn dataset with DVC"
Set up a remote and push, exactly as in Article 3:
mkdir -p ~/dvc-remote
dvc remote add -d storage ~/dvc-remote
git add .dvc/config
git commit -m "Configure DVC remote"
dvc push
The data is versioned, backed up, and reproducible by anyone who clones this repo and runs dvc pull. Keep that fact in mind. It matters later.
Part 3 — Building the pipeline
The one real data quirk
TotalCharges looks numeric but is stored as text, and 11 rows are blank rather than zero.2 Every one of those 11 rows turns out to be a brand-new customer with tenure == 0, someone who hasn't been billed yet. That's not corrupted data, it's a real customer state the column just wasn't designed to represent cleanly. Fixing it is one line:
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce').fillna(0)
Handling a mostly-categorical dataset
Every pipeline so far in this series used StandardScaler alone, because every feature was numeric. This dataset has 15 categorical columns and 4 numeric ones, which calls for one new tool: ColumnTransformer. It's a small, natural extension of the Pipeline pattern you already know, it just lets you apply different preprocessing to different columns, then hands the combined result to the model, all inside one object you can save and load as a single file.
"""train.py — Train a churn prediction pipeline and save it."""
import pandas as pd
import joblib
import os
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
df = pd.read_csv("data/telco_churn.csv")
# the one real data quirk in this dataset — see above
df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce").fillna(0)
df = df.drop(columns=["customerID"])
df["Churn"] = df["Churn"].map({"Yes": 1, "No": 0})
X = df.drop(columns=["Churn"])
y = df["Churn"]
numeric_features = ["SeniorCitizen", "tenure", "MonthlyCharges", "TotalCharges"]
categorical_features = [c for c in X.columns if c not in numeric_features]
preprocessor = ColumnTransformer(transformers=[
("num", StandardScaler(), numeric_features),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),
])
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", LogisticRegression(max_iter=1000, random_state=42)),
])
pipeline.fit(X_train, y_train)
pred = pipeline.predict(X_test)
print(f"Accuracy: {accuracy_score(y_test, pred):.4f}")
print(f"Precision: {precision_score(y_test, pred):.4f}")
print(f"Recall: {recall_score(y_test, pred):.4f}")
print(f"F1: {f1_score(y_test, pred):.4f}")
os.makedirs("models", exist_ok=True)
joblib.dump(pipeline, "models/model.pkl")
print("Saved full pipeline (preprocessing + model) to models/model.pkl")
OneHotEncoder(handle_unknown="ignore") matters more than it looks: it means a category the model never saw during training won't crash a live prediction later, it just gets treated as "none of the known categories." The whole Pipeline, preprocessing and model together, gets saved as one model.pkl. That single detail is what makes serving this later almost identical to Article 7's Heart Disease API, despite this dataset being far messier.
Run it:
python train.py
Accuracy: 0.8055
Precision: 0.6572
Recall: 0.5588
F1: 0.6040
Saved full pipeline (preprocessing + model) to models/model.pkl
Part 4 — What actually happens without a tracking tool
An 80.5% accuracy baseline is fine, but it's a logistic regression with default settings. Naturally, you try a few other things. This is the normal, universal next step in any real project, and it's worth watching exactly what happens when you do it the way most people actually do it: informally, in a scratch script, with print statements.
"""explore_models.py — trying a few different models and settings"""
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.linear_model import LogisticRegression
# ... same preprocessing and train/test split as train.py ...
def run(name, clf):
pipe = Pipeline([("preprocessor", preprocessor), ("classifier", clf)])
pipe.fit(X_train, y_train)
pred = pipe.predict(X_test)
print(f"{name}: acc={accuracy_score(y_test, pred):.4f} f1={f1_score(y_test, pred):.4f}")
run("RandomForest, 100 trees", RandomForestClassifier(n_estimators=100, random_state=42))
run("RandomForest, 200 trees, max_depth=10", RandomForestClassifier(n_estimators=200, max_depth=10, random_state=42))
run("RandomForest, 300 trees, max_depth=6", RandomForestClassifier(n_estimators=300, max_depth=6, random_state=42))
run("GradientBoosting, 100 est, lr=0.1", GradientBoostingClassifier(n_estimators=100, learning_rate=0.1, random_state=42))
run("GradientBoosting, 200 est, lr=0.05", GradientBoostingClassifier(n_estimators=200, learning_rate=0.05, random_state=42))
Six runs total, including the baseline. Here's every result, actually produced by running this exact code against the real dataset, not invented for the example:3
| Run | Accuracy | Precision | Recall | F1 |
|---|---|---|---|---|
| Logistic Regression (default) | 0.8055 | 0.6572 | 0.5588 | 0.6040 |
| Random Forest, 100 trees | 0.7864 | 0.6254 | 0.4866 | 0.5474 |
| Random Forest, 200 trees, depth 10 | 0.7999 | 0.6575 | 0.5134 | 0.5766 |
| Random Forest, 300 trees, depth 6 | 0.8048 | 0.6854 | 0.4893 | 0.5710 |
| Gradient Boosting, 100 est, lr 0.1 | 0.8027 | 0.6655 | 0.5160 | 0.5813 |
| Gradient Boosting, 200 est, lr 0.05 | 0.8048 | 0.6689 | 0.5241 | 0.5877 |
Now, genuinely, which one is best? By accuracy, the plain logistic regression and the heavily tuned gradient boosting model are separated by 0.0007, close enough to be noise. By recall, which matters a lot for churn specifically, since missing an actual churner is usually more expensive than a false alarm, the plain logistic regression wins outright. The simplest model in the list, with no tuning at all, is arguably the best one.
That's not a contrived result. It's a completely real, common outcome, and it's exactly the kind of thing that's easy to know in the moment and impossible to reconstruct a week later. Which terminal scrollback had the 300-tree run? Was max_depth=6 the good one or the bad one? If you saved a model file for each attempt, you likely have something like this in your models/ folder by now:
models/
├── model.pkl
├── model_rf.pkl
├── model_rf_v2.pkl
├── model_rf_v2_tuned.pkl
├── model_gb.pkl
└── model_gb_final_ACTUALLY.pkl
If that filename pattern looks familiar, it should. It's the exact same chaos Article 1 opened with, model_v2_final_ACTUALLY_FINAL.py, except this time it isn't code sitting untracked. It's decisions, and there's no tool watching them at all.
Hold that thought. We'll come back to it.
Part 5 — Locking in a model and building the API
Weighing accuracy against recall, the plain logistic regression from Part 3 is the one worth shipping: best accuracy, clearly best recall, and it's already sitting in models/model.pkl from the original train.py run. Version it properly, the way Article 3 taught:
dvc add models/model.pkl
git add models/model.pkl.dvc models/.gitignore
git commit -m "Track trained churn model (LogisticRegression, acc=0.8055) with DVC"
dvc push
Notice what just happened. The decision is now permanently recorded: this exact model, this exact commit message, this exact DVC-tracked binary. Compare that to the five other models sitting as loose .pkl files with no record of what hyperparameters produced them. One artifact in this whole project is properly accounted for. It's the one that happened to come from the tool built for versioning, not the one from Part 4.
The schema
Fifteen of nineteen input fields here are categorical, which is a good excuse to extend the Field constraints from Article 7 with something built for exactly this: Python's Literal type. Instead of just constraining a number's range, Literal constrains a string to a fixed, named set of values, and Pydantic enforces it exactly the way it enforced ge/le before.
"""schemas.py — Pydantic models for request validation and response shape."""
from typing import Literal
from pydantic import BaseModel, Field
class Customer(BaseModel):
"""Schema for a single customer's account and service data."""
gender: Literal["Female", "Male"]
SeniorCitizen: int = Field(..., ge=0, le=1, description="1 = senior citizen, 0 = not")
Partner: Literal["Yes", "No"]
Dependents: Literal["Yes", "No"]
tenure: int = Field(..., ge=0, description="Months as a customer")
PhoneService: Literal["Yes", "No"]
MultipleLines: Literal["Yes", "No", "No phone service"]
InternetService: Literal["DSL", "Fiber optic", "No"]
OnlineSecurity: Literal["Yes", "No", "No internet service"]
OnlineBackup: Literal["Yes", "No", "No internet service"]
DeviceProtection: Literal["Yes", "No", "No internet service"]
TechSupport: Literal["Yes", "No", "No internet service"]
StreamingTV: Literal["Yes", "No", "No internet service"]
StreamingMovies: Literal["Yes", "No", "No internet service"]
Contract: Literal["Month-to-month", "One year", "Two year"]
PaperlessBilling: Literal["Yes", "No"]
PaymentMethod: Literal[
"Electronic check", "Mailed check",
"Bank transfer (automatic)", "Credit card (automatic)"
]
MonthlyCharges: float = Field(..., gt=0)
TotalCharges: float = Field(..., ge=0)
class ChurnPrediction(BaseModel):
"""Schema for the prediction we send back."""
prediction: int
probability: float
interpretation: str
Every valid category for every field came directly from the real dataset, not guessed. Send anything outside these exact sets, a typo, a different capitalization, a category that plain doesn't exist, and FastAPI rejects it before your model ever sees it, the same guarantee Article 7 built.
The app
"""main.py — FastAPI app serving customer churn predictions."""
import joblib
import pandas as pd
from fastapi import FastAPI
from schemas import Customer, ChurnPrediction
app = FastAPI(
title="Customer Churn Prediction API",
description="Predicts whether a telecom customer is likely to churn.",
version="1.0.0",
)
# loaded once at startup — the rule from Article 6, unchanged
model = joblib.load("models/model.pkl")
@app.get("/health")
def health():
return {"status": "healthy"}
@app.post("/predict", response_model=ChurnPrediction)
def predict(customer: Customer):
data = customer.model_dump()
input_df = pd.DataFrame([data])
prediction = int(model.predict(input_df)[0])
probability = float(model.predict_proba(input_df)[0][1])
return ChurnPrediction(
prediction=prediction,
probability=round(probability, 4),
interpretation="Likely to churn" if prediction == 1 else "Likely to stay",
)
Notice input_df = pd.DataFrame([data]) is the entire preprocessing step. No manual scaling, no manual encoding, because the saved pipeline already contains the ColumnTransformer. Save the whole pipeline, and serving it stays this simple no matter how messy the raw data was.
git add schemas.py main.py
git commit -m "Add FastAPI app with Pydantic validation for churn predictions"
Part 6 — Testing it live
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
Two real customers from the dataset, chosen because the model gets both confidently right, make a clean demonstration.3
A high-risk profile: short tenure, month-to-month contract, fiber internet with none of the security or protection add-ons, paying by electronic check.
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{
"gender": "Female", "SeniorCitizen": 1, "Partner": "No", "Dependents": "No",
"tenure": 4, "PhoneService": "Yes", "MultipleLines": "Yes",
"InternetService": "Fiber optic", "OnlineSecurity": "No", "OnlineBackup": "No",
"DeviceProtection": "No", "TechSupport": "No", "StreamingTV": "Yes", "StreamingMovies": "Yes",
"Contract": "Month-to-month", "PaperlessBilling": "Yes", "PaymentMethod": "Electronic check",
"MonthlyCharges": 95.45, "TotalCharges": 396.1
}'
{"prediction": 1, "probability": 0.8371, "interpretation": "Likely to churn"}
A low-risk profile: long tenure, a one-year contract, security add-ons in place, paying by mailed check.
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{
"gender": "Male", "SeniorCitizen": 0, "Partner": "No", "Dependents": "No",
"tenure": 34, "PhoneService": "Yes", "MultipleLines": "No",
"InternetService": "DSL", "OnlineSecurity": "Yes", "OnlineBackup": "No",
"DeviceProtection": "Yes", "TechSupport": "No", "StreamingTV": "No", "StreamingMovies": "No",
"Contract": "One year", "PaperlessBilling": "No", "PaymentMethod": "Mailed check",
"MonthlyCharges": 56.95, "TotalCharges": 1889.5
}'
{"prediction": 0, "probability": 0.0445, "interpretation": "Likely to stay"}
Both are real historical customers. Both predictions match what actually happened to them. Now try an invalid category, gender misspelled as something the schema was never told about:
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"gender": "Nonbinary", "SeniorCitizen": 0, "Partner": "No", ...}'
{"detail": [{
"type": "literal_error",
"loc": ["body", "gender"],
"msg": "Input should be 'Female' or 'Male'",
"input": "Nonbinary",
"ctx": {"expected": "'Female' or 'Male'"}
}]}
Rejected cleanly, with the exact field and the exact allowed values named. Zero validation code written for this. That's Literal doing precisely what Field's ge/le did in Article 7, just for categories instead of numbers.
Part 7 — Dockerizing it
The Dockerfile follows the exact shape from Articles 5 and 7. Nothing here is new.
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends libgomp1 && \
rm -rf /var/lib/apt/lists/*
# dependencies before code — the layer-caching habit from Article 4
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY data/ ./data/
COPY train.py schemas.py main.py ./
# train during the build, same pattern as Article 7
RUN python train.py
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t churn-api:v1 .
docker run -d -p 8000:8000 --name churn-api churn-api:v1
curl http://localhost:8000/health
{"status": "healthy"}
Same container, same commands, same result on any machine that has Docker. That guarantee hasn't changed since Article 5, it's just now protecting a messier, more realistic dataset than any project so far.
Part 8 — The whole chain, start to finish
Here's every command from this article, in the order you'd actually run them, with nothing skipped:
# Git
mkdir churn_prediction && cd churn_prediction
git init
# ... .gitignore, README.md, requirements.txt ...
git add .gitignore README.md requirements.txt
git commit -m "Initial project setup"
# DVC — data first, always before a blanket git add
dvc init
git add .dvc .dvcignore
git commit -m "Initialize DVC"
dvc add data/telco_churn.csv
git add data/.gitignore data/telco_churn.csv.dvc
git commit -m "Track Telco churn dataset with DVC"
dvc remote add -d storage ~/dvc-remote
git add .dvc/config
git commit -m "Configure DVC remote"
dvc push
# Build and train
python train.py
dvc add models/model.pkl
git add models/model.pkl.dvc models/.gitignore
git commit -m "Track trained churn model with DVC"
dvc push
# FastAPI
git add schemas.py main.py
git commit -m "Add FastAPI app with Pydantic validation"
# Docker
git add Dockerfile
git commit -m "Add Dockerfile"
docker build -t churn-api:v1 .
docker run -d -p 8000:8000 --name churn-api churn-api:v1
# Ship it
git push origin main
Every one of those commands is something you already knew how to use before this article started. The only thing new was putting them in one sequence, on one real, messy dataset, without a single gap.
What this project just proved, and what it didn't
Look at what actually got tracked properly here. The code: every meaningful change is a Git commit with a real message. The data: telco_churn.csv is DVC-tracked, backed up to a remote, and pullable by anyone who clones the repo. The environment: the Dockerfile guarantees this exact API runs identically on any machine with Docker installed. The API itself: every field is validated by Pydantic before it reaches the model, and a bad request gets a precise, useful error instead of a crash.
Now look at Part 4 again. Six real experiments ran. Their results are sitting in this article as a table, because this article's author wrote them down. On a real project, at your actual job, that table doesn't exist unless you build it yourself, by hand, every single time, and remember to keep it updated as you try a seventh thing, then an eighth. Nothing in this entire pipeline, not Git, not DVC, not Docker, not FastAPI, was built to answer the question that actually decided which model got shipped: which exact configuration produced which exact number, and can you prove it?
That's not a gap in what you've learned. It's a gap in what these four tools were ever meant to solve. Git versions code. DVC versions data and models. Docker guarantees an environment. FastAPI validates and serves. None of them, even used perfectly, keep score across an afternoon of "let me just try one more thing."
That's exactly the job the next two articles exist to do. MLflow Tracking turns Part 4's ad-hoc script and mental math into a permanent, searchable record, every parameter, every metric, every run, logged automatically. MLflow Model Registry turns "which .pkl file was the good one again" into a managed, auditable lifecycle: staging, production, archived, with a real history behind each transition.
You've now felt the problem firsthand, on a real project, with real numbers. That's the right place to start.