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
    • 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
      • 1. Setup and data load
      • 2. Outlier check
      • 3. Identifiability check
      • 4. Production fit
      • 5. Recording the fit
      • 6. ML residual correction
      • 7. Comparison with the reference paper (BGS000-specific)
      • 8. Summary
    • 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
  • 5.4 BGS Model

Stage 5 Phase 4 — BGS Grism Model¶

Adapts Stage 3's single-dataset physical fit + identifiability check and Stage 4 Phase 1's ML residual correction to the blue grism (BGS000), never calibrated in this project before. Unlike RGS, there is exactly one BGS config (bgs000_0 — no ±4°/180° counterpart, neither in this project's data nor in the reference paper's own calibration), so there is no multi-dataset joint hierarchy here (Stage 4 Phase 3's tiers have nothing to share across with only one dataset) — the method adapted is Stage 3 + Stage 4 Phase 1 + Phase 5, not Phase 3.

BGS-specific nominal parameters (models/stage1_bgs_instrument.toml, from Euclid-NISP-Specs.md Section 4, Table 2 — Jahnke+2024): prism apex angle A_deg=1.77° (vs. RGS's 2.145°), groove density rho=15.1 grooves/mm (vs. 13.75), passband 926-1366 nm (vs. 1206-1892 nm, matching bgs000_0_first.csv's actual 901-1353 nm range). Grism material (Suprasil 3001) and the collimator/camera focal lengths are assumed shared with RGS (no BGS-specific values exist in either reference doc; Stage 3 already held the optics fixed across every RGS config on the same ground-test-bench assumption) — see that TOML's header comment for the full reasoning.

Out of scope here (flagged, not attempted): fusing this fit into Stage 5 Phase 3's zeroth-order material_k analysis — dispcraft/zeroth_dispersion.py hardcodes RGS's rank wavelengths (1206/1892 nm); reusing it for BGS's own passband edges (926/1366 nm) needs that generalized first, noted as a follow-up in Section 8.

1. Setup and data load¶

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 scipy.optimize import minimize
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 cost, 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

with open(MODELS_DIR / "stage1_bgs_instrument.toml", "rb") as f:
    base_config = tomllib.load(f)
model = ground_test_model_from_config(base_config)
NOMINAL_PARAMS = model.nominal_params

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

df_raw = median_per_spectrum(load_spectra(DATA_DIR / "bgs000_0_first.csv"))
print(f"bgs000_0: {len(df_raw)} rows, {df_raw['spectra_id'].nunique()} spectra, "
      f"wavelength {df_raw['wavelength'].min():.1f}-{df_raw['wavelength'].max():.1f} nm")
import tomllib from pathlib import Path import numpy as np import pandas as pd import matplotlib.pyplot as plt import mlflow from scipy.optimize import minimize 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 cost, 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 with open(MODELS_DIR / "stage1_bgs_instrument.toml", "rb") as f: base_config = tomllib.load(f) model = ground_test_model_from_config(base_config) NOMINAL_PARAMS = model.nominal_params mlflow.set_tracking_uri(f"sqlite:///{(REPO_ROOT / 'mlflow.db').resolve()}") df_raw = median_per_spectrum(load_spectra(DATA_DIR / "bgs000_0_first.csv")) print(f"bgs000_0: {len(df_raw)} rows, {df_raw['spectra_id'].nunique()} spectra, " f"wavelength {df_raw['wavelength'].min():.1f}-{df_raw['wavelength'].max():.1f} nm")
/home/zoubian/Workspace/dispers/dispcraft/.pixi/envs/default/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
bgs000_0: 5339 rows, 155 spectra, wavelength 901.2-1353.1 nm

2. Outlier check¶

Same diagnostic Stage 3 used on rgs000_0 (3-Intro_ML.ipynb, Section 2): fit the 2-parameter offset-only model, then rank spectra by mean |r_y| -- a single dominant outlier would stand out by a large margin against the rest. No exclusion assumed a priori; only applied if one actually appears.

In [2]:
Copied!
FREE_OFFSET_ONLY = ["offset_y_mm", "offset_z_mm"]
theta0_offset = np.array([NOMINAL_PARAMS[k] for k in FREE_OFFSET_ONLY])

result_raw = minimize(cost, theta0_offset, args=(df_raw, FREE_OFFSET_ONLY, model), method="Nelder-Mead",
                       options={"maxiter": 5000, "xatol": 1e-8, "fatol": 1e-12})
print(f"cost before fit: {cost(theta0_offset, df_raw, FREE_OFFSET_ONLY, model):.4f} mm^2")
print(f"result.x   = {result_raw.x} mm")
print(f"result.fun = {result_raw.fun:.4f} mm^2  (RMSE = {np.sqrt(result_raw.fun):.4f} mm)")

pred0 = predict_centroids(df_raw["y_nisp"], df_raw["z_nisp"], df_raw["wavelength"],
                           result_raw.x, FREE_OFFSET_ONLY, model)
df_raw = df_raw.assign(r_y=pred0[0] - df_raw["cent_y"].values, r_z=pred0[1] - df_raw["cent_z"].values)
per_spectrum = df_raw.groupby("spectra_id").agg(
    y_nisp=("y_nisp", "first"), z_nisp=("z_nisp", "first"),
    mean_abs_r_y=("r_y", lambda s: s.abs().mean()),
    mean_abs_r_z=("r_z", lambda s: s.abs().mean()),
).sort_values("mean_abs_r_y", ascending=False)
per_spectrum.head(8)
FREE_OFFSET_ONLY = ["offset_y_mm", "offset_z_mm"] theta0_offset = np.array([NOMINAL_PARAMS[k] for k in FREE_OFFSET_ONLY]) result_raw = minimize(cost, theta0_offset, args=(df_raw, FREE_OFFSET_ONLY, model), method="Nelder-Mead", options={"maxiter": 5000, "xatol": 1e-8, "fatol": 1e-12}) print(f"cost before fit: {cost(theta0_offset, df_raw, FREE_OFFSET_ONLY, model):.4f} mm^2") print(f"result.x = {result_raw.x} mm") print(f"result.fun = {result_raw.fun:.4f} mm^2 (RMSE = {np.sqrt(result_raw.fun):.4f} mm)") pred0 = predict_centroids(df_raw["y_nisp"], df_raw["z_nisp"], df_raw["wavelength"], result_raw.x, FREE_OFFSET_ONLY, model) df_raw = df_raw.assign(r_y=pred0[0] - df_raw["cent_y"].values, r_z=pred0[1] - df_raw["cent_z"].values) per_spectrum = df_raw.groupby("spectra_id").agg( y_nisp=("y_nisp", "first"), z_nisp=("z_nisp", "first"), mean_abs_r_y=("r_y", lambda s: s.abs().mean()), mean_abs_r_z=("r_z", lambda s: s.abs().mean()), ).sort_values("mean_abs_r_y", ascending=False) per_spectrum.head(8)
cost before fit: 0.4276 mm^2
result.x   = [-0.5274374  -0.07887111] mm
result.fun = 0.1432 mm^2  (RMSE = 0.3784 mm)
Out[2]:
y_nisp z_nisp mean_abs_r_y mean_abs_r_z
spectra_id
1702873995 142.682007 -49.523998 0.430719 0.104420
2028312684 142.682007 -27.563999 0.428155 0.112855
2600145980 142.682007 -71.484001 0.423765 0.101544
46361908 142.682007 14.712000 0.414888 0.291858
1876567498 142.682007 -106.529999 0.394937 0.116949
734772646 142.682007 36.672001 0.390635 0.320692
3079808466 142.682007 -121.169998 0.372267 0.219670
1247137738 142.682007 -135.809998 0.360318 0.205560
In [3]:
Copied!
# Decide exclusion from the ratio between the worst spectrum and the rest
# (Stage 3's rgs000_0 outlier stood out by more than an order of magnitude
# against the 2nd-worst spectrum -- the same threshold used here).
worst, second = per_spectrum["mean_abs_r_y"].iloc[0], per_spectrum["mean_abs_r_y"].iloc[1]
ratio = worst / second
print(f"worst/2nd-worst mean|r_y| ratio: {ratio:.2f}")

if ratio > 10:
    EXCLUDED_SPECTRA_IDS = [int(per_spectrum.index[0])]
    df0 = df_raw[~df_raw["spectra_id"].isin(EXCLUDED_SPECTRA_IDS)].drop(columns=["r_y", "r_z"]).reset_index(drop=True)
    print(f"excluded spectra_id={EXCLUDED_SPECTRA_IDS[0]}; "
          f"{len(df_raw) - len(df0)} rows dropped, {len(df0)} remain")
else:
    EXCLUDED_SPECTRA_IDS = []
    df0 = df_raw.drop(columns=["r_y", "r_z"]).reset_index(drop=True)
    print("no dominant outlier found (ratio <= 10) -- no exclusion applied")
# Decide exclusion from the ratio between the worst spectrum and the rest # (Stage 3's rgs000_0 outlier stood out by more than an order of magnitude # against the 2nd-worst spectrum -- the same threshold used here). worst, second = per_spectrum["mean_abs_r_y"].iloc[0], per_spectrum["mean_abs_r_y"].iloc[1] ratio = worst / second print(f"worst/2nd-worst mean|r_y| ratio: {ratio:.2f}") if ratio > 10: EXCLUDED_SPECTRA_IDS = [int(per_spectrum.index[0])] df0 = df_raw[~df_raw["spectra_id"].isin(EXCLUDED_SPECTRA_IDS)].drop(columns=["r_y", "r_z"]).reset_index(drop=True) print(f"excluded spectra_id={EXCLUDED_SPECTRA_IDS[0]}; " f"{len(df_raw) - len(df0)} rows dropped, {len(df0)} remain") else: EXCLUDED_SPECTRA_IDS = [] df0 = df_raw.drop(columns=["r_y", "r_z"]).reset_index(drop=True) print("no dominant outlier found (ratio <= 10) -- no exclusion applied")
worst/2nd-worst mean|r_y| ratio: 1.01
no dominant outlier found (ratio <= 10) -- no exclusion applied

3. Identifiability check¶

Mirrors Stage 3's own methodology exactly (3-Intro_ML.ipynb Section 2): try each candidate parameter free alongside the core offset_y_mm/offset_z_mm/tilt_deg/rho, bootstrap the fit, and read off the bootstrap std / correlation matrix. Tested here: A_deg (mirrors Stage 3's own check, expected non-identifiable for the same reason) and, new for BGS, material_k — BGS000's single dataset spans a ~440 nm-wide passband (vs. RGS's narrower per-tilt sampling), so its identifiability is not assumed to carry over from Stage 3's RGS-specific finding; it's tested directly, the same "explore, don't assume" reasoning Stage 5 Phase 3 used to revisit material_k for RGS via 0th-order data.

