User Guide — Using the Calibrated Models

Status: exploratory research prototype, ongoing — not production-ready. "Best model" below means best among the candidates this project has tried so far, not a model validated as meeting an accuracy requirement. Held-out accuracy is still 1.7-8.4x worse than the reference paper's own benchmark (docs/Status_Report.md §1) — treat this guide as "how to reproduce and inspect today's models," not "the finished model to deploy." Development is ongoing.

This guide shows how to load and run the best model for each prediction task ("mode") this project produced. It assumes the pixi environment is set up (pixi install, see the root README.md) and code runs via pixi run python ... or inside pixi shell.

For why these are the best models, parameter identifiability findings, and accuracy caveats, see docs/Status_Report.md — this guide only covers how to use them.

Which model should I use?

Mode Input Output Best model Held-out accuracy (RMSE)
RGS 1st-order trace prediction field position (y, z) [mm] + wavelength [nm], for rgs000/rgs180 × {0°,±4°} centroid (cent_y, cent_z) [mm] Joint 3-tier physical fit + independent ML residual (MLP(y)+MLP(z)) y: 0.019 mm, z: 0.075 mm (mean over 6 configs)
BGS 1st-order trace prediction field position (y, z) [mm] + wavelength [nm], for bgs000_0 centroid (cent_y, cent_z) [mm] Per-dataset physical fit + the same ML residual recipe y: 0.013 mm, z: 0.073 mm
0th-order dispersion (two-blob separation) field position (y, z) [mm] rank2−rank1 separation (dy, dz) [mm] Shared chromatic material_k physics baseline + per-config residual NN (RGS); NN-only baseline (BGS, not fused with a physics baseline, see Section 3) 0.006–0.008 mm (RGS), 0.008 mm (BGS)

Not covered below because they are not the recommended default (see Optional: field-dependent tier): the Stage 5 field-dependent correction term. It measurably helps in-sample but was found to be close to redundant with the ML residual above on held-out data (Status_Report.md, §7.1) — use it only if you've verified it helps for your own use case.

