File size: 20,943 Bytes
e0eb79a | 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 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 | """ReMDM reverse denoising with remasking strategies.
remdm_sample implements ReMDM Algorithm 1 (Wang et al.): Bernoulli
posterior unmasking with the Section 4.1 remasking schedules. The
craftax twin is src/diffusion/sampling.py:sample_plan. greedy_sample
is a separate MaskGIT-style argmax decoder used only for DAgger
collection (a documented engineering choice).
"""
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
import numpy as np
import torch
import torch.nn.functional as F
from torch import Tensor
from torch.distributions import Categorical
from src.diffusion.schedules import get_schedule
# NLE hazard glyph IDs and char codes (walls, locked doors, lava, water)
_HAZARD_GLYPHS: frozenset[int] = frozenset({2359, 2360, 2389, 2390})
_HAZARD_CHARS: frozenset[int] = frozenset(
{ord("|"), ord("-"), ord("+"), ord("L"), ord("W")}
)
# Cardinal action → (dy, dx) offsets
_CARDINAL_OFFSETS: dict[int, tuple[int, int]] = {
0: (-1, 0),
1: (0, 1),
2: (1, 0),
3: (0, -1),
}
_N_PHYSICS_CHECK = 8 # only inspect the first N plan positions
# Stability guards; values must match the craftax twin exactly.
_SIGMA_DENOM_EPS = 1e-8 # sigma_max and posterior denominators
# Demotion value for hazardous actions under the conf strategy.
_HAZARD_DECODE_PROB = 0.001
def _check_hazard(local_crop: np.ndarray, action: int) -> bool:
"""Return True if *action* from the agent's centre steps into a hazard.
Args:
local_crop: ``[crop_size, crop_size]`` glyph array.
action: Cardinal action index (0=N, 1=E, 2=S, 3=W).
Returns:
``True`` when the target cell contains a hazard glyph.
"""
if action not in _CARDINAL_OFFSETS:
return False
cs = local_crop.shape[0]
cy, cx = cs // 2, cs // 2
dy, dx = _CARDINAL_OFFSETS[action]
ny, nx = cy + dy, cx + dx
if not (0 <= ny < cs and 0 <= nx < cs):
return True
glyph = int(local_crop[ny, nx])
return glyph in _HAZARD_GLYPHS or glyph in _HAZARD_CHARS
def top_p_filter(logits: Tensor, top_p: float) -> Tensor:
"""Nucleus filtering (ReMDM Sec 5).
Keeps the smallest prefix of the descending-sorted distribution whose
cumulative mass reaches ``top_p``; all other logits go to ``-inf``.
Mirrors the craftax twin ``_nucleus_sample`` cutoff semantics.
Args:
logits: Raw logits. Shape ``[..., V]``.
top_p: Nucleus threshold in (0, 1]; ``>= 1`` disables filtering.
Returns:
Filtered logits with out-of-nucleus entries set to ``-inf``.
"""
if top_p is None or top_p >= 1.0:
return logits
probs = F.softmax(logits, dim=-1)
sorted_p, sorted_idx = probs.sort(dim=-1, descending=True)
cutoff = sorted_p.cumsum(dim=-1) - sorted_p # exclusive cumsum
remove_sorted = cutoff >= top_p
remove = remove_sorted.gather(-1, sorted_idx.argsort(dim=-1))
return logits.masked_fill(remove, float("-inf"))
def _compute_remask_prob(
strategy: str,
eta: float,
sigma_max: float,
psi: Tensor | None,
committed: Tensor | None = None,
) -> Tensor | float:
"""Compute per-token remasking probability.
The ``conf`` strategy consumes the stored decoding probability
``psi`` from the step each token was last unmasked (ReMDM Sec 4.1),
not the current step's fresh confidence.
Args:
strategy: One of ``"rescale"``, ``"cap"``, ``"conf"``.
eta: Base remasking strength hyperparameter.
sigma_max: ReMDM eq 7 value ``min(1, (1 - alpha_s) / alpha_t)``,
computed by the caller.
psi: Stored decoding probabilities at last unmask. Shape
``[B, L]``, ``+inf`` at masked positions. Required only for
the ``"conf"`` strategy.
committed: Boolean mask of committed (non-masked) positions.
Required only for the ``"conf"`` strategy.
Returns:
Scalar or ``[B, L]`` tensor of remasking probabilities.
"""
if strategy == "rescale":
return eta * sigma_max
if strategy == "cap":
return min(eta, sigma_max)
if strategy == "conf":
assert psi is not None, "conf strategy requires psi"
assert committed is not None, "conf strategy requires the committed mask"
# softmax(-psi) over committed positions, zero elsewhere,
# scaled by eta * sigma_max: mirrors craftax ``sigma_conf``.
neg = torch.where(
committed,
-psi,
torch.tensor(float("-inf"), device=psi.device, dtype=psi.dtype),
)
any_committed = committed.any(dim=-1, keepdim=True)
safe = torch.where(any_committed, neg, torch.zeros_like(neg))
weights = torch.softmax(safe, dim=-1)
return torch.where(
committed, weights * (eta * sigma_max), torch.zeros_like(weights)
)
raise ValueError(f"Unknown remask strategy: {strategy}")
@torch.no_grad()
def remdm_sample(
model: torch.nn.Module,
local_obs: Tensor,
global_obs: Tensor,
cfg: SimpleNamespace,
device: torch.device | str,
physics_aware: bool = True,
blind_global: bool = False,
return_analytics: bool = False,
num_steps: int | None = None,
history: Tensor | None = None,
hist_len: Tensor | None = None,
) -> Tensor | tuple[Tensor, list, list[float], list[int]]:
"""Generate action sequences via iterative ReMDM denoising.
Args:
model: Denoising model with forward signature
``(local_obs, global_obs, action_seq, t_discrete) -> dict``.
local_obs: Local crop observations. Shape ``[B, 9, 9]``.
global_obs: Global map observations. Shape ``[B, 21, 79]``.
cfg: Config namespace with ``seq_len``, ``mask_token``,
``action_dim``, ``diffusion_steps_eval``, ``temperature``,
``top_p``, ``eta``, ``remask_strategy``, ``noise_schedule``.
device: Torch device.
physics_aware: If ``True``, soft-penalise hazardous cardinal actions
by overriding their stored decoding probability to ``0.001`` so
the ``conf`` strategy preferentially remasks them. Only checks
the first ``_N_PHYSICS_CHECK`` positions.
blind_global: If ``True``, zero out the global map observation
(local-only ablation).
return_analytics: If ``True``, also return per-step analytics as
``(seq, path_per_step, tracking_confidence, tracking_masked)``.
num_steps: Override number of denoising steps (default uses
``cfg.diffusion_steps_eval``).
history: ``[B, seq_len]`` already-executed actions to lock into
the plan's leading positions (planning as inpainting,
Diffuser Sec. 3.3). ``None`` plans from a fully masked
sequence.
hist_len: ``[B]`` number of leading positions to lock. Required
with ``history``.
Returns:
When ``return_analytics=False`` (default): fully committed action
sequence of shape ``[B, seq_len]``, int64, with no MASK tokens.
When ``return_analytics=True``: tuple
``(seq, path_per_step, tracking_confidence, tracking_masked_count)``
where ``path_per_step`` is a list of ``[seq_len]`` numpy arrays,
``tracking_confidence`` a list of per-step avg unmasked confidence
floats, and ``tracking_masked_count`` a list of masked-token counts.
"""
B = local_obs.shape[0]
seq_len = cfg.seq_len
mask_token = cfg.mask_token
action_dim = cfg.action_dim
K = num_steps if num_steps is not None else cfg.diffusion_steps_eval
schedule_fn = get_schedule(cfg.noise_schedule)
local_obs = local_obs.to(device)
global_obs = global_obs.to(device)
if blind_global:
global_obs = torch.zeros_like(global_obs)
# Pre-compute numpy local crops for physics checks (CPU, batch loop)
local_np: np.ndarray | None = None # [B, crop, crop]
if physics_aware:
local_np = local_obs.cpu().numpy()
# Analytics buffers (only populated when return_analytics=True)
path_per_step: list[np.ndarray] = []
tracking_confidence: list[float] = []
tracking_masked_count: list[int] = []
# Start fully masked; psi stores the decoding probability at the step
# each token was last unmasked (+inf while masked), per ReMDM Sec 4.1.
seq = torch.full((B, seq_len), mask_token, dtype=torch.long, device=device)
psi = torch.full((B, seq_len), float("inf"), device=device)
# Historical prefix (Diffuser Sec. 3.3; spec-method §6.1/§6.2, SHARED):
# positions 0..hist_len-1 are observed, so they are fixed for the whole
# of denoising - never unmasked away, never remasked. The craftax twin
# is sample_plan_inpainting.
lock_mask: Tensor | None = None
if history is not None:
if hist_len is None:
raise ValueError("remdm_sample: history requires hist_len")
history = history.to(device)
pos = torch.arange(seq_len, device=device).unsqueeze(0) # [1, L]
lock_mask = pos < hist_len.to(device).unsqueeze(1) # [B, L]
seq = torch.where(lock_mask, history, seq)
# ReMDM Algorithm 1 (Wang et al.). Masked tokens unmask via
# independent Bernoulli draws from the approximate posterior;
# committed tokens remask w.p. sigma from the Sec 4.1 schedule.
# The craftax twin is sample_plan.
for idx in range(K):
t = (K - idx) / K
s = (K - idx - 1) / K
alpha_t = float(schedule_fn(torch.tensor(t)))
alpha_s = float(schedule_fn(torch.tensor(s)))
# Discrete conditioning bin for the learned timestep embedding
# (free per MDLM Sec 3.5: time conditioning is optional).
t_discrete = torch.full(
(B,),
min(int(t * cfg.num_diffusion_steps), cfg.num_diffusion_steps - 1),
dtype=torch.long,
device=device,
)
out = model(local_obs, global_obs, seq, t_discrete)
logits = out["actions"] # [B, seq_len, vocab]
# Mask invalid action tokens (indices >= action_dim)
logits[:, :, action_dim:] = float("-inf")
# psi, the confidence the `conf` remask strategy orders by, is the
# model's own probability for the token it commits — read off the raw
# posterior, before temperature and nucleus filtering, matching the
# craftax twin (`sampling.py:185-186`). Taking it after filtering
# makes psi a property of the decoding settings rather than of the
# model: whenever the nucleus collapses to a single token psi is
# exactly 1.0 however uncertain the model really is, so those
# positions are never remasked. The sampling distribution below is
# untouched.
raw_probs = F.softmax(logits, dim=-1) # [B, seq_len, vocab]
logits = logits / cfg.temperature
# Nucleus filtering
logits = top_p_filter(logits, cfg.top_p)
probs = F.softmax(logits, dim=-1) # [B, seq_len, action_dim]
preds = Categorical(probs=probs).sample() # [B, seq_len]
decode_prob = raw_probs.gather(-1, preds.unsqueeze(-1)).squeeze(-1) # [B, L]
# Physics softener (unsourced engineering, default off):
# demote hazardous cardinal actions to decode_prob=0.001 so the
# conf strategy preferentially remasks them.
if physics_aware and local_np is not None:
preds_np = preds.cpu().numpy() # [B, seq_len]
prob_override = decode_prob.clone()
for b in range(B):
crop_b = np.asarray(local_np[b]) # [crop, crop]
for pos in range(min(_N_PHYSICS_CHECK, seq_len)):
action = int(preds_np[b, pos])
if _check_hazard(crop_b, action):
prob_override[b, pos] = _HAZARD_DECODE_PROB
decode_prob = prob_override
committed = seq != mask_token # [B, seq_len]
# Remasking probability sigma in [0, sigma_max] (ReMDM eq 7)
sigma_max = min(1.0, (1.0 - alpha_s) / max(alpha_t, _SIGMA_DENOM_EPS))
sigma = _compute_remask_prob(
cfg.remask_strategy, cfg.eta, sigma_max, psi, committed
)
if not isinstance(sigma, Tensor):
sigma = torch.full((B, seq_len), float(sigma), device=device)
if lock_mask is not None:
sigma = torch.where(lock_mask, torch.zeros_like(sigma), sigma)
# Algorithm 1 posterior: masked tokens unmask w.p.
# (alpha_s - (1 - sigma) alpha_t) / (1 - alpha_t)
p_unmask = torch.clamp(
(alpha_s - (1.0 - sigma) * alpha_t) / max(1.0 - alpha_t, _SIGMA_DENOM_EPS),
0.0,
1.0,
)
do_unmask = ~committed & (torch.rand(B, seq_len, device=device) < p_unmask)
do_remask = committed & (torch.rand(B, seq_len, device=device) < sigma)
seq = torch.where(do_unmask, preds, seq)
seq = torch.where(do_remask, mask_token, seq)
psi = torch.where(do_unmask, decode_prob, psi)
psi = torch.where(do_remask, torch.full_like(psi, float("inf")), psi)
if lock_mask is not None:
seq = torch.where(lock_mask, history, seq)
psi = torch.where(lock_mask, torch.full_like(psi, float("inf")), psi)
# Analytics tracking
if return_analytics:
path_per_step.append(seq[0].cpu().numpy().copy())
still_masked = seq[0] == mask_token
unmasked_prob = psi[0][~still_masked]
avg_conf = unmasked_prob.mean().item() if unmasked_prob.numel() > 0 else 0.0
tracking_confidence.append(avg_conf)
tracking_masked_count.append(int(still_masked.sum().item()))
# Final greedy cleanup for any remaining masks (as in the craftax
# twin); replaces the previous commit-all step and assertion.
still_masked = seq == mask_token
if still_masked.any():
t_zero = torch.zeros(B, dtype=torch.long, device=device)
out = model(local_obs, global_obs, seq, t_zero)
logits = out["actions"]
logits[:, :, action_dim:] = float("-inf")
seq = torch.where(still_masked, logits.argmax(dim=-1), seq)
if lock_mask is not None:
seq = torch.where(lock_mask, history, seq)
if return_analytics:
return seq, path_per_step, tracking_confidence, tracking_masked_count
return seq
@torch.no_grad()
def greedy_sample(
model: torch.nn.Module,
local_obs: Tensor,
global_obs: Tensor,
cfg: SimpleNamespace,
device: torch.device | str,
blind_global: bool = False,
num_steps: int | None = None,
history: Tensor | None = None,
hist_len: Tensor | None = None,
) -> Tensor:
"""Greedy (argmax) MaskGIT sampling — no temperature, top-K, or remasking.
Used by ``DataCollector`` during DAgger for deterministic rollouts,
matching the reference ``run_model_episode`` behaviour.
Args:
model: Denoising model.
local_obs: Shape ``[B, 9, 9]``.
global_obs: Shape ``[B, 21, 79]``.
cfg: Config namespace.
device: Torch device.
blind_global: Zero out global map (local-only ablation).
history: ``[B, seq_len]`` already-executed actions to lock into
the plan's leading positions (see ``remdm_sample``).
hist_len: ``[B]`` number of leading positions to lock.
Returns:
Fully committed action sequence ``[B, seq_len]``, int64.
"""
B = local_obs.shape[0]
seq_len = cfg.seq_len
mask_token = cfg.mask_token
action_dim = cfg.action_dim
K = num_steps if num_steps is not None else cfg.diffusion_steps_eval
local_obs = local_obs.to(device)
global_obs = global_obs.to(device)
if blind_global:
global_obs = torch.zeros_like(global_obs)
seq = torch.full(
(B, seq_len),
mask_token,
dtype=torch.long,
device=device,
)
# Observed prefix stays fixed for the whole of decoding, as in
# remdm_sample (Diffuser Sec. 3.3; spec-method §6.1/§6.2).
lock_mask: Tensor | None = None
if history is not None:
if hist_len is None:
raise ValueError("greedy_sample: history requires hist_len")
history = history.to(device)
pos = torch.arange(seq_len, device=device).unsqueeze(0)
lock_mask = pos < hist_len.to(device).unsqueeze(1)
seq = torch.where(lock_mask, history, seq)
for k in range(1, K + 1):
ratio = k / K
t_discrete = torch.full(
(B,),
int(cfg.num_diffusion_steps * (1.0 - ratio)),
dtype=torch.long,
device=device,
)
out = model(local_obs, global_obs, seq, t_discrete)
logits = out["actions"] # [B, seq_len, vocab]
# Mask invalid action tokens
logits[:, :, action_dim:] = float("-inf")
# Greedy: argmax over softmax (no temperature, no top-K)
probs = F.softmax(logits, dim=-1) # [B, seq_len, action_dim]
confidences, preds = probs.max(dim=-1) # [B, seq_len] each
# MaskGIT progressive unmasking by confidence
num_to_unmask = max(1, int(seq_len * ratio))
is_masked = seq == mask_token # [B, seq_len]
# Score only masked positions for unmasking
scores = confidences.clone()
scores[~is_masked] = -1.0
_, topk_idx = scores.topk(num_to_unmask, dim=-1)
unmask_mask = torch.zeros_like(seq, dtype=torch.bool)
unmask_mask.scatter_(1, topk_idx, True)
unmask_mask = unmask_mask & is_masked
seq = torch.where(unmask_mask, preds, seq)
if lock_mask is not None:
seq = torch.where(lock_mask, history, seq)
# No remasking in greedy mode
# Force-commit any remaining masked tokens
still_masked = seq == mask_token
if still_masked.any():
t_zero = torch.zeros(B, dtype=torch.long, device=device)
out = model(local_obs, global_obs, seq, t_zero)
logits = out["actions"]
logits[:, :, action_dim:] = float("-inf")
preds = logits.argmax(dim=-1)
seq = torch.where(still_masked, preds, seq)
if lock_mask is not None:
seq = torch.where(lock_mask, history, seq)
return seq
@dataclass
class LockedPrefix:
"""Executed-action prefix for inpainted receding-horizon replanning.
A replan is conditioned on what the agent has actually done since
the current plan window opened: those positions are observed, so
they are locked for the whole of denoising rather than re-generated
(Diffuser Sec. 3.3; spec-method §6.1/§6.2, SHARED - the craftax
twin's ``sample_plan_inpainting`` + ``mpc_step`` do the same).
Author decision 2026-08-16.
A window closes once ``seq_len`` actions have been executed from
it; the next replan starts a fresh, fully masked window.
Args:
n: Number of parallel episodes.
seq_len: Plan length.
mask_token: MASK token id, used to fill unwritten positions.
"""
n: int
seq_len: int
mask_token: int
def __post_init__(self) -> None:
self.history = np.full(
(self.n, self.seq_len), self.mask_token, dtype=np.int64
)
self.hist_len = np.zeros(self.n, dtype=np.int64)
def start_window(self, idx: np.ndarray | None = None) -> None:
"""Open a fresh window for any row whose plan is used up.
Args:
idx: Rows about to replan; ``None`` means all rows.
"""
rows = np.arange(self.n) if idx is None else np.asarray(idx)
full = rows[self.hist_len[rows] >= self.seq_len]
if full.size:
self.history[full] = self.mask_token
self.hist_len[full] = 0
def as_tensors(
self, idx: np.ndarray, device: torch.device | str
) -> tuple[Tensor, Tensor]:
"""Return ``(history, hist_len)`` for ``idx`` as device tensors.
Args:
idx: Rows being replanned.
device: Torch device.
"""
return (
torch.from_numpy(self.history[idx]).to(device),
torch.from_numpy(self.hist_len[idx]).to(device),
)
def record(self, i: int, action: int) -> None:
"""Append an executed action to row ``i``'s prefix.
Args:
i: Row index.
action: Action just executed.
"""
self.history[i, self.hist_len[i]] = action
self.hist_len[i] += 1
def reset(self, i: int) -> None:
"""Clear row ``i`` (episode boundary).
Args:
i: Row index.
"""
self.history[i] = self.mask_token
self.hist_len[i] = 0
def is_full(self, i: int) -> bool:
"""Whether row ``i``'s window has no unexecuted positions left.
Args:
i: Row index.
"""
return bool(self.hist_len[i] >= self.seq_len)
|