Source code for spey.backends.default_pdf.third_moment

r"""
Third-Moment Expansion Tools
==============================

This module provides the mathematical machinery for the **third-moment expansion**
of the simplified likelihood, following :xref:`1809.05548` Sec. 2.

Motivation
----------

The standard simplified likelihood models background uncertainties as a multivariate
normal distribution centred at zero with covariance :math:`\Sigma`.  This is exact
only when the background fluctuations are themselves Gaussian.  When significant
asymmetry (skewness) is present — as is common for small background yields or
log-normal-shaped systematic uncertainties — the Gaussian approximation can over-
or under-cover.

The third-moment expansion extends the simplified likelihood by incorporating the
diagonal elements of the third central moment tensor :math:`m^{(3)}_i`, which
characterise the skewness of the background distribution in each bin.

Mathematical background
-----------------------

Given the first three moments of the background:

* :math:`m^{(1)}_i` — expected value (mean),
* :math:`m^{(2)}_{ij}` — covariance matrix (:math:`\Sigma_{ij}`),
* :math:`m^{(3)}_i` — diagonal third central moments (skewness per bin),

one defines per-bin coefficients via eqs. 2.9–2.11 of :xref:`1809.05548`:

.. math::

    C_i &= -\mathrm{sign}(m^{(3)}_i)\,\sqrt{2\,\Sigma_{ii}}
           \cos\!\left(\frac{4\pi}{3}
           + \frac{1}{3}\arctan\!\sqrt{\frac{8\,\Sigma_{ii}^3}{(m^{(3)}_i)^2} - 1}
           \right), \\[4pt]
    B_i &= \sqrt{\Sigma_{ii} - 2C_i^2}, \\[4pt]
    A_i &= m^{(1)}_i - C_i.

These coefficients enter the quadratic :math:`\lambda` function (eq. 2.6 of
:xref:`1809.05548`):

.. math::

    \lambda_i(\mu, \theta_i) = \mu\, n^s_i + A_i + B_i\,\theta_i + C_i\,\theta_i^2,

and a modified inter-bin correlation matrix (eq. 2.12 of :xref:`1809.05548`):

.. math::

    \rho_{ij} = \frac{1}{4C_i C_j}
    \left(\sqrt{(B_i B_j)^2 + 8\,C_i C_j\,\Sigma_{ij}} - B_i B_j\right).

**Validity condition**: the expansion requires :math:`8\,\Sigma_{ii}^3 \geq (m^{(3)}_i)^2`
for each bin.  Bins that violate this condition are treated with :math:`C_i = 0`
(reverting to the standard simplified likelihood for that bin).

When :math:`m^{(3)}_i = 0` for all bins the expansion reduces identically to the
standard simplified likelihood (:class:`~spey.backends.default_pdf.CorrelatedBackground`).

.. autosummary::
    :toctree: ../_generated/

    third_moment_expansion

Computing third moments from asymmetric uncertainties
------------------------------------------------------

When only asymmetric uncertainty envelopes :math:`(\sigma^+_i, \sigma^-_i)` are
available, the third moment can be estimated analytically by modelling the
background as a **bifurcated Gaussian** — a piecewise normal distribution that
uses :math:`\sigma^-_i` for :math:`\theta < 0` and :math:`\sigma^+_i` for
:math:`\theta \geq 0`:

.. math::

    p(\theta) = \frac{2}{\sigma^+_i + \sigma^-_i}
    \begin{cases}
      \mathcal{N}(\theta \mid 0, \sigma^-_i), & \theta < 0, \\
      \mathcal{N}(\theta \mid 0, \sigma^+_i), & \theta \geq 0.
    \end{cases}

The third central moment of this distribution is

.. math::

    m^{(3)}_i = \frac{2}{\sigma^+_i + \sigma^-_i}
    \left[\sigma^-_i \int_{-\infty}^{0} \theta^3\,
          \mathcal{N}(\theta\mid 0,\sigma^-_i)\,d\theta
    + \sigma^+_i \int_{0}^{\infty} \theta^3\,
          \mathcal{N}(\theta\mid 0,\sigma^+_i)\,d\theta\right],

which is computed numerically by :func:`compute_third_moments`.
See :xref:`2602.18611` for a usecase.

.. autosummary::
    :toctree: ../_generated/

    compute_third_moments

References
----------
* CMS Collaboration, *Simplified likelihood for the re-interpretation of public CMS
  results*, :xref:`1809.05548`, Sec. 2.
* CMS Collaboration, *Combined measurements and interpretations of Higgs boson
  production and decay in proton-proton collisions at* :math:`\sqrt{s} = 13` TeV,
  :xref:`2602.18611`
"""
import logging
from typing import Optional, Tuple, Union

