Outlier mixture likelihood¶
A few bad pixels (cosmic rays, detector artefacts, sky residuals) or a mis-calibrated band
can drag a Gaussian fit a long way, because a 20σ residual costs 200 in ln L. The outlier
mixture (Hogg, Bovy & Lang 2010, arXiv:1008.4686) lets each datum come either from the
ordinary noise model or, with probability f, from a Gaussian nsigma times broader. It
is Prospector's outlier model (prospect.likelihood.NoiseModel, branch f_outlier > 0),
reproduced to machine precision.
All outlier fractions default to 0; switch the mixture on explicitly.
Every f_outlier_* is 0 (off) unless your model samples it or fixes it to a non-zero
value. With no outlier parameter, or one fixed at 0, the likelihood is the ordinary
Gaussian, compiled to the same program as a model without the mixture.
The likelihood¶
Per unmasked datum i, with σ_eff,i the uncertainty after every noise term
(noise_floor, log_err_scale_*, log_jitter_*, log_f_calib_*, log_f_data_*, named
per observation like the outlier fractions), χ_i =
(y_i − μ_i)/σ_eff,i and log_det_i = ½ ln(2π σ_eff,i²):
ln p_good,i = −½ χ_i² − log_det_i
ln p_bad,i = −½ χ_i² / n² − log_det_i − ln n
ln L_i = logaddexp( ln(1 − f) + ln p_good,i , ln f + ln p_bad,i )
ln L = Σ_i ln L_i (masked data contribute 0)
This is Prospector's expression term by term: its var_bad = var n² gives
−½ ln(2π var n²) = −½ ln(2π var) − ln n, and its var is the noise model's diagonal
Sigma (σ² for the default NoiseModel, the kernel sum when kernels are attached), which
is CERIDWEN's σ_eff². tests/test_outlier_model.py checks equality with a NumPy
transcription of Prospector's code for every noise-term combination (rtol 1e-12) and with
values written by Prospector itself (tests/reference/prospector_outlier.npz).
One deliberate difference. Prospector's plain branch (f_outlier == 0,
NoiseModel.lnlikelihood, noise_model.py:90 at commit a78d153) computes
−½[ln(2π) χ² + Σ ln σ²]: the χ² is multiplied by ln 2π and n ln 2π is missing. Its
outlier branch is correct. CERIDWEN follows the outlier branch for f > 0 and, at f = 0,
uses its ordinary (correct) Gaussian. Prospector's value at exactly f = 0 is therefore
not a reference, and as f → 0 CERIDWEN tends to the correct Gaussian, not to
Prospector's number.
Using it with fitSED¶
The mixture is switched on per observation by name, like the other noise terms. Each observation gets its own fraction; nothing is shared between observations.
| parameter | observation | CERIDWEN default | Prospector template (reference only, TemplateLibrary["outlier_model"]) |
|---|---|---|---|
f_outlier_spec |
the Spectrum |
0 (off) | free, init 0.01, TopHat(1e-5, 0.5) |
nsigma_outlier_spec |
the Spectrum |
50 | fixed at 50 |
f_outlier_phot |
the Photometry |
0 (off) | fixed at 0; TopHat(0.0, 0.5) when freed |
nsigma_outlier_phot |
the Photometry |
50 | fixed at 50 |
f_outlier_lines |
the Lines |
0 (off) | no template (see below) |
nsigma_outlier_lines |
the Lines |
50 | — |
The per-observation names below have the same defaults: 0 (off) and 50.
Small photometric sets: keep f_outlier_phot at 0. On a clean mock with 8 NIRCam bands
and f_outlier_phot free (TopHat(0.0, 0.5)), the current SFR came out
0.592 [−0.344, 0.753] (median [16, 84 %]) against 0.770 [0.580, 0.965] for the Gaussian
fit: with few bands the fit can treat a rest-UV band as a possible outlier and the SFR
constraint loosens (A100 recovery test). Free it only when a bad band is
suspected and the photometry has enough bands to tell.
Several observations of one kind (two spectra, say "G140M" and "G395M"): each takes
its own f_outlier_spec_G140M, f_outlier_spec_G395M (and nsigma_outlier_spec_<name>),
<name> being the observation's name. The plain f_outlier_spec is accepted only when the
model has exactly one spectrum; with two it is an error, as is setting both the plain and the
per-observation name of one observation. An f_outlier_* / nsigma_outlier_* name that
matches no observation is an error (it would be sampled without entering the likelihood).
To opt in, sample a fraction by giving it a prior and a free_param_init (this example
switches the mixture on for the spectrum only; the photometry stays Gaussian):
from ceridwen.priors import TopHat
model = SedModel(csp, [phot, spec],
priors={**priors, "f_outlier_spec": TopHat(low=0.0, high=0.5)},
free_param_init={**init, "f_outlier_spec": jnp.array([0.01])},
transforms=transforms, zred=zred)
result = fitSED(model, output_dir="out")
The fitSED log names it: spec: DiagonalGaussianLikelihood, outlier mixture f =
f_outlier_spec (sampled), nsigma = 50 (fixed); every observation without it reads
outlier mixture off.
- Fixed values use the usual constant transform:
transforms={"f_outlier_phot": lambda th: jnp.array([0.05])}. It must be a constant (checked at setup); a fixed0.0leaves the mixture off, so the likelihood is the ordinary Gaussian, graph unchanged. nsigma_outlier_*defaults to 50 (Prospector's). Sample it with a prior whose lower bound is positive, or fix it with a transform. Setting it without the matchingfis an error.- Priors on
fmust be bounded inside [0, 1] (Uniform,TopHat,ClippedNormal). - With
run_sampler, build the noise model yourself:DiagonalNoiseModel(f_outlier="f_outlier_spec", nsigma_outlier=50.0)(a string is a theta key, a float a fixed value).
Line fluxes (Lines)¶
Prospector's Lines is an ordinary Observation (observation.py:495), so it carries a
noise model and lnprobfn sends it through the same NoiseModel.lnlike: the mixture works
for line fluxes there, it just has no template name. CERIDWEN names it f_outlier_lines.
With a handful of fluxes the data barely constrain f, so a fitted fraction mostly returns its
prior; the mixture is then a heavy-tailed likelihood that stops one mismeasured line
(blend, aperture, calibration) from dragging the fit, which is its main use here. A fixed
small value (for example 0.05) is often the sensible choice.
Upper limits¶
Data flagged as upper limits keep their one-sided penalty −½ max(μ − y, 0)²/σ² − log_det;
the mixture applies to the detections only (lnlike_diag_outlier_with_upper_limits).
Prospector has no one-sided kernel, so there is no reference for the limits; the detections
are Prospector's outlier branch exactly.
Emission-line marginalisation¶
Spectrum(marginalize_elines=True) marginalises the line fluxes jointly over the
marginalising spectrum, every Photometry and every Lines observation of the
model; that closed-form marginal assumes a Gaussian likelihood. The mixture is therefore
allowed only on observations outside that system — in practice a second spectrum that does
not marginalise its lines — and refused (NotImplementedError) inside it.
Prospector does it differently: with both on it fits the line amplitudes with the plain
uncertainties (sedmodel.py:324, spec_unc = None → obs["unc"]**2 at :686, ignoring even
its noise kernels), adds a Gaussian Occam penalty, and then applies the mixture to the
spectrum with the maximum-likelihood lines. That is not the marginal of the mixture over the
line fluxes, and is not reproduced.
The prior at f = 0 and its gradient¶
f is a fraction and the Gaussian is the mixture at f = 0, so the natural support is
[0, 1), and a lower bound of 0 keeps "no outliers" inside the model. ln L is concave in f
(a sum of logs of functions linear in f) and finite at 0, with the exact derivative
d ln L/df = Σ_i (p_bad,i − p_good,i)/L_i, which at f = 0 is Σ_i (p_bad,i/p_good,i − 1).
That is finite unless a datum lies beyond ~38σ (for nsigma = 50) (then +∞ in float64: the likelihood really
rises that steeply). CERIDWEN evaluates this derivative directly (a custom JVP,
ceridwen/likelihood/likelihood.py _mixture_jvp), so a sampled f with lower bound 0 has a
finite gradient at f = 0 (test_gradient_in_f_is_exact_and_finite_at_zero); computing it
through ln f would give 0·∞ = NaN. Prospector's lower bound of 1e-5 for
the spectrum is therefore not needed for numerical reasons; either bound works. The upper
bound 0.5 keeps the inlier component the majority, which is what makes "inlier" and
"outlier" identifiable (above 0.5 the two labels can swap).
Diagnostics¶
LikelihoodOutput with the mixture on: lnl_pointwise holds the per-datum mixture terms
ln L_i (they sum to lnl_total); chi and residuals are those of the inlier Gaussian,
(y − μ)/σ_eff. The posterior probability that a datum is an outlier,
p_i = exp(ln f + ln p_bad,i − ln L_i), is available off the sampling path:
(DiagonalGaussianLikelihood.outlier_probability, or the function
ceridwen.likelihood.outlier_probability). The sampled likelihood never computes it.
Not supported¶
- Correlated noise. The mixture is diagonal only, as Prospector's
(
assert Sigma.ndim == 1). On a spectrum with the GP likelihood (docs/gp_likelihood.md) the mixture is refused at setup. - Inside the emission-line marginalisation (above).
Non-finite data¶
Observations mask non-finite flux and non-finite or non-positive uncertainties at
construction and warn when those points were not already in your mask. The sampled
likelihood reads the data with the masked non-finite values replaced by finite
placeholders, so they reach neither the value nor the gradient (a NaN left in a masked
slot would otherwise give 0·NaN = NaN in the gradient, for the Gaussian likelihood as well).
obs.flux itself is kept as given.
Cost¶
The mixture replaces one −½χ² − log_det per datum by two Gaussians and a logaddexp.
Measured on one A100 (production spectrum of 3065 pixels + 8 bands, nebular on, fractions
sampled for spectrum and photometry, 240 interleaved calls): +0.6 % at the nested-sampling
batch width W = 100 (value and gradient, and value only), −0.1 % to +0.8 % at W = 1 and
500, and +1.9 % (+49 µs of 2.59 ms) for value and gradient at W = 4 (NUTS with four
chains), the one case above the 1 % target.
With it off (no outlier parameter, or f fixed at 0), the compiled likelihood is byte for
byte the one before the feature: the lowered StableHLO of jit(value_and_grad(lnprob))
is identical in all four scripts/bit_identity_check.py configurations.