dispcraft
  • Home

User Guide

  • Getting Started
  • Using the Calibrated Models
  • Notebooks
    • Overview
    • 1. Subject & Instrument Model
    • 2. Data Introduction
    • 3. ML Introduction
      • 1. Setup and Imports
      • 2. Fitting the Physical Model (Exercise 1)
        • Composing the forward model for this ground-test setup
        • Parameter classification: shared vs. dataset-specific
        • Exercise 1 scope: which parameters to fit, which to fix
        • Extending Exercise 1: trying more free parameters
        • Recording the fit for traceability
      • 3. Independent Per-Dataset Fits (Exercise 2)
      • 4. Evaluating the Fits (Exercise 3)
        • Findings
      • 5. ML Residual Correction (Exercise 4)
      • 6. Hybrid Model and Comparison (Exercise 5)
        • Generalization test: physics from 's own fit, ML from
        • Model Comparison Table
        • Key findings
        • Failure mode
        • Recommendation
      • 7. Conclusion: Best Hybrid Model per Configuration
      • 8. Recap
    • 4.1 ML Model Comparison
    • 4.2 Per-Dataset Pipeline
    • 4.3 Multi-Dataset Joint Fitting
    • 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
  • 3. ML Introduction

Stage 3 — Fitting, Residuals, and Hybrid Models (3-Intro_ML)¶

Fit the physical dispersion model to ground-test data, evaluate the fit via residuals, learn the residual structure with ML, and combine physics + ML into a hybrid model. See 3-Intro_ML/index.html for scope.

Model target: (y_nisp, z_nisp, wavelength) -> (cent_y, cent_z).

1. Setup and Imports¶

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
from sklearn.model_selection import GroupShuffleSplit, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error

from dispcraft.calibration import cost, ground_test_model_from_config, predict_centroids
from dispcraft.measurement import load_spectra, median_per_spectrum

DATA_DIR = Path("..") / "data"
MODELS_DIR = Path("..") / "models"

# {rgs000, rgs180} x {0, m4, p4} -- the 6 datasets in scope for this stage
# (bgs000_0 is excluded, per 3-Intro_ML/index.html Exercise 3).
CONFIGS = ["rgs000_0", "rgs000_m4", "rgs000_p4", "rgs180_0", "rgs180_m4", "rgs180_p4"]

RNG_SEED = 42
import tomllib from pathlib import Path import numpy as np import pandas as pd import matplotlib.pyplot as plt from scipy.optimize import minimize from sklearn.model_selection import GroupShuffleSplit, cross_val_score from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_squared_error from dispcraft.calibration import cost, ground_test_model_from_config, predict_centroids from dispcraft.measurement import load_spectra, median_per_spectrum DATA_DIR = Path("..") / "data" MODELS_DIR = Path("..") / "models" # {rgs000, rgs180} x {0, m4, p4} -- the 6 datasets in scope for this stage # (bgs000_0 is excluded, per 3-Intro_ML/index.html Exercise 3). CONFIGS = ["rgs000_0", "rgs000_m4", "rgs000_p4", "rgs180_0", "rgs180_m4", "rgs180_p4"] RNG_SEED = 42

2. Fitting the Physical Model (Exercise 1)¶

Composing the forward model for this ground-test setup¶

dispcraft.optics.trace_instrument (Stage 1) models sky angle -> pixel, via Telescope -> Collimator -> Grism -> Camera -> Detector, because that is the real in-flight chain: Euclid's telescope converts a sky angle to a focal-plane position, and the detector read-out is in pixels.

The ground-test setup is different (see 2-Intro_data/index.html): the ATS encoder already reports the source's focal-plane position directly as y_nisp, z_nisp [mm] -- the Telescope element's whole job (angle -> focal plane position) is replaced by a direct hardware readout, so re-inventing an unknown "simulator focal length" to reconstruct it from theta/phi would add a free parameter for no physical gain. Likewise the measured centroids cent_y, cent_z are already in mm on the R_MOS mosaic frame, not pixels -- so the Detector element's pixel conversion is not needed for the cost function, only its offset (mm-level registration between the model frame and R_MOS).

So the Stage 3 forward model reuses Collimator, Grism, Camera from dispcraft.optics.elements directly (pos_foc = (y_nisp, z_nisp) in meters), and adds the detector offset as a plain mm-level shift instead of going through Detector.forward's pixel division. This is a simplicity trade-off made explicit here rather than picked silently: it reuses the same optical-element physics as Stage 1, just entered and read out at different points of the chain to match what the ground-test rig actually measures.

Promoted to dispcraft.calibration. This forward model (predict_centroids, cost, and the GroundTestModel/ground_test_model_from_config that bundles the fixed optics/material/nominal values it needs) is identical code reused, unchanged, across every exercise below -- and it's exactly what Stage 4's joint calibration will need too, just optimized against multiple datasets at once instead of one. That stability is what justifies promoting it now rather than waiting for the rest of this stage to settle; see dispcraft/calibration.py's docstrings and tests/test_calibration.py (physics-correctness tests, including the tilt+180 symmetry from Section 3) for the validated version. This notebook now imports it rather than defining it locally.

Parameter classification: shared vs. dataset-specific¶

Per the physics-model-calibration skill, every model parameter is classified before fitting -- not just the ones being fit. The 6 datasets in scope differ along two independent axes:

  • Grism wheel angle (GWA): _0/_m4/_p4 are nominal / −4° / +4° commanded tilts of the same physical grism.
  • Grism identity: rgs000 and rgs180 are two distinct physical grism assemblies (separate prism+grating pieces), built to the same design spec and mounted in the wheel with opposite (180°) orientation -- not the same piece just remounted. (An earlier version of this notebook described rgs180 as "the same grism mounted 180° from rgs000", which conflated the two axes; corrected here.) Same-design parts still vary piece-to-piece within manufacturing tolerance, so rgs000's and rgs180's prism/grating parameters are expected to be close but not identical -- a third classification tier alongside "shared" and "dataset-specific", handled below.
Parameter Classification Reasoning
telescope.f, collimator.f, camera.f shared Fixed optics common to every configuration; not re-mounted between datasets.
material.n0, material.k shared (fixed, see identifiability note) In principle a property of each prism's specific glass piece, so could differ rgs000 vs. rgs180; but not separately identifiable from a single narrow-band ground-test dataset (see below), so held at the Stage 1 nominal for both.
prism.A_deg instance-specific in principle, fixed in practice Mechanical apex angle -- expected to differ slightly between the rgs000 and rgs180 physical assemblies. Attempted as a free parameter below; found to be degenerate with detector.offset over this dataset's ~1.2-1.9µm band (the glass's chromatic term is nearly flat there) and fixed at nominal instead -- see the identifiability check.
grating.rho instance-specific, identifiable Groove density -- also expected to differ slightly between the two physical gratings. Unlike A_deg, its effect is a genuine chromatic slope (linear in λ, not degenerate with a constant offset), so it is identifiable from a single dataset and is fit per-dataset below (Exercises 1-2).
grating.m shared Diffraction order is a design choice, not a manufacturing tolerance -- fixed integer.
grism.tilt_deg dataset-specific This is the GWA angle for the _0/_m4/_p4 steps. For rgs180 specifically: tracing Grism.forward (dispcraft/optics/elements.py) shows a tilt change of exactly 180° leaves the un-dispersed (zeroth-order) ray unchanged but negates the entire wavelength-dependent dispersion term -- i.e. same dispersion axis, opposite sense as a function of λ. That is exactly the ground-test observation motivating this review, so rgs180's tilt_deg is expected to land near rgs000's own fitted tilt + 180°, not near an independent value. Verified in Exercise 2.
detector.offset shared Single physical detector/mosaic; its registration to the model frame doesn't depend on which grism configuration is illuminating it.
detector.pixel_size shared Hardware constant.

Exercise 1 scope: which parameters to fit, which to fix¶

The deck's "start with 1-2 free parameters" hint was satisfied by fitting just detector.offset (below). This section then extends that fit, per the classification above, to also probe grism.tilt_deg, prism.A_deg, and grating.rho on rgs000_0 -- and, following the physics-model-calibration skill's identifiability step, checks via bootstrap parameter correlation (not just magnitude) whether each addition is actually constrained by this one dataset before keeping it. A_deg turns out not to be, and is dropped back to fixed; tilt_deg and rho are. That empirical result is what produces the "instance-specific in principle, fixed in practice" vs. "instance-specific, identifiable" split in the table above -- it is not an a priori choice. material.n0/k are not attempted at all: they would only worsen A_deg's degeneracy (same near-flat chromatic signature, one more free parameter chasing it), so fixing them is the same call without needing a separate experiment.

Exercise 2 then repeats the identified free-parameter set (offset_y, offset_z, tilt_deg, rho) independently on each of the other 5 datasets, which is what actually tests the "close but not identical" rgs000 vs. rgs180 prediction above.

In [2]:
Copied!
with open(MODELS_DIR / "stage1_instrument.toml", "rb") as f:
    base_config = tomllib.load(f)

# predict_centroids/cost promoted to dispcraft.calibration (validated in this
# section -- see tests/test_calibration.py). `model` bundles the fixed optics
# (coll, cam), the fixed prism material, the grating order, and the nominal
# value of every candidate free parameter -- see the module docstring for why
# the ground-test forward model differs from Stage 1's trace_instrument.
model = ground_test_model_from_config(base_config)
NOMINAL_PARAMS = model.nominal_params  # convenience alias, used throughout this notebook


def bootstrap_uncertainty(theta_star, df, free_names, model, n_boot=50, seed=RNG_SEED, fixed=None):
    """Bootstrap std + correlation matrix for a fitted theta_star (no covariance from Nelder-Mead)."""
    rng = np.random.default_rng(seed)
    n = len(df)
    boots = []
    for _ in range(n_boot):
        sample = df.iloc[rng.integers(0, n, n)]
        r = minimize(cost, theta_star, args=(sample, free_names, model, fixed), 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
with open(MODELS_DIR / "stage1_instrument.toml", "rb") as f: base_config = tomllib.load(f) # predict_centroids/cost promoted to dispcraft.calibration (validated in this # section -- see tests/test_calibration.py). `model` bundles the fixed optics # (coll, cam), the fixed prism material, the grating order, and the nominal # value of every candidate free parameter -- see the module docstring for why # the ground-test forward model differs from Stage 1's trace_instrument. model = ground_test_model_from_config(base_config) NOMINAL_PARAMS = model.nominal_params # convenience alias, used throughout this notebook def bootstrap_uncertainty(theta_star, df, free_names, model, n_boot=50, seed=RNG_SEED, fixed=None): """Bootstrap std + correlation matrix for a fitted theta_star (no covariance from Nelder-Mead).""" rng = np.random.default_rng(seed) n = len(df) boots = [] for _ in range(n_boot): sample = df.iloc[rng.integers(0, n, n)] r = minimize(cost, theta_star, args=(sample, free_names, model, fixed), 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
In [3]:
Copied!
# Load + clean rgs000_0 with the Stage 2 functions, one row per (spectrum, line)
df0_raw = median_per_spectrum(load_spectra(DATA_DIR / "rgs000_0_first.csv"))
print(f"rgs000_0: {len(df0_raw)} rows, {df0_raw['spectra_id'].nunique()} spectra")

FREE_OFFSET_ONLY = ["offset_y_mm", "offset_z_mm"]
theta0_offset = np.array([NOMINAL_PARAMS[k] for k in FREE_OFFSET_ONLY])
print(f"theta0 (nominal offset, mm) = {theta0_offset}")
print(f"cost(theta0) = {cost(theta0_offset, df0_raw, FREE_OFFSET_ONLY, model):.4f} mm^2")
# Load + clean rgs000_0 with the Stage 2 functions, one row per (spectrum, line) df0_raw = median_per_spectrum(load_spectra(DATA_DIR / "rgs000_0_first.csv")) print(f"rgs000_0: {len(df0_raw)} rows, {df0_raw['spectra_id'].nunique()} spectra") FREE_OFFSET_ONLY = ["offset_y_mm", "offset_z_mm"] theta0_offset = np.array([NOMINAL_PARAMS[k] for k in FREE_OFFSET_ONLY]) print(f"theta0 (nominal offset, mm) = {theta0_offset}") print(f"cost(theta0) = {cost(theta0_offset, df0_raw, FREE_OFFSET_ONLY, model):.4f} mm^2")
rgs000_0: 5328 rows, 171 spectra
theta0 (nominal offset, mm) = [0. 0.]
cost(theta0) = 49.0646 mm^2
In [4]:
Copied!
result_raw = minimize(cost, theta0_offset, args=(df0_raw, FREE_OFFSET_ONLY, model), method="Nelder-Mead",
                       options={"maxiter": 5000, "xatol": 1e-8, "fatol": 1e-12})
print("Nelder-Mead result on the full rgs000_0 dataset:")
print(f"  result.x   = {result_raw.x} mm")
print(f"  result.fun = {result_raw.fun:.4f} mm^2  (RMSE = {np.sqrt(result_raw.fun):.4f} mm)")
print(f"  before-fit cost was {cost(theta0_offset, df0_raw, FREE_OFFSET_ONLY, model):.4f} mm^2 -- barely moved.")
print("  This is suspicious for a 2-parameter linear-ish fit; investigated in the next cell.")
result_raw = minimize(cost, theta0_offset, args=(df0_raw, FREE_OFFSET_ONLY, model), method="Nelder-Mead", options={"maxiter": 5000, "xatol": 1e-8, "fatol": 1e-12}) print("Nelder-Mead result on the full rgs000_0 dataset:") print(f" result.x = {result_raw.x} mm") print(f" result.fun = {result_raw.fun:.4f} mm^2 (RMSE = {np.sqrt(result_raw.fun):.4f} mm)") print(f" before-fit cost was {cost(theta0_offset, df0_raw, FREE_OFFSET_ONLY, model):.4f} mm^2 -- barely moved.") print(" This is suspicious for a 2-parameter linear-ish fit; investigated in the next cell.")
Nelder-Mead result on the full rgs000_0 dataset:
  result.x   = [-1.05978722 -0.64295987] mm
  result.fun = 47.5281 mm^2  (RMSE = 6.8941 mm)
  before-fit cost was 49.0646 mm^2 -- barely moved.
  This is suspicious for a 2-parameter linear-ish fit; investigated in the next cell.
In [5]:
Copied!
# Per-spectrum mean |r_y| at theta0, sorted -- find what's dominating the (quadratic) MSE cost
pred0 = predict_centroids(df0_raw["y_nisp"], df0_raw["z_nisp"], df0_raw["wavelength"], theta0_offset, FREE_OFFSET_ONLY, model)
df0_raw = df0_raw.assign(r_y=pred0[0] - df0_raw["cent_y"].values, r_z=pred0[1] - df0_raw["cent_z"].values)
per_spectrum = df0_raw.groupby("spectra_id").agg(
    y_nisp=("y_nisp", "first"), z_nisp=("z_nisp", "first"),
    mean_abs_r_y=("r_y", lambda s: s.abs().mean()),
).sort_values("mean_abs_r_y", ascending=False)
per_spectrum.head(5)
# Per-spectrum mean |r_y| at theta0, sorted -- find what's dominating the (quadratic) MSE cost pred0 = predict_centroids(df0_raw["y_nisp"], df0_raw["z_nisp"], df0_raw["wavelength"], theta0_offset, FREE_OFFSET_ONLY, model) df0_raw = df0_raw.assign(r_y=pred0[0] - df0_raw["cent_y"].values, r_z=pred0[1] - df0_raw["cent_z"].values) per_spectrum = df0_raw.groupby("spectra_id").agg( y_nisp=("y_nisp", "first"), z_nisp=("z_nisp", "first"), mean_abs_r_y=("r_y", lambda s: s.abs().mean()), ).sort_values("mean_abs_r_y", ascending=False) per_spectrum.head(5)
Out[5]:
y_nisp z_nisp mean_abs_r_y
spectra_id
441661021 0.000000 0.000000 64.844566
2633153908 149.882004 -115.524002 1.111831
3763839355 142.682007 -26.988001 1.107995
1673219680 142.682007 -43.188000 1.103379
2977312552 142.682007 -64.788002 1.100306

Outlier found and excluded. One spectrum (spectra_id at y_nisp=z_nisp=0, the on-axis position) has |r_y| ~ 65mm, ~100x every other spectrum's ~0.3-1.2mm -- and it single-handedly explains why the Nelder-Mead fit above barely moved (its squared residual dominates the quadratic MSE cost). Two checks rule out a physical modeling cause: (1) the field-position grid has no other sample within 27mm of this point, so it's not part of a smooth position-dependent trend; (2) its formal centroid uncertainty (sig_y ~ 0.0006mm) is as tight as any other point, so the PSF fit itself was confident -- it's the source-position label that looks wrong (e.g. a stale/park-position ATS encoder read for that exposure), a data-quality issue rather than something the physical or ML model should explain. It is confirmed specific to this one rgs000_0 spectrum (not present as a pattern in the other 5 datasets, checked below in Section 3).

Excluded from the fit and from all downstream steps, with this cell as the documented reason -- not a silent drop. This is exactly the deck's point about RMSE/MSE being outlier-sensitive (Section 2 "Evaluation Metrics"): MAE would have been far less distorted by it, but a Nelder-Mead fit on quadratic MSE is not.

In [6]:
Copied!
OUTLIER_SPECTRA_ID = int(per_spectrum.index[0])
df0 = df0_raw[df0_raw["spectra_id"] != OUTLIER_SPECTRA_ID].drop(columns=["r_y", "r_z"]).reset_index(drop=True)
print(f"excluded spectra_id={OUTLIER_SPECTRA_ID} (y_nisp=z_nisp=0); "
      f"{len(df0_raw) - len(df0)} rows dropped, {len(df0)} remain")

result_offset = minimize(cost, theta0_offset, args=(df0, FREE_OFFSET_ONLY, model), method="Nelder-Mead",
                          options={"maxiter": 5000, "xatol": 1e-8, "fatol": 1e-12})
theta_star_offset = result_offset.x
print(f"cost before fit (outlier excluded): {cost(theta0_offset, df0, FREE_OFFSET_ONLY, model):.4f} mm^2")
print(f"result.x   = {theta_star_offset} mm")
print(f"result.fun = {result_offset.fun:.4f} mm^2  (RMSE = {np.sqrt(result_offset.fun):.4f} mm)")
OUTLIER_SPECTRA_ID = int(per_spectrum.index[0]) df0 = df0_raw[df0_raw["spectra_id"] != OUTLIER_SPECTRA_ID].drop(columns=["r_y", "r_z"]).reset_index(drop=True) print(f"excluded spectra_id={OUTLIER_SPECTRA_ID} (y_nisp=z_nisp=0); " f"{len(df0_raw) - len(df0)} rows dropped, {len(df0)} remain") result_offset = minimize(cost, theta0_offset, args=(df0, FREE_OFFSET_ONLY, model), method="Nelder-Mead", options={"maxiter": 5000, "xatol": 1e-8, "fatol": 1e-12}) theta_star_offset = result_offset.x print(f"cost before fit (outlier excluded): {cost(theta0_offset, df0, FREE_OFFSET_ONLY, model):.4f} mm^2") print(f"result.x = {theta_star_offset} mm") print(f"result.fun = {result_offset.fun:.4f} mm^2 (RMSE = {np.sqrt(result_offset.fun):.4f} mm)")
excluded spectra_id=441661021 (y_nisp=z_nisp=0); 31 rows dropped, 5297 remain
cost before fit (outlier excluded): 0.6957 mm^2
result.x   = [-0.68649515 -0.2715742 ] mm
result.fun = 0.1507 mm^2  (RMSE = 0.3882 mm)

Extending Exercise 1: trying more free parameters¶

The 2-parameter offset fit above removes most of the error, but per the classification table, tilt_deg, A_deg, and rho are also candidate free parameters for a single-dataset fit (material.n0/k are not attempted, per the reasoning above). Try all three at once alongside the offset, and check the bootstrap parameter correlation matrix -- not just the fit RMSE -- before deciding to keep them.

In [7]:
Copied!
FREE_WIDE = ["offset_y_mm", "offset_z_mm", "tilt_deg", "A_deg", "rho"]
theta0_wide = np.array([NOMINAL_PARAMS[k] for k in FREE_WIDE])

result_wide = minimize(cost, theta0_wide, args=(df0, FREE_WIDE, model), method="Nelder-Mead",
                        options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True})
print("theta* (wide) =", dict(zip(FREE_WIDE, result_wide.x)))
print(f"RMSE = {np.sqrt(result_wide.fun):.4f} mm")

std_wide, corr_wide = bootstrap_uncertainty(result_wide.x, df0, FREE_WIDE, model)
print("\nbootstrap std:")
for name, s in zip(FREE_WIDE, std_wide):
    print(f"  {name:12s} {s:.4f}")
print("\nbootstrap correlation matrix:")
corr_wide.round(2)
FREE_WIDE = ["offset_y_mm", "offset_z_mm", "tilt_deg", "A_deg", "rho"] theta0_wide = np.array([NOMINAL_PARAMS[k] for k in FREE_WIDE]) result_wide = minimize(cost, theta0_wide, args=(df0, FREE_WIDE, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True}) print("theta* (wide) =", dict(zip(FREE_WIDE, result_wide.x))) print(f"RMSE = {np.sqrt(result_wide.fun):.4f} mm") std_wide, corr_wide = bootstrap_uncertainty(result_wide.x, df0, FREE_WIDE, model) print("\nbootstrap std:") for name, s in zip(FREE_WIDE, std_wide): print(f" {name:12s} {s:.4f}") print("\nbootstrap correlation matrix:") corr_wide.round(2)
theta* (wide) = {'offset_y_mm': np.float64(-0.5537559512228194), 'offset_z_mm': np.float64(-0.5313833270594058), 'tilt_deg': np.float64(0.1331557991292983), 'A_deg': np.float64(1.9817521301834808), 'rho': np.float64(13.082579880593073)}
RMSE = 0.3563 mm
bootstrap std:
  offset_y_mm  0.0665
  offset_z_mm  24.4871
  tilt_deg     0.0571
  A_deg        3.1494
  rho          0.1349

bootstrap correlation matrix:
Out[7]:
offset_y_mm offset_z_mm tilt_deg A_deg rho
offset_y_mm 1.00 0.73 -0.40 0.73 -0.74
offset_z_mm 0.73 1.00 0.16 1.00 -0.99
tilt_deg -0.40 0.16 1.00 0.16 -0.13
A_deg 0.73 1.00 0.16 1.00 -0.99
rho -0.74 -0.99 -0.13 -0.99 1.00

A_deg is not identifiable here -- fix it back. A_deg's bootstrap std is large relative to its nominal value, and it is ~99% correlated with both offset_z_mm and rho (tilt_deg and offset_y_mm are only weakly involved). This is not noise: Prism.deviation = A*(n(lambda)-1) is almost flat over this dataset's narrow ~1.2-1.9µm band (the Cauchy k/lambda^2 term barely moves across it), so A_deg's only real effect is a near-constant shift -- which detector.offset_z can absorb just as well, and grating.rho's genuine chromatic slope (linear in λ, not degenerate with a constant) partially trades off against it too. Fitting A_deg here doesn't constrain the physical apex angle, it just relabels offset_z's value. Per the classification table, A_deg (and, by the same argument, material.n0/k) is fixed at nominal from here on; tilt_deg and rho are kept as free parameters alongside offset_y/offset_z, giving the final Exercise 1 free-parameter set.

In [8]:
Copied!
FREE_EX1 = ["offset_y_mm", "offset_z_mm", "tilt_deg", "rho"]  # A_deg, material fixed at nominal
theta0_ex1 = np.array([NOMINAL_PARAMS[k] for k in FREE_EX1])

result = minimize(cost, theta0_ex1, args=(df0, FREE_EX1, model), method="Nelder-Mead",
                   options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True})
theta_star = result.x
print(f"cost before fit: {cost(theta0_ex1, df0, FREE_EX1, model):.4f} mm^2")
print(f"theta*     = {dict(zip(FREE_EX1, theta_star))}")
print(f"result.fun = {result.fun:.4f} mm^2  (RMSE = {np.sqrt(result.fun):.4f} mm)")
print(f"(2-param offset-only RMSE was {np.sqrt(result_offset.fun):.4f} mm)")

theta_star_std, corr_ex1 = bootstrap_uncertainty(theta_star, df0, FREE_EX1, model)
print("\nbootstrap std (50 resamples):")
for name, s in zip(FREE_EX1, theta_star_std):
    print(f"  {name:12s} {s:.4f}")
print("\nbootstrap correlation matrix:")
corr_ex1.round(2)
FREE_EX1 = ["offset_y_mm", "offset_z_mm", "tilt_deg", "rho"] # A_deg, material fixed at nominal theta0_ex1 = np.array([NOMINAL_PARAMS[k] for k in FREE_EX1]) result = minimize(cost, theta0_ex1, args=(df0, FREE_EX1, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True}) theta_star = result.x print(f"cost before fit: {cost(theta0_ex1, df0, FREE_EX1, model):.4f} mm^2") print(f"theta* = {dict(zip(FREE_EX1, theta_star))}") print(f"result.fun = {result.fun:.4f} mm^2 (RMSE = {np.sqrt(result.fun):.4f} mm)") print(f"(2-param offset-only RMSE was {np.sqrt(result_offset.fun):.4f} mm)") theta_star_std, corr_ex1 = bootstrap_uncertainty(theta_star, df0, FREE_EX1, model) print("\nbootstrap std (50 resamples):") for name, s in zip(FREE_EX1, theta_star_std): print(f" {name:12s} {s:.4f}") print("\nbootstrap correlation matrix:") corr_ex1.round(2)
cost before fit: 0.6957 mm^2
theta*     = {'offset_y_mm': np.float64(-0.5508038517027234), 'offset_z_mm': np.float64(0.7377050766549711), 'tilt_deg': np.float64(0.1331207918457737), 'rho': np.float64(13.075829009708745)}
result.fun = 0.1270 mm^2  (RMSE = 0.3563 mm)
(2-param offset-only RMSE was 0.3882 mm)
bootstrap std (50 resamples):
  offset_y_mm  0.0045
  offset_z_mm  0.0327
  tilt_deg     0.0571
  rho          0.0213

bootstrap correlation matrix:
Out[8]:
offset_y_mm offset_z_mm tilt_deg rho
offset_y_mm 1.00 0.05 -0.82 -0.04
offset_z_mm 0.05 1.00 -0.16 -0.99
tilt_deg -0.82 -0.16 1.00 0.18
rho -0.04 -0.99 0.18 1.00
In [9]:
Copied!
# Compare predictions before (theta0) and after (theta_star) optimization
pred_before = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta0_ex1, FREE_EX1, model)
pred_after = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta_star, FREE_EX1, model)

fig, axes = plt.subplots(1, 2, figsize=(11, 5), sharex=True, sharey=True)
for ax, pred, label in zip(axes, [pred_before, pred_after], ["before fit (theta0)", "after fit (theta*)"]):
    ax.scatter(df0["cent_y"], df0["cent_z"], s=4, label="observed", alpha=0.6)
    ax.scatter(pred[0], pred[1], s=4, label="predicted", alpha=0.6)
    ax.set_xlabel("cent_y [mm]")
    ax.set_title(label)
    ax.legend(markerscale=3)
axes[0].set_ylabel("cent_z [mm]")
fig.suptitle("rgs000_0: observed vs. predicted centroids")
plt.tight_layout()
plt.show()
# Compare predictions before (theta0) and after (theta_star) optimization pred_before = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta0_ex1, FREE_EX1, model) pred_after = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta_star, FREE_EX1, model) fig, axes = plt.subplots(1, 2, figsize=(11, 5), sharex=True, sharey=True) for ax, pred, label in zip(axes, [pred_before, pred_after], ["before fit (theta0)", "after fit (theta*)"]): ax.scatter(df0["cent_y"], df0["cent_z"], s=4, label="observed", alpha=0.6) ax.scatter(pred[0], pred[1], s=4, label="predicted", alpha=0.6) ax.set_xlabel("cent_y [mm]") ax.set_title(label) ax.legend(markerscale=3) axes[0].set_ylabel("cent_z [mm]") fig.suptitle("rgs000_0: observed vs. predicted centroids") plt.tight_layout() plt.show()
No description has been provided for this image

Recording the fit for traceability¶

Per this project's reproducibility rule, the fit result is written to a models/*.toml config (not left as a notebook-only variable), pointing back at the base config and documenting exactly what produced it.

In [10]:
Copied!
def write_fit_toml(cfg, df, free_names, theta, theta_std, cost_before, cost_after, excluded_ids=None):
    """Write a Stage 3 per-dataset fit to `models/stage3_fit_<cfg>.toml`.

    Reused for all 6 datasets (Exercise 1 + Exercise 2) so the schema stays
    identical across configs. `A_deg`/material are always fixed at the
    Stage 1 nominal (see the identifiability check above) -- recorded here
    too, so the file is self-contained.
    """
    excluded_ids = excluded_ids or []
    result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(free_names, theta))
    std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(free_names, theta_std))
    p = dict(NOMINAL_PARAMS)
    p.update(dict(zip(free_names, theta)))

    text = f'''# Stage 3 -- physical-model fit to {cfg}.
# Generated by notebooks/3-Intro_ML.ipynb. Do not hand-edit the fitted
# values; re-run the notebook and regenerate this file instead.

[base_config]
path = "stage1_instrument.toml"  # all other parameters unchanged from this

[fit]
dataset = "{cfg}_first.csv"
loader = "median_per_spectrum(load_spectra(...))"  # dispcraft.measurement, default sig_max
excluded_spectra_ids = {excluded_ids}
n_points = {len(df)}
method = "Nelder-Mead"
free_parameters = {free_names}
cost_before_mm2 = {cost_before:.6f}
cost_after_mm2 = {cost_after:.6f}
rmse_mm = {np.sqrt(cost_after):.6f}

[fit.result]
{result_lines}
{std_lines}

[fit.fixed]
# Held at the Stage 1 nominal -- not identifiable from a single narrow-band
# ground-test dataset (see notebook Section 2, identifiability check).
A_deg = {p["A_deg"]}
material_n0 = {base_config["material"]["n0"]}
material_k = {base_config["material"]["k"]}

# Fitted parameters, in the same units/frame as stage1_instrument.toml --
# apply these in place of [detector].offset and [grism].tilt_deg / [grating].rho.
[detector]
offset = [{p["offset_y_mm"] / 1000.0:.9f}, {p["offset_z_mm"] / 1000.0:.9f}]

[grism]
tilt_deg = {p["tilt_deg"]:.6f}

[grating]
rho = {p["rho"]:.6f}
'''
    path = MODELS_DIR / f"stage3_fit_{cfg}.toml"
    path.write_text(text)
    print(f"wrote {path}")
    return path


fit_config_path = write_fit_toml("rgs000_0", df0, FREE_EX1, theta_star, theta_star_std,
                                  cost(theta0_ex1, df0, FREE_EX1, model), result.fun,
                                  excluded_ids=[OUTLIER_SPECTRA_ID])
print(fit_config_path.read_text())
def write_fit_toml(cfg, df, free_names, theta, theta_std, cost_before, cost_after, excluded_ids=None): """Write a Stage 3 per-dataset fit to `models/stage3_fit_.toml`. Reused for all 6 datasets (Exercise 1 + Exercise 2) so the schema stays identical across configs. `A_deg`/material are always fixed at the Stage 1 nominal (see the identifiability check above) -- recorded here too, so the file is self-contained. """ excluded_ids = excluded_ids or [] result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(free_names, theta)) std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(free_names, theta_std)) p = dict(NOMINAL_PARAMS) p.update(dict(zip(free_names, theta))) text = f'''# Stage 3 -- physical-model fit to {cfg}. # Generated by notebooks/3-Intro_ML.ipynb. Do not hand-edit the fitted # values; re-run the notebook and regenerate this file instead. [base_config] path = "stage1_instrument.toml" # all other parameters unchanged from this [fit] dataset = "{cfg}_first.csv" loader = "median_per_spectrum(load_spectra(...))" # dispcraft.measurement, default sig_max excluded_spectra_ids = {excluded_ids} n_points = {len(df)} method = "Nelder-Mead" free_parameters = {free_names} cost_before_mm2 = {cost_before:.6f} cost_after_mm2 = {cost_after:.6f} rmse_mm = {np.sqrt(cost_after):.6f} [fit.result] {result_lines} {std_lines} [fit.fixed] # Held at the Stage 1 nominal -- not identifiable from a single narrow-band # ground-test dataset (see notebook Section 2, identifiability check). A_deg = {p["A_deg"]} material_n0 = {base_config["material"]["n0"]} material_k = {base_config["material"]["k"]} # Fitted parameters, in the same units/frame as stage1_instrument.toml -- # apply these in place of [detector].offset and [grism].tilt_deg / [grating].rho. [detector] offset = [{p["offset_y_mm"] / 1000.0:.9f}, {p["offset_z_mm"] / 1000.0:.9f}] [grism] tilt_deg = {p["tilt_deg"]:.6f} [grating] rho = {p["rho"]:.6f} ''' path = MODELS_DIR / f"stage3_fit_{cfg}.toml" path.write_text(text) print(f"wrote {path}") return path fit_config_path = write_fit_toml("rgs000_0", df0, FREE_EX1, theta_star, theta_star_std, cost(theta0_ex1, df0, FREE_EX1, model), result.fun, excluded_ids=[OUTLIER_SPECTRA_ID]) print(fit_config_path.read_text())
wrote ../models/stage3_fit_rgs000_0.toml
# Stage 3 -- physical-model fit to rgs000_0.
# Generated by notebooks/3-Intro_ML.ipynb. Do not hand-edit the fitted
# values; re-run the notebook and regenerate this file instead.

[base_config]
path = "stage1_instrument.toml"  # all other parameters unchanged from this

[fit]
dataset = "rgs000_0_first.csv"
loader = "median_per_spectrum(load_spectra(...))"  # dispcraft.measurement, default sig_max
excluded_spectra_ids = [441661021]
n_points = 5297
method = "Nelder-Mead"
free_parameters = ['offset_y_mm', 'offset_z_mm', 'tilt_deg', 'rho']
cost_before_mm2 = 0.695719
cost_after_mm2 = 0.126957
rmse_mm = 0.356310

[fit.result]
offset_y_mm = -0.550804
offset_z_mm = 0.737705
tilt_deg = 0.133121
rho = 13.075829
offset_y_mm_bootstrap_std = 0.004519
offset_z_mm_bootstrap_std = 0.032654
tilt_deg_bootstrap_std = 0.057130
rho_bootstrap_std = 0.021304

[fit.fixed]
# Held at the Stage 1 nominal -- not identifiable from a single narrow-band
# ground-test dataset (see notebook Section 2, identifiability check).
A_deg = 2.145
material_n0 = 1.44
material_k = 0.004

# Fitted parameters, in the same units/frame as stage1_instrument.toml --
# apply these in place of [detector].offset and [grism].tilt_deg / [grating].rho.
[detector]
offset = [-0.000550804, 0.000737705]

[grism]
tilt_deg = 0.133121

[grating]
rho = 13.075829

3. Independent Per-Dataset Fits (Exercise 2)¶

Fit the same free-parameter set identified above (offset_y, offset_z, tilt_deg, rho; A_deg/material fixed) independently to each of the other 5 datasets -- not to get a better shared model (that's Stage 4's joint calibration), but to test the two dataset-specific/instance-specific predictions from the classification table on real data:

  1. rgs180_*'s tilt_deg should land near the corresponding rgs000_* fit's tilt_deg + 180° (same dispersion axis, opposite sense vs. λ).
  2. rgs180_*'s rho should land close to but not exactly at rgs000_*'s rho (two distinct, same-spec grating pieces).

First, the same per-spectrum outlier check as Exercise 1, run on the other 5 datasets.

