Skip to content

The ML Lifecycle - From Data to Deployment

What you’ll learn

  • the eight stages, and which of them are loops rather than steps
  • bad data against bad algorithms, measured: 0.1142 either way
  • underfitting and overfitting as one number — the train/test gap
  • why one held-out set is not enough, and what the train-dev set diagnoses
  • the No Free Lunch theorem, demonstrated on four datasets where the winner keeps moving

The lifecycle

diagram Diagram mermaid

Two of those arrows matter more than the boxes. Evaluation loops back to data, not to model selection — that is where the measurement below points. And monitoring loops back to problem definition, because the most common discovery in production is that you optimised the wrong thing.

StageThe question it answersGets it wrong by
1. Problem definitionWhat decision does the prediction feed, and what does each error cost?Optimising a metric nobody uses
2. Data collectionIs this sample representative of production?Training on a population you will not serve
3. CleaningWhat is missing, wrong, duplicated, leaking?Silently destroying the signal
4. Feature engineeringWhat representation makes the pattern learnable?Leaving the ceiling low
5. TrainingWhich model family, which hyperparameters?Overthinking the smallest lever
6. EvaluationWill this hold up on data it has never seen?Tuning on the test set
7. DeploymentCan it run at the required latency and cost?Shipping a 1 GB artefact to an edge device
8. MonitoringIs the world still the shape it was?Silent decay

Bad data or bad algorithm?

Every practitioner eventually asks: is my model underperforming because the algorithm is wrong, or because the data is bad? It is worth putting numbers on both.

Same 1,200-row dataset, four algorithms, three data conditions:

ModelClean, n=120015% of labels flippedClean but n=150
Logistic regression0.78920.70750.7600
RBF SVM0.90330.78920.8267
Random forest0.88830.77830.8200
Gradient boosting0.89920.75330.7467

Three quantities fall out:

  • Swapping the algorithm on clean data: 0.7892 → 0.9033, a spread of 0.1142.
  • Flipping 15% of the labels, best model to best model: 0.9033 → 0.7892, a cost of 0.1142.
  • Cutting the data 8×, best model to best model: 0.9033 → 0.8267, a cost of 0.0767.
figureTwo levers, the same sizematplotlib
A grouped bar chart of four algorithms under three data conditions. Green bars for clean data are highest, red bars for flipped labels lowest, amber bars for small data in between.A grouped bar chart of four algorithms under three data conditions. Green bars for clean data are highest, red bars for flipped labels lowest, amber bars for small data in between.
The algorithm spread on clean data is 0.1142, from logistic regression at 0.7892 to the RBF SVM at 0.9033. Corrupting 15% of the labels costs the best model exactly the same 0.1142. Neither lever dominates — and only one of them is usually the one people reach for.

The popular slogan is “data beats algorithms”. The measurement says something more useful: the two levers are comparable in size, and you cannot substitute one for the other. The best algorithm on corrupted labels (0.7892) is no better than the worst algorithm on clean data (0.7892) — exactly equal, which is a coincidence, but an instructive one.

The practical consequence: if your model is underperforming, look at the data first — not because data always matters more, but because a data problem cannot be fixed by any algorithm, while an algorithm problem takes ten minutes to test.

Underfitting and overfitting

The two failure modes of stage 5, and one number distinguishes them: the gap between training and held-out performance.

Decision tree depthTrainHeld outGap
10.80000.71900.0810
20.90000.83810.0619
40.91280.86670.0462
60.95130.85710.0941
100.98970.83810.1516
201.00000.81900.1810
figureMore capacity always helps the training setmatplotlib
Two curves against tree depth. The blue training curve rises to 1.0 and stays there; the amber held-out curve peaks at depth 4 and then declines, with the widening gap shaded red.Two curves against tree depth. The blue training curve rises to 1.0 and stays there; the amber held-out curve peaks at depth 4 and then declines, with the widening gap shaded red.
Training accuracy is monotone in depth and reaches a perfect 1.0000 — which is exactly why it tells you nothing. Held-out accuracy peaks at 0.8667 at depth 4 and falls to 0.8190 by depth 20. The shaded region is the overfitting.

Underfitting is a low training score. The model is not flexible enough to capture the pattern even on data it has seen. Depth 1 above: 0.8000 on training.

Overfitting is a large gap. The model captured noise specific to the training rows. Depth 20: train 1.0000, held out 0.8190, gap 0.1810.

See it move

