"""SketchMamba and matched-parameter baseline backbones. All models share the same I/O contract: input : float tensor (B, T, 5) stroke-5 features output : dict with h : (B, T, D) per-step hidden state (causal) cls_logits : (B, T, C) per-step class logits gmm : (B, T, 6M) raw GMM parameters (mixture, μx, μy, σx, σy, ρ) pen_logits : (B, T, 3) per-step pen-state logits Causal (Mamba, GRU, LSTM, causal-Transformer, causal 1D-CNN) backbones make the progressive-classification curve fall out for free: at any prefix length t the user reads ``cls_logits[:, t-1]``. """ from __future__ import annotations from dataclasses import dataclass from typing import Optional import torch import torch.nn as nn import torch.nn.functional as F # --------------------------------------------------------------------------- # # Shared building blocks # --------------------------------------------------------------------------- # class RMSNorm(nn.Module): def __init__(self, d: int, eps: float = 1e-6) -> None: super().__init__() self.weight = nn.Parameter(torch.ones(d)) self.eps = eps def forward(self, x: torch.Tensor) -> torch.Tensor: n = x.float().pow(2).mean(dim=-1, keepdim=True).add(self.eps).rsqrt() return (x.float() * n).to(x.dtype) * self.weight class SketchHeads(nn.Module): """Per-step classification + GMM + pen heads.""" def __init__(self, d_model: int, num_classes: int, gmm_components: int) -> None: super().__init__() self.cls = nn.Linear(d_model, num_classes) self.gmm = nn.Linear(d_model, 6 * gmm_components) self.pen = nn.Linear(d_model, 3) def forward(self, h: torch.Tensor) -> dict: return { "h": h, "cls_logits": self.cls(h), "gmm": self.gmm(h), "pen_logits": self.pen(h), } # --------------------------------------------------------------------------- # # SketchMamba # --------------------------------------------------------------------------- # def _resolve_mamba_cls(): """Return the Mamba implementation to use. Prefers the official CUDA-fused `mamba_ssm.Mamba`; falls back to a pure PyTorch reference (`MambaCPU`) on platforms where `mamba-ssm` is not installable (Windows, CPU-only, macOS). Both classes share the same parameter names and shapes, so the same checkpoint loads in either. """ try: from mamba_ssm import Mamba return Mamba except Exception: # noqa: BLE001 (covers ImportError + CUDA-load failures) from src.mamba_cpu import MambaCPU return MambaCPU class _MambaResBlock(nn.Module): def __init__( self, d_model: int, d_state: int, d_conv: int, expand: int, dropout: float ) -> None: super().__init__() mamba_cls = _resolve_mamba_cls() self.norm = RMSNorm(d_model) self.mamba = mamba_cls( d_model=d_model, d_state=d_state, d_conv=d_conv, expand=expand ) self.drop = nn.Dropout(dropout) def forward(self, x: torch.Tensor) -> torch.Tensor: return x + self.drop(self.mamba(self.norm(x))) class SketchMamba(nn.Module): def __init__( self, num_classes: int = 58, d_model: int = 192, n_layers: int = 6, d_state: int = 16, d_conv: int = 4, expand: int = 2, gmm_components: int = 20, dropout: float = 0.1, in_dim: int = 5, **_: object, ) -> None: super().__init__() self.embed = nn.Linear(in_dim, d_model) self.blocks = nn.ModuleList( [ _MambaResBlock(d_model, d_state, d_conv, expand, dropout) for _ in range(n_layers) ] ) self.final_norm = RMSNorm(d_model) self.heads = SketchHeads(d_model, num_classes, gmm_components) self.gmm_components = gmm_components self.num_classes = num_classes self.d_model = d_model def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> dict: h = self.embed(x) for blk in self.blocks: h = blk(h) h = self.final_norm(h) return self.heads(h) # --------------------------------------------------------------------------- # # GRU baseline (causal) # --------------------------------------------------------------------------- # class SketchGRU(nn.Module): def __init__( self, num_classes: int = 58, d_model: int = 192, n_layers: int = 3, gmm_components: int = 20, dropout: float = 0.1, in_dim: int = 5, **_: object, ) -> None: super().__init__() self.embed = nn.Linear(in_dim, d_model) self.rnn = nn.GRU( d_model, d_model, num_layers=n_layers, batch_first=True, dropout=dropout if n_layers > 1 else 0.0, ) self.final_norm = nn.LayerNorm(d_model) self.heads = SketchHeads(d_model, num_classes, gmm_components) self.gmm_components = gmm_components self.num_classes = num_classes self.d_model = d_model def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> dict: h, _ = self.rnn(self.embed(x)) return self.heads(self.final_norm(h)) # --------------------------------------------------------------------------- # # LSTM baseline (causal, Sketch-RNN-style) # --------------------------------------------------------------------------- # class SketchLSTM(nn.Module): def __init__( self, num_classes: int = 58, d_model: int = 192, n_layers: int = 3, gmm_components: int = 20, dropout: float = 0.1, in_dim: int = 5, **_: object, ) -> None: super().__init__() self.embed = nn.Linear(in_dim, d_model) self.rnn = nn.LSTM( d_model, d_model, num_layers=n_layers, batch_first=True, dropout=dropout if n_layers > 1 else 0.0, ) self.final_norm = nn.LayerNorm(d_model) self.heads = SketchHeads(d_model, num_classes, gmm_components) self.gmm_components = gmm_components self.num_classes = num_classes self.d_model = d_model def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> dict: h, _ = self.rnn(self.embed(x)) return self.heads(self.final_norm(h)) # --------------------------------------------------------------------------- # # Causal Transformer baseline # --------------------------------------------------------------------------- # class SketchTransformer(nn.Module): def __init__( self, num_classes: int = 58, d_model: int = 192, n_layers: int = 4, nhead: int = 4, dim_feedforward: int = 4 * 192, gmm_components: int = 20, dropout: float = 0.1, max_len: int = 256, in_dim: int = 5, **_: object, ) -> None: super().__init__() self.embed = nn.Linear(in_dim, d_model) self.pos = nn.Parameter(torch.zeros(1, max_len, d_model)) nn.init.trunc_normal_(self.pos, std=0.02) layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=dim_feedforward, dropout=dropout, activation="gelu", batch_first=True, norm_first=True, ) self.encoder = nn.TransformerEncoder(layer, num_layers=n_layers) self.final_norm = nn.LayerNorm(d_model) self.heads = SketchHeads(d_model, num_classes, gmm_components) self.gmm_components = gmm_components self.num_classes = num_classes self.d_model = d_model self.max_len = max_len def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> dict: T = x.size(1) if T > self.max_len: raise ValueError(f"Sequence length {T} exceeds max_len={self.max_len}") h = self.embed(x) + self.pos[:, :T] causal = torch.triu( torch.full((T, T), float("-inf"), device=x.device), diagonal=1 ) key_padding_mask = None if mask is not None: key_padding_mask = ~mask # True where padding h = self.encoder(h, mask=causal, src_key_padding_mask=key_padding_mask) return self.heads(self.final_norm(h)) # --------------------------------------------------------------------------- # # Causal 1D-CNN baseline # --------------------------------------------------------------------------- # class _CausalConv1d(nn.Module): def __init__(self, d: int, kernel: int = 5, dilation: int = 1) -> None: super().__init__() self.pad = (kernel - 1) * dilation self.conv = nn.Conv1d(d, d, kernel_size=kernel, dilation=dilation) def forward(self, x: torch.Tensor) -> torch.Tensor: # (B, D, T) x = F.pad(x, (self.pad, 0)) return self.conv(x) class SketchCNN1D(nn.Module): def __init__( self, num_classes: int = 58, d_model: int = 192, n_layers: int = 6, gmm_components: int = 20, dropout: float = 0.1, kernel: int = 5, in_dim: int = 5, **_: object, ) -> None: super().__init__() self.embed = nn.Linear(in_dim, d_model) self.layers = nn.ModuleList( [ nn.Sequential( _CausalConv1d(d_model, kernel=kernel, dilation=2 ** (i % 4)), nn.GELU(), nn.Dropout(dropout), ) for i in range(n_layers) ] ) self.norms = nn.ModuleList([nn.LayerNorm(d_model) for _ in range(n_layers)]) self.final_norm = nn.LayerNorm(d_model) self.heads = SketchHeads(d_model, num_classes, gmm_components) self.gmm_components = gmm_components self.num_classes = num_classes self.d_model = d_model def forward(self, x: torch.Tensor, mask: Optional[torch.Tensor] = None) -> dict: h = self.embed(x) # (B, T, D) for norm, layer in zip(self.norms, self.layers): t = norm(h).transpose(1, 2) # (B, D, T) t = layer(t).transpose(1, 2) # (B, T, D) h = h + t return self.heads(self.final_norm(h)) # --------------------------------------------------------------------------- # # Factory # --------------------------------------------------------------------------- # _REGISTRY = { "sketchmamba": SketchMamba, "gru": SketchGRU, "lstm": SketchLSTM, "transformer": SketchTransformer, "cnn1d": SketchCNN1D, } def build_model(model_cfg: dict) -> nn.Module: cfg = dict(model_cfg) mtype = cfg.pop("type", "sketchmamba") if mtype not in _REGISTRY: raise ValueError( f"Unknown model type '{mtype}'. Available: {list(_REGISTRY)}" ) return _REGISTRY[mtype](**cfg) def count_parameters(model: nn.Module) -> int: return sum(p.numel() for p in model.parameters() if p.requires_grad) # --------------------------------------------------------------------------- # # GMM parameter parsing (used by losses + sampling) # --------------------------------------------------------------------------- # @dataclass class GMMParams: pi: torch.Tensor # (..., M) log-mixture weights (log-softmax) mu_x: torch.Tensor # (..., M) mu_y: torch.Tensor # (..., M) sigma_x: torch.Tensor # (..., M) positive sigma_y: torch.Tensor # (..., M) positive rho: torch.Tensor # (..., M) in (-1, 1) def parse_gmm(raw: torch.Tensor, n_components: int) -> GMMParams: """raw : (..., 6M) -> GMMParams with stable activations.""" M = n_components pi, mu_x, mu_y, log_sx, log_sy, raw_rho = torch.split(raw, M, dim=-1) log_pi = F.log_softmax(pi, dim=-1) sigma_x = F.softplus(log_sx) + 1e-4 sigma_y = F.softplus(log_sy) + 1e-4 rho = torch.tanh(raw_rho).clamp(-0.99, 0.99) return GMMParams(log_pi, mu_x, mu_y, sigma_x, sigma_y, rho)