dispcraft
  • Home

User Guide

  • Getting Started
  • Using the Calibrated Models
  • Notebooks
    • Overview
    • 1. Subject & Instrument Model
    • 2. Data Introduction
    • 3. ML Introduction
    • 4.1 ML Model Comparison
    • 4.2 Per-Dataset Pipeline
      • 1. Fetch Phase 1's Candidate MLP Hyperparameters
      • 2. Reusable Per-Dataset Pipeline Function
      • 3. MLflow Experiment:
      • 4. Run Across All 6 Configs
      • 5. Parameter Consistency Analysis
        • Reading the consistency results
      • 6. RMSE-Across-Datasets Comparison
        • Physical explanation for the hardest-to-fit configuration
      • 7. Independent vs. Joint MLP: Cross-Dataset Verdict
        • Residual structure on the config with the largest divergence
        • Verdict
      • 8. Recap
    • 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.2 Per-Dataset Pipeline

Stage 4, Phase 2 — Applying the Pipeline to All Datasets (4.2-Per_Dataset_Pipeline)¶

Per 4-Projet/index.html, Section 2 / Task 2: wrap the physics + ML pipeline into a single reusable function and apply it to all 6 {rgs000,rgs180}x{0,m4,p4} calibration configurations, logging results to a new MLflow experiment ("per_dataset").

