Source code for spey.interface.statistical_model

"""
This module defines :class:`~spey.StatisticalModel`, the central user-facing object in
``spey``.  It wraps any backend that inherits :class:`~spey.BackendBase` and provides a
unified API for likelihood evaluation, hypothesis testing, upper-limit extraction, and
model combination.  The module also exposes :func:`statistical_model_wrapper`, the
decorator that backends are registered with, and the :data:`PoiTest` type alias used
throughout.
"""
import logging
from functools import wraps
from typing import Any, Callable, Dict, List, Optional, Tuple, Union

import numpy as np

from spey.base.backend_base import BackendBase, ModelConfig
from spey.base.hypotest_base import HypothesisTestingBase
from spey.optimizer.core import fit
from spey.system.cache import cache_results
from spey.system.exceptions import (
    CombinerNotAvailable,
    MethodNotAvailable,
    UnknownCrossSection,
)
from spey.utils import ExpectationType

#: Type alias for ``poi_test``: either a single :obj:`float` or a :obj:`dict`
#: mapping POI indices / names to their fixed values.
PoiTest = Union[float, Dict[Union[int, str], float]]

__all__ = ["StatisticalModel", "statistical_model_wrapper", "PoiTest"]


def __dir__():
    return __all__


log = logging.getLogger("Spey")

# pylint: disable=W1203


