Index Ai Academy
Phase 3 · Machine learning · Unsupervised

The scikit-learn workflow

Fit, predict and evaluate with the standard ML library.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Each lesson in this phase used one piece of scikit-learn, a popular Python library for machine learning. A real project needs all the pieces at once. They have to run in the right order, learn only from training data, and travel as one package you can hand to someone else. That package is called a pipeline: raw data goes in, and predictions come out. Most machine learning systems in real companies have this shape, whatever library they use.

What you'll be able to do

  • Use scikit-learn's shared interface: fit, predict, transform, score
  • Build one pipeline that handles missing values, categories and scaling
  • Tune and compare different models in a single cross-validated search
  • Save a trained pipeline, load it, and score new customers safely

What it covers

  1. One set of buttons for everything
  2. Worked example: a home-made gap filler
  3. A pipeline for messy data
  4. Reaching a setting inside a pipeline
  5. Tune, compare, test once, save
  6. Saving a model safely

One set of buttons for everything

Think of the gadgets in your kitchen. A kettle, a toaster and a blender do different jobs. Still, you'd be lost if each one switched on in a different way. scikit-learn avoids that problem. Every tool in it works with the same few buttons.

There are three kinds of tool:

KindMethodsExamples
Estimator (anything that learns)fit(X, y)all of the below
Predictorpredict, predict_proba, scoreLogisticRegression, RandomForestClassifier, KMeans
Transformertransform, fit_transformStandardScaler, SimpleImputer, OneHotEncoder, PCA

An estimator is anything that learns from data. It always learns with the same button, fit. You give it the inputs, called X. For most models you also give it the answers, called y. Here X is the table of features and y is the column you want to predict, such as whether a customer left.

  • A predictor is a model. After fit, predict gives answers for new rows. predict_proba gives probabilities, and score grades the model.
  • A transformer prepares data. After fit, transform changes data the same way every time. fit_transform does both in one go.

Two naming habits make the library easy to read. Settings go in the brackets when you create the tool, as in LogisticRegression(C=0.1). Things the tool learned during fit get names that end in an underscore, such as coef_, mean_ or cluster_centers_. So a trailing underscore tells you the value came from data.

Because every tool shares these buttons, you can chain them, like stations on an assembly line. The worked example below builds a tiny tool of your own with the same two buttons.

Worked example

Worked example: a home-made gap filler

This tool copies the buttons of scikit-learn's SimpleImputer. Its fit learns the median of the training values. Its transform fills each gap with that median. It also adds a 0/1 flag that says whether the value was missing.

import statistics

class TinyImputer:
    """Fills gaps with the training median and adds a was-missing flag."""
    def fit(self, values):
        known = [v for v in values if v is not None]
        self.median_ = statistics.median(known)   # learned from data, so it ends in _
        return self

    def transform(self, values):
        return [(self.median_ if v is None else v, int(v is None)) for v in values]

train_open_rates = [0.2, None, 0.6, 0.4]
imp = TinyImputer().fit(train_open_rates)
print("learned median_:", imp.median_)
print("new customers:", imp.transform([None, 0.9]))
It runs right here in your browser, with nothing to install.

The tool learned one number from the training rows. Sorted, the known rates are 0.2, 0.4 and 0.6, so the median is 0.4. It's stored as median_, with an underscore, because it came from data. Each new customer then gets a pair. The first has no rate, so they get 0.4 and a flag of 1. The second customer's 0.9 passes through, with a flag of 0. The new customers never change the median. Only training rows teach the tool anything.

Quick check
You fit the gap filler on training rates 0.5, a gap, 0.1 and 0.3. A new customer has no rate. What does this print?
import statistics
train = [0.5, None, 0.1, 0.3]
median_ = statistics.median([v for v in train if v is not None])   # what fit learns
new = None
print((median_ if new is None else new, int(new is None)))          # what transform does
  1. (0.0, 1)
  2. (0.3, 0)
  3. (0.3, 1)
  4. (0.45, 1)
Show the answer

(0.3, 1)

Sorted, the known rates are 0.1, 0.3 and 0.5, so the median is 0.3. The gap is filled with 0.3, and the flag is 1 because the value was missing.

A pipeline for messy data

The practice shop's customer table is messy in three ways:

  • Some number columns have gaps. email_open_rate is blank for people who never subscribed.
  • Some text columns have blanks. region is sometimes an empty string.
  • Some columns hold words, like "west" or "referral". A model can only do sums with numbers.

You met a fix for each of these in Preparing data. Here you wire the fixes together. Two tools do the wiring:

  • A ColumnTransformer works like a mail sorter. It sends each group of columns down its own chute. Number columns get one set of steps, and word columns get another.
  • A Pipeline works like a recipe card. It lists every step in order, ending with the model. The whole card then acts as one tool, with its own fit and predict.
import pandas as pd
import requests
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
numeric = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order",
           "discount_share", "email_open_rate", "support_tickets", "trail_club"]
categorical = ["region", "channel", "top_category"]

