MLflow Model Registry developer on retainer: scikit-learn 1.2.1 serialized ColumnTransformer raises AttributeError in scikit-learn 1.4.2 production environment after Staging → Production stage transition — 45-minute outage on first inference — MLflow developer on monthly retainer

mlflow 2.9.2 • Python 3.10 training vs Python 3.11 production • scikit-learn 1.2.1 training vs scikit-learn 1.4.2 production • ColumnTransformer remainder=’passthrough’ • conda.yaml records training version • production VMs upgraded before model transition • CI tests in training environment — passed • first inference October 15 2026 16:43 UTC raises AttributeError • 45-minute outage • fix: CI gate runs smoke-test in production environment docker image • 3.0h retainer engagement

On October 15, 2026 at 16:43 UTC, the IT service management platform at Greenfield Model Operations Group began returning 500 errors on SLA breach prediction requests. The service had been running without incident for four months. The deployment log showed a single change at 16:38 UTC: the sla-breach-model in the MLflow Model Registry had been promoted from Staging to Production (version 12, which had outperformed version 11 in a two-week A/B test). The production serving application loaded the new model via mlflow.pyfunc.load_model("models:/sla-breach-model/Production") on first request, and the predict_proba() call raised an AttributeError inside the loaded ColumnTransformer. The MLflow Registry retainer developer traced the root cause to a scikit-learn version mismatch: the model had been trained on sklearn 1.2.1, but the production serving virtual machines had been upgraded to sklearn 1.4.2 four days earlier as part of a routine infrastructure update. The internal serialized state of the ColumnTransformer was incompatible across the version gap. The total engagement was 3.0 hours.

Greenfield Model Operations Group uses MLflow Model Registry to manage the lifecycle of six production models. The SLA breach prediction model — a scikit-learn Pipeline that predicts whether incoming IT service tickets will breach their 4-hour response SLA — had been in production since June 2026 as version 11. In late September 2026, version 12 was trained on a refreshed dataset with six months of additional labeled tickets. The version 12 training run used mlflow.sklearn.log_model() to register the model, which recorded the training environment in a conda.yaml artifact:

# MLflow auto-generated conda.yaml for sla-breach-model v12
# Recorded at training time: October 1, 2026
# Training environment: Python 3.10, sklearn 1.2.1

name: mlflow-env
channels:
  - defaults
dependencies:
  - python=3.10
  - pip:
    - mlflow==2.9.2
    - scikit-learn==1.2.1
    - pandas==2.1.4
    - numpy==1.26.2
    - cloudpickle==3.0.0

The model Pipeline had been built with ColumnTransformer(remainder=’passthrough’) to pass through a subset of numeric features without transformation while applying a StandardScaler to others and a OneHotEncoder to categorical columns:

# training/train_model.py — sla-breach-model v12
# Greenfield Model Operations Group, sklearn 1.2.1, Python 3.10
import mlflow.sklearn
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
import pandas as pd

numeric_cols    = ["ticket_age_minutes", "assignee_workload", "priority_score",
                   "category_response_time_avg", "queue_depth"]
categorical_cols = ["ticket_source", "department", "priority_level"]
passthrough_cols = ["is_escalated", "is_repeat_customer", "sla_tier_numeric"]

preprocessor = ColumnTransformer(
    transformers=[
        ("num",  StandardScaler(), numeric_cols),
        ("cat",  OneHotEncoder(handle_unknown="ignore", sparse_output=False), categorical_cols),
    ],
    remainder="passthrough",  # passes passthrough_cols through unchanged
)

pipeline = Pipeline([
    ("preprocessor", preprocessor),
    ("classifier",   LogisticRegression(max_iter=1000, C=0.8)),
])

X_train, y_train = load_training_data()
pipeline.fit(X_train, y_train)

mlflow.sklearn.log_model(
    pipeline,
    artifact_path="model",
    registered_model_name="sla-breach-model",
    input_example=X_train.head(5),
    signature=mlflow.models.infer_signature(X_train, pipeline.predict_proba(X_train.head(100))),
)
# Logged as sla-breach-model version 12

