checkpoint
This commit is contained in:
commit
d0deefd8cf
35 changed files with 2543 additions and 0 deletions
156
src/general_bots_training/network.py
Normal file
156
src/general_bots_training/network.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""Policy-value network and observation encoding for Generals.io PPO."""
|
||||
|
||||
import equinox as eqx
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import jax.random as jrandom
|
||||
|
||||
|
||||
def obs_to_tensor(obs) -> jnp.ndarray:
|
||||
"""Encode an Observation into a (C, H, W) float32 tensor for the network.
|
||||
|
||||
Armies and army-counts are log-normalized; land counts are divided by the
|
||||
number of cells; scalar values are broadcast to spatial planes so a plain
|
||||
conv stack can consume them. Grid-size agnostic.
|
||||
"""
|
||||
H, W = obs.armies.shape
|
||||
armies = jnp.log1p(obs.armies.astype(jnp.float32)) / jnp.log(50.0)
|
||||
|
||||
def bcast(scalar):
|
||||
return jnp.broadcast_to(scalar.astype(jnp.float32), (H, W))
|
||||
|
||||
own_land = bcast(obs.owned_land_count / (H * W))
|
||||
own_army = bcast(jnp.log1p(obs.owned_army_count.astype(jnp.float32)) / jnp.log(50.0))
|
||||
opp_land = bcast(obs.opponent_land_count / (H * W))
|
||||
opp_army = bcast(jnp.log1p(obs.opponent_army_count.astype(jnp.float32)) / jnp.log(50.0))
|
||||
timestep = bcast(jnp.log1p(obs.timestep.astype(jnp.float32)) / jnp.log(1201.0))
|
||||
|
||||
return jnp.stack(
|
||||
[
|
||||
armies,
|
||||
obs.generals.astype(jnp.float32),
|
||||
obs.castles.astype(jnp.float32),
|
||||
obs.mountains.astype(jnp.float32),
|
||||
obs.neutral_cells.astype(jnp.float32),
|
||||
obs.owned_cells.astype(jnp.float32),
|
||||
obs.opponent_cells.astype(jnp.float32),
|
||||
obs.fog_cells.astype(jnp.float32),
|
||||
obs.structures_in_fog.astype(jnp.float32),
|
||||
own_land,
|
||||
own_army,
|
||||
opp_land,
|
||||
opp_army,
|
||||
timestep,
|
||||
],
|
||||
axis=0,
|
||||
)
|
||||
|
||||
|
||||
class PolicyValueNetwork(eqx.Module):
|
||||
"""Conv policy-value network.
|
||||
|
||||
Action layout: 4 full-move directions and 4 half-move (split) directions
|
||||
per source cell, plus one global pass action. Invalid moves are masked to
|
||||
-1e9; pass is always available.
|
||||
|
||||
The value head uses global average pooling over the spatial grid, so the
|
||||
network works for any board size without reshaping linear layers.
|
||||
"""
|
||||
|
||||
conv1: eqx.nn.Conv2d
|
||||
conv2: eqx.nn.Conv2d
|
||||
conv3: eqx.nn.Conv2d
|
||||
conv4: eqx.nn.Conv2d
|
||||
policy_conv: eqx.nn.Conv2d
|
||||
value_conv: eqx.nn.Conv2d
|
||||
value_linear1: eqx.nn.Linear
|
||||
value_linear2: eqx.nn.Linear
|
||||
|
||||
def __init__(self, key, in_channels: int = 14, channels=(32, 32, 32, 16)):
|
||||
keys = jrandom.split(key, 8)
|
||||
self.conv1 = eqx.nn.Conv2d(in_channels, channels[0], kernel_size=3, padding=1, key=keys[0])
|
||||
self.conv2 = eqx.nn.Conv2d(channels[0], channels[1], kernel_size=3, padding=1, key=keys[1])
|
||||
self.conv3 = eqx.nn.Conv2d(channels[1], channels[2], kernel_size=3, padding=1, key=keys[2])
|
||||
self.conv4 = eqx.nn.Conv2d(channels[2], channels[3], kernel_size=3, padding=1, key=keys[3])
|
||||
# 9 = 4 dirs (full) + 4 dirs (half) + 1 pass
|
||||
self.policy_conv = eqx.nn.Conv2d(channels[3], 9, kernel_size=1, key=keys[4])
|
||||
self.value_conv = eqx.nn.Conv2d(channels[3], 4, kernel_size=1, key=keys[5])
|
||||
self.value_linear1 = eqx.nn.Linear(4, 64, key=keys[6])
|
||||
self.value_linear2 = eqx.nn.Linear(64, 1, key=keys[7])
|
||||
|
||||
def _features(self, obs):
|
||||
x = jax.nn.relu(self.conv1(obs))
|
||||
x = jax.nn.relu(self.conv2(x))
|
||||
x = jax.nn.relu(self.conv3(x))
|
||||
x = jax.nn.relu(self.conv4(x))
|
||||
return x
|
||||
|
||||
def _value_from_features(self, feat):
|
||||
v = jax.nn.relu(self.value_conv(feat)) # (4, H, W)
|
||||
v = v.mean(axis=(1, 2)) # (4,) global average pool
|
||||
v = jax.nn.relu(self.value_linear1(v))
|
||||
return self.value_linear2(v)[0]
|
||||
|
||||
def value(self, obs):
|
||||
return self._value_from_features(self._features(obs))
|
||||
|
||||
def policy_value(self, obs, mask):
|
||||
"""Return deterministic masked policy logits and the critic value."""
|
||||
features = self._features(obs)
|
||||
return self._policy_logits(features, mask), self._value_from_features(features)
|
||||
|
||||
def _policy_logits(self, feat, mask):
|
||||
"""Return 8*H*W move logits plus one global pass logit."""
|
||||
logits = self.policy_conv(feat) # (9, H, W)
|
||||
mask_t = jnp.transpose(mask, (2, 0, 1)) # (4, H, W)
|
||||
penalty = (1.0 - mask_t) * -1e9
|
||||
move_penalty = jnp.concatenate([penalty, penalty], axis=0)
|
||||
move_logits = (logits[:8] + move_penalty).reshape(-1)
|
||||
pass_logit = jnp.mean(logits[8])[None]
|
||||
return jnp.concatenate([move_logits, pass_logit])
|
||||
|
||||
def __call__(self, obs, mask, key, action=None):
|
||||
"""
|
||||
Args:
|
||||
obs: (C, H, W) tensor.
|
||||
mask: (H, W, 4) valid-move mask.
|
||||
key: PRNG key (only used when action is None).
|
||||
action: if given, evaluate logprob of this [pass,row,col,dir,split];
|
||||
otherwise sample an action.
|
||||
|
||||
Returns:
|
||||
(action, value, logprob, entropy)
|
||||
"""
|
||||
logits, value = self.policy_value(obs, mask)
|
||||
|
||||
H, W = mask.shape[:2]
|
||||
cells = H * W
|
||||
|
||||
if action is None:
|
||||
idx = jrandom.categorical(key, logits)
|
||||
else:
|
||||
is_pass, row, col, direction, is_half = action
|
||||
encoded_dir = jnp.where(is_half > 0, direction + 4, direction)
|
||||
move_idx = encoded_dir * cells + row * W + col
|
||||
idx = jnp.where(is_pass > 0, 8 * cells, move_idx)
|
||||
|
||||
log_probs = jax.nn.log_softmax(logits)
|
||||
logprob = log_probs[idx]
|
||||
probs = jax.nn.softmax(logits)
|
||||
entropy = -jnp.sum(probs * log_probs)
|
||||
|
||||
if action is None:
|
||||
is_pass = idx == 8 * cells
|
||||
move_idx = jnp.minimum(idx, 8 * cells - 1)
|
||||
direction = move_idx // cells
|
||||
position = move_idx % cells
|
||||
row = jnp.where(is_pass, 0, position // W)
|
||||
col = jnp.where(is_pass, 0, position % W)
|
||||
is_half = (~is_pass) & (direction >= 4)
|
||||
actual_dir = jnp.where(is_pass, 0, jnp.where(is_half, direction - 4, direction))
|
||||
action = jnp.array(
|
||||
[is_pass.astype(jnp.int32), row, col, actual_dir, is_half.astype(jnp.int32)],
|
||||
dtype=jnp.int32,
|
||||
)
|
||||
|
||||
return action, value, logprob, entropy
|
||||
Loading…
Reference in a new issue