| """Spatial-only autoencoder (v3): compress only what is actually big. |
| |
| Lesson from v2: squeezing tiny exact state (pairwise alliances, player |
| scalars) through the latent and reconstructing it lossily is manufactured |
| difficulty — three training runs got alliance precision to 0.55 when the |
| policy could just read the bit. The bypass split: |
| |
| AE compresses (high-dimensional, spatial): |
| - tile ownership grid + terrain + fallout |
| - static structure planes (city/port/defense post/silo/SAM/factory) |
| |
| Bypass straight to the policy (small, exact): |
| - pairwise diplomacy (alliances, embargoes, expiry, pending requests) |
| - per-player scalars (troops, gold, tiles, alive, ...) |
| - transient units (nukes/transports/warships) as (owner, pos, target) |
| - attack aggregates, globals, legality masks |
| |
| Latent: z_grid (B, latent_c, H/16, W/16). No global vector: the policy can |
| pool spatially itself, and per-player/global state arrives via bypass. |
| |
| v3.1 additions (all off by default so old v3 checkpoints load unchanged): |
| - terrain_cond: the decoder consumes the 2 STATIC terrain planes (land, |
| magnitude) as free side-information at every scale, so the latent only |
| has to encode ownership relative to terrain. Fallout is dynamic state |
| and is never fed to the decoder. |
| - upsample_decoder: nearest-upsample + 3x3 conv stages (no checkerboard) |
| plus a full-resolution 3x3 refinement block before the classifier. |
| - latent_down: 8 or 16; latent grid at 1/8 or 1/16 resolution. |
| """ |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| from ae.units import STATIC_CLASSES |
|
|
| MAX_SLOTS = 128 |
| OWNER_EMB_DIM = 8 |
| TERRAIN_CHANNELS = 3 |
| NUM_STATIC = len(STATIC_CLASSES) |
|
|
| |
| STATIC_CLASS_WEIGHTS = [1.0, 1.0, 1.0, 4.0, 4.0, 2.0] |
|
|
|
|
| def conv_block(c_in: int, c_out: int, stride: int) -> nn.Sequential: |
| return nn.Sequential( |
| nn.Conv2d(c_in, c_out, kernel_size=3, stride=stride, padding=1), |
| nn.GroupNorm(8, c_out), |
| nn.SiLU(), |
| ) |
|
|
|
|
| def deconv_block(c_in: int, c_out: int) -> nn.Sequential: |
| return nn.Sequential( |
| nn.ConvTranspose2d(c_in, c_out, kernel_size=4, stride=2, padding=1), |
| nn.GroupNorm(8, c_out), |
| nn.SiLU(), |
| ) |
|
|
|
|
| class UpsampleBlock(nn.Module): |
| """Nearest 2x upsample + 3x3 conv (no ConvTranspose checkerboard). |
| |
| Optionally concatenates static terrain planes (at the post-upsample |
| resolution) before the conv. |
| """ |
|
|
| def __init__(self, c_in: int, c_out: int, extra_c: int = 0): |
| super().__init__() |
| self.conv = conv_block(c_in + extra_c, c_out, stride=1) |
|
|
| def forward(self, x: torch.Tensor, extra: torch.Tensor | None = None): |
| x = F.interpolate(x, scale_factor=2, mode="nearest") |
| if extra is not None: |
| x = torch.cat([x, extra], dim=1) |
| return self.conv(x) |
|
|
|
|
| STATIC_TERRAIN_C = 2 |
|
|
|
|
| class SpatialAE(nn.Module): |
| """Defaults preserve the original v3 architecture (old checkpoints load |
| with strict state_dict matching). v3.1 runs set terrain_cond=True and |
| upsample_decoder=True (and optionally latent_down=8).""" |
|
|
| def __init__( |
| self, |
| latent_c: int = 64, |
| terrain_cond: bool = False, |
| upsample_decoder: bool = False, |
| latent_down: int = 16, |
| ): |
| super().__init__() |
| if latent_down not in (8, 16): |
| raise ValueError(f"latent_down must be 8 or 16, got {latent_down}") |
| if latent_down == 8 and not upsample_decoder: |
| raise ValueError("latent_down=8 requires the v3.1 upsample decoder") |
| if terrain_cond and not upsample_decoder: |
| raise ValueError("terrain_cond requires the v3.1 upsample decoder") |
| self.latent_c = latent_c |
| self.terrain_cond = terrain_cond |
| self.upsample_decoder = upsample_decoder |
| self.latent_down = latent_down |
| self.owner_emb = nn.Embedding(MAX_SLOTS, OWNER_EMB_DIM) |
|
|
| stem = [ |
| conv_block(OWNER_EMB_DIM + TERRAIN_CHANNELS, 32, stride=1), |
| conv_block(32, 64, stride=2), |
| conv_block(64, 96, stride=2), |
| conv_block(96, 128, stride=2), |
| ] |
| if latent_down == 16: |
| stem.append(conv_block(128, 128, stride=2)) |
| self.enc_stem = nn.Sequential(*stem) |
| self.enc_fuse = nn.Sequential( |
| conv_block(128 + NUM_STATIC, 128, stride=1), |
| nn.Conv2d(128, latent_c, kernel_size=1), |
| ) |
|
|
| cond_c = STATIC_TERRAIN_C if terrain_cond else 0 |
| self.dec_in = conv_block(latent_c + cond_c, 128, stride=1) |
| if upsample_decoder: |
| chans = [128, 128, 96, 64, 32] if latent_down == 16 else [128, 96, 64, 32] |
| self.dec_up = nn.ModuleList( |
| UpsampleBlock(chans[i], chans[i + 1], extra_c=cond_c) |
| for i in range(len(chans) - 1) |
| ) |
| self.dec_refine = conv_block(32 + cond_c, 32, stride=1) |
| self.dec_out = nn.Conv2d(32, MAX_SLOTS, kernel_size=1) |
| else: |
| self.dec_tiles = nn.Sequential( |
| deconv_block(128, 128), |
| deconv_block(128, 96), |
| deconv_block(96, 64), |
| deconv_block(64, 32), |
| nn.Conv2d(32, MAX_SLOTS, kernel_size=1), |
| ) |
| |
| self.dec_units = nn.Conv2d(128, NUM_STATIC, kernel_size=1) |
|
|
| def encode( |
| self, |
| owners: torch.Tensor, |
| terrain: torch.Tensor, |
| static_planes: torch.Tensor, |
| ) -> torch.Tensor: |
| emb = self.owner_emb(owners).permute(0, 3, 1, 2) |
| g = self.enc_stem(torch.cat([emb, terrain], dim=1)) |
| return self.enc_fuse(torch.cat([g, static_planes], dim=1)) |
|
|
| def decode( |
| self, |
| z_grid: torch.Tensor, |
| terrain: torch.Tensor | None = None, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| if not self.terrain_cond: |
| h = self.dec_in(z_grid) |
| if self.upsample_decoder: |
| x = h |
| for up in self.dec_up: |
| x = up(x) |
| return self.dec_out(self.dec_refine(x)), self.dec_units(h) |
| return self.dec_tiles(h), self.dec_units(h) |
|
|
| if terrain is None: |
| raise ValueError("terrain_cond model needs terrain in decode()") |
| |
| static_t = terrain[:, :STATIC_TERRAIN_C] |
| pyramid = {1: static_t} |
| down = 2 |
| while down <= self.latent_down: |
| pyramid[down] = F.avg_pool2d(static_t, kernel_size=down) |
| down *= 2 |
|
|
| h = self.dec_in(torch.cat([z_grid, pyramid[self.latent_down]], dim=1)) |
| x = h |
| scale = self.latent_down |
| for up in self.dec_up: |
| scale //= 2 |
| x = up(x, pyramid[scale]) |
| x = self.dec_refine(torch.cat([x, pyramid[1]], dim=1)) |
| return self.dec_out(x), self.dec_units(h) |
|
|
| def forward(self, owners, terrain, static_planes): |
| z_grid = self.encode(owners, terrain, static_planes) |
| tile_logits, unit_logits = self.decode( |
| z_grid, terrain if self.terrain_cond else None |
| ) |
| return tile_logits, unit_logits, z_grid |
|
|