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 viaFitResults.load. For future you and other trspecfit users.export_fit→ one CSV + PNG directory per fit. One-way (noloadcounterpart). For Origin, MATLAB, paper plots, non-trspecfit colleagues.
Roadmap
Re-run notebook 10, whose fitted state is the material for everything below.
Save the full fit history, load it back, and compare straight from the archive.
Ship just the winning fits — filters and
select=on the archive channel, one reference perexport_fit.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:
%cdinto notebook 10’s directory so itsproject.yamland model YAMLs resolve.%%capturewraps the whole cell so the noisy run (notebook 10 keepsshow_output: 1for its own interactive use) doesn’t dump into this notebook’s output.%cd -back so any HDF5/CSV/PNG artifacts this notebook writes land in11_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:
|
what it keeps |
|---|---|
|
every variant, side by side |
|
the newest run per |
|
the best run per group by |
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.h5when 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 (pershow_output), and record the fit — persistence is always an explicit call on one of the two channels (save_fit/export_fit, or the project-levelsave_fits/export_fit).File.set_sigmais the only sigma entry point. Loaded archives keep their calibrated columns without it (§1); for a what-if recalibration of existing slots, use the rawchi2_red_rawcolumn (the two-line pandas recipe is in §1).
Next steps:
Uncertainty estimation — CI tables and MCMC chains ride along in saved slots (
conf_ci,mcmc):12_uncertainty_mcmcThe same archive workflow across many files at once:
20_multi_file_independent_fit