| """ |
| scripts/per_hop_learned_threshold_multistart.py - Multi-start |
| gradient-based per-hop threshold learning. |
| |
| Section 20 finding: gradient descent on the soft-F1 surrogate is |
| multi-modal on CAFF logit distributions. Seeds 1337 and 2024 |
| converged to the same useful basin (0.74, 0.82, 0.88), but seed 42 |
| got stuck in a high-precision local optimum (0.85, 0.88, 0.91) and |
| regressed F1 by 0.029. |
| |
| This script tests the natural fix: multi-start optimization. |
| |
| For each hop, we run K independent gradient descents from K |
| different starting thresholds, then pick whichever final theta |
| maximizes hard F1 on dev. The starting grid is sampled from the |
| threshold range that grid search (Section 12) sweeps over, so we |
| include a warm start at every coarse grid point. |
| |
| Algorithm: |
| starting_thetas = [0.30, 0.40, ..., 0.90] # 7 starts |
| for each hop: |
| results = [] |
| for theta_init in starting_thetas: |
| theta = nn.Parameter(logit(theta_init)) |
| optimize for N steps with Adam |
| track best hard_F1_dev |
| results.append((best_theta, best_hard_F1)) |
| winner = max(results, key=lambda r: r[1]) |
| Apply winner thresholds on test. |
| |
| If Section 20's diagnosis is right, this should fix the seed 42 |
| failure: at least one of the 7 starts will land in the (0.74, |
| 0.82, 0.88) basin, and the max-over-starts selection picks it. |
| |
| Usage: |
| python scripts/per_hop_learned_threshold_multistart.py |
| --config configs/caff_orphanet.yaml |
| --checkpoint runs/caff_orphanet/seed_42/best.pt |
| --num-starts 7 |
| --device cuda |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import logging |
| import sys |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import yaml |
|
|
| ROOT = Path(__file__).parent.parent |
| if str(ROOT) not in sys.path: |
| sys.path.insert(0, str(ROOT)) |
|
|
| from caff import ( |
| AblationFlags, |
| CAFFConfig, |
| CAFFEvaluator, |
| CAFFModel, |
| CAFFTripleDataset, |
| CachedBFSExtractor, |
| FrozenBioEncoder, |
| KnowledgeGraph, |
| RelationEmbeddingCache, |
| load_qa_split, |
| ) |
| from caff.evaluator import precision_recall_f1 |
| from caff.utils import set_global_seed |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", |
| datefmt="%H:%M:%S", |
| ) |
| logger = logging.getLogger("per_hop_multistart") |
|
|
|
|
| DEFAULT_LR = 0.05 |
| DEFAULT_STEPS = 1000 |
| DEFAULT_TAU = 1.0 |
| |
| |
| DEFAULT_STARTING_THETAS = [0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90] |
|
|
|
|
| def load_config(yaml_path: Path): |
| with yaml_path.open("r", encoding="utf-8") as f: |
| raw = yaml.safe_load(f) |
| cfg_dict = raw.get("config", {}) |
| abl_dict = raw.get("ablation", {}) |
| config = CAFFConfig(**cfg_dict) |
| ablation = AblationFlags(**abl_dict) if abl_dict else AblationFlags() |
| return config, ablation |
|
|
|
|
| def score_dataset(config, ablation, checkpoint_path, qa_path, cache_dir, device): |
| """Returns (scores, labels, hops). scores are post-sigmoid.""" |
| set_global_seed(config.seed, deterministic=config.deterministic) |
| kg = KnowledgeGraph.from_tsv( |
| config.kg_path, min_relation_freq=config.min_relation_freq |
| ) |
| encoder = FrozenBioEncoder(config.encoder_name, device=device) |
| rel_cache = RelationEmbeddingCache( |
| encoder=encoder, |
| relations=kg.relations, |
| cache_path=cache_dir / "relation_embeddings.pt", |
| ) |
| bfs = CachedBFSExtractor( |
| kg, L=config.L, K_r=config.K_r, cache_dir=cache_dir / "bfs" |
| ) |
| recs = load_qa_split(qa_path) |
| ds = CAFFTripleDataset(recs, bfs, require_gold=True) |
| logger.info(f" Dataset: {len(ds):,} triple instances from {qa_path}") |
|
|
| model = CAFFModel(config, rel_cache, ablation=ablation).to(device) |
| payload = torch.load(checkpoint_path, map_location=device, weights_only=False) |
| model.load_state_dict(payload["model"], strict=False) |
| model.eval() |
|
|
| evaluator = CAFFEvaluator( |
| config=config, encoder=encoder, |
| mode="teacher_forced", threshold=0.5, |
| ) |
| scores, instances, _ = evaluator._score_dataset(model, ds) |
| labels = np.array([i.label for i in instances]) |
| hops = np.array([i.hop for i in instances]) |
| return scores, labels, hops |
|
|
|
|
| def scores_to_logits(scores: np.ndarray) -> np.ndarray: |
| eps = 1e-7 |
| s = np.clip(scores, eps, 1.0 - eps) |
| return np.log(s / (1.0 - s)) |
|
|
|
|
| def score_to_logit(score: float) -> float: |
| eps = 1e-7 |
| s = max(min(score, 1.0 - eps), eps) |
| return float(np.log(s / (1.0 - s))) |
|
|
|
|
| def soft_f1_loss(logits: torch.Tensor, labels: torch.Tensor, |
| theta: torch.Tensor, tau: float = 1.0) -> torch.Tensor: |
| p = torch.sigmoid((logits - theta) / tau) |
| tp = (p * labels).sum() |
| fp = (p * (1.0 - labels)).sum() |
| fn = ((1.0 - p) * labels).sum() |
| f1 = 2.0 * tp / (2.0 * tp + fp + fn + 1e-12) |
| return 1.0 - f1 |
|
|
|
|
| def hard_f1_at_logit(logits: np.ndarray, labels: np.ndarray, theta_logit: float) -> float: |
| preds = (logits >= theta_logit).astype(int) |
| tp = int(((preds == 1) & (labels == 1)).sum()) |
| fp = int(((preds == 1) & (labels == 0)).sum()) |
| fn = int(((preds == 0) & (labels == 1)).sum()) |
| p = tp / max(tp + fp, 1) |
| r = tp / max(tp + fn, 1) |
| return 2 * p * r / max(p + r, 1e-12) |
|
|
|
|
| def learn_single_start(logits_np, labels_np, theta_init_logit, lr, steps, tau, device="cpu"): |
| """Run one gradient descent from a single starting theta. |
| |
| Returns the best (theta_logit, hard_f1_dev) seen along the trajectory. |
| """ |
| l_hop = torch.tensor(logits_np, dtype=torch.float32, device=device) |
| y_hop = torch.tensor(labels_np.astype(np.float32), device=device) |
| theta = nn.Parameter(torch.tensor([theta_init_logit], dtype=torch.float32, device=device)) |
| opt = torch.optim.Adam([theta], lr=lr) |
|
|
| best_hard_f1 = hard_f1_at_logit(logits_np, labels_np, theta_init_logit) |
| best_theta_logit = theta_init_logit |
|
|
| for step in range(steps): |
| opt.zero_grad() |
| loss = soft_f1_loss(l_hop, y_hop, theta, tau=tau) |
| loss.backward() |
| opt.step() |
|
|
| if step % 10 == 0 or step == steps - 1: |
| cur = theta.item() |
| hard_f1 = hard_f1_at_logit(logits_np, labels_np, cur) |
| if hard_f1 > best_hard_f1: |
| best_hard_f1 = hard_f1 |
| best_theta_logit = cur |
|
|
| return best_theta_logit, best_hard_f1 |
|
|
|
|
| def learn_per_hop_multistart(scores, labels, hops, num_hops, |
| starting_thetas, lr, steps, tau, device="cpu"): |
| """For each hop, run K starts and pick the best by hard_F1_dev.""" |
| logits = scores_to_logits(scores) |
|
|
| learned = {} |
| for hop in sorted(set(int(h) for h in hops)): |
| mask = hops == hop |
| logits_np = logits[mask] |
| labels_np = labels[mask] |
|
|
| n_pos = int(labels_np.sum()) |
| n_total = int(labels_np.size) |
|
|
| results = [] |
| for s_theta in starting_thetas: |
| init_logit = score_to_logit(s_theta) |
| best_theta_logit, best_hard_f1 = learn_single_start( |
| logits_np, labels_np, init_logit, lr, steps, tau, device, |
| ) |
| best_score = float(1.0 / (1.0 + np.exp(-best_theta_logit))) |
| results.append({ |
| "start": s_theta, |
| "best_theta_logit": best_theta_logit, |
| "best_theta_score": best_score, |
| "best_hard_f1": best_hard_f1, |
| }) |
|
|
| |
| winner = max(results, key=lambda r: r["best_hard_f1"]) |
| logger.info( |
| f" hop={hop}: WINNER theta={winner['best_theta_score']:.4f} " |
| f"(logit={winner['best_theta_logit']:+.4f}, " |
| f"hard_F1_dev={winner['best_hard_f1']:.4f}, " |
| f"from start={winner['start']:.2f}) " |
| f"[{n_pos:,}/{n_total:,} positives]" |
| ) |
| |
| for r in results: |
| marker = " <-- winner" if r is winner else "" |
| logger.info( |
| f" start={r['start']:.2f} -> theta={r['best_theta_score']:.4f} " |
| f"hard_F1={r['best_hard_f1']:.4f}{marker}" |
| ) |
|
|
| learned[hop] = { |
| "theta_logit": winner["best_theta_logit"], |
| "theta_score": winner["best_theta_score"], |
| "hard_f1_dev": winner["best_hard_f1"], |
| "winning_start": winner["start"], |
| "all_starts": results, |
| "n_pos": n_pos, |
| "n_total": n_total, |
| } |
| return learned |
|
|
|
|
| def apply_per_hop_learned(scores, labels, hops, learned): |
| preds = np.zeros_like(labels, dtype=int) |
| for hop, info in learned.items(): |
| mask = hops == hop |
| t = info["theta_score"] |
| preds[mask] = (scores[mask] >= t).astype(int) |
| tp = int(((preds == 1) & (labels == 1)).sum()) |
| fp = int(((preds == 1) & (labels == 0)).sum()) |
| fn = int(((preds == 0) & (labels == 1)).sum()) |
| p = tp / (tp + fp) if (tp + fp) > 0 else 0.0 |
| r = tp / (tp + fn) if (tp + fn) > 0 else 0.0 |
| f = 2 * p * r / (p + r) if (p + r) > 0 else 0.0 |
| return {"precision": p, "recall": r, "f1": f} |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--config", default="configs/caff_orphanet.yaml") |
| parser.add_argument( |
| "--checkpoint", default="runs/caff_orphanet/seed_42/best.pt" |
| ) |
| parser.add_argument("--dev-path", default="data/processed/dev.json") |
| parser.add_argument("--test-path", default="data/processed/test.json") |
| parser.add_argument("--device", default="cpu") |
| parser.add_argument("--cache-dir", default="cache") |
| parser.add_argument("--tau", type=float, default=DEFAULT_TAU) |
| parser.add_argument("--lr", type=float, default=DEFAULT_LR) |
| parser.add_argument("--steps", type=int, default=DEFAULT_STEPS) |
| parser.add_argument( |
| "--num-starts", type=int, default=7, |
| help="Number of starting thresholds. Default 7 = {0.3, 0.4, ..., 0.9}." |
| ) |
| args = parser.parse_args() |
|
|
| if args.num_starts == 7: |
| starting_thetas = DEFAULT_STARTING_THETAS |
| else: |
| starting_thetas = list(np.linspace(0.30, 0.90, args.num_starts)) |
|
|
| config_path = Path(args.config) |
| ckpt_path = Path(args.checkpoint) |
| if not config_path.exists() or not ckpt_path.exists(): |
| logger.error("Config or checkpoint not found.") |
| return 1 |
|
|
| config, ablation = load_config(config_path) |
|
|
| logger.info("Step 1/3: Score DEV set") |
| dev_scores, dev_labels, dev_hops = score_dataset( |
| config, ablation, ckpt_path, args.dev_path, |
| Path(args.cache_dir), args.device, |
| ) |
|
|
| logger.info(f"Step 2/3: Multi-start gradient descent " |
| f"({args.num_starts} starts, lr={args.lr}, " |
| f"steps={args.steps}, tau={args.tau})") |
| logger.info(f" Starting thetas: {[f'{t:.2f}' for t in starting_thetas]}") |
| learned = learn_per_hop_multistart( |
| dev_scores, dev_labels, dev_hops, num_hops=config.L, |
| starting_thetas=starting_thetas, |
| lr=args.lr, steps=args.steps, tau=args.tau, |
| device="cpu", |
| ) |
|
|
| logger.info("Step 3/3: Score TEST set and apply learned thresholds") |
| test_scores, test_labels, test_hops = score_dataset( |
| config, ablation, ckpt_path, args.test_path, |
| Path(args.cache_dir), args.device, |
| ) |
|
|
| g50 = precision_recall_f1(test_scores, test_labels, 0.50) |
| g80 = precision_recall_f1(test_scores, test_labels, 0.80) |
| learned_test = apply_per_hop_learned(test_scores, test_labels, test_hops, learned) |
|
|
| print() |
| print("=" * 80) |
| print(f"RESULTS ON HELD-OUT TEST SET " |
| f"(multi-start, n={args.num_starts}, tau={args.tau})") |
| print("=" * 80) |
| print(f"Per-hop thresholds LEARNED via multi-start soft-F1 on dev:") |
| for hop, info in learned.items(): |
| print(f" hop={hop}: theta={info['theta_score']:.4f} " |
| f"(logit={info['theta_logit']:+.4f}, " |
| f"hard_F1_dev={info['hard_f1_dev']:.4f}, " |
| f"won from start={info['winning_start']:.2f})") |
| print() |
| print(f"{'method':>40} | {'precision':>9} | {'recall':>7} | {'F1':>7}") |
| print("-" * 80) |
| print(f"{'global theta=0.50':>40} | {g50['precision']:>9.4f} | " |
| f"{g50['recall']:>7.4f} | {g50['f1']:>7.4f}") |
| print(f"{'global theta=0.80 (current baseline)':>40} | {g80['precision']:>9.4f} | " |
| f"{g80['recall']:>7.4f} | {g80['f1']:>7.4f}") |
| print(f"{'multi-start learned per-hop (NEW)':>40} | {learned_test['precision']:>9.4f} | " |
| f"{learned_test['recall']:>7.4f} | {learned_test['f1']:>7.4f}") |
| print("=" * 80) |
| delta = learned_test["f1"] - g80["f1"] |
| pct = 100 * delta / max(g80["f1"], 1e-9) |
| print(f"Multi-start learned F1 vs global theta=0.80: {delta:+.4f} ({pct:+.1f}%)") |
|
|
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|