Your First ML Pipeline
1 · The lesson
readReal ML code breaks easily. You scale the training data, fit a model, then forget to scale the test data. Or you scale all the data before splitting, leaking the test set's statistics into training. Or you save just the model and discover at deployment time that you never serialised the encoder.
Each of these is a bug you can't see: the model still runs, the numbers still look reasonable, but the predictions are wrong and nothing warns you.
scikit-learn's Pipeline is the fix. It chains the preprocessing steps and the final model into a single estimator that fits, predicts, and is saved as one unit. Make this a habit from the start, and you will avoid many hours of debugging.
Run these right here: scikit-learn, pandas and joblib all work in the browser. The first scikit-learn import takes a few seconds while it downloads. The one block that reads
data.csvis a template for your own data, so it needs a CSV of your own. Expected output is also shown in comments below each block.
1. The Problem Pipelines Solve
Here's the fragile version. Spot the bugs.
# DON'T do this from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # ❌ fits on ALL data (leakage) X_train, X_test, y_train, y_test = train_test_split(X_scaled, y) model = LogisticRegression().fit(X_train, y_train) y_pred = model.predict(X_test) # OK by accident
setup added so this can run · defines X, train_test_split, y
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) X = _AutoMock('X') def train_test_split(*_a, **_kw): print('-> train_test_split() called') return _AutoMock('train_test_split()') y = _AutoMock('y')
Two faults:
1. Leakage: fit_transform on the whole X means the scaler saw the test set's mean and standard deviation. The test set is no longer untouched.
2. Easy to break at deployment: to predict on a single new row, you must remember to scale it with the same scaler. If the scaler isn't saved, your production code gives wrong results without any error.
A pipeline makes both bugs impossible, because of the way it is built.
2. The Pipeline API
A Pipeline is a list of (name, estimator) tuples. All steps except the last must be transformers (have .fit_transform()). The last is the estimator (has .fit()/.predict()).
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression pipe = Pipeline([ ("scaler", StandardScaler()), ("clf", LogisticRegression(max_iter=1000)), ])
Now pipe behaves like any other sklearn estimator:
pipe.fit(X_train, y_train) # scales train, fits classifier on scaled train pipe.predict(X_test) # scales test (with TRAIN stats), then predicts pipe.score(X_test, y_test) # the same: scales test with TRAIN stats, then scores
setup added so this can run · defines X_train, y_train, X_test, y_test, pipe
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) X_train = _AutoMock('X_train') y_train = _AutoMock('y_train') X_test = _AutoMock('X_test') y_test = _AutoMock('y_test') pipe = _AutoMock('pipe')
The scaler is fitted only on X_train. When you predict on X_test, the same fitted scaler transforms it. There is no leakage and no forgotten step.
from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split X, y = make_classification(n_samples=500, n_features=10, random_state=42) X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42) pipe.fit(X_train, y_train) print("Test accuracy:", pipe.score(X_test, y_test)) # → Test accuracy: 0.864
setup added so this can run · defines pipe
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) pipe = _AutoMock('pipe')
3. Mixed Types: ColumnTransformer
Real datasets have numeric and categorical columns that need different preprocessing: scaling for one, one-hot encoding for the other. ColumnTransformer routes each column to the right transformer, then a Pipeline chains the result into the model.
import pandas as pd from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression df = pd.DataFrame({ "age": [25, 32, 47, 51, 28, 39, 60, 22, 45, 33], "income": [45, 62, 88, 95, 41, 70, 110, 38, 79, 58], "country": ["UK", "US", "UK", "DE", "FR", "US", "DE", "FR", "UK", "US"], "bought": [0, 1, 1, 1, 0, 1, 1, 0, 1, 1], }) X = df.drop(columns="bought") y = df["bought"] numeric_features = ["age", "income"] categorical_features = ["country"] preprocessor = ColumnTransformer([ ("num", StandardScaler(), numeric_features), ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features), ]) pipe = Pipeline([ ("preprocess", preprocessor), ("clf", LogisticRegression(max_iter=1000)), ]) pipe.fit(X, y) print("Train score:", pipe.score(X, y)) # → Train score: 1.0
pipe.fit(X, y) now does all of this in order:
1. Scale age and income with StandardScaler.
2. One-hot encode country into four 0/1 columns, one each for UK, US, DE and FR.
3. Concatenate the result.
4. Fit the logistic regression on the combined feature matrix.
pipe.predict(new_row) applies the same steps in the same order, with the same fitted values. You don't need any extra code to connect the steps.
handle_unknown="ignore" on OneHotEncoder is essential in production. Without it, a country that wasn't in the training data makes the pipeline raise an error at prediction time.
4. Cross-Validation on a Pipeline
Because a pipeline is an estimator, you pass it to cross_val_score like any other model:
from sklearn.model_selection import cross_val_score scores = cross_val_score(pipe, X, y, cv=3) # 3 folds: one class has only 3 rows in this 10-row table print(f"CV mean: {scores.mean():.3f} ± {scores.std():.3f}") # → CV mean: 1.000 ± 0.000 (10 rows are far too few for a meaningful score. This only shows how it works.)
setup added so this can run · re-runs 5 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) pipe = _AutoMock('pipe') X = _AutoMock('X') y = _AutoMock('y') def train_test_split(*_a, **_kw): print('-> train_test_split() called') return _AutoMock('train_test_split()') X_train = _AutoMock('X_train') y_train = _AutoMock('y_train') X_test = _AutoMock('X_test') y_test = _AutoMock('y_test') # Earlier blocks on this page that this one builds on, re-run quietly first. try: from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split import pandas as pd from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder except Exception: pass _pm_earlier = [ "# DON'T do this\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.linear_model import LogisticRegression\n\nscaler = StandardScaler()\nX_scaled = scaler.fit_transform(X) # ❌ fits on ALL data (leakage)\nX_train, X_test, y_train, y_test = train_test_split(X_scaled, y)\n\nmodel = LogisticRegression().fit(X_train, y_train)\ny_pred = model.predict(X_test) # OK by accident", 'from sklearn.pipeline import Pipeline\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.linear_model import LogisticRegression\n\npipe = Pipeline([\n ("scaler", StandardScaler()),\n ("clf", LogisticRegression(max_iter=1000)),\n])', 'pipe.fit(X_train, y_train) # scales train, fits classifier on scaled train\npipe.predict(X_test) # scales test (with TRAIN stats), then predicts\npipe.score(X_test, y_test) # the same: scales test with TRAIN stats, then scores', 'from sklearn.datasets import make_classification\nfrom sklearn.model_selection import train_test_split\n\nX, y = make_classification(n_samples=500, n_features=10, random_state=42)\nX_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)\n\npipe.fit(X_train, y_train)\nprint("Test accuracy:", pipe.score(X_test, y_test))\n# → Test accuracy: 0.864', 'import pandas as pd\nfrom sklearn.compose import ColumnTransformer\nfrom sklearn.preprocessing import StandardScaler, OneHotEncoder\nfrom sklearn.pipeline import Pipeline\nfrom sklearn.linear_model import LogisticRegression\n\ndf = pd.DataFrame({\n "age": [25, 32, 47, 51, 28, 39, 60, 22, 45, 33],\n "income": [45, 62, 88, 95, 41, 70, 110, 38, 79, 58],\n "country": ["UK", "US", "UK", "DE", "FR", "US", "DE", "FR", "UK", "US"],\n "bought": [0, 1, 1, 1, 0, 1, 1, 0, 1, 1],\n})\nX = df.drop(columns="bought")\ny = df["bought"]\n\nnumeric_features = ["age", "income"]\ncategorical_features = ["country"]\n\npreprocessor = ColumnTransformer([\n ("num", StandardScaler(), numeric_features),\n ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),\n])\n\npipe = Pipeline([\n ("preprocess", preprocessor),\n ("clf", LogisticRegression(max_iter=1000)),\n])\n\npipe.fit(X, y)\nprint("Train score:", pipe.score(X, y))\n# → Train score: 1.0', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
The important point: in each fold, the pipeline fits the scaler again on only that fold's training data. Cross-validation on a pipeline can't leak by design. Cross-validation on a dataset that you scaled by hand can easily leak.
This alone is a good reason to use a pipeline from the very beginning.
5. Hyperparameter Tuning with GridSearchCV
Pipelines work with GridSearchCV. The key is the double-underscore parameter syntax: stepname__paramname.
from sklearn.model_selection import GridSearchCV param_grid = { "clf__C": [0.01, 0.1, 1, 10], # logistic regression's regularisation "clf__solver": ["liblinear", "lbfgs"], } search = GridSearchCV(pipe, param_grid, cv=3, scoring="accuracy") # 3 folds, same reason as above search.fit(X, y) print("Best params:", search.best_params_) print("Best CV: ", round(search.best_score_, 3)) # → Best params: {'clf__C': 1, 'clf__solver': 'liblinear'} # → Best CV: 1.0
setup added so this can run · re-runs 5 earlier blocks on this page first
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) pipe = _AutoMock('pipe') X = _AutoMock('X') y = _AutoMock('y') def train_test_split(*_a, **_kw): print('-> train_test_split() called') return _AutoMock('train_test_split()') X_train = _AutoMock('X_train') y_train = _AutoMock('y_train') X_test = _AutoMock('X_test') y_test = _AutoMock('y_test') # Earlier blocks on this page that this one builds on, re-run quietly first. try: from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split import pandas as pd from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder except Exception: pass _pm_earlier = [ "# DON'T do this\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.linear_model import LogisticRegression\n\nscaler = StandardScaler()\nX_scaled = scaler.fit_transform(X) # ❌ fits on ALL data (leakage)\nX_train, X_test, y_train, y_test = train_test_split(X_scaled, y)\n\nmodel = LogisticRegression().fit(X_train, y_train)\ny_pred = model.predict(X_test) # OK by accident", 'from sklearn.pipeline import Pipeline\nfrom sklearn.preprocessing import StandardScaler\nfrom sklearn.linear_model import LogisticRegression\n\npipe = Pipeline([\n ("scaler", StandardScaler()),\n ("clf", LogisticRegression(max_iter=1000)),\n])', 'pipe.fit(X_train, y_train) # scales train, fits classifier on scaled train\npipe.predict(X_test) # scales test (with TRAIN stats), then predicts\npipe.score(X_test, y_test) # the same: scales test with TRAIN stats, then scores', 'from sklearn.datasets import make_classification\nfrom sklearn.model_selection import train_test_split\n\nX, y = make_classification(n_samples=500, n_features=10, random_state=42)\nX_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)\n\npipe.fit(X_train, y_train)\nprint("Test accuracy:", pipe.score(X_test, y_test))\n# → Test accuracy: 0.864', 'import pandas as pd\nfrom sklearn.compose import ColumnTransformer\nfrom sklearn.preprocessing import StandardScaler, OneHotEncoder\nfrom sklearn.pipeline import Pipeline\nfrom sklearn.linear_model import LogisticRegression\n\ndf = pd.DataFrame({\n "age": [25, 32, 47, 51, 28, 39, 60, 22, 45, 33],\n "income": [45, 62, 88, 95, 41, 70, 110, 38, 79, 58],\n "country": ["UK", "US", "UK", "DE", "FR", "US", "DE", "FR", "UK", "US"],\n "bought": [0, 1, 1, 1, 0, 1, 1, 0, 1, 1],\n})\nX = df.drop(columns="bought")\ny = df["bought"]\n\nnumeric_features = ["age", "income"]\ncategorical_features = ["country"]\n\npreprocessor = ColumnTransformer([\n ("num", StandardScaler(), numeric_features),\n ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_features),\n])\n\npipe = Pipeline([\n ("preprocess", preprocessor),\n ("clf", LogisticRegression(max_iter=1000)),\n])\n\npipe.fit(X, y)\nprint("Train score:", pipe.score(X, y))\n# → Train score: 1.0', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
clf__C reads as "the C parameter of the step named clf". Same pattern for any nested parameter: preprocess__num__with_mean, preprocess__cat__drop. Once you know the rule you can tune anything in the pipeline.
search.best_estimator_ is the best pipeline, fitted again on all the data you passed to search.fit, and ready to use. search itself also behaves like an estimator: search.predict(X_new) works directly.
6. Saving a Trained Pipeline
This is the main benefit: the whole fitted pipeline (preprocessing, model, everything) is saved as one object.
import joblib joblib.dump(pipe, "model.joblib") # Months later, in a totally different script or service: loaded = joblib.load("model.joblib") loaded.predict(X_new) # applies exactly the same scaling, encoding and model
setup added so this can run · defines pipe, X_new
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) pipe = _AutoMock('pipe') X_new = _AutoMock('X_new')
scikit-learn recommends joblib for saving models. It handles NumPy arrays efficiently, and you use it the same way as pickle.
Watch the versions: saved models depend on the library versions you trained with. If your production environment has scikit-learn 1.2 and you trained on 1.5, expect warnings or errors. Pin your versions in requirements.txt and ideally store the version alongside the model file.
Forward link: shipping this model behind an API is covered in deployment. Pipelines make deployment easy: you ship one object, not a collection of separate preprocessing scripts.
7. The End-to-End Script
Here's the smallest complete ML script for real-world use. Learn its structure well.
import joblib import pandas as pd from sklearn.model_selection import train_test_split, cross_val_score from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.pipeline import Pipeline from sklearn.ensemble import RandomForestClassifier # 1. Load df = pd.read_csv("data.csv") X = df.drop(columns="target") y = df["target"] # 2. Split X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42) # 3. Pipeline numeric_cols = X.select_dtypes("number").columns.tolist() categorical_cols = X.select_dtypes(exclude="number").columns.tolist() pre = ColumnTransformer([ ("num", StandardScaler(), numeric_cols), ("cat", OneHotEncoder(handle_unknown="ignore"), categorical_cols), ]) pipe = Pipeline([ ("pre", pre), ("clf", RandomForestClassifier(n_estimators=200, random_state=42)), ]) # 4. Validate cv = cross_val_score(pipe, X_train, y_train, cv=5) print(f"CV: {cv.mean():.3f} ± {cv.std():.3f}") # 5. Fit on full training set, score on hold-out pipe.fit(X_train, y_train) print(f"Test: {pipe.score(X_test, y_test):.3f}") # 6. Save joblib.dump(pipe, "model.joblib")
Load CSV → split → pipeline → cross-validate → fit → score → save. With about 10 more lines (a FastAPI wrapper), this script can run in production. You will write versions of this script many times.
Common Mistakes
- Scaling before the split. As shown in Section 1:
scaler.fit_transform(X), then split. The test set's statistics leaked into training. Always scale inside the pipeline. - Manually re-doing transforms at inference. "I'll just one-hot encode the new row in my API handler." Don't. Load the pipeline and call
.predict(). Code you rewrite by hand slowly stops matching the training code, and the errors don't show. - Not saving the pipeline as one unit. Saving model and encoder separately, then forgetting to apply both. Make the pipeline the only thing you serialise.
- Forgetting
handle_unknown="ignore"onOneHotEncoder. The first unknown category at prediction time → error. - Not pinning library versions. Your
model.joblibmay fail to load after a routinepip install --upgrade. Alwayspip freeze > requirements.txtfor any model that's leaving your laptop.
🎯 Your Turn: Build a Complete Pipeline
Build a full mixed-type pipeline on inline data, cross-validate it, and save the trained version.
Inline data (one numeric, one categorical column, binary target):
import pandas as pd df = pd.DataFrame({ "age": [22, 25, 31, 35, 41, 47, 52, 58, 29, 33, 44, 50, 27, 38, 55], "country": ["UK","US","UK","DE","FR","US","DE","UK","FR","US","DE","UK","FR","US","DE"], "bought": [0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1], })
Your task:
1. Build a Pipeline with a ColumnTransformer (scale age, one-hot country) followed by LogisticRegression(max_iter=1000).
2. Run 5-fold cross_val_score on the whole df (no train/test split needed, because the data is small).
3. Fit the pipeline on all of df and save it to "pipeline.joblib".
4. Return round(cv_scores.mean(), 3).
import pandas as pd import joblib from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score def build_and_save_pipeline(): df = pd.DataFrame({ "age": [22, 25, 31, 35, 41, 47, 52, 58, 29, 33, 44, 50, 27, 38, 55], "country": ["UK","US","UK","DE","FR","US","DE","UK","FR","US","DE","UK","FR","US","DE"], "bought": [0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1], }) X = df.drop(columns="bought") y = df["bought"] # TODO 1: build a ColumnTransformer # ("num", StandardScaler(), ["age"]) # ("cat", OneHotEncoder(handle_unknown="ignore"), ["country"]) # TODO 2: build a Pipeline ("pre", preprocessor), ("clf", LogisticRegression(max_iter=1000)) # TODO 3: cv = cross_val_score(pipe, X, y, cv=5) # TODO 4: pipe.fit(X, y); joblib.dump(pipe, "pipeline.joblib") # TODO 5: return round(cv.mean(), 3) pass print(build_and_save_pipeline())
Hint 1: ColumnTransformer takes a list of tuples
Each tuple is(name, transformer, columns). columns is a list of column names, even if there's only one. ["age"] not "age".
Hint 2: save right before returning
pipe.fit(X, y) mutates pipe in place and also returns it. After fitting, joblib.dump(pipe, "pipeline.joblib"). Then return the rounded CV mean.
Show full solution
import pandas as pd import joblib from sklearn.compose import ColumnTransformer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score def build_and_save_pipeline(): df = pd.DataFrame({ "age": [22, 25, 31, 35, 41, 47, 52, 58, 29, 33, 44, 50, 27, 38, 55], "country": ["UK","US","UK","DE","FR","US","DE","UK","FR","US","DE","UK","FR","US","DE"], "bought": [0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1], }) X = df.drop(columns="bought") y = df["bought"] preprocessor = ColumnTransformer([ ("num", StandardScaler(), ["age"]), ("cat", OneHotEncoder(handle_unknown="ignore"), ["country"]), ]) pipe = Pipeline([ ("pre", preprocessor), ("clf", LogisticRegression(max_iter=1000)), ]) cv = cross_val_score(pipe, X, y, cv=5) pipe.fit(X, y) joblib.dump(pipe, "pipeline.joblib") return round(cv.mean(), 3) print(build_and_save_pipeline()) # → 0.867 (a small dataset, so your CV score will be noisy. Any value near this is fine.)
You now have a pipeline.joblib on disk. To use it from any other script:
import joblib, pandas as pd loaded = joblib.load("pipeline.joblib") new_customer = pd.DataFrame([{"age": 40, "country": "UK"}]) print(loaded.predict(new_customer)) # [1] print(loaded.predict_proba(new_customer)) # [[0.18 0.82]]
The loaded pipeline scales the age, one-hot-encodes the country with the exact categories it learned at training, and runs the classifier, all with one .predict() call. That is the main benefit of pipelines.
What You Learned
- A
Pipelinechains preprocessing and a model into one estimator. Same.fit()/.predict()/.score()API. - Pipelines prevent leakage by fitting the transformers again on each fold's training data. They also prevent mismatched preprocessing by applying the same transforms at prediction time.
ColumnTransformersends different columns to different transformers: scaling for numeric, one-hot for categorical.- Hyperparameter tuning uses the
step__paramdouble-underscore syntax insideGridSearchCV. joblib.dumpserialises the whole fitted pipeline as one file, andjoblib.loadbrings it back.- The end-to-end script is six steps: load → split → pipeline → cross-validate → fit → save.
You now have everything you need to build a working ML feature: data in, predictions out, model on disk, scoreable, reproducible.
Where to next:
- Trees and ensembles: more about random forests and gradient boosting.
- Deployment: wrap your
.joblibin a FastAPI endpoint. - Deep learning intro: for problems where classical ML on tables is not enough.
- LLMs and the OpenAI API: the other main branch of modern ML tools.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.