1.12 Identifying the Best Run with the MLflow Client API
Key Takeaways
- `mlflow.search_runs()` returns a pandas DataFrame of runs; `MlflowClient().search_runs()` returns `Run` objects and is the API used inside pipelines.
- Ranking is done with `order_by=["metrics.val_f1 DESC"]` plus `max_results=1`; do not sort a collected DataFrame when the experiment is large.
- Filter strings are SQL-like and require the entity prefix: `metrics.`, `params.`, `tags.`, or `attributes.` — and parameter values are compared as strings.
- `run_view_type=ViewType.ACTIVE_ONLY` (the default) excludes deleted runs; `ALL` includes them, which is a common source of surprising 'best' runs.
- The best run's `run_id` yields the model URI `runs:/<run_id>/<artifact_path>`, which is what gets passed to `mlflow.register_model`.
1.12 Identifying the Best Run with the MLflow Client API
After a sweep of dozens or hundreds of trials, the best run has to be identified by code, not by eye — a promotion job cannot scroll the UI. MLflow exposes two search entry points, and the exam expects you to know both the syntax and the difference.
Two APIs, Two Return Types
import mlflow
from mlflow.tracking import MlflowClient
from mlflow.entities import ViewType
experiment = mlflow.get_experiment_by_name("/Shared/Experiments/churn_prediction")
# A) Fluent API -> pandas DataFrame, convenient for analysis in a notebook
runs_df = mlflow.search_runs(
experiment_ids=[experiment.experiment_id],
filter_string="metrics.val_f1 > 0.80 AND params.model_family = 'xgboost'",
order_by=["metrics.val_f1 DESC"],
max_results=1,
)
best_run_id = runs_df.iloc[0]["run_id"]
# B) Client API -> list of Run objects, preferred inside jobs and pipelines
client = MlflowClient()
best = client.search_runs(
experiment_ids=[experiment.experiment_id],
filter_string="metrics.val_f1 > 0.80",
run_view_type=ViewType.ACTIVE_ONLY,
order_by=["metrics.val_f1 DESC"],
max_results=1,
)[0]
print(best.info.run_id, best.data.metrics["val_f1"], best.data.params["max_depth"])
Use the fluent version when you want a DataFrame to plot or aggregate. Use the client
version in automation: it avoids materialising a DataFrame, exposes the full Run
entity (info, data, inputs), and is the same object the rest of the client API
consumes.
Filter-String Grammar
Every term must be prefixed with the entity it refers to:
| Prefix | Refers to | Example |
|---|---|---|
metrics. | A logged metric (numeric comparison) | metrics.val_auc >= 0.9 |
params. | A logged parameter (string comparison) | params.max_depth = '6' |
tags. | A run tag | tags.mlflow.runName LIKE 'sweep%' |
attributes. | Run attributes | attributes.status = 'FINISHED' |
Rules that trip people up:
- Parameters are stored as strings, so the comparison value must be quoted:
params.max_depth = '6', neverparams.max_depth = 6. - Conjunction is
ANDin upper case.ORis not supported in MLflow filter strings — a filter that needs a disjunction must be split into two searches. - Metric comparisons use the metric's latest logged value for that key.
Comparators by Field Type
| Field type | Supported comparators |
|---|---|
| Numeric — metrics and numeric attributes | =, !=, >, >=, <, <= |
| String — params, tags, string attributes | =, !=, LIKE (case-sensitive), ILIKE (case-insensitive) |
| Existence — tags and params only | IS NULL, IS NOT NULL |
Sets — attributes.run_id and datasets.* only | IN, with single-quoted members |
# Sweep runs that recorded an environment tag and cleared the F1 bar
client.search_runs(
experiment_ids=[experiment.experiment_id],
filter_string=(
"tags.mlflow.runName ILIKE 'sweep%' "
"AND tags.environment IS NOT NULL "
"AND metrics.val_f1 > 0.8"
),
order_by=["metrics.val_f1 DESC"],
)
Two more grammar rules the exam can lean on:
- Backtick awkward key names. A key containing a hyphen or starting with a digit
must be wrapped:
metrics.`cross-entropy-loss` < 0.5. Written bare, it is a parse error rather than an empty result. INis narrow. It applies toattributes.run_idand thedatasets.*fields only, and its members take single quotes. There is noINover an arbitrary param.
Passing search_all_experiments=True to mlflow.search_runs searches every
experiment the caller can read, which is convenient for a one-off audit and expensive
as a habit — prefer explicit experiment_ids in scheduled jobs.
Ordering, Limits, and View Types
order_by=["metrics.val_f1 DESC"]sorts server-side. Combined withmax_results=1, only one row crosses the wire — the correct pattern for an experiment with thousands of runs.- Multiple keys break ties:
order_by=["metrics.val_f1 DESC", "attributes.start_time ASC"]prefers the earliest run among equal scores, which makes promotion deterministic. run_view_typedefaults toACTIVE_ONLY. PassingViewType.ALLincludes deleted runs, andViewType.DELETED_ONLYreturns only them. If a search keeps returning a run someone deleted, this parameter is why.
From Best Run to Registered Model
model_uri = f"runs:/{best.info.run_id}/model"
version = mlflow.register_model(model_uri=model_uri,
name="prod_catalog.ml.churn_classifier")
runs:/<run_id>/<artifact_path> is the URI form for an artifact that has not been
registered yet. Once registered it is addressed as
models:/catalog.schema.name/<version> or by alias — see Sections 1.14 and 1.15.
Finding the Trials of One Sweep
Hyperopt and AutoML write each trial as a child run whose mlflow.parentRunId tag
holds the parent's run ID. A search across the experiment returns parents and children
together, so ranking purely by the trial metric can surface a parent run whose metric
was aggregated rather than measured. Constrain the search to one sweep:
children = client.search_runs(
experiment_ids=[experiment.experiment_id],
filter_string=f"tags.mlflow.parentRunId = '{parent_run_id}'",
order_by=["metrics.val_f1 DESC"],
max_results=1,
)
Exam tip: when a nested Hyperopt sweep is involved, remember that the parent run holds the summary while each trial is a child run. Searching for the best trial means searching the experiment's runs and ordering by the trial metric, not reading the parent.
Ordering, Ties, and Pagination
order_by accepts several keys and applies them in sequence, which is how ties are
broken deterministically:
runs = client.search_runs(
experiment_ids=[experiment.experiment_id],
filter_string="attributes.status = 'FINISHED'",
order_by=["metrics.val_f1 DESC", "attributes.start_time ASC"],
max_results=1,
)
Without a tiebreaker, two runs holding identical metrics come back in an unspecified
order, so re-running the same selection logic can register a different model version
from one day to the next. Two further details matter in practice. search_runs
paginates, so a result set larger than the page size requires following the returned
page token rather than assuming everything arrived in one call. And failed or killed
runs still appear in the results unless the filter excludes them — a run that crashed
after logging one epoch's metric can otherwise win a DESC ordering outright, which is
why attributes.status = 'FINISHED' belongs in the filter whenever a sweep might have
partial runs in it.
Which MLflow search query string correctly filters an experiment for runs where the validation accuracy exceeds 0.90 and the optimizer parameter was set to 'adam'?
A promotion job must select the single run with the highest val_roc_auc from an experiment containing 4,000 runs, and pass its model to mlflow.register_model. Which approach is correct and most efficient?
An MLflow search using filter_string="params.max_depth = 6" returns no runs, even though many runs logged max_depth as 6. Why?