Source code for pymixef.transforms
"""Canonical constraints between optimizer and natural parameter scales."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar
import numpy as np
from numpy.typing import ArrayLike, NDArray
from scipy.special import expit, logsumexp
from .errors import TransformError
FloatArray = NDArray[np.float64]
def _array(value: ArrayLike) -> FloatArray:
result = np.asarray(value, dtype=float)
if not np.all(np.isfinite(result)):
raise TransformError("Transform inputs must be finite.")
return result
def _softplus(value: FloatArray) -> FloatArray:
return np.logaddexp(0.0, value)
def _softplus_inverse(value: FloatArray) -> FloatArray:
if np.any(value <= 0.0):
raise TransformError("Softplus inverse requires strictly positive values.")
return value + np.log(-np.expm1(-value))
[docs]
class Transform(ABC):
"""Abstract optimizer-to-natural-scale transform."""
name: ClassVar[str]
[docs]
@abstractmethod
def forward(self, unconstrained: ArrayLike) -> FloatArray:
"""Map an unconstrained value to its natural scale."""
[docs]
@abstractmethod
def inverse(self, natural: ArrayLike) -> FloatArray:
"""Map a natural-scale value to its unconstrained scale."""
[docs]
@abstractmethod
def log_abs_det_jacobian(self, unconstrained: ArrayLike) -> float:
"""Return the log absolute determinant of the forward Jacobian."""
[docs]
def jacobian(self, unconstrained: ArrayLike) -> FloatArray:
"""Return a finite-difference Jacobian for general vector transforms."""
x = _array(unconstrained).reshape(-1)
y = np.asarray(self.forward(x), dtype=float).reshape(-1)
out = np.empty((y.size, x.size), dtype=float)
step = np.cbrt(np.finfo(float).eps)
for column in range(x.size):
delta = step * max(1.0, abs(x[column]))
upper = x.copy()
lower = x.copy()
upper[column] += delta
lower[column] -= delta
out[:, column] = (
np.asarray(self.forward(upper)).reshape(-1)
- np.asarray(self.forward(lower)).reshape(-1)
) / (2.0 * delta)
return out
[docs]
@dataclass(frozen=True, slots=True)
class IdentityTransform(Transform):
"""No constraint."""
name: ClassVar[str] = "identity"
[docs]
def forward(self, unconstrained: ArrayLike) -> FloatArray:
return _array(unconstrained).copy()
[docs]
def log_abs_det_jacobian(self, unconstrained: ArrayLike) -> float:
_array(unconstrained)
return 0.0
[docs]
@dataclass(frozen=True, slots=True)
class LogTransform(Transform):
"""Strictly-positive transform using ``exp``."""
name: ClassVar[str] = "log"
[docs]
def forward(self, unconstrained: ArrayLike) -> FloatArray:
x = _array(unconstrained)
with np.errstate(over="raise"):
try:
return np.exp(x)
except FloatingPointError as exc:
raise TransformError("Exponential transform overflowed.") from exc
[docs]
def inverse(self, natural: ArrayLike) -> FloatArray:
y = _array(natural)
if np.any(y <= 0.0):
raise TransformError("Log transform inverse requires positive values.")
return np.log(y)
[docs]
def log_abs_det_jacobian(self, unconstrained: ArrayLike) -> float:
return float(np.sum(_array(unconstrained)))
[docs]
@dataclass(frozen=True, slots=True)
class SoftplusTransform(Transform):
"""Smooth strictly-positive transform with a near-linear upper tail."""
name: ClassVar[str] = "softplus"
[docs]
def forward(self, unconstrained: ArrayLike) -> FloatArray:
return _softplus(_array(unconstrained))
[docs]
def inverse(self, natural: ArrayLike) -> FloatArray:
return _softplus_inverse(_array(natural))
[docs]
def log_abs_det_jacobian(self, unconstrained: ArrayLike) -> float:
x = _array(unconstrained)
return float(np.sum(-np.logaddexp(0.0, -x)))
[docs]
@dataclass(frozen=True, slots=True)
class BoundedTransform(Transform):
"""Map to the open interval ``(lower, upper)`` using a logistic map."""
lower: float = 0.0
upper: float = 1.0
name: ClassVar[str] = "bounded"
def __post_init__(self) -> None:
if not np.isfinite(self.lower) or not np.isfinite(self.upper):
raise TransformError("Bounded transform limits must be finite.")
if self.lower >= self.upper:
raise TransformError("Bounded transform requires lower < upper.")
[docs]
def forward(self, unconstrained: ArrayLike) -> FloatArray:
return self.lower + (self.upper - self.lower) * expit(_array(unconstrained))
[docs]
def inverse(self, natural: ArrayLike) -> FloatArray:
y = _array(natural)
if np.any((y <= self.lower) | (y >= self.upper)):
raise TransformError("Bounded transform inverse requires interior values.")
p = (y - self.lower) / (self.upper - self.lower)
return np.log(p) - np.log1p(-p)
[docs]
def log_abs_det_jacobian(self, unconstrained: ArrayLike) -> float:
x = _array(unconstrained)
return float(
x.size * np.log(self.upper - self.lower)
+ np.sum(-np.logaddexp(0.0, -x) - np.logaddexp(0.0, x))
)
[docs]
@dataclass(frozen=True, slots=True)
class SimplexTransform(Transform):
"""Additive-log-ratio map from ``R^(K-1)`` to a ``K``-simplex."""
name: ClassVar[str] = "simplex"
[docs]
def forward(self, unconstrained: ArrayLike) -> FloatArray:
x = _array(unconstrained).reshape(-1)
extended = np.concatenate((x, np.zeros(1)))
return np.exp(extended - logsumexp(extended))
[docs]
def inverse(self, natural: ArrayLike) -> FloatArray:
y = _array(natural).reshape(-1)
if y.size < 2 or np.any(y <= 0.0) or not np.isclose(np.sum(y), 1.0):
raise TransformError("Simplex values must be positive and sum to one.")
return np.log(y[:-1]) - np.log(y[-1])
[docs]
def log_abs_det_jacobian(self, unconstrained: ArrayLike) -> float:
return float(np.sum(np.log(self.forward(unconstrained))))
[docs]
@dataclass(frozen=True, slots=True)
class OrderedTransform(Transform):
"""Map a vector to a strictly increasing vector."""
name: ClassVar[str] = "ordered"
[docs]
def forward(self, unconstrained: ArrayLike) -> FloatArray:
x = _array(unconstrained).reshape(-1)
if x.size == 0:
return x
return np.concatenate((x[:1], x[0] + np.cumsum(_softplus(x[1:]))))
[docs]
def inverse(self, natural: ArrayLike) -> FloatArray:
y = _array(natural).reshape(-1)
if y.size == 0:
return y
increments = np.diff(y)
if np.any(increments <= 0.0):
raise TransformError("Ordered transform inverse requires increasing values.")
return np.concatenate((y[:1], _softplus_inverse(increments)))
[docs]
def log_abs_det_jacobian(self, unconstrained: ArrayLike) -> float:
x = _array(unconstrained).reshape(-1)
return float(np.sum(-np.logaddexp(0.0, -x[1:])))
[docs]
@dataclass(frozen=True, slots=True)
class CholeskyCovarianceTransform(Transform):
"""Map packed unconstrained Cholesky entries to an SPD covariance matrix."""
dimension: int
name: ClassVar[str] = "cholesky-covariance"
def __post_init__(self) -> None:
if self.dimension < 1:
raise TransformError("Covariance dimension must be positive.")
@property
def unconstrained_size(self) -> int:
return self.dimension * (self.dimension + 1) // 2
[docs]
def factor(self, unconstrained: ArrayLike) -> FloatArray:
x = _array(unconstrained).reshape(-1)
if x.size != self.unconstrained_size:
raise TransformError(f"Expected {self.unconstrained_size} packed values; got {x.size}.")
lower = np.zeros((self.dimension, self.dimension), dtype=float)
cursor = 0
for row in range(self.dimension):
for column in range(row + 1):
value = x[cursor]
lower[row, column] = np.exp(value) if row == column else value
cursor += 1
return lower
[docs]
def forward(self, unconstrained: ArrayLike) -> FloatArray:
factor = self.factor(unconstrained)
return factor @ factor.T
[docs]
def inverse(self, natural: ArrayLike) -> FloatArray:
matrix = _array(natural)
if matrix.shape != (self.dimension, self.dimension):
raise TransformError("Covariance matrix has the wrong dimensions.")
try:
lower = np.linalg.cholesky(matrix)
except np.linalg.LinAlgError as exc:
raise TransformError("Covariance matrix must be positive definite.") from exc
packed: list[float] = []
for row in range(self.dimension):
for column in range(row + 1):
value = lower[row, column]
packed.append(float(np.log(value) if row == column else value))
return np.asarray(packed)
[docs]
def log_abs_det_jacobian(self, unconstrained: ArrayLike) -> float:
lower = self.factor(unconstrained)
powers = np.arange(self.dimension + 1, 1, -1, dtype=float)
return float(self.dimension * np.log(2.0) + np.dot(powers, np.log(np.diag(lower))))
_TRANSFORMS: dict[str, type[Transform]] = {
"identity": IdentityTransform,
"log": LogTransform,
"positive": LogTransform,
"softplus": SoftplusTransform,
"bounded": BoundedTransform,
"simplex": SimplexTransform,
"ordered": OrderedTransform,
"cholesky-covariance": CholeskyCovarianceTransform,
}
[docs]
def get_transform(name: str, **options: object) -> Transform:
"""Construct a canonical transform by stable name."""
key = name.strip().lower().replace("_", "-")
try:
transform_type = _TRANSFORMS[key]
except KeyError as exc:
raise TransformError(
f"Unknown transform {name!r}.",
code="TRANSFORM-UNKNOWN-001",
details={"available": sorted(_TRANSFORMS)},
) from exc
return transform_type(**options) # type: ignore[arg-type]
__all__ = [
"BoundedTransform",
"CholeskyCovarianceTransform",
"IdentityTransform",
"LogTransform",
"OrderedTransform",
"SimplexTransform",
"SoftplusTransform",
"Transform",
"get_transform",
]