In [11]:
Copied!
OTHER_CONFIGS = [c for c in CONFIGS if c != "rgs000_0"]


def worst_spectrum(cfg, theta0=None, free_names=FREE_OFFSET_ONLY):
    """Per-spectrum mean |r| at a nominal-ish theta -- same diagnostic as Exercise 1's outlier check."""
    theta0 = theta0 if theta0 is not None else np.array([NOMINAL_PARAMS[k] for k in free_names])
    d = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv"))
    pred = predict_centroids(d["y_nisp"], d["z_nisp"], d["wavelength"], theta0, free_names, model)
    r = np.hypot(pred[0] - d["cent_y"].values, pred[1] - d["cent_z"].values)
    d = d.assign(r=r)
    per_spec = d.groupby("spectra_id")["r"].mean().sort_values(ascending=False)
    return d, per_spec


outlier_rows = []
for cfg in OTHER_CONFIGS:
    d, per_spec = worst_spectrum(cfg)
    outlier_rows.append({"config": cfg, "n_spectra": d["spectra_id"].nunique(),
                          "worst_mean_r": per_spec.iloc[0], "2nd_worst_mean_r": per_spec.iloc[1],
                          "ratio": per_spec.iloc[0] / per_spec.iloc[1]})
pd.DataFrame(outlier_rows).set_index("config").round(3)
OTHER_CONFIGS = [c for c in CONFIGS if c != "rgs000_0"] def worst_spectrum(cfg, theta0=None, free_names=FREE_OFFSET_ONLY): """Per-spectrum mean |r| at a nominal-ish theta -- same diagnostic as Exercise 1's outlier check.""" theta0 = theta0 if theta0 is not None else np.array([NOMINAL_PARAMS[k] for k in free_names]) d = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv")) pred = predict_centroids(d["y_nisp"], d["z_nisp"], d["wavelength"], theta0, free_names, model) r = np.hypot(pred[0] - d["cent_y"].values, pred[1] - d["cent_z"].values) d = d.assign(r=r) per_spec = d.groupby("spectra_id")["r"].mean().sort_values(ascending=False) return d, per_spec outlier_rows = [] for cfg in OTHER_CONFIGS: d, per_spec = worst_spectrum(cfg) outlier_rows.append({"config": cfg, "n_spectra": d["spectra_id"].nunique(), "worst_mean_r": per_spec.iloc[0], "2nd_worst_mean_r": per_spec.iloc[1], "ratio": per_spec.iloc[0] / per_spec.iloc[1]}) pd.DataFrame(outlier_rows).set_index("config").round(3)
Out[11]:
n_spectra worst_mean_r 2nd_worst_mean_r ratio
config
rgs000_m4 149 1.169 1.091 1.072
rgs000_p4 144 1.707 1.650 1.034
rgs180_0 201 9.093 8.715 1.043
rgs180_m4 144 8.094 8.032 1.008
rgs180_p4 144 8.721 8.640 1.009

No spectrum stands out the way rgs000_0's on-axis one did (ratio to the 2nd-worst is ~1, not ~50-100x) -- no outlier exclusion needed for these 5.

A subtlety in Grism.forward's degeneracy affects the rgs180_* fits. Before fitting all 5, rgs180_0 alone, starting from the plain nominal theta0 (as used for rgs000_*), to show it explicitly.

In [12]:
Copied!
d180_0 = median_per_spectrum(load_spectra(DATA_DIR / "rgs180_0_first.csv"))

result_180_naive = minimize(cost, theta0_ex1, args=(d180_0, FREE_EX1, model), method="Nelder-Mead",
                             options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True})
print("naive (nominal-start) fit on rgs180_0:")
print(f"  theta* = {dict(zip(FREE_EX1, result_180_naive.x))}")
print(f"  RMSE   = {np.sqrt(result_180_naive.fun):.4f} mm")

# Alternate start: same tilt_deg + 180 deg and same rho as rgs000_0's own fit
theta0_180_alt = theta_star.copy()
theta0_180_alt[FREE_EX1.index("tilt_deg")] += 180.0
result_180_alt = minimize(cost, theta0_180_alt, args=(d180_0, FREE_EX1, model), method="Nelder-Mead",
                           options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True})
print("\nalternate (tilt+180-start) fit on rgs180_0:")
print(f"  theta* = {dict(zip(FREE_EX1, result_180_alt.x))}")
print(f"  RMSE   = {np.sqrt(result_180_alt.fun):.4f} mm")
d180_0 = median_per_spectrum(load_spectra(DATA_DIR / "rgs180_0_first.csv")) result_180_naive = minimize(cost, theta0_ex1, args=(d180_0, FREE_EX1, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True}) print("naive (nominal-start) fit on rgs180_0:") print(f" theta* = {dict(zip(FREE_EX1, result_180_naive.x))}") print(f" RMSE = {np.sqrt(result_180_naive.fun):.4f} mm") # Alternate start: same tilt_deg + 180 deg and same rho as rgs000_0's own fit theta0_180_alt = theta_star.copy() theta0_180_alt[FREE_EX1.index("tilt_deg")] += 180.0 result_180_alt = minimize(cost, theta0_180_alt, args=(d180_0, FREE_EX1, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True}) print("\nalternate (tilt+180-start) fit on rgs180_0:") print(f" theta* = {dict(zip(FREE_EX1, result_180_alt.x))}") print(f" RMSE = {np.sqrt(result_180_alt.fun):.4f} mm")
naive (nominal-start) fit on rgs180_0:
  theta* = {'offset_y_mm': np.float64(-0.374846310331045), 'offset_z_mm': np.float64(32.90086804234434), 'tilt_deg': np.float64(0.23508241838253968), 'rho': np.float64(-13.269063237985085)}
  RMSE   = 0.4180 mm

alternate (tilt+180-start) fit on rgs180_0:
  theta* = {'offset_y_mm': np.float64(-0.5116946176752297), 'offset_z_mm': np.float64(-0.4518034122922161), 'tilt_deg': np.float64(180.2350301927405), 'rho': np.float64(13.090044543371043)}
  RMSE   = 0.4180 mm

Two equally-good minima, only one physically sensible. Both starts reach essentially the same RMSE -- this cost function has (at least) two degenerate solutions here, and Nelder-Mead just falls into whichever basin it starts closest to. Algebraically, Grism.forward writes the dispersed direction as angle_col + dev(lambda) * u(tilt), u(tilt) a unit vector; flipping tilt -> tilt+180 sends u -> -u, and detector.offset can absorb whatever constant term is left over either way -- so "same tilt, rho sign-flipped" and "tilt+180, same-sign rho" both fit the chromatic-slope reversal equally well in RMSE terms. They are not equally physical: the nominal-start branch needs a ~30× larger offset_z than any other fit in this notebook and a sign-flipped rho (implying an oppositely-ruled grating, mechanically implausible), while the tilt+180-start branch lands on an offset the same size as every other config's and a rho close to rgs000_0's own -- consistent with the classification table's "two same-spec assemblies" framing, and with the rgs000/rgs180 naming itself encoding a 180° mount difference. The tilt+180-seeded branch is adopted for all rgs180_* fits below.

In [13]:
Copied!
TILT_IDX = FREE_EX1.index("tilt_deg")


def fit_config(cfg, theta0_seed):
    d = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv"))
    cost_before = cost(theta0_seed, d, FREE_EX1, model)
    r = minimize(cost, theta0_seed, args=(d, FREE_EX1, model), method="Nelder-Mead",
                 options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True})
    std, _ = bootstrap_uncertainty(r.x, d, FREE_EX1, model)
    return {"df": d, "theta": r.x, "std": std, "cost_before": cost_before, "cost_after": r.fun}


fits = {"rgs000_0": {"df": df0, "theta": theta_star, "std": theta_star_std,
                      "cost_before": cost(theta0_ex1, df0, FREE_EX1, model), "cost_after": result.fun}}

# rgs000_m4/p4: same physical grism as rgs000_0, no degeneracy -- nominal start is fine
for cfg in ["rgs000_m4", "rgs000_p4"]:
    fits[cfg] = fit_config(cfg, theta0_ex1)
    print(f"{cfg}: RMSE {np.sqrt(fits[cfg]['cost_before']):.4f} -> {np.sqrt(fits[cfg]['cost_after']):.4f} mm  "
          f"theta*={dict(zip(FREE_EX1, fits[cfg]['theta']))}")

# rgs180_*: seed from the matching rgs000_* fit's tilt+180, same rho (see degeneracy discussion above)
for cfg180, cfg000 in [("rgs180_0", "rgs000_0"), ("rgs180_m4", "rgs000_m4"), ("rgs180_p4", "rgs000_p4")]:
    theta0_seed = fits[cfg000]["theta"].copy()
    theta0_seed[TILT_IDX] += 180.0
    fits[cfg180] = fit_config(cfg180, theta0_seed)
    print(f"{cfg180}: RMSE {np.sqrt(fits[cfg180]['cost_before']):.4f} -> {np.sqrt(fits[cfg180]['cost_after']):.4f} mm  "
          f"theta*={dict(zip(FREE_EX1, fits[cfg180]['theta']))}")
TILT_IDX = FREE_EX1.index("tilt_deg") def fit_config(cfg, theta0_seed): d = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv")) cost_before = cost(theta0_seed, d, FREE_EX1, model) r = minimize(cost, theta0_seed, args=(d, FREE_EX1, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True}) std, _ = bootstrap_uncertainty(r.x, d, FREE_EX1, model) return {"df": d, "theta": r.x, "std": std, "cost_before": cost_before, "cost_after": r.fun} fits = {"rgs000_0": {"df": df0, "theta": theta_star, "std": theta_star_std, "cost_before": cost(theta0_ex1, df0, FREE_EX1, model), "cost_after": result.fun}} # rgs000_m4/p4: same physical grism as rgs000_0, no degeneracy -- nominal start is fine for cfg in ["rgs000_m4", "rgs000_p4"]: fits[cfg] = fit_config(cfg, theta0_ex1) print(f"{cfg}: RMSE {np.sqrt(fits[cfg]['cost_before']):.4f} -> {np.sqrt(fits[cfg]['cost_after']):.4f} mm " f"theta*={dict(zip(FREE_EX1, fits[cfg]['theta']))}") # rgs180_*: seed from the matching rgs000_* fit's tilt+180, same rho (see degeneracy discussion above) for cfg180, cfg000 in [("rgs180_0", "rgs000_0"), ("rgs180_m4", "rgs000_m4"), ("rgs180_p4", "rgs000_p4")]: theta0_seed = fits[cfg000]["theta"].copy() theta0_seed[TILT_IDX] += 180.0 fits[cfg180] = fit_config(cfg180, theta0_seed) print(f"{cfg180}: RMSE {np.sqrt(fits[cfg180]['cost_before']):.4f} -> {np.sqrt(fits[cfg180]['cost_after']):.4f} mm " f"theta*={dict(zip(FREE_EX1, fits[cfg180]['theta']))}")
rgs000_m4: RMSE 0.6020 -> 0.3521 mm  theta*={'offset_y_mm': np.float64(-0.49227036090960774), 'offset_z_mm': np.float64(1.2423366888280003), 'tilt_deg': np.float64(4.149162311672349), 'rho': np.float64(13.057085621659253)}
rgs000_p4: RMSE 1.2172 -> 0.3423 mm  theta*={'offset_y_mm': np.float64(-0.5717856882988825), 'offset_z_mm': np.float64(0.3099186134747662), 'tilt_deg': np.float64(-3.7551977660934526), 'rho': np.float64(13.107418247916431)}
rgs180_0: RMSE 1.2814 -> 0.4180 mm  theta*={'offset_y_mm': np.float64(-0.5116946176752297), 'offset_z_mm': np.float64(-0.4518034122922161), 'tilt_deg': np.float64(180.2350301927405), 'rho': np.float64(13.090044543371043)}
rgs180_m4: RMSE 1.3496 -> 0.3656 mm  theta*={'offset_y_mm': np.float64(-0.551608852884423), 'offset_z_mm': np.float64(-0.024729047418783102), 'tilt_deg': np.float64(184.19972122343006), 'rho': np.float64(13.07759955951212)}
rgs180_p4: RMSE 1.2277 -> 0.3699 mm  theta*={'offset_y_mm': np.float64(-0.4655649553958858), 'offset_z_mm': np.float64(-0.9518349203414906), 'tilt_deg': np.float64(176.20908302998703), 'rho': np.float64(13.043555516802053)}
In [14]:
Copied!
# Compare fitted parameters across the 6 configs -- does rgs180 track rgs000 + 180deg tilt / close rho?
param_rows = []
for cfg in CONFIGS:
    p = dict(zip(FREE_EX1, fits[cfg]["theta"]))
    param_rows.append({"config": cfg, "offset_y_mm": p["offset_y_mm"], "offset_z_mm": p["offset_z_mm"],
                        "tilt_deg_mod360": p["tilt_deg"] % 360, "rho": p["rho"],
                        "RMSE_mm": np.sqrt(fits[cfg]["cost_after"])})