Continuing an open question from Phase 1. 4.1-ML_Comparison.ipynb compared five ML model families for residual correction on rgs000_0 alone, both per-axis (MLP(y), MLP(z) independently) and jointly (MLP(y,z), one model for both axes). It dropped SVM (in favor of this project's planned NN-based direction, not a clean numeric win) but explicitly left the independent-vs-joint MLP choice open — the margin was small (~6%) on rgs000_0's own test set and reversed sign on the rgs180_0 generalization check. Phase 1's Recap says this comparison "continues in Phase 2". This notebook carries both MLP variants through all 6 datasets and looks for a consistent winner across independent fits, rather than one dataset's noise-level margin.

Design decisions carried over from planning (see 4-Projet/TODO.md/the project plan):

  • Physical parameters: loaded from the frozen Stage 3 fits (models/stage3_fit_<cfg>.toml, all 6 already exist) — not refit here. Same pattern 4.1-ML_Comparison.ipynb used for rgs000_0.
  • ML correction: the winning MLP hyperparameters per axis/joint are read back from Phase 1's residual_correction MLflow experiment (no pre-selected "best" run was saved there — every swept combination is logged, so this reads the run history directly), then retrained fresh on each dataset's own train split — not the literal rgs000_0-trained model object (that cross-dataset generalization question is what Phase 1 Section 7 already tested once, separately).
In [1]:
Copied!
import tomllib
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import mlflow
from sklearn.model_selection import GroupShuffleSplit
from sklearn.preprocessing import StandardScaler
from sklearn.neural_network import MLPRegressor
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

CONFIGS = ["rgs000_0", "rgs000_m4", "rgs000_p4", "rgs180_0", "rgs180_m4", "rgs180_p4"]

with open(MODELS_DIR / "stage1_instrument.toml", "rb") as f:
    base_config = tomllib.load(f)
model = ground_test_model_from_config(base_config)
import tomllib from pathlib import Path import numpy as np import pandas as pd import matplotlib.pyplot as plt import mlflow from sklearn.model_selection import GroupShuffleSplit from sklearn.preprocessing import StandardScaler from sklearn.neural_network import MLPRegressor 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 CONFIGS = ["rgs000_0", "rgs000_m4", "rgs000_p4", "rgs180_0", "rgs180_m4", "rgs180_p4"] with open(MODELS_DIR / "stage1_instrument.toml", "rb") as f: base_config = tomllib.load(f) model = ground_test_model_from_config(base_config)

1. Fetch Phase 1's Candidate MLP Hyperparameters¶

No "best" model was saved to MLflow in Phase 1 (see its Recap) — every swept hyperparameter combination is logged in the residual_correction experiment, both independent-per-axis runs (axis="y"/"z", Phase 1 Section 4) and joint runs (axis="joint", Phase 1 Section 4b). Reading the winning combination back from that run history (rather than re-typing the numbers 4.1-ML_Comparison.ipynb printed) keeps this notebook traceable to Phase 1's tracked runs, the same way Phase 1 itself picked best_by_axis and joint_best.

In [2]:
Copied!
import ast

mlflow.set_tracking_uri(f"sqlite:///{(REPO_ROOT / 'mlflow.db').resolve()}")

residual_exp = mlflow.get_experiment_by_name("residual_correction")
assert residual_exp is not None, "run 4.1-ML_Comparison.ipynb first to populate this experiment"
runs = mlflow.search_runs(experiment_ids=[residual_exp.experiment_id])


def best_mlp_params(runs, axis, metric_col):
    sub = runs[(runs["params.model"] == "MLP") & (runs["params.axis"] == axis)]
    best = sub.loc[sub[metric_col].astype(float).idxmin()]
    return {
        "hidden_layer_sizes": ast.literal_eval(best["params.hidden_layer_sizes"]),
        "activation": best["params.activation"],
        "alpha": float(best["params.alpha"]),
        "random_state": int(best["params.random_state"]),
        "max_iter": int(best["params.max_iter"]),
        "early_stopping": best["params.early_stopping"] == "True",
    }


mlp_y_params = best_mlp_params(runs, "y", "metrics.test_rmse")
mlp_z_params = best_mlp_params(runs, "z", "metrics.test_rmse")
mlp_joint_params = best_mlp_params(runs, "joint", "metrics.test_dist_mm")

print("MLP(y)     (independent):", mlp_y_params)
print("MLP(z)     (independent):", mlp_z_params)
print("MLP(y,z)   (joint):      ", mlp_joint_params)
import ast mlflow.set_tracking_uri(f"sqlite:///{(REPO_ROOT / 'mlflow.db').resolve()}") residual_exp = mlflow.get_experiment_by_name("residual_correction") assert residual_exp is not None, "run 4.1-ML_Comparison.ipynb first to populate this experiment" runs = mlflow.search_runs(experiment_ids=[residual_exp.experiment_id]) def best_mlp_params(runs, axis, metric_col): sub = runs[(runs["params.model"] == "MLP") & (runs["params.axis"] == axis)] best = sub.loc[sub[metric_col].astype(float).idxmin()] return { "hidden_layer_sizes": ast.literal_eval(best["params.hidden_layer_sizes"]), "activation": best["params.activation"], "alpha": float(best["params.alpha"]), "random_state": int(best["params.random_state"]), "max_iter": int(best["params.max_iter"]), "early_stopping": best["params.early_stopping"] == "True", } mlp_y_params = best_mlp_params(runs, "y", "metrics.test_rmse") mlp_z_params = best_mlp_params(runs, "z", "metrics.test_rmse") mlp_joint_params = best_mlp_params(runs, "joint", "metrics.test_dist_mm") print("MLP(y) (independent):", mlp_y_params) print("MLP(z) (independent):", mlp_z_params) print("MLP(y,z) (joint): ", mlp_joint_params)
MLP(y)     (independent): {'hidden_layer_sizes': (64, 32), 'activation': 'relu', 'alpha': 0.0001, 'random_state': 42, 'max_iter': 2000, 'early_stopping': True}
MLP(z)     (independent): {'hidden_layer_sizes': (64, 64), 'activation': 'relu', 'alpha': 0.001, 'random_state': 42, 'max_iter': 2000, 'early_stopping': True}
MLP(y,z)   (joint):       {'hidden_layer_sizes': (64, 64), 'activation': 'relu', 'alpha': 0.01, 'random_state': 42, 'max_iter': 2000, 'early_stopping': True}

2. Reusable Per-Dataset Pipeline Function¶

run_pipeline(cfg) does the physics step once (load the frozen Stage 3 fit, compute residuals, group-aware 80/20 split) and then trains both ML candidates from it:

  • Candidate A — independent: two separate MLPRegressors (mlp_y_params, mlp_z_params), one per axis.
  • Candidate B — joint: one MLPRegressor (mlp_joint_params) predicting (r_y, r_z) together.

Both are fit fresh on this dataset's own train split (not the rgs000_0-trained object). The deciding metric throughout is the combined point-distance mean(hypot(r_y, r_z)) — the same metric 4.1-ML_Comparison.ipynb Section 4 established as "the mm-scale number that actually matters", not per-axis RMSE alone.

In [3]:
Copied!
def load_fit(cfg):
    with open(MODELS_DIR / f"stage3_fit_{cfg}.toml", "rb") as f:
        fit = tomllib.load(f)
    free_names = fit["fit"]["free_parameters"]
    theta = np.array([fit["fit"]["result"][k] for k in free_names])
    excluded_ids = fit["fit"]["excluded_spectra_ids"]
    return fit, free_names, theta, excluded_ids


def run_pipeline(cfg):
    fit, free_names, theta, excluded_ids = load_fit(cfg)

    df = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv"))
    df = df[~df["spectra_id"].isin(excluded_ids)].reset_index(drop=True)
    assert len(df) == fit["fit"]["n_points"], f"{cfg}: row count must match the TOML's recorded fit"

    pred = predict_centroids(df["y_nisp"], df["z_nisp"], df["wavelength"], theta, free_names, model)
    d = df.assign(r_y=pred[0] - df["cent_y"].values, r_z=pred[1] - df["cent_z"].values)

    X = d[["y_nisp", "z_nisp", "wavelength"]].values
    groups = d["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))

    scaler = StandardScaler().fit(X[train_idx])
    X_train_s, X_test_s = scaler.transform(X[train_idx]), scaler.transform(X[test_idx])
    r_y_train, r_z_train = d["r_y"].values[train_idx], d["r_z"].values[train_idx]
    r_y_test, r_z_test = d["r_y"].values[test_idx], d["r_z"].values[test_idx]

    physical_rmse_y = np.sqrt(mean_squared_error(r_y_test, np.zeros_like(r_y_test)))
    physical_rmse_z = np.sqrt(mean_squared_error(r_z_test, np.zeros_like(r_z_test)))
    physical_dist_test = np.mean(np.hypot(r_y_test, r_z_test))

    # Candidate A: independent per-axis MLPs
    mlp_y = MLPRegressor(**mlp_y_params).fit(X_train_s, r_y_train)
    mlp_z = MLPRegressor(**mlp_z_params).fit(X_train_s, r_z_train)
    hyb_y_ind = r_y_test - mlp_y.predict(X_test_s)
    hyb_z_ind = r_z_test - mlp_z.predict(X_test_s)

    # Candidate B: joint 2D-output MLP
    mlp_joint = MLPRegressor(**mlp_joint_params).fit(X_train_s, d[["r_y", "r_z"]].values[train_idx])
    pred_joint_test = mlp_joint.predict(X_test_s)
    hyb_y_joint = r_y_test - pred_joint_test[:, 0]
    hyb_z_joint = r_z_test - pred_joint_test[:, 1]

    candidates = {
        "independent": {
            "hybrid_rmse_y": np.sqrt(mean_squared_error(r_y_test, mlp_y.predict(X_test_s))),
            "hybrid_rmse_z": np.sqrt(mean_squared_error(r_z_test, mlp_z.predict(X_test_s))),
            "hybrid_dist_test": np.mean(np.hypot(hyb_y_ind, hyb_z_ind)),
            "models": {"y": mlp_y, "z": mlp_z},
        },
        "joint": {
            "hybrid_rmse_y": np.sqrt(mean_squared_error(r_y_test, pred_joint_test[:, 0])),
            "hybrid_rmse_z": np.sqrt(mean_squared_error(r_z_test, pred_joint_test[:, 1])),
            "hybrid_dist_test": np.mean(np.hypot(hyb_y_joint, hyb_z_joint)),
            "models": {"joint": mlp_joint},
        },
    }

    p = dict(zip(free_names, theta))
    return {
        "cfg": cfg, "df": d, "test_idx": test_idx, "scaler": scaler,
        "free_names": free_names, "theta": theta, "params": p,
        "physical_rmse_y": physical_rmse_y, "physical_rmse_z": physical_rmse_z,
        "physical_dist_test": physical_dist_test,
        "candidates": candidates,
    }
def load_fit(cfg): with open(MODELS_DIR / f"stage3_fit_{cfg}.toml", "rb") as f: fit = tomllib.load(f) free_names = fit["fit"]["free_parameters"] theta = np.array([fit["fit"]["result"][k] for k in free_names]) excluded_ids = fit["fit"]["excluded_spectra_ids"] return fit, free_names, theta, excluded_ids def run_pipeline(cfg): fit, free_names, theta, excluded_ids = load_fit(cfg) df = median_per_spectrum(load_spectra(DATA_DIR / f"{cfg}_first.csv")) df = df[~df["spectra_id"].isin(excluded_ids)].reset_index(drop=True) assert len(df) == fit["fit"]["n_points"], f"{cfg}: row count must match the TOML's recorded fit" pred = predict_centroids(df["y_nisp"], df["z_nisp"], df["wavelength"], theta, free_names, model) d = df.assign(r_y=pred[0] - df["cent_y"].values, r_z=pred[1] - df["cent_z"].values) X = d[["y_nisp", "z_nisp", "wavelength"]].values groups = d["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)) scaler = StandardScaler().fit(X[train_idx]) X_train_s, X_test_s = scaler.transform(X[train_idx]), scaler.transform(X[test_idx]) r_y_train, r_z_train = d["r_y"].values[train_idx], d["r_z"].values[train_idx] r_y_test, r_z_test = d["r_y"].values[test_idx], d["r_z"].values[test_idx] physical_rmse_y = np.sqrt(mean_squared_error(r_y_test, np.zeros_like(r_y_test))) physical_rmse_z = np.sqrt(mean_squared_error(r_z_test, np.zeros_like(r_z_test))) physical_dist_test = np.mean(np.hypot(r_y_test, r_z_test)) # Candidate A: independent per-axis MLPs mlp_y = MLPRegressor(**mlp_y_params).fit(X_train_s, r_y_train) mlp_z = MLPRegressor(**mlp_z_params).fit(X_train_s, r_z_train) hyb_y_ind = r_y_test - mlp_y.predict(X_test_s) hyb_z_ind = r_z_test - mlp_z.predict(X_test_s) # Candidate B: joint 2D-output MLP mlp_joint = MLPRegressor(**mlp_joint_params).fit(X_train_s, d[["r_y", "r_z"]].values[train_idx]) pred_joint_test = mlp_joint.predict(X_test_s) hyb_y_joint = r_y_test - pred_joint_test[:, 0] hyb_z_joint = r_z_test - pred_joint_test[:, 1] candidates = { "independent": { "hybrid_rmse_y": np.sqrt(mean_squared_error(r_y_test, mlp_y.predict(X_test_s))), "hybrid_rmse_z": np.sqrt(mean_squared_error(r_z_test, mlp_z.predict(X_test_s))), "hybrid_dist_test": np.mean(np.hypot(hyb_y_ind, hyb_z_ind)), "models": {"y": mlp_y, "z": mlp_z}, }, "joint": { "hybrid_rmse_y": np.sqrt(mean_squared_error(r_y_test, pred_joint_test[:, 0])), "hybrid_rmse_z": np.sqrt(mean_squared_error(r_z_test, pred_joint_test[:, 1])), "hybrid_dist_test": np.mean(np.hypot(hyb_y_joint, hyb_z_joint)), "models": {"joint": mlp_joint}, }, } p = dict(zip(free_names, theta)) return { "cfg": cfg, "df": d, "test_idx": test_idx, "scaler": scaler, "free_names": free_names, "theta": theta, "params": p, "physical_rmse_y": physical_rmse_y, "physical_rmse_z": physical_rmse_z, "physical_dist_test": physical_dist_test, "candidates": candidates, }

3. MLflow Experiment: per_dataset¶

Two runs per dataset (one per candidate), same one-run-per-combination granularity Phase 1 used — not one row per dataset hiding which approach won.

In [4]:
Copied!
EXPERIMENT_NAME = "per_dataset"
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)
EXPERIMENT_NAME = "per_dataset" 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)
Out[4]:
<Experiment: artifact_location='file:/home/zoubian/Workspace/dispers/dispcraft/mlruns', creation_time=1784811521876, effective_trace_archival_retention=None, experiment_id='2', last_update_time=1784811521876, lifecycle_stage='active', name='per_dataset', tags={}, trace_location=None, workspace='default'>

4. Run Across All 6 Configs¶

In [5]:
Copied!
results = {}
rows = []

for cfg in CONFIGS:
    res = run_pipeline(cfg)
    results[cfg] = res

    for approach, c in res["candidates"].items():
        improvement = 1 - c["hybrid_dist_test"] / res["physical_dist_test"]
        rows.append({
            "dataset": cfg, "approach": approach,
            "offset_y_mm": res["params"]["offset_y_mm"], "offset_z_mm": res["params"]["offset_z_mm"],
            "tilt_deg_mod360": res["params"]["tilt_deg"] % 360, "rho": res["params"]["rho"],
            "physical_dist_test": res["physical_dist_test"],
            "hybrid_dist_test": c["hybrid_dist_test"],
            "hybrid_rmse_y": c["hybrid_rmse_y"], "hybrid_rmse_z": c["hybrid_rmse_z"],
            "improvement_pct": 100 * improvement,
        })

        with mlflow.start_run(run_name=f"{cfg}_{approach}"):
            mlflow.log_param("dataset", cfg)
            mlflow.log_param("approach", approach)
            for k, v in res["params"].items():
                mlflow.log_param(k, v)
            if approach == "independent":
                for axis, p in [("y", mlp_y_params), ("z", mlp_z_params)]:
                    for k, v in p.items():
                        mlflow.log_param(f"mlp_{axis}_{k}", v)
            else:
                for k, v in mlp_joint_params.items():
                    mlflow.log_param(f"mlp_joint_{k}", v)
            mlflow.log_metric("physical_dist_test", res["physical_dist_test"])
            mlflow.log_metric("hybrid_dist_test", c["hybrid_dist_test"])
            mlflow.log_metric("hybrid_rmse_y", c["hybrid_rmse_y"])
            mlflow.log_metric("hybrid_rmse_z", c["hybrid_rmse_z"])
            mlflow.log_metric("improvement_pct", 100 * improvement)

results_df = pd.DataFrame(rows)
print(f"logged {len(rows)} runs to the '{EXPERIMENT_NAME}' MLflow experiment")
results_df.round(4)
results = {} rows = [] for cfg in CONFIGS: res = run_pipeline(cfg) results[cfg] = res for approach, c in res["candidates"].items(): improvement = 1 - c["hybrid_dist_test"] / res["physical_dist_test"] rows.append({ "dataset": cfg, "approach": approach, "offset_y_mm": res["params"]["offset_y_mm"], "offset_z_mm": res["params"]["offset_z_mm"], "tilt_deg_mod360": res["params"]["tilt_deg"] % 360, "rho": res["params"]["rho"], "physical_dist_test": res["physical_dist_test"], "hybrid_dist_test": c["hybrid_dist_test"], "hybrid_rmse_y": c["hybrid_rmse_y"], "hybrid_rmse_z": c["hybrid_rmse_z"], "improvement_pct": 100 * improvement, }) with mlflow.start_run(run_name=f"{cfg}_{approach}"): mlflow.log_param("dataset", cfg) mlflow.log_param("approach", approach) for k, v in res["params"].items(): mlflow.log_param(k, v) if approach == "independent": for axis, p in [("y", mlp_y_params), ("z", mlp_z_params)]: for k, v in p.items(): mlflow.log_param(f"mlp_{axis}_{k}", v) else: for k, v in mlp_joint_params.items(): mlflow.log_param(f"mlp_joint_{k}", v) mlflow.log_metric("physical_dist_test", res["physical_dist_test"]) mlflow.log_metric("hybrid_dist_test", c["hybrid_dist_test"]) mlflow.log_metric("hybrid_rmse_y", c["hybrid_rmse_y"]) mlflow.log_metric("hybrid_rmse_z", c["hybrid_rmse_z"]) mlflow.log_metric("improvement_pct", 100 * improvement) results_df = pd.DataFrame(rows) print(f"logged {len(rows)} runs to the '{EXPERIMENT_NAME}' MLflow experiment") results_df.round(4)
logged 12 runs to the 'per_dataset' MLflow experiment
Out[5]:
dataset approach offset_y_mm offset_z_mm tilt_deg_mod360 rho physical_dist_test hybrid_dist_test hybrid_rmse_y hybrid_rmse_z improvement_pct
0 rgs000_0 independent -0.5508 0.7377 0.1331 13.0758 0.3078 0.0538 0.0165 0.0626 82.5208
1 rgs000_0 joint -0.5508 0.7377 0.1331 13.0758 0.3078 0.0560 0.0195 0.0635 81.7897
2 rgs000_m4 independent -0.4923 1.2423 4.1492 13.0571 0.2928 0.0531 0.0143 0.0627 81.8509
3 rgs000_m4 joint -0.4923 1.2423 4.1492 13.0571 0.2928 0.0531 0.0147 0.0625 81.8714
4 rgs000_p4 independent -0.5718 0.3099 356.2448 13.1074 0.3485 0.0729 0.0157 0.0912 79.0914
5 rgs000_p4 joint -0.5718 0.3099 356.2448 13.1074 0.3485 0.0703 0.0159 0.0883 79.8189
6 rgs180_0 independent -0.5117 -0.4518 180.2350 13.0900 0.3283 0.0650 0.0144 0.0824 80.2150
7 rgs180_0 joint -0.5117 -0.4518 180.2350 13.0900 0.3283 0.0688 0.0157 0.0880 79.0556
8 rgs180_m4 independent -0.5516 -0.0247 184.1997 13.0776 0.3500 0.0623 0.0201 0.0721 82.1990
9 rgs180_m4 joint -0.5516 -0.0247 184.1997 13.0776 0.3500 0.0582 0.0183 0.0712 83.3655
10 rgs180_p4 independent -0.4656 -0.9518 176.2091 13.0436 0.3132 0.0678 0.0194 0.0781 78.3540
11 rgs180_p4 joint -0.4656 -0.9518 176.2091 13.0436 0.3132 0.0715 0.0195 0.0821 77.1601

5. Parameter Consistency Analysis¶

Physical parameters don't depend on the ML candidate, so this uses one row per dataset (either approach's copy — they're identical). Per the physics-model-calibration skill, checking which parameters look consistent across datasets identifies candidates for Phase 3's "shared" treatment.

In [6]:
Copied!
param_table = results_df[results_df["approach"] == "independent"].set_index("dataset")[
    ["offset_y_mm", "offset_z_mm", "tilt_deg_mod360", "rho"]
]
print(param_table.round(4))

print("\noffset_y_mm: mean={:.4f}, std={:.4f} mm".format(param_table["offset_y_mm"].mean(), param_table["offset_y_mm"].std()))
print("offset_z_mm: mean={:.4f}, std={:.4f} mm".format(param_table["offset_z_mm"].mean(), param_table["offset_z_mm"].std()))

rgs000_rho = param_table.loc[[c for c in CONFIGS if c.startswith("rgs000")], "rho"]
rgs180_rho = param_table.loc[[c for c in CONFIGS if c.startswith("rgs180")], "rho"]
print(f"\nrho, rgs000 group: mean={rgs000_rho.mean():.4f}, std={rgs000_rho.std():.4f} (within-group)")
print(f"rho, rgs180 group: mean={rgs180_rho.mean():.4f}, std={rgs180_rho.std():.4f} (within-group)")
print(f"rho, |rgs000 mean - rgs180 mean| = {abs(rgs000_rho.mean() - rgs180_rho.mean()):.4f} (across-group)")

print("\ntilt_deg_mod360 is dataset-specific by construction (GWA commanded angle) -- no consistency expected:")
print(param_table["tilt_deg_mod360"])
param_table = results_df[results_df["approach"] == "independent"].set_index("dataset")[ ["offset_y_mm", "offset_z_mm", "tilt_deg_mod360", "rho"] ] print(param_table.round(4)) print("\noffset_y_mm: mean={:.4f}, std={:.4f} mm".format(param_table["offset_y_mm"].mean(), param_table["offset_y_mm"].std())) print("offset_z_mm: mean={:.4f}, std={:.4f} mm".format(param_table["offset_z_mm"].mean(), param_table["offset_z_mm"].std())) rgs000_rho = param_table.loc[[c for c in CONFIGS if c.startswith("rgs000")], "rho"] rgs180_rho = param_table.loc[[c for c in CONFIGS if c.startswith("rgs180")], "rho"] print(f"\nrho, rgs000 group: mean={rgs000_rho.mean():.4f}, std={rgs000_rho.std():.4f} (within-group)") print(f"rho, rgs180 group: mean={rgs180_rho.mean():.4f}, std={rgs180_rho.std():.4f} (within-group)") print(f"rho, |rgs000 mean - rgs180 mean| = {abs(rgs000_rho.mean() - rgs180_rho.mean()):.4f} (across-group)") print("\ntilt_deg_mod360 is dataset-specific by construction (GWA commanded angle) -- no consistency expected:") print(param_table["tilt_deg_mod360"])
           offset_y_mm  offset_z_mm  tilt_deg_mod360      rho
dataset                                                      
rgs000_0       -0.5508       0.7377           0.1331  13.0758
rgs000_m4      -0.4923       1.2423           4.1492  13.0571
rgs000_p4      -0.5718       0.3099         356.2448  13.1074
rgs180_0       -0.5117      -0.4518         180.2350  13.0900
rgs180_m4      -0.5516      -0.0247         184.1997  13.0776
rgs180_p4      -0.4656      -0.9518         176.2091  13.0436

offset_y_mm: mean=-0.5240, std=0.0408 mm
offset_z_mm: mean=0.1436, std=0.7963 mm

rho, rgs000 group: mean=13.0801, std=0.0254 (within-group)
rho, rgs180 group: mean=13.0704, std=0.0241 (within-group)
rho, |rgs000 mean - rgs180 mean| = 0.0097 (across-group)

tilt_deg_mod360 is dataset-specific by construction (GWA commanded angle) -- no consistency expected:
dataset
rgs000_0       0.133121
rgs000_m4      4.149162
rgs000_p4    356.244802
rgs180_0     180.235030
rgs180_m4    184.199721
rgs180_p4    176.209083
Name: tilt_deg_mod360, dtype: float64

Reading the consistency results¶

  • offset_y_mm: consistent across all 6 datasets — mean −0.524 mm, std 0.041 mm (~8% relative spread), no grouping by grism identity or tilt. A good candidate for a shared parameter in Phase 3.
  • rho: close-but-not-identical between the two grism instances, as Stage 3 already found — within-group std (~0.025 for rgs000, ~0.024 for rgs180) is actually about the same size as the across-group mean difference (0.0097). Confirms instance-specific, identifiable: not a single shared value, but not dataset-specific either — one value per physical grism.
  • offset_z_mm — surprising, not consistent. Std (0.796 mm) is ~20x larger than offset_y_mm's, and it isn't scattered randomly: every rgs000_* value is positive (0.31–1.24 mm) and every rgs180_* value is negative (−0.02 to −0.95 mm) — a clean split by grism identity, not detector drift. This lines up with 3-Intro_ML.ipynb's own degeneracy discussion: flipping tilt_deg by 180° negates the dispersion-direction unit vector in Grism.forward, and detector.offset_z absorbs whatever constant term is left over — differently, depending on which side of that 180° flip a given grism sits on. So offset_z_mm, as currently parameterized, is not purely "single detector, one registration constant" the way offset_y_mm is — it's carrying a mounting-orientation-dependent sign. Flagged for Phase 3, not resolved here: naively sharing offset_z_mm across all 6 datasets would be physically wrong; either it stays dataset- (or at least grism-instance-)specific, or Phase 3's model needs an explicit sign/parity term tied to grism identity before offset_z itself can be shared.
  • tilt_deg: as expected, tracks the commanded GWA angle. Cross-check against Stage 3's rgs180 = rgs000 + 180° finding: rgs000_0→rgs180_0 differ by 180.10°, rgs000_m4→rgs180_m4 by 180.05°, rgs000_p4→ rgs180_p4 by 179.96° — all within ~0.1° of exactly 180°, confirming the frozen Stage 3 TOMLs were loaded correctly here.

6. RMSE-Across-Datasets Comparison¶

In [7]:
Copied!
rmse_pivot = results_df.pivot(index="dataset", columns="approach", values="hybrid_dist_test")
rmse_pivot["physical"] = results_df[results_df["approach"] == "independent"].set_index("dataset")["physical_dist_test"]
rmse_pivot = rmse_pivot[["physical", "independent", "joint"]].loc[CONFIGS]
print(rmse_pivot.round(4))

hardest = rmse_pivot["physical"].idxmax()
easiest = rmse_pivot["physical"].idxmin()
print(f"\nhardest-to-fit config (physical-only): {hardest} ({rmse_pivot.loc[hardest, 'physical']:.4f} mm)")
print(f"easiest-to-fit config (physical-only): {easiest} ({rmse_pivot.loc[easiest, 'physical']:.4f} mm)")

fig, ax = plt.subplots(figsize=(9, 4.5))
x = np.arange(len(CONFIGS))
width = 0.25
ax.bar(x - width, rmse_pivot["physical"], width, label="physical-only")
ax.bar(x, rmse_pivot["independent"], width, label="hybrid, MLP(y)+MLP(z)")
ax.bar(x + width, rmse_pivot["joint"], width, label="hybrid, MLP(y,z)")
ax.set_xticks(x)
ax.set_xticklabels(CONFIGS, rotation=30, ha="right")
ax.set_ylabel("combined point distance [mm]")
ax.legend()
ax.set_title("Physical vs. hybrid (both candidates), per dataset")
plt.tight_layout()
plt.show()
rmse_pivot = results_df.pivot(index="dataset", columns="approach", values="hybrid_dist_test") rmse_pivot["physical"] = results_df[results_df["approach"] == "independent"].set_index("dataset")["physical_dist_test"] rmse_pivot = rmse_pivot[["physical", "independent", "joint"]].loc[CONFIGS] print(rmse_pivot.round(4)) hardest = rmse_pivot["physical"].idxmax() easiest = rmse_pivot["physical"].idxmin() print(f"\nhardest-to-fit config (physical-only): {hardest} ({rmse_pivot.loc[hardest, 'physical']:.4f} mm)") print(f"easiest-to-fit config (physical-only): {easiest} ({rmse_pivot.loc[easiest, 'physical']:.4f} mm)") fig, ax = plt.subplots(figsize=(9, 4.5)) x = np.arange(len(CONFIGS)) width = 0.25 ax.bar(x - width, rmse_pivot["physical"], width, label="physical-only") ax.bar(x, rmse_pivot["independent"], width, label="hybrid, MLP(y)+MLP(z)") ax.bar(x + width, rmse_pivot["joint"], width, label="hybrid, MLP(y,z)") ax.set_xticks(x) ax.set_xticklabels(CONFIGS, rotation=30, ha="right") ax.set_ylabel("combined point distance [mm]") ax.legend() ax.set_title("Physical vs. hybrid (both candidates), per dataset") plt.tight_layout() plt.show()
approach   physical  independent   joint
dataset                                 
rgs000_0     0.3078       0.0538  0.0560
rgs000_m4    0.2928       0.0531  0.0531
rgs000_p4    0.3485       0.0729  0.0703
rgs180_0     0.3283       0.0650  0.0688
rgs180_m4    0.3500       0.0623  0.0582
rgs180_p4    0.3132       0.0678  0.0715

hardest-to-fit config (physical-only): rgs180_m4 (0.3500 mm)
easiest-to-fit config (physical-only): rgs000_m4 (0.2928 mm)
No description has been provided for this image

Physical explanation for the hardest-to-fit configuration¶

Physical-only combined distance, all 6 configs (mm): rgs000_0 0.308, rgs000_m4 0.293, rgs000_p4 0.349, rgs180_0 0.328, rgs180_m4 0.350, rgs180_p4 0.313.

No clean split by grism identity: rgs000_p4 (0.349) is nearly as hard as the overall hardest rgs180_m4 (0.350), and harder than rgs180_p4 (0.313) — so "rgs180 fits worse than rgs000" doesn't hold here. The pattern that does hold: the two ±4° configs at the extremes are both tilted configs (rgs000_m4 easiest, rgs180_m4/rgs000_p4 hardest), while both 0°-tilt configs (rgs000_0, rgs180_0) sit in a narrower, middling band (0.308–0.328 mm). Tilted configurations show more spread in fit quality (both the best and the two worst results are tilted configs) than the nominal 0° position. A plausible physical reading: 0° is likely the GWA's most repeatable mechanical stop (its nominal/rest position), while ±4° commanded positions depend on the grism-wheel actuator's positioning repeatability — small mechanical backlash or angle-readout error at a non-rest position would show up as extra fit residual that varies from one ±4° acquisition to the next, rather than as a systematic per-grism effect. The overall spread across all 6 configs is modest either way (0.293–0.350 mm, ~20% relative) — no configuration is a dramatic outlier.

7. Independent vs. Joint MLP: Cross-Dataset Verdict¶

The comparison Phase 1 left open, now run 6x instead of once. Phase 1's own single-dataset margin was small (~6%) and flipped sign on the rgs180_0 generalization check — the question here is whether a consistent winner emerges across 6 independent fits, or the same noise-level pattern persists.

In [8]:
Copied!
verdict_pivot = results_df.pivot(index="dataset", columns="approach", values="hybrid_dist_test").loc[CONFIGS]
verdict_pivot["winner"] = np.where(verdict_pivot["independent"] < verdict_pivot["joint"], "independent", "joint")
verdict_pivot["margin_pct"] = 100 * (verdict_pivot["joint"] - verdict_pivot["independent"]) / verdict_pivot[["independent", "joint"]].min(axis=1)
print(verdict_pivot.round(4))

win_counts = verdict_pivot["winner"].value_counts()
print(f"\nwins by dataset count: {win_counts.to_dict()}")
print(f"mean margin (joint - independent, % of the smaller): {verdict_pivot['margin_pct'].mean():+.2f}%")
print(f"margin range: {verdict_pivot['margin_pct'].min():+.2f}% to {verdict_pivot['margin_pct'].max():+.2f}%")

worst_divergence_cfg = verdict_pivot["margin_pct"].abs().idxmax()
print(f"\nlargest divergence between approaches: {worst_divergence_cfg} "
      f"({verdict_pivot.loc[worst_divergence_cfg, 'margin_pct']:+.2f}%)")
verdict_pivot = results_df.pivot(index="dataset", columns="approach", values="hybrid_dist_test").loc[CONFIGS] verdict_pivot["winner"] = np.where(verdict_pivot["independent"] < verdict_pivot["joint"], "independent", "joint") verdict_pivot["margin_pct"] = 100 * (verdict_pivot["joint"] - verdict_pivot["independent"]) / verdict_pivot[["independent", "joint"]].min(axis=1) print(verdict_pivot.round(4)) win_counts = verdict_pivot["winner"].value_counts() print(f"\nwins by dataset count: {win_counts.to_dict()}") print(f"mean margin (joint - independent, % of the smaller): {verdict_pivot['margin_pct'].mean():+.2f}%") print(f"margin range: {verdict_pivot['margin_pct'].min():+.2f}% to {verdict_pivot['margin_pct'].max():+.2f}%") worst_divergence_cfg = verdict_pivot["margin_pct"].abs().idxmax() print(f"\nlargest divergence between approaches: {worst_divergence_cfg} " f"({verdict_pivot.loc[worst_divergence_cfg, 'margin_pct']:+.2f}%)")
approach   independent   joint       winner  margin_pct
dataset                                                
rgs000_0        0.0538  0.0560  independent      4.1826
rgs000_m4       0.0531  0.0531        joint     -0.1133
rgs000_p4       0.0729  0.0703        joint     -3.6049
rgs180_0        0.0650  0.0688  independent      5.8602
rgs180_m4       0.0623  0.0582        joint     -7.0127
rgs180_p4       0.0678  0.0715  independent      5.5159

wins by dataset count: {'independent': 3, 'joint': 3}
mean margin (joint - independent, % of the smaller): +0.80%
margin range: -7.01% to +5.86%

largest divergence between approaches: rgs180_m4 (-7.01%)

Residual structure on the config with the largest divergence¶

Predicted-vs-actual for both candidates on the dataset where they disagree most (worst_divergence_cfg above) — not all 6 datasets, to keep this notebook proportionate; the aggregate table above is the primary evidence.

In [9]:
Copied!
res = results[worst_divergence_cfg]
test_idx = res["test_idx"]
d = res["df"]
r_y_test = d["r_y"].values[test_idx]
r_z_test = d["r_z"].values[test_idx]

X_test_s = res["scaler"].transform(d[["y_nisp", "z_nisp", "wavelength"]].values[test_idx])
mlp_y, mlp_z = res["candidates"]["independent"]["models"]["y"], res["candidates"]["independent"]["models"]["z"]
mlp_joint = res["candidates"]["joint"]["models"]["joint"]
pred_joint = mlp_joint.predict(X_test_s)

fig, axes = plt.subplots(1, 2, figsize=(11, 5))
for ax, actual, pred_ind, pred_j, label in [
    (axes[0], r_y_test, mlp_y.predict(X_test_s), pred_joint[:, 0], "y"),
    (axes[1], r_z_test, mlp_z.predict(X_test_s), pred_joint[:, 1], "z"),
]:
    lims = [actual.min(), actual.max()]
    ax.plot(lims, lims, "k--", lw=1, label="perfect")
    ax.scatter(actual, pred_ind, s=6, alpha=0.5, label="MLP (independent)")
    ax.scatter(actual, pred_j, s=6, alpha=0.5, label="MLP (joint)")
    ax.set_xlabel(f"actual r_{label} [mm]")
    ax.set_ylabel(f"predicted r_{label} [mm]")
    ax.set_title(f"axis {label}")
    ax.legend()
fig.suptitle(f"{worst_divergence_cfg} test set: predicted vs. actual residual, both candidates")
plt.tight_layout()
plt.show()
res = results[worst_divergence_cfg] test_idx = res["test_idx"] d = res["df"] r_y_test = d["r_y"].values[test_idx] r_z_test = d["r_z"].values[test_idx] X_test_s = res["scaler"].transform(d[["y_nisp", "z_nisp", "wavelength"]].values[test_idx]) mlp_y, mlp_z = res["candidates"]["independent"]["models"]["y"], res["candidates"]["independent"]["models"]["z"] mlp_joint = res["candidates"]["joint"]["models"]["joint"] pred_joint = mlp_joint.predict(X_test_s) fig, axes = plt.subplots(1, 2, figsize=(11, 5)) for ax, actual, pred_ind, pred_j, label in [ (axes[0], r_y_test, mlp_y.predict(X_test_s), pred_joint[:, 0], "y"), (axes[1], r_z_test, mlp_z.predict(X_test_s), pred_joint[:, 1], "z"), ]: lims = [actual.min(), actual.max()] ax.plot(lims, lims, "k--", lw=1, label="perfect") ax.scatter(actual, pred_ind, s=6, alpha=0.5, label="MLP (independent)") ax.scatter(actual, pred_j, s=6, alpha=0.5, label="MLP (joint)") ax.set_xlabel(f"actual r_{label} [mm]") ax.set_ylabel(f"predicted r_{label} [mm]") ax.set_title(f"axis {label}") ax.legend() fig.suptitle(f"{worst_divergence_cfg} test set: predicted vs. actual residual, both candidates") plt.tight_layout() plt.show()
No description has been provided for this image

Verdict¶

Tied, 3 datasets to 3. independent (MLP(y)+MLP(z)) wins on rgs000_0, rgs180_0, rgs180_p4; joint (MLP(y,z)) wins on rgs000_m4, rgs000_p4, rgs180_m4. Mean margin is +0.80% in independent's favor (i.e. essentially zero), and the per-dataset margins (−7.0% to +5.9%) show no consistent sign by grism identity or by tilt direction — the winner alternates dataset to dataset with no visible driver. This is the same noise-level pattern Phase 1 found on a single dataset (test-set margin flipped sign on the rgs180_0 generalization check); running it 6 more times did not resolve it into a consistent winner — if anything it confirms the two approaches really are comparably accurate on this data, not that either notebook's comparison was underpowered.

The residual-structure check on rgs180_m4 (the largest divergence, −7.0%, favoring joint) shows both candidates tracking the actual residuals about equally well on both axes — no visible qualitative difference explaining the numeric margin.

Per the verdict rule: with no consistent-sign winner across 6 independent fits, this stays undecided on accuracy grounds. Falling back to the hybrid-residual-modeling skill's simplicity-vs-accuracy default — prefer the simpler option unless the more complex one clearly earns its complexity — MLP(y)+MLP(z) (independent) is carried forward as the default for Phase 3: two smaller, independently tunable per-axis models are simpler to reason about and diagnose than one shared-weight network predicting both axes, and nothing in this notebook's 6-dataset comparison shows the joint model earning its added complexity. This is a documented tie-breaker, not a claim that independent is more accurate — Phase 3 or later work is free to revisit if new evidence (e.g. a case where the two axes' residuals are known to be correlated) favors joint modeling.

8. Recap¶

  • Reusable pipeline (run_pipeline(cfg)) built and run across all 6 {rgs000,rgs180}×{0,m4,p4} configs. Physical parameters loaded from the frozen Stage 3 fits (models/stage3_fit_<cfg>.toml) — row-count assertion passed for all 6, and the tilt_deg cross-check reconfirmed rgs180 ≈ rgs000 + 180° (within ~0.1°), so the reused fits loaded correctly.
  • MLflow "per_dataset" experiment: 12 runs logged (one independent + one joint per dataset), each with the fitted physical parameters, MLP hyperparameters used, and physical/hybrid test metrics.
  • Parameter consistency (Section 5): offset_y_mm behaves as a good shared-parameter candidate (~8% relative spread, no grouping); rho is instance-specific but identifiable, close between rgs000/rgs180 as Stage 3 predicted. offset_z_mm was not consistent — it splits cleanly by grism identity (positive for rgs000_*, negative for rgs180_*) rather than clustering around one shared value, traced to the same tilt+180° degeneracy 3-Intro_ML.ipynb flagged. This is new, concrete input for Phase 3's shared-vs-specific classification, not something Stage 3's qualitative discussion alone showed.
  • RMSE across datasets (Section 6): physical-only fit quality stays in a fairly narrow band (0.293–0.350 mm combined distance) across all 6 configs; the two extremes are both ±4°-tilt configurations, not a rgs000-vs-rgs180 split — read as GWA positioning repeatability being less consistent away from the 0° nominal stop, not a grism-specific effect. Hybrid ML correction (either candidate) improves 77–83% over physical-only, consistently across all 6 datasets.
  • Independent (MLP(y)+MLP(z)) vs. joint (MLP(y,z)) (Section 7): the question Phase 1 left open stayed open — a 3-3 tie across the 6 datasets, mean margin ≈0.8%, no consistent-sign pattern. Per the documented tie-breaker (simplicity-vs-accuracy default from the hybrid-residual-modeling skill), MLP(y)+MLP(z) (independent) is carried forward as the working choice — not because it measured more accurate, but because nothing here shows the joint model earning its extra complexity.
  • Stops here per this phase's scope. Phase 3 (4-Projet/index.html Section 3, "Multi-Dataset Fitting" — a combined cost function jointly optimizing shared parameters across all 6 datasets, then re-optimizing dataset-specific ones, then adding ML correction) is out of scope for this notebook and gets its own notebook once started. The offset_z_mm inconsistency finding above is directly relevant to how that phase should classify parameters as shared vs. dataset-specific.
Previous Next

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