"""Frozen EgoSpeak MiniROADA plus a trainable Omni residual branch. This module deliberately does not modify the vendored EgoSpeak checkout. The ``MiniROADABase`` parameter names match the released implementation so its raw state dict can be loaded strictly. Stage-one training freezes that complete base and learns only a zero-initialized residual over the ten anticipation logits. """ from __future__ import annotations import hashlib from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path from typing import Any, Literal import numpy as np import torch from torch import nn from torch.nn import functional as F from torch.utils.data import Dataset from easycom_head import NUM_CLASSES, NUM_HORIZONS, STEP_SECONDS OmniLayerFusion = Literal["single", "last", "mean"] def load_torch_payload(path: Path, *, weights_only: bool = False) -> Any: """Load a torch payload while retaining compatibility with older PyTorch.""" try: return torch.load(path, map_location="cpu", weights_only=weights_only) except TypeError: # pragma: no cover - only exercised by older PyTorch. return torch.load(path, map_location="cpu") def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def normalize_session_name(value: str | int) -> str: text = str(value).strip() suffix = text.removeprefix("Session_") if not suffix.isdigit(): raise ValueError(f"Invalid EasyCom session: {value!r}") return f"Session_{int(suffix)}" class MiniROADABase(nn.Module): """Clean-room parameter-compatible implementation of EgoSpeak MiniROADA.""" def __init__( self, *, rgb_dim: int = 2048, audio_dim: int = 5120, embedding_dim: int = 2048, hidden_dim: int = 1024, num_layers: int = 1, num_horizons: int = NUM_HORIZONS, num_classes: int = NUM_CLASSES, dropout: float = 0.2, ) -> None: super().__init__() self.rgb_dim = int(rgb_dim) self.audio_dim = int(audio_dim) self.embedding_dim = int(embedding_dim) self.hidden_dim = int(hidden_dim) self.num_layers = int(num_layers) self.num_horizons = int(num_horizons) self.num_classes = int(num_classes) self.layer1 = nn.Sequential( nn.Linear(self.rgb_dim + self.audio_dim, self.embedding_dim), nn.LayerNorm(self.embedding_dim), nn.ReLU(), nn.Dropout(p=dropout), ) self.relu = nn.ReLU() self.gru = nn.GRU( self.embedding_dim, self.hidden_dim, self.num_layers, batch_first=True, ) self.f_classification = nn.Sequential( nn.Linear(self.hidden_dim, self.num_classes) ) self.anticipation_layer = nn.Sequential( nn.Linear( self.hidden_dim, self.num_horizons * self.hidden_dim, ) ) def encode( self, rgb: torch.Tensor, audio: torch.Tensor, recurrent_state: torch.Tensor | None = None, ) -> tuple[torch.Tensor, torch.Tensor]: if rgb.ndim != 3 or rgb.shape[-1] != self.rgb_dim: raise ValueError( f"Expected RGB [B,S,{self.rgb_dim}], got {tuple(rgb.shape)}" ) if ( audio.ndim != 3 or audio.shape[:-1] != rgb.shape[:-1] or audio.shape[-1] != self.audio_dim ): raise ValueError( f"Expected audio [B,S,{self.audio_dim}] aligned with RGB, got " f"{tuple(audio.shape)}" ) embedded = self.layer1(torch.cat((rgb, audio), dim=-1)) hidden, next_state = self.gru(embedded, recurrent_state) return self.relu(hidden), next_state def anticipation_logits(self, hidden: torch.Tensor) -> torch.Tensor: shape = hidden.shape[:-1] anticipated = self.anticipation_layer(self.relu(hidden)).view( *shape, self.num_horizons, self.hidden_dim, ) return self.f_classification(self.relu(anticipated)) def current_logits(self, hidden: torch.Tensor) -> torch.Tensor: return self.f_classification(self.relu(hidden)) def forward( self, rgb: torch.Tensor, audio: torch.Tensor ) -> dict[str, torch.Tensor]: hidden, _ = self.encode(rgb, audio) return { "logits": self.current_logits(hidden), "anticipation_logits": self.anticipation_logits(hidden), } def _unwrap_state_dict(payload: Any) -> dict[str, torch.Tensor]: if not isinstance(payload, dict): raise TypeError("EgoSpeak checkpoint must contain a state dict") for key in ("state_dict", "model_state_dict"): nested = payload.get(key) if isinstance(nested, dict): payload = nested break if not payload or not all( isinstance(value, torch.Tensor) for value in payload.values() ): raise ValueError("EgoSpeak checkpoint does not contain tensor parameters") state = dict(payload) if all(key.startswith("module.") for key in state): state = {key.removeprefix("module."): value for key, value in state.items()} return state def load_frozen_miniroada(checkpoint: Path, **model_kwargs: Any) -> MiniROADABase: checkpoint = checkpoint.expanduser().resolve() if not checkpoint.is_file(): raise FileNotFoundError(checkpoint) base = MiniROADABase(**model_kwargs) state = _unwrap_state_dict(load_torch_payload(checkpoint, weights_only=True)) base.load_state_dict(state, strict=True) base.requires_grad_(False) base.eval() return base class EgoSpeakOmniResidual(nn.Module): """Feature fusion whose zero residual exactly recovers frozen MiniROADA.""" def __init__( self, base: MiniROADABase, *, omni_dim: int, fusion_dim: int = 256, tail_embedding_dim: int = 32, num_tail_bins: int = NUM_HORIZONS, dropout: float = 0.2, ) -> None: super().__init__() if fusion_dim <= 0 or tail_embedding_dim <= 0 or num_tail_bins <= 0: raise ValueError("Fusion and tail dimensions must be positive") self.base = base self.omni_dim = int(omni_dim) self.fusion_dim = int(fusion_dim) self.tail_embedding_dim = int(tail_embedding_dim) self.num_tail_bins = int(num_tail_bins) self.ego_projection = nn.Sequential( nn.LayerNorm(base.hidden_dim), nn.Linear(base.hidden_dim, fusion_dim), nn.GELU(), ) self.omni_projection = nn.Sequential( nn.LayerNorm(omni_dim), nn.Linear(omni_dim, fusion_dim), nn.GELU(), ) self.tail_embedding = nn.Embedding(num_tail_bins, tail_embedding_dim) self.fusion = nn.Sequential( nn.LayerNorm(3 * fusion_dim + tail_embedding_dim), nn.Linear(3 * fusion_dim + tail_embedding_dim, fusion_dim), nn.GELU(), nn.Dropout(dropout), ) self.residual_output = nn.Linear( fusion_dim, base.num_horizons * base.num_classes, ) nn.init.zeros_(self.residual_output.weight) nn.init.zeros_(self.residual_output.bias) self.base.requires_grad_(False) self.base.eval() def train(self, mode: bool = True) -> EgoSpeakOmniResidual: super().train(mode) # The released base remains deterministic: in particular, its dropout # must never reactivate during stage-one residual training. self.base.eval() return self def trainable_parameters(self) -> list[nn.Parameter]: return [parameter for parameter in self.parameters() if parameter.requires_grad] def _residual_logits( self, ego_hidden: torch.Tensor, omni: torch.Tensor, tail_bin: torch.Tensor, ) -> torch.Tensor: if omni.shape != (*ego_hidden.shape[:-1], self.omni_dim): raise ValueError( f"Omni/hidden shape mismatch: {tuple(omni.shape)} versus " f"{tuple(ego_hidden.shape)}" ) if tail_bin.shape != ego_hidden.shape[:-1]: raise ValueError( f"Tail-bin shape mismatch: {tuple(tail_bin.shape)} versus " f"{tuple(ego_hidden.shape[:-1])}" ) if tail_bin.dtype != torch.long: raise ValueError("tail_bin must be int64") if bool(((tail_bin < 0) | (tail_bin >= self.num_tail_bins)).any()): raise ValueError("tail_bin lies outside the configured block phase") ego = self.ego_projection(ego_hidden) global_state = self.omni_projection(omni) fused = torch.cat( ( ego, global_state, ego * global_state, self.tail_embedding(tail_bin), ), dim=-1, ) residual = self.residual_output(self.fusion(fused)) return residual.view( *ego_hidden.shape[:-1], self.base.num_horizons, self.base.num_classes, ) def forward_last( self, rgb: torch.Tensor, audio: torch.Tensor, omni: torch.Tensor, tail_bin: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: with torch.no_grad(): hidden, _ = self.base.encode(rgb, audio) ego_hidden = hidden[:, -1, :] base_logits = self.base.anticipation_logits(ego_hidden) residual = self._residual_logits(ego_hidden.detach(), omni, tail_bin) return base_logits + residual, base_logits def forward_sequence_chunk( self, rgb: torch.Tensor, audio: torch.Tensor, omni: torch.Tensor, tail_bin: torch.Tensor, recurrent_state: torch.Tensor | None, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: with torch.no_grad(): hidden, next_state = self.base.encode(rgb, audio, recurrent_state) base_logits = self.base.anticipation_logits(hidden) residual = self._residual_logits(hidden.detach(), omni, tail_bin) return base_logits + residual, base_logits, next_state def collapse_omni_states( states: torch.Tensor, mode: OmniLayerFusion, ) -> torch.Tensor: if not isinstance(states, torch.Tensor) or states.ndim not in (2, 3): raise ValueError("Omni states must be [T,D] or [T,L,D]") if not torch.is_floating_point(states) or not torch.isfinite(states).all(): raise ValueError("Omni states must be finite floating-point values") if states.ndim == 2: if mode not in ("single", "last", "mean"): raise ValueError(f"Unsupported Omni layer fusion: {mode}") return states.float().contiguous() if mode == "single": raise ValueError("Stacked Omni states require --omni-layer-fusion last or mean") if mode == "last": return states[:, -1, :].float().contiguous() if mode == "mean": return states.float().mean(dim=1).contiguous() raise ValueError(f"Unsupported Omni layer fusion: {mode}") @dataclass class EasyComSessionArrays: name: str rgb: np.ndarray audio: np.ndarray targets: np.ndarray omni: torch.Tensor tail_bins: torch.LongTensor omni_metadata: dict[str, Any] @property def num_steps(self) -> int: return len(self.targets) def _require_feature_array(path: Path, expected_dim: int) -> np.ndarray: if not path.is_file(): raise FileNotFoundError(path) array = np.load(path, mmap_mode="r", allow_pickle=False) if array.ndim != 2 or array.shape[1] != expected_dim: raise ValueError(f"Expected [T,{expected_dim}], got {array.shape}: {path}") if not np.issubdtype(array.dtype, np.floating): raise ValueError(f"Expected floating-point features: {path}") return array def _require_target_array(path: Path, expected_dim: int) -> np.ndarray: if not path.is_file(): raise FileNotFoundError(path) array = np.load(path, mmap_mode="r", allow_pickle=False) if array.ndim != 2 or array.shape[1] != expected_dim: raise ValueError(f"Expected [T,{expected_dim}], got {array.shape}: {path}") if not ( np.issubdtype(array.dtype, np.number) or np.issubdtype(array.dtype, np.bool_) ): raise ValueError(f"Expected numeric activity targets: {path}") return array def load_easycom_session_arrays( egospeak_feature_root: Path, omni_feature_root: Path, session: str | int, *, expected_omni_mode: str | None = "open-stream-replay", omni_layer_fusion: OmniLayerFusion = "single", rgb_dim: int = 2048, audio_dim: int = 5120, num_classes: int = NUM_CLASSES, ) -> EasyComSessionArrays: name = normalize_session_name(session) ego_root = egospeak_feature_root.expanduser().resolve() omni_root = omni_feature_root.expanduser().resolve() rgb = _require_feature_array( ego_root / "rgb_kinetics_resnet50" / f"{name}.npy", rgb_dim, ) audio = _require_feature_array( ego_root / "audio_wav2vec2" / f"{name}.npy", audio_dim, ) targets = _require_target_array( ego_root / "target_perframe" / f"{name}.npy", num_classes, ) lengths = {len(rgb), len(audio), len(targets)} if len(lengths) != 1: raise ValueError( f"{name}: unequal EgoSpeak RGB/audio/target lengths " f"{len(rgb)}/{len(audio)}/{len(targets)}" ) unique_targets = np.unique(np.asarray(targets)) if not np.isin(unique_targets, (0, 1)).all(): raise ValueError( f"{name}: target_perframe contains values outside binary activity: " f"{unique_targets.tolist()}" ) omni_path = omni_root / f"{name}.pt" if not omni_path.is_file(): raise FileNotFoundError(omni_path) payload = load_torch_payload(omni_path) if not isinstance(payload, dict): raise TypeError(f"Invalid Omni payload: {omni_path}") mode = payload.get("mode") if expected_omni_mode is not None and mode != expected_omni_mode: raise ValueError( f"{name}: Omni mode is {mode!r}, expected {expected_omni_mode!r}" ) omni = collapse_omni_states(payload.get("states"), omni_layer_fusion) if len(omni) != len(targets): raise ValueError( f"{name}: Omni/target length mismatch {len(omni)} vs {len(targets)}" ) if payload.get("label_indices") is None: raise ValueError(f"{name}: Omni payload lacks label_indices") label_indices = torch.as_tensor(payload["label_indices"], dtype=torch.long) expected_indices = torch.arange(len(targets), dtype=torch.long) if label_indices.ndim != 1 or not torch.equal(label_indices, expected_indices): raise ValueError(f"{name}: Omni label_indices are not dense zero-origin rows") config = payload.get("config") if not isinstance(config, dict): raise TypeError(f"{name}: Omni payload lacks extraction config") step_seconds = float(config.get("step_seconds", float("nan"))) native_block_seconds = float(config.get("native_block_seconds", float("nan"))) if not np.isclose(step_seconds, STEP_SECONDS, rtol=0.0, atol=1e-9): raise ValueError( f"{name}: Omni step_seconds={step_seconds}, expected {STEP_SECONDS}" ) block_steps_float = native_block_seconds / step_seconds block_steps = round(block_steps_float) if block_steps <= 0 or not np.isclose( block_steps_float, block_steps, rtol=0.0, atol=1e-9 ): raise ValueError( f"{name}: native block {native_block_seconds}s is not an integer " f"multiple of {step_seconds}s" ) if block_steps != NUM_HORIZONS: raise ValueError( f"{name}: expected ten 0.2-s tail bins in a 2-s block, got {block_steps}" ) times = torch.as_tensor(payload.get("times"), dtype=torch.float64) start = float(payload.get("start", 0.0)) expected_times = ( start + (torch.arange(len(targets), dtype=torch.float64) + 1) * step_seconds ) if times.ndim != 1 or not torch.allclose( times, expected_times, rtol=0.0, atol=1e-6 ): raise ValueError(f"{name}: Omni times do not match the dense 5 Hz grid") if bool(payload.get("partial", False)): raise ValueError(f"{name}: partial Omni smoke feature cannot train the model") tail_bins = torch.remainder(expected_indices, block_steps) metadata = { "path": str(omni_path), "mode": mode, "model_id": payload.get("model_id"), "config": config, "video_sampling_grid": payload.get("video_sampling_grid"), "media_interval_policy": payload.get("media_interval_policy"), "open_stream_policy": payload.get("open_stream_policy"), "state_shape_before_layer_fusion": list(payload["states"].shape), "state_shape": list(omni.shape), } return EasyComSessionArrays( name=name, rgb=rgb, audio=audio, targets=targets, omni=omni, tail_bins=tail_bins, omni_metadata=metadata, ) class EgoSpeakOmniWindowDataset(Dataset[tuple[torch.Tensor, ...]]): """MiniROADA windows plus the aligned current Omni state.""" def __init__( self, sessions: Sequence[EasyComSessionArrays], *, window_size: int = 128, stride: int = 4, num_horizons: int = NUM_HORIZONS, seed: int = 0, max_examples: int | None = None, ) -> None: if not sessions: raise ValueError("At least one training session is required") if window_size <= 0 or stride <= 0 or num_horizons <= 0: raise ValueError("Window, stride, and horizon counts must be positive") if max_examples is not None and max_examples <= 0: raise ValueError("max_examples must be positive") self.sessions = list(sessions) self.window_size = int(window_size) self.stride = int(stride) self.num_horizons = int(num_horizons) self.seed = int(seed) self.max_examples = max_examples self.indices: list[tuple[int, int]] = [] self.resample(0) def resample(self, epoch: int) -> None: rng = np.random.default_rng(self.seed + int(epoch)) indices: list[tuple[int, int]] = [] for session_index, session in enumerate(self.sessions): last_source = session.num_steps - self.num_horizons - 1 if last_source < 0: raise ValueError( f"{session.name}: only {session.num_steps} rows for " f"{self.num_horizons} horizons" ) offset = int(rng.integers(self.stride)) indices.extend( (session_index, source_index) for source_index in range(offset, last_source + 1, self.stride) ) if self.max_examples is not None: rng.shuffle(indices) indices = indices[: self.max_examples] if not indices: raise ValueError("Training resampling produced no examples") self.indices = indices def __len__(self) -> int: return len(self.indices) def __getitem__(self, index: int) -> tuple[torch.Tensor, ...]: session_index, source_index = self.indices[index] session = self.sessions[session_index] first = max(0, source_index - self.window_size + 1) length = source_index - first + 1 rgb = torch.zeros( self.window_size, session.rgb.shape[1], dtype=torch.float32, ) audio = torch.zeros( self.window_size, session.audio.shape[1], dtype=torch.float32, ) rgb[-length:] = torch.from_numpy( np.asarray(session.rgb[first : source_index + 1], dtype=np.float32).copy() ) audio[-length:] = torch.from_numpy( np.asarray(session.audio[first : source_index + 1], dtype=np.float32).copy() ) future = torch.from_numpy( np.asarray( session.targets[ source_index + 1 : source_index + self.num_horizons + 1 ], dtype=np.float32, ).copy() ) return ( rgb, audio, session.omni[source_index].float(), session.tail_bins[source_index], future, torch.tensor(session_index, dtype=torch.long), torch.tensor(source_index, dtype=torch.long), ) def miniroad_normalized_loss( logits: torch.Tensor, targets: torch.Tensor, ) -> torch.Tensor: """Released MiniROAD multi-positive anticipation objective.""" if logits.shape != targets.shape or logits.shape[-1] != NUM_CLASSES: raise ValueError( f"Expected equal [...,{NUM_CLASSES}] logits/targets, got " f"{tuple(logits.shape)} and {tuple(targets.shape)}" ) normalized_targets = F.normalize(targets, dim=-1) return torch.sum( -normalized_targets * F.log_softmax(logits, dim=-1), dim=-1, ).mean() def align_source_predictions( raw_prediction: np.ndarray, target_steps: int, ) -> tuple[np.ndarray, np.ndarray]: """Align MiniROADA [source,horizon,class] forecasts to target rows.""" raw_prediction = np.asarray(raw_prediction, dtype=np.float32) expected = (target_steps - NUM_HORIZONS, NUM_HORIZONS, NUM_CLASSES) if raw_prediction.shape != expected: raise ValueError( f"Expected raw prediction {expected}, got {raw_prediction.shape}" ) if not np.isfinite(raw_prediction).all(): raise ValueError("Raw prediction contains NaN/Inf") predictions = np.zeros( (target_steps, NUM_CLASSES, NUM_HORIZONS), dtype=np.float32, ) valid = np.zeros((target_steps, NUM_HORIZONS), dtype=bool) source_rows = np.arange(len(raw_prediction), dtype=np.int64) for horizon in range(NUM_HORIZONS): target_rows = source_rows + horizon + 1 predictions[target_rows, :, horizon] = raw_prediction[:, horizon, :] valid[target_rows, horizon] = True return predictions, valid