Machine Learning

Building a Tiny End-to-End ML Pipeline: From CSV to Prediction

A minimal but complete pipeline from a raw CSV to a saved model that predicts on new rows, with the reasoning behind each step, not just the code.

By Aissam Ait Ahmed Machine Learning 0 comments

Most "end-to-end ML tutorials" skip the parts that actually matter and jump straight to model.fit(). The steps before and after that single line — how you split the data, when you scale it, what you check before trusting the metrics, how you actually save something you can reuse — are where most of the real mistakes happen. Below is a complete pipeline on a small, made-up dataset, with the reasoning for each decision spelled out, not just the code.

The dataset

To keep this readable end to end, we're using a small synthetic dataset of 12 fictional customers with three features and a churn label (1 = left, 0 = stayed). Save this as customers.csv:

customer_id,tenure_months,monthly_charges,support_tickets,churned
1,2,89.50,4,1
2,34,45.00,0,0
3,1,95.00,5,1
4,28,52.30,1,0
5,3,88.00,3,1
6,41,39.99,0,0
7,6,79.00,2,1
8,52,60.00,1,0
9,2,91.20,4,1
10,19,55.00,1,0
11,4,84.50,3,1
12,37,48.00,0,0

This is small enough that you could eyeball the pattern yourself (short tenure and high monthly charges tend to correlate with churn here), which is exactly why it's useful for a walkthrough — you can sanity-check every step against what you already know is true about the toy data.

Step 1: Load it and actually look at it

import pandas as pd

df = pd.read_csv("customers.csv")
print(df.shape)
print(df.dtypes)
print(df.isna().sum())
print(df["churned"].value_counts())

This step gets skipped constantly, and it's where you catch problems early: wrong dtypes (a numeric column read as text because of a stray comma), missing values, or a target column that's overwhelmingly one class. In our 12-row set, churn is 6/6 — balanced by design, but real customer data almost never is, which is a problem the precision and recall discussion covers in more depth.

Step 2: Split before you touch anything else

This is the step people get backwards most often. The instinct is to scale the data, maybe impute missing values, then split into train and test. That's a mistake, because any statistic you compute on the full dataset — a mean, a standard deviation, a most-common category — leaks information from the test set into training. The model ends up being evaluated on data it was indirectly informed by, and your test accuracy will look better than what you'll see in production.

from sklearn.model_selection import train_test_split

X = df[["tenure_months", "monthly_charges", "support_tickets"]]
y = df["churned"]

X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y
)

Two details worth calling out:

  • stratify=y keeps the churn/no-churn ratio consistent between train and test. Without it, on a small dataset like this one, random chance could put nearly all the churned customers in one split.
  • random_state=42 makes the split reproducible. The number itself is arbitrary — it's just a seed fed into the same kind of pseudo-random number generator you'd find in any language's standard library. If you want to see what a seeded, repeatable random draw looks like on its own, outside of scikit-learn's internals, our random number generator is a simple way to play with the same underlying idea: same seed, same sequence, every time.

Step 3: Preprocess after the split, fit only on train

Scaling comes next, and the rule is: fit the scaler on the training data only, then apply that same fitted transformation to the test data.

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

Notice fit_transform on train, but just transform on test. That one-word difference is the whole leakage-prevention rule in code form. If you call fit_transform on the test set too, you're computing a separate mean and standard deviation for it, which means your "held-out" data was never held out from influencing its own preprocessing.

Step 4: Train a simple, honest baseline first

Before reaching for anything fancier, a logistic regression model tells you a lot for very little cost:

from sklearn.linear_model import LogisticRegression

model = LogisticRegression()
model.fit(X_train_scaled, y_train)

Under the hood, this is fitting weights using an iterative optimizer that updates parameters step by step to reduce a loss function — the same core update rule walked through by hand in the gradient descent post, just applied to a logistic loss instead of squared error, and handled by a tuned library implementation instead of a hand-rolled loop.

A baseline like this matters even if you plan to try a random forest or gradient boosted model afterward. If a two-line logistic regression gets you 90% of the way to your best model's performance, that tells you something honest about how much signal is actually in your features, before you spend time tuning something more complex.

Step 5: Evaluate without fooling yourself

