The previous lesson ended with a winning model — recall 0.68 against the baseline's 0.43 — that for now only exists as "the run that turned out well" and a churn_model.pkl in a folder. That file is the last weak link in the chain: nobody can tell, by looking at it, which run it came from, whether it's the good version, or who decided it was. In this lesson we solve that with MLflow's model registry: a catalog with a name, numbered versions, and aliases (@champion, @challenger) that turns "the hand-blessed pickle" into a versioned, traceable model, loadable by name from any code — exactly what module 4's prediction service will need.
Contents
- The problem of the hand-"blessed" model
- What a model registry is
- Registering the winning model as
churn-cineclick - Model versions
- Aliases and tags:
@champion,@challenger(and why the classic stages are deprecated) - Loading the model by alias from code
- The promotion flow: human review, always
- Metadata and lineage: documenting the model
The problem of the hand-"blessed" model
Think about how CineClick works today, even with everything built in the previous lessons. The pipeline produces models/churn_model.pkl. If tomorrow module 4's prediction service needs the model, what does it do? Copy that file? From which machine, from whose last dvc repro? And when the hyperparameter search produces a better candidate, how does it become "official"? By overwriting the pickle? Then we lose the previous one. By renaming it to churn_model_v2.pkl? That is literally how the v2 mystery was born.
The symptoms of a hand-managed model:
- No identity: a
.pkldoesn't carry its metrics, its run of origin, or its training data inside. - No versions: either you overwrite (losing history) or you rename by hand (suffix chaos:
_v2,_final,_FINAL_FOR_REAL). - No status: nothing distinguishes "the model in production" from "Tuesday's experiment". The status lives in Laura's head.
- No consumption contract: every consumer (a service, a batch job, a notebook) points at a different file path and learns about changes by surprise.
DVC versions the .pkl as a pipeline output, and that solves reproducibility (you can go back to any commit and regenerate it). But it doesn't solve lifecycle management: which version is official, who approved it, and how consumers ask for it. That's what the registry exists for.
What a model registry is
A model registry is a centralized catalog of models built around three key ideas:
- Registered model: an entry with a stable name — for us,
churn-cineclick. It represents the problem ("CineClick's churn model"), not one concrete file. It's the name by which everyone will ask for the model, forever. - Version: each time a model is registered under that name, the registry assigns it an incremental number (1, 2, 3...). Each version points to the artifacts of the run it came from: the binary, the signature, the
input_example. - Status (aliases/tags): labels marking which version plays which role — which is the champion in production, which is the challenger under evaluation.
| Pickle in a folder | Model registry | |
|---|---|---|
| Identity | File path (models/churn_model.pkl) |
Stable name + version number |
| History | Lost on overwrite (barring Git/DVC archaeology) | All versions coexist, listed |
| Origin (lineage) | Unknown | Direct link to the run: params, metrics, commit, data |
| Status (which one is the good one?) | Verbal convention / Laura's memory | Explicit, queryable alias (@champion) |
| Input contract | None | Validatable signature |
| How a consumer asks for it | By copying a file from somewhere | Stable URI: models:/churn-cineclick@champion |
| Audit (who promoted and when) | Impossible | Recorded in the registry |
One practical detail before we start: the registry needs a tracking server with a database as its backend store — it doesn't work against a plain mlruns/ folder. The server we stood up in the previous lesson already qualifies:
mlflow server \ --backend-store-uri sqlite:///mlflow.db \ --artifacts-destination ./mlartifacts \ --host 0.0.0.0 --port 5000
Registering the winning model as churn-cineclick
There are two ways to register a model. The first: after the fact, from an existing run. Laura locates the search's winning run in the UI (rf-300-20-balanced) and registers its model:
import mlflow
# The run_id is copied from the UI (or found with mlflow.search_runs)
run_id = "f0d4a91c2e8b4f7a9c31d5e6b8a2f4c0"
# "runs:/<run_id>/model" points to that run's 'model' artifact
version = mlflow.register_model(
model_uri=f"runs:/{run_id}/model",
name="churn-cineclick",
)
print(version.version) # -> 1 (first version of the registered model)The second, more convenient for the everyday flow: at logging time, adding registered_model_name to the log_model call we already had in train.py:
mlflow.sklearn.log_model(
model,
artifact_path="model",
signature=signature,
input_example=X.head(5),
registered_model_name="churn-cineclick", # registers a new version
)Which to use? It depends on team policy. Registering from train.py means every dvc repro creates a version — convenient, but it fills the registry with intermediate versions. Registering after the fact means only deliberately chosen candidates enter the catalog. CineClick adopts the second policy: only serious candidates enter the registry, picked by a human after looking at the run table; exploration stays in tracking.
Following that policy, Laura registers two versions, and the numbering tells the project's story:
- Version 1: the baseline model from module 2 (the
dvc reprorun with 100 trees) — accuracy 0.86, precision 0.61, recall 0.43. It's what is conceptually "in production" today. - Version 2: the search winner (
n_estimators=300,max_depth=20,class_weight="balanced") — accuracy 0.842, precision 0.55, recall 0.68, F1 0.61.
Note the happy irony: CineClick once again has "a v2"... but this time the v2 has a run of origin, params, metrics, commit, and data all linked. The difference between the two v2s is this entire module.
Model versions
Each version of a registered model is immutable in its essentials: it points to its run's artifacts and that doesn't change. What can be edited is its description, its tags, and its aliases. Ideas worth pinning down:
- Versions are automatic, incremental numbering: you don't choose "v2.1"; the registry assigns 1, 2, 3... The semantics ("this is the good one") don't go in the number, they go in the aliases.
- Multiple versions coexist: version 1 doesn't disappear when version 2 arrives. That enables instant rollback: if v2 disappoints, the alias goes back to v1 without retraining anything.
- A version links to its run: from the registry UI, one click takes you to the run with all its params and metrics. Lineage is navigable, not archaeological.
Aliases and tags: @champion and @challenger
How do you mark which version is "the official one"? Modern MLflow uses aliases: named pointers that point at exactly one version, and that can be moved.
A historical note that avoids confusion when reading old documentation: MLflow used to have a system of stages (Staging, Production, Archived) that versions "transitioned" through. That system has been deprecated since MLflow 2.9: it was rigid (only those fixed states) and ambiguous (can two versions be in Production?). Aliases replace it with clear advantages: you define whatever names your process needs, and each alias points to exactly one version. If a tutorial teaches you transition_model_version_stage, it's old material.
The convention CineClick adopts (which is by now a de facto standard):
@champion: the version approved for production. There is only one.@challenger: the version that's a candidate to dethrone it, under evaluation.
Assigning them, with the client API:
from mlflow import MlflowClient
client = MlflowClient()
# Version 1 (baseline model) is the current champion: it's what's running
client.set_registered_model_alias("churn-cineclick", "champion", version=1)
# Version 2 (balanced) enters as the challenger
client.set_registered_model_alias("churn-cineclick", "challenger", version=2)Tags complement aliases: while an alias is a unique pointer ("the champion"), a tag is descriptive information that can repeat across versions (qa_approved: "true", dataset: "july-2026"). Mental rule: alias = role, tag = attribute.
client.set_model_version_tag("churn-cineclick", version=2, key="subgroups_approved", value="pending")Loading the model by alias from code
Here comes the payoff for all the effort. Any consumer can ask for the model by its registry URI, knowing nothing about paths, runs, or versions:
import mlflow
mlflow.set_tracking_uri("http://127.0.0.1:5000")
# "Give me whichever version has the champion alias"
model = mlflow.sklearn.load_model("models:/churn-cineclick@champion")
# You can also pin a concrete version (useful for debugging or auditing):
model_v2 = mlflow.sklearn.load_model("models:/churn-cineclick/2")Two important observations:
- The consumer loading
@championdoesn't change a single line when a new version is promoted: the alias moves in the registry and the next load brings the new champion. Model deployment is decoupled from code deployment. - This URI —
models:/churn-cineclick@champion— is exactly the one module 4's FastAPI service will use to load the model at startup. We're pinning it down here as the contract between training and serving.
graph LR
R["Winning run<br/>(tracking, 03-01)"] -->|register_model| RM["Registered model<br/>churn-cineclick"]
RM --> V1["Version 1<br/>recall 0.43"]
RM --> V2["Version 2<br/>recall 0.68"]
V2 -->|alias| CH["@champion"]
CH -->|"models:/churn-cineclick@champion"| C1["Prediction service<br/>(module 4)"]
CH --> C2["Batch scoring"]
V1 -.->|possible rollback| CHThe promotion flow: human review, always
And how does version 2 go from @challenger to @champion? This is the part of the registry that is not technical, and that's deliberate: promoting a model is a decision with business consequences, and a person makes it. The system computes, compares, and recommends; the decision to promote is always human. (And in a regulated sector, auditable on top of that: who approved what, and when.)
The flow CineClick pins down:
- Candidate registered as a new version with the
@challengeralias, with its full description and lineage. - Review of global metrics against the champion, on the same test set (guaranteed here by
random_state=42and the pipeline's stratified split). v2 vs. v1: recall 0.68 vs. 0.43, F1 0.61 vs. 0.50, in exchange for precision 0.55 vs. 0.61. - Subgroup validation: global metrics can hide local degradations. Laura slices the metrics by
plan, bytenure_monthsbrackets, and bypayment_method, and checks that the recall improvement isn't concentrated in one segment while another gets worse. (This is also a first line of defense for fairness: making sure the model doesn't perform markedly worse for a specific group.) - Explicit business criterion: agreed with the retention team — "we accept precision dropping to 0.50 as long as recall exceeds 0.60, because the cost of an unnecessary call is far lower than that of an undetected cancellation". v2 clears it with margin.
- Decision and alias move, with the review documented:
# The reviewer (Laura, with retention's sign-off) moves the alias
client.set_registered_model_alias("churn-cineclick", "champion", version=2)
client.delete_registered_model_alias("churn-cineclick", "challenger")
client.set_model_version_tag("churn-cineclick", 2, "approved_by", "laura")
client.set_model_version_tag("churn-cineclick", 2, "promotion_date", "2026-07-07")- Rollback ready: if v2 disappoints after deployment,
set_registered_model_alias("churn-cineclick", "champion", version=1)and the next consumer that loads the alias goes back to the previous model. No retraining, no hunting for files.
In module 5 we'll automate parts of this flow (tests that verify the criteria as CD gates), and in 5.4 we'll see how to expose the challenger to real traffic (shadow/canary) before deciding. But the structure — candidate, evidence, criteria, human decision, rollback — is pinned down here.
Metadata and lineage: documenting the model
A version without context is just another pickle with a number. The last mile is recording in the registry everything a future reader (Marc six months from now, an auditor, Laura herself) will need:
client.update_model_version(
name="churn-cineclick",
version=2,
description=(
"RandomForest with class_weight='balanced', n_estimators=300, max_depth=20. "
"Raises recall from 0.43 to 0.68 (precision 0.61 -> 0.55, F1 0.50 -> 0.61). "
"Selected in the 2026-07-07 hyperparameter search (12 runs). "
"Criterion: maximize recall with precision >= 0.50 (agreed with retention)."
),
)
client.set_model_version_tag("churn-cineclick", 2, "git_commit", "a1b2c3d")
client.set_model_version_tag("churn-cineclick", 2, "dvc_data_md5", "af12bc90...")A good part of the lineage already comes "for free" because the version links to its run, and the run (previous lesson) carries the Git commit and the DVC data hash. The version's description and tags make it visible without digging: the model's fact sheet reads in the registry UI in thirty seconds. The signature we logged with the model completes the sheet: anyone can see which columns and types v2 expects before trying to use it. In module 6 we'll formalize this documentation (model cards, governance); the discipline of annotating every version starts here.
Common Mistakes and Tips
- Using the deprecated stages (
Staging/Production) because the tutorial you found is from 2022. They'll work for a while with warnings, but you're learning a system on its way out. Aliases and tags are the present. - Registering every run automatically "just in case": a registry with 80 versions and no criteria is as opaque as a folder with 80 pickles. Decide an entry policy (at CineClick: only human-reviewed candidates) and stick to it.
- Moving the
@championalias without leaving a trace of why: the move itself gets recorded, but the reason doesn't, unless you write it down (description/tags). The auditor's question isn't "when did it change?" but "why, and with what evidence?". - Loading by pinned version in production (
models:/churn-cineclick/2) and forgetting about it: you lose the decoupling the alias provides, and promotions stop reaching the service. Pinned version for debugging and auditing; alias for consuming. - Comparing challenger and champion on different test sets: if each was evaluated with a different split or a different version of the data, the comparison is noise. Same test set, same data (the runs' DVC hash lets you verify it).
- Forgetting that the registry needs a server with a database: against an
mlruns/folder without a backend store,register_modelfails. If you work alone,mlflow server --backend-store-uri sqlite:///mlflow.dbis enough.
Exercises
-
Rollback policy. Three days after promoting v2, the retention team reports that too many calls are going to premium-plan customers who weren't going to cancel. Write (a) the code to return
@championto version 1, (b) the tags you would leave on version 2 to document what happened, and (c) which analysis in the promotion flow should have caught the problem beforehand. -
Model URIs. State what each of these URIs loads and in which situation you would use each one:
models:/churn-cineclick@champion,models:/churn-cineclick/1,runs:/f0d4a91c.../model. -
Alias design. CineClick wants, in addition to champion and challenger, to keep track of the latest version that passed subgroup validation even if it isn't a candidate yet. Would you model it with an alias or with a tag? Justify the choice and write the code.
Solutions
- (a) Rollback:
from mlflow import MlflowClient
client = MlflowClient()
client.set_registered_model_alias("churn-cineclick", "champion", version=1)(b) Documentation on v2:
client.set_model_version_tag("churn-cineclick", 2, "status", "withdrawn")
client.set_model_version_tag("churn-cineclick", 2, "withdrawal_reason",
"excess false positives on premium plan (2026-07-10)")(c) Step 3 of the flow (subgroup validation): precision sliced by the plan=premium segment would have shown the concentration of false positives before promoting. Lesson: global metrics (precision 0.55) can be acceptable while one specific subgroup is far below.
-
models:/churn-cineclick@championloads whichever version holds the alias at that moment — it's the URI for production consumers, because it follows promotions without code changes.models:/churn-cineclick/1loads exactly version 1 even if the alias has moved — useful for auditing, comparing, or reproducing past behavior.runs:/f0d4a91c.../modelloads the artifact directly from the run, bypassing the registry — useful during exploration, before the model has candidate status. -
With an alias (for example
@approved): the requirement is to point at one concrete version ("the latest that passed validation"), which is exactly the semantics of an alias — a unique pointer that moves. Anapproved=truetag would pile up on every version that ever passed validation, and you'd have to hunt for the most recent one. Both can coexist: the tag as a historical attribute, the alias as the pointer to the current one.
Conclusion
The churn model is no longer a loose file: it's the registered model churn-cineclick, with version 1 (the baseline, recall 0.43) and version 2 (the balanced one, recall 0.68) coexisting in the catalog, v2 promoted to @champion after a review flow with explicit criteria and a human decision, and any consumer can load it with models:/churn-cineclick@champion — the URI module 4 will use as its contract. Versions, status, lineage, and rollback: the "blessed model" problem from the diagnosis is closed. But as we prepare the ground to serve it, a subtler risk surfaces: the model expects features (the ones features.py builds), and in production someone will have to compute them again, in different code and at a different moment. If that computation isn't identical to training's, the champion model will produce corrupted predictions without anything visibly failing. That problem — and the tool the industry has invented for it, the feature store, with the corresponding critical look — is the subject of the module's final lesson.
MLOps Course
Module 1: MLOps Fundamentals
- What MLOps is and why models die in the notebook
- The lifecycle of an ML model in production
- MLOps maturity levels and team roles
- The course project: from notebook to production
Module 2: From Notebook to Reproducible Code
- Structuring an ML project: from notebook to package
- Reproducible environments and dependency management
- Data versioning with DVC
- Reproducible training pipelines
Module 3: Experiments and Model Registry
- Experiment tracking with MLflow
- Model registry: versioning and promoting models
- Feature stores: when and what for
Module 4: Serving Models in Production
- Deployment patterns: batch, online and streaming
- A prediction service with FastAPI
- Packaging with Docker
- Scaling and deployment: Kubernetes and serverless
- Inference optimization: latency and cost
Module 5: Automation: CI/CD and Orchestration
- CI for ML: testing code, data and models
- CD: automating model deployment
- Orchestrating ML pipelines
- Release strategies: shadow, canary and A/B