All physical parameters used below come from frozen configs in models/*.toml — never hand-copy numbers out of a notebook. The ML residual/NN weights for every mode above are pre-trained and published in GitLab's model registry (dispers/dispcraft) — you load them, you don't retrain them.

models/best_model_<config>.toml (one per config, 7 total) records the complete recipe for that config's recommended model in one file: the physical fit to use ([physical]), every ML residual's full hyperparameter set ([first_order.hybrid_mlp_y]/_z, [zeroth_order.residual_nn]), and a direct GitLab URL to the currently-registered weights (registry_model_url/registry_version_url) — generated by scripts/freeze_best_models.py. It's documentation/provenance (enough to retrain an identical model by hand, or to click through and inspect the weights on GitLab) — load_calibrated_grism below doesn't read it, it talks to the physical fit + registry directly.


Getting access

dispers/dispcraft is a private GitLab project, so every call below needs MLFLOW_TRACKING_TOKEN set, including read-only loading — there's no anonymous access to the model registry.

Get a token (GitLab UI, https://gitlab.in2p3.fr/dispers/dispcraft):

  1. Go to Settings → Access Tokens on the project (creating one needs at least Maintainer role on the project — if you don't have that, ask a project maintainer to create one for you instead, or to add you to the project first).
  2. Create a Project Access Token with:
  3. Role: Reporter if you only need to load models (read-only); Developer if you also need to register new versions.
  4. Scope: read_api for read-only; api for registering (GitLab's model-registry endpoints don't support a narrower packages-only scope for writes).
  5. An expiration date (GitLab requires one).
  6. Copy the token immediately — GitLab only shows it once.

Use it: export as an environment variable before running any of the code below. dispcraft.model_registry already points MLFLOW_TRACKING_URI at the right GitLab endpoint by default, so this one variable is all you need:

export MLFLOW_TRACKING_TOKEN=<the token you just created>

Browse what's registered: the GitLab UI lists every registered model at https://gitlab.in2p3.fr/dispers/dispcraft/-/ml/models (versions, run metadata, etc.) if you want to look before loading. Or from Python:

from dispcraft.model_registry import list_models
list_models()

(Don't reach for MlflowClient().search_registered_models() directly — it always sends a filter parameter, even the default "", which this registry rejects with INVALID_PARAMETER_VALUE; list_models works around it with a direct, unfiltered REST call — see dispcraft/model_registry.py's module docstring, quirk 5.)

For reference, here are the 21 names currently registered (see scripts/register_models.py, the source of truth if this list ever goes stale):

Mode Registered model names
RGS 1st-order hybrid <config>-hybrid-mlp-y, <config>-hybrid-mlp-z for <config> in rgs000_0, rgs000_m4, rgs000_p4, rgs180_0, rgs180_m4, rgs180_p4 (12 total)
BGS 1st-order hybrid bgs000_0-hybrid-mlp-y, bgs000_0-hybrid-mlp-z
RGS zeroth-order <config>-zeroth-residual-nn for the same 6 RGS configs
BGS zeroth-order bgs000_0-zeroth-residual-nn

0. Common setup

All three modes below go through dispcraft.prediction, a single unified entry point: load_calibrated_grism(config, ...) reads the frozen models/*.toml fit and pulls the pre-trained residual model(s) from the registry, returning one CalibratedGrism object with a predict_first_order method (1st order, both RGS and BGS) and a predict_zeroth_order method (0th order). It composes dispcraft.calibration, dispcraft.zeroth_dispersion, and dispcraft.model_registry — it doesn't add or change either model, only removes the need to wire physics + ML residual together by hand per mode.

from dispcraft.prediction import load_calibrated_grism

load_calibrated_grism(config, models_dir="models", ...) reads models/*.toml relative to models_dir (default "models", i.e. run from the repo root, or pass your own path). It needs MLFLOW_TRACKING_TOKEN set (see Getting access) unless called with load_hybrid=False/load_zeroth=False, which skip the registry network calls entirely and build a physics-only model instead.


1. RGS 1st order — physical joint fit + ML residual

Best for: predicting where a dispersed spectral line lands on the detector, for any of the 6 {rgs000, rgs180} × {0°, -4°, +4°} configurations.

CONFIG = "rgs000_0"  # one of: rgs000_0, rgs000_m4, rgs000_p4, rgs180_0, rgs180_m4, rgs180_p4

grism = load_calibrated_grism(CONFIG, zeroth_material_k=0.0143)  # see Section 3 for material_k

grism.predict_first_order([10.0], [-20.0], [1500.0])  # (cent_y, cent_z), mm

Repeat with CONFIG set to each of the 6 values — the ML residual is a separate registered model per config, not shared, mirroring how this project trained it. load_calibrated_grism reads the frozen joint_specific_fit_<config>.toml (Tier 1+2+3) and the registered "<config>-hybrid-mlp-y"/"-z" models by default.

Held-out accuracy (GroupShuffleSplit, 20% held out, grouped by spectra_id, random_state=42 — as measured in notebooks/4.3-Multi_Dataset_Fitting.ipynb, Section 7, with the original sklearn MLPRegressor; the published registry models were retrained with dispcraft.ml's PyTorch implementation on the full dataset, not this held-out split, so treat this table as an accuracy reference, not a number you should expect to reproduce exactly by evaluating the loaded model in-sample):

Config Physical RMSE y/z (mm) Hybrid RMSE y/z (mm) Improvement
rgs000_0 0.159 / 0.298 0.018 / 0.064 81.7%
rgs000_m4 0.227 / 0.264 0.014 / 0.062 82.4%
rgs000_p4 0.190 / 0.345 0.017 / 0.087 79.8%
rgs180_0 0.201 / 0.292 0.017 / 0.080 80.6%
rgs180_m4 0.184 / 0.317 0.022 / 0.074 81.2%
rgs180_p4 0.205 / 0.287 0.024 / 0.083 77.8%

2. BGS 1st order — physical fit + ML residual

Best for: the broad-band grism, bgs000_0 (only one config exists — no ±4°/180° counterpart). Same recipe as RGS, different base config, frozen fit, and registered model names.

grism = load_calibrated_grism(
    "bgs000_0",
    instrument_toml="stage1_bgs_instrument.toml",
    fit_toml="bgs000_0_fit.toml",
    zeroth_material_k=None,  # bgs000_0's 0th order isn't fused with a physics baseline yet, see Section 3
)

grism.predict_first_order([10.0], [-20.0], [1500.0])  # (cent_y, cent_z), mm

Held-out accuracy: physical y=0.154 mm / z=0.357 mm → hybrid y=0.013 mm / z=0.073 mm (82.2% combined-distance improvement).

Two assumptions carried into stage1_bgs_instrument.toml, stated explicitly rather than silently assumed (see the file's header comment): the grism material and the collimator/camera focal lengths are reused verbatim from the RGS calibration (no BGS-specific ground-test measurement of these exists).


3. 0th-order dispersion — shared material physics + residual NN

Best for: predicting the wavelength-dependent separation between the two 0th-order "blobs" (rank 1 at 1206 nm, rank 2 at 1892 nm) as a function of field position. This is a separate quantity from Sections 1-2 (a rank2-rank1 separation, not an absolute centroid position) — CalibratedGrism gives it its own method, predict_zeroth_order, rather than folding it into predict_first_order's output shape.

RGS configs (physics baseline + residual NN)

The same grism object built in Section 1 already carries what predict_zeroth_order needs — the shared chromatic coefficient (zeroth_material_k, Stage 5 Phase 3 Step 4's material_k fit jointly across all 6 RGS configs' 0th-order data — frozen to models/zeroth_material_k.toml, but not yet read from there programmatically here, Status_Report.md §9/§10, hence passed as a literal) and the registered "<config>-zeroth-residual-nn" model:

grism.predict_zeroth_order([10.0], [-20.0])  # (dy, dz), the rank2-rank1 separation, mm

BGS (bgs000_0) — NN-only, no physics baseline

bgs000_0 does have a frozen first-order fit (models/bgs000_0_fit.toml, Section 2) that could in principle supply a 0th-order physics baseline the way the RGS configs' fits do — but it was never fused that way: bgs000_0 wasn't part of the joint material_k fit's scope, and dispcraft.zeroth_dispersion hardcodes RGS's rank wavelengths, so reusing it for BGS needs generalizing to a per-grism passband first (Status_Report.md §10 item 9, open). Section 2's grism was built with zeroth_material_k=None, so predict_zeroth_order is NN-only (Step 3, the standalone empirical model, unaffected by this gap), reading the registered "bgs000_0-zeroth-residual-nn" model:

grism.predict_zeroth_order([10.0], [-20.0])  # (dy, dz), mm -- NN-only, no physics baseline added

Accuracy (in-sample RMSE, since 0th order was not evaluated on a held-out split — see Status_Report.md §7.2):

Config Raw separation RMSE + shared material_k + residual NN Total reduction
rgs000_0 0.217 mm 0.014 mm 0.006 mm 97.1%
rgs000_m4 0.214 mm 0.013 mm 0.007 mm 96.7%
rgs000_p4 0.225 mm 0.016 mm 0.007 mm 97.0%
rgs180_0 0.217 mm 0.012 mm 0.007 mm 96.6%
rgs180_m4 0.223 mm 0.014 mm 0.007 mm 96.8%
rgs180_p4 0.214 mm 0.011 mm 0.007 mm 96.8%
bgs000_0 0.123 mm n/a (no physics baseline) 0.008 mm 93.5%

Optional: field-dependent tier (experimental)

Stage 5 Phase 2 (dispcraft.field_calibration) adds a per-position NN correction that replaces the RGS/BGS physical fit's constant offset_y_mm/offset_z_mm with a field-position-dependent term. It gives a large in-sample RMSE reduction (76-83%), but re-evaluated on a genuinely held-out split it turned out to be close to redundant with the ML residual already covered in Sections 1-2 (Status_Report.md §7.1) — 0.077 mm combined either way. It is not the recommended default, and not registered in the model registry — use it only if you've independently verified a benefit for your data, training it yourself via dispcraft.field_calibration.fit_field_dependent_tier. See that module's docstring and notebooks/5.2-Field_Dependent_Parameters.ipynb.


Retraining and re-registering a model

All 21 registered models (12 RGS + 2 BGS hybrid MLPs, 7 zeroth-order NNs) are produced by scripts/register_models.py, which trains each one from the frozen models/*.toml fits and the raw data/*.csv and pushes it to the registry. Re-run it whenever a frozen physical fit changes and the published weights need to catch up:

export MLFLOW_TRACKING_TOKEN=<a GitLab project access token>
pixi run python scripts/register_models.py

Token requirements (see dispcraft/model_registry.py's module docstring for the GitLab API quirks this script already works around): a project access token on dispers/dispcraft, role Developer, scope api (GitLab's model-registry endpoints don't support a narrower read-only or packages-only scope for writes). dispcraft.model_registry already points MLFLOW_TRACKING_URI at the right GitLab endpoint by default — you only need to set the token.


Reproducibility notes

  • Every physical parameter is traceable to a models/*.toml file — if a number here doesn't match one, trust the TOML, not this guide.
  • Every ML/NN model is traceable to a registered name + version in the GitLab model registry (dispers/dispcraft) — load_calibrated_grism(config) without a registry_version= always gets the latest of each. Pin a version if you need a specific one reproduced exactly. models/best_model_<config>.toml records each one's exact hyperparameters plus a direct GitLab URL to the version it was written against — re-run scripts/freeze_best_models.py after scripts/register_models.py registers a new version to keep it current.
  • random_state=42 throughout (MLP initialization); PyTorch training runs with deterministic=True, so re-running scripts/register_models.py against unchanged data/fits reproduces the same weights (up to registering them as a new version).
  • Units: field position (y_nisp, z_nisp) in mm (NISP focal-plane frame, R_NISP), wavelength in nm, centroids (cent_y, cent_z) in mm (detector mosaic frame, R_MOS).
  • dispcraft.prediction.DEFAULT_ZEROTH_MATERIAL_K = 0.0143 (Section 3) is frozen to models/zeroth_material_k.toml, but dispcraft/prediction.py and scripts/register_models.py still hardcode the value rather than reading it from that file — see Status_Report.md §9/§10 for the open item.