File size: 17,659 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 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 | """MageZero's graph network (Will Wroble, WillWroble/MageZero branch graph-encoder), for DraftZero's
imitation training and search (docs/022).
The network is MageZero's `NetGraph` (src/magezero/model.py at de225045, 2026-10-04), copied here
rather than imported: that branch's modules import each other as top-level names (`from vocab import
...`), and DraftZero pins MageZero v0.2 for the flat networks. The code below is the upstream model with
four changes, none of which changes what a default network computes:
* its sizes are constructor arguments (`arch`), so a sweep can vary them; the defaults are
upstream's constants, and a default network's state dict is upstream's, so it loads in
MageZero's own graph server (`load_checkpoint` there);
* `leaf_dropout`: drop a share of leaf edges while training, the graph analogue of the flat
networks' token dropout (0 = upstream);
* `local_depth`: LocalLayers per type within each pass (1 = upstream). Each extra layer re-attends
from the node's updated embedding to the same children; extra layers live in `local_extra`, so a
depth-1 network's state dict is upstream's;
* `forward` also returns the value head's input to its Tanh (`value_x`), which the imitation
trainer's cross-entropy needs, as supervised.value_logit does for the flat networks.
Input: the graph encoder's state graph (graph_tables), nodes typed ROOT, PLAYER, ZONE, STACK_OBJECT,
PERMANENT, CARD, ABILITY or LEAF, edges child -> parent with a label:
embeddings: typed node = type embedding; leaf = leaf vocab row + numeric value bucket
local layers: `passes` bottom-up passes, each `local_depth` LocalLayers per type, run in order
ABILITY -> CARD -> PERMANENT -> STACK_OBJECT -> ZONE -> PLAYER -> ROOT
every node attends over [itself, children + edge label embedding]
global layers: TransformerEncoder over [CLS, the state's internal nodes]
βββ MLP per node priority (read at ABILITY nodes: a play is its ability's node; Pass has one)
βββ MLP per node target (read at CARD / PERMANENT / STACK_OBJECT / PLAYER nodes; Stop Choosing is a CARD)
βββ MLP on CLS choose_use (false, true)
βββ MLP on CLS value (tanh, -1..1)
"""
from __future__ import annotations
import math
from enum import IntEnum
from typing import NamedTuple
import numpy as np
import torch
import torch.nn.functional as F
from torch import nn
UPSTREAM = "WillWroble/MageZero@de225045c82a2ddc107b99ac833e21ab6d566279 (graph-encoder)"
GLOBAL_MAX = 2 ** 31 - 1
# upstream's constants (model.py): the default architecture
ARCH_DEFAULT = {"type": "graph", "d_model": 512, "heads": 4, "ff": 1024, "dropout": 0.25, "passes": 2,
"global_layers": 2, "head_hidden": 256, "leaf_dropout": 0.0, "local_depth": 1}
# numeric value buckets: exact -5..20 (P/T, counters, mana, costs, small counts), then 21-25, 26-30,
# 31-40, 41-60, 61+ (life totals, library and zone sizes); everything below -5 is one bucket
VALUE_BOUNDS = list(range(-5, 22)) + [26, 31, 41, 61]
class NodeType(IntEnum):
"""Java FeatureGraph.Node.Type, plus the root."""
ROOT = 0
PLAYER = 1
ZONE = 2
STACK_OBJECT = 3
PERMANENT = 4
CARD = 5
ABILITY = 6
LEAF = 7
STAGES = (NodeType.ABILITY, NodeType.CARD, NodeType.PERMANENT, NodeType.STACK_OBJECT,
NodeType.ZONE, NodeType.PLAYER, NodeType.ROOT)
PRIORITY_TYPES = (NodeType.ABILITY,)
TARGET_TYPES = (NodeType.CARD, NodeType.PERMANENT, NodeType.STACK_OBJECT, NodeType.PLAYER)
# ------------------------------------------------------------------------------------------------
# ids: the Java encoder's hash (upstream vocab.py), to tell typed nodes from leaves
# ------------------------------------------------------------------------------------------------
_MASK = (1 << 64) - 1
_GOLDEN = 0x9E3779B185EBCA87
def _mix64(z: int) -> int:
z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & _MASK
z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & _MASK
return z ^ (z >> 31)
def feature_id(name: str) -> int:
"""The id XMage stores for a leaf name or edge label: StateEncoder.indexFor(hash64(name))."""
data = name.encode("utf-8")
h = _mix64(_GOLDEN ^ (len(data) * _GOLDEN & _MASK))
full = len(data) // 8 * 8
for i in range(0, full, 8):
h ^= _mix64(int.from_bytes(data[i:i + 8], "little"))
h = (((h << 27 | h >> 37) & _MASK) * _GOLDEN + 0x165667B19E3779F9) & _MASK
h ^= _mix64(int.from_bytes(data[full:], "little"))
h = ((h ^ (h >> 33)) * 0xFF51AFD7ED558CCD) & _MASK
h = ((h ^ (h >> 33)) * 0xC4CEB9FE1A85EC53) & _MASK
h ^= h >> 33
if h == 1 << 63: # Long.MIN_VALUE: Java's -h overflows and the remainder keeps the sign
return -((1 << 63) % GLOBAL_MAX)
return (h if h < 1 << 63 else (1 << 64) - h) % GLOBAL_MAX
# a typed node's id is the hash of its type name and the root's id is 0; every other node is a leaf
TYPE_IDS = {0: NodeType.ROOT}
for _t in NodeType:
if _t not in (NodeType.ROOT, NodeType.LEAF):
TYPE_IDS[feature_id(_t.name)] = _t
def node_types(ids: np.ndarray) -> np.ndarray:
types = np.full(ids.shape, NodeType.LEAF, dtype=np.int8)
for type_id, t in TYPE_IDS.items():
types[ids == type_id] = t
return types
def full_arch(arch: dict | None) -> dict:
a = {**ARCH_DEFAULT, **(arch or {})}
unknown = set(a) - set(ARCH_DEFAULT)
if unknown:
raise ValueError(f"unknown graph arch keys {sorted(unknown)} (known: {sorted(ARCH_DEFAULT)})")
if a["type"] != "graph":
raise ValueError(f"arch.type {a['type']!r}: graph_net builds only 'graph'")
if a["d_model"] % a["heads"]:
raise ValueError(f"d_model {a['d_model']} is not a multiple of heads {a['heads']}")
return a
def upstream_loadable(arch: dict) -> bool:
"""A network MageZero's own graph server loads as it is (upstream's constants)."""
a = full_arch(arch)
return all(a[k] == ARCH_DEFAULT[k] for k in ("d_model", "heads", "ff", "passes", "global_layers", "head_hidden",
"local_depth"))
# ------------------------------------------------------------------------------------------------
# the network
# ------------------------------------------------------------------------------------------------
class Graphs(NamedTuple):
"""A batch of state graphs, concatenated: the network's input."""
node_type: torch.Tensor # [N] NodeType
node_row: torch.Tensor # [N] leaf vocab row; -1 for typed nodes and leaves outside the vocab
node_value: torch.Tensor # [N] numeric value (0 for non-numeric)
node_offsets: torch.Tensor # [B+1] first node of each state
edge_child: torch.Tensor # [E] node index (batch-wide)
edge_parent: torch.Tensor # [E] node index (batch-wide)
edge_label: torch.Tensor # [E] edge vocab row + 1; 0 for labels outside the vocab
def to(self, device):
return Graphs(*(t.to(device, non_blocking=True) for t in self))
class Out(NamedTuple):
priority: torch.Tensor # [N] per-node priority score (-inf at leaves)
target: torch.Tensor # [N] per-node target score (-inf at leaves)
use: torch.Tensor # [B, 2] choose_use logits (false, true)
value: torch.Tensor # [B] tanh(value_x)
value_x: torch.Tensor # [B] the value head's input to its Tanh
def graph_index(offsets: torch.Tensor) -> torch.Tensor:
"""State of each node, from a batch's node offsets."""
return torch.repeat_interleave(torch.arange(offsets.numel() - 1, device=offsets.device), offsets.diff())
def segment_logsumexp(x: torch.Tensor, seg: torch.Tensor, n: int) -> torch.Tensor:
"""Log-sum-exp over the rows of x that share an index in seg (values 0..n-1); returns n rows
(-inf for an index with no row)."""
index = seg.view(-1, *([1] * (x.dim() - 1))).expand_as(x)
with torch.no_grad(): # the max only stabilizes; the result and its gradient don't depend on it
m = x.new_full((n, *x.shape[1:]), float("-inf")).scatter_reduce(0, index, x, "amax")
m = torch.where(torch.isfinite(m), m, torch.zeros_like(m))
return m + x.new_zeros((n, *x.shape[1:])).index_add(0, seg, (x - m[seg]).exp()).log()
class LocalLayer(nn.TransformerEncoderLayer):
"""A pre-norm transformer layer run over each node's [node, children...] sequence, keeping only
the node's output token (upstream's LocalLayer)."""
def __init__(self, d_model: int, heads: int, ff: int, dropout: float):
super().__init__(d_model, heads, ff, dropout, activation="gelu", batch_first=True, norm_first=True)
def forward(self, node: torch.Tensor, child: torch.Tensor, seg: torch.Tensor) -> torch.Tensor:
"""node: [S, d] this stage's nodes; child: [E, d] child tokens (child embedding + edge label
embedding); seg: [E] position in `node` of each child's parent. Returns the nodes' new embeddings."""
S, d = node.shape
H, dh = self.self_attn.num_heads, self.self_attn.head_dim
w, b = self.self_attn.in_proj_weight, self.self_attn.in_proj_bias
q, k, v = F.linear(self.norm1(node), w, b).chunk(3, dim=-1)
k_child, v_child = F.linear(self.norm1(child), w[d:], b[d:]).chunk(2, dim=-1)
seg = torch.cat([torch.arange(S, device=seg.device), seg]) # each node also attends to itself
k = torch.cat([k, k_child]).view(-1, H, dh)
v = torch.cat([v, v_child]).view(-1, H, dh)
score = (q.reshape(S, H, dh)[seg] * k).sum(-1).float() / math.sqrt(dh) # [S+E, H]
attn = (score - segment_logsumexp(score, seg, S)[seg]).exp()
attn = F.dropout(attn, self.self_attn.dropout, self.training)
out = torch.zeros(S, H, dh, device=node.device).index_add_(
0, seg, (attn.to(v.dtype).unsqueeze(-1) * v).float())
node = node + self.dropout1(self.self_attn.out_proj(out.view(S, d).to(node.dtype)))
return node + self._ff_block(self.norm2(node))
def _mlp(d: int, hidden: int, out_dim: int, *tail: nn.Module) -> nn.Sequential:
return nn.Sequential(nn.Linear(d, hidden), nn.ReLU(), nn.Linear(hidden, out_dim), *tail)
class NetGraph(nn.Module):
def __init__(self, num_embeddings: int = 0, num_edge_labels: int = 0, arch: dict | None = None):
super().__init__()
a = self.arch = full_arch(arch)
d = a["d_model"]
self.embedding = nn.Embedding(num_embeddings, d) # leaves
self.edge_embedding = nn.Embedding(num_edge_labels + 1, d, padding_idx=0) # row 0: unknown label
self.type_embedding = nn.Embedding(len(NodeType), d)
self.value_embedding = nn.Embedding(len(VALUE_BOUNDS) + 1, d)
self.register_buffer("value_bounds", torch.tensor(VALUE_BOUNDS), persistent=False)
self.local_layers = nn.ModuleList(
nn.ModuleDict({t.name: LocalLayer(d, a["heads"], a["ff"], a["dropout"]) for t in STAGES})
for _ in range(a["passes"]))
if a["local_depth"] < 1:
raise ValueError(f"local_depth {a['local_depth']}: at least 1")
if a["local_depth"] > 1: # the layers after each type's first, per pass (absent at depth 1: upstream's layout)
self.local_extra = nn.ModuleList(
nn.ModuleDict({t.name: nn.ModuleList(LocalLayer(d, a["heads"], a["ff"], a["dropout"])
for _ in range(a["local_depth"] - 1)) for t in STAGES})
for _ in range(a["passes"]))
self.cls = nn.Parameter(torch.randn(d))
global_layer = nn.TransformerEncoderLayer(d, a["heads"], a["ff"], a["dropout"], activation="gelu",
batch_first=True, norm_first=True)
self.global_layers = nn.TransformerEncoder(global_layer, a["global_layers"], norm=nn.LayerNorm(d),
enable_nested_tensor=False)
h = a["head_hidden"]
self.priority_head = _mlp(d, h, 1)
self.target_head = _mlp(d, h, 1)
self.binary_head = _mlp(d, h, 2)
self.value_head = _mlp(d, h, 1, nn.Tanh())
def forward(self, g: Graphs) -> Out:
B, N = g.node_offsets.numel() - 1, g.node_type.numel()
# initial embeddings: typed nodes their type, leaves their name and numeric value
leaves = (g.node_row >= 0).nonzero().squeeze(1)
value = torch.bucketize(g.node_value[leaves], self.value_bounds, right=True)
h = self.type_embedding(g.node_type).index_add(
0, leaves, self.embedding(g.node_row[leaves]) + self.value_embedding(value))
# leaves outside the vocab have no embedding, so their edges are dropped (and, while
# training, a leaf_dropout share of the others)
child_leaf = g.node_type[g.edge_child] == NodeType.LEAF
keep = ~child_leaf | (g.node_row[g.edge_child] >= 0)
if self.training and self.arch["leaf_dropout"] > 0:
keep = keep & ~(child_leaf & (torch.rand(keep.shape, device=keep.device) < self.arch["leaf_dropout"]))
child, parent, label = g.edge_child[keep], g.edge_parent[keep], g.edge_label[keep]
# one stage per type: every node of that type, its children, and each child's parent position
parent_type = g.node_type[parent]
stages = []
for t in STAGES:
nodes = (g.node_type == t).nonzero().squeeze(1)
if len(nodes):
edges = (parent_type == t).nonzero().squeeze(1)
stages.append((t.name, nodes, child[edges], label[edges], torch.searchsorted(nodes, parent[edges])))
# local layers, bottom-up passes: children earlier in the order arrive updated in this pass,
# children of the same type or later in the order (attachments, targets, linked exile) from the last one
extra = getattr(self, "local_extra", None)
for p, layers in enumerate(self.local_layers):
for name, nodes, c, lbl, seg in stages:
kids = h[c] + self.edge_embedding(lbl)
x = layers[name](h[nodes], kids, seg).to(h.dtype)
for layer in (extra[p][name] if extra is not None else ()):
x = layer(x, kids, seg).to(h.dtype)
h = h.index_copy(0, nodes, x)
# global layers: self-attention over [CLS, internal nodes], one padded sequence per state
internal = (g.node_type != NodeType.LEAF).nonzero().squeeze(1)
gi = graph_index(g.node_offsets)[internal]
counts = torch.bincount(gi, minlength=B)
pos = torch.arange(len(internal), device=h.device) - (counts.cumsum(0) - counts)[gi] + 1
x = h.new_zeros(B, int(counts.max()) + 1, h.shape[1])
x[:, 0] = self.cls.to(h.dtype)
x[gi, pos] = h[internal]
pad = torch.arange(x.shape[1], device=h.device) > counts.unsqueeze(1)
x = self.global_layers(x, src_key_padding_mask=pad)
cls, z = x[:, 0], x[gi, pos]
def per_node(head):
return torch.full((N,), float("-inf"), device=h.device).index_copy(
0, internal, head(z).squeeze(-1).float())
vx = self.value_head[:-1](cls).squeeze(-1).float()
return Out(per_node(self.priority_head), per_node(self.target_head), self.binary_head(cls).float(),
torch.tanh(vx), vx)
def parameter_counts(model: NetGraph) -> dict:
out = {"total": 0}
for name, p in model.named_parameters():
part = name.split(".")[0]
out[part] = out.get(part, 0) + p.numel()
out["total"] += p.numel()
return out
# ------------------------------------------------------------------------------------------------
# batches: graph rows -> the network's input, and options -> logits
# ------------------------------------------------------------------------------------------------
def gather(ptr: np.ndarray, sel: np.ndarray, *arrays: np.ndarray) -> tuple:
"""The CSR slices of rows `sel` (ptr over `arrays`), concatenated, and their new ptr."""
lens = (ptr[sel + 1] - ptr[sel]).astype(np.int64)
new = np.zeros(len(sel) + 1, np.int64)
np.cumsum(lens, out=new[1:])
pos = np.repeat(ptr[sel] - new[:-1], lens) + np.arange(new[-1])
return (new,) + tuple(a[pos] for a in arrays)
class OptionBatch(NamedTuple):
"""Per option of a batch: its row, its nodes (batch-wide) and which node belongs to which option."""
opt_row: torch.Tensor # [O] row of each option
node: torch.Tensor # [K] batch-wide node index of each option node
node_opt: torch.Tensor # [K] option of each option node
n_opt: int
def to(self, device):
return OptionBatch(self.opt_row.to(device), self.node.to(device), self.node_opt.to(device), self.n_opt)
def option_logits(out: Out, ob: OptionBatch, graph_type: torch.Tensor, opt_index: torch.Tensor) -> torch.Tensor:
"""[O] logit of each option: log-sum-exp of its nodes' scores under the head its row's decision type
reads (priority at PRIORITY rows, target at CHOOSE_TARGET rows; two copies of a card are one option,
so their probabilities add), or the choose_use logit at CHOOSE_USE rows (options [no, yes]).
opt_index: [O] each option's position within its row."""
gt = graph_type[ob.opt_row[ob.node_opt]]
s = torch.where(gt == 0, out.priority[ob.node], out.target[ob.node])
lg = segment_logsumexp(s.float(), ob.node_opt, ob.n_opt)
is_use = graph_type[ob.opt_row] == 5
if is_use.any():
u = out.use.float()[ob.opt_row, opt_index.clamp(max=1)]
lg = torch.where(is_use, u, lg)
return lg
|