Model Registry
Every example in this section continues with the well-known Adult Income
dataset [1] used throughout the rest of this documentation, and with the
Logistic Regression, Decision Tree, and Random Forest classifiers trained on it
using the model_tuner library [2].
Click here to view the corresponding codebase for this workflow.
That page ends by loading the model objects back from disk with explicit paths.
This works for one project on one machine. It stops working as soon as there are
several tracking stores under one project, or the mlruns/ tree gets copied
somewhere else, or you simply cannot remember which of eleven runs produced the
number in the paper.
model_registry indexes every model in every MLflow store under a project root
and addresses them by name. There are no hard-coded experiment ids, run ids, or
paths anywhere in the module, and mlflow itself is not a dependency: the
module reads the local FileStore tree (the meta.yaml files) directly from
disk.
Two problems it handles that the plain MLflow client does not:
Several tracking roots under one project.
mlruns/preprocessingandmlruns/modelsare both indexed, not just whichever one you point at.Relocated artifact trees. The
artifact_locationrecorded at training time often points into a directory that no longer exists, becausemlruns/trees get copied between machines and projects. Every MLflow artifact API then resolves to a missing path. Artifacts are instead rebased onto the store root actually found on disk.
Quick Start
from model_metrics.model_registry import available, load, load_all, rank
available() # everything on disk, with logged metrics
rank("rf", metric="roc_auc") # every random forest run, ordered
model_rf = load("rf_smote") # pin the variant you actually reported
models = load_all() # one model per variant
If nothing is found, the error explains why rather than just reporting an
empty result. Start with diagnose() when that happens.
Configuration
Every setting is optional and can be supplied by environment variable (before
import) or by calling configure() (before the first lookup).
Variable |
Meaning |
|---|---|
|
Directory to walk. Defaults to the working directory, walking up to the
repository boundary to find an |
|
Outcome token stripped from artifact folder names. |
|
Comma-separated store prefixes eligible to win a metric comparison. |
|
Artifact filename. Defaults to |
|
Set to |
Important
The default search root is the working directory, not the module’s own
location. Installed in site-packages the module is nowhere near your
project, and in a src/ layout its location resolves to src/ rather
than the repository. The walk climbs until it finds an mlruns/
directory, which also lets the registry work when called from a
notebooks/ subdirectory, and stops at .git so an unrelated
mlruns/ higher up the filesystem is never picked up silently.
- configure(root=None, target=None, model_file=None, loader=None, stores=None)
Override configuration at runtime and drop the cached index. Call before the first lookup, or after moving or regenerating
mlruns/.- Parameters:
root (str or pathlib.Path, optional) – Directory to walk.
target (str, optional) – Outcome token to strip from artifact folder names.
model_file (str, optional) – Artifact filename to look for inside each run.
loader (callable, optional) – Custom callable
path -> objectreplacing the default deserializer, for artifacts that need special handling.stores (str or sequence of str, optional) – Store prefixes eligible to win a metric comparison.
- Returns:
None.- Return type:
None
from model_metrics import model_registry
# Adult Income artifact folders are named <algo>_income
model_registry.configure(target="income")
- refresh()
Drop the cached filesystem index and the model load cache. Call after writing new runs during the same session.
- Returns:
None.- Return type:
None
Naming: algo vs variant
Several runs commonly write to one artifact folder, so a folder name alone cannot address a run. The registry splits the two apart:
algo comes from the artifact folder:
lr,dt,rf.variant comes from the MLflow run name:
lr_orig,rf_smote,rf_orig_no_sex.
variant is the addressable key. algo is what you group by.
Artifact folders are conventionally named <algo>_<TARGET>, so setting
MODEL_REGISTRY_TARGET to the outcome name turns rf_income into rf
and lets you group by algorithm rather than by outcome. The token is stripped
wherever it appears, not only as a suffix, so ablation variants survive
intact:
TARGET = "income"
rf_income -> rf
rf_income_no_sex -> rf_no_sex
This matters for the Adult Income examples specifically. A fairness workflow on
that dataset usually produces ablation variants alongside the full models,
dropping sex or race to see what the model loses. Those variants have to
stay individually addressable while still grouping under their algorithm, which
is what the wherever-it-appears rule buys.
Note
The default is an empty token, meaning no stripping, which is the safe behavior for a project whose naming convention is unknown.
Discovery
- available()
Every model found on disk, newest first, with its logged metrics.
- Returns:
DataFrame with
variant,algo,experiment,experiment_id,run_id,path(relative to the project root), and one column per logged metric.- Return type:
pandas.DataFrame
- rank(name, metric='roc_auc', ascending=False, experiment=None)
Order every run matching
nameby a metric. For looking, not for loading:rank("rf")shows every random forest run so you can see which one isrf_smote.- Parameters:
name (str) – A variant, an algo, a run id, or a qualified
experiment/variantkey.metric (str, optional) – Metric to sort on. Ignored if the runs did not log it.
ascending (bool, optional) – Sort direction. Defaults to
False(best first for higher-is-better metrics).experiment (str, optional) – Restrict to one experiment, by id or by name.
- Returns:
DataFrame with
variant,algo,experiment,run_id, and one column per logged metric.- Return type:
pandas.DataFrame
- Raises:
LookupError – If nothing matches
name.
- metric_names()
- is_available()
True if at least one loadable model was found, without raising. Use this to gate registry-dependent code paths in environments that may have no MLflow store at all.
- Returns:
Whether the index is non-empty.
- Return type:
- store_summary()
One row per tracking store on disk, with how many models it holds and whether it is currently eligible to win a metric comparison. Use it to see what
STORESis excluding before trusting abest_per_algo()result.- Returns:
DataFrame with
store,models,experiments, andeligible.- Return type:
pandas.DataFrame
- diagnose()
Print the resolved configuration, active deserializer, YAML parser, any shims and repairs applied so far, the store summary, and the full inventory. Also runs as
__main__.- Returns:
None.- Return type:
None
python -m model_metrics.model_registry
Notes
Failure messages are specific. Three distinct situations get three distinct explanations: the search root does not exist; no MLflow tracking store was found anywhere beneath it; stores were found but no run contains an artifact under the configured filename. Each names the setting to change.
Remote backends are out of scope. The module reads a local FileStore directory. Runs on a remote tracking server or a SQL backend need the mlflow client instead, and the error says so.
Stores: constraining what can win
The index is always global. available(), rank(), load(),
load_all(), and variants() see every model on disk, so nothing is ever
hidden from you.
STORES constrains only the metric-based selectors, best_per_algo() and
load_best_per_algo(), so that a superseded store cannot win a comparison it
was never meant to be in.
from model_metrics.model_registry import set_stores, stores, best_per_algo
set_stores("mlruns/models") # only the live store
set_stores("mlruns/models", "mlruns/archive") # two of them
set_stores() # clear the constraint
best_per_algo(stores="mlruns/models") # one call only
- set_stores(*prefixes)
Restrict which stores may win a metric comparison.
- stores()
- Returns:
The store prefixes currently constraining metric-based selection.
- Return type:
Important
Prefixes are matched on whole path segments. mlruns/models matches
mlruns/models and mlruns/models/12345, but not
mlruns/models_old/12345. A substring test would wrongly accept the
latter, which is the exact confusion this setting exists to prevent.
Note
When a constraint excludes every run that logged the requested metric, the error lists the stores that do have it, so widening the constraint is a matter of copying a name rather than going spelunking.
Selection
The default selection policy is "newest": when a name matches several runs,
the latest start_time wins, and a one-line note reports which run was chosen
and how.
Selecting by best test metric is available but is not the default, because picking the max-metric run is model selection on the evaluation set. Use it to inspect; pin by name to load.
- resolve(name, experiment=None, policy='newest', metric='roc_auc')
Resolve one entry without loading it.
- Parameters:
- Returns:
The resolved entry.
- Return type:
ModelEntry
- Raises:
If nothing matches; the error lists every available variant.
If
policy="best"but no matching run loggedmetric; the error lists the metrics that are present.
- load(name, experiment=None, policy='newest', metric='roc_auc')
Load one model. Cached. Aliased as
load_model.
- load_all(only=None, qualified=False)
One model per variant, not per algo, so runs sharing an artifact folder stay distinct.
Loading the three Adult Income classifiers by name gives the same objects the rest of the documentation uses, without pinning a path:
from model_metrics.model_registry import load
model_lr = load("lr_orig")
model_dt = load("dt_orig")
model_rf = load("rf_orig")
- resolve_metric(metric)
Map a loose metric name onto the key actually logged.
- Parameters:
metric (str) – A metric name, exact or approximate.
- Returns:
The logged key.
- Return type:
- Raises:
If the name is ambiguous; the error lists every candidate.
If nothing resembles it; the error lists every logged metric.
Note
resolve_metric normalizes spaces and hyphens to underscores, so
average_precision finds test_average_precision, valid ap, or
test Average Precision. Without that normalization a raw substring test
never matches and the default metric argument to best_per_algo() raises
on a perfectly ordinary store. Average-precision aliases (aucpr,
pr_auc, ap, and others) are recognized. Ambiguity raises rather than
silently ranking on the wrong split.
Metric-Based Selection
- best_per_algo(metric='average_precision', experiment=None, per_experiment=True, ascending=False, stores=None)
The winning run for each algo, ranked by
metric. Inspect before loading.- Parameters:
metric (str, optional) – Metric to rank on. Resolved through
resolve_metric.experiment (str, optional) – Restrict to one experiment.
per_experiment (bool, optional) – Keep runs from different experiments in separate groups instead of letting them compete. Defaults to
True.ascending (bool, optional) – Set
Truefor lower-is-better metrics.stores (tuple, optional) – Override the global store constraint for this call only.
- Returns:
DataFrame with
algo,store,winner,experiment, the ranking metric,run_id,n_candidates, and the winner’s remaining metrics.- Return type:
pandas.DataFrame
- Raises:
If no run logged the resolved metric.
If the store constraint excludes every run that did.
- load_best_per_algo(metric='average_precision', experiment=None, per_experiment=True, ascending=False, qualified=False, stores=None)
Load the top run for each algo by
metric.
Important
If metric is computed on your test set, this is model selection on the
test set and the winning score is optimistically biased. Fine for
exploration. For anything you report, pin the variant by name, or select on
validation data using the functions below.
Note
The n_candidates column says how many runs the winner beat under the
same constraints. A winner with n_candidates=1 won by default, which is
worth knowing before quoting it as the best of anything.
Validation-Set Selection
The defensible path: choose champions on validation data, then report test metrics for those winners only.
- score_candidates(data, scorer=None, name=None)
Score every indexed model against a held-out set.
- Parameters:
data (tuple, dict, or callable) – One of three forms. A
(X, y)tuple applies one matrix to every model. A dict keyed by algo, variant, or qualified key supplies a different matrix per model, which is what ablation variants need. A callableentry -> (X, y)covers anything more involved.scorer (callable, optional) – Callable
(y_true, y_score) -> float. Defaults toaverage_precision_score.name (str, optional) – Restrict to models matching this variant, algo, or run id.
- Returns:
DataFrame with
variant,algo,experiment,score,run_id, and anerrorcolumn for any model that failed.- Return type:
pandas.DataFrame
- select_on_validation(data, scorer=None, per_experiment=True)
Champion of each family, chosen on the data you pass. Pass validation data. Then report test metrics for these winners only.
- Parameters:
- Returns:
One row per winning model, sorted by score.
- Return type:
pandas.DataFrame
- load_selected(selection, qualified=False)
Load the models named in a
select_on_validation()frame.
The dict form of data is what makes this work for the Adult Income fairness
variants, where the ablated models were fitted on a narrower feature set and
cannot consume the full validation matrix:
from model_metrics.model_registry import select_on_validation, load_selected
winners = select_on_validation((X_valid, y_valid))
models = load_selected(winners)
# ablation variants scored against their own matrices in the same sweep
winners = select_on_validation({
"rf_orig": (X_valid, y_valid),
"rf_no_sex": (X_valid.drop(columns=["sex"]), y_valid),
})
Note
A model that fails to load or score is captured per row with an error
column rather than aborting the sweep, so one unloadable artifact does not
cost you the other twenty scores.
Cross-Version Loading
Artifacts are often written under a different scikit-learn than the one installed, and pickles reference private sklearn classes by module path. Those classes get renamed or removed between versions, so a plain load dies with something like:
AttributeError: Can't get attribute '_RemainderColsList' on
<module 'sklearn.compose._column_transformer'>
Two layers handle the drift.
Tolerant unpickling. Missing sklearn internals are synthesized rather than raising. Every load tries the configured deserializer, retries with the known shims installed, then falls back to an unpickler that synthesizes any remaining sklearn class on demand. Missing non-sklearn classes still raise, since synthesizing those would be guessing.
Post-load fitted-state repair. Shimming fixes unpickling but not fitted
state: newer transform() code reads instance attributes that an older
fit() never wrote. Each repair restores the attribute to the value that
reproduces the original training-version behavior, not the current version’s.
- repair_estimator(model, verbose=False)
Patch fitted attributes the installed scikit-learn expects but the pickle predates. Applied automatically on every load; exposed for models obtained some other way.
- shimmed()
- repaired()
- backend()
- Returns:
Which deserializer is in use:
"model_tuner","joblib", or"pickle".- Return type:
Notes
- The two repairs that ship:
SimpleImputer._fill_dtypeis set tostatistics_.dtype, which makes the newerastype()call a no-op and matches versions that did not cast at all. Gated on a probe of the installed sklearn: the attribute was introduced partway through the 1.x line, so on versions that never had it, its absence is normal and repairing anyway would fillrepaired()with noise.ColumnTransformerremainder columns. A synthesized_RemainderColsListunpickles as an empty list, which underremainder="passthrough"silently drops every passthrough column. This is the failure most likely to bite an Adult Income pipeline, since those routinely one-hot the categorical columns and pass the numeric ones through. It surfaces far downstream asValueError: Feature shape mismatch, expected: 25, got 15. The remainder set is fully determined by fitted state, so it is rebuilt exactly as sklearn computes it at fit time and written to all three places that must agree:_transformer_to_input_indices,_remainder, andtransformers_. Repairing_remainderalone is not enough, becausetransform()readstransformers_when fitted.
- Deserializer selection:
model_tuner’sloadObjects[2] is used when installed, so whatever it does at save time is mirrored at load time.Otherwise
joblib(which reads plain pickles too), thenpickle.Override with
configure(loader=...)for artifacts needing special handling.
Important
None of this makes cross-version loading supported. It makes it survivable
long enough to verify. Both layers warn when they fire, and the warning
points at verify_entry(). The correct fix is to pin the training
scikit-learn version; check the run’s artifacts/*/requirements.txt for
what that was.
Verification
This is the check that makes a cross-version load defensible. If the recomputed score matches the logged one, the estimator state survived whatever shimming and repair was needed. If it does not, do not use the model.
- verify_entry(name, X, y, split='test', tol=1e-3, **resolve_kw)
Recompute ROC AUC, average precision, and Brier score for a loaded model and compare them against what was logged.
- Parameters:
name (str) – The variant to verify.
X (array-like) – Feature matrix corresponding to
split.y (array-like) – True labels corresponding to
split.split (str, optional) – Which logged metrics to compare against, matched as a prefix on the logged key (
"test","valid","train"). PassNoneto compare against every logged metric.tol (float, optional) – Absolute tolerance for the comparison. Defaults to
1e-3.resolve_kw – Forwarded to
resolve(experiment,policy,metric).
- Returns:
DataFrame with
logged_metric,logged,recomputed,delta, andok.- Return type:
pandas.DataFrame
- verify_all(X, y, metric='average_precision', split='test', tol=1e-3, **best_kw)
Verify every champion returned by
best_per_algo()in one call.- Returns:
One row per model with
algo,variant,n_compared,max_abs_delta, and a singleok.- Return type:
pandas.DataFrame
Important
split must correspond to the data you pass. Comparing a logged
validation score against metrics recomputed on the test set will always
disagree and means nothing. When no logged key matches the requested split,
the function warns and returns an empty frame rather than silently reporting
success.
Note
The default tolerance is 1e-3 rather than something tighter because
MLflow commonly stores metrics rounded to three decimals. verify_all
reports the worst absolute delta alongside the pass/fail, so a single bad
load cannot hide behind an aggregate.
from model_metrics.model_registry import verify_entry, verify_all
verify_entry("rf_orig", X_test, y_test) # test split
verify_entry("rf_orig", X_valid, y_valid, split="valid")
verify_all(X_test, y_test)
The ModelEntry Record
resolve() returns a frozen dataclass describing one indexed model. Most
workflows never touch it directly, but it is what the DataFrame-returning
functions are built from.
Field |
Meaning |
|---|---|
|
From the artifact folder: |
|
From the MLflow run name: |
|
Local artifact path, verified to exist. |
|
MLflow run id. |
|
MLflow experiment id. |
|
MLflow experiment name. |
|
The tracking store this run belongs to. |
|
Run start timestamp, used by the |
|
Mapping of logged metric key to value. |
|
|
Optional Dependencies
The module degrades rather than requiring anything beyond pandas.
mlflow is not needed at all. The FileStore tree is read directly.
model_tuner [2], joblib, pickle: tried in that order for deserialization.
backend()reports which is active.PyYAML is used when present. Without it, a built-in flat-scalar parser covers every field the module actually reads (
experiment_id,name,run_id,run_uuid,run_name,lifecycle_stage,start_time). MLflow’smeta.yamlfiles are flat key/value documents, so the fallback is not a compromise.