churn-api / churn /validation.py
BrejBala's picture
deploy: customer-churn-mlops API Space (tier3-deployment)
482a2b0
Raw
History Blame Contribute Delete
5.8 kB
"""Pandera data-validation contracts for the Telco Customer Churn dataset.
Both the training contract (cleaned frame + target) and the serving contract
(19 feature columns, no target) derive from a single source of truth: the
``ALLOWED`` mapping of categorical feature → permitted value set.
Usage
-----
Training pipeline : ``validate_clean(df)`` — call after ``clean_telco()``
Serving / batch : ``validate_serving(df)`` — call before the scoring pipeline
"""
from __future__ import annotations
import pandas as pd
import pandera.pandas as pa
# ---------------------------------------------------------------------------
# Single source of truth — allowed categorical values
# ---------------------------------------------------------------------------
_YES_NO: frozenset[str] = frozenset({"No", "Yes"})
_YES_NO_NS: frozenset[str] = frozenset({"No", "No internet service", "Yes"})
_YES_NO_NPS: frozenset[str] = frozenset({"No", "No phone service", "Yes"})
# ALLOWED is the one place to update when the Telco vocabulary changes.
# TRAIN_SCHEMA and SERVE_SCHEMA both derive their isin() checks from it, so
# train and serve contracts can never silently diverge on categorical domains.
# The consistency test in tests/test_churn_validation.py asserts that each
# set here equals the Pydantic Literal in api/main.py::PredictRequest.
ALLOWED: dict[str, frozenset[str]] = {
"gender": frozenset({"Female", "Male"}),
"SeniorCitizen": frozenset({"0", "1"}), # str after clean_telco(); int at API boundary
"Partner": _YES_NO,
"Dependents": _YES_NO,
"PhoneService": _YES_NO,
"MultipleLines": _YES_NO_NPS,
"InternetService": frozenset({"DSL", "Fiber optic", "No"}),
"OnlineSecurity": _YES_NO_NS,
"OnlineBackup": _YES_NO_NS,
"DeviceProtection": _YES_NO_NS,
"TechSupport": _YES_NO_NS,
"StreamingTV": _YES_NO_NS,
"StreamingMovies": _YES_NO_NS,
"Contract": frozenset({"Month-to-month", "One year", "Two year"}),
"PaperlessBilling": _YES_NO,
"PaymentMethod": frozenset({
"Bank transfer (automatic)",
"Credit card (automatic)",
"Electronic check",
"Mailed check",
}),
}
# ---------------------------------------------------------------------------
# Schema builders — shared by both contracts
# ---------------------------------------------------------------------------
def _cat_col(allowed: frozenset[str]) -> pa.Column:
return pa.Column(dtype=str, checks=pa.Check.isin(list(allowed)), nullable=False)
def _feature_columns() -> dict[str, pa.Column]:
"""Build the 19 feature columns that appear in both training and serving schemas."""
cols: dict[str, pa.Column] = {
"tenure": pa.Column(
dtype=float,
checks=[pa.Check.ge(0), pa.Check.le(72)],
nullable=False,
),
"MonthlyCharges": pa.Column(
dtype=float,
checks=pa.Check.gt(0),
nullable=False,
),
"TotalCharges": pa.Column(
dtype=float,
checks=pa.Check.ge(0),
nullable=False,
),
}
for feature, allowed in ALLOWED.items():
cols[feature] = _cat_col(allowed)
return cols
# ---------------------------------------------------------------------------
# Exported schemas
# ---------------------------------------------------------------------------
_TRAIN_COLS: dict[str, pa.Column] = _feature_columns()
_TRAIN_COLS["Churn"] = pa.Column(dtype=int, checks=pa.Check.isin([0, 1]), nullable=False)
TRAIN_SCHEMA: pa.DataFrameSchema = pa.DataFrameSchema(
columns=_TRAIN_COLS,
coerce=False,
strict=False, # extra columns are allowed; all listed columns are required
)
SERVE_SCHEMA: pa.DataFrameSchema = pa.DataFrameSchema(
columns=_feature_columns(),
coerce=False,
strict=False,
)
# ---------------------------------------------------------------------------
# Error formatting
# ---------------------------------------------------------------------------
def _format_errors(exc: pa.errors.SchemaErrors) -> str:
try:
cols = exc.failure_cases["column"].dropna().unique().tolist()
detail = exc.failure_cases[["column", "check", "failure_case"]].to_string(index=False)
return f"column(s) {cols}:\n{detail}"
except Exception:
return str(exc)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def validate_clean(df: pd.DataFrame) -> pd.DataFrame:
"""Validate the post-``clean_telco`` DataFrame against the training contract.
Runs a lazy (collect-all-errors) Pandera check. On violation, raises
``ValueError`` naming every offending column and failure case. Returns
``df`` unchanged on success.
"""
try:
return TRAIN_SCHEMA.validate(df, lazy=True)
except pa.errors.SchemaErrors as exc:
raise ValueError(
f"validate_clean: {len(exc.failure_cases)} violation(s) in {_format_errors(exc)}"
) from exc
except pa.errors.SchemaError as exc:
raise ValueError(f"validate_clean: {exc}") from exc
def validate_serving(df: pd.DataFrame) -> pd.DataFrame:
"""Validate a serving-input DataFrame (19 features, no Churn target).
Reuses the same ``ALLOWED`` sets as the training contract. Suitable for
batch scoring and drift-monitoring pipelines.
"""
try:
return SERVE_SCHEMA.validate(df, lazy=True)
except pa.errors.SchemaErrors as exc:
raise ValueError(
f"validate_serving: {len(exc.failure_cases)} violation(s) in {_format_errors(exc)}"
) from exc
except pa.errors.SchemaError as exc:
raise ValueError(f"validate_serving: {exc}") from exc