AnonMLuser's picture
Anonymous artefact release
e0eb79a verified
Raw
History Blame Contribute Delete
20.9 kB
"""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)