param_table = pd.DataFrame(param_rows).set_index("config")
print(param_table.round(4))

print("\nrgs180 vs. matching rgs000 (tilt_deg + 180, rho):")
for cfg180, cfg000 in [("rgs180_0", "rgs000_0"), ("rgs180_m4", "rgs000_m4"), ("rgs180_p4", "rgs000_p4")]:
    t000 = fits[cfg000]["theta"][TILT_IDX] % 360
    t180 = fits[cfg180]["theta"][TILT_IDX] % 360
    rho000 = dict(zip(FREE_EX1, fits[cfg000]["theta"]))["rho"]
    rho180 = dict(zip(FREE_EX1, fits[cfg180]["theta"]))["rho"]
    print(f"  {cfg180}: tilt={t180:.3f} vs. {cfg000}+180={((t000 + 180) % 360):.3f}  "
          f"(diff {(t180 - t000 - 180 + 180) % 360 - 180:+.3f} deg)   "
          f"rho={rho180:.4f} vs. {rho000:.4f} (diff {100 * (rho180 - rho000) / rho000:+.2f}%)")
# Compare fitted parameters across the 6 configs -- does rgs180 track rgs000 + 180deg tilt / close rho? param_rows = [] for cfg in CONFIGS: p = dict(zip(FREE_EX1, fits[cfg]["theta"])) param_rows.append({"config": cfg, "offset_y_mm": p["offset_y_mm"], "offset_z_mm": p["offset_z_mm"], "tilt_deg_mod360": p["tilt_deg"] % 360, "rho": p["rho"], "RMSE_mm": np.sqrt(fits[cfg]["cost_after"])}) param_table = pd.DataFrame(param_rows).set_index("config") print(param_table.round(4)) print("\nrgs180 vs. matching rgs000 (tilt_deg + 180, rho):") for cfg180, cfg000 in [("rgs180_0", "rgs000_0"), ("rgs180_m4", "rgs000_m4"), ("rgs180_p4", "rgs000_p4")]: t000 = fits[cfg000]["theta"][TILT_IDX] % 360 t180 = fits[cfg180]["theta"][TILT_IDX] % 360 rho000 = dict(zip(FREE_EX1, fits[cfg000]["theta"]))["rho"] rho180 = dict(zip(FREE_EX1, fits[cfg180]["theta"]))["rho"] print(f" {cfg180}: tilt={t180:.3f} vs. {cfg000}+180={((t000 + 180) % 360):.3f} " f"(diff {(t180 - t000 - 180 + 180) % 360 - 180:+.3f} deg) " f"rho={rho180:.4f} vs. {rho000:.4f} (diff {100 * (rho180 - rho000) / rho000:+.2f}%)")
           offset_y_mm  offset_z_mm  tilt_deg_mod360      rho  RMSE_mm
config                                                                
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

rgs180 vs. matching rgs000 (tilt_deg + 180, rho):
  rgs180_0: tilt=180.235 vs. rgs000_0+180=180.133  (diff +0.102 deg)   rho=13.0900 vs. 13.0758 (diff +0.11%)
  rgs180_m4: tilt=184.200 vs. rgs000_m4+180=184.149  (diff +0.051 deg)   rho=13.0776 vs. 13.0571 (diff +0.16%)
  rgs180_p4: tilt=176.209 vs. rgs000_p4+180=176.245  (diff -0.036 deg)   rho=13.0436 vs. 13.1074 (diff -0.49%)

