Reproducible Training Pipelines

Suraj Kumar

Suraj Kumar

Last updated November 5, 2026·16 min read

MLOps course · lesson 11 of 24
  1. 1What Is MLOps? From Notebooks to Reliable Production
  2. 2MLOps Principles and Maturity: From Manual to Automated
  3. 3ML Fundamentals for MLOps: Splits, Leakage, and Metrics
  4. 4A Reproducible ML Project: Layout, Seeds, and Data Code
  5. 5Data Engineering Basics for ML: Ingestion, SQL, and Skew
  6. 6Pipeline Orchestration: Apache Airflow and Kubeflow
  7. 7Batch vs Streaming: Spark, Kafka, and Flink
  8. 8Data Versioning and Lineage: DVC and Beyond
  9. 9Data Validation and Quality in ML Pipelines
  10. 10Experiment Tracking with MLflow
  11. 11Reproducible Training Pipelines
  12. 12Model Evaluation and Validation Gates
  13. 13Model Registry and Versioning
  14. 14CI/CD for Machine Learning
  15. 15Containers and Kubernetes for ML
  16. 16Infrastructure as Code: Terraform and Ansible
  17. 17Cloud ML Platforms: AWS, Azure, GCP, and Self-Built
  18. 18Model Serving Patterns: Batch, Online, and Streaming
  19. 19Inference Engineering Basics: Latency, Batching, and Cost
  20. 20Monitoring and Observability for ML Models
  21. 21Drift Detection and Retraining for ML Models
  22. 22Explainable AI: LIME, SHAP, and Model Interpretability
  23. 23Edge AI Deployment: Jetson, TFLite, and Mobile ML
  24. 24Capstone: An End-to-End MLOps Pipeline
View full syllabus

In data science education, machine learning is often taught as a sequential series of notebook cells. You load a CSV into Pandas, inspect summary statistics, drop a few outliers in cell 12, fill nulls with column means in cell 14, scale features with an unlinked Standard Scaler in cell 18, and fit a classifier in cell 25.

When that exploratory code is handed over to an engineering team for deployment, disaster strikes. Notebook cells executed out of order leave hidden global state in Python's memory. Preprocessing transformations applied to the training dataset exist as disconnected scripts. When the production API service attempts to score live customer requests, backend developers must reverse-engineer the notebook and manually rewrite the feature transformations in Java, Go, or a separate Python microservice.

Inevitably, the serving code handles a null value slightly differently or applies scaling parameters derived from a different population. The result is training-serving skew: a catastrophic, silent decay in prediction accuracy between training and production.

In this lesson, you will learn how to transition from exploratory notebook experimentation to production-grade training pipelines. You will explore declarative pipeline configurations, understand how atomic transformers eliminate training-serving skew, study how scikit-learn pipelines unify preprocessing and modeling, and implement a fully tested, reproducible training pipeline in Python.

Why exploratory notebooks fail in production

Jupyter notebooks are superb environments for exploratory data analysis, data visualization, and rapid prototyping. However, their execution model conflicts directly with the requirements of software engineering and MLOps.

Comparing fragile out-of-order notebook exploration with a deterministic production pipeline

Three fundamental problems make raw notebooks unsuitable for production execution:

1. Non-linear execution and hidden state

A notebook does not enforce top-to-bottom execution. A developer might execute cell 10, jump down to cell 22 to test an imputation strategy, delete cell 15, and re-execute cell 12.

The Python kernel retains all variables, modified DataFrames, and monkey-patched functions in memory. If you save the notebook and hand it to a colleague, running "Restart Kernel and Run All" often crashes with NameError or produces different evaluation metrics. Production training must run as an immutable, non-interactive script executed from the command line.

2. Disconnected preprocessing artifacts

In many notebook workflows, feature transformers are created as standalone objects:

python
1# A common anti-pattern in notebook workflows
2scaler = StandardScaler()
3X_train_scaled = scaler.fit_transform(X_train)
4
5# Model is trained on the scaled array
6model = GradientBoostingClassifier()
7model.fit(X_train_scaled, y_train)
8
9# Only the model is saved to disk
10joblib.dump(model, "churn_model.pkl")

Notice the fatal flaw: only churn_model.pkl was saved. The fitted scaler object (which holds the exact mean and variance computed over X_train) was discarded in memory.

