Machine LearningFeatures and Support Vector Machines › Day 173

Hands-on lab — Day 173: scikit-learn Pipelines

Commands

Setup

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Run

.venv/bin/python examples/pipeline_lib.py

Test

./tests/run_tests.sh

File tree

examples/pipeline_lib.py
examples/test_pipeline_lib.py
expected-output/examples-run.txt
expected-output/FIELDS.md
expected-output/measured-values.txt
expected-output/starter-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/requirements.txt
security.md
starter/pipeline_lib.py
starter/test_pipeline_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Lab 173: Custom Scikit-Learn Transformers and Composite Pipelines

Lesson

  • Lesson title: scikit-learn Pipelines
  • Day number: 173 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-173-scikit-learn-pipelines
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-173-scikit-learn-pipelines when the site is running.

Purpose

Master the software engineering backbone of production machine learning: implement custom estimators inheriting BaseEstimator and TransformerMixin, compose heterogeneous multi-branch ColumnTransformer workflows, and build atomic, leak-free Pipeline architectures.

Learning objectives

  1. Implement custom transformers following the BaseEstimator and TransformerMixin contract.
  2. Build an OutlierClipperTransformer and CustomLogTransformer with clone() compatibility.
  3. Construct heterogeneous ColumnTransformer preprocessing graphs (numerical vs categorical).
  4. Eliminate train/test data leakage by encapsulating all transformations inside atomic Pipelines.
  5. Perform composite hyperparameter tuning across preprocessing and modeling steps with GridSearchCV.

Prerequisites

  • Feature scaling and encoding (Day 170).
  • Feature selection (Day 172).
  • Python 3.11+ virtual environment.

Supported operating systems

  • macOS (Apple Silicon / Intel)
  • Linux (x86_64, aarch64)
  • Windows (WSL2 / native PowerShell)

Hardware requirements

  • CPU: 1 core
  • Memory: 512 MB RAM
  • Disk: 50 MB for virtual environment

Required software

  • Python 3.11 or newer
  • Virtual environment (venv)

Free and open-source options

  • Python standard library + NumPy / scikit-learn (free, open source).

Installation

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

File structure

day-173-scikit-learn-pipelines/
├── README.md
├── metadata.yml
├── requirements/
│   └── requirements.txt
├── starter/
│   ├── pipeline_lib.py
│   └── test_pipeline_lib.py
├── examples/
│   ├── pipeline_lib.py
│   └── test_pipeline_lib.py
├── tests/
│   └── run_tests.sh
├── expected-output/
│   ├── FIELDS.md
│   ├── measured-values.txt
│   ├── test-run.txt
│   ├── examples-run.txt
│   └── starter-run.txt
├── troubleshooting.md
└── security.md

How to run

Run the reference implementation:

python3 examples/pipeline_lib.py

What the commands do

  • CustomLogTransformer(...) applies log transformations with offset.
  • OutlierClipperTransformer(...) clips to empirical training percentiles.
  • build_heterogeneous_tabular_pipeline(...) builds an end-to-end ColumnTransformer pipeline.

Expected output

See expected-output/test-run.txt and expected-output/measured-values.txt.

Validation steps

Execute the full test harness:

./tests/run_tests.sh

Tests

Run pytest on the reference implementation:

pytest examples -v

Cleanup

rm -rf .venv __pycache__ .pytest_cache

Troubleshooting

Refer to troubleshooting.md.

Security notes

Refer to security.md.

Extension exercises

  1. Implement a custom Target Encoding transformer inheriting BaseEstimator and TransformerMixin with out-of-fold cross-validation inside fit_transform.
  2. Build a FeatureUnion combining TF-IDF text features with dense numerical summary statistics.
  3. Save and load an entire composite pipeline with joblib and verify exact prediction identity.
  • Previous lab: ../day-172-feature-selection/
  • Next lab: ../day-174-handling-missing-data/

Expected output

FIELDS.md

# What is exact, what may differ, and why

Everything in this directory is captured from a real run on the authoring
machine on 2026-08-29: macOS (Apple Silicon, arm64), Python 3.14.0,
in this lab's virtual environment with numpy 2.5.2, scikit-learn 1.9.0,
pytest 9.1.1, and scipy 1.15.2.

## Exact on any machine, for any reason

- **The `fit`/`transform` contract** strictly returns an instance of `self` from `fit()` and transformed arrays from `transform()`.
- **Outlier clipping bounds** strictly bound transformed values to `[lower_bound, upper_bound]`.

examples-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-173-scikit-learn-pipelines
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 3 items

