| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import abc |
| from typing import List, Tuple, Union, Optional |
| from warnings import warn |
|
|
| import math |
|
|
| import torch |
| import torch.nn as nn |
|
|
| from functools import partial |
|
|
| from torch_harmonics.cache import lru_cache |
| from torch_harmonics.quadrature import _precompute_grid, _precompute_latitudes, _precompute_longitudes |
| from torch_harmonics._disco_convolution import _get_psi, _disco_s2_contraction_torch, _disco_s2_transpose_contraction_torch |
| from torch_harmonics._disco_convolution import _disco_s2_contraction_cuda, _disco_s2_transpose_contraction_cuda |
| from torch_harmonics.filter_basis import FilterBasis, get_filter_basis |
|
|
| |
| try: |
| from disco_helpers import preprocess_psi |
| import disco_cuda_extension |
|
|
| _cuda_extension_available = True |
| except ImportError as err: |
| disco_cuda_extension = None |
| _cuda_extension_available = False |
|
|
|
|
| def _normalize_convolution_tensor_s2( |
| psi_idx, psi_vals, in_shape, out_shape, kernel_size, quad_weights, transpose_normalization=False, basis_norm_mode="mean", merge_quadrature=False, eps=1e-9 |
| ): |
| """Normalizes convolution tensor values based on specified normalization mode. |
| |
| This function applies different normalization strategies to the convolution tensor |
| values based on the basis_norm_mode parameter. It can normalize individual basis |
| functions, compute mean normalization across all basis functions, or use support |
| weights. The function also optionally merges quadrature weights into the tensor. |
| |
| Parameters |
| ----------- |
| psi_idx: torch.Tensor |
| Index tensor for the sparse convolution tensor. |
| psi_vals: torch.Tensor |
| Value tensor for the sparse convolution tensor. |
| in_shape: Tuple[int] |
| Tuple of (nlat_in, nlon_in) representing input grid dimensions. |
| out_shape: Tuple[int] |
| Tuple of (nlat_out, nlon_out) representing output grid dimensions. |
| kernel_size: int |
| Number of kernel basis functions. |
| quad_weights: torch.Tensor |
| Quadrature weights for numerical integration. |
| transpose_normalization: bool |
| If True, applies normalization in transpose direction. |
| basis_norm_mode: str |
| Normalization mode, one of ["none", "individual", "mean", "support"]. |
| merge_quadrature: bool |
| If True, multiplies values by quadrature weights. |
| eps: float |
| Small epsilon value to prevent division by zero. |
| |
| Returns |
| ------- |
| torch.Tensor |
| Normalized convolution tensor values. |
| |
| Raises |
| ------ |
| ValueError |
| If basis_norm_mode is not one of the supported modes. |
| """ |
|
|
| |
| if basis_norm_mode == "none": |
| return psi_vals |
|
|
| |
| idx = torch.stack([psi_idx[0], psi_idx[1], psi_idx[2] // in_shape[1], psi_idx[2] % in_shape[1]], dim=0) |
|
|
| |
| ikernel = idx[0] |
|
|
| if transpose_normalization: |
| ilat_out = idx[2] |
| ilat_in = idx[1] |
| |
| nlat_out = in_shape[0] |
| correction_factor = out_shape[1] / in_shape[1] |
| else: |
| ilat_out = idx[1] |
| ilat_in = idx[2] |
| nlat_out = out_shape[0] |
|
|
| |
| q = quad_weights[ilat_in].reshape(-1) |
|
|
| |
| vnorm = torch.zeros(kernel_size, nlat_out, device=psi_vals.device) |
| support = torch.zeros(kernel_size, nlat_out, device=psi_vals.device) |
|
|
| |
| for ik in range(kernel_size): |
| for ilat in range(nlat_out): |
|
|
| |
| iidx = torch.argwhere((ikernel == ik) & (ilat_out == ilat)) |
|
|
| |
| |
| vnorm[ik, ilat] = torch.sum(psi_vals[iidx].abs() * q[iidx]) |
|
|
| |
| support[ik, ilat] = torch.sum(q[iidx]) |
|
|
| |
| for ik in range(kernel_size): |
| for ilat in range(nlat_out): |
|
|
| iidx = torch.argwhere((ikernel == ik) & (ilat_out == ilat)) |
|
|
| if basis_norm_mode == "individual": |
| val = vnorm[ik, ilat] |
| elif basis_norm_mode == "mean": |
| val = vnorm[ik, :].mean() |
| elif basis_norm_mode == "support": |
| val = support[ik, ilat] |
| elif basis_norm_mode == "none": |
| val = 1.0 |
| else: |
| raise ValueError(f"Unknown basis normalization mode {basis_norm_mode}.") |
|
|
| psi_vals[iidx] = psi_vals[iidx] / (val + eps) |
|
|
| if merge_quadrature: |
| psi_vals[iidx] = psi_vals[iidx] * q[iidx] |
|
|
| if transpose_normalization and merge_quadrature: |
| psi_vals = psi_vals / correction_factor |
|
|
| return psi_vals |
|
|
|
|
| @lru_cache(typed=True, copy=True) |
| def _precompute_convolution_tensor_s2( |
| in_shape: Tuple[int], |
| out_shape: Tuple[int], |
| filter_basis: FilterBasis, |
| grid_in: Optional[str] = "equiangular", |
| grid_out: Optional[str] = "equiangular", |
| theta_cutoff: Optional[float] = 0.01 * math.pi, |
| theta_eps: Optional[float] = 1e-3, |
| transpose_normalization: Optional[bool] = False, |
| basis_norm_mode: Optional[str] = "mean", |
| merge_quadrature: Optional[bool] = False, |
| ): |
| """ |
| Precomputes the rotated filters at positions $R^{-1}_j \omega_i = R^{-1}_j R_i \nu = Y(-\theta_j)Z(\phi_i - \phi_j)Y(\theta_j)\nu$. |
| Assumes a tensorized grid on the sphere with an equidistant sampling in longitude as described in Ocampo et al. |
| The output tensor has shape kernel_shape x nlat_out x (nlat_in * nlon_in). |
| |
| The rotation of the Euler angles uses the YZY convention, which applied to the northpole $(0,0,1)^T$ yields |
| $$ |
| Y(\alpha) Z(\beta) Y(\gamma) n = |
| {\begin{bmatrix} |
| \cos(\gamma)\sin(\alpha) + \cos(\alpha)\cos(\beta)\sin(\gamma) \\ |
| \sin(\beta)\sin(\gamma) \\ |
| \cos(\alpha)\cos(\gamma)-\cos(\beta)\sin(\alpha)\sin(\gamma) |
| \end{bmatrix}} |
| $$ |
| |
| Parameters |
| ----------- |
| in_shape: Tuple[int] |
| Input shape of the convolution tensor |
| out_shape: Tuple[int] |
| Output shape of the convolution tensor |
| filter_basis: FilterBasis |
| Filter basis functions |
| grid_in: str |
| Input grid type |
| grid_out: str |
| Output grid type |
| theta_cutoff: float |
| Theta cutoff for the filter basis functions |
| theta_eps: float |
| Epsilon for the theta cutoff |
| transpose_normalization: bool |
| Whether to normalize the convolution tensor in the transpose direction |
| basis_norm_mode: str |
| Mode for basis normalization |
| merge_quadrature: bool |
| Whether to merge the quadrature weights into the convolution tensor |
| |
| Returns |
| ------- |
| out_idx: torch.Tensor |
| Index tensor of the convolution tensor |
| out_vals: torch.Tensor |
| Values tensor of the convolution tensor |
| |
| """ |
|
|
| assert len(in_shape) == 2 |
| assert len(out_shape) == 2 |
|
|
| kernel_size = filter_basis.kernel_size |
|
|
| nlat_in, nlon_in = in_shape |
| nlat_out, nlon_out = out_shape |
|
|
| |
| lats_in, win = _precompute_latitudes(nlat_in, grid=grid_in) |
| lats_out, wout = _precompute_latitudes(nlat_out, grid=grid_out) |
|
|
| |
| |
| lons_in = _precompute_longitudes(nlon_in) |
|
|
| |
| |
| if transpose_normalization: |
| quad_weights = wout.reshape(-1, 1) / nlon_in / 2.0 |
| else: |
| quad_weights = win.reshape(-1, 1) / nlon_in / 2.0 |
|
|
| |
| theta_cutoff_eff = (1.0 + theta_eps) * theta_cutoff |
|
|
| out_idx = [] |
| out_vals = [] |
|
|
| beta = lons_in |
| gamma = lats_in.reshape(-1, 1) |
|
|
| |
| cbeta = torch.cos(beta) |
| sbeta = torch.sin(beta) |
| cgamma = torch.cos(gamma) |
| sgamma = torch.sin(gamma) |
|
|
| |
| out_roff = torch.zeros(nlat_out + 1, dtype=torch.int64, device=lons_in.device) |
| out_roff[0] = 0 |
| for t in range(nlat_out): |
| |
| alpha = -lats_out[t] |
|
|
| |
| |
| |
| x = torch.cos(alpha) * cbeta * sgamma + cgamma * torch.sin(alpha) |
| y = sbeta * sgamma |
| z = -cbeta * torch.sin(alpha) * sgamma + torch.cos(alpha) * cgamma |
|
|
| |
| |
| norm = torch.sqrt(x * x + y * y + z * z) |
| x = x / norm |
| y = y / norm |
| z = z / norm |
|
|
| |
| theta = torch.arccos(z) |
| phi = torch.arctan2(y, x) |
| phi = torch.where(phi < 0.0, phi + 2 * torch.pi, phi) |
|
|
| |
| iidx, vals = filter_basis.compute_support_vals(theta, phi, r_cutoff=theta_cutoff_eff) |
|
|
| |
| idx = torch.stack([iidx[:, 0], t * torch.ones_like(iidx[:, 0]), iidx[:, 1] * nlon_in + iidx[:, 2]], dim=0) |
|
|
| |
| out_idx.append(idx) |
| out_vals.append(vals) |
| out_roff[t + 1] = out_roff[t] + iidx.shape[0] |
|
|
| |
| out_idx = torch.cat(out_idx, dim=-1) |
| out_vals = torch.cat(out_vals, dim=-1) |
|
|
| out_vals = _normalize_convolution_tensor_s2( |
| out_idx, |
| out_vals, |
| in_shape, |
| out_shape, |
| kernel_size, |
| quad_weights, |
| transpose_normalization=transpose_normalization, |
| basis_norm_mode=basis_norm_mode, |
| merge_quadrature=merge_quadrature, |
| ) |
|
|
| out_idx = out_idx.contiguous() |
| out_vals = out_vals.to(dtype=torch.float32).contiguous() |
|
|
| return out_idx, out_vals, out_roff |
|
|
|
|
| class DiscreteContinuousConv(nn.Module, metaclass=abc.ABCMeta): |
| """ |
| Abstract base class for discrete-continuous convolutions |
| |
| Parameters |
| ----------- |
| in_channels: int |
| Number of input channels |
| out_channels: int |
| Number of output channels |
| kernel_shape: Union[int, Tuple[int], Tuple[int, int]] |
| Shape of the kernel |
| basis_type: Optional[str] |
| Type of the basis functions |
| groups: Optional[int] |
| Number of groups |
| bias: Optional[bool] |
| Whether to use bias |
| |
| Returns |
| ------- |
| out: torch.Tensor |
| Output tensor |
| """ |
|
|
| def __init__( |
| self, |
| in_channels: int, |
| out_channels: int, |
| kernel_shape: Union[int, Tuple[int], Tuple[int, int]], |
| basis_type: Optional[str] = "piecewise linear", |
| groups: Optional[int] = 1, |
| bias: Optional[bool] = True, |
| ): |
| super().__init__() |
|
|
| self.kernel_shape = kernel_shape |
|
|
| |
| self.filter_basis = get_filter_basis(kernel_shape=kernel_shape, basis_type=basis_type) |
|
|
| |
| self.groups = groups |
|
|
| |
| if in_channels % self.groups != 0: |
| raise ValueError("Error, the number of input channels has to be an integer multiple of the group size") |
| if out_channels % self.groups != 0: |
| raise ValueError("Error, the number of output channels has to be an integer multiple of the group size") |
| self.groupsize = in_channels // self.groups |
| scale = math.sqrt(1.0 / self.groupsize / self.kernel_size) |
| self.weight = nn.Parameter(scale * torch.randn(out_channels, self.groupsize, self.kernel_size)) |
|
|
| if bias: |
| self.bias = nn.Parameter(torch.zeros(out_channels)) |
| else: |
| self.bias = None |
|
|
| @property |
| def kernel_size(self): |
| return self.filter_basis.kernel_size |
|
|
| @abc.abstractmethod |
| def forward(self, x: torch.Tensor): |
| raise NotImplementedError |
|
|
|
|
| class DiscreteContinuousConvS2(DiscreteContinuousConv): |
| """ |
| Discrete-continuous (DISCO) convolutions on the 2-Sphere as described in [1]. |
| |
| Parameters |
| ----------- |
| in_channels: int |
| Number of input channels |
| out_channels: int |
| Number of output channels |
| in_shape: Tuple[int] |
| Input shape of the convolution tensor |
| out_shape: Tuple[int] |
| Output shape of the convolution tensor |
| kernel_shape: Union[int, Tuple[int], Tuple[int, int]] |
| Shape of the kernel |
| basis_type: Optional[str] |
| Type of the basis functions |
| basis_norm_mode: Optional[str] |
| Mode for basis normalization |
| groups: Optional[int] |
| Number of groups |
| grid_in: Optional[str] |
| Input grid type |
| grid_out: Optional[str] |
| Output grid type |
| bias: Optional[bool] |
| Whether to use bias |
| theta_cutoff: Optional[float] |
| Theta cutoff for the filter basis functions |
| |
| Returns |
| ------- |
| out: torch.Tensor |
| Output tensor |
| |
| References |
| ---------- |
| [1] Ocampo, Price, McEwen, Scalable and equivariant spherical CNNs by discrete-continuous (DISCO) convolutions, ICLR (2023), arXiv:2209.13603 |
| """ |
|
|
| def __init__( |
| self, |
| in_channels: int, |
| out_channels: int, |
| in_shape: Tuple[int], |
| out_shape: Tuple[int], |
| kernel_shape: Union[int, Tuple[int], Tuple[int, int]], |
| basis_type: Optional[str] = "piecewise linear", |
| basis_norm_mode: Optional[str] = "mean", |
| groups: Optional[int] = 1, |
| grid_in: Optional[str] = "equiangular", |
| grid_out: Optional[str] = "equiangular", |
| bias: Optional[bool] = True, |
| theta_cutoff: Optional[float] = None, |
| ): |
| super().__init__(in_channels, out_channels, kernel_shape, basis_type, groups, bias) |
|
|
| self.nlat_in, self.nlon_in = in_shape |
| self.nlat_out, self.nlon_out = out_shape |
|
|
| |
| assert self.nlon_in % self.nlon_out == 0 |
|
|
| |
| if theta_cutoff is None: |
| theta_cutoff = torch.pi / float(self.nlat_out - 1) |
|
|
| if theta_cutoff <= 0.0: |
| raise ValueError("Error, theta_cutoff has to be positive.") |
|
|
| idx, vals, _ = _precompute_convolution_tensor_s2( |
| in_shape, |
| out_shape, |
| self.filter_basis, |
| grid_in=grid_in, |
| grid_out=grid_out, |
| theta_cutoff=theta_cutoff, |
| transpose_normalization=False, |
| basis_norm_mode=basis_norm_mode, |
| merge_quadrature=True, |
| ) |
|
|
| |
| ker_idx = idx[0, ...].contiguous() |
| row_idx = idx[1, ...].contiguous() |
| col_idx = idx[2, ...].contiguous() |
| vals = vals.contiguous() |
|
|
| if _cuda_extension_available: |
| |
| roff_idx = preprocess_psi(self.kernel_size, self.nlat_out, ker_idx, row_idx, col_idx, vals).contiguous() |
| self.register_buffer("psi_roff_idx", roff_idx, persistent=False) |
|
|
| |
| self.register_buffer("psi_ker_idx", ker_idx, persistent=False) |
| self.register_buffer("psi_row_idx", row_idx, persistent=False) |
| self.register_buffer("psi_col_idx", col_idx, persistent=False) |
| self.register_buffer("psi_vals", vals, persistent=False) |
|
|
| |
| self.psi = _get_psi(self.kernel_size, self.psi_idx, self.psi_vals, self.nlat_in, self.nlon_in, self.nlat_out, self.nlon_out) |
|
|
| def extra_repr(self): |
| return f"in_shape={(self.nlat_in, self.nlon_in)}, out_shape={(self.nlat_out, self.nlon_out)}, in_chans={self.groupsize * self.groups}, out_chans={self.weight.shape[0]}, filter_basis={self.filter_basis}, kernel_shape={self.kernel_shape}, groups={self.groups}" |
|
|
| @property |
| def psi_idx(self): |
| return torch.stack([self.psi_ker_idx, self.psi_row_idx, self.psi_col_idx], dim=0).contiguous() |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
|
|
| if x.is_cuda and _cuda_extension_available: |
| x = _disco_s2_contraction_cuda( |
| x, self.psi_roff_idx, self.psi_ker_idx, self.psi_row_idx, self.psi_col_idx, self.psi_vals, self.kernel_size, self.nlat_out, self.nlon_out |
| ) |
| else: |
| if x.is_cuda: |
| warn("couldn't find CUDA extension, falling back to slow PyTorch implementation") |
| x = _disco_s2_contraction_torch(x, self.psi.to(x.device), self.nlon_out) |
|
|
| |
| B, C, K, H, W = x.shape |
| x = x.reshape(B, self.groups, self.groupsize, K, H, W) |
|
|
| |
| out = torch.einsum("bgckxy,gock->bgoxy", x, self.weight.reshape(self.groups, -1, self.weight.shape[1], self.weight.shape[2])).contiguous() |
| out = out.reshape(B, -1, H, W) |
|
|
| if self.bias is not None: |
| out = out + self.bias.reshape(1, -1, 1, 1) |
|
|
| return out |
|
|
|
|
| class DiscreteContinuousConvTransposeS2(DiscreteContinuousConv): |
| """ |
| Discrete-continuous (DISCO) transpose convolutions on the 2-Sphere as described in [1]. |
| |
| Parameters |
| ----------- |
| in_channels: int |
| Number of input channels |
| out_channels: int |
| Number of output channels |
| in_shape: Tuple[int] |
| Input shape of the convolution tensor |
| out_shape: Tuple[int] |
| Output shape of the convolution tensor |
| kernel_shape: Union[int, Tuple[int], Tuple[int, int]] |
| Shape of the kernel |
| basis_type: Optional[str] |
| Type of the basis functions |
| basis_norm_mode: Optional[str] |
| Mode for basis normalization |
| groups: Optional[int] |
| Number of groups |
| grid_in: Optional[str] |
| Input grid type |
| grid_out: Optional[str] |
| Output grid type |
| bias: Optional[bool] |
| Whether to use bias |
| theta_cutoff: Optional[float] |
| Theta cutoff for the filter basis functions |
| |
| Returns |
| -------- |
| out: torch.Tensor |
| Output tensor |
| |
| References |
| ---------- |
| [1] Ocampo, Price, McEwen, Scalable and equivariant spherical CNNs by discrete-continuous (DISCO) convolutions, ICLR (2023), arXiv:2209.13603 |
| """ |
|
|
| def __init__( |
| self, |
| in_channels: int, |
| out_channels: int, |
| in_shape: Tuple[int], |
| out_shape: Tuple[int], |
| kernel_shape: Union[int, Tuple[int], Tuple[int, int]], |
| basis_type: Optional[str] = "piecewise linear", |
| basis_norm_mode: Optional[str] = "mean", |
| groups: Optional[int] = 1, |
| grid_in: Optional[str] = "equiangular", |
| grid_out: Optional[str] = "equiangular", |
| bias: Optional[bool] = True, |
| theta_cutoff: Optional[float] = None, |
| ): |
| super().__init__(in_channels, out_channels, kernel_shape, basis_type, groups, bias) |
|
|
| self.nlat_in, self.nlon_in = in_shape |
| self.nlat_out, self.nlon_out = out_shape |
|
|
| |
| assert self.nlon_out % self.nlon_in == 0 |
|
|
| |
| if theta_cutoff is None: |
| theta_cutoff = torch.pi / float(self.nlat_in - 1) |
|
|
| if theta_cutoff <= 0.0: |
| raise ValueError("Error, theta_cutoff has to be positive.") |
|
|
| |
| idx, vals, _ = _precompute_convolution_tensor_s2( |
| out_shape, |
| in_shape, |
| self.filter_basis, |
| grid_in=grid_out, |
| grid_out=grid_in, |
| theta_cutoff=theta_cutoff, |
| transpose_normalization=True, |
| basis_norm_mode=basis_norm_mode, |
| merge_quadrature=True, |
| ) |
|
|
| |
| ker_idx = idx[0, ...].contiguous() |
| row_idx = idx[1, ...].contiguous() |
| col_idx = idx[2, ...].contiguous() |
| vals = vals.contiguous() |
|
|
| if _cuda_extension_available: |
| |
| roff_idx = preprocess_psi(self.kernel_size, self.nlat_in, ker_idx, row_idx, col_idx, vals).contiguous() |
| self.register_buffer("psi_roff_idx", roff_idx, persistent=False) |
|
|
| |
| self.register_buffer("psi_ker_idx", ker_idx, persistent=False) |
| self.register_buffer("psi_row_idx", row_idx, persistent=False) |
| self.register_buffer("psi_col_idx", col_idx, persistent=False) |
| self.register_buffer("psi_vals", vals, persistent=False) |
|
|
| |
| self.psi_st = _get_psi(self.kernel_size, self.psi_idx, self.psi_vals, self.nlat_in, self.nlon_in, self.nlat_out, self.nlon_out, semi_transposed=True) |
|
|
| def extra_repr(self): |
| return f"in_shape={(self.nlat_in, self.nlon_in)}, out_shape={(self.nlat_out, self.nlon_out)}, in_chans={self.groupsize * self.groups}, out_chans={self.weight.shape[0]}, filter_basis={self.filter_basis}, kernel_shape={self.kernel_shape}, groups={self.groups}" |
|
|
| @property |
| def psi_idx(self): |
| return torch.stack([self.psi_ker_idx, self.psi_row_idx, self.psi_col_idx], dim=0).contiguous() |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
|
|
| |
| B, C, H, W = x.shape |
| x = x.reshape(B, self.groups, self.groupsize, H, W) |
|
|
| |
| x = torch.einsum("bgcxy,gock->bgokxy", x, self.weight.reshape(self.groups, -1, self.weight.shape[1], self.weight.shape[2])).contiguous() |
| x = x.reshape(B, -1, x.shape[-3], H, W) |
|
|
| if x.is_cuda and _cuda_extension_available: |
| out = _disco_s2_transpose_contraction_cuda( |
| x, self.psi_roff_idx, self.psi_ker_idx, self.psi_row_idx, self.psi_col_idx, self.psi_vals, self.kernel_size, self.nlat_out, self.nlon_out |
| ) |
| else: |
| if x.is_cuda: |
| warn("couldn't find CUDA extension, falling back to slow PyTorch implementation") |
| out = _disco_s2_transpose_contraction_torch(x, self.psi_st.to(x.device), self.nlon_out) |
|
|
| if self.bias is not None: |
| out = out + self.bias.reshape(1, -1, 1, 1) |
|
|
| return out |
|
|