Save, Load, and Export Fit Results#

This notebook is the canonical reference for the FitResults archive API — the HDF5 round-trip (save_fit / FitResults.load) and its one-way CSV/PNG sibling (export_fit). See 10_model_comparison for the details of the fits; this notebook focuses on what to do with the results after a comparison.

The two channels

  • save_fit → HDF5. Structured, lossless, σ-snapshot included. Round-trips back into trspecfit via FitResults.load. For future you and other trspecfit users.

  • export_fit → one CSV + PNG directory per fit. One-way (no load counterpart). For Origin, MATLAB, paper plots, non-trspecfit colleagues.

Roadmap

  1. Re-run notebook 10, whose fitted state is the material for everything below.

  2. Save the full fit history, load it back, and compare straight from the archive.

  3. Ship just the winning fits — filters and select= on the archive channel, one reference per export_fit.

  4. Browse the loaded archive — the query API and the anatomy of a slot.

0. Re-run Example Notebook 10#

The next cell %runs notebook 10 in the kernel so its variables (file, project) are in scope below; the fit history of project holds the seven runs of the six models (base_GLP, base_Gauss, sbs_x0_only, sbs_x0_A, m2d_w_IRF, m2d_wo_IRF). The mechanics:

  • %cd into notebook 10’s directory so its project.yaml and model YAMLs resolve.

  • %%capture wraps the whole cell so the noisy run (notebook 10 keeps show_output: 1 for its own interactive use) doesn’t dump into this notebook’s output.

  • %cd - back so any HDF5/CSV/PNG artifacts this notebook writes land in 11_save_load_export/.

Expected runtime: under a minute. The cell after the preamble prints a short confirmation so you know the fits landed.

%%capture
# The preamble mechanics are in the cell above: %cd over, %run, %cd back.
# The path is quoted: %%capture tokenizes the cell body as Python, and a
# bare ../10_model_comparison reads as a malformed number ("10_model...").
%cd -q "../10_model_comparison"
%run example.ipynb
%cd -q -
# Sanity check: notebook 10 should have left 7 fits of 6 models on one file.
n_models = len(project.results.models(file=file.name))
n_fits = len(project.results.find(file=file.name))
assert (n_models, n_fits) == (6, 7), "notebook 10 did not run to completion"
print(f"OK: {n_fits} fits of {n_models} models on file {file.name!r}")
OK: 7 fits of 6 models on file 'synthetic'
# This notebook's own imports — everything else (file, project, X0_BASE, …)
# is already in the kernel from the %run preamble.
import dataclasses
from pathlib import Path

import numpy as np

import trspecfit

1. Save → Load → Compare Across Sessions#

file.save_fit(path) is sugar for project.save_fits(path, file=file). By default it archives every distinct fit in the fit history (select="all"). A changed bound on a parameter that is not fixed, a vary flag, or a window is its own variant that lands side by side, so the two base_GLP baseline variants from notebook 10 §4 ride along as a group; exact re-runs deduplicate to one slot. FitResults.load(path) reads the archive without needing a Project, so this is the persistence path that survives kernel restarts and travels well between machines. With a Project in hand, project.load_fits(path) is the same call as a method; either way the loaded FitResults is a separate view that never merges into the live fit history.

Each saved slot carries its own σ snapshot (noise_type, sigma_source, sigma_type, sigma_data, sigma_eff). A loaded archive therefore shows the same calibrated columns the live session did; no need to re-set_sigma on the recipient side.

What if you disagree with the saved σ? The raw column chi2_red_raw rides along (request it via metrics= if your view dropped it), so a what-if recalibration is two lines of pandas — no rehydrated Project required:

df = loaded.compare_models(fit_type="2d")  # single-file archive: file= optional
df["chi2_red_alt"] = df["chi2_red_raw"] / alt_sigma**2
archive_path = "comparison.fit.h5"
# overwrite=True is slot-scoped: it lets a re-run replace slots an earlier run of
# this notebook stored with divergent fitted values (append + collision rule: Tips).
file.save_fit(archive_path, overwrite=True)

loaded = trspecfit.FitResults.load(archive_path)
print(loaded)
Saved 7 slot(s) across 1 file(s) to comparison.fit.h5
FitResults(7 slots, 1 file)
# lossless: the archived slot equals the live one, field for field
live = project.results.get(handle="m-free")
arch = loaded.get(handle="m-free")
print("params equal:    ", arch.params.equals(live.params))
print("fit array equal: ", np.array_equal(arch.fit, live.fit))
print("metrics equal:   ", arch.metrics == live.metrics)
params equal:     True
fit array equal:  True
metrics equal:    True
# Same comparison API, but driven from the on-disk archive — no Project
# required, and file= is optional on a single-file archive.
loaded.compare_models(fit_type="baseline")
file model fit_type handle selection_json chi2_red_raw sigma_eff chi2_red r2 aic bic
0 synthetic base_Gauss baseline 66abe7af {"base_t_ind": [0, 6], "e_lim": [40, 320]} 0.051192 0.093723 5.827871 0.988938 1612.664494 1630.838442
1 synthetic base_GLP baseline 0e4fa26e {"base_t_ind": [0, 6], "e_lim": [40, 320]} 0.008824 0.093723 1.004512 0.998100 287.236404 309.045141
2 synthetic base_GLP baseline 82c32c49 {"base_t_ind": [0, 6], "e_lim": [40, 320]} 0.039654 0.093723 4.514316 0.991431 1251.436927 1269.610875
# The long form survives the round-trip too — per-slice rows straight from disk.
loaded.compare_models(fit_type="sbs", sbs_aggregation="long").head(6)
file model fit_type handle selection_json slice_index chi2_red_raw sigma_eff chi2_red r2 aic bic
0 synthetic sbs_x0_only sbs 1f338da7 {"e_lim": [40, 320], "t_lim": null} 0 0.047670 0.229574 0.904483 0.989630 254.350708 257.985497
1 synthetic sbs_x0_A sbs f9534f93 {"e_lim": [40, 320], "t_lim": null} 0 0.047840 0.229574 0.907706 0.989630 256.342380 263.611959
2 synthetic sbs_x0_only sbs 1f338da7 {"e_lim": [40, 320], "t_lim": null} 1 0.054876 0.229574 1.041213 0.988017 292.498431 296.133221
3 synthetic sbs_x0_A sbs f9534f93 {"e_lim": [40, 320], "t_lim": null} 1 0.055017 0.229574 1.043877 0.988029 294.197827 301.467406
4 synthetic sbs_x0_only sbs 1f338da7 {"e_lim": [40, 320], "t_lim": null} 2 0.049049 0.229574 0.930657 0.989294 261.653396 265.288186
5 synthetic sbs_x0_A sbs f9534f93 {"e_lim": [40, 320], "t_lim": null} 2 0.049226 0.229574 0.934005 0.989294 263.653396 270.922976

2. Ship Just the Winning Fits#

Notebook 10’s verdict was: base_GLP wins at baseline, m2d_w_IRF wins at 2D. The natural next step is to ship exactly those slots — leaving losers, SbS exploration, and the rest of the history behind.

save_fit takes model= / fit_type= filters, the counterparts of the models= / fit_type= of compare_models; they narrow to a group. With the two base_GLP variants (§1) in the history, a bare filter save ships both (select="all" is the default — the archive is the record); select= chooses within the group:

select=

what it keeps

"all"

every variant, side by side

"latest"

the newest run per (file, model, fit_type) group

"best" + by=

the best run per group by "aic", "bic", "chi2_red_raw" (lowest) or "chi2_red" (closest to 1; needs one σ across the group); a group spanning several fit windows refuses to rank — never a cross-model verdict; that call stays yours, via compare_models

An 8-character handle or a label names one exact fit directly — reference-style selection is project-wide (project.save_fits only; the per-file save_fit takes just the keyword forms).

export_fit is different in kind. It takes exactly one reference — the 8-character handle from any table, or a label — and writes that one fit as a directory of CSVs and PNGs at <root>/files/<file>/<model>/<handle>/, with a fit_info.csv naming what it holds. It is the channel for handing a result to someone without trspecfit; the archive stays the record. The listing printed below shows exactly what a 2D fit vs a baseline yields.

# model= / fit_type= narrow to the base_GLP baseline group — two variants
# since notebook 10 §4, so a bare filter save would ship both. select="best"
# picks one per group; by= names the criterion.
file.save_fit(
    "winner_base.fit.h5", model="base_GLP", fit_type="baseline",
    select="best", by="aic", overwrite=True,
)
file.save_fit("winner_2d.fit.h5", model="m2d_w_IRF", fit_type="2d", overwrite=True)

# One slot each: the m2d_w_IRF group only ever had one run.
print(trspecfit.FitResults.load("winner_base.fit.h5"))
print(trspecfit.FitResults.load("winner_2d.fit.h5"))
Saved 1 slot(s) across 1 file(s) to winner_base.fit.h5
Saved 1 slot(s) across 1 file(s) to winner_2d.fit.h5
FitResults(1 slot, 1 file)
FitResults(1 slot, 1 file)
# Notebook 10 §4 labeled its winning baseline run "m-free". A label selects
# straight from the Project — reference-style select= is project-wide (see above).
project.save_fits("winner_base_label.fit.h5", select="m-free", overwrite=True)
print(trspecfit.FitResults.load("winner_base_label.fit.h5"))
Saved 1 slot(s) across 1 file(s) to winner_base_label.fit.h5
FitResults(1 slot, 1 file)
# export_fit takes one reference and writes one fit. The baseline winner
# goes by its label from notebook 10 §4. Interactively you would type the
# 8-character id of the 2D winner off compare_models(); the notebook looks
# the slot up instead, so it survives a re-simulation, and passes the full
# handle — export_fit accepts either form.
slot_2d = project.results.get(file=file.name, model="m2d_w_IRF", fit_type="2d")
# Either entry point: file.export_fit checks that the fit belongs to this
# file; project.export_fit takes any reference in the project.
file.export_fit("m-free", filepath="winners", overwrite=True)
project.export_fit(slot_2d.handle, filepath="winners", overwrite=True)

# One tree, one directory per fit, fit_info.csv in each — the channel
# contrast in file form.
for p in sorted(Path("winners").rglob("*")):
    if p.is_file():
        print(f"  {p}")
Exported slot 0e4fa26e to winners/files/synthetic/base_GLP/0e4fa26e
Exported slot adbed5bf to winners/files/synthetic/m2d_w_IRF/adbed5bf
  winners/files/synthetic/base_GLP/0e4fa26e/correl.csv
  winners/files/synthetic/base_GLP/0e4fa26e/fit_1d.csv
  winners/files/synthetic/base_GLP/0e4fa26e/fit_info.csv
  winners/files/synthetic/base_GLP/0e4fa26e/metrics.csv
  winners/files/synthetic/base_GLP/0e4fa26e/params.csv
  winners/files/synthetic/m2d_w_IRF/adbed5bf/2D_data_fit_res.png
  winners/files/synthetic/m2d_w_IRF/adbed5bf/correl.csv
  winners/files/synthetic/m2d_w_IRF/adbed5bf/energy.csv
  winners/files/synthetic/m2d_w_IRF/adbed5bf/fit_2d.csv
  winners/files/synthetic/m2d_w_IRF/adbed5bf/fit_info.csv
  winners/files/synthetic/m2d_w_IRF/adbed5bf/metrics.csv
  winners/files/synthetic/m2d_w_IRF/adbed5bf/observed_2d.csv
  winners/files/synthetic/m2d_w_IRF/adbed5bf/params.csv
  winners/files/synthetic/m2d_w_IRF/adbed5bf/time.csv

3. Browse the Loaded Archive#

Beyond compare_models, FitResults exposes a small query API: files(), models(), find() (multi-match), and get() (exactly one match, raises otherwise). Slots carry their identity (the handle plus the stored identity fields the anatomy cell below prints), params DataFrame, observed/fit arrays, and metrics — everything needed to inspect a fit without reloading models or data.

print("files:   ", loaded.files())
print("models:  ", loaded.models())
# find() returns every matching slot — here, both SbS fits.
print("sbs fits:", [s.model_name for s in loaded.find(fit_type="sbs")])
files:    ['synthetic']
models:   ['base_Gauss', 'base_GLP', 'sbs_x0_only', 'sbs_x0_A', 'm2d_w_IRF', 'm2d_wo_IRF']
sbs fits: ['sbs_x0_only', 'sbs_x0_A']

The query layer is the same object on both sides: the base_GLP variant group saved in §1 answers variants() from the archive exactly as it did live in notebook 10 — including the m-free label.

# The archived variant group, queried without any live Project:
v_arch = loaded.variants(model="base_GLP", fit_type="baseline")
v_arch
handle label timestamp GLP_01_m.vary
0 0e4fa26e m-free 2026-10-04T04:04:08+00:00 True
1 82c32c49 NaN 2026-10-04T04:04:25+00:00 False

With the two base_GLP baseline slots in the archive, a bare get(file=..., model=..., fit_type=...) refuses to guess between them — the next cell shows the LookupError (2 slots match … variants() shows how they differ), which points you at variants(). Pin the exact run by its handle or label instead — here, the m-free label set in notebook 10. The last printed line shows the fitted GLP_01_x0 recovers the truth value (X0_BASE) used to simulate the data (see notebook 10).

# Two base_GLP baseline slots — a bare group filter refuses to guess:
try:
    loaded.get(file=loaded.files()[0], model="base_GLP", fit_type="baseline")
except LookupError as e:
    print(f"LookupError: {e}\n")

# Pin the exact run — a label works anywhere a handle does:
slot = loaded.get(handle="m-free")
print(f"base_GLP baseline slot {slot.handle[:8]}:")
print(f"  observed shape: {slot.observed.shape}")
print(f"  fit shape:      {slot.fit.shape}")
print(f"  metrics:        {slot.metrics}")
print(f"  params rows:    {len(slot.params)}")
# the fitted position next to the truth notebook 10 generated the data with
x0_fit = slot.params.set_index("name").loc["GLP_01_x0", "value"]
print(f"  GLP_01_x0:      {x0_fit:.4f}  (truth X0_BASE = {X0_BASE})")
slot.params
LookupError: 2 slots match file='synthetic', model='base_GLP', fit_type='baseline'; variants() shows how they differ — pass the chosen row's handle to get(handle=...).

base_GLP baseline slot 0e4fa26e:
  observed shape: (280,)
  fit shape:      (280,)
  metrics:        {'chi2_raw': 2.417682163035188, 'chi2_red_raw': 0.008823657529325504, 'chi2': 275.2364036988207, 'chi2_red': 1.0045124222584698, 'r2': 0.9981002149755679, 'aic': 287.2364036988207, 'bic': 309.0451413178362}
  params rows:    8
  GLP_01_x0:      8.0013  (truth X0_BASE = 8.0)
name value stderr init_value min max vary expr
0 LinBack_m 0.050349 0.001408 0.01 -1.00 1.0 True None
1 LinBack_b 0.985878 0.015108 0.00 -5.00 5.0 True None
2 LinBack_xStart 0.000000 0.000000 0.00 -inf inf False None
3 LinBack_xStop 20.000000 0.000000 20.00 -inf inf False None
4 GLP_01_A 10.012142 0.037203 12.00 5.00 15.0 True None
5 GLP_01_x0 8.001277 0.001592 8.00 5.00 15.0 True None
6 GLP_01_F 0.997143 0.006454 1.50 0.75 2.5 True None
7 GLP_01_m 0.858619 0.008587 0.30 0.00 1.0 True None

Anatomy of a slot#

get() / find() return a SavedFitSlot — a frozen dataclass. The cell above pulled a few of its fields. The loop below prints every field with its shape (arrays, DataFrames) or keys (dicts): the selection dict, the σ snapshot (sigma_data / sigma_eff), confidence intervals (conf_ci), the MCMC chain (mcmc) when present, and more. The identity fields (handle, optimization_hash, input_files, model_structure, fit_view_sha256) are what the archive uses to tell variants apart; you read them through variants() and diff() rather than directly.

for f in dataclasses.fields(slot):
    value = getattr(slot, f.name)
    if hasattr(value, "shape"):
        detail = f"{type(value).__name__}{value.shape}"
    elif isinstance(value, dict):
        detail = f"dict[{', '.join(value.keys())}]"
    elif value is None:
        detail = "None"
    else:
        detail = type(value).__name__
    print(f"{f.name:18} {detail}")
handle             str
optimization_hash  str
input_files        str
model_structure    str
fit_view_sha256    str
file_name          str
model_name         str
fit_type           str
selection_json     str
observed           ndarray(280,)
fit                ndarray(280,)
fit_alg            str
timestamp          str
noise_type         str
sigma_source       str
sigma_type         str
sigma_data         float
sigma_eff          float
selection          dict[base_t_ind, e_lim]
params             DataFrame(8, 8)
metrics            dict[chi2_raw, chi2_red_raw, chi2, chi2_red, r2, aic, bic]
noise_scale        float
sigma              None
dark               None
calibration        None
model_yaml         tuple
label              str
joint_ref          None
conf_ci            None
correl             DataFrame(6, 6)
mcmc               None
params_meta        None
params_stderr      None
fit_settings       dict[backend, fit_alg_1, fit_alg_2, stages, try_ci]
components         ndarray(2, 280)
component_names    list
fit_ini            ndarray(280,)
params_init        None

Tips#

  • Default path: file.save_fit() / project.save_fits() write to ./fit_results/<project_name>.fit.h5 when no path is given; file.export_fit(ref) / project.export_fit(ref) write below ./fit_results/<project_name>/. Pass an explicit path to override (as the cells above do).

  • select= chooses what to persist — the defaults, "best" + by=, and the project-wide rule for handle/label references are all spelled out in §2.

  • Append + collision rule: writing to an existing archive augments it. An exact re-run shares its handle and deduplicates to one slot; a changed input creates a new variant that archives side by side. Within one handle, fitted values are compared at fixed tolerances — divergent values refuse to write without overwrite=True, and replacing an already-stored attachment (CI / MCMC) also needs it; only absent→present attachments enrich freely.

  • Fits never write to disk: fit_* methods compute, display (per show_output), and record the fit — persistence is always an explicit call on one of the two channels (save_fit / export_fit, or the project-level save_fits / export_fit).

  • File.set_sigma is the only sigma entry point. Loaded archives keep their calibrated columns without it (§1); for a what-if recalibration of existing slots, use the raw chi2_red_raw column (the two-line pandas recipe is in §1).

Next steps: