# Copyright (c) 2024 PaddlePaddle Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import math import operator from collections.abc import Sequence from functools import reduce from typing import TYPE_CHECKING, Literal import paddle from paddle.base.data_feeder import check_type, convert_dtype from paddle.base.framework import Variable from paddle.distribution import distribution from paddle.distribution.beta import Beta from paddle.framework import in_dynamic_mode if TYPE_CHECKING: from paddle import Tensor from paddle._typing.dtype_like import _DTypeLiteral __all__ = ["LKJCholesky"] def mvlgamma(a, p): """ Computes the multivariate log gamma function for input `a` and dimension `p`. """ pi = paddle.to_tensor(math.pi, dtype=a.dtype) j = paddle.arange(1, p + 1, dtype=a.dtype) gammaln_terms = paddle.lgamma(a.unsqueeze(-1) + (1 - j) / 2) gammaln_sum = paddle.sum(gammaln_terms, axis=-1) return (p * (p - 1) / 4) * paddle.log(pi) + gammaln_sum def tril_indices(n, k=0): """ Returns the indices of the lower triangular part of an n x n matrix, including the k-th diagonal. """ full_matrix = paddle.ones((n, n), dtype='int32') tril_matrix = paddle.tril(full_matrix, diagonal=k) rows, cols = paddle.nonzero(tril_matrix, as_tuple=True) return rows.flatten(), cols.flatten() def matrix_to_tril(x, diagonal=0): """ Extracts the lower triangular part of the input matrix or batch of matrices `x`, including the specified diagonal. """ tril_mask = paddle.tril(paddle.ones_like(x), diagonal=diagonal) tril_elements = paddle.masked_select(x, tril_mask.astype('bool')) return tril_elements def vec_to_tril_matrix( p_flatten, dim, last_dim, flatten_shape, sample_shape=(), diag=0 ): """ Constructs a batch of lower triangular matrices from a given input tensor `p`. """ # Calculate the dimension of the square matrix based on the last but one dimension of `p` # Define the output shape, which adds two dimensions for the square matrix shape0 = flatten_shape // last_dim output_shape = ( *sample_shape, shape0 // reduce(operator.mul, sample_shape, 1), dim, dim, ) # Create index_matrix = [index0, rows, cols] rows, cols = paddle.meshgrid(paddle.arange(dim), paddle.arange(dim)) mask = rows > cols lower_indices = paddle.stack([rows[mask], cols[mask]], axis=1) repeated_lower_indices = paddle.repeat_interleave( lower_indices, shape0, axis=0 ) index0 = paddle.arange(shape0).unsqueeze(1).tile([last_dim, 1]) index_matrix = paddle.concat([index0, repeated_lower_indices], axis=1) # Sort the indices sorted_indices = paddle.argsort(index_matrix[:, 0]) index_matrix = index_matrix[sorted_indices] # Set the value matrix = paddle.zeros(shape=(shape0, dim, dim), dtype=p_flatten.dtype) matrix = paddle.scatter_nd_add(matrix, index_matrix, p_flatten).reshape( output_shape ) return matrix def tril_matrix_to_vec(mat: Tensor, diag: int = 0) -> Tensor: r""" Convert a `D x D` matrix or a batch of matrices into a (batched) vector which comprises of lower triangular elements from the matrix in row order. """ out_shape = mat.shape[:-2] n = mat.shape[-1] if diag < -n or diag >= n: raise ValueError(f"diag ({diag}) provided is outside [{-n}, {n - 1}].") rows, cols = paddle.meshgrid(paddle.arange(n), paddle.arange(n)) tril_mask = diag + rows >= cols vec_len = (n + diag) * (n + diag + 1) // 2 out_shape += (vec_len,) # Use the mask to index the lower triangular elements from the input matrix tril_mask = paddle.broadcast_to(tril_mask, mat.shape) vec = paddle.masked_select(mat, tril_mask).reshape(out_shape) return vec class LKJCholesky(distribution.Distribution): """ The LKJCholesky class represents the LKJ distribution over Cholesky factors of correlation matrices. This class implements the LKJ distribution over Cholesky factors of correlation matrices, as described in Lewandowski, Kurowicka, and Joe (2009). It supports two sampling methods: "onion" and "cvine". Args: dim (int): The dimension of the correlation matrices. concentration (float, optional): The concentration parameter of the LKJ distribution. Default is 1.0. sample_method (str, optional): The sampling method to use, either "onion" or "cvine". Default is "onion". Example: .. code-block:: pycon >>> import paddle >>> dim = 3 >>> lkj = paddle.distribution.LKJCholesky(dim=dim) >>> sample = lkj.sample() >>> sample.shape paddle.Size([3, 3]) """ concentration: Tensor dtype: _DTypeLiteral dim: int sample_method: Literal["onion", "cvine"] def __init__( self, dim: int = 2, concentration: float = 1.0, sample_method: Literal["onion", "cvine"] = "onion", ) -> None: if not in_dynamic_mode(): check_type( dim, "dim", (int, Variable, paddle.pir.Value), "LKJCholesky", ) check_type( concentration, "concentration", (float, list, tuple, Variable, paddle.pir.Value), "LKJCholesky", ) # Get/convert concentration/rate to tensor. if self._validate_args(concentration): self.concentration = concentration self.dtype = convert_dtype(concentration.dtype) else: [self.concentration] = self._to_tensor(concentration) self.dtype = paddle.get_default_dtype() self.dim = dim if not self.dim >= 2: raise ValueError( f"Expected dim greater than or equal to 2. Found dim={dim}." ) elif not isinstance(self.dim, int): raise TypeError(f"Expected dim to be an integer. Found dim={dim}.") if in_dynamic_mode(): if not paddle.all(self.concentration > 0): raise ValueError("The arg of `concentration` must be positive.") self.sample_method = sample_method batch_shape = self.concentration.shape event_shape = (dim, dim) # This is used to draw vectorized samples from the beta distribution in Sec. 3.2 of [1]. marginal_conc = self.concentration + 0.5 * (self.dim - 2) offset = paddle.arange( self.dim - 1, dtype=self.concentration.dtype, ) if sample_method == "onion": offset = paddle.concat( [paddle.zeros((1,), dtype=offset.dtype), offset] ) beta_conc1 = offset + 0.5 beta_conc0 = marginal_conc.unsqueeze(-1) - 0.5 * offset self._beta = Beta(beta_conc1, beta_conc0) elif sample_method == "cvine": offset_tril = matrix_to_tril( paddle.broadcast_to(0.5 * offset, [self.dim - 1, self.dim - 1]) ) beta_conc = marginal_conc.unsqueeze(-1) - offset_tril self._beta = Beta(beta_conc, beta_conc) else: raise ValueError("`method` should be one of 'cvine' or 'onion'.") super().__init__(batch_shape, event_shape) def _onion(self, sample_shape: Sequence[int]) -> Tensor: """Generate a sample using the "onion" method. Args: sample_shape (tuple): The shape of the samples to be generated. Returns: w (Tensor): The Cholesky factor of the sampled correlation matrix. """ # Sample y from the Beta distribution y = self._beta.sample(sample_shape).unsqueeze(-1) # Sample u from the standard normal distribution and create a lower triangular matrix u_normal = paddle.randn( self._extend_shape(sample_shape), dtype=y.dtype ).tril(-1) # Normalize u to get u_hypersphere u_hypersphere = u_normal / u_normal.norm(axis=-1, keepdim=True) # Replace NaNs in first row # TODO: check if static graph can use fill_ # u_hypersphere[..., 0, :].fill_(0.0) # u_hypersphere[..., 0, :] = 0.0 u_hypersphere_other = u_hypersphere[..., 1:, :] zero_shape = (*tuple(u_hypersphere.shape[:-2]), 1, self.dim) zero_row = paddle.zeros(shape=zero_shape, dtype=u_hypersphere.dtype) u_hypersphere = paddle.concat([zero_row, u_hypersphere_other], axis=-2) w = paddle.sqrt(y) * u_hypersphere # Fill diagonal elements; clamp for numerical stability eps = paddle.finfo(w.dtype).tiny diag_elems = paddle.clip(1 - paddle.sum(w**2, axis=-1), min=eps).sqrt() w += paddle.diag_embed(diag_elems) return w def _cvine(self, sample_shape: Sequence[int]) -> Tensor: """Generate a sample using the "cvine" method. Args: sample_shape (tuple): The shape of the samples to be generated. Returns: r (Tensor): The Cholesky factor of the sampled correlation matrix. """ # Sample beta and calculate partial correlations beta_sample = self._beta.sample(sample_shape).unsqueeze(-1) partial_correlation = 2 * beta_sample - 1 if self.dim == 2: partial_correlation = partial_correlation.unsqueeze(-2) # Construct the lower triangular matrix from the partial correlations last_dim = self.dim * (self.dim - 1) // 2 flatten_shape = last_dim * reduce(operator.mul, sample_shape, 1) if len(self.concentration.shape) != 0: flatten_shape *= self.concentration.shape[-1] partial_correlation = partial_correlation.reshape((flatten_shape,)) partial_correlation = vec_to_tril_matrix( partial_correlation, self.dim, last_dim, flatten_shape, sample_shape, -1, ) # Clip partial correlations for numerical stability eps = paddle.finfo(beta_sample.dtype).tiny r = paddle.clip(partial_correlation, min=(-1 + eps), max=(1 - eps)) # Calculate the cumulative product of the square root of 1 - z z = r**2 z1m_cumprod_sqrt = paddle.cumprod(paddle.sqrt(1 - z), dim=-1) # Shift the elements and pad with 1.0 pad_width = [0, 0] * (z1m_cumprod_sqrt.ndim - 1) + [1, 0] z1m_cumprod_sqrt_shifted = paddle.nn.functional.pad( z1m_cumprod_sqrt[..., :-1], pad=pad_width, mode="constant", value=1.0, ) # Calculate the final Cholesky factor r += paddle.eye( partial_correlation.shape[-2], partial_correlation.shape[-1] ) r = r * z1m_cumprod_sqrt_shifted return r def sample(self, sample_shape: Sequence[int] = []) -> Tensor: """Generate a sample using the specified sampling method.""" if not isinstance(sample_shape, Sequence): raise TypeError('sample shape must be Sequence object.') if self.sample_method == "onion": res = self._onion(sample_shape) else: res = self._cvine(sample_shape) output_shape = list(sample_shape) output_shape.extend(self.concentration.shape) output_shape.extend([self.dim, self.dim]) return res.reshape(output_shape) def log_prob(self, value: Tensor) -> Tensor: r"""Compute the log probability density of the given Cholesky factor under the LKJ distribution. Args: value (Tensor): The Cholesky factor of the correlation matrix for which the log probability density is to be computed. Returns: log_prob (Tensor): The log probability density of the given Cholesky factor under the LKJ distribution. """ # 1.Compute the order vector. diag_elems = paddle.diagonal(value, offset=0, axis1=-1, axis2=-2)[ ..., 1: ] order = paddle.arange(2, self.dim + 1, dtype=self.concentration.dtype) order = 2 * (self.concentration - 1).unsqueeze(-1) + self.dim - order # 2.Compute the unnormalized log probability density unnormalized_log_pdf = paddle.sum( order * paddle.log(diag_elems), axis=-1 ) # 3.Compute the normalization constant (page 1999 of [1]) dm1 = self.dim - 1 alpha = self.concentration + 0.5 * dm1 denominator = paddle.lgamma(alpha) * dm1 numerator = mvlgamma(alpha - 0.5, dm1) # 4.Compute the constant term related to pi # pi_constant in [1] is D * (D - 1) / 4 * log(pi) # pi_constant in multigammaln is (D - 1) * (D - 2) / 4 * log(pi) # hence, we need to add a pi_constant = (D - 1) * log(pi) / 2 pi_constant = 0.5 * dm1 * math.log(math.pi) # 5.Compute the normalization term and return the final log probability density: normalize_term = pi_constant + numerator - denominator return unnormalized_log_pdf - normalize_term