vx-one / models /network.py
jsantillana's picture
Add VX-One source code
908fd1a verified
Raw History Blame Contribute Delete
6.44 kB
"""VXOneNet (spec F2 Β§2): proyecciΓ³n + embeddings de texto + tronco + cabezas.
Estructura por bloque: Linear β†’ LayerNorm β†’ GELU β†’ Dropout(0.1); bloque 1
mapea R^{d_in} → R^w, bloques 2..L son w→w con residual h_b = h_{b-1} +
bloque_b(h_{b-1}). LayerNorm (no BatchNorm): streaming por shards, batch-1 en
inferencia y drift temporal (F2 Β§2.1).
Cabezas por dataset (F2 Β§3.1):
- UNSW: noul(1) + choice_family(10) + corn(3 condicionales)
- CIC: noul(1) + choice_family(8|6) + corn + choice_ddos(4) [solo strat]
- EMBER: noul(1) + choice_family(11, unknown entrenado)
Dims congeladas (Β§2.2) y conteo de parΓ‘metros verificado (Β§2.3): los tests
comparan contra la tabla exacta de la spec.
"""
from __future__ import annotations
from dataclasses import dataclass
import torch
from torch import nn
# ── Dims congeladas por escala (F2 Β§2.2) ────────────────────────────────
SCALES: dict[str, dict[str, int]] = {
"nano": {"L": 2, "w_unsw": 224, "w_cic": 224, "w_ember": 192,
"d0_unsw": 112, "d0_cic": 112, "d0_ember": 20, "e": 16},
"small": {"L": 3, "w_unsw": 576, "w_cic": 576, "w_ember": 576,
"d0_unsw": 288, "d0_cic": 288, "d0_ember": 96, "e": 32},
"base": {"L": 4, "w_unsw": 1280, "w_cic": 1280, "w_ember": 1280,
"d0_unsw": 512, "d0_cic": 512, "d0_ember": 384, "e": 64},
}
DROPOUT = 0.1
@dataclass(frozen=True)
class HeadSpec:
"""Cabezas activas de una corrida (nombres congelados)."""
n_candidates: int # choice_family (K)
ddos_candidates: int = 0 # 0 = head ddos ausente
has_score: bool = True # False en EMBER (S6)
@property
def names(self) -> tuple[str, ...]:
ns = ["noul", "choice"]
if self.has_score:
ns.append("score")
if self.ddos_candidates:
ns.append("ddos")
return tuple(ns)
def head_spec_for(source: str, scale: str, split_scheme: str,
n_candidates: int) -> HeadSpec:
"""Candidate set por corrida (D12): familias con β‰₯1 fila en train.
La verificaciΓ³n de n_candidates ocurre en data.py; aquΓ­ solo se tipa.
"""
if source == "unsw":
return HeadSpec(n_candidates=n_candidates)
if source == "cic":
return HeadSpec(n_candidates=n_candidates,
ddos_candidates=4 if split_scheme == "strat" else 0)
if source == "ember":
return HeadSpec(n_candidates=n_candidates, has_score=False)
raise ValueError(f"source desconocido: {source!r}")
class _Block(nn.Module):
def __init__(self, d_in: int, w: int) -> None:
super().__init__()
self.lin = nn.Linear(d_in, w)
self.ln = nn.LayerNorm(w)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return nn.functional.dropout(
nn.functional.gelu(self.ln(self.lin(x))), DROPOUT,
training=self.training,
)
class VXOneNet(nn.Module):
"""Motor VX-One v0.1. d_num incluye el flag de fila (d+1 ya aplicado
por el feature stage: la entrada de P0 es [u ; r] ∈ R^{d+1})."""
def __init__(
self,
source: str,
scale: str,
heads: HeadSpec,
d_num: int, # d canΓ³nico (sin flag); P0 entra con d+1
vocab_sizes: dict[str, int] | None = None, # {campo: V+2} (solo UNSW)
) -> None:
super().__init__()
cfg = SCALES[scale]
self.source = source
self.scale = scale
self.heads = heads
if source == "ember":
w, d0, L = cfg["w_ember"], cfg["d0_ember"], cfg["L"]
else:
w = cfg["w_unsw"] if source == "unsw" else cfg["w_cic"]
d0 = cfg["d0_unsw"] if source == "unsw" else cfg["d0_cic"]
L = cfg["L"]
e = cfg["e"]
self.vocab_sizes = vocab_sizes or {}
m = len(self.vocab_sizes)
# P0: Linear((d+1) β†’ d0) β€” el +1 es el flag de fila (Β§2.3)
self.proj = nn.Linear(d_num + 1, d0)
# Embeddings lookup: una tabla por campo, (V+2) Γ— e
self.emb = nn.ModuleDict(
{f: nn.Embedding(vs, e) for f, vs in self.vocab_sizes.items()}
)
d_in = d0 + m * e
# Tronco: bloque 1 d_in→w, bloques 2..L w→w con residual
self.blocks = nn.ModuleList([_Block(d_in, w)] + [_Block(w, w) for _ in range(L - 1)])
# Cabezas (Β§2.3): Noul w+1; Choice wK+K; CORN w+3; ddos 4w+4
self.noul = nn.Linear(w, 1)
self.choice = nn.Linear(w, heads.n_candidates)
self.has_score = heads.has_score
if heads.has_score:
self.corn_u = nn.Linear(w, 1, bias=False) # u^T h
self.corn_b = nn.Parameter(torch.tensor([1.0, 0.0, -1.0])) # init Β§2.1
self.has_ddos = heads.ddos_candidates > 0
if self.has_ddos:
self.ddos = nn.Linear(w, heads.ddos_candidates)
# ── forward ──────────────────────────────────────────────────────
def trunk(self, u_r: torch.Tensor, txt_slots: dict[str, torch.Tensor] | None = None) -> torch.Tensor:
h = self.proj(u_r)
if self.emb:
parts = [h]
for f, tab in self.emb.items():
parts.append(tab(txt_slots[f]))
h = torch.cat(parts, dim=1)
for i, block in enumerate(self.blocks):
h = h + block(h) if i > 0 else block(h)
return h
def forward(self, u_r: torch.Tensor, txt_slots: dict[str, torch.Tensor] | None = None) -> dict[str, torch.Tensor]:
"""u_r float32 [B, d+1]; txt_slots {campo: int64 [B]} (solo UNSW)."""
h = self.trunk(u_r, txt_slots)
out: dict[str, torch.Tensor] = {
"noul": self.noul(h).squeeze(-1), # logits [B]
"choice": self.choice(h), # logits [B, K]
}
if self.has_score:
out["corn_logits"] = (self.corn_u(h).squeeze(-1).unsqueeze(1)
+ self.corn_b.unsqueeze(0)) # [B, 3]
if self.has_ddos:
out["ddos"] = self.ddos(h) # logits [B, 4]
return out
# ── conteo de parΓ‘metros (contrato Β§2.3; ApΓ©ndice B) ─────────────
def n_parameters(self) -> int:
return sum(p.numel() for p in self.parameters())