prep = ColumnTransformer([
    ("num", make_pipeline(SimpleImputer(strategy="median", add_indicator=True), StandardScaler()), numeric),
    ("cat", make_pipeline(SimpleImputer(missing_values="", strategy="constant", fill_value="unknown"),
                          OneHotEncoder(handle_unknown="ignore")), categorical),
])
pipe = Pipeline([("prep", prep), ("model", LogisticRegression(max_iter=1000))])

X, y = df[numeric + categorical], df["churned"]
X_dev, X_test, y_dev, y_test = train_test_split(X, y, test_size=0.2, random_state=0, stratify=y)
scores = cross_val_score(pipe, X_dev, y_dev, cv=5, scoring="roc_auc")
print(f"cross-validated AUC: {scores.mean():.3f} ± {scores.std():.3f}")

pipe.fit(X_dev, y_dev)
print("columns the model sees:", len(pipe.named_steps["prep"].get_feature_names_out()))

Here's what the code does, in order:

  1. It loads the customers. It lists the number columns (numeric) and the word columns (categorical).
  2. prep is the mail sorter. make_pipeline builds a short chain of steps for each chute.
  3. pipe is the recipe card: preparation first, then a logistic regression model.
  4. train_test_split locks away 20% of the customers as a test set. The other 80%, called X_dev and y_dev here, is your working data. stratify=y keeps the share of customers who left about the same on both sides.
  5. cross_val_score runs 5-fold cross-validation on the working data. It prints the average AUC. After the ± sign comes the spread of the five fold scores. AUC measures how well the model ranks leavers above stayers: 1.0 is perfect and 0.5 is a coin flip.
  6. Last, it fits the pipeline on all the working data and counts the columns the model receives.

That count is bigger than the 11 columns you started with. The steps below explain why.

What each chute does, learned from training rows only:

  • Numbers: fill gaps with the median, add a 0/1 "was missing" column, then standardize. Standardizing puts every column on the same scale. Then order values in dollars and shares between 0 and 1 can sit side by side. The flag is there because a gap can itself be a signal. Never subscribing to email might predict that a customer leaves.
  • Words: treat blanks as "unknown", then one-hot encode. One-hot encoding gives each category its own 0/1 column. handle_unknown="ignore" deals with surprises. Say a new sales channel appears next quarter, one the model never saw in training. Its row gets all zeros in the channel columns instead of an error.
Quick check
One-hot encoding turns a column with 4 categories into how many columns?
  1. 16
  2. 2
  3. 4
  4. 1
Show the answer

4

One 0-or-1 column per category. That's why the count of columns grows.

Why bother making it one object? Because cross_val_score now refits every step inside every fold. The medians, the scaling and the list of categories are all relearned from that fold's training rows. Nothing from the validation rows leaks in. Leakage is when information from the rows you test on sneaks into training, so your score looks better than it really is. Later, when you predict, there's no step to forget. The pipeline runs them all.

Go deeper Which columns get a "was missing" flag

SimpleImputer(add_indicator=True) adds a flag only for columns that had gaps in the training data. A column with no gaps gets no flag, since that flag would always be 0. Like the median, the choice of which columns to flag is learned during fit.

Quick check
In training, email_open_rate has some gaps. After the numeric steps (median fill with add_indicator=True, then scaling), how many columns come out for it?
  1. One: the filled, scaled value
  2. Zero: columns with gaps are dropped
  3. Two: the filled, scaled value and a was-missing flag
  4. One for every missing row
Show the answer

Two: the filled, scaled value and a was-missing flag

The imputer keeps the column, with gaps filled by the median, and adds one 0/1 flag column. The scaler then scales both. That's one reason the model sees more columns than you started with.

Reaching a setting inside a pipeline

A pipeline holds several steps, and each step has its own settings. To reach a setting inside a step, join the step's name and the setting's name with two underscores: step__setting. So model__C means "the C of the step called model". It works like a folder path, such as model/C, with __ in place of the slash.

As a reminder from Regularization, C sets the penalty in logistic regression. A smaller C means a stronger penalty and a simpler model.

Quick check
In a Pipeline with steps named "prep" and "model", how do you put the model's C into a GridSearchCV grid?
  1. "model__C"
  2. "C"
  3. "prep__model__C"
  4. "model.C"
Show the answer

"model__C"

Step name, two underscores, then the setting's name.

Tune, compare, test once, save

GridSearchCV tries every setting you list. It cross-validates each one and keeps the best. You can even search over which model fills the "model" step. The code below compares three kinds of model:

  • logistic regression, with three values of C;
  • a random forest, with two values of min_samples_leaf, the fewest customers allowed in each leaf of a tree;
  • boosted trees (HistGradientBoostingClassifier), with two values of max_depth, how many questions deep each tree can go.

Then it scores the winner once on the locked test set. Last, it saves the winner to a file, loads it back and scores two new customers.

import joblib
import pandas as pd
import requests
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import GridSearchCV, train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
numeric = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order",
           "discount_share", "email_open_rate", "support_tickets", "trail_club"]
