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):
- 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).
- Create a Project Access Token with:
- Role:
Reporterif you only need to load models (read-only);Developerif you also need to register new versions. - Scope:
read_apifor read-only;apifor registering (GitLab's model-registry endpoints don't support a narrower packages-only scope for writes). - An expiration date (GitLab requires one).
- 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/*.tomlfile — 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 aregistry_version=always gets the latest of each. Pin a version if you need a specific one reproduced exactly.models/best_model_<config>.tomlrecords each one's exact hyperparameters plus a direct GitLab URL to the version it was written against — re-runscripts/freeze_best_models.pyafterscripts/register_models.pyregisters a new version to keep it current. random_state=42throughout (MLP initialization); PyTorch training runs withdeterministic=True, so re-runningscripts/register_models.pyagainst 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 tomodels/zeroth_material_k.toml, butdispcraft/prediction.pyandscripts/register_models.pystill hardcode the value rather than reading it from that file — seeStatus_Report.md§9/§10 for the open item.