Stage 8 — Chebyshev-Polynomial Residual Model¶
Tests whether replacing Stage 4's generic MLPRegressor residual
(hybrid_joint_ml) with a residual model that mirrors the reference paper's
own functional form (arXiv:2506.08378, docs/2506.08378v2-nisp_grism_trace_model.md
Eq. 2+3 — Chebyshev-in-wavelength whose coefficients are themselves
Chebyshev-in-field-position) does better, worse, or comparably, on the same
held-out protocol notebooks/6-Status_Report.ipynb established.
Starts from the joint 3-tier physical fit exactly as already frozen
(models/joint_specific_fit_<cfg>.toml) — "the process that gave the best
results" — and swaps only the residual-correction step
(dispcraft/chebyshev_residual.py). Two orders are compared, in this order:
order 1 (wave_order=spatial_order=1, 8 coefficients/axis) first, then
order 0 (a single constant/axis — degenerate with the physical model's
own offset_y_mm/offset_z_mm, included as a sanity floor). Scope: the 6
RGS configs only (matches the joint-fit process; bgs000_0 has its own
separate single-dataset fit, not part of this comparison).
Setup¶
import tomllib
from pathlib import Path
import numpy as np
import pandas as pd
import mlflow
from sklearn.model_selection import GroupShuffleSplit
import dispcraft.chebyshev_residual as cr
from dispcraft.calibration import ground_test_model_from_config, predict_centroids
REPO_ROOT = Path("..")
DATA_DIR = REPO_ROOT / "data"
MODELS_DIR = REPO_ROOT / "models"
RNG_SEED = 42 # same seed as 4.1/4.3/4.5/5.2/5.3/6 -- reproduces their splits exactly
CONFIGS = ["rgs000_0", "rgs000_m4", "rgs000_p4", "rgs180_0", "rgs180_m4", "rgs180_p4"]
with open(MODELS_DIR / "stage1_instrument.toml", "rb") as f:
base_config = tomllib.load(f)
model = ground_test_model_from_config(base_config)
mlflow.set_tracking_uri(f"sqlite:///{(REPO_ROOT / 'mlflow.db').resolve()}")
/home/zoubian/Workspace/dispers/dispcraft/.pixi/envs/default/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html from .autonotebook import tqdm as notebook_tqdm
1. Load frozen joint fits and data¶
Reuses notebooks/6-Status_Report.ipynb's own loading pattern:
models/stage3_fit_<cfg>.toml for each config's excluded-spectra list (row
count check), models/joint_specific_fit_<cfg>.toml for the frozen Tier
1+2+3 physical fit this notebook builds on unchanged.
def load_toml(path):
with open(path, "rb") as f:
return tomllib.load(f)
def numeric_result(fit_result):
return {k: v for k, v in fit_result.items() if not k.endswith("_bootstrap_std")}
from dispcraft.measurement import load_spectra, median_per_spectrum
dfs = {}
joint_fits = {}
for cfg in CONFIGS:
indep = load_toml(MODELS_DIR / f"stage3_fit_{cfg}.toml")
joint = load_toml(MODELS_DIR / f"joint_specific_fit_{cfg}.toml")
df = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv"))
df = df[~df["spectra_id"].isin(indep["fit"]["excluded_spectra_ids"])].reset_index(drop=True)
assert len(df) == indep["fit"]["n_points"], f"{cfg}: row count must match stage3_fit_{cfg}.toml"
dfs[cfg] = df
joint_fits[cfg] = {"fixed": {**joint["fit"]["fixed"], **numeric_result(joint["fit"]["result"])}}
print(f"loaded {len(dfs)} datasets, row counts: {[len(dfs[c]) for c in CONFIGS]}")
loaded 6 datasets, row counts: [5297, 4772, 4577, 6198, 4596, 4604]
2. Held-out split and baselines¶
Identical protocol to 6-Status_Report.ipynb's test_split
(GroupShuffleSplit, test_size=0.2, random_state=42, grouped by
spectra_id) — a field position never straddles train/test. Two baselines
read for the comparison table: the physical joint fit alone (recomputed
here, held-out), and hybrid_joint_ml's held-out RMSE (read back from the
stage4 MLflow experiment, not retrained — matches 6's own
stage4_hybrid_rmse).
def test_split(df):
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=RNG_SEED)
train_idx, test_idx = next(gss.split(df, groups=df["spectra_id"].values))
return df.iloc[train_idx].reset_index(drop=True), df.iloc[test_idx].reset_index(drop=True)
def physical_residual(df, fixed):
"""r_y, r_z = pred_phys - data -- dispcraft.calibration.cost's own sign
convention, and the one dispcraft.chebyshev_residual.fit_chebyshev_residual
expects its input residual columns in (see that module's docstring)."""
pred = predict_centroids(df["y_nisp"], df["z_nisp"], df["wavelength"], theta=[], free_names=[],
model=model, fixed=fixed)
return pred[0] - df["cent_y"].values, pred[1] - df["cent_z"].values
def axis_rmse(r_y, r_z):
return float(np.sqrt(np.mean(r_y**2))), float(np.sqrt(np.mean(r_z**2)))
train_test = {cfg: test_split(dfs[cfg]) for cfg in CONFIGS}
physical_rows = []
for cfg in CONFIGS:
_, df_test = train_test[cfg]
r_y, r_z = physical_residual(df_test, joint_fits[cfg]["fixed"])
y, z = axis_rmse(r_y, r_z)
physical_rows.append({"cfg": cfg, "physical_rmse_y_mm": y, "physical_rmse_z_mm": z})
physical_test = pd.DataFrame(physical_rows).set_index("cfg").loc[CONFIGS]
physical_test.round(5)
| physical_rmse_y_mm | physical_rmse_z_mm | |
|---|---|---|
| cfg | ||
| rgs000_0 | 0.15920 | 0.29802 |
| rgs000_m4 | 0.22662 | 0.26412 |
| rgs000_p4 | 0.18968 | 0.34455 |
| rgs180_0 | 0.20131 | 0.29223 |
| rgs180_m4 | 0.18399 | 0.31683 |
| rgs180_p4 | 0.20515 | 0.28717 |
def stage4_hybrid_rmse():
"""Per-axis, held-out RMSE of hybrid_joint_ml (joint physical fit +
independent MLP(y)+MLP(z) residual, Stage 4 Phase 3 Section 4) -- read
back from MLflow, not retrained. Mirrors notebooks/6-Status_Report
.ipynb's own stage4_hybrid_rmse exactly."""
exp = mlflow.get_experiment_by_name("stage4")
assert exp is not None, "run notebooks/4.3-Multi_Dataset_Fitting.ipynb first to populate the 'stage4' experiment"
runs = mlflow.search_runs(experiment_ids=[exp.experiment_id]).set_index("params.dataset")
return runs.loc[CONFIGS, ["metrics.hybrid_rmse_y", "metrics.hybrid_rmse_z"]].rename(
columns={"metrics.hybrid_rmse_y": "hybrid_ml_rmse_y_mm", "metrics.hybrid_rmse_z": "hybrid_ml_rmse_z_mm"}
).astype(float)
hybrid_ml_test = stage4_hybrid_rmse()
hybrid_ml_test.round(5)
| hybrid_ml_rmse_y_mm | hybrid_ml_rmse_z_mm | |
|---|---|---|
| params.dataset | ||
| rgs000_0 | 0.01761 | 0.06361 |
| rgs000_m4 | 0.01408 | 0.06165 |
| rgs000_p4 | 0.01744 | 0.08690 |
| rgs180_0 | 0.01664 | 0.07965 |
| rgs180_m4 | 0.02154 | 0.07430 |
| rgs180_p4 | 0.02394 | 0.08309 |
3. Chebyshev residual, order 1¶
wave_order=spatial_order=1 (dispcraft.chebyshev_residual.n_chebyshev_terms(1,1) = 8
coefficients/axis). Fit on the train split's physical-fit residual (OLS, no
random seed needed), evaluated on the held-out test split — same
train/evaluate protocol as hybrid_joint_ml and the field-dependent tier's
own held-out re-evaluation (6, Section 4).
def chebyshev_held_out_rmse(cfg, wave_order, spatial_order):
df_train, df_test = train_test[cfg]
fixed = joint_fits[cfg]["fixed"]
r_y_train, r_z_train = physical_residual(df_train, fixed)
d_train = df_train.assign(r_y=r_y_train, r_z=r_z_train)
cheb_model = cr.fit_chebyshev_residual(d_train, wave_order, spatial_order, *cr.RGS_PASSBAND_NM)
pred_test = cr.predict_centroids_chebyshev(df_test["y_nisp"], df_test["z_nisp"], df_test["wavelength"],
model, fixed, cheb_model)
r_y_test = pred_test[0] - df_test["cent_y"].values
r_z_test = pred_test[1] - df_test["cent_z"].values
rmse_y, rmse_z = axis_rmse(r_y_test, r_z_test)
return rmse_y, rmse_z, cheb_model
def run_chebyshev_order(wave_order, spatial_order, log_mlflow=True):
if log_mlflow:
mlflow.set_experiment("chebyshev_residual")
rows, models_by_cfg = [], {}
for cfg in CONFIGS:
rmse_y, rmse_z, cheb_model = chebyshev_held_out_rmse(cfg, wave_order, spatial_order)
rows.append({"cfg": cfg, "chebyshev_rmse_y_mm": rmse_y, "chebyshev_rmse_z_mm": rmse_z})
models_by_cfg[cfg] = cheb_model
if log_mlflow:
with mlflow.start_run(run_name=f"{cfg}_order{wave_order}"):
mlflow.log_params({"dataset": cfg, "wave_order": wave_order, "spatial_order": spatial_order,
"n_terms_per_axis": cr.n_chebyshev_terms(wave_order, spatial_order)})
mlflow.log_metrics({"rmse_y": rmse_y, "rmse_z": rmse_z, "combined": float(np.hypot(rmse_y, rmse_z))})
table = pd.DataFrame(rows).set_index("cfg").loc[CONFIGS]
return table, models_by_cfg
chebyshev1_test, chebyshev1_models = run_chebyshev_order(wave_order=1, spatial_order=1)
chebyshev1_test.round(5)
2026/09/01 11:02:55 INFO mlflow.tracking.fluent: Experiment with name 'chebyshev_residual' does not exist. Creating a new experiment.
| chebyshev_rmse_y_mm | chebyshev_rmse_z_mm | |
|---|---|---|
| cfg | ||
| rgs000_0 | 0.15129 | 0.12368 |
| rgs000_m4 | 0.13342 | 0.12503 |
| rgs000_p4 | 0.13767 | 0.13376 |
| rgs180_0 | 0.14486 | 0.11838 |
| rgs180_m4 | 0.14987 | 0.13861 |
| rgs180_p4 | 0.10949 | 0.12855 |
4. Chebyshev residual, order 0¶
wave_order=spatial_order=0: a single constant/axis, degenerate with the
physical model's own offset_y_mm/offset_z_mm — expected to land close to
the physical-only baseline, included as a sanity floor rather than a serious
candidate.
chebyshev0_test, chebyshev0_models = run_chebyshev_order(wave_order=0, spatial_order=0)
chebyshev0_test.round(5)
| chebyshev_rmse_y_mm | chebyshev_rmse_z_mm | |
|---|---|---|
| cfg | ||
| rgs000_0 | 0.16103 | 0.29910 |
| rgs000_m4 | 0.22679 | 0.26415 |
| rgs000_p4 | 0.19204 | 0.34580 |
| rgs180_0 | 0.20221 | 0.29352 |
| rgs180_m4 | 0.19200 | 0.31705 |
| rgs180_p4 | 0.20926 | 0.29425 |
5. Comparison table¶
Same shape as Status_Report.md's existing tables: physical-only, the
production hybrid_joint_ml, both Chebyshev orders, and the reference
paper's held-out (Argon) figure.
PX_MM_CORRECT = 0.018 # 18 um pixel pitch (Jahnke+2024, via Euclid-NISP-Specs.md), matches notebooks/6
paper_held_out_px = 0.5
paper_held_out_mm = paper_held_out_px * PX_MM_CORRECT
summary_rows = [
{"model": "Physical model, joint fitting (Tier 1+2+3)",
"rmse_y_mm": physical_test["physical_rmse_y_mm"].mean(), "rmse_z_mm": physical_test["physical_rmse_z_mm"].mean(),
"note": "mean over 6 datasets, held-out test split"},
{"model": "Hybrid, joint fitting + ML (hybrid_joint_ml, production default)",
"rmse_y_mm": hybrid_ml_test["hybrid_ml_rmse_y_mm"].mean(), "rmse_z_mm": hybrid_ml_test["hybrid_ml_rmse_z_mm"].mean(),
"note": "mean over 6 datasets, held-out test split; Status_Report.md Section 5"},
{"model": "Hybrid, joint fitting + Chebyshev residual, order 1",
"rmse_y_mm": chebyshev1_test["chebyshev_rmse_y_mm"].mean(), "rmse_z_mm": chebyshev1_test["chebyshev_rmse_z_mm"].mean(),
"note": "8 coefficients/axis, mean over 6 datasets, held-out test split"},
{"model": "Hybrid, joint fitting + Chebyshev residual, order 0",
"rmse_y_mm": chebyshev0_test["chebyshev_rmse_y_mm"].mean(), "rmse_z_mm": chebyshev0_test["chebyshev_rmse_z_mm"].mean(),
"note": "1 coefficient/axis (sanity floor), mean over 6 datasets, held-out test split"},
{"model": "Reference (arXiv 2506.08378), held-out (Argon)",
"rmse_y_mm": paper_held_out_mm, "rmse_z_mm": paper_held_out_mm,
"note": f"combined figure, not axis-split in source ({paper_held_out_px} px = {paper_held_out_mm:.4f} mm)"},
]
comparison_table = pd.DataFrame(summary_rows).set_index("model")
comparison_table["combined_mm"] = np.hypot(comparison_table["rmse_y_mm"], comparison_table["rmse_z_mm"])
comparison_table.round(5)
| rmse_y_mm | rmse_z_mm | note | combined_mm | |
|---|---|---|---|---|
| model | ||||
| Physical model, joint fitting (Tier 1+2+3) | 0.19433 | 0.30049 | mean over 6 datasets, held-out test split | 0.35785 |
| Hybrid, joint fitting + ML (hybrid_joint_ml, production default) | 0.01854 | 0.07487 | mean over 6 datasets, held-out test split; Sci... | 0.07713 |
| Hybrid, joint fitting + Chebyshev residual, order 1 | 0.13777 | 0.12800 | 8 coefficients/axis, mean over 6 datasets, hel... | 0.18805 |
| Hybrid, joint fitting + Chebyshev residual, order 0 | 0.19722 | 0.30231 | 1 coefficient/axis (sanity floor), mean over 6... | 0.36095 |
| Reference (arXiv 2506.08378), held-out (Argon) | 0.00900 | 0.00900 | combined figure, not axis-split in source (0.5... | 0.01273 |
6. Findings¶
Order 0 reproduces the physical-only baseline almost exactly (0.361mm vs.
0.358mm combined) — confirms the expected degeneracy with the physical
model's own offset_y_mm/offset_z_mm; not a competitive candidate, a
sanity floor.
Order 1 is a genuine, substantial improvement over the raw physical fit
(0.358mm -> 0.188mm combined, z especially: 0.300mm -> 0.128mm) — real
low-order wavelength/field structure exists in the residual, consistent with
Stage 5 Phase 2 Step 1's own linear-term diagnostic. Order 1 beats order 0 in
all 6 configs, on both axes.
Neither order comes close to hybrid_joint_ml (0.077mm combined): order
1 is 2.4x worse, and its own gap to the reference paper's held-out figure
(~21x) is far wider than hybrid_joint_ml's (~8.6x). An 8-coefficient linear
basis per axis is evidently far less expressive than the generic MLP
residual's effective capacity on this data. A genuine, informative negative
result (same treatment Stage 5 Phase 2 gave the field-dependent tier), not a
bug — order 2 and non-diagonal (wave_order, spatial_order) combinations are
untested follow-ups, out of scope for this comparison (only orders 1 and 0
were agreed).
Recorded in docs/Status_Report.md Section 8 and frozen to
models/chebyshev_residual_fit_<cfg>.toml via
scripts/freeze_chebyshev_fits.py. hybrid_joint_ml remains the
recommended production model (docs/User_Guide.md).
print("=== Chebyshev residual vs. hybrid_joint_ml, held-out, mean over 6 datasets ===")
for label, tbl, cols in [
("physical (joint fit alone)", physical_test, ("physical_rmse_y_mm", "physical_rmse_z_mm")),
("hybrid_joint_ml", hybrid_ml_test, ("hybrid_ml_rmse_y_mm", "hybrid_ml_rmse_z_mm")),
("chebyshev order 1", chebyshev1_test, ("chebyshev_rmse_y_mm", "chebyshev_rmse_z_mm")),
("chebyshev order 0", chebyshev0_test, ("chebyshev_rmse_y_mm", "chebyshev_rmse_z_mm")),
]:
y, z = tbl[cols[0]].mean(), tbl[cols[1]].mean()
print(f"{label:32s} y={y:.4f} mm z={z:.4f} mm combined={np.hypot(y, z):.4f} mm")
print()
print(f"reference paper held-out (Argon, combined): {paper_held_out_mm:.4f} mm")
=== Chebyshev residual vs. hybrid_joint_ml, held-out, mean over 6 datasets === physical (joint fit alone) y=0.1943 mm z=0.3005 mm combined=0.3578 mm hybrid_joint_ml y=0.0185 mm z=0.0749 mm combined=0.0771 mm chebyshev order 1 y=0.1378 mm z=0.1280 mm combined=0.1881 mm chebyshev order 0 y=0.1972 mm z=0.3023 mm combined=0.3610 mm reference paper held-out (Argon, combined): 0.0090 mm