Fitting From a Configuration File#
galspec.pipeline fits one spectrum, or a whole sample with one model, from a
TOML file instead of a Python script:
./example/fit_iras07546_config.sh # the published IRAS 07546 fit
./example/fit_iras07546_config.sh --dry-run # what would be fitted, no sampling
The pipeline is a consumer of the models: it builds the same
JaxCompoundModel a script would assemble, and runs
the same JNesty_Fit. It adds no model code and
changes none, so a fit produced from a file and a fit produced by hand of the
same model and data are the same fit. galspec/__init__.py does not import
it; nothing changes for code that does not use it.
The running example in this chapter is
example/fit_iras07546_config.toml: the seventeen-component, 27-parameter
model of example/fit_iras07546_jax.py, written as a file and checked against
the script parameter by script parameter
(develop/dev_config/test_iras07546_config.py). Read it next to this page.
A file can be much shorter than that one. Nested sampling draws from the prior, so neither the starting values nor the priors have to be written down – the IRAS file below writes no starting value at all, only what the model is (line centres, a fixed redshift, the ties) – and the narrow-line profile can travel inside the spectrum’s own FITS file. A complete configuration for one emission line is:
[data]
file = "spectrum.fits"
narrow_template_key = "NARROW" # optional; a Gaussian if omitted
[model]
[[model.component]]
type = "Line_template"
wavec = "@Halpha"
That file has two free parameters, the profile’s amplitude and its velocity, and
a continuum component would be the natural next line. --dry-run prints the
prior every omitted parameter ended up with, and the same list goes into
run_report.json.
Starting from a template#
The file format has a lot of optional keys, so the command will write you a commented starter:
$ python -m galspec.pipeline --template config.toml
wrote config.toml
edit [data] file, then
python -m galspec.pipeline config.toml --dry-run # what would be fitted
python -m galspec.pipeline config.toml # the fit
--template alone writes config.toml in the current directory; give it a
path to write elsewhere, - to print to standard output, and --force to
overwrite a file that is already there (without it an existing file is an error,
so the command cannot eat a configuration you have written). It exits without
touching any data.
The starter is a working configuration rather than a sheet of placeholders: in a
checkout it points at the two-line mock spectrum in example/data, so the first
--dry-run reports one source and eight free parameters before you edit
anything, and the fit it describes takes about forty seconds on a CPU. From there
the edits are: [data] file (or glob/catalog for a sample), the
components in [model], and the priors. Every section it writes is explained
below.
Why a file#
A sample is fitted with the same model, the same priors and the same sampler settings for every source, and only the data and a few flux scales differ. Writing that down once – instead of copy-pasting a script per object – is what makes a sample fit reproducible: the file is hashed into the output, and the run report records every setting that produced each result.
Requirements on the data#
One FITS file per source. The pipeline reads one file at a time; a “sample” is a list of files, not a list of rows inside one file.
Three arrays: wavelength, flux and error. All three are mandatory. A missing error array is an error, not a guess: an uncertainty invented from
std(flux)is not a measurement, so the pipeline refuses to make one.The arrays are found either as columns of a binary table or as image HDUs whose
EXTNAMEmatches the key:[data] wave_key = "WAVELENGTH" # default; matched case-insensitively flux_key = "FLUX" error_key = "ERROR"
A column name is matched first; an image HDU is looked up by extension name. Use
wave_hdu/flux_hdu/error_hduwhen a file has more than one candidate (they take an HDU index or an extension name).Wavelength is converted to Angstrom, flux is not converted. The unit is taken from
TUNITn(a table column) orBUNIT/CUNIT1(an image), or from[data] wave_unit/flux_unit. A wavelength innmorumis converted; an axis that is not a length (velocity, frequency, pixels) is rejected, because the models are evaluated on a wavelength grid. The flux unit is recorded in the output header and the report and otherwise left alone: nof_lambda/f_nuconversion is attempted.The header supplies the two corrections. A spectrum is fitted in the rest frame, with the Galaxy’s reddening removed, and both numbers can be read from the file rather than written into the configuration. One keyword each:
quantity
header keyword (
[data])what the pipeline does
redshift
z_key, default"ZRED"divides the wavelength by
1 + z; the model’s line centres are rest wavelengths ("@Halpha"or6562.819)Galactic reddening
e_bv_key, default"EBV"de-reddens the flux and the error with
extinction.ccm89atextinction_rv, on the observed grid before the wavelength is shifted. The keyword’s name says how to read it: a colour excess is taken as is, anA_Vis divided byR_Vfirst, soe_bv_key = "A_V"is a complete statement.A single keyword, not a list of spellings, because a file either has the keyword or it does not: a list turns “this file has no redshift in it” into a quiet fallback onto whichever of five keys happened to be there for an unrelated reason. If the file stores it under another name, name it once (
z_key = "MYZ").extinction_rvis a configuration value only – no header is trusted for it, since a survey that storesA_Vleans onR_Vand a wrongR_Vwould propagate into every flux without leaving a trace. It defaults to3.1, the Milky Way value.The precedence is: the catalog’s
zcolumn, the header,z_default. A value that is not in the file is assumed to be zero and the run warns: the report recordsz_sourceande_bv_sourceper source, so a correction that was assumed is visible next to one that was applied. Writing the key in the configuration (z_default = 0.0, orextinction = falsefor a synthetic spectrum) counts as stating it, and the run is then silent.Note
The pipeline does not mask sky lines and does not synthesize errors: if your spectra need that, prepare a FITS file with the three arrays – that file is the input, and it keeps the processing visible and repeatable. See Preparing a file for the IRAS spectrum, which needs both.
Preparing a file#
Two files in example/data/ show why the three arrays are a requirement rather
than a convenience, and what “prepare it” means in practice. A template is the
one extra thing a file may carry, and it is deliberately optional: a spectrum
without one is fitted with Gaussian lines.
spec-0651-52141-0434.fits is a BOSS coadd: it has flux, loglam and
ivar, so there is a wavelength grid and a noise estimate, but no error array
and no length unit. One short script turns it into pipeline input – a table with
WAVELENGTH = 10**loglam, FLUX = flux and ERROR = 1/sqrt(ivar), with
the masked pixels dropped – and the choices it makes (which pixels to keep, what
to do with zero ivar) stay in the file instead of being buried in a fitting
script.
ir07546sed.fits is a single image with no error and an observed-frame
logarithmic axis; the published fit (example.iras07546_model) dereddens it
with extinction.ccm89, divides by 1 + z and derives the error from a
running median. Only the corrections can move into the pipeline, because the
header can carry them; the error cannot, because it is not a number in the file.
develop/dev_config/convert_iras07546.py writes the input therefore:
python develop/dev_config/convert_iras07546.py # -> example/data/iras07546_pipeline.fits
It stores the observed-frame WAVELENGTH, FLUX and ERROR columns, puts
REDSHIFT and EBV (and EBVRV) in the header, adds the measured
narrow-line profile as a fourth extension (NARROW: velocity in km/s,
normalised flux – what [data] narrow_template_key reads, so the profile
travels with the spectrum instead of beside it), and then checks itself:
it applies the pipeline’s own de-reddening and de-shifting to what it wrote and
asserts that the result is iras07546_model.load_spectrum() to 1e-12
relative. Wavelengths round trip exactly (0.0), flux and error at 1e-16. That
check is what makes the configuration a reproduction of the script rather than an
approximation of it. python develop/dev_config/convert_iras07546.py
--check-only runs the check without writing.
A configuration is then a file of declarations, and the section-by-section
walkthrough below reads example/fit_iras07546_config.toml in order.
The run section#
[run]
output_dir = "out/iras07546"
workers = 1
[run] says where the outputs go and how many processes do the work:
key |
default |
meaning |
|---|---|---|
|
|
resolved relative to the configuration file, not the working directory. |
|
|
|
|
|
refit even when a source’s outputs exist. |
|
unset |
the sampler’s progress bar: unset means “on a terminal, with
|
The data section#
[data]
file = "data/iras07546_pipeline.fits"
narrow_template_key = "NARROW" # the measured line profile, inside the file
z_key = "REDSHIFT" # one keyword; the default is "ZRED"
e_bv_key = "EBV" # ... and "EBV"
extinction = true
extinction_rv = 3.1 # R_V of the law; the default, config-only
wave_min = 4200.0
wave_max = 6800.0
continuum_percentile = 15.0
min_pixels = 50000
key |
default |
meaning |
|---|---|---|
|
– |
exactly one of them: one spectrum, a list of spectra, a shell pattern
(relative to the config file, or |
|
|
catalog column with the source identifier (falls back to the file stem). |
|
|
catalog column with the spectrum path, relative to the catalog. |
|
– |
catalog column with the redshift, if it has one. |
|
|
array names, matched case-insensitively. |
|
– |
restrict a lookup to one HDU (index or extension name). |
|
from the header |
override the header’s unit. |
|
– |
name (or index) of an HDU holding the narrow-line profile as a two-column
table: velocity in km/s, then normalised flux. Each spectrum then carries
its own profile, which is what a sample usually needs – the lines of
different galaxies are not the same shape. A component’s
|
|
|
the header keyword holding the object’s redshift. One keyword, not a list:
name the one your files use ( |
|
|
redshift when neither the catalog nor the header says. Declaring it explicitly is what silences the “assumed” warning. |
|
|
the header keyword holding the Galactic reddening. A colour-excess name
( |
|
|
de-redden with that keyword; |
|
|
|
|
– |
rest-frame range to fit. |
|
– |
list of |
|
|
refuse a source with fewer pixels than this after the cuts. The IRAS window keeps 56956; the value in the file fails loudly if a wrong window is used by accident. |
|
|
percentile behind the |
A catalog looks like:
id,file,z
J0925,spectra/J0925.fits,0.0312
J0931,spectra/J0931.fits,0.0455
The fit section#
[fit]
nlive = 400
dlogz = 0.01
max_iterations = 200000
share_compilation = true
device = "auto"
[fit] passes sampler settings to JNesty_Fit; the
values above are the ones example/fit_iras07546_jax.py uses
(NLIVE = 400, DLOGZ = 0.01).
key |
default |
meaning |
|---|---|---|
|
|
live points. The main cost knob; 500 to 1000 is usual for a dozen parameters, 400 is what the IRAS model was published with. |
|
|
stop when the remaining evidence is below this (nats). |
|
|
safety cap. |
|
|
|
|
|
|
|
automatic |
forwarded to JNesty; leave unset unless you are tuning. |
|
|
forwarded to JNesty. |
|
|
compile the likelihood once for the whole sample (see Compiling once for a sample). |
|
– |
a directory for JAX’s on-disk compilation cache. |
The output section#
[output]
figures = "pdf" # one <id>_fit.pdf; "png" or "both" also work
fit_plot = true
corner = true
corner_params = [
"Continuum.amplitude", "Continuum.alpha", "FeII.amplitude",
"b_halpha.amp_c", "b_halpha.amp_w0", "b_halpha.dv_c", "b_halpha.sigma_c",
# ... five more
]
histogram = true
hist_params = ["Continuum.alpha", "FeII.amplitude", "b_halpha.amp_c", # ...]
[output] controls the two files. figures (default "pdf") chooses the
container of the figures – one <id>_fit.pdf, one PNG per page ("png", for a
talk), or both ("both"); the pages themselves are the same either way.
thin (default 1) keeps every n-th posterior sample in the FITS – the FITS is
the only copy of the posterior, so a value above 1 throws samples away and is meant
for a run whose file size, not whose analysis, is the problem. fit_plot
(default true) draws the fit page; corner and histogram (default
false) add those pages. The summary page is always there.
corner_params and hist_params select the parameters to draw, and both
spellings are accepted: the qualified name (b_halpha.amp_c), which does not
depend on the summation order, or the flat one (amp_c_3), which is what the
FITS column order stores. A name that is not a free parameter of the model is an
error listing the free ones. A 27-parameter corner plot is a 27x27 square nobody
reads, which is why the IRAS file names thirteen.
The figures are pages of one PDF, written in the order [output] lists them.
They are the library’s, not the pipeline’s own drawings: the fit plot is
galspec.plot.plot_fit_jax() – data, total model, every component and the
residuals in units of the error, the same figure example/fit_iras07546_jax.py
writes – the histograms are galspec.plot.plot_posterior_hist(), and the
corner is galspec.plot.plot_corner(). So a pipeline figure and the figure a
hand-written script makes of the same fit agree, and the qualified LaTeX labels and
prior annotations are the ones the rest of GalSpec uses. One consequence worth
knowing: a corner plot needs a posterior that moved, so a run stopped before the
sampler explored skips that one figure with a warning instead of failing the fit.
The model section#
[model] holds require_priors (default false) and one
[[model.component]] per additive term, in the order they are summed:
[model]
# require_priors = true # every prior must be written out to fit
[[model.component]]
type = "PowerLaw1D" # any kind galspec.jax_models knows
name = "Continuum" # used by ties, priors and plots
amplitude = 0.55 # optional: the model has a default too
x_0 = 5500.0 # not optional: it is where the power law sits
alpha = 1.0
fixed = { x_0 = true }
[model.component.priors.amplitude]
kind = "loguniform" # optional: the default is loguniform over
lower = "0.2*continuum_level" # 1e-3 .. 3*max_flux, which is wider
upper = "4*continuum_level"
The order matters twice: it is the order of the sum, and the flat parameter names
carry the index (amp_c_3 is the fourth component’s amp_c). Reordering the
file therefore renames parameters; the qualified spelling is the stable one, which
is why ties, priors and plots use it.
Keys of a component are one of:
typeandname;a parameter value – anything the model names as a parameter (
amplitude,dv,sigma,amp_c0, …), which may be a number or an expression. Optional: an omitted value keeps the model class’s default, which nested sampling ignores anyway. Three values are not optional in practice –wavec/wavec0/wavec1andx_0– because their model default (5000 A, 1 A) does not describe the line or the pivot you mean: a line component has to name its centre, with a number or with"@Halpha", or pin it with[model.component.bounds.wavec];a constructor argument (
n_components,par_w,degree,template_name,Star_types,R,template_file) – these are passed to the model’s constructor and must not collide with a parameter name. A key ending in_fileis a path: it is resolved relative to the configuration file (an absolute path is used as written) and never parsed as an expression;the tables
fixed,bounds,params,tiesandpriors.
Anything else is rejected with its TOML path, so a typo cannot become an unused keyword argument that silently does nothing.
Expressions#
Any number in [model] and [data] may be an expression over the
statistics of the source being fitted, which is what lets one file serve a sample
whose flux scale differs from spectrum to spectrum:
token |
value for the current source |
|---|---|
|
|
|
|
|
the median, and the |
|
standard deviation of the flux |
|
pixels being fitted |
|
the redshift used |
The language is numbers, those names, + - * / ** and parentheses.
Everything else – a call, an attribute, a subscript – is rejected when the
file is read, before any spectrum is touched. "3*max_flux" sets a starting
value; [model.component.bounds] amplitude = ["0.01*peak_flux",
"10*peak_flux"] sets the box in the same units, which is what makes a
magnitude-limited sample fit with one file.
How the statistics are measured#
The tokens are computed once per source, from the pixels that are actually fitted – after the rest-frame window, the masks and the de-reddening, but in the flux unit the file carries, so a token inherits that unit and no conversion is attempted.
continuum_level and median_flux come straight from the flux array:
np.percentile(flux, continuum_percentile) and np.median(flux). A
percentile is what a continuum level should be – it is a statement about where
the spectrum sits, not about its brightest feature – and it is already immune to
a few bad pixels, so nothing is smoothed. continuum_percentile therefore has
to be chosen for the spectrum: a spectrum with 60% of its window filled by
emission lines wants something like 15 or 20, one that is mostly continuum wants
the default 50.
max_flux, min_flux and peak_flux set the scale of every amplitude
default and are measured on a despiked copy of the flux, because a single hot
pixel would otherwise decide them:
a running median of five pixels, which outvotes any outlier up to two pixels wide – a cosmic ray, a bad column, a sky residual left in one channel;
a sigma clip of the residual against that running median: a pixel that stands more than three residual spreads away and agrees with neither neighbour is replaced by the median. The neighbour test is what keeps a real line, whose flanks do not look like that;
the extremes of the result.
A real line is wide compared with five pixels at any sensible dispersion, so its
peak survives: the despiked maximum of the clean IRAS spectrum differs from
max(flux) by 4e-4 in relative terms – while a one-pixel spike of any height does
not. A narrow line loses the tip of its profile – a one-pixel line keeps 80%
of its peak, a two-pixel line 93%, five pixels or more 98% – which is the right
trade for a number whose job is to set a prior scale. rms keeps the raw array, since it
is a noise estimate and despiking it would remove exactly what it measures.
The IRAS file uses max_flux rather than peak_flux on purpose: the script’s
set_priors() uses np.max(flux), which is not max(|flux|) when the
noise dips below zero, and a reproduction should not depend on that difference.
Arithmetic between constants is allowed and is used for the atomic ratios, so a starting value is the ratio itself rather than a rounded copy:
amp_c0 = 1.8
amp_c1 = "1.8/2.98" # the [O III] 5007/4959 ratio
Line names such as "@Halpha" resolve to the rest wavelength from
galspec.utils.line_wave_dict (Halpha, Hbeta, OIII_5007,
NII_6583, SII_6716, HeI_5876, …); other values are written in
Angstrom.
Fixed, bounds, params#
To hold a parameter at its value:
[model.component]
type = "Line_Gaussian"
name = "Halpha"
fixed = { wavec = true } # Line_Gaussian already fixes it by default
To change a box:
[model.component.bounds]
amplitude = ["0.01*peak_flux", "5*peak_flux"]
sigma = [50.0, 1200.0]
Each parameter can also be written as its own table, which is convenient when it needs several settings:
[model.component.params.sigma]
value = 300.0
bounds = [50.0, 1200.0]
prior = { kind = "uniform", lower = 50.0, upper = 1200.0 }
Ties#
A tie makes one parameter follow another. The plain form is a reference:
[model.component.ties]
dv = "Halpha.dv" # this line moves with Halpha
sigma = { ref = "Halpha.sigma", scale = 1.5 } # 1.5 * Halpha.sigma
The named kinds are the GalSpec conventions, written as
reference * scale + offset so that they survive serialisation as pure data:
|
reference parameter |
value |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
that parameter |
|
The IRAS model is held together by 29 such ties, and they are the whole physical
content of the model beyond the line centres and the fixed values: one narrow-line
velocity, one broad-line kinematics, and three atomic ratios. Written as they appear in the
file, for the four kinds of reader – the profile comes from the data file
([data] narrow_template_key), so no component repeats a path:
# long-wavelength narrow lines share the [O III] velocity
[[model.component]]
type = "Line_template_doublet"
name = "NII"
amp_c0 = 1.0
amp_c1 = "1.0/2.96"
dv_c = 0.0
wavec0 = "@NII_6583"
wavec1 = "@NII_6548"
[model.component.ties]
amp_c1 = { kind = "doublet_ratio", ref = "NII", ratio = 2.96 }
dv_c = "OIII.dv_c" # ... and [O III] owns the velocity
A component that needs a different profile from the rest of the spectrum writes
template_file = "profiles/nii.txt" (two columns, velocity then flux, resolved
relative to the configuration) and overrides narrow_template_key for itself.
# broad H-beta keeps its own peak and its own wind amplitude, and copies the # shape of broad H-alpha, which is the only line whose kinematics are free [[model.component]] type = “Line_MultiGauss” name = “b_hbeta” n_components = 2 amp_c = 0.22 dv_c = 0.0 sigma_c = 1500.0 wavec = “@Hbeta” amp_w0 = 0.20 dv_w0 = 0.0 sigma_w0 = 6000.0
[model.component.ties] dv_c = “b_halpha.dv_c” sigma_c = “b_halpha.sigma_c” dv_w0 = “b_halpha.dv_w0” sigma_w0 = “b_halpha.sigma_w0”
The iron pseudo-continuum is tied the same way (five kernel parameters follow broad H-alpha), so the iron costs the sampler exactly one number, its amplitude.
Ties are validated when the model is built: a reference to a component or parameter that does not exist is an error naming the alternatives. Tying a parameter that is fixed is allowed (the tie then simply never matters); putting a prior on a tied parameter is rejected, because the prior would never be used.
Priors#
A parameter with no prior is uniform over its bounds. Every prior takes the
arguments of its class in galspec.priors, plus expressions where a number
is expected:
[model.component.priors.amp_c0]
kind = "loguniform" # uniform, tophat, loguniform, spikeandslab,
lower = "0.01*peak_flux" # normal, clippednormal
upper = "5*peak_flux"
[model.component.priors.dv]
kind = "normal"
mean = 0.0
sigma = 150.0
[model.component.priors.sigma_w0]
kind = "normal"
mean = "Halpha.sigma_c" # centred on another parameter
sigma = 0.4
relative = true
nsigma = 3
Default priors#
Nested sampling draws from the prior, so a configuration does not have to say
where to start: an omitted parameter value falls back to the model class’s own
default (amplitude = 1, sigma = 200 km/s, …), which never biases the
answer – it only changes the point the sampler prints while it warms up.
A parameter the file says nothing about also gets a default prior, chosen from its name and resolved against the source’s own statistics:
parameter |
default prior |
why |
|---|---|---|
|
|
a line is between a thousandth of the continuum and three times the
brightest pixel, and “twice as faint” and “twice as bright” are equally
probable. Both ends are scaled by the spectrum, so one file serves a
sample whose flux spans decades; an absolute |
|
|
a wind amplitude is a fraction of the line. |
|
|
systematics and a few hundred km/s of gravitational slop. |
|
|
a wind is wider and can be more offset. |
|
|
any width a spectrograph can resolve, from a stellar line in a high-resolution spectrum to a broad-line-region profile. A broad line wants a floor near 300 km/s, which is a statement about the object rather than about units, so write it out – the IRAS file does. |
|
|
the wing, i.e. an outflow. |
|
|
a power-law index, both signs. |
|
|
Hermite moments. |
|
|
optical depth of an absorption model. |
|
|
covering fraction. |
|
|
a redshift correction. |
|
|
a broadening kernel. |
A prior or a bound written in the file always wins, so the defaults are what is in
force only where the file is silent. --dry-run lists every parameter that
took one, with the numbers it resolved to, and the same list is in
run_report.json under model.default_priors: nothing about the prior is
invisible.
Two kinds of parameter are deliberately not defaulted, because the right range
depends on the data in a way a name cannot express – a linear continuum’s
slope and intercept, and a free wavec: a wrong box there changes the
evidence silently instead of failing. Those still need
[model.component.bounds.slope]
... # = [low, high]
require_priors = true demands a prior written in the file on every free
parameter – defaults do not count – so it is the way to make a configuration
state its priors even though it no longer has to. Two checks run before the
sampler starts, so a mistake costs a second instead of a night:
every free parameter needs a finite sampling range – both bounds, or a prior with finite limits (a centred prior such as
normaladditionally needs the bounds, because they truncate it);no free parameter means nothing to fit, which is also an error.
The IRAS file leaves twenty-one of its twenty-seven priors to the defaults:
every line amplitude (loguniform(1e-3*continuum_level, 3*max_flux), the
reference script’s loguniform(1e-3, 3*max_flux) with a floor that scales
instead of one absolute number), alpha, the three wind amplitudes and one wind
velocity. Six are written out, and each is worth knowing about when writing a new
file:
the broad lines’ widths: 300-8000 km/s, while the default floor is 10 km/s, which a narrow line needs. A floor of 300 is a statement about the object, not about units, so it cannot be a default;
the continuum’s
0.2-4*continuum_leveland the iron template’s3*continuum_levelceiling – a pseudo-continuum is scaled by the continuum, not by the brightest emission line;the two narrow-line velocity windows, broad H-alpha at 2500 km/s and [O III] at 1000 km/s, which the systemic reference needs.
The scaled amplitude floor is not cosmetic for this object: 1e-3 of a continuum
level of 0.4991 is 5e-4, half the script’s absolute floor, so a log-uniform prior
with the same ceiling is wider by ln(12.99/5e-4) / ln(12.99/1e-3) = 1.074 in its
log-range, and a log-uniform density scales as 1/ln(b/a). Each of the sixteen
amplitudes therefore lowers the evidence by ln(1.074) = 0.071, and the total is
about -1.1: the published 130677.27 belongs to the script’s prior, and a file
that uses the scaled floor would report about 130676.1.
Only the number moves. The two priors differ by a constant factor wherever both are non-zero, so the posterior is the same up to the renormalisation the added faint tail implies – and that tail (amplitudes between 5e-4 and 1e-3, where no line of this spectrum lives) can only bring the shift back toward zero. The offset is also worth putting next to the ±0.58 sampling error: it is a deliberate difference in the family of models, not a defect in the fit. A short run cannot measure it either – a nested-sampling run truncated at a few hundred iterations is dominated by its un-converged initial state, and the numbers it reports differ by far more than one nat – which is why the value above is the exact prior-volume ratio rather than a measured difference. The mapping is one line of the script per table of the file, which is the property to aim for when writing a new configuration:
in |
in |
|---|---|
|
the |
|
the |
|
eight |
|
five |
|
the six |
|
|
Output#
For a source <id>:
<output_dir>/<id>/<id>_fit.fits # the model, the data and the whole posterior
<output_dir>/<id>/<id>_fit.pdf # a summary page, then the figures
<output_dir>/run_report.json # what was fitted, with what, how
Two files per source, and nothing else. There is no <id>_posterior.npz and no
directory of .png figures: the FITS already holds every array such an archive
would hold, and a second copy of a posterior is a second thing to keep in step with
the first. (A run made before this changed wrote the archive; its arrays are in the
FITS under the names below, which is what a script reading np.load should read
instead.)
<id>_fit.fits is the file
galspec.jnesty_fit_io.save_jnesty_fit_to_fits() writes. The primary header
carries the provenance – source identifier, input file, pixels, redshift and where
it came from, the extinction removed (EBV, EBVRV, EXTINCT), the
configuration’s name and the first 16 characters of its SHA-256, the fit date – and
the sampler’s verdict: LOGZ/LOGZERR, NITER (iterations, i.e. dead
points), NSAMP (rows in the sample arrays – NITER + NLIVE before thinning),
NCALL, NLIVE, NDIM, BOUND, THIN, DEVICE, RUNTIME, and the
diagnostics
INFORM (the Bayesian information H), ACCRATE, EFF, CONVERGED,
DLZ, DLZTHR.
The extensions hold the run:
HDU |
what it holds |
|---|---|
|
every sample ( |
|
one entry per sample: log-likelihood, log-weight, log-volume, and the same point in the unit cube |
|
the run’s trajectories, which is what the dynesty-style |
|
the column order of |
|
the point the fit started from and the maximum-likelihood sample |
|
the arrays that were fitted, after the cuts and the two corrections |
|
the model itself, so the fit can be redrawn without the configuration file |
That is enough to re-derive every number in this chapter from the file alone:
from astropy.io import fits
hdul = fits.open("out/<id>/<id>_fit.fits")
samples = hdul["SAMPLES"].data # (niter, ndim), in PARAM_NAMES order
weights = hdul["LOGWT"].data # ln(weight) of each sample
equal = hdul["SAMPLES_EQ"].data # already resampled
print(hdul[0].header["LOGZ"], hdul[0].header["INFORM"])
With figures = "png" the same pages are written beside the FITS as
<id>_summary.png, <id>_fit.png, <id>_corner.png, <id>_histogram.png,
and figures = "both" writes both sets; nothing else about the run changes, so one
can be re-rendered from the other without re-sampling – except that only the PDF can
be re-rendered from anything, because the FITS carries every number the pages are
drawn from.
<id>_fit.pdf is one document. Page one is text – the evidence and the run’s
diagnostics, the data and the two corrections the pipeline applied, and a table of
every free parameter with its median, 16th and 84th percentile and
maximum-likelihood value, marking with * a parameter on the pipeline’s default
prior and with ! one pressed against a prior limit (a limit, not a measurement).
Then come the pages [output] asks for: the fit, the corner plot, the marginal
posteriors. The pages are vector drawings, so a 57000-pixel spectrum stays sharp at
any zoom – which is also why losing the separate PNGs costs nothing.
run_report.json records the settings, library versions, and per source: the
status, evidence, iterations, timing (compile and sampling separately), a
parameters table with the median, the 16th and 84th percentile and the
maximum-likelihood value of every free parameter, which parameters are pressed
against a prior limit (prior_dominated), which parameters took a default prior
(model.default_priors, with the numbers it resolved to for this source), the
flux tokens that were used, the redshift and extinction provenance, any warning
raised while reading, and the outputs. A source that fails is recorded with its error and the run continues; the
report is written even when every source fails.
Resuming and parallelism#
A source whose output files all exist is skipped unless force = true (or
--force), so an interrupted run is continued by running the same command
again. workers > 1 fits sources in spawned processes; each process writes its
own sources, and the report is assembled from their results. Failures are
isolated per source in both modes. Set GALSPEC_PIPELINE_TRACE=1 to keep the
traceback in the report.
Compiling once for a sample#
Every JAX program has to be compiled before it runs, and compiling the
likelihood and the prior of a full model takes a fraction of a second to a second
(0.6 s for the 39-parameter example in develop/dev_jax/bench_shared_compilation.py).
With share_compilation = true – the pipeline’s default – the spectrum
travels to the compiled likelihood as an argument, so the compiled program does
not contain the data and is reused for the next source. In a four-worker run of
develop/dev_config/mock_sample.toml the report shows 0.26-0.38 s of compiling
for the first source each worker fitted and 0.01 s for the second.
JNesty’s own loops accept the same context, so its kernels are reused as well,
provided the model structure (not the data) stays the same. The report records
compile_seconds and sample_seconds separately, so the saving – or its
absence – is visible per source rather than argued about.
The one thing that defeats the reuse is a model that carries per-source numbers
of its own: a narrow-line profile read out of each spectrum
([data] narrow_template_key) is a constant inside the traced program, not an
argument, so two sources with different profiles trace two different programs.
The cost is one recompile of the whole likelihood kernel – there is no “template
part” to compile separately, because the profile is arithmetic inside the same
expression as the model sum and the residual. Measured on the 27-parameter IRAS
model (56956 pixels, CPU): 0.5-0.7 s for a new profile, 0.07 s when the
profile repeats (the trace plus a registry hit). The prior transform is a separate
program with no model in it, so it is reused; JNesty’s own sampling kernels never
touch the model and are not recompiled at all.
Avoiding even that would mean passing the profile as a runtime array argument,
which needs the model to accept one – a change to galspec.jax_models,
and the pipeline deliberately changes no model. Half a second per source against
minutes of sampling is the trade the file takes; the report records it per source
as compile_seconds.
The registry hashes the program including its constants, which matters here: JAX
prints a large array constant in abbreviated form, so two models that differ only
in their profile trace to the same text, and a key made from the text alone would
hand the second source the first source’s kernel – a fit with the wrong line
shape, and nothing downstream would show it. tests/test_shared_compilation.py
checks that a model differing only in a constant compiles again and gives its own
likelihood.
This is a compilation saving, not a speed-up of the sampling: the compiled
arithmetic is the same, and per-iteration cost is unchanged within run-to-run
noise. The numbers do not change either – the same computation is traced, and
tests/test_shared_compilation.py checks that the likelihood, the prior
transform and a short run agree with the per-source path.
compilation_cache_dir additionally keeps JAX’s on-disk cache, which is what
helps across processes (workers > 1) and across runs.
Command line#
python -m galspec.pipeline CONFIG.toml [options]
--template [PATH] write a starter configuration (PATH defaults to config.toml;
"-" prints it) and exit; --force overwrites
--dry-run read every spectrum and report what would be fitted
--list-sources print the source ids and files the config selects
--workers N fit in N processes
--force refit even when the outputs exist
--output DIR write here instead of [run] output_dir
--device CPU|GPU override [fit] device
--quiet only failures and the final summary
A fit prints nothing between the fitting line and its result, which for a
27-parameter model on a CPU means hours: nested sampling converges by itself and
has nothing to report in between. In a terminal the sampler draws a progress bar, so
silence there means something else; when the output is redirected the bar is
suppressed (it is written with carriage returns, which is noise in a log) and the run
says so before it starts. [run] progress forces either behaviour, and --quiet
turns both the log and the bar off.
--dry-run is the one to run first on a new sample: it reads every file,
resolves every expression against that source’s flux, builds the model, and
reports the pixels, the rest-frame range, the redshift and its source, the
tokens and the number of free parameters – without sampling. For the IRAS file:
$ ./example/fit_iras07546_config.sh --dry-run
[1/1] iras07546_pipeline: 56956 pixels, 4200.0-6800.0 A rest, z=0.0953 (header:REDSHIFT), peak_flux=4.332, 27 free parameters
example/fit_iras07546_config.sh is a two-line wrapper around the command; it
passes its arguments through, so --device cpu or --output work with it
too. The same thing from Python:
from galspec.pipeline import load_config, run_pipeline
config = load_config("sample.toml")
report = run_pipeline(config) # or workers=8, force=True
print(report["counts"], report["sources"][0]["log_evidence"])
The IRAS reproduction#
example/fit_iras07546_config.toml is the model of
example/fit_iras07546_jax.py – seventeen additive components, 27 free
parameters, 56956 pixels – and it reproduces the published run:
./example/fit_iras07546_config.sh # ~12 min on a GPU
./example/fit_iras07546_config.sh --device cpu # hours
The claim is checkable and is checked by
develop/dev_config/test_iras07546_config.py, which runs in a few minutes:
the spectrum equals
iras07546_model.load_spectrum()to1.3e-16relative – the best possible, since both are the same float64 numbers;the model is the same: same 17 components in the same order, same 27 free parameters in the same order, same starting values, bounds, priors (kind and numbers) and the same 29 ties;
prior_transformand the log-likelihood agree point by point, so the sampling problem is identical;a capped fit (50 live points, 300 iterations) drives the sampler end to end and writes every output.
Published numbers to compare against
(example/iras07546_model.REFERENCE):
Gaussian narrow lines 30 free log Z = 130889.60 +/- 0.62 (10.2 min)
measured narrow profile 27 free log Z = 130677.27 +/- 0.58 (11.7 min) <- this file
Repeat runs of one configuration have given 130656.22, 130656.34 and 130658.06 – about two nats of scatter, from the last bits of a GPU reduction reaching the sampler’s ordering – and re-extracting the narrow-line profile moved the evidence by about twenty. Quote an evidence from this family to a nat or two; the differences the model is judged on are two hundred. What the file reproduces exactly is the problem – the data, the model, the priors – not the floating-point trajectory of the sampler.
develop/dev_config/index.html (written by
develop/dev_config/make_report.py) is the illustrated version of this
walkthrough: it draws the spectrum the converter writes, the model at its
starting values split into continuum, iron, narrow and broad, the prior boxes,
and the parity figures.
A small example to copy from#
develop/dev_config/mock_two_line.toml fits the two-line mock spectrum that
ships with GalSpec – a linear continuum plus two Gaussians, one tied velocity,
token priors:
python -m galspec.pipeline develop/dev_config/mock_two_line.toml --dry-run
python -m galspec.pipeline develop/dev_config/mock_two_line.toml
The fit recovers the line centres to a fraction of a pixel and the two very
different widths of the mock (about 1300 and 160 km/s), with residuals consistent
with the 1% noise. develop/dev_config/mock_sample.toml is the same model on
six spectra of different brightness:
python develop/dev_config/make_mock_sample.py writes them, then
--workers 4 fits them in about 45 s and the report shows both widths
recovered in every source while the amplitudes follow each source’s
peak_flux.
These two live in develop/ because they are tests of the notation, not
science; example/ holds the two examples a reader should start from
(fit_iras07546_jax.py in Python and fit_iras07546_config.toml in TOML).
Limitations#
One FITS file per source; no row-slicing of a multispec file.
No error synthesis, no sky-line masking and no flux-unit conversion: prepare the file you want fitted. The two corrections the pipeline does apply, the redshift and the Galactic extinction, are read from the header and recorded.
The model is a sum of components; a product (e.g. a convolution wrapper) is not expressible in the file yet.
A prior cannot be nested inside another prior (
spikeandslabuses its default slab).dynamic.py’sDynamicNestedSampleris not covered by the shared compilation path;JNesty_FitusesNestedSampler.