dispcraft
  • Home

User Guide

  • Getting Started
  • Using the Calibrated Models
  • Notebooks
    • Overview
    • 1. Subject & Instrument Model
    • 2. Data Introduction
    • 3. ML Introduction
    • 4.1 ML Model Comparison
    • 4.2 Per-Dataset Pipeline
    • 4.3 Multi-Dataset Joint Fitting
      • 1. Parameter Tiers
      • 2. Forward Model Extension: /
      • 3. Load Stage 1 (Independent Per-Dataset) Results
        • Recap — Section 0
      • 4. Section 1 — Tier 1: Global Joint Fit (, )
        • Recap — Section 1
      • 5. Section 2 — Tier 2: Per-Grism-Instance Joint Fit (, , )
        • Reading the identifiability check
        • Recap — Section 2
      • 6. Section 3 — Tier 3: Per-Dataset Re-Fit (, , )
        • Convergence check: would another alternation pass move Tier 1?
        • Recap — Section 3
      • 7. Section 4 — ML Residual Correction (Deck's Stage 4)
        • Recap — Section 4
      • 8. Section 5 — Final Comparison (Task 3 Deliverable)
        • Key findings
        • Caveats / failure modes
        • On deviating from the deck's 2-tier split
        • Recommendation
    • 4.5 Comparison With Published Results
    • 5.1 PyTorch Migration
    • 5.2 Field-Dependent Parameters
    • 5.3 Zeroth-Order Dispersion
    • 5.4 BGS Model
    • 6. Status Report Assembly
    • 8. Chebyshev Residual Model
  • Status Report
  • Beginner Introduction (Slides)
  • Interactive Model (Webapp)

Reference

  • Euclid NISP Specs
  • Reference Paper Summary

API Reference

  • Overview
  • optics
  • measurement
  • calibration
  • field_calibration
  • zeroth_dispersion
  • chebyshev_residual
  • ml
  • model_registry
  • prediction
dispcraft
  • User Guide
  • Notebooks
  • 4.3 Multi-Dataset Joint Fitting

Stage 4, Phase 3 — Multi-Dataset Fitting (4.3-Multi_Dataset_Fitting)¶

Per 4-Projet/index.html, Section 3 / Task 3: the 6 calibration datasets share the same physical instrument, but not all fitted parameters are shared the same way. The deck sketches a simple shared-vs-specific split with a 2-stage alternating fit (fix specific, jointly fit shared; fix shared, refit specific). This notebook follows that alternating strategy, but with a 3-tier parameter hierarchy instead of the deck's 2-tier one — the physical reality of this instrument (see 3-Intro_ML.ipynb's classification table and 4.2-Per_Dataset_Pipeline.ipynb Section 5's empirical findings) doesn't collapse into a single "shared"/"specific" split:

  1. Globally shared (one physical bench, all 6 datasets): collimator and camera focal length.
  2. Shared per grism instance (rgs000_* and rgs180_* are two distinct physical grism assemblies, same design, mounted 180° apart — not one grism read out twice, per 3-Intro_ML.ipynb Section 3): prism apex angle, prism glass index, grating period.
  3. Dataset-specific (each of the 6 individual acquisitions): GWA angle / grism tilt, and both detector alignment offsets (the alignment depends on the tilt geometry of that specific acquisition).

This reopens two parameters 3-Intro_ML.ipynb fixed as non-identifiable on single-dataset fits: the prism apex angle A_deg (found ~99% bootstrap-correlated with offset_z_mm/rho there) and the glass index material.n0 (never even tried, same reasoning). Collimator/camera focal length were never tried at all. Fitting A_deg/material.n0 jointly across 3 datasets with genuinely different tilt geometries (0°, ±4°) is a real chance to break that single-dataset degeneracy — Section 2 below repeats the same bootstrap-correlation identifiability check to confirm (or refute) that, rather than assuming it. material.k (absorption) stays fixed throughout, at every tier — not claimed identifiable from centroid-position data.

dispcraft.calibration.predict_centroids/cost don't support varying collimator/camera focal length or material.n0 (they come from the fixed GroundTestModel.coll/.cam/.material objects, not the free-parameter dict). This notebook defines a superset, predict_centroids_full/ cost_full, that does — kept notebook-local for now (not merged into dispcraft/calibration.py), consistent with this project's promote-only-on-confirmation rule, since this parameter set is still being validated.

In [1]:
Copied!
import tomllib
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import minimize
import mlflow

from dispcraft.calibration import ground_test_model_from_config
from dispcraft.measurement import load_spectra, median_per_spectrum
from dispcraft.optics import Camera, Collimator, Grating, Grism, Material, Prism

REPO_ROOT = Path("..")
DATA_DIR = REPO_ROOT / "data"
MODELS_DIR = REPO_ROOT / "models"
RNG_SEED = 42

CONFIGS = ["rgs000_0", "rgs000_m4", "rgs000_p4", "rgs180_0", "rgs180_m4", "rgs180_p4"]
GRISM_INSTANCES = {
    "rgs000": ["rgs000_0", "rgs000_m4", "rgs000_p4"],
    "rgs180": ["rgs180_0", "rgs180_m4", "rgs180_p4"],
}

with open(MODELS_DIR / "stage1_instrument.toml", "rb") as f:
    base_config = tomllib.load(f)
model = ground_test_model_from_config(base_config)
import tomllib from pathlib import Path import numpy as np import pandas as pd import matplotlib.pyplot as plt from scipy.optimize import minimize import mlflow from dispcraft.calibration import ground_test_model_from_config from dispcraft.measurement import load_spectra, median_per_spectrum from dispcraft.optics import Camera, Collimator, Grating, Grism, Material, Prism REPO_ROOT = Path("..") DATA_DIR = REPO_ROOT / "data" MODELS_DIR = REPO_ROOT / "models" RNG_SEED = 42 CONFIGS = ["rgs000_0", "rgs000_m4", "rgs000_p4", "rgs180_0", "rgs180_m4", "rgs180_p4"] GRISM_INSTANCES = { "rgs000": ["rgs000_0", "rgs000_m4", "rgs000_p4"], "rgs180": ["rgs180_0", "rgs180_m4", "rgs180_p4"], } with open(MODELS_DIR / "stage1_instrument.toml", "rb") as f: base_config = tomllib.load(f) model = ground_test_model_from_config(base_config)

1. Parameter Tiers¶

Declared explicitly up front, per the physics-model-calibration skill — not left implicit in the fitting code below.

Tier Parameters Scope Reasoning
1. Global coll_f, cam_f all 6 datasets Fixed optics common to every configuration; not re-mounted between acquisitions.
2. Per grism instance A_deg, material_n0, rho 3 datasets each (rgs000_* / rgs180_*) Properties of each physical prism+grating piece — expected close but not identical between the two same-design instances (confirmed for rho already; A_deg/material_n0 untested at the instance level so far, only ruled non-identifiable per-dataset).
3. Dataset-specific tilt_deg, offset_y_mm, offset_z_mm each of the 6 tilt_deg is the commanded GWA angle, the variable that distinguishes the 6 acquisitions by construction. Both offsets are kept dataset-specific: offset_y_mm is empirically consistent (4.2 Section 5) but the alignment it corrects for is geometry-dependent, and offset_z_mm demonstrably is not consistent — it splits cleanly by grism identity via the tilt+180° degeneracy in Grism.forward (4.2 Section 5).

material_k is fixed at the Stage 1 nominal at every tier, never fit.

In [2]:
Copied!
GLOBAL_PARAMS = ["coll_f", "cam_f"]
INSTANCE_PARAMS = ["A_deg", "material_n0", "rho"]
SPECIFIC_PARAMS = ["tilt_deg", "offset_y_mm", "offset_z_mm"]
ALL_PARAMS = GLOBAL_PARAMS + INSTANCE_PARAMS + SPECIFIC_PARAMS

print("Tier 1 (global, all 6 datasets):       ", GLOBAL_PARAMS)
print("Tier 2 (per grism instance, 3 each):    ", INSTANCE_PARAMS)
print("Tier 3 (dataset-specific, each of 6):   ", SPECIFIC_PARAMS)
GLOBAL_PARAMS = ["coll_f", "cam_f"] INSTANCE_PARAMS = ["A_deg", "material_n0", "rho"] SPECIFIC_PARAMS = ["tilt_deg", "offset_y_mm", "offset_z_mm"] ALL_PARAMS = GLOBAL_PARAMS + INSTANCE_PARAMS + SPECIFIC_PARAMS print("Tier 1 (global, all 6 datasets): ", GLOBAL_PARAMS) print("Tier 2 (per grism instance, 3 each): ", INSTANCE_PARAMS) print("Tier 3 (dataset-specific, each of 6): ", SPECIFIC_PARAMS)
Tier 1 (global, all 6 datasets):        ['coll_f', 'cam_f']
Tier 2 (per grism instance, 3 each):     ['A_deg', 'material_n0', 'rho']
Tier 3 (dataset-specific, each of 6):    ['tilt_deg', 'offset_y_mm', 'offset_z_mm']

2. Forward Model Extension: predict_centroids_full / cost_full¶

Superset of dispcraft.calibration.predict_centroids/cost that also allows coll_f, cam_f, and material_n0 to vary — everything else (the forward chain itself, the offset convention) is identical. Used for every fit in this notebook, so all three tiers live in the same parameter space and can be mixed freely between theta (free) and fixed.

In [3]:
Copied!
FULL_NOMINAL = {
    **model.nominal_params,
    "coll_f": model.coll.f,
    "cam_f": model.cam.f,
    "material_n0": model.material.n0,
}


def predict_centroids_full(y_nisp_mm, z_nisp_mm, wavelength_nm, theta, free_names, base_model, fixed=None):
    """Like calibration.predict_centroids, but also allows coll_f, cam_f, and
    material_n0 to vary. Notebook-local for Phase 3's 3-tier joint fit --
    not promoted to dispcraft/calibration.py pending validation."""
    p = dict(FULL_NOMINAL)
    if fixed:
        p.update(fixed)
    p.update(dict(zip(free_names, theta)))

    material = Material(n0=p["material_n0"], k=base_model.material.k)
    prism = Prism(material=material, A=np.radians(p["A_deg"]))
    grating = Grating(m=base_model.m_order, rho=p["rho"])
    grism = Grism(prism, grating, tilt=np.radians(p["tilt_deg"]))
    coll, cam = Collimator(f=p["coll_f"]), Camera(f=p["cam_f"])

    pos_foc = np.stack([np.asarray(y_nisp_mm), np.asarray(z_nisp_mm)]) / 1000.0  # mm -> m
    wavelength_um = np.asarray(wavelength_nm) / 1000.0  # nm -> um
    angle_col = coll.forward(pos_foc)
    angle_gr = grism.forward(angle_col, wavelength_um)
    pos_cam = cam.forward(angle_gr)  # m
    offset_mm = np.array([p["offset_y_mm"], p["offset_z_mm"]])
    return pos_cam * 1000.0 + offset_mm[:, None]  # mm


def cost_full(theta, df, free_names, base_model, fixed=None):
    """MSE cost over (r_y, r_z), vectorized over all rows of df. Same shape as
    calibration.cost, built on predict_centroids_full."""
    pred = predict_centroids_full(df["y_nisp"], df["z_nisp"], df["wavelength"], theta, free_names, base_model, fixed)
    r_y = pred[0] - df["cent_y"].values
    r_z = pred[1] - df["cent_z"].values
    return np.mean(r_y**2 + r_z**2)


# Sanity check: with coll_f/cam_f/material_n0 fixed at nominal and the same
# free_names/theta calibration.predict_centroids supports, the two must agree.
from dispcraft.calibration import cost as cost_orig

_df_check = median_per_spectrum(load_spectra(DATA_DIR / "rgs000_0_first.csv"))
_free_check = ["offset_y_mm", "offset_z_mm", "tilt_deg", "rho"]
_theta_check = np.array([FULL_NOMINAL[k] for k in _free_check])
_c_orig = cost_orig(_theta_check, _df_check, _free_check, model)
_c_full = cost_full(_theta_check, _df_check, _free_check, model)
assert np.isclose(_c_orig, _c_full), (_c_orig, _c_full)
print(f"predict_centroids_full/cost_full agree with calibration.cost at nominal: {_c_orig:.6f} mm^2")
FULL_NOMINAL = { **model.nominal_params, "coll_f": model.coll.f, "cam_f": model.cam.f, "material_n0": model.material.n0, } def predict_centroids_full(y_nisp_mm, z_nisp_mm, wavelength_nm, theta, free_names, base_model, fixed=None): """Like calibration.predict_centroids, but also allows coll_f, cam_f, and material_n0 to vary. Notebook-local for Phase 3's 3-tier joint fit -- not promoted to dispcraft/calibration.py pending validation.""" p = dict(FULL_NOMINAL) if fixed: p.update(fixed) p.update(dict(zip(free_names, theta))) material = Material(n0=p["material_n0"], k=base_model.material.k) prism = Prism(material=material, A=np.radians(p["A_deg"])) grating = Grating(m=base_model.m_order, rho=p["rho"]) grism = Grism(prism, grating, tilt=np.radians(p["tilt_deg"])) coll, cam = Collimator(f=p["coll_f"]), Camera(f=p["cam_f"]) pos_foc = np.stack([np.asarray(y_nisp_mm), np.asarray(z_nisp_mm)]) / 1000.0 # mm -> m wavelength_um = np.asarray(wavelength_nm) / 1000.0 # nm -> um angle_col = coll.forward(pos_foc) angle_gr = grism.forward(angle_col, wavelength_um) pos_cam = cam.forward(angle_gr) # m offset_mm = np.array([p["offset_y_mm"], p["offset_z_mm"]]) return pos_cam * 1000.0 + offset_mm[:, None] # mm def cost_full(theta, df, free_names, base_model, fixed=None): """MSE cost over (r_y, r_z), vectorized over all rows of df. Same shape as calibration.cost, built on predict_centroids_full.""" pred = predict_centroids_full(df["y_nisp"], df["z_nisp"], df["wavelength"], theta, free_names, base_model, fixed) r_y = pred[0] - df["cent_y"].values r_z = pred[1] - df["cent_z"].values return np.mean(r_y**2 + r_z**2) # Sanity check: with coll_f/cam_f/material_n0 fixed at nominal and the same # free_names/theta calibration.predict_centroids supports, the two must agree. from dispcraft.calibration import cost as cost_orig _df_check = median_per_spectrum(load_spectra(DATA_DIR / "rgs000_0_first.csv")) _free_check = ["offset_y_mm", "offset_z_mm", "tilt_deg", "rho"] _theta_check = np.array([FULL_NOMINAL[k] for k in _free_check]) _c_orig = cost_orig(_theta_check, _df_check, _free_check, model) _c_full = cost_full(_theta_check, _df_check, _free_check, model) assert np.isclose(_c_orig, _c_full), (_c_orig, _c_full) print(f"predict_centroids_full/cost_full agree with calibration.cost at nominal: {_c_orig:.6f} mm^2")
predict_centroids_full/cost_full agree with calibration.cost at nominal: 49.064635 mm^2

3. Load Stage 1 (Independent Per-Dataset) Results¶

Stage 1 of the deck's alternating strategy is already done — the 6 independent fits from 3-Intro_ML.ipynb / models/stage3_fit_<cfg>.toml, carried through 4.1/4.2 unchanged. Loaded here as this phase's starting point, not refit.

In [4]:
Copied!
def load_fit(cfg):
    with open(MODELS_DIR / f"stage3_fit_{cfg}.toml", "rb") as f:
        fit = tomllib.load(f)
    free_names = fit["fit"]["free_parameters"]
    theta = np.array([fit["fit"]["result"][k] for k in free_names])
    excluded_ids = fit["fit"]["excluded_spectra_ids"]
    return fit, free_names, theta, excluded_ids


dfs = {}
stage1_fits = {}
for cfg in CONFIGS:
    fit, free_names, theta, excluded_ids = load_fit(cfg)
    df = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv"))
    df = df[~df["spectra_id"].isin(excluded_ids)].reset_index(drop=True)
    assert len(df) == fit["fit"]["n_points"], f"{cfg}: row count must match the TOML's recorded fit"
    dfs[cfg] = df
    stage1_fits[cfg] = {"free_names": free_names, "theta": theta, "params": dict(zip(free_names, theta)),
                         "rmse_mm": fit["fit"]["rmse_mm"], "excluded_ids": excluded_ids}

print(f"loaded {len(dfs)} datasets, row counts: {[len(dfs[c]) for c in CONFIGS]}")
def load_fit(cfg): with open(MODELS_DIR / f"stage3_fit_{cfg}.toml", "rb") as f: fit = tomllib.load(f) free_names = fit["fit"]["free_parameters"] theta = np.array([fit["fit"]["result"][k] for k in free_names]) excluded_ids = fit["fit"]["excluded_spectra_ids"] return fit, free_names, theta, excluded_ids dfs = {} stage1_fits = {} for cfg in CONFIGS: fit, free_names, theta, excluded_ids = load_fit(cfg) df = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv")) df = df[~df["spectra_id"].isin(excluded_ids)].reset_index(drop=True) assert len(df) == fit["fit"]["n_points"], f"{cfg}: row count must match the TOML's recorded fit" dfs[cfg] = df stage1_fits[cfg] = {"free_names": free_names, "theta": theta, "params": dict(zip(free_names, theta)), "rmse_mm": fit["fit"]["rmse_mm"], "excluded_ids": excluded_ids} print(f"loaded {len(dfs)} datasets, row counts: {[len(dfs[c]) for c in CONFIGS]}")
loaded 6 datasets, row counts: [5297, 4772, 4577, 6198, 4596, 4604]
In [5]:
Copied!
stage1_table = pd.DataFrame({
    cfg: {**stage1_fits[cfg]["params"], "tilt_deg_mod360": stage1_fits[cfg]["params"]["tilt_deg"] % 360,
          "rmse_mm": stage1_fits[cfg]["rmse_mm"]}
    for cfg in CONFIGS
}).T[["offset_y_mm", "offset_z_mm", "tilt_deg_mod360", "rho", "rmse_mm"]]
stage1_table.round(4)
stage1_table = pd.DataFrame({ cfg: {**stage1_fits[cfg]["params"], "tilt_deg_mod360": stage1_fits[cfg]["params"]["tilt_deg"] % 360, "rmse_mm": stage1_fits[cfg]["rmse_mm"]} for cfg in CONFIGS }).T[["offset_y_mm", "offset_z_mm", "tilt_deg_mod360", "rho", "rmse_mm"]] stage1_table.round(4)
Out[5]:
offset_y_mm offset_z_mm tilt_deg_mod360 rho rmse_mm
rgs000_0 -0.5508 0.7377 0.1331 13.0758 0.3563
rgs000_m4 -0.4923 1.2423 4.1492 13.0571 0.3521
rgs000_p4 -0.5718 0.3099 356.2448 13.1074 0.3423
rgs180_0 -0.5117 -0.4518 180.2350 13.0900 0.4180
rgs180_m4 -0.5516 -0.0247 184.1997 13.0776 0.3656
rgs180_p4 -0.4656 -0.9518 176.2091 13.0436 0.3699

Recap — Section 0¶

  • 3-tier parameter classification declared: GLOBAL_PARAMS, INSTANCE_PARAMS, SPECIFIC_PARAMS.
  • predict_centroids_full/cost_full defined and checked against dispcraft.calibration's originals at nominal coll/cam/material values — agree exactly, so this superset is a safe drop-in for the rest of this notebook.
  • All 6 datasets loaded (own excluded_spectra_ids applied, row counts verified against each stage3_fit_<cfg>.toml), Stage 1 parameter table reproduced above.

Stopping here for review before Section 1 (Tier 1: global joint fit of coll_f/cam_f).

4. Section 1 — Tier 1: Global Joint Fit (coll_f, cam_f)¶

Per the deck's own pseudocode (4-Projet/index.html §3): a combined cost that is the unweighted sum of per-dataset MSE (total += mse(...), no n_points weighting). All 6 datasets have never had coll_f/cam_f fit before — both were always taken directly from stage1_instrument.toml. Every other parameter (Tier 2 + Tier 3) is held fixed at that dataset's own Stage 1 value (A_deg/material_n0 at nominal, matching how Stage 1 itself treated them).

Physically, coll_f/cam_f set the imaging magnification (y_nisp,z_nisp -> angle -> position, ratio cam_f/coll_f) and the dispersion plate scale (cam_f alone, via Camera.forward's -f*theta applied to the grism's added deviation) — different physical effects from A_deg's near-constant chromatic shift, so there's no a priori reason to expect the same single-dataset degeneracy here.

Caveat carried from the plan: n_points ranges 4577–6198 (~35% spread) across the 6 datasets; the unweighted sum gives smaller datasets outsized per-point leverage. Followed as specified by the deck; noted, not corrected.

In [6]:
Copied!
def fixed_for_tier1(cfg, stage1_fits):
    """All non-global params held at Stage-1 values (A_deg/material_n0 at
    nominal, matching Stage 1's own fixed treatment of them)."""
    p = dict(stage1_fits[cfg]["params"])  # offset_y_mm, offset_z_mm, tilt_deg, rho
    p["A_deg"] = FULL_NOMINAL["A_deg"]
    p["material_n0"] = FULL_NOMINAL["material_n0"]
    return p


def cost_joint(theta, dfs, free_names, fixed_by_cfg, model):
    """Unweighted sum of cost_full(...) over all datasets in `dfs`, matching
    the deck's own pseudocode (total += mse(...), no n_points weighting)."""
    return sum(cost_full(theta, dfs[cfg], free_names, model, fixed=fixed_by_cfg[cfg]) for cfg in dfs)


def per_dataset_rmse(theta, free_names, fixed_by_cfg, dfs, model):
    return {cfg: float(np.sqrt(cost_full(theta, dfs[cfg], free_names, model, fixed=fixed_by_cfg[cfg])))
            for cfg in dfs}


fixed_tier1 = {cfg: fixed_for_tier1(cfg, stage1_fits) for cfg in CONFIGS}
theta0_tier1 = np.array([FULL_NOMINAL[k] for k in GLOBAL_PARAMS])
print(f"theta0 (nominal coll_f, cam_f) = {theta0_tier1}")
print(f"cost_joint(theta0) = {cost_joint(theta0_tier1, dfs, GLOBAL_PARAMS, fixed_tier1, model):.4f} mm^2 (summed over 6 datasets)")
def fixed_for_tier1(cfg, stage1_fits): """All non-global params held at Stage-1 values (A_deg/material_n0 at nominal, matching Stage 1's own fixed treatment of them).""" p = dict(stage1_fits[cfg]["params"]) # offset_y_mm, offset_z_mm, tilt_deg, rho p["A_deg"] = FULL_NOMINAL["A_deg"] p["material_n0"] = FULL_NOMINAL["material_n0"] return p def cost_joint(theta, dfs, free_names, fixed_by_cfg, model): """Unweighted sum of cost_full(...) over all datasets in `dfs`, matching the deck's own pseudocode (total += mse(...), no n_points weighting).""" return sum(cost_full(theta, dfs[cfg], free_names, model, fixed=fixed_by_cfg[cfg]) for cfg in dfs) def per_dataset_rmse(theta, free_names, fixed_by_cfg, dfs, model): return {cfg: float(np.sqrt(cost_full(theta, dfs[cfg], free_names, model, fixed=fixed_by_cfg[cfg]))) for cfg in dfs} fixed_tier1 = {cfg: fixed_for_tier1(cfg, stage1_fits) for cfg in CONFIGS} theta0_tier1 = np.array([FULL_NOMINAL[k] for k in GLOBAL_PARAMS]) print(f"theta0 (nominal coll_f, cam_f) = {theta0_tier1}") print(f"cost_joint(theta0) = {cost_joint(theta0_tier1, dfs, GLOBAL_PARAMS, fixed_tier1, model):.4f} mm^2 (summed over 6 datasets)")
theta0 (nominal coll_f, cam_f) = [2. 1.]
cost_joint(theta0) = 0.8133 mm^2 (summed over 6 datasets)
In [7]:
Copied!
result_tier1 = minimize(cost_joint, theta0_tier1, args=(dfs, GLOBAL_PARAMS, fixed_tier1, model),
                         method="Nelder-Mead",
                         options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True})
