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¶
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.
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]
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)
| 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 |
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
| 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 |
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.
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_centroidsminuscent_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/MLflowhybrid_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.
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).
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)
| 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... |
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)
| 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 |
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()
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 of0.009 mm(0.5 px, combined) — roughly 2x worse ony, 8x worse onz. 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_klterms 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_mmare 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) lagsy(cross-dispersion) more than the input noise floor alone would predict. The paper's own centroiding noise floor is only ~2.5x worse inzthany(§4.1 of the reference doc), but this project's hybridzRMSE is ~4x itsyRMSE — consistent with the missing spatial term biting harder onz, 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_0only) 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 as0.15 mm, which is larger than this project's joint+MLzresult (0.0749 mm) — i.e., the wrong conversion would make it look like this project's hybrid model beats the published reference onz. Under the correct18 µm/px, the reference number is0.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/zRMSE separately is the best available option, but not a like-for-like axis comparison. - Different input pipelines (this project's
median_per_spectrumvs. 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.