vx-one / baselines /tabular.py
jsantillana's picture
Add VX-One source code
908fd1a verified
Raw History Blame Contribute Delete
11.4 kB
"""Baselines tabulares (spec F2 §5.3, anti-strawman): XGBoost, Random Forest
y MLP con el MISMO protocolo que VX-One.
Simetría con el motor (todo lo que recibe VX-One, reciben los baselines):
- Mismos inputs: u_r del FeatureStage (d+1) + one-hot de los campos
textuales con el MISMO vocab fitted en train (la rama de embeddings
aprendida NO se replica — ese edge lo mide E6-A1).
- Mismos pesos ω (§3.5) vía sample_weight en Choice/ddos. Por estar
entrenados con el mismo tilt, les aplica el MISMO de-tilt analítico
z − log ω antes de T (fit_choice_temperature; Prop. 2 simétrica).
- Score SIN pesos (simétrico con CORN D3) y softmax nominal 4 clases —
la comparación ordinal-vs-nominal es parte del diseño (F2 §3.4).
- Noul sin pesos (Prop. 1).
- Misma calibración: 1 T por cabeza, mismo criterio NLL, mismo val,
mismo buscador (src.models.calibration). Árboles solo emiten probs →
logits = log p (estrictamente monótono, mismo argmax).
- Mismo criterio de parada M_val (§6.4) con ŝ = Σ k·π_k (decode idéntico).
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import torch
from sklearn.metrics import f1_score, roc_auc_score
from torch import nn
from src.models.calibration import (fit_choice_temperature,
fit_noul_temperature,
fit_temperature_scalar)
SEED = 42
# ── features: u_r + one-hot textual (mismos inputs que el motor) ───────
def baseline_features(stage, x_num: np.ndarray,
txt: dict[str, list] | None,
vocabs: dict | None) -> np.ndarray:
u, _ = stage.transform(x_num)
blocks = [u.astype(np.float32)]
if txt and vocabs:
for k, vocab in vocabs.items():
ids = np.asarray(vocab.encode(txt[k]), dtype=np.int64)
onehot = np.zeros((len(ids), vocab.size), dtype=np.float32)
onehot[np.arange(len(ids)), ids] = 1.0
blocks.append(onehot)
return np.concatenate(blocks, axis=1)
# ── adaptador probs → logits (los árboles no exponen logits) ───────────
def _probs_to_logits(p: np.ndarray, binary: bool) -> np.ndarray:
p = np.clip(p, 1e-7, 1 - 1e-7)
if binary:
return np.log(p / (1 - p))
return np.log(p)
@dataclass
class HeadCalibration:
"""Temperaturas fitted en val (§5.3). score_T None en EMBER."""
noul_T: float
choice_T: float
score_T: float | None = None
ddos_T: float | None = None
def calibrate_heads(val: dict[str, np.ndarray], omega: torch.Tensor,
omega_ddos: torch.Tensor | None = None, *,
fam_keep: np.ndarray | None = None,
sev_keep: np.ndarray | None = None) -> HeadCalibration:
"""val trae p_noul/p_choice/p_score/p_ddos (probs) + y_*. Mismo criterio
NLL, mismo val y mismo buscador que VX-One (§5). fam_keep/sev_keep:
máscaras booleanas para excluir de la calibración filas con clase
ausente en train (scheme time); p_ddos debe llegar pre-recortado a las
filas de la máscara ddos (gather tolera desalineados en silencio)."""
fm = val["fam_mask"] if fam_keep is None else (val["fam_mask"] & fam_keep)
cal = HeadCalibration(
noul_T=fit_noul_temperature(
torch.tensor(_probs_to_logits(val["p_noul"], binary=True)),
torch.tensor(val["y_bin"])),
choice_T=fit_choice_temperature(
torch.tensor(_probs_to_logits(val["p_choice"], binary=False)),
torch.tensor(val["y_fam"][fm]), omega),
)
if "p_score" in val:
m = np.ones(len(val["y_sev"]), dtype=bool) if sev_keep is None else sev_keep
z_s = torch.tensor(_probs_to_logits(val["p_score"][m], binary=False))
y_s = torch.tensor(val["y_sev"][m])
cal.score_T = fit_temperature_scalar(
lambda T: -torch.log_softmax(z_s / T, 1)
.gather(1, y_s.unsqueeze(1)).mean().item())
if "p_ddos" in val and omega_ddos is not None:
cal.ddos_T = fit_choice_temperature(
torch.tensor(_probs_to_logits(val["p_ddos"], binary=False)),
torch.tensor(val["y_ddos"]), omega_ddos)
return cal
# ── sample weights ω normalizadas a media 1 ────────────────────────────
def _sample_weights(y: np.ndarray, omega: np.ndarray) -> np.ndarray:
w = omega[y]
return w * (len(y) / w.sum())
# ── XGBoost / RF: un modelo por cabeza ─────────────────────────────────
_XGB_PARAMS = {
"n_estimators": 500, "max_depth": 8, "learning_rate": 0.1,
"subsample": 0.8, "colsample_bytree": 0.8,
"tree_method": "hist", "n_jobs": -1, "random_state": SEED,
"eval_metric": "logloss",
}
def fit_xgboost(x_tr: np.ndarray, y_tr: np.ndarray,
w_tr: np.ndarray | None, task: str) -> object:
"""task: 'binary' | 'multiclass' (num_class se deriva de y). El val se
reserva para calibración — sin early stopping interno (§5.3)."""
import xgboost as xgb
params = dict(_XGB_PARAMS)
if task == "binary":
params["objective"] = "binary:logistic"
else:
params["objective"] = "multi:softprob"
params["num_class"] = int(y_tr.max()) + 1
model = xgb.XGBClassifier(**params)
model.fit(x_tr, y_tr, sample_weight=w_tr)
return model
def fit_rf(x_tr: np.ndarray, y_tr: np.ndarray,
w_tr: np.ndarray | None, task: str = "multiclass") -> object:
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(
n_estimators=300, min_samples_leaf=1, n_jobs=-1, random_state=SEED)
model.fit(x_tr, y_tr, sample_weight=w_tr)
return model
def predict_tree(model, x: np.ndarray, binary: bool) -> np.ndarray:
"""Probs del árbol → logits-compatible (1D en binario, [N,K] en multi)."""
p = model.predict_proba(x)
if binary:
return p[:, 1].astype(np.float64)
return p.astype(np.float64)
# ── MLP tabular: una red multi-head (BN→ReLU estándar, no LN) ──────────
class MLPBaseline(nn.Module):
def __init__(self, d_in: int, K: int, has_score: bool,
width: int = 256) -> None:
super().__init__()
self.trunk = nn.Sequential(
nn.Linear(d_in, width), nn.BatchNorm1d(width), nn.ReLU(), nn.Dropout(0.1),
nn.Linear(width, width), nn.BatchNorm1d(width), nn.ReLU(), nn.Dropout(0.1),
)
self.noul = nn.Linear(width, 1)
self.choice = nn.Linear(width, K)
self.has_score = has_score
if has_score:
self.score = nn.Linear(width, 4) # nominal (vs CORN de VX-One)
def forward(self, x: torch.Tensor) -> dict[str, torch.Tensor]:
h = self.trunk(x)
out = {"noul": self.noul(h).squeeze(-1), "choice": self.choice(h)}
if self.has_score:
out["score"] = self.score(h)
return out
def _m_val_mlp(o: dict[str, np.ndarray], has_score: bool) -> float:
"""M_val espejo de train._m_val (§6.4) con score nominal: ŝ = Σ k·π_k."""
p_mal = 1.0 / (1.0 + np.exp(-o["noul"]))
fam_mask = o["y_fam"] >= 0
m = [
roc_auc_score(o["y_bin"], p_mal)
if len(np.unique(o["y_bin"])) > 1 else 0.5,
f1_score(o["y_fam"][fam_mask], np.argmax(o["choice"], 1)[fam_mask],
average="macro", zero_division=0),
]
if has_score:
pi = np.exp(o["score"] - o["score"].max(1, keepdims=True))
pi /= pi.sum(1, keepdims=True)
sev_hat = (pi * np.arange(4)).sum(axis=1)
m.append(1.0 - float(np.abs(sev_hat - o["y_sev"]).mean()) / 3.0)
return float(np.mean(m))
@torch.no_grad()
def _mlp_forward(net: MLPBaseline, x: np.ndarray, y_bin, y_fam, y_sev,
device: str, bs: int = 8192) -> dict[str, np.ndarray]:
was_training = net.training
net.eval()
outs: dict[str, list[np.ndarray]] = {}
for i in range(0, len(x), bs):
o = net(torch.tensor(x[i:i + bs], dtype=torch.float32, device=device))
for k, v in o.items():
outs.setdefault(k, []).append(v.float().cpu().numpy())
flat = {k: np.concatenate(v) for k, v in outs.items()}
flat.update(y_bin=np.asarray(y_bin), y_fam=np.asarray(y_fam))
if y_sev is not None:
flat["y_sev"] = np.asarray(y_sev)
if was_training:
net.train()
return flat
def train_mlp(x_tr: np.ndarray, y_bin: np.ndarray, y_fam: np.ndarray,
omega: np.ndarray, y_sev: np.ndarray | None,
x_va: np.ndarray, y_bin_va: np.ndarray, y_fam_va: np.ndarray,
y_sev_va: np.ndarray | None, *,
epochs: int = 30, batch_size: int = 512, lr: float = 1e-3,
device: str = "cpu", width: int = 256, patience: int = 4,
seed: int = SEED, log=print) -> MLPBaseline:
"""MLP multi-head con ω en Choice (weighted CE), Noul/Score sin pesos,
early stop sobre el MISMO M_val (fam_mask −1 excluido de Choice)."""
torch.manual_seed(seed)
K = int(y_fam.max()) + 1
net = MLPBaseline(x_tr.shape[1], K, has_score=y_sev is not None,
width=width).to(device)
opt = torch.optim.AdamW(net.parameters(), lr=lr, weight_decay=1e-4)
x_t = torch.tensor(x_tr, dtype=torch.float32, device=device)
yb_t = torch.tensor(y_bin, dtype=torch.float32, device=device)
yf_t = torch.tensor(y_fam, dtype=torch.int64, device=device)
ys_t = (torch.tensor(y_sev, dtype=torch.int64, device=device)
if y_sev is not None else None)
fm_t = yf_t >= 0 # filas sin familia (−1) fuera de la CE de Choice
n = len(x_t)
w_fam = torch.zeros(n, dtype=torch.float32, device=device)
if bool(fm_t.any()):
w_fam[fm_t] = torch.tensor(_sample_weights(y_fam[fm_t], omega),
dtype=torch.float32)
best_m, bad, best_state = -1.0, 0, None
for ep in range(epochs):
net.train()
perm = torch.randperm(n, device=device)
for s in range(0, n, batch_size):
idx = perm[s:s + batch_size]
o = net(x_t[idx])
loss = nn.functional.binary_cross_entropy_with_logits(
o["noul"], yb_t[idx])
fi = fm_t[idx]
if bool(fi.any()):
w_i = w_fam[idx][fi]
loss = loss + (nn.functional.cross_entropy(
o["choice"][fi], yf_t[idx][fi], reduction="none")
.mul(w_i).sum() / w_i.sum())
if ys_t is not None:
loss = loss + nn.functional.cross_entropy(o["score"], ys_t[idx])
opt.zero_grad(set_to_none=True)
loss.backward()
opt.step()
va = _mlp_forward(net, x_va, y_bin_va, y_fam_va, y_sev_va, device)
mv = _m_val_mlp(va, y_sev is not None)
if mv > best_m:
best_m, bad = mv, 0
best_state = {k: v.clone() for k, v in net.state_dict().items()}
else:
bad += 1
if ep % 5 == 0 or bad >= patience:
log(f"[mlp] epoch {ep + 1}/{epochs} M_val={mv:.4f} bad={bad}")
if bad >= patience:
break
if best_state is not None:
net.load_state_dict(best_state)
return net