Files
caption-test/src/models/sofia_v71/layers.py
2026-04-29 08:04:33 +03:00

399 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SOFIA v7.1 custom layers.
Includes:
- GGeM: Generalized Mean Pooling with per-channel learnable exponent (F11)
- CircularHarmonicPool: Formally SO(2)-invariant pooling via polar + FFT magnitude (NH2 novel)
- AltitudeFiLM: FiLM conditioning on UAV altitude (NH4 novel)
- RoPE2D: 2D Rotary Position Embedding for attention
- SqueezeExcite: standard SE block
- LayerNorm2d: channel-last LN wrapper for 2D features
All rotation-invariance and FiLM modules are NOVEL contributions of SOFIA v7.1-α.
"""
from __future__ import annotations
import math
from typing import Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
# ============================================================
# GGeM: Generalized Mean Pooling (F11)
# ============================================================
class GGeM(nn.Module):
"""Per-channel learnable Generalized Mean pooling.
Formula:
GGeM(F)_c = (1/HW · Σ F_{c,h,w}^{p_c})^{1/p_c}
p_c = softplus(p_hat_c) ∈ (0, ∞)
"""
def __init__(self, channels: int, init_p: float = 3.0, eps: float = 1e-6) -> None:
super().__init__()
# softplus^{-1}(init_p) = log(exp(init_p) - 1)
hat_init = math.log(math.exp(init_p) - 1.0)
self.hat_p = nn.Parameter(torch.full((channels,), hat_init))
self.eps = eps
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""x: [B, C, H, W] -> [B, C]"""
p = F.softplus(self.hat_p).view(1, -1, 1, 1) # [1, C, 1, 1]
x_clamped = x.clamp(min=self.eps)
x_pow = x_clamped.pow(p)
x_mean = x_pow.mean(dim=(2, 3), keepdim=True)
out = x_mean.pow(1.0 / p)
return out.flatten(1)
# ============================================================
# CHP: Circular Harmonic Pool (NH2 novel — formally SO(2)-invariant)
# ============================================================
class CircularHarmonicPool(nn.Module):
"""Formally SO(2) rotation-invariant pooling.
Algorithm:
1. Sample input feature map at polar grid (r, θ) via bilinear grid_sample
2. Apply 1D real FFT along θ-axis
3. Keep magnitudes of first N harmonics (invariant to shift = rotation)
4. GGeM pool over rings r (per-channel-per-harmonic)
5. Flatten to descriptor [B, C * N]
Output is theoretically invariant to input rotation of any angle.
See HYP Phase 4'' Section 4''.1 NH2 and Section 4''.5 for formal proof.
"""
def __init__(
self,
channels: int,
rings: int = 8,
angles: int = 16,
harmonics: int = 4,
r_min: float = 0.1,
r_max: float = 1.0,
) -> None:
super().__init__()
assert harmonics <= angles // 2 + 1, (
f"harmonics {harmonics} cannot exceed angles//2+1 = {angles // 2 + 1}"
)
self.channels = channels
self.rings = rings
self.angles = angles
self.harmonics = harmonics
# GGeM over rings (per channel × per harmonic)
self.ggem = GGeM(channels * harmonics, init_p=3.0)
# Precompute polar grid in normalized [-1, 1] coords for grid_sample
grid = self._make_polar_grid(rings, angles, r_min, r_max) # [R, T, 2]
self.register_buffer("polar_grid", grid, persistent=False)
@staticmethod
def _make_polar_grid(R: int, T: int, r_min: float, r_max: float) -> torch.Tensor:
r_values = torch.linspace(r_min, r_max, R) # [R]
theta_values = torch.linspace(0.0, 2 * math.pi, T + 1)[:-1] # [T]
r_grid, theta_grid = torch.meshgrid(r_values, theta_values, indexing="ij") # [R, T]
x = r_grid * torch.cos(theta_grid)
y = r_grid * torch.sin(theta_grid)
grid = torch.stack([x, y], dim=-1) # [R, T, 2]
return grid
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Args:
x: [B, C, H, W] feature map
Returns:
descriptor: [B, C * harmonics]
"""
B, C, H, W = x.shape
assert C == self.channels, (
f"Input channels {C} != expected {self.channels}"
)
# 1. Polar sampling
# grid: [B, R, T, 2]
grid = self.polar_grid.unsqueeze(0).expand(B, -1, -1, -1)
polar = F.grid_sample(
x, grid,
mode="bilinear",
padding_mode="zeros",
align_corners=True,
) # [B, C, R, T]
# 2. 1D real FFT along angular axis
polar_fft = torch.fft.rfft(polar, dim=-1) # [B, C, R, T//2+1]
polar_fft = polar_fft[..., : self.harmonics] # [B, C, R, N]
# 3. Magnitude (rotation invariant)
magnitude = polar_fft.abs() # [B, C, R, N]
# 4. Reshape for GGeM: treat (C, N) as combined channel dim, rings as spatial
# Shape: [B, C*N, R, 1]
magnitude_reshaped = (
magnitude
.permute(0, 1, 3, 2) # [B, C, N, R]
.reshape(B, C * self.harmonics, self.rings, 1)
)
# 5. GGeM pool over rings (H=R, W=1)
descriptor = self.ggem(magnitude_reshaped) # [B, C*N]
return descriptor
# ============================================================
# FiLM: Altitude-conditioned modulation (NH4 novel)
# ============================================================
class AltitudeFiLM(nn.Module):
"""FiLM modulation conditioned on scalar altitude.
F' = γ(h) · F + β(h) where γ,β ∈ R^C are produced by MLP.
At altitude=None, produces identity (γ=1, β=0) via zero-init of final layer.
"""
def __init__(
self,
channels: int,
hidden_dim: int = 64,
altitude_norm: float = 500.0,
) -> None:
super().__init__()
self.channels = channels
self.altitude_norm = altitude_norm
self.mlp = nn.Sequential(
nn.Linear(1, hidden_dim),
nn.GELU(),
nn.Linear(hidden_dim, 2 * channels),
)
# Zero-init last layer → initial γ=0 before residual, β=0
nn.init.zeros_(self.mlp[-1].weight)
nn.init.zeros_(self.mlp[-1].bias)
def forward(self, x: torch.Tensor, altitude: Optional[torch.Tensor] = None) -> torch.Tensor:
"""
Args:
x: [B, C, H, W]
altitude: [B] or [B, 1] scalar altitude in meters (or None for neutral)
Returns:
[B, C, H, W]
"""
B = x.shape[0]
if altitude is None:
altitude = torch.zeros(B, 1, device=x.device, dtype=x.dtype)
elif altitude.dim() == 1:
altitude = altitude.unsqueeze(-1)
h_norm = altitude.to(x.dtype) / self.altitude_norm
gamma_beta = self.mlp(h_norm) # [B, 2C]
gamma, beta = gamma_beta.chunk(2, dim=-1)
# Residual form: γ = 1 + delta_γ (starts at identity)
gamma = gamma.view(B, self.channels, 1, 1) + 1.0
beta = beta.view(B, self.channels, 1, 1)
return gamma * x + beta
# ============================================================
# TextFiLM: text-conditioned modulation (extension for caption fusion)
# ============================================================
class TextFiLM(nn.Module):
"""FiLM modulation conditioned on a text embedding.
F' = γ(z) · F + β(z) where γ,β ∈ R^C are produced by an MLP
from z ∈ R^{D_txt}. Identity at init via zero-init of last layer
(so γ=1, β=0 before training shifts the residual).
"""
def __init__(
self,
channels: int,
text_dim: int = 1024,
hidden_dim: int = 256,
) -> None:
super().__init__()
self.channels = channels
self.text_dim = text_dim
self.mlp = nn.Sequential(
nn.Linear(text_dim, hidden_dim),
nn.GELU(),
nn.Linear(hidden_dim, 2 * channels),
)
nn.init.zeros_(self.mlp[-1].weight)
nn.init.zeros_(self.mlp[-1].bias)
def forward(self, x: torch.Tensor, text_emb: Optional[torch.Tensor] = None) -> torch.Tensor:
"""
Args:
x: [B, C, H, W]
text_emb: [B, D_txt] or None for no-op (identity)
Returns:
[B, C, H, W]
"""
if text_emb is None:
return x
B = x.shape[0]
gamma_beta = self.mlp(text_emb.to(x.dtype)) # [B, 2C]
gamma, beta = gamma_beta.chunk(2, dim=-1)
gamma = gamma.view(B, self.channels, 1, 1) + 1.0
beta = beta.view(B, self.channels, 1, 1)
return gamma * x + beta
# ============================================================
# RoPE 2D
# ============================================================
class RoPE2D(nn.Module):
"""2D Rotary Position Embedding.
Splits head_dim into two halves: first half gets x-position encoding,
second half gets y-position encoding. For each half, applies standard
1D RoPE rotation.
Reference: RoFormer (B49) adapted for 2D.
"""
def __init__(self, head_dim: int, max_resolution: int = 64, base: float = 10000.0) -> None:
super().__init__()
assert head_dim % 2 == 0, "head_dim must be even for RoPE"
assert head_dim % 4 == 0, "head_dim must be divisible by 4 for 2D RoPE"
self.head_dim = head_dim
self.half_dim = head_dim // 2 # dedicated to each axis
self.max_resolution = max_resolution
# Frequencies for each axis (half_dim per axis, sin+cos pairs)
freqs = 1.0 / (base ** (torch.arange(0, self.half_dim, 2).float() / self.half_dim))
self.register_buffer("freqs", freqs, persistent=False)
def _make_embeds(self, H: int, W: int, device: torch.device) -> Tuple[torch.Tensor, torch.Tensor]:
"""Produce cos/sin embeddings for HW tokens in raster order."""
y_pos = torch.arange(H, device=device, dtype=torch.float32)
x_pos = torch.arange(W, device=device, dtype=torch.float32)
freqs_y = torch.einsum("i,j->ij", y_pos, self.freqs) # [H, half_dim/2]
freqs_x = torch.einsum("i,j->ij", x_pos, self.freqs) # [W, half_dim/2]
# Expand to full grid: [H, W, half_dim/2] each
freqs_y = freqs_y.unsqueeze(1).expand(-1, W, -1) # [H, W, half_dim/2]
freqs_x = freqs_x.unsqueeze(0).expand(H, -1, -1) # [H, W, half_dim/2]
# Concatenate: x-axis freqs into first half, y-axis into second half
# Each half is [H, W, half_dim/2]; we pair-up for complex rotation
freqs_combined_x = torch.cat([freqs_x, freqs_x], dim=-1) # [H, W, half_dim]
freqs_combined_y = torch.cat([freqs_y, freqs_y], dim=-1) # [H, W, half_dim]
freqs_full = torch.cat([freqs_combined_x, freqs_combined_y], dim=-1) # [H, W, head_dim]
cos = freqs_full.cos().reshape(H * W, -1)
sin = freqs_full.sin().reshape(H * W, -1)
return cos, sin
@staticmethod
def _rotate_half(x: torch.Tensor) -> torch.Tensor:
"""Rotate: (x1, x2) -> (-x2, x1)."""
x1, x2 = x.chunk(2, dim=-1)
return torch.cat([-x2, x1], dim=-1)
def forward(self, q: torch.Tensor, k: torch.Tensor, H: int, W: int) -> Tuple[torch.Tensor, torch.Tensor]:
"""
Args:
q, k: [B, heads, HW, head_dim]
H, W: spatial dims for position computation
Returns:
q_rot, k_rot with positional encoding applied
"""
cos, sin = self._make_embeds(H, W, q.device)
cos = cos.to(q.dtype).unsqueeze(0).unsqueeze(0) # [1, 1, HW, head_dim]
sin = sin.to(q.dtype).unsqueeze(0).unsqueeze(0)
q_rot = (q * cos) + (self._rotate_half(q) * sin)
k_rot = (k * cos) + (self._rotate_half(k) * sin)
return q_rot, k_rot
# ============================================================
# SE: Squeeze-Excite
# ============================================================
class SqueezeExcite(nn.Module):
"""Standard Squeeze-Excite channel attention."""
def __init__(self, channels: int, reduction: int = 16) -> None:
super().__init__()
hidden = max(1, channels // reduction)
self.pool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Sequential(
nn.Conv2d(channels, hidden, 1),
nn.SiLU(inplace=True),
nn.Conv2d(hidden, channels, 1),
nn.Sigmoid(),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
s = self.pool(x)
s = self.fc(s)
return x * s
# ============================================================
# LayerNorm2d: LN over channels for (B, C, H, W) layout
# ============================================================
class LayerNorm2d(nn.Module):
"""LayerNorm over C dimension for 4D tensors."""
def __init__(self, channels: int, eps: float = 1e-6) -> None:
super().__init__()
self.norm = nn.LayerNorm(channels, eps=eps)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# [B, C, H, W] → [B, H, W, C] → LN → [B, C, H, W]
x = x.permute(0, 2, 3, 1)
x = self.norm(x)
x = x.permute(0, 3, 1, 2).contiguous()
return x
if __name__ == "__main__":
# Smoke test
torch.manual_seed(0)
# GGeM test
g = GGeM(64)
x = torch.randn(2, 64, 8, 8)
out = g(x)
print(f"GGeM out: {out.shape}") # [2, 64]
# CHP test — verify rotation invariance
chp = CircularHarmonicPool(32, rings=8, angles=16, harmonics=4)
x = torch.randn(1, 32, 16, 16)
out1 = chp(x)
# Rotate x by 90° and verify invariance (approximately)
x_rot = torch.rot90(x, k=1, dims=(-2, -1))
out2 = chp(x_rot)
diff = (out1 - out2).abs().max().item()
print(f"CHP rotation-invariance max diff: {diff:.4e} (should be small)")
print(f"CHP out shape: {out1.shape}") # [1, 128]
# FiLM test
film = AltitudeFiLM(64)
x = torch.randn(2, 64, 8, 8)
altitudes = torch.tensor([150.0, 300.0])
out = film(x, altitudes)
print(f"FiLM out: {out.shape}") # [2, 64, 8, 8]
# RoPE2D test
rope = RoPE2D(32)
q = torch.randn(2, 4, 64, 32) # [B, heads, HW, head_dim]
k = torch.randn(2, 4, 64, 32)
q_r, k_r = rope(q, k, 8, 8)
print(f"RoPE out: q={q_r.shape}, k={k_r.shape}")