Stage 4, Phase 1 — ML Model Comparison on a Single Dataset (4.1-ML_Comparison)¶
Per 4-Projet/index.html, Section 1 / Task 1: starting from the optimized
physical model of Stage 3, systematically compare several ML models for
residual correction on rgs000_0, tracking every model + hyperparameter
combination as its own MLflow run.
Scope: this notebook covers Phase 1 only (residual_correction MLflow
experiment, single dataset). Stage 4 has 6 phases total (see the deck's
roadmap table); Phase 2 ("Applying the Pipeline to All Datasets") and beyond
get their own notebooks (4.2-..., etc.) once this phase is reviewed --
nothing here wraps the pipeline into a reusable multi-dataset function.
Model target: (y_nisp, z_nisp, wavelength) -> (r_y, r_z), the residual left
over after Stage 3's physical fit -- same convention as
3-Intro_ML.ipynb Section 5.
Fixed parameters -- cross-checked against Euclid-NISP-Specs.md¶
This phase doesn't refit the physical model -- it runs entirely on top of
the frozen Stage 3 fit (models/stage3_fit_rgs000_0.toml), which itself
holds several parameters fixed at GroundTestModel.nominal_params
(models/stage1_instrument.toml). Cross-checked against the authoritative
instrument specs (Euclid-NISP-Specs.md, Jahnke et al. 2024, Table 2 / §2)
before building on them:
| Parameter | Fixed value used | Spec value (RGS000/180) | Status |
|---|---|---|---|
prism.A_deg |
2.145° | 2.145° ± 30″ | matches exactly -- grounded |
grating.rho |
13.75 mm⁻¹ | 13.75 mm⁻¹ (groove density) | matches exactly -- grounded |
detector.pixel_size |
18 µm | 18 µm (pixel pitch) | matches exactly -- grounded |
telescope.f |
24.5 m | F/20.4 × 1.20 m primary ≈ 24.48 m | matches (derived) |
material.n0, material.k |
1.44 / 0.004 µm² | not given (glass named as Suprasil 3001, no dispersion coefficients in the source docs) | not spec-grounded -- synthetic placeholder, consistent with Stage 3 finding these non-identifiable from this dataset anyway |
Everything held fixed here is either spec-grounded or was already flagged
non-identifiable in Stage 3 for an independent reason -- no unexplained
guesses. One more spec note worth flagging so it isn't misread later: the
spec's 0.3"/pix plate scale is an angular on-sky quantity, distinct from
4-Projet/index.html's own 1 NISP pixel ~ 0.3 mm physical pixel size --
they share the number 0.3 but are not the same fact. That only matters for
Phase 5's literature-unit conversion, not this notebook's RMSE-in-mm
comparisons.
import tomllib
import time
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import mlflow
import mlflow.sklearn
from sklearn.model_selection import GroupShuffleSplit, ParameterGrid, ParameterSampler
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor, HistGradientBoostingRegressor
from sklearn.svm import SVR
from sklearn.neural_network import MLPRegressor
from sklearn.multioutput import MultiOutputRegressor
from sklearn.metrics import mean_squared_error
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
1. Starting Point: Stage 3's Physical Fit¶
Load models/stage3_fit_rgs000_0.toml directly -- free_parameters +
[fit.result] give exactly the free_names/theta Stage 3 already fit via
scipy.optimize.minimize, so there's no refit here. Load rgs000_0 the same
way Stage 3 did (median_per_spectrum(load_spectra(...))), exclude the same
mislabeled-position outlier spectrum the TOML records, and compute the
residuals r_y, r_z via predict_centroids -- this is "the optimized
physical model of Section 3" the deck's Task 1 says to start from
(mirrors add_residuals() in 3-Intro_ML.ipynb).
with open(MODELS_DIR / "stage1_instrument.toml", "rb") as f:
base_config = tomllib.load(f)
model = ground_test_model_from_config(base_config)
with open(MODELS_DIR / "stage3_fit_rgs000_0.toml", "rb") as f:
fit0 = tomllib.load(f)
free_names = fit0["fit"]["free_parameters"]
theta0 = np.array([fit0["fit"]["result"][k] for k in free_names])
excluded_ids = fit0["fit"]["excluded_spectra_ids"]
df0 = median_per_spectrum(load_spectra(DATA_DIR / "rgs000_0_first.csv"))
df0 = df0[~df0["spectra_id"].isin(excluded_ids)].reset_index(drop=True)
assert len(df0) == fit0["fit"]["n_points"], "row count must match the TOML's recorded fit"
pred0 = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta0, free_names, model)
d0 = df0.assign(r_y=pred0[0] - df0["cent_y"].values, r_z=pred0[1] - df0["cent_z"].values)
recomputed_rmse = np.sqrt(np.mean(d0["r_y"] ** 2 + d0["r_z"] ** 2))
print(f"rgs000_0: {len(d0)} rows, {d0['spectra_id'].nunique()} spectra")
print(f"TOML rmse_mm = {fit0['fit']['rmse_mm']:.6f}, recomputed = {recomputed_rmse:.6f} (must match)")
rgs000_0: 5297 rows, 170 spectra TOML rmse_mm = 0.356310, recomputed = 0.356310 (must match)
2. Train/Test Split (group-aware)¶
Features X = [y_nisp, z_nisp, wavelength], one model per axis (r_y,
r_z), an 80/20 split via GroupShuffleSplit on spectra_id -- the same
group-aware pattern 3-Intro_ML.ipynb established (each spectrum contributes
~30 rows at the same field position; a plain row-wise split would leak
position information across train/test, per the hybrid-residual-modeling
skill's natural-grouping rule).
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
3. MLflow Experiment¶
Tracking store is sqlite:///mlflow.db at the repo root -- mlflow 3.x has
put the plain file store (./mlruns) into maintenance mode and recommends a
database backend, so this notebook uses that instead of the deck's literal
./mlruns example. Same idea, current mechanism. Run pixi run mlflow-ui
from the repo root to browse the residual_correction experiment.
mlflow.set_tracking_uri(f"sqlite:///{(REPO_ROOT / 'mlflow.db').resolve()}")
EXPERIMENT_NAME = "residual_correction"
if mlflow.get_experiment_by_name(EXPERIMENT_NAME) is None:
mlflow.create_experiment(EXPERIMENT_NAME, artifact_location=f"file:{(REPO_ROOT / 'mlruns').resolve()}")
mlflow.set_experiment(EXPERIMENT_NAME)
2026/07/23 14:37:55 INFO mlflow.store.db.utils: Creating initial MLflow database tables...
2026/07/23 14:37:55 INFO mlflow.store.db.utils: Updating database tables
<Experiment: artifact_location='file:/home/zoubian/Workspace/dispers/dispcraft/mlruns', creation_time=1784810280203, effective_trace_archival_retention=None, experiment_id='1', last_update_time=1784810280203, lifecycle_stage='active', name='residual_correction', tags={}, trace_location=None, workspace='default'>
4. Systematic Hyperparameter Comparison¶
Five model families, per the deck's table, each searched over >=3
hyperparameter combinations (GridSearchCV-style exhaustive grid for
RandomForest/GradientBoosting, RandomizedSearchCV-style sampling for
SVM/MLP since those grids are larger). LinearRegression has no
hyperparameters to search (the deck's own table lists "None" for it), so it
gets a single run per axis -- the baseline.
Every combination gets its own MLflow run (not just the best -- the
deck's box-yellow instruction on the intro slide is explicit about this):
each candidate is refit on the train split and logged with its
hyperparameters (mlflow.log_param) plus train_rmse, test_rmse, and
train_test_ratio = test_rmse / train_rmse (mlflow.log_metric) -- ratio
defined this way so it's always >=1 with larger meaning more overfit,
matching the deck's "which model overfits most (largest train/test ratio)"
phrasing directly.
MODEL_SPECS = {
"Linear": {
"ctor": LinearRegression,
"grid": [{}],
"search": "grid",
},
"RandomForest": {
"ctor": RandomForestRegressor,
"grid": {"n_estimators": [100, 200], "max_depth": [4, 8, 12]},
"search": "grid",
"fixed": {"random_state": RNG_SEED},
},
"GradientBoosting": {
"ctor": HistGradientBoostingRegressor,
"grid": {"max_iter": [100, 200], "max_depth": [4, 6], "learning_rate": [0.05, 0.1]},
"search": "grid",
"fixed": {"random_state": RNG_SEED},
},
"SVM": {
"ctor": SVR,
"grid": {"C": [1.0, 10.0, 100.0], "kernel": ["rbf"], "epsilon": [0.001, 0.01, 0.05]},
"search": "random",
"n_iter": 6,
},
"MLP": {
"ctor": MLPRegressor,
"grid": {"hidden_layer_sizes": [(32,), (64, 32), (64, 64)],
"activation": ["relu", "tanh"], "alpha": [1e-4, 1e-3, 1e-2]},
"search": "random",
"n_iter": 6,
"fixed": {"random_state": RNG_SEED, "max_iter": 2000, "early_stopping": True},
},
}
t_start = time.time()
run_records = []
best_by_axis = {}
for axis, col in [("y", "r_y"), ("z", "r_z")]:
y_train = d0[col].values[train_idx]
y_test = d0[col].values[test_idx]
for model_name, spec in MODEL_SPECS.items():
if spec["search"] == "grid":
combos = list(ParameterGrid(spec["grid"]))
else:
combos = list(ParameterSampler(spec["grid"], n_iter=spec["n_iter"], random_state=RNG_SEED))
for combo_idx, combo in enumerate(combos):
params = {**spec.get("fixed", {}), **combo}
est = spec["ctor"](**params)
t0 = time.time()
est.fit(X_train_s, y_train)
fit_time = time.time() - t0
train_rmse = np.sqrt(mean_squared_error(y_train, est.predict(X_train_s)))
test_rmse = np.sqrt(mean_squared_error(y_test, est.predict(X_test_s)))
ratio = test_rmse / train_rmse if train_rmse > 0 else np.nan
with mlflow.start_run(run_name=f"{model_name}_{axis}_{combo_idx}"):
mlflow.log_param("axis", axis)
mlflow.log_param("model", model_name)
for k, v in params.items():
mlflow.log_param(k, v)
mlflow.log_metric("train_rmse", train_rmse)
mlflow.log_metric("test_rmse", test_rmse)
mlflow.log_metric("train_test_ratio", ratio)
mlflow.log_metric("fit_time_s", fit_time)
run_records.append({"axis": axis, "model": model_name, "params": params, "estimator": est,
"train_rmse": train_rmse, "test_rmse": test_rmse, "ratio": ratio})
cur_best = best_by_axis.get(axis)
if cur_best is None or test_rmse < cur_best["test_rmse"]:
best_by_axis[axis] = run_records[-1]
print(f"total runs logged: {len(run_records)}, wall time: {time.time() - t_start:.1f}s")
total runs logged: 54, wall time: 75.7s
Results table¶
Pivoted one row per model family (rather than one row per axis) so each
family's two per-axis choices can be paired up into a single combined-axes
metric: distance between the corrected prediction and the actual data
point, mean(hypot(hybrid_r_y, hybrid_r_z)) -- the mm-scale number that
actually matters for the deck's Task 1 step 3 ("compare all runs, identify
the best model"), since test_rmse_y and test_rmse_z alone don't say how
good the combined 2D correction is. A Physics-only row (no ML
correction) is included as the baseline.
r_y_train, r_z_train = d0["r_y"].values[train_idx], d0["r_z"].values[train_idx]
r_y_test, r_z_test = d0["r_y"].values[test_idx], d0["r_z"].values[test_idx]
results_df = pd.DataFrame(run_records)
best_idx = results_df.groupby(["axis", "model"])["test_rmse"].idxmin()
best_rows = results_df.loc[best_idx].set_index(["axis", "model"])
pivot_rows = []
for model_name in MODEL_SPECS:
by, bz = best_rows.loc[("y", model_name)], best_rows.loc[("z", model_name)]
train_dist = np.mean(np.hypot(r_y_train - by["estimator"].predict(X_train_s),
r_z_train - bz["estimator"].predict(X_train_s)))
test_dist = np.mean(np.hypot(r_y_test - by["estimator"].predict(X_test_s),
r_z_test - bz["estimator"].predict(X_test_s)))
pivot_rows.append({"model": model_name,
"test_rmse_y": by["test_rmse"], "ratio_y": by["ratio"],
"test_rmse_z": bz["test_rmse"], "ratio_z": bz["ratio"],
"train_dist_mm": train_dist, "test_dist_mm": test_dist,
"dist_ratio": test_dist / train_dist})
physics_train_dist = np.mean(np.hypot(r_y_train, r_z_train))
physics_test_dist = np.mean(np.hypot(r_y_test, r_z_test))
pivot_rows.append({"model": "Physics-only",
"test_rmse_y": np.sqrt(np.mean(r_y_test ** 2)), "ratio_y": np.nan,
"test_rmse_z": np.sqrt(np.mean(r_z_test ** 2)), "ratio_z": np.nan,
"train_dist_mm": physics_train_dist, "test_dist_mm": physics_test_dist,
"dist_ratio": physics_test_dist / physics_train_dist})
section4_table = pd.DataFrame(pivot_rows).set_index("model").sort_values("test_dist_mm")
section4_table.round(4)
| test_rmse_y | ratio_y | test_rmse_z | ratio_z | train_dist_mm | test_dist_mm | dist_ratio | |
|---|---|---|---|---|---|---|---|
| model | |||||||
| SVM | 0.0200 | 1.1981 | 0.0614 | 1.1653 | 0.0482 | 0.0538 | 1.1147 |
| MLP | 0.0165 | 1.2536 | 0.0626 | 1.3934 | 0.0393 | 0.0538 | 1.3681 |
| GradientBoosting | 0.0204 | 7.5204 | 0.0725 | 17.4355 | 0.0041 | 0.0605 | 14.9367 |
| RandomForest | 0.0200 | 13.2533 | 0.0986 | 45.2014 | 0.0016 | 0.0789 | 49.2376 |
| Linear | 0.1502 | 1.1080 | 0.2643 | 1.1161 | 0.2401 | 0.2707 | 1.1276 |
| Physics-only | 0.1459 | NaN | 0.3171 | NaN | 0.3167 | 0.3078 | 0.9719 |
physics_rmse = {axis: np.sqrt(np.mean(d0[col].values[test_idx] ** 2))
for axis, col in [("y", "r_y"), ("z", "r_z")]}
for axis, b in best_by_axis.items():
improvement = 1 - b["test_rmse"] / physics_rmse[axis]
print(f"{axis}: physics-only test RMSE = {physics_rmse[axis]:.4f} mm | "
f"best = {b['model']} (test RMSE = {b['test_rmse']:.4f} mm, "
f"{improvement:.1%} improvement)")
y: physics-only test RMSE = 0.1459 mm | best = MLP (test RMSE = 0.0165 mm, 88.7% improvement) z: physics-only test RMSE = 0.3171 mm | best = SVM (test RMSE = 0.0614 mm, 80.6% improvement)
4b. Joint 2D-Output Models — One Model for Both Axes¶
Section 4 fits an independent model per axis (r_y, r_z). Here the same
five model families are tested the other way: a single model per
hyperparameter combination predicting (r_y, r_z) together, to test whether
sharing structure across axes captures correlation an independent-per-axis
model can't.
LinearRegression, RandomForestRegressor, and MLPRegressor support
multi-output natively -- one real joint model per combination
(RandomForestRegressor's trees split to jointly reduce variance across both
targets; MLPRegressor's hidden layers are shared across both output
units). HistGradientBoostingRegressor and SVR don't support multi-output
natively in this sklearn version, so they're wrapped in
sklearn.multioutput.MultiOutputRegressor -- worth flagging explicitly
(simplicity-vs-accuracy trade-off, per the hybrid-residual-modeling
skill): a wrapped model is really still two independent single-target
models fit back-to-back under one API call, not a fundamentally different
capability than Section 4's independent approach. Same grids as Section 4,
same MLflow experiment (residual_correction, logged with axis="joint"),
every combination logged as its own run.
Y_train = d0[["r_y", "r_z"]].values[train_idx]
Y_test = d0[["r_y", "r_z"]].values[test_idx]
JOINT_MODEL_SPECS = {
"Linear": {"ctor": lambda **p: LinearRegression(**p), "grid": [{}], "search": "grid", "native": True},
"RandomForest": {"ctor": lambda **p: RandomForestRegressor(**p),
"grid": {"n_estimators": [100, 200], "max_depth": [4, 8, 12]}, "search": "grid",
"fixed": {"random_state": RNG_SEED}, "native": True},
"GradientBoosting": {"ctor": lambda **p: MultiOutputRegressor(HistGradientBoostingRegressor(**p)),
"grid": {"max_iter": [100, 200], "max_depth": [4, 6], "learning_rate": [0.05, 0.1]},
"search": "grid", "fixed": {"random_state": RNG_SEED}, "native": False},
"SVM": {"ctor": lambda **p: MultiOutputRegressor(SVR(**p)),
"grid": {"C": [1.0, 10.0, 100.0], "kernel": ["rbf"], "epsilon": [0.001, 0.01, 0.05]},
"search": "random", "n_iter": 6, "native": False},
"MLP": {"ctor": lambda **p: MLPRegressor(**p),
"grid": {"hidden_layer_sizes": [(32,), (64, 32), (64, 64)],
"activation": ["relu", "tanh"], "alpha": [1e-4, 1e-3, 1e-2]},
"search": "random", "n_iter": 6,
"fixed": {"random_state": RNG_SEED, "max_iter": 2000, "early_stopping": True}, "native": True},
}
t_start_joint = time.time()
joint_records = []
for model_name, spec in JOINT_MODEL_SPECS.items():
if spec["search"] == "grid":
combos = list(ParameterGrid(spec["grid"]))
else:
combos = list(ParameterSampler(spec["grid"], n_iter=spec["n_iter"], random_state=RNG_SEED))
for combo_idx, combo in enumerate(combos):
params = {**spec.get("fixed", {}), **combo}
est = spec["ctor"](**params)
t0 = time.time()
est.fit(X_train_s, Y_train)
fit_time = time.time() - t0
train_pred = est.predict(X_train_s)
test_pred = est.predict(X_test_s)
train_rmse_y = np.sqrt(mean_squared_error(Y_train[:, 0], train_pred[:, 0]))
train_rmse_z = np.sqrt(mean_squared_error(Y_train[:, 1], train_pred[:, 1]))
test_rmse_y = np.sqrt(mean_squared_error(Y_test[:, 0], test_pred[:, 0]))
test_rmse_z = np.sqrt(mean_squared_error(Y_test[:, 1], test_pred[:, 1]))
train_dist = np.mean(np.hypot(Y_train[:, 0] - train_pred[:, 0], Y_train[:, 1] - train_pred[:, 1]))
test_dist = np.mean(np.hypot(Y_test[:, 0] - test_pred[:, 0], Y_test[:, 1] - test_pred[:, 1]))
dist_ratio = test_dist / train_dist if train_dist > 0 else np.nan
with mlflow.start_run(run_name=f"{model_name}_joint_{combo_idx}"):
mlflow.log_param("axis", "joint")
mlflow.log_param("model", model_name)
mlflow.log_param("native_multioutput", spec["native"])
for k, v in params.items():
mlflow.log_param(k, v)
mlflow.log_metric("train_rmse_y", train_rmse_y)
mlflow.log_metric("train_rmse_z", train_rmse_z)
mlflow.log_metric("test_rmse_y", test_rmse_y)
mlflow.log_metric("test_rmse_z", test_rmse_z)
mlflow.log_metric("train_dist_mm", train_dist)
mlflow.log_metric("test_dist_mm", test_dist)
mlflow.log_metric("dist_ratio", dist_ratio)
mlflow.log_metric("fit_time_s", fit_time)
joint_records.append({"model": model_name, "params": params, "estimator": est, "native": spec["native"],
"train_rmse_y": train_rmse_y, "test_rmse_y": test_rmse_y,
"train_rmse_z": train_rmse_z, "test_rmse_z": test_rmse_z,
"train_dist_mm": train_dist, "test_dist_mm": test_dist, "dist_ratio": dist_ratio})
print(f"section 4b: {len(joint_records)} joint runs logged, wall time: {time.time() - t_start_joint:.1f}s")
section 4b: 27 joint runs logged, wall time: 62.2s
Joint models results table¶
One row per model family (best hyperparameter combination by combined test distance), for direct comparison against Section 4's pivoted table above.
joint_df = pd.DataFrame(joint_records)
joint_best_idx = joint_df.groupby("model")["test_dist_mm"].idxmin()
joint_best = joint_df.loc[joint_best_idx].set_index("model").sort_values("test_dist_mm")
joint_best[["native", "train_dist_mm", "test_dist_mm", "dist_ratio", "test_rmse_y", "test_rmse_z"]].round(4)
| native | train_dist_mm | test_dist_mm | dist_ratio | test_rmse_y | test_rmse_z | |
|---|---|---|---|---|---|---|
| model | ||||||
| SVM | False | 0.0384 | 0.0560 | 1.4580 | 0.0203 | 0.0650 |
| MLP | True | 0.0406 | 0.0560 | 1.3815 | 0.0195 | 0.0635 |
| GradientBoosting | False | 0.0138 | 0.0593 | 4.2944 | 0.0204 | 0.0732 |
| RandomForest | True | 0.0010 | 0.0931 | 89.9754 | 0.0534 | 0.1105 |
| Linear | True | 0.2401 | 0.2707 | 1.1276 | 0.1502 | 0.2643 |
Overall re-evaluation: independent vs. joint¶
The real baseline to compare the joint 2D models against isn't any single
row of Section 4's pivoted table (which pairs same-family models on both
axes) -- it's the true best achievable with two independently chosen
per-axis models, i.e. best_by_axis from Section 4 (potentially two
different families, e.g. one model for y and a different one for z).
That combined-distance number, plus every joint model's best combined
distance from the table above, sorted together:
overall_rows = [{
"approach": "Independent (best per axis, mixed families)",
"model_y": best_by_axis["y"]["model"], "model_z": best_by_axis["z"]["model"],
"test_rmse_y": best_by_axis["y"]["test_rmse"], "test_rmse_z": best_by_axis["z"]["test_rmse"],
"test_dist_mm": np.mean(np.hypot(r_y_test - best_by_axis["y"]["estimator"].predict(X_test_s),
r_z_test - best_by_axis["z"]["estimator"].predict(X_test_s))),
}]
for model_name, row in joint_best.iterrows():
overall_rows.append({"approach": f"Joint {model_name}", "model_y": model_name, "model_z": model_name,
"test_rmse_y": row["test_rmse_y"], "test_rmse_z": row["test_rmse_z"],
"test_dist_mm": row["test_dist_mm"]})
overall_table = pd.DataFrame(overall_rows).set_index("approach").sort_values("test_dist_mm")
display(overall_table.round(4))
winner = overall_table.index[0]
print(f"\nOverall winner by combined point-distance: '{winner}' "
f"(test_dist_mm = {overall_table.iloc[0]['test_dist_mm']:.4f} mm)")
| model_y | model_z | test_rmse_y | test_rmse_z | test_dist_mm | |
|---|---|---|---|---|---|
| approach | |||||
| Independent (best per axis, mixed families) | MLP | SVM | 0.0165 | 0.0614 | 0.0529 |
| Joint SVM | SVM | SVM | 0.0203 | 0.0650 | 0.0560 |
| Joint MLP | MLP | MLP | 0.0195 | 0.0635 | 0.0560 |
| Joint GradientBoosting | GradientBoosting | GradientBoosting | 0.0204 | 0.0732 | 0.0593 |
| Joint RandomForest | RandomForest | RandomForest | 0.0534 | 0.1105 | 0.0931 |
| Joint Linear | Linear | Linear | 0.1502 | 0.2643 | 0.2707 |
Overall winner by combined point-distance: 'Independent (best per axis, mixed families)' (test_dist_mm = 0.0529 mm)
5. Comparing the Leading Candidates¶
Section 4b's numeric ranking put the independent, mixed-family pick (MLP for
y, SVM for z) narrowly ahead of every joint model. The margins involved
are small enough that this section compares the leading candidates visually
and qualitatively -- not just by their headline metric -- before committing
to (and saving) any one of them:
- MLP (independent,
y) -- Section 4's winner fory, uncontested. - SVM (independent,
z) -- Section 4's numeric winner forz. - MLP (independent,
z) -- Section 4's close second forz(test_rmse_zwithin ~2% of SVM's). - MLP (joint) -- Section 4b's leading joint candidate, predicting both axes from one model.
mlp_y = best_by_axis["y"]["estimator"] # independent, y
svm_z = best_rows.loc[("z", "SVM")]["estimator"] # independent, z -- numeric winner
mlp_z = best_rows.loc[("z", "MLP")]["estimator"] # independent, z -- close second
joint_mlp = joint_best.loc["MLP", "estimator"] # joint, both axes
print(f"z candidates -- SVM test_rmse={best_rows.loc[('z','SVM')]['test_rmse']:.4f} mm, "
f"MLP test_rmse={best_rows.loc[('z','MLP')]['test_rmse']:.4f} mm, "
f"joint MLP test_rmse_z={joint_best.loc['MLP','test_rmse_z']:.4f} mm")
print("Nothing saved to MLflow yet -- see the discussion and decision point below.")
z candidates -- SVM test_rmse=0.0614 mm, MLP test_rmse=0.0626 mm, joint MLP test_rmse_z=0.0635 mm Nothing saved to MLflow yet -- see the discussion and decision point below.
fig, axes = plt.subplots(1, 2, figsize=(11, 5))
ax = axes[0]
lims_y = [r_y_test.min(), r_y_test.max()]
ax.plot(lims_y, lims_y, "k--", lw=1, label="perfect")
ax.scatter(r_y_test, mlp_y.predict(X_test_s), s=6, alpha=0.5, label="MLP (independent)")
ax.scatter(r_y_test, joint_mlp.predict(X_test_s)[:, 0], s=6, alpha=0.5, label="MLP (joint)")
ax.set_xlabel("actual r_y [mm]")
ax.set_ylabel("predicted r_y [mm]")
ax.set_title("axis y")
ax.legend()
ax = axes[1]
lims_z = [r_z_test.min(), r_z_test.max()]
ax.plot(lims_z, lims_z, "k--", lw=1, label="perfect")
ax.scatter(r_z_test, svm_z.predict(X_test_s), s=6, alpha=0.5, label="SVM (independent)")
ax.scatter(r_z_test, mlp_z.predict(X_test_s), s=6, alpha=0.5, label="MLP (independent)")
ax.scatter(r_z_test, joint_mlp.predict(X_test_s)[:, 1], s=6, alpha=0.5, label="MLP (joint)")
ax.set_xlabel("actual r_z [mm]")
ax.set_ylabel("predicted r_z [mm]")
ax.set_title("axis z")
ax.legend()
fig.suptitle("rgs000_0 test set: predicted vs. actual residual -- leading candidates")
plt.tight_layout()
plt.show()
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
ax = axes[0]
ax.hist(r_y_test, bins=30, alpha=0.4, label="physics-only")
ax.hist(r_y_test - mlp_y.predict(X_test_s), bins=30, alpha=0.5, label="hybrid, MLP (independent)")
ax.hist(r_y_test - joint_mlp.predict(X_test_s)[:, 0], bins=30, alpha=0.5, label="hybrid, MLP (joint)")
ax.set_xlabel("r_y [mm]")
ax.set_title("axis y")
ax.legend()
ax = axes[1]
ax.hist(r_z_test, bins=30, alpha=0.4, label="physics-only")
ax.hist(r_z_test - svm_z.predict(X_test_s), bins=30, alpha=0.5, label="hybrid, SVM (independent)")
ax.hist(r_z_test - mlp_z.predict(X_test_s), bins=30, alpha=0.5, label="hybrid, MLP (independent)")
ax.hist(r_z_test - joint_mlp.predict(X_test_s)[:, 1], bins=30, alpha=0.5, label="hybrid, MLP (joint)")
ax.set_xlabel("r_z [mm]")
ax.set_title("axis z")
ax.legend()
fig.suptitle("Residual histogram, before/after ML correction (test set)")
plt.tight_layout()
plt.show()
Residual structure after correction¶
r_y/r_z vs. field position, before vs. after each candidate's correction
-- tests whether the post-correction residual still carries structure (deck
analysis question 5), and is also the most direct way to check the
"structured pattern" observed in the z-axis histogram/scatter above: a model
whose corrected residual still trends with field position is systematically
wrong in a way a flat, structureless one isn't, regardless of which has the
lower aggregate RMSE.
b_y = best_by_axis["y"]
y_nisp_test = d0["y_nisp"].values[test_idx]
r_y_test = d0["r_y"].values[test_idx]
hybrid_r_y_test = r_y_test - b_y["estimator"].predict(X_test_s)
fig, axes = plt.subplots(1, 2, figsize=(10, 4), sharey=True)
for ax, resid, title in zip(axes, [r_y_test, hybrid_r_y_test], ["physics-only", f"hybrid ({b_y['model']})"]):
ax.scatter(y_nisp_test, resid, s=6, alpha=0.4)
ax.axhline(0, color="k", lw=0.5)
ax.set_xlabel("y_nisp [mm]")
ax.set_title(title)
axes[0].set_ylabel("r_y [mm]")
fig.suptitle("r_y vs. field position, before/after ML correction (test set)")
plt.tight_layout()
plt.show()
z_nisp_test = d0["z_nisp"].values[test_idx]
fig, axes = plt.subplots(1, 4, figsize=(20, 4), sharey=True)
candidates_z = [("physics-only", None), ("SVM (independent)", svm_z), ("MLP (independent)", mlp_z)]
for ax, (title, est) in zip(axes[:3], candidates_z):
resid = r_z_test if est is None else r_z_test - est.predict(X_test_s)
ax.scatter(z_nisp_test, resid, s=6, alpha=0.4)
ax.axhline(0, color="k", lw=0.5)
ax.set_title(title)
ax.set_xlabel("z_nisp [mm]")
resid_joint = r_z_test - joint_mlp.predict(X_test_s)[:, 1]
axes[3].scatter(z_nisp_test, resid_joint, s=6, alpha=0.4)
axes[3].axhline(0, color="k", lw=0.5)
axes[3].set_title("MLP (joint)")
axes[3].set_xlabel("z_nisp [mm]")
axes[0].set_ylabel("r_z [mm]")
fig.suptitle("r_z vs. field position, before/after each candidate's correction (test set)")
plt.tight_layout()
plt.show()
Discussion: is the numeric winner actually the best choice?¶
The margins between the leading candidates are small enough that Section 4b's headline ranking shouldn't be taken as final on its own:
- z-axis, independent models: SVM's test RMSE (0.0614 mm) beats MLP's
(0.0626 mm) by ~2% -- comfortably inside what re-running
RandomizedSearchCVwith a different seed or a larger sample could shift (only 6 candidates were sampled per model family here, not an exhaustive search). - Independent-mixed vs. joint MLP: the true best independent pair (MLP
y+ SVMz, 0.0529 mm combined distance) beats the best joint model (MLP, 0.0560 mm) by ~6% -- small, and joint MLP is clearly ahead of every other joint alternative (SVM/GradientBoosting/RandomForest/Linear), so it isn't a weak joint candidate either. - Residual structure: the z-axis histogram above does show a visibly spiky, non-smooth shape for the SVM-corrected residual, exactly as flagged -- but plotting MLP (independent) and MLP (joint) alongside it shows the same spikiness in all three, and the field-position scatter above shows no systematic trend distinguishing any of them either. That points to the spikiness being a property of the test set (only 34 distinct spectra/field positions on this axis, each contributing ~30 rows at nearly the same value, so any model's histogram at this bin width looks clumpy) rather than an SVM-specific failure -- worth checking explicitly rather than assuming, but it doesn't end up uniquely indicting SVM.
- Overfit ratio (
ratio_z/dist_ratio) cuts the other way: SVMz's ratio (1.165) is actually lower (better) than either MLP variant's, independent (1.393) or joint (1.382) -- by that diagnostic SVM generalizes slightly better here, not worse. - Forward-compatibility: independent of today's numbers, this notebook is a proof of concept -- if the planned direction for later stages is a more advanced, NN-based, joint model, picking MLP (independent or joint) now costs a small, plausibly noise-level accuracy difference in exchange for continuity with that direction, rather than adopting SVM (a non-NN, non-joint architecture with no obvious growth path toward that target) only to replace it again shortly.
So this isn't clear-cut either way: the numeric winner (independent, MLP
y + SVM z) is still the best-measured option today, and the
residual-structure concern turns out to apply about equally to all three
z-axis candidates rather than singling out SVM -- but the margins are
small, and the architectural-continuity argument is a legitimate,
independent reason to prefer MLP anyway.
7. Generalization Test on rgs180_0¶
Section 5's decision is postponed -- so instead of testing one pick, this
runs the generalization test for all three candidates under discussion,
each refit on all of rgs000_0 (train+test) and applied to rgs180_0's
own Stage 3 physical fit as the baseline (not rgs000_0's) -- same pattern
3-Intro_ML.ipynb validated for its RF-only version:
- MLP(y) + SVM(z) -- Section 4's measured best (independent, mixed families)
- MLP(y) + MLP(z) -- independent, single family
- MLP(y,z) -- joint, one model for both axes
Generalizing to a different physical grism instance is a genuinely new
test: none of the three candidates were tuned against rgs180_0 in any
way, so unlike Section 5's same-dataset train/test margins (which could
partly reflect this particular 80/20 split), this result isn't subject to
the same overfitting-to-the-split concern.
with open(MODELS_DIR / "stage3_fit_rgs180_0.toml", "rb") as f:
fit180 = tomllib.load(f)
free180 = fit180["fit"]["free_parameters"]
theta180 = np.array([fit180["fit"]["result"][k] for k in free180])
excl180 = fit180["fit"]["excluded_spectra_ids"]
df180 = median_per_spectrum(load_spectra(DATA_DIR / "rgs180_0_first.csv"))
df180 = df180[~df180["spectra_id"].isin(excl180)].reset_index(drop=True)
pred180 = predict_centroids(df180["y_nisp"], df180["z_nisp"], df180["wavelength"], theta180, free180, model)
d180 = df180.assign(r_y=pred180[0] - df180["cent_y"].values, r_z=pred180[1] - df180["cent_z"].values)
X_all_s = scaler.transform(X) # all of rgs000_0, same scaler fit on the train split
X180_s = scaler.transform(d180[["y_nisp", "z_nisp", "wavelength"]].values)
r180_y, r180_z = d180["r_y"].values, d180["r_z"].values
phys180_rmse_y, phys180_rmse_z = np.sqrt(np.mean(r180_y ** 2)), np.sqrt(np.mean(r180_z ** 2))
phys180_dist = np.mean(np.hypot(r180_y, r180_z))
# refit each candidate on ALL of rgs000_0 (train+test) -- rgs180_0 itself is now the held-out set
mlp_y_full = MLPRegressor(**best_by_axis["y"]["params"]).fit(X_all_s, d0["r_y"].values)
svm_z_full = SVR(**best_rows.loc[("z", "SVM")]["params"]).fit(X_all_s, d0["r_z"].values)
mlp_z_full = MLPRegressor(**best_rows.loc[("z", "MLP")]["params"]).fit(X_all_s, d0["r_z"].values)
joint_mlp_full = MLPRegressor(**joint_best.loc["MLP", "params"]).fit(X_all_s, d0[["r_y", "r_z"]].values)
GEN_THRESHOLD = 0.9 # hybrid distance must beat physics-only by >10% to count as "generalizes"
candidates_gen = {
"MLP(y) + SVM(z)": (mlp_y_full.predict(X180_s), svm_z_full.predict(X180_s)),
"MLP(y) + MLP(z)": (mlp_y_full.predict(X180_s), mlp_z_full.predict(X180_s)),
"MLP(y,z) [joint]": (joint_mlp_full.predict(X180_s)[:, 0], joint_mlp_full.predict(X180_s)[:, 1]),
}
gen_rows = []
for name, (pred_y, pred_z) in candidates_gen.items():
hybrid_y, hybrid_z = r180_y - pred_y, r180_z - pred_z
hybrid_dist = np.mean(np.hypot(hybrid_y, hybrid_z))
gen_rows.append({"candidate": name,
"hybrid_rmse_y": np.sqrt(np.mean(hybrid_y ** 2)),
"hybrid_rmse_z": np.sqrt(np.mean(hybrid_z ** 2)),
"hybrid_dist_mm": hybrid_dist,
"generalizes": hybrid_dist < GEN_THRESHOLD * phys180_dist})
gen_summary = pd.DataFrame(gen_rows).set_index("candidate").sort_values("hybrid_dist_mm")
print(f"physics-only on rgs180_0: RMSE_y={phys180_rmse_y:.4f} mm, RMSE_z={phys180_rmse_z:.4f} mm, "
f"combined distance={phys180_dist:.4f} mm")
gen_summary.round(4)
physics-only on rgs180_0: RMSE_y=0.1712 mm, RMSE_z=0.3814 mm, combined distance=0.3642 mm
| hybrid_rmse_y | hybrid_rmse_z | hybrid_dist_mm | generalizes | |
|---|---|---|---|---|
| candidate | ||||
| MLP(y) + MLP(z) | 0.0418 | 0.1162 | 0.1018 | True |
| MLP(y,z) [joint] | 0.0403 | 0.1224 | 0.1059 | True |
| MLP(y) + SVM(z) | 0.0418 | 0.1208 | 0.1076 | True |
Does this help choose a model?¶
Some, but this is still too tight to be seriously decided from these numbers
alone. On rgs000_0's own test set, SVM edged MLP for z by ~2%; on
rgs180_0 that edge reverses by ~4-6% in MLP's favor. Both differences are
on the same order as what re-running RandomizedSearchCV with a different
seed (only 6 samples per family) could plausibly produce, and a single
generalization dataset is one data point, not a validated trend -- this
result nudges the comparison, it doesn't settle it.
Conclusion: model development continues with MLP (NN-based) rather than SVM going forward -- not because these numbers conclusively prove MLP superior (they don't, they're a coin flip either way), but because the planned model evolution for later phases (more advanced, NN-based models) makes an NN-based choice make more sense here regardless of a few-percent margin. SVM is dropped from further consideration on that basis, not on today's metrics.
The choice between the two remaining MLP variants -- MLP(y) + MLP(z)
(independent) and MLP(y,z) (joint) -- stays open. Both are close on every
metric in this notebook (test RMSE, combined distance, and now
generalization), and the joint model is the more natural fit for the
NN-based direction above. Rather than force a pick on noise-level margins,
both are carried forward into Phase 2 and compared further there.
8. Analysis¶
1. Which model achieves the lowest test RMSE? Improvement over the
physical model alone? See the results table (Section 4) and the
physics-only-vs-best printout above -- read off the winning model per axis
and its improvement percentage directly from that output rather than
duplicating the numbers here (they'll shift slightly if grids/seeds change).
Addendum after Sections 4b/5/7: the combined point-distance metric put the
independent, mixed-family pair (MLP y + SVM z) numerically ahead of
every joint model, and SVM narrowly ahead of MLP for z -- but these
margins turned out to be within noise (Section 5), and the ranking even
reversed on the rgs180_0 generalization test (Section 7). The eventual
call -- continuing with MLP rather than SVM -- rests on the planned
NN-based model evolution (Section 7's discussion), not on these numbers
being decisive. Whether to keep y/z independent or move to the joint
model is left open, to be resolved by further comparison in Phase 2.
2. Is the improvement in r_y and r_z symmetric? Compare
physics_rmse["y"] vs. physics_rmse["z"] and the two best test_rmse
values above. z (cross-dispersion / spatial axis) consistently shows a
larger physics-only residual and a larger post-correction residual than y
in this setup -- consistent with Stage 3's own finding that the z-axis fit is
less tightly constrained (larger bootstrap std on offset_z_mm there than on
offset_y_mm). The ML correction narrows the gap but the underlying
asymmetry in how well-constrained each axis's physical fit is doesn't
disappear.
3. Which model overfits most (largest train/test ratio)? Expected?
Read the ratio column of the results table directly -- RandomForest and
GradientBoosting show by far the largest ratios (train RMSE near zero, test
RMSE much larger): expected, since neither has a regularization constraint
active here (no min_samples_leaf/max_leaf_nodes cap, and max_depth up
to 12 lets RF memorize individual training points on a dataset this size).
Linear shows the smallest ratio (high-bias, low-variance, by construction).
SVM and MLP land in between, closest to 1 -- both have explicit
regularization terms (C/epsilon for SVM, alpha weight decay + early
stopping for MLP) doing real work. The same pattern holds in Section 4b's
joint models (dist_ratio), including for MultiOutputRegressor-wrapped
GradientBoosting/SVM -- expected, since wrapping doesn't change each
underlying single-target model's own tendency to overfit.
4. Does the best model generalize? Section 7 tests all three leading
candidates (not just one) against rgs180_0's own correct physics
baseline -- all three generalize (>10% improvement over physics-only), and
the ranking among them shifts slightly relative to Section 4/5's
same-dataset numbers (see Section 7's discussion). Treated as one data point
informing, not deciding, the MLP-vs-SVM and independent-vs-joint questions
above -- not as proof that any one candidate is "the" generalizing model.
5. Are post-correction residuals approximately random, or still
structured? See Section 5's field-position plots (both axes) -- the
z-axis histogram/scatter does show visible structure, but it turned out to
be shared across all three z-axis candidates (SVM, independent MLP, joint
MLP) rather than singling any one out, most likely a test-set granularity
artifact (only 34 distinct z field positions in the test split) rather
than a model-quality signal.
9. Recap¶
- Started from Stage 3's frozen
rgs000_0physical fit (models/stage3_fit_rgs000_0.toml) -- no refit here, per this phase's scope. - Fixed parameters underlying that fit (
prism.A_deg,grating.rho,detector.pixel_size) cross-checked againstEuclid-NISP-Specs.mdand found spec-grounded, not guessed;material.n0/kremain a documented synthetic placeholder (Stage 3 already found them non-identifiable here). - Five ML model families (Linear, RandomForest, GradientBoosting, SVM, MLP)
compared per axis (
r_y,r_z) via grid/randomized hyperparameter search, every combination logged as its own MLflow run in theresidual_correctionexperiment (sqlite:///mlflow.db-- browse withpixi run mlflow-ui); Section 4's results table adds the combined-axes metric that actually matters -- point distance between the corrected prediction and the data,mean(hypot(hybrid_r_y, hybrid_r_z)). - Also tested the same five families as joint 2D-output models (Section 4b) and compared them against the independent per-axis pair on that same metric.
- Numeric margins between the leading candidates (SVM
zvs. MLPz, independent vs. joint) turned out to be tight throughout -- same order as search noise on the same-dataset test set (Section 5), and the ranking even reversed on thergs180_0generalization test (Section 7). None of this notebook's own metrics settle the choice on their own. - Conclusion: continue with MLP / NN-based models, not SVM, going
forward -- decided on the planned model evolution (later phases moving
toward more advanced, NN-based models), not on a clear numeric win here.
Between the two remaining MLP variants -- independent (
MLP(y) + MLP(z)) and joint (MLP(y,z)) -- no pick is made in this notebook; both are carried forward and compared further in Phase 2. - Nothing saved to MLflow as a final "best" model in this phase -- the per-combination sweep (Sections 4/4b) is fully logged, but selecting and persisting a single model artifact is deferred until the independent-vs-joint choice is resolved.
- Stops here per this phase's scope -- no reusable per-dataset function,
no run across the other 5 datasets, no
"per_dataset"MLflow experiment. That's Phase 2 (4-Projet/index.htmlSection 2), a separate notebook, where theMLP(y)+MLP(z)vs.MLP(y,z)comparison also continues.