spinor-lib / spinor_lib /utils.py
SoulFireMage's picture
Initial commit: spinor library with SU(2) rotations, autograd and nn modules
51b20c8
Raw History Blame Contribute Delete
3.99 kB
"""Utility functions for the Spinor library."""
from typing import Optional, Sequence, Union
import torch
import numpy as np
TensorLike = Union[torch.Tensor, np.ndarray, list, tuple, float, complex]
# Threshold below which a norm is treated as zero (used to avoid divide-by-zero).
DEFAULT_EPS = 1e-12
def get_backend(tensor: object) -> str:
"""Detect the backend of an array-like object ("torch" or "numpy")."""
if isinstance(tensor, torch.Tensor):
return "torch"
if isinstance(tensor, np.ndarray):
return "numpy"
raise TypeError(f"Unsupported tensor type: {type(tensor)}")
def ensure_torch_tensor(
data: TensorLike,
dtype: Optional[torch.dtype] = None,
device: Optional[Union[str, torch.device]] = None,
) -> torch.Tensor:
"""
Convert input data to a PyTorch tensor.
Existing tensors are returned as-is (so autograd leaves are preserved),
NumPy arrays are wrapped without copying (memory is shared), and anything
else goes through ``torch.as_tensor``.
"""
if isinstance(data, torch.Tensor):
tensor = data
elif isinstance(data, np.ndarray):
tensor = torch.from_numpy(data)
else:
tensor = torch.as_tensor(data)
if dtype is not None or device is not None:
tensor = tensor.to(dtype=dtype, device=device)
return tensor
def validate_spinor_dimension(tensor: torch.Tensor, dim: int = 2) -> None:
"""Validate that the last dimension matches the expected spinor dimension."""
if tensor.ndim == 0:
raise ValueError("Spinor tensor must have at least one dimension")
if tensor.shape[-1] != dim:
raise ValueError(
f"Last dimension must be {dim} for spinors, got {tensor.shape[-1]}"
)
def check_broadcastable(a: torch.Tensor, b: torch.Tensor) -> torch.Size:
"""Return the broadcast shape of two tensors, raising ValueError if impossible."""
try:
return torch.broadcast_shapes(a.shape, b.shape)
except RuntimeError as exc:
raise ValueError(
f"Shapes {tuple(a.shape)} and {tuple(b.shape)} are not broadcastable"
) from exc
def real_dtype_of(dtype: torch.dtype) -> torch.dtype:
"""Return the real dtype underlying a (possibly complex) dtype."""
if dtype == torch.complex64:
return torch.float32
if dtype == torch.complex128:
return torch.float64
if dtype == torch.complex32:
return torch.float16
return dtype
def complex_dtype_of(dtype: torch.dtype) -> torch.dtype:
"""Return the complex dtype whose real part has the given precision."""
if dtype.is_complex:
return dtype
if dtype == torch.float64:
return torch.complex128
# float32 and everything lower-precision (or non-floating) map to complex64
return torch.complex64
def resolve_dtype_and_device(
inputs: Sequence[object],
dtype: Optional[torch.dtype],
device: Optional[Union[str, torch.device]],
) -> "tuple[torch.dtype, torch.device]":
"""
Pick the real working dtype and device for a computation.
If ``dtype`` is given it wins (a complex dtype is mapped to its real part).
Otherwise, use the highest-precision floating dtype among any tensor inputs,
falling back to the torch default. The device comes from ``device`` or the
first tensor input, falling back to CPU.
"""
if dtype is not None:
real_dtype = real_dtype_of(dtype)
else:
real_dtype = None
for x in inputs:
if isinstance(x, torch.Tensor) and x.is_floating_point():
real_dtype = x.dtype if real_dtype is None else torch.promote_types(real_dtype, x.dtype)
if real_dtype is None:
real_dtype = torch.get_default_dtype()
if device is None:
for x in inputs:
if isinstance(x, torch.Tensor):
device = x.device
break
return real_dtype, torch.device(device) if device is not None else torch.device("cpu")