[docs] class StatisticalModel(HypothesisTestingBase): r""" Unified interface to any ``spey`` statistical model backend. :class:`~spey.StatisticalModel` is the central user-facing object in ``spey``. It wraps any backend that inherits :class:`~spey.BackendBase`, giving every backend a consistent API for: * evaluating the (negative) log-likelihood :math:`-\log\mathcal{L}(\mu,\hat{\theta}_\mu)`; * maximising the likelihood to obtain :math:`\hat\mu` and :math:`\hat{\theta}`; * computing p-values, CLs values, and upper limits via asymptotic, toy-based, or :math:`\chi^2` calculators; * generating Asimov data; * combining two models with the ``@`` operator. Instances are normally obtained through :func:`spey.get_backend` rather than constructed directly: .. code-block:: python import spey # Obtain a backend constructor wrapped as StatisticalModel pdf_wrapper = spey.get_backend("default.poisson") # Build the statistical model model = pdf_wrapper( signal_yields=[12.0, 15.0], background_yields=[50.0, 60.0], data=[48, 63], analysis="my_analysis", xsection=0.05, # pb ) **Likelihood evaluation** .. code-block:: python # Negative log-likelihood at mu = 1 nll = model.likelihood(poi_test=1.0) # Profile likelihood ratio test statistic nll_free, _ = model.maximize_likelihood() # Asimov (expected) likelihood nll_asimov = model.asimov_likelihood(poi_test=1.0) **Hypothesis testing** .. code-block:: python # Observed CLs value cls_obs = model.exclusion_confidence_level(poi_test=1.0) # Expected (apriori) CLs value cls_exp = model.exclusion_confidence_level( poi_test=1.0, expected=spey.ExpectationType.apriori ) # One-sided 95 % CL upper limit on the signal strength mu_ul = model.poi_upper_limit(confidence_level=0.95) # Upper limit on the cross section (requires xsection to be set) xsec_ul = model.s95obs **Multi-parameter fits** When a backend exposes more than one parameter of interest, ``poi_test`` and ``poi_indices`` accept either an ``int`` index, a ``str`` parameter name, or a ``dict`` mapping indices / names to values: .. code-block:: python # Fix mu_0 = 1.0, mu_1 = 0.5 nll = model.likelihood(poi_test={0: 1.0, 1: 0.5}) # Retrieve fitted values of two named POIs muhat_dict, nll = model.maximize_likelihood(poi_indices=[0, 1]) **Model combination** .. code-block:: python combined = model_a @ model_b # uses the @ operator # or equivalently: combined = model_a.combine(model_b) Args: backend (:class:`~spey.BackendBase`): Statistical model backend. Must be an instance of a class that inherits :class:`~spey.BackendBase`. analysis (``str``): Unique identifier of the statistical model used for book-keeping purposes. xsection (``float``, default ``np.nan``): Signal cross section in units chosen by the user. Only required for :func:`~spey.StatisticalModel.excluded_cross_section`, :attr:`~spey.StatisticalModel.s95obs`, and :attr:`~spey.StatisticalModel.s95exp`. ntoys (``int``, default ``1000``): Number of pseudo-experiments (toys) used by the toy-based hypothesis-testing calculator. Ignored when the asymptotic or :math:`\chi^2` calculator is used. Raises: :obj:`AssertionError`: If ``backend`` does not inherit :class:`~spey.BackendBase`. Returns: :class:`~spey.StatisticalModel`: A statistical model object wrapping the given backend with a unified hypothesis- testing interface. """ __slots__ = ["xsection", "analysis", "_backend"] def __init__( self, backend: BackendBase, analysis: str, xsection: float = np.nan, ntoys: int = 1000, ): super().__init__(ntoys=ntoys) assert isinstance(backend, BackendBase), "Invalid backend" self._backend: BackendBase = backend self.xsection: float = xsection """Value of the cross section, unit is defined by the user.""" self.analysis: str = analysis """Unique identifier as analysis name""" def __repr__(self): calc = f"calculators={self.available_calculators}" if np.isnan(self.xsection): return ( f"StatisticalModel(analysis='{self.analysis}', backend={self.backend_type}, " + f"{calc})" ) return ( f"StatisticalModel(analysis='{self.analysis}', " f"xsection={self.xsection:.3e} [au], backend={self.backend_type}, {calc})" ) @property def backend(self) -> BackendBase: """ The underlying backend instance. Returns: ~spey.BackendBase: The backend object that was supplied at construction time. All likelihood and sampling calls are delegated to this object. """ return self._backend @property def backend_type(self) -> str: """ Human-readable name of the backend. Returns the value of the backend's ``name`` attribute when present, and falls back to the class name otherwise. Returns: ``str``: Backend identifier string (e.g. ``"default.poisson"``). """ return getattr(self.backend, "name", self.backend.__class__.__name__) @property def config(self) -> ModelConfig: """Retreive model configuration""" return self.backend.config @property def is_asymptotic_calculator_available(self) -> bool: """ Whether the asymptotic calculator can be used with this backend. The asymptotic calculator requires either: * a working :func:`~spey.BackendBase.expected_data` implementation, **or** * both :func:`~spey.BackendBase.asimov_negative_loglikelihood` and :func:`~spey.BackendBase.minimize_asimov_negative_loglikelihood` to be overridden by the backend. Returns: ``bool``: ``True`` if the asymptotic calculator is available. """ return self.backend.expected_data != BackendBase.expected_data or ( self.backend.asimov_negative_loglikelihood != BackendBase.asimov_negative_loglikelihood and self.backend.minimize_asimov_negative_loglikelihood != BackendBase.minimize_asimov_negative_loglikelihood ) @property def is_toy_calculator_available(self) -> bool: """ Whether the toy (pseudo-experiment) calculator can be used with this backend. Requires the backend to override :func:`~spey.BackendBase.get_sampler`. Returns: ``bool``: ``True`` if the toy calculator is available. """ return self.backend.get_sampler != BackendBase.get_sampler @property def is_chi_square_calculator_available(self) -> bool: r""" Whether the :math:`\chi^2` calculator can be used with this backend. The :math:`\chi^2` calculator only requires the negative log-likelihood, which every backend must implement, so this property always returns ``True``. Returns: ``bool``: Always ``True``. """ return True @property def available_calculators(self) -> List[str]: """ Returns available calculator names. Possible entries are ``'toy'``, ``'asymptotic'``, and ``'chi_square'``, depending on what the underlying backend supports. Returns: ``List[str]``: Subset of ``['toy', 'asymptotic', 'chi_square']`` listing the calculators that are available for this model. """ calc = ["toy"] * self.is_toy_calculator_available calc += ["asymptotic"] * self.is_asymptotic_calculator_available calc += ["chi_square"] * self.is_chi_square_calculator_available return calc @property def is_alive(self) -> bool: """Returns True if at least one bin has non-zero signal yield.""" return self.backend.is_alive
[docs] def excluded_cross_section( self, expected: ExpectationType = ExpectationType.observed ) -> float: """ Compute excluded cross section value at 95% CL Args: expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. Raises: ~spey.system.exceptions.UnknownCrossSection: If the cross-section is ``nan``. Returns: ``float``: Returns the upper limit at 95% CL on cross section value where the unit is defined by the user. """ if np.isnan(self.xsection): raise UnknownCrossSection("Cross-section value has not been initialised.") return ( self.poi_upper_limit(expected=expected, confidence_level=0.95) * self.xsection )
@property def s95exp(self) -> float: """ Expected excluded cross section at 95% CL (pre-fit / *a-priori* expectation). Shorthand for ``excluded_cross_section(ExpectationType.apriori)``. The result represents the cross-section value that would be excluded at the 95% confidence level if no signal were present (SM hypothesis), expressed in the same units as :attr:`~spey.StatisticalModel.xsection`. Raises: ~spey.system.exceptions.UnknownCrossSection: If :attr:`~spey.StatisticalModel.xsection` has not been set (i.e. is ``nan``). Returns: ``float``: Expected 95% CL excluded cross section value in user-defined units. """ return self.excluded_cross_section(ExpectationType.apriori) @property def s95obs(self) -> float: """ Observed excluded cross section at 95% CL (post-fit / *observed* expectation). Shorthand for ``excluded_cross_section(ExpectationType.observed)``. The result represents the cross-section value excluded at the 95% confidence level using the actual observed data, expressed in the same units as :attr:`~spey.StatisticalModel.xsection`. Raises: ~spey.system.exceptions.UnknownCrossSection: If :attr:`~spey.StatisticalModel.xsection` has not been set (i.e. is ``nan``). Returns: ``float``: Observed 95% CL excluded cross section value in user-defined units. """ return self.excluded_cross_section(ExpectationType.observed) def _resolve_poi_test(self, poi_test: PoiTest) -> Union[float, Dict[int, float]]: """Normalise a ``poi_test`` for the optimiser's ``fixed_poi_value`` kwarg. * A plain ``float`` is returned unchanged so the single-POI fast path in :func:`spey.optimizer.core.fit` is preserved. * A ``dict`` (string- or int-keyed) is delegated to :meth:`~spey.base.model_config.ModelConfig.resolve_poi_indices`, which translates string POI names to their integer indices. """ if not isinstance(poi_test, dict): return poi_test return self.backend.config().resolve_poi_indices(poi_test)
[docs] def prepare_for_fit( self, data: Optional[Union[List[float], np.ndarray]] = None, expected: ExpectationType = ExpectationType.observed, allow_negative_signal: Optional[bool] = True, **kwargs, ) -> Dict: r""" Prepare backend for the optimiser. Args: data (``Union[List[float], np.ndarray]``, default ``None``): input data that to fit expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. allow_negative_signal (``bool``, default ``True``): If ``True`` :math:`\hat\mu` value will be allowed to be negative. Returns: ``Dict``: Dictionary of necessary toolset for the fit. objective function, ``"func"``, use gradient boolean, ``"do_grad"`` and function to compute negative log-likelihood with given fit parameters, ``"nll"``. """ do_grad = kwargs.pop("do_grad", True) try: objective_and_grad = self.backend.get_objective_function( expected=expected, data=data, do_grad=do_grad ) except NotImplementedError: log.debug("Gradient is not available, will not be included in computation.") do_grad = False objective_and_grad = self.backend.get_objective_function( expected=expected, data=data, do_grad=do_grad ) constraints = kwargs.pop("constraints", []) if hasattr(self.backend, "constraints"): for constraint in self.backend.constraints: constraints.append(constraint) return { **kwargs, "func": objective_and_grad, "do_grad": do_grad, "model_configuration": self.backend.config( allow_negative_signal=allow_negative_signal ), "logpdf": self.backend.get_logpdf_func(expected=expected, data=data), "constraints": constraints, }
[docs] def likelihood( self, poi_test: PoiTest = 1.0, expected: ExpectationType = ExpectationType.observed, return_nll: bool = True, data: Optional[Union[List[float], np.ndarray]] = None, return_parameters: bool = False, init_pars: Optional[List[float]] = None, par_bounds: Optional[List[Tuple[float, float]]] = None, **kwargs, ) -> Union[float, Tuple[float, np.ndarray]]: r""" Compute the likelihood of the statistical model at a fixed parameter of interest. Args: poi_test (:obj:`~spey.interface.statistical_model.PoiTest`, default ``1.0``): Parameter of interest, :math:`\mu`. Can be a single ``float`` (fixes the primary POI identified by :attr:`~spey.base.model_config.ModelConfig.poi_index`) or a ``dict`` mapping POI indices (``int``) or names (``str``) to their fixed values. String keys are resolved via :attr:`~spey.base.model_config.ModelConfig.parameter_names`. When a ``float`` is given, behaviour is identical to previous versions. expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. return_nll (``bool``, default ``True``): If ``True``, returns negative log-likelihood value. if ``False`` returns likelihood value. data (``Union[List[float], np.ndarray]``, default ``None``): input data that to fit. If ``None`` data will be set according to ``expected`` input. return_parameters (``bool``, default ``False``): Return fit parameters. init_pars (``List[float]``, default ``None``): initial parameters for the optimiser par_bounds (``List[Tuple[float, float]]``, default ``None``): parameter bounds for the optimiser. kwargs: Keyword arguments forwarded through ``prepare_for_fit`` to the optimiser. The following keys are recognised: **Consumed by** ``prepare_for_fit``: * ``do_grad`` (``bool``, default ``True``): Whether to request the gradient of the objective function from the backend. Falls back to ``False`` automatically if the backend raises :obj:`NotImplementedError`. * ``constraints`` (``List[Dict]``, default ``[]``): Additional `scipy-style constraint dicts <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html>`_ to pass to the optimiser. Any constraints defined on the backend itself are always appended. **Consumed by** ``fit`` (the core optimisation loop): * ``minimizer`` (``str``, default ``"scipy"`` or the value of the ``SPEY_OPTIMISER`` environment variable): Selects the numerical minimiser. Accepted values are ``"scipy"`` and ``"minuit"`` (requires ``iminuit``). * ``hessian`` (``Callable[[np.ndarray], np.ndarray]``, default ``None``): Hessian of the objective function with respect to the variational parameters. Passed to scipy as the ``hess`` argument; ignored by the minuit minimiser. **Scipy-minimiser options** (used when ``minimizer="scipy"``): * ``method`` (``str``, default ``"SLSQP"``): Scipy optimisation method (e.g. ``"SLSQP"``, ``"L-BFGS-B"``, ``"trust-constr"``). * ``maxiter`` (``int``, default ``10000``): Maximum number of iterations. * ``tol`` (``float``, default ``1e-6``): Convergence tolerance. * ``disp`` (``bool``, default ``False``): If ``True``, print convergence messages. * ``ntrials`` (``int``, default ``1``): Number of re-tries with progressively expanded parameter bounds when the minimiser does not converge. **Minuit-minimiser options** (used when ``minimizer="minuit"``): * ``method`` (``str``, default ``"migrad"``): Minuit algorithm. Accepted values are ``"migrad"`` and ``"simplex"``. * ``maxiter`` (``int``, default ``10000``): Maximum number of function calls. * ``tol`` (``float``, default ``1e-6``): Convergence tolerance. * ``disp`` (``int``, default ``0``): Minuit print level (``0`` = silent). * ``strategy`` (``int``, default ``0``): Minuit strategy (``0`` = fast, ``1`` = default, ``2`` = slow but more accurate). * ``errordef`` (``float``, default ``Minuit.LIKELIHOOD``): Value by which Minuit defines a one-sigma interval (``0.5`` for NLL, ``1.0`` for :math:`\chi^2`). Unknown keys are logged as a warning and silently discarded by the minimiser. Returns: ``float``: Likelihood of the statistical model at a fixed signal strength. """ try: return self.backend.negative_loglikelihood( poi_test=poi_test, expected=expected, data=data, return_parameters=return_parameters, init_pars=init_pars, par_bounds=par_bounds, **kwargs, ) except NotImplementedError: log.debug( "Backend does not implement negative_loglikelihood(), " "falling back to numerical optimisation." ) if "fixed_poi_value" in kwargs: log.warning( "Passing 'fixed_poi_value' as a keyword argument to likelihood() is not " "supported and has been ignored. Use the 'poi_test' argument instead." ) kwargs.pop("fixed_poi_value") fit_opts = self.prepare_for_fit(expected=expected, data=data, **kwargs) if ( fit_opts["model_configuration"].npar == 1 and fit_opts["model_configuration"].poi_index is not None and isinstance(poi_test, float) ): fit_param = np.array([poi_test]) logpdf = fit_opts["logpdf"](fit_param) else: logpdf, fit_param = fit( **fit_opts, initial_parameters=init_pars, bounds=par_bounds, fixed_poi_value=self._resolve_poi_test(poi_test), ) log.debug(f"fit parameters : \n\t{fit_param}") out = -logpdf if return_nll else np.exp(logpdf) if return_parameters: return out, fit_param return out
[docs] @cache_results(maxsize=128, copy_on_return=True, per_instance=True) def generate_asimov_data( self, expected: ExpectationType = ExpectationType.observed, test_statistic: str = "qtilde", init_pars: Optional[List[float]] = None, par_bounds: Optional[List[Tuple[float, float]]] = None, **kwargs, ) -> List[float]: r""" Generate Asimov data for the statistical model. This function generates a set of parameters (nuisance and poi i.e. :math:`\theta` and :math:`\mu`) with respect to ``test_statistic`` input which determines the value of :math:`\mu` i.e. if ``test_statistic="q0"`` :math:`\mu=1` and 0 for anything else. The objective function is used to optimize the statistical model to find the fit parameters for fixed poi optimisation. Then fit parameters are used to retrieve the expected data through :func:`~spey.BackendBase.expected_data` function. Args: expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. test_statistic (``Text``, default ``"qtilde"``): test statistics. * ``'qtilde'``: (default) performs the calculation using the alternative test statistic, :math:`\tilde{q}_{\mu}`, see eq. (62) of :xref:`1007.1727` (:func:`~spey.hypothesis_testing.test_statistics.qmu_tilde`). .. warning:: Note that this assumes that :math:`\hat\mu\geq0`, hence ``allow_negative_signal`` assumed to be ``False``. If this function has been executed by user, ``spey`` assumes that this is taken care of throughout the external code consistently. Whilst computing p-values or upper limit on :math:`\mu` through ``spey`` this is taken care of automatically in the backend. * ``'q'``: performs the calculation using the test statistic :math:`q_{\mu}`, see eq. (54) of :xref:`1007.1727` (:func:`~spey.hypothesis_testing.test_statistics.qmu`). * ``'q0'``: performs the calculation using the discovery test statistic, see eq. (47) of :xref:`1007.1727` :math:`q_{0}` (:func:`~spey.hypothesis_testing.test_statistics.q0`). init_pars (``List[float]``, default ``None``): initial parameters for the optimiser par_bounds (``List[Tuple[float, float]]``, default ``None``): parameter bounds for the optimiser. kwargs: keyword arguments for the optimiser. Returns: ``List[float]``: Asimov data """ if "fixed_poi_value" in kwargs: log.warning( "Passing 'fixed_poi_value' as a keyword argument to generate_asimov_data() is " "not supported and has been ignored. The POI value used for Asimov data " "generation is determined by 'test_statistic' (1.0 for 'q0', 0.0 otherwise)." ) kwargs.pop("fixed_poi_value") fit_opts = self.prepare_for_fit( expected=expected, allow_negative_signal=test_statistic in ["q", "qmu"], **kwargs, ) _, fit_pars = fit( **fit_opts, initial_parameters=init_pars, bounds=par_bounds, fixed_poi_value=1.0 if test_statistic == "q0" else 0.0, ) log.debug(f"fit parameters:\n\t {fit_pars}") return self.backend.expected_data(fit_pars)
[docs] def asimov_likelihood( self, poi_test: PoiTest = 1.0, expected: ExpectationType = ExpectationType.observed, return_nll: bool = True, test_statistics: str = "qtilde", init_pars: Optional[List[float]] = None, par_bounds: Optional[List[Tuple[float, float]]] = None, **kwargs, ) -> float: r""" Compute likelihood of the statistical model generated with the Asimov data. Args: poi_test (:obj:`~spey.interface.statistical_model.PoiTest`, default ``1.0``): Parameter of interest, :math:`\mu`. Accepts the same formats as :func:`~spey.StatisticalModel.likelihood`: a plain ``float`` fixes the primary POI, while a ``dict`` of ``{index_or_name: value}`` fixes multiple parameters simultaneously. expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. return_nll (``bool``, default ``True``): If ``True``, returns negative log-likelihood value. if ``False`` returns likelihood value. test_statistics (``Text``, default ``"qtilde"``): test statistics. * ``'qtilde'``: (default) performs the calculation using the alternative test statistic, :math:`\tilde{q}_{\mu}`, see eq. (62) of :xref:`1007.1727` (:func:`~spey.hypothesis_testing.test_statistics.qmu_tilde`). .. warning:: Note that this assumes that :math:`\hat\mu\geq0`, hence ``allow_negative_signal`` assumed to be ``False``. If this function has been executed by user, ``spey`` assumes that this is taken care of throughout the external code consistently. Whilst computing p-values or upper limit on :math:`\mu` through ``spey`` this is taken care of automatically in the backend. * ``'q'``: performs the calculation using the test statistic :math:`q_{\mu}`, see eq. (54) of :xref:`1007.1727` (:func:`~spey.hypothesis_testing.test_statistics.qmu`). * ``'q0'``: performs the calculation using the discovery test statistic, see eq. (47) of :xref:`1007.1727` :math:`q_{0}` (:func:`~spey.hypothesis_testing.test_statistics.q0`). The choice of ``test_statistics`` will effect the generation of the Asimov data where the fit is performed via :math:`\mu=1` if ``test_statistics="q0"`` and :math:`\mu=0` for others. Note that this :math:`\mu` does not correspond to the ``poi_test`` input of this function but it determines how Asimov data is generated. init_pars (``List[float]``, default ``None``): initial parameters for the optimiser par_bounds (``List[Tuple[float, float]]``, default ``None``): parameter bounds for the optimiser. kwargs: Keyword arguments forwarded to both the Asimov data generation fit (via :func:`~spey.StatisticalModel.generate_asimov_data`) and the subsequent likelihood evaluation (via :func:`~spey.StatisticalModel.likelihood`). Both calls receive an independent copy of ``kwargs``. **Consumed by** ``prepare_for_fit``: * ``do_grad`` (``bool``, default ``True``): Whether to request the gradient of the objective function from the backend. Falls back to ``False`` automatically if the backend raises :obj:`NotImplementedError`. * ``constraints`` (``List[Dict]``, default ``[]``): Additional `scipy-style constraint dicts <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html>`_ to pass to the optimiser. Any constraints defined on the backend itself are always appended. **Consumed by** ``fit`` (the core optimisation loop): * ``minimizer`` (``str``, default ``"scipy"`` or the value of the ``SPEY_OPTIMISER`` environment variable): Selects the numerical minimiser. Accepted values are ``"scipy"`` and ``"minuit"`` (requires ``iminuit``). * ``hessian`` (``Callable[[np.ndarray], np.ndarray]``, default ``None``): Hessian of the objective function. Passed to scipy as ``hess``; ignored by minuit. **Scipy-minimiser options** (used when ``minimizer="scipy"``): * ``method`` (``str``, default ``"SLSQP"``): Scipy optimisation method. * ``maxiter`` (``int``, default ``10000``): Maximum number of iterations. * ``tol`` (``float``, default ``1e-6``): Convergence tolerance. * ``disp`` (``bool``, default ``False``): If ``True``, print convergence messages. * ``ntrials`` (``int``, default ``1``): Number of re-tries with progressively expanded parameter bounds when the minimiser does not converge. **Minuit-minimiser options** (used when ``minimizer="minuit"``): * ``method`` (``str``, default ``"migrad"``): Minuit algorithm (``"migrad"`` or ``"simplex"``). * ``maxiter`` (``int``, default ``10000``): Maximum number of function calls. * ``tol`` (``float``, default ``1e-6``): Convergence tolerance. * ``disp`` (``int``, default ``0``): Minuit print level (``0`` = silent). * ``strategy`` (``int``, default ``0``): Minuit strategy (``0`` = fast, ``1`` = default, ``2`` = slow but more accurate). * ``errordef`` (``float``, default ``Minuit.LIKELIHOOD``): Value by which Minuit defines a one-sigma interval (``0.5`` for NLL, ``1.0`` for :math:`\chi^2`). .. note:: ``fixed_poi_value`` is **not** an accepted kwarg here. The POI used for Asimov data generation is determined by ``test_statistics`` (``1.0`` for ``"q0"``, ``0.0`` otherwise), and ``poi_test`` fixes the POI for the likelihood evaluation. Passing ``fixed_poi_value`` would cause a :obj:`TypeError` in both inner calls and is therefore intercepted and discarded with a warning. Unknown keys are logged as a warning and silently discarded by the minimiser. Returns: ``float``: likelihood computed for asimov data """ try: return self.backend.asimov_negative_loglikelihood( poi_test=poi_test, expected=expected, test_statistics=test_statistics, init_pars=init_pars, par_bounds=par_bounds, **kwargs, ) except NotImplementedError: log.debug( "Backend does not implement `asimov_negative_loglikelihood()`, " "falling back to numerical optimisation." ) return self.likelihood( poi_test=poi_test, expected=expected, return_nll=return_nll, data=self.generate_asimov_data( expected=expected, test_statistic=test_statistics, init_pars=init_pars, par_bounds=par_bounds, **kwargs, ), init_pars=init_pars, par_bounds=par_bounds, **kwargs, )
[docs] @cache_results(maxsize=128, copy_on_return=True, per_instance=True) def maximize_likelihood( # pylint: disable = arguments-renamed self, return_nll: Optional[bool] = True, expected: Optional[ExpectationType] = ExpectationType.observed, allow_negative_signal: Optional[bool] = True, data: Optional[Union[List[float], np.ndarray]] = None, init_pars: Optional[List[float]] = None, par_bounds: Optional[List[Tuple[float, float]]] = None, poi_indices: Optional[List[Union[int, str]]] = None, **kwargs, ) -> Tuple[Union[float, Dict[Union[int, str], float]], float]: r""" Find the maximum of the likelihood. Args: return_nll (``bool``, default ``True``): If ``True``, returns negative log-likelihood value. if ``False`` returns likelihood value. expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. allow_negative_signal (``bool``, default ``True``): If ``True`` :math:`\hat\mu` value will be allowed to be negative. data (``Union[List[float], np.ndarray]``, default ``None``): input data that to fit. If ``None`` data will be set according to ``expected`` input. init_pars (``List[float]``, default ``None``): initial parameters for the optimiser par_bounds (``List[Tuple[float, float]]``, default ``None``): parameter bounds for the optimiser. poi_indices (``List[Union[int, str]]``, default ``None``): If ``None``, returns a single ``float`` for the primary POI (identified by :attr:`~spey.base.model_config.ModelConfig.poi_index`). If a list of parameter indices (``int``) or names (``str``) is provided, returns a ``dict`` mapping each requested key to its fitted value. String keys are resolved via :attr:`~spey.base.model_config.ModelConfig.parameter_names`. kwargs: Keyword arguments forwarded through ``prepare_for_fit`` to the optimiser. Accepts the same keys as :func:`~spey.StatisticalModel.likelihood`: **Consumed by** ``prepare_for_fit``: * ``do_grad`` (``bool``, default ``True``): Whether to request the gradient of the objective function from the backend. Falls back to ``False`` automatically if the backend raises :obj:`NotImplementedError`. * ``constraints`` (``List[Dict]``, default ``[]``): Additional `scipy-style constraint dicts <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html>`_ to pass to the optimiser. Any constraints defined on the backend itself are always appended. * ``fixed_poi_value`` (``Union[float, Dict[int, float]]``, default ``None``): Fix one or more parameters of interest during the optimisation while allowing the remaining parameters (nuisance and other POIs) to be minimised freely. A plain ``float`` fixes the primary POI (identified by :attr:`~spey.base.model_config.ModelConfig.poi_index`); a ``dict`` of ``{index: value}`` fixes multiple POIs simultaneously. This is particularly useful in multi-POI fits where, for example, one signal strength is held fixed while others are profiled out. **Consumed by** ``fit`` (the core optimisation loop): * ``minimizer`` (``str``, default ``"scipy"`` or the value of the ``SPEY_OPTIMISER`` environment variable): Selects the numerical minimiser. Accepted values are ``"scipy"`` and ``"minuit"`` (requires ``iminuit``). * ``hessian`` (``Callable[[np.ndarray], np.ndarray]``, default ``None``): Hessian of the objective function with respect to the variational parameters. Passed to scipy as the ``hess`` argument; ignored by the minuit minimiser. **Scipy-minimiser options** (used when ``minimizer="scipy"``): * ``method`` (``str``, default ``"SLSQP"``): Scipy optimisation method. * ``maxiter`` (``int``, default ``10000``): Maximum number of iterations. * ``tol`` (``float``, default ``1e-6``): Convergence tolerance. * ``disp`` (``bool``, default ``False``): If ``True``, print convergence messages. * ``ntrials`` (``int``, default ``1``): Number of re-tries with progressively expanded parameter bounds when the minimiser does not converge. **Minuit-minimiser options** (used when ``minimizer="minuit"``): * ``method`` (``str``, default ``"migrad"``): Minuit algorithm (``"migrad"`` or ``"simplex"``). * ``maxiter`` (``int``, default ``10000``): Maximum number of function calls. * ``tol`` (``float``, default ``1e-6``): Convergence tolerance. * ``disp`` (``int``, default ``0``): Minuit print level. * ``strategy`` (``int``, default ``0``): Minuit strategy (``0`` = fast, ``1`` = default, ``2`` = slow but more accurate). * ``errordef`` (``float``, default ``Minuit.LIKELIHOOD``): Value by which Minuit defines a one-sigma interval. Unknown keys are logged as a warning and silently discarded by the minimiser. Returns: ``Tuple[Union[float, Dict[Union[int, str], float]], float]``: When ``poi_indices=None``: :math:`\hat\mu` (``float``) and the (negative) log-likelihood. When ``poi_indices`` is provided: a ``dict`` of ``{index_or_name: fitted_value}`` and the (negative) log-likelihood. """ try: return self.backend.minimize_negative_loglikelihood( expected=expected, allow_negative_signal=allow_negative_signal, data=data, init_pars=init_pars, par_bounds=par_bounds, poi_indices=poi_indices, **kwargs, ) except NotImplementedError: log.debug( "Backend does not implement `minimize_negative_loglikelihood()`, " "falling back to numerical optimisation." ) if "fixed_poi_value" in kwargs: kwargs.update( {"fixed_poi_value": self._resolve_poi_test(kwargs["fixed_poi_value"])} ) fit_opts = self.prepare_for_fit( expected=expected, allow_negative_signal=allow_negative_signal, data=data, **kwargs, ) logpdf, fit_param = fit( **fit_opts, initial_parameters=init_pars, bounds=par_bounds ) log.debug(f"fit parameters:\n\t {fit_param}") nll = -logpdf if return_nll else np.exp(logpdf) if poi_indices is None: muhat = fit_param[self.backend.config().poi_index] return muhat, nll config = self.backend.config() result: Dict[Union[int, str], float] = {} for key in poi_indices: if isinstance(key, str): if config.parameter_names is None: raise ValueError( "Cannot resolve POI name: parameter_names not set in ModelConfig." ) idx = config.parameter_names.index(key) else: idx = int(key) result[key] = float(fit_param[idx]) return result, nll
[docs] @cache_results(maxsize=128, copy_on_return=True, per_instance=True) def maximize_asimov_likelihood( # pylint: disable = arguments-renamed self, return_nll: bool = True, expected: ExpectationType = ExpectationType.observed, test_statistics: str = "qtilde", init_pars: Optional[List[float]] = None, par_bounds: Optional[List[Tuple[float, float]]] = None, poi_indices: Optional[List[Union[int, str]]] = None, **kwargs, ) -> Tuple[Union[float, Dict[Union[int, str], float]], float]: r""" Find the maximum of the likelihood which computed with respect to Asimov data. Args: return_nll (``bool``, default ``True``): If ``True``, returns negative log-likelihood value. if ``False`` returns likelihood value. expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. test_statistics (``Text``, default ``"qtilde"``): test statistic. * ``'qtilde'``: (default) performs the calculation using the alternative test statistic, :math:`\tilde{q}_{\mu}`, see eq. (62) of :xref:`1007.1727` (:func:`~spey.hypothesis_testing.test_statistics.qmu_tilde`). .. warning:: Note that this assumes that :math:`\hat\mu\geq0`, hence ``allow_negative_signal`` assumed to be ``False``. If this function has been executed by user, ``spey`` assumes that this is taken care of throughout the external code consistently. Whilst computing p-values or upper limit on :math:`\mu` through ``spey`` this is taken care of automatically in the backend. * ``'q'``: performs the calculation using the test statistic :math:`q_{\mu}`, see eq. (54) of :xref:`1007.1727` (:func:`~spey.hypothesis_testing.test_statistics.qmu`). * ``'q0'``: performs the calculation using the discovery test statistic, see eq. (47) of :xref:`1007.1727` :math:`q_{0}` (:func:`~spey.hypothesis_testing.test_statistics.q0`). init_pars (``List[float]``, default ``None``): initial parameters for the optimiser par_bounds (``List[Tuple[float, float]]``, default ``None``): parameter bounds for the optimiser. poi_indices (``List[Union[int, str]]``, default ``None``): If ``None``, returns the primary POI value as a single ``float``. If a list of parameter indices (``int``) or names (``str``) is provided, returns a ``dict`` mapping each requested key to its fitted value. Passed directly to :func:`~spey.StatisticalModel.maximize_likelihood`. kwargs: Keyword arguments forwarded to both the Asimov data generation fit (via :func:`~spey.StatisticalModel.generate_asimov_data`) and the subsequent maximisation (via :func:`~spey.StatisticalModel.maximize_likelihood`). Both calls receive an independent copy of ``kwargs``. **Consumed by** ``prepare_for_fit``: * ``do_grad`` (``bool``, default ``True``): Whether to request the gradient of the objective function from the backend. Falls back to ``False`` automatically if the backend raises :obj:`NotImplementedError`. * ``constraints`` (``List[Dict]``, default ``[]``): Additional `scipy-style constraint dicts <https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html>`_ to pass to the optimiser. Any constraints defined on the backend itself are always appended. **Consumed by** ``fit`` (the core optimisation loop): * ``minimizer`` (``str``, default ``"scipy"`` or the value of the ``SPEY_OPTIMISER`` environment variable): Selects the numerical minimiser. Accepted values are ``"scipy"`` and ``"minuit"`` (requires ``iminuit``). * ``hessian`` (``Callable[[np.ndarray], np.ndarray]``, default ``None``): Hessian of the objective function. Passed to scipy as ``hess``; ignored by minuit. * ``fixed_poi_value`` (``Union[float, Dict[int, float]]``, default ``None``): Fix one or more POIs during the **maximisation** step while allowing the remaining parameters to be profiled freely. A plain ``float`` fixes the primary POI; a ``dict`` of ``{index: value}`` fixes multiple POIs simultaneously. This kwarg is intercepted and discarded (with a warning) in the Asimov data generation step, where the POI is already determined by ``test_statistics``. **Scipy-minimiser options** (used when ``minimizer="scipy"``): * ``method`` (``str``, default ``"SLSQP"``): Scipy optimisation method. * ``maxiter`` (``int``, default ``10000``): Maximum number of iterations. * ``tol`` (``float``, default ``1e-6``): Convergence tolerance. * ``disp`` (``bool``, default ``False``): If ``True``, print convergence messages. * ``ntrials`` (``int``, default ``1``): Number of re-tries with progressively expanded parameter bounds when the minimiser does not converge. **Minuit-minimiser options** (used when ``minimizer="minuit"``): * ``method`` (``str``, default ``"migrad"``): Minuit algorithm (``"migrad"`` or ``"simplex"``). * ``maxiter`` (``int``, default ``10000``): Maximum number of function calls. * ``tol`` (``float``, default ``1e-6``): Convergence tolerance. * ``disp`` (``int``, default ``0``): Minuit print level (``0`` = silent). * ``strategy`` (``int``, default ``0``): Minuit strategy (``0`` = fast, ``1`` = default, ``2`` = slow but more accurate). * ``errordef`` (``float``, default ``Minuit.LIKELIHOOD``): Value by which Minuit defines a one-sigma interval (``0.5`` for NLL, ``1.0`` for :math:`\chi^2`). Unknown keys are logged as a warning and silently discarded by the minimiser. Returns: ``Tuple[Union[float, Dict[Union[int, str], float]], float]``: When ``poi_indices=None``: :math:`\hat\mu` (``float``) and the (negative) log-likelihood. When ``poi_indices`` is provided: a ``dict`` of ``{index_or_name: fitted_value}`` and the (negative) log-likelihood. """ try: return self.backend.minimize_asimov_negative_loglikelihood( expected=expected, test_statistics=test_statistics, init_pars=init_pars, par_bounds=par_bounds, poi_indices=poi_indices, **kwargs, ) except NotImplementedError: log.debug( "Backend does not implement `minimize_asimov_negative_loglikelihood()`, " "falling back to numerical optimisation." ) allow_negative_signal: bool = test_statistics in ["q", "qmu"] return self.maximize_likelihood( return_nll=return_nll, expected=expected, allow_negative_signal=allow_negative_signal, data=self.generate_asimov_data( expected=expected, test_statistic=test_statistics, init_pars=init_pars, par_bounds=par_bounds, **kwargs, ), init_pars=init_pars, par_bounds=par_bounds, poi_indices=poi_indices, **kwargs, )
[docs] def fixed_poi_sampler( self, poi_test: PoiTest, size: Optional[int] = None, expected: ExpectationType = ExpectationType.observed, init_pars: Optional[List[float]] = None, par_bounds: Optional[List[Tuple[float, float]]] = None, **kwargs, ) -> Union[np.ndarray, Callable[[int], np.ndarray]]: r""" Sample data from the statistical model with fixed parameter of interest. Args: poi_test (:obj:`~spey.interface.statistical_model.PoiTest`): Parameter of interest, :math:`\mu`. A plain ``float`` fixes the primary POI; a ``dict`` of ``{index_or_name: value}`` fixes multiple parameters at once. size (``int``, default ``None``): sample size. If ``None`` a callable function will be returned which takes sample size as input. expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. init_pars (``List[float]``, default ``None``): initial parameters for the optimiser par_bounds (``List[Tuple[float, float]]``, default ``None``): parameter bounds for the optimiser. kwargs: keyword arguments for the optimiser. Raises: ~spey.system.exceptions.MethodNotAvailable: If backend does not have sampler implementation. Returns: ``Union[np.ndarray, Callable[[int], np.ndarray]]``: Sampled data with shape of ``(size, number of bins)`` or callable function to sample from directly. """ fit_opts = self.prepare_for_fit(expected=expected, **kwargs) if ( fit_opts["model_configuration"].npar == 1 and fit_opts["model_configuration"].poi_index is not None and isinstance(poi_test, float) ): fit_param = np.array([poi_test]) else: _, fit_param = fit( **fit_opts, initial_parameters=init_pars, bounds=par_bounds, fixed_poi_value=self._resolve_poi_test(poi_test), ) log.debug(f"fit parameters:\n\t {fit_param}") try: sampler = self.backend.get_sampler(fit_param) except NotImplementedError as exc: raise MethodNotAvailable( f"{self.backend_type} backend does not have sampling capabilities" ) from exc return sampler(size) if isinstance(size, int) else sampler
[docs] def sigma_mu_from_hessian( self, poi_test: PoiTest, expected: ExpectationType = ExpectationType.observed, init_pars: Optional[List[float]] = None, par_bounds: Optional[List[Tuple[float, float]]] = None, **kwargs, ) -> float: r""" Compute variance of :math:`\mu` from inverse Hessian. See eq. (27-28) in :xref:`1007.1727`. Args: poi_test (:obj:`~spey.interface.statistical_model.PoiTest`): Parameter of interest, :math:`\mu`. A plain ``float`` fixes the primary POI; a ``dict`` of ``{index_or_name: value}`` fixes multiple parameters simultaneously. expected (~spey.ExpectationType): Sets which values the fitting algorithm should focus and p-values to be computed. * :obj:`~spey.ExpectationType.observed`: Computes the p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth (default). * :obj:`~spey.ExpectationType.aposteriori`: Computes the expected p-values with via post-fit prescription which means that the experimental data will be assumed to be the truth. * :obj:`~spey.ExpectationType.apriori`: Computes the expected p-values with via pre-fit prescription which means that the SM will be assumed to be the truth. init_pars (``List[float]``, default ``None``): initial parameters for the optimiser par_bounds (``List[Tuple[float, float]]``, default ``None``): parameter bounds for the optimiser. kwargs: keyword arguments for the optimiser. Raises: ~spey.system.exceptions.MethodNotAvailable: If backend does not have Hessian implementation. Returns: ``float``: variance on parameter of interest. """ try: hessian_func = self.backend.get_hessian_logpdf_func(expected=expected) except NotImplementedError as exc: raise MethodNotAvailable( f"{self.backend_type} backend does not have Hessian definition." ) from exc fit_opts = self.prepare_for_fit(expected=expected, **kwargs) _ = fit_opts.pop("logpdf") _, fit_param = fit( **fit_opts, initial_parameters=init_pars, bounds=par_bounds, fixed_poi_value=self._resolve_poi_test(poi_test), ) log.debug(f"fit parameters:\n\t {fit_param}") hessian = -1.0 * hessian_func(fit_param) log.debug(f"full hessian: {hessian}") poi_index = self.backend.config().poi_index return np.sqrt(np.linalg.inv(hessian)[poi_index, poi_index])
[docs] def combine(self, other, **kwargs): """ Combination routine between two statistical models. .. note:: This function's availability is backend dependent. Args: other (:obj:`~spey.StatisticalModel`): Statistical model to be combined with this model kwargs: backend specific arguments. Raises: :obj:`~spey.system.exceptions.CombinerNotAvailable`: If this statistical model does not have a combination routine implementation. ``AssertionError``: If the combination routine in the backend does not return a :obj:`~spey.BackendBase` object. Returns: :obj:`~spey.StatisticalModel`: Returns a new combined statistical model. """ try: combined = self.backend.combine(other.backend, **kwargs) assert isinstance(combined, BackendBase), "Invalid combination operation." return StatisticalModel( backend=combined, analysis=f"combine[{self.analysis}, {other.analysis}]" ) except NotImplementedError as err: raise CombinerNotAvailable( f"{self.backend_type} backend does not have a combination routine." ) from err
def __matmul__(self, other): """ Combine two statistical models using the ``@`` operator. Equivalent to calling :func:`~spey.StatisticalModel.combine`. The combined model inherits its analysis label from both operands: .. code-block:: python combined = model_a @ model_b # same as: combined = model_a.combine(model_b) Args: other (:obj:`~spey.StatisticalModel`): Statistical model to combine with. Raises: ~spey.system.exceptions.CombinerNotAvailable: If this model's backend does not implement a combination routine. Returns: :obj:`~spey.StatisticalModel`: A new combined statistical model. """ return self.combine(other)
[docs] def statistical_model_wrapper( func: BackendBase, ) -> Callable[[Any], StatisticalModel]: """ Decorator that promotes a :class:`~spey.BackendBase` constructor into a :class:`~spey.StatisticalModel` factory. :func:`~spey.get_backend` applies this decorator automatically before returning a backend to the user, so direct use is only required when registering a custom backend outside of ``spey``'s plugin system. The returned callable accepts all backend-specific positional and keyword arguments plus the three universal keyword arguments documented below, and returns a fully initialised :class:`~spey.StatisticalModel`. Example usage for custom backend registration: .. code-block:: python from spey.interface.statistical_model import statistical_model_wrapper from my_package import MyBackend MyModel = statistical_model_wrapper(MyBackend) model = MyModel( *backend_args, analysis="my_analysis", xsection=0.05, ) Args: func (~spey.BackendBase): Backend class (or callable) whose constructor will be wrapped. Must produce an instance that inherits :class:`~spey.BackendBase`. Raises: :obj:`AssertionError`: If the object returned by ``func`` does not inherit :class:`~spey.BackendBase`. Returns: ``Callable[[Any], StatisticalModel]``: A wrapper callable that accepts the following inputs: * **\\*args**: Backend-specific positional arguments forwarded to ``func``. * **analysis** (``str``, default ``"__unknown_analysis__"``): Unique identifier of the statistical model used for book-keeping purposes. * **xsection** (``float``, default ``nan``): Signal cross section in user-defined units. Only required for cross-section upper-limit computations. * **ntoys** (``int``, default ``1000``): Number of toy pseudo-experiments for toy-based hypothesis testing. * **\\*\\*kwargs**: Backend-specific keyword arguments forwarded to ``func``. """ @wraps(func) def wrapper( *args, analysis: str = "__unknown_analysis__", xsection: float = np.nan, ntoys: int = 1000, **kwargs, ) -> StatisticalModel: return StatisticalModel( backend=func(*args, **kwargs), analysis=analysis, xsection=xsection, ntoys=ntoys, ) docstring = ( "\n\n" + "<>" * 30 + "\n\n" + """ Universal keyword arguments added by :func:`~spey.statistical_model_wrapper`: Args: analysis (``str``, default ``"__unknown_analysis__"``): Unique identifier of the statistical model used for book-keeping purposes. xsection (``float``, default ``nan``): Signal cross section in user-defined units. Only required for cross-section upper-limit computations (e.g. :attr:`~spey.StatisticalModel.s95obs`). ntoys (``int``, default ``1000``): Number of toy pseudo-experiments used by the toy-based hypothesis-testing calculator. Ignored for asymptotic and :math:`\\chi^2` calculators. Raises: :obj:`AssertionError`: If the backend instance does not inherit :class:`~spey.BackendBase`. Returns: ~spey.StatisticalModel: Backend wrapped with the unified :class:`~spey.StatisticalModel` interface. """ ) if wrapper.__doc__ is not None: wrapper.__doc__ += docstring else: wrapper.__doc__ = docstring return wrapper