sketch Overfitting vs underfitting p5.js
The grey points jitter with noise. The blue underfit line barely reacts, the gold good-fit curve ignores the noise entirely, and the pink overfit curve writhes as it chases every wiggle. Click to reshuffle the noise pattern.

The pink curve is the point. It passes closer to every training point than the gold one does — its training error is genuinely lower — and it is wrong about the underlying shape. Watch it thrash while the gold curve, which describes the actual pattern, does not move at all.

Read the two numbers together and the diagnosis is immediate:

TrainHeld outDiagnosisFix
LowLowUnderfittingMore capacity, better features
HighLowOverfittingRegularise, simplify, more data
HighHighWorkingShip it
LowHighSomething is brokenCheck for a leak or a bad split

That last row is not a joke — it usually means the test set is easier than the training set, or the two have been swapped.

One held-out set is not enough

You split off a test set, tune your model, and report the test score. The problem: every tuning decision you made was informed by that test score, so it is no longer held out. You have leaked, a little, thirty times.

The standard fix is three splits: train, validation (tune here), and test (touch once). Phase 7 replaces the validation set with cross-validation, which is better still.

The train-dev set

There is a subtler failure the three-way split cannot diagnose. Suppose you train an image classifier on web photos, but production is phone photos. Your validation and test sets are phone photos — correctly, since that is what you will serve. Training score 0.98, validation score 0.83.

Is that overfitting, or is it that phone photos are simply different from web photos? The split cannot tell you, and the two problems have opposite fixes: overfitting wants regularisation, data mismatch wants different training data.

Andrew Ng’s train-dev set resolves it. Hold out a slice of the training distribution — web photos the model never trained on — and score that too:

figureThe train-dev set tells you WHICH problem you havematplotlib
Four labelled blocks in a row — train and train-dev drawn from web photos, dev and test drawn from phone photos — with three double-headed arrows underneath naming variance, data mismatch and dev-set overfitting.Four labelled blocks in a row — train and train-dev drawn from web photos, dev and test drawn from phone photos — with three double-headed arrows underneath naming variance, data mismatch and dev-set overfitting.
Each gap has one meaning. Train to train-dev is variance, because both are the same distribution. Train-dev to dev is data mismatch, because only the distribution changed. Dev to test is having tuned too hard on the dev set.
GapNameWhat to do
Human error → trainBiasBigger model, better features, train longer
Train → train-devVarianceRegularise, get more training data
Train-dev → devData mismatchGet training data that looks like production
Dev → testOverfitting the dev setStop tuning; get a fresh dev set

This is worth internalising early. Most “the model does not work in production” reports are data mismatch, and most teams respond by regularising, which does nothing.

No Free Lunch

Wolpert’s theorem says that averaged over all possible problems, every algorithm performs identically. The practical version is weaker and far more useful: there is no algorithm that wins on every dataset, so you must try several.

Four models, four datasets:

DatasetLogisticNaive Bayes5-NNRandom forest
Two moons0.88000.87830.94000.9333
Wine0.98320.96630.94940.9722
Breast cancer0.98070.93850.96490.9596
Digits0.92040.80690.94440.9366
figureBest model per row is boxed — the box never stays in one columnmatplotlib
A heatmap of four datasets by four models with accuracies printed in each cell and the row winner outlined in red. The outlined cell sits in a different column for different rows.A heatmap of four datasets by four models with accuracies printed in each cell and the row winner outlined in red. The outlined cell sits in a different column for different rows.
5-NN wins on moons and digits; logistic regression wins on wine and breast cancer. Naive Bayes never wins and is worst on digits at 0.8069 — yet it is within 0.002 of logistic regression on moons. No column is safe to pick in advance.

Note the practical implication for the lifecycle: stage 5 is not “choose the model”, it is “try four and measure”. That costs minutes.

Note also what the theorem does not say. It does not say all algorithms are equally good on your problem — they clearly are not, spanning 0.8069 to 0.9444 on digits. It says you cannot know which without checking.

Monitoring: the stage everyone skips

A deployed model has no idea the world has changed. The types page measured a batch model decaying from 0.9800 to 0.5100 under gentle drift, silently, with no error.

Three things worth monitoring, in increasing order of usefulness:

Input distributions. Cheap, immediate, no labels required. If the mean of a feature moves three standard deviations, something upstream changed.

Prediction distributions. Also label-free. If your fraud model suddenly flags 8% of transactions where it used to flag 2%, investigate before the metric arrives.

