Logging and Loading Models With MLflow
Context
Logging a model with MLflow writes the fitted pipeline into the tracking store together with a record of the columns it takes and the type of each, and registering it files that model under a name with a version number. An alias is a label on one version of that name, and any script that asks for the label gets whichever version carries it today.
A prediction service loads its model this way at startup, a retraining job hands its result over as a new version, and a bad release is undone by moving the label back. The service of the next two lessons asks for listings-price by its label and never by a file path, so changing the model it serves is one command, with no edit to its code and no file copied between machines.
Lesson 6.2.1 recorded every run's parameters, its scores and its pipeline, and left the pipeline without a name: reaching it meant knowing a run's random id or a folder inside mlruns/. Loaded back by name, the model then refuses listings that look correct. A year typed as 1998 arrives as an integer where training saw a float, a blank rating arrives as None, which the encoder has never met, and Lesson 3.2.3's three listings, priced together without a complaint, fail one at a time, which is exactly how a service receives them.
Goal
- Log the price pipeline with
mlflow.sklearn.log_modelunder a signature inferred from the 2,400 training rows and a three-row input example, and read the signature back from the registry. - Explain why MLflow refuses to save this pipeline without
skops_trusted_types=["numpy.dtype"], and find the step and the attributes that hold the type. - Register every training run as a numbered version of
listings-price, and point thechampionalias at one version withpromote.py. - Load
models:/listings-price@championas a pyfunc model and as a scikit-learn pipeline, and state which one-row listings each refuses and which it prices. - Build one-row frames with the training dtypes through
to_frame, and reproduce Lesson 3.2.3's three prices one listing at a time.
Prerequisites
None of this is required, and the lesson stands on its own. Both are here for a look at a model registry from outside before working inside one.
- YouTube - "MLflow Model Registry: A Complete Guide to Managing ML Models" by Code Kamikaze. Twelve minutes on setting up MLflow, registering a model and keeping its versions in the registry.
- MLflow 3.17.1 documentation - "MLflow Model Registry", the section "Concepts". One line each on a logged model, a registered model, a model version numbered from 1 and a model alias such as
champion, the vocabulary this lesson uses.
Table of Contents
- Logging the Pipeline With a Signature
- skops and Its Trusted Types
- The Registry and Its Versions
- An Alias and promote.py
- Loading by Alias as pyfunc and as sklearn
- Explicit Dtypes and to_frame
Theory
Nothing new installs in this lesson: the registry, both loaders and skops, Lesson 6.2.1's file format, all arrived with mlflow==3.17.1 in Lesson 6.2.1, and uv tree --invert --package skops shows skops 0.16.0 there as a dependency of mlflow. Everything runs in ~/dev/listings-service as Lesson 6.2.1 left it, with (bootcamp) deactivated as Module 6's setup page says: train.py records each run in mlflow.db and logs its pipeline, and the registry holds nothing yet. The lesson changes four project files, src/listings/train.py, src/listings/promote.py (new), src/listings/data.py and tests/test_data.py, and every probe script lives in Lesson 6.1.3's probe folder, ~/dev/listings-probes, run from the project folder as uv run python ../listings-probes/<name>.py, so the project holds only what it ships. MLflow's log lines carry the time they were written, and run ids and model ids are random, so yours differ from the ones below. The version numbers 1 and 2 are a registry's first two, so they match here when listings-price has never been registered before.
Logging the Pipeline With a Signature
What. mlflow.sklearn.log_model(pipe, ...) saves a fitted pipeline as a logged model of the active run, a folder of files under mlruns/. Its signature is the list of input columns, each with a type and a flag saying whether it is required, plus the type of the output, and infer_signature(X, predictions) reads one from a frame and the model's predictions on it. Its input_example is a few real rows saved beside the model.
Why. A pipeline file says nothing about what it takes. The signature is what lets a loader check a listing before the pipeline sees it, the fifth subtitle's subject, and the example shows anyone who opens the model a valid input without the training data.
When. Every time a model is logged that something other than its own training script will load.
src/listings/train.py, in full:
"""Fit the price pipeline, score it, and log it to MLflow as a new version."""
import argparse
import mlflow
from mlflow.models import infer_signature
from sklearn.metrics import mean_absolute_error, r2_score
from listings.data import load, split
from listings.features import build_pipeline
MODEL_NAME = "listings-price"
def evaluate(pipe, X, y):
predicted = pipe.predict(X)
return {
"mae": mean_absolute_error(y, predicted),
"r2": r2_score(y, predicted),
}
def log_pipeline(pipe, X_train):
signature = infer_signature(X_train, pipe.predict(X_train))
return mlflow.sklearn.log_model(
pipe,
name="model",
signature=signature,
input_example=X_train.head(3),
registered_model_name=MODEL_NAME,
skops_trusted_types=["numpy.dtype"],
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--strategy", default="median")
parser.add_argument("--no-indicator", action="store_true")
args = parser.parse_args()
params = {"strategy": args.strategy, "indicator": not args.no_indicator}
X_train, X_test, y_train, y_test = split(load())
mlflow.set_experiment("listings-price")
with mlflow.start_run():
mlflow.log_params(params)
pipe = build_pipeline(**params).fit(X_train, y_train)
scores = evaluate(pipe, X_test, y_test)
mlflow.log_metrics(scores)
info = log_pipeline(pipe, X_train)
version = info.registered_model_version
print(f"version {version}", f"{scores['mae']:,.0f}", round(scores["r2"], 3))
if __name__ == "__main__":
main()The log_model call Lesson 6.2.1 made inside the run moves into log_pipeline, which infers the signature from the 2,400 training rows and the pipeline's predictions on them and saves the first three of those rows as the example. registered_model_name files the model under MODEL_NAME in the same call, the third subtitle's registry, and skops_trusted_types is Lesson 6.2.1's line, the second subtitle's. log_model returns a ModelInfo, and its registered_model_version is the number the registry gave this model, which the last line now prints where Lesson 6.2.1 printed the run id, so start_run() no longer needs its as run. Train once with the default settings:
uv run python -m listings.train/home/goku/dev/listings-service/.venv/lib/python3.12/site-packages/mlflow/types/utils.py:440: UserWarning: Hint: Inferred schema contains integer column(s). Integer columns in Python cannot represent missing values. If your input data contains missing values at inference time, it will be encoded as floats and will cause a schema enforcement error. The best way to avoid this problem is to infer the model schema based on a realistic data sample (training dataset) that includes missing values. Alternatively, you can declare integer columns as doubles (float64) whenever these columns may have missing values. See `Handling Integers With Missing Values <https://www.mlflow.org/docs/latest/models.html#handling-integers-with-missing-values>`_ for more details.
warnings.warn(
2026/11/03 13:30:42 INFO mlflow.utils.uv_utils: Detected uv project: found uv.lock and pyproject.toml in /home/goku/dev/listings-service
2026/11/03 13:30:43 INFO mlflow.utils.uv_utils: Detected uv project: found uv.lock and pyproject.toml in /home/goku/dev/listings-service
2026/11/03 13:30:43 INFO mlflow.utils.environment: Detected uv project at /home/goku/dev/listings-service. Attempting to export requirements via 'uv export'.
2026/11/03 13:30:43 INFO mlflow.utils.uv_utils: Exported 90 dependencies via uv
2026/11/03 13:30:43 INFO mlflow.utils.environment: Successfully exported 90 requirements from uv project. Skipping package capture based inference.
2026/11/03 13:30:44 WARNING mlflow.utils.environment: Failed to resolve installed pip version. ``pip`` will be added to conda.yaml environment spec without a version specifier.
Successfully registered model 'listings-price'.
Created version '1' of model 'listings-price'.
version 1 56,562 0.766The UserWarning is infer_signature's, raised from MLflow's own types/utils.py. rooms and bathrooms have no blank in the training rows, so pandas holds them as int64 and the signature types them as integers, and an integer column cannot hold a blank, which pandas writes in a numeric column as the float NaN. A listing sent later with rooms blank would therefore arrive as a float column and be refused, so the warning is a hint about those two columns, never a failure, and every run of this script prints it. The six log lines after it are the requirements export Lesson 6.2.1 read line by line, the two after them are the registration, the third subtitle's, and the scores are Lesson 6.2.1's default run to the digit, because the pipeline and the split are the same. The newest folder under mlruns/1/models/ is the model just logged:
latest=mlruns/1/models/$(ls -t mlruns/1/models | head -1)/artifacts
ls -1 $latestMLmodel
conda.yaml
input_example.json
model.skops
python_env.yaml
requirements.txt
serving_input_example.jsonBeside Lesson 6.2.1's five files, two are new: input_example.json holds the three training rows, and serving_input_example.json holds them again wrapped under a dataframe_split key. MLmodel now also carries the signature and the name of the example's file. Read the signature back from the registry, in ~/dev/listings-probes/signature.py:
import mlflow
# version 1 of the name, addressed through the registry
info = mlflow.models.get_model_info("models:/listings-price/1")
print(info.signature.inputs)
print(info.signature.outputs)uv run python ../listings-probes/signature.py['area_sqm': double (optional), 'year_built': double (optional), 'distance_km': double (required), 'rooms': long (required), 'bathrooms': long (required), 'neighborhood': string (required), 'property_type': string (required), 'energy_rating': string (optional), 'condition': string (required)]
[Tensor('float64', (-1,))]models:/listings-price/1 is a registry address, the name and a version number. double is MLflow's name for a 64-bit float and long for a 64-bit integer. area_sqm, year_built and energy_rating are optional because the training rows hold blanks in them, 90, 110 and 215 of the 2,400, and the other six are required. year_built is a double, not an integer, because its blanks made pandas store it as float64. The output is a float64 tensor whose -1 means any number of rows, one price per listing.
DON'T:
- DON'T log a model a service will load without a signature. Lesson 6.2.1's models carry none,
metadata.signaturereadsNoneon them, and loaded as pyfunc the default run's priced a listing with 3.5 rooms at 394,641 without a word. - DON'T infer the signature from rows chosen by hand. Inferred from the 2,400 training rows, it marks exactly the three columns that ever held a blank as optional.
skops and Its Trusted Types
What. Lesson 6.2.1's skops format refuses to rebuild a type nobody has trusted, and MLflow loads its skops file back right after writing it, so log_model stopped until skops_trusted_types=["numpy.dtype"] named the one type this pipeline needs. skops.io itself shows the check without MLflow: dumps(obj) writes an object as skops bytes, get_untrusted_types(data=...) lists what a load would refuse before anything is built, and loads(data, trusted=[...]) rebuilds it with the named types allowed.
Why. A model fetched from a registry was written by some other run, often on some other machine, so the loader should know which types the file may become before it becomes them. The list is that knowledge, written down once, at the save.
When. Whenever a save raises the untrusted-types error, and only with a list of types you recognize.
Watch the refusal and the trust in ~/dev/listings-probes/trust.py:
import skops.io
from listings.data import load, split
from listings.features import build_pipeline
X_train, X_test, y_train, y_test = split(load())
pipe = build_pipeline().fit(X_train, y_train)
data = skops.io.dumps(pipe) # the skops file, as bytes
print(skops.io.get_untrusted_types(data=data))
try:
skops.io.loads(data) # no trust list given
except Exception as err:
print(type(err).__name__, err)
trusted = skops.io.loads(data, trusted=["numpy.dtype"])
print(trusted.predict(X_test[:2]).round())uv run python ../listings-probes/trust.py['numpy.dtype']
UntrustedTypesFoundException Untrusted types found in the file: ['numpy.dtype'].
[159471. 494584.]The one untrusted type is numpy.dtype, NumPy's object for a type such as float64, which skops 0.16.0 does not trust by default. One step of the fitted pipeline keeps such objects among its attributes, and Lab Exercise 1 finds which. Trusted, the same bytes rebuild a pipeline that prices the first two test rows. The list travels with the model, so no loader is ever handed it again:
grep -A1 skops_trusted_types $latest/MLmodel skops_trusted_types:
- numpy.dtypeDON'T:
- DON'T paste every type an untrusted-types error names into the list unread. Each entry is a type you are vouching for, and
numpy.dtypeis safe to vouch for because it describes a type and runs nothing. - DON'T silence the error with
serialization_format="pickle"or"cloudpickle", whichlog_modelaccepts. Either brings back the file that runs its maker's code on loading, the risk Lesson 3.2.3 named forjoblib.
The Registry and Its Versions
What. The model registry lives in mlflow.db beside the runs. A registered model is a name, and each registration under it adds a version, numbered from 1 and never reused, that points at one logged model and through it at the run that produced it. registered_model_name= in log_model registers in the same call, MlflowClient().search_model_versions(...) lists the versions, and mlflow.get_run(run_id) returns a run with its parameters and metrics.
Why. A name and a number are an identity that survives moving a folder, cloning the project to a new machine or cleaning mlruns/ by hand, and the version's run keeps the parameters and scores, so "which training run made the model being served" has one answer.
When. For every model that a later script, test or service will load.
Register a second version, the pipeline without the missing indicators, through Lesson 6.2.1's flag:
uv run python -m listings.train --no-indicatorIt prints the same warning and log lines, and then:
Registered model 'listings-price' already exists. Creating a new version of this model...
Created version '2' of model 'listings-price'.
version 2 56,336 0.766List the versions with their runs, in ~/dev/listings-probes/versions.py:
import mlflow
from mlflow import MlflowClient
client = MlflowClient()
for version in client.search_model_versions("name='listings-price'"):
run = mlflow.get_run(version.run_id) # the run that logged it
mae = run.data.metrics["mae"]
print(version.version, run.data.params, f"{mae:,.0f}")
print(version.source, version.run_id, version.aliases)uv run python ../listings-probes/versions.py2 {'strategy': 'median', 'indicator': 'False'} 56,336
models:/m-74f863d3b4cb445cb9aef27b5e7874ee 2aa31f0378d74cbbb57347f1fc9155ff []
1 {'strategy': 'median', 'indicator': 'True'} 56,562
models:/m-4584d273306842929b37a3c7634d7d73 c2b9b9c88bf04252851ef6cd4300da93 []search_model_versions takes a filter string, and returns the newest version first. A version's source is its logged model's own address, models:/m- and the model id, the folder name under mlruns/1/models/, and its run_id reaches the run's parameters and metrics, the parameters as text, as Lesson 6.2.1 found them. The experiment holds five runs, Lesson 6.2.1's three and these two, and only these two are versions, because a run registers its model only when registered_model_name is passed. Both aliases lists are empty, and the next subtitle sets one.
DON'T:
- DON'T read a higher version number as a better model. A version number counts registrations, and version 2 is only the later of the two.
- DON'T delete
mlflow.dbto tidy the project. The registry's names, versions and aliases live in it, and.gitignorekeeps it out of the repository, so a lost one comes back only by training and promoting again.
An Alias and promote.py
What. An alias is a name on a registered model that points at one of its versions. MlflowClient().set_registered_model_alias(name, alias, version) creates it, or moves it when it already exists, models:/listings-price@champion addresses whichever version carries it, and get_model_version_by_alias(name, alias) returns that version. One version may carry several aliases, and an alias names one version at a time.
Why. Code that loads @champion never changes when the served model does. Promoting a new version, or rolling back to an old one, is moving the label in the registry, with no edit, no rebuild of the code and no file copied.
When. Once a version is chosen to serve, and every time that choice changes.
src/listings/promote.py, new:
"""Point the champion alias at one registered version of the price model."""
import sys
from mlflow import MlflowClient
from listings.train import MODEL_NAME
def promote(version, alias="champion"):
MlflowClient().set_registered_model_alias(MODEL_NAME, alias, version)
if __name__ == "__main__":
promote(sys.argv[1])
print(f"{MODEL_NAME} version {sys.argv[1]} is now champion")promote imports MODEL_NAME from train.py, so the two scripts can never disagree about the name, and takes the alias as a parameter so other code can set one beside champion. Run as a module, it promotes the version given on the command line, which sys.argv[1] holds as a string, and the client accepts it. Point the champion at version 1:
uv run python -m listings.promote 1listings-price version 1 is now championVersion 1 is Lesson 3.2.3's pipeline, the one this project has reproduced since Lesson 6.1.1, with the indicators that keep the fact that a value was blank. It is the champion because it is the project's model, and not because of its score. Read the alias back, in ~/dev/listings-probes/alias.py:
from mlflow import MlflowClient
client = MlflowClient()
print(client.get_registered_model("listings-price").aliases)
champion = client.get_model_version_by_alias("listings-price", "champion")
print(champion.version, champion.aliases)
# the same two versions, listed by search
print([v.aliases for v in client.search_model_versions("name='listings-price'")])uv run python ../listings-probes/alias.py{'champion': 1}
1 ['champion']
[[], []]The registered model maps each alias to its version, and the version fetched by its alias lists the alias. search_model_versions still shows two empty lists, although version 1 carries champion: in MLflow 3.17.1 a search does not fill in aliases, so an alias is read through get_registered_model or get_model_version_by_alias.
DON'T:
- DON'T search for the champion with
search_model_versions. It listed[[], []]while version 1 carriedchampion, so a loop over its results finds no alias anywhere. - DON'T write a version number into a script that serves.
@championfollows every promote, and a hard-coded/1keeps serving version 1 after the team has moved on.
Loading by Alias as pyfunc and as sklearn
What. mlflow.pyfunc.load_model(uri) returns a PyFuncModel, a wrapper whose predict first checks the input frame against the signature, converts what it can convert safely, and only then calls the pipeline's own predict. mlflow.sklearn.load_model(uri) returns the fitted Pipeline itself, with no check. Both resolve an alias once, at the moment they load.
Why. pyfunc is the interface every kind of MLflow model shares, and the one that enforces the signature, so it is what a service loads. The sklearn loader gives back the pipeline's steps and attributes, which is what a script that inspects the model needs.
When. pyfunc to serve a model, and sklearn to look inside one.
Send one listing, and seven variants of it, through both loaders, in ~/dev/listings-probes/loaders.py:
import mlflow
import pandas as pd
URI = "models:/listings-price@champion"
as_pyfunc = mlflow.pyfunc.load_model(URI)
as_sklearn = mlflow.sklearn.load_model(URI)
print(type(as_pyfunc).__name__, type(as_sklearn).__name__)
print(as_pyfunc.metadata.model_id, as_pyfunc.metadata.run_id)
listing = {"area_sqm": 85.0, "year_built": 1998.0, "distance_km": 3.0,
"rooms": 3, "bathrooms": 1, "neighborhood": "Riverside",
"property_type": "apartment", "energy_rating": "C",
"condition": "good"}
one = pd.DataFrame([listing])
frames = {
"as trained": one,
"year_built 1998": one.assign(year_built=1998),
"rooms 3.5": one.assign(rooms=3.5),
"rooms '3'": one.assign(rooms="3"),
"area_sqm '85'": one.assign(area_sqm="85"),
"extra garage": one.assign(garage=1),
"no rooms": one.drop(columns="rooms"),
"no year_built": one.drop(columns="year_built"),
}
for name, frame in frames.items():
for model in (as_pyfunc, as_sklearn):
try:
print(name, model.predict(frame).round())
except (mlflow.exceptions.MlflowException, ValueError) as err:
# the reason MLflow puts after its own "Error: "
print(name, str(err).rsplit("Error: ", 1)[-1])uv run python ../listings-probes/loaders.pyPyFuncModel Pipeline
m-4584d273306842929b37a3c7634d7d73 c2b9b9c88bf04252851ef6cd4300da93
as trained [384235.]
as trained [384235.]
year_built 1998 Incompatible input types for column year_built. Can not safely convert int64 to float64.
year_built 1998 [384235.]
rooms 3.5 Incompatible input types for column rooms. Can not safely convert float64 to int64.
rooms 3.5 [394641.]
rooms '3' Incompatible input types for column rooms. Can not safely convert str to int64.
rooms '3' [384235.]
area_sqm '85' Incompatible input types for column area_sqm. Can not safely convert str to float64.
area_sqm '85' [384235.]
2026/11/03 13:32:49 WARNING mlflow.models.utils: Found extra inputs in the model input that are not defined in the model signature: `['garage']`. These inputs will be ignored.
extra garage [384235.]
extra garage [384235.]
no rooms Model is missing inputs ['rooms'].
no rooms columns are missing: {'rooms'}
no year_built columns are missing: {'year_built'}
no year_built columns are missing: {'year_built'}Each pair of lines is pyfunc, then sklearn. The champion's model_id and run_id are version 1's from versions.py, so the alias resolved to version 1, and the listing as trained is 384,235 through both loaders, Lesson 3.2.3's first price. pyfunc's whole message opens with the frame and the schema it compared, and rsplit keeps only the reason after them.
| One-row frame | pyfunc | sklearn |
|---|---|---|
| as trained | 384,235 | 384,235 |
year_built as the integer 1998 |
refused, int64 to float64 |
384,235 |
rooms 3.5 |
refused, float64 to int64 |
394,641 |
rooms as the text "3" |
refused, str to int64 |
384,235 |
area_sqm as the text "85" |
refused, str to float64 |
384,235 |
an extra garage column |
a WARNING, the column dropped, 384,235 | 384,235, no word |
no rooms column |
refused by name, Model is missing inputs |
columns are missing |
no year_built column |
passed on, the pipeline raises | columns are missing |
pyfunc converts a column only when no value can change. It widens an integer column to a double when the integer type is 32 bits or narrower, or when every value is 0 or 1, and refuses int64, because a float64 holds every whole number exactly only up to 2 to the 53rd and an int64 reaches far past it. It never parses text into a number and never cuts a fraction to an integer. A required column must be present, and an optional one may be absent, so a frame without year_built passes the check and the pipeline itself refuses it. The sklearn loader checks nothing: it parses "3" and "85" into numbers and prices a 3.5-room flat at 394,641, half a room's price above three rooms, a listing no seller can post.
An alias is read when a model loads, never afterwards, in ~/dev/listings-probes/held.py:
import mlflow
import pandas as pd
from listings.promote import promote
URI = "models:/listings-price@champion"
listing = pd.DataFrame([{"area_sqm": 85.0, "year_built": 1998.0,
"distance_km": 3.0, "rooms": 3, "bathrooms": 1,
"neighborhood": "Riverside",
"property_type": "apartment",
"energy_rating": "C", "condition": "good"}])
held = mlflow.pyfunc.load_model(URI) # version 1
promote(2)
fresh = mlflow.pyfunc.load_model(URI) # loaded after the move
print(held.metadata.run_id == fresh.metadata.run_id)
print(held.predict(listing).round(), fresh.predict(listing).round())
promote(1) # champion back on version 1uv run python ../listings-probes/held.pyFalse
[384235.] [385826.]The model loaded before the move still answers as version 1, at 384,235, and the one loaded after it answers as version 2, at 385,826. A process that loads @champion once at startup, as Lesson 6.2.4's service does, keeps serving the version it loaded until it loads again.
DON'T:
- DON'T serve the pipeline from
mlflow.sklearn.load_model. It priced 3.5 rooms at 394,641 and parsed"85"into an area, where pyfunc refused both and named the column. - DON'T read pyfunc's refusal of
1998as pickiness. A float64 holdsint64values exactly only up to 2 to the 53rd, and MLflow converts nothing that could change a value. - DON'T expect a running process to follow a promote.
heldanswered 384,235 afterchampionhad moved to version 2.
Explicit Dtypes and to_frame
What. DTYPES in data.py maps every feature to the dtype it had in the training rows, and to_frame(rows) builds a frame from a list of dictionaries with the columns in FEATURES order and casts it with astype(DTYPES). The cast turns None into a blank of the column's own dtype, NaN in a float64 or a text column, and an integer year into a float.
Why. pandas chooses each column's dtype from the values it is given. One row gives it one value per column: 1998 alone makes an int64 column, and None alone makes an object column holding the Python None, which pyfunc lets through an optional column and the encoder has never seen. A service receives its listings one at a time.
When. Every time rows reach a model as dictionaries, a JSON request, a form or a queue message, and above all one row at a time.
Lesson 3.2.3 priced three Riverside apartments: one complete, one with the area blank, and one with the area, the year and the rating blank. A form sends them as dictionaries, with None for a blank and the year as the whole number 1998. In ~/dev/listings-probes/one_row.py:
import mlflow
import pandas as pd
model = mlflow.pyfunc.load_model("models:/listings-price@champion")
complete = {"area_sqm": 85.0, "year_built": 1998, "distance_km": 3.0,
"rooms": 3, "bathrooms": 1, "neighborhood": "Riverside",
"property_type": "apartment", "energy_rating": "C",
"condition": "good"}
no_area = complete | {"area_sqm": None} # | merges two dicts
three_blank = no_area | {"year_built": None, "energy_rating": None}
listings = [complete, no_area, three_blank]
together = pd.DataFrame(listings)
print(list(together.dtypes.astype(str)))
print(model.predict(together).round())
for listing in listings:
alone = pd.DataFrame([listing])
try:
print(model.predict(alone).round())
except (mlflow.exceptions.MlflowException, ValueError) as err:
print(str(err).rsplit("Error: ", 1)[-1])
print(list(pd.DataFrame([three_blank]).dtypes.astype(str)))uv run python ../listings-probes/one_row.py['float64', 'float64', 'float64', 'int64', 'int64', 'str', 'str', 'str', 'str']
[384235. 426163. 434348.]
Incompatible input types for column year_built. Can not safely convert int64 to float64.
Incompatible input types for column year_built. Can not safely convert int64 to float64.
Found unknown categories [None] in column 2 during transform
['object', 'object', 'float64', 'int64', 'int64', 'str', 'str', 'object', 'str']Together, the three listings come back at Lesson 3.2.3's three prices, because the third listing's None made pandas store the area and the year as float64 with NaN and the rating as text with a blank, the training dtypes. Alone, the first two carry 1998 as an int64 and pyfunc refuses them. The third passes pyfunc, whose optional columns admit an object column of None, and fails in the pipeline: OneHotEncoder meets None in its third column, the rating, where training had only text and NaN. The fix is two additions to src/listings/data.py, shown in full:
"""Load the listings and split them the one way every lesson uses."""
import pandas as pd
from sklearn.model_selection import train_test_split
DATA_PATH = "data/listings.csv"
NUMERIC = ["area_sqm", "year_built", "distance_km", "rooms", "bathrooms"]
NOMINAL = ["neighborhood", "property_type", "energy_rating"]
LEVELS = ["poor", "fair", "good", "excellent"]
FEATURES = NUMERIC + NOMINAL + ["condition"]
TARGET = "price"
DTYPES = {
"area_sqm": "float64",
"year_built": "float64",
"distance_km": "float64",
"rooms": "int64",
"bathrooms": "int64",
"neighborhood": "str",
"property_type": "str",
"energy_rating": "str",
"condition": "str",
}
def load(path=DATA_PATH):
return pd.read_csv(path)
def split(listings):
return train_test_split(
listings[FEATURES], listings[TARGET], test_size=0.2, random_state=42
)
def to_frame(rows):
return pd.DataFrame(rows, columns=FEATURES).astype(DTYPES)DTYPES is the signature's types written as pandas dtypes, "str" being pandas 3's text dtype. columns=FEATURES fixes the order and the set of columns, so a dictionary that lacks a key gets that column blank and a key outside the nine is dropped. The three listings again, one at a time through to_frame, in ~/dev/listings-probes/priced.py:
import mlflow
from listings.data import to_frame
as_pyfunc = mlflow.pyfunc.load_model("models:/listings-price@champion")
as_sklearn = mlflow.sklearn.load_model("models:/listings-price@champion")
complete = {"area_sqm": 85.0, "year_built": 1998, "distance_km": 3.0,
"rooms": 3, "bathrooms": 1, "neighborhood": "Riverside",
"property_type": "apartment", "energy_rating": "C",
"condition": "good"}
no_area = complete | {"area_sqm": None}
three_blank = no_area | {"year_built": None, "energy_rating": None}
print(list(to_frame([three_blank]).dtypes.astype(str)))
print(to_frame([three_blank]).isna().iloc[0].tolist())
for listing in (complete, no_area, three_blank):
frame = to_frame([listing]) # one row at a time
print(as_pyfunc.predict(frame).round(), as_sklearn.predict(frame).round())uv run python ../listings-probes/priced.py['float64', 'float64', 'float64', 'int64', 'int64', 'str', 'str', 'str', 'str']
[True, True, False, False, False, False, False, True, False]
[384235.] [384235.]
[426163.] [426163.]
[434348.] [434348.]The lone third listing now has the training dtypes, with its three blanks as real NaNs, and each listing alone gets Lesson 3.2.3's price from both loaders: 384,235, 426,163 and 434,348. A test keeps it that way. In tests/test_data.py, the import line becomes from listings.data import FEATURES, TARGET, split, to_frame, and a fourth test goes at the end:
def test_to_frame_matches_training_dtypes(split_data):
X_train = split_data[0]
blank = {"area_sqm": None, "year_built": None, "distance_km": 3.0,
"rooms": 3, "bathrooms": 1, "neighborhood": "Riverside",
"property_type": "apartment", "energy_rating": None,
"condition": "good"}
frame = to_frame([blank])
assert frame.dtypes.equals(X_train.dtypes)
assert frame[["area_sqm", "year_built", "energy_rating"]].isna().all(axis=None)frame.dtypes.equals(X_train.dtypes) compares the nine dtypes, in order, with the training rows' own, and isna().all(axis=None) is True only when every cell of the three columns is blank, so the Nones became real blanks. The whole suite, then the commit:
uv run pytest
git add src tests
git commit -m "feat: register the model with a signature and load it by alias"
git status --short============================= test session starts ==============================
platform linux -- Python 3.12.8, pytest-9.1.1, pluggy-1.7.0
rootdir: /home/goku/dev/listings-service
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.15.1
collected 18 items
tests/test_data.py .... [ 22%]
tests/test_features.py ..... [ 50%]
tests/test_model.py ......... [100%]
============================== 18 passed in 1.55s ==============================
[main 335b697] feat: register the model with a signature and load it by alias
4 files changed, 63 insertions(+), 7 deletions(-)
create mode 100644 src/listings/promote.pyEighteen pass, Lesson 6.2.1's seventeen and the new one, and git status --short prints nothing: mlflow.db and mlruns/ are in .gitignore, and the probe scripts sit outside the project. The seconds and the commit hash differ on your machine.
DON'T:
- DON'T build a one-row frame with
pd.DataFrame([listing])for a model. The same three listings priced together and failed alone, two on anint64year and one on aNonerating. - DON'T treat
to_frameas validation.astypecuts 3.9 rooms to theint643 without a word, and the champion prices that listing at 384,235, the three-room price, while"n/a"for an area raisescould not convert string to float: 'n/a': Error while type casting for column 'area_sqm'. Checking values belongs before it, the job of Lesson 6.2.4's request schema.
Lab Exercises
Lab Exercise 1: The Step That Needs the Trust, and an Alias Moved
In ~/dev/listings-probes/lab1.py, fit build_pipeline() on the training rows. For the model, each of the three column groups and each of the two steps inside the numeric group, print the step's name and the types skops would refuse to load. On the step that holds a refused type, print every attribute whose value is a NumPy dtype, reading the attributes with vars(step), Python's dictionary of an object's attributes, and testing each value with isinstance(value, np.dtype). Then point champion at version 2 with promote and print the registered model's aliases, try to promote version 7, print the error and the aliases again, then move champion back to version 1 and print the version the alias names. Say in a comment why MLflow refused to save the pipeline without skops_trusted_types and what the version 7 attempt left in the registry.
Lab Exercise 2: Six Hundred Listings, One at a Time
In ~/dev/listings-probes/lab2.py, load the champion as pyfunc and turn the 600 test rows into a list of dictionaries with X_test.to_dict(orient="records"), one dictionary per row, the shape a service receives. Print the number of dictionaries and the first one whose rating is blank. Price each dictionary alone through pd.DataFrame([row]), count the refusals by their reason and print the counts beside the number of test rows with a blank rating. Then price each dictionary alone through to_frame, check that the 600 prices agree with the champion's prices for the whole test frame at once, and print their MAE beside the MAE version 1's run logged, read through the champion's own run_id. Say in a comment why the refused rows fail and what the matching MAE proves.
Solution 1: The Step That Needs the Trust, and an Alias Moved
import numpy as np
import skops.io
from mlflow import MlflowClient
from mlflow.exceptions import MlflowException
from listings.data import load, split
from listings.features import build_pipeline
from listings.promote import promote
from listings.train import MODEL_NAME
X_train, X_test, y_train, y_test = split(load())
pipe = build_pipeline().fit(X_train, y_train)
num = pipe["prep"].named_transformers_["num"]
# ** unpacks each mapping into one dictionary
steps = {"model": pipe["model"], **pipe["prep"].named_transformers_, **num.named_steps}
for name, step in steps.items():
print(name, skops.io.get_untrusted_types(data=skops.io.dumps(step)))
impute = num["impute"]
print({k: v for k, v in vars(impute).items() if isinstance(v, np.dtype)})
client = MlflowClient()
promote(2)
print(client.get_registered_model(MODEL_NAME).aliases)
try:
promote(7)
except MlflowException as err:
print(err)
print(client.get_registered_model(MODEL_NAME).aliases)
promote(1)
print(client.get_model_version_by_alias(MODEL_NAME, "champion").version)
# SimpleImputer keeps the dtype it fitted on and the one it fills with as
# numpy.dtype objects, a type skops 0.16.0 does not trust by default, so
# MLflow's reload of its own file failed. Version 7 does not exist, so the
# call raised and left champion on version 2 until promote(1) moved it.uv run python ../listings-probes/lab1.pymodel []
num ['numpy.dtype']
cat []
cond []
impute ['numpy.dtype']
scale []
{'_fit_dtype': dtype('float64'), '_fill_dtype': dtype('float64')}
{'champion': 2}
Model Version (name=listings-price, version=7) not found
{'champion': 2}
1The numeric group reports the type because the imputer inside it does, and the scaler, the encoders and the linear model hold none, so the whole trust list exists for one step. The imputer's two dtypes carry a leading underscore, Python's mark for an attribute meant for the library's own use, which is why scikit-learn's documented attributes of the imputer never list them. The failed promote raised before anything changed, and the aliases read the same after it.
DON'T:
- DON'T trust
numpy.dtypefor a pipeline you have not read. Here it is the imputer's own record offloat64, and the list in another project's model can name a type that runs code. - DON'T leave
championwherever a check moved it. Lesson 6.2.4's service loads the alias it finds, and every lesson after this one expects version 1.
Solution 2: Six Hundred Listings, One at a Time
from collections import Counter
import mlflow
import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error
from listings.data import load, split, to_frame
X_train, X_test, y_train, y_test = split(load())
model = mlflow.pyfunc.load_model("models:/listings-price@champion")
rows = X_test.to_dict(orient="records")
print(len(rows), next(r for r in rows if pd.isna(r["energy_rating"])))
refused = Counter() # counts each reason
for row in rows:
try:
model.predict(pd.DataFrame([row]))
except mlflow.exceptions.MlflowException as err:
refused[str(err).rsplit("Error: ", 1)[1]] += 1
print(refused)
print(X_test["energy_rating"].isna().sum())
alone = np.array([model.predict(to_frame([row]))[0] for row in rows])
batch = model.predict(X_test)
print(np.allclose(alone, batch))
logged = mlflow.get_run(model.metadata.run_id).data.metrics["mae"]
print(f"{mean_absolute_error(y_test, alone):,.0f}", f"{logged:,.0f}")
# A blank rating comes out of to_dict as the float nan, so a row alone
# makes a float64 rating column, and pyfunc never turns a float into
# text. Through to_frame every row prices, and the MAE matches the
# logged one: the registry serves the pipeline that run scored.uv run python ../listings-probes/lab2.py600 {'area_sqm': 174.4, 'year_built': 2016.0, 'distance_km': 8.4, 'rooms': 6, 'bathrooms': 3, 'neighborhood': 'Southgate', 'property_type': 'house', 'energy_rating': nan, 'condition': 'good'}
Counter({'Incompatible input types for column energy_rating. Can not safely convert float64 to <U0.': 52})
52
True
56,562 56,562All 52 refusals are the 52 test rows with a blank rating, and <U0 is NumPy's name for its text type, the type MLflow checks a string column against. The blank areas and years pass alone, because a float nan in a float64 column is already the training dtype, and the years come out of to_dict as floats, 2016.0, since the column was float64. Through to_frame the 600 lone prices agree with the batch's, compared with np.allclose, the comparison test_batch_and_column_order_do_not_matter in tests/test_model.py makes, and their MAE is version 1's 56,562.
DON'T:
- DON'T test a model only on a batch. The 600 rows priced together, and 52 of them were refused one at a time.
- DON'T compare 600 lone prices to the batch's with
==. Two computations of the same price are compared within a tolerance, the waytests/test_model.pycompares a row alone with the same row in a batch.
Theory Assessment
Q1: Three Kinds of Column
The signature read back from version 1 types year_built as double (optional), rooms as long (required) and distance_km as double (required). Say where each type and each flag came from, why every training run prints a hint about integer columns, and what pyfunc does with a one-row frame whose year_built is the integer 1998.
Hint: Count the blanks of each column in the 2,400 training rows, and ask what dtype pandas gives a column with a blank and one without.
Q2: A List of One Type
A teammate proposes skops_trusted_types=get_untrusted_types(data=skops.io.dumps(pipe)) in log_pipeline, so that no future pipeline ever stops a save again. Say which step of this pipeline needs the trust and why, what the list permits and what it still refuses, where MLflow keeps it so that neither load_model call in this lesson passes it, and what the teammate's line would do to a pipeline whose file holds a type that runs code.
Hint: Read the lines under skops_trusted_types in MLmodel, and ask who reads the list a save writes there.
Q3: Promote the Lower Score
A teammate reads versions.py, sees version 2's MAE of 56,336 under version 1's 56,562, and runs promote 2 while a service that loaded @champion at startup is running. Say what the 226 was measured on and why choosing by it breaks Lesson 3.1.2's rule, what moving the alias does to the running service, what version 2 does to Lesson 3.2.3's third listing, and what single command undoes the promote.
Hint: Run the third listing through models:/listings-price/2, and read what held.py printed after the alias moved.
Q4: Required Is Not Complete
A teammate reads distance_km: double (required) and concludes that pyfunc refuses any listing without a distance. They send the complete Riverside listing with "distance_km": None through to_frame and the champion, then the same listing with "rooms": None. Say what each one does and why, what required actually checks, what wrote the price for the first one, and why the second never reaches pyfunc.
Hint: to_frame casts before pyfunc checks anything. Ask what a blank becomes in a float64 column and in an int64 one, and read the numeric imputer's statistics_.
Answer 1:
All three came from infer_signature reading the 2,400 training rows. year_built holds 110 blanks there, so pandas stores it as float64 and the signature makes it a double, optional because it was blank at least once. rooms holds none, so it stays int64, a long, and is required, and distance_km is a float64 because its values have decimals, required because it has no blank either. The hint comes from the two integer columns, rooms and bathrooms: an integer column cannot hold a blank, so a request with either one blank would arrive as floats and be refused, and infer_signature warns about it on every run, from MLflow's own types/utils.py. A one-row frame with year_built as 1998 is an int64 column, and pyfunc refuses it with Can not safely convert int64 to float64, because MLflow widens an integer column to a double only when it is 32 bits or narrower or holds nothing but 0 and 1, and an int64 can hold whole numbers a float64 cannot store exactly.
DON'T read double (optional) as a promise that pyfunc converts a whole-number year. It is the dtype the column had in training, and an int64 one is refused.
Answer 2:
The numeric group's SimpleImputer needs it: it keeps _fit_dtype and _fill_dtype, the dtype it fitted on and the one it fills with, as numpy.dtype objects, which skops 0.16.0 does not trust by default, and Lab Exercise 1 found no other step holding an untrusted type. The list permits exactly the types it names, so any other untrusted type in the file is still refused. A save writes it into MLmodel under skops_trusted_types, and every loader reads it from there, which is why neither load_model call passes it. The teammate's line trusts whatever the file holds, so a pipeline carrying a type that runs code would have that type trusted at the save, written into MLmodel, and rebuilt by every loader that ever reads the version, the check turned into a list of everything it was meant to catch.
DON'T build the trust list from the file it is meant to check. A list is a set of types someone read and vouched for, and numpy.dtype is on this one because the imputer's two attributes were read.
Answer 3:
The 226 is the gap between the two versions' MAE on the 600 test rows, the rows train.py scores every run on. Choosing a version by that number makes the test rows decide, Lesson 3.1.2's rule against choosing anything by the test score, and the R² of both is 0.766, so the scores barely separate them anyway. Moving the alias changes nothing in the running service: it loaded version 1 at startup, and held.py showed a model loaded before the move answering 384,235 after champion reached version 2, so the service keeps serving version 1 until it restarts, and then serves version 2 without anyone having looked at what that means. For Lesson 3.2.3's third listing it means 392,147 instead of 434,348, 42,201 lower, because without the indicators a blank area and year are only the medians, and the fact that they were blank is gone. The undo is uv run python -m listings.promote 1.
DON'T promote by the test MAE. The test rows score a choice once, and a gap of 226 on 600 rows hides a 42,201 difference on a listing with blanks.
Answer 4:
The first is priced at 367,266. to_frame casts None in the float64 distance_km column to NaN, a valid float64 value, so the column is present and of the right type, and pyfunc passes it. required means only that the column must be in the frame, and says nothing about its values. The imputer then writes the training median, 5.75 in statistics_, into the blank, and a listing whose distance nobody sent gets a confident price, with no indicator column either, because distance_km had no blank at fit time. The second never reaches pyfunc, because to_frame itself raises TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType': an int64 column cannot hold a blank, so the cast fails. Built without to_frame, the same listing makes an object column and pyfunc refuses it with Can not safely convert object to int64. In both cases the refusal comes from the integer type and not from required.
DON'T rely on the signature to refuse a blank in a float column. It refuses missing columns and wrong types, and a blank distance is neither.
Assignment
Assignment: A Promote That Can Be Undone
promote.py moves champion and forgets where it was, so undoing a bad promote means someone remembering the old number. Write ~/dev/listings-probes/rollback.py with two functions over the registry. promote_keeping(version) reads the version champion names, and when there is one and it differs from version, points a second alias, previous, at it before moving champion, through promote's own alias parameter. rollback() moves champion to the version previous names and then deletes previous, and with no previous it stops the script with the message no previous version to roll back to.
Two things the lesson did not use. get_model_version_by_alias raises MlflowException, Registered model alias previous not found. for instance, when the alias does not exist, so reading an alias that may be absent means catching it and returning None. And MlflowClient().delete_registered_model_alias(name, alias) removes an alias and leaves its version untouched.
After each step, load @champion afresh and print the step's name, the registered model's aliases and the champion's price for Lesson 3.2.3's third listing. Run: the start, promote_keeping(2), promote_keeping(2) a second time, rollback(), and rollback() once more, then print the script's exit code from the shell.
Hints:
- Versions come back from the registry as integers and from the command line as strings, so compare them as
str(old) != str(version). raise SystemExit("message")prints the message to standard error and ends the script with exit code 1.
Solution:
import mlflow
from mlflow import MlflowClient
from mlflow.exceptions import MlflowException
from listings.data import to_frame
from listings.promote import promote
from listings.train import MODEL_NAME
client = MlflowClient()
blank = {"area_sqm": None, "year_built": None, "distance_km": 3.0,
"rooms": 3, "bathrooms": 1, "neighborhood": "Riverside",
"property_type": "apartment", "energy_rating": None,
"condition": "good"}
def holder(alias):
try:
return client.get_model_version_by_alias(MODEL_NAME, alias).version
except MlflowException:
return None # no such alias
def promote_keeping(version):
old = holder("champion")
if old is not None and str(old) != str(version):
promote(old, alias="previous")
promote(version)
def rollback():
old = holder("previous")
if old is None:
raise SystemExit("no previous version to roll back to")
promote(old)
client.delete_registered_model_alias(MODEL_NAME, "previous")
def show(step):
model = mlflow.pyfunc.load_model(f"models:/{MODEL_NAME}@champion")
price = model.predict(to_frame([blank]))[0]
print(step, client.get_registered_model(MODEL_NAME).aliases, f"{price:,.0f}")
show("start")
promote_keeping(2)
show("promote 2")
promote_keeping(2)
show("promote 2 again")
rollback()
show("rollback")
rollback()uv run python ../listings-probes/rollback.py
echo $?start {'champion': 1} 434,348
promote 2 {'champion': 2, 'previous': 1} 392,147
promote 2 again {'champion': 2, 'previous': 1} 392,147
rollback {'champion': 1} 434,348
no previous version to roll back to
1The first promote kept version 1 under previous, and the champion's price for the blank listing fell to version 2's 392,147. The second call found champion already on version 2 and left previous alone, which is what the str(old) != str(version) test is for: without it previous would move to version 2, and the rollback would roll back to the version it was meant to leave. The rollback brought back 434,348 and removed previous, so the registry ends as it started, {'champion': 1}, with both versions still in it. The second rollback found no previous and stopped with code 1, so a script or a pipeline that calls it sees the failure instead of a silent success. Every show loads @champion again, the only way a process sees a moved alias.
DON'T delete previous before champion has moved. The deletion is the last step, so a rollback that fails halfway still leaves the old version named.
Additional Resources
Optional, and nothing later depends on either. Both are for a closer look at the two checks this lesson ran into.
- MLflow 3.17.1 documentation - "Model Signatures and Input Examples", the section "Signature Enforcement and Validation". MLflow's own rules for what a signature checks: required and optional fields, extra fields ignored, the conversions it applies and the ones it rejects,
longtodoubleamong them, and the trap of an integer column with missing values. - skops documentation - "Secure persistence with skops". Why pickle can execute arbitrary code during loading, how skops builds only trusted types, and how
get_untrusted_typesshows what a file holds before it is loaded.
Summary
A model that leaves its training script needs three things it did not need inside it: a name, a statement of what it takes, and a way to be found without knowing where it sits. The registry gives the first, the signature the second and the alias the third, and each of them is a small piece of text beside the pipeline rather than a change to the pipeline itself. Nothing in the model changed between joblib.dump and @champion. What changed is how much of it can be checked, named and moved by someone who never ran its training.
The refusals were the useful part. skops refused a type nobody had vouched for, pyfunc refused a year in the wrong integer type and a room count with a fraction, and both were right in a way the scikit-learn loader was not, since it priced a 3.5-room flat with full confidence. Each refusal named the column and the reason, which is the difference between an error and a wrong price: a wrong price is never reported by anything.
And the hardest failure was the one a batch hid. Three listings priced together and failed one at a time, because pandas reads a column's type from whatever values it holds, and one row gives it too little to read. Writing the dtypes down in DTYPES moved that knowledge out of pandas' guesses and into the project, where a test can hold it, and it is the line that lets a service, receiving one listing at a time, give Lesson 3.2.3's prices to the unit.
Cheat Sheet
Part A - Reference Table
| Task | Code | Note |
|---|---|---|
| Log with a signature | log_model(pipe, name="model", signature=..., input_example=X_train.head(3)) |
signature from infer_signature(X_train, pipe.predict(X_train)) |
| Register while logging | registered_model_name="listings-price" |
prints Created version 'N' |
| Version number | info.registered_model_version |
from the returned ModelInfo |
| skops trust | skops_trusted_types=["numpy.dtype"] |
the imputer's _fit_dtype and _fill_dtype, written into MLmodel |
| What skops refuses | skops.io.get_untrusted_types(data=skops.io.dumps(obj)) |
before anything is built |
| Read a signature | mlflow.models.get_model_info("models:/listings-price/1").signature |
double (optional) where training had blanks |
| List versions | client.search_model_versions("name='listings-price'") |
newest first, aliases always empty |
| A version's run | mlflow.get_run(version.run_id).data |
params as strings, metrics as floats |
| Set or move an alias | client.set_registered_model_alias(name, "champion", 1) |
promote.py, an unknown version raises |
| Read an alias | client.get_model_version_by_alias(name, "champion") |
or get_registered_model(name).aliases |
| Address by alias | models:/listings-price@champion |
resolved once, at load |
| Address by version | models:/listings-price/1 |
never follows a promote |
| Serve | mlflow.pyfunc.load_model(uri) |
checks the signature, refuses int64 to float64 |
| Inspect | mlflow.sklearn.load_model(uri) |
the Pipeline, no check, priced 3.5 rooms |
| One row | to_frame([listing]) |
astype(DTYPES), None becomes NaN |
| Not validation | to_frame on 3.9 rooms |
cut to 3 without a word |
Part B - Compact Code Block
signature = infer_signature(X_train, pipe.predict(X_train))
info = mlflow.sklearn.log_model(
pipe, name="model", signature=signature,
input_example=X_train.head(3),
registered_model_name="listings-price",
skops_trusted_types=["numpy.dtype"], # trust the imputer's dtypes
)
MlflowClient().set_registered_model_alias("listings-price", "champion", 1)
model = mlflow.pyfunc.load_model("models:/listings-price@champion")
model.predict(to_frame([listing])) # training dtypes, one row