Project 12 · MLOps / AI Engineering
Take an AI Model From Laptop to Production
You already have a trained machine-learning model. Now turn it into a real service that another application can call safely, test it, package it, detect data drift, release a second version, and prove that rollback actually restores the old model.
The practical problem: a model on your laptop is not yet a usable product
Imagine a retention application needs to ask: “Is this customer likely to churn?” Your notebook can make a prediction, but the application cannot import your notebook, guess your preprocessing, know which model version is active, or safely recover if a new release is bad. We need to turn the model into a dependable service with an explicit input contract.
The model itself is deliberately simple: Logistic Regression on eight churn-related fields. The learning goal is everything around the model—artifact integrity, serving, validation, tests, Docker, CI, drift, version switching and rollback.
Input
Eight validated customer fields such as tenure, charges, contract and support status.
Core service
A saved preprocessing + Logistic Regression pipeline loaded once at API startup.
Operational proof
46 tests, Docker runtime checks, drift checks and real HTTP requests in CI.
Output
stay/churn class, churn probability, active model version and measured inference latency.
What does “production” mean in this project?
It does not mean “put a model file on a server”. A production-style system must know exactly what model is loaded, reject malformed inputs before inference, preserve the training preprocessing, expose a stable API contract, produce observable logs, run repeatable tests, and let an operator move forward or backward between model versions.
Step 1: Create the pinned engineering environment
Work inside projects/model-to-production. The verified build used Python 3.13 and fully pinned dependencies.
Open requirements.txt
numpy==2.5.3
pandas==3.0.6
scikit-learn==1.9.1
joblib==1.6.0
fastapi==0.142.4
pydantic==2.13.5
uvicorn==0.54.0
httpx==0.28.1
pytest==9.1.1
# Resolved transitive dependencies, pinned for repeatable local/CI/container installs.
annotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
certifi==2026.7.22
click==8.5.0
cloudpickle==3.1.2
colorama==0.4.6
h11==0.16.0
httpcore==1.0.9
idna==3.20
iniconfig==2.3.1
narwhals==2.26.0
opentelemetry-api==1.45.1
packaging==26.3
pluggy==1.6.0
pydantic_core==2.46.5
Pygments==2.21.0
python-dateutil==2.9.0.post0
scipy==1.18.1
six==1.17.0
starlette==1.7.0
threadpoolctl==3.7.0
typing-inspection==0.4.4
typing_extensions==4.16.0
tzdata==2026.5
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
python -m pip checkCheck before continuing
Step 2: Use a checksum-verified churn dataset
The service uses tenure, MonthlyCharges, TotalCharges, Contract, PaymentMethod, InternetService, OnlineSecurity and TechSupport. The customer identifier and the churn target never enter the feature pipeline.
Check before continuing
Step 3: Save preprocessing and classifier as one pipeline
numeric = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("encode", OneHotEncoder(handle_unknown="ignore")),
])
prepare = ColumnTransformer([
("numeric", numeric, NUMERIC),
("categorical", categorical, list(CATEGORIES)),
])
pipeline = Pipeline([
("prepare", prepare),
("classifier", LogisticRegression(
C=1.0,
max_iter=2000,
random_state=42,
)),
])If preprocessing lived separately in the API, training and serving could silently disagree. Saving one fitted Pipeline means the exact imputation, scaling, encoding and classifier used at training time are reused at inference time.
Open complete src/train.py
"""Offline-only training: python -m src.train --version v1."""
import argparse
from datetime import datetime, timezone
import hashlib
from io import BytesIO
from importlib.metadata import version as package_version
import json
from pathlib import Path
import platform
import tempfile
import joblib
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from src.contract import ROOT, SEED, NUMERIC, CATEGORIES, FEATURES, DATA_PATH, DATA_SHA256, DATA_URL, DATA_REVISION, validate_version
def read_dataset(path: Path = DATA_PATH) -> pd.DataFrame:
content = path.read_bytes()
if hashlib.sha256(content).hexdigest() != DATA_SHA256:
raise ValueError("Dataset checksum mismatch; run python -m scripts.prepare_data.")
frame = pd.read_csv(BytesIO(content))
if frame.shape != (7043, 21) or not frame.customerID.is_unique:
raise ValueError("Expected the original 7,043-row, 21-column IBM sample.")
frame["TotalCharges"] = pd.to_numeric(frame.TotalCharges.replace(r"^\s*$", np.nan, regex=True), errors="raise")
if set(frame.Churn.unique()) != {"No", "Yes"}:
raise ValueError("Unexpected target values.")
for field, categories in CATEGORIES.items():
if not set(frame[field].unique()).issubset(categories):
raise ValueError(f"Unexpected category in {field}.")
return frame
def split_data(frame):
return train_test_split(frame[FEATURES], frame.Churn.eq("Yes").astype(int),
test_size=0.2, stratify=frame.Churn, random_state=SEED)
def make_pipeline(c=1.0):
numeric = Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())])
categorical = Pipeline([("impute", SimpleImputer(strategy="most_frequent")),
("encode", OneHotEncoder(handle_unknown="ignore"))])
prepare = ColumnTransformer([("numeric", numeric, NUMERIC), ("categorical", categorical, list(CATEGORIES))])
return Pipeline([("prepare", prepare), ("classifier", LogisticRegression(C=c, max_iter=2000, random_state=SEED))])
def train(model_version="v1", models_dir: Path = ROOT / "models", c=1.0):
validate_version(model_version)
target = models_dir / model_version
if target.exists():
raise FileExistsError(f"{model_version} already exists. Versions are immutable; choose a new version.")
X_train, X_test, y_train, y_test = split_data(read_dataset())
pipeline = make_pipeline(c)
pipeline.fit(X_train, y_train)
probability = pipeline.predict_proba(X_test)[:, list(pipeline.classes_).index(1)]
prediction = (probability >= 0.5).astype(int)
metrics = {
"accuracy": accuracy_score(y_test, prediction),
"precision": precision_score(y_test, prediction, zero_division=0),
"recall": recall_score(y_test, prediction, zero_division=0),
"f1": f1_score(y_test, prediction, zero_division=0),
"roc_auc": roc_auc_score(y_test, probability),
"confusion_matrix": confusion_matrix(y_test, prediction, labels=[0, 1]).tolist(),
"majority_accuracy": float((y_test == y_train.mode().iloc[0]).mean()),
}
metadata = {
"schema_version": 1, "model_version": model_version,
"trained_at": datetime.now(timezone.utc).isoformat(),
"algorithm": "LogisticRegression", "parameters": {"C": c, "max_iter": 2000},
"features": FEATURES, "categories": CATEGORIES, "threshold": 0.5,
"training_rows": len(X_train), "test_rows": len(X_test), "seed": SEED,
"dataset": {"id": "IBM Telco Customer Churn", "source": DATA_URL, "revision": DATA_REVISION, "sha256": DATA_SHA256},
"metrics": metrics, "python_version": platform.python_version(),
"packages": {name: package_version(name) for name in ["numpy", "pandas", "scikit-learn", "joblib"]},
}
models_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".training-", dir=models_dir) as temporary:
stage = Path(temporary)
joblib.dump(pipeline, stage / "model.joblib")
metadata["artifact_sha256"] = hashlib.sha256((stage / "model.joblib").read_bytes()).hexdigest()
(stage / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
reloaded = joblib.load(stage / "model.joblib") # Only this process's trusted output.
np.testing.assert_allclose(reloaded.predict_proba(X_test), pipeline.predict_proba(X_test), rtol=0, atol=0)
# Rename an already complete directory; readers never see a half-written version.
stage.rename(target)
print(json.dumps(metadata, indent=2))
return metadata
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--version", default="v1")
parser.add_argument("--c", type=float, default=1.0, help="Logistic Regression inverse regularization strength")
arguments = parser.parse_args()
if not np.isfinite(arguments.c) or arguments.c <= 0:
parser.error("--c must be finite and positive")
train(arguments.version, c=arguments.c)
Check before continuing
Step 4: Measure the model before serving it
v1 holdout confusion matrix
The majority-class accuracy is about 73.46%, so 79.28% is better than always predicting “stay”. But this project does not pretend the model is perfect: recall near 53.5% means many churners are still missed.
Check before continuing
Step 5: Make model artifacts versioned and self-describing
Training writes models/v1/model.joblib plus metadata.json. Metadata records the algorithm, feature contract, training timestamp, dataset fingerprint, package versions, metrics and the model-file SHA-256. Versions are immutable: training refuses to overwrite an existing version.
Check before continuing
Step 6: Define a strict API input contract with Pydantic
Open complete api/schemas.py
"""Strict public contract: required fields, no silent string/bool coercion."""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class Customer(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid", allow_inf_nan=False)
tenure: int = Field(ge=0, le=120, description="Months; training sample spans 0–72.")
MonthlyCharges: float = Field(ge=0, le=500)
TotalCharges: float | None = Field(ge=0, le=50000, description="Required key; null means unknown and uses the training median.")
Contract: Literal["Month-to-month", "One year", "Two year"]
PaymentMethod: Literal["Electronic check", "Mailed check", "Bank transfer (automatic)", "Credit card (automatic)"]
InternetService: Literal["DSL", "Fiber optic", "No"]
OnlineSecurity: Literal["Yes", "No", "No internet service"]
TechSupport: Literal["Yes", "No", "No internet service"]
@model_validator(mode="after")
def consistent_services(self):
for field in ("OnlineSecurity", "TechSupport"):
if (self.InternetService == "No") != (getattr(self, field) == "No internet service"):
raise ValueError(f"{field} must agree with InternetService.")
return self
class Prediction(BaseModel):
predicted_class: Literal["stay", "churn"]
churn_probability: float = Field(ge=0, le=1)
model_version: str
inference_latency_ms: float = Field(ge=0)
Real invalid request → HTTP 422
Check before continuing
Step 7: Expose health, model information and prediction endpoints
Open complete api/main.py
"""python -m uvicorn api.main:app --host 127.0.0.1 --port 8000"""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from api.schemas import Customer, Prediction
from src.contract import ROOT
from src.model_loader import load_active
from src.predict import predict
from src.logging_utils import log_event
def create_app(config_path: Path = ROOT / "config" / "model.json", models_dir: Path = ROOT / "models"):
@asynccontextmanager
async def lifespan(app):
# No artifact paths/versions are accepted from HTTP clients.
try:
app.state.model = load_active(config_path, models_dir)
except Exception as error:
log_event("model_load", "failed", error_type=type(error).__name__)
raise
log_event("model_load", "ready", app.state.model.metadata.model_version)
yield
app.state.model = None
application = FastAPI(title="Customer Churn Prediction Service", version="1.0.0", lifespan=lifespan)
@application.exception_handler(RequestValidationError)
async def invalid_request(request, error):
log_event("request_validation", "rejected", application.state.model.metadata.model_version, status_code=422)
# Keep locations/messages useful without echoing customer values in errors.
details = [{"loc": list(item["loc"]), "msg": item["msg"], "type": item["type"]}
for item in error.errors()]
return JSONResponse(status_code=422, content={"detail": details})
@application.get("/health")
def health():
return {"status": "ok", "model_loaded": True, "active_version": application.state.model.metadata.model_version}
@application.get("/model-info")
def model_info():
metadata = application.state.model.metadata
return {"active_version": metadata.model_version, "algorithm": metadata.algorithm,
"trained_at": metadata.trained_at, "evaluation_metrics": metadata.metrics.model_dump(),
"threshold": metadata.threshold, "feature_schema": Customer.model_json_schema()}
@application.post("/predict", response_model=Prediction)
def predict_customer(customer: Customer):
loaded = application.state.model
try:
result = predict(customer, loaded)
except Exception as error:
log_event("inference", "failed", loaded.metadata.model_version, error_type=type(error).__name__, status_code=500)
return JSONResponse(status_code=500, content={"detail": "Inference failed; consult operator logs."})
log_event("inference", "ok", result.model_version, inference_latency_ms=result.inference_latency_ms, status_code=200)
return result
return application
app = create_app()
python -m uvicorn api.main:app --host 127.0.0.1 --port 8000Real interactive API documentation
GET /health
GET /model-info
Check before continuing
Step 8: Perform inference without retraining per request
def predict(customer: Customer, loaded: LoadedModel) -> Prediction:
start = perf_counter()
frame = pd.DataFrame(
[customer.model_dump()],
columns=FEATURES,
)
probability = float(
loaded.pipeline.predict_proba(frame)[0, 1]
)
label = "churn" if probability >= 0.5 else "stay"
return Prediction(
predicted_class=label,
churn_probability=probability,
model_version=loaded.metadata.model_version,
inference_latency_ms=(perf_counter() - start) * 1000,
)Real POST /predict request and response
The latency timer covers dataframe construction, preprocessing, probability prediction and class selection. It does not include network travel, request validation, response serialization or startup model loading.
Check before continuing
Step 9: Test failure paths, not only the happy prediction
Production engineering is largely about proving what happens when things go wrong. Tests reject missing fields, wrong types, unknown categories, corrupt metadata, bad hashes, unsafe version names, failed switches and malformed drift batches.
Open API tests
from fastapi.testclient import TestClient
import pytest
from api.main import create_app
from src.model_loader import ModelLoadError
def test_health_info_and_real_prediction(artifacts, configuration, customer):
with TestClient(create_app(configuration, artifacts)) as client:
assert client.get("/health").json() == {"status": "ok", "model_loaded": True, "active_version": "v1"}
info = client.get("/model-info").json()
assert info["algorithm"] == "LogisticRegression"
assert "TotalCharges" in info["feature_schema"]["required"]
response = client.post("/predict", json=customer)
assert response.status_code == 200
result = response.json()
assert result["model_version"] == "v1"
assert result["predicted_class"] in {"stay", "churn"}
assert 0 <= result["churn_probability"] <= 1
assert result["inference_latency_ms"] >= 0
assert client.post("/predict", json={**customer, "TotalCharges": None}).status_code == 200
@pytest.mark.parametrize("field,value", [
("tenure", -1), ("tenure", 121), ("tenure", "12"), ("tenure", True),
("MonthlyCharges", -1), ("MonthlyCharges", 501), ("MonthlyCharges", "70"),
("TotalCharges", -1), ("Contract", "Forever"), ("PaymentMethod", "cash"),
("InternetService", "Unknown"), ("OnlineSecurity", "Maybe"), ("TechSupport", "No internet service"),
])
def test_invalid_values_rejected(artifacts, configuration, customer, field, value):
with TestClient(create_app(configuration, artifacts)) as client:
response = client.post("/predict", json={**customer, field: value})
assert response.status_code == 422
assert response.json()["detail"]
def test_missing_and_extra_fields_rejected(artifacts, configuration, customer):
with TestClient(create_app(configuration, artifacts)) as client:
del customer["tenure"]
assert client.post("/predict", json=customer).status_code == 422
assert client.post("/predict", json={**customer, "tenure": 12, "customer_id": "private"}).status_code == 422
def test_missing_model_fails_startup(configuration, tmp_path):
with pytest.raises(ModelLoadError, match="missing"):
with TestClient(create_app(configuration, tmp_path)):
pass
python -m pytest -q
46 passed, 1 documented HTTPX deprecation warningCheck before continuing
Step 10: Detect when production inputs stop looking like training data
The project stores training-only reference statistics. Numeric fields compare mean shift in units of training standard deviation; categorical fields use total-variation distance. These thresholds are explicit educational heuristics.
Open complete src/drift.py
"""Educational feature-shift checks, not a retraining or performance decision."""
import argparse
import json
from pathlib import Path
import numpy as np
import pandas as pd
from src.contract import NUMERIC, CATEGORIES, FEATURES, ROOT, validate_version
def reference_statistics(training: pd.DataFrame):
return {
"schema_version": 1, "training_rows": len(training),
"numeric": {name: {"mean": float(training[name].mean()),
"std": float(training[name].std(ddof=0)),
"missing_fraction": float(training[name].isna().mean())} for name in NUMERIC},
"categorical": {name: {str(k): float(v) for k, v in training[name].value_counts(normalize=True).items()}
for name in CATEGORIES},
}
def check_drift(batch: pd.DataFrame, reference: dict):
if len(batch) < 50:
raise ValueError("Use at least 50 rows; tiny batches make distribution comparisons unstable.")
if batch.columns.tolist() != FEATURES or reference.get("schema_version") != 1:
raise ValueError("Batch/reference feature contract mismatch.")
results = {}
for name in NUMERIC:
values = pd.to_numeric(batch[name], errors="raise")
nonmissing = values.dropna().to_numpy(dtype=float)
if len(nonmissing) == 0 or not np.isfinite(nonmissing).all():
raise ValueError(f"{name} needs finite observed values.")
baseline = reference["numeric"][name]
change = abs(float(values.mean()) - baseline["mean"])
# Constant-reference columns use an explicit equality test, not division by zero.
scale = baseline["std"]
shift = change / scale if scale > 0 else (0.0 if change == 0 else None)
missing_change = abs(float(values.isna().mean()) - baseline["missing_fraction"])
status = "DRIFT DETECTED" if shift is None or shift >= 0.5 or missing_change >= 0.1 else "OK"
results[name] = {"status": status, "absolute_mean_shift_in_training_std": shift,
"missing_fraction_change": missing_change}
for name, categories in CATEGORIES.items():
if batch[name].isna().any() or not set(batch[name]).issubset(categories):
raise ValueError(f"Unexpected or missing category in {name}.")
observed = batch[name].value_counts(normalize=True).to_dict()
expected = reference["categorical"][name]
total_variation = 0.5 * sum(abs(observed.get(category, 0) - expected.get(category, 0)) for category in categories)
results[name] = {"status": "WARNING" if total_variation >= 0.15 else "OK",
"total_variation_distance": total_variation}
return {"rows": len(batch), "features": results,
"action": "Investigate data quality and labeled performance; drift alone does not justify retraining."}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--version", default="v1")
parser.add_argument("--batch", type=Path, required=True)
args = parser.parse_args()
validate_version(args.version)
reference = json.loads((ROOT / "models" / args.version / "reference.json").read_text())
result = check_drift(pd.read_csv(args.batch)[FEATURES], reference)
for name, item in result["features"].items():
print(f"{name}: {item['status']}")
print(result["action"])
if __name__ == "__main__":
main()
Shifted batch: real drift evidence
Drift does not prove model quality got worse. The correct next action is to investigate data quality and collect fresh labels before deciding whether retraining is justified.
Check before continuing
Step 11: Create v2 to practice rollout—not to pretend every new model is better
v2 is different, but not better
python -m src.train --version v2 --c 0.5
python -m scripts.prepare_drift --version v2
python -m scripts.switch_model --version v2
# Restart API workers, verify v2, then roll back:
python -m scripts.switch_model --version v1Check before continuing
Step 12: Prove rollback restores the exact old behavior
Open operator-only switching code
"""Operator-only version selection; restart the API after a successful switch."""
import argparse
import json
import os
from pathlib import Path
import tempfile
from src.contract import ROOT, validate_version
from src.model_loader import load_version
from src.logging_utils import log_event
def switch(version: str, config_path: Path = ROOT / "config" / "model.json", models_dir: Path = ROOT / "models"):
validate_version(version)
load_version(version, models_dir) # Reject missing/invalid targets before altering config.
content = json.dumps({"active_version": version}, indent=2) + "\n"
temporary = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=config_path.parent, delete=False) as file:
temporary = Path(file.name)
file.write(content)
file.flush()
os.fsync(file.fileno())
os.replace(temporary, config_path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
log_event("model_configuration", "selected_restart_required", version)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--version", required=True)
switch(parser.parse_args().version)
print("Configuration updated. Restart every API worker/container to load this version.")
Open the rollback test
import json
from fastapi.testclient import TestClient
import pytest
from api.main import create_app
from scripts.switch_model import switch
from src.model_loader import ModelLoadError
from src.train import train
def test_switch_requires_restart_and_rollback_restores_predictions(artifacts, configuration, customer):
train("v2", artifacts, c=0.5)
with TestClient(create_app(configuration, artifacts)) as old_worker:
first = old_worker.post("/predict", json=customer).json()
switch("v2", configuration, artifacts)
assert old_worker.get("/health").json()["active_version"] == "v1"
with TestClient(create_app(configuration, artifacts)) as new_worker:
assert new_worker.get("/model-info").json()["active_version"] == "v2"
second = new_worker.post("/predict", json=customer).json()
assert second["model_version"] == "v2"
assert second["churn_probability"] != first["churn_probability"]
switch("v1", configuration, artifacts)
with TestClient(create_app(configuration, artifacts)) as rollback_worker:
third = rollback_worker.post("/predict", json=customer).json()
assert third["model_version"] == "v1"
assert third["churn_probability"] == first["churn_probability"]
assert third["predicted_class"] == first["predicted_class"]
def test_failed_switch_keeps_existing_configuration(artifacts, configuration):
before = configuration.read_bytes()
with pytest.raises(ModelLoadError):
switch("v999", configuration, artifacts)
assert configuration.read_bytes() == before
assert not list(configuration.parent.glob("tmp*"))
v1 probability: 0.650994284370...
v2 probability: 0.651233111317...
rollback v1 probability: 0.650994284370...The active version is loaded once when an API worker starts. Changing model.json does not magically replace the model inside an already-running process; workers/containers must be restarted in this educational design.
Check before continuing
Step 13: Package only what inference needs into a non-root Docker image
Open Dockerfile
FROM python:3.13.16-slim-bookworm@sha256:a1165e272e578941b84abc79e4ab38a0305cd12803a5c4247979ac7655f4d641
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-cache-dir --no-compile -r requirements.txt \
&& python -m pip check \
&& useradd --create-home --uid 10001 appuser
# No raw data, training scripts, test suite, credentials, or caches in the image.
COPY api/ ./api/
COPY src/__init__.py src/contract.py src/model_loader.py src/predict.py src/logging_utils.py ./src/
COPY --chown=10001:10001 config/ ./config/
# Training publishes a private temporary directory; give the runtime user access.
COPY --chown=10001:10001 models/ ./models/
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
CMD python -c "import json,urllib.request; r=json.load(urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=2)); assert r['status']=='ok' and r['model_loaded'] is True" || exit 1
CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]
docker build -t churn-service:local .
docker run --rm --name churn-local --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges -p 127.0.0.1:8000:8000 churn-service:localRaw training data and training scripts are not copied into the runtime image. The verified CI container ran with a read-only filesystem, dropped Linux capabilities, used the application health check and served real requests as a non-root user.
Check before continuing
Step 14: Make GitHub Actions prove the release path
Latest Codex engineering verification: GitHub Actions run 37776763301. Docker was unavailable in the local Windows environment, so the Linux build and runtime claims come from CI where Docker actually executed.
Check before continuing
Step 15: Understand the production project folder
├── api/ # FastAPI + Pydantic contract
├── config/model.json # active version
├── data/ # downloaded locally; not shipped in image
├── models/v1/ + models/v2/ # generated immutable versions
├── scripts/ # data, drift, switch, smoke checks
├── src/ # training, loading, inference, drift, logs
├── tests/ # 46 behavioral tests
├── Dockerfile
├── requirements.txt
└── README.md
Check before continuing
Troubleshooting checkpoints
API will not start: inspect the model-load error first. Missing files, checksum mismatch, incompatible package versions and contract mismatches intentionally fail startup.
422 from /predict: read the field location and validation message. Do not weaken strict validation merely to make malformed requests pass.
Switched to v2 but /health still reports v1: restart that worker/container. The model is intentionally loaded once per process lifecycle.
Drift checker fires: investigate the input pipeline and collect fresh labeled outcomes. Do not trigger automatic retraining from drift alone.
Docker is unavailable locally: do not claim local verification. Use the exact CI evidence where the image was built and run.
How to explain this project in an interview
Why save preprocessing with the classifier?
Serving must apply the exact fitted transformations used in training; duplicating preprocessing in API code creates training-serving skew.
Why load the model once?
Model loading is startup work. Reloading or retraining on every request wastes latency and makes behavior unpredictable.
Why include model_version in every response?
It makes predictions traceable during releases, debugging and rollback.
Why can v2 be slightly worse?
The point of v2 here is release engineering. We do not keep testing the holdout until a new version appears better.
What does drift tell you?
That current inputs differ from the training reference under configured heuristics. It does not by itself prove accuracy has fallen.
Why is rollback tested?
A rollback plan that has never restored actual old predictions is documentation, not operational evidence.