categorical = ["region", "channel", "top_category"]
prep = ColumnTransformer([
    ("num", make_pipeline(SimpleImputer(strategy="median", add_indicator=True), StandardScaler()), numeric),
    ("cat", make_pipeline(SimpleImputer(missing_values="", strategy="constant", fill_value="unknown"),
                          OneHotEncoder(handle_unknown="ignore")), categorical),
])
pipe = Pipeline([("prep", prep), ("model", LogisticRegression(max_iter=1000))])
X_dev, X_test, y_dev, y_test = train_test_split(df[numeric + categorical], df["churned"], test_size=0.2,
                                                random_state=0, stratify=df["churned"])

grid = [
    {"model": [LogisticRegression(max_iter=1000)], "model__C": [0.01, 0.1, 1]},
    {"model": [RandomForestClassifier(n_estimators=100, random_state=0)], "model__min_samples_leaf": [5, 20]},
    {"model": [HistGradientBoostingClassifier(random_state=0)], "model__max_depth": [2, 3]},
]
search = GridSearchCV(pipe, grid, cv=5, scoring="roc_auc").fit(X_dev, y_dev)
for params, score in zip(search.cv_results_["params"], search.cv_results_["mean_test_score"]):
    setting = {k: v for k, v in params.items() if k != "model"}
    print(f"{type(params['model']).__name__:<32} {setting}  AUC {score:.3f}")
print(f"\nwinner's test AUC (scored once): {roc_auc_score(y_test, search.predict_proba(X_test)[:, 1]):.3f}")

joblib.dump(search.best_estimator_, "churn_model.joblib")
model = joblib.load("churn_model.joblib")
new_customers = pd.DataFrame([
    {"tenure_months": 3, "orders_last_year": 1, "avg_order_value": 55.0, "days_since_last_order": 140,
     "discount_share": 0.4, "email_open_rate": None, "support_tickets": 2, "trail_club": 0,
     "region": "", "channel": "podcast", "top_category": "home"},
    {"tenure_months": 40, "orders_last_year": 7, "avg_order_value": 130.0, "days_since_last_order": 12,
     "discount_share": 0.05, "email_open_rate": 0.4, "support_tickets": 0, "trail_club": 1,
     "region": "west", "channel": "referral", "top_category": "apparel"},
])
print("churn probability for two new customers:", model.predict_proba(new_customers)[:, 1].round(2))

Each of the first lines is one setting and its cross-validated AUC. Next comes the winner's AUC on the test set. That's the number you report, because the model never saw those rows while you were choosing. In predict_proba(...)[:, 1], the [:, 1] picks the second column of probabilities: the chance of leaving. The first column is the chance of staying.

Logistic regression with a strong penalty wins here. The dataset is small, and each feature mostly pushes churn risk up or down on its own. The more flexible tree models find nothing extra here: they score a few points lower, so they don't pay off. Some larger datasets have features that work together: the effect of one depends on another. On data like that, boosted trees often come first. That's why it's worth searching across models, not only across settings.

The first new customer is a stress test. Their open rate is missing and their region is blank. Their channel, "podcast", never appeared in training. The pipeline handles all three with no extra code.

Go deeper What GridSearchCV keeps after the search

After it tries every setting, GridSearchCV refits the best one on all the rows you gave it. Here that's all the working data. The refitted pipeline is best_estimator_. Its settings are in best_params_, and every score is in cv_results_. When you call search.predict_proba, it uses best_estimator_ for you.

Quick check
How many different settings does the grid above compare?
  1. 35
  2. 3
  3. 12
  4. 7
Show the answer

7

Logistic regression has 3 values of C, the forest has 2 and the boosted trees have 2: 3 + 2 + 2 = 7. With 5 folds each, that's 35 training runs, plus one final refit of the winner.

Saving a model safely

joblib.dump writes the whole fitted pipeline to a file, and joblib.load reads it back. Two cautions:

  • Only load files you trust. Loading a joblib or pickle file can run code hidden inside it. Treat a model file from a stranger like a program from a stranger.
  • Pin the library versions. A model saved with one version of scikit-learn may not load under another. Or it may load and behave differently. Write down the exact versions next to the file, and use those same versions wherever you load it.

The workflow, start to finish

  1. Split off a test set first, and don't look at it.
  2. Put every preparation step and the model in one pipeline.
  3. Cross-validate on the rest to compare models and tune settings.
  4. Score the winner once on the test set, and report that number.
  5. If you like, refit on all the labeled data. Then save it with its versions, and score new rows through the same pipeline.
  6. Monitor it. Customers change over time, and this slow change is called drift. A churn model trained last year slowly goes stale.
Try it now

Every scikit-learn project follows this shape. Fit and predict look the same for every model.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why put the scaler inside the pipeline rather than scaling the data once up front?

  1. So it's refit on each training fold and the same steps run automatically at prediction time
  2. Because scikit-learn can't scale data on its own, and only a pipeline can call the scaler
  3. It makes the model more accurate on the training data, which is the score that matters most
  4. Pipelines skip scaling for categorical columns, which a scaler used alone would corrupt

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson