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 pattern4.1-ML_Comparison.ipynbused forrgs000_0. - ML correction: the winning MLP hyperparameters per axis/joint are read
back from Phase 1's
residual_correctionMLflow 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 literalrgs000_0-trained model object (that cross-dataset generalization question is what Phase 1 Section 7 already tested once, separately).
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.
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 separateMLPRegressors (mlp_y_params,mlp_z_params), one per axis. - Candidate B —
joint: oneMLPRegressor(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.
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.
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: 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¶
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
| 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.
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 forrgs000, ~0.024 forrgs180) 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 thanoffset_y_mm's, and it isn't scattered randomly: everyrgs000_*value is positive (0.31–1.24 mm) and everyrgs180_*value is negative (−0.02 to −0.95 mm) — a clean split by grism identity, not detector drift. This lines up with3-Intro_ML.ipynb's own degeneracy discussion: flippingtilt_degby 180° negates the dispersion-direction unit vector inGrism.forward, anddetector.offset_zabsorbs whatever constant term is left over — differently, depending on which side of that 180° flip a given grism sits on. Sooffset_z_mm, as currently parameterized, is not purely "single detector, one registration constant" the wayoffset_y_mmis — it's carrying a mounting-orientation-dependent sign. Flagged for Phase 3, not resolved here: naively sharingoffset_z_mmacross 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 beforeoffset_zitself can be shared.tilt_deg: as expected, tracks the commanded GWA angle. Cross-check against Stage 3'srgs180 = rgs000 + 180°finding:rgs000_0→rgs180_0differ by 180.10°,rgs000_m4→rgs180_m4by 180.05°,rgs000_p4→rgs180_p4by 179.96° — all within ~0.1° of exactly 180°, confirming the frozen Stage 3 TOMLs were loaded correctly here.
6. RMSE-Across-Datasets Comparison¶
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)
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.
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.
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()
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 thetilt_degcross-check reconfirmedrgs180 ≈ rgs000 + 180°(within ~0.1°), so the reused fits loaded correctly. - MLflow
"per_dataset"experiment: 12 runs logged (oneindependent+ onejointper dataset), each with the fitted physical parameters, MLP hyperparameters used, and physical/hybrid test metrics. - Parameter consistency (Section 5):
offset_y_mmbehaves as a good shared-parameter candidate (~8% relative spread, no grouping);rhois instance-specific but identifiable, close betweenrgs000/rgs180as Stage 3 predicted.offset_z_mmwas not consistent — it splits cleanly by grism identity (positive forrgs000_*, negative forrgs180_*) rather than clustering around one shared value, traced to the same tilt+180° degeneracy3-Intro_ML.ipynbflagged. 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-rgs180split — 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 thehybrid-residual-modelingskill),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.htmlSection 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. Theoffset_z_mminconsistency finding above is directly relevant to how that phase should classify parameters as shared vs. dataset-specific.