| """ |
| The four learned pieces: an encoder (real state -> latent), a dynamics model |
| (latent + action -> predicted next latent + reward), a value head (latent -> |
| estimated cost-to-go), and a diagnostic decoder (latent -> reconstructed |
| state, used only to sanity-check the latent isn't collapsing -- never |
| consulted for a real decision at inference time). |
| |
| Config-driven: nothing here is hardcoded to Connect-4 beyond `state_dim`/ |
| `num_actions`, so this file would work for any domain with a fixed-length |
| tuple state and a fixed discrete action space. |
| """ |
| import torch |
| import torch.nn as nn |
|
|
|
|
| def mlp(dims, out_activation=None): |
| layers = [] |
| for i in range(len(dims) - 1): |
| layers.append(nn.Linear(dims[i], dims[i + 1])) |
| is_last = i == len(dims) - 2 |
| if not is_last: |
| layers.append(nn.ReLU()) |
| elif out_activation is not None: |
| layers.append(out_activation) |
| return nn.Sequential(*layers) |
|
|
|
|
| class Encoder(nn.Module): |
| def __init__(self, state_dim, latent_dim, hidden_dim=128): |
| super().__init__() |
| self.net = mlp([state_dim, hidden_dim, hidden_dim, latent_dim]) |
|
|
| def forward(self, state): |
| return self.net(state) |
|
|
|
|
| class DynamicsModel(nn.Module): |
| def __init__(self, latent_dim, num_actions, hidden_dim=128): |
| super().__init__() |
| self.num_actions = num_actions |
| self.trunk = mlp([latent_dim + num_actions, hidden_dim, hidden_dim]) |
| self.next_latent_head = nn.Linear(hidden_dim, latent_dim) |
| self.reward_head = nn.Linear(hidden_dim, 1) |
|
|
| def forward(self, z, action_idx): |
| action_onehot = nn.functional.one_hot(action_idx, self.num_actions).float() |
| h = self.trunk(torch.cat([z, action_onehot], dim=-1)) |
| next_z = self.next_latent_head(h) |
| reward = self.reward_head(h).squeeze(-1) |
| return next_z, reward |
|
|
|
|
| class ValueHead(nn.Module): |
| def __init__(self, latent_dim, hidden_dim=128): |
| super().__init__() |
| self.net = mlp([latent_dim, hidden_dim, hidden_dim, 1]) |
|
|
| def forward(self, z): |
| return self.net(z).squeeze(-1) |
|
|
|
|
| class Decoder(nn.Module): |
| def __init__(self, latent_dim, state_dim, hidden_dim=128): |
| super().__init__() |
| self.net = mlp([latent_dim, hidden_dim, hidden_dim, state_dim]) |
|
|
| def forward(self, z): |
| return self.net(z) |
|
|
|
|
| class WorldModel(nn.Module): |
| def __init__(self, state_dim, num_actions, latent_dim=64, hidden_dim=128): |
| super().__init__() |
| self.state_dim = state_dim |
| self.num_actions = num_actions |
| self.latent_dim = latent_dim |
| self.encoder = Encoder(state_dim, latent_dim, hidden_dim) |
| self.dynamics = DynamicsModel(latent_dim, num_actions, hidden_dim) |
| self.value = ValueHead(latent_dim, hidden_dim) |
| self.decoder = Decoder(latent_dim, state_dim, hidden_dim) |
| |
| |
| |
| |
| |
| self.register_buffer("value_target_mean", torch.tensor(0.0)) |
| self.register_buffer("value_target_std", torch.tensor(1.0)) |
|
|
| def encode(self, state): |
| return self.encoder(state) |
|
|
| def imagine_step(self, z, action_idx): |
| return self.dynamics(z, action_idx) |
|
|
| def evaluate(self, z): |
| """Always returns real-scale value estimates (remaining cost, same |
| units `imagine_step`'s predicted reward uses) -- the head internally |
| predicts a normalized target, denormalized here so no caller needs |
| to know normalization is happening.""" |
| raw = self.value(z) |
| return raw * self.value_target_std + self.value_target_mean |
|
|
| def reconstruct(self, z): |
| return self.decoder(z) |
|
|