PLantDetect-WA / src /models /subcenter_arcface.py
thenukegun10x's picture
Update src\models\subcenter_arcface.py
a19671a verified
Raw
History Blame Contribute Delete
4.07 kB
"""
Sub-Center ArcFace with K Sub-Centers per Class.
Designed for fine-grained botanical vision to capture multi-modal phenology
(e.g., Flower vs. Leaf/Foliage vs. Fruit/Seed Pods) without intra-class noise.
"""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class SubCenterArcFace(nn.Module):
"""
Sub-Center ArcFace for 512-D L2 Embeddings.
Maintains K sub-centers per class (default K=3).
"""
def __init__(self, embed_dim: int, n_classes: int, k_subcenters: int = 3, m: float = 0.5, s: float = 30.0, easy_margin: bool = False):
super().__init__()
self.embed_dim = embed_dim
self.n_classes = n_classes
self.k_subcenters = k_subcenters
self.m = m
self.s = s
self.easy_margin = easy_margin
# Weight matrix: [n_classes * k_subcenters, embed_dim]
self.W = nn.Parameter(torch.randn(n_classes * k_subcenters, embed_dim) * 0.01)
nn.init.xavier_uniform_(self.W)
# Precomputed angular constants
self.cos_m = math.cos(m)
self.sin_m = math.sin(m)
self.th = math.cos(math.pi - m)
self.mm = math.sin(math.pi - m) * m
def forward(self, emb: torch.Tensor, y: torch.Tensor = None) -> torch.Tensor:
"""
emb: [B, D] L2 normalized embedding
y: [B] Ground-truth class labels (0 .. n_classes-1)
Returns: [B, n_classes] Logits scaled by s
"""
# Normalize weights and embeddings
W_norm = F.normalize(self.W, dim=1) # [C * K, D]
emb_norm = F.normalize(emb, dim=1) # [B, D]
# Compute cosine similarity across all sub-centers: [B, C * K]
cos_all = F.linear(emb_norm, W_norm)
B = emb.size(0)
# Reshape to [B, C, K] and select the maximum cosine per class
cos_sub = cos_all.view(B, self.n_classes, self.k_subcenters)
cos_max, _ = cos_sub.max(dim=-1) # [B, C]
cos = cos_max.clamp(-1.0 + 1e-7, 1.0 - 1e-7)
# Inference mode: return scaled max cosine logits
if y is None:
return cos * self.s
# Training mode: Apply additive angular margin to ground-truth class
sin = torch.sqrt((1.0 - cos ** 2).clamp(0.0, 1.0))
cos_m = cos * self.cos_m - sin * self.sin_m
if self.easy_margin:
cos_m = torch.where(cos > 0, cos_m, cos)
else:
cos_m = torch.where(cos > self.th, cos_m, cos - self.mm)
one_hot = F.one_hot(y, num_classes=self.n_classes).bool()
logits = torch.where(one_hot, cos_m, cos)
return logits * self.s
def diversity_loss(self) -> torch.Tensor:
"""
Penalise sub-center collapse (only winning K=1 gets gradients via max-pool).
Computes mean absolute pairwise cosine between sub-centers within each class;
encourages K=3 centers to stay diverse (e.g., flower/leaf/fruit).
"""
if self.k_subcenters <= 1:
return torch.tensor(0.0, device=self.W.device, dtype=self.W.dtype)
# Reshape to [C, K, D] and L2-normalize per sub-center
W_ck = F.normalize(self.W.view(self.n_classes, self.k_subcenters, self.embed_dim), dim=-1)
# Pairwise cosine within each class: [C, K, K]
cos = torch.einsum("ckd,cjd->ckj", W_ck, W_ck)
# Mask diagonal (self-similarity =1)
mask = torch.eye(self.k_subcenters, device=cos.device, dtype=torch.bool).unsqueeze(0).expand(
self.n_classes, -1, -1
)
cos_off = cos[~mask]
if cos_off.numel() == 0:
return torch.tensor(0.0, device=cos.device, dtype=cos.dtype)
# Want low similarity → penalise absolute correlation; hinge variant: relu(cos - 0.2)
# Use abs mean for symmetry (both positive collapse and opposite collapse are diverse enough?)
# but positive collapse is the failure mode, so use mean of positive part.
# Here we use abs mean scaled: keeps gradient if centers align or anti-align strongly.
return cos_off.abs().mean()