File size: 10,785 Bytes
872cf4d | 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 | """Amortized iterative MPC controller over a frozen LeWM world model.
The controller holds one hidden token per action block and repeatedly refines
those tokens. Each refinement decodes the tokens to bounded action blocks,
rolls them through the *frozen* predictor, reads the consequences, and emits a
correction. Both transformers are shared across refinements, so K adds compute
and depth of reasoning but no parameters.
"""
import torch
from torch import nn
from lejepa_control.rollout import (
goal_distance,
rollout_contexts,
rollout_plan,
)
class Block(nn.Module):
"""Pre-norm transformer block."""
def __init__(self, dim, heads, mlp_ratio=4, dropout=0.1):
super().__init__()
self.norm1 = nn.LayerNorm(dim)
self.attn = nn.MultiheadAttention(
dim, heads, dropout=dropout, batch_first=True
)
self.norm2 = nn.LayerNorm(dim)
self.mlp = nn.Sequential(
nn.Linear(dim, mlp_ratio * dim),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(mlp_ratio * dim, dim),
nn.Dropout(dropout),
)
def forward(self, x):
h = self.norm1(x)
x = x + self.attn(h, h, h, need_weights=False)[0]
return x + self.mlp(self.norm2(x))
class Encoder(nn.Module):
"""Stack of pre-norm blocks with a final norm."""
def __init__(self, dim, depth, heads, dropout=0.1):
super().__init__()
self.blocks = nn.ModuleList(
Block(dim, heads, dropout=dropout) for _ in range(depth)
)
self.norm = nn.LayerNorm(dim)
def forward(self, x):
for block in self.blocks:
x = block(x)
return self.norm(x)
class IterativeController(nn.Module):
"""Predicts and iteratively refines a continuous action plan.
Args:
latent_dim: World-model latent width (192 for LeWM PushT).
action_dim: Native env action dim (2 for PushT).
frameskip: Env actions per world-model transition (5).
horizon: Plan length in world-model transitions (H).
num_context: Context frames the predictor consumes (N=3).
refinements: Refinement iterations K.
action_center / action_scale: Per-dim tanh bounds, expressed in the
*normalized* action space the world model was trained on. Defaults
correspond to raw PushT actions in [-1, 1].
"""
def __init__(
self,
latent_dim=192,
action_dim=2,
frameskip=5,
horizon=5,
num_context=3,
refinements=3,
width=256,
depth=4,
heads=8,
dropout=0.1,
action_center=0.0,
action_scale=1.0,
no_latent_proj=False,
fused=False,
):
super().__init__()
if no_latent_proj:
assert width == latent_dim, (
'--no-latent-proj requires width == latent_dim, got '
f'width={width} latent_dim={latent_dim}'
)
self.horizon = horizon
self.refinements = refinements
self.num_context = num_context
self.frameskip = frameskip
self.action_dim = action_dim
self.block_dim = frameskip * action_dim
self.latent_dim = latent_dim
self.no_latent_proj = no_latent_proj
self.fused = fused
center = torch.as_tensor(action_center).float().expand(action_dim)
scale = torch.as_tensor(action_scale).float().expand(action_dim)
self.register_buffer('action_center', center.clone())
self.register_buffer('action_scale', scale.clone())
# --- conditioning: context frames + goal ---------------------------
# identity when tokens already live in the world model's own
# coordinates (width == latent_dim); a learned projection otherwise
self.latent_proj = (
nn.Identity() if no_latent_proj else nn.Linear(latent_dim, width)
)
self.context_pos = nn.Parameter(torch.randn(1, num_context, width) * 0.02)
self.goal_token = nn.Parameter(torch.randn(1, 1, width) * 0.02)
# --- plan tokens ---------------------------------------------------
self.plan_query = nn.Parameter(torch.randn(1, horizon, width) * 0.02)
self.plan_pos = nn.Parameter(torch.randn(1, horizon, width) * 0.02)
if fused:
# one operator Phi: per-slot feature [y_j, x_hat, x_hat - x_G,
# ||x_hat - x_G||^2] -> one projection -> one transformer, used
# both to seed Y^0 (zeros trick) and to emit refinement deltas
self.slot_proj = nn.Linear(width + 2 * latent_dim + 1, width)
self.net = Encoder(width, depth * 2, heads, dropout)
else:
# --- consequence encoder F_theta --------------------------------
# per horizon step: [y_j, x_hat, x_hat - x_G, ||x_hat - x_G||^2]
self.consequence_proj = nn.Linear(width + 2 * latent_dim + 1, width)
self.consequence_net = Encoder(width, depth, heads, dropout)
# --- refinement transformer G_theta ------------------------------
self.refine_proj = nn.Linear(2 * width, width)
self.refine_net = Encoder(width, depth, heads, dropout)
# small (not zero) init: refinements start near-identity, but the
# consequence transformer still receives gradient on the first step
self.delta_head = nn.Linear(width, width)
nn.init.normal_(self.delta_head.weight, std=0.01)
nn.init.zeros_(self.delta_head.bias)
# one learned step size per refinement, squashed into (0, 1)
self.step_logit = nn.Parameter(torch.zeros(refinements))
# --- action head ---------------------------------------------------
self.action_head = nn.Sequential(
nn.LayerNorm(width),
nn.Linear(width, width),
nn.GELU(),
nn.Linear(width, self.block_dim),
)
def step_sizes_repr(self):
"""Learned refinement step sizes, for logging."""
with torch.no_grad():
return [round(v, 3) for v in torch.sigmoid(self.step_logit).tolist()]
def condition(self, ctx_emb, goal_emb):
"""Build conditioning tokens from context latents and the goal."""
ctx = self.latent_proj(ctx_emb) + self.context_pos
goal = self.latent_proj(goal_emb).unsqueeze(1) + self.goal_token
return torch.cat([ctx, goal], dim=1) # (B, N+1, W)
def to_actions(self, plan_tokens):
"""Decode plan tokens to bounded action blocks ``(B, H, 5*d_a)``."""
raw = self.action_head(plan_tokens)
raw = raw.unflatten(-1, (self.frameskip, self.action_dim))
bounded = self.action_center + self.action_scale * torch.tanh(raw)
return bounded.flatten(-2)
def _run_refine(self, plan_tokens, cond, consequence):
"""Split ``G_theta`` body: plan tokens + consequences -> hidden."""
x = self.refine_proj(torch.cat([plan_tokens, consequence], dim=-1))
x = x + self.plan_pos
x = self.refine_net(torch.cat([x, cond], dim=1))
return x[:, : self.horizon]
def _run_fused(self, slot_features, cond):
"""Fused ``Phi`` body: raw per-slot features -> hidden, one operator."""
x = self.slot_proj(slot_features)
x = x + self.plan_pos
x = self.net(torch.cat([x, cond], dim=1))
return x[:, : self.horizon]
def initial_plan(self, cond):
"""``Y^(0)`` from learned queries, with no consequences known yet."""
queries = self.plan_query.expand(cond.size(0), -1, -1)
if self.fused:
zeros = queries.new_zeros(
queries.size(0), self.horizon, 2 * self.latent_dim + 1
)
slot_features = torch.cat([queries, zeros], dim=-1)
# absolute, not a delta: same "zeros trick" semantics as the
# split path's initial_plan, routed through the same operator
return self._run_fused(slot_features, cond)
return self._run_refine(queries, cond, torch.zeros_like(queries))
def refine(self, plan_tokens, cond, pred, goal_emb):
"""One refinement: read consequences, emit a correction to the plan."""
delta_goal = pred - goal_emb.unsqueeze(1)
dist = delta_goal.pow(2).mean(dim=-1, keepdim=True)
features = torch.cat([plan_tokens, pred, delta_goal, dist], dim=-1)
if self.fused:
return self.delta_head(self._run_fused(features, cond))
cons = self.consequence_proj(features) + self.plan_pos
cons = self.consequence_net(torch.cat([cons, cond], dim=1))
cons = cons[:, : self.horizon]
return self.delta_head(self._run_refine(plan_tokens, cond, cons))
def forward(self, model, ctx_emb, past_actions, goal_emb):
"""Run the full refinement loop against the frozen world model.
Args:
model: Frozen ``LeWM``.
ctx_emb: ``(B, N, D)`` context latents.
past_actions: ``(B, N-1, 5*d_a)`` normalized executed blocks.
goal_emb: ``(B, D)`` goal latent.
Returns:
Dict with per-iteration ``plans``, ``rollouts``, ``distances`` and
``contexts``; each list holds ``K+1`` entries (initial plan plus K
refinements).
"""
cond = self.condition(ctx_emb, goal_emb)
tokens = self.initial_plan(cond)
plans, rollouts, distances, contexts = [], [], [], []
for k in range(self.refinements + 1):
actions = self.to_actions(tokens)
pred, frames = rollout_plan(
model, ctx_emb, past_actions, actions, return_frames=True
)
plans.append(actions)
rollouts.append(pred)
distances.append(goal_distance(pred, goal_emb))
contexts.append(rollout_contexts(frames, self.num_context))
if k == self.refinements:
break
delta = self.refine(tokens, cond, pred, goal_emb)
# running more refinements at eval than were trained reuses the
# last learned step size rather than indexing past it
idx = min(k, self.step_logit.numel() - 1)
tokens = tokens + torch.sigmoid(self.step_logit[idx]) * delta
return {
'plans': plans,
'rollouts': rollouts,
'distances': distances,
'contexts': contexts,
'step_sizes': torch.sigmoid(self.step_logit),
}
|