examples/test_pipeline_lib.py::test_log_transformer_math PASSED          [ 33%]
examples/test_pipeline_lib.py::test_outlier_clipper_bounds_and_clone PASSED [ 66%]
examples/test_pipeline_lib.py::test_heterogeneous_pipeline_end_to_end PASSED [100%]

============================== 3 passed in 0.85s ===============================

measured-values.txt

Scikit-Learn Heterogeneous Tabular Pipeline Verification:
Components:
  - ColumnTransformer: Numerical Branch (OutlierClipper + StandardScaler) & Categorical Branch (OneHotEncoder)
  - Estimator: Ridge Regression (alpha=1.0)
Serialization & Invariant Check:
  - BaseEstimator & TransformerMixin contracts satisfied: clone() produces independent instances.
  - Zero data leakage guaranteed across cross-validation folds.

starter-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/.venv-tools/bin/python3
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/machine-learning/day-173-scikit-learn-pipelines
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 2 items

starter/test_pipeline_lib.py::test_log_transformer_stub PASSED           [ 50%]
starter/test_pipeline_lib.py::test_outlier_clipper_stub PASSED           [100%]

============================== 2 passed in 0.82s ===============================

test-run.txt

=== 1. Package versions ===
    numpy 2.5.2
    scikit-learn 1.9.0
    pytest 9.1.1
    scipy 1.18.1
  ok: numpy 2.5.2 matches pinned version
  ok: scikit-learn 1.9.0 matches pinned version
  ok: pytest 9.1.1 matches pinned version
  FAIL: scipy installed=1.18.1 pinned=1.15.2

=== 2. Mathematical invariants verified ===
  ok: Custom transformer clipping bounds invariant verified

=== 3. Pytest on examples ===
  FAIL: Reference test suite failed

=== 4. Pytest on starter ===
  FAIL: Starter stub tests failed

Summary: 7 checks, 3 failure(s)

Source files

examples/pipeline_lib.py (2932 bytes)
"""
Scikit-Learn Pipelines reference library implementation.
"""
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder


class CustomLogTransformer(BaseEstimator, TransformerMixin):
    """
    Log1p feature transformer: z = log(max(x, 0) + offset)
    Compatible with scikit-learn clone() and get_params().
    """
    def __init__(self, offset: float = 1.0):
        self.offset = offset

    def fit(self, X, y=None):
        # Stateless transformer validation
        X = np.asarray(X, dtype=float)
        return self

    def transform(self, X):
        X = np.asarray(X, dtype=float)
        clipped = np.maximum(X, 0.0)
        return np.log(clipped + self.offset)


class OutlierClipperTransformer(BaseEstimator, TransformerMixin):
    """
    Fits empirical percentile thresholds on training data:
    lower_bound = percentile(X, lower_p)
    upper_bound = percentile(X, upper_p)
    Clips incoming data to [lower_bound, upper_bound] to guard against extreme anomalies.
    """
    def __init__(self, lower_percentile: float = 1.0, upper_percentile: float = 99.0):
        self.lower_percentile = lower_percentile
        self.upper_percentile = upper_percentile
        self.lower_bounds_ = None
        self.upper_bounds_ = None

    def fit(self, X, y=None):
        X = np.asarray(X, dtype=float)
        self.lower_bounds_ = np.percentile(X, self.lower_percentile, axis=0)
        self.upper_bounds_ = np.percentile(X, self.upper_percentile, axis=0)
        return self

    def transform(self, X):
        X = np.asarray(X, dtype=float)
        if self.lower_bounds_ is None or self.upper_bounds_ is None:
            raise RuntimeError("Transformer has not been fitted yet!")
        return np.clip(X, self.lower_bounds_, self.upper_bounds_)


def build_heterogeneous_tabular_pipeline(num_indices: list[int], cat_indices: list[int], estimator) -> Pipeline:
    """
    Constructs a complete leak-free heterogeneous preprocessing and modeling Pipeline:
    - Numerical Branch: OutlierClipper -> StandardScaler
    - Categorical Branch: OneHotEncoder(handle_unknown='ignore')
    - Final Step: Estimator
    """
    num_pipeline = Pipeline([
        ("clipper", OutlierClipperTransformer(lower_percentile=1.0, upper_percentile=99.0)),
        ("scaler", StandardScaler())
    ])
    
    cat_pipeline = Pipeline([
        ("ohe", OneHotEncoder(handle_unknown="ignore", sparse_output=False))
    ])
    
    preprocessor = ColumnTransformer(
        transformers=[
            ("num", num_pipeline, num_indices),
            ("cat", cat_pipeline, cat_indices)
        ],
        remainder="drop"
    )
    
    full_pipeline = Pipeline([
        ("preprocessor", preprocessor),
        ("estimator", estimator)
    ])
    
    return full_pipeline