In [4]:
Copied!
def bootstrap_uncertainty(theta_star, df, free_names, model, n_boot=50, seed=RNG_SEED, fixed=None):
    rng = np.random.default_rng(seed)
    n = len(df)
    boots = []
    for _ in range(n_boot):
        sample = df.iloc[rng.integers(0, n, n)]
        r = minimize(cost, theta_star, args=(sample, free_names, model, fixed), method="Nelder-Mead",
                     options={"maxiter": 8000, "maxfev": 8000, "xatol": 1e-8, "fatol": 1e-12, "adaptive": True})
        boots.append(r.x)
    boots = np.array(boots)
    std = boots.std(axis=0)
    corr = pd.DataFrame(np.corrcoef(boots.T), index=free_names, columns=free_names)
    return std, corr


FREE_CORE = ["offset_y_mm", "offset_z_mm", "tilt_deg", "rho"]


def identifiability_check(extra_param):
    free_names = FREE_CORE + [extra_param]
    theta0 = np.array([NOMINAL_PARAMS[k] for k in free_names])
    r = minimize(cost, theta0, args=(df0, free_names, model), method="Nelder-Mead",
                 options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True})
    std, corr = bootstrap_uncertainty(r.x, df0, free_names, model)
    print(f"=== {extra_param} identifiability check ===")
    print(f"theta* = {dict(zip(free_names, r.x))}")
    print(f"RMSE = {np.sqrt(r.fun):.4f} mm")
    print("bootstrap std:", dict(zip(free_names, std.round(5))))
    other_corrs = corr.loc[extra_param].drop(extra_param)
    print(f"max |corr({extra_param}, other)| = {other_corrs.abs().max():.3f} (with {other_corrs.abs().idxmax()})")
    print()
    return r, std, corr


