File size: 6,832 Bytes
a8351e8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | """Load and run DraftZero's FDN graph network from its public release, without pickles or MageZero.
The release (built by export_gnn_release.py) holds:
model.safetensors the network's weights, float32
config.json the architecture (`arch`) and the encoder's constants
vocab.json leaf_ids and edge_ids: the raw feature ids that have embedding rows, row order
graph_net.py the network (a copy of draft-zero's src/draftzero/gameplay/graph_net.py)
goldens.jsonl.gz encoded test states with this network's outputs, for checking a port
A state is the graph encoder's output in MageZero's graph-server layout (one dict per state):
indices [nodes] raw feature id per node: StateEncoder.indexFor(hash64(name)); typed nodes
are the hash of their type name, the root is 0
values [nodes] numeric value per node (0 for non-numeric nodes)
edge_child [edges] node index, local to the state
edge_parent [edges] node index, local to the state
edge_label [edges] raw hashed edge label
python gnn_release.py check <release dir> # run the goldens, print the largest differences
"""
from __future__ import annotations
import gzip
import json
import sys
from pathlib import Path
import numpy as np
import torch
sys.path.insert(0, str(Path(__file__).resolve().parent))
try:
import graph_net as gn # the copy shipped beside this file in the release
except ImportError: # in draft-zero's tree
from draftzero.gameplay import graph_net as gn
# MageZero ActionType ordinals (graph_type): which head scores a decision's options
PRIORITY, CHOOSE_TARGET, CHOOSE_USE = 0, 3, 5
class Vocab:
"""Raw feature id -> embedding row (-1 outside the vocab)."""
def __init__(self, ids):
self.ids = np.asarray(ids, np.int64)
self._order = np.argsort(self.ids, kind="stable")
self._sorted = self.ids[self._order]
def __len__(self) -> int:
return len(self.ids)
def lookup(self, ids) -> np.ndarray:
ids = np.asarray(ids, np.int64)
pos = np.minimum(np.searchsorted(self._sorted, ids), len(self.ids) - 1)
return np.where(self._sorted[pos] == ids, self._order[pos], -1)
def load(release_dir, device: str = "cpu"):
"""(model in eval mode, leaf vocab, edge vocab, config) from a release directory."""
from safetensors.torch import load_file
d = Path(release_dir)
cfg = json.loads((d / "config.json").read_text())
vocab = json.loads((d / "vocab.json").read_text())
leaves, edges = Vocab(vocab["leaf_ids"]), Vocab(vocab["edge_ids"])
model = gn.NetGraph(len(leaves), len(edges), cfg["arch"])
model.load_state_dict(load_file(str(d / "model.safetensors")))
return model.to(device).eval(), leaves, edges, cfg
def to_graphs(states: list[dict], leaves: Vocab, edges: Vocab) -> gn.Graphs:
"""A batch of encoded states -> the network's input (as graph_server.py maps them)."""
parts = {k: [] for k in ("type", "row", "value", "child", "parent", "label")}
offsets, base = [0], 0
for s in states:
ids = np.asarray(s["indices"], np.int64)
types = gn.node_types(ids)
parts["type"].append(types.astype(np.int64))
parts["row"].append(np.where(types == gn.NodeType.LEAF, leaves.lookup(ids), -1))
parts["value"].append(np.asarray(s["values"], np.int64))
parts["child"].append(np.asarray(s["edge_child"], np.int64) + base)
parts["parent"].append(np.asarray(s["edge_parent"], np.int64) + base)
parts["label"].append(edges.lookup(s["edge_label"]) + 1)
base += len(ids)
offsets.append(base)
t = {k: torch.from_numpy(np.concatenate(v).astype(np.int64)) for k, v in parts.items()}
return gn.Graphs(t["type"], t["row"], t["value"], torch.tensor(offsets), t["child"], t["parent"], t["label"])
@torch.no_grad()
def evaluate(model, states: list[dict], leaves: Vocab, edges: Vocab) -> list[dict]:
"""Per state: priority and target (one score per node, -inf at leaves), use [no, yes], value, value_x."""
g = to_graphs(states, leaves, edges)
out = model(g)
res = []
for i in range(len(states)):
a, b = int(g.node_offsets[i]), int(g.node_offsets[i + 1])
res.append({"priority": out.priority[a:b].numpy(), "target": out.target[a:b].numpy(),
"use": out.use[i].numpy(), "value": float(out.value[i]), "value_x": float(out.value_x[i])})
return res
def option_logits(res: dict, graph_type: int, options: list[list[int]]) -> np.ndarray:
"""One logit per legal option. An option is a list of node indices (one ability node per copy of a
card for a play, Pass's node, a target's node, Stop Choosing's node; "attack with X?" is a target
choice between Stop Choosing and the defending player). Its logit is the log-sum-exp of its nodes'
scores under the head the decision reads: priority at PRIORITY, target otherwise, and the use head's
[no, yes] at a CHOOSE_USE question that isn't an attack."""
if graph_type == CHOOSE_USE:
return np.asarray(res["use"][:len(options)], np.float64)
scores = res["priority"] if graph_type == PRIORITY else res["target"]
out = []
for nodes in options:
x = scores[np.asarray(nodes, np.int64)].astype(np.float64)
m = x.max()
out.append(m + np.log(np.exp(x - m).sum()) if np.isfinite(m) else -np.inf)
return np.asarray(out)
def read_goldens(path) -> list[dict]:
with gzip.open(path, "rt") as f:
return [json.loads(line) for line in f]
def check(release_dir, batch: int = 16) -> dict:
"""Run the release's goldens through this loader and report the largest differences."""
d = Path(release_dir)
model, leaves, edges, _ = load(d)
goldens = read_goldens(d / "goldens.jsonl.gz")
worst = {"option_logits": 0.0, "value_x": 0.0, "use": 0.0}
argmax_agree = 0
for a in range(0, len(goldens), batch):
chunk = goldens[a:a + batch]
for g, r in zip(chunk, evaluate(model, [g["state"] for g in chunk], leaves, edges)):
want = g["expected"]
got = option_logits(r, g["graph_type"], g["options"])
worst["option_logits"] = max(worst["option_logits"], float(np.max(np.abs(got - want["option_logits"]))))
worst["value_x"] = max(worst["value_x"], abs(r["value_x"] - want["value_x"]))
worst["use"] = max(worst["use"], float(np.max(np.abs(r["use"] - want["use"]))))
argmax_agree += int(np.argmax(got) == np.argmax(want["option_logits"]))
return {"states": len(goldens), "max_abs_diff": worst, "argmax_agree": argmax_agree}
if __name__ == "__main__":
if len(sys.argv) != 3 or sys.argv[1] != "check":
sys.exit(__doc__)
print(json.dumps(check(sys.argv[2]), indent=1))
|