PLantDetect-WA / src /models /mor_vit.py
thenukegun10x's picture
Update src\models\mor_vit.py
7198674 verified
Raw
History Blame Contribute Delete
14.7 kB
"""MoR-ViT: Mixture-of-Recursions on a pretrained DINOv3 ViT stem.
Maps the llm-pipeline MoR recipe to a vision transformer:
entry block -> pretrained DINOv3 stem (unique weights)
shared core -> ``RecursiveAttentionBlock`` stack reused at every recursion
depth router -> per-TOKEN ``TokenRouter`` (expert choice over patch tokens)
recursion emb -> learned per-recursion embedding added before the core
token freeze -> a patch token that stops routing keeps its current state
Routing is per patch token (adaptive compute over the image), not per slice:
every study keeps the full recursion budget, but only the "hard" patch tokens
are recursed deeply. Slice features are mean-pooled over patch tokens, then
weighted by a ``SliceAttentionPool`` into a study vector.
``features()`` exposes the frozen feature extractor used by linear probing
without running the classification head.
"""
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 TokenScore(nn.Module):
"""Per-token continuation score: token embedding -> scalar logit.
``slice_gain`` (zero-initialised) couples the slice-level abnormality
score into the patch decision; the forward is exactly legacy at init.
"""
def __init__(self, dim: int, hidden: int, init_bias: float = 0.0):
super().__init__()
self.in_proj = nn.Linear(dim, hidden)
self.out_proj = nn.Linear(hidden, 1)
self.slice_gain = nn.Parameter(torch.zeros(1))
with torch.no_grad():
self.out_proj.bias.fill_(init_bias)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.out_proj(F.silu(self.in_proj(x))).squeeze(-1)
class SliceScore(nn.Module):
"""Per-slice continuation score: pooled patch feature -> scalar logit.
Gives the patch router a slice-level view, so routing stays consistent
across the slices of a study (a finding shows on several adjacent slices).
"""
def __init__(self, dim: int, hidden: int, init_bias: float = 0.0):
super().__init__()
self.in_proj = nn.Linear(dim, hidden)
self.out_proj = nn.Linear(hidden, 1)
with torch.no_grad():
self.out_proj.bias.fill_(init_bias)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.out_proj(F.silu(self.in_proj(x))).squeeze(-1)
class TokenRouter(nn.Module):
"""Expert-choice depth router over patch tokens.
One ``TokenScore`` head per recursion, plus a learned recursion embedding
so the router can condition on how deep a token already is. The router
input can be spatially conditioned (stem patch position embedding) and
slice-conditioned (``SliceScore`` coupled through a zero-init gain).
Router biases initialise from the per-recursion capacity so the right
fraction of tokens continues from step 0.
"""
def __init__(
self,
dim: int,
hidden: int,
n_recursions: int,
capacities: list[float],
init_bias: float = 0.0,
warmup_steps: int = 0,
init_from_capacity: bool = True,
):
super().__init__()
self.n_recursions = n_recursions
self.capacities = list(capacities)
self.warmup_steps = warmup_steps
self.heads = nn.ModuleList(
[TokenScore(dim, hidden, init_bias) for _ in range(n_recursions)]
)
self.rec_emb = nn.Parameter(torch.zeros(n_recursions, dim))
self.slice_score = SliceScore(dim, hidden)
if init_from_capacity:
for r, head in enumerate(self.heads):
cap = max(1e-3, min(1.0 - 1e-3, self.capacities[min(r, len(self.capacities) - 1)]))
with torch.no_grad():
head.out_proj.bias.fill_(float(torch.logit(torch.tensor(cap))))
def capacity(self, r: int, step: int) -> float:
"""Token fraction kept at recursion ``r``, ramping from 1.0 during warmup."""
target = self.capacities[r]
if self.warmup_steps > 0 and step < self.warmup_steps:
t = step / self.warmup_steps
return 1.0 - (1.0 - target) * t
return target
def forward(
self,
x: torch.Tensor,
r: int,
patch_pos: torch.Tensor | None = None,
slice_feat: torch.Tensor | None = None,
) -> torch.Tensor:
h = x + self.rec_emb[r]
if patch_pos is not None:
h = h + patch_pos
score = self.heads[r](h)
if slice_feat is not None:
score = score + self.heads[r].slice_gain * self.slice_score(slice_feat).unsqueeze(1)
return score
class RecursiveAttentionBlock(nn.Module):
"""Shared pre-norm attention block reused at every recursion.
Operates on the active token group only (the caller gathers/scatters), so
attention is plain unmasked full attention - no -inf masks, no NaN paths.
"""
def __init__(self, dim: int, n_heads: int = 6, mlp_ratio: float = 4.0, dropout: float = 0.0):
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.mlp = nn.Sequential(
nn.Linear(dim, int(dim * mlp_ratio)),
nn.GELU(),
nn.Linear(int(dim * mlp_ratio), dim),
)
def forward(self, x: 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)
x = x + self.mlp(self.norm2(x))
return x
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 MoRViT(nn.Module):
"""Pretrained DINOv3 stem + MoR recursive 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,
use_mor: bool = True,
dense: bool = False,
n_recursions: int = 3,
capacities: tuple[float, ...] = (1.0, 2.0 / 3.0, 1.0 / 3.0),
core_blocks: int = 2,
router_hidden: int = 128,
n_heads: int = 6,
n_classes: int = 12,
router_warmup_steps: int = 0,
router_init_bias: float = 0.0,
):
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.n_recursions = n_recursions
self.capacities = list(capacities)
assert len(self.capacities) == n_recursions
self.use_mor = use_mor
self.dense = dense
self.stem = stem
stem_dim = self.stem.embed_dim
self.core_dim = stem_dim
if stem_dim != 384:
self.input_proj = nn.Linear(stem_dim, 384)
self.core_dim = 384
else:
self.input_proj = None
dim = self.core_dim
self.n_prefix = int(getattr(self.stem, "num_prefix_tokens", 1))
pe = getattr(self.stem, "pos_embed", None)
if isinstance(pe, torch.Tensor) and pe.dim() == 3 and pe.size(1) > self.n_prefix:
patch_pos = pe[:, self.n_prefix:].detach().clone() # [1, P, stem_dim]
if self.input_proj is not None:
with torch.no_grad():
patch_pos = self.input_proj(patch_pos)
self.register_buffer("patch_pos", patch_pos, persistent=False)
else:
self.patch_pos = None
if freeze_stem:
for p in self.stem.parameters():
p.requires_grad_(False)
self._soft_route_maps: list[torch.Tensor] = []
self.core = nn.ModuleList(
[RecursiveAttentionBlock(dim, n_heads=n_heads) for _ in range(core_blocks)]
)
self.router = (
TokenRouter(
dim,
router_hidden,
n_recursions,
self.capacities,
init_bias=router_init_bias,
warmup_steps=router_warmup_steps,
)
if not dense
else None
)
self.rec_emb = nn.Parameter(torch.zeros(n_recursions, dim))
self.exit_norm = nn.LayerNorm(dim)
self.pool = SliceAttentionPool(dim)
self.head = nn.Sequential(
nn.Linear(dim, dim * 2),
nn.GELU(),
nn.Linear(dim * 2, n_classes),
)
def _run_core(self, tokens: torch.Tensor, r: int) -> torch.Tensor:
x = tokens + self.rec_emb[r]
for block in self.core:
x = block(x)
return x
def _moR(self, tokens: torch.Tensor, step: int = 0) -> tuple[torch.Tensor, list[float]]:
"""Run the recursion loop over a batch of token sequences.
Dense mode runs the shared core over every token at every recursion
(the same-budget control for adaptive routing). Adaptive mode gathers
the top-k patch tokens (by router score) plus the prefix tokens,
processes them through the core, and scatters them back; non-selected
patch tokens keep their previous state.
The returned tokens are a soft mixture of the per-recursion states
weighted by the router's continuation probabilities, so the router
receives real gradient (hard top-k alone is non-differentiable). The
soft routing maps are stored for the optional smoothness loss.
"""
if self.dense:
for r in range(self.n_recursions):
tokens = self._run_core(tokens, r)
return tokens, [1.0] * self.n_recursions
S, T, D = tokens.shape
n_pref = self.n_prefix
P = T - n_pref
stats: list[float] = []
self._soft_route_maps = []
num = tokens.clone()
den = torch.ones(S, T, device=tokens.device)
w_pat = torch.ones(S, P, device=tokens.device)
pref_ones = torch.ones(S, n_pref, device=tokens.device)
pe = (
self.patch_pos
if (self.patch_pos is not None and self.patch_pos.shape[1] == P)
else None
)
for r in range(self.n_recursions - 1):
cap = self.router.capacity(r, step)
pat = tokens[:, n_pref:]
pat_scores = self.router(pat, r, patch_pos=pe, slice_feat=pat.mean(1))
p = torch.sigmoid(pat_scores)
self._soft_route_maps.append(p)
k = max(1, min(P, int(round(P * cap))))
sel = pat_scores.topk(k, dim=1).indices # [S, k]
pref = torch.arange(n_pref, device=tokens.device).expand(S, n_pref)
idx = torch.cat([pref, sel + n_pref], dim=1) # [S, M]
idx3 = idx.unsqueeze(-1).expand(S, idx.size(1), D)
stats.append((n_pref + k) / T)
gathered = tokens.gather(1, idx3)
updated = self._run_core(gathered, r)
tokens = tokens.scatter(1, idx3, updated)
w_pat = w_pat * p
w = torch.cat([pref_ones, w_pat], dim=1)
num = num + tokens * w.unsqueeze(-1)
den = den + w
tokens = self._run_core(tokens, self.n_recursions - 1)
stats.append(1.0)
num = num + tokens * w.unsqueeze(-1)
den = den + w
mixed = num / den.unsqueeze(-1)
return mixed, stats
def routing_smoothness_loss(self) -> torch.Tensor | float:
"""Total-variation penalty on the soft routing maps (spatial coherence).
Knee findings occupy contiguous regions; a scattered routing map is a
bug signal. Add ``weight * this`` to the training loss.
"""
if not self._soft_route_maps:
return 0.0
total = None
for p in self._soft_route_maps: # [S, P]
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 0.0
return total / len(self._soft_route_maps)
def features(
self, x: torch.Tensor, step: int = 0, 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, step, pool)
def features_from_tokens(
self, tokens: torch.Tensor, step: int = 0, pool: str = "patchmean"
) -> torch.Tensor:
"""Features from cached stem tokens (feat cache path): [S, D]."""
tokens = tokens.float() # _moR 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
if self.use_mor or self.dense:
tokens, _ = self._moR(tokens, step)
tokens = self.exit_norm(tokens)
if pool == "cls":
return tokens[:, 0]
return tokens[:, self.n_prefix :].mean(1)
def forward(
self, x: torch.Tensor, step: int = 0
) -> tuple[torch.Tensor, list[float]]:
"""Forward one study: [S, 3, H, W] slices -> (logits [n_classes], route_stats)."""
slice_feats = self.features(x, step) # [S, D]
study = self.pool(slice_feats) # [D]
logits = self.head(study)
return logits, []