On September 30, 2026 — one day before the training run — the infrastructure team completed a long-planned upgrade of the production serving virtual machines, including a Python version bump from 3.10 to 3.11 and a scikit-learn version bump from 1.2.1 to 1.4.2. The upgrade was logged in the infrastructure change register. The data science team was not notified because the change was considered a routine library patch with no breaking changes in the sklearn 1.4.x release notes for the specific features they used. The conda.yaml recorded by MLflow at training time faithfully reflected the training environment (sklearn==1.2.1), but no automated check compared the conda.yaml against the production serving environment before the Staging → Production transition was approved.

Version 12 was promoted to Staging on October 5 after initial training metric review. CI integration tests ran the model in the training environment container (Python 3.10, sklearn 1.2.1), confirmed predict_proba() outputs matched expected schema, and marked the Staging tests as passed. A two-week A/B test in production shadowed version 12 against version 11 — but the shadow test loaded the version 12 model in the same production Python 3.11 + sklearn 1.4.2 runtime. The shadow test passed without errors because it served "shadow" traffic asynchronously: it called mlflow.pyfunc.load_model() in a background thread and discarded the result. The actual predict_proba(df) call was never exercised against the shadow model in the A/B framework — only the model loading was validated, not the prediction path.

On October 15 at 16:38 UTC, version 12 was transitioned to Production. At 16:43 UTC, the first live inference request arrived. The production serving application called predict_proba(df) on the loaded model. The traceback was:

# Production serving log — October 15, 2026 16:43:07 UTC
# sla-breach-model v12 loaded successfully via mlflow.pyfunc.load_model()
# First inference call raises:

AttributeError: 'ColumnTransformer' object has no attribute '_feature_names_out_remainder'

Traceback (most recent call last):
  File "serving/predictor.py", line 34, in predict
    return self.model.predict_proba(df)
  File "/opt/venv/lib/python3.11/site-packages/sklearn/pipeline.py", line 592, in predict_proba
    Xt = self._fit_transform(X, fit_params)
  File "/opt/venv/lib/python3.11/site-packages/sklearn/pipeline.py", line 449, in _fit_transform
    Xt = transformer.transform(Xt)
  File "/opt/venv/lib/python3.11/site-packages/sklearn/compose/_column_transformer.py", line 897, in transform
    feature_names = self._get_feature_names_out()
  File "/opt/venv/lib/python3.11/site-packages/sklearn/compose/_column_transformer.py", line 561, in _get_feature_names_out
    remainder_names = self._feature_names_out_remainder
AttributeError: 'ColumnTransformer' object has no attribute '_feature_names_out_remainder'

The _feature_names_out_remainder attribute was introduced to the fitted ColumnTransformer state in scikit-learn 1.3.0 as part of the get_feature_names_out() stabilization work. When a ColumnTransformer is fitted on sklearn 1.3.0 or later, the fit() method stores self._feature_names_out_remainder on the fitted object — a list of feature name strings for the columns handled by remainder=’passthrough’. When sklearn 1.4.2’s ColumnTransformer.transform() calls _get_feature_names_out() internally as part of its output validation, it tries to read self._feature_names_out_remainder. The object was fitted in sklearn 1.2.1, where this attribute did not yet exist, so the pickled state loaded by MLflow did not contain it. The AttributeError was deterministic on every inference call: every request to the version 12 model failed.

The on-call engineer rolled back to version 11 at 17:28 UTC — 45 minutes after the failure. The rollback required a manual MlflowClient().transition_model_version_stage() call to move version 11 back to Production and a service restart to reload the model. Version 12 was transitioned back to Staging pending investigation.

The retainer developer confirmed the root cause by reproducing the error in a local environment with the version mismatch recreated:

# diagnosis/reproduce_error.py
# Step 1: serialize model in sklearn 1.2.1 environment
# (Run in Docker image: python:3.10-slim with sklearn==1.2.1)
import pickle
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler
import numpy as np

ct = ColumnTransformer(
    transformers=[("num", StandardScaler(), [0, 1])],
    remainder="passthrough"
)
X = np.random.randn(100, 5)
ct.fit(X)

# Check fitted attributes in sklearn 1.2.1:
print(hasattr(ct, "_feature_names_out_remainder"))  # False — attribute doesn't exist in 1.2.1

with open("/tmp/ct_121.pkl", "wb") as f:
    pickle.dump(ct, f)

# Step 2: load and transform in sklearn 1.4.2 environment
# (Run in Docker image: python:3.11-slim with sklearn==1.4.2)
import pickle
import numpy as np

with open("/tmp/ct_121.pkl", "rb") as f:
    ct_loaded = pickle.load(f)

X_new = np.random.randn(10, 5)
ct_loaded.transform(X_new)
# Raises: AttributeError: 'ColumnTransformer' object has no attribute '_feature_names_out_remainder'

The fix required two changes. The immediate fix was to re-train version 13 of the model in the production Python/sklearn environment so that the fitted ColumnTransformer would have _feature_names_out_remainder in its serialized state. The structural fix was a CI validation gate that prevents any future Staging → Production transition until the model has been successfully loaded and called with predict_proba() in a Docker image that exactly matches the production serving environment:

# .github/workflows/validate-model-promotion.yml
# Runs on every pull request that updates the model version approved for Production

name: Validate model before Production transition

on:
  workflow_dispatch:
    inputs:
      model_name:
        description: "MLflow registered model name"
        required: true
      model_version:
        description: "Version number to validate"
        required: true

jobs:
  validate-in-production-environment:
    runs-on: ubuntu-latest
    # Use the SAME Docker image as the production serving VMs
    container: ghcr.io/greenfield-model-ops/serving-runtime:latest
    # serving-runtime:latest = Python 3.11 + sklearn 1.4.2 + mlflow 2.9.2

    steps:
      - uses: actions/checkout@v4
      - name: Run production environment smoke test
        env:
          MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
        run: |
          python - <<'EOF'
          import mlflow
          import pandas as pd
          import numpy as np

          model_name = "${{ inputs.model_name }}"
          model_version = "${{ inputs.model_version }}"

          # Load via registry URI — same call as production serving code
          model_uri = f"models:/{model_name}/{model_version}"
          model = mlflow.pyfunc.load_model(model_uri)

          # Load the input_example logged at training time as the smoke-test input
          model_info = mlflow.models.get_model_info(model_uri)
          input_example = mlflow.artifacts.load_dict(
              model_info.artifact_uri + "/input_example.json"
          )
          X_test = pd.DataFrame(input_example["inputs"])

          # Run actual inference — this is the call that failed in production
          result = model.predict(X_test)
          assert result is not None and len(result) == len(X_test), \
              f"Inference returned unexpected result shape: {result.shape}"

          print(f"PASS: {model_name} v{model_version} smoke test in production environment")
          EOF

Version 13 was trained in the production environment (Python 3.11, sklearn 1.4.2) on October 18, registered to the MLflow Registry, promoted to Staging, validated by the new CI gate (which passed, confirming the re-serialized ColumnTransformer contained _feature_names_out_remainder), and promoted to Production on October 22 without incident. A policy addendum was added to the model promotion runbook requiring that every new model training run record the production serving environment version — not the training environment version — in the registered model’s tags, so that version mismatches are visible in the MLflow Registry UI before a transition is attempted.

Total retainer engagement: 3.0 hours (1.0h root cause: production serving log traceback analysis, sklearn version comparison between training conda.yaml and production pip freeze, ColumnTransformer._feature_names_out_remainder attribute addition timeline in sklearn 1.3.0 changelog, local reproduction of the version mismatch error, CI test environment configuration review confirming tests ran in training container not production container; 0.5h impact assessment: 45-minute outage scope, SLA breach prediction service downtime impact on IT service management team, rollback procedure confirmation and timing; 1.0h fix: CI production environment validation gate design using production serving Docker image, smoke-test inference job implementation with input_example loading, workflow_dispatch trigger configuration, version 13 training in production environment and promotion pipeline; 0.5h policy: version mismatch prevention policy documentation, model promotion runbook update, production environment version tagging requirement for all new training runs). Retainer hours logged to HourTab with MLflow version, the sklearn environment version mismatch root cause, and the production environment CI gate policy, so the client could audit the full 3.0-hour engagement at the shared URL without logging in.

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →