Skip to content

An End-to-End ML Project

After this lesson, you will be able to:

  • Walk through all 8 steps of a machine learning project, from problem framing to deployment, knowing the concrete output of each step.
  • Choose a metric suited to the task, establish a baseline for comparison, and understand why the median serves as the optimal constant baseline for MAE.
  • Implement the complete workflow in scikit-learn: stratified splitting, a Pipeline with ColumnTransformer, model comparison via cross-validation, hyperparameter tuning with GridSearchCV, and a single final test evaluation.
  • Identify three pitfalls that compromise reported results: data leakage, peeking repeatedly at the test set, and selecting an inappropriate metric.

A medical clinic provides records for 442 diabetes patients. Each patient has 10 baseline measurements: age, sex, body mass index (BMI), average blood pressure, and six blood serum measurements. One year later, a quantitative measure of disease progression was recorded. The clinic asks: for a new patient, can we predict disease progression in advance to identify who needs intensive monitoring?

This is the standard load_diabetes dataset available in scikit-learn, published by Efron et al. in 2004. Here are the first five patients:

Patientagesexbmibps1s2s3s4s5s6Progression
059232.110115793.23844.859887151
148121.687183103.27033.89186975
272230.59315693.64144.672885141
324125.384198131.44054.890389206
450123.0101192125.45244.290580135

bp is mean arterial pressure. s1 through s6 represent blood serum measurements: total cholesterol, LDL, HDL, total cholesterol / HDL ratio, log of serum triglycerides, and blood sugar level. sex is coded as 1 and 2; the original documentation does not specify which code corresponds to male or female.

With familiar tools in hand, it is tempting to proceed as follows: standardize the entire table, train ten candidate models, evaluate each on a test set, pick the one with the best score, and report that figure. Each step sounds reasonable in isolation. Yet the reported score will be overly optimistic: the test set contributed to computing mean and variance during scaling, and it was actively used to select among ten models. When the clinic deploys the model, real-world error will exceed what was promised.

The root issue is not model architecture. It is the order of operations and which data is accessible at each moment.

Experienced ML engineers follow a structured checklist. This lesson follows an 8-step workflow, closely mirroring chapter 2 of Aurélien Géron’s textbook (see Further Reading). The guiding principle is simple: set aside the test set immediately, make all modeling and engineering decisions using only the training set, and open the test set exactly once at the very end.

The task for this lesson: predict disease progression after one year from 10 baseline features, reporting an error estimate that the clinic can trust on future patients.

Step 1: Frame the Problem and Choose a Metric

Section titled “Step 1: Frame the Problem and Choose a Metric”

Before writing code, answer three fundamental questions.

Supervised or unsupervised? Regression or classification? We have ground-truth targets, making it supervised learning. The target is a continuous value ranging from 25 to 346, so this is a regression problem. If the clinic only needed to know “severe vs. mild progression”, the task would become classification, requiring entirely different metrics.

How will performance be evaluated? We use Mean Absolute Error (MAE):

MAE=1m∑i=1m∣y(i)−y^(i)∣\text{MAE} = \frac{1}{m}\sum_{i=1}^{m}\left\lvert y^{(i)} - \hat{y}^{(i)}\right\rvert

Reason: MAE shares the exact unit of the target. Saying “on average, predictions deviate by 46 points on a scale from 25 to 346” is immediately clear to medical practitioners. MSE penalizes large errors quadratically, making it harder to interpret directly. If a large error is catastrophic compared to several smaller errors, MSE (or RMSE) is preferable. There is no universally “correct” metric: the metric must reflect the actual domain cost of errors.

What constitutes “good” performance? An isolated MAE value lacks context. We require a baseline: the simplest sensible heuristic that learns nothing complex. For MAE, the optimal constant prediction is the median of the training labels. Why the median rather than the mean?

Let cc be our constant prediction. Shifting cc upward by an infinitesimal amount δ\delta without crossing any target values reduces error by δ\delta for every patient whose label is greater than cc, but adds δ\delta for every patient whose label is less than cc. The total change in MAE is:

δm(#{y(i)<c}−#{y(i)>c})\frac{\delta}{m}\Big(\#\{y^{(i)} < c\} - \#\{y^{(i)} > c\}\Big)

where #{⋅}\#\{\cdot\} denotes the count of matching patients. As long as more points lie above cc than below, increasing cc decreases MAE. Equilibrium is reached when the counts on both sides balance, which defines the median. For MSE, setting the derivative with respect to cc to zero yields the mean. In scikit-learn, this baseline is DummyRegressor(strategy="median"). Any model that fails to outperform this baseline has learned nothing of practical value.

Step 2: Acquire Data, Split Immediately, Put the Test Set Away

Section titled “Step 2: Acquire Data, Split Immediately, Put the Test Set Away”

As soon as data is loaded, reserve a test set (here 20%, or 89 patients) and do not inspect it. Why split before even exploring the data? Because human intuition acts like an estimator. If you examine the full dataset and notice “feature s5 appears strongly predictive”, that hypothesis was derived from the test set. A test set remains an unbiased benchmark only if zero decisions were influenced by its contents.

Stratified split. Purely random splitting can, by chance, underrepresent severe cases in the test set. In classification, passing targets to stratify preserves class ratios. In regression, target categories do not exist naturally, so we create them: bin the continuous target into 5 equal-frequency quantiles (pd.qcut), then stratify across these bins. Consequently, the test set contains 18, 18, 18, 17, and 18 patients across the five progression tiers from mild to severe.

Exploratory Data Analysis (EDA) investigates distributions and anomalies before modeling: feature ranges, missing or corrupted values, categorical encodings, and correlations with the target. Crucially, all exploration occurs strictly on the training set.

Fundamental tools include describe() and correlation coefficients between each feature and the target. Pearson correlation ranges in [−1,1][-1, 1], where values further from 0 indicate stronger linear association. Two caveats:

  • A near-zero correlation does not imply no relationship; it only indicates the absence of a linear relationship. A U-shaped relationship yields near-zero correlation.
  • Correlation is not causation. This observational dataset contains no clinical interventions. Even though BMI correlates with disease progression, the model cannot infer how much weight loss would slow progression.

EDA also uncovers features requiring dedicated encoding. Here, sex takes values 1 and 2: it is a numerically encoded categorical variable, not a continuous measurement.

In this step, data transformations are specified: imputing missing entries, encoding categorical variables, scaling numerical features, and engineering new interactions. Detailed techniques are covered in Data Preprocessing, Augmentation, and Imbalanced Data. The core requirement is that every transformation step that learns from data (such as computing scaling statistics) must be fitted strictly on training folds.

The most robust design encapsulates preprocessing and estimation inside a Pipeline. For the diabetes data:

  • OneHotEncoder(drop="if_binary") converts sex into a single 0/1 binary feature.
  • StandardScaler() standardizes the remaining 9 numerical features. Ridge regression requires this step because its regularization penalty treats all feature weights symmetrically.

When cross-validating the pipeline, scikit-learn refits StandardScaler inside each fold on the training partition only. The validation fold plays no part in computing mean and variance, preventing data leakage.

Step 5: Shortlist Models via Cross-Validation

Section titled “Step 5: Shortlist Models via Cross-Validation”

Now we evaluate candidate models. Avoid committing early to a single model. Train several diverse architectures with default or sensible hyperparameters, evaluating them via cross-validation on the training set:

  1. Baseline DummyRegressor: establishes the minimum performance floor.
  2. Regularized linear model, specifically Ridge: fast, minimal hyperparameters, resistant to overfitting.
  3. Nonlinear ensemble, specifically Random Forest: captures feature interactions and nonlinear curves, though requiring larger sample sizes.

Record both the mean and standard deviation of metric scores across folds. When two models differ by less than their cross-validation standard deviation, neither can be declared superior with confidence. The output of this stage is a shortlist of two or three promising architectures for further tuning.

Every estimator possesses hyperparameters set prior to training: alpha in Ridge, or tree depth and ensemble size in Random Forest. Hyperparameter tuning searches for optimal configurations via cross-validation, using one of two primary strategies:

  • Grid Search (GridSearchCV): evaluates all parameter combinations across a predefined grid. A grid of 6 alpha values across 5 folds requires 30 fits. Suitable when tuning one or two parameters.
  • Random Search (RandomizedSearchCV): samples a fixed budget of configurations (n_iter) randomly from defined probability distributions. When tuning many hyperparameters where only a few govern performance, random search finds competitive solutions with significantly fewer evaluations (Bergstra and Bengio, 2012).

Within a pipeline, hyperparameters are referenced via <step_name>__<param_name> with double underscores. make_pipeline generates lowercase step names from class names, making Ridge’s regularization parameter ridge__alpha. After search completion, GridSearchCV automatically refits the winning configuration on the entire training set (refit=True by default).

A subtle detail: the cross-validation score of the winning hyperparameter configuration is slightly optimistic, as it represents the maximum of multiple noisy estimates. This selection bias reinforces why an untouched test set remains necessary.

Step 7: Error Analysis, Then Evaluate on the Test Set Once

Section titled “Step 7: Error Analysis, Then Evaluate on the Test Set Once”

Before opening the test set, investigate where the model errs. Error analysis leverages out-of-fold predictions: cross_val_predict obtains a prediction for every training patient using a model that never saw that specific individual. Errors can then be grouped across cohorts, such as progression severity. For instance, the model might predict mild cases accurately while underestimating severe cases—the exact group the clinic cares about most. Such insights may warrant revisiting Step 1 (redefining the metric), Step 4 (engineering specific features), or Step 5 (trying different model families). Revisiting earlier stages is fully sound here because the test set remains untouched.

Once the pipeline is finalized, evaluate on the test set exactly once. This single number serves as the unbiased estimate of generalization error on new patients. Post-evaluation modifications based on test results invalidate the estimate. If test MAE degrades substantially compared to cross-validation, investigate potential data leakage or train/test distribution shift. Re-evaluating requires fresh, unseen data.

Models deliver real-world value only when integrated into serving systems. This section outlines key principles; serving models behind HTTP endpoints is detailed in Deploying Custom Models, and containerization is covered in Docker and Cloud.

Serialize the complete pipeline, not merely the estimator weights. Preprocessing is an integral part of the model: incoming patient records must be scaled using the training set’s exact mean and standard deviation. The standard tool is joblib:

import joblib
joblib.dump(search.best_estimator_, "diabetes-ridge.joblib") # save
model = joblib.load("diabetes-ridge.joblib") # load in serving environment

This snippet assumes an already fitted search object as shown in the third code example, intended for execution in a local environment. Note two recommendations from the scikit-learn documentation: joblib files can execute arbitrary code upon deserialization, so only load trusted artifacts; and models should be loaded in an environment with the matching scikit-learn version used during training, alongside records of training data checksums and cross-validation scores.

Monitoring. Environments evolve over time. Clinics upgrade diagnostic equipment, measurement scales shift, or demographic distributions drift: the input distribution diverges from training data. This phenomenon is known as data drift. Alternatively, the relationship between features and outcomes can change—such as when a new medication slows progression rates—termed concept drift. A minimal monitoring workflow checks incoming input statistics against the training baseline and recalculates MAE once true progression outcomes become available. If error degrades, retrain on fresh data following the complete 8-step cycle.

  • The workflow is iterative, not linear. Step 7 frequently routes work back to Steps 1, 4, or 5. The only irreversible boundary is opening the test set.
  • Small sample sizes exhibit variance across splits. With only 89 test patients, test MAE fluctuates noticeably depending on the random split seed (explored in the third code cell).
  • Strong predictive correlation does not imply causality (Step 3).
  • This overview abstracts certain complex production concerns: raw data collection and labeling pipelines, patient privacy and HIPAA compliance, demographic fairness auditing, and non-technical stakeholder communication.

The table below summarizes the 8-step methodology. Notice the final column: each stage produces a tangible artifact. If an artifact cannot be clearly stated, the step is incomplete.

StepCore QuestionDeliverableIn This Lesson
1. Frame ProblemWhat is predicted, how is it measured, what is acceptable?Task type, metric, baselineRegression, MAE, median baseline
2. Split DataIs the test set representative?Isolated train and test sets353 train, 89 test, stratified into 5 target bins
3. Explore (EDA)What patterns and structures exist in the data?Feature notes, value ranges, correlation tablebmi and s5 strongest correlations; sex identified as categorical
4. Prepare DataHow should features be transformed?A ColumnTransformer specificationsex to 0/1 binary, numerical features standardized
5. Shortlist ModelsWhich model families are promising?Model shortlist, cross-validation scoresDummy baseline, Ridge, Random Forest
6. Tune HyperparametersWhat hyperparameter values optimize validation score?Finalized tuned pipelineGridSearchCV over alpha for Ridge
7. Error Analysis & TestWhere does it fail? What is expected real-world error?Final test score, error breakdownTest MAE, error distribution across progression tiers
8. Deploy & MonitorHow is it served, and when must it be retrained?Serialized model file, monitoring planjoblib artifact, data drift tracking

In Introduction to Machine Learning, the workflow was introduced simply as split, fit, and score. Here, between “split” and “score” lies an iterative loop of exploration, preprocessing, validation, and error analysis—operating exclusively on training data.

The three code cells below run independently: each loads and partitions data with identical parameters (random_state=0), observing the identical 353 training and 89 test patients. The initial run may take slightly longer while the browser loads pandas and scikit-learn. Data is imported with scaled=False to preserve raw units; scikit-learn’s default pre-scaled return values would bypass the preprocessing demonstration in Step 4.

The first cell covers Steps 2 and 3: stratified partitioning, followed by summary statistics and correlations computed strictly on the training set.

Load, split, and explore data

import pandas as pd
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
# 442 patients, 10 features in original units (scaled=False)
X, y = load_diabetes(return_X_y=True, as_frame=True, scaled=False)
print(f"X: {X.shape}, y: {y.shape}")
# Split RIGHT at the start. Stratify on 5 groups of y (20% each),
# so the test set has mild, moderate and severe patients alike.
y_bins = pd.qcut(y, 5, labels=False)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y_bins, random_state=0)
print(f"train: {len(X_train)}, test: {len(X_test)}")
# From here on, look at the training set only.
pd.set_option("display.width", 79)
print(X_train.describe().T[["mean", "std", "min", "max"]].round(1))
print(X_train["sex"].value_counts().to_dict())
# Correlation of each feature with y
corr = X_train.corrwith(y_train).sort_values(ascending=False)
print(corr.round(2).to_dict())
print(f"y_train: mean = {y_train.mean():.1f}, "
f"min = {y_train.min():.0f}, max = {y_train.max():.0f}")

The first run downloads Python, about 10 MB.

  • The training set contains 353 patients; the test set contains 89. From the comment # From here on, look at the training set only., all computations draw solely on X_train and y_train.
  • Feature scales vary substantially: s5 standard deviation is 0.5, while s1 is 34.2. This variance underscores the necessity of standardization prior to Ridge regression.
  • sex contains 189 entries coded 1 and 164 coded 2, confirming its categorical structure.
  • The strongest positive correlations with progression are bmi (0.57) and s5 (0.55), followed by bp (0.44). s3 (HDL cholesterol) exhibits negative correlation (-0.38): higher HDL levels align with milder disease progression. sex correlation is near zero (0.03), though this alone does not justify dropping the feature.
  • Training targets range from 25 to 346 with a mean of 152.1. An MAE around 45 represents noticeable variance relative to this scale: communicate realistic expectations to healthcare partners.

The second cell covers Steps 4 and 5. prep is the ColumnTransformer from Step 4, chained to each candidate model using make_pipeline. KFold(n_splits=5, shuffle=True, random_state=0) partitions the 353 training samples into 5 folds.

Baseline, Ridge, and Random Forest

import pandas as pd
from sklearn.compose import make_column_transformer
from sklearn.datasets import load_diabetes
from sklearn.dummy import DummyRegressor
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import Ridge
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
# Load and split exactly as in the first cell
X, y = load_diabetes(return_X_y=True, as_frame=True, scaled=False)
y_bins = pd.qcut(y, 5, labels=False)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y_bins, random_state=0)
# sex (1 or 2) becomes one 0/1 column; the others are standardized
prep = make_column_transformer(
(OneHotEncoder(drop="if_binary"), ["sex"]),
remainder=StandardScaler())
models = {
"baseline": DummyRegressor(strategy="median"), # always the median
"ridge": Ridge(alpha=1.0),
"forest": RandomForestRegressor(n_estimators=50, min_samples_leaf=5,
random_state=0),
}
cv = KFold(n_splits=5, shuffle=True, random_state=0)
for name, model in models.items():
pipe = make_pipeline(prep, model) # preprocessing refit in every fold
scores = -cross_val_score(pipe, X_train, y_train, cv=cv,
scoring="neg_mean_absolute_error")
print(f"{name:8s} CV MAE = {scores.mean():.1f} +/- {scores.std():.1f}")
# Extra: the forest's MAE on its own training set, to compare with CV
forest = make_pipeline(prep, models["forest"]).fit(X_train, y_train)
train_mae = (forest.predict(X_train) - y_train).abs().mean()
print(f"forest train MAE = {train_mae:.1f}")

