Source code for multigeom.analysis.metrics

"""
Cross-encoder similarity measures for the multi-scale Fisher metric G(x)
and the classical Fisher J(x).

Two measures, both used in the paper:

  1. **Subspace alignment** — angle-only, eigenvalue-blind.
       𝒜_k(A, B) = (1/k) ‖V_Aᵀ V_B‖_F²
                 = (1/k) Σ_i cos² θ_i        (paper Eq. (eq:subspace-alignment))

  2. **Bures–Wasserstein distance** — sensitive to both orientation and scale.
       d_BW²(A, B) = tr(A) + tr(B) − 2 tr[(A^{1/2} B A^{1/2})^{1/2}]   (Eq. (eq:bures))

       In practice we evaluate the cross term on the truncated eigendecomposition
       A ≈ V_A Λ_A V_Aᵀ, B ≈ V_B Λ_B V_Bᵀ:
           tr[(Λ_A^{1/2} C Λ_B Cᵀ Λ_A^{1/2})^{1/2}] = Σ_i √μ_i
       where C = V_Aᵀ V_B ∈ ℝ^{k × k} and {μ_i} are the eigenvalues of
       M = Λ_A^{1/2} C Λ_B Cᵀ Λ_A^{1/2}. We symmetrise M before
       diagonalisation to guard against numerical asymmetry.

Both functions take two truncated eigendecompositions in the convention
returned by `multigeom.geometry.estimator.conditional_pair_estimate` and
`multigeom.geometry.fisher.fisher_eigendecomposition`: eigenvectors as **rows** of a
[k, P] matrix, eigenvalues as a [k] vector, both descending.
"""

from __future__ import annotations

import torch


def _maybe_truncate(eigvals: torch.Tensor, eigvecs: torch.Tensor, top_k: int | None):
    """Restrict to the top-`top_k` rows if requested."""
    if top_k is None:
        return eigvals, eigvecs
    return eigvals[:top_k], eigvecs[:top_k]


[docs] def subspace_alignment( eigvecs_A: torch.Tensor, eigvecs_B: torch.Tensor, top_k: int | None = None, ) -> float: """ Compute 𝒜_k = (1/k) ‖V_Aᵀ V_B‖_F² where V_A, V_B are the top-k eigenvectors of two PSD matrices. Args: eigvecs_A, eigvecs_B: [k, P] tensors with eigenvectors as rows. top_k: if given, restrict both inputs to their top-`top_k` rows. Returns: Scalar in [0, 1]. 1.0 ↔ identical subspaces. """ if eigvecs_A.dim() != 2 or eigvecs_B.dim() != 2: raise ValueError("eigvecs_A and eigvecs_B must be 2-D [k, P]") if top_k is not None: eigvecs_A = eigvecs_A[:top_k] eigvecs_B = eigvecs_B[:top_k] if eigvecs_A.shape[0] != eigvecs_B.shape[0]: raise ValueError( f"Mismatched k: {eigvecs_A.shape[0]} vs {eigvecs_B.shape[0]}; " f"slice both to the same top_k first." ) k = eigvecs_A.shape[0] overlap = eigvecs_A @ eigvecs_B.T # [k, k] return float((overlap.norm(p="fro") ** 2) / k)
[docs] def bures_wasserstein( eigvals_A: torch.Tensor, eigvecs_A: torch.Tensor, eigvals_B: torch.Tensor, eigvecs_B: torch.Tensor, top_k: int | None = None, ) -> float: """ Compute the Bures–Wasserstein distance d_BW(A, B) on the cone of PSD matrices, using truncated eigendecompositions. Args: eigvals_*: [k] tensors of eigenvalues (descending order). eigvecs_*: [k, P] tensors of eigenvectors as rows. top_k: if given, restrict to top-`top_k` eigenpairs of each. Returns: d_BW(A, B) ≥ 0. Returns 0 iff A = B. """ eigvals_A, eigvecs_A = _maybe_truncate(eigvals_A, eigvecs_A, top_k) eigvals_B, eigvecs_B = _maybe_truncate(eigvals_B, eigvecs_B, top_k) if eigvals_A.shape != eigvals_B.shape: raise ValueError( f"Mismatched k: {tuple(eigvals_A.shape)} vs {tuple(eigvals_B.shape)}" ) C = eigvecs_A.to(torch.float64) @ eigvecs_B.to(torch.float64).T # [k, k] return bures_wasserstein_from_overlap(eigvals_A, eigvals_B, C)
def bures_wasserstein_from_overlap( eigvals_A: torch.Tensor, eigvals_B: torch.Tensor, overlap: torch.Tensor, ) -> float: """ Bures-Wasserstein distance from eigenvalues and the overlap matrix C = V_Aᵀ V_B (eigenvectors as columns; with the rows convention used in this package, C = eigvecs_A @ eigvecs_Bᵀ). Lets callers compute C once and slice C[:k, :k] for every rank k. Rows convention: A ≈ eigvecs_Aᵀ Λ_A eigvecs_A. The cross term tr[(A^{1/2} B A^{1/2})^{1/2}] equals Σ_i √μ_i, with μ_i the eigenvalues of M = Λ_A^{1/2} C Λ_B Cᵀ Λ_A^{1/2} (paper App. "Comparing different geometries"). M is symmetrised before diagonalisation. """ la = eigvals_A.to(torch.float64).clamp(min=0) lb = eigvals_B.to(torch.float64).clamp(min=0) C = overlap.to(torch.float64) if C.shape != (la.shape[0], lb.shape[0]): raise ValueError( f"overlap shape {tuple(C.shape)} does not match " f"eigenvalue counts {la.shape[0]}, {lb.shape[0]}" ) sqrt_la = la.sqrt() M = (sqrt_la.unsqueeze(1) * C * lb.unsqueeze(0)) @ C.T * sqrt_la.unsqueeze(0) M = 0.5 * (M + M.T) cross_term = torch.linalg.eigvalsh(M).clamp(min=0).sqrt().sum() d_sq = (la.sum() + lb.sum() - 2.0 * cross_term).clamp(min=0) return float(d_sq.sqrt())