theta_star_tier1 = result_tier1.x
print(f"theta* (coll_f, cam_f) = {dict(zip(GLOBAL_PARAMS, theta_star_tier1))}")
print(f"cost_joint: {cost_joint(theta0_tier1, dfs, GLOBAL_PARAMS, fixed_tier1, model):.4f} -> {result_tier1.fun:.4f} mm^2 (sum over 6 datasets)")

# Multi-start validation: only 2 free dims, no degenerate branches reachable
# since Tier 2/3 params are frozen at already-resolved Stage-1 values -- a
# cheap check, not a hard requirement.
starts = [theta0_tier1, theta0_tier1 + [0.05, 0], theta0_tier1 - [0.05, 0],
          theta0_tier1 + [0, 0.05], theta0_tier1 - [0, 0.05]]
multi_start_thetas = []
for s in starts:
    r = minimize(cost_joint, s, args=(dfs, GLOBAL_PARAMS, fixed_tier1, model), method="Nelder-Mead",
                 options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True})
    multi_start_thetas.append(r.x)
multi_start_thetas = np.array(multi_start_thetas)
print(f"\nmulti-start thetas:\n{multi_start_thetas}")
print(f"multi-start spread (std): {multi_start_thetas.std(axis=0)}  -- should be << bootstrap std below")
result_tier1 = minimize(cost_joint, theta0_tier1, args=(dfs, GLOBAL_PARAMS, fixed_tier1, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True}) theta_star_tier1 = result_tier1.x print(f"theta* (coll_f, cam_f) = {dict(zip(GLOBAL_PARAMS, theta_star_tier1))}") print(f"cost_joint: {cost_joint(theta0_tier1, dfs, GLOBAL_PARAMS, fixed_tier1, model):.4f} -> {result_tier1.fun:.4f} mm^2 (sum over 6 datasets)") # Multi-start validation: only 2 free dims, no degenerate branches reachable # since Tier 2/3 params are frozen at already-resolved Stage-1 values -- a # cheap check, not a hard requirement. starts = [theta0_tier1, theta0_tier1 + [0.05, 0], theta0_tier1 - [0.05, 0], theta0_tier1 + [0, 0.05], theta0_tier1 - [0, 0.05]] multi_start_thetas = [] for s in starts: r = minimize(cost_joint, s, args=(dfs, GLOBAL_PARAMS, fixed_tier1, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True}) multi_start_thetas.append(r.x) multi_start_thetas = np.array(multi_start_thetas) print(f"\nmulti-start thetas:\n{multi_start_thetas}") print(f"multi-start spread (std): {multi_start_thetas.std(axis=0)} -- should be << bootstrap std below")
theta* (coll_f, cam_f) = {'coll_f': np.float64(1.9992778575841643), 'cam_f': np.float64(1.0004149832063765)}
cost_joint: 0.8133 -> 0.7969 mm^2 (sum over 6 datasets)
multi-start thetas:
[[1.99927786 1.00041498]
 [1.99927786 1.00041499]
 [1.99927786 1.00041498]
 [1.99927786 1.00041499]
 [1.99927786 1.00041499]]
multi-start spread (std): [1.67541935e-09 8.94264729e-10]  -- should be << bootstrap std below
In [8]:
Copied!
def bootstrap_uncertainty_joint(theta_star, dfs, free_names, fixed_by_cfg, model, n_boot=30, seed=RNG_SEED):
    """Bootstrap std + correlation matrix for a joint multi-dataset fit.

    Resamples each dataset independently (own size, with replacement),
    recombines into cost_joint, re-minimizes from theta_star each replicate.
    No existing function covers this multi-dataset case (bootstrap_uncertainty
    in 3-Intro_ML.ipynb only resamples one dataset)."""
    rng = np.random.default_rng(seed)
    boots = []
    for _ in range(n_boot):
        resampled = {cfg: dfs[cfg].iloc[rng.integers(0, len(dfs[cfg]), len(dfs[cfg]))] for cfg in dfs}
        r = minimize(cost_joint, theta_star, args=(resampled, free_names, fixed_by_cfg, model),
                     method="Nelder-Mead",
                     options={"maxiter": 8000, "maxfev": 8000, "xatol": 1e-8, "fatol": 1e-12, "adaptive": True})
        boots.append(r.x)
    boots = np.array(boots)
    std = boots.std(axis=0)
    corr = pd.DataFrame(np.corrcoef(boots.T), index=free_names, columns=free_names)
    return std, corr


std_tier1, corr_tier1 = bootstrap_uncertainty_joint(theta_star_tier1, dfs, GLOBAL_PARAMS, fixed_tier1, model)
print("bootstrap std (30 resamples):")
for name, s in zip(GLOBAL_PARAMS, std_tier1):
    print(f"  {name:10s} {s:.6f}")
print("\nbootstrap correlation matrix:")
corr_tier1.round(3)
def bootstrap_uncertainty_joint(theta_star, dfs, free_names, fixed_by_cfg, model, n_boot=30, seed=RNG_SEED): """Bootstrap std + correlation matrix for a joint multi-dataset fit. Resamples each dataset independently (own size, with replacement), recombines into cost_joint, re-minimizes from theta_star each replicate. No existing function covers this multi-dataset case (bootstrap_uncertainty in 3-Intro_ML.ipynb only resamples one dataset).""" rng = np.random.default_rng(seed) boots = [] for _ in range(n_boot): resampled = {cfg: dfs[cfg].iloc[rng.integers(0, len(dfs[cfg]), len(dfs[cfg]))] for cfg in dfs} r = minimize(cost_joint, theta_star, args=(resampled, free_names, fixed_by_cfg, model), method="Nelder-Mead", options={"maxiter": 8000, "maxfev": 8000, "xatol": 1e-8, "fatol": 1e-12, "adaptive": True}) boots.append(r.x) boots = np.array(boots) std = boots.std(axis=0) corr = pd.DataFrame(np.corrcoef(boots.T), index=free_names, columns=free_names) return std, corr std_tier1, corr_tier1 = bootstrap_uncertainty_joint(theta_star_tier1, dfs, GLOBAL_PARAMS, fixed_tier1, model) print("bootstrap std (30 resamples):") for name, s in zip(GLOBAL_PARAMS, std_tier1): print(f" {name:10s} {s:.6f}") print("\nbootstrap correlation matrix:") corr_tier1.round(3)
bootstrap std (30 resamples):
  coll_f     0.000848
  cam_f      0.000424

bootstrap correlation matrix:
Out[8]:
coll_f cam_f
coll_f 1.000 0.998
cam_f 0.998 1.000
In [9]:
Copied!
# Per-dataset RMSE: Stage 1 (independent fit) vs. Tier-1-forced (coll_f/cam_f
# jointly fit, everything else still at Stage-1 values). Note cost_joint's
# converged value is a SUM of 6 MSEs, not a pooled quantity given unequal
# n_points -- always report the per-dataset breakdown, never sqrt(cost/6).
rmse_tier1 = per_dataset_rmse(theta_star_tier1, GLOBAL_PARAMS, fixed_tier1, dfs, model)
rmse_before_tier1 = per_dataset_rmse(theta0_tier1, GLOBAL_PARAMS, fixed_tier1, dfs, model)

comparison_tier1 = pd.DataFrame({
    "stage1_rmse_mm": {cfg: stage1_fits[cfg]["rmse_mm"] for cfg in CONFIGS},
    "tier1_before_rmse_mm": rmse_before_tier1,
    "tier1_after_rmse_mm": rmse_tier1,
}).loc[CONFIGS]
comparison_tier1.round(4)
# Per-dataset RMSE: Stage 1 (independent fit) vs. Tier-1-forced (coll_f/cam_f # jointly fit, everything else still at Stage-1 values). Note cost_joint's # converged value is a SUM of 6 MSEs, not a pooled quantity given unequal # n_points -- always report the per-dataset breakdown, never sqrt(cost/6). rmse_tier1 = per_dataset_rmse(theta_star_tier1, GLOBAL_PARAMS, fixed_tier1, dfs, model) rmse_before_tier1 = per_dataset_rmse(theta0_tier1, GLOBAL_PARAMS, fixed_tier1, dfs, model) comparison_tier1 = pd.DataFrame({ "stage1_rmse_mm": {cfg: stage1_fits[cfg]["rmse_mm"] for cfg in CONFIGS}, "tier1_before_rmse_mm": rmse_before_tier1, "tier1_after_rmse_mm": rmse_tier1, }).loc[CONFIGS] comparison_tier1.round(4)
Out[9]:
stage1_rmse_mm tier1_before_rmse_mm tier1_after_rmse_mm
rgs000_0 0.3563 0.3563 0.3509
rgs000_m4 0.3521 0.3521 0.3501
rgs000_p4 0.3423 0.3423 0.3417
rgs180_0 0.4180 0.4180 0.4089
rgs180_m4 0.3656 0.3656 0.3637
rgs180_p4 0.3699 0.3699 0.3674
In [10]:
Copied!
# Residual (data - model) histograms at the Tier-1-forced parameters, all 6
# datasets -- inspected for structure, not just aggregate RMSE.
fig, axes = plt.subplots(2, 6, figsize=(20, 6), sharex="row")
for col, cfg in enumerate(CONFIGS):
    pred = predict_centroids_full(dfs[cfg]["y_nisp"], dfs[cfg]["z_nisp"], dfs[cfg]["wavelength"],
                                   theta_star_tier1, GLOBAL_PARAMS, model, fixed=fixed_tier1[cfg])
    r_y = pred[0] - dfs[cfg]["cent_y"].values
    r_z = pred[1] - dfs[cfg]["cent_z"].values
    axes[0, col].hist(r_y, bins=40, color="tab:blue")
    axes[0, col].set_title(cfg)
    axes[1, col].hist(r_z, bins=40, color="tab:orange")
axes[0, 0].set_ylabel("r_y [mm]")
axes[1, 0].set_ylabel("r_z [mm]")
fig.suptitle("Residuals at Tier-1-forced parameters (coll_f/cam_f jointly fit, Tier 2/3 at Stage-1 values)")
plt.tight_layout()
plt.show()
# Residual (data - model) histograms at the Tier-1-forced parameters, all 6 # datasets -- inspected for structure, not just aggregate RMSE. fig, axes = plt.subplots(2, 6, figsize=(20, 6), sharex="row") for col, cfg in enumerate(CONFIGS): pred = predict_centroids_full(dfs[cfg]["y_nisp"], dfs[cfg]["z_nisp"], dfs[cfg]["wavelength"], theta_star_tier1, GLOBAL_PARAMS, model, fixed=fixed_tier1[cfg]) r_y = pred[0] - dfs[cfg]["cent_y"].values r_z = pred[1] - dfs[cfg]["cent_z"].values axes[0, col].hist(r_y, bins=40, color="tab:blue") axes[0, col].set_title(cfg) axes[1, col].hist(r_z, bins=40, color="tab:orange") axes[0, 0].set_ylabel("r_y [mm]") axes[1, 0].set_ylabel("r_z [mm]") fig.suptitle("Residuals at Tier-1-forced parameters (coll_f/cam_f jointly fit, Tier 2/3 at Stage-1 values)") plt.tight_layout() plt.show()
No description has been provided for this image
In [11]:
Copied!
def write_tier1_toml(theta, std, cost_before, cost_after, rmse_by_cfg):
    result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(GLOBAL_PARAMS, theta))
    std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(GLOBAL_PARAMS, std))
    rmse_lines = "\n".join(f"{cfg} = {rmse_by_cfg[cfg]:.6f}" for cfg in CONFIGS)

    text = f'''# Stage 4 Phase 3 -- Tier 1: global joint fit of coll_f/cam_f across all 6
# datasets. Generated by notebooks/4.3-Multi_Dataset_Fitting.ipynb. Do not
# hand-edit; re-run the notebook and regenerate this file instead.

[base_config]
path = "stage1_instrument.toml"

[fit]
tier = "global"
datasets = {CONFIGS}
method = "Nelder-Mead"
free_parameters = {GLOBAL_PARAMS}
cost_joint_before_mm2 = {cost_before:.6f}
cost_joint_after_mm2 = {cost_after:.6f}

[fit.result]
{result_lines}
{std_lines}

# Per-dataset RMSE at this Tier-1 result, with Tier 2/3 params still at
# their Stage-1 (independent) values -- not yet re-optimized.
[fit.per_dataset_rmse_mm]
{rmse_lines}
'''
    path = MODELS_DIR / "joint_global_shared.toml"
    path.write_text(text)
    print(f"wrote {path}")
    return path


cost_before_tier1 = cost_joint(theta0_tier1, dfs, GLOBAL_PARAMS, fixed_tier1, model)
tier1_toml_path = write_tier1_toml(theta_star_tier1, std_tier1, cost_before_tier1, result_tier1.fun, rmse_tier1)
print(tier1_toml_path.read_text())
def write_tier1_toml(theta, std, cost_before, cost_after, rmse_by_cfg): result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(GLOBAL_PARAMS, theta)) std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(GLOBAL_PARAMS, std)) rmse_lines = "\n".join(f"{cfg} = {rmse_by_cfg[cfg]:.6f}" for cfg in CONFIGS) text = f'''# Stage 4 Phase 3 -- Tier 1: global joint fit of coll_f/cam_f across all 6 # datasets. Generated by notebooks/4.3-Multi_Dataset_Fitting.ipynb. Do not # hand-edit; re-run the notebook and regenerate this file instead. [base_config] path = "stage1_instrument.toml" [fit] tier = "global" datasets = {CONFIGS} method = "Nelder-Mead" free_parameters = {GLOBAL_PARAMS} cost_joint_before_mm2 = {cost_before:.6f} cost_joint_after_mm2 = {cost_after:.6f} [fit.result] {result_lines} {std_lines} # Per-dataset RMSE at this Tier-1 result, with Tier 2/3 params still at # their Stage-1 (independent) values -- not yet re-optimized. [fit.per_dataset_rmse_mm] {rmse_lines} ''' path = MODELS_DIR / "joint_global_shared.toml" path.write_text(text) print(f"wrote {path}") return path cost_before_tier1 = cost_joint(theta0_tier1, dfs, GLOBAL_PARAMS, fixed_tier1, model) tier1_toml_path = write_tier1_toml(theta_star_tier1, std_tier1, cost_before_tier1, result_tier1.fun, rmse_tier1) print(tier1_toml_path.read_text())
wrote ../models/joint_global_shared.toml
# Stage 4 Phase 3 -- Tier 1: global joint fit of coll_f/cam_f across all 6
# datasets. Generated by notebooks/4.3-Multi_Dataset_Fitting.ipynb. Do not
# hand-edit; re-run the notebook and regenerate this file instead.

[base_config]
path = "stage1_instrument.toml"

[fit]
tier = "global"
datasets = ['rgs000_0', 'rgs000_m4', 'rgs000_p4', 'rgs180_0', 'rgs180_m4', 'rgs180_p4']
method = "Nelder-Mead"
free_parameters = ['coll_f', 'cam_f']
cost_joint_before_mm2 = 0.813328
cost_joint_after_mm2 = 0.796935

[fit.result]
coll_f = 1.999278
cam_f = 1.000415
coll_f_bootstrap_std = 0.000848
cam_f_bootstrap_std = 0.000424

# Per-dataset RMSE at this Tier-1 result, with Tier 2/3 params still at
# their Stage-1 (independent) values -- not yet re-optimized.
[fit.per_dataset_rmse_mm]
rgs000_0 = 0.350851
rgs000_m4 = 0.350127
rgs000_p4 = 0.341741
rgs180_0 = 0.408908
rgs180_m4 = 0.363723
rgs180_p4 = 0.367373

In [12]:
Copied!
mlflow.set_tracking_uri(f"sqlite:///{(REPO_ROOT / 'mlflow.db').resolve()}")
EXPERIMENT_NAME_TIER1 = "stage2"  # deck-mandated name (Task 3 step 2); collides in spelling only
# with this project's own project-Stage-3 -- disambiguated via TOML filenames instead.
if mlflow.get_experiment_by_name(EXPERIMENT_NAME_TIER1) is None:
    mlflow.create_experiment(EXPERIMENT_NAME_TIER1, artifact_location=f"file:{(REPO_ROOT / 'mlruns').resolve()}")
mlflow.set_experiment(EXPERIMENT_NAME_TIER1)

with mlflow.start_run(run_name="global_shared_fit"):
    mlflow.log_param("tier", "global")
    mlflow.log_param("free_parameters", GLOBAL_PARAMS)
    mlflow.log_param("n_datasets", len(CONFIGS))
    for name, v, s in zip(GLOBAL_PARAMS, theta_star_tier1, std_tier1):
        mlflow.log_param(name, v)
        mlflow.log_metric(f"{name}_bootstrap_std", s)
    mlflow.log_metric("cost_joint_before_mm2", cost_before_tier1)
    mlflow.log_metric("cost_joint_after_mm2", result_tier1.fun)
    for cfg in CONFIGS:
        mlflow.log_metric(f"rmse_{cfg}", rmse_tier1[cfg])

print(f"logged 1 run ('global_shared_fit') to the '{EXPERIMENT_NAME_TIER1}' MLflow experiment")
mlflow.set_tracking_uri(f"sqlite:///{(REPO_ROOT / 'mlflow.db').resolve()}") EXPERIMENT_NAME_TIER1 = "stage2" # deck-mandated name (Task 3 step 2); collides in spelling only # with this project's own project-Stage-3 -- disambiguated via TOML filenames instead. if mlflow.get_experiment_by_name(EXPERIMENT_NAME_TIER1) is None: mlflow.create_experiment(EXPERIMENT_NAME_TIER1, artifact_location=f"file:{(REPO_ROOT / 'mlruns').resolve()}") mlflow.set_experiment(EXPERIMENT_NAME_TIER1) with mlflow.start_run(run_name="global_shared_fit"): mlflow.log_param("tier", "global") mlflow.log_param("free_parameters", GLOBAL_PARAMS) mlflow.log_param("n_datasets", len(CONFIGS)) for name, v, s in zip(GLOBAL_PARAMS, theta_star_tier1, std_tier1): mlflow.log_param(name, v) mlflow.log_metric(f"{name}_bootstrap_std", s) mlflow.log_metric("cost_joint_before_mm2", cost_before_tier1) mlflow.log_metric("cost_joint_after_mm2", result_tier1.fun) for cfg in CONFIGS: mlflow.log_metric(f"rmse_{cfg}", rmse_tier1[cfg]) print(f"logged 1 run ('global_shared_fit') to the '{EXPERIMENT_NAME_TIER1}' MLflow experiment")
logged 1 run ('global_shared_fit') to the 'stage2' MLflow experiment

Recap — Section 1¶

  • Result: coll_f = 1.99928 ± 0.00085 m (nominal 2.0), cam_f = 1.00041 ± 0.00042 m (nominal 1.0) — both land within a few bootstrap std of nominal, and the multi-start spread (~1e-9) is far below the bootstrap std, confirming a single well-converged basin rather than solver luck.
  • Flagged finding — near-degeneracy: coll_f/cam_f are 99.8% bootstrap-correlated. Not surprising given Collimator/Camera's forward chain (the imaging term scales with cam_f/coll_f, only the dispersion term breaks the tie via cam_f alone) — but it means this dataset constrains the ratio far better than either focal length individually. Both are still nudged from nominal in the same direction the correlation predicts. Carried forward as a caveat, not fixed here (Tier 1's role is Stage 1 of the deck's alternating strategy — full identifiability discussion belongs in the Section 5 write-up).
  • Per-dataset RMSE improved for all 6 datasets (e.g. rgs180_0: 0.4180 → 0.4089 mm), consistent with the deck's "should improve" framing — though small next to the ~35% n_points spread caveat above. Residual histograms shown above, no obvious new structure introduced.
  • Written to models/joint_global_shared.toml; 1 run logged to MLflow experiment "stage2" (global_shared_fit).

Stopping here for review before Section 2 (Tier 2: per-grism-instance joint fit of A_deg/material_n0/rho, including the identifiability check against the single-dataset A_deg↔offset_z_mm degeneracy).

5. Section 2 — Tier 2: Per-Grism-Instance Joint Fit (A_deg, material_n0, rho)¶

Two independent combined-cost fits, one per grism instance, each summing over that instance's own 3 datasets (GRISM_INSTANCES). coll_f/cam_f fixed at Section 1's result; tilt_deg/offset_y_mm/offset_z_mm fixed at each dataset's own Stage-1 value.

Identifiability check first. 3-Intro_ML.ipynb found A_deg ~99% bootstrap-correlated with offset_z_mm/rho on a single dataset — not identifiable there because the prism's chromatic term is nearly flat over this data's narrow band, so A_deg only produces a near-constant shift that offset_z_mm (also free in that check) could absorb just as well. Before trusting a "production" Tier-2 fit that holds offset_z_mm fixed, this section repeats that exact check — A_deg/material_n0/rho shared across an instance's 3 datasets, offset_z_mm free per dataset (it's genuinely dataset-specific) — to see whether the 3 different tilt geometries (0°, ±4°) break the degeneracy or not.

In [13]:
Copied!
def instance_of(cfg):
    return "rgs000" if cfg.startswith("rgs000") else "rgs180"


def fixed_for_tier2(cfg, stage1_fits, theta_star_tier1):
    """Base fixed dict for any Tier-2 fit on `cfg`: Tier-1 coll_f/cam_f (from
    Section 1), Tier-3 params at Stage-1 values. A_deg/material_n0 start at
    nominal here but get overridden by theta wherever they're in free_names
    -- so this same helper serves both the free-A_deg/material_n0/rho
    production fit and the wide identifiability check below."""
    p = fixed_for_tier1(cfg, stage1_fits)  # A_deg/material_n0 nominal, tilt_deg/offset_y/offset_z/rho at Stage-1
    p["coll_f"], p["cam_f"] = theta_star_tier1
    return p


def cost_joint_wide(theta, dfs_group, shared_names, cfgs, fixed_by_cfg, model):
    """Like cost_joint, but `offset_z_mm` is free PER DATASET while
    shared_names stay one shared value across the group. theta layout:
    [*shared value per shared_names, *offset_z_mm one per cfg in cfgs order].
    Mirrors 3-Intro_ML's single-dataset wide identifiability check, extended
    to a joint multi-dataset cost."""
    n_shared = len(shared_names)
    shared_theta = theta[:n_shared]
    total = 0.0
    for i, cfg in enumerate(cfgs):
        theta_d = np.concatenate([shared_theta, [theta[n_shared + i]]])
        total += cost_full(theta_d, dfs_group[cfg], shared_names + ["offset_z_mm"], model, fixed=fixed_by_cfg[cfg])
    return total
def instance_of(cfg): return "rgs000" if cfg.startswith("rgs000") else "rgs180" def fixed_for_tier2(cfg, stage1_fits, theta_star_tier1): """Base fixed dict for any Tier-2 fit on `cfg`: Tier-1 coll_f/cam_f (from Section 1), Tier-3 params at Stage-1 values. A_deg/material_n0 start at nominal here but get overridden by theta wherever they're in free_names -- so this same helper serves both the free-A_deg/material_n0/rho production fit and the wide identifiability check below.""" p = fixed_for_tier1(cfg, stage1_fits) # A_deg/material_n0 nominal, tilt_deg/offset_y/offset_z/rho at Stage-1 p["coll_f"], p["cam_f"] = theta_star_tier1 return p def cost_joint_wide(theta, dfs_group, shared_names, cfgs, fixed_by_cfg, model): """Like cost_joint, but `offset_z_mm` is free PER DATASET while shared_names stay one shared value across the group. theta layout: [*shared value per shared_names, *offset_z_mm one per cfg in cfgs order]. Mirrors 3-Intro_ML's single-dataset wide identifiability check, extended to a joint multi-dataset cost.""" n_shared = len(shared_names) shared_theta = theta[:n_shared] total = 0.0 for i, cfg in enumerate(cfgs): theta_d = np.concatenate([shared_theta, [theta[n_shared + i]]]) total += cost_full(theta_d, dfs_group[cfg], shared_names + ["offset_z_mm"], model, fixed=fixed_by_cfg[cfg]) return total
In [14]:
Copied!
def bootstrap_uncertainty_wide(theta_star, dfs_group, shared_names, cfgs, fixed_by_cfg, model,
                                n_boot=30, seed=RNG_SEED):
    rng = np.random.default_rng(seed)
    names = shared_names + [f"offset_z_mm__{cfg}" for cfg in cfgs]
    boots = []
    for _ in range(n_boot):
        resampled = {cfg: dfs_group[cfg].iloc[rng.integers(0, len(dfs_group[cfg]), len(dfs_group[cfg]))] for cfg in cfgs}
        r = minimize(cost_joint_wide, theta_star, args=(resampled, shared_names, cfgs, fixed_by_cfg, model),
                     method="Nelder-Mead",
                     options={"maxiter": 12000, "maxfev": 12000, "xatol": 1e-8, "fatol": 1e-12, "adaptive": True})
        boots.append(r.x)
    boots = np.array(boots)
    std = boots.std(axis=0)
    corr = pd.DataFrame(np.corrcoef(boots.T), index=names, columns=names)
    return std, corr


wide_results = {}
for instance, cfgs in GRISM_INSTANCES.items():
    fixed_wide = {cfg: fixed_for_tier2(cfg, stage1_fits, theta_star_tier1) for cfg in cfgs}
    rho0 = np.mean([stage1_fits[cfg]["params"]["rho"] for cfg in cfgs])
    theta0_wide = np.concatenate([
        [FULL_NOMINAL["A_deg"], FULL_NOMINAL["material_n0"], rho0],
        [stage1_fits[cfg]["params"]["offset_z_mm"] for cfg in cfgs],
    ])
    dfs_group = {cfg: dfs[cfg] for cfg in cfgs}

    r_wide = minimize(cost_joint_wide, theta0_wide, args=(dfs_group, INSTANCE_PARAMS, cfgs, fixed_wide, model),
                       method="Nelder-Mead",
                       options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True})
    std_wide, corr_wide = bootstrap_uncertainty_wide(r_wide.x, dfs_group, INSTANCE_PARAMS, cfgs, fixed_wide, model)
    wide_results[instance] = {"theta": r_wide.x, "std": std_wide, "corr": corr_wide, "cost_after": r_wide.fun}

    names = INSTANCE_PARAMS + [f"offset_z_mm__{cfg}" for cfg in cfgs]
    print(f"=== {instance} wide identifiability check ===")
    print(f"theta* = {dict(zip(names, r_wide.x))}")
    print("bootstrap std:", dict(zip(names, std_wide.round(5))))
    print()
def bootstrap_uncertainty_wide(theta_star, dfs_group, shared_names, cfgs, fixed_by_cfg, model, n_boot=30, seed=RNG_SEED): rng = np.random.default_rng(seed) names = shared_names + [f"offset_z_mm__{cfg}" for cfg in cfgs] boots = [] for _ in range(n_boot): resampled = {cfg: dfs_group[cfg].iloc[rng.integers(0, len(dfs_group[cfg]), len(dfs_group[cfg]))] for cfg in cfgs} r = minimize(cost_joint_wide, theta_star, args=(resampled, shared_names, cfgs, fixed_by_cfg, model), method="Nelder-Mead", options={"maxiter": 12000, "maxfev": 12000, "xatol": 1e-8, "fatol": 1e-12, "adaptive": True}) boots.append(r.x) boots = np.array(boots) std = boots.std(axis=0) corr = pd.DataFrame(np.corrcoef(boots.T), index=names, columns=names) return std, corr wide_results = {} for instance, cfgs in GRISM_INSTANCES.items(): fixed_wide = {cfg: fixed_for_tier2(cfg, stage1_fits, theta_star_tier1) for cfg in cfgs} rho0 = np.mean([stage1_fits[cfg]["params"]["rho"] for cfg in cfgs]) theta0_wide = np.concatenate([ [FULL_NOMINAL["A_deg"], FULL_NOMINAL["material_n0"], rho0], [stage1_fits[cfg]["params"]["offset_z_mm"] for cfg in cfgs], ]) dfs_group = {cfg: dfs[cfg] for cfg in cfgs} r_wide = minimize(cost_joint_wide, theta0_wide, args=(dfs_group, INSTANCE_PARAMS, cfgs, fixed_wide, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True}) std_wide, corr_wide = bootstrap_uncertainty_wide(r_wide.x, dfs_group, INSTANCE_PARAMS, cfgs, fixed_wide, model) wide_results[instance] = {"theta": r_wide.x, "std": std_wide, "corr": corr_wide, "cost_after": r_wide.fun} names = INSTANCE_PARAMS + [f"offset_z_mm__{cfg}" for cfg in cfgs] print(f"=== {instance} wide identifiability check ===") print(f"theta* = {dict(zip(names, r_wide.x))}") print("bootstrap std:", dict(zip(names, std_wide.round(5)))) print()
=== rgs000 wide identifiability check ===
theta* = {'A_deg': np.float64(0.7013021909800532), 'material_n0': np.float64(2.3571042691349957), 'rho': np.float64(13.133341495598277), 'offset_z_mm__rgs000_0': np.float64(0.7499849882844645), 'offset_z_mm__rgs000_m4': np.float64(1.220640590836935), 'offset_z_mm__rgs000_p4': np.float64(0.3621281837881992)}
bootstrap std: {'A_deg': np.float64(0.29763), 'material_n0': np.float64(0.59165), 'rho': np.float64(0.01366), 'offset_z_mm__rgs000_0': np.float64(0.02309), 'offset_z_mm__rgs000_m4': np.float64(0.02242), 'offset_z_mm__rgs000_p4': np.float64(0.02375)}

=== rgs180 wide identifiability check ===
theta* = {'A_deg': np.float64(0.6137631678275328), 'material_n0': np.float64(2.551508756031744), 'rho': np.float64(13.127479216790281), 'offset_z_mm__rgs180_0': np.float64(-0.4945908740305127), 'offset_z_mm__rgs180_m4': np.float64(-0.046498306368463416), 'offset_z_mm__rgs180_p4': np.float64(-0.9228473693741215)}
bootstrap std: {'A_deg': np.float64(0.10556), 'material_n0': np.float64(0.43072), 'rho': np.float64(0.01406), 'offset_z_mm__rgs180_0': np.float64(0.0258), 'offset_z_mm__rgs180_m4': np.float64(0.02548), 'offset_z_mm__rgs180_p4': np.float64(0.02705)}

In [15]:
Copied!
for instance in GRISM_INSTANCES:
    print(f"=== {instance}: bootstrap correlation matrix ===")
    corr = wide_results[instance]["corr"]
    print(corr.round(3))
    a_deg_corrs = corr.loc["A_deg"].drop("A_deg")
    print(f"\nmax |corr(A_deg, other)| = {a_deg_corrs.abs().max():.3f} "
          f"(with {a_deg_corrs.abs().idxmax()})  -- vs. ~0.99 single-dataset (3-Intro_ML.ipynb)")
    print()
for instance in GRISM_INSTANCES: print(f"=== {instance}: bootstrap correlation matrix ===") corr = wide_results[instance]["corr"] print(corr.round(3)) a_deg_corrs = corr.loc["A_deg"].drop("A_deg") print(f"\nmax |corr(A_deg, other)| = {a_deg_corrs.abs().max():.3f} " f"(with {a_deg_corrs.abs().idxmax()}) -- vs. ~0.99 single-dataset (3-Intro_ML.ipynb)") print()
=== rgs000: bootstrap correlation matrix ===
                        A_deg  material_n0    rho  offset_z_mm__rgs000_0  \
A_deg                   1.000       -0.957 -0.837                  0.224   
material_n0            -0.957        1.000  0.758                 -0.221   
rho                    -0.837        0.758  1.000                 -0.163   
offset_z_mm__rgs000_0   0.224       -0.221 -0.163                  1.000   
offset_z_mm__rgs000_m4  0.205       -0.192 -0.122                  0.971   
offset_z_mm__rgs000_p4  0.176       -0.178 -0.126                  0.974   

                        offset_z_mm__rgs000_m4  offset_z_mm__rgs000_p4  
A_deg                                    0.205                   0.176  
material_n0                             -0.192                  -0.178  
rho                                     -0.122                  -0.126  
offset_z_mm__rgs000_0                    0.971                   0.974  
offset_z_mm__rgs000_m4                   1.000                   0.976  
offset_z_mm__rgs000_p4                   0.976                   1.000  

max |corr(A_deg, other)| = 0.957 (with material_n0)  -- vs. ~0.99 single-dataset (3-Intro_ML.ipynb)

=== rgs180: bootstrap correlation matrix ===
                        A_deg  material_n0    rho  offset_z_mm__rgs180_0  \
A_deg                   1.000       -0.940 -0.424                 -0.032   
material_n0            -0.940        1.000  0.335                 -0.067   
rho                    -0.424        0.335  1.000                 -0.069   
offset_z_mm__rgs180_0  -0.032       -0.067 -0.069                  1.000   
offset_z_mm__rgs180_m4  0.019       -0.146 -0.060                  0.975   
offset_z_mm__rgs180_p4 -0.009       -0.110 -0.088                  0.971   

                        offset_z_mm__rgs180_m4  offset_z_mm__rgs180_p4  
A_deg                                    0.019                  -0.009  
material_n0                             -0.146                  -0.110  
rho                                     -0.060                  -0.088  
offset_z_mm__rgs180_0                    0.975                   0.971  
offset_z_mm__rgs180_m4                   1.000                   0.981  
offset_z_mm__rgs180_p4                   0.981                   1.000  

max |corr(A_deg, other)| = 0.940 (with material_n0)  -- vs. ~0.99 single-dataset (3-Intro_ML.ipynb)

Reading the identifiability check¶

The original degeneracy is broken. A_deg's correlation with offset_z_mm drops from ~0.99 (single dataset, 3-Intro_ML.ipynb) to 0.16–0.22 (rgs000) and 0.01–0.07 (rgs180) once fit jointly across the 3 tilt geometries — joint fitting across genuinely different tilts does what it should: it separates the tilt-dependent offset_z_mm from the tilt-independent chromatic shift.

But a new one appears: A_deg ↔ material_n0 at ~95–96%. Never visible before — Stage 3 never freed material_n0 at all. This is a different, more fundamental degeneracy than the first: the prism's deviation is δ = A*(n(λ)-1), a product, so a joint fit across tilts constrains that product well (rho stays sane, ~13.13 with tight std) but cannot split it into A_deg and material_n0 individually — no amount of tilt diversity changes that, since the product structure doesn't depend on tilt at all. Individually, both parameters land far from anything physical (A_deg ≈0.6–0.7° vs. nominal 2.145°, material_n0 ≈2.4–2.6 vs. nominal 1.44 — no real glass has that index) with large relative bootstrap std (A_deg ~30–40%, material_n0 ~20–25%).

Decision: fix material_n0 at nominal, free only A_deg + rho for the production Tier-2 fit — reverting material_n0 to the same fixed-in-practice treatment Stage 3 originally gave A_deg alone, for the same reason (not separately identifiable from this data, here because of a structural product-degeneracy rather than a narrow-band one). A_deg alone (without material_n0 competing for the same signal) is worth retrying, since it's rho and offset_z_mm it needs to be distinguished from now, and the A_deg↔offset_z_mm correlation above is already low.

In [16]:
Copied!
INSTANCE_FREE_PARAMS = ["A_deg", "rho"]  # material_n0 fixed at nominal -- see identifiability check above

tier2_results = {}
for instance, cfgs in GRISM_INSTANCES.items():
    fixed_tier2 = {cfg: fixed_for_tier2(cfg, stage1_fits, theta_star_tier1) for cfg in cfgs}
    rho0 = np.mean([stage1_fits[cfg]["params"]["rho"] for cfg in cfgs])
    theta0 = np.array([FULL_NOMINAL["A_deg"], rho0])
    dfs_group = {cfg: dfs[cfg] for cfg in cfgs}

    cost_before = cost_joint(theta0, dfs_group, INSTANCE_FREE_PARAMS, fixed_tier2, model)
    r = minimize(cost_joint, theta0, args=(dfs_group, INSTANCE_FREE_PARAMS, fixed_tier2, model),
                 method="Nelder-Mead",
                 options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True})
    std, corr = bootstrap_uncertainty_joint(r.x, dfs_group, INSTANCE_FREE_PARAMS, fixed_tier2, model)

    tier2_results[instance] = {"theta": r.x, "std": std, "corr": corr,
                                "cost_before": cost_before, "cost_after": r.fun, "fixed_by_cfg": fixed_tier2}
    print(f"=== {instance} (production Tier 2: A_deg + rho) ===")
    print(f"theta* = {dict(zip(INSTANCE_FREE_PARAMS, r.x))}")
    print(f"bootstrap std = {dict(zip(INSTANCE_FREE_PARAMS, std.round(5)))}")
    print(f"cost_joint: {cost_before:.4f} -> {r.fun:.4f} mm^2 (sum over {len(cfgs)} datasets)")
    print("bootstrap correlation:")
    print(corr.round(3))
    print()
INSTANCE_FREE_PARAMS = ["A_deg", "rho"] # material_n0 fixed at nominal -- see identifiability check above tier2_results = {} for instance, cfgs in GRISM_INSTANCES.items(): fixed_tier2 = {cfg: fixed_for_tier2(cfg, stage1_fits, theta_star_tier1) for cfg in cfgs} rho0 = np.mean([stage1_fits[cfg]["params"]["rho"] for cfg in cfgs]) theta0 = np.array([FULL_NOMINAL["A_deg"], rho0]) dfs_group = {cfg: dfs[cfg] for cfg in cfgs} cost_before = cost_joint(theta0, dfs_group, INSTANCE_FREE_PARAMS, fixed_tier2, model) r = minimize(cost_joint, theta0, args=(dfs_group, INSTANCE_FREE_PARAMS, fixed_tier2, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True}) std, corr = bootstrap_uncertainty_joint(r.x, dfs_group, INSTANCE_FREE_PARAMS, fixed_tier2, model) tier2_results[instance] = {"theta": r.x, "std": std, "corr": corr, "cost_before": cost_before, "cost_after": r.fun, "fixed_by_cfg": fixed_tier2} print(f"=== {instance} (production Tier 2: A_deg + rho) ===") print(f"theta* = {dict(zip(INSTANCE_FREE_PARAMS, r.x))}") print(f"bootstrap std = {dict(zip(INSTANCE_FREE_PARAMS, std.round(5)))}") print(f"cost_joint: {cost_before:.4f} -> {r.fun:.4f} mm^2 (sum over {len(cfgs)} datasets)") print("bootstrap correlation:") print(corr.round(3)) print()
=== rgs000 (production Tier 2: A_deg + rho) ===
theta* = {'A_deg': np.float64(2.1435032409762096), 'rho': np.float64(13.074063403315844)}
bootstrap std = {'A_deg': np.float64(0.00142), 'rho': np.float64(0.0075)}
cost_joint: 0.3652 -> 0.3652 mm^2 (sum over 3 datasets)
bootstrap correlation:
       A_deg    rho
A_deg  1.000  0.982
rho    0.982  1.000

=== rgs180 (production Tier 2: A_deg + rho) ===
theta* = {'A_deg': np.float64(2.1436441275804983), 'rho': np.float64(13.063186624006697)}
bootstrap std = {'A_deg': np.float64(0.00237), 'rho': np.float64(0.01296)}
cost_joint: 0.4372 -> 0.4372 mm^2 (sum over 3 datasets)
bootstrap correlation:
       A_deg    rho
A_deg  1.000  0.993
rho    0.993  1.000

In [17]:
Copied!
def full_fixed_for_tier12(cfg, stage1_fits, theta_star_tier1, tier2_results):
    """Complete parameter dict for `cfg` at the current Tier-1+2-forced state:
    coll_f/cam_f (Tier 1), A_deg/rho (Tier 2, this cfg's grism instance,
    material_n0 at nominal), tilt_deg/offset_y_mm/offset_z_mm (Stage 1,
    Tier 3 not yet re-optimized)."""
    p = fixed_for_tier2(cfg, stage1_fits, theta_star_tier1)
    inst = instance_of(cfg)
    p.update(dict(zip(INSTANCE_FREE_PARAMS, tier2_results[inst]["theta"])))
    return p


fixed_tier12 = {cfg: full_fixed_for_tier12(cfg, stage1_fits, theta_star_tier1, tier2_results) for cfg in CONFIGS}
rmse_tier12 = {cfg: float(np.sqrt(cost_full(np.array([]), dfs[cfg], [], model, fixed=fixed_tier12[cfg])))
               for cfg in CONFIGS}

comparison_tier12 = pd.DataFrame({
    "stage1_rmse_mm": {cfg: stage1_fits[cfg]["rmse_mm"] for cfg in CONFIGS},
    "tier1_forced_rmse_mm": rmse_tier1,
    "tier1+2_forced_rmse_mm": rmse_tier12,
}).loc[CONFIGS]
comparison_tier12.round(4)
def full_fixed_for_tier12(cfg, stage1_fits, theta_star_tier1, tier2_results): """Complete parameter dict for `cfg` at the current Tier-1+2-forced state: coll_f/cam_f (Tier 1), A_deg/rho (Tier 2, this cfg's grism instance, material_n0 at nominal), tilt_deg/offset_y_mm/offset_z_mm (Stage 1, Tier 3 not yet re-optimized).""" p = fixed_for_tier2(cfg, stage1_fits, theta_star_tier1) inst = instance_of(cfg) p.update(dict(zip(INSTANCE_FREE_PARAMS, tier2_results[inst]["theta"]))) return p fixed_tier12 = {cfg: full_fixed_for_tier12(cfg, stage1_fits, theta_star_tier1, tier2_results) for cfg in CONFIGS} rmse_tier12 = {cfg: float(np.sqrt(cost_full(np.array([]), dfs[cfg], [], model, fixed=fixed_tier12[cfg]))) for cfg in CONFIGS} comparison_tier12 = pd.DataFrame({ "stage1_rmse_mm": {cfg: stage1_fits[cfg]["rmse_mm"] for cfg in CONFIGS}, "tier1_forced_rmse_mm": rmse_tier1, "tier1+2_forced_rmse_mm": rmse_tier12, }).loc[CONFIGS] comparison_tier12.round(4)
Out[17]:
stage1_rmse_mm tier1_forced_rmse_mm tier1+2_forced_rmse_mm
rgs000_0 0.3563 0.3509 0.3508
rgs000_m4 0.3521 0.3501 0.3520
rgs000_p4 0.3423 0.3417 0.3439
rgs180_0 0.4180 0.4089 0.4101
rgs180_m4 0.3656 0.3637 0.3639
rgs180_p4 0.3699 0.3674 0.3697
In [18]:
Copied!
# rho: production Tier-2 (instance-shared) vs. Stage-1 per-dataset values
rho_compare_rows = []
for instance, cfgs in GRISM_INSTANCES.items():
    tier2_rho = tier2_results[instance]["theta"][INSTANCE_FREE_PARAMS.index("rho")]
    tier2_A = tier2_results[instance]["theta"][INSTANCE_FREE_PARAMS.index("A_deg")]
    for cfg in cfgs:
        rho_compare_rows.append({"dataset": cfg, "instance": instance,
                                  "stage1_rho": stage1_fits[cfg]["params"]["rho"],
                                  "tier2_rho_shared": tier2_rho, "tier2_A_deg_shared": tier2_A})
pd.DataFrame(rho_compare_rows).set_index("dataset").round(4)
# rho: production Tier-2 (instance-shared) vs. Stage-1 per-dataset values rho_compare_rows = [] for instance, cfgs in GRISM_INSTANCES.items(): tier2_rho = tier2_results[instance]["theta"][INSTANCE_FREE_PARAMS.index("rho")] tier2_A = tier2_results[instance]["theta"][INSTANCE_FREE_PARAMS.index("A_deg")] for cfg in cfgs: rho_compare_rows.append({"dataset": cfg, "instance": instance, "stage1_rho": stage1_fits[cfg]["params"]["rho"], "tier2_rho_shared": tier2_rho, "tier2_A_deg_shared": tier2_A}) pd.DataFrame(rho_compare_rows).set_index("dataset").round(4)
Out[18]:
instance stage1_rho tier2_rho_shared tier2_A_deg_shared
dataset
rgs000_0 rgs000 13.0758 13.0741 2.1435
rgs000_m4 rgs000 13.0571 13.0741 2.1435
rgs000_p4 rgs000 13.1074 13.0741 2.1435
rgs180_0 rgs180 13.0900 13.0632 2.1436
rgs180_m4 rgs180 13.0776 13.0632 2.1436
rgs180_p4 rgs180 13.0436 13.0632 2.1436
In [19]:
Copied!
# Residual histograms at Tier-1+2-forced parameters, all 6 datasets
fig, axes = plt.subplots(2, 6, figsize=(20, 6), sharex="row")
for col, cfg in enumerate(CONFIGS):
    pred = predict_centroids_full(dfs[cfg]["y_nisp"], dfs[cfg]["z_nisp"], dfs[cfg]["wavelength"],
                                   np.array([]), [], model, fixed=fixed_tier12[cfg])
    r_y = pred[0] - dfs[cfg]["cent_y"].values
    r_z = pred[1] - dfs[cfg]["cent_z"].values
    axes[0, col].hist(r_y, bins=40, color="tab:blue")
    axes[0, col].set_title(cfg)
    axes[1, col].hist(r_z, bins=40, color="tab:orange")
axes[0, 0].set_ylabel("r_y [mm]")
axes[1, 0].set_ylabel("r_z [mm]")
fig.suptitle("Residuals at Tier-1+2-forced parameters (coll_f/cam_f + A_deg/rho per instance jointly fit, Tier 3 at Stage-1 values)")
plt.tight_layout()
plt.show()
# Residual histograms at Tier-1+2-forced parameters, all 6 datasets fig, axes = plt.subplots(2, 6, figsize=(20, 6), sharex="row") for col, cfg in enumerate(CONFIGS): pred = predict_centroids_full(dfs[cfg]["y_nisp"], dfs[cfg]["z_nisp"], dfs[cfg]["wavelength"], np.array([]), [], model, fixed=fixed_tier12[cfg]) r_y = pred[0] - dfs[cfg]["cent_y"].values r_z = pred[1] - dfs[cfg]["cent_z"].values axes[0, col].hist(r_y, bins=40, color="tab:blue") axes[0, col].set_title(cfg) axes[1, col].hist(r_z, bins=40, color="tab:orange") axes[0, 0].set_ylabel("r_y [mm]") axes[1, 0].set_ylabel("r_z [mm]") fig.suptitle("Residuals at Tier-1+2-forced parameters (coll_f/cam_f + A_deg/rho per instance jointly fit, Tier 3 at Stage-1 values)") plt.tight_layout() plt.show()
No description has been provided for this image
In [20]:
Copied!
def write_tier2_toml(instance, cfgs, result):
    theta, std = result["theta"], result["std"]
    result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(INSTANCE_FREE_PARAMS, theta))
    std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(INSTANCE_FREE_PARAMS, std))
    rmse_lines = "\n".join(f"{cfg} = {rmse_tier12[cfg]:.6f}" for cfg in cfgs)

    text = f'''# Stage 4 Phase 3 -- Tier 2: per-grism-instance joint fit of A_deg/rho for
# the {instance} instance (3 datasets: {cfgs}). Generated by
# notebooks/4.3-Multi_Dataset_Fitting.ipynb. Do not hand-edit; re-run the
# notebook and regenerate this file instead.

[base_config]
path = "stage1_instrument.toml"

[tier1]
path = "joint_global_shared.toml"  # coll_f/cam_f, applied fixed here

[fit]
tier = "instance"
instance = "{instance}"
datasets = {cfgs}
method = "Nelder-Mead"
free_parameters = {INSTANCE_FREE_PARAMS}
cost_joint_before_mm2 = {result["cost_before"]:.6f}
cost_joint_after_mm2 = {result["cost_after"]:.6f}

[fit.result]
{result_lines}
{std_lines}

[fit.fixed]
# material_n0 fixed at nominal -- found ~95% bootstrap-correlated with
# A_deg even when jointly fit across 3 tilt geometries (product
# degeneracy in Prism.deviation = A*(n-1), not resolved by tilt diversity).
material_n0 = {FULL_NOMINAL["material_n0"]}
material_k = {base_config["material"]["k"]}

# Per-dataset RMSE at the current Tier-1+2-forced state (Tier 3 still at
# Stage-1 values, not yet re-optimized).
[fit.per_dataset_rmse_mm]
{rmse_lines}
'''
    path = MODELS_DIR / f"joint_instance_shared_{instance}.toml"
    path.write_text(text)
    print(f"wrote {path}")
    return path


for instance, cfgs in GRISM_INSTANCES.items():
    write_tier2_toml(instance, cfgs, tier2_results[instance])
def write_tier2_toml(instance, cfgs, result): theta, std = result["theta"], result["std"] result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(INSTANCE_FREE_PARAMS, theta)) std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(INSTANCE_FREE_PARAMS, std)) rmse_lines = "\n".join(f"{cfg} = {rmse_tier12[cfg]:.6f}" for cfg in cfgs) text = f'''# Stage 4 Phase 3 -- Tier 2: per-grism-instance joint fit of A_deg/rho for # the {instance} instance (3 datasets: {cfgs}). Generated by # notebooks/4.3-Multi_Dataset_Fitting.ipynb. Do not hand-edit; re-run the # notebook and regenerate this file instead. [base_config] path = "stage1_instrument.toml" [tier1] path = "joint_global_shared.toml" # coll_f/cam_f, applied fixed here [fit] tier = "instance" instance = "{instance}" datasets = {cfgs} method = "Nelder-Mead" free_parameters = {INSTANCE_FREE_PARAMS} cost_joint_before_mm2 = {result["cost_before"]:.6f} cost_joint_after_mm2 = {result["cost_after"]:.6f} [fit.result] {result_lines} {std_lines} [fit.fixed] # material_n0 fixed at nominal -- found ~95% bootstrap-correlated with # A_deg even when jointly fit across 3 tilt geometries (product # degeneracy in Prism.deviation = A*(n-1), not resolved by tilt diversity). material_n0 = {FULL_NOMINAL["material_n0"]} material_k = {base_config["material"]["k"]} # Per-dataset RMSE at the current Tier-1+2-forced state (Tier 3 still at # Stage-1 values, not yet re-optimized). [fit.per_dataset_rmse_mm] {rmse_lines} ''' path = MODELS_DIR / f"joint_instance_shared_{instance}.toml" path.write_text(text) print(f"wrote {path}") return path for instance, cfgs in GRISM_INSTANCES.items(): write_tier2_toml(instance, cfgs, tier2_results[instance])
wrote ../models/joint_instance_shared_rgs000.toml
wrote ../models/joint_instance_shared_rgs180.toml
In [21]:
Copied!
mlflow.set_experiment("stage2")  # same deck-mandated experiment as Tier 1's global run

for instance, cfgs in GRISM_INSTANCES.items():
    result = tier2_results[instance]
    with mlflow.start_run(run_name=f"instance_shared_fit_{instance}"):
        mlflow.log_param("tier", "instance")
        mlflow.log_param("instance", instance)
        mlflow.log_param("datasets", cfgs)
        mlflow.log_param("free_parameters", INSTANCE_FREE_PARAMS)
        mlflow.log_param("material_n0_fixed_at", FULL_NOMINAL["material_n0"])
        for name, v, s in zip(INSTANCE_FREE_PARAMS, result["theta"], result["std"]):
            mlflow.log_param(name, v)
            mlflow.log_metric(f"{name}_bootstrap_std", s)
        mlflow.log_metric("cost_joint_before_mm2", result["cost_before"])
        mlflow.log_metric("cost_joint_after_mm2", result["cost_after"])
        for cfg in cfgs:
            mlflow.log_metric(f"rmse_{cfg}", rmse_tier12[cfg])

print("logged 2 runs ('instance_shared_fit_rgs000', 'instance_shared_fit_rgs180') to the 'stage2' MLflow experiment")
mlflow.set_experiment("stage2") # same deck-mandated experiment as Tier 1's global run for instance, cfgs in GRISM_INSTANCES.items(): result = tier2_results[instance] with mlflow.start_run(run_name=f"instance_shared_fit_{instance}"): mlflow.log_param("tier", "instance") mlflow.log_param("instance", instance) mlflow.log_param("datasets", cfgs) mlflow.log_param("free_parameters", INSTANCE_FREE_PARAMS) mlflow.log_param("material_n0_fixed_at", FULL_NOMINAL["material_n0"]) for name, v, s in zip(INSTANCE_FREE_PARAMS, result["theta"], result["std"]): mlflow.log_param(name, v) mlflow.log_metric(f"{name}_bootstrap_std", s) mlflow.log_metric("cost_joint_before_mm2", result["cost_before"]) mlflow.log_metric("cost_joint_after_mm2", result["cost_after"]) for cfg in cfgs: mlflow.log_metric(f"rmse_{cfg}", rmse_tier12[cfg]) print("logged 2 runs ('instance_shared_fit_rgs000', 'instance_shared_fit_rgs180') to the 'stage2' MLflow experiment")
logged 2 runs ('instance_shared_fit_rgs000', 'instance_shared_fit_rgs180') to the 'stage2' MLflow experiment

Recap — Section 2¶

  • Identifiability check: confirmed the original single-dataset A_deg↔offset_z_mm degeneracy is broken by joint fitting across tilt geometries (correlation 0.99 → 0.01–0.22), but surfaced a new A_deg↔material_n0 product-degeneracy instead (~95–96%) — decided to fix material_n0 at nominal and free only A_deg + rho for the production fit.
  • Production result — a much better-behaved fit: A_deg = 2.1435° (rgs000) / 2.1436° (rgs180) — essentially identical between the two instances, and almost exactly the Stage-1 nominal (2.145°), with tight bootstrap std (~0.001–0.002°, <0.1% relative) — a real contrast with the wide check's unstable ~0.6–0.7° estimate. rho = 13.074 (rgs000) / 13.063 (rgs180), close to Stage 1's per-dataset spread. A_deg/rho still show residual correlation (0.98–0.99) — smaller than the 3-way degeneracy above, but not zero; noted, not chased further.
  • A_deg landing this close between instances is itself a finding: it suggests the apex angle may not need instance-level granularity at all (worth a callout in the Section 5 write-up) — though this fit can't distinguish "genuinely global" from "two instances that happen to be manufactured this close."
  • RMSE at this Tier-1+2-forced state moved slightly worse than Tier-1-forced for most datasets (e.g. rgs000_m4: 0.3501→0.3520mm, rgs180_p4: 0.3674→0.3697mm) — expected: forcing one shared A_deg/rho per instance while Tier 3 (offset_z_mm/tilt_deg) is still frozen at each dataset's own Stage-1-optimal value necessarily costs some fit quality per dataset; Section 3's job is to recover it.
  • Written to models/joint_instance_shared_rgs000.toml and ..._rgs180.toml; 2 runs logged to MLflow experiment "stage2".

Stopping here for review before Section 3 (Tier 3: per-dataset re-fit of tilt_deg/offset_y_mm/offset_z_mm, with Tier 1+2 now fixed).

6. Section 3 — Tier 3: Per-Dataset Re-Fit (tilt_deg, offset_y_mm, offset_z_mm)¶

6 independent single-dataset fits, coll_f/cam_f fixed at Section 1's result and A_deg/rho fixed at that dataset's grism instance's Section 2 result (material_n0 still at nominal). Per the deck: "this is a refinement step — RMSE should improve slightly" relative to Stage 1, not relative to the intermediate Tier-1/Tier-1+2-forced states above (those are expected to be worse, since Tier 3 was still frozen there).

Warm-started from each dataset's own Stage-1 tilt_deg/offset_y_mm/ offset_z_mm — already on the physically correct tilt branch, so the rgs180 tilt+180° seeding trick from 3-Intro_ML.ipynb isn't needed again here (that trick was for starting from nominal; this starts from an already-resolved value on the right side of the degeneracy). Reuses cost_joint/bootstrap_uncertainty_joint with a single-entry dfs dict — both are already generic over "however many datasets are in the dict."

In [22]:
Copied!
tier3_results = {}
for cfg in CONFIGS:
    theta0 = np.array([stage1_fits[cfg]["params"][k] for k in SPECIFIC_PARAMS])
    dfs_single = {cfg: dfs[cfg]}
    fixed_single = {cfg: fixed_tier12[cfg]}  # coll_f/cam_f (tier1), A_deg/rho (tier2 instance), material_n0 nominal

    cost_before = cost_joint(theta0, dfs_single, SPECIFIC_PARAMS, fixed_single, model)
    r = minimize(cost_joint, theta0, args=(dfs_single, SPECIFIC_PARAMS, fixed_single, model), method="Nelder-Mead",
                 options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True})
    std, _ = bootstrap_uncertainty_joint(r.x, dfs_single, SPECIFIC_PARAMS, fixed_single, model)

    tier3_results[cfg] = {"theta": r.x, "std": std, "cost_before": cost_before, "cost_after": r.fun}
    print(f"{cfg}: RMSE {np.sqrt(cost_before):.4f} -> {np.sqrt(r.fun):.4f} mm  "
          f"theta*={dict(zip(SPECIFIC_PARAMS, r.x.round(4)))}")
tier3_results = {} for cfg in CONFIGS: theta0 = np.array([stage1_fits[cfg]["params"][k] for k in SPECIFIC_PARAMS]) dfs_single = {cfg: dfs[cfg]} fixed_single = {cfg: fixed_tier12[cfg]} # coll_f/cam_f (tier1), A_deg/rho (tier2 instance), material_n0 nominal cost_before = cost_joint(theta0, dfs_single, SPECIFIC_PARAMS, fixed_single, model) r = minimize(cost_joint, theta0, args=(dfs_single, SPECIFIC_PARAMS, fixed_single, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True}) std, _ = bootstrap_uncertainty_joint(r.x, dfs_single, SPECIFIC_PARAMS, fixed_single, model) tier3_results[cfg] = {"theta": r.x, "std": std, "cost_before": cost_before, "cost_after": r.fun} print(f"{cfg}: RMSE {np.sqrt(cost_before):.4f} -> {np.sqrt(r.fun):.4f} mm " f"theta*={dict(zip(SPECIFIC_PARAMS, r.x.round(4)))}")
rgs000_0: RMSE 0.3508 -> 0.3508 mm  theta*={'tilt_deg': np.float64(0.129), 'offset_y_mm': np.float64(-0.5494), 'offset_z_mm': np.float64(0.7357)}
rgs000_m4: RMSE 0.3520 -> 0.3501 mm  theta*={'tilt_deg': np.float64(4.152), 'offset_y_mm': np.float64(-0.4966), 'offset_z_mm': np.float64(1.2065)}
rgs000_p4: RMSE 0.3439 -> 0.3418 mm  theta*={'tilt_deg': np.float64(-3.7533), 'offset_y_mm': np.float64(-0.5743), 'offset_z_mm': np.float64(0.3479)}
rgs180_0: RMSE 0.4101 -> 0.4089 mm  theta*={'tilt_deg': np.float64(180.2389), 'offset_y_mm': np.float64(-0.5092), 'offset_z_mm': np.float64(-0.4827)}
rgs180_m4: RMSE 0.3639 -> 0.3637 mm  theta*={'tilt_deg': np.float64(184.2012), 'offset_y_mm': np.float64(-0.5524), 'offset_z_mm': np.float64(-0.0347)}
rgs180_p4: RMSE 0.3697 -> 0.3674 mm  theta*={'tilt_deg': np.float64(176.2084), 'offset_y_mm': np.float64(-0.4682), 'offset_z_mm': np.float64(-0.911)}
In [23]:
Copied!
# Tier-3 param table: Stage 1 vs. this section, per dataset, with bootstrap std
param_rows = []
for cfg in CONFIGS:
    s1 = stage1_fits[cfg]["params"]
    t3 = dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["theta"]))
    t3_std = dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["std"]))
    row = {"dataset": cfg}
    for k in SPECIFIC_PARAMS:
        row[f"stage1_{k}"] = s1[k]
        row[f"tier3_{k}"] = t3[k]
        row[f"tier3_{k}_std"] = t3_std[k]
    param_rows.append(row)
tier3_param_table = pd.DataFrame(param_rows).set_index("dataset")
tier3_param_table.round(4)
# Tier-3 param table: Stage 1 vs. this section, per dataset, with bootstrap std param_rows = [] for cfg in CONFIGS: s1 = stage1_fits[cfg]["params"] t3 = dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["theta"])) t3_std = dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["std"])) row = {"dataset": cfg} for k in SPECIFIC_PARAMS: row[f"stage1_{k}"] = s1[k] row[f"tier3_{k}"] = t3[k] row[f"tier3_{k}_std"] = t3_std[k] param_rows.append(row) tier3_param_table = pd.DataFrame(param_rows).set_index("dataset") tier3_param_table.round(4)
Out[23]:
stage1_tilt_deg tier3_tilt_deg tier3_tilt_deg_std stage1_offset_y_mm tier3_offset_y_mm tier3_offset_y_mm_std stage1_offset_z_mm tier3_offset_z_mm tier3_offset_z_mm_std
dataset
rgs000_0 0.1331 0.1290 0.0671 -0.5508 -0.5494 0.0052 0.7377 0.7357 0.0031
rgs000_m4 4.1492 4.1520 0.0641 -0.4923 -0.4966 0.0037 1.2423 1.2065 0.0045
rgs000_p4 -3.7552 -3.7533 0.0705 -0.5718 -0.5743 0.0052 0.3099 0.3479 0.0041
rgs180_0 180.2350 180.2389 0.0641 -0.5117 -0.5092 0.0045 -0.4518 -0.4827 0.0040
rgs180_m4 184.1997 184.2012 0.0602 -0.5516 -0.5524 0.0037 -0.0247 -0.0347 0.0043
rgs180_p4 176.2091 176.2084 0.0645 -0.4656 -0.4682 0.0043 -0.9518 -0.9110 0.0049
In [24]:
Copied!
# RMSE across every stage of the alternating strategy, per dataset
rmse_tier3 = {cfg: float(np.sqrt(tier3_results[cfg]["cost_after"])) for cfg in CONFIGS}

rmse_all_stages = pd.DataFrame({
    "stage1_independent": {cfg: stage1_fits[cfg]["rmse_mm"] for cfg in CONFIGS},
    "tier1_forced": rmse_tier1,
    "tier1+2_forced": rmse_tier12,
    "tier3_joint_final": rmse_tier3,
}).loc[CONFIGS]
rmse_all_stages["delta_stage1_to_tier3"] = rmse_all_stages["tier3_joint_final"] - rmse_all_stages["stage1_independent"]
rmse_all_stages.round(4)
# RMSE across every stage of the alternating strategy, per dataset rmse_tier3 = {cfg: float(np.sqrt(tier3_results[cfg]["cost_after"])) for cfg in CONFIGS} rmse_all_stages = pd.DataFrame({ "stage1_independent": {cfg: stage1_fits[cfg]["rmse_mm"] for cfg in CONFIGS}, "tier1_forced": rmse_tier1, "tier1+2_forced": rmse_tier12, "tier3_joint_final": rmse_tier3, }).loc[CONFIGS] rmse_all_stages["delta_stage1_to_tier3"] = rmse_all_stages["tier3_joint_final"] - rmse_all_stages["stage1_independent"] rmse_all_stages.round(4)
Out[24]:
stage1_independent tier1_forced tier1+2_forced tier3_joint_final delta_stage1_to_tier3
rgs000_0 0.3563 0.3509 0.3508 0.3508 -0.0055
rgs000_m4 0.3521 0.3501 0.3520 0.3501 -0.0020
rgs000_p4 0.3423 0.3417 0.3439 0.3418 -0.0005
rgs180_0 0.4180 0.4089 0.4101 0.4089 -0.0091
rgs180_m4 0.3656 0.3637 0.3639 0.3637 -0.0019
rgs180_p4 0.3699 0.3674 0.3697 0.3674 -0.0025
In [25]:
Copied!
# Residual histograms at the final Tier-1+2+3 joint parameters, all 6 datasets
fig, axes = plt.subplots(2, 6, figsize=(20, 6), sharex="row")
for col, cfg in enumerate(CONFIGS):
    fixed_final = dict(fixed_tier12[cfg])
    fixed_final.update(dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["theta"])))
    pred = predict_centroids_full(dfs[cfg]["y_nisp"], dfs[cfg]["z_nisp"], dfs[cfg]["wavelength"],
                                   np.array([]), [], model, fixed=fixed_final)
    r_y = pred[0] - dfs[cfg]["cent_y"].values
    r_z = pred[1] - dfs[cfg]["cent_z"].values
    axes[0, col].hist(r_y, bins=40, color="tab:blue")
    axes[0, col].set_title(cfg)
    axes[1, col].hist(r_z, bins=40, color="tab:orange")
axes[0, 0].set_ylabel("r_y [mm]")
axes[1, 0].set_ylabel("r_z [mm]")
fig.suptitle("Residuals at the final joint fit (Tier 1 + Tier 2 + Tier 3), all 6 datasets")
plt.tight_layout()
plt.show()
# Residual histograms at the final Tier-1+2+3 joint parameters, all 6 datasets fig, axes = plt.subplots(2, 6, figsize=(20, 6), sharex="row") for col, cfg in enumerate(CONFIGS): fixed_final = dict(fixed_tier12[cfg]) fixed_final.update(dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["theta"]))) pred = predict_centroids_full(dfs[cfg]["y_nisp"], dfs[cfg]["z_nisp"], dfs[cfg]["wavelength"], np.array([]), [], model, fixed=fixed_final) r_y = pred[0] - dfs[cfg]["cent_y"].values r_z = pred[1] - dfs[cfg]["cent_z"].values axes[0, col].hist(r_y, bins=40, color="tab:blue") axes[0, col].set_title(cfg) axes[1, col].hist(r_z, bins=40, color="tab:orange") axes[0, 0].set_ylabel("r_y [mm]") axes[1, 0].set_ylabel("r_z [mm]") fig.suptitle("Residuals at the final joint fit (Tier 1 + Tier 2 + Tier 3), all 6 datasets") plt.tight_layout() plt.show()
No description has been provided for this image

Convergence check: would another alternation pass move Tier 1?¶

The deck notes "Stages 2 and 3 can be iterated until convergence before applying stage 4." One explicit extra pass, not a silent loop: re-fit coll_f/cam_f (Tier 1) with Tier 2 and this section's freshly re-fit Tier 3 values as the new fixed baseline (instead of Stage 1's), and check whether the result moves outside Section 1's bootstrap std.

In [26]:
Copied!
fixed_tier1_iter2 = {}
for cfg in CONFIGS:
    p = dict(fixed_tier12[cfg])  # A_deg/rho (tier2 instance), material_n0 nominal
    p.update(dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["theta"])))  # tilt/offsets now Tier-3 refit
    fixed_tier1_iter2[cfg] = p

result_tier1_iter2 = minimize(cost_joint, theta_star_tier1, args=(dfs, GLOBAL_PARAMS, fixed_tier1_iter2, model),
                               method="Nelder-Mead",
                               options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True})
shift = result_tier1_iter2.x - theta_star_tier1
shift_in_std = shift / std_tier1

print(f"Tier 1 re-fit with Tier-2/3-updated baseline: {dict(zip(GLOBAL_PARAMS, result_tier1_iter2.x))}")
print(f"original Section-1 result:                     {dict(zip(GLOBAL_PARAMS, theta_star_tier1))}")
print(f"shift: {dict(zip(GLOBAL_PARAMS, shift))}")
print(f"shift in bootstrap-std units: {dict(zip(GLOBAL_PARAMS, shift_in_std.round(3)))}")
print(f"\n{'Converged: shift << 1 bootstrap std, one alternation pass is sufficient.' if np.all(np.abs(shift_in_std) < 0.5) else 'Shift is non-negligible relative to bootstrap std -- would warrant another alternation pass.'}")
fixed_tier1_iter2 = {} for cfg in CONFIGS: p = dict(fixed_tier12[cfg]) # A_deg/rho (tier2 instance), material_n0 nominal p.update(dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["theta"]))) # tilt/offsets now Tier-3 refit fixed_tier1_iter2[cfg] = p result_tier1_iter2 = minimize(cost_joint, theta_star_tier1, args=(dfs, GLOBAL_PARAMS, fixed_tier1_iter2, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-13, "adaptive": True}) shift = result_tier1_iter2.x - theta_star_tier1 shift_in_std = shift / std_tier1 print(f"Tier 1 re-fit with Tier-2/3-updated baseline: {dict(zip(GLOBAL_PARAMS, result_tier1_iter2.x))}") print(f"original Section-1 result: {dict(zip(GLOBAL_PARAMS, theta_star_tier1))}") print(f"shift: {dict(zip(GLOBAL_PARAMS, shift))}") print(f"shift in bootstrap-std units: {dict(zip(GLOBAL_PARAMS, shift_in_std.round(3)))}") print(f"\n{'Converged: shift << 1 bootstrap std, one alternation pass is sufficient.' if np.all(np.abs(shift_in_std) < 0.5) else 'Shift is non-negligible relative to bootstrap std -- would warrant another alternation pass.'}")
Tier 1 re-fit with Tier-2/3-updated baseline: {'coll_f': np.float64(1.9992726265644216), 'cam_f': np.float64(1.0004158466217676)}
original Section-1 result:                     {'coll_f': np.float64(1.9992778575841643), 'cam_f': np.float64(1.0004149832063765)}
shift: {'coll_f': np.float64(-5.231019742701903e-06), 'cam_f': np.float64(8.634153911835085e-07)}
shift in bootstrap-std units: {'coll_f': np.float64(-0.006), 'cam_f': np.float64(0.002)}

Converged: shift << 1 bootstrap std, one alternation pass is sufficient.
In [27]:
Copied!
def write_tier3_toml(cfg, result, fixed):
    theta, std = result["theta"], result["std"]
    result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(SPECIFIC_PARAMS, theta))
    std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(SPECIFIC_PARAMS, std))

    text = f'''# Stage 4 Phase 3 -- Tier 3: per-dataset re-fit of tilt_deg/offset_y_mm/
# offset_z_mm for {cfg}, with Tier 1 (coll_f/cam_f) and Tier 2 (A_deg/rho,
# this dataset's grism instance) held fixed. Generated by
# notebooks/4.3-Multi_Dataset_Fitting.ipynb. Do not hand-edit; re-run the
# notebook and regenerate this file instead.

[base_config]
path = "stage1_instrument.toml"

[tier1]
path = "joint_global_shared.toml"

[tier2]
path = "joint_instance_shared_{instance_of(cfg)}.toml"

[fit]
dataset = "{cfg}"
method = "Nelder-Mead"
free_parameters = {SPECIFIC_PARAMS}
cost_before_mm2 = {result["cost_before"]:.6f}
cost_after_mm2 = {result["cost_after"]:.6f}
rmse_mm = {np.sqrt(result["cost_after"]):.6f}

[fit.result]
{result_lines}
{std_lines}

[fit.fixed]
coll_f = {fixed["coll_f"]:.6f}
cam_f = {fixed["cam_f"]:.6f}
A_deg = {fixed["A_deg"]:.6f}
rho = {fixed["rho"]:.6f}
material_n0 = {fixed["material_n0"]:.6f}
material_k = {base_config["material"]["k"]}
'''
    path = MODELS_DIR / f"joint_specific_fit_{cfg}.toml"
    path.write_text(text)
    print(f"wrote {path}")
    return path


for cfg in CONFIGS:
    write_tier3_toml(cfg, tier3_results[cfg], fixed_tier12[cfg])
def write_tier3_toml(cfg, result, fixed): theta, std = result["theta"], result["std"] result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(SPECIFIC_PARAMS, theta)) std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(SPECIFIC_PARAMS, std)) text = f'''# Stage 4 Phase 3 -- Tier 3: per-dataset re-fit of tilt_deg/offset_y_mm/ # offset_z_mm for {cfg}, with Tier 1 (coll_f/cam_f) and Tier 2 (A_deg/rho, # this dataset's grism instance) held fixed. Generated by # notebooks/4.3-Multi_Dataset_Fitting.ipynb. Do not hand-edit; re-run the # notebook and regenerate this file instead. [base_config] path = "stage1_instrument.toml" [tier1] path = "joint_global_shared.toml" [tier2] path = "joint_instance_shared_{instance_of(cfg)}.toml" [fit] dataset = "{cfg}" method = "Nelder-Mead" free_parameters = {SPECIFIC_PARAMS} cost_before_mm2 = {result["cost_before"]:.6f} cost_after_mm2 = {result["cost_after"]:.6f} rmse_mm = {np.sqrt(result["cost_after"]):.6f} [fit.result] {result_lines} {std_lines} [fit.fixed] coll_f = {fixed["coll_f"]:.6f} cam_f = {fixed["cam_f"]:.6f} A_deg = {fixed["A_deg"]:.6f} rho = {fixed["rho"]:.6f} material_n0 = {fixed["material_n0"]:.6f} material_k = {base_config["material"]["k"]} ''' path = MODELS_DIR / f"joint_specific_fit_{cfg}.toml" path.write_text(text) print(f"wrote {path}") return path for cfg in CONFIGS: write_tier3_toml(cfg, tier3_results[cfg], fixed_tier12[cfg])
wrote ../models/joint_specific_fit_rgs000_0.toml
wrote ../models/joint_specific_fit_rgs000_m4.toml
wrote ../models/joint_specific_fit_rgs000_p4.toml
wrote ../models/joint_specific_fit_rgs180_0.toml
wrote ../models/joint_specific_fit_rgs180_m4.toml
wrote ../models/joint_specific_fit_rgs180_p4.toml
In [28]:
Copied!
EXPERIMENT_NAME_TIER3 = "stage3"  # deck-mandated name (Task 3 step 3)
if mlflow.get_experiment_by_name(EXPERIMENT_NAME_TIER3) is None:
    mlflow.create_experiment(EXPERIMENT_NAME_TIER3, artifact_location=f"file:{(REPO_ROOT / 'mlruns').resolve()}")
mlflow.set_experiment(EXPERIMENT_NAME_TIER3)

for cfg in CONFIGS:
    result = tier3_results[cfg]
    fixed = fixed_tier12[cfg]
    with mlflow.start_run(run_name=f"{cfg}_specific_refit"):
        mlflow.log_param("dataset", cfg)
        mlflow.log_param("instance", instance_of(cfg))
        mlflow.log_param("free_parameters", SPECIFIC_PARAMS)
        for k in ["coll_f", "cam_f", "A_deg", "rho", "material_n0"]:
            mlflow.log_param(k, fixed[k])
        for name, v, s in zip(SPECIFIC_PARAMS, result["theta"], result["std"]):
            mlflow.log_param(name, v)
            mlflow.log_metric(f"{name}_bootstrap_std", s)
        mlflow.log_metric("rmse_stage1", stage1_fits[cfg]["rmse_mm"])
        mlflow.log_metric("rmse_tier3", rmse_tier3[cfg])
        mlflow.log_metric("delta_rmse", rmse_tier3[cfg] - stage1_fits[cfg]["rmse_mm"])

print(f"logged {len(CONFIGS)} runs to the '{EXPERIMENT_NAME_TIER3}' MLflow experiment")
EXPERIMENT_NAME_TIER3 = "stage3" # deck-mandated name (Task 3 step 3) if mlflow.get_experiment_by_name(EXPERIMENT_NAME_TIER3) is None: mlflow.create_experiment(EXPERIMENT_NAME_TIER3, artifact_location=f"file:{(REPO_ROOT / 'mlruns').resolve()}") mlflow.set_experiment(EXPERIMENT_NAME_TIER3) for cfg in CONFIGS: result = tier3_results[cfg] fixed = fixed_tier12[cfg] with mlflow.start_run(run_name=f"{cfg}_specific_refit"): mlflow.log_param("dataset", cfg) mlflow.log_param("instance", instance_of(cfg)) mlflow.log_param("free_parameters", SPECIFIC_PARAMS) for k in ["coll_f", "cam_f", "A_deg", "rho", "material_n0"]: mlflow.log_param(k, fixed[k]) for name, v, s in zip(SPECIFIC_PARAMS, result["theta"], result["std"]): mlflow.log_param(name, v) mlflow.log_metric(f"{name}_bootstrap_std", s) mlflow.log_metric("rmse_stage1", stage1_fits[cfg]["rmse_mm"]) mlflow.log_metric("rmse_tier3", rmse_tier3[cfg]) mlflow.log_metric("delta_rmse", rmse_tier3[cfg] - stage1_fits[cfg]["rmse_mm"]) print(f"logged {len(CONFIGS)} runs to the '{EXPERIMENT_NAME_TIER3}' MLflow experiment")
logged 6 runs to the 'stage3' MLflow experiment

Recap — Section 3¶

  • Result: RMSE improved over Stage 1 for all 6 datasets, exactly the deck's "should improve slightly" expectation — rgs180_0 improved most (0.4180 → 0.4089 mm, Δ −0.0091), rgs000_p4 least (0.3423 → 0.3418 mm, Δ −0.0005). Confirms the joint strategy is a net win, not just a redistribution: the same 3 datasets that got temporarily worse in Section 2's Tier-1+2-forced state recover past Stage 1 once Tier 3 is re-optimized.
  • Convergence check: essentially converged after one pass — re-fitting Tier 1 with the freshly-updated Tier 2/3 baseline shifts coll_f/cam_f by only −0.006/+0.002 bootstrap-std units. No further alternation needed.
  • tilt_deg and offset_y_mm barely moved from Stage 1 (≤0.06° and ≤0.005mm typically); offset_z_mm moved the most (up to ±0.04mm, e.g. rgs000_m4: 1.2423→1.2065, rgs180_p4: −0.9518→−0.9110) — the parameter absorbing most of the adjustment from per-dataset-optimal to instance-shared A_deg/rho, consistent with offset_z_mm's role in the tilt+180° degeneracy discussion throughout this notebook.
  • Written to 6 models/joint_specific_fit_<cfg>.toml files; 6 runs logged to MLflow experiment "stage3".

Stopping here for review before Section 4 (ML residual correction on top of the final joint physical fit, reusing Phase 2's independent MLP(y)+MLP(z) default).

7. Section 4 — ML Residual Correction (Deck's Stage 4)¶

With all physical parameters now fixed (Tier 1 + Tier 2 + Tier 3, the final joint fit from Section 3), compute residuals per dataset and train an ML correction on top — reusing 4.2-Per_Dataset_Pipeline.ipynb's pipeline essentially unchanged, but only the independent MLP(y)+MLP(z) candidate (Phase 2's carried-forward default; joint MLP(y,z) tied with no consistent winner across 6 datasets, so not re-run here). MLP hyperparameters are read back from Phase 1's residual_correction MLflow experiment, same as 4.2 did, then retrained fresh on each dataset's own train split.

In [29]:
Copied!
import ast

from sklearn.model_selection import GroupShuffleSplit
from sklearn.preprocessing import StandardScaler
from sklearn.neural_network import MLPRegressor
from sklearn.metrics import mean_squared_error

residual_exp = mlflow.get_experiment_by_name("residual_correction")
assert residual_exp is not None, "run 4.1-ML_Comparison.ipynb first to populate this experiment"
runs = mlflow.search_runs(experiment_ids=[residual_exp.experiment_id])


def best_mlp_params(runs, axis, metric_col):
    sub = runs[(runs["params.model"] == "MLP") & (runs["params.axis"] == axis)]
    best = sub.loc[sub[metric_col].astype(float).idxmin()]
    return {
        "hidden_layer_sizes": ast.literal_eval(best["params.hidden_layer_sizes"]),
        "activation": best["params.activation"],
        "alpha": float(best["params.alpha"]),
        "random_state": int(best["params.random_state"]),
        "max_iter": int(best["params.max_iter"]),
        "early_stopping": best["params.early_stopping"] == "True",
    }


mlp_y_params = best_mlp_params(runs, "y", "metrics.test_rmse")
mlp_z_params = best_mlp_params(runs, "z", "metrics.test_rmse")
print("MLP(y):", mlp_y_params)
print("MLP(z):", mlp_z_params)
import ast from sklearn.model_selection import GroupShuffleSplit from sklearn.preprocessing import StandardScaler from sklearn.neural_network import MLPRegressor from sklearn.metrics import mean_squared_error residual_exp = mlflow.get_experiment_by_name("residual_correction") assert residual_exp is not None, "run 4.1-ML_Comparison.ipynb first to populate this experiment" runs = mlflow.search_runs(experiment_ids=[residual_exp.experiment_id]) def best_mlp_params(runs, axis, metric_col): sub = runs[(runs["params.model"] == "MLP") & (runs["params.axis"] == axis)] best = sub.loc[sub[metric_col].astype(float).idxmin()] return { "hidden_layer_sizes": ast.literal_eval(best["params.hidden_layer_sizes"]), "activation": best["params.activation"], "alpha": float(best["params.alpha"]), "random_state": int(best["params.random_state"]), "max_iter": int(best["params.max_iter"]), "early_stopping": best["params.early_stopping"] == "True", } mlp_y_params = best_mlp_params(runs, "y", "metrics.test_rmse") mlp_z_params = best_mlp_params(runs, "z", "metrics.test_rmse") print("MLP(y):", mlp_y_params) print("MLP(z):", mlp_z_params)
MLP(y): {'hidden_layer_sizes': (64, 32), 'activation': 'relu', 'alpha': 0.0001, 'random_state': 42, 'max_iter': 2000, 'early_stopping': True}
MLP(z): {'hidden_layer_sizes': (64, 64), 'activation': 'relu', 'alpha': 0.001, 'random_state': 42, 'max_iter': 2000, 'early_stopping': True}
In [30]:
Copied!
def final_fixed_for(cfg):
    """Complete Tier-1+2+3 parameter dict for `cfg`: coll_f/cam_f (Tier 1),
    A_deg/rho (Tier 2, this dataset's grism instance), material_n0 at
    nominal, tilt_deg/offset_y_mm/offset_z_mm (Tier 3, this section's
    per-dataset re-fit)."""
    p = dict(fixed_tier12[cfg])
    p.update(dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["theta"])))
    return p


fixed_final_by_cfg = {cfg: final_fixed_for(cfg) for cfg in CONFIGS}
def final_fixed_for(cfg): """Complete Tier-1+2+3 parameter dict for `cfg`: coll_f/cam_f (Tier 1), A_deg/rho (Tier 2, this dataset's grism instance), material_n0 at nominal, tilt_deg/offset_y_mm/offset_z_mm (Tier 3, this section's per-dataset re-fit).""" p = dict(fixed_tier12[cfg]) p.update(dict(zip(SPECIFIC_PARAMS, tier3_results[cfg]["theta"]))) return p fixed_final_by_cfg = {cfg: final_fixed_for(cfg) for cfg in CONFIGS}
In [31]:
Copied!
def run_stage4_pipeline(cfg):
    df = dfs[cfg]
    fixed = fixed_final_by_cfg[cfg]
    pred = predict_centroids_full(df["y_nisp"], df["z_nisp"], df["wavelength"], np.array([]), [], model, fixed=fixed)
    d = df.assign(r_y=pred[0] - df["cent_y"].values, r_z=pred[1] - df["cent_z"].values)

    X = d[["y_nisp", "z_nisp", "wavelength"]].values
    groups = d["spectra_id"].values
    gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=RNG_SEED)
    train_idx, test_idx = next(gss.split(X, groups=groups))

    scaler = StandardScaler().fit(X[train_idx])
    X_train_s, X_test_s = scaler.transform(X[train_idx]), scaler.transform(X[test_idx])
    r_y_train, r_z_train = d["r_y"].values[train_idx], d["r_z"].values[train_idx]
    r_y_test, r_z_test = d["r_y"].values[test_idx], d["r_z"].values[test_idx]

    physical_rmse_y = np.sqrt(mean_squared_error(r_y_test, np.zeros_like(r_y_test)))
    physical_rmse_z = np.sqrt(mean_squared_error(r_z_test, np.zeros_like(r_z_test)))
    physical_dist_test = np.mean(np.hypot(r_y_test, r_z_test))

    mlp_y = MLPRegressor(**mlp_y_params).fit(X_train_s, r_y_train)
    mlp_z = MLPRegressor(**mlp_z_params).fit(X_train_s, r_z_train)
    hyb_y = r_y_test - mlp_y.predict(X_test_s)
    hyb_z = r_z_test - mlp_z.predict(X_test_s)

    return {
        "cfg": cfg, "physical_rmse_y": physical_rmse_y, "physical_rmse_z": physical_rmse_z,
        "physical_dist_test": physical_dist_test,
        "hybrid_rmse_y": np.sqrt(mean_squared_error(r_y_test, mlp_y.predict(X_test_s))),
        "hybrid_rmse_z": np.sqrt(mean_squared_error(r_z_test, mlp_z.predict(X_test_s))),
        "hybrid_dist_test": np.mean(np.hypot(hyb_y, hyb_z)),
    }


stage4_results = {cfg: run_stage4_pipeline(cfg) for cfg in CONFIGS}
stage4_df = pd.DataFrame(stage4_results.values()).set_index("cfg").loc[CONFIGS]
stage4_df["improvement_pct"] = 100 * (1 - stage4_df["hybrid_dist_test"] / stage4_df["physical_dist_test"])
stage4_df.round(4)
def run_stage4_pipeline(cfg): df = dfs[cfg] fixed = fixed_final_by_cfg[cfg] pred = predict_centroids_full(df["y_nisp"], df["z_nisp"], df["wavelength"], np.array([]), [], model, fixed=fixed) d = df.assign(r_y=pred[0] - df["cent_y"].values, r_z=pred[1] - df["cent_z"].values) X = d[["y_nisp", "z_nisp", "wavelength"]].values groups = d["spectra_id"].values gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=RNG_SEED) train_idx, test_idx = next(gss.split(X, groups=groups)) scaler = StandardScaler().fit(X[train_idx]) X_train_s, X_test_s = scaler.transform(X[train_idx]), scaler.transform(X[test_idx]) r_y_train, r_z_train = d["r_y"].values[train_idx], d["r_z"].values[train_idx] r_y_test, r_z_test = d["r_y"].values[test_idx], d["r_z"].values[test_idx] physical_rmse_y = np.sqrt(mean_squared_error(r_y_test, np.zeros_like(r_y_test))) physical_rmse_z = np.sqrt(mean_squared_error(r_z_test, np.zeros_like(r_z_test))) physical_dist_test = np.mean(np.hypot(r_y_test, r_z_test)) mlp_y = MLPRegressor(**mlp_y_params).fit(X_train_s, r_y_train) mlp_z = MLPRegressor(**mlp_z_params).fit(X_train_s, r_z_train) hyb_y = r_y_test - mlp_y.predict(X_test_s) hyb_z = r_z_test - mlp_z.predict(X_test_s) return { "cfg": cfg, "physical_rmse_y": physical_rmse_y, "physical_rmse_z": physical_rmse_z, "physical_dist_test": physical_dist_test, "hybrid_rmse_y": np.sqrt(mean_squared_error(r_y_test, mlp_y.predict(X_test_s))), "hybrid_rmse_z": np.sqrt(mean_squared_error(r_z_test, mlp_z.predict(X_test_s))), "hybrid_dist_test": np.mean(np.hypot(hyb_y, hyb_z)), } stage4_results = {cfg: run_stage4_pipeline(cfg) for cfg in CONFIGS} stage4_df = pd.DataFrame(stage4_results.values()).set_index("cfg").loc[CONFIGS] stage4_df["improvement_pct"] = 100 * (1 - stage4_df["hybrid_dist_test"] / stage4_df["physical_dist_test"]) stage4_df.round(4)
Out[31]:
physical_rmse_y physical_rmse_z physical_dist_test hybrid_rmse_y hybrid_rmse_z hybrid_dist_test improvement_pct
cfg
rgs000_0 0.1592 0.2980 0.3019 0.0176 0.0636 0.0551 81.7365
rgs000_m4 0.2266 0.2641 0.3022 0.0141 0.0616 0.0532 82.4088
rgs000_p4 0.1897 0.3445 0.3458 0.0174 0.0869 0.0699 79.7705
rgs180_0 0.2013 0.2922 0.3224 0.0166 0.0797 0.0627 80.5528
rgs180_m4 0.1840 0.3168 0.3394 0.0215 0.0743 0.0638 81.1922
rgs180_p4 0.2052 0.2872 0.3181 0.0239 0.0831 0.0708 77.7567
In [32]:
Copied!
EXPERIMENT_NAME_TIER4 = "stage4"  # deck-mandated name (Task 3 step 4)
if mlflow.get_experiment_by_name(EXPERIMENT_NAME_TIER4) is None:
    mlflow.create_experiment(EXPERIMENT_NAME_TIER4, artifact_location=f"file:{(REPO_ROOT / 'mlruns').resolve()}")
mlflow.set_experiment(EXPERIMENT_NAME_TIER4)

for cfg in CONFIGS:
    res = stage4_results[cfg]
    with mlflow.start_run(run_name=f"{cfg}_hybrid"):
        mlflow.log_param("dataset", cfg)
        mlflow.log_param("instance", instance_of(cfg))
        for k, v in fixed_final_by_cfg[cfg].items():
            mlflow.log_param(k, v)
        for axis, p in [("y", mlp_y_params), ("z", mlp_z_params)]:
            for k, v in p.items():
                mlflow.log_param(f"mlp_{axis}_{k}", v)
        mlflow.log_metric("physical_dist_test", res["physical_dist_test"])
        mlflow.log_metric("hybrid_dist_test", res["hybrid_dist_test"])
        mlflow.log_metric("hybrid_rmse_y", res["hybrid_rmse_y"])
        mlflow.log_metric("hybrid_rmse_z", res["hybrid_rmse_z"])
        mlflow.log_metric("improvement_pct", 100 * (1 - res["hybrid_dist_test"] / res["physical_dist_test"]))

