| """Adapter exposing the trained planner as a ``stable_worldmodel`` solver. |
| |
| Mirrors :class:`lejepa_control.solver.ControllerSolver` so the planner drops |
| into ``WorldModelPolicy`` wherever the baseline controller or ``CEMSolver`` |
| goes. Same env, same wrappers, same preprocessing, same receding-horizon |
| execution — the only thing that differs is how the plan is produced, which is |
| what makes the baseline comparison a fair swap. |
| """ |
|
|
| import gymnasium as gym |
| import torch |
|
|
| from lejepa_control_2.planner import RecursivePlanner |
|
|
|
|
| class PlannerSolver: |
| """Runs the three-loop recursion instead of CEM's sampling loop. |
| |
| Args: |
| model: The frozen ``LeWM``. |
| planner: A trained :class:`RecursivePlanner`. |
| device: Device to plan on. |
| cycles / inner: Override ``T`` / ``n`` at eval time. This is the |
| anytime-inference sweep (ablation 1): the recursion is weight-tied, |
| so the same checkpoint can be run at any depth. ``None`` keeps the |
| trained values. |
| """ |
|
|
| def __init__(self, model, planner, device='cuda', cycles=None, inner=None): |
| self.model = model |
| self.planner = planner.to(device).eval() |
| self.device = device |
| self.cycles = cycles |
| self.inner = inner |
| self._n_envs = 1 |
| self._horizon = planner.horizon |
| self._action_dim = planner.action_dim |
| self._action_block = planner.frameskip |
|
|
| def configure(self, *, action_space: gym.Space, n_envs: int, config) -> None: |
| self._n_envs = n_envs |
| self._horizon = config.horizon |
| self._action_block = config.action_block |
| self._action_dim = int(action_space.shape[-1]) |
|
|
| assert self._action_block == self.planner.frameskip, ( |
| f'action_block {self._action_block} != planner frameskip ' |
| f'{self.planner.frameskip}' |
| ) |
|
|
| @property |
| def action_dim(self) -> int: |
| return self._action_dim * self._action_block |
|
|
| @property |
| def n_envs(self) -> int: |
| return self._n_envs |
|
|
| @property |
| def horizon(self) -> int: |
| return self._horizon |
|
|
| def _encode(self, pixels): |
| with torch.no_grad(): |
| return self.model.encode({'pixels': pixels.to(self.device)})['emb'] |
|
|
| @torch.no_grad() |
| def solve(self, info_dict: dict, init_action=None) -> dict: |
| """Plan for every env in ``info_dict``; returns ``{'actions': ...}``.""" |
| pixels = info_dict['pixels'] |
| if pixels.ndim == 4: |
| pixels = pixels.unsqueeze(1) |
| B, T = pixels.shape[:2] |
|
|
| ctx = self._encode(pixels) |
|
|
| goal = info_dict['goal'] |
| if goal.ndim == 4: |
| goal = goal.unsqueeze(1) |
| goal_emb = self._encode(goal)[:, -1] |
|
|
| num_context = self.planner.num_context |
| if T < num_context: |
| pad = ctx[:, :1].expand(B, num_context - T, -1) |
| ctx = torch.cat([pad, ctx], dim=1) |
| elif T > num_context: |
| ctx = ctx[:, -num_context:] |
|
|
| block_dim = self._action_block * self._action_dim |
| past = info_dict.get('action_history') |
| if past is None: |
| past = ctx.new_zeros(B, num_context - 1, block_dim) |
| else: |
| past = past.to(self.device).float() |
| if past.size(1) < num_context - 1: |
| pad = past.new_zeros( |
| B, num_context - 1 - past.size(1), block_dim |
| ) |
| past = torch.cat([pad, past], dim=1) |
| else: |
| past = past[:, -(num_context - 1) :] |
| past = torch.nan_to_num(past, 0.0) |
|
|
| out = self.planner( |
| self.model, |
| ctx, |
| past, |
| goal_emb, |
| horizon=self._horizon, |
| cycles=self.cycles, |
| inner=self.inner, |
| ) |
|
|
| actions = out['blocks'] |
| terminal = out['distances'][:, -1] |
|
|
| result = { |
| 'actions': actions.detach().float().cpu(), |
| 'costs': terminal.detach().float().cpu(), |
| 'terminal_distance': terminal.mean().item(), |
| } |
| if out['cycle_distances'] is not None: |
| |
| |
| result['per_cycle'] = ( |
| out['cycle_distances'].mean(dim=(0, 1)).detach().cpu().tolist() |
| ) |
| return result |
|
|
| __call__ = solve |
|
|
|
|
| def load_planner(path, device='cuda', cycles=None, inner=None, horizon=None): |
| """Rebuild a planner from a training checkpoint.""" |
| ckpt = torch.load(path, map_location=device, weights_only=False) |
| saved = ckpt['args'] |
| a_mean = torch.tensor(ckpt['action_mean']) |
| a_std = torch.tensor(ckpt['action_std']) |
|
|
| planner = RecursivePlanner( |
| latent_dim=ckpt['latent_dim'], |
| width=saved['width'], |
| hidden=saved['hidden'], |
| inner=saved['inner'], |
| cycles=saved['cycles'], |
| horizon=ckpt.get('horizon', saved.get('horizon', 5)), |
| use_feedback=saved['use_feedback'], |
| warm_start=saved['warm_start'], |
| lambda_z=saved['lambda_z'], |
| learn_lambda_z=saved['learn_lambda_z'], |
| detach_schedule=saved['detach_schedule'], |
| action_center=(-a_mean / a_std), |
| action_scale=(1.0 / a_std), |
| ) |
| planner.load_state_dict(ckpt['state_dict']) |
| planner.to(device).eval() |
| if cycles is not None: |
| planner.cycles = cycles |
| if inner is not None: |
| planner.inner = inner |
| if horizon is not None: |
| planner.horizon = horizon |
| return planner, ckpt |
|
|