Source code for skfolio.distribution.copula._student_t

"""Bivariate Student's t Copula Estimation."""

# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# Credits: Matteo Manzi, Vincent Maladière, Carlo Nicolini
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import math

import numpy as np
import scipy.integrate as si
import scipy.optimize as so
import scipy.special as sp
import scipy.stats as st
import sklearn.utils.validation as skv

from skfolio.distribution.copula._base import _RHO_BOUNDS, BaseBivariateCopula
from skfolio.distribution.copula._utils import _apply_margin_swap
from skfolio.typing import ArrayLike, FloatArray

# Student's t copula with dof less than 2.0 is so extremely heavy-tailed that even the
# mean (and many moments) of the distribution do not exist. So Impractical in practice,
# and dof above 50 tends to a Gaussian Copula so we limit it to the interval [2, 50] for
# improved stability and robustness.
_DOF_BOUNDS = (2.0, 50.0)


[docs] class StudentTCopula(BaseBivariateCopula): r"""Bivariate Student's t Copula Estimation. The bivariate Student's t copula density is defined as: .. math:: C_{\nu, \rho}(u, v) = T_{\nu, \rho} \Bigl(t_{\nu}^{-1}(u),\;t_{\nu}^{-1}(v)\Bigr) where: - :math:`\nu > 0` is the degrees of freedom. - :math:`\rho \in (-1, 1)` is the correlation coefficient. - :math:`T_{\nu, \rho}(x, y)` is the CDF of the bivariate t-distribution. - :math:`t_{\nu}^{-1}(p)` is the quantile function (inverse CDF) of the univariate t-distribution. Student's t copula with degrees of freedom (dof) less than 2.0 is extremely heavy-tailed, to the extent that even the mean (and many moments) do not exist, rendering it impractical. Conversely, for dof above 50 the t copula behaves similarly to a Gaussian copula. Thus, for improved stability and robustness, the dof is limited to the interval [2, 50]. .. note:: Rotations are not needed for elliptical copula (e.g., Gaussian or Student-t) because its correlation parameter :math:`\rho \in (-1, 1)` naturally covers both positive and negative dependence, and they exhibit symmetric tail behavior. Parameters ---------- itau : bool, default=True itau : bool, default=True If True, :math:`\rho` is estimated using the Kendall's tau inversion method; otherwise, we use the MLE (Maximum Likelihood Estimation) method. The MLE is slower but more accurate. kendall_tau : float, optional If `itau` is True and `kendall_tau` is provided, this value is used; otherwise, it is computed. tolerance : float, default=1e-4 Convergence tolerance for the MLE optimization. random_state : int, RandomState instance or None, default=None Seed or random state to ensure reproducibility. Attributes ---------- rho_ : float Fitted correlation coefficient (:math:`\rho`) in [-1, 1]. dof_ : float Fitted degrees of freedom (:math:`\nu`) > 2. Examples -------- >>> from skfolio.datasets import load_sp500_dataset >>> from skfolio.preprocessing import prices_to_returns >>> from skfolio.distribution import StudentTCopula, compute_pseudo_observations >>> >>> # Load historical prices and convert them to returns >>> prices = load_sp500_dataset() >>> X = prices_to_returns(prices).tail(504) >>> X = X[["AAPL", "JPM"]] >>> >>> # Convert returns to pseudo observation in the interval [0,1] >>> X = compute_pseudo_observations(X) >>> >>> # Initialize the Copula estimator >>> model = StudentTCopula() >>> >>> # Fit the model to the data. >>> model.fit(X) StudentTCopula(...) >>> >>> # Display the fitted parameter and tail dependence coefficients >>> print(model.fitted_repr) StudentTCopula(rho=0.385, dof=4.90) >>> print(model.lower_tail_dependence) 0.157... >>> print(model.upper_tail_dependence) 0.157... >>> >>> # Compute the log-likelihood, total log-likelihood, CDF, Partial Derivative, >>> # Inverse Partial Derivative, AIC, and BIC >>> log_likelihood = model.score_samples(X) >>> score = model.score(X) >>> cdf = model.cdf(X) >>> p = model.partial_derivative(X) >>> u = model.inverse_partial_derivative(X) >>> aic = model.aic(X) >>> bic = model.bic(X) >>> >>> # Generate 5 new samples >>> samples = model.sample(n_samples=5) >>> >>> # Plot the tail concentration function. >>> fig = model.plot_tail_concentration() >>> >>> # Plot a 2D contour of the estimated PDF. >>> fig = model.plot_pdf_2d() >>> >>> # Plot a 3D surface of the estimated PDF. >>> fig = model.plot_pdf_3d() References ---------- .. [1] "An Introduction to Copulas (2nd ed.)", Nelsen (2006) .. [2] "Multivariate Models and Dependence Concepts", Joe, Chapman & Hall (1997) .. [3] "Quantitative Risk Management: Concepts, Techniques and Tools", McNeil, Frey & Embrechts (2005) .. [4] "The t Copula and Related Copulas", Demarta & McNeil (2005) .. [5] "Copula Methods in Finance", Cherubini, Luciano & Vecchiato (2004) """ rho_: float dof_: float _n_params = 2 def __init__( self, itau: bool = True, kendall_tau: float | None = None, tolerance: float = 1e-4, random_state: int | None = None, ) -> None: super().__init__(random_state=random_state) self.itau = itau self.kendall_tau = kendall_tau self.tolerance = tolerance
[docs] def fit(self, X: ArrayLike, y: None = None) -> StudentTCopula: r"""Fit the Bivariate Student's t Copula. If `itau` is True, it uses a Kendall-based two-step method: - Estimates the correlation parameter (:math:`\rho`) from Kendall's tau inversion. - Optimizes the degrees of freedom (:math:`\nu`) by maximizing the log-likelihood. Otherwise, it uses the full MLE method: optimizes both :math:`\rho` and :math:`\nu` by maximizing the log-likelihood. Parameters ---------- X : array-like of shape (n_observations, 2) An array of bivariate inputs `(u, v)` where each row represents a bivariate observation. Both `u` and `v` must be in the interval [0, 1], having been transformed to uniform marginals. y : None Ignored. Provided for compatibility with scikit-learn's API. Returns ------- self : StudentTCopula Returns the instance itself. """ X = self._validate_X(X, reset=True) if self.kendall_tau is None: kendall_tau = st.kendalltau(X[:, 0], X[:, 1]).statistic else: kendall_tau = self.kendall_tau # Either used directly or for initial guess rho_from_tau = np.clip( np.sin((np.pi * kendall_tau) / 2.0), a_min=_RHO_BOUNDS[0], a_max=_RHO_BOUNDS[1], ) if self.itau: res = so.minimize_scalar( _neg_log_likelihood, args=( rho_from_tau, X, ), bounds=_DOF_BOUNDS, method="bounded", options={"xatol": self.tolerance}, ) if not res.success: raise RuntimeError(f"Optimization failed: {res.message}") self.dof_ = res.x self.rho_ = rho_from_tau else: # We'll use L-BFGS-B for the optimization because: # 1) The bivariate Student-t copula's negative log-likelihood is smooth, # making gradient-based methods more efficient than derivative-free # methods. # 2) L-BFGS-B directly supports simple box bounds (e.g., -1 < rho < 1, # 0 < nu < 50). # 3) It's typically faster and more stable for small-dimensional problems # than more general constraint solvers (like trust-constr or SLSQP) result = so.minimize( fun=lambda x: _neg_log_likelihood(dof=x[0], rho=x[1], X=X), x0=np.array([3.0, rho_from_tau]), bounds=(_DOF_BOUNDS, _RHO_BOUNDS), method="L-BFGS-B", tol=self.tolerance, ) if not result.success: raise RuntimeError(f"Optimization failed: {result.message}") self.dof_, self.rho_ = result.x return self
[docs] def cdf(self, X: ArrayLike) -> FloatArray: r"""Compute the CDF of the bivariate Student-t copula. Parameters ---------- X : array-like of shape (n_observations, 2) An array of bivariate inputs `(u, v)` where each row represents a bivariate observation. Both `u` and `v` must be in the interval `[0, 1]`, having been transformed to uniform marginals. Returns ------- cdf : ndarray of shape (n_observations,) CDF values for each observation in X. Raises ------ RuntimeError If the numerical integration does not converge. Notes ----- The CDF is computed by deterministic adaptive quadrature over a finite angular interval. For interior inputs, let :math:`x=t_\nu^{-1}(u)`, :math:`y=t_\nu^{-1}(v)`, :math:`a=(x+y)/(2\sqrt{\nu})`, and :math:`b=(x-y)/(2\sqrt{\nu})`. Then .. math:: C(u,v) = \max(u+v-1, 0) + \frac{1}{2\pi} \int_0^{\arccos(-\rho)} \left(1 + \frac{a^2}{\sin^2(\theta/2)} + \frac{b^2}{\cos^2(\theta/2)}\right)^{-\nu/2} d\theta. This follows by integrating the CDF's derivative with respect to correlation from -1 to :math:`\rho`, with the substitution :math:`r=-\cos(\theta)`. """ skv.check_is_fitted(self) X = self._validate_X(X, reset=False, clip=False) return _student_t_copula_cdf(X, rho=self.rho_, dof=self.dof_)
[docs] def survival_probability(self, X: ArrayLike) -> FloatArray: r"""Return the probability that both variables exceed their respective thresholds. Parameters ---------- X : array-like of shape (n_observations, 2) Uniform marginal probability levels `(u, v)` in `[0, 1]`. Each row specifies the thresholds for the two variables. Returns ------- probabilities : ndarray of shape (n_observations,) Joint probabilities :math:`P(U > u, V > v)` for each row in X. Raises ------ RuntimeError If the numerical integration does not converge. See Also -------- cdf : Probability that both variables are below their respective thresholds. tail_concentration : Conditional probabilities at a common quantile. Notes ----- Radial symmetry gives :math:`S(u, v) = C(1-u, 1-v)`. Evaluating the reflected CDF avoids subtracting nearly equal probabilities in the upper tail. It uses the same deterministic quadrature as :meth:`cdf`. """ skv.check_is_fitted(self) X = self._validate_X(X, reset=False, clip=False) return _student_t_copula_cdf(1.0 - X, rho=self.rho_, dof=self.dof_)
[docs] def partial_derivative( self, X: ArrayLike, first_margin: bool = False ) -> FloatArray: r"""Compute the h-function (partial derivative) for the bivariate Student's t copula. The h-function with respect to the second margin represents the conditional distribution function of :math:`u` given :math:`v`: .. math:: \begin{aligned} h(u \mid v) &= \frac{\partial C(u,v)}{\partial v} \\ &= t_{\nu+1}\!\left(\frac{t_\nu^{-1}(u) - \rho\,t_\nu^{-1}(v)} {\sqrt{\frac{(1-\rho^2)\left(\nu + \left(t_\nu^{-1}(v)\right)^2\right)}{\nu+1}}}\right). \end{aligned} where: - :math:`\nu > 0` is the degrees of freedom. - :math:`\rho \in (-1, 1)` is the correlation coefficient. - :math:`t_{\nu}^{-1}(p)` is the quantile function (inverse CDF) of the univariate \(t\)-distribution. Parameters ---------- X : array-like of shape (n_observations, 2) An array of bivariate inputs `(u, v)` where each row represents a bivariate observation. Both `u` and `v` must be in the interval `[0, 1]`, having been transformed to uniform marginals. first_margin : bool, default=False If True, compute the partial derivative with respect to the first margin `u`; otherwise, compute the partial derivative with respect to the second margin `v`. Returns ------- p : ndarray of shape (n_observations,) h-function values :math:`h(u \mid v) \;=\; p` for each observation in X. """ skv.check_is_fitted(self) X = self._validate_X(X, reset=False) X = _apply_margin_swap(X, first_margin=first_margin) # Compute the inverse CDF (percent point function) using stdtrit for better # performance u_inv, v_inv = sp.stdtrit(self.dof_, X).T # Compute the denominator: sqrt((1 - rho^2) * (nu + y^2) / (nu + 1)) z = (u_inv - self.rho_ * v_inv) / ( np.sqrt((1 - self.rho_**2) * (self.dof_ + v_inv**2) / (self.dof_ + 1)) ) # Student's t CDF with (nu+1) degrees of freedom using stdtr for better # performance p = sp.stdtr(self.dof_ + 1, z) return p
[docs] def inverse_partial_derivative( self, X: ArrayLike, first_margin: bool = False ) -> FloatArray: r"""Compute the inverse of the bivariate copula's partial derivative, commonly known as the inverse h-function [1]_. Let :math:`C(u, v)` be a bivariate copula. The h-function with respect to the second margin is defined by .. math:: h(u \mid v) \;=\; \frac{\partial\,C(u, v)}{\partial\,v}, which is the conditional distribution of :math:`U` given :math:`V = v`. The **inverse h-function**, denoted :math:`h^{-1}(p \mid v)`, is the unique value :math:`u \in [0,1]` such that .. math:: h(u \mid v) \;=\; p, \quad \text{where } p \in [0,1]. In practical terms, given :math:`(p, v)` in :math:`[0, 1]^2`, :math:`h^{-1}(p \mid v)` solves for the :math:`u` satisfying :math:`p = \partial C(u, v)/\partial v`. Parameters ---------- X : array-like of shape (n_observations, 2) An array of bivariate inputs `(p, v)`, each in the interval `[0, 1]`. - The first column `p` corresponds to the value of the h-function. - The second column `v` is the conditioning variable. first_margin : bool, default=False If True, compute the inverse partial derivative with respect to the first margin `u`; otherwise, compute the inverse partial derivative with respect to the second margin `v`. Returns ------- u : ndarray of shape (n_observations,) A 1D-array of length `n_observations`, where each element is the computed :math:`u = h^{-1}(p \mid v)` for the corresponding pair in `X`. References ---------- .. [1] "Multivariate Models and Dependence Concepts", Joe, H. (1997) .. [2] "An Introduction to Copulas", Nelsen, R. B. (2006) """ skv.check_is_fitted(self) X = self._validate_X(X, reset=False) X = _apply_margin_swap(X, first_margin=first_margin) p_inv = sp.stdtrit(self.dof_ + 1, X[:, 0]) v_inv = sp.stdtrit(self.dof_, X[:, 1]) u_inv = ( p_inv * np.sqrt((self.dof_ + v_inv**2) / (self.dof_ + 1) * (1 - self.rho_**2)) + self.rho_ * v_inv ) u = sp.stdtr(self.dof_, u_inv) return u
[docs] def score_samples(self, X: ArrayLike) -> FloatArray: """Compute the log-likelihood of each sample (log-pdf) under the model. Parameters ---------- X : array-like of shape (n_observations, 2) An array of bivariate inputs `(u, v)` where each row represents a bivariate observation. Both `u` and `v` must be in the interval `[0, 1]`, having been transformed to uniform marginals. Returns ------- density : ndarray of shape (n_observations,) The log-likelihood of each sample under the fitted copula. """ skv.check_is_fitted(self) X = self._validate_X(X, reset=False) log_density = _sample_scores(X=X, rho=self.rho_, dof=self.dof_) return log_density
@property def lower_tail_dependence(self) -> float: """Theoretical lower tail dependence coefficient.""" skv.check_is_fitted(self) arg = -np.sqrt((self.dof_ + 1) * (1 - self.rho_) / (1 + self.rho_)) return 2 * sp.stdtr(self.dof_ + 1, arg) @property def upper_tail_dependence(self) -> float: """Theoretical upper tail dependence coefficient.""" return self.lower_tail_dependence @property def fitted_repr(self) -> str: """String representation of the fitted copula.""" return f"{self.__class__.__name__}(rho={self.rho_:0.3f}, dof={self.dof_:0.2f})"
def _student_t_copula_cdf(X: FloatArray, rho: float, dof: float) -> FloatArray: """Evaluate the Student-t copula CDF using the angular integral in `cdf`.""" smaller = np.min(X, axis=1) larger = np.max(X, axis=1) lower = np.maximum(smaller - (1 - larger), 0) cdf = smaller.copy() interior = np.flatnonzero((smaller > 0) & (larger < 1)) quantiles = sp.stdtrit(dof, X[interior]) upper_angle = math.acos(-rho) scale = 2 * math.sqrt(dof) exponent = -float(dof) / 2 def integrand(angle: float, a: float, b: float) -> float: """Evaluate the nonnegative angular integrand in logarithmic form.""" sin_term = a / math.sin(angle / 2) cos_term = b / math.cos(angle / 2) return math.exp( exponent * math.log1p(sin_term * sin_term + cos_term * cos_term) ) / (2 * math.pi) for i, (x, y) in zip(interior, quantiles, strict=True): # Use Python scalars to avoid NumPy overhead in the quadrature callback. a = float((x + y) / scale) b = float((x - y) / scale) # Near x = -y, the integrand changes on a narrow scale at angle zero. # Geometric breakpoints keep adaptive quadrature from missing this layer. width = 2 * math.atan2(abs(a), math.hypot(1, b)) points = [] if width > 0: while width < upper_angle: points.append(width) width *= 8 # Scaling the absolute tolerance preserves accuracy when C(q, q) is # divided by q to compute tail concentration. result = si.quad( integrand, 0, upper_angle, args=(a, b), points=points, epsabs=1e-8 * smaller[i], epsrel=1e-8, limit=200, full_output=True, ) if len(result) > 3: raise RuntimeError(f"Student-t copula CDF integration failed: {result[3]}") # Enforce the Frechet bounds against floating-point rounding only after # checking that the integration converged. cdf[i] = min(max(lower[i] + result[0], lower[i]), smaller[i]) return cdf def _neg_log_likelihood(dof: float, rho: float, X: FloatArray) -> float: """Negative log-likelihood function for optimization. Parameters ---------- X : array-like of shape (n_observations, 2) An array of bivariate inputs `(u, v)` where each row represents a bivariate observation. Both `u` and `v` must be in the interval `[0, 1]`, having been transformed to uniform marginals. rho : float Correlation copula parameter. dof : float Degree of freedom copula parameter. Returns ------- value : float The negative log-likelihood value. """ return -np.sum(_sample_scores(X=X, rho=rho, dof=dof)) def _sample_scores(X: FloatArray, rho: float, dof: float) -> FloatArray: """Compute the log-likelihood of each sample (log-pdf) under the bivariate Gaussian copula model. Parameters ---------- X : array-like of shape (n_observations, 2) An array of bivariate inputs `(u, v)` where each row represents a bivariate observation. Both `u` and `v` must be in the interval `[0, 1]`, having been transformed to uniform marginals. rho : float Gaussian copula parameter. Returns ------- density : ndarray of shape (n_observations,) The log-likelihood of each sample under the fitted copula. Raises ------ ValueError If rho is not in (-1, 1) or dof is not positive. """ if not (-1.0 <= rho <= 1.0): raise ValueError("rho must be between -1 and 1.") if not 1.0 <= dof <= 50: raise ValueError("Degrees of freedom `dof` must be between 1 and 50.") # Inverse CDF (ppf) using stdtrit for better performance x, y = sp.stdtrit(dof, X).T a = 1.0 - rho**2 log_density = ( sp.gammaln((dof + 2.0) / 2.0) + sp.gammaln(dof / 2.0) - 2.0 * sp.gammaln((dof + 1.0) / 2.0) - np.log(a) / 2 + (dof + 1.0) / 2.0 * (np.log1p(x**2 / dof) + np.log1p(y**2 / dof)) - (dof + 2.0) / 2.0 * np.log1p((x**2 - 2 * rho * x * y + y**2) / a / dof) ) return log_density