Download baselines/tabular.py from jsantillana/vx-one: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/jsantillana/vx-one/resolve/main/baselines/tabular.py
- Command line
-
hf download hf://jsantillana/vx-one/baselines/tabular.py
-
curl -L -o tabular.py https://huggingface.co/jsantillana/vx-one/resolve/main/baselines/tabular.py
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) | |
| 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)) | |
| 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 | |