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
      • Fixed parameters -- cross-checked against
      • 1. Starting Point: Stage 3's Physical Fit
      • 2. Train/Test Split (group-aware)
      • 3. MLflow Experiment
      • 4. Systematic Hyperparameter Comparison
        • Results table
      • 4b. Joint 2D-Output Models — One Model for Both Axes
        • Joint models results table
        • Overall re-evaluation: independent vs. joint
      • 5. Comparing the Leading Candidates
        • Residual structure after correction
        • Discussion: is the numeric winner actually the best choice?
      • 7. Generalization Test on
        • Does this help choose a model?
      • 8. Analysis
      • 9. Recap
    • 4.2 Per-Dataset Pipeline
    • 4.3 Multi-Dataset Joint Fitting
    • 4.5 Comparison With Published Results
    • 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.1 ML Model Comparison

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.

In [1]:
Copied!
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
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).

In [2]:
Copied!
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)")
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).

In [3]:
Copied!
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])
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.

In [4]:
Copied!
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)
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
Out[4]:
<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.

In [5]:
Copied!
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},
    },
}
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}, }, }
In [6]:
Copied!
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")
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.

In [7]:
Copied!
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)
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)
Out[7]:
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
In [8]:
Copied!
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)")
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.

In [9]:
Copied!
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")
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.

In [10]:
Copied!
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)
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)
Out[10]:
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:

In [11]:
Copied!
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)")
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 for y, uncontested.
  • SVM (independent, z) -- Section 4's numeric winner for z.
  • MLP (independent, z) -- Section 4's close second for z (test_rmse_z within ~2% of SVM's).
  • MLP (joint) -- Section 4b's leading joint candidate, predicting both axes from one model.
In [12]:
Copied!
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.")
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.
In [13]:
Copied!
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, 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()
No description has been provided for this image
In [14]:
Copied!
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()
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()
No description has been provided for this image

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.

In [15]:
Copied!
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()
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()
No description has been provided for this image
In [16]:
Copied!
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()
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()
No description has been provided for this image

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 RandomizedSearchCV with 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 + SVM z, 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: SVM z'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.

In [17]:
Copied!
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)
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
Out[17]:
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_0 physical 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 against Euclid-NISP-Specs.md and found spec-grounded, not guessed; material.n0/k remain 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 the residual_correction experiment (sqlite:///mlflow.db -- browse with pixi 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 z vs. MLP z, 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 the rgs180_0 generalization 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.html Section 2), a separate notebook, where the MLP(y)+MLP(z) vs. MLP(y,z) comparison also continues.
Previous Next

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