dispcraft
  • Home

User Guide

  • Getting Started
  • Using the Calibrated Models
  • Notebooks
    • Overview
    • 1. Subject & Instrument Model
    • 2. Data Introduction
    • 3. ML Introduction
    • 4.1 ML Model Comparison
    • 4.2 Per-Dataset Pipeline
    • 4.3 Multi-Dataset Joint Fitting
    • 4.5 Comparison With Published Results
      • 0. Setup — load this project's models and results
      • 1. This project's results
      • 2. Reference paper benchmarks
      • 3. Definitions and coverage check (Task 5's checklist)
      • 4. Comparison table
      • 5. Discussion — sources of discrepancy and a proposed improvement
      • 6. Recap
    • 5.1 PyTorch Migration
    • 5.2 Field-Dependent Parameters
    • 5.3 Zeroth-Order Dispersion
    • 5.4 BGS Model
    • 6. Status Report Assembly
    • 8. Chebyshev Residual Model
  • Status Report
  • Beginner Introduction (Slides)
  • Interactive Model (Webapp)

Reference

  • Euclid NISP Specs
  • Reference Paper Summary

API Reference

  • Overview
  • optics
  • measurement
  • calibration
  • field_calibration
  • zeroth_dispersion
  • chebyshev_residual
  • ml
  • model_registry
  • prediction
dispcraft
  • User Guide
  • Notebooks
  • 4.5 Comparison With Published Results

Stage 4, Phase 5 — Comparison with Published Results (4.5-Comparison_With_Published_Results)¶

Per 4-Projet/index.html, Section 5 / Task 5: compare this project's calibration accuracy against the reference paper, arXiv 2506.08378 (Gillard et al., "The NISP spectroscopy channel, on ground performance and calibration"). The paper's relevant numbers are extracted in 2506.08378v2-nisp_grism_trace_model.md Section 4 ("Model accuracy — benchmarks for comparison with new models") — this notebook uses that extraction as its source, not the raw PDF.

Phase 4 (joint physics+ML refinement with a regularization penalty) is skipped by decision — deferred to later, per CLAUDE.md. Its row in the comparison table below is marked "not attempted", not filled with a result.

Per Task 5's own instructions, before comparing we verify: residual definition, metric, units, and dataset/wavelength coverage match (Section 3) — this turns up a real unit-conversion problem in the deck itself (Section 2), which we correct before building the comparison table (Section 4) and discussing the gap (Section 5).

0. Setup — load this project's models and results¶

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

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import mlflow
from sklearn.model_selection import GroupShuffleSplit

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

REPO_ROOT = Path("..")
DATA_DIR = REPO_ROOT / "data"
MODELS_DIR = REPO_ROOT / "models"
RNG_SEED = 42  # same seed as notebooks/4.1 and 4.3 -- reproduces their train/test 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()}")
import tomllib from pathlib import Path import numpy as np import pandas as pd import matplotlib.pyplot as plt import mlflow from sklearn.model_selection import GroupShuffleSplit from dispcraft.calibration import ground_test_model_from_config, predict_centroids from dispcraft.measurement import load_spectra, median_per_spectrum REPO_ROOT = Path("..") DATA_DIR = REPO_ROOT / "data" MODELS_DIR = REPO_ROOT / "models" RNG_SEED = 42 # same seed as notebooks/4.1 and 4.3 -- reproduces their train/test 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()}")

1. This project's results¶

Reload the frozen fit results from models/*.toml (no refitting) and the logged ML metrics from MLflow (no retraining) — everything needed for the comparison already exists on disk from Phases 1-3; this notebook only re-evaluates the physical models on a consistent held-out split and reads back the ML numbers.

Per the deck: "Use test-set RMSE only." All numbers below are evaluated on the same held-out 20% test split Phase 3 Section 4 used (GroupShuffleSplit(test_size=0.2, random_state=42), grouped by spectra_id so no spectrum straddles train/test).

Caveat carried over from Phase 3: the physical parameters (independent and joint fits alike) were optimized on the full dataset, not just the train partition — only the MLP residual correctors are trained strictly on the train split. So "test-set RMSE" here is a fair, apples-to-apples comparison between this project's own strategies, but it is not as strict a held-out test as the paper's Argon-line validation, which used data entirely separate from the Fabry-Pérot fit. Keep this asymmetry in mind in Section 5.

In [2]:
Copied!
def load_toml(path):
    with open(path, "rb") as f:
        return tomllib.load(f)


def numeric_result(fit_result):
    """Drop the *_bootstrap_std entries, keep only the fitted values."""
    return {k: v for k, v in fit_result.items() if not k.endswith("_bootstrap_std")}


dfs = {}
independent_fits = {}
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

    independent_fits[cfg] = {
        "free_names": indep["fit"]["free_parameters"],
        "theta": np.array([indep["fit"]["result"][k] for k in indep["fit"]["free_parameters"]]),
    }
    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]}")
def load_toml(path): with open(path, "rb") as f: return tomllib.load(f) def numeric_result(fit_result): """Drop the *_bootstrap_std entries, keep only the fitted values.""" return {k: v for k, v in fit_result.items() if not k.endswith("_bootstrap_std")} dfs = {} independent_fits = {} 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 independent_fits[cfg] = { "free_names": indep["fit"]["free_parameters"], "theta": np.array([indep["fit"]["result"][k] for k in indep["fit"]["free_parameters"]]), } 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]
In [3]:
Copied!
def test_split(df):
    gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=RNG_SEED)
    _, test_idx = next(gss.split(df, groups=df["spectra_id"].values))
    return df.iloc[test_idx]


def physical_axis_rmse(df_test, theta, free_names, fixed=None):
    pred = predict_centroids(df_test["y_nisp"], df_test["z_nisp"], df_test["wavelength"],
                              theta, free_names, model, fixed=fixed)
    r_y = pred[0] - df_test["cent_y"].values
    r_z = pred[1] - df_test["cent_z"].values
    return float(np.sqrt(np.mean(r_y**2))), float(np.sqrt(np.mean(r_z**2)))


physical_rows = []
for cfg in CONFIGS:
    df_test = test_split(dfs[cfg])
    iy, iz = physical_axis_rmse(df_test, independent_fits[cfg]["theta"], independent_fits[cfg]["free_names"])
    jy, jz = physical_axis_rmse(df_test, np.array([]), [], fixed=joint_fits[cfg]["fixed"])
    physical_rows.append({"cfg": cfg, "independent_rmse_y_mm": iy, "independent_rmse_z_mm": iz,
                           "joint_rmse_y_mm": jy, "joint_rmse_z_mm": jz})

physical_test = pd.DataFrame(physical_rows).set_index("cfg").loc[CONFIGS]
physical_test.round(5)
def test_split(df): gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=RNG_SEED) _, test_idx = next(gss.split(df, groups=df["spectra_id"].values)) return df.iloc[test_idx] def physical_axis_rmse(df_test, theta, free_names, fixed=None): pred = predict_centroids(df_test["y_nisp"], df_test["z_nisp"], df_test["wavelength"], theta, free_names, model, fixed=fixed) r_y = pred[0] - df_test["cent_y"].values r_z = pred[1] - df_test["cent_z"].values return float(np.sqrt(np.mean(r_y**2))), float(np.sqrt(np.mean(r_z**2))) physical_rows = [] for cfg in CONFIGS: df_test = test_split(dfs[cfg]) iy, iz = physical_axis_rmse(df_test, independent_fits[cfg]["theta"], independent_fits[cfg]["free_names"]) jy, jz = physical_axis_rmse(df_test, np.array([]), [], fixed=joint_fits[cfg]["fixed"]) physical_rows.append({"cfg": cfg, "independent_rmse_y_mm": iy, "independent_rmse_z_mm": iz, "joint_rmse_y_mm": jy, "joint_rmse_z_mm": jz}) physical_test = pd.DataFrame(physical_rows).set_index("cfg").loc[CONFIGS] physical_test.round(5)
Out[3]:
independent_rmse_y_mm independent_rmse_z_mm joint_rmse_y_mm joint_rmse_z_mm
cfg
rgs000_0 0.14590 0.31715 0.15920 0.29802
rgs000_m4 0.19935 0.27440 0.22662 0.26412
rgs000_p4 0.17085 0.35708 0.18968 0.34455
rgs180_0 0.17841 0.31507 0.20131 0.29223
rgs180_m4 0.16886 0.33924 0.18399 0.31683
rgs180_p4 0.17702 0.30215 0.20515 0.28717
In [4]:
Copied!
def stage4_hybrid_rmse():
    """Per-axis, held-out RMSE of the joint physical fit + independent MLP(y)+MLP(z)
    correction (Phase 3, Section 4) -- read back from MLflow, not retrained."""
    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": "joint_ml_rmse_y_mm", "metrics.hybrid_rmse_z": "joint_ml_rmse_z_mm"}
    ).astype(float)


joint_ml_test = stage4_hybrid_rmse()
joint_ml_test
def stage4_hybrid_rmse(): """Per-axis, held-out RMSE of the joint physical fit + independent MLP(y)+MLP(z) correction (Phase 3, Section 4) -- read back from MLflow, not retrained.""" 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": "joint_ml_rmse_y_mm", "metrics.hybrid_rmse_z": "joint_ml_rmse_z_mm"} ).astype(float) joint_ml_test = stage4_hybrid_rmse() joint_ml_test
Out[4]:
joint_ml_rmse_y_mm joint_ml_rmse_z_mm
params.dataset
rgs000_0 0.017610 0.063612
rgs000_m4 0.014082 0.061646
rgs000_p4 0.017443 0.086905
rgs180_0 0.016640 0.079652
rgs180_m4 0.021537 0.074296
rgs180_p4 0.023943 0.083087
In [5]:
Copied!
def best_single_dataset_mlp():
    """Phase 1's best independent MLP(y)/MLP(z), rgs000_0 only -- read back
    from MLflow ('residual_correction' experiment), not retrained.

    Independent (single-axis) runs log their test RMSE under the generic
    `metrics.test_rmse` column, not `test_rmse_y`/`test_rmse_z` -- those are
    only populated by the joint 2D-output model, which predicts both axes at
    once."""
    exp = mlflow.get_experiment_by_name("residual_correction")
    assert exp is not None, "run notebooks/4.1-ML_Comparison.ipynb first to populate the 'residual_correction' experiment"
    runs = mlflow.search_runs(experiment_ids=[exp.experiment_id])
    mlp = runs[runs["params.model"] == "MLP"]
    best_y = mlp[mlp["params.axis"] == "y"].sort_values("metrics.test_rmse").iloc[0]
    best_z = mlp[mlp["params.axis"] == "z"].sort_values("metrics.test_rmse").iloc[0]
    return float(best_y["metrics.test_rmse"]), float(best_z["metrics.test_rmse"])


single_ml_rmse_y, single_ml_rmse_z = best_single_dataset_mlp()
print(f"Phase 1 best independent MLP (rgs000_0 only): rmse_y={single_ml_rmse_y:.4f} mm, rmse_z={single_ml_rmse_z:.4f} mm")
def best_single_dataset_mlp(): """Phase 1's best independent MLP(y)/MLP(z), rgs000_0 only -- read back from MLflow ('residual_correction' experiment), not retrained. Independent (single-axis) runs log their test RMSE under the generic `metrics.test_rmse` column, not `test_rmse_y`/`test_rmse_z` -- those are only populated by the joint 2D-output model, which predicts both axes at once.""" exp = mlflow.get_experiment_by_name("residual_correction") assert exp is not None, "run notebooks/4.1-ML_Comparison.ipynb first to populate the 'residual_correction' experiment" runs = mlflow.search_runs(experiment_ids=[exp.experiment_id]) mlp = runs[runs["params.model"] == "MLP"] best_y = mlp[mlp["params.axis"] == "y"].sort_values("metrics.test_rmse").iloc[0] best_z = mlp[mlp["params.axis"] == "z"].sort_values("metrics.test_rmse").iloc[0] return float(best_y["metrics.test_rmse"]), float(best_z["metrics.test_rmse"]) single_ml_rmse_y, single_ml_rmse_z = best_single_dataset_mlp() print(f"Phase 1 best independent MLP (rgs000_0 only): rmse_y={single_ml_rmse_y:.4f} mm, rmse_z={single_ml_rmse_z:.4f} mm")
Phase 1 best independent MLP (rgs000_0 only): rmse_y=0.0165 mm, rmse_z=0.0626 mm

2. Reference paper benchmarks¶

Numbers from 2506.08378v2-nisp_grism_trace_model.md Section 4 ("Model accuracy — benchmarks for comparison with new models"), which already distinguishes in-sample vs. held-out and states units explicitly.

Unit-conversion check. The deck (4-Projet/index.html, Task 5) says "Convert your RMSE to the same unit if necessary (1 NISP pixel ≈ 0.3 mm)". That figure does not match this project's own Euclid-NISP-Specs.md, which cites Jahnke et al. 2024 for a measured pixel pitch of 18 µm — the 0.3 figure is very likely the 0.3″/pixel angular plate scale mistaken for a linear pixel size. The paper's own text corroborates the 18 µm figure directly: it states its in-sample residuals as both mm and px in the same sentence, and those two numbers are only mutually consistent with ~18 µm/px, not 300 µm/px (checked below). We use the correct 18 µm/px conversion throughout and flag anywhere the deck's figure would have changed a conclusion.

In [6]:
Copied!
PX_MM_CORRECT = 0.018  # 18 um pixel pitch (Jahnke+2024, via Euclid-NISP-Specs.md) -- physical detector pixel size
PX_MM_DECK = 0.3       # deck's stated "1 NISP pixel ~= 0.3 mm" -- see markdown above

# Reference paper (arXiv 2506.08378), Section 4 of `2506.08378v2-nisp_grism_trace_model.md`
paper_in_sample_y_mm = 7e-4   # "< 7e-4 mm (< 0.04 px)", cross-dispersion, FoV-pooled, in-sample (Fabry-Perot)
paper_in_sample_z_mm = 1e-3   # "< 1e-3 mm (< 0.06 px)", dispersion, FoV-pooled, in-sample
paper_held_out_px = 0.5       # Argon-line validation; axis not separately reported in the source

implied_y_um_per_px = paper_in_sample_y_mm / 0.04 * 1000
implied_z_um_per_px = paper_in_sample_z_mm / 0.06 * 1000
print(f"paper's own in-sample y figure (0.7e-3 mm / 0.04 px) implies {implied_y_um_per_px:.1f} um/px")
print(f"paper's own in-sample z figure (1.0e-3 mm / 0.06 px) implies {implied_z_um_per_px:.1f} um/px")
print(f"Euclid-NISP-Specs.md pixel pitch (Jahnke+2024, measured):    18.0 um/px  <- matches, confirms this is the right conversion")
print(f"deck's stated conversion:                                  {PX_MM_DECK*1000:.0f} um/px  <- ~17x too large, do not use")

paper_held_out_mm = paper_held_out_px * PX_MM_CORRECT
paper_held_out_mm_deck = paper_held_out_px * PX_MM_DECK
print(f"\nheld-out Argon validation: {paper_held_out_px} px")
print(f"  -> {paper_held_out_mm:.4f} mm using the correct 18 um/px")
print(f"  -> {paper_held_out_mm_deck:.4f} mm using the deck's conversion ({paper_held_out_mm_deck/paper_held_out_mm:.1f}x larger -- see Section 5 for why this matters)")
PX_MM_CORRECT = 0.018 # 18 um pixel pitch (Jahnke+2024, via Euclid-NISP-Specs.md) -- physical detector pixel size PX_MM_DECK = 0.3 # deck's stated "1 NISP pixel ~= 0.3 mm" -- see markdown above # Reference paper (arXiv 2506.08378), Section 4 of `2506.08378v2-nisp_grism_trace_model.md` paper_in_sample_y_mm = 7e-4 # "< 7e-4 mm (< 0.04 px)", cross-dispersion, FoV-pooled, in-sample (Fabry-Perot) paper_in_sample_z_mm = 1e-3 # "< 1e-3 mm (< 0.06 px)", dispersion, FoV-pooled, in-sample paper_held_out_px = 0.5 # Argon-line validation; axis not separately reported in the source implied_y_um_per_px = paper_in_sample_y_mm / 0.04 * 1000 implied_z_um_per_px = paper_in_sample_z_mm / 0.06 * 1000 print(f"paper's own in-sample y figure (0.7e-3 mm / 0.04 px) implies {implied_y_um_per_px:.1f} um/px") print(f"paper's own in-sample z figure (1.0e-3 mm / 0.06 px) implies {implied_z_um_per_px:.1f} um/px") print(f"Euclid-NISP-Specs.md pixel pitch (Jahnke+2024, measured): 18.0 um/px <- matches, confirms this is the right conversion") print(f"deck's stated conversion: {PX_MM_DECK*1000:.0f} um/px <- ~17x too large, do not use") paper_held_out_mm = paper_held_out_px * PX_MM_CORRECT paper_held_out_mm_deck = paper_held_out_px * PX_MM_DECK print(f"\nheld-out Argon validation: {paper_held_out_px} px") print(f" -> {paper_held_out_mm:.4f} mm using the correct 18 um/px") print(f" -> {paper_held_out_mm_deck:.4f} mm using the deck's conversion ({paper_held_out_mm_deck/paper_held_out_mm:.1f}x larger -- see Section 5 for why this matters)")
paper's own in-sample y figure (0.7e-3 mm / 0.04 px) implies 17.5 um/px
paper's own in-sample z figure (1.0e-3 mm / 0.06 px) implies 16.7 um/px
Euclid-NISP-Specs.md pixel pitch (Jahnke+2024, measured):    18.0 um/px  <- matches, confirms this is the right conversion
deck's stated conversion:                                  300 um/px  <- ~17x too large, do not use

held-out Argon validation: 0.5 px
  -> 0.0090 mm using the correct 18 um/px
  -> 0.1500 mm using the deck's conversion (16.7x larger -- see Section 5 for why this matters)

3. Definitions and coverage check (Task 5's checklist)¶

  • Residual definition: both this project (predict_centroids minus cent_y/cent_z) and the paper ("predicted vs. measured") use predicted − observed focal-plane position. Same convention.
  • Metric: RMSE per axis in both cases (paper's Fig. 12 histograms and its Argon-validation number; this project's physical_axis_rmse/MLflow hybrid_rmse_y/hybrid_rmse_z). No mismatch here.
  • Axis convention: y = cross-dispersion, z = dispersion, in both — the reference doc states this project's frame (anchored to RGS000) is the one the paper itself uses as the common reference for all grisms.
  • Unit: mm natively in both; pixel conversion corrected above.
  • Dataset / wavelength coverage: checked below.
In [7]:
Copied!
for cfg in CONFIGS:
    wl = dfs[cfg]["wavelength"]
    print(f"{cfg}: wavelength {wl.min():.1f}-{wl.max():.1f} nm ({len(dfs[cfg])} points)")

print("\nPaper's red-grism passband (RGS000/RGS180, all tilts, Jahnke+2024 Table 2,")
print("via the reference doc Sect 2.4): 1206-1892 nm")
for cfg in CONFIGS: wl = dfs[cfg]["wavelength"] print(f"{cfg}: wavelength {wl.min():.1f}-{wl.max():.1f} nm ({len(dfs[cfg])} points)") print("\nPaper's red-grism passband (RGS000/RGS180, all tilts, Jahnke+2024 Table 2,") print("via the reference doc Sect 2.4): 1206-1892 nm")
rgs000_0: wavelength 1211.3-1866.6 nm (5297 points)
rgs000_m4: wavelength 1211.3-1866.6 nm (4772 points)
rgs000_p4: wavelength 1211.3-1866.6 nm (4577 points)
rgs180_0: wavelength 1211.3-1866.6 nm (6198 points)
rgs180_m4: wavelength 1211.3-1866.6 nm (4596 points)
rgs180_p4: wavelength 1211.3-1866.6 nm (4604 points)

Paper's red-grism passband (RGS000/RGS180, all tilts, Jahnke+2024 Table 2,
via the reference doc Sect 2.4): 1206-1892 nm

4. Comparison table¶

Per the deck's Task 5 table, extended with a per-dataset breakdown (the model-comparison-report skill: don't bury numbers in prose, show the same metric computed the same way for every row).

In [8]:
Copied!
summary_rows = [
    {"model": "Physical model, independent per-dataset fit",
     "rmse_y_mm": physical_test["independent_rmse_y_mm"].mean(), "rmse_z_mm": physical_test["independent_rmse_z_mm"].mean(),
     "source": "Task 2 (Phase 2)", "note": "mean over 6 datasets, held-out test split"},
    {"model": "Hybrid, best ML, single dataset",
     "rmse_y_mm": single_ml_rmse_y, "rmse_z_mm": single_ml_rmse_z,
     "source": "Task 1 (Phase 1)", "note": "rgs000_0 only, held-out test split"},
    {"model": "Physical model, joint fitting (Tier 1+2+3)",
     "rmse_y_mm": physical_test["joint_rmse_y_mm"].mean(), "rmse_z_mm": physical_test["joint_rmse_z_mm"].mean(),
     "source": "Task 3 (Phase 3)", "note": "mean over 6 datasets, held-out test split"},
    {"model": "Hybrid, joint fitting + ML",
     "rmse_y_mm": joint_ml_test["joint_ml_rmse_y_mm"].mean(), "rmse_z_mm": joint_ml_test["joint_ml_rmse_z_mm"].mean(),
     "source": "Task 3 (Phase 3)", "note": "mean over 6 datasets, held-out test split"},
    {"model": "Hybrid, joint refinement (regularized)",
     "rmse_y_mm": np.nan, "rmse_z_mm": np.nan,
     "source": "Task 4 (Phase 4)", "note": "NOT ATTEMPTED -- skipped by decision, deferred (see CLAUDE.md)"},
    {"model": "Reference (arXiv 2506.08378), in-sample",
     "rmse_y_mm": paper_in_sample_y_mm, "rmse_z_mm": paper_in_sample_z_mm,
     "source": "Paper Sect 4.2 / Fig 12", "note": "FoV-pooled, all 7 configs, in-sample (Fabry-Perot)"},
    {"model": "Reference (arXiv 2506.08378), held-out (Argon)",
     "rmse_y_mm": paper_held_out_mm, "rmse_z_mm": paper_held_out_mm,
     "source": "Paper Sect 4.2", "note": f"combined figure, NOT axis-split in source ({paper_held_out_px} px = {paper_held_out_mm:.4f} mm); shown in both columns"},
]
comparison_table = pd.DataFrame(summary_rows).set_index("model")
comparison_table["rmse_y_px"] = comparison_table["rmse_y_mm"] / PX_MM_CORRECT
comparison_table["rmse_z_px"] = comparison_table["rmse_z_mm"] / PX_MM_CORRECT
comparison_table[["rmse_y_mm", "rmse_z_mm", "rmse_y_px", "rmse_z_px", "source", "note"]].round(5)
summary_rows = [ {"model": "Physical model, independent per-dataset fit", "rmse_y_mm": physical_test["independent_rmse_y_mm"].mean(), "rmse_z_mm": physical_test["independent_rmse_z_mm"].mean(), "source": "Task 2 (Phase 2)", "note": "mean over 6 datasets, held-out test split"}, {"model": "Hybrid, best ML, single dataset", "rmse_y_mm": single_ml_rmse_y, "rmse_z_mm": single_ml_rmse_z, "source": "Task 1 (Phase 1)", "note": "rgs000_0 only, held-out test split"}, {"model": "Physical model, joint fitting (Tier 1+2+3)", "rmse_y_mm": physical_test["joint_rmse_y_mm"].mean(), "rmse_z_mm": physical_test["joint_rmse_z_mm"].mean(), "source": "Task 3 (Phase 3)", "note": "mean over 6 datasets, held-out test split"}, {"model": "Hybrid, joint fitting + ML", "rmse_y_mm": joint_ml_test["joint_ml_rmse_y_mm"].mean(), "rmse_z_mm": joint_ml_test["joint_ml_rmse_z_mm"].mean(), "source": "Task 3 (Phase 3)", "note": "mean over 6 datasets, held-out test split"}, {"model": "Hybrid, joint refinement (regularized)", "rmse_y_mm": np.nan, "rmse_z_mm": np.nan, "source": "Task 4 (Phase 4)", "note": "NOT ATTEMPTED -- skipped by decision, deferred (see CLAUDE.md)"}, {"model": "Reference (arXiv 2506.08378), in-sample", "rmse_y_mm": paper_in_sample_y_mm, "rmse_z_mm": paper_in_sample_z_mm, "source": "Paper Sect 4.2 / Fig 12", "note": "FoV-pooled, all 7 configs, in-sample (Fabry-Perot)"}, {"model": "Reference (arXiv 2506.08378), held-out (Argon)", "rmse_y_mm": paper_held_out_mm, "rmse_z_mm": paper_held_out_mm, "source": "Paper Sect 4.2", "note": f"combined figure, NOT axis-split in source ({paper_held_out_px} px = {paper_held_out_mm:.4f} mm); shown in both columns"}, ] comparison_table = pd.DataFrame(summary_rows).set_index("model") comparison_table["rmse_y_px"] = comparison_table["rmse_y_mm"] / PX_MM_CORRECT comparison_table["rmse_z_px"] = comparison_table["rmse_z_mm"] / PX_MM_CORRECT comparison_table[["rmse_y_mm", "rmse_z_mm", "rmse_y_px", "rmse_z_px", "source", "note"]].round(5)
Out[8]:
rmse_y_mm rmse_z_mm rmse_y_px rmse_z_px source note
model
Physical model, independent per-dataset fit 0.17340 0.31751 9.63320 17.63967 Task 2 (Phase 2) mean over 6 datasets, held-out test split
Hybrid, best ML, single dataset 0.01647 0.06262 0.91521 3.47865 Task 1 (Phase 1) rgs000_0 only, held-out test split
Physical model, joint fitting (Tier 1+2+3) 0.19433 0.30049 10.79589 16.69367 Task 3 (Phase 3) mean over 6 datasets, held-out test split
Hybrid, joint fitting + ML 0.01854 0.07487 1.03015 4.15923 Task 3 (Phase 3) mean over 6 datasets, held-out test split
Hybrid, joint refinement (regularized) NaN NaN NaN NaN Task 4 (Phase 4) NOT ATTEMPTED -- skipped by decision, deferred...
Reference (arXiv 2506.08378), in-sample 0.00070 0.00100 0.03889 0.05556 Paper Sect 4.2 / Fig 12 FoV-pooled, all 7 configs, in-sample (Fabry-Pe...
Reference (arXiv 2506.08378), held-out (Argon) 0.00900 0.00900 0.50000 0.50000 Paper Sect 4.2 combined figure, NOT axis-split in source (0.5...
In [9]:
Copied!
per_dataset = physical_test.join(joint_ml_test)
# sqrt(rmse_y^2 + rmse_z^2): a standard combined-RMSE proxy, not identical to
# mean(hypot(r_y, r_z)) used in 4.3 but close and consistent across rows here.
for strategy in ["independent", "joint", "joint_ml"]:
    per_dataset[f"{strategy}_rmse_combined_mm"] = np.hypot(
        per_dataset[f"{strategy}_rmse_y_mm"], per_dataset[f"{strategy}_rmse_z_mm"])
per_dataset.round(4)
per_dataset = physical_test.join(joint_ml_test) # sqrt(rmse_y^2 + rmse_z^2): a standard combined-RMSE proxy, not identical to # mean(hypot(r_y, r_z)) used in 4.3 but close and consistent across rows here. for strategy in ["independent", "joint", "joint_ml"]: per_dataset[f"{strategy}_rmse_combined_mm"] = np.hypot( per_dataset[f"{strategy}_rmse_y_mm"], per_dataset[f"{strategy}_rmse_z_mm"]) per_dataset.round(4)
Out[9]:
independent_rmse_y_mm independent_rmse_z_mm joint_rmse_y_mm joint_rmse_z_mm joint_ml_rmse_y_mm joint_ml_rmse_z_mm independent_rmse_combined_mm joint_rmse_combined_mm joint_ml_rmse_combined_mm
cfg
rgs000_0 0.1459 0.3171 0.1592 0.2980 0.0176 0.0636 0.3491 0.3379 0.0660
rgs000_m4 0.1993 0.2744 0.2266 0.2641 0.0141 0.0616 0.3392 0.3480 0.0632
rgs000_p4 0.1709 0.3571 0.1897 0.3445 0.0174 0.0869 0.3959 0.3933 0.0886
rgs180_0 0.1784 0.3151 0.2013 0.2922 0.0166 0.0797 0.3621 0.3549 0.0814
rgs180_m4 0.1689 0.3392 0.1840 0.3168 0.0215 0.0743 0.3789 0.3664 0.0774
rgs180_p4 0.1770 0.3021 0.2052 0.2872 0.0239 0.0831 0.3502 0.3529 0.0865
In [10]:
Copied!
strategies_labels = ["Physical\n(independent)", "Physical\n(joint)", "Hybrid, single-dataset\nML (rgs000_0 only)",
                     "Hybrid, joint fit\n+ ML", "Reference\n(in-sample)"]
y_vals = [physical_test["independent_rmse_y_mm"].mean(), physical_test["joint_rmse_y_mm"].mean(),
          single_ml_rmse_y, joint_ml_test["joint_ml_rmse_y_mm"].mean(), paper_in_sample_y_mm]
z_vals = [physical_test["independent_rmse_z_mm"].mean(), physical_test["joint_rmse_z_mm"].mean(),
          single_ml_rmse_z, joint_ml_test["joint_ml_rmse_z_mm"].mean(), paper_in_sample_z_mm]

fig, ax = plt.subplots(figsize=(10, 5))
x = np.arange(len(strategies_labels))
width = 0.35
ax.bar(x - width / 2, y_vals, width, label="RMSE y (cross-dispersion)", color="tab:blue")
ax.bar(x + width / 2, z_vals, width, label="RMSE z (dispersion)", color="tab:orange")
ax.axhline(paper_held_out_mm, color="0.3", linestyle="--", linewidth=1.5,
           label=f"Reference held-out, Argon (combined, not axis-split): {paper_held_out_mm:.4f} mm")
ax.set_yscale("log")
ax.set_xticks(x)
ax.set_xticklabels(strategies_labels)
ax.set_ylabel("RMSE [mm], held-out test split (log scale)")
ax.legend(loc="upper right", fontsize=9)
ax.set_title("This project's models vs. the reference paper (arXiv 2506.08378)")
plt.tight_layout()
plt.show()
strategies_labels = ["Physical\n(independent)", "Physical\n(joint)", "Hybrid, single-dataset\nML (rgs000_0 only)", "Hybrid, joint fit\n+ ML", "Reference\n(in-sample)"] y_vals = [physical_test["independent_rmse_y_mm"].mean(), physical_test["joint_rmse_y_mm"].mean(), single_ml_rmse_y, joint_ml_test["joint_ml_rmse_y_mm"].mean(), paper_in_sample_y_mm] z_vals = [physical_test["independent_rmse_z_mm"].mean(), physical_test["joint_rmse_z_mm"].mean(), single_ml_rmse_z, joint_ml_test["joint_ml_rmse_z_mm"].mean(), paper_in_sample_z_mm] fig, ax = plt.subplots(figsize=(10, 5)) x = np.arange(len(strategies_labels)) width = 0.35 ax.bar(x - width / 2, y_vals, width, label="RMSE y (cross-dispersion)", color="tab:blue") ax.bar(x + width / 2, z_vals, width, label="RMSE z (dispersion)", color="tab:orange") ax.axhline(paper_held_out_mm, color="0.3", linestyle="--", linewidth=1.5, label=f"Reference held-out, Argon (combined, not axis-split): {paper_held_out_mm:.4f} mm") ax.set_yscale("log") ax.set_xticks(x) ax.set_xticklabels(strategies_labels) ax.set_ylabel("RMSE [mm], held-out test split (log scale)") ax.legend(loc="upper right", fontsize=9) ax.set_title("This project's models vs. the reference paper (arXiv 2506.08378)") plt.tight_layout() plt.show()
No description has been provided for this image

5. Discussion — sources of discrepancy and a proposed improvement¶

Key findings

  • Even the best hybrid result here is 2-8x worse than the reference, and the physical-only models are two to three orders of magnitude worse. Joint fit + ML: rmse_y = 0.0185 mm (1.0 px), rmse_z = 0.0749 mm (4.2 px), vs. the paper's held-out Argon benchmark of 0.009 mm (0.5 px, combined) — roughly 2x worse on y, 8x worse on z. Physical-only (independent or joint): y ≈ 0.17-0.19 mm (~10 px), z ≈ 0.30-0.32 mm (~17 px) — 250-450x worse than the paper's in-sample residual (<0.0007/0.001 mm) and 20-35x worse than its held-out one.
  • The gap is dominated by the physical model's missing spatial term, not a modeling bug. The paper's Eq. (2)+(3) fits a full 2D-Chebyshev surface per trace coefficient — up to 16 a_kl terms each, describing how the trace shape varies continuously across the ±85 mm FoV. This project's physical model has no equivalent: offset_y_mm/offset_z_mm are single constants per dataset (Tier 3), not functions of (y0, z0). The ML correction partially compensates for this — it does see (y_nisp, z_nisp) as MLP inputs — but a generic MLP is a far less data-efficient way to recover a smooth low-order spatial surface than an explicit low-order polynomial fit to the same ~4600-6200 points per dataset.
  • z (dispersion direction) lags y (cross-dispersion) more than the input noise floor alone would predict. The paper's own centroiding noise floor is only ~2.5x worse in z than y (§4.1 of the reference doc), but this project's hybrid z RMSE is ~4x its y RMSE — consistent with the missing spatial term biting harder on z, since dispersion carries most of the wavelength-dependent structure.
  • Independent vs. joint physical fit is a wash here too (y: 0.173 vs. 0.194 mm — joint worse; z: 0.318 vs. 0.300 mm — joint slightly better), reproducing Phase 3's own held-out finding: joint fitting's value is the parameter structure, not a demonstrated accuracy win.
  • The single-dataset MLP (Phase 1, rgs000_0 only) and the joint+ML pipeline (Phase 3, all 6 datasets) land within ~15% of each other (y: 0.0165 vs. 0.0185 mm; z: 0.0626 vs. 0.0749 mm) — the independent-MLP default generalizes across the dataset family about as well as it did on its original single dataset.
  • The deck's incorrect pixel conversion is not a cosmetic issue — it flips a conclusion. Under the deck's stated 0.3 mm/px, the paper's held-out Argon number reads as 0.15 mm, which is larger than this project's joint+ML z result (0.0749 mm) — i.e., the wrong conversion would make it look like this project's hybrid model beats the published reference on z. Under the correct 18 µm/px, the reference number is 0.009 mm, and the reference remains ahead of every result in this notebook by 2-8x. This is exactly the failure mode Task 5 warns about ("verify units before comparing").

Caveats

  • The comparison is approximate by construction (Task 5's own framing): this project's physical model is a compact, interpretable few-parameter geometric-optics chain (9 total free parameters across all 6 datasets); the paper's is a phenomenological per-position polynomial fit with dozens of coefficients per grism configuration. They are not the same class of model, and the paper's is built to minimize residuals, not for optical interpretability.
  • This project's "test-set RMSE" still involves physical parameters fit on the full dataset (Section 1's caveat) — a softer held-out standard than the paper's Argon validation, which is fully independent data. If anything this makes this project's numbers look better than they would under a stricter split, so the gap to the reference is a conservative (lower) bound on the true gap.
  • The paper's held-out figure is a single combined number, not axis-split in the source — comparing it to this project's y/z RMSE separately is the best available option, but not a like-for-like axis comparison.
  • Different input pipelines (this project's median_per_spectrum vs. the paper's PSF-template Fabry-Pérot/Argon centroiding) may carry different noise floors; not characterized here.

Proposed improvement

Add an explicit, low-order 2D polynomial (or Chebyshev) spatial term to the physical model — offset_y_mm(y0, z0), offset_z_mm(y0, z0) instead of per-dataset constants — mirroring the paper's Eq. (3) with a handful of coefficients rather than dozens. This targets the dominant identified discrepancy (missing FoV-position dependence) directly and keeps it physically interpretable and TOML-traceable, rather than asking the MLP to recover a smooth spatial surface purely empirically. It could be evaluated against the current MLP residual on both accuracy and coefficient count — an explicit interpretability-vs-accuracy tradeoff, not a silent choice.

6. Recap¶

Stopping here for review before Phase 6 (status report). Phase 4 (joint physics+ML refinement with regularization) remains deliberately skipped in this comparison — its row in the table above is a gap, not a result.

Previous Next

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