Actual performance. The only thing that really matters, and it requires labels, which usually arrive late — sometimes months late, for churn or default prediction. Build the label-collection pipeline on day one, not when the model starts failing.

In code

The evaluation half of the lifecycle, done properly:

lifecycle_evaluation.py
from sklearn.datasets import make_classification
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
 
X, y = make_classification(n_samples=1200, n_features=20, n_informative=8,
                           n_redundant=4, class_sep=0.9, random_state=5)
 
# 1. Split off a test set FIRST and do not touch it again until the end.
X_dev, X_test, y_dev, y_test = train_test_split(
    X, y, test_size=0.2, random_state=0, stratify=y)
 
# 2. Baseline, so every later number is interpretable.
print("baseline", round(cross_val_score(DummyClassifier(), X_dev, y_dev, cv=5).mean(), 4))
 
# 3. Try several families — No Free Lunch says you cannot skip this.
candidates = {
    "logistic":      make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
    "RBF SVM":       make_pipeline(StandardScaler(), SVC()),
    "random forest": RandomForestClassifier(n_estimators=200, random_state=0),
    "boosting":      HistGradientBoostingClassifier(random_state=0),
}
scores = {n: cross_val_score(m, X_dev, y_dev, cv=5).mean() for n, m in candidates.items()}
for n, s in sorted(scores.items(), key=lambda kv: -kv[1]):
    print(f"  {n:15s} {s:.4f}")
 
# 4. ONE final number, on the untouched test set.
best_name = max(scores, key=scores.get)
final = candidates[best_name].fit(X_dev, y_dev).score(X_test, y_test)
print(f"chose {best_name}, test accuracy {final:.4f}")
lifecycle_evaluation.py
from sklearn.datasets import make_classification
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
 
X, y = make_classification(n_samples=1200, n_features=20, n_informative=8,
                           n_redundant=4, class_sep=0.9, random_state=5)
 
# 1. Split off a test set FIRST and do not touch it again until the end.
X_dev, X_test, y_dev, y_test = train_test_split(
    X, y, test_size=0.2, random_state=0, stratify=y)
 
# 2. Baseline, so every later number is interpretable.
print("baseline", round(cross_val_score(DummyClassifier(), X_dev, y_dev, cv=5).mean(), 4))
 
# 3. Try several families — No Free Lunch says you cannot skip this.
candidates = {
    "logistic":      make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
    "RBF SVM":       make_pipeline(StandardScaler(), SVC()),
    "random forest": RandomForestClassifier(n_estimators=200, random_state=0),
    "boosting":      HistGradientBoostingClassifier(random_state=0),
}
scores = {n: cross_val_score(m, X_dev, y_dev, cv=5).mean() for n, m in candidates.items()}
for n, s in sorted(scores.items(), key=lambda kv: -kv[1]):
    print(f"  {n:15s} {s:.4f}")
 
# 4. ONE final number, on the untouched test set.
best_name = max(scores, key=scores.get)
final = candidates[best_name].fit(X_dev, y_dev).score(X_test, y_test)
print(f"chose {best_name}, test accuracy {final:.4f}")

Diagnosing the failure mode:

diagnose.py
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
 
X, y = make_moons(n_samples=600, noise=0.32, random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.35,
                                          random_state=0, stratify=y)
 
for depth in (1, 4, 20):
    m = DecisionTreeClassifier(max_depth=depth, random_state=0).fit(X_tr, y_tr)
    train, test = m.score(X_tr, y_tr), m.score(X_te, y_te)
    gap = train - test
    if train < 0.85:
        verdict = "UNDERFIT — not enough capacity"
    elif gap > 0.10:
        verdict = "OVERFIT — memorising the training rows"
    else:
        verdict = "reasonable"
    print(f"depth {depth:2d}: train {train:.4f}  test {test:.4f}  gap {gap:.4f}  {verdict}")
 
# depth  1: train 0.8000  test 0.7190  gap 0.0810  UNDERFIT — not enough capacity
# depth  4: train 0.9128  test 0.8667  gap 0.0462  reasonable
# depth 20: train 1.0000  test 0.8190  gap 0.1810  OVERFIT — memorising the training rows
diagnose.py
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
 
X, y = make_moons(n_samples=600, noise=0.32, random_state=0)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.35,
                                          random_state=0, stratify=y)
 
