dispcraft.model_registry

model_registry

MLflow model-registry helpers for persisting residual-correction models.

This project's original convention (see CLAUDE.md, dispcraft/ml.py) was to not checkpoint residual-correction models -- retrain from data on every use instead, since Stage 4/5's own experimentation never needed a persisted artifact. Distributing the recommended production models (the "best model per mode" in docs/User_Guide.md) changes that: end users of the library should not have to retrain an MLP just to get a prediction.

Registers/loads dispcraft.ml.LitResidualRegressor instances via MLflow's model registry API, pointed by default at GitLab's MLflow-compatible model registry for this project (DEFAULT_TRACKING_URI below; override by setting MLFLOW_TRACKING_URI yourself before importing this module). Both reading and writing need MLFLOW_TRACKING_TOKEN set to a GitLab access token -- dispers/dispcraft is a private project, so even read-only calls (load_model, latest_version) get a bare 401 without one. Registering a new version additionally needs that token to carry the api scope and Developer role; a lower-privileged (e.g. read_api, Reporter role) token is enough for load_model alone -- see docs/User_Guide.md.

Four GitLab-specific quirks found while validating this module against the real endpoint (none of this is exercised by tests/, which stay offline -- scripts/register_models.py is what actually exercises it, against real data):

  1. MlflowClient().search_model_versions("") (empty filter string) raises INVALID_PARAMETER_VALUE -- latest_version below always passes a real name='...' filter to avoid it.
  2. Unlike a local sqlite:/// tracking store, GitLab's registry has no auto-created "Default" experiment (experiment_id=0) -- start_run() with no experiment set raises RESOURCE_DOES_NOT_EXIST. register_model always calls mlflow.set_experiment(...) first, which creates the experiment on first use.
  3. MLflow 3's default mlflow.pytorch.log_model(...) / mlflow.register_model(...) path creates a "Logged Model" tracking entity as a side effect (CreateLoggedModel/SearchLoggedModels) that GitLab's implementation doesn't support (bare 404, wrapped as INTERNAL_ERROR) -- this silently aborts after printing "Successfully registered model", leaving a registered model with zero versions attached. Worked around by not using the pytorch "flavor" packaging at all: register_model saves a plain torch.save({"state_dict": ..., "hparams": ...}) checkpoint as a single artifact and calls MlflowClient.create_model_version directly against it, and load_model reconstructs LitResidualRegressor from that checkpoint rather than via mlflow.pytorch.load_model (whose "models:/" URI resolution also hit a separate path-nesting mismatch against this registry -- downloads succeeded but Model.load()'s expected MLmodel file wasn't at the root of the download). Simpler and more robust than chasing either bug: skip the flavor system, since all we need is a state_dict + hyperparams.
  4. MlflowClient.search_model_versions(...) 404s outright against this registry (unlike search_registered_models, which works) -- latest_version uses get_registered_model(name).latest_versions instead, which returns the correct per-name version list.
  5. MlflowClient.search_registered_models() itself always sends a filter query parameter, even "" when called with no arguments -- which hits the same INVALID_PARAMETER_VALUE as quirk 1, this time with no way to avoid it from the client (there's no name to filter by when you want all models). A bare GET .../registered-models/search with no filter parameter at all works fine and returns everything -- list_models makes that call directly with requests rather than going through the MLflow client.
  6. ModelVersion.source (a field vanilla MLflow uses for an artifact path) is repurposed by GitLab's implementation to hold that version's own web UI URL instead (.../-/ml/models/<model_id>/versions/<version>) -- model_web_url below reads it straight off the same get_registered_model(name).latest_versions call latest_version already makes, no extra API call needed. (A GraphQL-based lookup of GitLab's numeric Ml::Model ID was tried first and worked, but this is simpler -- one call, one API, already-imported MlflowClient -- and client.get_model_version(name, version) 404s on this registry the same way search_model_versions does (quirk 4), so a specific older version's URL is derived by substituting the trailing /versions/<n> segment of the latest one rather than a second lookup.)

latest_version

latest_version(name)

Highest version number currently registered under name.

Uses get_registered_model(name).latest_versions, not search_model_versions -- the latter is a fourth GitLab quirk found while validating this module: SearchModelVersions 404s outright on this registry (unlike search_registered_models, which works), while GetRegisteredModel's latest_versions field returns the real, per-name version list correctly.

Source code in dispcraft/model_registry.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def latest_version(name):
    """Highest version number currently registered under `name`.

    Uses `get_registered_model(name).latest_versions`, not
    `search_model_versions` -- the latter is a *fourth* GitLab quirk found
    while validating this module: `SearchModelVersions` 404s outright on
    this registry (unlike `search_registered_models`, which works), while
    `GetRegisteredModel`'s `latest_versions` field returns the real,
    per-name version list correctly.
    """
    client = MlflowClient()
    versions = client.get_registered_model(name).latest_versions
    if not versions:
        raise ValueError(f"no registered model versions found for {name!r}")
    return max(int(v.version) for v in versions)

list_models

list_models()

Names of every model currently registered (sorted).

Bypasses MlflowClient.search_registered_models() -- see quirk 5 in the module docstring -- with a direct, unfiltered REST call instead.

Source code in dispcraft/model_registry.py
139
140
141
142
143
144
145
146
147
148
149
150
def list_models():
    """Names of every model currently registered (sorted).

    Bypasses `MlflowClient.search_registered_models()` -- see quirk 5 in the
    module docstring -- with a direct, unfiltered REST call instead.
    """
    url = f"{mlflow.get_tracking_uri()}/api/2.0/mlflow/registered-models/search"
    token = os.environ.get("MLFLOW_TRACKING_TOKEN")
    headers = {"Authorization": f"Bearer {token}"} if token else {}
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    return sorted(m["name"] for m in response.json().get("registered_models", []))

load_model

load_model(name, version=None)

Load a registered LitResidualRegressor by name.

Parameters:
  • name
  • version (int, optional -- defaults to `latest_version(name)`, default: None ) –
Returns:
  • (lit_model, extra) : (dispcraft.ml.LitResidualRegressor, dict) -- the

    model, in eval mode, ready for dispcraft.ml.predict; extra is whatever dict was passed to register_model (empty if none).

Source code in dispcraft/model_registry.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def load_model(name, version=None):
    """Load a registered `LitResidualRegressor` by name.

    Parameters
    ----------
    name    : str -- registered model name
    version : int, optional -- defaults to `latest_version(name)`

    Returns
    -------
    (lit_model, extra) : (dispcraft.ml.LitResidualRegressor, dict) -- the
        model, in eval mode, ready for `dispcraft.ml.predict`; `extra` is
        whatever dict was passed to `register_model` (empty if none).
    """
    if version is None:
        version = latest_version(name)
    with tempfile.TemporaryDirectory() as tmp:
        local_dir = mlflow.artifacts.download_artifacts(artifact_uri=f"models:/{name}/{version}", dst_path=tmp)
        ckpt_path = next(Path(local_dir).rglob(_CHECKPOINT_NAME))
        ckpt = torch.load(ckpt_path, weights_only=False)

    hp = ckpt["hparams"]
    lit_model = LitResidualRegressor(
        n_inputs=hp["n_inputs"], n_outputs=hp["n_outputs"], hidden_layer_sizes=hp["hidden_layer_sizes"],
        activation=hp["activation"], alpha=hp["alpha"], lr=hp["lr"], batch_size=hp["batch_size"],
    )
    lit_model.load_state_dict(ckpt["state_dict"])
    lit_model.eval()
    return lit_model, ckpt.get("extra", {})

model_web_url

model_web_url(name, version=None)

(model_url, version_url) -- GitLab UI links for a registered model, for documentation/models/*.toml where a human wants to click through and inspect a model, as opposed to load_model (name/version alone is enough to fetch weights programmatically).

Reads the URL straight off ModelVersion.source (see quirk 6 in the module docstring) -- no extra API call beyond what latest_version already makes.

Parameters:
  • name
  • version (int, optional -- defaults to `latest_version(name)`; an older, default: None ) –

    version's URL is derived by substituting the trailing /versions/<n> segment (get_model_version 404s on this registry, quirk 6), so this assumes every version shares the same .../-/ml/models/<model_id> prefix, true by construction (one registered model, many versions).

Returns:
  • (model_url, version_url) : (str, str)
Source code in dispcraft/model_registry.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def model_web_url(name, version=None):
    """(model_url, version_url) -- GitLab UI links for a registered model,
    for documentation/`models/*.toml` where a human wants to click through
    and inspect a model, as opposed to `load_model` (name/version alone is
    enough to fetch weights programmatically).

    Reads the URL straight off `ModelVersion.source` (see quirk 6 in the
    module docstring) -- no extra API call beyond what `latest_version`
    already makes.

    Parameters
    ----------
    name    : str -- registered model name
    version : int, optional -- defaults to `latest_version(name)`; an older
        version's URL is derived by substituting the trailing
        `/versions/<n>` segment (`get_model_version` 404s on this registry,
        quirk 6), so this assumes every version shares the same
        `.../-/ml/models/<model_id>` prefix, true by construction (one
        registered model, many versions).

    Returns
    -------
    (model_url, version_url) : (str, str)
    """
    client = MlflowClient()
    versions = client.get_registered_model(name).latest_versions
    if not versions:
        raise ValueError(f"no registered model versions found for {name!r}")
    latest = versions[0]
    model_url = latest.source.rsplit("/versions/", 1)[0]
    version = version if version is not None else latest.version
    return model_url, f"{model_url}/versions/{version}"

register_model

register_model(lit_model, name, params=None, extra=None, run_name=None, experiment_name=DEFAULT_EXPERIMENT)

Register a trained LitResidualRegressor as a new version of the model registry entry name (creating both the experiment and the registered model on first use).

Parameters:
  • lit_model (dispcraft.ml.LitResidualRegressor -- already trained (eval mode)) –
  • name
  • params

    (e.g. the dict passed to dispcraft.ml.train_residual_mlp)

  • extra

    model that isn't part of its own state (e.g. a fitted StandardScaler's mean_/scale_ for the RGS/BGS hybrid MLPs, which standardize (y, z, wavelength) before prediction). Returned back by load_model. Not needed for the zeroth-order NNs, whose standardization is a fixed formula (standardize_field), not fitted.

  • run_name
  • experiment_name (str, default: `DEFAULT_EXPERIMENT` ) –
Returns:
  • str -- the new model version number
Source code in dispcraft/model_registry.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def register_model(lit_model, name, params=None, extra=None, run_name=None, experiment_name=DEFAULT_EXPERIMENT):
    """Register a trained `LitResidualRegressor` as a new version of the
    model registry entry `name` (creating both the experiment and the
    registered model on first use).

    Parameters
    ----------
    lit_model : dispcraft.ml.LitResidualRegressor -- already trained (eval mode)
    name      : str -- registered model name, e.g. "rgs000_0-hybrid-mlp-y"
    params    : dict, optional -- hyperparameters to log alongside the run
        (e.g. the dict passed to `dispcraft.ml.train_residual_mlp`)
    extra     : dict, optional -- arbitrary picklable data needed to use the
        model that isn't part of its own state (e.g. a fitted
        `StandardScaler`'s `mean_`/`scale_` for the RGS/BGS hybrid MLPs,
        which standardize `(y, z, wavelength)` before prediction). Returned
        back by `load_model`. Not needed for the zeroth-order NNs, whose
        standardization is a fixed formula (`standardize_field`), not fitted.
    run_name  : str, optional -- MLflow run name, defaults to `name`
    experiment_name : str, default `DEFAULT_EXPERIMENT`

    Returns
    -------
    str -- the new model version number
    """
    mlflow.set_experiment(experiment_name)
    with mlflow.start_run(run_name=run_name or name) as run:
        if params:
            mlflow.log_params(params)
        with tempfile.TemporaryDirectory() as tmp:
            ckpt_path = Path(tmp) / _CHECKPOINT_NAME
            torch.save({"state_dict": lit_model.state_dict(), "hparams": dict(lit_model.hparams),
                        "extra": extra or {}}, ckpt_path)
            mlflow.log_artifact(str(ckpt_path), artifact_path="model")
        run_id = run.info.run_id
        artifact_uri = mlflow.get_artifact_uri("model")

    client = MlflowClient()
    try:
        client.get_registered_model(name)
    except mlflow.exceptions.MlflowException:
        client.create_registered_model(name)
    mv = client.create_model_version(name=name, source=artifact_uri, run_id=run_id)
    return mv.version