result_a, std_a, corr_a = identifiability_check("A_deg")
def bootstrap_uncertainty(theta_star, df, free_names, model, n_boot=50, seed=RNG_SEED, fixed=None): rng = np.random.default_rng(seed) n = len(df) boots = [] for _ in range(n_boot): sample = df.iloc[rng.integers(0, n, n)] r = minimize(cost, theta_star, args=(sample, free_names, model, fixed), method="Nelder-Mead", options={"maxiter": 8000, "maxfev": 8000, "xatol": 1e-8, "fatol": 1e-12, "adaptive": True}) boots.append(r.x) boots = np.array(boots) std = boots.std(axis=0) corr = pd.DataFrame(np.corrcoef(boots.T), index=free_names, columns=free_names) return std, corr FREE_CORE = ["offset_y_mm", "offset_z_mm", "tilt_deg", "rho"] def identifiability_check(extra_param): free_names = FREE_CORE + [extra_param] theta0 = np.array([NOMINAL_PARAMS[k] for k in free_names]) r = minimize(cost, theta0, args=(df0, free_names, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True}) std, corr = bootstrap_uncertainty(r.x, df0, free_names, model) print(f"=== {extra_param} identifiability check ===") print(f"theta* = {dict(zip(free_names, r.x))}") print(f"RMSE = {np.sqrt(r.fun):.4f} mm") print("bootstrap std:", dict(zip(free_names, std.round(5)))) other_corrs = corr.loc[extra_param].drop(extra_param) print(f"max |corr({extra_param}, other)| = {other_corrs.abs().max():.3f} (with {other_corrs.abs().idxmax()})") print() return r, std, corr result_a, std_a, corr_a = identifiability_check("A_deg")
=== A_deg identifiability check ===
theta* = {'offset_y_mm': np.float64(-0.5885896134407426), 'offset_z_mm': np.float64(-10.305105053128646), 'tilt_deg': np.float64(0.2670767299994993), 'rho': np.float64(14.254764826188747), 'A_deg': np.float64(0.3287458569156568)}
RMSE = 0.3535 mm
bootstrap std: {'offset_y_mm': np.float64(0.06976), 'offset_z_mm': np.float64(12.87609), 'tilt_deg': np.float64(0.08596), 'rho': np.float64(0.18117), 'A_deg': np.float64(1.63819)}
max |corr(A_deg, other)| = 1.000 (with offset_z_mm)

In [5]:
Copied!
result_k, std_k, corr_k = identifiability_check("material_k")
result_k, std_k, corr_k = identifiability_check("material_k")
=== material_k identifiability check ===
theta* = {'offset_y_mm': np.float64(-0.5369976017318494), 'offset_z_mm': np.float64(0.7628123227064458), 'tilt_deg': np.float64(0.267076570481411), 'rho': np.float64(14.254764571123005), 'material_k': np.float64(0.0007429311726906166)}
RMSE = 0.3535 mm
bootstrap std: {'offset_y_mm': np.float64(0.00385), 'offset_z_mm': np.float64(0.29746), 'tilt_deg': np.float64(0.08596), 'rho': np.float64(0.18117), 'material_k': np.float64(0.0037)}
max |corr(material_k, other)| = 0.994 (with offset_z_mm)

4. Production fit¶

Free parameters settled by Section 3's checks: offset_y_mm, offset_z_mm, tilt_deg, rho, plus material_k only if Section 3 found it identifiable (bootstrap std small relative to the fitted value, and not strongly correlated with another free parameter) -- A_deg fixed at the BGS000 nominal (1.77°) either way, matching Stage 3's own RGS treatment unless Section 3 says otherwise.

In [6]:
Copied!
# Decide FREE_PROD from Section 3's numbers: material_k identifiable if its
# bootstrap std is small relative to the fitted value (well-constrained)
# and it isn't strongly correlated with rho/tilt (no degeneracy).
k_star = dict(zip(FREE_CORE + ["material_k"], result_k.x))["material_k"]
k_std = dict(zip(FREE_CORE + ["material_k"], std_k))["material_k"]
k_rel_std = abs(k_std / k_star) if k_star != 0 else np.inf
k_max_corr = corr_k.loc["material_k"].drop("material_k").abs().max()
material_k_identifiable = (k_rel_std < 0.3) and (k_max_corr < 0.9)

print(f"material_k: fitted={k_star:.5f}, bootstrap_std={k_std:.5f} (rel={k_rel_std:.2f}), "
      f"max|corr|={k_max_corr:.3f} -> identifiable={material_k_identifiable}")