for depth in (1, 4, 20):
    m = DecisionTreeClassifier(max_depth=depth, random_state=0).fit(X_tr, y_tr)
    train, test = m.score(X_tr, y_tr), m.score(X_te, y_te)
    gap = train - test
    if train < 0.85:
        verdict = "UNDERFIT — not enough capacity"
    elif gap > 0.10:
        verdict = "OVERFIT — memorising the training rows"
    else:
        verdict = "reasonable"
    print(f"depth {depth:2d}: train {train:.4f}  test {test:.4f}  gap {gap:.4f}  {verdict}")
 
# depth  1: train 0.8000  test 0.7190  gap 0.0810  UNDERFIT — not enough capacity
# depth  4: train 0.9128  test 0.8667  gap 0.0462  reasonable
# depth 20: train 1.0000  test 0.8190  gap 0.1810  OVERFIT — memorising the training rows

Pitfalls

Treating the lifecycle as linear. It has two loops. Evaluation sends you back to the data far more often than to the model.

Tuning on the test set. Thirty decisions informed by one number is thirty small leaks. Use a validation set or cross-validation, and touch the test set once.

Regularising when the real problem is data mismatch. The train-dev set exists exactly to distinguish these. Regularisation cannot fix training on web photos and serving phone photos.

Picking an algorithm before measuring. The winner moved between columns on all four datasets above. Trying four families costs minutes.

Judging on training accuracy. It reached 1.0000 at depth 20 while held-out accuracy was at its worst.

Shipping without monitoring. The measured decay is 0.9800 → 0.5100 with no alert. If you cannot detect that, you will find out from a user.

Defining the problem after building the model. Stage 1 exists because it is the cheapest stage to redo and the most expensive to get wrong.

Recap

  • Eight stages, two loops: evaluation returns to the data, monitoring returns to the problem definition.
  • The two levers are the same size: algorithm spread 0.1142, label-noise cost 0.1142, and 8× less data cost 0.0767.
  • Underfitting is a low train score; overfitting is a large train/test gap. At depth 20: 1.0000 and 0.8190, gap 0.1810.
  • One held-out set is not enough. Train / validation / test, and touch the test set once.
  • The train-dev set separates variance from data mismatch — two problems with opposite fixes.
  • No Free Lunch: the best model moved between columns on all four datasets.
  • Monitoring is stage 8 and gets skipped, which is why silent decay is the most common production failure.
quizCheck yourself
  1. Your model scores 1.0000 on training data and 0.8190 on held-out data. What is the diagnosis?

    Show answer

    B — Overfitting — the 0.1810 gap means it memorised training-specific noise — A high train score with a much lower held-out score is the definition of overfitting. In the measured table this was the depth-20 tree, which had the best training score and the second-worst held-out score.

  2. Training accuracy 0.98, train-dev 0.96, dev 0.83. What is the problem?

    Show answer

    B — Data mismatch — the dev distribution differs from training, and only different data fixes it — Train to train-dev is only 0.02, so variance is small and the model is not overfitting. The 0.13 drop from train-dev to dev is pure distribution change. Regularising here would achieve nothing; you need training data that resembles production.

  3. Flipping 15% of the labels cost the best model 0.1142. The spread across four algorithms on clean data was also 0.1142. What follows?

    Show answer

    B — The two levers are comparable, and they are not interchangeable — no algorithm recovers corrupted labels — The best model on flipped labels scored 0.7892 — identical to the WORST model on clean labels. Trying every algorithm cannot buy back what the labels lost, which is why the data is worth checking first.

  4. 5-NN won on moons and digits; logistic regression won on wine and breast cancer. What does No Free Lunch actually claim?

    Show answer

    B — No algorithm wins on every dataset, so you cannot pick one in advance without measuring — The algorithms clearly differ on each individual dataset — 0.8069 to 0.9444 on digits. The claim is about the absence of a universally best choice, which is why trying several families is a required step rather than an optional one.

  5. Which lifecycle stage most often gets skipped, and what does that cost?

    Show answer

    B — Monitoring — models decay silently, with no error and no alert — A skipped stage that fails loudly gets fixed. Monitoring is the one whose absence is invisible: the measured drift example fell from 0.9800 to 0.5100 while returning perfectly well-formed predictions the entire time.

🧪 Try It Yourself

Exercise 1 – Split first, baseline first

Exercise 2 – Diagnose the failure mode

Exercise 3 – Bad labels beat every algorithm

Exercise 4 – No Free Lunch, demonstrated

Exercise 5 – Simulate the train-dev diagnosis

Exercise 6 – Price the act of choosing a model

Next

Setting up the ML Environment — the last practical step before Phase 2: a reproducible Python environment, pinned versions, and a sanity-check script.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did