When the live API receives a raw customer payload {"tenure_months": 12, "monthly_spend": 149.50}, the model cannot score it directly because it expects standardized numerical inputs. If the serving team attempts to scale inputs using live request statistics or forgets to subtract the training mean, predictions become meaningless.

3. Hardcoded parameters and unpinned seeds

Notebook cells frequently hardcode file paths, split ratios, and hyperparameters directly into function calls. In addition, train-test splits and stochastic algorithms are often run without pinned seeds (random_state=None), guaranteeing that subsequent executions produce different data partitions and different model weights.

The atomic pipeline pattern

The solution to these operational failures is the Atomic Pipeline Pattern.

In an atomic pipeline, data preprocessing, feature imputation, categorical encoding, and model scoring are encapsulated into a single unified object. The pipeline exposes two primary methods:

  • fit(X, y): Learns transformation parameters (medians, means, standard deviations, categorical vocabularies) strictly from the training split, and fits the estimator weights.
  • predict(X) / predict_proba(X): Takes raw, un-transformed customer records as input, applies the exact learned transformations sequentially, and computes predictions.

When the training job finishes, you do not serialize the model weights in isolation. You serialize the entire pipeline object.

The serving service loads the single artifact. It feeds the raw incoming JSON payload directly into pipeline.predict(). The exact same transformation logic, down to the exact floating-point standard deviation learned during training, is executed in production. Training-serving skew is structurally impossible.

Declarative configuration: separating code from hyperparameters

A reproducible training pipeline never hardcodes execution parameters in source code. All hyperparameters, feature column lists, file paths, and random seeds must be externalized into a declarative configuration file.

Here is the declarative configuration used for StayWell's customer churn training pipeline:

yaml
1# StayWell Training Pipeline Configuration
2# File: configs/staywell_churn_v2.yaml
3# Environment: Production Retraining
4
5metadata:
6  pipeline_name: staywell-churn-training
7  version: 2.4.0
8  owner: mlops-team@staywell.internal
9
10data:
11  input_path: data/staged/staywell_features_20261104.parquet
12  output_artifact_dir: artifacts/models/v2.4.0/
13  dataset_hash: 7b9c1d0f8a32b1e459021cfa6b0091ef
14
15features:
16  numerical:
17    - tenure_months
18    - monthly_spend
19    - support_tickets
20  categorical:
21    - contract_type
22  target: churn
23
24training:
25  random_seed: 42
26  test_size: 0.20
27  learning_rate: 0.05
28  epochs: 80
29  l2_penalty: 0.01
30  decision_threshold: 0.35
31
32evaluation:
33  min_roc_auc_threshold: 0.85
34  max_latency_p95_ms: 5.0

By decoupling configuration from code, hyperparameter sweeps and scheduled weekly retrainings can be executed by passing a configuration file path to the training entry point:

bash
1python -m src.train --config configs/staywell_churn_v2.yaml

The scikit-learn pipeline ecosystem

In the Python data science ecosystem, scikit-learn provides the standard implementation of the atomic pipeline pattern through Pipeline and ColumnTransformer.

The official scikit-learn Pipeline Documentation (verified on 4 November 2026) explains how transformers and estimators chain together into a single composite estimator.

python
1# Production scikit-learn training pipeline for StayWell churn
2# Reference: scikit-learn compose API (from the docs, not run)
3import joblib
4from sklearn.compose import ColumnTransformer
5from sklearn.ensemble import GradientBoostingClassifier
6from sklearn.impute import SimpleImputer
7from sklearn.pipeline import Pipeline
8from sklearn.preprocessing import OneHotEncoder, StandardScaler
9
10def build_staywell_pipeline(config: dict) -> Pipeline:
11    # 1. Numerical preprocessor: impute missing values with median, then standardize
12    numeric_transformer = Pipeline(steps=[
13        ("imputer", SimpleImputer(strategy="median")),
14        ("scaler", StandardScaler())
15    ])
16
17    # 2. Categorical preprocessor: encode categories with one-hot vectors
18    categorical_transformer = Pipeline(steps=[
19        ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False))
20    ])
21
22    # 3. ColumnTransformer routes specific columns to their respective pipelines
23    preprocessor = ColumnTransformer(transformers=[
24        ("num", numeric_transformer, config["features"]["numerical"]),
25        ("cat", categorical_transformer, config["features"]["categorical"])
26    ])
27
28    # 4. Final pipeline bundles preprocessors and estimator
29    pipeline = Pipeline(steps=[
30        ("preprocessor", preprocessor),
31        ("classifier", GradientBoostingClassifier(
32            learning_rate=config["training"]["learning_rate"],
33            n_estimators=config["training"]["epochs"],
34            random_state=config["training"]["random_seed"]
35        ))
36    ])
37
38    return pipeline
39
40# Train and export atomic bundle
41pipeline = build_staywell_pipeline(config)
42pipeline.fit(X_train, y_train)
43
44# Save the entire pipeline: preprocessing transformers and classifier are serialized together
45joblib.dump(pipeline, "artifacts/models/staywell_pipeline.joblib")

When saved with joblib.dump(), the output file contains the ColumnTransformer, the SimpleImputer medians, the StandardScaler means and scales, the OneHotEncoder categories, and the GradientBoostingClassifier tree weights. Loading this single file restores the complete inference graph.

What is saved with a model: the complete artifact bundle

Saving model weights alone is insufficient for long-term production reliability. If library dependencies change across minor Python releases, unpickling an artifact can fail or alter numerical behavior.

The complete production model package bundling transformers, weights, signature, config, manifest, and lineage

A production model deployment package contains six essential components:

  1. Fitted Transformers: Imputer values, standardizer means, variance scales, and categorical dictionary lookups learned exclusively from the training split.
  2. Estimator Weights: Algorithm weights, tree structures, feature importance vectors, and calibrated decision thresholds.
  3. Model Signature and Schema Contract: Declarative specifications of input column names, expected data types, and output score formats. Frameworks like MLflow enforce this contract during live requests.
  4. Pipeline Configuration: The frozen declarative YAML or JSON configuration file used to launch the training run.
  5. Environment Manifest: Pinned requirements.txt or conda.yaml specifying exact library versions down to the patch release, alongside base Docker container digests.
  6. Lineage and Provenance Hashes: The Git commit SHA of the training codebase, the cryptographic hash of the input dataset, and the training timestamp.

The limits of determinism in machine learning

Engineers coming from traditional software often ask: if we pin the random seed, pin library dependencies, and hash the input data, will model training produce bit-for-bit identical weights on every machine?

The realistic answer is: mostly, but with hardware and parallelism caveats.

While single-threaded CPU algorithms (like scikit-learn with n_jobs=1 and a fixed random_state) are fully deterministic on the same processor architecture, subtle non-determinism can arise from:

  • Multi-threaded CPU reductions: Multi-threaded BLAS/LAPACK operations (such as OpenBLAS or Intel MKL) perform floating-point additions across worker threads in non-deterministic order. Because floating-point addition is non-associative ((a+b)+c≠a+(b+c)(a + b) + c \neq a + (b + c) due to rounding), different thread schedules introduce minute floating-point variances.
  • GPU parallel reductions: In deep learning frameworks (PyTorch and TensorFlow), atomic operations on CUDA cores accumulate gradients asynchronously, causing minor variances across identical training runs unless deterministic flags (torch.use_deterministic_algorithms(True)) are enabled at a performance penalty.
  • Architecture differences: Running the same training script on an Apple Silicon ARM chip versus an Intel x86_64 cloud server can produce minor precision differences in transcendental mathematical functions (exp, log, sin).

For classical tabular MLOps, pinning seeds and running single-node deterministic routines ensures practical reproducibility: evaluation metrics remain consistent to multiple decimal places.

Hands-on implementation: the StayWell training pipeline

To demonstrate how an atomic pipeline operates from first principles without relying on third-party frameworks, let us implement a complete, reproducible training pipeline using the Python standard library.

This implementation provides:

  • A declarative PipelineConfig dataclass
  • Numerical median imputation and standard scaling
  • Categorical one-hot encoding with sorted deterministic vocabularies
  • Logistic regression classifier with gradient descent and pinned random seed
  • Deterministic training: identical seeds produce bit-for-bit identical weights
  • Complete bundle export and import: saving transformers, weights, and metadata into a JSON package ready for live serving

The pipeline implementation (training_pipeline.py)

Here is the complete implementation:

