| from __future__ import annotations |
|
|
| import math |
| import warnings |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
|
|
| def _activation(name: str) -> nn.Module: |
| activations = { |
| "gelu": nn.GELU, |
| "relu": nn.ReLU, |
| "silu": nn.SiLU, |
| "tanh": nn.Tanh, |
| } |
| try: |
| return activations[name.lower()]() |
| except KeyError as error: |
| raise ValueError(f"Unsupported activation: {name}") from error |
|
|
|
|
| class MLP(nn.Module): |
| def __init__( |
| self, |
| input_dim: int, |
| hidden_dim: int, |
| output_dim: int, |
| activation: str, |
| ) -> None: |
| super().__init__() |
| self.layers = nn.Sequential( |
| nn.Linear(input_dim, hidden_dim), |
| _activation(activation), |
| nn.Linear(hidden_dim, output_dim), |
| ) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| return self.layers(x) |
|
|
|
|
| def _iterative_pinv(matrix: torch.Tensor, iterations: int = 6) -> torch.Tensor: |
| absolute = matrix.abs() |
| column_norm = absolute.sum(dim=-1).amax() |
| row_norm = absolute.sum(dim=-2).amax() |
| scale = (column_norm * row_norm).clamp_min(torch.finfo(matrix.dtype).eps) |
| inverse = matrix.transpose(-1, -2) / scale |
| identity = torch.eye( |
| matrix.shape[-1], dtype=matrix.dtype, device=matrix.device |
| ).reshape(1, 1, matrix.shape[-1], matrix.shape[-1]) |
| for _ in range(iterations): |
| product = matrix @ inverse |
| inverse = 0.25 * inverse @ ( |
| 13 * identity |
| - product @ (15 * identity - product @ (7 * identity - product)) |
| ) |
| return inverse |
|
|
|
|
| class NystromAttention(nn.Module): |
| """Linear-complexity Nyström approximation of multi-head attention.""" |
|
|
| def __init__( |
| self, |
| dim: int, |
| heads: int, |
| dim_head: int, |
| dropout: float, |
| num_landmarks: int = 256, |
| pinv_iterations: int = 6, |
| residual_conv_kernel: int = 33, |
| ) -> None: |
| super().__init__() |
| inner_dim = heads * dim_head |
| self.heads = heads |
| self.dim_head = dim_head |
| self.num_landmarks = num_landmarks |
| self.pinv_iterations = pinv_iterations |
| self.scale = dim_head**-0.5 |
| self.to_qkv = nn.Linear(dim, inner_dim * 3, bias=False) |
| self.to_out = nn.Sequential(nn.Linear(inner_dim, dim), nn.Dropout(dropout)) |
| padding = residual_conv_kernel // 2 |
| self.residual = nn.Conv2d( |
| heads, |
| heads, |
| kernel_size=(residual_conv_kernel, 1), |
| padding=(padding, 0), |
| groups=heads, |
| bias=False, |
| ) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| batch_size, point_count, _ = x.shape |
| landmarks = self.num_landmarks |
| group_size = math.ceil(point_count / landmarks) |
| padded_points = landmarks * group_size |
| padding = padded_points - point_count |
| if padding: |
| x = F.pad(x, (0, 0, padding, 0)) |
|
|
| query, key, value = self.to_qkv(x).chunk(3, dim=-1) |
|
|
| def split_heads(tensor: torch.Tensor) -> torch.Tensor: |
| return tensor.reshape( |
| batch_size, padded_points, self.heads, self.dim_head |
| ).permute(0, 2, 1, 3) |
|
|
| query, key, value = map(split_heads, (query, key, value)) |
| query = query * self.scale |
| query_landmarks = query.reshape( |
| batch_size, self.heads, landmarks, group_size, self.dim_head |
| ).mean(dim=3) |
| key_landmarks = key.reshape( |
| batch_size, self.heads, landmarks, group_size, self.dim_head |
| ).mean(dim=3) |
|
|
| similarity1 = torch.einsum("bhid,bhjd->bhij", query, key_landmarks) |
| similarity2 = torch.einsum( |
| "bhid,bhjd->bhij", query_landmarks, key_landmarks |
| ) |
| similarity3 = torch.einsum("bhid,bhjd->bhij", query_landmarks, key) |
| attention1 = similarity1.softmax(dim=-1) |
| attention2 = similarity2.softmax(dim=-1) |
| attention3 = similarity3.softmax(dim=-1) |
| attention2_inverse = _iterative_pinv( |
| attention2, iterations=self.pinv_iterations |
| ) |
| output = (attention1 @ attention2_inverse) @ (attention3 @ value) |
| output = output + self.residual(value) |
| output = output.permute(0, 2, 1, 3).reshape(batch_size, padded_points, -1) |
| return self.to_out(output[:, -point_count:]) |
|
|
|
|
| class LinearAttention(nn.Module): |
| def __init__(self, dim: int, heads: int, dim_head: int, dropout: float) -> None: |
| super().__init__() |
| self.heads = heads |
| self.dim_head = dim_head |
| self.query = nn.Linear(dim, dim) |
| self.key = nn.Linear(dim, dim) |
| self.value = nn.Linear(dim, dim) |
| self.dropout = nn.Dropout(dropout) |
| self.output = nn.Linear(dim, dim) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| batch_size, point_count, channels = x.shape |
|
|
| def split_heads(tensor: torch.Tensor) -> torch.Tensor: |
| return tensor.reshape( |
| batch_size, point_count, self.heads, self.dim_head |
| ).transpose(1, 2) |
|
|
| query = split_heads(self.query(x)).softmax(dim=-1) |
| key = split_heads(self.key(x)).softmax(dim=-1) |
| value = split_heads(self.value(x)) |
| context = key.transpose(-2, -1) @ value |
| output = self.dropout((query @ context) / float(point_count) + query) |
| output = output.transpose(1, 2).reshape(batch_size, point_count, channels) |
| return self.output(output) |
|
|
|
|
| class SelfAttention(nn.Module): |
| def __init__(self, dim: int, heads: int, dim_head: int, dropout: float) -> None: |
| super().__init__() |
| self.heads = heads |
| self.dim_head = dim_head |
| inner_dim = heads * dim_head |
| self.query = nn.Linear(dim, inner_dim, bias=False) |
| self.key = nn.Linear(dim, inner_dim, bias=False) |
| self.value = nn.Linear(dim, inner_dim, bias=False) |
| self.output = nn.Sequential(nn.Linear(inner_dim, dim), nn.Dropout(dropout)) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| batch_size, point_count, _ = x.shape |
|
|
| def split_heads(tensor: torch.Tensor) -> torch.Tensor: |
| return tensor.reshape( |
| batch_size, point_count, self.heads, self.dim_head |
| ).transpose(1, 2) |
|
|
| query = split_heads(self.query(x)).softmax(dim=-1) * self.dim_head**-0.5 |
| key = split_heads(self.key(x)).softmax(dim=-2) |
| value = split_heads(self.value(x)) |
| context = torch.einsum("bhnd,bhne->bhde", key, value) |
| output = torch.einsum("bhnd,bhde->bhne", query, context) |
| output = output.transpose(1, 2).reshape(batch_size, point_count, -1) |
| return self.output(output) |
|
|
|
|
| def _make_attention( |
| attn_type: str, |
| hidden_dim: int, |
| num_heads: int, |
| dropout: float, |
| ) -> nn.Module: |
| arguments = { |
| "dim": hidden_dim, |
| "heads": num_heads, |
| "dim_head": hidden_dim // num_heads, |
| "dropout": dropout, |
| } |
| if attn_type == "nystrom": |
| return NystromAttention(**arguments) |
| if attn_type == "linear": |
| return LinearAttention(**arguments) |
| if attn_type == "selfAttention": |
| return SelfAttention(**arguments) |
| raise ValueError("attn_type must be nystrom, linear, or selfAttention") |
|
|
|
|
| def _safe_cholesky(matrix: torch.Tensor) -> torch.Tensor: |
| try: |
| factor = torch.linalg.cholesky(matrix) |
| if torch.isnan(factor).any(): |
| raise RuntimeError("Cholesky factor contains NaN") |
| return factor |
| except RuntimeError as original_error: |
| if torch.isnan(matrix).any(): |
| raise ValueError("Orthogonal feature covariance contains NaN") from original_error |
| jitter = 1.0e-6 if matrix.dtype == torch.float32 else 1.0e-8 |
| stabilized = matrix.clone() |
| previous = 0.0 |
| for exponent in range(10): |
| current = jitter * 10**exponent |
| stabilized.diagonal().add_(current - previous) |
| previous = current |
| try: |
| factor = torch.linalg.cholesky(stabilized) |
| warnings.warn( |
| f"Feature covariance required Cholesky jitter {current}", |
| RuntimeWarning, |
| ) |
| return factor |
| except RuntimeError: |
| continue |
| raise original_error |
|
|
|
|
| class OrthogonalNeuralBlock(nn.Module): |
| def __init__( |
| self, |
| hidden_dim: int, |
| num_heads: int, |
| dropout: float, |
| activation: str, |
| attn_type: str, |
| mlp_ratio: int, |
| psi_dim: int, |
| out_dim: int, |
| last_layer: bool, |
| momentum: float = 0.9, |
| ) -> None: |
| super().__init__() |
| self.momentum = momentum |
| self.register_buffer("feature_cov", torch.zeros(psi_dim, psi_dim)) |
| self.mu = nn.Parameter(torch.zeros(psi_dim)) |
| self.norm1 = nn.LayerNorm(hidden_dim) |
| self.attention = _make_attention( |
| attn_type, hidden_dim, num_heads, dropout |
| ) |
| self.norm2 = nn.LayerNorm(hidden_dim) |
| self.mlp = MLP( |
| hidden_dim, |
| hidden_dim * mlp_ratio, |
| hidden_dim, |
| activation, |
| ) |
| self.projection = nn.Linear(hidden_dim, psi_dim) |
| self.norm3 = nn.LayerNorm(hidden_dim) |
| self.field_output = ( |
| nn.Linear(hidden_dim, out_dim) |
| if last_layer |
| else MLP( |
| hidden_dim, |
| hidden_dim * mlp_ratio, |
| hidden_dim, |
| activation, |
| ) |
| ) |
|
|
| def forward( |
| self, feature: torch.Tensor, field: torch.Tensor |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| feature = self.attention(self.norm1(feature)) + feature |
| feature = self.mlp(self.norm2(feature)) + feature |
| projected = self.projection(feature) |
| if self.training: |
| covariance = torch.einsum( |
| "bnc,bnd->cd", projected, projected |
| ) / (projected.shape[0] * projected.shape[1]) |
| with torch.no_grad(): |
| self.feature_cov.mul_(self.momentum).add_( |
| covariance, alpha=1.0 - self.momentum |
| ) |
| else: |
| covariance = self.feature_cov |
|
|
| factor = _safe_cholesky(covariance) |
| inverse_transpose = torch.linalg.inv(factor).transpose(-2, -1) |
| orthogonal = projected @ inverse_transpose |
| field = (orthogonal * F.softplus(self.mu)) @ ( |
| orthogonal.transpose(-2, -1) @ field |
| ) + field |
| return feature, self.field_output(self.norm3(field)) |
|
|
|
|
| class ONO(nn.Module): |
| """Orthogonal Neural Operator for pointwise physical-field forecasting.""" |
|
|
| def __init__( |
| self, |
| in_dim: int, |
| out_dim: int, |
| hidden_dim: int = 64, |
| num_layers: int = 4, |
| num_heads: int = 4, |
| space_dim: int = 2, |
| include_pos: bool = True, |
| dropout: float = 0.0, |
| activation: str = "gelu", |
| mlp_ratio: int = 1, |
| attn_type: str = "nystrom", |
| psi_dim: int = 8, |
| ) -> None: |
| super().__init__() |
| self.in_dim = int(in_dim) |
| self.out_dim = int(out_dim) |
| self.hidden_dim = int(hidden_dim) |
| self.num_layers = int(num_layers) |
| self.num_heads = int(num_heads) |
| self.space_dim = int(space_dim) |
| self.include_pos = bool(include_pos) |
| if self.hidden_dim % self.num_heads: |
| raise ValueError("hidden_dim must be divisible by num_heads") |
| if min(self.num_layers, self.num_heads, int(mlp_ratio), int(psi_dim)) < 1: |
| raise ValueError("num_layers, num_heads, mlp_ratio and psi_dim must be positive") |
| if not 0.0 <= float(dropout) < 1.0: |
| raise ValueError("dropout must be in [0, 1)") |
|
|
| feature_dim = self.in_dim + (self.space_dim if self.include_pos else 0) |
| self.preprocess_feature = MLP( |
| feature_dim, self.hidden_dim * 2, self.hidden_dim, activation |
| ) |
| self.preprocess_field = MLP( |
| feature_dim, self.hidden_dim * 2, self.hidden_dim, activation |
| ) |
| self.blocks = nn.ModuleList( |
| OrthogonalNeuralBlock( |
| hidden_dim=self.hidden_dim, |
| num_heads=self.num_heads, |
| dropout=float(dropout), |
| activation=activation, |
| attn_type=attn_type, |
| mlp_ratio=int(mlp_ratio), |
| psi_dim=int(psi_dim), |
| out_dim=self.out_dim, |
| last_layer=layer == self.num_layers - 1, |
| ) |
| for layer in range(self.num_layers) |
| ) |
| self.placeholder = nn.Parameter( |
| torch.rand(self.hidden_dim) / self.hidden_dim |
| ) |
| self.apply(self._initialize_weights) |
|
|
| @staticmethod |
| def _initialize_weights(module: nn.Module) -> None: |
| if isinstance(module, nn.Linear): |
| nn.init.trunc_normal_(module.weight, std=0.02) |
| if module.bias is not None: |
| nn.init.zeros_(module.bias) |
| elif isinstance(module, (nn.LayerNorm, nn.BatchNorm1d)): |
| if module.bias is not None: |
| nn.init.zeros_(module.bias) |
| if module.weight is not None: |
| nn.init.ones_(module.weight) |
|
|
| def forward( |
| self, pos: torch.Tensor, field: torch.Tensor | None = None |
| ) -> torch.Tensor: |
| batch_size, point_count, coordinate_dim = pos.shape |
| if coordinate_dim != self.space_dim: |
| raise ValueError( |
| f"Expected pos[..., {self.space_dim}], got {tuple(pos.shape)}" |
| ) |
| if field is None: |
| if self.in_dim: |
| raise ValueError("field is required when in_dim > 0") |
| inputs = pos if self.include_pos else pos.new_empty(batch_size, point_count, 0) |
| else: |
| if field.shape != (batch_size, point_count, self.in_dim): |
| raise ValueError( |
| f"Expected field [B, {point_count}, {self.in_dim}], " |
| f"got {tuple(field.shape)}" |
| ) |
| inputs = torch.cat((pos, field), dim=-1) if self.include_pos else field |
|
|
| feature = self.preprocess_feature(inputs) |
| field_feature = self.preprocess_field(inputs) + self.placeholder[None, None, :] |
| for block in self.blocks: |
| feature, field_feature = block(feature, field_feature) |
| return field_feature |
|
|