Source code for brutus.analysis.offsets

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Photometric offset analysis for systematic calibration corrections.

This module provides robust photometric offset computation for correcting
systematic differences between observed photometry and model predictions.
The implementation uses bootstrap resampling for uncertainty estimation
and supports optional Bayesian prior constraints.

Photometric offsets are multiplicative corrections applied to observed fluxes
to account for systematic calibration differences, photometric system
transformations, or model systematics. The offsets are computed by analyzing
model/data flux ratios across a sample of well-fit objects.

Classes
-------
PhotometricOffsetsConfig : Configuration container
    Encapsulates all configuration parameters with validation

Functions
---------
photometric_offsets : Compute offsets
    Main function for computing multiplicative photometric offsets
_vectorized_bootstrap_median : Bootstrap implementation
    Vectorized bootstrap for performance
_loo_log_weights : Leave-one-out importance weights
    Reweight full-posterior draws to the leave-one-out posterior
_generate_seds : Chunked model SED generation
    Bounds peak memory for large samples
_validate_inputs : Input validation
    Validate input arrays for consistency

See Also
--------
brutus.analysis.individual.BruteForce : Provides fitted parameters for offset computation
brutus.utils.photometry.phot_loglike : Likelihood convention used for reweighting
brutus.core.sed_utils.get_seds : Model SED generation

Notes
-----
The offset computation workflow:

1. **Generate model SEDs** for all fitted objects and posterior samples
2. **Scale by distance**: Apply inverse square law
3. **Compute flux ratios**: model_flux / observed_flux for each band
4. **Reweight samples**: For bands used in fitting, importance-reweight the
   full-posterior draws to the leave-one-out posterior :math:`P(M|D_{-i})`
   using :math:`w \\propto L_{-i}/L_{\\rm full}` to avoid circularity
5. **Bootstrap sampling**: Resample objects and models to estimate median
   offset and uncertainty
6. **Apply priors** (optional): Incorporate Bayesian prior constraints

The key innovation is reweighting step 4: the posterior draws provided as
input target the full-data posterior, so weighting each draw by the
likelihood ratio between the leave-one-out and full-data likelihoods yields
unbiased offset estimates for bands that were used in the original fitting.

Examples
--------
Basic offset computation from BruteForce results:

>>> from brutus.analysis.offsets import photometric_offsets
>>> from brutus.data import load_models
>>> from brutus.core import StarGrid
>>> from brutus.analysis import BruteForce
>>>
>>> # Fit photometry (assuming this has been done)
>>> # fitter = BruteForce(grid)
>>> # results = fitter.fit(phot, err, mask, ...)
>>>
>>> # Extract fitted parameters
>>> # models, idxs, avs, rvs, dists = extract_from_results(results)
>>>
>>> # Compute offsets
>>> offsets, errors, n_used = photometric_offsets(
...     phot, err, mask, models, idxs, avs, rvs, dists
... )
>>>
>>> # Apply corrections
>>> phot_corrected = phot * offsets[None, :]

Advanced usage with configuration:

>>> from brutus.analysis.offsets import PhotometricOffsetsConfig
>>>
>>> # Custom configuration
>>> config = PhotometricOffsetsConfig(
...     min_bands_used=5,
...     n_bootstrap=500,
...     uncertainty_method='bootstrap_std',
...     random_seed=42
... )
>>>
>>> offsets, errors, n_used = photometric_offsets(
...     phot, err, mask, models, idxs, avs, rvs, dists,
...     config=config
... )
"""

from typing import Optional, Tuple

import numpy as np
from scipy.special import xlogy

from ..core.sed_utils import get_seds

__all__ = ["photometric_offsets", "PhotometricOffsetsConfig"]

# Cap on (object, sample) rows per get_seds call. Bounds the transient
# fancy-index copy of `models` and get_seds' internal allocations to
# O(_SED_CHUNK_ROWS * nfilt) instead of O(nobj * nsamps * nfilt).
_SED_CHUNK_ROWS = 500_000


[docs] class PhotometricOffsetsConfig: """ Configuration class for photometric offsets computation. This class encapsulates all configuration parameters and provides sensible defaults with the ability to customize behavior. Parameters ---------- min_bands_used : int, optional Minimum number of bands, excluding the band being calibrated, required for objects where the current band was used in fitting. Default is 4 (i.e. at least 5 observed bands in total, matching the legacy ``> 3 + 1`` cut). min_bands_unused : int, optional Minimum number of observed bands required for objects where the current band was not used in fitting. Default is 3. (The legacy v0.8.3 pipeline effectively required 4; pass ``min_bands_unused=4`` to reproduce it.) n_bootstrap : int, optional Number of bootstrap realizations for uncertainty estimation. Default is 300. uncertainty_method : str, optional Method for uncertainty estimation. Options: - 'bootstrap_std': Standard deviation of bootstrap medians - 'bootstrap_iqr': Scaled interquartile range of bootstrap medians Default is 'bootstrap_iqr'. progress_interval : int, optional Print progress every N iterations. Set to 0 for no progress. Default is 10. use_vectorized_bootstrap : bool, optional Use vectorized bootstrap implementation for better performance. Default is True. random_seed : int, optional Random seed for reproducible results. Default is None. validate_inputs : bool, optional Perform input validation. Default is True. See Also -------- photometric_offsets : Main function using this configuration Notes ----- The configuration defaults are chosen to balance statistical robustness with computational efficiency: - min_bands_used=4: Ensures the leave-one-out reweighting retains at least 4 bands (positive degrees of freedom under the default dimensionality prior) - min_bands_unused=3: Minimum for meaningful photometric constraints - n_bootstrap=300: Sufficient for stable uncertainty estimates - bootstrap_iqr: More robust to outliers than standard deviation Examples -------- >>> config = PhotometricOffsetsConfig( ... min_bands_used=5, ... n_bootstrap=500, ... random_seed=42 ... ) >>> offsets, errors, n_used = photometric_offsets( ... phot, err, mask, models, idxs, avs, rvs, dists, ... config=config ... ) """
[docs] def __init__( self, min_bands_used: int = 4, min_bands_unused: int = 3, n_bootstrap: int = 300, uncertainty_method: str = "bootstrap_iqr", progress_interval: int = 10, use_vectorized_bootstrap: bool = True, random_seed: Optional[int] = None, validate_inputs: bool = True, ): self.min_bands_used = min_bands_used self.min_bands_unused = min_bands_unused self.n_bootstrap = n_bootstrap self.uncertainty_method = uncertainty_method self.progress_interval = progress_interval self.use_vectorized_bootstrap = use_vectorized_bootstrap self.random_seed = random_seed self.validate_inputs = validate_inputs # Validate configuration self._validate_config()
def _validate_config(self): """Validate configuration parameters.""" if self.min_bands_used < 1: raise ValueError("min_bands_used must be >= 1") if self.min_bands_unused < 1: raise ValueError("min_bands_unused must be >= 1") if self.n_bootstrap < 1: raise ValueError("n_bootstrap must be >= 1") if self.uncertainty_method not in [ "bootstrap_std", "bootstrap_iqr", ]: raise ValueError(f"Unknown uncertainty_method: {self.uncertainty_method}") if self.progress_interval < 0: raise ValueError("progress_interval must be >= 0")
def _validate_inputs( phot: np.ndarray, err: np.ndarray, mask: np.ndarray, models: np.ndarray, idxs: np.ndarray, reds: np.ndarray, dreds: np.ndarray, dists: np.ndarray, ) -> None: """Validate input arrays for photometric_offsets. Value checks apply only to observed (mask > 0) entries: the canonical BruteForce input format uses NaN flux and zero/NaN error as placeholders in unobserved bands, and those entries never enter the computation. """ # Check basic types arrays = [phot, err, mask, models, idxs, reds, dreds, dists] names = ["phot", "err", "mask", "models", "idxs", "reds", "dreds", "dists"] for arr, name in zip(arrays, names): if not isinstance(arr, np.ndarray): raise TypeError(f"{name} must be numpy array, got {type(arr)}") # Check shapes nobj, nfilt = phot.shape nsamps = idxs.shape[1] if err.shape != (nobj, nfilt): raise ValueError(f"err shape {err.shape} != phot shape {phot.shape}") if mask.shape != (nobj, nfilt): raise ValueError(f"mask shape {mask.shape} != phot shape {phot.shape}") if idxs.shape != (nobj, nsamps): raise ValueError(f"idxs shape {idxs.shape} != expected ({nobj}, {nsamps})") if reds.shape != (nobj, nsamps): raise ValueError(f"reds shape {reds.shape} != expected ({nobj}, {nsamps})") if dreds.shape != (nobj, nsamps): raise ValueError(f"dreds shape {dreds.shape} != expected ({nobj}, {nsamps})") if dists.shape != (nobj, nsamps): raise ValueError(f"dists shape {dists.shape} != expected ({nobj}, {nsamps})") # Check for valid values in observed entries only (masked entries may # legitimately hold NaN/zero placeholders). observed = mask > 0 if not np.all(np.isfinite(phot[observed])): raise ValueError("phot contains non-finite values in unmasked bands") if not np.all(err[observed] > 0): raise ValueError("err must be positive in unmasked bands") with np.errstate(invalid="ignore"): if not np.all(np.isin(mask[np.isfinite(mask)], [0, 1])) or np.any( ~np.isfinite(mask) ): raise ValueError("mask must contain only 0s and 1s") if not np.all(dists > 0): raise ValueError("dists must be positive") def _generate_seds( models: np.ndarray, idxs: np.ndarray, reds: np.ndarray, dreds: np.ndarray, dists: np.ndarray, chunk_rows: int = _SED_CHUNK_ROWS, ) -> np.ndarray: """ Generate distance-scaled model SED fluxes in memory-bounded chunks. Chunking only bounds the size of the transient inputs/outputs of each get_seds call; the per-element operations are identical to a single call, so the result is bitwise identical. Parameters ---------- models : np.ndarray of shape (n_models, n_filters, n_coeffs) Magnitude polynomial coefficients. idxs, reds, dreds, dists : np.ndarray of shape (n_objects, n_samples) Model indices, A(V), R(V), and distances (kpc) per posterior draw. chunk_rows : int, optional Maximum number of (object, sample) rows per get_seds call. Returns ------- seds : np.ndarray of shape (n_objects, n_samples, n_filters) Model fluxes scaled to the observed distances. """ nobj, nsamps = idxs.shape nfilt = models.shape[1] seds = np.empty((nobj, nsamps, nfilt), dtype=float) block = max(1, chunk_rows // max(nsamps, 1)) for start in range(0, nobj, block): stop = min(start + block, nobj) flat = get_seds( models[idxs[start:stop].ravel()], av=reds[start:stop].ravel(), rv=dreds[start:stop].ravel(), return_flux=True, ) flat = flat / dists[start:stop].ravel()[:, None] ** 2 seds[start:stop] = flat.reshape(stop - start, nsamps, nfilt) return seds def _loo_log_weights( chi2_full: np.ndarray, band_chi2: np.ndarray, ndim: np.ndarray, dim_prior: bool, dof_reduction: int = 1, ) -> np.ndarray: r""" Importance log-weights reweighting full-posterior draws to the leave-one-out (LOO) posterior. The posterior draws supplied to `photometric_offsets` target the full-data posterior :math:`P(M|D)`, while the offset for a fitted band i must be estimated under :math:`P(M|D_{-i})`. The importance weight is therefore the likelihood ratio :math:`w = L_{-i}/L_{\rm full}` evaluated at each draw — weighting by :math:`L_{-i}` alone would target :math:`\propto L_{\rm full} \cdot L_{-i}` and only partially remove the circular dependence on band i. Terms constant across draws of the same object (Gaussian normalizations, log-determinants, gamma functions) cancel under the per-object weight normalization and are omitted. Parameters ---------- chi2_full : np.ndarray of shape (n_objects, n_samples) Chi-square summed over all observed bands (band i included). band_chi2 : np.ndarray of shape (n_objects, n_samples) Band i's chi-square contribution (0 where band i is masked). ndim : np.ndarray of shape (n_objects,) Number of observed bands per object (band i included). dim_prior : bool If True, likelihoods follow the chi-square dimensional log-PDF convention of `phot_loglike(dim_prior=True)`; if False, the Gaussian log-likelihood convention. dof_reduction : int, optional Degrees of freedom subtracted from the effective dimensionality (matching the phot_loglike convention used by this module). Default is 1. Returns ------- lnw : np.ndarray of shape (n_objects, n_samples) Unnormalized importance log-weights; -inf where the LOO target is undefined (non-positive degrees of freedom). """ chi2_loo = chi2_full - band_chi2 np.clip(chi2_loo, 0.0, None, out=chi2_loo) # guard cancellation error if dim_prior: # ln w = ln chi2pdf(chi2_loo; dof_loo) - ln chi2pdf(chi2_full; dof_full) # with per-object gamma/log-2 normalizations dropped. dof_full = ndim - dof_reduction dof_loo = dof_full - 1 # band i removed from the data a_full = 0.5 * dof_full[:, None] a_loo = 0.5 * dof_loo[:, None] with np.errstate(divide="ignore", invalid="ignore"): lnw = ( xlogy(a_loo - 1.0, chi2_loo) - 0.5 * chi2_loo - xlogy(a_full - 1.0, chi2_full) + 0.5 * chi2_full ) lnw[dof_loo <= 0] = -np.inf lnw[np.isnan(lnw)] = -np.inf else: # Gaussian likelihoods: ln L_{-i} - ln L_full = +0.5 * chi2_i (+ const). lnw = 0.5 * band_chi2 return lnw def _vectorized_bootstrap_median( ratios: np.ndarray, weights: np.ndarray, obj_weights: np.ndarray, n_bootstrap: int, rng: np.random.Generator, ) -> np.ndarray: """ Vectorized bootstrap implementation for better performance. Model draws use per-row inverted-CDF sampling: the weight CDFs are computed once and each replicate's draw reduces to a vectorized comparison count, equivalent to ``np.searchsorted(cdf_row, u, "left")`` per row. This samples the same distribution as per-object ``rng.choice(nsamps, p=w)`` calls (zero-weight samples are never selected) while removing the O(n_bootstrap * n_objects) Python loop; the random stream differs from the per-object-choice implementation. Parameters ---------- ratios : np.ndarray of shape (n_objects, n_samples) Model/data ratios for each object and sample weights : np.ndarray of shape (n_objects, n_samples) Model weights for each object and sample obj_weights : np.ndarray of shape (n_objects,) Object selection weights n_bootstrap : int Number of bootstrap realizations rng : np.random.Generator Random number generator Returns ------- bootstrap_medians : np.ndarray of shape (n_bootstrap,) Bootstrap median estimates """ n_objects, n_samples = ratios.shape bootstrap_medians = np.zeros(n_bootstrap) # Pre-generate object resampling indices obj_indices = rng.choice(n_objects, size=(n_bootstrap, n_objects), p=obj_weights) # Normalized per-row weight CDFs (computed once). Rows with zero total # weight fall back to sample 0, matching the legacy per-object behavior. cdf = np.cumsum(weights, axis=1) totals = cdf[:, -1].copy() valid_rows = totals > 0 cdf[valid_rows] /= totals[valid_rows, None] # last entry becomes exactly 1.0 for i in range(n_bootstrap): rows = obj_indices[i] # Inverted-CDF draw: index of the first CDF entry >= u. u = rng.random(n_objects) model_indices = np.sum(cdf[rows] < u[:, None], axis=1) model_indices[~valid_rows[rows]] = 0 np.clip(model_indices, 0, n_samples - 1, out=model_indices) bootstrap_medians[i] = np.median(ratios[rows, model_indices]) return bootstrap_medians
[docs] def photometric_offsets( phot: np.ndarray, err: np.ndarray, mask: np.ndarray, models: np.ndarray, idxs: np.ndarray, reds: np.ndarray, dreds: np.ndarray, dists: np.ndarray, sel: Optional[np.ndarray] = None, weights: Optional[np.ndarray] = None, mask_fit: Optional[np.ndarray] = None, old_offsets: Optional[np.ndarray] = None, dim_prior: bool = True, prior_mean: Optional[np.ndarray] = None, prior_std: Optional[np.ndarray] = None, verbose: bool = True, config: Optional[PhotometricOffsetsConfig] = None, rng: Optional[np.random.Generator] = None, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: r""" Compute multiplicative photometric offsets between data and models. This function computes photometric offsets that account for systematic differences between observed photometry and model predictions. The offsets are computed by comparing model/data flux ratios across a sample of objects, with proper uncertainty estimation and optional prior constraints. Parameters ---------- phot : np.ndarray of shape (n_objects, n_filters) Observed flux densities for all objects. Entries in masked bands may be non-finite placeholders; observed entries must be finite, and non-positive observed fluxes are excluded per band. err : np.ndarray of shape (n_objects, n_filters) Associated flux errors for all objects. Must be positive in observed bands; masked entries are ignored. mask : np.ndarray of shape (n_objects, n_filters) Binary mask (0/1, bool or numeric) indicating observed bands for each object. models : np.ndarray of shape (n_models, n_filters, n_coeffs) Magnitude polynomial coefficients for generating reddened photometry. idxs : np.ndarray of shape (n_objects, n_samples) Model indices fit to each object. reds : np.ndarray of shape (n_objects, n_samples) A(V) reddening values for each object and sample. dreds : np.ndarray of shape (n_objects, n_samples) R(V) reddening curve shape values for each object and sample. dists : np.ndarray of shape (n_objects, n_samples) Distance values (kpc) for each object and sample. sel : np.ndarray of shape (n_objects), optional Boolean selection of objects to use. Default uses all objects. weights : np.ndarray of shape (n_objects, n_samples), optional Sample weights for each object. Default uses uniform weights. mask_fit : np.ndarray of shape (n_filters), optional Boolean mask indicating which filters were used in fitting. Default assumes all filters were used. old_offsets : np.ndarray of shape (n_filters), optional Previous offsets to remove before computing new ones. Default is no previous offsets. dim_prior : bool, optional Whether the likelihoods entering the leave-one-out importance ratio follow the chi-square dimensionality-prior convention of `phot_loglike(dim_prior=True, dof_reduction=1)` (True) or the Gaussian log-likelihood convention (False). Default is True. prior_mean : np.ndarray of shape (n_filters), optional Gaussian prior means for offsets. Must be provided with prior_std. prior_std : np.ndarray of shape (n_filters), optional Gaussian prior standard deviations for offsets. verbose : bool, optional Whether to print progress information. Default is True. config : PhotometricOffsetsConfig, optional Configuration object with analysis parameters. Default uses standard configuration. rng : np.random.Generator, optional Random number generator for reproducible results. Default creates new generator. Returns ------- offsets : np.ndarray of shape (n_filters) Multiplicative photometric offsets (model/data ratios). offset_errors : np.ndarray of shape (n_filters) Uncertainties on the photometric offsets. n_objects_used : np.ndarray of shape (n_filters) Number of objects that actually informed each offset (objects with non-positive fluxes or fully degenerate weights are excluded). Examples -------- >>> import numpy as np >>> from brutus.analysis.offsets import photometric_offsets >>> >>> # Mock data for demonstration >>> n_obj, n_filt, n_samp = 100, 5, 50 >>> phot = np.random.uniform(0.1, 10, (n_obj, n_filt)) >>> err = 0.1 * phot >>> mask = np.random.choice([0, 1], (n_obj, n_filt), p=[0.1, 0.9]) >>> >>> # Mock fitted parameters >>> models = np.random.random((1000, n_filt, 3)) >>> idxs = np.random.randint(0, 1000, (n_obj, n_samp)) >>> reds = np.random.uniform(0, 2, (n_obj, n_samp)) >>> dreds = np.random.uniform(2.5, 4.5, (n_obj, n_samp)) >>> dists = np.random.uniform(0.1, 10, (n_obj, n_samp)) >>> >>> # Compute offsets >>> offsets, errors, n_used = photometric_offsets( ... phot, err, mask, models, idxs, reds, dreds, dists ... ) >>> print(f"Computed offsets: {offsets}") See Also -------- PhotometricOffsetsConfig : Configuration options brutus.core.sed_utils.get_seds : Model SED generation brutus.utils.photometry.phot_loglike : Likelihood computation brutus.analysis.individual.BruteForce : Source of fitted parameters Notes ----- The photometric offset for each band is computed as: 1. **Generate model SEDs** for all fitted objects and posterior samples 2. **Scale by distance**: :math:`F_{\\rm model} = F_0 / d^2` 3. **Compute flux ratios**: :math:`r = F_{\\rm model} / F_{\\rm obs}` 4. **Reweight samples**: For bands used in fitting, importance-reweight the full-posterior draws to :math:`P(M|D_{-i})` (band i excluded) with weights :math:`w \\propto L_{-i}/L_{\\rm full}` to avoid circularity 5. **Bootstrap**: Resample objects and models with weights, compute median 6. **Uncertainty**: From bootstrap distribution (IQR or std) 7. **Apply priors** (optional): Bayesian combination with prior The reweighting in step 4 is critical: if a band was used in the original fit, including it in offset computation would create a circular dependency. The input draws target the full posterior :math:`P(M|D)`, so each draw is weighted by the likelihood ratio :math:`L_{-i}/L_{\\rm full}` (band i's inverse likelihood, up to normalization); weighting by :math:`L_{-i}` alone would only partially remove the circularity. Since the chi-square is additive over bands, all per-band leave-one-out likelihoods derive from a single precomputed pass rather than one full likelihood evaluation per band. The offsets should be applied as: .. math:: F_{\\rm corrected} = F_{\\rm observed} \\times {\\rm offset} For iterative refinement, provide old_offsets from previous iteration. References ---------- The bootstrap methodology follows standard non-parametric uncertainty estimation. The likelihood-ratio importance reweighting ensures unbiased estimates for bands included in the original fit. """ # Handle configuration if config is None: config = PhotometricOffsetsConfig() # Set up random number generator if rng is None: rng = np.random.default_rng(config.random_seed) # Validate inputs if config.validate_inputs: _validate_inputs(phot, err, mask, models, idxs, reds, dreds, dists) # Initialize parameters nobj, nfilt = phot.shape nsamps = idxs.shape[1] if sel is None: sel = np.ones(nobj, dtype=bool) if weights is None: weights = np.ones((nobj, nsamps), dtype=float) if mask_fit is None: mask_fit = np.ones(nfilt, dtype=bool) if old_offsets is None: old_offsets = np.ones(nfilt) # Boolean view of the mask: validation accepts numeric 0/1 masks, but # the selection logic below requires bools (float & bool raises). mask_bool = np.asarray(mask) > 0 # Per-filter inputs must be 1-D of length nfilt; a scalar or wrong-length # array would otherwise surface much later as an opaque IndexError or # broadcasting error. mask_fit = np.atleast_1d(np.asarray(mask_fit)).astype(bool) if mask_fit.shape != (nfilt,): raise ValueError(f"mask_fit must have shape ({nfilt},), got {mask_fit.shape}") old_offsets = np.atleast_1d(np.asarray(old_offsets, dtype=float)) if old_offsets.shape != (nfilt,): raise ValueError( f"old_offsets must have shape ({nfilt},), got {old_offsets.shape}" ) # Priors are a pair: silently ignoring a lone prior_mean/prior_std would # discard information the caller clearly intended to supply. if (prior_mean is None) != (prior_std is None): raise ValueError("prior_mean and prior_std must be provided together") # Generate model SEDs (chunked to bound peak memory) if verbose and config.progress_interval > 0: print("Generating model SEDs...") seds = _generate_seds(models, idxs, reds, dreds, dists) # Initialize output arrays offsets = np.ones(nfilt) offset_errors = np.zeros(nfilt) n_objects_used = np.zeros(nfilt, dtype=int) ndim = mask_bool.sum(axis=1) sample_weight_sums = np.sum(weights, axis=1) # One-pass precompute for leave-one-out reweighting: chi-square is # additive over bands, so each band's leave-one-out chi-square is the # total minus that band's contribution (no per-band likelihood pass). # Masked bands contribute 0 (their flux/error placeholders never enter). # Only the 2-D running total is stored; each band's 2-D contribution is # recomputed on demand in the filter loop below, so no second # (nobj, nsamps, nfilt) array is kept alive alongside `seds` (which # would double peak memory for large calibration samples). if np.any(mask_fit): phot_adj = np.where(mask_bool, phot, 0.0) * old_offsets var_adj = np.where(mask_bool, err, 1.0) ** 2 * old_offsets**2 var_safe = np.where(var_adj > 0, var_adj, np.inf) def _band_chi2(b, rows=slice(None)): """Band b's chi-square contribution, shape (nrows, nsamps).""" resid = phot_adj[rows, b][:, None] - seds[rows, :, b] np.square(resid, out=resid) resid /= var_safe[rows, b][:, None] resid *= mask_bool[rows, b][:, None] return resid chi2_full = np.zeros((nobj, nsamps)) for b in range(nfilt): chi2_full += _band_chi2(b) # Process each filter for i in range(nfilt): if verbose and config.progress_interval > 0: print(f"Processing filter {i+1}/{nfilt}...") # Select objects with sufficient coverage min_bands = config.min_bands_used if mask_fit[i] else config.min_bands_unused if mask_fit[i]: # Bands available after excluding the current band must reach # min_bands (legacy behavior: >= min_bands + 1 bands in total). band_counts = ndim - mask_bool[:, i] else: # Don't exclude current band band_counts = ndim base_valid = ( mask_bool[:, i] & sel & (band_counts >= min_bands) & (sample_weight_sums > 0) ) # Ratios divide by the observed flux: zero/negative fluxes (common # near the detection limit) would yield inf or sign-flipped # "multiplicative" offsets, so they are excluded per band. with np.errstate(invalid="ignore"): positive_flux = phot[:, i] > 0 n_nonpositive = int(np.sum(base_valid & ~positive_flux)) if n_nonpositive > 0 and verbose: print( f" Warning: excluded {n_nonpositive} objects with " f"non-positive flux for filter {i+1}" ) valid_objects = base_valid & positive_flux obj_indices = np.where(valid_objects)[0] n = len(obj_indices) if n == 0: n_objects_used[i] = 0 if verbose: print(f" Warning: No valid objects for filter {i+1}") continue # Compute model/data ratios ratios = seds[obj_indices, :, i] / phot[obj_indices, None, i] # Compute weights (importance-reweight if band was used in fit) if mask_fit[i]: lnw = _loo_log_weights( chi2_full[obj_indices], _band_chi2(i, obj_indices), ndim[obj_indices], dim_prior, ) # Per-object softmax; rows that are entirely -inf collapse to 0. lnw_max = np.max(lnw, axis=1, keepdims=True) lnw_max = np.where(np.isfinite(lnw_max), lnw_max, 0.0) with np.errstate(over="ignore"): model_weights = np.exp(lnw - lnw_max) else: # Use uniform weights model_weights = np.ones((n, nsamps)) # Apply sample weights model_weights = model_weights * weights[obj_indices] # Objects whose weights collapsed (all zero or non-finite) carry no # information about this band; exclude them rather than silently # substituting posterior draw 0 with uniform object weight. weight_sums = np.sum(model_weights, axis=1) good = np.isfinite(weight_sums) & (weight_sums > 0) n_bad = n - int(np.sum(good)) if n_bad > 0: if verbose: print( f" Warning: excluded {n_bad} objects with degenerate " f"weights for filter {i+1}" ) ratios = ratios[good] model_weights = model_weights[good] weight_sums = weight_sums[good] n = len(ratios) n_objects_used[i] = n if n == 0: if verbose: print(f" Warning: No informative objects for filter {i+1}") continue # Normalize weights model_weights /= weight_sums[:, None] # Object weights for bootstrap (uniform over surviving objects) obj_weights = np.full(n, 1.0 / n) # Bootstrap uncertainty estimation if config.use_vectorized_bootstrap: bootstrap_medians = _vectorized_bootstrap_median( ratios, model_weights, obj_weights, config.n_bootstrap, rng ) else: # Original bootstrap implementation bootstrap_medians = [] for j in range(config.n_bootstrap): if ( verbose and config.progress_interval > 0 and j % config.progress_interval == 0 ): print(f" Bootstrap {j+1}/{config.n_bootstrap}") # Sample objects obj_sample = rng.choice(n, size=n, p=obj_weights) # Sample models model_sample = np.array( [ ( rng.choice(nsamps, p=model_weights[k]) if np.sum(model_weights[k]) > 0 else 0 ) for k in obj_sample ] ) # Compute median sample_ratios = ratios[obj_sample, model_sample] bootstrap_medians.append(np.median(sample_ratios)) bootstrap_medians = np.array(bootstrap_medians) # Compute offset and uncertainty offsets[i] = np.median(bootstrap_medians) if config.uncertainty_method == "bootstrap_std": offset_errors[i] = np.std(bootstrap_medians) elif config.uncertainty_method == "bootstrap_iqr": q25, q75 = np.percentile(bootstrap_medians, [25, 75]) offset_errors[i] = (q75 - q25) / 1.349 # Convert IQR to std equivalent # Apply priors if provided if prior_mean is not None and prior_std is not None: if len(prior_mean) != nfilt or len(prior_std) != nfilt: raise ValueError("Prior arrays must have length n_filters") prior_mean = np.asarray(prior_mean, dtype=float) prior_std = np.asarray(prior_std, dtype=float) # Bands with no informative objects have placeholder 1.0 +/- 0.0 # estimates that must not act as infinitely precise measurements; # with no data, the posterior is the prior. estimated = n_objects_used > 0 var_total = offset_errors**2 + prior_std**2 combined = (offsets * prior_std**2 + prior_mean * offset_errors**2) / var_total combined_err = offset_errors * prior_std / np.sqrt(var_total) offsets = np.where(estimated, combined, prior_mean) offset_errors = np.where(estimated, combined_err, prior_std) else: # Without a prior, a band with no informative objects carries no # measurement at all: report infinite (not zero) uncertainty so the # placeholder offset of 1 cannot masquerade as an infinitely precise # estimate downstream. offset_errors = np.where(n_objects_used > 0, offset_errors, np.inf) if verbose: print("Photometric offset computation complete.") return offsets, offset_errors, n_objects_used