The scikit-learn workflow
Fit, predict and evaluate with the standard ML library.
Create a free account Try part of it here
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
- One set of buttons for everything
- Worked example: a home-made gap filler
- A pipeline for messy data
- Reaching a setting inside a pipeline
- Tune, compare, test once, save
- 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:
| Kind | Methods | Examples |
|---|---|---|
| Estimator (anything that learns) | fit(X, y) | all of the below |
| Predictor | predict, predict_proba, score | LogisticRegression, RandomForestClassifier, KMeans |
| Transformer | transform, fit_transform | StandardScaler, 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,predictgives answers for new rows.predict_probagives probabilities, andscoregrades the model. - A transformer prepares data. After
fit,transformchanges data the same way every time.fit_transformdoes 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: 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]))
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.
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
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_rateis blank for people who never subscribed. - Some text columns have blanks.
regionis 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
fitandpredict.
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:
- It loads the customers. It lists the number columns (
numeric) and the word columns (categorical). prepis the mail sorter.make_pipelinebuilds a short chain of steps for each chute.pipeis the recipe card: preparation first, then a logistic regression model.train_test_splitlocks away 20% of the customers as a test set. The other 80%, calledX_devandy_devhere, is your working data.stratify=ykeeps the share of customers who left about the same on both sides.cross_val_scoreruns 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.- 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.
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.
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.
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 ofmax_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.
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
jobliborpicklefile 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
- Split off a test set first, and don't look at it.
- Put every preparation step and the model in one pipeline.
- Cross-validate on the rest to compare models and tune settings.
- Score the winner once on the test set, and report that number.
- If you like, refit on all the labeled data. Then save it with its versions, and score new rows through the same pipeline.
- Monitor it. Customers change over time, and this slow change is called drift. A churn model trained last year slowly goes stale.
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
Try a quiz question
Why put the scaler inside the pipeline rather than scaling the data once up front?