import autograd.numpy as np
from scipy import integrate
from scipy.stats import norm

from spey.system.exceptions import warning_tracker

# pylint: disable=E1101,E1120,W1203
log = logging.getLogger("Spey")


[docs] @warning_tracker def third_moment_expansion( expectation_value: np.ndarray, covariance_matrix: np.ndarray, third_moment: Optional[np.ndarray] = None, return_correlation_matrix: bool = False, ) -> Tuple: r""" Compute the :math:`A`, :math:`B`, :math:`C` coefficients and optional modified correlation matrix for the third-moment expansion of the simplified likelihood. Given the background expectation values :math:`m^{(1)}_i`, covariance matrix :math:`\Sigma_{ij}`, and diagonal third moments :math:`m^{(3)}_i`, the function implements eqs. 2.9–2.12 of :xref:`1809.05548`: .. math:: C_i &= -\mathrm{sign}(m^{(3)}_i)\,\sqrt{2\,\Sigma_{ii}} \cos\!\left(\frac{4\pi}{3} + \frac{1}{3}\arctan\!\sqrt{\frac{8\,\Sigma_{ii}^3}{(m^{(3)}_i)^2} - 1} \right), \\[4pt] B_i &= \sqrt{\Sigma_{ii} - 2C_i^2}, \\[4pt] A_i &= m^{(1)}_i - C_i. When ``return_correlation_matrix=True``, the modified inter-bin correlation matrix is also computed (eq. 2.12): .. math:: \rho_{ij} = \frac{1}{4C_i C_j} \left(\sqrt{(B_i B_j)^2 + 8\,C_i C_j\,\Sigma_{ij}} - B_i B_j\right). A small regularisation :math:`\varepsilon = 10^{-5}` is added to :math:`C_i` in the denominator to avoid division by zero when :math:`C_i = 0`. .. attention:: The expansion requires :math:`8\,\Sigma_{ii}^3 \geq (m^{(3)}_i)^2` for every bin. Bins that violate this condition produce ``NaN`` values for :math:`C_i`, which are silently replaced by zero, effectively reverting to the standard simplified likelihood (:math:`\lambda_i = \mu n^s_i + n^b_i + B_i\,\theta_i`) for those bins. A warning is emitted when this occurs. Args: expectation_value (``np.ndarray``): Per-bin background expectation values :math:`\{m^{(1)}_i\}` (shape :math:`N`). covariance_matrix (``np.ndarray``): :math:`N \times N` background covariance matrix :math:`\Sigma`. third_moment (``np.ndarray``): Diagonal elements of the third-moment tensor :math:`\{m^{(3)}_i\}` (shape :math:`N`). return_correlation_matrix (``bool``, default ``False``): If ``True``, also compute and return the modified correlation matrix :math:`\rho`. Returns: ``Tuple[np.ndarray, np.ndarray, np.ndarray] | Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]``: The triple :math:`(A, B, C)` (each of shape :math:`N`), and additionally :math:`\rho` (shape :math:`N \times N`) when ``return_correlation_matrix=True``. """ cov_diag = np.diag(covariance_matrix) if not np.all(8.0 * cov_diag**3 >= third_moment**2): log.warning( r"Third moments does not satisfy the following condition: $8\Sigma_{ii}^3 \geq (m^{(3)}_i)^2$" ) log.warning("The values that do not satisfy this condition will be set to zero.") # arXiv:1809.05548 eq. 2.9 C = ( -np.sign(third_moment) * np.sqrt(2.0 * cov_diag) * np.cos( (4.0 * np.pi / 3.0) + (1.0 / 3.0) * np.arctan(np.sqrt(((8.0 * cov_diag**3) / third_moment**2) - 1.0)) ) ) log.debug(f"C: {C}") C = np.where(np.isnan(C), 0.0, C) # arXiv:1809.05548 eq. 2.10 B = np.sqrt(cov_diag - 2 * C**2) log.debug(f"B: {B}") # arXiv:1809.05548 eq. 2.11 A = expectation_value - C log.debug(f"A: {A}") # arXiv:1809.05548 eq. 2.12 eps = 1e-5 if return_correlation_matrix: corr = np.zeros((C.shape[0], C.shape[0])) for i in range(corr.shape[0]): for j in range(corr.shape[0]): ci = C[i] + eps if C[i] >= 0 else C[i] - eps cj = C[j] + eps if C[j] >= 0 else C[j] - eps cicj = ci * cj bibj = B[i] * B[j] discr1 = bibj**2 discr2 = 8 * cicj * covariance_matrix[i, j] discr = discr1 + discr2 corr[i, j] = (np.sqrt(abs(discr)) - bibj) / 4 / cicj if i != j: corr[j, i] = corr[i, j] log.debug(f"rho: {corr}") return A, B, C, corr return A, B, C
[docs] def compute_third_moments( absolute_upper_uncertainties: np.ndarray, absolute_lower_uncertainties: np.ndarray, return_integration_error: bool = False, ) -> Union[Tuple[np.ndarray, np.ndarray], np.ndarray]: r""" Estimate the diagonal third central moments :math:`\{m^{(3)}_i\}` from asymmetric uncertainty envelopes using a bifurcated Gaussian model. When only the upper and lower uncertainty envelopes :math:`(\sigma^+_i, \sigma^-_i)` are available (rather than the full background distribution), the background fluctuation in bin :math:`i` is modelled as a **bifurcated Gaussian**: .. math:: p_i(\theta) = \frac{2}{\sigma^+_i + \sigma^-_i} \begin{cases} \mathcal{N}(\theta \mid 0,\, \sigma^-_i), & \theta < 0, \\ \mathcal{N}(\theta \mid 0,\, \sigma^+_i), & \theta \geq 0. \end{cases} The third central moment of this distribution is .. math:: m^{(3)}_i = \mathbb{E}_i[\theta^3] = \frac{2}{\sigma^+_i + \sigma^-_i} \left[ \sigma^-_i \int_{-\infty}^{0} \theta^3\, \mathcal{N}(\theta \mid 0, \sigma^-_i)\,d\theta + \sigma^+_i \int_{0}^{\infty} \theta^3\, \mathcal{N}(\theta \mid 0, \sigma^+_i)\,d\theta \right], which is evaluated numerically via :func:`scipy.integrate.quad`. .. note:: The :math:`k`-th central moment of a distribution :math:`p` is defined as .. math:: m^{(k)} = \mathbb{E}[(X - c)^k] = \int_{-\infty}^{\infty} (x - c)^k\, p(x)\, dx, with :math:`c = 0` for the bifurcated Gaussian (which is already centred). .. attention:: :func:`third_moment_expansion` requires :math:`8\,\Sigma_{ii}^3 \geq (m^{(3)}_i)^2`. Because this function derives :math:`m^{(3)}_i` independently from the covariance matrix, the condition is not guaranteed to hold. Whether it is satisfied depends on the interplay between the envelopes and the diagonal of :math:`\Sigma`. Args: absolute_upper_uncertainties (``np.ndarray``): Per-bin upper absolute uncertainties :math:`\{\sigma^+_i\}` (positive values). absolute_lower_uncertainties (``np.ndarray``): Per-bin lower absolute uncertainties :math:`\{\sigma^-_i\}` (positive values; signs are taken as absolute internally). return_integration_error (``bool``, default ``False``): If ``True``, also return the per-bin numerical integration errors from :func:`scipy.integrate.quad`. Returns: ``np.ndarray`` or ``Tuple[np.ndarray, np.ndarray]``: Array of diagonal third moments :math:`\{m^{(3)}_i\}` (shape :math:`N`). When ``return_integration_error=True``, a 2-tuple ``(third_moments, errors)`` is returned. """ def compute_x3BifurgatedGaussian(x: float, upper: float, lower: float) -> float: Norm = 2.0 / (upper + lower) if x >= 0.0: # integrate from 0 to inf return Norm * upper * x**3 * norm.pdf(x, 0.0, upper) # integrate from -inf to 0 return Norm * lower * x**3 * norm.pdf(x, 0.0, lower) third_moment, error = [], [] for upper, lower in zip(absolute_upper_uncertainties, absolute_lower_uncertainties): third_moment_tmp, error_tmp = integrate.quad( compute_x3BifurgatedGaussian, -np.inf, np.inf, args=(abs(upper), abs(lower)) ) third_moment.append(third_moment_tmp) error.append(error_tmp) if return_integration_error: return np.array(third_moment), np.array(error) log.debug(f"Error: {error}") return np.array(third_moment)