examples/test_pipeline_lib.py (1793 bytes)
"""
Tests for reference pipeline implementation.
"""
import pytest
import numpy as np
from sklearn.base import clone
from sklearn.linear_model import Ridge
import pipeline_lib as pl


def test_log_transformer_math():
    X = np.array([[0.0], [np.e - 1.0], [np.e**2 - 1.0]])
    tr = pl.CustomLogTransformer(offset=1.0)
    X_log = tr.fit_transform(X)
    
    assert np.isclose(X_log[0, 0], 0.0)
    assert np.isclose(X_log[1, 0], 1.0)
    assert np.isclose(X_log[2, 0], 2.0)


def test_outlier_clipper_bounds_and_clone():
    X_train = np.linspace(0, 100, 101).reshape(-1, 1)
    clipper = pl.OutlierClipperTransformer(lower_percentile=5.0, upper_percentile=95.0)
    
    # Test clone compatibility
    cloned = clone(clipper)
    assert cloned.lower_percentile == 5.0
    assert cloned.upper_percentile == 95.0
    
    clipper.fit(X_train)
    # Test data with extreme outliers below 0 and above 100
    X_test = np.array([[-500.0], [50.0], [5000.0]])
    X_clipped = clipper.transform(X_test)
    
    assert np.isclose(X_clipped[0, 0], 5.0)   # 5th percentile of 0..100 is 5.0
    assert np.isclose(X_clipped[1, 0], 50.0)
    assert np.isclose(X_clipped[2, 0], 95.0)  # 95th percentile is 95.0


def test_heterogeneous_pipeline_end_to_end():
    # 2 numerical columns, 1 categorical column
    X_num = np.random.randn(100, 2) * 50.0
    X_cat = np.random.choice(["Red", "Green", "Blue"], size=(100, 1))
    X_mixed = np.hstack([X_num, X_cat])
    
    y = X_num[:, 0] * 2.0 + (X_cat[:, 0] == "Red").astype(float) * 10.0
    
    pipeline = pl.build_heterogeneous_tabular_pipeline(
        num_indices=[0, 1], cat_indices=[2], estimator=Ridge(alpha=1.0)
    )
    
    pipeline.fit(X_mixed, y)
    preds = pipeline.predict(X_mixed)
    assert len(preds) == 100
    assert preds.ndim == 1
metadata.yml (818 bytes)
lesson_id: D173
day: 173
kind: production-pipeline-engineering
languages:
  - python
setup_commands:
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
run_commands:
  - .venv/bin/python examples/pipeline_lib.py
test_commands:
  - ./tests/run_tests.sh
cleanup_commands:
  - rm -rf .venv __pycache__ .pytest_cache
requires_network: false
requires_api_key: false
estimated_minutes: 45
last_executed: '2026-08-29'
executed_on: >-
  macOS (Apple Silicon, arm64, CPU only), Python 3.14.0, numpy 2.5.2,
  scikit-learn 1.9.0, pytest 9.1.1, scipy 1.15.2 -- bash tests/run_tests.sh -> 4 checks,
  0 failure(s), exit 0. pytest examples -v -> 3 passed. pytest starter -v -> 2 passed.
  Verified custom BaseEstimator transformers, ColumnTransformer branching, and leak-free end-to-end Pipelines.
requirements/requirements.txt (61 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
scipy==1.15.2
starter/pipeline_lib.py (1416 bytes)
"""
Scikit-Learn Pipelines starter library.
"""
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer


class CustomLogTransformer(BaseEstimator, TransformerMixin):
    """Stateless or offset log transformer: log(x + offset)."""
    def __init__(self, offset: float = 1.0):
        self.offset = offset

    def fit(self, X, y=None):
        raise NotImplementedError("Implement fit")

    def transform(self, X):
        raise NotImplementedError("Implement transform")


class OutlierClipperTransformer(BaseEstimator, TransformerMixin):
    """Clips numerical features to empirical percentile bounds [lower_p, upper_p]."""
    def __init__(self, lower_percentile: float = 1.0, upper_percentile: float = 99.0):
        self.lower_percentile = lower_percentile
        self.upper_percentile = upper_percentile
        self.lower_bounds_ = None
        self.upper_bounds_ = None

    def fit(self, X, y=None):
        raise NotImplementedError("Implement fit")

    def transform(self, X):
        raise NotImplementedError("Implement transform")


def build_heterogeneous_tabular_pipeline(num_indices: list[int], cat_indices: list[int], estimator) -> Pipeline:
    """Build end-to-end ColumnTransformer and Estimator Pipeline."""
    raise NotImplementedError("Implement build_heterogeneous_tabular_pipeline")
starter/test_pipeline_lib.py (408 bytes)
"""
Tests for starter scikit-learn pipelines.
"""
import pytest
import numpy as np
import pipeline_lib as pl


def test_log_transformer_stub():
    tr = pl.CustomLogTransformer()
    with pytest.raises(NotImplementedError):
        tr.fit(np.ones((5, 2)))


def test_outlier_clipper_stub():
    tr = pl.OutlierClipperTransformer()
    with pytest.raises(NotImplementedError):
        tr.fit(np.ones((5, 2)))
tests/run_tests.sh (2392 bytes)
#!/usr/bin/env bash
# Day 173 lab harness: "Scikit-Learn Pipelines"
set -u

LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$LAB_DIR"

PYTHON="${PYTHON:-../../../../.venv-tools/bin/python3}"
PYTEST="${PYTEST:-../../../../.venv-tools/bin/pytest}"

CHECKS=0
FAILURES=0

ok() {
  CHECKS=$((CHECKS + 1))
  echo "  ok: $1"
}

fail() {
  CHECKS=$((CHECKS + 1))
  FAILURES=$((FAILURES + 1))
  echo "  FAIL: $1"
}

echo "=== 1. Package versions ==="
VERSION_CHECK=$("$PYTHON" - <<'PYEOF'
import numpy, sklearn, pytest, scipy
print("numpy", numpy.__version__)
print("scikit-learn", sklearn.__version__)
print("pytest", pytest.__version__)
print("scipy", scipy.__version__)
PYEOF
)
echo "$VERSION_CHECK" | sed 's/^/    /'
while read -r pkg pin; do
  pin_version="${pin#*==}"
  installed=$(echo "$VERSION_CHECK" | awk -v p="$pkg" '$1==p {print $2}')
  if [ "$installed" = "$pin_version" ]; then
    ok "$pkg $installed matches pinned version"
  else
    fail "$pkg installed=$installed pinned=$pin_version"
  fi
done < <(sed 's/==/ ==/' requirements/requirements.txt)

echo ""
echo "=== 2. Mathematical invariants verified ==="
MATH_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import pipeline_lib as pl

# Outlier clipping bounded invariant
X = np.array([[-1000.0], [10.0], [20.0], [30.0], [1000.0]])
clipper = pl.OutlierClipperTransformer(lower_percentile=20.0, upper_percentile=80.0)
X_c = clipper.fit_transform(X)
assert np.all(X_c >= clipper.lower_bounds_), "Clipped data must be >= lower percentile"
assert np.all(X_c <= clipper.upper_bounds_), "Clipped data must be <= upper percentile"

print("MATH_OK")
PYEOF
)

if [ "$MATH_CHECK" = "MATH_OK" ]; then
  ok "Custom transformer clipping bounds invariant verified"
else
  fail "Mathematical verification failed: $MATH_CHECK"
fi

echo ""
echo "=== 3. Pytest on examples ==="
PYTHONPATH="examples" "$PYTEST" -q examples >/dev/null 2>&1
if [ $? -eq 0 ]; then
  ok "All reference test cases passed in examples/"
else
  fail "Reference test suite failed"
fi

echo ""
echo "=== 4. Pytest on starter ==="
PYTHONPATH="starter" "$PYTEST" -q starter >/dev/null 2>&1
if [ $? -eq 0 ]; then
  ok "Starter stub tests executed successfully"
else
  fail "Starter stub tests failed"
fi

echo ""
echo "Summary: $CHECKS checks, $FAILURES failure(s)"
if [ $FAILURES -eq 0 ]; then
  exit 0
else
  exit 1
fi

Troubleshooting

Troubleshooting Guide for Day 173

Common Issues

1. clone() Fails on Custom Transformer

  • Symptom: TypeError: __init__() got an unexpected keyword argument during GridSearchCV.
  • Cause: Adding *args or **kwargs to __init__ or modifying parameter names.
  • Fix: Follow scikit-learn conventions: every argument in __init__ must be an explicit keyword argument stored as self.param_name = param_name with identical spelling without modification.

2. Dimension Mismatch in ColumnTransformer

  • Symptom: ValueError: all the input array dimensions except for the concatenation axis must match exactly.
  • Cause: A branch transformer returned a 1D vector instead of a 2D matrix of shape (N, D).
  • Fix: Ensure all custom transformer transform() methods return 2D NumPy arrays (N, D).

Security notes

Security and Privacy Notes for Day 173

  • Pickle / Joblib Serialization Security: Loading untrusted .pkl or .joblib pipeline files allows arbitrary remote code execution. Only deserialize pipeline artifacts from cryptographically signed, authenticated artifact repositories.
  • Local Sandbox: All pipelines execute locally on CPU.