World-Action-Verifier / src /wm_dataset.py
ffeng1017's picture
WAV interactive demo (ZeroGPU)
23a59ea verified
Raw
History Blame Contribute Delete
25.9 kB
# wm_dataset.py
import os
import glob
import json
import bisect
import random as _random
from collections import OrderedDict
from typing import Dict, Mapping, Optional, Sequence, Union
import torch
from torch.utils.data import Dataset
class WMDataset(Dataset):
"""
RGB-native world model dataset.
data_dir and frames_dir may each be a str or list[str]. Lists are treated as
paired roots; a length-1 root is broadcast to match a longer one.
"""
def __init__(
self,
data_dir: Union[str, Sequence[str]],
frames_dir: Union[str, Sequence[str]],
seq_len: int,
img_size: int = 128,
action_dim: int = 16,
lang_dim: int = 512,
cache_mb: int = 2048,
verbose: bool = True,
tasks_json: str = "../tasks.json",
tasks: Optional[list[str]] = None,
strict_tasks: bool = True,
ddp_partition: bool = False,
iid_sampling: bool = False,
samples_per_shard: int = 1,
task_weights: Optional[Mapping[str, float]] = None,
):
super().__init__()
# normalize data_dir / frames_dir to lists, and pair them
if isinstance(data_dir, (str, os.PathLike)):
data_dirs = [str(data_dir)]
else:
data_dirs = [str(x) for x in data_dir]
if isinstance(frames_dir, (str, os.PathLike)):
frames_dirs = [str(frames_dir)]
else:
frames_dirs = [str(x) for x in frames_dir]
if len(data_dirs) != len(frames_dirs):
if len(data_dirs) == 1:
data_dirs = data_dirs * len(frames_dirs)
elif len(frames_dirs) == 1:
frames_dirs = frames_dirs * len(data_dirs)
else:
raise ValueError(f"data_dir and frames_dir must have same length (or one must be length-1). "
f"Got {len(data_dirs)} and {len(frames_dirs)}")
self.data_dirs = data_dirs
self.frames_dirs = frames_dirs
self.sources = list(zip(self.data_dirs, self.frames_dirs))
self.T = int(seq_len)
self.H = int(img_size)
self.W = int(img_size)
self.A = int(action_dim)
self.lang_dim = int(lang_dim)
self.cache_bytes = int(cache_mb) * 1024 * 1024
self.verbose = bool(verbose)
self.tasks_filter = None if tasks is None else set(tasks)
self.strict_tasks = bool(strict_tasks)
# --- Task metadata (action_dim + text_embedding) ---
self.task_meta: Optional[dict] = None
if tasks_json and os.path.exists(tasks_json):
try:
with open(tasks_json, "r") as f:
self.task_meta = json.load(f)
except Exception as e:
if self.verbose:
print(f"[WMDataset] Warning: failed to load tasks_json={tasks_json}: {e}")
self.task_meta = None
elif tasks_json and self.verbose:
print(f"[WMDataset] Warning: tasks_json not found at {tasks_json} (continuing with zeros lang_emb + default action masks).")
self._zero_lang = torch.zeros(self.lang_dim, dtype=torch.float32)
# LRU cache for shards: key=(task_idx, seg_idx, shard_idx) -> frames tensor
self._cache = OrderedDict()
self._cache_nbytes = 0
# Discover tasks from ALL data_dirs/*.pt (dedup, preserve first-seen order)
found_tasks = []
seen = set()
for dd in self.data_dirs:
demo_paths = sorted(glob.glob(os.path.join(dd, "*.pt")))
for p in demo_paths:
t = os.path.splitext(os.path.basename(p))[0]
if t not in seen:
seen.add(t)
found_tasks.append(t)
if self.tasks_filter is not None:
requested = [t for t in tasks if t in self.tasks_filter] if tasks is not None else []
if len(requested) == 0:
requested = [t for t in found_tasks if t in self.tasks_filter]
tasks = requested
if self.verbose:
missing = [t for t in self.tasks_filter if t not in set(found_tasks)]
print(f"[WMDataset] Task filter: keeping {len(tasks)}/{len(found_tasks)} tasks")
if missing:
msg = f"[WMDataset] WARNING: {len(missing)} requested tasks not found in data_dir(s) (e.g. {missing[:5]})"
if self.strict_tasks:
raise FileNotFoundError(msg)
else:
print(msg)
else:
tasks = found_tasks
# DDP-aware strided task partitioning: each rank handles every N-th task
# so shard pools are disjoint and per-worker caches stay effective.
if ddp_partition:
rank = int(os.environ.get("RANK", "0"))
world_size = int(os.environ.get("WORLD_SIZE", "1"))
if world_size > 1 and len(tasks) >= world_size:
tasks = [tasks[i] for i in range(rank, len(tasks), world_size)]
if self.verbose:
print(f"[WMDataset] DDP partition: rank {rank}/{world_size} "
f"→ {len(tasks)} tasks (strided)")
self.iid_sampling = bool(iid_sampling)
self.samples_per_shard = int(samples_per_shard)
# Per-worker state for iid_sampling locality (initialized lazily)
self._cur_shard_entry: Optional[int] = None
self._cur_shard_draws: int = 0
# Stored per-task
self.tasks = []
self.demo_paths = [] # stores list of per-task demo paths (joined)
self.shard_lists = [] # per task -> list[segments], each segment is list[shard_paths]
self.shard_cum = [] # per task -> list[segments], each segment is list[int] cumulative frame counts per shard
self.seg_cum_frames = [] # per task -> cumulative frame counts across segments (for indexing)
self.ep = []
self.act = []
self.rew = []
self.valid_starts = []
self._cum_counts = []
# Precomputed per-task metadata used by __getitem__
self._emb_ids = []
self._act_dims = []
self._act_mask_1d = []
self._lang_embs = []
total = 0
for task in tasks:
# --- gather segments for this task from each (data_dir, frames_dir) source ---
seg_eps = []
seg_acts = []
seg_rews = []
seg_shards = []
seg_shard_cums = []
seg_num_frames = []
seg_demo_paths = []
ep_offset = 0 # ensures episode ids are unique across segments
for (dd, fd) in self.sources:
dp = os.path.join(dd, f"{task}.pt")
shard_glob = os.path.join(fd, task, "*shard*.pt")
shards = sorted(glob.glob(shard_glob))
if not os.path.exists(dp) or len(shards) == 0:
continue
try:
td = torch.load(dp, map_location="cpu", weights_only=False)
except Exception as e:
if self.verbose:
print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): torch.load demo failed: {e}")
continue
try:
ep = td["episode"].to(torch.int64).cpu()
act = td["action"].cpu()
rew = td["reward"].cpu()
except Exception as e:
if self.verbose:
print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): missing keys in demo: {e}")
continue
if rew.ndim == 2 and rew.shape[-1] == 1:
rew = rew.squeeze(-1)
rew = rew.to(torch.float32)
if act.ndim == 1:
act = act.unsqueeze(-1)
act = act.to(torch.float32)
N = int(rew.shape[0])
if act.shape[0] != N or ep.shape[0] != N:
if self.verbose:
print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): length mismatch ep/act/rew.")
continue
# Determine per-shard frame counts from index file or last shard
index_path = os.path.join(fd, task, f"{task}_index.json")
shard_sizes = None
if os.path.exists(index_path):
try:
with open(index_path, "r") as f:
index = json.load(f)
shard_sizes = [int(index[os.path.basename(s)]) for s in shards]
except Exception:
shard_sizes = None
if shard_sizes is None:
# Fallback: load each shard to get its size
shard_sizes = []
fallback_ok = True
for s in shards:
try:
td_s = torch.load(s, map_location="cpu", weights_only=False)
shard_sizes.append(int(td_s["frames"].shape[0]))
except Exception as e:
if self.verbose:
print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): failed to read shard {s}: {e}")
fallback_ok = False
break
if not fallback_ok:
continue
N_frames_avail = sum(shard_sizes)
N_eff = min(N, N_frames_avail)
if N_eff < (self.T + 1):
if self.verbose:
print(f"[WMDataset] Skipping task={task} source=({dd},{fd}): not enough frames (N_eff={N_eff}) for T={self.T}.")
continue
ep = ep[:N_eff]
act = act[:N_eff]
rew = rew[:N_eff]
# Make episode IDs unique across segments to prevent windows crossing boundaries
if ep.numel() > 0:
seg_max = int(ep.max().item())
else:
seg_max = 0
ep = ep + ep_offset
ep_offset += seg_max + 1
# Build cumulative shard frame counts for this segment, capped at N_eff
seg_shard_cum = []
running = 0
for sz in shard_sizes:
running += sz
seg_shard_cum.append(min(running, N_eff))
if running >= N_eff:
break
seg_eps.append(ep)
seg_acts.append(act)
seg_rews.append(rew)
seg_shards.append(shards)
seg_num_frames.append(int(N_eff))
seg_demo_paths.append(dp)
seg_shard_cums.append(seg_shard_cum)
if len(seg_eps) == 0:
if self.verbose:
print(f"[WMDataset] Skipping task={task}: missing demo+shards across all sources.")
continue
# Concatenate segments for this task
ep = torch.cat(seg_eps, dim=0)
act = torch.cat(seg_acts, dim=0)
rew = torch.cat(seg_rews, dim=0)
N_eff = int(rew.shape[0])
# Valid starts: need obs indices i..i+T and transitions at indices i+1..i+T
start_count = N_eff - self.T # i in [0, start_count-1]
# episode consistency: ensure the whole window is within same episode
ep_ok = (ep[:start_count] == ep[self.T:self.T + start_count])
# filter invalid transitions (nan action or nan reward)
act_nan = torch.isnan(act).any(dim=-1)
rew_nan = torch.isnan(rew)
step_ok = ~(act_nan | rew_nan) # length N_eff
# transitions live at indices 1..N_eff-1
step_ok2 = step_ok[1:] # length N_eff-1
# for each start i, need step_ok at indices (i+1 .. i+T) all true
cs = torch.cumsum(step_ok2.to(torch.int32), dim=0)
end = torch.arange(start_count) + (self.T - 1)
prev = torch.arange(start_count) - 1
prev_cs = torch.zeros(start_count, dtype=cs.dtype)
m = prev >= 0
prev_cs[m] = cs[prev[m]]
window_sum = cs[end] - prev_cs
window_ok = (window_sum == self.T)
valid = ep_ok & window_ok
valid_idx = valid.nonzero(as_tuple=False).flatten()
if valid_idx.numel() == 0:
if self.verbose:
print(f"[WMDataset] Skipping task={task}: no valid windows after filtering.")
continue
# --- per-task action_dim + mask from tasks.json ---
act_dim = self.A
if self.task_meta is not None and task in self.task_meta:
md = self.task_meta[task]
if "action_dim" in md:
try:
act_dim = int(md["action_dim"])
except Exception:
act_dim = self.A
act_dim = max(0, min(act_dim, self.A))
act_mask_1d = torch.zeros(self.A, dtype=torch.float32)
if act_dim > 0:
act_mask_1d[:act_dim] = 1.0
# --- per-task language embedding from tasks.json ---
lang = self._zero_lang
if self.lang_dim > 0 and self.task_meta is not None and task in self.task_meta and "text_embedding" in self.task_meta[task]:
te = self.task_meta[task]["text_embedding"]
l = torch.tensor(te, dtype=torch.float32)
if l.numel() != self.lang_dim:
raise RuntimeError(f"text_embedding dim mismatch for task {task}: {tuple(l.shape)} vs {self.lang_dim}")
lang = l
# Store
self.tasks.append(task)
self.demo_paths.append(seg_demo_paths)
# per-task segments of shard lists + cumulative frame counts
self.shard_lists.append(seg_shards)
self.shard_cum.append(seg_shard_cums)
cum = []
s = 0
for nf in seg_num_frames:
s += int(nf)
cum.append(s)
self.seg_cum_frames.append(cum)
self.ep.append(ep)
self.act.append(act)
self.rew.append(rew)
self.valid_starts.append(valid_idx)
# precomputed metadata per task index
task_idx = len(self.tasks) - 1
self._emb_ids.append(torch.tensor(task_idx, dtype=torch.long))
self._act_dims.append(act_dim)
self._act_mask_1d.append(act_mask_1d)
self._lang_embs.append(lang)
total += int(valid_idx.numel())
self._cum_counts.append(total)
if self.verbose:
print(f"[WMDataset] task={task} segments={len(seg_shards)} N_eff={N_eff} "
f"valid={valid_idx.numel()} act_dim={act_dim} lang={'yes' if lang is not self._zero_lang else 'no'}")
self.num_tasks = len(self.tasks)
assert self.num_tasks > 0, "No tasks found with both demo .pt and frame shards."
# Resolve optional per-task sampling weights (iid_sampling only).
# task_weights is a dict {task_name: weight} specifying relative
# task draw probabilities. Within a task we keep shard weights
# proportional to per-shard valid_starts for cache locality.
# Tasks not present in the dict default to weight 0 (excluded).
# If None, fall back to the legacy behavior where shard weight =
# its valid_starts count, which makes P(task) ∝ total valid_starts.
if task_weights is not None:
tw_list: list[float] = []
missing: list[str] = []
for t in self.tasks:
if t in task_weights:
w = float(task_weights[t])
if w < 0:
raise ValueError(f"task_weights[{t!r}] is negative: {w}")
tw_list.append(w)
else:
missing.append(t)
tw_list.append(0.0)
if missing and self.verbose:
print(f"[WMDataset] Warning: {len(missing)} loaded tasks have no "
f"task_weights entry and will be excluded from sampling "
f"(e.g. {missing[:5]})")
self.task_weights: Optional[list[float]] = tw_list
else:
self.task_weights = None
# Precompute per-shard valid starts for iid_sampling. Each entry maps
# a single on-disk shard file to the valid_starts whose first frame
# lives in that shard, so drawing samples_per_shard consecutive samples
# from one entry keeps a single file hot in the LRU cache.
self._shard_entries: list[dict] = []
self._shard_weights: list[float] = []
if self.iid_sampling:
# Collect raw shard entries grouped by task first so we can
# renormalize shard weights to honor task_weights without
# changing the intra-task distribution.
per_task_entries: list[list[tuple[dict, float]]] = [[] for _ in range(self.num_tasks)]
for ti in range(self.num_tasks):
seg_cum = self.seg_cum_frames[ti]
vs = self.valid_starts[ti]
for si in range(len(self.shard_cum[ti])):
seg_offset = 0 if si == 0 else seg_cum[si - 1]
for shi in range(len(self.shard_cum[ti][si])):
local_start = 0 if shi == 0 else self.shard_cum[ti][si][shi - 1]
local_end = self.shard_cum[ti][si][shi]
g_start = seg_offset + local_start
g_end = seg_offset + local_end
mask = (vs >= g_start) & (vs < g_end)
shard_vs = vs[mask]
if shard_vs.numel() > 0:
entry = {"task_idx": ti, "valid_starts": shard_vs}
per_task_entries[ti].append((entry, float(shard_vs.numel())))
if self.task_weights is None:
# Legacy path: shard weight = its valid_starts count.
for ti in range(self.num_tasks):
for entry, w in per_task_entries[ti]:
self._shard_entries.append(entry)
self._shard_weights.append(w)
else:
# Rescale so that the total shard weight for task ti equals
# task_weights[ti] (relative scale is what matters). Tasks
# with weight 0 contribute no shard entries and are
# effectively excluded.
for ti in range(self.num_tasks):
entries = per_task_entries[ti]
if not entries:
continue
w_task = float(self.task_weights[ti])
if w_task <= 0:
continue
total_vs = sum(w for _, w in entries)
if total_vs <= 0:
continue
scale = w_task / total_vs
for entry, w in entries:
self._shard_entries.append(entry)
self._shard_weights.append(w * scale)
if self.verbose:
msg = f"[WMDataset] iid_sampling: {len(self._shard_entries)} shard entries " \
f"(samples_per_shard={self.samples_per_shard})"
if self.task_weights is not None:
nz = sum(1 for w in self.task_weights if w > 0)
msg += f" | task_weights active ({nz}/{self.num_tasks} tasks with w>0)"
print(msg)
if self.verbose:
print(f"[WMDataset] Total valid sequences: {self._cum_counts[-1]} across {self.num_tasks} tasks.")
def __len__(self):
return self._cum_counts[-1]
def _lookup(self, idx: int):
lo, hi = 0, len(self._cum_counts) - 1
while lo < hi:
mid = (lo + hi) // 2
if idx < self._cum_counts[mid]:
hi = mid
else:
lo = mid + 1
task_idx = lo
prev = 0 if task_idx == 0 else self._cum_counts[task_idx - 1]
local = idx - prev
start = int(self.valid_starts[task_idx][local].item())
return task_idx, start
def _cache_get(self, key):
if key in self._cache:
v = self._cache.pop(key)
self._cache[key] = v
return v
return None
def _cache_put(self, key, tensor):
nbytes = tensor.nbytes
while self._cache_nbytes + nbytes > self.cache_bytes and len(self._cache) > 0:
_, v = self._cache.popitem(last=False)
self._cache_nbytes -= v.nbytes
self._cache[key] = tensor
self._cache_nbytes += nbytes
def _load_shard_frames(self, task_idx: int, seg_idx: int, shard_idx: int) -> torch.Tensor:
key = (task_idx, seg_idx, shard_idx)
cached = self._cache_get(key)
if cached is not None:
return cached
path = self.shard_lists[task_idx][seg_idx][shard_idx]
td = torch.load(path, map_location="cpu", weights_only=False)
frames = td["frames"]
# Normalize to (N,3,H,W)
if frames.ndim == 4 and frames.shape[-1] == 3 and frames.shape[1] != 3:
frames = frames.permute(0, 3, 1, 2).contiguous()
# Ensure uint8 storage (robust to float in [0,1] or [0,255])
if frames.dtype != torch.uint8:
frames_f = frames.to(torch.float32)
mx = float(frames_f.max().item()) if frames_f.numel() > 0 else 0.0
if mx > 1.5:
frames = frames_f.clamp(0, 255).to(torch.uint8)
else:
frames = (frames_f.clamp(0, 1) * 255.0).to(torch.uint8)
if frames.shape[-2] != self.H or frames.shape[-1] != self.W:
raise RuntimeError(f"Shard frame size {tuple(frames.shape[-2:])} != {(self.H, self.W)} in {path}")
self._cache_put(key, frames)
return frames
# map global frame idx -> segment -> shard within segment
def _get_frames(self, task_idx: int, start: int, length: int) -> torch.Tensor:
out = []
idx = int(start)
remaining = int(length)
seg_cum = self.seg_cum_frames[task_idx]
while remaining > 0:
seg_idx = bisect.bisect_right(seg_cum, idx)
prev_cum = 0 if seg_idx == 0 else seg_cum[seg_idx - 1]
seg_end = seg_cum[seg_idx]
local_idx = idx - prev_cum
# map local frame index within segment to shard using cumulative counts
shard_cum = self.shard_cum[task_idx][seg_idx]
shard_idx = bisect.bisect_right(shard_cum, local_idx)
shard_start = 0 if shard_idx == 0 else shard_cum[shard_idx - 1]
off = local_idx - shard_start
frames = self._load_shard_frames(task_idx, seg_idx, shard_idx)
take = min(remaining, frames.shape[0] - off)
# don't read past the segment's N_eff (important if segment was truncated)
seg_remaining = seg_end - idx
take = min(take, seg_remaining)
if take <= 0:
raise RuntimeError(
f"Frame indexing error task={self.tasks[task_idx]} idx={idx} seg_idx={seg_idx} "
f"local_idx={local_idx} shard_idx={shard_idx} off={off} shard_len={frames.shape[0]}"
)
out.append(frames[off:off + take])
idx += take
remaining -= take
return torch.cat(out, dim=0) # (length,3,H,W) uint8
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
if self.iid_sampling:
# Locality-friendly sampling: draw samples_per_shard consecutive
# samples from the same on-disk shard before switching, keeping
# the file hot in the LRU cache and reducing I/O.
if (self._cur_shard_entry is None
or self._cur_shard_draws >= self.samples_per_shard):
self._cur_shard_entry = _random.choices(
range(len(self._shard_entries)),
weights=self._shard_weights,
k=1,
)[0]
self._cur_shard_draws = 0
entry = self._shard_entries[self._cur_shard_entry]
self._cur_shard_draws += 1
task_idx = entry["task_idx"]
vs = entry["valid_starts"]
start = int(vs[_random.randrange(vs.numel())].item())
else:
task_idx, start = self._lookup(int(idx))
obs = self._get_frames(task_idx, start, self.T + 1) # (T+1,3,H,W) uint8
act = self.act[task_idx][start + 1 : start + 1 + self.T] # (T,16) float32 (padded)
rew = self.rew[task_idx][start + 1 : start + 1 + self.T] # (T,) float32
Ad = int(self._act_dims[task_idx])
act_padded = torch.zeros(self.T, self.A, dtype=torch.float32)
if Ad > 0:
act_padded[:, :Ad] = torch.nan_to_num(act[:, :Ad], nan=0.0)
act_mask = self._act_mask_1d[task_idx][None, :].expand(self.T, self.A).contiguous()
return {
"obs": obs,
"act": act_padded,
"act_mask": act_mask,
"rew": rew,
"lang_emb": self._lang_embs[task_idx],
"emb_id": self._emb_ids[task_idx],
}
def collate_batch(batch):
out = {}
for k in batch[0].keys():
out[k] = torch.stack([b[k] for b in batch], dim=0)
return out