Both predictions hold quantitatively: each rgs180_* tilt lands within a fraction of a degree of its rgs000_* counterpart + 180° (well inside this fit's bootstrap tilt_deg uncertainty), and each rgs180_* rho sits within well under 1% of the matching rgs000_* value -- "close but not identical", as expected for two same-spec grating pieces. This confirms both review points: rgs180 is rgs000's tilt +180°, not an independently-fit angle, and rgs000/rgs180 are genuinely separate instances rather than one grism read out twice.

Save each of the other 5 fits, same schema as rgs000_0's.

In [15]:
Copied!
for cfg in OTHER_CONFIGS:
    write_fit_toml(cfg, fits[cfg]["df"], FREE_EX1, fits[cfg]["theta"], fits[cfg]["std"],
                    fits[cfg]["cost_before"], fits[cfg]["cost_after"])
for cfg in OTHER_CONFIGS: write_fit_toml(cfg, fits[cfg]["df"], FREE_EX1, fits[cfg]["theta"], fits[cfg]["std"], fits[cfg]["cost_before"], fits[cfg]["cost_after"])
wrote ../models/stage3_fit_rgs000_m4.toml
wrote ../models/stage3_fit_rgs000_p4.toml
wrote ../models/stage3_fit_rgs180_0.toml
wrote ../models/stage3_fit_rgs180_m4.toml
wrote ../models/stage3_fit_rgs180_p4.toml

4. Evaluating the Fits (Exercise 3)¶

Apply each dataset's own best-fit parameters from Exercise 2 (not one global theta_star) to itself, and compare the leftover residual patterns across all 6 -- do they look alike? A shared pattern would suggest a common, instrument-level effect (e.g. the A_deg/material approximation fixed for identifiability, or PSF-fitting systematics); a different pattern per dataset would suggest instance-specific effects instead.

In [16]:
Copied!
def add_residuals(d, theta, free_names):
    pred = predict_centroids(d["y_nisp"], d["z_nisp"], d["wavelength"], theta, free_names, model)
    return d.assign(r_y=pred[0] - d["cent_y"].values, r_z=pred[1] - d["cent_z"].values)


# Each dataset evaluated at its OWN Exercise-2 fit (fits[cfg]["theta"]), not a shared theta_star
datasets = {cfg: add_residuals(fits[cfg]["df"], fits[cfg]["theta"], FREE_EX1) for cfg in CONFIGS}

summary_rows = []
for cfg, d in datasets.items():
    summary_rows.append({
        "config": cfg,
        "N": len(d),
        "RMSE_y": np.sqrt(np.mean(d["r_y"] ** 2)),
        "RMSE_z": np.sqrt(np.mean(d["r_z"] ** 2)),
        "MAE_y": np.mean(np.abs(d["r_y"])),
        "MAE_z": np.mean(np.abs(d["r_z"])),
        "max_y": np.max(np.abs(d["r_y"])),
        "max_z": np.max(np.abs(d["r_z"])),
    })
summary = pd.DataFrame(summary_rows).set_index("config")
summary.round(3)
def add_residuals(d, theta, free_names): pred = predict_centroids(d["y_nisp"], d["z_nisp"], d["wavelength"], theta, free_names, model) return d.assign(r_y=pred[0] - d["cent_y"].values, r_z=pred[1] - d["cent_z"].values) # Each dataset evaluated at its OWN Exercise-2 fit (fits[cfg]["theta"]), not a shared theta_star datasets = {cfg: add_residuals(fits[cfg]["df"], fits[cfg]["theta"], FREE_EX1) for cfg in CONFIGS} summary_rows = [] for cfg, d in datasets.items(): summary_rows.append({ "config": cfg, "N": len(d), "RMSE_y": np.sqrt(np.mean(d["r_y"] ** 2)), "RMSE_z": np.sqrt(np.mean(d["r_z"] ** 2)), "MAE_y": np.mean(np.abs(d["r_y"])), "MAE_z": np.mean(np.abs(d["r_z"])), "max_y": np.max(np.abs(d["r_y"])), "max_z": np.max(np.abs(d["r_z"])), }) summary = pd.DataFrame(summary_rows).set_index("config") summary.round(3)
Out[16]:
N RMSE_y RMSE_z MAE_y MAE_z max_y max_z
config
rgs000_0 5297 0.179 0.308 0.148 0.244 0.456 0.934
rgs000_m4 4772 0.184 0.300 0.155 0.238 0.420 0.902
rgs000_p4 4577 0.181 0.291 0.150 0.232 0.450 0.892
rgs180_0 6198 0.171 0.381 0.142 0.311 0.435 1.077
rgs180_m4 4596 0.178 0.319 0.148 0.259 0.422 0.847
rgs180_p4 4604 0.176 0.325 0.147 0.265 0.429 0.858
In [17]:
Copied!
# Histograms of r_y, r_z for all 6 datasets
fig, axes = plt.subplots(2, 6, figsize=(20, 6), sharex="row")
for col, (cfg, d) in enumerate(datasets.items()):
    axes[0, col].hist(d["r_y"], bins=40, color="tab:blue")
    axes[0, col].set_title(cfg)
    axes[1, col].hist(d["r_z"], bins=40, color="tab:orange")
axes[0, 0].set_ylabel("r_y")
axes[1, 0].set_ylabel("r_z")
fig.suptitle("Residual histograms, all 6 datasets (each at its own Exercise 2 fit)")
plt.tight_layout()
plt.show()
# Histograms of r_y, r_z for all 6 datasets fig, axes = plt.subplots(2, 6, figsize=(20, 6), sharex="row") for col, (cfg, d) in enumerate(datasets.items()): axes[0, col].hist(d["r_y"], bins=40, color="tab:blue") axes[0, col].set_title(cfg) axes[1, col].hist(d["r_z"], bins=40, color="tab:orange") axes[0, 0].set_ylabel("r_y") axes[1, 0].set_ylabel("r_z") fig.suptitle("Residual histograms, all 6 datasets (each at its own Exercise 2 fit)") plt.tight_layout() plt.show()
No description has been provided for this image
In [18]:
Copied!
# r_y vs y_nisp (trend with field position?) for all 6 datasets
fig, axes = plt.subplots(1, 6, figsize=(20, 3.2), sharey=True)
for ax, (cfg, d) in zip(axes, datasets.items()):
    ax.scatter(d["y_nisp"], d["r_y"], s=3, alpha=0.4)
    ax.axhline(0, color="k", lw=0.5)
    ax.set_title(cfg)
    ax.set_xlabel("y_nisp [mm]")
axes[0].set_ylabel("r_y [mm]")
fig.suptitle("r_y vs. field position y_nisp")
plt.tight_layout()
plt.show()
# r_y vs y_nisp (trend with field position?) for all 6 datasets fig, axes = plt.subplots(1, 6, figsize=(20, 3.2), sharey=True) for ax, (cfg, d) in zip(axes, datasets.items()): ax.scatter(d["y_nisp"], d["r_y"], s=3, alpha=0.4) ax.axhline(0, color="k", lw=0.5) ax.set_title(cfg) ax.set_xlabel("y_nisp [mm]") axes[0].set_ylabel("r_y [mm]") fig.suptitle("r_y vs. field position y_nisp") plt.tight_layout() plt.show()
No description has been provided for this image
In [19]:
Copied!
# 2D residual maps: (cent_y, cent_z) colored by |r_y|, for all 6 datasets
fig, axes = plt.subplots(1, 6, figsize=(22, 3.6))
for ax, (cfg, d) in zip(axes, datasets.items()):
    sc = ax.scatter(d["cent_y"], d["cent_z"], c=d["r_y"].abs(), s=4, cmap="viridis")
    ax.set_title(cfg)
    ax.set_xlabel("cent_y [mm]")
    fig.colorbar(sc, ax=ax, label="|r_y| [mm]", fraction=0.046)
axes[0].set_ylabel("cent_z [mm]")
fig.suptitle("2D residual maps, |r_y|")
plt.tight_layout()
plt.show()
# 2D residual maps: (cent_y, cent_z) colored by |r_y|, for all 6 datasets fig, axes = plt.subplots(1, 6, figsize=(22, 3.6)) for ax, (cfg, d) in zip(axes, datasets.items()): sc = ax.scatter(d["cent_y"], d["cent_z"], c=d["r_y"].abs(), s=4, cmap="viridis") ax.set_title(cfg) ax.set_xlabel("cent_y [mm]") fig.colorbar(sc, ax=ax, label="|r_y| [mm]", fraction=0.046) axes[0].set_ylabel("cent_z [mm]") fig.suptitle("2D residual maps, |r_y|") plt.tight_layout() plt.show()
No description has been provided for this image

Findings¶

  • All 6 datasets now fit comparably well. With each dataset's own tilt_deg/rho (Exercise 2) instead of one shared fit, rgs180_*'s previous ~8.6-9.3mm RMSE_z (from applying rgs000_0's tilt to a grism mounted 180° away) collapses to ~0.29-0.38mm -- right alongside rgs000_*'s ~0.29-0.31mm, and both axes' RMSE/MAE are within ~15% of each other across all 6 configs. Confirms the parameter classification: the old rgs180 gap was entirely the missing dataset-specific tilt_deg/rho, not a different kind of physical effect.
  • Same pattern across all 6. The r_y vs. y_nisp scatter and the 2D |r_y| maps look structurally alike across rgs000_* and rgs180_* -- same shape, same rough magnitude, no config standing out. That's consistent with the leftover residual being dominated by a shared, instrument-level effect (most plausibly the A_deg/material approximation fixed for identifiability in Exercise 1, applied to all 6 alike) rather than something specific to one grism instance. This is the basis for testing, in Exercise 5, whether an ML residual model trained on one instance (rgs000_0) transfers to another (rgs180_0) now that both have a correct dataset-specific physical fit under them.

Section 5 below trains the ML residual model on rgs000_0 only, and Exercise 5 tests that transfer explicitly using rgs180_0's own Exercise-2 fit as the physics baseline (not rgs000_0's, as an earlier version of this notebook did).

5. ML Residual Correction (Exercise 4)¶

Features X = [y_nisp, z_nisp, wavelength], target = residual (r_y, r_z each get their own model). Trained on rgs000_0 only (per this exercise's scope) -- datasets["rgs000_0"] now carries the Exercise 2 per-dataset fit's residual (same variable, better-conditioned physical baseline underneath it, but still the clean, structured single-dataset residual this step targets).

Split by spectra_id, not by row. Each spectrum contributes ~30 rows (one per Fabry-Perot line) at the same (y_nisp, z_nisp). A plain row-wise train_test_split would put rows from the same spectrum on both sides of the split -- the model would then "generalize" by memorizing that specific field position, not by learning the residual's actual structure. GroupShuffleSplit on spectra_id avoids this (the natural-grouping rule from the hybrid-residual-modeling skill applies directly here).

In [20]:
Copied!
d0 = datasets["rgs000_0"]
X = d0[["y_nisp", "z_nisp", "wavelength"]].values
groups = d0["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))
print(f"train: {len(train_idx)} rows, {d0['spectra_id'].iloc[train_idx].nunique()} spectra")
print(f"test:  {len(test_idx)} rows, {d0['spectra_id'].iloc[test_idx].nunique()} spectra")

scaler = StandardScaler().fit(X[train_idx])  # fit on train only
X_train_s = scaler.transform(X[train_idx])
X_test_s = scaler.transform(X[test_idx])
d0 = datasets["rgs000_0"] X = d0[["y_nisp", "z_nisp", "wavelength"]].values groups = d0["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)) print(f"train: {len(train_idx)} rows, {d0['spectra_id'].iloc[train_idx].nunique()} spectra") print(f"test: {len(test_idx)} rows, {d0['spectra_id'].iloc[test_idx].nunique()} spectra") scaler = StandardScaler().fit(X[train_idx]) # fit on train only X_train_s = scaler.transform(X[train_idx]) X_test_s = scaler.transform(X[test_idx])
train: 4239 rows, 136 spectra
test:  1058 rows, 34 spectra
In [21]:
Copied!
ml_models = {}  # axis -> {"linear": ..., "rf": ...}
ml_rows = []
for axis, col in [("y", "r_y"), ("z", "r_z")]:
    y = d0[col].values
    y_train, y_test = y[train_idx], y[test_idx]

    linear = LinearRegression().fit(X_train_s, y_train)
    rf = RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED).fit(X_train_s, y_train)
    ml_models[axis] = {"linear": linear, "rf": rf}

    phys_rmse = np.sqrt(np.mean(y_test**2))  # physics-only = residual itself, no correction
    lin_rmse = np.sqrt(mean_squared_error(y_test, linear.predict(X_test_s)))
    rf_rmse = np.sqrt(mean_squared_error(y_test, rf.predict(X_test_s)))
    cv_rf = cross_val_score(RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED),
                             X_train_s, y_train, cv=5, scoring="neg_root_mean_squared_error")

    ml_rows.append({"axis": axis, "physics_only_RMSE": phys_rmse, "linear_RMSE": lin_rmse,
                     "RF_RMSE": rf_rmse, "RF_cv_RMSE_mean": -cv_rf.mean(), "RF_cv_RMSE_std": cv_rf.std()})

ml_summary = pd.DataFrame(ml_rows).set_index("axis")
ml_summary.round(4)
ml_models = {} # axis -> {"linear": ..., "rf": ...} ml_rows = [] for axis, col in [("y", "r_y"), ("z", "r_z")]: y = d0[col].values y_train, y_test = y[train_idx], y[test_idx] linear = LinearRegression().fit(X_train_s, y_train) rf = RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED).fit(X_train_s, y_train) ml_models[axis] = {"linear": linear, "rf": rf} phys_rmse = np.sqrt(np.mean(y_test**2)) # physics-only = residual itself, no correction lin_rmse = np.sqrt(mean_squared_error(y_test, linear.predict(X_test_s))) rf_rmse = np.sqrt(mean_squared_error(y_test, rf.predict(X_test_s))) cv_rf = cross_val_score(RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED), X_train_s, y_train, cv=5, scoring="neg_root_mean_squared_error") ml_rows.append({"axis": axis, "physics_only_RMSE": phys_rmse, "linear_RMSE": lin_rmse, "RF_RMSE": rf_rmse, "RF_cv_RMSE_mean": -cv_rf.mean(), "RF_cv_RMSE_std": cv_rf.std()}) ml_summary = pd.DataFrame(ml_rows).set_index("axis") ml_summary.round(4)
Out[21]:
physics_only_RMSE linear_RMSE RF_RMSE RF_cv_RMSE_mean RF_cv_RMSE_std
axis
y 0.1459 0.1502 0.0214 0.0278 0.0041
z 0.3171 0.2643 0.1013 0.0886 0.0077
In [22]:
Copied!
# Predicted vs. actual residual, test set, both axes
fig, axes = plt.subplots(1, 2, figsize=(10, 5))
for ax, axis, col in zip(axes, ["y", "z"], ["r_y", "r_z"]):
    y_test = d0[col].values[test_idx]
    lin_pred = ml_models[axis]["linear"].predict(X_test_s)
    rf_pred = ml_models[axis]["rf"].predict(X_test_s)
    lims = [y_test.min(), y_test.max()]
    ax.plot(lims, lims, "k--", lw=1, label="perfect")
    ax.scatter(y_test, lin_pred, s=6, alpha=0.5, label="Linear")
    ax.scatter(y_test, rf_pred, s=6, alpha=0.5, label="RandomForest")
    ax.set_xlabel(f"actual {col} [mm]")
    ax.set_ylabel(f"predicted {col} [mm]")
    ax.legend()
fig.suptitle("rgs000_0 test set: predicted vs. actual residual")
plt.tight_layout()
plt.show()
# Predicted vs. actual residual, test set, both axes fig, axes = plt.subplots(1, 2, figsize=(10, 5)) for ax, axis, col in zip(axes, ["y", "z"], ["r_y", "r_z"]): y_test = d0[col].values[test_idx] lin_pred = ml_models[axis]["linear"].predict(X_test_s) rf_pred = ml_models[axis]["rf"].predict(X_test_s) lims = [y_test.min(), y_test.max()] ax.plot(lims, lims, "k--", lw=1, label="perfect") ax.scatter(y_test, lin_pred, s=6, alpha=0.5, label="Linear") ax.scatter(y_test, rf_pred, s=6, alpha=0.5, label="RandomForest") ax.set_xlabel(f"actual {col} [mm]") ax.set_ylabel(f"predicted {col} [mm]") ax.legend() fig.suptitle("rgs000_0 test set: predicted vs. actual residual") plt.tight_layout() plt.show()
No description has been provided for this image

6. Hybrid Model and Comparison (Exercise 5)¶

y_hybrid = y_phys(theta_star) + delta_y_ML. Compare physics-only vs. hybrid on the rgs000_0 test set, then test generalization on rgs180_0: apply rgs180_0's own Exercise-2 best-fit physical parameters (not rgs000_0's) as the physics baseline, and the ML residual model trained only on rgs000_0 (Exercise 4) on top of it. This is the meaningful version of the generalization question -- with rgs180_0's physics already correct (Exercise 3), does an ML correction learned on a different physical instance still help, hurt, or do nothing?

In [23]:
Copied!
# Hybrid vs. physics-only RMSE on the rgs000_0 test set (RandomForest as the ML component)
hybrid_rows = []
for axis, col in [("y", "r_y"), ("z", "r_z")]:
    r_test = d0[col].values[test_idx]
    ml_pred = ml_models[axis]["rf"].predict(X_test_s)
    hybrid_resid = r_test - ml_pred  # what's left after the ML correction
    hybrid_rows.append({
        "axis": axis,
        "physics_only_RMSE": np.sqrt(np.mean(r_test**2)),
        "hybrid_RMSE": np.sqrt(np.mean(hybrid_resid**2)),
    })
hybrid_summary = pd.DataFrame(hybrid_rows).set_index("axis")
hybrid_summary.round(4)
# Hybrid vs. physics-only RMSE on the rgs000_0 test set (RandomForest as the ML component) hybrid_rows = [] for axis, col in [("y", "r_y"), ("z", "r_z")]: r_test = d0[col].values[test_idx] ml_pred = ml_models[axis]["rf"].predict(X_test_s) hybrid_resid = r_test - ml_pred # what's left after the ML correction hybrid_rows.append({ "axis": axis, "physics_only_RMSE": np.sqrt(np.mean(r_test**2)), "hybrid_RMSE": np.sqrt(np.mean(hybrid_resid**2)), }) hybrid_summary = pd.DataFrame(hybrid_rows).set_index("axis") hybrid_summary.round(4)
Out[23]:
physics_only_RMSE hybrid_RMSE
axis
y 0.1459 0.0214
z 0.3171 0.1013
In [24]:
Copied!
# Overlaid residual histograms + side-by-side 2D maps, physics-only vs hybrid (y-axis, test set)
r_y_test = d0["r_y"].values[test_idx]
hybrid_r_y_test = r_y_test - ml_models["y"]["rf"].predict(X_test_s)
cent_y_test = d0["cent_y"].values[test_idx]
cent_z_test = d0["cent_z"].values[test_idx]

fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
axes[0].hist(r_y_test, bins=30, alpha=0.6, label="physics-only")
axes[0].hist(hybrid_r_y_test, bins=30, alpha=0.6, label="hybrid")
axes[0].set_xlabel("r_y [mm]")
axes[0].legend()
axes[0].set_title("Residual histogram (overlaid)")

for ax, resid, title in zip(axes[1:], [r_y_test, hybrid_r_y_test], ["physics-only", "hybrid"]):
    sc = ax.scatter(cent_y_test, cent_z_test, c=np.abs(resid), s=8, cmap="viridis",
                     vmin=0, vmax=np.abs(r_y_test).max())
    ax.set_xlabel("cent_y [mm]")
    ax.set_title(f"2D map, |r_y| -- {title}")
    fig.colorbar(sc, ax=ax, label="|r_y| [mm]")
axes[1].set_ylabel("cent_z [mm]")
fig.suptitle("rgs000_0 test set: physics-only vs. hybrid")
plt.tight_layout()
plt.show()
# Overlaid residual histograms + side-by-side 2D maps, physics-only vs hybrid (y-axis, test set) r_y_test = d0["r_y"].values[test_idx] hybrid_r_y_test = r_y_test - ml_models["y"]["rf"].predict(X_test_s) cent_y_test = d0["cent_y"].values[test_idx] cent_z_test = d0["cent_z"].values[test_idx] fig, axes = plt.subplots(1, 3, figsize=(16, 4.5)) axes[0].hist(r_y_test, bins=30, alpha=0.6, label="physics-only") axes[0].hist(hybrid_r_y_test, bins=30, alpha=0.6, label="hybrid") axes[0].set_xlabel("r_y [mm]") axes[0].legend() axes[0].set_title("Residual histogram (overlaid)") for ax, resid, title in zip(axes[1:], [r_y_test, hybrid_r_y_test], ["physics-only", "hybrid"]): sc = ax.scatter(cent_y_test, cent_z_test, c=np.abs(resid), s=8, cmap="viridis", vmin=0, vmax=np.abs(r_y_test).max()) ax.set_xlabel("cent_y [mm]") ax.set_title(f"2D map, |r_y| -- {title}") fig.colorbar(sc, ax=ax, label="|r_y| [mm]") axes[1].set_ylabel("cent_z [mm]") fig.suptitle("rgs000_0 test set: physics-only vs. hybrid") plt.tight_layout() plt.show()
No description has been provided for this image

Generalization test: physics from rgs180_0's own fit, ML from rgs000_0¶

Retrain the RF residual models on all of rgs000_0 (no held-out test set needed now -- rgs180_0 itself is the held-out set), and apply them to rgs180_0's residuals -- which, since Section 4 above, are already computed from rgs180_0's own Exercise-2 physical fit (fits["rgs180_0"]), not rgs000_0's. So physics_only_RMSE below is rgs180_0's real, correctly-fit baseline (~0.17-0.38mm, not the ~9mm mismatched-tilt one from the original version of this notebook) -- a much more informative baseline to test the ML correction against.

In [25]:
Copied!
X_all_s = scaler.transform(X)  # all of rgs000_0, same scaler (fit on the train split above)
d180 = datasets["rgs180_0"]  # residuals from rgs180_0's OWN Exercise-2 fit (fits["rgs180_0"]), not rgs000_0's
X180_s = scaler.transform(d180[["y_nisp", "z_nisp", "wavelength"]].values)

gen_rows = []
for axis, col in [("y", "r_y"), ("z", "r_z")]:
    rf_full = RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED)
    rf_full.fit(X_all_s, d0[col].values)
    r180 = d180[col].values
    ml_pred180 = rf_full.predict(X180_s)
    hybrid180 = r180 - ml_pred180
    gen_rows.append({
        "axis": axis,
        "physics_only_RMSE": np.sqrt(np.mean(r180**2)),
        "hybrid_RMSE": np.sqrt(np.mean(hybrid180**2)),
        "generalizes": np.sqrt(np.mean(hybrid180**2)) < 0.9 * np.sqrt(np.mean(r180**2)),
    })