FREE_PROD = FREE_CORE + (["material_k"] if material_k_identifiable else [])
print(f"production free parameters: {FREE_PROD}")
# Decide FREE_PROD from Section 3's numbers: material_k identifiable if its # bootstrap std is small relative to the fitted value (well-constrained) # and it isn't strongly correlated with rho/tilt (no degeneracy). k_star = dict(zip(FREE_CORE + ["material_k"], result_k.x))["material_k"] k_std = dict(zip(FREE_CORE + ["material_k"], std_k))["material_k"] k_rel_std = abs(k_std / k_star) if k_star != 0 else np.inf k_max_corr = corr_k.loc["material_k"].drop("material_k").abs().max() material_k_identifiable = (k_rel_std < 0.3) and (k_max_corr < 0.9) print(f"material_k: fitted={k_star:.5f}, bootstrap_std={k_std:.5f} (rel={k_rel_std:.2f}), " f"max|corr|={k_max_corr:.3f} -> identifiable={material_k_identifiable}") FREE_PROD = FREE_CORE + (["material_k"] if material_k_identifiable else []) print(f"production free parameters: {FREE_PROD}")
material_k: fitted=0.00074, bootstrap_std=0.00370 (rel=4.98), max|corr|=0.994 -> identifiable=False
production free parameters: ['offset_y_mm', 'offset_z_mm', 'tilt_deg', 'rho']
In [7]:
Copied!
theta0_prod = np.array([NOMINAL_PARAMS[k] for k in FREE_PROD])
cost_before = cost(theta0_prod, df0, FREE_PROD, model)

result_prod = minimize(cost, theta0_prod, args=(df0, FREE_PROD, model), method="Nelder-Mead",
                        options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True})
theta_star_prod = result_prod.x
std_prod, corr_prod = bootstrap_uncertainty(theta_star_prod, df0, FREE_PROD, model)

print(f"cost before fit: {cost_before:.4f} mm^2")
print(f"theta*     = {dict(zip(FREE_PROD, theta_star_prod))}")
print(f"RMSE       = {np.sqrt(result_prod.fun):.4f} mm  (before: {np.sqrt(cost_before):.4f} mm)")
print("bootstrap std:", dict(zip(FREE_PROD, std_prod.round(5))))
print(f"\nsanity check vs. spec nominal: tilt_deg near 0 deg? "
      f"{dict(zip(FREE_PROD, theta_star_prod)).get('tilt_deg'):.3f} deg; "
      f"rho near 15.1 grooves/mm? {dict(zip(FREE_PROD, theta_star_prod)).get('rho'):.3f}")
theta0_prod = np.array([NOMINAL_PARAMS[k] for k in FREE_PROD]) cost_before = cost(theta0_prod, df0, FREE_PROD, model) result_prod = minimize(cost, theta0_prod, args=(df0, FREE_PROD, model), method="Nelder-Mead", options={"maxiter": 20000, "maxfev": 20000, "xatol": 1e-9, "fatol": 1e-14, "adaptive": True}) theta_star_prod = result_prod.x std_prod, corr_prod = bootstrap_uncertainty(theta_star_prod, df0, FREE_PROD, model) print(f"cost before fit: {cost_before:.4f} mm^2") print(f"theta* = {dict(zip(FREE_PROD, theta_star_prod))}") print(f"RMSE = {np.sqrt(result_prod.fun):.4f} mm (before: {np.sqrt(cost_before):.4f} mm)") print("bootstrap std:", dict(zip(FREE_PROD, std_prod.round(5)))) print(f"\nsanity check vs. spec nominal: tilt_deg near 0 deg? " f"{dict(zip(FREE_PROD, theta_star_prod)).get('tilt_deg'):.3f} deg; " f"rho near 15.1 grooves/mm? {dict(zip(FREE_PROD, theta_star_prod)).get('rho'):.3f}")
cost before fit: 0.4276 mm^2
theta*     = {'offset_y_mm': np.float64(-0.5358120414120086), 'offset_z_mm': np.float64(1.0168630330393515), 'tilt_deg': np.float64(0.26703413848047564), 'rho': np.float64(14.102827580492296)}
RMSE       = 0.3535 mm  (before: 0.6539 mm)
bootstrap std: {'offset_y_mm': np.float64(0.0033), 'offset_z_mm': np.float64(0.03223), 'tilt_deg': np.float64(0.08596), 'rho': np.float64(0.02945)}

sanity check vs. spec nominal: tilt_deg near 0 deg? 0.267 deg; rho near 15.1 grooves/mm? 14.103
In [8]:
Copied!
pred_before = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta0_prod, FREE_PROD, model)
pred_after = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta_star_prod, FREE_PROD, model)

fig, axes = plt.subplots(1, 2, figsize=(11, 5), sharex=True, sharey=True)
for ax, pred, label in zip(axes, [pred_before, pred_after], ["before fit (theta0)", "after fit (theta*)"]):
    ax.scatter(df0["cent_y"], df0["cent_z"], s=4, label="observed", alpha=0.6)
    ax.scatter(pred[0], pred[1], s=4, label="predicted", alpha=0.6)
    ax.set_xlabel("cent_y [mm]")
    ax.set_title(label)
    ax.legend(markerscale=3)
axes[0].set_ylabel("cent_z [mm]")
fig.suptitle("bgs000_0: observed vs. predicted centroids")
plt.tight_layout()
plt.show()
pred_before = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta0_prod, FREE_PROD, model) pred_after = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta_star_prod, FREE_PROD, model) fig, axes = plt.subplots(1, 2, figsize=(11, 5), sharex=True, sharey=True) for ax, pred, label in zip(axes, [pred_before, pred_after], ["before fit (theta0)", "after fit (theta*)"]): ax.scatter(df0["cent_y"], df0["cent_z"], s=4, label="observed", alpha=0.6) ax.scatter(pred[0], pred[1], s=4, label="predicted", alpha=0.6) ax.set_xlabel("cent_y [mm]") ax.set_title(label) ax.legend(markerscale=3) axes[0].set_ylabel("cent_z [mm]") fig.suptitle("bgs000_0: observed vs. predicted centroids") plt.tight_layout() plt.show()
No description has been provided for this image

5. Recording the fit¶

In [9]:
Copied!
def write_bgs_fit_toml(df, free_names, theta, theta_std, cost_before, cost_after, excluded_ids):
    result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(free_names, theta))
    std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(free_names, theta_std))
    p = dict(NOMINAL_PARAMS)
    p.update(dict(zip(free_names, theta)))
    fixed_lines = []
    if "A_deg" not in free_names:
        fixed_lines.append(f"A_deg = {p['A_deg']}")
    if "material_n0" not in free_names:
        fixed_lines.append(f"material_n0 = {p['material_n0']}")
    if "material_k" not in free_names:
        fixed_lines.append(f"material_k = {p['material_k']}")
    fixed_block = "\n".join(fixed_lines)

    text = f'''# Stage 5 Phase 4 -- physical-model fit to bgs000_0.
# Generated by notebooks/5.4-BGS_Model.ipynb. Do not hand-edit the fitted
# values; re-run the notebook and regenerate this file instead.

[base_config]
path = "stage1_bgs_instrument.toml"  # all other parameters unchanged from this

[fit]
dataset = "bgs000_0_first.csv"
loader = "median_per_spectrum(load_spectra(...))"  # dispcraft.measurement, default sig_max
excluded_spectra_ids = {excluded_ids}
n_points = {len(df)}
method = "Nelder-Mead"
free_parameters = {free_names}
cost_before_mm2 = {cost_before:.6f}
cost_after_mm2 = {cost_after:.6f}
rmse_mm = {np.sqrt(cost_after):.6f}

[fit.result]
{result_lines}
{std_lines}

[fit.fixed]
# Held at the BGS000 nominal (stage1_bgs_instrument.toml) -- see notebook
# Section 3's identifiability check for why each fixed parameter isn't free.
{fixed_block}
'''
    path = MODELS_DIR / "bgs000_0_fit.toml"
    path.write_text(text)
    print(f"wrote {path}")
    return path


bgs_fit_path = write_bgs_fit_toml(df0, FREE_PROD, theta_star_prod, std_prod, cost_before, result_prod.fun,
                                   EXCLUDED_SPECTRA_IDS)
print(bgs_fit_path.read_text())
def write_bgs_fit_toml(df, free_names, theta, theta_std, cost_before, cost_after, excluded_ids): result_lines = "\n".join(f"{name} = {v:.6f}" for name, v in zip(free_names, theta)) std_lines = "\n".join(f"{name}_bootstrap_std = {s:.6f}" for name, s in zip(free_names, theta_std)) p = dict(NOMINAL_PARAMS) p.update(dict(zip(free_names, theta))) fixed_lines = [] if "A_deg" not in free_names: fixed_lines.append(f"A_deg = {p['A_deg']}") if "material_n0" not in free_names: fixed_lines.append(f"material_n0 = {p['material_n0']}") if "material_k" not in free_names: fixed_lines.append(f"material_k = {p['material_k']}") fixed_block = "\n".join(fixed_lines) text = f'''# Stage 5 Phase 4 -- physical-model fit to bgs000_0. # Generated by notebooks/5.4-BGS_Model.ipynb. Do not hand-edit the fitted # values; re-run the notebook and regenerate this file instead. [base_config] path = "stage1_bgs_instrument.toml" # all other parameters unchanged from this [fit] dataset = "bgs000_0_first.csv" loader = "median_per_spectrum(load_spectra(...))" # dispcraft.measurement, default sig_max excluded_spectra_ids = {excluded_ids} n_points = {len(df)} method = "Nelder-Mead" free_parameters = {free_names} cost_before_mm2 = {cost_before:.6f} cost_after_mm2 = {cost_after:.6f} rmse_mm = {np.sqrt(cost_after):.6f} [fit.result] {result_lines} {std_lines} [fit.fixed] # Held at the BGS000 nominal (stage1_bgs_instrument.toml) -- see notebook # Section 3's identifiability check for why each fixed parameter isn't free. {fixed_block} ''' path = MODELS_DIR / "bgs000_0_fit.toml" path.write_text(text) print(f"wrote {path}") return path bgs_fit_path = write_bgs_fit_toml(df0, FREE_PROD, theta_star_prod, std_prod, cost_before, result_prod.fun, EXCLUDED_SPECTRA_IDS) print(bgs_fit_path.read_text())
wrote ../models/bgs000_0_fit.toml
# Stage 5 Phase 4 -- physical-model fit to bgs000_0.
# Generated by notebooks/5.4-BGS_Model.ipynb. Do not hand-edit the fitted
# values; re-run the notebook and regenerate this file instead.

[base_config]
path = "stage1_bgs_instrument.toml"  # all other parameters unchanged from this

[fit]
dataset = "bgs000_0_first.csv"
loader = "median_per_spectrum(load_spectra(...))"  # dispcraft.measurement, default sig_max
excluded_spectra_ids = []
n_points = 5339
method = "Nelder-Mead"
free_parameters = ['offset_y_mm', 'offset_z_mm', 'tilt_deg', 'rho']
cost_before_mm2 = 0.427584
cost_after_mm2 = 0.124968
rmse_mm = 0.353508

[fit.result]
offset_y_mm = -0.535812
offset_z_mm = 1.016863
tilt_deg = 0.267034
rho = 14.102828
offset_y_mm_bootstrap_std = 0.003303
offset_z_mm_bootstrap_std = 0.032228
tilt_deg_bootstrap_std = 0.085960
rho_bootstrap_std = 0.029446

[fit.fixed]
# Held at the BGS000 nominal (stage1_bgs_instrument.toml) -- see notebook
# Section 3's identifiability check for why each fixed parameter isn't free.
A_deg = 1.77
material_n0 = 1.44
material_k = 0.004

6. ML residual correction¶

Reuses Stage 4's already-validated winning MLP(y)/MLP(z) hyperparameters (residual_correction MLflow experiment) rather than a fresh grid search -- BGS is a single dataset like rgs000_0 was for Phase 1, and re-deriving best-architecture-per-family from scratch would widen scope well beyond "adapt the method." Held out the same way as every other hybrid evaluation in this project (GroupShuffleSplit, test_size=0.2, random_state=42).

