r"""
Nuisance-Free (Simple) Likelihood Backends
===========================================
This module provides three backend classes that implement **nuisance-free** (or
trivially-marginalised) likelihoods in which the expected counts depend only on
the signal strength :math:`\mu` — no per-bin systematic nuisance parameters appear
in the optimisation.
All three share :class:`SimplePDFBase`, which stores signal and background yields,
constructs the minimum-POI bound, and provides the standard
:meth:`~spey.base.BackendBase.get_objective_function`,
:meth:`~spey.base.BackendBase.get_logpdf_func`, and
:meth:`~spey.base.BackendBase.get_hessian_logpdf_func` interfaces required by the
spey optimisation engine.
Available backends
------------------
``default.poisson`` — :class:`Poisson`
Pure Poisson likelihood. Each bin contributes independently:
.. math::
\mathcal{L}(\mu) = \prod_{i=1}^{N}
\mathrm{Poiss}\!\left(n^{\rm obs}_i \,\big|\, \mu n^s_i + n^b_i\right).
An optional ``absolute_uncertainties`` argument extends the model with
unconstrained per-bin nuisance parameters:
.. math::
\mathcal{L}(\mu, \boldsymbol{\theta}) = \prod_{i=1}^{N}
\mathrm{Poiss}\!\left(n^{\rm obs}_i \,\big|\,
\mu n^s_i + n^b_i + \theta_i \sigma_i\right).
.. autosummary::
:toctree: ../_generated/
Poisson
``default.normal`` — :class:`Gaussian`
Product of independent Gaussians (uncorrelated):
.. math::
\mathcal{L}(\mu) = \prod_{i=1}^{N}
\frac{1}{\sigma_i \sqrt{2\pi}}
\exp\!\left[-\frac{(\mu n^s_i + n^b_i - n^{\rm obs}_i)^2}
{2\sigma_i^2}\right].
This is appropriate when the expected counts are large enough that Poisson
statistics can be well approximated by a Gaussian.
.. autosummary::
:toctree: ../_generated/
Gaussian
``default.multivariate_normal`` — :class:`MultivariateNormal`
Multivariate Gaussian with an arbitrary (possibly :math:`\mu`-dependent)
covariance matrix:
.. math::
\mathcal{L}(\mu) = \frac{1}{\sqrt{(2\pi)^N \det\Sigma}}
\exp\!\left[-\tfrac{1}{2}
(\boldsymbol{\lambda}(\mu) - \mathbf{n}^{\rm obs})^\top
\Sigma^{-1}
(\boldsymbol{\lambda}(\mu) - \mathbf{n}^{\rm obs})\right],
where :math:`\lambda_i(\mu) = \mu n^s_i + n^b_i`. The covariance matrix
:math:`\Sigma` may be a constant array or a callable of the full parameter
vector, enabling :math:`\mu`-dependent uncertainties.
.. autosummary::
:toctree: ../_generated/
MultivariateNormal
Parameter layout
----------------
All simple backends use a minimal parameter vector::
pars = [μ, (signal_par_0, signal_par_1, …)]
Index 0 is always :math:`\mu`. Additional signal parameters are only present when
:class:`MultivariateNormal` is initialised with a callable ``signal_yields`` and
``n_signal_parameters > 0``.
Minimum POI bound
-----------------
The lower bound on :math:`\mu` is set to the most negative signal strength for which
no bin has negative expected yield,
.. math::
\mu_{\min} = -\min_{i:\,n^s_i > 0} \frac{n^b_i}{n^s_i},
matching the convention used in the other default PDF backends. When
``signal_yields`` is a callable the bound is set to :math:`-\infty`.
"""
from typing import Callable, List, Optional, Tuple, Union
from autograd import hessian, jacobian
from autograd import numpy as np
from autograd import value_and_grad
from scipy.optimize import NonlinearConstraint
from spey._version import __version__
from spey.backends.distributions import MainModel
from spey.base import BackendBase, ModelConfig
from spey.system.exceptions import InvalidInput
from spey.utils import ExpectationType
# pylint: disable=E1101,E1120,W0613
[docs]
class SimplePDFBase(BackendBase):
r"""
Abstract base class for nuisance-free (simple) PDF backends.
Subclasses implement statistical models in which the expected bin counts
depend only on the signal strength :math:`\mu`,
.. math::
\lambda_i(\mu) = \mu\, n^s_i + n^b_i,
with no systematic nuisance parameters entering the fit (or, in the case of
:class:`Poisson` with ``absolute_uncertainties``, unconstrained nuisances that
are marginalised by the optimiser without an explicit constraint model).
This class provides:
* Common storage and :mod:`autograd`-compatible array initialisation.
* Minimum-POI computation: :math:`\mu_{\min} = -\min_{i} n^b_i / n^s_i` over
bins with :math:`n^s_i > 0`.
* A lazily-initialised :attr:`main_model` property based on the
:math:`\lambda(\mu)` function above.
* Default implementations of :meth:`get_objective_function`,
:meth:`get_logpdf_func`, :meth:`get_hessian_logpdf_func`,
:meth:`get_sampler`, and :meth:`expected_data` that all delegate to
:attr:`main_model`.
Subclasses customise the model by overriding :attr:`main_model` (by setting
``self._main_model`` directly in ``__init__``) and by passing ``self._main_kwargs``
to change the distribution type used by :class:`~spey.backends.distributions.MainModel`.
"""
name: str = "__simplepdf_base__"
"""Name of the backend"""
version: str = __version__
"""Version of the backend"""
author: str = "SpeysideHEP"
"""Author of the backend"""
spey_requires: str = __version__
"""Spey version required for the backend"""
__slots__ = [
"data",
"signal_yields",
"background_yields",
"_main_model",
"_main_kwargs",
"_config",
]
[docs]
def __init__(
self,
signal_yields: Union[List[float], Callable[[np.ndarray], np.ndarray]],
background_yields: List[float],
data: List[int],
n_signal_parameters: int = 0,
signal_parameter_bounds: Optional[
List[Tuple[Optional[float], Optional[float]]]
] = None,
):
self.data = np.array(data, dtype=np.float64)
if callable(signal_yields):
self.signal_yields = signal_yields
else:
self.signal_yields = np.array(signal_yields, dtype=np.float64)
self.background_yields = np.array(background_yields, dtype=np.float64)
self._main_model = None
"""main model"""
self._main_kwargs = {}
"""Keyword arguments for main model"""
self.constraints = []
"""Constraints to be used during optimisation process"""
self.n_signal_parameters = n_signal_parameters
"""Number of signal parameters"""
minimum_poi = -np.inf
if self.is_alive and not callable(self.signal_yields):
sig = self.signal_yields
minimum_poi = -np.min(self.background_yields[sig > 0.0] / sig[sig > 0.0])
if signal_parameter_bounds is None:
_sig_par_bounds = [(None, None)] * n_signal_parameters
else:
if len(signal_parameter_bounds) != n_signal_parameters:
raise ValueError(
f"signal_parameter_bounds has {len(signal_parameter_bounds)} entries "
f"but n_signal_parameters is {n_signal_parameters}."
)
_sig_par_bounds = [
b if b is not None else (None, None) for b in signal_parameter_bounds
]
self._config = ModelConfig(
poi_index=0,
minimum_poi=minimum_poi,
suggested_init=[1.0] + [1.0] * n_signal_parameters,
suggested_bounds=[(minimum_poi, 10)] + _sig_par_bounds,
parameter_names=["mu"]
+ [f"signal_par_{i}" for i in range(n_signal_parameters)],
)
@property
def is_alive(self) -> bool:
"""
Returns True if at least one bin has non-zero signal yield.
.. versionchanged:: 0.2.7
When ``signal_yields`` is a callable, always returns ``True`` since the
actual yields are not known until evaluation time.
"""
if callable(self.signal_yields):
return True
return np.any(self.signal_yields > 0.0)
def config(
self, allow_negative_signal: bool = True, poi_upper_bound: float = 10.0
) -> ModelConfig:
r"""
Model configuration.
Args:
allow_negative_signal (``bool``, default ``True``): If ``True`` :math:`\hat\mu`
value will be allowed to be negative.
poi_upper_bound (``float``, default ``40.0``): upper bound for parameter
of interest, :math:`\mu`.
Returns:
~spey.base.ModelConfig:
Model configuration. Information regarding the position of POI in
parameter list, suggested input and bounds.
.. versionchanged:: 0.2.7
When the model has extra signal or nuisance parameters (e.g. added by
subclasses via :attr:`n_signal_parameters`), those additional bounds and
:attr:`~spey.base.ModelConfig.parameter_names` are now correctly preserved
when ``allow_negative_signal`` is ``False`` or ``poi_upper_bound`` differs
from the default.
"""
if allow_negative_signal and poi_upper_bound == 10.0:
return self._config
extra_bounds = self._config.suggested_bounds[1:]
return ModelConfig(
self._config.poi_index,
self._config.minimum_poi,
self._config.suggested_init,
[(0, poi_upper_bound)] + extra_bounds,
parameter_names=self._config.parameter_names,
)
@property
def main_model(self) -> MainModel:
r"""
Main model distribution — Poisson (or Gaussian) term of the likelihood.
For :class:`SimplePDFBase` the expected count in bin :math:`i` is simply
.. math::
\lambda_i(\mu) = \mu\, n^s_i + n^b_i,
with no nuisance parameters. When ``signal_yields`` is a callable, the
extra signal parameters ``pars[1:]`` are forwarded to it at each evaluation:
.. math::
\lambda_i(\mu, \boldsymbol{\phi}) =
\mu\, n^s_i(\boldsymbol{\phi}) + n^b_i,
where :math:`\boldsymbol{\phi}` denotes the additional signal parameters.
Subclasses may replace this model entirely by setting ``self._main_model``
before the first access (e.g. :class:`Poisson` with uncertainty extension).
.. versionchanged:: 0.2.7
Callable ``signal_yields`` support added.
Returns:
:class:`~spey.backends.distributions.MainModel`:
Lazily-initialised main model.
"""
if self._main_model is None:
def lam(pars: np.ndarray) -> np.ndarray:
"""
Compute the per-bin expected count :math:`\\lambda_i(\\mu)`.
Args:
pars (``np.ndarray``): Parameter vector; ``pars[0]`` is :math:`\\mu`
and ``pars[1:]`` are forwarded to a callable ``signal_yields``.
Returns:
``np.ndarray``:
Per-bin expected counts :math:`\\{\\lambda_i\\}`.
"""
sig = (
self.signal_yields(pars[1:])
if callable(self.signal_yields)
else self.signal_yields
)
return pars[0] * sig + self.background_yields
self._main_model = MainModel(lam, **self._main_kwargs)
return self._main_model
def get_objective_function(
self,
expected: ExpectationType = ExpectationType.observed,
data: Optional[np.ndarray] = None,
do_grad: bool = True,
) -> Callable[[np.ndarray], Union[Tuple[float, np.ndarray], float]]:
r"""
Return the objective function :math:`-\ln\mathcal{L}(\mu)` used by the optimiser.
Because there is no constraint model, the objective is simply the negative
log-likelihood of the main model:
.. math::
-\ln\mathcal{L}(\mu) = -\ln\mathcal{L}_{\rm main}(\mu).
When ``do_grad=True`` the returned callable is wrapped with
:func:`autograd.value_and_grad` to supply exact gradients via automatic
differentiation.
Args:
expected (:class:`~spey.ExpectationType`): Controls which dataset is used
during fitting.
* :obj:`~spey.ExpectationType.observed`: Real observed data (default).
* :obj:`~spey.ExpectationType.aposteriori`: Post-fit expected data.
* :obj:`~spey.ExpectationType.apriori`: Pre-fit SM expectation
(background yields used as pseudo-data).
data (``np.ndarray``, default ``None``): Override the observed data.
When ``None``, the array selected by ``expected`` is used.
do_grad (``bool``, default ``True``): If ``True``, the returned callable
yields ``(nll, grad_nll)``; if ``False`` it yields only ``nll``.
Returns:
``Callable[[np.ndarray], float | Tuple[float, np.ndarray]]``:
A function of the parameter vector :math:`(\mu, \ldots)` that returns
the NLL and optionally its gradient.
"""
current_data = (
self.background_yields if expected == ExpectationType.apriori else self.data
)
data = current_data if data is None else data
def negative_loglikelihood(pars: np.ndarray) -> np.ndarray:
"""Compute twice negative log-likelihood"""
return -self.main_model.log_prob(pars, data)
if do_grad:
return value_and_grad(negative_loglikelihood, argnum=0)
return negative_loglikelihood
def get_logpdf_func(
self,
expected: ExpectationType = ExpectationType.observed,
data: Optional[np.array] = None,
) -> Callable[[np.ndarray, np.ndarray], float]:
r"""
Return a callable that evaluates :math:`\ln\mathcal{L}(\mu)`.
The log-likelihood is computed directly from :attr:`main_model` with no
constraint term:
.. math::
\ln\mathcal{L}(\mu) = \ln\mathcal{L}_{\rm main}(\mu).
This is used internally for the profile likelihood ratio and Hessian-based
variance estimation.
Args:
expected (:class:`~spey.ExpectationType`): Dataset prescription.
* :obj:`~spey.ExpectationType.observed`: Real observed data (default).
* :obj:`~spey.ExpectationType.aposteriori`: Post-fit expected data.
* :obj:`~spey.ExpectationType.apriori`: Pre-fit SM expectation
(background yields used as pseudo-data).
data (``np.ndarray``, default ``None``): Override data.
Returns:
``Callable[[np.ndarray], float]``:
Function of the parameter vector :math:`(\mu, \ldots)` returning the
scalar log-likelihood.
"""
current_data = (
self.background_yields if expected == ExpectationType.apriori else self.data
)
data = current_data if data is None else data
return lambda pars: self.main_model.log_prob(pars, data)
def get_hessian_logpdf_func(
self,
expected: ExpectationType = ExpectationType.observed,
data: Optional[np.ndarray] = None,
) -> Callable[[np.ndarray], float]:
r"""
Return a callable that evaluates the Hessian of :math:`\ln\mathcal{L}(\mu)`.
The Hessian :math:`H_{ab} = \partial^2 \ln\mathcal{L} / \partial p_a \partial p_b`
is computed via :func:`autograd.hessian`. Its primary use is to estimate the
variance on :math:`\hat\mu` through the Fisher information:
.. math::
\sigma_{\hat\mu}^2 \approx \left[H^{-1}\right]_{00},
where index 0 corresponds to :math:`\mu`. For purely Poissonian or Gaussian
likelihoods with no nuisance parameters the Hessian is a :math:`1 \times 1`
matrix.
Args:
expected (:class:`~spey.ExpectationType`): Dataset prescription.
* :obj:`~spey.ExpectationType.observed`: Real observed data (default).
* :obj:`~spey.ExpectationType.aposteriori`: Post-fit expected data.
* :obj:`~spey.ExpectationType.apriori`: Pre-fit SM expectation.
data (``np.ndarray``, default ``None``): Override data.
Returns:
``Callable[[np.ndarray], np.ndarray]``:
Function of the parameter vector returning the
:math:`(N_{\rm par} \times N_{\rm par})` Hessian matrix of
:math:`\ln\mathcal{L}`.
"""
current_data = (
self.background_yields if expected == ExpectationType.apriori else self.data
)
data = current_data if data is None else data
def log_prob(pars: np.ndarray) -> np.ndarray:
"""Compute log-probability"""
return self.main_model.log_prob(pars, data)
return hessian(log_prob, argnum=0)
def get_sampler(self, pars: np.ndarray) -> Callable[[int], np.ndarray]:
r"""
Return a callable that draws pseudo-data from the main model.
Because there is no constraint model, all samples come from the Poisson (or
Gaussian) main model evaluated at the expected yields
:math:`\lambda(\mu) = \mu\, n^s + n^b`.
Args:
pars (``np.ndarray``): Fixed parameter vector :math:`(\mu, \ldots)` at
which expected yields are evaluated before sampling.
Returns:
``Callable[[int], np.ndarray]``:
A function ``sampler(sample_size)`` that returns an array of shape
``(sample_size, N_bins)`` with generated pseudo-counts.
"""
def sampler(sample_size: int, *args, **kwargs) -> np.ndarray:
"""
Draw ``sample_size`` pseudo-experiments from the main model.
Args:
sample_size (``int``): Number of pseudo-experiments to generate.
Returns:
``np.ndarray``:
Array of shape ``(sample_size, N_bins)`` with the generated counts.
"""
return self.main_model.sample(pars, sample_size)
return sampler
def expected_data(self, pars: List[float], **kwargs) -> List[float]:
r"""
Compute the expected data vector :math:`\{\lambda_i(\mu)\}` at the given
parameter point.
Args:
pars (``List[float]``): Parameter vector :math:`(\mu, \ldots)`.
Returns:
``List[float]``:
Per-bin expected counts :math:`\lambda_i(\mu) = \mu n^s_i + n^b_i`.
"""
return self.main_model.expected_data(pars)
[docs]
class Poisson(SimplePDFBase):
r"""
Pure Poisson likelihood (``default.poisson``).
The simplest possible statistical model: no systematic uncertainties, no
constraint model. The likelihood is a product of independent Poisson terms,
.. math::
\mathcal{L}(\mu) = \prod_{i=1}^{N}
\mathrm{Poiss}\!\left(n^{\rm obs}_i \,\big|\, \mu n^s_i + n^b_i\right),
where :math:`n^s_i`, :math:`n^b_i`, and :math:`n^{\rm obs}_i` are the signal
yield, background yield, and observed count in bin :math:`i` respectively.
**Optional background uncertainties**
When ``absolute_uncertainties`` is provided, unconstrained per-bin nuisance
parameters :math:`\theta_i` are added to the expected count:
.. math::
\mathcal{L}(\mu, \boldsymbol{\theta}) = \prod_{i=1}^{N}
\mathrm{Poiss}\!\left(n^{\rm obs}_i \,\big|\,
\mu n^s_i + n^b_i + \theta_i \sigma_i\right).
.. note::
These nuisance parameters are **unconstrained** (no Gaussian penalty is
applied). For constrained nuisances see
:class:`~spey.backends.default_pdf.UncorrelatedBackground`.
The positivity constraint :math:`n^b_i + \theta_i \sigma_i \geq 0` is
enforced via :class:`~scipy.optimize.NonlinearConstraint`.
Args:
signal_yields (``List[float]``): Per-bin signal yields :math:`\{n^s_i\}`.
background_yields (``List[float]``): Per-bin expected background yields
:math:`\{n^b_i\}`.
data (``List[int]``): Per-bin observed counts :math:`\{n^{\rm obs}_i\}`.
absolute_uncertainties (``List[float]``, default ``None``): Per-bin absolute
background uncertainties :math:`\{\sigma_i\}`. When provided, the model
gains :math:`N` additional unconstrained nuisance parameters.
"""
name: str = "default.poisson"
"""Name of the backend"""
version: str = SimplePDFBase.version
"""Version of the backend"""
author: str = SimplePDFBase.author
"""Author of the backend"""
spey_requires: str = SimplePDFBase.spey_requires
"""Spey version required for the backend"""
[docs]
def __init__(
self,
signal_yields: List[float],
background_yields: List[float],
data: List[int],
absolute_uncertainties: Optional[List[float]] = None,
):
super().__init__(
signal_yields=signal_yields, background_yields=background_yields, data=data
)
if absolute_uncertainties is not None:
self.absolute_uncertainties = np.array(absolute_uncertainties)
assert len(self.absolute_uncertainties) == len(
self.data
), "Invalid input dimension."
def lam(pars: np.ndarray) -> np.ndarray:
return (
pars[0] * self.signal_yields
+ self.background_yields
+ pars[slice(1, len(self.data) + 1)] * self.absolute_uncertainties
)
self._main_model = MainModel(lam)
self._config = ModelConfig(
poi_index=0,
minimum_poi=self._config.minimum_poi,
suggested_init=[1.0] * (len(self.data) + 1),
suggested_bounds=[(self._config.minimum_poi, 10)]
+ [(None, None)] * len(self.data),
)
def constraint(pars: np.ndarray) -> np.ndarray:
"""Compute the constraint term"""
return (
self.background_yields
+ pars[slice(1, len(self.data) + 1)] * self.background_yields
)
jac_constr = jacobian(constraint)
self.constraints.append(
NonlinearConstraint(constraint, 0.0, np.inf, jac=jac_constr)
)
[docs]
class Gaussian(SimplePDFBase):
r"""
Product of independent Gaussians — uncorrelated normal likelihood
(``default.normal``).
This backend replaces the Poisson term with a Gaussian approximation, which is
accurate when the expected counts are large. The likelihood is
.. math::
\mathcal{L}(\mu) = \prod_{i=1}^{N}
\frac{1}{\sigma_i \sqrt{2\pi}}
\exp\!\left[-\frac{(\mu n^s_i + n^b_i - n^{\rm obs}_i)^2}
{2\sigma_i^2}\right],
where :math:`\sigma_i` is the absolute uncertainty in bin :math:`i`.
Because the bins are independent this is equivalent to a :math:`\chi^2`
goodness-of-fit test with the total prediction :math:`\mu n^s_i + n^b_i`
as the hypothesis.
.. versionadded:: 0.1.9
Args:
signal_yields (``List[float]``): Per-bin signal yields :math:`\{n^s_i\}`.
background_yields (``List[float]``): Per-bin expected background yields
:math:`\{n^b_i\}`.
data (``List[int]``): Per-bin observed counts :math:`\{n^{\rm obs}_i\}`.
absolute_uncertainties (``List[float]``): Per-bin absolute uncertainties
:math:`\{\sigma_i\}` that enter the Gaussian widths.
n_signal_parameters (``int``, default ``0``): number of additional free parameters
to pass to a callable ``signal_yields``. Has no effect when ``signal_yields``
is a plain array. When greater than zero the optimiser parameter vector is
extended to ``[mu, signal_par_0, ..., signal_par_{n-1}]``.
signal_parameter_bounds (:code:`List[Tuple[Optional[float], Optional[float]]] | None`):
Optimiser bounds for each extra signal parameter. Each entry
is a ``(lower, upper)`` pair; use ``None`` for an unbounded side. When
``None``, every extra signal parameter receives ``(None, None)``. Must have
exactly ``n_signal_parameters`` entries when provided.
"""
name: str = "default.normal"
"""Name of the backend"""
version: str = SimplePDFBase.version
"""Version of the backend"""
author: str = SimplePDFBase.author
"""Author of the backend"""
spey_requires: str = SimplePDFBase.spey_requires
"""Spey version required for the backend"""
__slots__ = ["absolute_uncertainties"]
[docs]
def __init__(
self,
signal_yields: Union[List[float], Callable[[np.ndarray], np.ndarray]],
background_yields: List[float],
data: List[int],
absolute_uncertainties: List[float],
n_signal_parameters: int = 0,
signal_parameter_bounds: Optional[
List[Tuple[Optional[float], Optional[float]]]
] = None,
):
super().__init__(
signal_yields=signal_yields,
background_yields=background_yields,
data=data,
n_signal_parameters=n_signal_parameters,
signal_parameter_bounds=signal_parameter_bounds,
)
self.absolute_uncertainties = (
absolute_uncertainties
if callable(absolute_uncertainties)
else np.array(absolute_uncertainties, dtype=np.float64)
)
"""absolute uncertainties on the background"""
self._main_kwargs = {"cov": self.absolute_uncertainties, "pdf_type": "normal"}
[docs]
class MultivariateNormal(SimplePDFBase):
r"""
Multivariate Normal likelihood with optional parameter-dependent covariance
(``default.multivariate_normal``).
This backend models the likelihood as a single multivariate normal distribution
over all bins simultaneously, capturing inter-bin correlations through an
:math:`N \times N` covariance matrix :math:`\Sigma`:
.. math::
\mathcal{L}(\mu) =
\frac{1}{\sqrt{(2\pi)^N \det\Sigma(\mu)}}
\exp\!\left[-\frac{1}{2}
\bigl(\boldsymbol{\lambda}(\mu) - \mathbf{n}^{\rm obs}\bigr)^\top
\Sigma(\mu)^{-1}
\bigl(\boldsymbol{\lambda}(\mu) - \mathbf{n}^{\rm obs}\bigr)
\right],
where :math:`\lambda_i(\mu) = \mu n^s_i + n^b_i` and :math:`\Sigma(\mu)` may
be constant or :math:`\mu`-dependent (see below).
**Callable covariance matrix**
``covariance_matrix`` can be a callable that takes the full parameter vector
``pars`` and returns an :math:`N \times N` covariance matrix. This allows, for
example, signal-strength-dependent uncertainties:
.. math::
\Sigma(\mu) = \Sigma_{\rm bkg} + \mu^2\, \Sigma_{\rm sig}.
.. versionadded:: 0.1.9
.. versionchanged:: 0.2.6
Callable ``covariance_matrix`` support added.
``covariance_matrix`` can also take callable function as an input where
function takes nuisance parameters as inputs and return a new covariance matrix as output.
**Example — callable covariance matrix:**
.. code:: python3
>>> import spey
>>> import numpy as np
>>> signal_yields = np.array([12.0, 15.0])
>>> background_yields = np.array([50.0, 48.0])
>>> data = np.array([36., 33.])
>>> covariance_matrix = np.array([[144.0, 13.0], [25.0, 256.0]])
>>> covariance_signal = np.array([[5.0, 1.0], [2.0, 3.0]])
>>> # ``pars`` include all the parameters of the model,
>>> # including the POI and any extra signal parameters
>>> def cov_matrix(pars: np.ndarray) -> np.ndarray:
>>> return covariance_matrix + covariance_signal * pars[0]**2
>>> pdf_wrapper = spey.get_backend('default.multivariate_normal')
>>> model = pdf_wrapper(
... signal_yields=signal_yields,
... background_yields=background_yields,
... data=data,
... covariance_matrix=cov_matrix,
... )
**Example — callable signal yields with additional parameters:**
``signal_yields`` can be a callable that accepts extra parameters beyond :math:`\mu`
and returns the per-bin signal yields as a ``np.ndarray``. The number of extra
parameters must be declared via ``n_signal_parameters`` so that the optimiser can
construct the correct parameter vector. The first element of the parameter vector is
always :math:`\mu`; the remaining elements (``pars[1:]``) are forwarded to the
callable at each function evaluation.
.. code:: python3
>>> import spey
>>> import numpy as np
>>> background_yields = np.array([50.0, 48.0])
>>> data = np.array([36., 33.])
>>> covariance_matrix = np.array([[144.0, 13.0], [25.0, 256.0]])
>>> base_signal = np.array([12.0, 15.0])
>>> # signal_yields is a function of one extra parameter (signal_par_0)
>>> def signal_yields(extra_pars: np.ndarray) -> np.ndarray:
... return base_signal * (1.0 + extra_pars[0])
>>> pdf_wrapper = spey.get_backend('default.multivariate_normal')
>>> model = pdf_wrapper(
... signal_yields=signal_yields,
... background_yields=background_yields,
... data=data,
... covariance_matrix=covariance_matrix,
... n_signal_parameters=1,
... )
>>> muhat, nll = model.maximize_likelihood()
The resulting :class:`~spey.base.ModelConfig` will contain two parameters:
``["mu", "signal_par_0"]``.
.. attention::
The functional ``signal_yields`` is used to compute the expected signal yields in each bin.
The optimiser only passes relevant parameter set to ``signal_yields`` function, excluding
signal strength (i.e. the first parameter :math:`\mu`). Callable ``covariance_matrix``
receives the full parameter vector, including the POI and any extra signal parameters.
.. versionchanged:: 0.2.6
The ability to input a callable covariance matrix has been added.
.. versionchanged:: 0.2.7
``signal_yields`` now accepts a callable with signature
``(extra_pars: np.ndarray) -> np.ndarray`` in addition to a plain array.
The companion argument ``n_signal_parameters`` (default ``0``) declares how many
extra parameters the callable expects; they are appended to the parameter vector
after :math:`\mu` and their names are automatically set to
``signal_par_0``, ``signal_par_1``, …
Args:
signal_yields (``List[float] | Callable[[np.ndarray], np.ndarray]``): signal yields
per bin, or a callable that takes the extra signal parameters (``pars[1:]``) and
returns the signal yields as a ``np.ndarray``.
background_yields (``List[float]``): background yields per bin.
data (``List[int]``): observed data per bin.
covariance_matrix (``List[List[float]] | callable``): covariance matrix (square matrix),
or a callable that takes the full parameter vector and returns a covariance matrix.
* If you have a correlation matrix and absolute uncertainties please use
:func:`~spey.helper_functions.correlation_to_covariance`
n_signal_parameters (``int``, default ``0``): number of additional free parameters
to pass to a callable ``signal_yields``. Has no effect when ``signal_yields``
is a plain array. When greater than zero the optimiser parameter vector is
extended to ``[mu, signal_par_0, ..., signal_par_{n-1}]``.
signal_parameter_bounds (:code:`List[Tuple[Optional[float], Optional[float]]] | None`):
Optimiser bounds for each extra signal parameter. Each entry
is a ``(lower, upper)`` pair; use ``None`` for an unbounded side. When
``None``, every extra signal parameter receives ``(None, None)``. Must have
exactly ``n_signal_parameters`` entries when provided.
"""
name: str = "default.multivariate_normal"
"""Name of the backend"""
version: str = SimplePDFBase.version
"""Version of the backend"""
author: str = SimplePDFBase.author
"""Author of the backend"""
spey_requires: str = SimplePDFBase.spey_requires
"""Spey version required for the backend"""
__slots__ = ["covariance_matrix", "n_signal_parameters"]
[docs]
def __init__(
self,
signal_yields: Union[List[float], Callable[[np.ndarray], np.ndarray]],
background_yields: List[float],
data: List[int],
covariance_matrix: Union[List[List[float]], callable],
n_signal_parameters: int = 0,
signal_parameter_bounds: Optional[
List[Tuple[Optional[float], Optional[float]]]
] = None,
):
super().__init__(
signal_yields=signal_yields,
background_yields=background_yields,
data=data,
n_signal_parameters=n_signal_parameters,
signal_parameter_bounds=signal_parameter_bounds,
)
if not callable(covariance_matrix):
self.covariance_matrix = np.array(covariance_matrix, dtype=np.float64)
if (
self.covariance_matrix.shape[0] != len(self.background_yields)
and len(self.covariance_matrix.shape) == 2
):
raise InvalidInput(
"Dimensionality of the covariance matrix should match to the background"
)
else:
self.covariance_matrix = covariance_matrix
self._main_kwargs = {
"cov": self.covariance_matrix,
"pdf_type": "multivariate_normal",
}