The first run downloads Python, about 10 MB.

  • Baseline (constant median prediction) yields a cross-validation MAE of 65.6.
  • Ridge achieves 45.8, approximately a 30% error reduction relative to the baseline, confirming that meaningful predictive patterns have been learned.
  • Random Forest records 49.3, underperforming Ridge. The 3.5 point gap is comparable to fold standard deviations (3.4 and 3.6), so while Ridge cannot be deemed conclusively superior, Random Forest offers no advantage.
  • The final output line clarifies why: on the training partition itself, Random Forest achieves an MAE of 32.3 compared to 49.3 on validation folds. With 353 samples and 10 features, the tree ensemble partially fits training noise; furthermore, underlying relationships in this dataset are largely linear, allowing the simpler linear model to prevail.

Takeaway: model complexity does not guarantee superior performance, and only cross-validation exposes this distinction reliably. Ridge advances as the shortlisted candidate.

Tune, Analyze Errors, and Evaluate on Test Set

Section titled “Tune, Analyze Errors, and Evaluate on Test Set”

The third cell executes Steps 6 and 7 in sequence: tuning alpha via GridSearchCV, analyzing out-of-fold residuals, and concluding with a single test set evaluation.

GridSearchCV, error analysis, and test evaluation

import pandas as pd
from sklearn.compose import make_column_transformer
from sklearn.datasets import load_diabetes
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import (GridSearchCV, KFold, cross_val_predict,
train_test_split)
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
X, y = load_diabetes(return_X_y=True, as_frame=True, scaled=False)
y_bins = pd.qcut(y, 5, labels=False)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y_bins, random_state=0)
prep = make_column_transformer(
(OneHotEncoder(drop="if_binary"), ["sex"]), remainder=StandardScaler())
pipe = make_pipeline(prep, Ridge())
# 1) Tune alpha with cross-validation, on the training set only
cv = KFold(n_splits=5, shuffle=True, random_state=0)
grid = {"ridge__alpha": [0.1, 1, 3, 10, 30, 100]}
search = GridSearchCV(pipe, grid, cv=cv, scoring="neg_mean_absolute_error")
search.fit(X_train, y_train) # then refits on the whole training set
for a, s in zip(grid["ridge__alpha"], search.cv_results_["mean_test_score"]):
print(f"alpha = {a:5g}: CV MAE = {-s:.2f}")
# 2) Error analysis on out-of-fold predictions (test set still unseen)
oof = cross_val_predict(search.best_estimator_, X_train, y_train, cv=cv)
# Mean error (y_hat - y) in 4 groups of y: positive means too high
group = pd.qcut(y_train, 4, labels=["low", "mid-low", "mid-high", "high"])
print((oof - y_train).groupby(group, observed=True).mean().round(1))
# 3) Score the test set ONCE, with the final model
pred = pd.Series(search.predict(X_test), index=y_test.index)
print(f"test MAE = {mean_absolute_error(y_test, pred):.1f}")
worst = (pred - y_test).abs().nlargest(3).index # 3 largest errors
print(pd.DataFrame({"y": y_test[worst], "y_hat": pred[worst].round(),
"bmi": X_test.loc[worst, "bmi"]}))

The first run downloads Python, about 10 MB.

Tuning. alpha = 10 yields the lowest cross-validation MAE at 45.66. Yet examining the full sweep reveals that from alpha = 0.1 to 30, MAE only shifts from 45.95 to 45.66—a difference under 0.3. Only alpha = 100 degrades noticeably (46.92). Tuning yields modest marginal improvement here. This is common: selecting appropriate feature representations and model architectures routinely outweighs minor hyperparameter adjustments.

Error Analysis. The subsequent table segments the 353 training patients into four tiers of true progression, calculating mean residual y^−y\hat{y} - y within each tier:

  • Mildest cases are overpredicted by an average of 44.2 points, while severe cases are underpredicted by an average of 52.9 points.
  • The model regresses predictions toward the central mean. The 10 baseline metrics do not fully explain severe progression trajectories, prompting the estimator to predict conservatively toward the population average.

This provides critical clinical context: the model exhibits its largest errors on the very patient group requiring closest monitoring. If identifying high-risk cases is paramount, Step 1 should be revisited—framing the problem as binary classification focused on recall, or acquiring richer diagnostic indicators.

Test Set Evaluation. Final test MAE is 39.9, which is lower than the cross-validation score of 45.66. The model did not become intrinsically superior: these 89 test patients happen to be easier to predict. Per-fold validation MAEs ranged from 40.5 to 50.7, and keeping alpha = 10 while varying the split random_state across 1, 2, 3, and 4 yields test MAEs of 48.8, 45.9, 50.9, and 37.8 respectively. A small test set yields variable estimates. Transparent reporting states: “Test MAE 39.9 on 89 patients; cross-validation indicates an expected error around 46”, avoiding selective reporting.

Three largest test set residuals, examined strictly for diagnostic understanding rather than post-hoc parameter tweaking:

  • Patient 92: true progression 48, predicted 191. BMI 26.8 (near median).
  • Patient 304: true progression 253, predicted 123. BMI 22.8 (low).
  • Patient 382: true progression 132, predicted 253. BMI 32.0 (high).

The latter two patients diverge from the general population pattern where elevated BMI corresponds to severe progression. Because the linear model assigns substantial weight to BMI, it makes large errors on individuals who break this trend—confirming patterns flagged during out-of-fold analysis.

Question 1 of 4 You run StandardScaler().fit_transform(X) on all 442 patients before calling train_test_split. What is wrong?
Question 2 of 4 You try 30 model configurations. For each configuration, you fit on the training set, check MAE on the test set, and finally report the minimum test MAE of 38. On new patients, what will MAE most likely be?
Question 3 of 4 You build a model to detect a rare disease occurring in only 2% of patients. Cross-validation yields an accuracy of 0.98. Which step of the workflow helps you realize this number may be meaningless?
Question 4 of 4 In the third code cell, the cross-validation MAE of the final model is 45.66, while the test MAE is 39.9. What should you do?

Key Takeaways:

  • A disciplined ML project advances through 8 steps: problem framing and metric selection, data splitting, EDA, preprocessing, model shortlisting, hyperparameter tuning, error analysis with single-pass test evaluation, and deployment with monitoring. Each step yields a defined deliverable, and Steps 3 through 7 form an iterative feedback loop.
  • Isolate the test set at the onset (stratifying across target quantiles for regression when appropriate). Formulate all modeling decisions using training data, encapsulate transformers within a Pipeline, and tune configurations via cross-validation. Evaluate on the test set exactly once.
  • Metrics must reflect practical error penalties and be benchmarked against simple heuristics. MAE =1m∑i∣y(i)−y^(i)∣= \frac{1}{m}\sum_i\lvert y^{(i)} - \hat{y}^{(i)}\rvert uses the training median as its optimal constant baseline. On the diabetes dataset, Ridge (cross-validation MAE ≈46\approx 46) outperforms both the baseline (65.6) and Random Forest (49.3).
  • Persist the entire pipeline rather than standalone model weights, record dependency versions, and monitor for input data drift post-deployment.

Further Reading:

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About