PLantDetect-WA / src /models /moe_vit.py
thenukegun10x's picture
Update src\models\moe_vit.py
2574ad2 verified
Raw
History Blame Contribute Delete
17 kB
"""MoE-ViT: MoE++ (zero/copy/constant experts) on a pretrained DINOv3 ViT stem.
Replaces the MoR recursive core with a plain stacked-transformer core whose
FFNs are MoE++ layers (arXiv:2410.07348):
dense MLP (d_ff=1536) -> 4 FFN experts (d_ff=384) + 1 zero + 1 copy
+ 2 constant experts, top_k=2 per token
Routing is per patch token, so easy image regions land on zero/copy/constant
specialists (near-zero compute) while hard regions route to real FFN experts -
adaptive compute at the finest granularity, with no recursion machinery.
The frozen stem and the study-level pooling/head mirror MoR-ViT so the two
models share the same ``features()`` / ``forward()`` contract and can be
compared on identical benchmarks.
"""
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
try:
import timm
HAS_TIMM = True
except ImportError: # pragma: no cover - optional dependency
HAS_TIMM = False
class SwiGLU(nn.Module):
def __init__(self, d_model: int, d_ff: int):
super().__init__()
self.w1 = nn.Linear(d_model, d_ff, bias=False)
self.w2 = nn.Linear(d_ff, d_model, bias=False)
self.w3 = nn.Linear(d_model, d_ff, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.w2(F.silu(self.w1(x)) * self.w3(x))
class ConstantExpert(nn.Module):
"""MoE++ constant expert: alpha1*x + alpha2*v (negligible params)."""
def __init__(self, d_model: int):
super().__init__()
self.v = nn.Parameter(torch.zeros(d_model))
self.wc = nn.Linear(d_model, 2, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
alpha = F.softmax(self.wc(x), dim=-1)
return (alpha[..., 0:1] * x + alpha[..., 1:2] * self.v.to(dtype=x.dtype)).to(dtype=x.dtype)
class MoEFFN(nn.Module):
"""Sparse MoE FFN with MoE++ zero / copy / constant experts.
Index layout: [FFN | zero | copy | constant]. Routing is softmax top-k
with renormalisation; only tokens selected for a real FFN expert are
computed (``index_select``), so zero-compute routing is real skipping.
Returns the load-balance aux loss and pre-softmax gate logits.
"""
def __init__(
self,
d_model: int,
d_ff: int,
num_ffn: int = 4,
top_k: int = 2,
n_zero: int = 1,
n_copy: int = 1,
n_const: int = 2,
tau: float = 0.75,
gating_residual: bool = True,
gate_ctx: bool = False,
):
super().__init__()
self.num_ffn = num_ffn
self.top_k = min(top_k, num_ffn + n_zero + n_copy + n_const)
self.n_zero = max(0, n_zero)
self.n_copy = max(0, n_copy)
self.n_const = max(0, n_const)
self.n_zc = self.n_zero + self.n_copy + self.n_const
self.total_experts = num_ffn + self.n_zc
self.last_counts: torch.Tensor | None = None
self.tau = float(tau)
self.gate_ctx = gate_ctx
self.copy_start = num_ffn + self.n_zero
self.const_start = self.copy_start + self.n_copy
self.router = nn.Linear(d_model, self.total_experts, bias=False)
self.experts = nn.ModuleList([SwiGLU(d_model, d_ff) for _ in range(num_ffn)])
self.const_experts = nn.ModuleList([ConstantExpert(d_model) for _ in range(self.n_const)])
self.gating_residual = None
if gating_residual:
self.gating_residual = nn.Linear(self.total_experts, self.total_experts, bias=False)
nn.init.zeros_(self.gating_residual.weight)
self.ctx_proj = None
if gate_ctx:
self.ctx_proj = nn.Linear(d_model, self.total_experts, bias=False)
nn.init.zeros_(self.ctx_proj.weight)
eta = torch.ones(self.total_experts)
if self.n_zc > 0:
eta[num_ffn:] = self.tau
self.register_buffer("eta", eta, persistent=False)
def forward(
self,
x: torch.Tensor,
prev_gate: torch.Tensor | None = None,
ctx: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""Returns (output, aux_loss, gate_logits, gate_weights)."""
B, T, D = x.shape
x_2d = x.reshape(-1, D)
N = x_2d.size(0)
gate_logits = self.router(x_2d)
if self.ctx_proj is not None and ctx is not None:
gate_logits = gate_logits + self.ctx_proj(ctx).repeat_interleave(T, dim=0)
if self.gating_residual is not None and prev_gate is not None:
if prev_gate.dim() == 3:
prev_gate = prev_gate.reshape(-1, prev_gate.size(-1))
if prev_gate.size(0) == N and prev_gate.size(-1) == self.total_experts:
gate_logits = gate_logits + self.gating_residual(prev_gate)
gate_weights = F.softmax(gate_logits, dim=-1)
top_weights, top_indices = gate_weights.topk(self.top_k, dim=-1)
top_weights = top_weights / top_weights.sum(dim=-1, keepdim=True).clamp(min=1e-9)
route_weights = top_weights.to(dtype=x_2d.dtype)
flat_expert = top_indices.reshape(-1)
flat_token = (
torch.arange(N, device=x.device)
.unsqueeze(1)
.expand(-1, self.top_k)
.reshape(-1)
)
flat_weight = route_weights.reshape(-1, 1)
order = torch.argsort(flat_expert, stable=True)
sorted_tokens = flat_token[order]
sorted_weights = flat_weight[order]
sorted_x = x_2d.index_select(0, sorted_tokens)
counts = torch.bincount(
flat_expert, minlength=self.total_experts
).to(torch.int64)
offsets = torch.cat(
[torch.zeros(1, dtype=torch.int64, device=x.device),
torch.cumsum(counts, 0)[:-1]]
)
output = torch.zeros_like(x_2d)
for expert_idx in range(self.num_ffn):
lo = int(offsets[expert_idx])
hi = lo + int(counts[expert_idx])
if lo == hi:
continue
tids = sorted_tokens[lo:hi]
w = sorted_weights[lo:hi]
output.index_add_(
0, tids,
(self.experts[expert_idx](sorted_x[lo:hi]) * w).to(dtype=output.dtype),
)
if self.n_zc > 0:
for i in range(self.n_copy):
expert_idx = self.copy_start + i
lo = int(offsets[expert_idx])
hi = lo + int(counts[expert_idx])
if lo == hi:
continue
tids = sorted_tokens[lo:hi]
w = sorted_weights[lo:hi]
output.index_add_(
0, tids, (sorted_x[lo:hi] * w).to(dtype=output.dtype)
)
for i in range(self.n_const):
expert_idx = self.const_start + i
lo = int(offsets[expert_idx])
hi = lo + int(counts[expert_idx])
if lo == hi:
continue
tids = sorted_tokens[lo:hi]
w = sorted_weights[lo:hi]
output.index_add_(
0, tids,
(self.const_experts[i](sorted_x[lo:hi]) * w).to(dtype=output.dtype),
)
counts = torch.zeros(self.total_experts, device=x.device, dtype=gate_weights.dtype)
counts.scatter_add_(0, flat_expert, torch.ones_like(flat_expert, dtype=gate_weights.dtype))
self.last_counts = counts.detach()
fractions = counts / counts.sum().clamp(min=1e-12)
avg_prob = gate_weights.mean(dim=0)
if self.n_zc > 0:
aux_loss = self.total_experts * (self.eta.to(dtype=gate_weights.dtype) * fractions * avg_prob).sum()
else:
aux_loss = self.total_experts * (fractions * avg_prob).sum()
return (output.view(B, T, D), aux_loss, gate_logits,
gate_weights.view(B, T, self.total_experts))
class MoEAttentionBlock(nn.Module):
"""Pre-norm attention + MoE++ FFN. Stores the FFN gate maps for the
spatial-smoothness (TV) loss."""
def __init__(
self,
dim: int,
n_heads: int = 6,
d_ff: int = 384,
num_ffn: int = 4,
top_k: int = 2,
n_zero: int = 1,
n_copy: int = 1,
n_const: int = 2,
tau: float = 0.75,
gating_residual: bool = True,
gate_ctx: bool = False,
):
super().__init__()
self.n_heads = n_heads
self.head_dim = dim // n_heads
self.q = nn.Linear(dim, dim)
self.k = nn.Linear(dim, dim)
self.v = nn.Linear(dim, dim)
self.proj = nn.Linear(dim, dim)
self.norm1 = nn.LayerNorm(dim)
self.norm2 = nn.LayerNorm(dim)
self.moe = MoEFFN(
dim, d_ff, num_ffn=num_ffn, top_k=top_k,
n_zero=n_zero, n_copy=n_copy, n_const=n_const,
tau=tau, gating_residual=gating_residual, gate_ctx=gate_ctx,
)
def forward(
self, x: torch.Tensor, prev_gate: torch.Tensor | None = None,
ctx: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
S, T, D = x.shape
H = self.n_heads
xn = self.norm1(x)
q = self.q(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
k = self.k(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
v = self.v(xn).reshape(S, T, H, self.head_dim).transpose(1, 2)
attn = (q @ k.transpose(-1, -2) / (self.head_dim ** 0.5)).softmax(dim=-1)
out = (attn @ v).transpose(1, 2).reshape(S, T, D)
x = x + self.proj(out)
y, aux, gate_logits, gate_weights = self.moe(
self.norm2(x), prev_gate=prev_gate, ctx=ctx)
x = x + y
return x, aux, gate_logits, gate_weights
class SliceAttentionPool(nn.Module):
"""Weight slices by learned relevance before aggregating into a study vector."""
def __init__(self, dim: int):
super().__init__()
self.query = nn.Parameter(torch.randn(dim))
self.scale = dim ** -0.5
def forward(self, x: torch.Tensor) -> torch.Tensor:
w = F.softmax(x @ self.query * self.scale, dim=0)
return (w.unsqueeze(1) * x).sum(0)
class PerTargetPool(nn.Module):
"""Per-finding attention over slices: each of n_classes findings owns a
query that weights the slices most relevant to it -> [n_classes, D]."""
def __init__(self, dim: int, n_classes: int):
super().__init__()
self.queries = nn.Parameter(torch.randn(n_classes, dim) * dim ** -0.5)
self.scale = dim ** -0.5
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: [S, D] slice vectors -> [n_classes, S] attention weights
w = F.softmax(x @ self.queries.T * self.scale, dim=0)
return (x.unsqueeze(0) * w.T.unsqueeze(-1)).sum(1)
class PerTargetHead(nn.Module):
"""Shared MLP then per-target projection (12 findings, each its own weight)."""
def __init__(self, dim: int, n_classes: int, hidden: int = 256):
super().__init__()
self.mlp = nn.Sequential(nn.Linear(dim, hidden), nn.GELU())
self.w = nn.Parameter(torch.randn(n_classes, hidden) * hidden ** -0.5)
self.b = nn.Parameter(torch.zeros(n_classes))
def forward(self, ctx: torch.Tensor) -> torch.Tensor:
# ctx: [n_classes, D] -> logits [n_classes]
h = self.mlp(ctx)
return (h * self.w).sum(-1) + self.b
class MoEViT(nn.Module):
"""Pretrained DINOv3 stem + stacked MoE++ core with per-token routing."""
def __init__(
self,
*,
stem_name: str = "vit_small_patch16_dinov3",
stem: nn.Module | None = None,
pretrained: bool = True,
freeze_stem: bool = True,
core_blocks: int = 2,
n_heads: int = 6,
d_ff: int = 384,
num_ffn: int = 4,
top_k: int = 2,
n_zero: int = 1,
n_copy: int = 1,
n_const: int = 2,
tau: float = 0.75,
gating_residual: bool = True,
n_classes: int = 12,
gate_ctx: bool = False,
per_target: bool = False,
):
super().__init__()
if stem is None:
if not HAS_TIMM:
raise ImportError("timm is required for a pretrained stem")
stem = timm.create_model(stem_name, pretrained=pretrained, num_classes=0)
self.num_ffn = num_ffn
self.top_k = top_k
self.gate_ctx = gate_ctx
self.per_target = per_target
self.stem = stem
stem_dim = self.stem.embed_dim
self.core_dim = 384
self.input_proj = nn.Linear(stem_dim, self.core_dim) if stem_dim != 384 else None
dim = self.core_dim
self.n_prefix = int(getattr(self.stem, "num_prefix_tokens", 1))
if freeze_stem:
for p in self.stem.parameters():
p.requires_grad_(False)
self._ffn_maps: list[torch.Tensor] = []
self.blocks = nn.ModuleList(
[
MoEAttentionBlock(
dim, n_heads=n_heads, d_ff=d_ff, num_ffn=num_ffn, top_k=top_k,
n_zero=n_zero, n_copy=n_copy, n_const=n_const,
tau=tau, gating_residual=gating_residual, gate_ctx=gate_ctx,
)
for _ in range(core_blocks)
]
)
self.exit_norm = nn.LayerNorm(dim)
if per_target:
self.pool = PerTargetPool(dim, n_classes)
self.head = PerTargetHead(dim, n_classes)
else:
self.pool = SliceAttentionPool(dim)
self.head = nn.Sequential(
nn.Linear(dim, dim * 2),
nn.GELU(),
nn.Linear(dim * 2, n_classes),
)
def moe_aux_loss(self) -> torch.Tensor:
total = None
for b in self.blocks:
if not hasattr(b, "_aux"):
continue
total = b._aux if total is None else total + b._aux
if total is None:
return torch.tensor(0.0, device=next(self.parameters()).device)
return total / len(self.blocks)
def routing_tv_loss(self) -> torch.Tensor:
"""Total-variation penalty on the per-patch FFN-mass maps.
Knee findings occupy contiguous regions; scattered routing is a bug
signal. The map is the probability mass routed to real FFN experts,
reshaped to the patch grid.
"""
if not self._ffn_maps:
return torch.tensor(0.0, device=next(self.parameters()).device)
total = None
for p in self._ffn_maps:
P = p.size(1)
h = int(round(P ** 0.5))
if h * h != P:
continue
g = p.reshape(p.size(0), h, h)
tv = (g[:, 1:, :] - g[:, :-1, :]).abs().mean() + (
g[:, :, 1:] - g[:, :, :-1]
).abs().mean()
total = tv if total is None else total + tv
if total is None:
return torch.tensor(0.0, device=next(self.parameters()).device)
return total / len(self._ffn_maps)
def features(
self, x: torch.Tensor, pool: str = "patchmean"
) -> torch.Tensor:
"""Per-slice features (no head): [S, D] from [S, 3, H, W] slices."""
tokens = self.stem.forward_features(x) # [S, T, D]
return self.features_from_tokens(tokens, pool)
def features_from_tokens(
self, tokens: torch.Tensor, pool: str = "patchmean"
) -> torch.Tensor:
"""Features from cached stem tokens (feat cache path): [S, D]."""
tokens = tokens.float() # core internals are f32; bf16 cache upcasts exactly
if self.input_proj is not None:
with torch.autocast("cuda", enabled=False):
tokens = self.input_proj(tokens).float() # keep f32; autocast would make it bf16
self._ffn_maps = []
prev_gate = None
ctx = tokens.mean(dim=1) if self.gate_ctx else None # [B, D] per-slice context
for b in self.blocks:
tokens, aux, gate_logits, gate_weights = b(
tokens, prev_gate=prev_gate, ctx=ctx)
b._aux = aux
self._ffn_maps.append(
gate_weights[:, self.n_prefix:, : self.num_ffn].sum(dim=-1)
)
prev_gate = gate_logits
tokens = self.exit_norm(tokens)
if pool == "cls":
return tokens[:, 0]
return tokens[:, self.n_prefix:].mean(1)
def forward(
self, x: torch.Tensor
) -> tuple[torch.Tensor, list[float]]:
"""Forward one study: [S, 3, H, W] slices -> (logits [n_classes], aux_stats)."""
slice_feats = self.features(x) # [S, D]
study = self.pool(slice_feats) # [D] or [n_classes, D]
logits = self.head(study)
if logits.dim() == 2:
logits = logits.squeeze(-1)
return logits, [self.moe_aux_loss().item()]