gen_summary = pd.DataFrame(gen_rows).set_index("axis")
gen_summary.round(4)
X_all_s = scaler.transform(X) # all of rgs000_0, same scaler (fit on the train split above) d180 = datasets["rgs180_0"] # residuals from rgs180_0's OWN Exercise-2 fit (fits["rgs180_0"]), not rgs000_0's X180_s = scaler.transform(d180[["y_nisp", "z_nisp", "wavelength"]].values) gen_rows = [] for axis, col in [("y", "r_y"), ("z", "r_z")]: rf_full = RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED) rf_full.fit(X_all_s, d0[col].values) r180 = d180[col].values ml_pred180 = rf_full.predict(X180_s) hybrid180 = r180 - ml_pred180 gen_rows.append({ "axis": axis, "physics_only_RMSE": np.sqrt(np.mean(r180**2)), "hybrid_RMSE": np.sqrt(np.mean(hybrid180**2)), "generalizes": np.sqrt(np.mean(hybrid180**2)) < 0.9 * np.sqrt(np.mean(r180**2)), }) gen_summary = pd.DataFrame(gen_rows).set_index("axis") gen_summary.round(4)
Out[25]:
physics_only_RMSE hybrid_RMSE generalizes
axis
y 0.1712 0.0416 True
z 0.3814 0.1328 True

Model Comparison Table¶

The deck's closing table also asks for a Gradient Boosting row (the "try HistGradientBoosting" suggestion from Section 3) alongside Linear and Random Forest, so it is added here for completeness.

In [26]:
Copied!
from sklearn.ensemble import HistGradientBoostingRegressor

GEN_THRESHOLD = 0.9  # hybrid RMSE must beat physics-only by >10% on rgs180_0 to count as "generalizes"

# Physical model, before vs. after the Exercise-1/2 fit, on the rgs000_0 test rows
pred_test_before = predict_centroids(d0["y_nisp"].values[test_idx], d0["z_nisp"].values[test_idx],
                                      d0["wavelength"].values[test_idx], theta0_ex1, FREE_EX1, model)
r_y_test_before = pred_test_before[0] - d0["cent_y"].values[test_idx]
r_z_test_before = pred_test_before[1] - d0["cent_z"].values[test_idx]

rows = [
    {"model": "Physical (before fit)", "RMSE_y": np.sqrt(np.mean(r_y_test_before**2)),
     "RMSE_z": np.sqrt(np.mean(r_z_test_before**2)), "Generalizes?": "--"},
    {"model": "Physical (after fit)", "RMSE_y": np.sqrt(np.mean(d0["r_y"].values[test_idx] ** 2)),
     "RMSE_z": np.sqrt(np.mean(d0["r_z"].values[test_idx] ** 2)), "Generalizes?": "Yes (by construction)"},
]

ml_estimators = {
    "Physical + Linear": lambda: LinearRegression(),
    "Physical + Random Forest": lambda: RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED),
    "Physical + Gradient Boosting": lambda: HistGradientBoostingRegressor(max_depth=6, random_state=RNG_SEED),
}

for name, make_estimator in ml_estimators.items():
    row = {"model": name}
    gen_flags = []
    for axis, col in [("y", "r_y"), ("z", "r_z")]:
        est_test = make_estimator().fit(X_train_s, d0[col].values[train_idx])
        hybrid_test = d0[col].values[test_idx] - est_test.predict(X_test_s)
        row[f"RMSE_{axis}"] = np.sqrt(np.mean(hybrid_test**2))

        est_full = make_estimator().fit(X_all_s, d0[col].values)
        hybrid180 = d180[col].values - est_full.predict(X180_s)
        phys180_rmse = np.sqrt(np.mean(d180[col].values ** 2))
        gen_flags.append(np.sqrt(np.mean(hybrid180**2)) < GEN_THRESHOLD * phys180_rmse)
    row["Generalizes?"] = "y: Yes, z: Yes" if all(gen_flags) else (
        "y: Yes, z: No" if gen_flags[0] and not gen_flags[1] else
        "y: No, z: Yes" if gen_flags[1] and not gen_flags[0] else "No")
    rows.append(row)

comparison_table = pd.DataFrame(rows).set_index("model")
comparison_table.round(4)
from sklearn.ensemble import HistGradientBoostingRegressor GEN_THRESHOLD = 0.9 # hybrid RMSE must beat physics-only by >10% on rgs180_0 to count as "generalizes" # Physical model, before vs. after the Exercise-1/2 fit, on the rgs000_0 test rows pred_test_before = predict_centroids(d0["y_nisp"].values[test_idx], d0["z_nisp"].values[test_idx], d0["wavelength"].values[test_idx], theta0_ex1, FREE_EX1, model) r_y_test_before = pred_test_before[0] - d0["cent_y"].values[test_idx] r_z_test_before = pred_test_before[1] - d0["cent_z"].values[test_idx] rows = [ {"model": "Physical (before fit)", "RMSE_y": np.sqrt(np.mean(r_y_test_before**2)), "RMSE_z": np.sqrt(np.mean(r_z_test_before**2)), "Generalizes?": "--"}, {"model": "Physical (after fit)", "RMSE_y": np.sqrt(np.mean(d0["r_y"].values[test_idx] ** 2)), "RMSE_z": np.sqrt(np.mean(d0["r_z"].values[test_idx] ** 2)), "Generalizes?": "Yes (by construction)"}, ] ml_estimators = { "Physical + Linear": lambda: LinearRegression(), "Physical + Random Forest": lambda: RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED), "Physical + Gradient Boosting": lambda: HistGradientBoostingRegressor(max_depth=6, random_state=RNG_SEED), } for name, make_estimator in ml_estimators.items(): row = {"model": name} gen_flags = [] for axis, col in [("y", "r_y"), ("z", "r_z")]: est_test = make_estimator().fit(X_train_s, d0[col].values[train_idx]) hybrid_test = d0[col].values[test_idx] - est_test.predict(X_test_s) row[f"RMSE_{axis}"] = np.sqrt(np.mean(hybrid_test**2)) est_full = make_estimator().fit(X_all_s, d0[col].values) hybrid180 = d180[col].values - est_full.predict(X180_s) phys180_rmse = np.sqrt(np.mean(d180[col].values ** 2)) gen_flags.append(np.sqrt(np.mean(hybrid180**2)) < GEN_THRESHOLD * phys180_rmse) row["Generalizes?"] = "y: Yes, z: Yes" if all(gen_flags) else ( "y: Yes, z: No" if gen_flags[0] and not gen_flags[1] else "y: No, z: Yes" if gen_flags[1] and not gen_flags[0] else "No") rows.append(row) comparison_table = pd.DataFrame(rows).set_index("model") comparison_table.round(4)
Out[26]:
RMSE_y RMSE_z Generalizes?
model
Physical (before fit) 0.6832 0.4565 --
Physical (after fit) 0.1459 0.3171 Yes (by construction)
Physical + Linear 0.1502 0.2643 y: Yes, z: Yes
Physical + Random Forest 0.0214 0.1013 y: Yes, z: Yes
Physical + Gradient Boosting 0.0220 0.0747 y: Yes, z: Yes

Key findings¶

  • The physical fit alone (Exercise 1) already removes most of the error on rgs000_0: RMSE drops from the "before fit" row to well under the ~0.3mm pixel-size target on both axes.
  • Random Forest and Gradient Boosting both clear the physics-only and Linear baselines by roughly an order of magnitude on rgs000_0's held-out test set (cross-validation RMSE in Section 4 tracked test RMSE closely, so this is not train-set memorization -- the group-aware split ruled that out). Linear barely beats physics-only, meaning the residual really is non-linear in (y_nisp, z_nisp, wavelength).
  • Generalization to rgs180_0 is now tested against its own correct physics baseline (Exercise 2's fit, not rgs000_0's mismatched one). With that fixed, whether the rgs000_0-trained ML correction still helps, hurts, or does nothing on rgs180_0's (already small) leftover residual is a genuine test of whether that residual is a shared, instrument-level effect or specific to the rgs000_0 instance -- see the printed Generalizes? column above for the actual per-axis result.

Failure mode¶

The z-axis blowup from the earlier version of this notebook (rgs180_0 z-residual ~9mm, unfixable by any ML variant) was never an ML failure -- it was a missing dataset-specific physical parameter (grism.tilt_deg, and to a smaller extent grating.rho), now supplied per-dataset in Exercise 2. The remaining failure mode, if any, is visible directly in the Generalizes? column above: an axis where the hybrid model does not beat physics-only on rgs180_0 indicates that axis's leftover residual pattern differs between the rgs000 and rgs180 grism instances (both same-spec, per the classification table, but not identical) -- a case for training a dataset-specific ML residual model rather than reusing rgs000_0's, not a case for a larger/different ML model here.

Recommendation¶

