FlyinGodzilla's picture
Add verified optional distilled routers and shared inference release
84478eb verified
Raw History Blame Contribute Delete
25.4 kB
"""
AMOR: Adaptive Metacognitive Output Router.
A post-hoc hybrid architecture that gates attention by output prediction entropy.
A recurrent backbone (Mamba-2 or Gated DeltaNet) is followed by K stacked AMOR
blocks; each block measures the entropy of softmax(lm_head(h)) and, where the
backbone is uncertain, fires causal self-attention to refine the residual stream.
Input -> [Backbone Mixer + SwiGLU MLP] x N -> [AMOR Block] x K -> Norm -> LM Head
|
Entropy Gate (fire if uncertain)
|
Attention (Q,K,V from residual)
The 6 architecture variants are flag combinations of a single class:
backbone in {'mamba2', 'gdn'} # recurrent mixer
n_amor_blocks in {1, 3} # stack depth
residual_mode in {'classic', 'h'} # block-0 pre-norm placement
K, V are standard linear projections of the residual stream (not SSM internal
state). Training uses dense attention with output masking ('full_with_mask');
'true_sparse' is provided for inference. Each block has its own EntropyGate
(EMA-tracked threshold), Q/K/V/O, RoPE on Q/K, and a per-channel alpha fusion.
"""
import math
from typing import Tuple, Dict, Any, Optional
import torch
import torch.nn as nn
import torch.nn.functional as F
try:
import mamba_ssm
if not hasattr(mamba_ssm, 'mamba_chunk_scan_combined'):
from mamba_ssm.ops.triton.ssd_combined import (
mamba_chunk_scan_combined,
mamba_split_conv1d_scan_combined,
)
mamba_ssm.mamba_chunk_scan_combined = mamba_chunk_scan_combined
mamba_ssm.mamba_split_conv1d_scan_combined = mamba_split_conv1d_scan_combined
except ImportError:
pass
try:
from transformers import Mamba2Config, Mamba2ForCausalLM
except ImportError:
Mamba2Config = None
Mamba2ForCausalLM = None
try:
from fla.layers import GatedDeltaNet as FLAGatedDeltaNet
except ImportError:
FLAGatedDeltaNet = None
# =============================================================================
# Building blocks
# =============================================================================
class RMSNorm(nn.Module):
def __init__(self, d_model: int, eps: float = 1e-6):
super().__init__()
self.weight = nn.Parameter(torch.ones(d_model))
self.eps = eps
def forward(self, x: torch.Tensor) -> torch.Tensor:
rms = torch.sqrt(torch.mean(x ** 2, dim=-1, keepdim=True) + self.eps)
return self.weight * x / rms
class SwiGLUMLP(nn.Module):
def __init__(self, d_model: int, d_ff: int):
super().__init__()
self.gate_proj = nn.Linear(d_model, d_ff, bias=False)
self.up_proj = nn.Linear(d_model, d_ff, bias=False)
self.down_proj = nn.Linear(d_ff, d_model, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
class RotaryPositionEmbedding(nn.Module):
def __init__(self, head_dim: int, max_seq_len: int = 8192, base: float = 10000.0):
super().__init__()
self.head_dim = head_dim
inv_freq = 1.0 / (base ** (torch.arange(0, head_dim, 2).float() / head_dim))
self.register_buffer('inv_freq', inv_freq, persistent=False)
self._build_cache(max_seq_len)
def _build_cache(self, seq_len: int):
t = torch.arange(seq_len, dtype=self.inv_freq.dtype, device=self.inv_freq.device)
freqs = torch.outer(t, self.inv_freq)
emb = torch.cat([freqs, freqs], dim=-1)
self.register_buffer('cos_cached', emb.cos(), persistent=False)
self.register_buffer('sin_cached', emb.sin(), persistent=False)
def forward(self, q: torch.Tensor, k: torch.Tensor, offset: int = 0):
seq_len = q.shape[2]
cos = self.cos_cached[offset:offset + seq_len].unsqueeze(0).unsqueeze(0)
sin = self.sin_cached[offset:offset + seq_len].unsqueeze(0).unsqueeze(0)
return self._apply_rotary(q, cos, sin), self._apply_rotary(k, cos, sin)
@staticmethod
def _apply_rotary(x, cos, sin):
d = x.shape[-1]
x1, x2 = x[..., :d // 2], x[..., d // 2:]
return x * cos + torch.cat([-x2, x1], dim=-1) * sin
class KVProjection(nn.Module):
def __init__(self, d_model: int, n_heads: int = 12):
super().__init__()
self.d_model = d_model
self.n_heads = n_heads
self.head_dim = d_model // n_heads
self.k_proj = nn.Linear(d_model, d_model, bias=False)
self.v_proj = nn.Linear(d_model, d_model, bias=False)
def forward(self, h: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
return self.k_proj(h), self.v_proj(h)
# =============================================================================
# Entropy gate
# =============================================================================
class EntropyGate(nn.Module):
"""
Hard binary gate driven by output-level prediction entropy.
Threshold is an EMA of the batch entropy median (no gradient). Two offset
modes: 'fixed' adds a constant; 'adaptive' adds offset_k * EMA(std), giving
a scale-independent fire rate as the entropy distribution width changes
across model scale and training stage.
entropy = -sum(p * log(p)) / log(vocab_size)
threshold = EMA(median) + offset # fixed
threshold = EMA(median) + offset_k * EMA(std) # adaptive
gate = (entropy > threshold) # hard binary
"""
def __init__(
self,
vocab_size: int,
init_threshold: float = 0.3,
ema_momentum: float = 0.99,
offset: float = 0.03,
offset_mode: str = 'adaptive',
offset_k: float = 0.2,
):
super().__init__()
self.max_entropy = math.log(vocab_size)
self.ema_momentum = ema_momentum
self.offset = offset
self.offset_mode = offset_mode
self.offset_k = offset_k
self.register_buffer('running_threshold', torch.tensor(init_threshold))
self.register_buffer('running_std', torch.tensor(0.1))
self.router = None
def forward(self, logits: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
# Detached: entropy drives gating decisions only. CE loss trains the
# backbone via the final logits path, never through the gate.
if self.router is not None:
if self.training:
raise RuntimeError("Fitted routers are for inference; detach them before training.")
with torch.no_grad():
entropy = self.router(logits.detach())
else:
logits_f32 = logits.detach().float()
probs = F.softmax(logits_f32, dim=-1)
raw_entropy = -(probs * (probs + 1e-10).log()).sum(dim=-1)
entropy = raw_entropy / self.max_entropy
if self.training:
entropy_detached = entropy.detach()
batch_median = entropy_detached.median()
self.running_threshold.mul_(self.ema_momentum).add_(
batch_median, alpha=1.0 - self.ema_momentum
)
if self.offset_mode == 'adaptive':
batch_std = entropy_detached.std()
self.running_std.mul_(self.ema_momentum).add_(
batch_std, alpha=1.0 - self.ema_momentum
)
if self.offset_mode == 'adaptive':
threshold = self.running_threshold + self.offset_k * self.running_std
else:
threshold = self.running_threshold + self.offset
gate = (entropy > threshold).float()
return gate, entropy
# =============================================================================
# AMOR block
# =============================================================================
class AMORBlock(nn.Module):
"""
Entropy-gated attention block.
gate = EntropyGate(lm_head(h_normed))
attn_out = Attention(h_normed) * gate
output = h_normed + alpha * attn_out
Attention modes:
'full_with_mask': dense attention over all positions, output masked by
the gate. Used for training (GPU-friendly).
'true_sparse': Q and the attention computation only run for firing
positions. Output is bit-identical given the same
weights/inputs. Used for inference.
Zero-initialized out_proj + per-channel alpha (sigmoid(raw_alpha), clamped
floor of ~0.27) handle output magnitude scaling. RoPE on Q, K.
"""
def __init__(
self,
d_model: int,
vocab_size: int,
n_heads: int = 12,
init_alpha: float = 0.0,
init_threshold: float = 0.3,
gate_offset: float = 0.03,
offset_mode: str = 'adaptive',
offset_k: float = 0.2,
attention_mode: str = 'full_with_mask',
max_seq_len: int = 4096,
):
super().__init__()
self.d_model = d_model
self.n_heads = n_heads
self.head_dim = d_model // n_heads
self.attention_mode = attention_mode
self.gate = EntropyGate(
vocab_size,
init_threshold=init_threshold,
offset=gate_offset,
offset_mode=offset_mode,
offset_k=offset_k,
)
self.rope = RotaryPositionEmbedding(self.head_dim, max_seq_len)
self.q_proj = nn.Linear(d_model, d_model, bias=False)
self.kv_proj = KVProjection(d_model, n_heads)
self.out_proj = nn.Linear(d_model, d_model, bias=False)
nn.init.zeros_(self.out_proj.weight)
if self.out_proj.bias is not None:
nn.init.zeros_(self.out_proj.bias)
# Per-channel raw alpha; sigmoid + lower clamp at -1 (≈0.27 floor) to
# prevent collapse. Channels learn their own residual mixing weights.
self.raw_alpha = nn.Parameter(torch.full((d_model,), float(init_alpha)))
def get_alpha(self) -> torch.Tensor:
return torch.sigmoid(torch.clamp(self.raw_alpha, min=-1.0))
def _attention_full_with_mask(self, h_norm: torch.Tensor, gate: torch.Tensor) -> torch.Tensor:
batch, seq, _ = h_norm.shape
Q = self.q_proj(h_norm).view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
K, V = self.kv_proj(h_norm)
K = K.view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
V = V.view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
Q, K = self.rope(Q, K)
attn_out = F.scaled_dot_product_attention(Q, K, V, is_causal=True)
attn_out = attn_out.transpose(1, 2).contiguous().view(batch, seq, self.d_model)
attn_out = self.out_proj(attn_out)
return attn_out * gate.unsqueeze(-1)
def _attention_true_sparse(self, h_norm: torch.Tensor, gate: torch.Tensor) -> torch.Tensor:
batch, seq, _ = h_norm.shape
device = h_norm.device
K, V = self.kv_proj(h_norm)
K = K.view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
V = V.view(batch, seq, self.n_heads, self.head_dim).transpose(1, 2)
cos_all = self.rope.cos_cached[:seq].unsqueeze(0).unsqueeze(0)
sin_all = self.rope.sin_cached[:seq].unsqueeze(0).unsqueeze(0)
K = RotaryPositionEmbedding._apply_rotary(K, cos_all, sin_all)
active_mask = gate > 0.5
if not active_mask.any():
return torch.zeros(batch, seq, self.d_model, device=device, dtype=h_norm.dtype)
active_indices = active_mask.nonzero(as_tuple=False)
num_active = active_indices.shape[0]
batch_indices = active_indices[:, 0]
seq_indices = active_indices[:, 1]
h_active = h_norm[active_mask]
Q_active = self.q_proj(h_active).view(num_active, self.n_heads, self.head_dim)
cos_q = self.rope.cos_cached[seq_indices].unsqueeze(1)
sin_q = self.rope.sin_cached[seq_indices].unsqueeze(1)
Q_active = RotaryPositionEmbedding._apply_rotary(Q_active, cos_q, sin_q)
K_selected = K[batch_indices]
V_selected = V[batch_indices]
Q_active = Q_active.unsqueeze(2)
scores = torch.matmul(Q_active, K_selected.transpose(-2, -1)) / math.sqrt(self.head_dim)
positions = seq_indices.view(num_active, 1, 1, 1)
key_positions = torch.arange(seq, device=device).view(1, 1, 1, seq)
scores = scores.masked_fill(key_positions > positions, float('-inf'))
attn_weights = torch.nan_to_num(F.softmax(scores, dim=-1), 0.0)
out_active = torch.matmul(attn_weights, V_selected).squeeze(2)
out_active = out_active.contiguous().view(num_active, self.d_model)
out_active = self.out_proj(out_active)
attn_out = torch.zeros(batch, seq, self.d_model, device=device, dtype=h_norm.dtype)
attn_out[active_mask] = out_active
return attn_out
def forward(
self,
h: torch.Tensor,
backbone_logits: torch.Tensor,
) -> Tuple[torch.Tensor, Dict[str, Any]]:
gate, entropy = self.gate(backbone_logits)
if self.attention_mode == 'true_sparse':
attn_out = self._attention_true_sparse(h, gate)
else:
attn_out = self._attention_full_with_mask(h, gate)
alpha = self.get_alpha()
h_out = h + alpha * attn_out
info = {
'gate': gate,
'entropy': entropy,
'alpha': alpha.mean(),
'alpha_std': alpha.std(),
'fire_rate': gate.mean(),
}
return h_out, info
# =============================================================================
# GDN backbone block (used when backbone='gdn')
# =============================================================================
class GDNBlock(nn.Module):
"""Pre-norm Gated DeltaNet + pre-norm SwiGLU MLP. Requires `fla` library."""
def __init__(self, d_model: int, d_ff: int, head_dim: int = 64, expand_v: float = 2.0):
super().__init__()
if FLAGatedDeltaNet is None:
raise ImportError(
"flash-linear-attention is required for backbone='gdn'. "
"Install with: pip install flash-linear-attention"
)
self.d_model = d_model
n_heads = int(0.75 * d_model / head_dim)
assert n_heads * head_dim == int(0.75 * d_model), \
f"0.75 * d_model={d_model} must be divisible by head_dim={head_dim}"
self.norm_ssm = RMSNorm(d_model)
self.gdn = FLAGatedDeltaNet(
hidden_size=d_model,
num_heads=n_heads,
head_dim=head_dim,
expand_v=expand_v,
mode='chunk',
)
self.norm_mlp = RMSNorm(d_model)
self.mlp = SwiGLUMLP(d_model, d_ff)
def forward(self, x: torch.Tensor) -> torch.Tensor:
h = self.norm_ssm(x)
h = self.gdn(h)[0]
x = x + h
x = x + self.mlp(self.norm_mlp(x))
return x
# =============================================================================
# Unified AMOR model
# =============================================================================
class AMOR(nn.Module):
"""
Unified AMOR model. Six published variants are flag combinations:
backbone='mamba2', n_amor_blocks=3, residual_mode='classic' # AMOR
backbone='mamba2', n_amor_blocks=1, residual_mode='classic' # AMOR-classic
backbone='mamba2', n_amor_blocks=3, residual_mode='h' # AMOR-h
backbone='gdn', n_amor_blocks=3, residual_mode='classic' # AMOR-GDN
backbone='gdn', n_amor_blocks=1, residual_mode='classic' # AMOR-classic-GDN
backbone='gdn', n_amor_blocks=3, residual_mode='h' # AMOR-GDN-h
Architecture:
Embedding
-> [Backbone Mixer + SwiGLU MLP] x n_layer
-> norm_f # applied to residual iff residual_mode='classic'
-> [pre_norm -> AMOR Block -> residual fusion] x n_amor_blocks
-> final_norm
-> LM Head (tied to embedding)
residual_mode='classic':
norm_f is applied once to the residual stream after the backbone. Block 0
therefore has no extra pre-norm and its residual wraps the norm_f'd
stream; blocks 1+ each carry their own pre-norm and their residual wraps
the un-normed h.
residual_mode='h':
norm_f is reused inline as block 0's pre-norm; the residual stream stays
un-normed from the end of the backbone through every AMOR block. All
blocks are symmetric: pre-norm -> attention -> residual wraps un-normed h.
Only final_norm sees the residual, immediately before lm_head.
Default dimensions match a ~180M-param scale (d_model=768, n_layer=12,
d_ff=1216, head_dim=64).
"""
def __init__(
self,
# Architecture flags
backbone: str = 'mamba2',
n_amor_blocks: int = 3,
residual_mode: str = 'classic',
# Common dims
vocab_size: int = 128256,
d_model: int = 768,
n_layer: int = 12,
d_ff: int = 1216,
head_dim: int = 64,
# Mamba-2 backbone params
d_state: int = 128,
d_conv: int = 4,
expand: int = 2,
n_groups: int = 1,
# GDN backbone params
expand_v: float = 2.0,
# AMOR block params
n_heads: int = 12,
init_alpha: float = 0.0,
init_threshold: float = 0.3,
gate_offset: float = 0.03,
offset_mode: str = 'adaptive',
offset_k: float = 0.2,
attention_mode: str = 'full_with_mask',
max_seq_len: int = 4096,
):
super().__init__()
if backbone not in ('mamba2', 'gdn'):
raise ValueError(f"backbone must be 'mamba2' or 'gdn', got {backbone!r}")
if residual_mode not in ('classic', 'h'):
raise ValueError(f"residual_mode must be 'classic' or 'h', got {residual_mode!r}")
if n_amor_blocks < 1:
raise ValueError(f"n_amor_blocks must be >= 1, got {n_amor_blocks}")
self.backbone = backbone
self.residual_mode = residual_mode
self.n_amor_blocks = n_amor_blocks
self.vocab_size = vocab_size
self.d_model = d_model
self.n_layer = n_layer
self.d_ff = d_ff
self._build_backbone(
d_model=d_model, n_layer=n_layer, d_ff=d_ff, vocab_size=vocab_size,
head_dim=head_dim, d_state=d_state, d_conv=d_conv, expand=expand,
n_groups=n_groups, expand_v=expand_v,
)
self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
self.lm_head.weight = self.embed.weight
self.amor_blocks = nn.ModuleList()
self.amor_pre_norms = nn.ModuleList()
for i in range(n_amor_blocks):
self.amor_blocks.append(AMORBlock(
d_model=d_model,
vocab_size=vocab_size,
n_heads=n_heads,
init_alpha=init_alpha,
init_threshold=init_threshold,
gate_offset=gate_offset,
offset_mode=offset_mode,
offset_k=offset_k,
attention_mode=attention_mode,
max_seq_len=max_seq_len,
))
# Block 0's pre-norm slot is None for both modes:
# 'classic' -> input is already norm_f'd, no pre-norm needed.
# 'h' -> norm_f is reused inline as block 0's pre-norm.
if i == 0:
self.amor_pre_norms.append(None)
else:
self.amor_pre_norms.append(RMSNorm(d_model))
self.final_norm = RMSNorm(d_model)
self._init_weights()
def _build_backbone(
self, d_model, n_layer, d_ff, vocab_size, head_dim,
d_state, d_conv, expand, n_groups, expand_v,
):
if self.backbone == 'mamba2':
if Mamba2ForCausalLM is None:
raise ImportError(
"transformers (with Mamba2 support) is required for backbone='mamba2'. "
"Install with: pip install 'transformers>=4.43' mamba-ssm causal-conv1d"
)
ssm_n_heads = (d_model * expand) // head_dim
self.mamba_config = Mamba2Config(
vocab_size=vocab_size,
hidden_size=d_model,
num_hidden_layers=n_layer,
state_size=d_state,
conv_kernel=d_conv,
expand=expand,
head_dim=head_dim,
num_heads=ssm_n_heads,
n_groups=n_groups,
)
mamba_model = Mamba2ForCausalLM(self.mamba_config)
self.embed = mamba_model.backbone.embeddings
self.ssd_layers = mamba_model.backbone.layers
self.norm_f = mamba_model.backbone.norm_f
self.mlp_norms = nn.ModuleList([RMSNorm(d_model) for _ in range(n_layer)])
self.mlps = nn.ModuleList([SwiGLUMLP(d_model, d_ff) for _ in range(n_layer)])
del mamba_model
else:
self.embed = nn.Embedding(vocab_size, d_model)
self.layers = nn.ModuleList([
GDNBlock(d_model=d_model, d_ff=d_ff, head_dim=head_dim, expand_v=expand_v)
for _ in range(n_layer)
])
self.norm_f = RMSNorm(d_model)
def _init_weights(self):
std = 0.02
nn.init.normal_(self.embed.weight, mean=0.0, std=std)
if self.backbone == 'mamba2':
for layer in self.ssd_layers:
for m in layer.modules():
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, mean=0.0, std=std)
for mlp in self.mlps:
for m in mlp.modules():
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, mean=0.0, std=std)
else:
for layer in self.layers:
for m in layer.gdn.modules():
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, mean=0.0, std=std)
for m in layer.mlp.modules():
if isinstance(m, nn.Linear):
nn.init.normal_(m.weight, mean=0.0, std=std)
for amor_block in self.amor_blocks:
nn.init.normal_(amor_block.q_proj.weight, mean=0.0, std=std)
nn.init.normal_(amor_block.kv_proj.k_proj.weight, mean=0.0, std=std)
nn.init.normal_(amor_block.kv_proj.v_proj.weight, mean=0.0, std=std)
# out_proj stays zero-initialized.
def _run_backbone(self, input_ids: torch.Tensor) -> torch.Tensor:
h = self.embed(input_ids)
if self.backbone == 'mamba2':
for ssd_layer, mlp_norm, mlp in zip(self.ssd_layers, self.mlp_norms, self.mlps):
h = ssd_layer(h)
h = h + mlp(mlp_norm(h))
else:
for layer in self.layers:
h = layer(h)
return h
def forward(
self,
input_ids: torch.Tensor,
return_details: bool = False,
):
h = self._run_backbone(input_ids)
if self.residual_mode == 'classic':
# norm_f flows into the residual; block 0 reads the norm_f'd stream
# directly, blocks 1+ apply their own pre-norm.
h = self.norm_f(h)
all_info = {}
for i, (amor_block, pre_norm) in enumerate(zip(self.amor_blocks, self.amor_pre_norms)):
if pre_norm is not None:
h_normed = pre_norm(h)
elif self.residual_mode == 'h':
# Block 0 in 'h' mode: norm_f is the inline pre-norm; residual
# stays un-normed.
h_normed = self.norm_f(h)
else:
# Block 0 in 'classic' mode: input is already norm_f'd.
h_normed = h
logits_for_gate = (h_normed if amor_block.gate.router is not None
else self.lm_head(h_normed))
h_out, info = amor_block(h_normed, logits_for_gate)
del logits_for_gate
if self.residual_mode == 'h':
# All blocks symmetric: residual wraps un-normed h.
h = h + (h_out - h_normed)
else:
if pre_norm is not None:
# Residual wraps un-normed h (h was not pre-normed for this block).
h = h + (h_out - h_normed)
else:
# Block 0: residual wraps the norm_f'd stream (h == h_normed here).
h = h_out
if return_details:
all_info[f'block_{i}'] = info
h = self.final_norm(h)
logits = self.lm_head(h)
if return_details:
fire_rates = [v['fire_rate'] for v in all_info.values() if 'fire_rate' in v]
alphas = [v['alpha'] for v in all_info.values() if 'alpha' in v]
details = {
'per_block': all_info,
'fire_rate': sum(fire_rates) / len(fire_rates) if fire_rates else 0.0,
'alpha': sum(alphas) / len(alphas) if alphas else 0.0,
}
return logits, details
return logits