In [10]:
Copied!
import ast

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")
print("MLP(y):", mlp_y_params)
print("MLP(z):", mlp_z_params)
import ast 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") print("MLP(y):", mlp_y_params) print("MLP(z):", mlp_z_params)
MLP(y): {'hidden_layer_sizes': (64, 32), 'activation': 'relu', 'alpha': 0.0001, 'random_state': 42, 'max_iter': 2000, 'early_stopping': True}
MLP(z): {'hidden_layer_sizes': (64, 64), 'activation': 'relu', 'alpha': 0.001, 'random_state': 42, 'max_iter': 2000, 'early_stopping': True}
In [11]:
Copied!
pred_final = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"],
                                theta_star_prod, FREE_PROD, model)
d = df0.assign(r_y=pred_final[0] - df0["cent_y"].values, r_z=pred_final[1] - df0["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 = float(np.sqrt(mean_squared_error(r_y_test, np.zeros_like(r_y_test))))
physical_rmse_z = float(np.sqrt(mean_squared_error(r_z_test, np.zeros_like(r_z_test))))
physical_dist_test = float(np.mean(np.hypot(r_y_test, r_z_test)))

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_test = r_y_test - mlp_y.predict(X_test_s)
hyb_z_test = r_z_test - mlp_z.predict(X_test_s)

hybrid_rmse_y = float(np.sqrt(mean_squared_error(r_y_test, mlp_y.predict(X_test_s))))
hybrid_rmse_z = float(np.sqrt(mean_squared_error(r_z_test, mlp_z.predict(X_test_s))))
hybrid_dist_test = float(np.mean(np.hypot(hyb_y_test, hyb_z_test)))

bgs_results = pd.DataFrame({
    "physical_rmse_y_mm": [physical_rmse_y], "physical_rmse_z_mm": [physical_rmse_z],
    "hybrid_rmse_y_mm": [hybrid_rmse_y], "hybrid_rmse_z_mm": [hybrid_rmse_z],
    "physical_dist_mm": [physical_dist_test], "hybrid_dist_mm": [hybrid_dist_test],
}, index=["bgs000_0"])
bgs_results["improvement_pct"] = 100 * (1 - bgs_results["hybrid_dist_mm"] / bgs_results["physical_dist_mm"])
bgs_results.round(5)
pred_final = predict_centroids(df0["y_nisp"], df0["z_nisp"], df0["wavelength"], theta_star_prod, FREE_PROD, model) d = df0.assign(r_y=pred_final[0] - df0["cent_y"].values, r_z=pred_final[1] - df0["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 = float(np.sqrt(mean_squared_error(r_y_test, np.zeros_like(r_y_test)))) physical_rmse_z = float(np.sqrt(mean_squared_error(r_z_test, np.zeros_like(r_z_test)))) physical_dist_test = float(np.mean(np.hypot(r_y_test, r_z_test))) 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_test = r_y_test - mlp_y.predict(X_test_s) hyb_z_test = r_z_test - mlp_z.predict(X_test_s) hybrid_rmse_y = float(np.sqrt(mean_squared_error(r_y_test, mlp_y.predict(X_test_s)))) hybrid_rmse_z = float(np.sqrt(mean_squared_error(r_z_test, mlp_z.predict(X_test_s)))) hybrid_dist_test = float(np.mean(np.hypot(hyb_y_test, hyb_z_test))) bgs_results = pd.DataFrame({ "physical_rmse_y_mm": [physical_rmse_y], "physical_rmse_z_mm": [physical_rmse_z], "hybrid_rmse_y_mm": [hybrid_rmse_y], "hybrid_rmse_z_mm": [hybrid_rmse_z], "physical_dist_mm": [physical_dist_test], "hybrid_dist_mm": [hybrid_dist_test], }, index=["bgs000_0"]) bgs_results["improvement_pct"] = 100 * (1 - bgs_results["hybrid_dist_mm"] / bgs_results["physical_dist_mm"]) bgs_results.round(5)
Out[11]:
physical_rmse_y_mm physical_rmse_z_mm hybrid_rmse_y_mm hybrid_rmse_z_mm physical_dist_mm hybrid_dist_mm improvement_pct
bgs000_0 0.15395 0.35748 0.01331 0.0726 0.33096 0.059 82.17369
In [12]:
Copied!
EXPERIMENT_NAME = "bgs_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)

with mlflow.start_run(run_name="bgs000_0_hybrid"):
    mlflow.log_param("dataset", "bgs000_0")
    mlflow.log_param("free_parameters", FREE_PROD)
    for name, v in zip(FREE_PROD, theta_star_prod):
        mlflow.log_param(name, v)
    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)
    mlflow.log_metric("physical_dist_test", physical_dist_test)
    mlflow.log_metric("hybrid_dist_test", hybrid_dist_test)
    mlflow.log_metric("hybrid_rmse_y", hybrid_rmse_y)
    mlflow.log_metric("hybrid_rmse_z", hybrid_rmse_z)
    mlflow.log_metric("improvement_pct", float(bgs_results["improvement_pct"].iloc[0]))

print(f"logged 1 run to the '{EXPERIMENT_NAME}' MLflow experiment")
EXPERIMENT_NAME = "bgs_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) with mlflow.start_run(run_name="bgs000_0_hybrid"): mlflow.log_param("dataset", "bgs000_0") mlflow.log_param("free_parameters", FREE_PROD) for name, v in zip(FREE_PROD, theta_star_prod): mlflow.log_param(name, v) 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) mlflow.log_metric("physical_dist_test", physical_dist_test) mlflow.log_metric("hybrid_dist_test", hybrid_dist_test) mlflow.log_metric("hybrid_rmse_y", hybrid_rmse_y) mlflow.log_metric("hybrid_rmse_z", hybrid_rmse_z) mlflow.log_metric("improvement_pct", float(bgs_results["improvement_pct"].iloc[0])) print(f"logged 1 run to the '{EXPERIMENT_NAME}' MLflow experiment")
logged 1 run to the 'bgs_residual_correction' MLflow experiment

7. Comparison with the reference paper (BGS000-specific)¶

Mirrors 4.5's methodology: same 18 µm/px conversion (not the deck's wrong 0.3 mm/px), this time against BGS000's own reference numbers. In-sample residual is reported as <7e-4 mm (y) / <1e-3 mm (z) "for every grism configuration" including BGS000 (Fig. 12); the held-out Argon validation (≈0.5 px) is pooled across grisms in the source ("16 spectrograms per grism"), not broken out per grism -- usable only as a combined-figure comparison point, same caveat Phase 5 documented for RGS.

In [13]:
Copied!
PX_MM_CORRECT = 0.018  # 18 um pixel pitch (Jahnke+2024, via Euclid-NISP-Specs.md)

paper_in_sample_y_mm = 7e-4
paper_in_sample_z_mm = 1e-3
paper_held_out_px = 0.5
paper_held_out_mm = paper_held_out_px * PX_MM_CORRECT

bgs_summary_rows = [
    {"model": "Physical model (this notebook)", "rmse_y_mm": physical_rmse_y, "rmse_z_mm": physical_rmse_z,
     "note": "held-out test split"},
    {"model": "Hybrid, physical + ML (this notebook)", "rmse_y_mm": hybrid_rmse_y, "rmse_z_mm": hybrid_rmse_z,
     "note": "held-out test split"},
    {"model": "Reference (arXiv 2506.08378), in-sample", "rmse_y_mm": paper_in_sample_y_mm, "rmse_z_mm": paper_in_sample_z_mm,
     "note": "FoV-pooled, in-sample (Fabry-Perot), stated for every grism config incl. BGS000"},
    {"model": "Reference (arXiv 2506.08378), held-out (Argon)", "rmse_y_mm": paper_held_out_mm, "rmse_z_mm": paper_held_out_mm,
     "note": f"combined figure, pooled across all grisms, not BGS-specific ({paper_held_out_px} px = {paper_held_out_mm:.4f} mm)"},
]
bgs_comparison_table = pd.DataFrame(bgs_summary_rows).set_index("model")
bgs_comparison_table["rmse_y_px"] = bgs_comparison_table["rmse_y_mm"] / PX_MM_CORRECT
bgs_comparison_table["rmse_z_px"] = bgs_comparison_table["rmse_z_mm"] / PX_MM_CORRECT
bgs_comparison_table[["rmse_y_mm", "rmse_z_mm", "rmse_y_px", "rmse_z_px", "note"]].round(5)
PX_MM_CORRECT = 0.018 # 18 um pixel pitch (Jahnke+2024, via Euclid-NISP-Specs.md) paper_in_sample_y_mm = 7e-4 paper_in_sample_z_mm = 1e-3 paper_held_out_px = 0.5 paper_held_out_mm = paper_held_out_px * PX_MM_CORRECT bgs_summary_rows = [ {"model": "Physical model (this notebook)", "rmse_y_mm": physical_rmse_y, "rmse_z_mm": physical_rmse_z, "note": "held-out test split"}, {"model": "Hybrid, physical + ML (this notebook)", "rmse_y_mm": hybrid_rmse_y, "rmse_z_mm": hybrid_rmse_z, "note": "held-out test split"}, {"model": "Reference (arXiv 2506.08378), in-sample", "rmse_y_mm": paper_in_sample_y_mm, "rmse_z_mm": paper_in_sample_z_mm, "note": "FoV-pooled, in-sample (Fabry-Perot), stated for every grism config incl. BGS000"}, {"model": "Reference (arXiv 2506.08378), held-out (Argon)", "rmse_y_mm": paper_held_out_mm, "rmse_z_mm": paper_held_out_mm, "note": f"combined figure, pooled across all grisms, not BGS-specific ({paper_held_out_px} px = {paper_held_out_mm:.4f} mm)"}, ] bgs_comparison_table = pd.DataFrame(bgs_summary_rows).set_index("model") bgs_comparison_table["rmse_y_px"] = bgs_comparison_table["rmse_y_mm"] / PX_MM_CORRECT bgs_comparison_table["rmse_z_px"] = bgs_comparison_table["rmse_z_mm"] / PX_MM_CORRECT bgs_comparison_table[["rmse_y_mm", "rmse_z_mm", "rmse_y_px", "rmse_z_px", "note"]].round(5)
Out[13]:
rmse_y_mm rmse_z_mm rmse_y_px rmse_z_px note
model
Physical model (this notebook) 0.15395 0.35748 8.55260 19.85978 held-out test split
Hybrid, physical + ML (this notebook) 0.01331 0.07260 0.73969 4.03318 held-out test split
Reference (arXiv 2506.08378), in-sample 0.00070 0.00100 0.03889 0.05556 FoV-pooled, in-sample (Fabry-Perot), stated fo...
Reference (arXiv 2506.08378), held-out (Argon) 0.00900 0.00900 0.50000 0.50000 combined figure, pooled across all grisms, not...
In [14]:
Copied!
fig, ax = plt.subplots(figsize=(8, 4.5))
labels = ["Physical\n(this notebook)", "Hybrid\n(this notebook)", "Reference\n(in-sample)"]
y_vals = [physical_rmse_y, hybrid_rmse_y, paper_in_sample_y_mm]
z_vals = [physical_rmse_z, hybrid_rmse_z, paper_in_sample_z_mm]
x = np.arange(len(labels))
width = 0.35
ax.bar(x - width / 2, y_vals, width, label="RMSE y (cross-dispersion)", color="tab:blue")
ax.bar(x + width / 2, z_vals, width, label="RMSE z (dispersion)", color="tab:orange")
ax.axhline(paper_held_out_mm, color="0.3", linestyle="--", linewidth=1.5,
           label=f"Reference held-out, Argon (combined, all grisms): {paper_held_out_mm:.4f} mm")
ax.set_yscale("log")
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.set_ylabel("RMSE [mm], held-out test split (log scale)")
ax.legend(loc="upper right", fontsize=8)
ax.set_title("BGS000: this notebook's models vs. the reference paper (arXiv 2506.08378)")
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(8, 4.5)) labels = ["Physical\n(this notebook)", "Hybrid\n(this notebook)", "Reference\n(in-sample)"] y_vals = [physical_rmse_y, hybrid_rmse_y, paper_in_sample_y_mm] z_vals = [physical_rmse_z, hybrid_rmse_z, paper_in_sample_z_mm] x = np.arange(len(labels)) width = 0.35 ax.bar(x - width / 2, y_vals, width, label="RMSE y (cross-dispersion)", color="tab:blue") ax.bar(x + width / 2, z_vals, width, label="RMSE z (dispersion)", color="tab:orange") ax.axhline(paper_held_out_mm, color="0.3", linestyle="--", linewidth=1.5, label=f"Reference held-out, Argon (combined, all grisms): {paper_held_out_mm:.4f} mm") ax.set_yscale("log") ax.set_xticks(x) ax.set_xticklabels(labels) ax.set_ylabel("RMSE [mm], held-out test split (log scale)") ax.legend(loc="upper right", fontsize=8) ax.set_title("BGS000: this notebook's models vs. the reference paper (arXiv 2506.08378)") plt.tight_layout() plt.show()
No description has been provided for this image

8. Summary¶

In [15]:
Copied!
print("=== BGS000 physical fit ===")
print(f"free parameters: {FREE_PROD}")
print(f"fitted values: {dict(zip(FREE_PROD, theta_star_prod.round(5)))}")
print(f"RMSE (full dataset): {np.sqrt(result_prod.fun):.4f} mm  (before fit: {np.sqrt(cost_before):.4f} mm)")
print(f"excluded_spectra_ids: {EXCLUDED_SPECTRA_IDS}")
print()
print("=== held-out test split ===")
print(f"physical: y={physical_rmse_y:.4f} mm  z={physical_rmse_z:.4f} mm")
print(f"hybrid:   y={hybrid_rmse_y:.4f} mm  z={hybrid_rmse_z:.4f} mm  "
      f"({float(bgs_results['improvement_pct'].iloc[0]):.1f}% combined-distance improvement)")
print(f"reference paper, held-out (Argon, combined, all grisms): {paper_held_out_mm:.4f} mm")
print(f"gap: y {hybrid_rmse_y/paper_held_out_mm:.2f}x, z {hybrid_rmse_z/paper_held_out_mm:.2f}x worse")
print()
print(f"material_k identifiable from BGS000's own passband: {material_k_identifiable}")
print()
print("Follow-up not attempted here: fuse this fit into Stage 5 Phase 3's zeroth-order")
print("material_k analysis -- needs dispcraft/zeroth_dispersion.py's hardcoded RGS rank")
print("wavelengths (1206/1892 nm) generalized to BGS's own passband edges (926/1366 nm) first.")
print("=== BGS000 physical fit ===") print(f"free parameters: {FREE_PROD}") print(f"fitted values: {dict(zip(FREE_PROD, theta_star_prod.round(5)))}") print(f"RMSE (full dataset): {np.sqrt(result_prod.fun):.4f} mm (before fit: {np.sqrt(cost_before):.4f} mm)") print(f"excluded_spectra_ids: {EXCLUDED_SPECTRA_IDS}") print() print("=== held-out test split ===") print(f"physical: y={physical_rmse_y:.4f} mm z={physical_rmse_z:.4f} mm") print(f"hybrid: y={hybrid_rmse_y:.4f} mm z={hybrid_rmse_z:.4f} mm " f"({float(bgs_results['improvement_pct'].iloc[0]):.1f}% combined-distance improvement)") print(f"reference paper, held-out (Argon, combined, all grisms): {paper_held_out_mm:.4f} mm") print(f"gap: y {hybrid_rmse_y/paper_held_out_mm:.2f}x, z {hybrid_rmse_z/paper_held_out_mm:.2f}x worse") print() print(f"material_k identifiable from BGS000's own passband: {material_k_identifiable}") print() print("Follow-up not attempted here: fuse this fit into Stage 5 Phase 3's zeroth-order") print("material_k analysis -- needs dispcraft/zeroth_dispersion.py's hardcoded RGS rank") print("wavelengths (1206/1892 nm) generalized to BGS's own passband edges (926/1366 nm) first.")
=== BGS000 physical fit ===
free parameters: ['offset_y_mm', 'offset_z_mm', 'tilt_deg', 'rho']
fitted values: {'offset_y_mm': np.float64(-0.53581), 'offset_z_mm': np.float64(1.01686), 'tilt_deg': np.float64(0.26703), 'rho': np.float64(14.10283)}
RMSE (full dataset): 0.3535 mm  (before fit: 0.6539 mm)
excluded_spectra_ids: []

=== held-out test split ===
physical: y=0.1539 mm  z=0.3575 mm
hybrid:   y=0.0133 mm  z=0.0726 mm  (82.2% combined-distance improvement)
reference paper, held-out (Argon, combined, all grisms): 0.0090 mm
gap: y 1.48x, z 8.07x worse

material_k identifiable from BGS000's own passband: False

Follow-up not attempted here: fuse this fit into Stage 5 Phase 3's zeroth-order
material_k analysis -- needs dispcraft/zeroth_dispersion.py's hardcoded RGS rank
wavelengths (1206/1892 nm) generalized to BGS's own passband edges (926/1366 nm) first.
Previous Next

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