Source code for cuqi.distribution._modifiedhalfnormal

import numpy as np
import scipy.stats as sps
import scipy.special as special
from cuqi.distribution import Distribution
from cuqi.utilities import force_ndarray

[docs] class ModifiedHalfNormal(Distribution): """ Represents a modified half-normal (MHN) distribution, a three-parameter family of distributions generalizing the Gamma distribution. The distribution is continuous with pdf .. math:: f(x; \\alpha, \\beta, \\gamma) \propto x^{(\\alpha-1)} * \exp(-\\beta * x^2 + \\gamma * x) The MHN generalizes the half-normal distribution, because :math:`f(x; 1, \\beta, 0) \propto \exp(-\\beta * x^2)` The MHN generalizes the gamma distribution because :math:`f(x; \\alpha, 0, -\\gamma) \propto x^{(\\alpha-1)} * \exp(- \\gamma * x)` Reference: [1] Sun, et al. "The Modified-Half-Normal distribution: Properties and an efficient sampling scheme." Communications in Statistics-Theory and Methods Parameters ---------- alpha : float or array_like The polynomial exponent parameter :math:`\\alpha` of the MHN distribution. Must be positive. beta : float or array_like The quadratic exponential parameter :math:`\\beta` of the MHN distribution. Must be positive. gamma : float or array_like The linear exponential parameter :math:`\\gamma` of the MHN distribution. """
[docs] def __init__(self, alpha=None, beta=None, gamma=None, is_symmetric=False, **kwargs): # Init from abstract distribution class super().__init__(is_symmetric=is_symmetric, **kwargs) self.alpha = alpha self.beta = beta self.gamma = gamma
@property def alpha(self): """ The polynomial exponent parameter of the MHN distribution. Must be positive. """ return self._alpha @alpha.setter def alpha(self, value): self._alpha = force_ndarray(value, flatten=True) @property def beta(self): """ The quadratic exponential parameter of the MHN distribution. Must be positive. """ return self._beta @beta.setter def beta(self, value): self._beta = force_ndarray(value, flatten=True) @property def gamma(self): """ The linear exponential parameter of the MHN distribution. """ return self._gamma @gamma.setter def gamma(self, value): self._gamma = force_ndarray(value, flatten=True)
[docs] def logpdf(self, x): # Unnormalized return np.sum((self.alpha - 1)*np.log(x) - self.beta * x * x + self.gamma * x)
def _gradient_scalar(self, val): if val <= 0.0: return np.nan return (self.alpha - 1)/val - 2*self.beta*val + self.gamma def _gradient(self, val, *args, **kwargs): return np.array([self._gradient_scalar(v) for v in val]) def _MHN_sample_gamma_proposal(self, alpha, beta, gamma, rng, delta=None): """ Sample from a modified half-normal distribution using a Gamma distribution proposal. """ if delta is None: delta = beta + (gamma*gamma - gamma*np.sqrt(gamma*gamma + 8*beta*alpha))/(4*alpha) while True: T = rng.gamma(alpha/2, 1.0/delta) X = np.sqrt(T) U = rng.uniform() if X > 0 and np.log(U) < -(beta-delta)*T + gamma*X - gamma*gamma/(4*(beta-delta)): return X def _MHN_sample_normal_proposal(self, alpha, beta, gamma, mu, rng): """ Sample from a modified half-normal distribution using a Normal/Gaussian distribution proposal. """ if mu is None: mu = (gamma + np.sqrt(gamma*gamma + 8*beta*(alpha - 1)))/(4*beta) while True: X = rng.normal(mu, np.sqrt(0.5/beta)) U = rng.uniform() if X > 0 and np.log(U) < (alpha-1)*np.log(X) - np.log(mu) + (2*beta*mu-gamma)*(mu-X): return X def _MHN_sample_positive_gamma_1(self, alpha, beta, gamma, rng): """ Sample from a modified half-normal distribution, assuming alpha is greater than one and gamma is positive. """ if gamma <= 0.0: raise ValueError("gamma needs to be positive") if alpha <= 1.0: raise ValueError("alpha needs to be greater than 1.0") # Decide whether to use Normal or sqrt(Gamma) proposals for acceptance-rejectance scheme mu = (gamma + np.sqrt(gamma*gamma + 8*beta*(alpha - 1)))/(4*beta) K1 = 2*np.sqrt(np.pi) K1 *= np.power((np.sqrt(beta)*(alpha-1))/(2*beta*mu-gamma), alpha - 1) K1 *= np.exp(-(alpha-1)+beta*mu*mu) delta = beta + (gamma*gamma - gamma*np.sqrt(gamma*gamma + 8*beta*alpha))/(4*alpha) K2 = np.power(beta/delta, 0.5*alpha) K2 *= special.gamma(alpha/2.0) K2 *= np.exp(gamma*gamma/(4*(beta-delta))) if K2 > K1: # Use normal proposal return self._MHN_sample_normal_proposal(alpha, beta, gamma, mu, rng) else: # Use sqrt(gamma) proposal return self._MHN_sample_gamma_proposal(alpha, beta, gamma, rng, delta) def _MHN_sample_negative_gamma(self, alpha, beta, gamma, rng, m=None): """ Sample from a modified half-normal distribution, assuming gamma is negative. The argument 'm' is the matching point, see Algorithm 3 from [1] for details. """ if gamma > 0.0: raise ValueError("gamma needs to be negative") if m is None: if alpha <= 1.0: m = 1.0 else: m = "mode" # The acceptance rate of this choice is at least 0.5*sqrt(2) approx 70.7 percent, according to Theorem 4 from [1]. if isinstance(m, str) and m.lower() == "mode": m = (gamma + np.sqrt(gamma*gamma + 8*beta*alpha))/(4*beta) while True: val1 = (beta*m-gamma)/(2*beta*m-gamma) val2 = m*(beta*m-gamma) T = rng.gamma(alpha*val1, 1.0/val2) X = m*np.power(T,val1) U = rng.uniform() if np.log(U) < val2*T-beta*X*X+gamma*X: return X def _MHN_sample(self, alpha, beta, gamma, m=None, rng=None): """ Sample from a modified half-normal distribution using an algorithm from [1]. """ if rng == None: rng = np.random if gamma <= 0.0: return self._MHN_sample_negative_gamma(alpha, beta, gamma, m=m, rng=rng) if alpha > 1: return self._MHN_sample_positive_gamma_1(alpha, beta, gamma, rng=rng) return self._MHN_sample_gamma_proposal(alpha, beta, gamma, rng=rng) def _sample(self, N, rng=None): if hasattr(self.alpha, '__getitem__'): return np.array([[self._MHN_sample(self.alpha[i], self.beta[i], self.gamma[i], rng=rng) for i in range(len(self.alpha))] for _ in range(N)]) else: return np.array([self._MHN_sample(self.alpha, self.beta, self.gamma, rng=rng) for i in range(N)])