Source code for bayesflow.adapters.transforms.as_covariance_matrix

import keras.ops as ops
import numpy as np
import math
from bayesflow.utils.serialization import serializable, serialize
from bayesflow.types import Tensor

from .constrain import Constrain
from .elementwise_transform import ElementwiseTransform


[docs] @serializable("bayesflow.adapters") class AsCovarianceMatrix(ElementwiseTransform): """ Constrains neural network predictions of a variable to a valid (symmetric, positive definite) K x K covariance (or precision) matrix. The unconstrained representation is a flat vector of K * (K + 1) / 2 entries of the lower Cholesky factor of the covariance matrix. First, the K diagonal entries, passed through a lower-bounded :py:class:`~transforms.Constrain` transform to keep them positive, followed by the K * (K - 1) / 2 entries from the lower triangular. Parameters ---------- cholesky : bool, optional Whether the *constrained* side of the transform is the Cholesky factor, rather than the full covariance matrix. Default is False (use the full covariance matrix). diag_kwargs : dict, optional Keyword arguments forwarded to the :py:class:`~transforms.Constrain` transform that constrains the diagonal to be positive. The `lower` bound is always fixed to 0.0. All other arguments use the default of :py:class:`~transforms.Constrain`. Examples -------- >>> adapter = bf.Adapter().as_covariance_matrix("cov_x", diag_kwargs={"method": "exp"}) """ def __init__(self, *, cholesky: bool = False, diag_kwargs: dict = None): super().__init__() self.cholesky = cholesky self.diag_kwargs = diag_kwargs or {} self.diag_kwargs["lower"] = 0.0 self.diag_transform = Constrain(**self.diag_kwargs)
[docs] def forward(self, data: Tensor, **kwargs) -> Tensor: K = ops.shape(data)[-1] batch_shape = tuple(ops.shape(data)[:-2]) if not self.cholesky: data = ops.cholesky(data) diag = ops.diagonal(data, axis1=-2, axis2=-1) diag = self.diag_transform(diag) # extract flattened off-diagonal rows, cols = np.tril_indices(K, k=-1) idx = K * rows + cols data_flat = ops.reshape(data, batch_shape + (K * K,)) lower = ops.take(data_flat, idx, axis=-1) return ops.concatenate([diag, lower], axis=-1)
[docs] def inverse(self, data: Tensor, **kwargs) -> Tensor: P = ops.shape(data)[-1] K = (math.isqrt(1 + 8 * P) - 1) // 2 batch_shape = tuple(ops.shape(data)[:-1]) diag = self.diag_transform(data[..., :K], inverse=True) lower = data[..., K:] # Index map into [0, diag..., lower...] for each flattened (i, j) position rows, cols = np.tril_indices(K, k=-1) idx_map = np.zeros(K * K, dtype="int32") # upper triangle -> slot 0 (zero) idx_map[np.arange(K) * (K + 1)] = 1 + np.arange(K) # diagonal idx_map[rows * K + cols] = 1 + K + np.arange(len(rows)) # strictly lower padded = ops.concatenate([ops.zeros_like(diag[..., :1]), diag, lower], axis=-1) L = ops.reshape(ops.take(padded, idx_map, axis=-1), batch_shape + (K, K)) if not self.cholesky: return ops.matmul(L, ops.swapaxes(L, -1, -2)) # L @ L^T return L
[docs] def get_config(self) -> dict: config = { "cholesky": self.cholesky, "diag_kwargs": self.diag_kwargs, } return serialize(config)
[docs] def log_det_jac(self, data: Tensor, inverse: bool = False, **kwargs) -> Tensor: K = ops.shape(data)[-1] if not self.cholesky: data = ops.cholesky(data) diag = ops.diagonal(data, axis1=-2, axis2=-1) ldj = ops.sum(self.diag_transform.ldj(diag), axis=-1) if not self.cholesky: weight = ops.cast(K, diag.dtype) - ops.arange(K, dtype=diag.dtype) chol_to_matrix_ldj = K * ops.log(ops.cast(2.0, diag.dtype)) + ops.sum(weight * ops.log(diag), axis=-1) ldj = ldj - chol_to_matrix_ldj if inverse: ldj = -ldj return self._sum_except_batch(ldj)