Source code for pycbc.distributions.angular
# Copyright (C) 2016 Collin Capano
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 3 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
# Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""
This modules provides classes for evaluating angular distributions.
"""
import logging
from configparser import Error
import numpy
from pycbc import VARARGS_DELIM
from pycbc import boundaries
from pycbc.distributions import bounded
from pycbc.distributions import uniform
logger = logging.getLogger('pycbc.distributions.angular')
[docs]
class UniformAngle(uniform.Uniform):
"""A uniform distribution in which the dependent variable is between
`[0,2pi)`.
The domain of the distribution may optionally be made cyclic using the
`cyclic_domain` parameter.
Bounds may be provided to limit the range for which the pdf has support.
If provided, the parameter bounds are in radians.
Parameters
----------
cyclic_domain : {False, bool}
If True, cyclic bounds on [0, 2pi) are applied to all values when
evaluating the pdf. This is done prior to any additional bounds
specified for a parameter are applied. Default is False.
\**params :
The keyword arguments should provide the names of parameters and
(optionally) their corresponding bounds, as either
`boundaries.Bounds` instances or tuples. The bounds must be
in [0,2PI). These are converted to radians for storage. None may also
be passed; in that case, the domain bounds will be used.
Notes
------
For more information, see Uniform.
"""
name = 'uniform_angle'
_domainbounds = (0, 2*numpy.pi)
def __init__(self, cyclic_domain=False, **params):
# _domain is a bounds instance used to apply cyclic conditions; this is
# applied first, before any bounds specified in the initialization
# are used
self._domain = boundaries.Bounds(self._domainbounds[0],
self._domainbounds[1], cyclic=cyclic_domain)
for p,bnds in params.items():
if bnds is None:
bnds = self._domain
elif isinstance(bnds, boundaries.Bounds):
# convert to radians
bnds._min = bnds._min.__class__(bnds._min)
bnds._max = bnds._max.__class__(bnds._max)
else:
# create a Bounds instance from the given tuple
bnds = boundaries.Bounds(bnds[0], bnds[1])
# check that the bounds are in the domain
if bnds.min < self._domain.min or bnds.max > self._domain.max:
raise ValueError("bounds must be in [{x},{y}); "
"got [{a},{b})".format(x=self._domain.min,
y=self._domain.max, a=bnds.min,
b=bnds.max))
# update
params[p] = bnds
super(UniformAngle, self).__init__(**params)
@property
def domain(self):
"""Returns the domain of the distribution."""
return self._domain
[docs]
def apply_boundary_conditions(self, **kwargs):
"""Maps values to be in [0, 2pi) (the domain) first, before applying
any additional boundary conditions.
Parameters
----------
\**kwargs :
The keyword args should be the name of a parameter and value to
apply its boundary conditions to. The arguments need not include
all of the parameters in self.
Returns
-------
dict
A dictionary of the parameter names and the conditioned values.
"""
# map values to be within the domain
kwargs = dict([[p, self._domain.apply_conditions(val)]
for p,val in kwargs.items() if p in self._bounds])
# now apply additional conditions
return super(UniformAngle, self).apply_boundary_conditions(**kwargs)
[docs]
@classmethod
def from_config(cls, cp, section, variable_args):
"""Returns a distribution based on a configuration file.
The parameters for the distribution are retrieved from the section
titled "[`section`-`variable_args`]" in the config file. By default,
only the name of the distribution (`uniform_angle`) needs to be
specified. This will results in a uniform prior on `[0, 2pi)`. To
make the domain cyclic, add `cyclic_domain =`. To specify boundaries
that are not `[0, 2pi)`, add `(min|max)-var` arguments, where `var`
is the name of the variable.
For example, this will initialize a variable called `theta` with a
uniform distribution on `[0, 2pi)` without cyclic boundaries:
.. code-block:: ini
[{section}-theta]
name = uniform_angle
This will make the domain cyclic on `[0, 2pi)`:
.. code-block:: ini
[{section}-theta]
name = uniform_angle
cyclic_domain =
Parameters
----------
cp : pycbc.workflow.WorkflowConfigParser
A parsed configuration file that contains the distribution
options.
section : str
Name of the section in the configuration file.
variable_args : str
The names of the parameters for this distribution, separated by
``VARARGS_DELIM``. These must appear in the "tag" part
of the section header.
Returns
-------
UniformAngle
A distribution instance from the pycbc.inference.prior module.
"""
# we'll retrieve the setting for cyclic_domain directly
additional_opts = {'cyclic_domain': cp.has_option_tag(section,
'cyclic_domain', variable_args)}
return bounded.bounded_from_config(cls, cp, section, variable_args,
bounds_required=False,
additional_opts=additional_opts)
[docs]
class SinAngle(UniformAngle):
r"""A sine distribution; the pdf of each parameter `\theta` is given by:
..math::
p(\theta) = \frac{\sin \theta}{\cos\theta_0 - \cos\theta_1}, \theta_0 \leq \theta < \theta_1,
and 0 otherwise. Here, :math:`\theta_0, \theta_1` are the bounds of the
parameter.
The domain of this distribution is `[0, pi]`. This is accomplished by
putting hard boundaries at `[0, pi]`. Bounds may be provided to further
limit the range for which the pdf has support. As with `UniformAngle`,
these are initialized in radians.
Parameters
----------
\**params :
The keyword arguments should provide the names of parameters and
(optionally) their corresponding bounds, as either
`boundaries.Bounds` instances or tuples. The bounds must be
in [0,PI]. These are converted to radians for storage. None may also
be passed; in that case, the domain bounds will be used.
"""
name = 'sin_angle'
_func = numpy.cos
_dfunc = numpy.sin
_arcfunc = numpy.arccos
_domainbounds = (0, numpy.pi)
def __init__(self, **params):
super(SinAngle, self).__init__(**params)
# replace the domain
self._domain = boundaries.Bounds(self._domainbounds[0],
self._domainbounds[1], btype_min='closed', btype_max='closed',
cyclic=False)
self._lognorm = -sum([numpy.log(
abs(self._func(bnd[1]) - self._func(bnd[0]))) \
for bnd in self._bounds.values()])
self._norm = numpy.exp(self._lognorm)
def _cdfinv_param(self, arg, value):
"""Return inverse of cdf for mapping unit interval to parameter bounds.
"""
scale = (numpy.cos(self._bounds[arg][0])
- numpy.cos(self._bounds[arg][1]))
offset = 1. + numpy.cos(self._bounds[arg][1]) / scale
new_value = numpy.arccos(-scale * (value - offset))
return new_value
def _pdf(self, **kwargs):
"""Returns the pdf at the given values. The keyword arguments must
contain all of parameters in self's params. Unrecognized arguments are
ignored.
"""
if kwargs not in self:
return 0.
return self._norm * \
self._dfunc(numpy.array([kwargs[p] for p in self._params])).prod()
def _logpdf(self, **kwargs):
"""Returns the log of the pdf at the given values. The keyword
arguments must contain all of parameters in self's params. Unrecognized
arguments are ignored.
"""
if kwargs not in self:
return -numpy.inf
return self._lognorm + \
numpy.log(self._dfunc(
numpy.array([kwargs[p] for p in self._params]))).sum()
[docs]
class CosAngle(SinAngle):
r"""A cosine distribution. This is the same thing as a sine distribution,
but with the domain shifted to `[-pi/2, pi/2]`. See SinAngle for more
details.
Parameters
----------
\**params :
The keyword arguments should provide the names of parameters and
(optionally) their corresponding bounds, as either
`boundaries.Bounds` instances or tuples. The bounds must be
in [-PI/2, PI/2].
"""
name = 'cos_angle'
_func = numpy.sin
_dfunc = numpy.cos
_arcfunc = numpy.arcsin
_domainbounds = (-numpy.pi/2, numpy.pi/2)
def _cdfinv_param(self, param, value):
a = self._bounds[param][0]
b = self._bounds[param][1]
scale = numpy.sin(b) - numpy.sin(a)
offset = 1. - numpy.sin(b)/(numpy.sin(b) - numpy.sin(a))
new_value = numpy.arcsin((value - offset) * scale)
return new_value
[docs]
class UniformSolidAngle(bounded.BoundedDist):
"""A distribution that is uniform in the solid angle of a sphere. The names
of the two angluar parameters can be specified on initalization.
Parameters
----------
polar_angle : {'theta', str}
The name of the polar angle.
azimuthal_angle : {'phi', str}
The name of the azimuthal angle.
polar_bounds : {None, tuple}
Limit the polar angle to the given bounds. If None provided, the polar
angle will vary from 0 (the north pole) to pi (the south pole). The
bounds should be specified as factors of pi. For example, to limit
the distribution to the northern hemisphere, set
`polar_bounds=(0,0.5)`.
azimuthal_bounds : {None, tuple}
Limit the azimuthal angle to the given bounds. If None provided, the
azimuthal angle will vary from 0 to 2pi. The
bounds should be specified as factors of pi. For example, to limit
the distribution to the one hemisphere, set `azimuthal_bounds=(0,1)`.
azimuthal_cyclic_domain : {False, bool}
Make the domain of the azimuthal angle be cyclic; i.e., azimuthal
values are constrained to be in [0, 2pi) using cyclic boundaries prior
to applying any other boundary conditions and prior to evaluating the
pdf. Default is False.
"""
name = 'uniform_solidangle'
_polardistcls = SinAngle
_azimuthaldistcls = UniformAngle
_default_polar_angle = 'theta'
_default_azimuthal_angle = 'phi'
def __init__(self, polar_angle=None, azimuthal_angle=None,
polar_bounds=None, azimuthal_bounds=None,
azimuthal_cyclic_domain=False):
if polar_angle is None:
polar_angle = self._default_polar_angle
if azimuthal_angle is None:
azimuthal_angle = self._default_azimuthal_angle
self._polardist = self._polardistcls(**{
polar_angle: polar_bounds})
self._azimuthaldist = self._azimuthaldistcls(**{
azimuthal_angle: azimuthal_bounds,
'cyclic_domain': azimuthal_cyclic_domain})
self._polar_angle = polar_angle
self._azimuthal_angle = azimuthal_angle
self._bounds = self._polardist.bounds.copy()
self._bounds.update(self._azimuthaldist.bounds)
self._params = sorted(self._bounds.keys())
@property
def bounds(self):
"""dict: The bounds on each angle. The keys are the names of the polar
and azimuthal angles, the values are the minimum and maximum of each,
in radians. For example, if the distribution was initialized with
`polar_angle='theta', polar_bounds=(0,0.5)` then the bounds will have
`'theta': 0, 1.5707963267948966` as an entry."""
return self._bounds
@property
def polar_angle(self):
"""str: The name of the polar angle."""
return self._polar_angle
@property
def azimuthal_angle(self):
"""str: The name of the azimuthal angle."""
return self._azimuthal_angle
def _cdfinv_param(self, param, value):
""" Return the cdfinv for a single given parameter """
if param == self.polar_angle:
return self._polardist._cdfinv_param(param, value)
elif param == self.azimuthal_angle:
return self._azimuthaldist._cdfinv_param(param, value)
[docs]
def apply_boundary_conditions(self, **kwargs):
"""Maps the given values to be within the domain of the azimuthal and
polar angles, before applying any other boundary conditions.
Parameters
----------
\**kwargs :
The keyword args must include values for both the azimuthal and
polar angle, using the names they were initilialized with. For
example, if `polar_angle='theta'` and `azimuthal_angle=`phi`, then
the keyword args must be `theta={val1}, phi={val2}`.
Returns
-------
dict
A dictionary of the parameter names and the conditioned values.
"""
polarval = kwargs[self._polar_angle]
azval = kwargs[self._azimuthal_angle]
# constrain each angle to its domain
polarval = self._polardist._domain.apply_conditions(polarval)
azval = self._azimuthaldist._domain.apply_conditions(azval)
# apply any other boundary conditions
polarval = self._bounds[self._polar_angle].apply_conditions(polarval)
azval = self._bounds[self._azimuthal_angle].apply_conditions(azval)
return {self._polar_angle: polarval, self._azimuthal_angle: azval}
def _pdf(self, **kwargs):
"""
Returns the pdf at the given angles.
Parameters
----------
\**kwargs:
The keyword arguments should specify the value for each angle,
using the names of the polar and azimuthal angles as the keywords.
Unrecognized arguments are ignored.
Returns
-------
float
The value of the pdf at the given values.
"""
return self._polardist._pdf(**kwargs) * \
self._azimuthaldist._pdf(**kwargs)
def _logpdf(self, **kwargs):
"""
Returns the logpdf at the given angles.
Parameters
----------
\**kwargs:
The keyword arguments should specify the value for each angle,
using the names of the polar and azimuthal angles as the keywords.
Unrecognized arguments are ignored.
Returns
-------
float
The value of the pdf at the given values.
"""
return self._polardist._logpdf(**kwargs) +\
self._azimuthaldist._logpdf(**kwargs)
[docs]
@classmethod
def from_config(cls, cp, section, variable_args):
"""Returns a distribution based on a configuration file.
The section must have the names of the polar and azimuthal angles in
the tag part of the section header. For example:
.. code-block:: ini
[prior-theta+phi]
name = uniform_solidangle
If nothing else is provided, the default names and bounds of the polar
and azimuthal angles will be used. To specify a different name for
each angle, set the `polar-angle` and `azimuthal-angle` attributes. For
example:
.. code-block:: ini
[prior-foo+bar]
name = uniform_solidangle
polar-angle = foo
azimuthal-angle = bar
Note that the names of the variable args in the tag part of the section
name must match the names of the polar and azimuthal angles.
Bounds may also be specified for each angle, as factors of pi. For
example:
.. code-block:: ini
[prior-theta+phi]
polar-angle = theta
azimuthal-angle = phi
min-theta = 0
max-theta = 0.5
This will return a distribution that is uniform in the upper
hemisphere.
By default, the domain of the azimuthal angle is `[0, 2pi)`. To make
this domain cyclic, add `azimuthal_cyclic_domain =`.
Parameters
----------
cp : ConfigParser instance
The config file.
section : str
The name of the section.
variable_args : str
The names of the parameters for this distribution, separated by
``VARARGS_DELIM``. These must appear in the "tag" part
of the section header.
Returns
-------
UniformSolidAngle
A distribution instance from the pycbc.inference.prior module.
"""
tag = variable_args
variable_args = variable_args.split(VARARGS_DELIM)
# get the variables that correspond to the polar/azimuthal angles
try:
polar_angle = cp.get_opt_tag(section, 'polar-angle', tag)
except Error:
polar_angle = cls._default_polar_angle
try:
azimuthal_angle = cp.get_opt_tag(section, 'azimuthal-angle', tag)
except Error:
azimuthal_angle = cls._default_azimuthal_angle
if polar_angle not in variable_args:
raise Error("polar-angle %s is not one of the variable args (%s)"%(
polar_angle, ', '.join(variable_args)))
if azimuthal_angle not in variable_args:
raise Error("azimuthal-angle %s is not one of the variable args "%(
azimuthal_angle) + "(%s)"%(', '.join(variable_args)))
# get the bounds, if provided
polar_bounds = bounded.get_param_bounds_from_config(
cp, section, tag,
polar_angle)
azimuthal_bounds = bounded.get_param_bounds_from_config(
cp, section, tag,
azimuthal_angle)
# see if the a cyclic domain is desired for the azimuthal angle
azimuthal_cyclic_domain = cp.has_option_tag(section,
'azimuthal_cyclic_domain', tag)
return cls(polar_angle=polar_angle, azimuthal_angle=azimuthal_angle,
polar_bounds=polar_bounds,
azimuthal_bounds=azimuthal_bounds,
azimuthal_cyclic_domain=azimuthal_cyclic_domain)
__all__ = ['UniformAngle', 'SinAngle', 'CosAngle', 'UniformSolidAngle']