Model Registry¶
For anyone who trains models with Ubunye and needs to keep versions, move them from test to live, and roll back. The registry works with any ML library.
- The problem: you train a new model every week. You need to know which version is live, which data made it, and you need a safe way to swap it.
- When to use it: as soon as a model leaves your laptop.
- The one command:
ubunye models list --use-case fraud_detection --model FraudRiskModel --store .ubunye/model_store
How a task uses the registry
A task always runs its transformations.py. You train and register a model
there, in Python: see Transform: Training a model,
the page that owns that topic.
Naming type: model under transform: in config.yaml does not run a
model: ubunye run warns (FutureWarning) and still runs transformations.py.
The transform.params block (with its registry: settings) is read only when
your transformations.py hands it to ModelTransform, as shown in
Registry settings for a model transform.
Lifecycle stages¶
| Stage | Meaning |
|---|---|
development |
Freshly registered; not yet validated |
staging |
Passed quality gates; ready for shadow or canary testing |
production |
Live; serving predictions |
archived |
Replaced or retired; kept for audit |
Every register() call creates a development version.
Promotion moves a version up the chain; demotion moves it down or to archived.
Storage layout¶
{store_path}/
└── {use_case}/
└── {model_name}/
├── registry.json ← version index (all metadata)
└── versions/
└── 1.0.0/
├── model/ ← opaque artifact (from model.save())
├── metadata.json ← model.metadata() output
└── metrics.json ← train() metrics
The engine calls model.save(path). The artifact format (pickle, ONNX, pt, …) is yours.
CLI commands¶
List versions¶
ubunye models list \
--use-case fraud_detection \
--model FraudRiskModel \
--store .ubunye/model_store
Output:
Version Stage Registered Key metrics
------------ -------------- ---------------------------- ------------------------------
1.3.0 production 2024-06-15T02:10:00.512301 auc=0.930, f1=0.880
1.2.0 staging 2024-06-10T01:45:00.104877 auc=0.910, f1=0.870
1.1.0 archived 2024-06-01T00:30:00.861020 auc=0.880, f1=0.850
Add --json to list (and to compare) for one JSON document, for scripts and
agents. Every models command takes --backend / -b (see
Choosing a backend).
Show version details¶
ubunye models info \
--use-case fraud_detection \
--model FraudRiskModel \
--version 1.3.0 \
--store .ubunye/model_store
Prints the version as JSON: timestamps, metrics, metadata and the id of the run that registered it.
Promote¶
ubunye models promote \
--use-case fraud_detection \
--model FraudRiskModel \
--version 1.3.0 \
--to production \
--promoted-by alice \
--store .ubunye/model_store
Promoting to production automatically archives the current production version.
--to takes staging or production. The version must pass the promotion gates
stored with the model (see below); --force skips them, prints a warning and
records on the version that it was forced.
Demote¶
ubunye models demote \
--use-case fraud_detection \
--model FraudRiskModel \
--version 1.3.0 \
--to staging \
--store .ubunye/model_store
--to takes development, staging or archived. Demote only moves a version
down: staging only from production, and never to production. Moving up is
a promotion, so it goes through promote and its gates. The Python API
(ModelRegistry.demote) refuses the same moves with a RegistryError.
Rollback¶
Restores a previous version to production and archives the current one:
ubunye models rollback \
--use-case fraud_detection \
--model FraudRiskModel \
--version 1.2.0 \
--store .ubunye/model_store
Archive¶
ubunye models archive \
--use-case fraud_detection \
--model FraudRiskModel \
--version 1.1.0 \
--store .ubunye/model_store
Compare metrics¶
ubunye models compare \
--use-case fraud_detection \
--model FraudRiskModel \
--versions 1.2.0 --versions 1.3.0 \
--store .ubunye/model_store
Give --versions twice, once per version. --versions 1.2.0 1.3.0 is refused.
Output:
Comparing fraud_detection/FraudRiskModel: 1.2.0 vs 1.3.0
Metric v1.2.0 v1.3.0 Delta
-------------------- ------------------ ------------------ ----------
auc 0.9100 0.9300 +0.0200
f1 0.8700 0.8800 +0.0100
accuracy 0.9400 0.9500 +0.0100
Promotion gates¶
A gate is a rule a version must pass before it moves up (to staging or
production). If any gate fails, the version does not move.
Gate types¶
| Key pattern | Passes when |
|---|---|
min_<metric> |
The metric is >= the threshold |
max_<metric> |
The metric is <= the threshold |
require_drift_check |
metadata()["drift_check_passed"] is True (see Drift Detection) |
<metric> is a name your model's train() returns. The model on
Model Contract returns auc and f1, so its gates are
min_auc and min_f1.
Three rules that surprise people
- A missing metric blocks. A gate on a metric the model never returned
(say
max_losswhentrain()returns onlyaucandf1) fails every time, withMetric 'loss' not found in model metrics. - Stored gates cover every version of the model. Gates you pass to
register(..., promotion_gates=...)are stored with the model, not with that one version. The newest stored set is checked for every later promotion of any version, including older ones, and includingubunye models promote. Registering 1.0.1 with gates also blocks 1.0.0 if 1.0.0 fails them. gates=on onepromote()call replaces the stored gates for that one promotion only.
Store gates with the model¶
mv = registry.register(
"fraud_detection", "FraudRiskModel", None, model, metrics,
promotion_gates={"min_auc": 0.85, "min_f1": 0.80},
)
ubunye models promote has no flag for gates. It checks the stored ones.
Gates for one promotion¶
from ubunye.models import ModelRegistry, ModelStage
registry = ModelRegistry(".ubunye/model_store")
mv = registry.promote(
use_case="fraud_detection",
model_name="FraudRiskModel",
version="1.3.0",
to_stage=ModelStage.STAGING,
gates={"min_auc": 0.85, "min_f1": 0.80, "require_drift_check": True},
promoted_by="alice",
)
When a gate fails¶
promote raises PromotionBlockedError (a kind of ValueError, importable from
ubunye.core.errors). It names each failing gate with its actual value and
threshold. The version stays where it was. The CLI prints the same message as an
[ERROR] and exits 1:
Promotion blocked — 1 gate(s) failed:
- require_drift_check: Drift check: not found or failed in metadata.
Version: 1.0.1
Target stage: staging
Hint: Fix the failing gates or adjust thresholds in promotion_gates config.
ubunye models promote --force skips the gates, prints a warning and records on
the version that it was forced.
Python API¶
from ubunye.models import ModelRegistry, ModelStage, ModelVersion
registry = ModelRegistry(".ubunye/model_store")
# List all versions (newest first)
versions = registry.list_versions("fraud_detection", "FraudRiskModel")
# Get the production artifact path
artifact_path, mv = registry.get_model(
"fraud_detection", "FraudRiskModel", stage=ModelStage.PRODUCTION
)
# Load via your own model class
from model import FraudRiskModel
model = FraudRiskModel.load(artifact_path)
# Promote
registry.promote("fraud_detection", "FraudRiskModel", "1.3.0", ModelStage.PRODUCTION)
# Rollback to a previous version
registry.rollback("fraud_detection", "FraudRiskModel", "1.2.0")
# Compare
diff = registry.compare_versions("fraud_detection", "FraudRiskModel", "1.2.0", "1.3.0")
# Returns: {"auc": {"a": 0.91, "b": 0.93, "delta": 0.02}, ...}
Choosing a backend¶
Where models live is a plugin: a registry backend. Two ship with Ubunye:
| Name | What it does |
|---|---|
filesystem |
The default. A folder, or an s3://, gs:// or abfss:// URL. No cloud service needed. |
mlflow |
The MLflow Model Registry. Each version is a registered model version; stages are aliases. See MLflow Model Registry. |
The backend is chosen in one order:
- the name you ask for:
--backend/-bon everyubunye modelscommand,registry.backendin a model transform, orbackend=in Python; - else the
UBUNYE_REGISTRY_BACKENDenvironment variable; - else
filesystem.
The host never decides: running on Databricks does not switch you to MLflow.
--store (or registry.store) is handed to the backend as it is.
# config.yaml: read by ModelTransform, called from your transformations.py
transform:
params:
action: train
model_class: "model.FraudRiskModel"
registry:
store: ".ubunye/model_store"
backend: filesystem # optional
use_case: fraud_detection
from ubunye.models import ModelRegistry, resolve_registry
registry = ModelRegistry(".ubunye/model_store", backend="filesystem")
backend = resolve_registry("filesystem", store=".ubunye/model_store") # the raw backend
ubunye.models.backends.available() lists the installed names. Asking for one that is not installed fails with that list.
Why it works this way: ADR 010.
MLflow Model Registry¶
--backend mlflow keeps models in the MLflow Model Registry.
- Where MLflow is is MLflow's business:
MLFLOW_TRACKING_URIandMLFLOW_REGISTRY_URI, read the way MLflow reads them. --storeis the MLflow experiment that holds one run per version. The run carries the saved model as itsmodelartifact. Use a workspace path on Databricks, for example/Shared/ubunye/models.- The model is the registered model
{use_case}.{model_name}. SetUBUNYE_MLFLOW_MODEL_NAMEto change it, for examplemain.ml.{use_case}_{model_name}for a three part Unity Catalog name. - A version is a registered model version. The Ubunye version string, the
stage, the metrics, the metadata, the promotion gates and the lineage run id
are version tags (
ubunye.version,ubunye.stage,ubunye.metric.<name>,ubunye.meta.<key>). - Stages
stagingandproductionare also MLflow aliases, somodels:/fraud_detection.FraudRiskModel@productionloads the production version from any MLflow client.developmentandarchivedare tags only: many versions can be in them at once, and an alias points at one.
If the experiment does not exist, Ubunye creates it, and MLflow picks where its
artifacts go (./mlruns for a local SQLite store). Create the experiment
yourself to choose another place.
The MLflow stage API is never used. It is deprecated, and Unity Catalog has none, so the same backend works there.
export MLFLOW_TRACKING_URI=sqlite:///mlflow.db
ubunye models promote -u fraud_detection -m FraudRiskModel -s ubunye-models \
-b mlflow --version 1.0.1 --to production
The class used to be MLflowRegistryBackend: a folder registry that also
logged a best effort MLflow run. It still works when you build it in Python,
and warns that it is deprecated. Models it stored stay in their folder; read
them with --backend filesystem.
Why it works this way: ADR 014.
Writing a backend¶
A backend is a class with six methods, the
RegistryBackend protocol: register, get,
list_versions, promote, demote and delete. Its constructor takes the
store string. Register it in your package:
pip install your package and --backend myreg works. No edit to Ubunye.
Keep it small. Ubunye does the rest on top of those six methods, for every
backend: version numbers when none is given, refusing a duplicate version,
promotion gates (stored as the promotion_gates metadata key and checked
before promote is called), one production version at a time, rollback,
archive and compare. Your backend must:
- persist the
metadataandlineage_run_idit is given and return them inModelVersionInfo.metadata; - report stages with the public names (
development,staging,production,archived), whatever it calls them inside; - raise
VersionNotFoundErrorfor a version or stage it does not have; - refuse a
demotethat moves a version up: callubunye.models.lifecycle.check_demote(version, current_stage, to_stage)first; - return
list_versionsnewest first; - return from
getan artifact path thatYourModel.load()can read.
Prove it with the conformance suite:
import pytest
from ubunye.testing.registry_conformance import RegistryConformance
class TestMyRegistry(RegistryConformance):
@pytest.fixture
def backend(self, tmp_path):
return MyRegistryBackend(str(tmp_path / "store"))
Auto-versioning¶
When register() is given version=None, the registry bumps the patch
component of the highest existing version:
To pin a specific version, pass it:
A version that already exists is refused with VersionExistsError.
Lineage integration¶
A model registered inside a run (from a task's transformations.py) gets that
run's ID in ModelVersion.lineage_run_id, unless you pass lineage_run_id=
yourself. Run with --lineage so there is a run record to look the ID up in:
ubunye run -d pipelines -u fraud -p ml -t train --lineage
ubunye models info --use-case fraud_detection --model FraudRiskModel \
--version 1.3.0 --store .ubunye/model_store
# → "lineage_run_id": "abc123..."
ubunye lineage show -d pipelines -u fraud -p ml -t train --run-id abc123...
This lets you trace exactly which data, config hash, and code produced each model version.
The run record names the model (0.9)¶
The link also goes the other way. When a ModelTransform trains or scores, the run
record gets a model_runs entry:
- train: the model, the version registered, the stage it reached (or
development), and the gate result:passed,blockedwith the gate, metric, value and threshold that blocked it,no gates, or none when no promotion was asked. - predict: the model, the version and the stage it was loaded from.
ubunye lineage trace -d pipelines -u fraud -p ml -t train
# MODELS
# train FraudRiskModel 1.3.0 -> staging (gates passed)
ubunye lineage show prints the same entries as JSON. Why it works this way:
ADR 012.
Registry settings for a model transform¶
| Key | Default | What it does |
|---|---|---|
on_blocked |
raise |
A promotion the gates block fails the run. record keeps the run going; the version stays in development and the record says which gate blocked it. |
stamp_columns |
false |
Predict: add model_version and model_stage columns to the scored rows (pandas and Spark). Refuses to overwrite a column of that name. |
check_features |
not set | Predict: compare the frame with the version's declared features. Not set: warn, record, and call the model. true: refuse the frame with FeatureContractError. false: no check. |
transform:
params:
action: predict
model_class: model.FraudRiskModel
registry:
store: .ubunye/model_store
use_case: fraud_detection
use_stage: production
stamp_columns: true
check_features: true
ubunye run always runs the task's transformations.py (see the box at the top
of this page), so call the transform from it: