Take a model from a table to an endpoint
One classical supervised model carried through the whole lifecycle — messy table, honest split, defended metric, tuned model, served endpoint, drift monitor — with no deep learning anywhere.
Most ML engineering jobs are gradient boosting on a table that arrives dirty, served behind an endpoint that somebody has to keep working. Almost nobody builds that end to end before their first job, which is why interviews for these roles are full of candidates who can explain attention and cannot say why a random split is wrong when rows share a customer. This project puts you on the other side of that: you will have made the split decision, chosen the metric against a stated cost, and written down the number that triggers a retrain.
Have these behind you first
You can start without them and look things up as you go. It will take longer and you will understand less of what you typed.
Get a real table and find out what is wrong with it
You build: A loaded dataset with a written data report naming its size, its base rate and the specific defects you found.
If a line here loses you, this is where it is taught
- NumPy & pandas: pandas on a real table
Loading and interrogating a table that was not prepared for you, which is every table.
- Data quality & labelling: Finding and fixing bad data
The defects to look for specifically, rather than staring at df.describe() hoping something stands out.
- SQL & data access: Getting the data out
Most real tables arrive from a warehouse, and the extraction query is part of the pipeline you have to be able to rerun.
Pick a dataset with genuine mess in it and a target worth predicting: loan default, customer churn, hospital readmission, a Kaggle dataset that was not already cleaned for a tutorial. Avoid iris, titanic and anything with a canonical solved notebook, because the mess is the subject of this project and those have had it removed.
Before any modelling, find out what you have. Not a summary table you glance at — a written report you could hand to someone, because in a job that is exactly what it becomes.
import pandas as pd
df = pd.read_parquet("raw.parquet")
print(len(df), "rows", df["target"].mean().round(4), "base rate")
# Missingness by column, worst first. Anything above 5% is a decision you
# have to make and record, not a default to fill with zero.
missing = df.isna().mean().sort_values(ascending=False)
print(missing[missing > 0.05])
# Constant and near-constant columns: they carry no signal and will survive
# every feature selection step you write, wasting your time later.
for c in df.columns:
top = df[c].value_counts(normalize=True, dropna=False).iloc[0]
if top > 0.99:
print("near-constant:", c, round(top, 4))
# Duplicated rows, and duplicated entities with different targets, which is
# a labelling problem rather than a deduplication one.
print("exact duplicate rows:", df.duplicated().sum())
conflict = df.groupby("customer_id")["target"].nunique()
print("ids with conflicting labels:", (conflict > 1).sum())Find the columns that only exist after the outcome
Real tables are full of fields written by the process you are trying to predict: a collections_agent_id that is only populated for defaults, a closed_reason on churned accounts. Train on one and you get 0.99 AUC and a model that is useless in production, because the field is empty at prediction time. For every column, ask when its value is written relative to the moment you would make the prediction. Any column that fails that test goes in a list in the report and never enters the pipeline.
Done when
A one-page report giving the row count, the target's base rate, every column with more than 5% missing, and at least three concrete defects — each with the line of code that found it.
Build a split that does not lie
You build: A split function returning train, validation and test, with the leak it prevents asserted in a test.
If a line here loses you, this is where it is taught
- Overfitting & regularisation: Memorising instead of learning
What the validation set is for, and what it stops measuring the moment information crosses it.
- Time series: Data where order carries meaning
Why a temporal split is the only honest one when the data has a time axis, and how to size the gap.
- Data quality & labelling: Where ground truth comes from
When the label is written down relative to the features, which determines where the boundary can legally go.
This decision determines whether every number in the rest of the project means anything, and it takes fifteen minutes to get wrong and a fortnight to discover. Ask two questions of your table. Does a single entity appear in more than one row? Does the data span time, and will the model be used on data from after it was trained?
If yes to the first, split by group. If yes to the second, split by time. If yes to both, split by time and then check for groups that straddle the boundary.
from sklearn.model_selection import GroupShuffleSplit
def time_split(df, ts="applied_at", group="customer_id",
val_start="2024-07-01", test_start="2024-10-01"):
"Train on the past, validate on the next quarter, test on the one after."
train = df[df[ts] < val_start]
val = df[(df[ts] >= val_start) & (df[ts] < test_start)]
test = df[df[ts] >= test_start]
# A customer whose first loan is in train and second in test leaks: the
# model has seen that customer's behaviour and the outcome attached to it.
leaked = set(train[group]) & set(val[group]) | set(train[group]) & set(test[group])
train = train[~train[group].isin(leaked)]
return train, val, test
def test_split_does_not_leak():
train, val, test = time_split(df)
assert set(train["customer_id"]).isdisjoint(val["customer_id"])
assert set(train["customer_id"]).isdisjoint(test["customer_id"])
assert train["applied_at"].max() < val["applied_at"].min()
assert val["applied_at"].max() < test["applied_at"].min()A random split leaks whenever rows share anything
train_test_split with shuffle=True assumes rows are independent. They almost never are. One customer with forty transactions, one patient with six admissions, one sensor with a year of readings — shuffle those and the model memorises the entity, scores beautifully on validation, and collapses on customers it has never seen. If your validation score is far better than your first production week, this is the reason, and it is the single most common way a model gets shipped that should not have been.
Set the test set aside now and do not touch it until stage six. Not to check a hunch, not to see whether the feature helped. Every look at it turns it into a second validation set, and then you have no unbiased estimate left.
Done when
A test in your suite that fails if any group id or any timestamp appears on both sides of the boundary, and a sentence naming which split rule you used and why.
The baseline that ignores the features
You build: A score on the validation set from a model that uses no features at all, plus a score from logistic regression on the raw columns.
If a line here loses you, this is where it is taught
- Supervised learning: Learning a mapping from examples
What the model is actually fitting, which is what the trivial baseline strips away to leave a floor.
- Model evaluation & metrics: Choosing the metric that matches the cost of being wrong
Picking the provisional scoring function, so every later number is comparable to this one.
Predict the majority class, or the training mean for a regression. Score it. That number is the floor, and it is the number your gradient boosting model has to beat by enough to justify its existence.
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, roc_auc_score
def evaluate(model, X, y):
p = model.predict_proba(X)[:, 1]
return {"auc": round(roc_auc_score(y, p), 4),
"ap": round(average_precision_score(y, p), 4)}
dummy = DummyClassifier(strategy="prior").fit(X_train, y_train)
print("dummy ", evaluate(dummy, X_val, y_val))
# dummy {'auc': 0.5, 'ap': 0.031} <- average precision equals the base rate
logit = LogisticRegression(max_iter=1000).fit(X_train_numeric, y_train)
print("logistic", evaluate(logit, X_val_numeric, y_val))If the simple model is close, the project is nearly over
When logistic regression on six raw columns lands within a point or two of your tuned ensemble, the honest conclusion is that the signal is thin and the extra complexity is not paying for itself. Ship the simple model: it trains in a second, it explains itself to a regulator, and nothing about it breaks at 3am. Recognising this is worth more in an interview than another two points of AUC.
Done when
Two numbers on the validation set from the same scoring function you will use for everything else: a constant predictor, and a simple linear or logistic model. Both written into the README.
Engineer features inside a pipeline
You build: A single fitted object that goes from a raw row to a prediction, with every transform fitted on the training split only.
If a line here loses you, this is where it is taught
- Feature engineering: Shaping inputs so the model can use them
Shaping the inputs so the model can use them, and which transforms are worth the complexity they add.
- Feature engineering: Features for real tabular problems
The specific features that work on real tabular problems: aggregations, ratios, recency, cardinality handling.
- NumPy & pandas: Memory, views and larger-than-memory data
Chained assignment and silent copies are how a feature ends up computed on the wrong frame and nobody notices.
Everything that touches the data has to live inside the pipeline. The moment you compute a median in a notebook cell and use it to fill both splits, you have leaked the validation set into training — quietly, with no error, and in a way that makes your offline number better than production will be.
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.ensemble import HistGradientBoostingClassifier
numeric = ["amount", "term_months", "income", "days_since_last_application"]
categorical = ["product", "channel", "region"]
pre = ColumnTransformer([
("num", Pipeline([
# The median is learned from train only, because the Pipeline is
# fitted on train only. That is the whole reason for this structure.
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
]), numeric),
("cat", Pipeline([
("impute", SimpleImputer(strategy="constant", fill_value="MISSING")),
# Categories unseen at training time must not raise in production.
("onehot", OneHotEncoder(handle_unknown="infrequent_if_exist",
min_frequency=20)),
]), categorical),
])
pipe = Pipeline([("pre", pre),
("model", HistGradientBoostingClassifier(random_state=0))])
pipe.fit(X_train, y_train)
print(evaluate(pipe, X_val, y_val))For engineered features that depend on history — a customer's average order value, their count of prior applications — the rule is that every input to the feature must be dated before the prediction moment. Compute them with a cutoff, per row, and never with a groupby over the whole table.
An aggregate over the whole table is a time machine
df.groupby("customer_id")["amount"].mean() includes the row you are predicting and every row after it. In training that is a feature containing the future; in production the future has not happened and the feature is a different quantity entirely. The model will be excellent offline and worthless live. If one feature dominates your importances and you cannot state exactly when its value becomes knowable, assume this until you have proved otherwise.
Done when
One Pipeline object, saved to disk, that takes a raw dataframe and returns a probability; and a validation score that beats the trivial baseline by more than the spread of five different random seeds.
Choose the metric, then choose the threshold
You build: A written cost for each kind of error, the metric that follows from it, and an operating threshold chosen against that cost rather than left at 0.5.
If a line here loses you, this is where it is taught
- Model evaluation & metrics: Choosing a metric: four worked cases
Four worked cases of picking a metric from a stated cost, which is what this stage asks you to do for your own problem.
- Imbalanced and messy data: When one class is rare
What changes when the positive class is 3% of rows, and which of the standard fixes are worth applying.
- Imbalanced and messy data: Metrics and thresholds, worked through
Turning a probability into a decision, which is where the metric argument becomes an actual number in production.
Write the cost sentence before you look at any more scores. "A false positive costs us a 12 pound manual review. A false negative costs us the 800 pound loan." Once that exists, the metric argument stops being a preference and becomes arithmetic: those numbers say recall matters roughly sixty times more than precision, and they say what threshold to use.
import numpy as np
from sklearn.metrics import precision_recall_curve
COST_FP = 12 # a manual review
COST_FN = 800 # the loss we did not prevent
p = pipe.predict_proba(X_val)[:, 1]
thresholds = np.linspace(0.01, 0.99, 99)
costs = []
for t in thresholds:
pred = p >= t
fp = int(((pred == 1) & (y_val == 0)).sum())
fn = int(((pred == 0) & (y_val == 1)).sum())
costs.append(fp * COST_FP + fn * COST_FN)
best = thresholds[int(np.argmin(costs))]
print("threshold", round(best, 3), "expected cost", min(costs))
print("cost at the default 0.5:", costs[49])For a rare positive class, report average precision rather than ROC AUC. ROC AUC is computed against the true negative count, which is enormous, so it stays comfortable while precision at your operating point is terrible. Average precision moves when the thing you care about moves.
Accuracy on imbalanced data is the loudest lie in ML
At a 3% base rate, predicting "no" for everybody is 97% accurate and catches nothing. Every interviewer knows this and a surprising number of candidates still quote accuracy. Worse, the same trap operates quietly inside a tuning loop: optimise accuracy and the search happily converges on a model that has learned to predict the majority class, then reports a number that looks like success.
On reweighting and resampling: try class_weight="balanced" first because it costs one argument, and be sceptical of SMOTE. Synthetic minority oversampling changes the probabilities the model outputs, so anything downstream that treats them as probabilities is now wrong, and on most real problems it does not beat threshold tuning. If you use it, resample inside the cross-validation fold or you have leaked again.
Done when
One sentence naming the cost of a false positive and a false negative in the units of the problem, the metric chosen because of it, and a threshold picked on the validation set by minimising expected cost — with the cost curve plotted.
Tune it, then stop tuning it
You build: A logged search over hyperparameters, a chosen configuration, and a single test-set number produced once.
If a line here loses you, this is where it is taught
- Hyperparameter tuning: Tuning, in order of what matters
Which parameters to search and in what order, so thirty runs land somewhere rather than wandering.
- Experiment tracking: Knowing which run produced which result
Attaching the config to the score, so the model you ship is provably the one that produced the number you reported.
- Overfitting & regularisation: Regularisation and validation, hands on
Reading the gap between training and validation score and knowing which knob closes it.
Random search over a sensible range beats grid search at the same budget, because most hyperparameters do not matter and grid search spends its budget resolving them anyway. For gradient boosting, learning rate and the number of trees interact and everything else is second-order.
import itertools, json, random
from sklearn.base import clone
space = {
"model__learning_rate": [0.01, 0.03, 0.1, 0.3],
"model__max_leaf_nodes": [15, 31, 63, 127],
"model__min_samples_leaf": [10, 20, 50, 100],
"model__l2_regularization": [0.0, 0.1, 1.0],
}
rng = random.Random(0)
combos = list(itertools.product(*space.values()))
rng.shuffle(combos)
results = []
for combo in combos[:30]:
params = dict(zip(space.keys(), combo))
m = clone(pipe).set_params(**params).fit(X_train, y_train)
row = {"params": params,
"train": evaluate(m, X_train, y_train)["ap"],
"val": evaluate(m, X_val, y_val)["ap"]}
results.append(row)
print(json.dumps(row)) # or log it to MLflow / Weights and Biases
best = max(results, key=lambda r: r["val"])
print("best", best)
# A large train-val gap here is overfitting: fewer leaves, more
# min_samples_leaf, more l2 - in that order.The test set is used once, at the end, on one model
Refit the chosen configuration on train plus validation, evaluate on test, write the number down, and stop. If you evaluate twenty candidates on test and pick the best, the test score is now an optimistic estimate of a model you selected using it, and you have no honest number left to report. There is no undo for this: the only fix is a fresh holdout you have not looked at.
Done when
At least thirty configurations logged with their validation scores and their config, the chosen one refitted on train plus validation, and exactly one test-set evaluation in your git history.
Put it behind an endpoint
You build: A containerised HTTP service that takes a JSON row and returns a prediction and the model version.
If a line here loses you, this is where it is taught
- MLOps & model lifecycle: Serving a model: batch, online, shadow
Whether this model wants batch, online or shadow serving, which is a decision before it is an implementation.
- Deployment & serving: Running an AI service
The shape of the service around the model: health checks, versioning, and what happens under load.
- Containers: A Dockerfile that builds fast and runs safely
Pinning the environment, because a scikit-learn version bump can change predictions without changing your code.
The pipeline object is the whole artefact: it carries the imputers, the encoder and the model together, so the service does no feature engineering of its own. Any preprocessing that lives in the API code rather than the pipeline is a training-serving skew waiting to happen.
import joblib, pandas as pd
from fastapi import FastAPI
from pydantic import BaseModel
MODEL_VERSION = "2024-11-03-hgb-r0"
pipe = joblib.load("model.joblib")
THRESHOLD = 0.082 # chosen in the metric stage, not invented here
class Application(BaseModel):
amount: float
term_months: int
income: float | None = None # nullable: the imputer handles it
days_since_last_application: float | None = None
product: str
channel: str
region: str
app = FastAPI()
@app.get("/health")
def health():
return {"ok": True, "model_version": MODEL_VERSION}
@app.post("/predict")
def predict(a: Application):
row = pd.DataFrame([a.model_dump()])
p = float(pipe.predict_proba(row)[0, 1])
# Return the probability as well as the decision: downstream teams will
# want to move the threshold without redeploying the model.
return {"probability": p, "decision": p >= THRESHOLD,
"model_version": MODEL_VERSION}Pin the versions or the predictions drift on their own
A model pickled under scikit-learn 1.4 and loaded under 1.6 may load with a warning and predict differently, and joblib will not stop you. Pin exact versions in the image, and make the parity test — twenty known rows, identical outputs — part of the build. If that test fails after a dependency bump, you have caught a silent behaviour change that would otherwise have shipped.
Done when
A test that posts twenty rows from the test set to the running container over HTTP and gets back predictions identical to the offline ones, to the last decimal place.
Monitor it, and decide the retraining trigger
You build: A daily job that logs input distributions, prediction rates and label coverage, and a written trigger for retraining.
If a line here loses you, this is where it is taught
- MLOps & model lifecycle: Monitoring drift in practice
Which drift measures are worth computing and what threshold on them is actually actionable rather than noisy.
- MLOps & model lifecycle: Keeping a model working after launch
What happens to a model after launch, which is the part of the job this stage exists to rehearse.
- Model evaluation & metrics: Regression metrics and connecting to outcomes
Connecting the monitored metric to the cost you wrote down, so the trigger is defensible rather than arbitrary.
Log every prediction with its inputs, its output, the model version and a request id. Without that row you cannot compute anything on this page, and you cannot add it retrospectively.
Two things are worth watching, and they are not the same thing. Input drift tells you the world has moved and is available immediately. Performance drift tells you the model is wrong, and it arrives only when labels do — which for a loan may be ninety days. You need both, and you need to know which one your trigger is based on.
import numpy as np
def psi(reference: np.ndarray, current: np.ndarray, bins: int = 10) -> float:
"Population stability index. Below 0.1 is stable, above 0.25 is a real shift."
edges = np.quantile(reference, np.linspace(0, 1, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
r = np.histogram(reference, edges)[0] / len(reference)
c = np.histogram(current, edges)[0] / len(current)
eps = 1e-6 # empty bins otherwise give infinity
return float(np.sum((c - r) * np.log((c + eps) / (r + eps))))
daily = predictions_log[predictions_log["date"] == yesterday]
for col in numeric:
print(col, round(psi(X_train[col].to_numpy(), daily[col].to_numpy()), 3))
print("positive rate", round(daily["decision"].mean(), 4),
"vs training", round(y_train.mean(), 4))
print("null rate", daily[numeric].isna().mean().round(3).to_dict())Then write the trigger down, as a number, in the README. For example: retrain when PSI on any of the top five features exceeds 0.25 for three consecutive days, or when realised average precision on the last 1,000 labelled decisions falls more than 15% below the test-set figure, or after six months regardless. An alert with no stated action is something everyone learns to ignore within a fortnight.
A model with no monitoring is a model you find out about from a customer
Silent failure is the normal failure mode here. Nothing throws: an upstream team renames a category, the imputer quietly fills every row with the median, and the model returns confident nonsense for months. The cheapest guard is the dullest one — alert on the null rate per feature and on the positive prediction rate, both of which move within a day of an upstream change and neither of which needs a single label to compute.
Done when
A table or dashboard showing, per day: request count, a distance measure per feature against the training reference, the positive prediction rate, and realised performance where labels have arrived — plus a stated number that means retrain.
What you have at the end
A repository with a reproducible pipeline from raw table to prediction, a test that fails if the split leaks, a metric chosen against a written cost, a containerised endpoint whose predictions match the offline ones exactly, and a monitoring job with a stated retraining trigger. You can walk an interviewer through every decision and say what you would do differently with more data, which is the actual question behind most ML system design rounds.
The other projects