Adopt the hybrid model (physical fit + Random Forest residual correction) for same-configuration prediction (rgs000_0-like data): it is simpler than Gradient Boosting for comparable accuracy here, so it is the better default per the "simplicity vs. accuracy" trade-off, with Gradient Boosting as a documented alternative if more data later shows it pulling ahead. Whether it is safe to reuse across configurations (e.g. on rgs180_*) is exactly what the Generalizes? column and the Conclusion section below quantify -- adopt it there only where it is shown to actually help.

7. Conclusion: Best Hybrid Model per Configuration¶

Per this exercise's scope (Exercise 4), the ML residual model is trained only on rgs000_0 -- no dataset-specific ML model exists for the other 5 configs. So "the best hybrid model" for each config is:

  • rgs000_0: physics (Exercise 2) + its own-trained RF residual correction (Exercise 4/5's test-set RMSE).
  • The other 5: extend Exercise 5's cross-instance generalization test from rgs180_0 to all of them. Apply the rgs000_0-trained RF to each config's own Exercise-2 residual; keep the hybrid correction per-axis only where it actually beats that config's own physics-only RMSE, otherwise report physics-only (Exercise 2) as the best available model for that axis -- adopting a cross-instance ML correction that doesn't help would silently regress accuracy relative to the physical fit already in hand.
In [27]:
Copied!
final_rows = []

# rgs000_0: own-trained RF, test-set accuracy (Exercise 4/5)
r_y_test = d0["r_y"].values[test_idx]
r_z_test = d0["r_z"].values[test_idx]
hybrid_y_test = r_y_test - ml_models["y"]["rf"].predict(X_test_s)
hybrid_z_test = r_z_test - ml_models["z"]["rf"].predict(X_test_s)
final_rows.append({
    "config": "rgs000_0",
    "physics_RMSE_y": ml_summary.loc["y", "physics_only_RMSE"], "physics_RMSE_z": ml_summary.loc["z", "physics_only_RMSE"],
    "hybrid_RMSE_y": ml_summary.loc["y", "RF_RMSE"], "hybrid_RMSE_z": ml_summary.loc["z", "RF_RMSE"],
    "physics_mean_dist_mm": np.mean(np.hypot(r_y_test, r_z_test)),
    "hybrid_mean_dist_mm": np.mean(np.hypot(hybrid_y_test, hybrid_z_test)),
    "used_ML_y": True, "used_ML_z": True, "ML_source": "own-trained (Exercise 4 test set)",
})

# rgs000_0-trained RF, applied to each other config's own (full-dataset) Exercise-2 residual --
# adopt the correction per-axis only where it actually beats that config's own physics-only RMSE
rf_full_by_axis = {axis: RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED).fit(X_all_s, d0[col].values)
                   for axis, col in [("y", "r_y"), ("z", "r_z")]}

for cfg in OTHER_CONFIGS:
    d = datasets[cfg]
    Xc_s = scaler.transform(d[["y_nisp", "z_nisp", "wavelength"]].values)
    row = {"config": cfg, "ML_source": "rgs000_0-trained (cross-instance)"}
    hybrid_resid = {}
    for axis, col in [("y", "r_y"), ("z", "r_z")]:
        r = d[col].values
        phys_rmse = np.sqrt(np.mean(r ** 2))
        ml_resid = r - rf_full_by_axis[axis].predict(Xc_s)
        hybrid_rmse = np.sqrt(np.mean(ml_resid ** 2))
        used_ml = hybrid_rmse < phys_rmse
        row[f"physics_RMSE_{axis}"] = phys_rmse
        row[f"hybrid_RMSE_{axis}"] = hybrid_rmse if used_ml else phys_rmse  # only adopt if it helps
        row[f"used_ML_{axis}"] = used_ml
        hybrid_resid[axis] = ml_resid if used_ml else r  # per-axis best residual, for the joint distance below
    row["physics_mean_dist_mm"] = np.mean(np.hypot(d["r_y"].values, d["r_z"].values))
    row["hybrid_mean_dist_mm"] = np.mean(np.hypot(hybrid_resid["y"], hybrid_resid["z"]))
    final_rows.append(row)

final_table = pd.DataFrame(final_rows).set_index("config")
final_table = final_table[["physics_RMSE_y", "hybrid_RMSE_y", "used_ML_y",
                            "physics_RMSE_z", "hybrid_RMSE_z", "used_ML_z",
                            "physics_mean_dist_mm", "hybrid_mean_dist_mm", "ML_source"]]
final_table.round(4)
final_rows = [] # rgs000_0: own-trained RF, test-set accuracy (Exercise 4/5) r_y_test = d0["r_y"].values[test_idx] r_z_test = d0["r_z"].values[test_idx] hybrid_y_test = r_y_test - ml_models["y"]["rf"].predict(X_test_s) hybrid_z_test = r_z_test - ml_models["z"]["rf"].predict(X_test_s) final_rows.append({ "config": "rgs000_0", "physics_RMSE_y": ml_summary.loc["y", "physics_only_RMSE"], "physics_RMSE_z": ml_summary.loc["z", "physics_only_RMSE"], "hybrid_RMSE_y": ml_summary.loc["y", "RF_RMSE"], "hybrid_RMSE_z": ml_summary.loc["z", "RF_RMSE"], "physics_mean_dist_mm": np.mean(np.hypot(r_y_test, r_z_test)), "hybrid_mean_dist_mm": np.mean(np.hypot(hybrid_y_test, hybrid_z_test)), "used_ML_y": True, "used_ML_z": True, "ML_source": "own-trained (Exercise 4 test set)", }) # rgs000_0-trained RF, applied to each other config's own (full-dataset) Exercise-2 residual -- # adopt the correction per-axis only where it actually beats that config's own physics-only RMSE rf_full_by_axis = {axis: RandomForestRegressor(n_estimators=200, max_depth=8, random_state=RNG_SEED).fit(X_all_s, d0[col].values) for axis, col in [("y", "r_y"), ("z", "r_z")]} for cfg in OTHER_CONFIGS: d = datasets[cfg] Xc_s = scaler.transform(d[["y_nisp", "z_nisp", "wavelength"]].values) row = {"config": cfg, "ML_source": "rgs000_0-trained (cross-instance)"} hybrid_resid = {} for axis, col in [("y", "r_y"), ("z", "r_z")]: r = d[col].values phys_rmse = np.sqrt(np.mean(r ** 2)) ml_resid = r - rf_full_by_axis[axis].predict(Xc_s) hybrid_rmse = np.sqrt(np.mean(ml_resid ** 2)) used_ml = hybrid_rmse < phys_rmse row[f"physics_RMSE_{axis}"] = phys_rmse row[f"hybrid_RMSE_{axis}"] = hybrid_rmse if used_ml else phys_rmse # only adopt if it helps row[f"used_ML_{axis}"] = used_ml hybrid_resid[axis] = ml_resid if used_ml else r # per-axis best residual, for the joint distance below row["physics_mean_dist_mm"] = np.mean(np.hypot(d["r_y"].values, d["r_z"].values)) row["hybrid_mean_dist_mm"] = np.mean(np.hypot(hybrid_resid["y"], hybrid_resid["z"])) final_rows.append(row) final_table = pd.DataFrame(final_rows).set_index("config") final_table = final_table[["physics_RMSE_y", "hybrid_RMSE_y", "used_ML_y", "physics_RMSE_z", "hybrid_RMSE_z", "used_ML_z", "physics_mean_dist_mm", "hybrid_mean_dist_mm", "ML_source"]] final_table.round(4)
Out[27]:
physics_RMSE_y hybrid_RMSE_y used_ML_y physics_RMSE_z hybrid_RMSE_z used_ML_z physics_mean_dist_mm hybrid_mean_dist_mm ML_source
config
rgs000_0 0.1459 0.0214 True 0.3171 0.1013 True 0.3078 0.0827 own-trained (Exercise 4 test set)
rgs000_m4 0.1841 0.0106 True 0.3002 0.0507 True 0.3109 0.0443 rgs000_0-trained (cross-instance)
rgs000_p4 0.1806 0.0138 True 0.2908 0.0801 True 0.3011 0.0646 rgs000_0-trained (cross-instance)
rgs180_0 0.1712 0.0416 True 0.3814 0.1328 True 0.3642 0.1137 rgs000_0-trained (cross-instance)
rgs180_m4 0.1778 0.0319 True 0.3194 0.1216 True 0.3210 0.1007 rgs000_0-trained (cross-instance)
rgs180_p4 0.1761 0.0337 True 0.3253 0.1222 True 0.3252 0.1016 rgs000_0-trained (cross-instance)

Reading the table: used_ML_* marks which axes actually benefit from reusing rgs000_0's ML residual model on a different physical instance; where it's False, the physics-only Exercise-2 fit is already the best model available for that config/axis (an untrained cross-instance ML model should not be adopted just because one exists). *_mean_dist_mm is the mean Euclidean distance mean(hypot(r_y, r_z)) between predicted and observed (cent_y, cent_z) -- a single combined-axes accuracy number alongside the per-axis RMSEs, computed the same way for physics-only and for the best-available (per-axis) hybrid correction. This is this project's "interpretability vs. performance" trade-off made explicit per config, rather than assumed globally: rgs000_0 gets the full hybrid benefit because it has its own trained residual model; every other config gets it only where the transfer is demonstrated, not assumed. A dataset-specific ML residual model for rgs180_* (mirroring Exercise 4 on rgs000_0) would be the natural next step if those configs need it, and is deferred rather than assumed to already work.

8. Recap¶

  • Parameter classification corrected: rgs000/rgs180 are two distinct physical grism assemblies (same design, mounted 180° apart), not one grism read out twice -- their prism/grating parameters are expected close but not identical, and rgs180's tilt_deg is rgs000's own fitted tilt
    • 180°, not an independent value (Grism.forward's tilt+180 symmetry: same dispersion axis, opposite sense vs. λ).
  • Exercise 1 fit detector.offset + grism.tilt_deg + grating.rho (4 free params, up from the original 2) on rgs000_0, after identifying and excluding a single mislabeled-position outlier spectrum. prism.A_deg (and material.n0/k) were tried too and found non-identifiable from this single narrow-band dataset (bootstrap correlation ~0.99 with detector.offset_z/grating.rho) -- fixed at nominal instead, with the reasoning recorded alongside the fit.
  • Exercise 2 repeated that same free-parameter set independently on the other 5 datasets, resolving a genuine degenerate-minima subtlety for rgs180_* (tilt+180-seeded vs. nominal-seeded Nelder-Mead reach equally good RMSE; only the tilt+180 branch is physically sensible) and confirming both corrected classification predictions quantitatively. All 6 fits recorded in models/stage3_fit_<config>.toml.
  • Exercise 3: applying each dataset's own fit to itself brings all 6 configs to comparable, small RMSE (no more rgs180's ~9mm z-blowup), with visibly similar leftover residual patterns across all 6 -- consistent with a shared, instrument-level effect rather than a per-instance one.
  • Exercise 4: ML residual models (Linear, Random Forest, Gradient Boosting) trained on rgs000_0 only, with a spectrum-grouped train/test split (row-wise splitting would have leaked position information, given ~30 rows share each field position).
  • Exercise 5 / Conclusion: generalization tested against each config's own correct physics baseline (not rgs000_0's, as an earlier version of this notebook did) -- see Section 6-7's tables for the actual per-axis, per-config result of whether the rgs000_0-trained ML correction transfers, and the final best-available hybrid model + RMSE per configuration.
  • Promoted to dispcraft/: predict_centroids/cost and the GroundTestModel context, into dispcraft/calibration.py (tests/test_calibration.py) -- the one piece stable and reused unchanged across every exercise, and what Stage 4's joint calibration will build on directly. The per-dataset fit orchestration (bootstrap uncertainty, TOML recording, outlier diagnostics) and the ML/hybrid pipeline stay in the notebook -- both are still shaped around single-dataset fits and comparative model selection respectively, and are likely to be reshaped once Stage 4 defines the joint-fit shape, so promoting them now risks writing them twice.

Full multi-dataset joint calibration (fitting shared parameters across all 6 datasets simultaneously, rather than 6 independent single-dataset fits) is Stage 4's "Autonomous work," not started here.

Previous Next

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