ML Engineer26h

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.

Stage 1 of 8, 3h

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

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.

Stage 2 of 8, 3h

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

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()
Keep this in the test suite, not in a notebook cell. It is the assertion most likely to catch a real mistake six weeks from now.

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.

Stage 3 of 8, 2h

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

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.

Stage 4 of 8, 4h

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

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.

Stage 5 of 8, 4h

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

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])
On a 3% base rate with this cost ratio the chosen threshold is usually somewhere near 0.08, and the saving against 0.5 is large.

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.

Stage 6 of 8, 4h

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

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.

Stage 7 of 8, 3h

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

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.

Stage 8 of 8, 3h

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

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