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))