Source code for brutus.analysis.populations

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

"""
Isochrone-based stellar population analysis with mixture-before-marginalization.

This module provides functions for Bayesian inference of coeval stellar population
parameters using isochrone fitting. The implementation uses the mathematically
correct approach of applying mixture models before marginalization over stellar
parameters (mass, secondary mass fraction).

The key innovation is the mixture-before-marginalization approach, which properly
accounts for field contamination by applying the mixture model at each grid point
before integrating over stellar parameters. This differs from traditional approaches
that marginalize first and mix later, which can produce biased results.

Functions
---------
isochrone_population_loglike : Main likelihood function
    Compute log-likelihood for coeval stellar population
generate_isochrone_population_grid : Grid generation
    Generate (mass, SMF) grid for population modeling
compute_isochrone_cluster_loglike : Cluster likelihood
    Compute membership likelihood for each grid point
compute_isochrone_outlier_loglike : Outlier likelihood
    Compute field contamination likelihood
apply_isochrone_mixture_model : Mixture model
    Apply mixture before marginalization
marginalize_isochrone_grid : Marginalization
    Integrate over stellar parameters with geometric jacobians

See Also
--------
brutus.core.populations.StellarPop : Stellar population synthesis
brutus.utils.photometry : Photometric likelihood functions
brutus.priors : Prior probability distributions

Notes
-----
The workflow follows these steps:

1. Generate isochrone grid over (mass, SMF) parameter space
2. Compute cluster likelihood for each (grid_point, object) pair
3. Compute outlier likelihood for each (grid_point, object) pair
4. Apply mixture model: P(data|mass,SMF) = w_c * P_c + w_o * P_o
5. Marginalize over (mass, SMF) with proper geometric jacobians
6. Sum log-likelihoods over all objects

This approach is designed for use with external MCMC or optimization codes
(e.g., emcee, dynesty, scipy.optimize) that vary the population parameters
[Fe/H], log(age), A_V, R_V, distance.

Examples
--------
Basic usage with emcee:

>>> from brutus.core.populations import StellarPop, Isochrone
>>> from brutus.analysis.populations import isochrone_population_loglike
>>>
>>> # Initialize stellar population model
>>> iso = Isochrone()
>>> pop = StellarPop(isochrone=iso)
>>>
>>> # Define log-likelihood function for MCMC
>>> def lnprob(theta):
...     return isochrone_population_loglike(
...         theta, pop, obs_flux, obs_err,
...         parallax=parallax, parallax_err=parallax_err
...     )
>>>
>>> # Run MCMC
>>> import emcee
>>> sampler = emcee.EnsembleSampler(nwalkers, ndim, lnprob)
>>> sampler.run_mcmc(initial_pos, nsteps)
"""

from __future__ import division, print_function

import warnings

import numpy as np
from scipy.special import logsumexp

# Import photometry utilities
from ..utils.photometry import (
    chisquare_outlier_loglike,
    phot_loglike,
    uniform_outlier_loglike,
)

__all__ = [
    "isochrone_population_loglike",
    "generate_isochrone_population_grid",
    "compute_isochrone_cluster_loglike",
    "compute_isochrone_outlier_loglike",
    "apply_isochrone_mixture_model",
    "marginalize_isochrone_grid",
]


[docs] def generate_isochrone_population_grid( stellarpop, feh, loga, av, rv, dist, smf_grid=None, eep_grid=None, mini_bound=0.08, eep_binary_max=480.0, corr_params=None, ): r""" Generate isochrone population grid over (mass, SMF) parameter space. Parameters ---------- stellarpop : StellarPop object StellarPop model from core.populations module with get_seds method feh, loga, av, rv, dist : float Stellar population parameters (metallicity, log age, extinction, distance) smf_grid : array-like, optional Secondary mass fraction grid. Default is 21 uniform points from 0.0 to 1.0 eep_grid : array-like, optional EEP grid for isochrone evaluation. Default is 1000 points from 202 to 808 mini_bound : float, optional Minimum initial mass for evaluation. Default 0.08 solar masses eep_binary_max : float, optional Maximum EEP for binary modeling. Default 480.0 corr_params : array-like, optional Empirical correction parameters [dtdm, drdm, msto_smooth, feh_scale] Returns ------- grid : dict Dictionary containing: - 'photometry': array, shape (N_total_points, N_filters) - model photometry - 'masses': array, shape (N_total_points,) - stellar masses - 'smf_values': array, shape (N_total_points,) - SMF values for each point - 'mass_jacobians': array, shape (N_total_points,) - mass grid spacing - 'smf_jacobians': array, shape (N_total_points,) - SMF grid spacing - 'grid_info': dict with SMF grid structure information See Also -------- compute_isochrone_cluster_loglike : Use this grid for likelihood computation StellarPop.get_seds : Underlying SED generation Notes ----- The grid is constructed by: 1. Looping over SMF values (binary mass ratios, passed to ``StellarPop.get_seds`` as ``binary_fraction``) 2. For each SMF, computing isochrone along EEP dimension 3. Extracting masses from the isochrone 4. Computing geometric jacobians (grid spacings) for proper integration 5. Filtering invalid models (NaN photometry from impossible binaries) The jacobians are critical for proper marginalization - they represent the geometric factors :math:`dm` and :math:`d({\\rm SMF})` in the integral: .. math:: P({\\rm data}) = \\int \\int P({\\rm data}|m, {\\rm SMF}) \\, dm \\, d({\\rm SMF}) Binary models are only computed for EEP ≤ eep_binary_max (typically main sequence) to avoid unphysical binary configurations. Models above ``eep_binary_max`` are independent of SMF, so they are stored once (from the first SMF slice) carrying the **full** SMF measure — the sum of all SMF grid spacings — so that the SMF marginalization weights them consistently with main-sequence models that accumulate over every slice. Their recorded ``smf_values`` entry is the slice they were generated from and is not meaningful for these SMF-independent models. """ # Set default grids if smf_grid is None: smf_grid = np.linspace(0.0, 1.0, 21) if eep_grid is None: eep_grid = np.linspace(202.0, 808.0, 1000) smf_grid = np.asarray(smf_grid) eep_grid = np.asarray(eep_grid) # Compute SMF jacobians (grid spacing) if len(smf_grid) > 1: smf_jacobians = np.gradient(smf_grid) else: smf_jacobians = np.array([1.0]) # Storage for combined grid all_photometry = [] all_masses = [] all_smf_values = [] all_mass_jacobians = [] all_smf_jacobians = [] # Track whether the SMF-independent post-MS block has been stored yet. # Models with eep > eep_binary_max never have a binary companion, so their # SEDs are identical across all SMF slices; they are stored once, carrying # the *full* SMF measure (see below). post_ms_stored = False total_smf_measure = np.sum(smf_jacobians) # Evaluate every SMF slice. StellarPop.get_seds_smf_grid computes the # SMF-independent primary isochrone predictions/SEDs once and reuses # them across slices (only the per-SMF secondary components are # recomputed, ~2x faster with the default 21-point SMF grid); duck-typed # population objects without that method fall back to one get_seds call # per slice. Failed slices are recorded as None and skipped below. if hasattr(stellarpop, "get_seds_smf_grid"): try: slice_results = stellarpop.get_seds_smf_grid( smf_grid, feh=feh, loga=loga, av=av, rv=rv, eep=eep_grid, dist=dist, mini_bound=mini_bound, eep_binary_max=eep_binary_max, corr_params=corr_params, ) except Exception as e: warnings.warn(f"Failed to generate isochrones for SMF grid: {e}") slice_results = [None] * len(smf_grid) else: slice_results = [] for smf in smf_grid: try: slice_results.append( stellarpop.get_seds( feh=feh, loga=loga, av=av, rv=rv, eep=eep_grid, binary_fraction=smf, dist=dist, mini_bound=mini_bound, eep_binary_max=eep_binary_max, corr_params=corr_params, ) ) except Exception as e: warnings.warn(f"Failed to generate isochrone for SMF={smf}: {e}") slice_results.append(None) # Loop over SMF grid. `smf` is the secondary mass fraction, i.e. # StellarPop's `binary_fraction` (mass-ratio) argument. for i, (smf, result) in enumerate(zip(smf_grid, slice_results)): if result is None: continue sed, params1, params2 = result # Extract mass grid and compute jacobians masses = params1["mini"] if len(masses) > 1: mass_jacobians = np.gradient(masses) else: mass_jacobians = np.array([1.0]) # Create masks for valid models. with warnings.catch_warnings(): warnings.simplefilter("ignore") base_valid = mass_jacobians > 0.0 # Per-slice (binary-capable) block: eep <= eep_binary_max. # Don't check for finite SED - let likelihood handle NaNs. slice_mask = base_valid & (eep_grid <= eep_binary_max) # SMF-independent post-MS block (no binary companion possible). post_ms_mask = base_valid & (eep_grid > eep_binary_max) blocks = [(slice_mask, smf_jacobians[i])] if not post_ms_stored and np.any(post_ms_mask): # Store the post-MS models once with the total SMF measure. # P(data | m, smf) is constant in SMF for these models, so # integrating over the SMF axis contributes the full measure # (sum of all SMF grid spacings), not a single slice's spacing. # Storing one slice's spacing instead would underweight post-MS # (e.g. red-giant) models by ~len(smf_grid) relative to # main-sequence models, which accumulate over every slice. blocks.append((post_ms_mask, total_smf_measure)) post_ms_stored = True for block_mask, smf_jac in blocks: valid_indices = np.where(block_mask)[0] if len(valid_indices) == 0: continue # Store valid models sed_valid = sed[valid_indices] masses_valid = masses[valid_indices] mass_jacobians_valid = mass_jacobians[valid_indices] # Convert magnitudes to fluxes photometry_valid = 10 ** (-0.4 * sed_valid) # Store in combined arrays all_photometry.append(photometry_valid) all_masses.append(masses_valid) all_smf_values.append(np.full(len(masses_valid), smf)) all_mass_jacobians.append(mass_jacobians_valid) all_smf_jacobians.append(np.full(len(masses_valid), smf_jac)) # Combine all arrays if len(all_photometry) == 0: raise ValueError("No valid isochrone models generated") combined_photometry = np.vstack(all_photometry) combined_masses = np.concatenate(all_masses) combined_smf_values = np.concatenate(all_smf_values) combined_mass_jacobians = np.concatenate(all_mass_jacobians) combined_smf_jacobians = np.concatenate(all_smf_jacobians) return { "photometry": combined_photometry, "masses": combined_masses, "smf_values": combined_smf_values, "mass_jacobians": combined_mass_jacobians, "smf_jacobians": combined_smf_jacobians, "grid_info": { "smf_grid": smf_grid, "eep_grid": eep_grid, "n_total_points": len(combined_masses), }, }
[docs] def compute_isochrone_cluster_loglike( obs_flux, obs_err, isochrone_grid, parallax=None, parallax_err=None, distance=None, dim_prior=True, mask=None, ): r""" Compute cluster membership likelihood using existing photometry infrastructure. Parameters ---------- obs_flux : array-like, shape (N_objects, N_filters) Observed flux densities obs_err : array-like, shape (N_objects, N_filters) Flux errors isochrone_grid : dict Isochrone grid from generate_isochrone_population_grid() parallax : array-like, shape (N_objects,), optional Parallax measurements (mas) parallax_err : array-like, shape (N_objects,), optional Parallax errors (mas) distance : float, optional Population distance (pc). Required if parallax provided dim_prior : bool, optional Whether to use chi-square (True) or normal (False) likelihood mask : array-like, shape (N_objects, N_filters), optional Data mask (1=use, 0=skip) Returns ------- lnl_cluster : array-like, shape (N_grid_points, N_objects) Cluster membership log-likelihood for each grid point and object. Invalid models (NaN photometry) are assigned NaN likelihood. See Also -------- brutus.utils.photometry.phot_loglike : Photometric likelihood function compute_isochrone_outlier_loglike : Complementary outlier likelihood generate_isochrone_population_grid : Creates the isochrone_grid input Notes ----- The likelihood includes both photometric and parallax components: .. math:: \\ln L_{\\rm cluster} = \\ln L_{\\rm phot} + \\ln L_{\\rm parallax} where the photometric likelihood uses either chi-square (dim_prior=True) or Gaussian (dim_prior=False) formulation. Invalid models with NaN photometry are preserved as NaN in the output to be properly handled during marginalization (they contribute zero probability via logsumexp). """ obs_flux = np.asarray(obs_flux) obs_err = np.asarray(obs_err) n_objects, n_filters = obs_flux.shape model_photometry = isochrone_grid["photometry"] # shape (N_grid_points, N_filters) # Check for invalid models (NaN photometry from impossible binary configs) model_valid_mask = np.all( np.isfinite(model_photometry), axis=1 ) # shape (N_grid_points,) # Replace NaN models with finite values for phot_loglike (will set to NaN after) model_photometry_clean = np.where( np.isfinite(model_photometry), model_photometry, 0.0, # Temporary replacement, will be masked out ) # Build the effective data mask: combine any user mask with a finiteness/ # positivity check so that a stray NaN flux or zero error in one band # excludes just that band instead of poisoning the object's likelihood at # every grid point (which would drive the total log-likelihood to -inf # for every theta). The same mask is shared with the outlier component # by the caller so both mixture components see identical data. with np.errstate(invalid="ignore"): data_valid = np.isfinite(obs_flux) & np.isfinite(obs_err) & (obs_err > 0) if mask is None: mask = data_valid.astype(float) else: mask = np.asarray(mask) * data_valid # Parallax contribution. Under the chi-square dimensionality prior the # parallax enters the *chi-square* (with one extra degree of freedom per # measured parallax), matching the BruteForce convention; mixing a # Gaussian density into a chi-square log-pdf would combine incompatible # measures. Without the dimensionality prior, everything is a Gaussian # density and the parallax term is added as one. extra_chi2 = None extra_dims = None lnl_parallax = 0.0 if parallax is not None and parallax_err is not None and distance is not None: parallax = np.asarray(parallax) parallax_err = np.asarray(parallax_err) # Parallax prediction from distance parallax_pred = 1000.0 / distance # mas # Parallax mask with np.errstate(invalid="ignore"): parallax_mask = ( np.isfinite(parallax) & np.isfinite(parallax_err) & (parallax_err > 0) ) if np.any(parallax_mask): # Parallax chi-square contribution with np.errstate(invalid="ignore", divide="ignore"): chi2_parallax = np.where( parallax_mask, (parallax - parallax_pred) ** 2 / np.where(parallax_mask, parallax_err, 1.0) ** 2, 0.0, ) if dim_prior: extra_chi2 = chi2_parallax extra_dims = parallax_mask.astype(int) else: lnl_parallax = np.where( parallax_mask, -0.5 * ( chi2_parallax + np.log( 2 * np.pi * np.where(parallax_mask, parallax_err, 1.0) ** 2 ) ), 0.0, )[None, :] # Compute photometric likelihood using existing infrastructure. Passing # the shared 2-D model grid triggers phot_loglike's matrix-multiplication # fast path (no (Nobj, Ngrid, Nfilt) temporaries). lnl_phot = phot_loglike( obs_flux, obs_err, model_photometry_clean, mask=mask, dim_prior=dim_prior, extra_chi2=extra_chi2, extra_dims=extra_dims, ) # shape (N_objects, N_grid_points) # Transpose to get correct orientation for masking lnl_phot = lnl_phot.T # Now shape (N_grid_points, N_objects) # For invalid models, set likelihood to NaN (will be handled in marginalization) lnl_phot[~model_valid_mask, :] = np.nan # Combine photometric and parallax likelihoods lnl_cluster = lnl_phot + lnl_parallax # shape (N_grid_points, N_objects) # Already in standard grid-first ordering return lnl_cluster # shape (N_grid_points, N_objects)
[docs] def compute_isochrone_outlier_loglike( obs_flux, obs_err, isochrone_grid=None, parallax=None, parallax_err=None, dim_prior=True, outlier_model_func=None, mask=None, **outlier_kwargs, ): """ Compute outlier likelihood with stellar-parameter-aware interface. Parameters ---------- obs_flux : array-like, shape (N_objects, N_filters) Observed flux densities obs_err : array-like, shape (N_objects, N_filters) Flux errors isochrone_grid : dict, optional Isochrone grid containing stellar parameters for potential dependence parallax : array-like, shape (N_objects,), optional Parallax measurements (mas) parallax_err : array-like, shape (N_objects,), optional Parallax errors (mas) dim_prior : bool, optional Use chi-square (True) or uniform (False) outlier model outlier_model_func : callable, optional Custom outlier model function. Must accept a ``mask`` keyword (the same band mask seen by the cluster likelihood) in addition to ``stellar_params``, ``parallax``, and ``parallax_err``. mask : array-like, shape (N_objects, N_filters), optional Data mask (1=use, 0=skip). Should be the same mask used for the cluster likelihood so both mixture components describe the same data. **outlier_kwargs : dict Additional arguments for outlier model Returns ------- lnl_outlier : array-like, shape (N_grid_points, N_objects) Outlier likelihood for each grid point and object """ obs_flux = np.asarray(obs_flux) obs_err = np.asarray(obs_err) n_objects = obs_flux.shape[0] # Extract stellar parameters for potential use stellar_params = None if isochrone_grid is not None: stellar_params = { "masses": isochrone_grid["masses"], "smf_values": isochrone_grid["smf_values"], } # Compute outlier likelihood if outlier_model_func is not None: # Custom outlier model lnl_outlier = outlier_model_func( obs_flux, obs_err, stellar_params=stellar_params, parallax=parallax, parallax_err=parallax_err, mask=mask, **outlier_kwargs, ) elif dim_prior: # Default chi-square outlier model lnl_outlier = chisquare_outlier_loglike( obs_flux, obs_err, stellar_params=stellar_params, parallax=parallax, parallax_err=parallax_err, mask=mask, **outlier_kwargs, ) else: # Default uniform outlier model lnl_outlier = uniform_outlier_loglike( obs_flux, obs_err, stellar_params=stellar_params, parallax=parallax, parallax_err=parallax_err, mask=mask, **outlier_kwargs, ) # Handle broadcasting to grid shape lnl_outlier = np.asarray(lnl_outlier) if isochrone_grid is not None: n_grid_points = len(isochrone_grid["masses"]) if lnl_outlier.shape == (n_objects,): # Stellar-independent: broadcast over grid lnl_outlier = np.broadcast_to( lnl_outlier[None, :], (n_grid_points, n_objects) ) elif lnl_outlier.shape == (n_grid_points, n_objects): # Stellar-dependent: already correct shape pass else: raise ValueError( f"Outlier likelihood shape {lnl_outlier.shape} incompatible " f"with expected ({n_grid_points}, {n_objects}) or ({n_objects},)" ) else: # No grid provided - assume stellar-independent if lnl_outlier.ndim == 1: lnl_outlier = lnl_outlier[None, :] # shape (1, N_objects) return lnl_outlier
[docs] def apply_isochrone_mixture_model( lnl_cluster, lnl_outlier, cluster_prob, field_fraction ): r""" Apply mixture model at each grid point: mixture before marginalization. For each (grid_point, object) pair: P(data|mass,SMF) = P_cluster * P(data|cluster) + P_outlier * P(data|outlier) Parameters ---------- lnl_cluster : array-like, shape (N_grid_points, N_objects) Cluster membership likelihoods lnl_outlier : array-like, shape (N_grid_points, N_objects) Outlier model likelihoods cluster_prob : float Prior probability of cluster membership (external) field_fraction : float Fraction of cluster stars that are field contaminants (fitted parameter) Returns ------- lnl_mixture : array-like, shape (N_grid_points, N_objects) Mixed log-likelihoods for each grid point and object See Also -------- marginalize_isochrone_grid : Next step after mixture application compute_isochrone_cluster_loglike : Cluster likelihood component compute_isochrone_outlier_loglike : Outlier likelihood component Notes ----- The mixture model is applied as: .. math:: P({\\rm data}|m, {\\rm SMF}) = w_c \\cdot P_c + w_o \\cdot P_o where: - :math:`w_c = P_{\\rm cluster} \\cdot (1 - f_{\\rm field})` - :math:`w_o = 1 - w_c` - :math:`P_{\\rm cluster}` is the prior probability (cluster_prob) - :math:`f_{\\rm field}` is the field contamination fraction This is computed in log-space using logsumexp for numerical stability: .. math:: \\ln L_{\\rm mix} = \\ln(\\exp(\\ln L_c + \\ln w_c) + \\exp(\\ln L_o + \\ln w_o)) The key distinction from traditional approaches is that this mixture is applied **before** marginalization over stellar parameters, which is mathematically correct for contaminated populations. """ lnl_cluster = np.asarray(lnl_cluster) lnl_outlier = np.asarray(lnl_outlier) # Ensure compatible shapes if lnl_cluster.shape != lnl_outlier.shape: raise ValueError( f"Cluster and outlier likelihood shapes must match: " f"{lnl_cluster.shape} vs {lnl_outlier.shape}" ) # Compute mixture probabilities # P(cluster member & not field) = cluster_prob * (1 - field_fraction) # P(outlier OR field) = 1 - cluster_prob * (1 - field_fraction) ln_cluster_weight = np.log(cluster_prob * (1.0 - field_fraction)) ln_outlier_weight = np.log(1.0 - cluster_prob * (1.0 - field_fraction)) # Apply the mixture in log space with np.logaddexp: a single fused, # numerically stable ufunc for the two-term log-sum-exp. Compared to a # hand-rolled max/exp/log expression this avoids ~5 full # (N_grid_points, N_objects) temporaries (each 1.7 GB at Nobj=1e4 with # the default grid) and measures ~1.6x faster; it also returns -inf # (rather than NaN) when both terms are -inf. lnl_mixture = np.logaddexp( lnl_cluster + ln_cluster_weight, lnl_outlier + ln_outlier_weight ) return lnl_mixture
[docs] def marginalize_isochrone_grid(lnl_mixture, mass_jacobians, smf_jacobians): r""" Marginalize mixed likelihoods over (mass, SMF) grid with geometric jacobians. Performs: P(data|population_params) = ∫∫ P(data|mass,SMF) dm d(SMF) Parameters ---------- lnl_mixture : array-like, shape (N_grid_points, N_objects) Mixed likelihoods at each grid point mass_jacobians : array-like, shape (N_grid_points,) Mass grid spacing (geometric factors for integration) smf_jacobians : array-like, shape (N_grid_points,) SMF grid spacing (geometric factors for integration) Returns ------- lnl_marginalized : array-like, shape (N_objects,) Marginalized log-likelihoods for each object See Also -------- apply_isochrone_mixture_model : Previous step before marginalization generate_isochrone_population_grid : Provides the jacobians Notes ----- Performs the integration: .. math:: P({\\rm data}|\\theta) = \\int \\int P({\\rm data}|m, {\\rm SMF}, \\theta) \\, dm \\, d({\\rm SMF}) numerically using a grid-based approach: .. math:: \\ln P \\approx \\ln \\frac{\\sum_{i,j} \\exp(\\ln L_{i,j}) \\cdot \\Delta m_i \\cdot \\Delta({\\rm SMF})_j}{\\sum_{i,j} \\Delta m_i \\cdot \\Delta({\\rm SMF})_j} where: - :math:`\\ln L_{i,j}` is the mixed likelihood at grid point (i,j) - :math:`\\Delta m_i` is the mass grid spacing (mass_jacobians) - :math:`\\Delta({\\rm SMF})_j` is the SMF grid spacing (smf_jacobians) Invalid models (with NaN likelihood) are converted to -∞ before the logsumexp operation, so they contribute zero probability. The measure is normalized over the valid grid points (denominator above), making the flat measure a proper uniform prior p(m, SMF | θ): without this, the θ-dependent grid volume multiplies every mixture component — including the θ-independent outlier model — and biases both the population parameters and the field fraction. The jacobians represent geometric integration weights and are crucial for obtaining unbiased parameter estimates. """ lnl_mixture = np.asarray(lnl_mixture) mass_jacobians = np.asarray(mass_jacobians) smf_jacobians = np.asarray(smf_jacobians) # Compute total geometric jacobian geometric_jacobian = mass_jacobians * smf_jacobians # shape (N_grid_points,) ln_jacobian = np.log(geometric_jacobian) # shape (N_grid_points,) # Normalize the integration measure over the *valid* grid points, turning # the flat measure into a proper (uniform) prior p(m, SMF | theta). The # grid volume Z(theta) = sum of jacobians changes with the population # parameters (isochrone mass range, invalid-model region); without this # normalization every component of the mixture — including the # theta-independent outlier model — is multiplied by Z(theta), biasing # the inferred population parameters and the field fraction. valid_rows = np.any(np.isfinite(lnl_mixture), axis=1) & np.isfinite(ln_jacobian) if not np.any(valid_rows): n_objects = lnl_mixture.shape[1] return np.full(n_objects, -np.inf) ln_measure_norm = logsumexp(ln_jacobian[valid_rows]) # Add jacobian to likelihoods for proper integration lnl_with_jacobian = ( lnl_mixture + ln_jacobian[:, None] - ln_measure_norm ) # shape (N_grid_points, N_objects) # Convert NaN to -inf for logsumexp (invalid models contribute nothing to marginalization) lnl_with_jacobian = np.where( np.isfinite(lnl_with_jacobian), lnl_with_jacobian, -np.inf ) # Marginalize over grid using logsumexp lnl_marginalized = logsumexp(lnl_with_jacobian, axis=0) # shape (N_objects,) return lnl_marginalized
[docs] def isochrone_population_loglike( theta, stellarpop, obs_phot, obs_err, parallax=None, parallax_err=None, cluster_prob=0.95, dim_prior=True, outlier_model_func=None, smf_grid=None, eep_grid=None, mini_bound=0.08, eep_binary_max=480.0, corr_params=None, return_components=False, mask=None, **outlier_kwargs, ): r""" Compute log-likelihood for coeval stellar population using isochrone fitting. Uses the mathematically correct mixture-before-marginalization approach: 1. Generate isochrone grid over (mass, SMF) 2. Compute cluster and outlier likelihoods at each grid point 3. Apply mixture model at each grid point 4. Marginalize over (mass, SMF) with proper geometric factors 5. Sum over all objects Parameters ---------- theta : array-like, shape (6,) Population parameters: [feh, loga, av, rv, dist, field_frac] where field_frac is the field contamination fraction (0 to 1). stellarpop : StellarPop object StellarPop model from core.populations module with get_seds method obs_phot : array-like, shape (N_objects, N_filters) Observed flux densities in units of 10**(-0.4 * mag) obs_err : array-like, shape (N_objects, N_filters) Flux density errors in same units parallax : array-like, shape (N_objects,), optional Parallax measurements (mas) parallax_err : array-like, shape (N_objects,), optional Parallax errors (mas) cluster_prob : float, optional Prior probability of cluster membership. Default 0.95 dim_prior : bool, optional Use chi-square (True) or normal (False) likelihood. Default True outlier_model_func : callable, optional Custom outlier model function smf_grid : array-like, optional Secondary mass fraction grid for binary modeling eep_grid : array-like, optional EEP grid for isochrone evaluation mini_bound : float, optional Minimum initial mass for isochrone. Default 0.08 eep_binary_max : float, optional Maximum EEP for binary modeling. Default 480.0 corr_params : array-like, optional Empirical correction parameters [dtdm, drdm, msto_smooth, feh_scale] forwarded to the isochrone grid generation (and from there to ``StellarPop.get_seds``). Default None (model defaults). return_components : bool, optional Return intermediate results for debugging. Default False mask : array-like, shape (N_objects, N_filters), optional Data validity mask **outlier_kwargs : dict Additional arguments passed to outlier model Returns ------- lnl_total : float Total log-likelihood summed over all objects components : dict, optional Intermediate results (if return_components=True) containing: - 'lnl_total': same as primary return value - 'lnl_per_object': array of per-object likelihoods - 'isochrone_grid': the generated grid dictionary - 'lnl_cluster': cluster likelihood array - 'lnl_outlier': outlier likelihood array - 'lnl_mixture': mixed likelihood array See Also -------- generate_isochrone_population_grid : Step 1 - Grid generation compute_isochrone_cluster_loglike : Step 2 - Cluster likelihood compute_isochrone_outlier_loglike : Step 3 - Outlier likelihood apply_isochrone_mixture_model : Step 4 - Mixture model marginalize_isochrone_grid : Step 5 - Marginalization brutus.core.populations.StellarPop : Stellar population model Notes ----- This function implements the complete mixture-before-marginalization workflow: .. math:: \\ln L(\\theta) = \\sum_i \\ln \\left[ \\int \\int \\left( w_c P_c(d_i|m, s, \\theta) + w_o P_o(d_i) \\right) dm \\, ds \\right] where: - :math:`\\theta = [{\\rm Fe/H}, \\log {\\rm age}, A_V, R_V, d, f_{\\rm field}]` are population parameters - :math:`m` is stellar mass - :math:`s` is secondary mass fraction (SMF) - :math:`w_c, w_o` are mixture weights - :math:`P_c, P_o` are cluster and outlier likelihoods - :math:`d_i` is data for object i The function is designed for use with MCMC samplers like emcee or nested sampling codes like dynesty. It handles errors gracefully by returning -∞ for failed computations. Examples -------- Use with emcee for MCMC sampling: >>> def lnprob(theta): ... if not in_prior_bounds(theta): ... return -np.inf ... return isochrone_population_loglike(theta, pop, flux, err) >>> >>> sampler = emcee.EnsembleSampler(nwalkers, ndim, lnprob) >>> sampler.run_mcmc(p0, nsteps) Extract intermediate results for diagnostics: >>> lnl, components = isochrone_population_loglike( ... theta, pop, flux, err, return_components=True ... ) >>> print(f"Per-object likelihoods: {components['lnl_per_object']}") """ theta = np.asarray(theta) if len(theta) != 6: raise ValueError(f"Expected 6 population parameters, got {len(theta)}") feh, loga, av, rv, dist, field_fraction = theta # Check inputs obs_phot = np.asarray(obs_phot) obs_err = np.asarray(obs_err) if obs_phot.shape != obs_err.shape: raise ValueError("Photometry and error shapes must match") try: # 1. Generate isochrone population grid isochrone_grid = generate_isochrone_population_grid( stellarpop, feh, loga, av, rv, dist, smf_grid=smf_grid, eep_grid=eep_grid, mini_bound=mini_bound, eep_binary_max=eep_binary_max, corr_params=corr_params, ) # Effective data mask shared by BOTH mixture components, so the # cluster and outlier likelihoods always describe the same bands. with np.errstate(invalid="ignore"): data_valid = np.isfinite(obs_phot) & np.isfinite(obs_err) & (obs_err > 0) eff_mask = data_valid.astype(float) if mask is None else mask * data_valid # 2. Compute cluster likelihood lnl_cluster = compute_isochrone_cluster_loglike( obs_phot, obs_err, isochrone_grid, parallax=parallax, parallax_err=parallax_err, distance=dist, dim_prior=dim_prior, mask=eff_mask, ) # 3. Compute outlier likelihood lnl_outlier = compute_isochrone_outlier_loglike( obs_phot, obs_err, isochrone_grid, parallax=parallax, parallax_err=parallax_err, dim_prior=dim_prior, outlier_model_func=outlier_model_func, mask=eff_mask, **outlier_kwargs, ) # 4. Apply mixture model lnl_mixture = apply_isochrone_mixture_model( lnl_cluster, lnl_outlier, cluster_prob, field_fraction ) # 5. Marginalize over stellar parameters lnl_marginalized = marginalize_isochrone_grid( lnl_mixture, isochrone_grid["mass_jacobians"], isochrone_grid["smf_jacobians"], ) # 6. Sum over all objects lnl_total = np.sum(lnl_marginalized) if not np.isfinite(lnl_total): lnl_total = -np.inf except Exception as e: warnings.warn(f"Likelihood computation failed: {e}") lnl_total = -np.inf lnl_marginalized = None if return_components: components = { "lnl_total": lnl_total, "lnl_per_object": ( lnl_marginalized if lnl_marginalized is not None else np.full(obs_phot.shape[0], -np.inf) ), "isochrone_grid": isochrone_grid if "isochrone_grid" in locals() else None, "lnl_cluster": lnl_cluster if "lnl_cluster" in locals() else None, "lnl_outlier": lnl_outlier if "lnl_outlier" in locals() else None, "lnl_mixture": lnl_mixture if "lnl_mixture" in locals() else None, } return lnl_total, components return lnl_total