mlflow
sklearn
machine learning
custom transformer
model deployment

mlflow How to save a sklearn pipeline with custom transformer?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Saving a scikit-learn pipeline with a custom transformer in MLflow requires that the custom transformer class is importable at load time. The standard approach is to define your custom transformer in a separate module (not in a notebook or __main__), then log the pipeline with mlflow.sklearn.log_model(). If the transformer lives in __main__, MLflow cannot deserialize it on load. The solution is to either package the transformer as a module or use MLflow's code_path parameter to include the source file.

The Problem

python
1import mlflow
2from sklearn.pipeline import Pipeline
3from sklearn.base import BaseEstimator, TransformerMixin
4from sklearn.linear_model import LogisticRegression
5
6# Custom transformer defined in the notebook or script
7class TextCleaner(BaseEstimator, TransformerMixin):
8    def fit(self, X, y=None):
9        return self
10
11    def transform(self, X):
12        return [text.lower().strip() for text in X]
13
14pipeline = Pipeline([
15    ('cleaner', TextCleaner()),
16    ('model', LogisticRegression())
17])
18
19# This saves successfully...
20mlflow.sklearn.log_model(pipeline, "model")
21
22# But loading fails:
23model = mlflow.sklearn.load_model("runs:/<run_id>/model")
24# ModuleNotFoundError: No module named '__main__'
25# or
26# AttributeError: Can't get attribute 'TextCleaner' on <module '__main__'>

