"""
a module that stores useful prior functions to evaluate spin priors in terms of effective spin parameters
"""
import numpy as np
from scipy.special import spence as PL
from scipy.stats import gaussian_kde
from .conversions import chip_from_q_component_spins
"""
**********************************************************************************************************
These functions are lightly modified versions of Tom Callister's work here:
https://github.com/tcallister/effective-spin-priors/blob/main/priors.py
**************************************************************************************************************
"""
[docs]
def Di(z):
"""Wrapper for the scipy implementation of Spence's function.
Note that we adhere to the Mathematica convention as detailed in:
https://reference.wolfram.com/language/ref/PolyLog.html
Parameters
----------
z : float, complex, array-like
Input scalar or array-like value(s) at which to evaluate the dilogarithm.
Returns
-------
array-like
Array equivalent to PolyLog[2,z], as defined by Mathematica.
"""
return PL(1.0 - z + 0j)
[docs]
def chi_effective_prior_from_aligned_spins(chi_eff, q, a_max=1.0):
r"""Calculate the conditional prior density for effective spin :math:`p(\chi_\mathrm{eff}\mid q)`
corresponding to uniform and aligned component spin priors.
Notes
-----
This was modified from the original version to handle higher-dimensional arrays gracefully.
Parameters
----------
chi_eff : float, array-like
Effective spin value(s) at which to compute the prior density.
q : float, array-like
Mass ratio value(s) to condition on. Expected convention :math:`q<1`.
a_max : float, default=1.0
Maximum allowed dimensionless component spin magnitude.
Returns
-------
array-like
Prior density at given :math:`\chi_\mathrm{eff}` value(s).
"""
# Ensure that `chi_eff` is an array
chi_eff = np.atleast_1d(chi_eff)
# Set up various piecewise cases
caseA = (chi_eff > a_max * (1.0 - q) / (1.0 + q)) * (chi_eff <= a_max)
caseB = (chi_eff < -a_max * (1.0 - q) / (1.0 + q)) * (chi_eff >= -a_max)
caseC = (chi_eff >= -a_max * (1.0 - q) / (1.0 + q)) * (chi_eff <= a_max * (1.0 - q) / (1.0 + q))
pdfs = np.select(
[caseA, caseB, caseC],
[
(1.0 + q) ** 2.0 * (a_max - chi_eff) / (4.0 * q * a_max**2),
(1.0 + q) ** 2.0 * (a_max + chi_eff) / (4.0 * q * a_max**2),
(1.0 + q) / (2.0 * a_max),
],
)
return pdfs
[docs]
def chi_effective_prior_from_isotropic_spins(chi_eff, q, a_max=1.0):
r"""Calculate the conditional prior density for effective spin :math:`p(\chi_\mathrm{eff}\mid q)`
corresponding to uniform and isotropic component spin priors.
Parameters
----------
chi_eff : float, array-like
Effective spin value(s) at which to compute the prior density.
q : float, array-like
Mass ratio value(s) to condition on. Expected convention :math:`q<1`.
a_max : float, default=1.0
Maximum allowed dimensionless component spin magnitude.
Returns
-------
array-like
Prior density at given effective spin value(s).
"""
# Ensure that `chi_eff` is an array and take absolute value
chi_eff = np.abs(np.atleast_1d(chi_eff))
# Set up various piecewise cases
# pdfs = np.ones(chi_eff.size, dtype=complex) * (-1.0)
caseZ = chi_eff == 0
caseA = (chi_eff > 0) * (chi_eff < a_max * (1.0 - q) / (1.0 + q)) * (chi_eff < q * a_max / (1.0 + q))
caseB = (chi_eff < a_max * (1.0 - q) / (1.0 + q)) * (chi_eff > q * a_max / (1.0 + q))
caseC = (chi_eff > a_max * (1.0 - q) / (1.0 + q)) * (chi_eff < q * a_max / (1.0 + q))
caseD = (chi_eff > a_max * (1.0 - q) / (1.0 + q)) * (chi_eff < a_max / (1.0 + q)) * (chi_eff >= q * a_max / (1.0 + q))
caseE = (chi_eff > a_max * (1.0 - q) / (1.0 + q)) * (chi_eff > a_max / (1.0 + q)) * (chi_eff < a_max)
caseF = chi_eff >= a_max
with np.errstate(invalid="ignore"):
caseZ_pdfs = (1.0 + q) / (2.0 * a_max) * (2.0 - np.log(q))
caseA_pdfs = (
(1.0 + q)
/ (4.0 * q * a_max**2)
* (
q * a_max * (4.0 + 2.0 * np.log(a_max) - np.log(q**2 * a_max**2 - (1.0 + q) ** 2 * chi_eff**2))
- 2.0 * (1.0 + q) * chi_eff * np.arctanh((1.0 + q) * chi_eff / (q * a_max))
+ (1.0 + q) * chi_eff * (Di(-q * a_max / ((1.0 + q) * chi_eff)) - Di(q * a_max / ((1.0 + q) * chi_eff)))
)
)
caseB_pdfs = (
(1.0 + q)
/ (4.0 * q * a_max**2)
* (
4.0 * q * a_max
+ 2.0 * q * a_max * np.log(a_max)
- 2.0 * (1.0 + q) * chi_eff * np.arctanh(q * a_max / ((1.0 + q) * chi_eff))
- q * a_max * np.log((1.0 + q) ** 2 * chi_eff**2 - q**2 * a_max**2)
+ (1.0 + q) * chi_eff * (Di(-q * a_max / ((1.0 + q) * chi_eff)) - Di(q * a_max / ((1.0 + q) * chi_eff)))
)
)
caseC_pdfs = (
(1.0 + q)
/ (4.0 * q * a_max**2)
* (
2.0 * (1.0 + q) * (a_max - chi_eff)
- (1.0 + q) * chi_eff * np.log(a_max) ** 2.0
+ (a_max + (1.0 + q) * chi_eff * np.log((1.0 + q) * chi_eff)) * np.log(q * a_max / (a_max - (1.0 + q) * chi_eff))
- (1.0 + q) * chi_eff * np.log(a_max) * (2.0 + np.log(q) - np.log(a_max - (1.0 + q) * chi_eff))
+ q * a_max * np.log(a_max / (q * a_max - (1.0 + q) * chi_eff))
+ (1.0 + q) * chi_eff * np.log((a_max - (1.0 + q) * chi_eff) * (q * a_max - (1.0 + q) * chi_eff) / q)
+ (1.0 + q) * chi_eff * (Di(1.0 - a_max / ((1.0 + q) * chi_eff)) - Di(q * a_max / ((1.0 + q) * chi_eff)))
)
)
caseD_pdfs = (
(1.0 + q)
/ (4.0 * q * a_max**2)
* (
-chi_eff * np.log(a_max) ** 2
+ 2.0 * (1.0 + q) * (a_max - chi_eff)
+ q * a_max * np.log(a_max / ((1.0 + q) * chi_eff - q * a_max))
+ a_max * np.log(q * a_max / (a_max - (1.0 + q) * chi_eff))
- chi_eff * np.log(a_max) * (2.0 * (1.0 + q) - np.log((1.0 + q) * chi_eff) - q * np.log((1.0 + q) * chi_eff / a_max))
+ (1.0 + q) * chi_eff * np.log((-q * a_max + (1.0 + q) * chi_eff) * (a_max - (1.0 + q) * chi_eff) / q)
+ (1.0 + q) * chi_eff * np.log(a_max / ((1.0 + q) * chi_eff)) * np.log((a_max - (1.0 + q) * chi_eff) / q)
+ (1.0 + q) * chi_eff * (Di(1.0 - a_max / ((1.0 + q) * chi_eff)) - Di(q * a_max / ((1.0 + q) * chi_eff)))
)
)
caseE_pdfs = (
(1.0 + q)
/ (4.0 * q * a_max**2)
* (
2.0 * (1.0 + q) * (a_max - chi_eff)
- (1.0 + q) * chi_eff * np.log(a_max) ** 2
+ np.log(a_max) * (a_max - 2.0 * (1.0 + q) * chi_eff - (1.0 + q) * chi_eff * np.log(q / ((1.0 + q) * chi_eff - a_max)))
- a_max * np.log(((1.0 + q) * chi_eff - a_max) / q)
+ (1.0 + q) * chi_eff * np.log(((1.0 + q) * chi_eff - a_max) * ((1.0 + q) * chi_eff - q * a_max) / q)
+ (1.0 + q) * chi_eff * np.log((1.0 + q) * chi_eff) * np.log(q * a_max / ((1.0 + q) * chi_eff - a_max))
- q * a_max * np.log(((1.0 + q) * chi_eff - q * a_max) / a_max)
+ (1.0 + q) * chi_eff * (Di(1.0 - a_max / ((1.0 + q) * chi_eff)) - Di(q * a_max / ((1.0 + q) * chi_eff)))
)
)
caseF_pdfs = 0.0
# Deal with spins on the boundary between cases
fallback = np.zeros_like(chi_eff)
boundaries = ~np.any([caseZ, caseA, caseB, caseC, caseD, caseE, caseF], axis=0)
if np.any(boundaries):
fallback[boundaries] = 0.5 * (
chi_effective_prior_from_isotropic_spins(chi_eff[boundaries] + 1e-6, q, a_max=a_max)
+ chi_effective_prior_from_isotropic_spins(chi_eff[boundaries] - 1e-6, q, a_max=a_max)
)
pdfs = np.select(
[caseZ, caseA, caseB, caseC, caseD, caseE, caseF],
[caseZ_pdfs, caseA_pdfs, caseB_pdfs, caseC_pdfs, caseD_pdfs, caseE_pdfs, caseF_pdfs],
default=fallback,
)
return np.real(pdfs)
[docs]
def chi_p_prior_from_isotropic_spins(chi_p, q, a_max=1.0):
r"""Calculate the conditional prior density for effective precession :math:`p(\chi_\mathrm{p}\mid q)`
corresponding to uniform and isotropic component spin priors.
Parameters
----------
chi_p : float, array-like
Effective precession value(s) at which to compute the prior density.
q : float, array-like
Mass ratio value(s) to condition on. Expected convention :math:`q<1`.
a_max : float, default=1.0
Maximum allowed dimensionless component spin magnitude.
Returns
-------
array-like
Prior density at given effective spin value(s).
"""
# Ensure that `chi_p` is an array
chi_p = np.atleast_1d(chi_p)
# Set up various piecewise cases
caseA = chi_p < q * a_max * (3.0 + 4.0 * q) / (4.0 + 3.0 * q)
caseB = (chi_p >= q * a_max * (3.0 + 4.0 * q) / (4.0 + 3.0 * q)) * (chi_p < a_max)
with np.errstate(invalid="ignore"):
caseA_pdfs = (
(1.0 / (a_max**2 * q))
* ((4.0 + 3.0 * q) / (3.0 + 4.0 * q))
* (
np.arccos((4.0 + 3.0 * q) * chi_p / ((3.0 + 4.0 * q) * q * a_max))
* (a_max - np.sqrt(a_max**2 - chi_p**2) + chi_p * np.arccos(chi_p / a_max))
+ np.arccos(chi_p / a_max)
* (
a_max * q * (3.0 + 4.0 * q) / (4.0 + 3.0 * q)
- np.sqrt(a_max**2 * q**2 * ((3.0 + 4.0 * q) / (4.0 + 3.0 * q)) ** 2 - chi_p**2)
+ chi_p * np.arccos((4.0 + 3.0 * q) * chi_p / ((3.0 + 4.0 * q) * a_max * q))
)
)
)
caseB_pdfs = (1.0 / a_max) * np.arccos(chi_p / a_max)
pdfs = np.select([caseA, caseB], [caseA_pdfs, caseB_pdfs])
return pdfs
[docs]
def chi_p_prior_given_chi_eff_q(chi_p, chi_eff, q, a_max=1.0, ndraws=10000, bw_method="scott"):
r"""Calculate the prior density for effective precession conditioned on effective spin
and mass ratio, :math:`p(\chi_\mathrm{p}\mid \chi_\mathrm{eff}, q)`, corresponding to uniform
and isotropic component spin priors.
The prior density is computed numerically by:
1. drawing random component spins and tilts,
2. computing the corresponding effective precession values,
3. training a kernel density estimate (KDE),
4. building an interpolant using the KDE's density evaluated over a grid.
Notes
-----
This can be `np.vectorize`ed for convenience, but it can become _painfully_ slow, especially
when operating near boundaries where monte-carlo sampling of tilts results in frequent
unphysical spins (e.g., extremal effective spins).
Parameters
----------
chi_p : float, array-like
Effective precession value at which to compute the prior density.
chi_eff : float
Effective spin value to condition on.
q : float
Mass ratio value to condition on. Expected convention :math:`q<1`.
a_max : float, default=1.0
Maximum allowed dimensionless component spin magnitude.
ndraws : int, default=10000
Number of draws from the component spin priors used to train KDE.
**kwargs: dict, optional
Keyword arguments to pass to :func:`chi_p_prior_given_chi_eff_q`.
See Also
--------
joint_prior_from_isotropic_spins
Returns
-------
array-like
Prior density at given effective precession value.
"""
# Draw random spin magnitudes.
# Note that, given a fixed chi_eff, a1 can be no larger than (1+q)*chi_eff,
# and a2 can be no larger than (1+q)*chi_eff/q
a1 = np.random.random(ndraws) * a_max
a2 = np.random.random(ndraws) * a_max
# Draw random tilts for spin 2
cost2 = 2.0 * np.random.random(ndraws) - 1.0
# Finally, given our conditional value for chi_eff, we can solve for cost1
# Note, though, that we still must require that the implied value of cost1 be *physical*
cost1 = (chi_eff * (1.0 + q) - q * a2 * cost2) / a1
# While any cost1 values remain unphysical, redraw a1, a2, and cost2, and recompute
# Repeat as necessary
while np.any(cost1 < -1) or np.any(cost1 > 1):
to_replace = np.where((cost1 < -1) | (cost1 > 1))[0]
a1[to_replace] = np.random.random(to_replace.size) * a_max
a2[to_replace] = np.random.random(to_replace.size) * a_max
cost2[to_replace] = 2.0 * np.random.random(to_replace.size) - 1.0
cost1 = (chi_eff * (1.0 + q) - q * a2 * cost2) / a1
# Compute precessing spins and corresponding weights, build KDE
# See `Joint-ChiEff-ChiP-Prior.ipynb` for a discussion of these weights
chi_p_draws = chip_from_q_component_spins(q, a1, a2, cost1, cost2)
jacobian_weights = (1.0 + q) / a1
prior_kde = gaussian_kde(chi_p_draws, weights=jacobian_weights, bw_method=bw_method)
# Compute maximum chi_p
if (1.0 + q) * np.abs(chi_eff) / q < a_max:
max_chi_p = a_max
else:
max_chi_p = np.sqrt(a_max**2 - ((1.0 + q) * np.abs(chi_eff) - q) ** 2.0)
# Set up a grid slightly inside (0,max chi_p) and evaluate KDE
reference_grid = np.linspace(0.05 * max_chi_p, 0.95 * max_chi_p, 50)
reference_vals = prior_kde(reference_grid)
# Manually prepend/append zeros at the boundaries
reference_grid = np.concatenate([[0], reference_grid, [max_chi_p]])
reference_vals = np.concatenate([[0], reference_vals, [0]])
norm_constant = np.trapz(reference_vals, reference_grid)
# Interpolate!
p_chi_p = np.interp(chi_p, reference_grid, reference_vals / norm_constant)
return p_chi_p
[docs]
def joint_prior_from_isotropic_spins(chi_p, chi_eff, q, a_max=1.0, **kwargs):
r"""Calculate the joint prior density for effective spin and precession
conditioned on mass ratio, corresponding to uniform and isotropic component
spin priors,
.. math::
:math:`p(\chi_\mathrm{eff}, \chi_\mathrm{p}\mid q) = p(\chi_\mathrm{p} \mid \chi_\mathrm{eff}, q) p(\chi_\mathrm{eff} \mid q)`.
Parameters
----------
chi_p : float, array-like
Effective precession value(s) at which to compute the prior density.
chi_eff : float, array-like
Effective spin value(s) to condition on.
q : float, array-like
Mass ratio value(s) to condition on. Expected convention :math:`q<1`.
a_max : float, default=1.0
Maximum allowed dimensionless component spin magnitude.
**kwargs: dict, optional
Keyword arguments to pass to :func:`chi_p_prior_given_chi_eff_q`.
See Also
--------
chi_effective_prior_from_isotropic_spins, chi_p_prior_given_chi_eff_q
Returns
-------
array-like
Prior density at given effective precession value(s).
"""
# Convert to arrays for safety
chi_p = np.atleast_1d(chi_p)
chi_eff = np.atleast_1d(chi_eff)
# Compute marginal prior on chi_eff, conditional prior on chi_p, and multiply to get joint prior!
chi_p_prior_given_chi_eff_q_vectorized = np.vectorize(
chi_p_prior_given_chi_eff_q,
excluded=["a_max", "ndraws", "bw_method"],
)
p_chi_eff = chi_effective_prior_from_isotropic_spins(chi_eff, q, a_max=a_max)
p_chi_p_given_chi_eff = chi_p_prior_given_chi_eff_q_vectorized(chi_p, chi_eff, q, a_max=a_max, **kwargs)
joint_p_chi_p_chi_eff = p_chi_eff * p_chi_p_given_chi_eff
return joint_p_chi_p_chi_eff