from sklearn.metrics import classification_report, confusion_matrix

predictions = model.predict(X_test_scaled)

print(confusion_matrix(y_test, predictions))
print(classification_report(y_test, predictions))

Resist the urge to stop at accuracy. On a balanced 3-row test split it's not misleading here, but the moment your real dataset has an imbalanced target — say 95% of customers don't churn — a model that just predicts "no churn" every time can post 95% accuracy while catching zero actual churners. That specific trap, with a full numeric walkthrough, is covered in this post on precision, recall, and the mistake most people make evaluating models. The short version for this pipeline: always check the confusion matrix, not just the top-line accuracy number, before deciding a model is good enough to ship.

Step 6: Save the model, and everything it needs to run again

A trained model that only exists inside your notebook's memory isn't a pipeline, it's a demo. Saving it properly means saving the scaler too, because a model trained on scaled features is useless if you feed it raw ones later.

import joblib

joblib.dump(model, "churn_model.joblib")
joblib.dump(scaler, "churn_scaler.joblib")

And loading it back, in a separate script or a separate process entirely, to predict on one new customer:

import joblib
import pandas as pd

model = joblib.load("churn_model.joblib")
scaler = joblib.load("churn_scaler.joblib")

new_customer = pd.DataFrame(
[{"tenure_months": 3, "monthly_charges": 91.0, "support_tickets": 4}]
)

new_scaled = scaler.transform(new_customer)
prediction = model.predict(new_scaled)
probability = model.predict_proba(new_scaled)

print("Predicted churn:", bool(prediction[0]))
print("Confidence:", probability[0])

This is the part that actually makes it "end to end" — a CSV went in, and a runnable prediction on a brand-new row came out the other side, using artifacts saved to disk rather than variables sitting in memory.

Cross-validation: a better estimate than one lucky split

The single train/test split above has a real weakness on a tiny 12-row dataset: which three rows happened to land in the test set can swing the reported metrics a lot, purely by chance. A model that looks strong on this particular split might look mediocre on a different random split of the same data, and with only 12 rows total, that's not a hypothetical — it's likely.

from sklearn.model_selection import cross_val_score

scores = cross_val_score(
LogisticRegression(), scaler.fit_transform(X), y, cv=5, scoring="f1"
)
print(scores)
print("Mean F1:", scores.mean())

Five-fold cross-validation splits the data into five chunks, trains on four of them and tests on the fifth, five times, rotating which chunk is held out each time. You get five separate performance numbers instead of one, and the spread between them tells you something a single split can't: whether your model's performance is stable across different slices of the data, or whether it's quietly dependent on which rows happened to end up where. A tight cluster of five F1 scores is a much more trustworthy signal than one good number from a single lucky split.

For a real project, I'd treat the single train/test split walked through above as the version you build and debug the pipeline with, and cross-validation as the version you actually trust before reporting a number to anyone else. The extra compute cost of training five models instead of one is trivial on a dataset this size, and it stays cheap enough to run routinely even on datasets with a few hundred thousand rows — it only becomes a real consideration once training a single model is itself slow.

What I'd double-check before calling this done

  • Column order. If new_customer's columns aren't in the same order and named the same way as training, some model types will silently accept it and produce garbage instead of erroring.
  • Version pinning. A model pickled with one version of scikit-learn can fail to load, or load with subtly different behavior, on another version. Pin your library versions for anything beyond a personal experiment.
  • Class balance in production data. A model evaluated on a small, balanced test split can behave differently once it sees the real, messier distribution of live data.
  • Feature drift over time. A model trained on this month's customer behavior may quietly degrade as behavior shifts; nothing here monitors for that, and in a real system something should.
  • Reproducibility of the whole pipeline, not just the model. If someone else runs this notebook a year from now with a newer pandas or scikit-learn version installed, subtly different default behavior in either library can change results in ways that are hard to trace back to their actual cause.

None of these steps are individually hard. The value of walking through all of them together is seeing how a single bad habit — scaling before splitting, or trusting accuracy on imbalanced data — can undo the correctness of every step around it, even when every other part of the pipeline was built correctly. A pipeline is only as trustworthy as its weakest individual step, not its average step.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Machine Learning Free Resources Explore Tools