Download scripts/probe.py from lewislululu/jevon: direct link, hf CLI and curl.
- Browser
- Download file 7.69 kB
-
https://huggingface.co/lewislululu/jevon/resolve/main/scripts/probe.py
- Command line
-
hf download hf://lewislululu/jevon/scripts/probe.py
-
curl -L -o probe.py https://huggingface.co/lewislululu/jevon/resolve/main/scripts/probe.py
7.69 kB
| #!/usr/bin/env python3 | |
| """Measure what the planner's field actually encodes. | |
| Fits a ridge probe from the planner's per-cell features to the true BFS | |
| distance and reports R^2. This is the diagnostic that caught the central | |
| training bug: with only the sparse decision questions supervising it, the | |
| recurrence scored R^2 = 0.21 -- it was running, it was differentiable, its | |
| accuracy numbers looked reasonable, and it was not computing distances. | |
| Also reports the field head's own predictions, which is what the decision | |
| heads actually read. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sys | |
| from pathlib import Path | |
| ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(ROOT)) | |
| import torch | |
| import envs.maze as mz | |
| from jevon.grid import bfs_field, encode_maze, pad_boards | |
| from jevon.inference import resolve_weights | |
| from jevon.modeling import JevonConfig, JevonModel | |
| from jevon.planner import default_iterations | |
| def ridge_r2(features: torch.Tensor, target: torch.Tensor) -> tuple[float, float]: | |
| design = torch.cat([features, torch.ones(len(features), 1)], 1) | |
| gram = design.T @ design + 1e-3 * torch.eye(design.shape[1]) | |
| weights = torch.linalg.lstsq(gram, design.T @ target.unsqueeze(1)).solution | |
| pred = (design @ weights).squeeze(1) | |
| ss_res = ((pred - target) ** 2).sum() | |
| ss_tot = ((target - target.mean()) ** 2).sum() | |
| return float(1 - ss_res / ss_tot), float((pred - target).abs().mean()) | |
| def descent_accuracy(head: torch.Tensor, truth: torch.Tensor) -> float: | |
| """Fraction of cells whose lowest-predicted neighbour is a correct step. | |
| R^2 scores the field's absolute values, but greedy descent only ever | |
| compares a cell's four neighbours, so a field can be numerically mediocre | |
| and still route perfectly -- or fit well on average and still invert the | |
| one comparison that matters. This is the quantity that predicts solve | |
| rate, so it is measured rather than inferred from R^2. | |
| """ | |
| head = head.detach() | |
| hits = total = 0 | |
| h, w = truth.shape | |
| for r in range(h): | |
| for c in range(w): | |
| here = float(truth[r, c]) | |
| if here <= 0: # blocked, unreachable, or the goal | |
| continue | |
| options = [(rr, cc) for rr, cc in | |
| ((r - 1, c), (r + 1, c), (r, c - 1), (r, c + 1)) | |
| if 0 <= rr < h and 0 <= cc < w and truth[rr, cc] >= 0] | |
| if not options: | |
| continue | |
| pick = min(options, key=lambda x: float(head[x[0], x[1]])) | |
| # Correct iff that neighbour lies on *a* shortest path. | |
| hits += float(truth[pick]) == here - 1 | |
| total += 1 | |
| return hits / max(total, 1) | |
| def descent_outcomes(head: torch.Tensor, truth: torch.Tensor, | |
| cap_mult: int = 8) -> float: | |
| """Fraction of cells from which greedy descent actually reaches the goal. | |
| Per-step accuracy is not the quantity a controller lives or dies by. A | |
| field that is right 99% of the time but contains one spurious basin traps | |
| forever; a field that is right 70% of the time with no basin still arrives, | |
| just by a longer route. Solve rate equals *this* number, so report it next | |
| to the per-step figure and let the gap between them speak. | |
| """ | |
| head = head.detach() | |
| h, w = truth.shape | |
| goal = min(((r, c) for r in range(h) for c in range(w) | |
| if float(truth[r, c]) == 0.0), default=None) | |
| if goal is None: | |
| return 0.0 | |
| reached = total = 0 | |
| cap = cap_mult * h * w | |
| for r in range(h): | |
| for c in range(w): | |
| if float(truth[r, c]) < 0: | |
| continue | |
| total += 1 | |
| cur, seen = (r, c), set() | |
| for _ in range(cap): | |
| if cur == goal: | |
| reached += 1 | |
| break | |
| if cur in seen: | |
| break # a cycle: descent never escapes | |
| seen.add(cur) | |
| options = [(rr, cc) for rr, cc in | |
| ((cur[0] - 1, cur[1]), (cur[0] + 1, cur[1]), | |
| (cur[0], cur[1] - 1), (cur[0], cur[1] + 1)) | |
| if 0 <= rr < h and 0 <= cc < w and truth[rr, cc] >= 0] | |
| if not options: | |
| break | |
| cur = min(options, key=lambda x: float(head[x[0], x[1]])) | |
| return reached / max(total, 1) | |
| def main() -> None: | |
| p = argparse.ArgumentParser(description=__doc__) | |
| p.add_argument("--checkpoint", default="runs/jevon-final") | |
| # None means "prefer balanced.pt", the checkpoint every shipped number | |
| # comes from. See jevon.inference.resolve_weights. | |
| p.add_argument("--weights", default=None) | |
| p.add_argument("--sizes", default="11,21,31,51") | |
| p.add_argument("--episodes", type=int, default=4) | |
| p.add_argument("--device", default="cpu") | |
| p.add_argument("--out", default=None) | |
| args = p.parse_args() | |
| root = Path(args.checkpoint) | |
| cfg = JevonConfig(**json.loads((root / "config.json").read_text())) | |
| model = JevonModel(cfg) | |
| model.load_state_dict(torch.load(resolve_weights(root, args.weights), | |
| map_location="cpu")) | |
| model.eval().to(args.device) | |
| report = {} | |
| print(f"{'size':>5s} {'cells':>7s} {'probe R2':>9s} {'probe MAE':>10s} " | |
| f"{'head R2':>8s} {'descent':>8s} {'reach':>7s} " | |
| f"{'diameter':>9s} {'T':>6s}") | |
| for size in [int(s) for s in args.sizes.split(",")]: | |
| feats, heads, dists, descents, reaches = [], [], [], [], [] | |
| diameter = 0 | |
| steps = default_iterations(size) | |
| with torch.no_grad(): | |
| for i in range(args.episodes): | |
| topology = mz.TOPOLOGIES[i % len(mz.TOPOLOGIES)] | |
| # Seeds ending in 9 are the held-out test split. | |
| state = mz.generate_maze(size, topology, 70_009 + i * 10) | |
| board, _, target = encode_maze(state) | |
| boards, passable, _ = pad_boards([board]) | |
| cells = model.encode_boards(boards.to(args.device), | |
| passable.to(args.device), steps, 0) | |
| head = model.predict_field( | |
| cells, boards.to(args.device), | |
| passable.to(args.device), iterations=steps)[0, 0] | |
| truth = bfs_field(board, target) | |
| mask = truth >= 0 | |
| diameter = max(diameter, int(truth.max())) | |
| descents.append(descent_accuracy(head.cpu(), truth)) | |
| reaches.append(descent_outcomes(head.cpu(), truth)) | |
| feats.append(cells[0, :, mask].T.cpu()) | |
| heads.append(head[mask].cpu()) | |
| dists.append(truth[mask]) | |
| features = torch.cat(feats) | |
| truth = torch.cat(dists) | |
| r2, mae = ridge_r2(features, truth) | |
| head_r2, _ = ridge_r2(torch.cat(heads).unsqueeze(1), truth) | |
| descent = sum(descents) / max(len(descents), 1) | |
| reach = sum(reaches) / max(len(reaches), 1) | |
| report[size] = {"cells": len(truth), "probe_r2": r2, "probe_mae": mae, | |
| "head_r2": head_r2, "descent_acc": descent, | |
| "reach_rate": reach, | |
| "diameter": diameter, "iterations": steps} | |
| print(f"{size:5d} {len(truth):7d} {r2:9.4f} {mae:10.2f} {head_r2:8.4f} " | |
| f"{descent:8.4f} {reach:7.3f} {diameter:9d} {steps:6d}") | |
| if args.out: | |
| Path(args.out).parent.mkdir(parents=True, exist_ok=True) | |
| Path(args.out).write_text(json.dumps(report, indent=2)) | |
| if __name__ == "__main__": | |
| main() | |