description: Treat feature engineering as an API contract: scaling, one-hot encoding, and avoiding train/test leakage before a model ships.¶
Week 6 — Features Are the Model’s API¶
Course: Applied ML Foundations for SaaS Analytics
Who this is for: Engineers who have designed request payloads. Feature engineering is that, plus a timeline rule.
🎯 What you will be able to do¶
- Treat a feature vector as a versioned contract the training job and the
/predicthandler must share - Scale numbers so “dollars” and “click counts” can sit in the same model
- One-hot encode
plan_typewithout treating free < starter < pro as a number line - Fit the scaler on train only — the leak that will follow you to production
- Draw a wall between “known at score time” and “the future”
Think of it like… an API contract + a time machine rule.
The model only sees the JSON you send it. If a field would not exist when you score a live user at noon on Tuesday, it cannot exist in training either. That is leakage: the model cheated on the exam by reading tomorrow’s answer key.
If you already write software¶
A feature vector is an API contract.
/predict accepts a JSON body. Training must build that same body from historical rows. If a field would not exist at noon on Tuesday when you score a live user, it cannot exist in the training table. That is leakage: the model read tomorrow’s answer key.
Training job Scoring service
──────────── ──────────────
row → features → model.fit request JSON → same features → model.predict
scaler.fit(X_train) scaler.transform(X_live) ← same scaler pickle
never touch X_test to fit never invent fields the client cannot send
The time-machine rule¶
Ask of every column: would I have known this at score time?
| Column | Known at score time? | Keep? |
|---|---|---|
plan_type, mrr, tenure_so_far |
yes | yes |
usage_last_30d |
yes, if you compute it from events before now | yes |
churn_date / is_churned |
that is the label | target, not a feature |
days_until_churn |
future | leak, delete |
avg_sentiment_after_cancel |
future | leak, delete |
Picture the scaler¶
StandardScaler subtracts the mean and divides by the std. If you fit it on train+test, test information leaked into the transform. It is the same bug as using production traffic to tune a cache key, then being surprised the benchmark looks good.
The scaler is part of the model. It ships in the same pickle. New data gets transform only.
Laptop budget
No GPU. Aimed at ~8 GB RAM. Training uses a few thousand sampled customers (or short sequences) so this week should finish in a few minutes on CPU. The ideas are the same if you later set n=None and train on all ~49k rows.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from pipelines.features import build_features
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
🏢 Scenario — churn features the scoring service can actually compute¶
We want to flag accounts that will cancel. At score time we know:
- plan, MRR, tenure so far, usage so far, events so far
We do not know churn_date. We must not sneak it in as has_churn_flag.
timeline: signup -------- now -------- churn?
▲
└── score time. Nothing to the right of this wall
may enter X. The label y may look right of the wall.
Engineer mental model
Features = request body. Scaler + encoder = middleware that must ship next to the .pkl. If production sends raw dollars and the model expects “standard deviations from the training mean,” every score is garbage and nobody gets a stack trace.
df = build_features(as_of="2024-06-01") # laptop sample is ~8k; n=None for the full extract
print(df.shape)
print(df[["user_id", "plan_type", "mrr", "tenure_so_far", "total_usage",
"features_adopted", "total_events", "is_churned"]].head())
print("\nLabel rate (ever-churned among people still around at as_of):",
df["is_churned"].mean().round(3))
print("That lifetime flag is still the wrong label — Week 8. The *features* are honest.")
Scaling — why trees shrug and linear models panic¶
mrr is 0–500. total_usage can be tens of thousands. A linear model / k-means / neural net adds these numbers. The big column shouts down the small one.
A tree only asks “is usage > 40?” — units do not matter.
Math, translated
StandardScaler: subtract the column’s mean, divide by its standard deviation. After that, “1” means “one typical-spread above average,” the same z-score idea from Week 1. log1p(usage) is “compress the whales so they do not own the axis.”
fig, axes = plt.subplots(1, 3, figsize=(12, 3.4))
axes[0].hist(df["total_usage"].clip(upper=np.percentile(df["total_usage"], 99)),
bins=30, color="#6366f1")
axes[0].set_title("Raw usage — whales squash the axis")
axes[1].hist(df["log_usage"], bins=30, color="#0f766e")
axes[1].set_title("log1p(usage) — readable shape")
# WRONG: scaler fit on everyone. We show it only to picture the shape.
demo = StandardScaler().fit_transform(df[["total_usage"]])
axes[2].hist(demo, bins=30, color="#f59e0b")
axes[2].set_title("StandardScaler(usage) — mean 0, still skewed")
for ax in axes:
ax.set_ylabel("users")
plt.tight_layout()
plt.show()
print("Trees: raw is fine. Linear / k-means / nets: log then scale, and fit on TRAIN only.")
Categories are not numbers¶
plan_type is free / starter / pro / enterprise. If you map those to 0,1,2,3 you are telling the model “enterprise is three more than free” and “the step from free→starter equals starter→pro.” Sometimes that is true. Usually it is a lie.
One-hot: four yes/no columns. Honest, a bit wide.
Watch out — the scaler leak
scaler.fit_transform(X) on the full table peeks at the test set’s mean and spread. That is a small leak that becomes a habit. Fit on train. Transform test. In production, the saved scaler is the fit.
numeric = ["mrr", "tenure_so_far", "log_usage", "features_adopted",
"total_events", "n_support"]
categorical = ["plan_type"]
label = "is_churned"
X = df[numeric + categorical]
y = df[label]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# Time-based split is even better (Week 15). Stratified random is the honest starter.
prep = ColumnTransformer(
transformers=[
("num", StandardScaler(), numeric),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical),
]
)
prep.fit(X_train) # train only
X_train_t = prep.transform(X_train)
X_test_t = prep.transform(X_test)
names = numeric + list(prep.named_transformers_["cat"].get_feature_names_out(categorical))
print("Train rows", X_train_t.shape, "Test rows", X_test_t.shape)
print("Feature contract:")
for n in names:
print(" ", n)
print("\nScaled train means (numeric should sit near 0):")
print(np.round(X_train_t[:, : len(numeric)].mean(axis=0), 3))
Leakage hall of shame (we will keep coming back)¶
| Looks clever | Why it is cheating |
|---|---|
has_churn_flag as a feature |
That is the label |
lifetime_value = mrr * tenure as a target, tenure as a feature |
The model multiplies two columns it was handed |
| Fit scaler / one-hot encoder on all rows | Test set leaked into preprocessing |
| Random split when the world is a time series | The model trains on “next month” and tests on “last month” |
Ship / don’t ship
A feature ships if a tired on-call engineer can compute it from today’s warehouses for a single user_id with no peek at the label table. If you cannot write that function, it is not a feature.
Email, name, ticket body, user_id, churn_date, and lifetime tenure_days do not go in X. pipelines/contract.py rejects unknown keys so PII cannot wander in. The one function that builds the row is pipelines.features.build_features(as_of=...) — Week 3 and Week 16 hang the job on it.
✍️ Exercise¶
When you can explain the week out loud, do the exercises. Starter: python exercises/ml/week-06/starter.py from the repo root.
🤔 Reflection¶
- Why is “churned in the next 30 days” a better label than “ever churned”?
- A teammate one-hot encodes
user_id. What happens? - Where does the scaler live in your repo — next to the model, or re-fit in the API process?
🔗 Next week¶
Classification: a model is a function features → risk score. We pick a threshold the sales team can staff.