training_pipeline.py

python
1"""Self-contained reproducible machine learning training pipeline for StayWell.
2
3Implements an atomic pipeline with feature imputation, numerical scaling,
4categorical one-hot encoding, model fitting, deterministic seed locking,
5and model artifact packaging with full metadata.
6"""
7
8from __future__ import annotations
9
10import dataclasses
11import json
12import math
13import random
14from pathlib import Path
15from typing import Any, Dict, List, Optional
16
17
18def sigmoid(z: float) -> float:
19    """Standard numerically stable sigmoid function."""
20    if z < -40.0:
21        return 0.0
22    if z > 40.0:
23        return 1.0
24    return 1.0 / (1.0 + math.exp(-z))
25
26
27@dataclasses.dataclass(frozen=True)
28class PipelineConfig:
29    """Declarative configuration parameters for the training pipeline."""
30    numerical_features: List[str]
31    categorical_features: List[str]
32    target_column: str
33    random_seed: int = 42
34    learning_rate: float = 0.05
35    epochs: int = 80
36    l2_penalty: float = 0.01
37    decision_threshold: float = 0.35
38
39
40class StayWellChurnPipeline:
41    """Unified pipeline bundling preprocessing transformers, estimator weights, and metadata."""
42
43    def __init__(self, config: PipelineConfig) -> None:
44        self.config = config
45        self.is_fitted = False
46
47        # Fitted transformer state
48        self.medians_: Dict[str, float] = {}
49        self.means_: Dict[str, float] = {}
50        self.stds_: Dict[str, float] = {}
51        self.categories_: Dict[str, List[str]] = {}
52
53        # Fitted estimator state
54        self.weights_: List[float] = []
55        self.bias_: float = 0.0
56        self.feature_names_: List[str] = []
57
58    def fit(self, dataset: List[Dict[str, Any]]) -> StayWellChurnPipeline:
59        """Fit all transformers and estimator weights deterministically."""
60        # 1. Fit numerical imputers (median)
61        for col in self.config.numerical_features:
62            vals = [float(row[col]) for row in dataset if row.get(col) is not None]
63            vals.sort()
64            n = len(vals)
65            med = vals[n // 2] if n % 2 != 0 else (vals[n // 2 - 1] + vals[n // 2]) / 2.0
66            self.medians_[col] = med
67
68        # 2. Impute and fit scalers (mean, std)
69        for col in self.config.numerical_features:
70            imputed = [float(row[col]) if row.get(col) is not None else self.medians_[col] for row in dataset]
71            mean_val = sum(imputed) / len(imputed)
72            var_val = sum((x - mean_val) ** 2 for x in imputed) / len(imputed)
73            std_val = math.sqrt(var_val) if var_val > 1e-9 else 1.0
74            self.means_[col] = mean_val
75            self.stds_[col] = std_val
76
77        # 3. Fit categorical encoders
78        for col in self.config.categorical_features:
79            cats = sorted({str(row[col]) for row in dataset if row.get(col) is not None})
80            self.categories_[col] = cats
81
82        # 4. Construct feature matrix columns
83        self.feature_names_ = []
84        for col in self.config.numerical_features:
85            self.feature_names_.append(col)
86        for col in self.config.categorical_features:
87            for cat in self.categories_[col]:
88                self.feature_names_.append(f"{col}_{cat}")
89
90        # Transform entire training dataset
91        X: List[List[float]] = []
92        y: List[float] = []
93        for row in dataset:
94            vec = self._transform_row(row)
95            X.append(vec)
96            target_val = float(row[self.config.target_column])
97            y.append(target_val)
98
99        # 5. Fit logistic regression using gradient descent with fixed seed
100        rng = random.Random(self.config.random_seed)
101        num_features = len(self.feature_names_)
102        self.weights_ = [rng.gauss(0.0, 0.01) for _ in range(num_features)]
103        self.bias_ = 0.0
104
105        n_samples = len(X)
106        for _ in range(self.config.epochs):
107            grad_w = [0.0] * num_features
108            grad_b = 0.0
109
110            for i in range(n_samples):
111                linear = sum(self.weights_[j] * X[i][j] for j in range(num_features)) + self.bias_
112                p = sigmoid(linear)
113                err = p - y[i]
114                for j in range(num_features):
115                    grad_w[j] += err * X[i][j]
116                grad_b += err
117
118            # Gradient update with L2 regularization
119            lr = self.config.learning_rate
120            l2 = self.config.l2_penalty
121            for j in range(num_features):
122                self.weights_[j] -= lr * ((grad_w[j] / n_samples) + l2 * self.weights_[j])
123            self.bias_ -= lr * (grad_b / n_samples)
124
125        self.is_fitted = True
126        return self
127
128    def _transform_row(self, row: Dict[str, Any]) -> List[float]:
129        """Apply fitted transformers to a single record."""
130        vec: List[float] = []
131
132        # Numerical: impute then standardize
133        for col in self.config.numerical_features:
134            raw = row.get(col)
135            val = float(raw) if raw is not None else self.medians_[col]
136            standardized = (val - self.means_[col]) / self.stds_[col]
137            vec.append(standardized)
138
139        # Categorical: one-hot encode
140        for col in self.config.categorical_features:
141            raw_cat = str(row.get(col, ""))
142            for cat in self.categories_[col]:
143                vec.append(1.0 if raw_cat == cat else 0.0)
144
145        return vec
146
147    def predict_proba(self, row: Dict[str, Any]) -> float:
148        """Score customer churn probability from a raw input record."""
149        if not self.is_fitted:
150            raise RuntimeError("Pipeline must be fitted before predict_proba.")
151        vec = self._transform_row(row)
152        linear = sum(self.weights_[j] * vec[j] for j in range(len(vec))) + self.bias_
153        return sigmoid(linear)
154
155    def predict(self, row: Dict[str, Any]) -> int:
156        """Classify customer churn (1 or 0) using calibrated decision threshold."""
157        prob = self.predict_proba(row)
158        return 1 if prob >= self.config.decision_threshold else 0
159
160    def save(self, file_path: str, metadata: Optional[Dict[str, Any]] = None) -> None:
161        """Package fitted pipeline and provenance metadata into an artifact bundle."""
162        if not self.is_fitted:
163            raise RuntimeError("Cannot save unfitted pipeline.")
164        bundle = {
165            "config": dataclasses.asdict(self.config),
166            "fitted_state": {
167                "medians": self.medians_,
168                "means": self.means_,
169                "stds": self.stds_,
170                "categories": self.categories_,
171                "weights": self.weights_,
172                "bias": self.bias_,
173                "feature_names": self.feature_names_,
174            },
175            "metadata": metadata or {},
176        }
177        path = Path(file_path)
178        path.parent.mkdir(parents=True, exist_ok=True)
179        with open(path, "w", encoding="utf-8") as f:
180            json.dump(bundle, f, indent=2)
181
182    @classmethod
183    def load(cls, file_path: str) -> StayWellChurnPipeline:
184        """Load a production pipeline artifact bundle ready for inference."""
185        with open(file_path, "r", encoding="utf-8") as f:
186            bundle = json.load(f)
187
188        config = PipelineConfig(**bundle["config"])
189        pipe = cls(config)
190        state = bundle["fitted_state"]
191        pipe.medians_ = state["medians"]
192        pipe.means_ = state["means"]
193        pipe.stds_ = state["stds"]
194        pipe.categories_ = state["categories"]
195        pipe.weights_ = state["weights"]
196        pipe.bias_ = state["bias"]
197        pipe.feature_names_ = state["feature_names"]
198        pipe.is_fitted = True
199        return pipe

The unit test suite (training_pipeline_test.py)

Here is the test suite validating training determinism, bundle serialization, inference with missing inputs, and decision threshold routing:

training_pipeline_test.py

python
1"""Unit tests for the StayWell reproducible training pipeline."""
2
3import shutil
4import tempfile
5import unittest
6from pathlib import Path
7from training_pipeline import PipelineConfig, StayWellChurnPipeline
8
9
10class TestTrainingPipeline(unittest.TestCase):
11
12    def setUp(self):
13        self.test_dir = tempfile.mkdtemp(prefix="test_pipeline_")
14        self.config = PipelineConfig(
15            numerical_features=["tenure_months", "monthly_spend", "support_tickets"],
16            categorical_features=["contract_type"],
17            target_column="churn",
18            random_seed=42,
19            learning_rate=0.1,
20            epochs=50,
21            decision_threshold=0.35,
22        )
23
24        # Synthetic dataset (12 records)
25        self.data = [
26            {"tenure_months": 2, "monthly_spend": 140.0, "support_tickets": 4, "contract_type": "month-to-month", "churn": 1},
27            {"tenure_months": 36, "monthly_spend": 60.0, "support_tickets": 0, "contract_type": "two-year", "churn": 0},
28            {"tenure_months": 4, "monthly_spend": 180.0, "support_tickets": 5, "contract_type": "month-to-month", "churn": 1},
29            {"tenure_months": 24, "monthly_spend": 85.0, "support_tickets": 1, "contract_type": "one-year", "churn": 0},
30            {"tenure_months": 1, "monthly_spend": 110.0, "support_tickets": 3, "contract_type": "month-to-month", "churn": 1},
31            {"tenure_months": 48, "monthly_spend": 55.0, "support_tickets": 0, "contract_type": "two-year", "churn": 0},
32            {"tenure_months": 8, "monthly_spend": 125.0, "support_tickets": 2, "contract_type": "month-to-month", "churn": 1},
33            {"tenure_months": 18, "monthly_spend": 90.0, "support_tickets": 1, "contract_type": "one-year", "churn": 0},
34            {"tenure_months": 3, "monthly_spend": 160.0, "support_tickets": 4, "contract_type": "month-to-month", "churn": 1},
35            {"tenure_months": 60, "monthly_spend": 50.0, "support_tickets": 0, "contract_type": "two-year", "churn": 0},
36            {"tenure_months": 6, "monthly_spend": 130.0, "support_tickets": 3, "contract_type": "month-to-month", "churn": 1},
37            {"tenure_months": 12, "monthly_spend": 95.0, "support_tickets": 1, "contract_type": "one-year", "churn": 0},
38        ]
39
40    def tearDown(self):
41        shutil.rmtree(self.test_dir)
42
43    def test_deterministic_training_reproducibility(self):
44        # Train two independent pipelines with the same seed and data
45        pipe_a = StayWellChurnPipeline(self.config).fit(self.data)
46        pipe_b = StayWellChurnPipeline(self.config).fit(self.data)
47
48        # Weights must be bit-for-bit identical
49        for wa, wb in zip(pipe_a.weights_, pipe_b.weights_):
50            self.assertEqual(wa, wb)
51        self.assertEqual(pipe_a.bias_, pipe_b.bias_)
52
53        test_row = {"tenure_months": 3, "monthly_spend": 150.0, "support_tickets": 3, "contract_type": "month-to-month"}
54        self.assertEqual(pipe_a.predict_proba(test_row), pipe_b.predict_proba(test_row))
55
56    def test_pipeline_serialization_and_serving(self):
57        pipe = StayWellChurnPipeline(self.config).fit(self.data)
58        save_path = Path(self.test_dir) / "churn_model_v1.json"
59        metadata = {"git_commit": "a94f8e12", "dataset_hash": "7b9c1d0f", "model_version": "1.0.0"}
60
61        pipe.save(str(save_path), metadata=metadata)
62        self.assertTrue(save_path.is_file())
63
64        # Load in a fresh instance
65        loaded_pipe = StayWellChurnPipeline.load(str(save_path))
66        self.assertTrue(loaded_pipe.is_fitted)
67
68        test_row = {"tenure_months": 36, "monthly_spend": 60.0, "support_tickets": 0, "contract_type": "two-year"}
69        prob_original = pipe.predict_proba(test_row)
70        prob_loaded = loaded_pipe.predict_proba(test_row)
71        self.assertAlmostEqual(prob_original, prob_loaded, places=7)
72
73    def test_handling_missing_values_at_inference(self):
74        pipe = StayWellChurnPipeline(self.config).fit(self.data)
75
76        # Raw record with missing numerical value (support_tickets is None)
77        raw_row = {"tenure_months": 10, "monthly_spend": 90.0, "support_tickets": None, "contract_type": "one-year"}
78        # Must execute without error using fitted median
79        prob = pipe.predict_proba(raw_row)
80        self.assertTrue(0.0 <= prob <= 1.0)
81
82    def test_decision_threshold_classification(self):
83        pipe = StayWellChurnPipeline(self.config).fit(self.data)
84
85        # High risk customer: low tenure, high spend, many tickets
86        high_risk = {"tenure_months": 1, "monthly_spend": 200.0, "support_tickets": 6, "contract_type": "month-to-month"}
87        self.assertEqual(pipe.predict(high_risk), 1)
88
89        # Low risk customer: high tenure, low spend, 0 tickets
90        low_risk = {"tenure_months": 48, "monthly_spend": 45.0, "support_tickets": 0, "contract_type": "two-year"}
91        self.assertEqual(pipe.predict(low_risk), 0)
92
93
94if __name__ == "__main__":
95    unittest.main()

Save both files in the same folder and run the tests from that folder:

bash
1python3 -m unittest discover -p '*_test.py'

Every test runs in sub-millisecond execution time. Running these unit tests verifies that your pipeline code satisfies the requirements of reproducible machine learning.

Mistakes beginners make

1. Fitting transformers on the full dataset before splitting

A common blunder is calling scaler.fit_transform(X) on the complete historical dataset before invoking train_test_split(). The scaler computes means and variances that incorporate information from the test split, causing subtle data leakage and over-optimistic evaluation scores.

Fix: Split your dataset into training and evaluation sets first. Fit all imputers, scalers, and encoders strictly on the training partition (X_train), and only call .transform() on evaluation and test splits.

2. Exporting model weights without preprocessing transformers

Developers often serialize only the trained estimator object, assuming the production engineering team will implement identical preprocessing logic in the serving application. When minor differences in missing-value imputation or categorical encodings creep in, model performance degrades without any obvious error.

Fix: Use composite pipelines (such as scikit-learn Pipelines or custom atomic pipeline classes). Package the fitted transformers and the estimator weights into a single serializable bundle, guaranteeing identical execution during training and inference.

3. Hardcoding hyperparameters and file paths inside code

Placing parameters like learning_rate = 0.05 or paths like /home/user/data.csv directly inside Python functions prevents pipelines from being automated by orchestrators or tested in containerized CI environments.

Fix: Declare all hyperparameters, dataset paths, column mappings, and random seeds in external configuration files (such as YAML or JSON). Have your pipeline ingest the configuration file path as a CLI argument.

4. Overlooking the calibrated decision threshold

Binary classifiers output probabilities between 0.0 and 1.0, but beginners frequently rely on the default 0.50 cutoff to assign class labels. For imbalanced business problems like customer churn (where positive cases represent 15% of records), a 0.50 threshold often fails to capture high-risk customers.

Fix: Calibrate the optimal decision threshold during validation (for example, setting the cutoff at 0.35 to maximize high-recall business objectives). Store this threshold directly in the pipeline configuration and export it inside the model artifact bundle.

Recap and try it yourself

In this lesson, you explored how to turn exploratory notebook cells into reproducible training pipelines:

  • Raw notebooks suffer from non-linear execution order, hidden global memory state, and unlinked preprocessing transformers.
  • The atomic pipeline pattern unifies data imputation, feature scaling, categorical encoding, and model scoring into a single deployable object.
  • Declarative configuration files separate code from hyperparameters, data paths, and pinned random seeds.
  • Scikit-learn's Pipeline and ColumnTransformer provide the standard Python implementation for composite estimators.
  • Production model packages bundle fitted transformers, estimator weights, model signatures, configurations, environment manifests, and lineage hashes.

Try it yourself

To gain hands-on experience building reproducible training pipelines on your machine:

  1. Run the pipeline test suite: Copy training_pipeline.py and training_pipeline_test.py into a local directory and run python3 -m unittest training_pipeline_test.py. Verify that all four unit tests pass.
  2. Add an interaction feature: Modify StayWellChurnPipeline._transform_row() to compute an interaction term (such as monthly_spend / (tenure_months + 1.0)). Update self.feature_names_ and re-run training to observe how the pipeline handles engineered features.
  3. Write a mutation test: Intentionally alter StayWellChurnPipeline.predict() to reverse the decision threshold logic. Run the test suite and confirm that test_decision_threshold_classification catches the defect.
  4. Inspect the serialized JSON bundle: Train the pipeline and call .save("model_bundle.json"). Open the resulting JSON file in a text editor to verify that all transformer means, standard deviations, categories, and estimator weights are fully captured.