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) 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. """ # 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 which models have been computed (for binary masking) identical_models_computed = False # Loop over SMF grid for i, smf in enumerate(smf_grid): # Generate isochrone for this SMF try: sed, params1, params2 = stellarpop.get_seds( feh=feh, loga=loga, av=av, rv=rv, eep=eep_grid, smf=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}") continue # 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 mask for valid models with warnings.catch_warnings(): warnings.simplefilter("ignore") if identical_models_computed: # Mask out repeated single-star models for SMF > 0 # Don't check for finite SED - let likelihood handle NaNs valid_mask = (mass_jacobians > 0.0) & (eep_grid <= eep_binary_max) else: # First time - only require positive mass jacobian # Don't check for finite SED - let likelihood handle NaNs valid_mask = mass_jacobians > 0.0 identical_models_computed = True valid_indices = np.where(valid_mask)[0] if len(valid_indices) > 0: # 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_jacobians[i])) # 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) n_grid_points = model_photometry.shape[0] # 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 ) # Reshape models for phot_loglike: (N_objects, N_grid_points, N_filters) model_photometry_reshaped = np.broadcast_to( model_photometry_clean[None, :, :], (n_objects, n_grid_points, n_filters) ) # Compute photometric likelihood using existing infrastructure lnl_phot = phot_loglike( obs_flux, obs_err, model_photometry_reshaped, mask=mask, dim_prior=dim_prior ) # 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 # Add parallax contribution if provided 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 parallax_mask = ( np.isfinite(parallax) & np.isfinite(parallax_err) & (parallax_err > 0) ) if np.any(parallax_mask): # Parallax chi-square contribution chi2_parallax = (parallax - parallax_pred) ** 2 / parallax_err**2 lnl_parallax = np.where( parallax_mask, -0.5 * (chi2_parallax + np.log(2 * np.pi * parallax_err**2)), 0.0, ) # Broadcast to grid shape (now lnl_phot is already transposed) lnl_parallax = lnl_parallax[None, :] # shape (1, N_objects) # 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, **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 **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, **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, **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, **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 mixture model using numerically stable log-sum-exp cluster_term = lnl_cluster + ln_cluster_weight outlier_term = lnl_outlier + ln_outlier_weight # For two terms, direct numpy is faster than scipy.special.logsumexp max_term = np.maximum(cluster_term, outlier_term) lnl_mixture = max_term + np.log( np.exp(cluster_term - max_term) + np.exp(outlier_term - max_term) ) 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 \\sum_{i,j} \\exp(\\ln L_{i,j}) \\cdot \\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 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,) # Add jacobian to likelihoods for proper integration lnl_with_jacobian = ( lnl_mixture + ln_jacobian[:, None] ) # 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, 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 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, ) # 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=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, **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