The issue is that pickle (used by MLflow's sklearn flavor) stores the module path of the class. If the class is in __main__, it cannot be found during deserialization in a different process.

Solution 1: Define Transformer in a Separate Module

python
1# custom_transformers.py (separate file)
2from sklearn.base import BaseEstimator, TransformerMixin
3
4class TextCleaner(BaseEstimator, TransformerMixin):
5    def fit(self, X, y=None):
6        return self
7
8    def transform(self, X):
9        return [text.lower().strip() for text in X]
python
1# train.py
2import mlflow
3from sklearn.pipeline import Pipeline
4from sklearn.linear_model import LogisticRegression
5from custom_transformers import TextCleaner  # Import from module
6
7pipeline = Pipeline([
8    ('cleaner', TextCleaner()),
9    ('model', LogisticRegression())
10])
11
12pipeline.fit(X_train, y_train)
13
14with mlflow.start_run():
15    mlflow.sklearn.log_model(pipeline, "model")
16
17# Loading works because TextCleaner is in custom_transformers module
18model = mlflow.sklearn.load_model("runs:/<run_id>/model")

The key requirement is that custom_transformers must be importable when loading the model — it must be installed as a package or on the Python path.

Solution 2: Use code_path Parameter

Include the source file with the model artifact:

python
1# train.py
2import mlflow
3from custom_transformers import TextCleaner
4
5pipeline = Pipeline([
6    ('cleaner', TextCleaner()),
7    ('model', LogisticRegression())
8])
9
10pipeline.fit(X_train, y_train)
11
12with mlflow.start_run():
13    mlflow.sklearn.log_model(
14        pipeline,
15        "model",
16        code_path=["custom_transformers.py"]  # Include the source file
17    )

When loading, MLflow adds the code_path files to sys.path, making the custom classes importable:

python
model = mlflow.sklearn.load_model("runs:/<run_id>/model")
# MLflow automatically adds custom_transformers.py to the path

Solution 3: Package as a Python Module

For production, package your transformers as an installable module:

 
1my_ml_package/
2    setup.py
3    my_ml_package/
4        __init__.py
5        transformers.py    # TextCleaner defined here
python
1# setup.py
2from setuptools import setup, find_packages
3
4setup(
5    name="my-ml-package",
6    version="1.0.0",
7    packages=find_packages(),
8)
python
1# train.py
2from my_ml_package.transformers import TextCleaner
3
4# Log with pip_requirements to ensure the package is installed on load
5mlflow.sklearn.log_model(
6    pipeline,
7    "model",
8    pip_requirements=["my-ml-package==1.0.0"]
9)

Solution 4: Use MLflow's Pyfunc Flavor

For maximum portability, wrap the pipeline in a custom pyfunc model:

python
1import mlflow.pyfunc
2import cloudpickle
3
4class SklearnPipelineWrapper(mlflow.pyfunc.PythonModel):
5    def __init__(self, pipeline):
6        self.pipeline = pipeline
7
8    def predict(self, context, model_input):
9        return self.pipeline.predict(model_input)
10
11pipeline.fit(X_train, y_train)
12
13with mlflow.start_run():
14    mlflow.pyfunc.log_model(
15        "model",
16        python_model=SklearnPipelineWrapper(pipeline),
17        code_path=["custom_transformers.py"],
18        pip_requirements=["scikit-learn", "pandas"]
19    )

Custom Transformer with Parameters

python
1# custom_transformers.py
2from sklearn.base import BaseEstimator, TransformerMixin
3import numpy as np
4
5class OutlierRemover(BaseEstimator, TransformerMixin):
6    def __init__(self, threshold=3.0):
7        self.threshold = threshold  # Must match parameter name in __init__
8
9    def fit(self, X, y=None):
10        self.mean_ = np.mean(X, axis=0)
11        self.std_ = np.std(X, axis=0)
12        return self
13
14    def transform(self, X):
15        z_scores = np.abs((X - self.mean_) / self.std_)
16        mask = (z_scores < self.threshold).all(axis=1)
17        return X[mask]
18
19    def get_params(self, deep=True):
20        return {"threshold": self.threshold}
21
22    def set_params(self, **params):
23        for key, value in params.items():
24            setattr(self, key, value)
25        return self

Scikit-learn requires get_params() and set_params() to work with Pipeline, GridSearchCV, and serialization. BaseEstimator provides default implementations that use __init__ parameter names.

Complete Example

python
1# custom_transformers.py
2from sklearn.base import BaseEstimator, TransformerMixin
3import pandas as pd
4
5class FeatureSelector(BaseEstimator, TransformerMixin):
6    def __init__(self, columns=None):
7        self.columns = columns
8
9    def fit(self, X, y=None):
10        return self
11
12    def transform(self, X):
13        return X[self.columns] if self.columns else X
14
15# train.py
16import mlflow
17from sklearn.pipeline import Pipeline
18from sklearn.preprocessing import StandardScaler
19from sklearn.linear_model import LogisticRegression
20from custom_transformers import FeatureSelector
21
22pipeline = Pipeline([
23    ('selector', FeatureSelector(columns=['age', 'income'])),
24    ('scaler', StandardScaler()),
25    ('model', LogisticRegression())
26])
27
28pipeline.fit(X_train, y_train)
29accuracy = pipeline.score(X_test, y_test)
30
31with mlflow.start_run():
32    mlflow.log_metric("accuracy", accuracy)
33    mlflow.sklearn.log_model(
34        pipeline,
35        "model",
36        code_path=["custom_transformers.py"],
37        registered_model_name="my-classifier"
38    )

Common Pitfalls

  • Defining custom transformers in __main__: Pickle stores the module path. If the class is in __main__ (a notebook cell or the main script), it cannot be deserialized in a different process. Always define custom transformers in importable modules.
  • Forgetting code_path: Without code_path, the model artifact does not include your custom code. Loading the model in a different environment fails with ModuleNotFoundError. Always specify code_path or install the transformer module.
  • Mismatched __init__ parameters and get_params: Scikit-learn's default get_params() inspects __init__ parameter names. If __init__ takes self.threshold but you set self._threshold, cloning and serialization break. The attribute name must match the parameter name.
  • Stateful transformers without fit: If your transformer learns from data (e.g., computes mean/std), those fitted attributes must be serializable. Avoid storing non-picklable objects (database connections, file handles) as fitted attributes.
  • Version mismatches: The scikit-learn version used to save the model must match the version used to load it. Log the version with mlflow.log_param("sklearn_version", sklearn.__version__) and specify it in pip_requirements.

Summary

  • Define custom transformers in separate importable modules, not in __main__
  • Use code_path=["my_module.py"] in mlflow.sklearn.log_model() to bundle custom code
  • For production, package transformers as installable Python packages
  • Inherit from BaseEstimator and TransformerMixin for scikit-learn compatibility
  • Ensure __init__ parameter names match attribute names for get_params() to work
  • Use mlflow.pyfunc.log_model() for maximum portability across environments

Course illustration
Course illustration

All Rights Reserved.