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¶
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/_p4are nominal / −4° / +4° commanded tilts of the same physical grism. - Grism identity:
rgs000andrgs180are 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 describedrgs180as "the same grism mounted 180° fromrgs000", which conflated the two axes; corrected here.) Same-design parts still vary piece-to-piece within manufacturing tolerance, sorgs000's andrgs180'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.
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
# 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
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.
# 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)
| 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.
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.
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:
| 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.
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:
| 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 |
# 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()
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.
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())
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:
rgs180_*'stilt_degshould land near the correspondingrgs000_*fit'stilt_deg+ 180° (same dispersion axis, opposite sense vs. λ).rgs180_*'srhoshould land close to but not exactly atrgs000_*'srho(two distinct, same-spec grating pieces).
First, the same per-spectrum outlier check as Exercise 1, run on the other 5 datasets.
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)
| 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.
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.
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)}
# 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.
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.
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)
| 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 |
# 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()
# 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()
# 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()
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.3mmRMSE_z(from applyingrgs000_0's tilt to a grism mounted 180° away) collapses to ~0.29-0.38mm -- right alongsidergs000_*'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 oldrgs180gap was entirely the missing dataset-specifictilt_deg/rho, not a different kind of physical effect. - Same pattern across all 6. The
r_yvs.y_nispscatter and the 2D|r_y|maps look structurally alike acrossrgs000_*andrgs180_*-- 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 theA_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).
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
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)
| 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 |
# 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()
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?
# 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)
| physics_only_RMSE | hybrid_RMSE | |
|---|---|---|
| axis | ||
| y | 0.1459 | 0.0214 |
| z | 0.3171 | 0.1013 |
# 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()
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.
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)
| 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.
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)
| 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_0is now tested against its own correct physics baseline (Exercise 2's fit, notrgs000_0's mismatched one). With that fixed, whether thergs000_0-trained ML correction still helps, hurts, or does nothing onrgs180_0's (already small) leftover residual is a genuine test of whether that residual is a shared, instrument-level effect or specific to thergs000_0instance -- see the printedGeneralizes?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_0to all of them. Apply thergs000_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.
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)
| 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/rgs180are 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, andrgs180'stilt_degisrgs000's own fitted tilt- 180°, not an independent value (
Grism.forward's tilt+180 symmetry: same dispersion axis, opposite sense vs. λ).
- 180°, not an independent value (
- Exercise 1 fit
detector.offset+grism.tilt_deg+grating.rho(4 free params, up from the original 2) onrgs000_0, after identifying and excluding a single mislabeled-position outlier spectrum.prism.A_deg(andmaterial.n0/k) were tried too and found non-identifiable from this single narrow-band dataset (bootstrap correlation ~0.99 withdetector.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 inmodels/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_0only, 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 thergs000_0-trained ML correction transfers, and the final best-available hybrid model + RMSE per configuration. - Promoted to
dispcraft/:predict_centroids/costand theGroundTestModelcontext, intodispcraft/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.