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

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:
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:
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.0By decoupling configuration from code, hyperparameter sweeps and scheduled weekly retrainings can be executed by passing a configuration file path to the training entry point:
1python -m src.train --config configs/staywell_churn_v2.yamlThe 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.
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.

A production model deployment package contains six essential components:
- Fitted Transformers: Imputer values, standardizer means, variance scales, and categorical dictionary lookups learned exclusively from the training split.
- Estimator Weights: Algorithm weights, tree structures, feature importance vectors, and calibrated decision thresholds.
- 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.
- Pipeline Configuration: The frozen declarative YAML or JSON configuration file used to launch the training run.
- Environment Manifest: Pinned
requirements.txtorconda.yamlspecifying exact library versions down to the patch release, alongside base Docker container digests. - 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 ( 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
PipelineConfigdataclass - 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
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 pipeThe 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
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:
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
PipelineandColumnTransformerprovide 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:
- Run the pipeline test suite: Copy
training_pipeline.pyandtraining_pipeline_test.pyinto a local directory and runpython3 -m unittest training_pipeline_test.py. Verify that all four unit tests pass. - Add an interaction feature: Modify
StayWellChurnPipeline._transform_row()to compute an interaction term (such asmonthly_spend / (tenure_months + 1.0)). Updateself.feature_names_and re-run training to observe how the pipeline handles engineered features. - Write a mutation test: Intentionally alter
StayWellChurnPipeline.predict()to reverse the decision threshold logic. Run the test suite and confirm thattest_decision_threshold_classificationcatches the defect. - 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.