MLOps: MLflow & Unity Catalog¶
ml4 logs a fitted pipeline to MLflow as a pyfunc model on one canonical path,
so a single mlflow.pyfunc.load_model serves calibrated, thresholded predictions
with no ml4-specific loader. Logging is opt-in, best-effort, and hardened to load
in a fresh environment or on Databricks.
In short
- Pass
fit(mlops=MLOpsRunSettings(...))to log the fitted pipeline to MLflow as a pyfunc;MLflowSettings.experiment_nameis the on switch. - Logging is best-effort: a logging or registry error warns and never fails a good fit.
- The run carries non-default params,
fit_*metrics in the metric's own units, and provenance tags;evaluate()addstest_*scores to the same run. - The pyfunc returns probabilities by default; pass
params={"output": "labels"}for labels. - Register to the MLflow Model Registry with a plain name, or to Unity Catalog with a three-level name.
Optional dependency
MLflow logging needs the mlops extra: pip install "parleml-ml4[mlops]", which installs
mlflow-skinny: the tracking client, the registry (Unity Catalog included) and the
pyfunc wrapper, without the tracking server or UI. Install mlflow as well if you want
those or mlflow models serve. MLflow is
imported lazily inside the logger, so a plain install that never logs needs
none of it, and a missing MLflow degrades to a skipped log, never a failed
fit.
Opt-in via fit(mlops=...)¶
Pass an MLOpsRunSettings to fit. It bundles the per-run MLOps knobs into two
sub-records, mlflow and registry. An empty MLOpsRunSettings() reproduces the
"no MLOps" behaviour exactly.
from parleml_ml4 import AutoSupervised, Ml4Config
from parleml_ml4.mlops_settings import MLOpsRunSettings, MLflowSettings, ModelRegistrySettings
cfg = Ml4Config(); cfg.data.target_column = "y"
mlops = MLOpsRunSettings(
mlflow=MLflowSettings(experiment_name="risk-q1", run_name="baseline"),
)
asv = AutoSupervised(cfg)
model = asv.fit(train_df, mlops=mlops)
asv.mlflow_run_id_ # the run id of the logging pass (None if not logged)
MLflowSettings.experiment_name is the master switch: None (the default)
disables tracking entirely. Logging is best-effort: a logging or registry
error is caught and warned, and never fails a good fit.
The logging pass is the registered stage of the fit's event stream. With no mlops, or
MLflow disabled, nothing registers the pipeline, so that stage is recorded skipped with
reason disabled_by_config. The fitted pipeline is returned in memory; persist it with
save().
| Sub-record | Purpose | Key fields |
|---|---|---|
MLflowSettings |
which run this fit logs to | experiment_name, run_name, run_description, nested, custom_tags |
ModelRegistrySettings |
where to register on success | model_name, unity_catalog_model_name, unity_catalog_aliases |
What lands in the run besides the model¶
- Params: the run's non-default config only (the same stripped diff a preset round-trip
uses), flattened to dotted keys:
engine.time_limit,hpo.provider, and so on. Logging the full config would bury the handful of knobs that differ between two runs. -
Metrics: the fit-time headline numbers: the constant-baseline gate's strongest model and baseline scores on the run's metric, the fraction of the baseline's error removed (
baseline_improvement), and the measured serving cost (infer_cost_s_per_row). Outer-test scores are not produced here, because fitting is not reporting: they come fromevaluate(), which the fit does not run. They do end up in the same run; see The honest test score.The two gate scores are logged in the metric's own units. Internally they live in AutoGluon space, where every metric is higher-is-better and a loss carries a flipped sign, so a run's RMSE is held as
-0.88. That is fine while the number travels next to the field naming its metric (which is howmeasures.jsoncarries it), but an MLflow metric key is the name, and its compare-runs table is a sort-by-this-column surface: a column calledfit_root_mean_squared_errorshowing-0.88reads as an RMSE of −0.88 and ranks two runs backwards.baseline_improvementis deliberately left alone: a fraction of error removed has no direction to correct. -
Tags: the provenance that qualifies those
fit_*numbers, on the one other plane a compare-runs view reads. A metric key is a bare number; these say how it was produced. They are written alongside your owncustom_tags, never instead of them.Tag Values What it tells you ml4.fitting_setoof/holdoutWhich rows every post-fit layer (calibration, decision threshold, conformal residuals, the baseline gate) was fitted on: the bagged out-of-fold pass over every training row, or a reserved slice the deployed model never trained on. ml4.strongest_score_basisuntuned/out_of_fold/in_sampleHow the strongest model's decision was selected before fit_<metric>was computed.untuned= no decision parameter came from these rows (regression, a probability metric, a binary threshold that refused to tune, or a multiclass run serving plain argmax).out_of_fold= the binary operating point was re-selected fold-wise first.in_sample= the parameter was selected on the very rows scored, so the number is an upper bound, not a measurement: a binary set too thin to re-select fold-wise, or any multiclass run whose tuned decision layer serves (its K one-vs-rest bars and the comparison that kept them all came from these rows, and no fold-wise re-selection of that composite rule exists).ml4.decision_layer_revision1,2, …Which revision of the decision layer (calibration + the decision threshold + the baseline gate) produced this run's numbers. 1at fit; higher when the layer was re-decided afterwards without retraining. A re-logged model is otherwise indistinguishable from the one it replaced.ml4.ensemble_weights_selected_in_sampletrue/falseOOF runs only. truewhen the strongest model is AutoGluon's greedy weighted ensemble, whose blend weights were hill-climbed on the very rows the score is computed over. Absent on the holdout path, where the question cannot arise.There is no
fit_<metric>_at_tuned_thresholdkey: the basis is a per-run fact, not a per-run key name. A different column name would break the sort a compare-runs table exists for.
Re-deciding the layer is not a refit, and the registry must not confuse the two¶
A refit retrains the model. Re-deciding the decision layer does not: it re-fits calibration, the decision threshold and the baseline gate from the strongest model's own stored out-of-sample output, in seconds, and leaves the trained model untouched. The distinction matters to MLOps in three places:
- Model selection stays frozen. The leaderboard ranked candidates on the calibration
bake-off's criterion, so a re-decided layer means the strongest model was selected under one layer
and serves another. Re-running selection would cost what the cheap path exists to avoid, so
the discrepancy is reported instead, as
decision_provenance.selection_consistent. - A registered model owns its bytes. Registration takes a model-private snapshot of the artifact, so re-deciding an experiment's layer does not reach a model registered from it. That is deliberate: a production model must not change because someone adjusted the experiment it came from.
- The revision travels.
ml4.decision_layer_revisionis on the MLflow run, on the exported airgapped scorer, and on every derived artifact, because a number is only attributable to a model and a layer, not to a model alone.
The honest test score lands in the same run¶
fit_<metric> is measured on the rows the post-fit layers were selected on. The number a
reader of the run wants is the one measured on rows nothing was fitted to, and that
one only exists after evaluate(), which happens in a separate pass, after the fit's MLflow run
has already been closed. evaluate() therefore writes it back:
model = asv.fit(train_df, mlops=mlops) # logs fit_* + the provenance tags, closes the run
measures = asv.evaluate(test_X, test_y) # adds test_* to that same run
No knob: the façade forwards the run id it recorded on mlflow_run_id_, which is set only by a
fit(mlops=...) that really logged. A run id can also be passed straight to the library
function, parleml_ml4.evaluation.evaluate(pipeline, X, y, mlflow_run_id="…"), which is how you attach
a report to a run this process did not create.
| Metric | What it is |
|---|---|
test_<metric> |
The deployed model on the test set, at the frozen operating point, in the metric's own units, directly comparable with fit_<metric>. |
test_<metric>_ci95_low / _high |
The bootstrap 95% interval around it (absent when bootstrap=0). Converted and re-sorted with the score, so a loss metric's interval does not come back inverted. |
test_rows |
Rows handed to evaluate(). A score without its n is not interpretable. |
test_unscored_rows |
Rows carrying a class the model has no column for; present only when there were some. |
test_<objective>_at_operating_point / test_<objective>_optimistic |
Binary only. The deployed cut vs a cut re-tuned on the test set itself. The gap is what threshold selection would have bought you in hindsight; the first number is the one to trust. |
test_calibration_brier_raw, test_interval_coverage_*, … |
Every other number the report produces, verbatim. |
Failures here are per-key and never fatal: a rejected metric costs a warning naming the run,
and the measures dict evaluate() returns is identical either way.
The pyfunc: labels vs probabilities¶
The logged model is an Ml4PyfuncModel wrapping the ml4 bundle. Classification
defaults to probabilities (the contract downstream consumers expect);
params={"output": "labels"} returns thresholded labels instead. Regression
always returns point predictions.
import mlflow.pyfunc
served = mlflow.pyfunc.load_model("runs:/<run_id>/model")
proba = served.predict(X) # default: probabilities
labels = served.predict(X, params={"output": "labels"}) # thresholded labels
Set the pseudonymisation key where the model is served
A model that pseudonymises identifier columns (card numbers, emails, phones, IPs …)
scores only under the key it was fitted with, and does not store that key. Set
ML4_PSEUDONYMISATION_KEY in the serving environment to the training environment's
value before mlflow.pyfunc.load_model; loading refuses to start without it and names the
key fingerprint the model needs. See
The pseudonymisation key.
The output param is declared in the model signature, so callers keep the
label-vs-proba switch even when a full input/output signature is inferred (which
happens automatically when an input_example is supplied; AutoSupervised.fit
supplies a small one for you).
Requirement pinning¶
A model logged on the training box must load and serve in a fresh environment.
log_pipeline therefore pins a deployed-chain-only pip_requirements:
- the always-present serving chain (
autogluon.tabular,pandas,numpy,scikit-learn,scipy,mlflow,cloudpickle), each pinned to its installed version; - the always-present feature-engineering chain:
category_encoders,kmeans1d,skrub,RapidFuzz,disposable-email-domains,phonenumbers,tldextract,user-agents. These are not a model dependency, which is why they are easy to miss. The AutoGluon learner carries ml4's global feature generator, that object holds a reference tobuild_global_transformer, and restoring it therefore importsparleml_ml4.fe.transformers, whose__init__imports every transformer module. It happens insideTabularPredictor.load, on every ml4 pipeline, whatever the task, roster or recipe. The libraries that aretry-guarded or imported lazily (usaddress,pycountry,pgeocode,names_dataset,holidays) are deliberately not pinned: a recipe that never uses them must not pay for them; - leaf-library pins so the strongest model can be unpickled: LightGBM for a
GBMdeploy, XGBoost forXGB, CatBoost forCAT, imodels for any glass-box, and interpret-core forEBM. LightGBM is also pinned when the interval layer has CQR companions (they areLGBMRegressor); torchonly when a torch-backed family (e.g.NN_TORCH,FASTAI, TabICL) is deployed, so a plain GBM deploy keeps a small serve image.
The serving Python is pinned to the training interpreter, and the ml4 release is
pinned as a requirement (parleml-ml4==<version that fitted the model>) so
FittedPipeline.load can import parleml_ml4 at serve time. A model never carries ml4's
code, so the serving environment must be able to install that release: publish the
wheels somewhere it can reach and pass the index or --find-links line in
extra_pip_requirements. Logging fails when the installed ml4 has no readable version.
log_pipeline also takes extra_pip_requirements, appended verbatim, for
things ml4 cannot infer, such as a private index URL or a company-internal wheel. It
is a log_pipeline argument only: the AutoSupervised.fit(mlops=…) path does not
expose it, so reaching it means calling
parleml_ml4.delivery.mlflow_logging.log_pipeline yourself. Nothing ml4 itself needs
belongs there. If a serve image is missing an ml4 dependency, that is a defect in
the pins above, not something to work around per caller.
Registry vs Unity Catalog¶
Registration is driven by ModelRegistrySettings. A plain tracking run leaves the
model unregistered.
| Registry target | How to trigger |
|---|---|
| MLflow Model Registry | ModelRegistrySettings(model_name="risk_model") (a plain name) |
| Databricks Unity Catalog | ModelRegistrySettings(unity_catalog_model_name="catalog.schema.model") (a three-level name, registry URI databricks-uc) |
mlops = MLOpsRunSettings(
mlflow=MLflowSettings(experiment_name="risk-q1"),
registry=ModelRegistrySettings(
unity_catalog_model_name="main.risk.pd_model", # 3-level → Unity Catalog
unity_catalog_aliases=["Production"],
),
)
model = AutoSupervised(cfg).fit(train_df, mlops=mlops)
Under the hood, log_pipeline takes registered_model_name (the UC three-level
name if given, else the plain registry model_name). A three-level name
registers to Unity Catalog when the registry URI is set to databricks-uc.
Aliases¶
The first entry of unity_catalog_aliases is set as a registry alias (e.g.
Production) pointing at the just-registered version. Aliasing is best-effort: a
failure never fails the log, since the model is already registered.
Serving from the registry
Once registered with an alias, load by alias with
mlflow.pyfunc.load_model("models:/main.risk.pd_model@Production"). The same
labels-vs-proba output param applies.