print(f"logged {len(CONFIGS)} runs to the '{EXPERIMENT_NAME_TIER4}' MLflow experiment")
EXPERIMENT_NAME_TIER4 = "stage4" # deck-mandated name (Task 3 step 4) if mlflow.get_experiment_by_name(EXPERIMENT_NAME_TIER4) is None: mlflow.create_experiment(EXPERIMENT_NAME_TIER4, artifact_location=f"file:{(REPO_ROOT / 'mlruns').resolve()}") mlflow.set_experiment(EXPERIMENT_NAME_TIER4) for cfg in CONFIGS: res = stage4_results[cfg] with mlflow.start_run(run_name=f"{cfg}_hybrid"): mlflow.log_param("dataset", cfg) mlflow.log_param("instance", instance_of(cfg)) for k, v in fixed_final_by_cfg[cfg].items(): mlflow.log_param(k, v) for axis, p in [("y", mlp_y_params), ("z", mlp_z_params)]: for k, v in p.items(): mlflow.log_param(f"mlp_{axis}_{k}", v) mlflow.log_metric("physical_dist_test", res["physical_dist_test"]) mlflow.log_metric("hybrid_dist_test", res["hybrid_dist_test"]) mlflow.log_metric("hybrid_rmse_y", res["hybrid_rmse_y"]) mlflow.log_metric("hybrid_rmse_z", res["hybrid_rmse_z"]) mlflow.log_metric("improvement_pct", 100 * (1 - res["hybrid_dist_test"] / res["physical_dist_test"])) print(f"logged {len(CONFIGS)} runs to the '{EXPERIMENT_NAME_TIER4}' MLflow experiment")
logged 6 runs to the 'stage4' MLflow experiment
In [33]:
Copied!
fig, ax = plt.subplots(figsize=(9, 4.5))
x = np.arange(len(CONFIGS))
width = 0.35
ax.bar(x - width/2, stage4_df["physical_dist_test"], width, label="physical-only (Tier 1+2+3 joint fit)")
ax.bar(x + width/2, stage4_df["hybrid_dist_test"], width, label="hybrid, MLP(y)+MLP(z)")
ax.set_xticks(x)
ax.set_xticklabels(CONFIGS, rotation=30, ha="right")
ax.set_ylabel("combined point distance [mm]")
ax.legend()
ax.set_title("Physical-only (final joint fit) vs. hybrid, per dataset")
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(9, 4.5)) x = np.arange(len(CONFIGS)) width = 0.35 ax.bar(x - width/2, stage4_df["physical_dist_test"], width, label="physical-only (Tier 1+2+3 joint fit)") ax.bar(x + width/2, stage4_df["hybrid_dist_test"], width, label="hybrid, MLP(y)+MLP(z)") ax.set_xticks(x) ax.set_xticklabels(CONFIGS, rotation=30, ha="right") ax.set_ylabel("combined point distance [mm]") ax.legend() ax.set_title("Physical-only (final joint fit) vs. hybrid, per dataset") plt.tight_layout() plt.show()
No description has been provided for this image

Recap — Section 4¶

  • ML residual correction trained on top of the final Tier-1+2+3 joint physical fit, independent MLP(y)+MLP(z) only (Phase 2's default).
  • Result: physical-only test-set distance 0.302–0.346mm across the 6 datasets, hybrid 0.053–0.071mm — 77.8–82.4% improvement, closely matching Phase 2's per-dataset numbers (77–83%) as a consistency check: the joint physical fit and Stage 1's independent fit leave a similarly shaped residual for the MLP to correct, as expected given how close the final joint parameters landed to Stage 1's own per-dataset values.
  • 6 runs logged to MLflow experiment "stage4".

Stopping here for review before Section 5 (final comparison: RMSE per dataset × {independent, joint, joint+ML} strategy, plus the write-up of which datasets/parameters were most affected by joint fitting).

8. Section 5 — Final Comparison (Task 3 Deliverable)¶

Per the deck's Task 3, step 5: "Build a comparison table: RMSE per dataset × strategy (independent / joint / joint+ML)." Per the model-comparison-report skill, all three strategies are evaluated with the same metric, computed the same way: the combined point distance mean(hypot(r_y, r_z)) on the same held-out test split used in Section 4 (deterministic given RNG_SEED, so the "independent" and "joint" physical-only columns are recomputed on that split rather than reusing the full-dataset RMSE from Sections 1-3 — those used the full dataset for the alternating-strategy checkpoints, which is right for tracking convergence, but the ML comparison needs a held-out set to be a fair comparison at all, so this final table uses the test split throughout for consistency).

In [34]:
Copied!
def independent_physical_test_metric(cfg):
    """Stage-1 (independent, nominal coll_f/cam_f/A_deg/material_n0) physical
    fit, evaluated on the same held-out test split Section 4 used."""
    df = dfs[cfg]
    X = df[["y_nisp", "z_nisp", "wavelength"]].values
    groups = df["spectra_id"].values
    gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=RNG_SEED)
    _, test_idx = next(gss.split(X, groups=groups))

    free_names = stage1_fits[cfg]["free_names"]
    theta = stage1_fits[cfg]["theta"]
    pred = predict_centroids_full(df["y_nisp"].values[test_idx], df["z_nisp"].values[test_idx],
                                   df["wavelength"].values[test_idx], theta, free_names, model)
    r_y = pred[0] - df["cent_y"].values[test_idx]
    r_z = pred[1] - df["cent_z"].values[test_idx]
    return float(np.mean(np.hypot(r_y, r_z)))


independent_test_dist = {cfg: independent_physical_test_metric(cfg) for cfg in CONFIGS}

final_comparison = pd.DataFrame({
    "independent_mm": independent_test_dist,
    "joint_mm": {cfg: stage4_results[cfg]["physical_dist_test"] for cfg in CONFIGS},
    "joint_ml_mm": {cfg: stage4_results[cfg]["hybrid_dist_test"] for cfg in CONFIGS},
}).loc[CONFIGS]
final_comparison["joint_vs_independent_pct"] = 100 * (1 - final_comparison["joint_mm"] / final_comparison["independent_mm"])
final_comparison["joint_ml_vs_independent_pct"] = 100 * (1 - final_comparison["joint_ml_mm"] / final_comparison["independent_mm"])
final_comparison.round(4)
def independent_physical_test_metric(cfg): """Stage-1 (independent, nominal coll_f/cam_f/A_deg/material_n0) physical fit, evaluated on the same held-out test split Section 4 used.""" df = dfs[cfg] X = df[["y_nisp", "z_nisp", "wavelength"]].values groups = df["spectra_id"].values gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=RNG_SEED) _, test_idx = next(gss.split(X, groups=groups)) free_names = stage1_fits[cfg]["free_names"] theta = stage1_fits[cfg]["theta"] pred = predict_centroids_full(df["y_nisp"].values[test_idx], df["z_nisp"].values[test_idx], df["wavelength"].values[test_idx], theta, free_names, model) r_y = pred[0] - df["cent_y"].values[test_idx] r_z = pred[1] - df["cent_z"].values[test_idx] return float(np.mean(np.hypot(r_y, r_z))) independent_test_dist = {cfg: independent_physical_test_metric(cfg) for cfg in CONFIGS} final_comparison = pd.DataFrame({ "independent_mm": independent_test_dist, "joint_mm": {cfg: stage4_results[cfg]["physical_dist_test"] for cfg in CONFIGS}, "joint_ml_mm": {cfg: stage4_results[cfg]["hybrid_dist_test"] for cfg in CONFIGS}, }).loc[CONFIGS] final_comparison["joint_vs_independent_pct"] = 100 * (1 - final_comparison["joint_mm"] / final_comparison["independent_mm"]) final_comparison["joint_ml_vs_independent_pct"] = 100 * (1 - final_comparison["joint_ml_mm"] / final_comparison["independent_mm"]) final_comparison.round(4)
Out[34]:
independent_mm joint_mm joint_ml_mm joint_vs_independent_pct joint_ml_vs_independent_pct
rgs000_0 0.3078 0.3019 0.0551 1.9265 82.0883
rgs000_m4 0.2928 0.3022 0.0532 -3.2026 81.8455
rgs000_p4 0.3485 0.3458 0.0699 0.7876 79.9298
rgs180_0 0.3283 0.3224 0.0627 1.7872 80.9004
rgs180_m4 0.3500 0.3394 0.0638 3.0424 81.7644
rgs180_p4 0.3132 0.3181 0.0708 -1.5669 77.4082
In [35]:
Copied!
fig, ax = plt.subplots(figsize=(9, 4.5))
x = np.arange(len(CONFIGS))
width = 0.25
ax.bar(x - width, final_comparison["independent_mm"], width, label="independent (Stage 1)")
ax.bar(x, final_comparison["joint_mm"], width, label="joint (Tier 1+2+3)")
ax.bar(x + width, final_comparison["joint_ml_mm"], width, label="joint + ML")
ax.set_xticks(x)
ax.set_xticklabels(CONFIGS, rotation=30, ha="right")
ax.set_ylabel("combined point distance, test split [mm]")
ax.legend()
ax.set_title("RMSE per dataset x strategy: independent / joint / joint+ML")
plt.tight_layout()
plt.show()

print(f"joint vs. independent: benefits most = {final_comparison['joint_vs_independent_pct'].idxmax()} "
      f"({final_comparison['joint_vs_independent_pct'].max():+.2f}%), "
      f"least = {final_comparison['joint_vs_independent_pct'].idxmin()} "
      f"({final_comparison['joint_vs_independent_pct'].min():+.2f}%)")
print(f"mean joint improvement over independent: {final_comparison['joint_vs_independent_pct'].mean():+.2f}%")
fig, ax = plt.subplots(figsize=(9, 4.5)) x = np.arange(len(CONFIGS)) width = 0.25 ax.bar(x - width, final_comparison["independent_mm"], width, label="independent (Stage 1)") ax.bar(x, final_comparison["joint_mm"], width, label="joint (Tier 1+2+3)") ax.bar(x + width, final_comparison["joint_ml_mm"], width, label="joint + ML") ax.set_xticks(x) ax.set_xticklabels(CONFIGS, rotation=30, ha="right") ax.set_ylabel("combined point distance, test split [mm]") ax.legend() ax.set_title("RMSE per dataset x strategy: independent / joint / joint+ML") plt.tight_layout() plt.show() print(f"joint vs. independent: benefits most = {final_comparison['joint_vs_independent_pct'].idxmax()} " f"({final_comparison['joint_vs_independent_pct'].max():+.2f}%), " f"least = {final_comparison['joint_vs_independent_pct'].idxmin()} " f"({final_comparison['joint_vs_independent_pct'].min():+.2f}%)") print(f"mean joint improvement over independent: {final_comparison['joint_vs_independent_pct'].mean():+.2f}%")
No description has been provided for this image
joint vs. independent: benefits most = rgs180_m4 (+3.04%), least = rgs000_m4 (-3.20%)
mean joint improvement over independent: +0.46%

Key findings¶

  • Joint+ML dominates the physical-strategy choice. Adding the MLP residual correction improves 77.4–82.1% over independent-physical regardless of which physical strategy it's layered on (joint or independent) — on this dataset, the ML correction matters far more to final accuracy than whether the physical fit is joint or per-dataset.
  • On held-out data, joint-vs-independent physical accuracy is a wash, not a clear win — report this honestly. The full-dataset RMSE progression in Section 3 showed universal improvement (all 6 datasets), but that's the same data the fit was optimized against. On the held-out test split used here (the fair, apples-to-apples comparison with the ML step), the effect is small and mixed: mean +0.46%, ranging from rgs180_m4's best case (+3.04%) to rgs000_m4 and rgs180_p4 actually getting slightly worse (−3.20%, −1.57%). At this fit quality (~0.3mm RMSE on ~4600–6200 points), the joint-vs-independent physical accuracy difference is smaller than test-split sampling noise — the joint strategy's real value here is the parameter structure, not a demonstrated accuracy win (see Recommendation below).
  • Which parameters moved most across tiers: offset_z_mm moved the most in the Tier 3 re-fit (up to ±0.04mm, Section 3) — it's the parameter absorbing the adjustment from per-dataset-optimal to instance-shared A_deg/rho. coll_f/cam_f (Tier 1) and A_deg (Tier 2) barely moved from nominal at all (<0.1% relative) — the instrument's fixed optics and prism angle really do behave like design constants, not fitting artifacts. rho (Tier 2) landed close to Stage 1's per-dataset spread, as expected from Phase 2's own finding.
  • Tier-2 identifiability, resolved: the single-dataset A_deg↔ offset_z_mm degeneracy (~99%, 3-Intro_ML.ipynb) is broken by joint fitting across the 3 tilt geometries (→0.01–0.22). A different degeneracy, A_deg↔material_n0 (~95%), was found instead — resolved by fixing material_n0 at nominal, the same fixed-in-practice treatment Stage 3 originally gave A_deg alone.
  • A_deg landed close enough between the two grism instances (2.1435° vs. 2.1436°) that instance-level granularity may not have been necessary for this parameter at all — this fit can't distinguish "genuinely global" from "two instances manufactured this close," but it's a candidate worth testing directly in a later stage.

Caveats / failure modes¶

  • Unweighted per-dataset cost sum (Sections 1–2, per the deck's own pseudocode) despite n_points ranging 4577–6198 (~35% spread) — smaller datasets get outsized per-point leverage in the shared-parameter fits. Not corrected; a n_points-weighted variant is a documented, untried alternative.
  • coll_f/cam_f are 99.8% bootstrap-correlated (Section 1) — this dataset constrains their ratio far better than either value individually.
  • A_deg/rho retain residual correlation (0.98–0.99) even in the narrowed production Tier-2 fit (Section 2), smaller than the 3-way A_deg/material_n0/offset_z_mm degeneracy it replaced but not zero.
  • offset_z_mm's grism-identity split is still unexplained by the model, only worked around. It stays correctly classified as dataset-specific (not shared) because of the tilt+180° degeneracy in Grism.forward, but no parametrization here explains why it splits the way it does beyond "it absorbs whatever the tilt flip leaves behind" — a real physical effect (e.g. a mounting-orientation-dependent term) could resolve this in a future stage rather than treating it as 6 independent numbers forever.
  • Held-out sample size is modest (~900–1200 test rows per dataset, 20% of each), which is almost certainly why the joint-vs-independent test-split comparison above is noisy enough to flip sign — a caveat on trusting the exact per-dataset ranking, not just a general disclaimer.

On deviating from the deck's 2-tier split¶

The deck's Task 3 sketches one combined-cost stage (shared vs. specific). This notebook used a 3-tier hierarchy instead (global / per-grism-instance / per-dataset) because the instrument genuinely has that structure — two distinct physical grism assemblies, not one shared design remounted six times — and collapsing tiers 1 and 2 together would have forced A_deg and rho to either be six independent numbers (losing the instance-level physical constraint) or one number spanning both grisms (wrongly assuming rgs000/rgs180 are optically identical, which Stage 3 already showed they aren't quite). The parameter count ends up the same either way (24 total free values, same as Stage 1's 4×6), but the joint model expresses that count as physically meaningful groups — 2 bench constants, 2 grism-instance pairs, 6 per-acquisition alignments — rather than 6 independent fits that happen to agree in places. That's the "quality of analysis, not just low RMSE" the deck explicitly asks to be evaluated on.

Recommendation¶

Carry forward the 3-tier structure (joint_global_shared.toml, joint_instance_shared_{rgs000,rgs180}.toml, joint_specific_fit_<cfg>.toml) as this instrument's calibration model, not because it demonstrably reduces held-out RMSE (it doesn't, clearly — see above) but because it's the more honest physical model: it makes the shared-vs-specific structure explicit and testable rather than implicit in six independently-run fits, and Section 2's identifiability check is the kind of finding (the A_deg↔material_n0 degeneracy) that six separate per-dataset fits would never have surfaced. The ML residual correction (Section 4) remains the dominant source of accuracy improvement regardless of which physical strategy underlies it.

End of Phase 3. CLAUDE.md's Stage 4 status section update is left for explicit confirmation, per this project's pacing rule.

Previous Next

Built with MkDocs using a theme provided by Read the Docs.
dispers/dispcraft « Previous Next »