"""Train lightweight MLP heads for downstream plan-validity classification.""" from __future__ import annotations import argparse import csv import json import math import os import random from pathlib import Path import numpy as np import torch import torch.nn as nn from sklearn.metrics import ( accuracy_score, average_precision_score, balanced_accuracy_score, confusion_matrix, f1_score, precision_score, recall_score, roc_auc_score, ) from torch.utils.data import DataLoader, TensorDataset from code.downstream.features import load_feature_matrix DEFAULT_EVAL_SPLITS = ["validation", "test-interpolation", "test-extrapolation"] class ValidityMLP(nn.Module): """Small binary classifier over frozen transition-model summaries.""" def __init__(self, input_dim: int, hidden_dims: list[int], dropout: float): super().__init__() layers: list[nn.Module] = [] prev = input_dim for hidden_dim in hidden_dims: layers.extend( [ nn.Linear(prev, hidden_dim), nn.LayerNorm(hidden_dim), nn.ReLU(), nn.Dropout(dropout), ] ) prev = hidden_dim layers.append(nn.Linear(prev, 1)) self.net = nn.Sequential(*layers) def forward(self, x): return self.net(x).squeeze(-1) def set_seed(seed: int) -> None: random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def resolve_device(device_arg: str) -> torch.device: if device_arg == "auto": if torch.cuda.is_available(): return torch.device("cuda") if torch.backends.mps.is_available(): return torch.device("mps") return torch.device("cpu") if device_arg == "cuda" and not torch.cuda.is_available(): return torch.device("cpu") if device_arg == "mps" and not torch.backends.mps.is_available(): return torch.device("cpu") return torch.device(device_arg) def load_split(dataset_dir: str | Path, family: str, seed: int, split: str) -> dict: path = Path(dataset_dir) / "features" / family / f"seed_{seed}" / f"{split}.npz" if not path.exists(): raise FileNotFoundError(f"Missing feature split: {path}") return load_feature_matrix(path) def standardize(train_X: np.ndarray, *arrays: np.ndarray): mean = train_X.mean(axis=0) std = train_X.std(axis=0) std = np.where(std < 1e-6, 1.0, std) return [(arr - mean) / std for arr in arrays], mean, std def make_loader(X: np.ndarray, y: np.ndarray, batch_size: int, shuffle: bool) -> DataLoader: ds = TensorDataset( torch.tensor(X, dtype=torch.float32), torch.tensor(y, dtype=torch.float32), ) return DataLoader(ds, batch_size=batch_size, shuffle=shuffle) def train(args) -> dict: set_seed(args.seed) device = resolve_device(args.device) train_data = load_split(args.dataset_dir, args.family, args.source_seed, "train") val_data = load_split(args.dataset_dir, args.family, args.source_seed, "validation") eval_data = { split: load_split(args.dataset_dir, args.family, args.source_seed, split) for split in args.eval_splits } feature_names = [str(name) for name in train_data["feature_names"]] keep_mask = build_feature_keep_mask(feature_names, args.exclude_feature_patterns) filtered_feature_names = [name for name, keep in zip(feature_names, keep_mask) if keep] train_X = np.asarray(train_data["X"], dtype=np.float32)[:, keep_mask] train_y = np.asarray(train_data["y"], dtype=np.int64) val_X = np.asarray(val_data["X"], dtype=np.float32)[:, keep_mask] val_y = np.asarray(val_data["y"], dtype=np.int64) if train_X.shape[0] == 0: raise RuntimeError("Training feature matrix is empty.") if len(np.unique(train_y)) < 2: raise RuntimeError("Training labels contain only one class.") arrays = [train_X, val_X] + [ np.asarray(data["X"], dtype=np.float32)[:, keep_mask] for data in eval_data.values() ] standardized, mean, std = standardize(train_X, *arrays) train_X = standardized[0] val_X = standardized[1] eval_X_by_split = { split: standardized[idx + 2] for idx, split in enumerate(eval_data) } model = ValidityMLP( input_dim=train_X.shape[1], hidden_dims=args.hidden_dims, dropout=args.dropout, ).to(device) positives = float(train_y.sum()) negatives = float(len(train_y) - train_y.sum()) pos_weight_value = negatives / positives if positives > 0 else 1.0 pos_weight = torch.tensor([pos_weight_value], dtype=torch.float32, device=device) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay) train_loader = make_loader(train_X, train_y, args.batch_size, shuffle=True) val_loader = make_loader(val_X, val_y, args.batch_size, shuffle=False) best_val_loss = float("inf") best_state = None patience_left = args.patience history: list[dict] = [] for epoch in range(1, args.epochs + 1): model.train() train_losses = [] for xb, yb in train_loader: xb = xb.to(device) yb = yb.to(device) logits = model(xb) loss = criterion(logits, yb) optimizer.zero_grad() loss.backward() optimizer.step() train_losses.append(float(loss.item())) val_loss = evaluate_loss(model, val_loader, criterion, device) train_loss = float(np.mean(train_losses)) if train_losses else 0.0 history.append({"epoch": epoch, "train_loss": train_loss, "val_loss": val_loss}) if val_loss < best_val_loss - 1e-6: best_val_loss = val_loss best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()} patience_left = args.patience else: patience_left -= 1 if args.verbose: print(f"epoch={epoch} train_loss={train_loss:.4f} val_loss={val_loss:.4f}") if patience_left <= 0: break if best_state is not None: model.load_state_dict(best_state) split_predictions = {} split_metrics = {} all_eval = { "train": (train_X, train_y, train_data), "validation": (val_X, val_y, val_data), } for split, data in eval_data.items(): all_eval[split] = ( eval_X_by_split[split], np.asarray(data["y"], dtype=np.int64), data, ) for split, (X, y, data) in all_eval.items(): probs = predict_probs(model, X, args.batch_size, device) metrics = compute_metrics(y, probs) metrics.update({"split": split, "group": "overall", "group_value": "overall"}) split_metrics[split] = metrics split_predictions[split] = (probs, data) output_dir = Path(args.output_dir) / args.family / f"source_seed_{args.source_seed}" / f"head_seed_{args.seed}" output_dir.mkdir(parents=True, exist_ok=True) write_outputs( output_dir=output_dir, model=model, mean=mean, std=std, args=args, feature_names=[str(name) for name in train_data["feature_names"]], kept_feature_names=filtered_feature_names, excluded_feature_patterns=args.exclude_feature_patterns, history=history, split_metrics=split_metrics, split_predictions=split_predictions, ) print(f"Wrote downstream validity outputs to {output_dir}") return split_metrics def build_feature_keep_mask( feature_names: list[str], exclude_patterns: list[str] | None, ) -> np.ndarray: """Return a boolean mask excluding feature names containing any pattern.""" patterns = [pattern.lower() for pattern in (exclude_patterns or []) if pattern] if not patterns: return np.ones(len(feature_names), dtype=bool) keep = [] for name in feature_names: lowered = name.lower() keep.append(not any(pattern in lowered for pattern in patterns)) mask = np.asarray(keep, dtype=bool) if not mask.any(): raise ValueError("Feature exclusion removed every feature.") return mask def evaluate_loss(model, loader, criterion, device) -> float: model.eval() losses = [] with torch.no_grad(): for xb, yb in loader: xb = xb.to(device) yb = yb.to(device) losses.append(float(criterion(model(xb), yb).item())) return float(np.mean(losses)) if losses else 0.0 def predict_probs(model, X: np.ndarray, batch_size: int, device: torch.device) -> np.ndarray: loader = DataLoader(torch.tensor(X, dtype=torch.float32), batch_size=batch_size) probs = [] model.eval() with torch.no_grad(): for xb in loader: xb = xb.to(device) probs.append(torch.sigmoid(model(xb)).detach().cpu().numpy()) return np.concatenate(probs, axis=0) if probs else np.asarray([], dtype=np.float32) def compute_metrics(y_true: np.ndarray, probs: np.ndarray) -> dict: preds = (probs >= 0.5).astype(np.int64) labels = np.asarray(y_true, dtype=np.int64) unique = np.unique(labels) roc_auc = float("nan") auprc = float("nan") if len(unique) == 2: roc_auc = float(roc_auc_score(labels, probs)) auprc = float(average_precision_score(labels, probs)) tn, fp, fn, tp = confusion_matrix(labels, preds, labels=[0, 1]).ravel() return { "num_examples": int(len(labels)), "positive_rate": float(labels.mean()) if len(labels) else float("nan"), "accuracy": float(accuracy_score(labels, preds)) if len(labels) else float("nan"), "balanced_accuracy": float(balanced_accuracy_score(labels, preds)) if len(unique) == 2 else float("nan"), "auroc": roc_auc, "auprc": auprc, "f1": float(f1_score(labels, preds, zero_division=0)), "precision": float(precision_score(labels, preds, zero_division=0)), "recall": float(recall_score(labels, preds, zero_division=0)), "tn": int(tn), "fp": int(fp), "fn": int(fn), "tp": int(tp), } def write_outputs( *, output_dir: Path, model: nn.Module, mean: np.ndarray, std: np.ndarray, args, feature_names: list[str], kept_feature_names: list[str], excluded_feature_patterns: list[str], history: list[dict], split_metrics: dict, split_predictions: dict, ) -> None: torch.save( { "model_state_dict": model.state_dict(), "mean": mean.astype(np.float32), "std": std.astype(np.float32), "original_feature_names": feature_names, "feature_names": kept_feature_names, "excluded_feature_patterns": excluded_feature_patterns, "args": vars(args), }, output_dir / "validity_mlp.pt", ) with open(output_dir / "history.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=["epoch", "train_loss", "val_loss"]) writer.writeheader() writer.writerows(history) metric_fields = [ "split", "group", "group_value", "num_examples", "positive_rate", "accuracy", "balanced_accuracy", "auroc", "auprc", "f1", "precision", "recall", "tn", "fp", "fn", "tp", ] rows = list(split_metrics.values()) rows.extend(group_metric_rows(split_predictions)) with open(output_dir / "metrics.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=metric_fields) writer.writeheader() for row in rows: writer.writerow({field: row.get(field, "") for field in metric_fields}) with open(output_dir / "metrics.json", "w", encoding="utf-8") as f: json.dump(split_metrics, f, indent=2, allow_nan=True) prediction_fields = [ "split", "candidate_id", "domain", "problem", "corruption_type", "label_valid", "prob_valid", "pred_valid", ] with open(output_dir / "predictions.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=prediction_fields) writer.writeheader() for split, (probs, data) in split_predictions.items(): labels = np.asarray(data["y"], dtype=np.int64) for idx, prob in enumerate(probs): writer.writerow( { "split": split, "candidate_id": str(data["candidate_ids"][idx]), "domain": str(data["domains"][idx]), "problem": str(data["problems"][idx]), "corruption_type": str(data["corruption_types"][idx]), "label_valid": int(labels[idx]), "prob_valid": float(prob), "pred_valid": int(prob >= 0.5), } ) def group_metric_rows(split_predictions: dict) -> list[dict]: rows: list[dict] = [] for split, (probs, data) in split_predictions.items(): y = np.asarray(data["y"], dtype=np.int64) for group_name, values in [ ("domain", data["domains"]), ("corruption_type", data["corruption_types"]), ]: for value in sorted(set(str(item) for item in values)): idxs = np.asarray([str(item) == value for item in values], dtype=bool) if not idxs.any(): continue metrics = compute_metrics(y[idxs], probs[idxs]) metrics.update( { "split": split, "group": group_name, "group_value": value, } ) rows.append(metrics) return rows def main() -> None: parser = argparse.ArgumentParser(description="Train downstream validity MLP.") parser.add_argument("--dataset_dir", default="outputs/downstream_validity/frozen_transition_validity") parser.add_argument("--family", required=True) parser.add_argument("--source_seed", type=int, default=13) parser.add_argument("--seed", type=int, default=13, help="MLP head seed") parser.add_argument("--output_dir", default=None) parser.add_argument("--eval_splits", nargs="+", default=DEFAULT_EVAL_SPLITS) parser.add_argument("--hidden_dims", nargs="+", type=int, default=[64, 32]) parser.add_argument("--dropout", type=float, default=0.1) parser.add_argument("--epochs", type=int, default=100) parser.add_argument("--batch_size", type=int, default=64) parser.add_argument("--lr", type=float, default=1e-3) parser.add_argument("--weight_decay", type=float, default=1e-4) parser.add_argument("--patience", type=int, default=12) parser.add_argument("--device", choices=["auto", "cuda", "mps", "cpu"], default="cpu") parser.add_argument( "--exclude_feature_patterns", nargs="*", default=[], help="Exclude features whose names contain any of these substrings.", ) parser.add_argument("--verbose", action="store_true") args = parser.parse_args() args.dataset_dir = str(Path(args.dataset_dir).resolve()) if args.output_dir is None: args.output_dir = str(Path(args.dataset_dir) / "mlp_results") else: args.output_dir = str(Path(args.output_dir).resolve()) os.makedirs(args.output_dir, exist_ok=True) train(args) if __name__ == "__main__": main()