1426 lines
53 KiB
Python
1426 lines
53 KiB
Python
"""Shared head, labels, and metrics for the EasyCom/Ego4D experiments."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Literal, Sequence
|
|
from uuid import UUID
|
|
|
|
import numpy as np
|
|
import torch
|
|
from torch import nn
|
|
|
|
if TYPE_CHECKING:
|
|
from local_turn_features import FramePersonFeatures
|
|
|
|
|
|
OFFICIAL_TRAIN_SESSIONS = (
|
|
"Session_7",
|
|
"Session_11",
|
|
"Session_10",
|
|
"Session_6",
|
|
"Session_9",
|
|
"Session_4",
|
|
"Session_8",
|
|
"Session_5",
|
|
"Session_12",
|
|
)
|
|
OFFICIAL_TEST_SESSIONS = ("Session_3", "Session_2", "Session_1")
|
|
CLASS_NAMES = (
|
|
"background",
|
|
"camera_wearer_speaking",
|
|
"other_people_speaking",
|
|
)
|
|
FOUR_STATE_NAMES = (
|
|
"silence",
|
|
"wearer_only",
|
|
"others_only",
|
|
"overlap",
|
|
)
|
|
STEP_SECONDS = 0.2
|
|
NUM_HORIZONS = 10
|
|
NUM_CLASSES = 3
|
|
NUM_FOUR_STATES = len(FOUR_STATE_NAMES)
|
|
LOSS_NAMES = (
|
|
"lstr-mce",
|
|
"miniroad-normalized",
|
|
"independent-bce",
|
|
"four-state-ce",
|
|
)
|
|
TRANSITION_OBJECTIVE_NAMES = (
|
|
"none",
|
|
"r1-supervised",
|
|
"r2-expected-reward",
|
|
)
|
|
DATASET_NAMES = ("easycom", "ego4d")
|
|
LAYER_FUSION_MODES = (
|
|
"single",
|
|
"fixed-mean",
|
|
"scalar-mixture",
|
|
"concat-mlp",
|
|
)
|
|
CURRENT_FRAME_PERSON_SET_FUSION_MODES = (
|
|
"frame-set-attention",
|
|
"oracle-frame-set-attention",
|
|
"audio-person-frame-set",
|
|
"gated-person-state-adapter",
|
|
)
|
|
TRACK_PERSON_FUSION_MODES = ("track-gru-residual",)
|
|
PERSON_SET_FUSION_MODES = (
|
|
*CURRENT_FRAME_PERSON_SET_FUSION_MODES,
|
|
*TRACK_PERSON_FUSION_MODES,
|
|
)
|
|
LOCAL_FUSION_MODES = ("pooled", *PERSON_SET_FUSION_MODES)
|
|
LOCAL_FUSION_SCOPES = {
|
|
"pooled": "pooled-baseline",
|
|
"frame-set-attention": "f1-current-frame-person-set",
|
|
"oracle-frame-set-attention": "causal-oracle-visible-person-speaking-history",
|
|
"audio-person-frame-set": "f5-screening-current-frame-no-person-history",
|
|
"gated-person-state-adapter": "f6-screening-post-thinker-not-internal",
|
|
"track-gru-residual": "causal-anonymous-track-gru-pooled-baseline-residual",
|
|
}
|
|
DatasetName = Literal["easycom", "ego4d"]
|
|
|
|
|
|
class TurnPredictionHead(nn.Module):
|
|
"""Map one Omni state to configurable ``[horizon, output]`` logits."""
|
|
|
|
def __init__(
|
|
self,
|
|
hidden_size: int = 2048,
|
|
num_horizons: int = NUM_HORIZONS,
|
|
num_classes: int = NUM_CLASSES,
|
|
dropout: float = 0.1,
|
|
num_input_layers: int = 1,
|
|
learnable_layer_mixture: bool = False,
|
|
layer_fusion: str | None = None,
|
|
transition_auxiliary: bool = False,
|
|
local_visual_dim: int = 0,
|
|
local_audio_dim: int = 0,
|
|
local_projection_dim: int = 256,
|
|
local_fusion: str = "pooled",
|
|
person_attention_dim: int = 256,
|
|
person_attention_heads: int = 4,
|
|
person_history_frames: int = 16,
|
|
person_track_max_gap_seconds: float = 1.0,
|
|
) -> None:
|
|
super().__init__()
|
|
if num_input_layers <= 0:
|
|
raise ValueError("num_input_layers must be positive")
|
|
if layer_fusion is None:
|
|
layer_fusion = (
|
|
"scalar-mixture" if learnable_layer_mixture else "single"
|
|
)
|
|
if layer_fusion not in LAYER_FUSION_MODES:
|
|
raise ValueError(
|
|
f"Unknown layer_fusion={layer_fusion!r}; expected one of "
|
|
f"{LAYER_FUSION_MODES}"
|
|
)
|
|
if learnable_layer_mixture and layer_fusion != "scalar-mixture":
|
|
raise ValueError(
|
|
"learnable_layer_mixture=True is only compatible with "
|
|
"layer_fusion='scalar-mixture'"
|
|
)
|
|
if layer_fusion != "single" and num_input_layers < 2:
|
|
raise ValueError(
|
|
f"layer_fusion={layer_fusion!r} requires at least two input layers"
|
|
)
|
|
if layer_fusion == "single" and num_input_layers != 1:
|
|
raise ValueError(
|
|
"Multiple input layers require a non-single layer_fusion"
|
|
)
|
|
self.hidden_size = hidden_size
|
|
self.num_horizons = num_horizons
|
|
self.num_classes = num_classes
|
|
self.dropout = dropout
|
|
self.num_input_layers = num_input_layers
|
|
self.layer_fusion = layer_fusion
|
|
self.learnable_layer_mixture = layer_fusion == "scalar-mixture"
|
|
self.transition_auxiliary = transition_auxiliary
|
|
if min(local_visual_dim, local_audio_dim, local_projection_dim) < 0:
|
|
raise ValueError("Local dimensions must be non-negative")
|
|
self.local_visual_dim = local_visual_dim
|
|
self.local_audio_dim = local_audio_dim
|
|
self.local_projection_dim = local_projection_dim
|
|
if local_fusion not in LOCAL_FUSION_MODES:
|
|
raise ValueError(
|
|
f"Unknown local_fusion={local_fusion!r}; expected one of "
|
|
f"{LOCAL_FUSION_MODES}"
|
|
)
|
|
if person_attention_dim <= 0 or person_attention_heads <= 0:
|
|
raise ValueError("Person attention dimension/heads must be positive")
|
|
if person_attention_dim % person_attention_heads:
|
|
raise ValueError("Person attention dimension must be divisible by heads")
|
|
if person_history_frames <= 0:
|
|
raise ValueError("person_history_frames must be positive")
|
|
if person_track_max_gap_seconds <= 0:
|
|
raise ValueError("person_track_max_gap_seconds must be positive")
|
|
if local_fusion in PERSON_SET_FUSION_MODES and (
|
|
local_visual_dim <= 0 or local_audio_dim <= 0
|
|
):
|
|
raise ValueError(
|
|
"Person-set fusion requires both local visual people and audio"
|
|
)
|
|
self.local_fusion = local_fusion
|
|
self.person_attention_dim = person_attention_dim
|
|
self.person_attention_heads = person_attention_heads
|
|
self.person_history_frames = person_history_frames
|
|
self.person_track_max_gap_seconds = person_track_max_gap_seconds
|
|
if self.learnable_layer_mixture:
|
|
# Zero initialization exactly reproduces a uniform layer mean at
|
|
# step zero while allowing training to select useful query layers.
|
|
self.layer_logits = nn.Parameter(torch.zeros(num_input_layers))
|
|
else:
|
|
self.register_parameter("layer_logits", None)
|
|
if layer_fusion == "concat-mlp":
|
|
fusion_input_size = num_input_layers * hidden_size
|
|
self.layer_fusion_net: nn.Module | None = nn.Sequential(
|
|
nn.LayerNorm(fusion_input_size),
|
|
nn.Linear(fusion_input_size, hidden_size),
|
|
nn.GELU(),
|
|
)
|
|
else:
|
|
self.layer_fusion_net = None
|
|
self.local_projections = nn.ModuleDict()
|
|
fused_size = hidden_size
|
|
projected_modalities = (
|
|
(("audio", local_audio_dim),)
|
|
if local_fusion in CURRENT_FRAME_PERSON_SET_FUSION_MODES
|
|
else (("visual", local_visual_dim), ("audio", local_audio_dim))
|
|
)
|
|
for name, dimension in projected_modalities:
|
|
if dimension:
|
|
self.local_projections[name] = (
|
|
nn.Sequential(
|
|
nn.LayerNorm(dimension),
|
|
nn.Linear(dimension, local_projection_dim),
|
|
nn.GELU(),
|
|
)
|
|
if local_projection_dim
|
|
else nn.Identity()
|
|
)
|
|
fused_size += (local_projection_dim or dimension) + 1
|
|
self.person_input_projection: nn.Module | None = None
|
|
self.person_state_query: nn.Module | None = None
|
|
self.person_audio_query: nn.Module | None = None
|
|
self.person_attention: nn.MultiheadAttention | None = None
|
|
self.null_person: nn.Parameter | None = None
|
|
self.audio_person_attention: nn.MultiheadAttention | None = None
|
|
self.audio_person_gate: nn.Parameter | None = None
|
|
self.person_state_adapter: nn.Module | None = None
|
|
self.person_state_adapter_gate: nn.Parameter | None = None
|
|
self.person_track_gru: nn.GRU | None = None
|
|
self.person_track_delta: nn.Module | None = None
|
|
self.person_track_residual_gate: nn.Parameter | None = None
|
|
if local_fusion in PERSON_SET_FUSION_MODES:
|
|
self.person_input_projection = nn.Sequential(
|
|
nn.LayerNorm(local_visual_dim),
|
|
nn.Linear(local_visual_dim, person_attention_dim),
|
|
nn.GELU(),
|
|
)
|
|
self.person_state_query = nn.Sequential(
|
|
nn.LayerNorm(hidden_size),
|
|
nn.Linear(hidden_size, person_attention_dim),
|
|
)
|
|
self.person_audio_query = nn.Sequential(
|
|
nn.LayerNorm(local_audio_dim),
|
|
nn.Linear(local_audio_dim, person_attention_dim),
|
|
)
|
|
self.person_attention = nn.MultiheadAttention(
|
|
person_attention_dim,
|
|
person_attention_heads,
|
|
dropout=dropout,
|
|
batch_first=True,
|
|
)
|
|
self.null_person = nn.Parameter(
|
|
torch.zeros(1, 1, person_attention_dim)
|
|
)
|
|
nn.init.normal_(self.null_person, std=0.02)
|
|
if local_fusion == "audio-person-frame-set":
|
|
# Screening version of the F5 reverse read: an audio-only
|
|
# query first reads the current person memory, then a
|
|
# zero-initialized residual gate adds that evidence to the
|
|
# regular G+A floor query. This remains a current-frame
|
|
# module; it is not the full history-conditioned F5 block.
|
|
self.audio_person_attention = nn.MultiheadAttention(
|
|
person_attention_dim,
|
|
person_attention_heads,
|
|
dropout=dropout,
|
|
batch_first=True,
|
|
)
|
|
self.audio_person_gate = nn.Parameter(torch.zeros(()))
|
|
if local_fusion == "gated-person-state-adapter":
|
|
# Screening version of F6 for precomputed frozen states. It
|
|
# injects selected person evidence after the Thinker readout,
|
|
# not into a Thinker transformer block. The scalar gate makes
|
|
# the visual residual exactly zero at initialization.
|
|
self.person_state_adapter = nn.Sequential(
|
|
nn.LayerNorm(person_attention_dim),
|
|
nn.Linear(person_attention_dim, person_attention_dim),
|
|
nn.GELU(),
|
|
nn.Linear(person_attention_dim, hidden_size),
|
|
)
|
|
self.person_state_adapter_gate = nn.Parameter(torch.zeros(()))
|
|
elif local_fusion == "track-gru-residual":
|
|
# Apply one shared causal GRU independently to every anonymous
|
|
# face track. The regular pooled G+A+V head remains the exact
|
|
# base path; this branch can only add a zero-gated correction.
|
|
self.person_track_gru = nn.GRU(
|
|
person_attention_dim,
|
|
person_attention_dim,
|
|
num_layers=1,
|
|
batch_first=True,
|
|
)
|
|
delta_input = hidden_size // 2 + person_attention_dim + 1
|
|
self.person_track_delta = nn.Sequential(
|
|
nn.LayerNorm(delta_input),
|
|
nn.Linear(delta_input, hidden_size // 2),
|
|
nn.GELU(),
|
|
nn.Dropout(dropout),
|
|
nn.Linear(hidden_size // 2, num_horizons * num_classes),
|
|
)
|
|
self.person_track_residual_gate = nn.Parameter(torch.zeros(()))
|
|
else:
|
|
fused_size += person_attention_dim + 1
|
|
self.net = nn.Sequential(
|
|
nn.LayerNorm(fused_size),
|
|
nn.Linear(fused_size, hidden_size // 2),
|
|
nn.GELU(),
|
|
nn.Dropout(dropout),
|
|
nn.Linear(hidden_size // 2, num_horizons * num_classes),
|
|
)
|
|
self.transition_head: nn.Module | None
|
|
if transition_auxiliary:
|
|
# Share the activity MLP trunk and add only a small binary readout
|
|
# for each future horizon.
|
|
self.transition_head = nn.Linear(hidden_size // 2, num_horizons)
|
|
else:
|
|
self.transition_head = None
|
|
|
|
def _fuse_layers(self, states: torch.Tensor) -> torch.Tensor:
|
|
if states.shape[-1] != self.hidden_size:
|
|
raise ValueError(
|
|
f"Expected hidden size {self.hidden_size}, got {states.shape[-1]}"
|
|
)
|
|
if self.layer_fusion != "single":
|
|
if states.ndim < 3 or states.shape[-2] != self.num_input_layers:
|
|
raise ValueError(
|
|
"Multi-layer input must end in "
|
|
f"[{self.num_input_layers},{self.hidden_size}], got "
|
|
f"{tuple(states.shape)}"
|
|
)
|
|
if self.layer_fusion == "fixed-mean":
|
|
states = states.mean(dim=-2)
|
|
elif self.layer_fusion == "scalar-mixture":
|
|
assert self.layer_logits is not None
|
|
weights = self.layer_logits.softmax(dim=0).to(states.dtype)
|
|
view_shape = (1,) * (states.ndim - 2) + (
|
|
self.num_input_layers,
|
|
1,
|
|
)
|
|
states = (states * weights.view(view_shape)).sum(dim=-2)
|
|
elif self.layer_fusion == "concat-mlp":
|
|
assert self.layer_fusion_net is not None
|
|
states = states.flatten(start_dim=-2)
|
|
states = self.layer_fusion_net(states)
|
|
return states
|
|
|
|
@property
|
|
def local_input_size(self) -> int:
|
|
if self.local_fusion in CURRENT_FRAME_PERSON_SET_FUSION_MODES:
|
|
return self.local_audio_dim + 1
|
|
return sum(d + 1 for d in (self.local_visual_dim, self.local_audio_dim) if d)
|
|
|
|
def _frame_set_context(
|
|
self,
|
|
states: torch.Tensor,
|
|
local: torch.Tensor,
|
|
person_features: torch.Tensor | None,
|
|
person_mask: torch.Tensor | None,
|
|
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
if states.ndim != 2:
|
|
raise ValueError("Person-set fusion expects batched [B,D] states")
|
|
if local.shape != (len(states), self.local_audio_dim + 1):
|
|
raise ValueError(
|
|
"Person-set fusion requires aligned [audio,valid] local features"
|
|
)
|
|
if (
|
|
person_features is None
|
|
or person_features.ndim != 3
|
|
or person_features.shape[0] != len(states)
|
|
or person_features.shape[2] != self.local_visual_dim
|
|
):
|
|
raise ValueError(
|
|
"Person-set fusion requires person features [B,P,visual_dim]"
|
|
)
|
|
if (
|
|
person_mask is None
|
|
or person_mask.dtype != torch.bool
|
|
or person_mask.shape != person_features.shape[:2]
|
|
):
|
|
raise ValueError("Person-set fusion requires a boolean person mask [B,P]")
|
|
if not torch.isfinite(person_features).all():
|
|
raise ValueError("Person features contain NaN/Inf")
|
|
assert self.person_input_projection is not None
|
|
assert self.person_state_query is not None
|
|
assert self.person_audio_query is not None
|
|
assert self.person_attention is not None
|
|
assert self.null_person is not None
|
|
|
|
audio_values = local[:, : self.local_audio_dim]
|
|
audio_valid = local[:, self.local_audio_dim : self.local_audio_dim + 1]
|
|
audio_projected = self.local_projections["audio"](audio_values) * audio_valid
|
|
audio_query = self.person_audio_query(audio_values) * audio_valid
|
|
query = self.person_state_query(states) + audio_query
|
|
people = self.person_input_projection(person_features)
|
|
null = self.null_person.expand(len(states), -1, -1)
|
|
memory = torch.cat((null, people), dim=1)
|
|
null_mask = torch.ones(
|
|
(len(states), 1),
|
|
dtype=torch.bool,
|
|
device=person_mask.device,
|
|
)
|
|
memory_valid = torch.cat((null_mask, person_mask), dim=1)
|
|
if self.audio_person_attention is not None:
|
|
assert self.audio_person_gate is not None
|
|
audio_context, _ = self.audio_person_attention(
|
|
audio_query[:, None, :],
|
|
memory,
|
|
memory,
|
|
key_padding_mask=~memory_valid,
|
|
need_weights=False,
|
|
)
|
|
query = query + (
|
|
torch.tanh(self.audio_person_gate)
|
|
* audio_context[:, 0, :]
|
|
* audio_valid
|
|
)
|
|
context, _ = self.person_attention(
|
|
query[:, None, :],
|
|
memory,
|
|
memory,
|
|
key_padding_mask=~memory_valid,
|
|
need_weights=False,
|
|
)
|
|
person_valid = person_mask.any(dim=1, keepdim=True).to(states.dtype)
|
|
return audio_projected, audio_valid, context[:, 0, :], person_valid
|
|
|
|
def _track_gru_context(
|
|
self,
|
|
states: torch.Tensor,
|
|
local: torch.Tensor,
|
|
person_histories: torch.Tensor | None,
|
|
history_mask: torch.Tensor | None,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""Encode each causal track with a shared GRU, then query the set."""
|
|
|
|
if states.ndim != 2:
|
|
raise ValueError("Track GRU fusion expects batched [B,D] states")
|
|
if local.shape != (len(states), self.local_input_size):
|
|
raise ValueError("Track GRU fusion requires pooled [visual,audio,valid] input")
|
|
if (
|
|
person_histories is None
|
|
or person_histories.ndim != 4
|
|
or person_histories.shape[0] != len(states)
|
|
or person_histories.shape[2] != self.person_history_frames
|
|
or person_histories.shape[3] != self.local_visual_dim
|
|
):
|
|
raise ValueError(
|
|
"Track GRU fusion requires person histories "
|
|
"[B,P,person_history_frames,visual_dim]"
|
|
)
|
|
if (
|
|
history_mask is None
|
|
or history_mask.dtype != torch.bool
|
|
or history_mask.shape != person_histories.shape[:3]
|
|
):
|
|
raise ValueError("Track GRU fusion requires a boolean history mask [B,P,L]")
|
|
if not torch.isfinite(person_histories).all():
|
|
raise ValueError("Person track histories contain NaN/Inf")
|
|
if bool((history_mask[:, :, 1:] & ~history_mask[:, :, :-1]).any()):
|
|
raise ValueError("Person track histories must be left aligned")
|
|
assert self.person_input_projection is not None
|
|
assert self.person_state_query is not None
|
|
assert self.person_audio_query is not None
|
|
assert self.person_attention is not None
|
|
assert self.null_person is not None
|
|
assert self.person_track_gru is not None
|
|
|
|
batch, people, length, _ = person_histories.shape
|
|
person_valid = history_mask.any(dim=-1)
|
|
if people:
|
|
projected = self.person_input_projection(person_histories)
|
|
flat = projected.reshape(batch * people, length, -1)
|
|
lengths = history_mask.sum(dim=-1).reshape(-1)
|
|
safe_lengths = lengths.clamp_min(1).to(device="cpu")
|
|
packed = nn.utils.rnn.pack_padded_sequence(
|
|
flat,
|
|
safe_lengths,
|
|
batch_first=True,
|
|
enforce_sorted=False,
|
|
)
|
|
_, hidden = self.person_track_gru(packed)
|
|
track_states = hidden[-1].reshape(batch, people, -1)
|
|
track_states = track_states * person_valid[..., None]
|
|
else:
|
|
track_states = states.new_zeros(
|
|
(batch, 0, self.person_attention_dim)
|
|
)
|
|
|
|
visual_width = self.local_visual_dim + 1
|
|
audio_values = local[:, visual_width : visual_width + self.local_audio_dim]
|
|
audio_valid = local[:, visual_width + self.local_audio_dim :]
|
|
if audio_valid.shape != (batch, 1):
|
|
raise AssertionError("Unexpected pooled AV local feature layout")
|
|
query = self.person_state_query(states)
|
|
query = query + self.person_audio_query(audio_values) * audio_valid
|
|
memory = torch.cat(
|
|
(self.null_person.expand(batch, -1, -1), track_states), dim=1
|
|
)
|
|
memory_valid = torch.cat(
|
|
(
|
|
torch.ones((batch, 1), dtype=torch.bool, device=states.device),
|
|
person_valid,
|
|
),
|
|
dim=1,
|
|
)
|
|
context, _ = self.person_attention(
|
|
query[:, None, :],
|
|
memory,
|
|
memory,
|
|
key_padding_mask=~memory_valid,
|
|
need_weights=False,
|
|
)
|
|
any_person = person_valid.any(dim=1, keepdim=True).to(states.dtype)
|
|
return context[:, 0, :] * any_person, any_person
|
|
|
|
def _shared_features(
|
|
self,
|
|
states: torch.Tensor,
|
|
local: torch.Tensor | None = None,
|
|
person_features: torch.Tensor | None = None,
|
|
person_mask: torch.Tensor | None = None,
|
|
) -> torch.Tensor:
|
|
"""Return the activity MLP representation shared by both readouts."""
|
|
|
|
states = self._fuse_layers(states)
|
|
if self.local_fusion in CURRENT_FRAME_PERSON_SET_FUSION_MODES:
|
|
if local is None:
|
|
raise ValueError("Person-set fusion requires local audio features")
|
|
audio, audio_valid, person_context, person_valid = self._frame_set_context(
|
|
states,
|
|
local,
|
|
person_features,
|
|
person_mask,
|
|
)
|
|
if self.local_fusion == "gated-person-state-adapter":
|
|
assert self.person_state_adapter is not None
|
|
assert self.person_state_adapter_gate is not None
|
|
states = states + (
|
|
torch.tanh(self.person_state_adapter_gate)
|
|
* self.person_state_adapter(person_context)
|
|
* person_valid
|
|
)
|
|
states = torch.cat((states, audio, audio_valid), dim=-1)
|
|
else:
|
|
states = torch.cat(
|
|
(states, audio, audio_valid, person_context, person_valid),
|
|
dim=-1,
|
|
)
|
|
elif self.local_input_size:
|
|
if local is None or local.shape != (*states.shape[:-1], self.local_input_size):
|
|
raise ValueError(
|
|
f"Head requires aligned local features of width {self.local_input_size}"
|
|
)
|
|
pieces = [states]
|
|
offset = 0
|
|
for name, dimension in (
|
|
("visual", self.local_visual_dim),
|
|
("audio", self.local_audio_dim),
|
|
):
|
|
if dimension:
|
|
values = local[..., offset : offset + dimension]
|
|
valid = local[..., offset + dimension : offset + dimension + 1]
|
|
# Mask AFTER projection too: a learned bias is not an observation.
|
|
pieces.extend((self.local_projections[name](values) * valid, valid))
|
|
offset += dimension + 1
|
|
states = torch.cat(pieces, dim=-1)
|
|
elif local is not None and local.shape[-1] != 0:
|
|
raise ValueError("Local features supplied to a baseline head")
|
|
if self.local_fusion not in PERSON_SET_FUSION_MODES and (
|
|
person_features is not None or person_mask is not None
|
|
):
|
|
raise ValueError("Person sets supplied to a non-frame-set head")
|
|
return self.net[:-1](states)
|
|
|
|
def _activity_logits(
|
|
self,
|
|
states: torch.Tensor,
|
|
local: torch.Tensor | None,
|
|
person_features: torch.Tensor | None,
|
|
person_mask: torch.Tensor | None,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
features = self._shared_features(
|
|
states,
|
|
local,
|
|
person_features,
|
|
person_mask,
|
|
)
|
|
flat_logits = self.net[-1](features)
|
|
if self.local_fusion == "track-gru-residual":
|
|
if local is None:
|
|
raise ValueError("Track GRU residual requires pooled local AV")
|
|
assert self.person_track_delta is not None
|
|
assert self.person_track_residual_gate is not None
|
|
fused_states = self._fuse_layers(states)
|
|
context, person_valid = self._track_gru_context(
|
|
fused_states,
|
|
local,
|
|
person_features,
|
|
person_mask,
|
|
)
|
|
delta = self.person_track_delta(
|
|
torch.cat((features, context, person_valid), dim=-1)
|
|
)
|
|
flat_logits = flat_logits + (
|
|
torch.tanh(self.person_track_residual_gate)
|
|
* delta
|
|
* person_valid
|
|
)
|
|
return features, flat_logits
|
|
|
|
def forward(
|
|
self,
|
|
states: torch.Tensor,
|
|
local: torch.Tensor | None = None,
|
|
person_features: torch.Tensor | None = None,
|
|
person_mask: torch.Tensor | None = None,
|
|
) -> torch.Tensor:
|
|
features, logits = self._activity_logits(
|
|
states,
|
|
local,
|
|
person_features,
|
|
person_mask,
|
|
)
|
|
return logits.view(
|
|
*features.shape[:-1], self.num_horizons, self.num_classes
|
|
)
|
|
|
|
def forward_with_transition(
|
|
self,
|
|
states: torch.Tensor,
|
|
local: torch.Tensor | None = None,
|
|
person_features: torch.Tensor | None = None,
|
|
person_mask: torch.Tensor | None = None,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""Return activity logits and binary transition logits."""
|
|
|
|
if self.transition_head is None:
|
|
raise RuntimeError(
|
|
"This head was created without transition_auxiliary=True"
|
|
)
|
|
features, flat_activity_logits = self._activity_logits(
|
|
states,
|
|
local,
|
|
person_features,
|
|
person_mask,
|
|
)
|
|
activity_logits = flat_activity_logits.view(
|
|
*features.shape[:-1],
|
|
self.num_horizons,
|
|
self.num_classes,
|
|
)
|
|
transition_logits = self.transition_head(features)
|
|
return activity_logits, transition_logits
|
|
|
|
def layer_mixture_weights(self) -> torch.Tensor | None:
|
|
"""Return normalized oldest-to-newest layer weights, if enabled."""
|
|
|
|
if self.layer_logits is None:
|
|
return None
|
|
return self.layer_logits.softmax(dim=0)
|
|
|
|
def person_interaction_gates(self) -> dict[str, float]:
|
|
"""Return bounded residual strengths for screening fusion modes."""
|
|
|
|
result: dict[str, float] = {}
|
|
if self.audio_person_gate is not None:
|
|
result["audio_person"] = float(
|
|
torch.tanh(self.audio_person_gate.detach()).cpu()
|
|
)
|
|
if self.person_state_adapter_gate is not None:
|
|
result["person_state_adapter"] = float(
|
|
torch.tanh(self.person_state_adapter_gate.detach()).cpu()
|
|
)
|
|
if self.person_track_residual_gate is not None:
|
|
result["person_track_residual"] = float(
|
|
torch.tanh(self.person_track_residual_gate.detach()).cpu()
|
|
)
|
|
return result
|
|
|
|
|
|
def _validate_loss_inputs(
|
|
logits: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
*,
|
|
num_output_classes: int = NUM_CLASSES,
|
|
) -> None:
|
|
if logits.shape != targets.shape:
|
|
raise ValueError(
|
|
f"logits/targets shape mismatch: {tuple(logits.shape)} vs "
|
|
f"{tuple(targets.shape)}"
|
|
)
|
|
if logits.shape[-1] != num_output_classes:
|
|
raise ValueError(
|
|
f"Expected {num_output_classes} classes, got {logits.shape[-1]}"
|
|
)
|
|
|
|
|
|
def _validated_binary_activity_targets(
|
|
targets: torch.Tensor,
|
|
*,
|
|
allow_noncanonical_binary: bool = False,
|
|
) -> torch.Tensor:
|
|
"""Validate three-label activity rows.
|
|
|
|
EasyCom uses four explicit activity states. Ego4D's released target files
|
|
are consumed by EgoSpeak's MCE objective as binary/multi-positive weights,
|
|
so the compatibility path preserves any binary row instead of silently
|
|
rewriting it to an EasyCom state.
|
|
"""
|
|
|
|
if targets.ndim < 1 or targets.shape[-1] != NUM_CLASSES:
|
|
raise ValueError(
|
|
f"Expected activity targets [...,{NUM_CLASSES}], got "
|
|
f"{tuple(targets.shape)}"
|
|
)
|
|
if not torch.isfinite(targets).all():
|
|
raise ValueError("Activity targets contain NaN or Inf")
|
|
rounded = targets.round()
|
|
if not torch.allclose(targets, rounded, atol=1e-6, rtol=0.0):
|
|
raise ValueError("Activity targets must contain only binary 0/1 values")
|
|
expected_background = rounded[..., 1:].sum(dim=-1) == 0
|
|
valid = rounded[..., 0].bool() == expected_background
|
|
if allow_noncanonical_binary:
|
|
return rounded
|
|
if not bool(valid.all()):
|
|
invalid = torch.unique(rounded[~valid].reshape(-1, NUM_CLASSES), dim=0)
|
|
invalid_rows = ", ".join(
|
|
str([int(value) for value in row.tolist()])
|
|
for row in invalid[:8]
|
|
)
|
|
if len(invalid) > 8:
|
|
invalid_rows += ", ..."
|
|
raise ValueError(
|
|
"Activity targets must be one of [1,0,0], [0,1,0], "
|
|
"[0,0,1], or [0,1,1]; observed invalid "
|
|
f"row(s): {invalid_rows}"
|
|
)
|
|
return rounded
|
|
|
|
|
|
def activity_targets_to_four_states(targets: torch.Tensor) -> torch.LongTensor:
|
|
"""Map three-label activity targets to silence/wearer/others/overlap."""
|
|
|
|
targets = _validated_binary_activity_targets(targets)
|
|
return (targets[..., 1] + 2 * targets[..., 2]).long()
|
|
|
|
|
|
def transition_targets_from_activity(
|
|
activity_sequence: torch.Tensor,
|
|
) -> torch.Tensor:
|
|
"""Mark every activity-state change between adjacent 5 Hz target rows.
|
|
|
|
The input ends in [time, 3] and the output ends in [time - 1].
|
|
A silence/onset, offset, speaker handover, or overlap-state change is one
|
|
positive transition.
|
|
"""
|
|
|
|
activity_sequence = _validated_binary_activity_targets(activity_sequence)
|
|
if activity_sequence.ndim < 2 or activity_sequence.shape[-2] < 2:
|
|
raise ValueError(
|
|
"Activity sequence must contain at least two target rows, got "
|
|
f"{tuple(activity_sequence.shape)}"
|
|
)
|
|
changed = activity_sequence[..., 1:, :] != activity_sequence[..., :-1, :]
|
|
return changed.any(dim=-1).to(activity_sequence.dtype)
|
|
|
|
|
|
def weighted_transition_bce_loss(
|
|
logits: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
*,
|
|
pos_weight: float = 3.0,
|
|
) -> torch.Tensor:
|
|
"""Binary transition loss with an explicit positive-class weight."""
|
|
|
|
if logits.shape != targets.shape:
|
|
raise ValueError(
|
|
f"Transition logits/targets mismatch: {tuple(logits.shape)} vs "
|
|
f"{tuple(targets.shape)}"
|
|
)
|
|
if pos_weight <= 0:
|
|
raise ValueError("Transition pos_weight must be positive")
|
|
targets = targets.to(device=logits.device, dtype=logits.dtype)
|
|
if not torch.isfinite(targets).all() or not torch.all(
|
|
(targets == 0) | (targets == 1)
|
|
):
|
|
raise ValueError("Transition targets must contain only finite 0/1 values")
|
|
return nn.functional.binary_cross_entropy_with_logits(
|
|
logits,
|
|
targets,
|
|
pos_weight=logits.new_tensor(pos_weight),
|
|
)
|
|
|
|
|
|
def expected_transition_reward(
|
|
logits: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
*,
|
|
pos_weight: float = 3.0,
|
|
) -> torch.Tensor:
|
|
"""Exact expected weighted correctness of a Bernoulli transition policy.
|
|
|
|
Correct transition/non-transition actions receive reward one and incorrect
|
|
actions zero. The expectation is evaluated analytically rather than by
|
|
sampling, and normalized by the sum of example weights to remain in [0, 1].
|
|
"""
|
|
|
|
if logits.shape != targets.shape:
|
|
raise ValueError(
|
|
f"Transition logits/targets mismatch: {tuple(logits.shape)} vs "
|
|
f"{tuple(targets.shape)}"
|
|
)
|
|
if pos_weight <= 0:
|
|
raise ValueError("Transition pos_weight must be positive")
|
|
targets = targets.to(device=logits.device, dtype=logits.dtype)
|
|
if not torch.isfinite(targets).all() or not torch.all(
|
|
(targets == 0) | (targets == 1)
|
|
):
|
|
raise ValueError("Transition targets must contain only finite 0/1 values")
|
|
probability = logits.sigmoid()
|
|
probability_correct = (
|
|
targets * probability + (1.0 - targets) * (1.0 - probability)
|
|
)
|
|
weights = 1.0 + (pos_weight - 1.0) * targets
|
|
return (weights * probability_correct).sum() / weights.sum()
|
|
|
|
|
|
def transition_aware_objective(
|
|
activity_loss: torch.Tensor,
|
|
transition_logits: torch.Tensor,
|
|
transition_targets: torch.Tensor,
|
|
*,
|
|
objective: str,
|
|
transition_weight: float = 0.2,
|
|
pos_weight: float = 3.0,
|
|
reward_weight: float = 0.1,
|
|
) -> dict[str, torch.Tensor]:
|
|
"""Compose the R1 or R2 transition-aware training objective."""
|
|
|
|
if objective not in TRANSITION_OBJECTIVE_NAMES[1:]:
|
|
raise ValueError(
|
|
f"Transition objective must be R1 or R2, got {objective!r}"
|
|
)
|
|
if transition_weight < 0 or reward_weight < 0:
|
|
raise ValueError("Transition and reward weights must be non-negative")
|
|
transition_loss = weighted_transition_bce_loss(
|
|
transition_logits,
|
|
transition_targets,
|
|
pos_weight=pos_weight,
|
|
)
|
|
expected_reward = expected_transition_reward(
|
|
transition_logits,
|
|
transition_targets,
|
|
pos_weight=pos_weight,
|
|
)
|
|
applied_reward = (
|
|
expected_reward
|
|
if objective == "r2-expected-reward"
|
|
else expected_reward.detach().new_zeros(())
|
|
)
|
|
total = (
|
|
activity_loss
|
|
+ transition_weight * transition_loss
|
|
- reward_weight * applied_reward
|
|
)
|
|
return {
|
|
"total": total,
|
|
"activity": activity_loss,
|
|
"transition": transition_loss,
|
|
"expected_reward": expected_reward,
|
|
"applied_reward": applied_reward,
|
|
}
|
|
|
|
|
|
def output_classes_for_loss(loss_name: str) -> int:
|
|
"""Return the number of head logits required by one training objective."""
|
|
|
|
if loss_name not in LOSS_NAMES:
|
|
raise ValueError(f"Unknown loss {loss_name!r}; expected one of {LOSS_NAMES}")
|
|
return NUM_FOUR_STATES if loss_name == "four-state-ce" else NUM_CLASSES
|
|
|
|
|
|
def output_space_for_loss(loss_name: str) -> str:
|
|
"""Return a stable checkpoint description of the head output space."""
|
|
|
|
output_classes_for_loss(loss_name)
|
|
if loss_name == "four-state-ce":
|
|
return "four-state-softmax"
|
|
if loss_name == "independent-bce":
|
|
return "three-label-sigmoid"
|
|
return "three-class-softmax"
|
|
|
|
|
|
def head_output_names_for_loss(loss_name: str) -> tuple[str, ...]:
|
|
output_classes_for_loss(loss_name)
|
|
return FOUR_STATE_NAMES if loss_name == "four-state-ce" else CLASS_NAMES
|
|
|
|
|
|
def egospeak_multihorizon_loss(
|
|
logits: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
*,
|
|
reduction: Literal["mean", "sum", "none"] = "mean",
|
|
) -> torch.Tensor:
|
|
"""Reproduce the LSTR ``MCE`` loss used by the supplied shell/config.
|
|
|
|
EasyCom targets may contain both active speaker classes as ``[0, 1, 1]``.
|
|
LSTR preserves those weights directly and sums their negative log-softmax
|
|
terms. It does not normalize the target row.
|
|
"""
|
|
|
|
_validate_loss_inputs(logits, targets)
|
|
targets = targets.to(device=logits.device, dtype=logits.dtype)
|
|
loss = -(targets * nn.functional.log_softmax(logits, dim=-1)).sum(dim=-1)
|
|
if reduction == "mean":
|
|
return loss.mean()
|
|
if reduction == "sum":
|
|
return loss.sum()
|
|
if reduction == "none":
|
|
return loss
|
|
raise ValueError(f"Unknown reduction: {reduction}")
|
|
|
|
|
|
def miniroad_multihorizon_loss(
|
|
logits: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
*,
|
|
reduction: Literal["mean", "sum", "none"] = "mean",
|
|
) -> torch.Tensor:
|
|
"""Reproduce MiniROAD's L2-normalized multi-positive anticipation loss."""
|
|
|
|
_validate_loss_inputs(logits, targets)
|
|
targets = nn.functional.normalize(
|
|
targets.to(device=logits.device, dtype=logits.dtype),
|
|
p=2,
|
|
dim=-1,
|
|
)
|
|
loss = -(targets * nn.functional.log_softmax(logits, dim=-1)).sum(dim=-1)
|
|
if reduction == "mean":
|
|
return loss.mean()
|
|
if reduction == "sum":
|
|
return loss.sum()
|
|
if reduction == "none":
|
|
return loss
|
|
raise ValueError(f"Unknown reduction: {reduction}")
|
|
|
|
|
|
def independent_bce_multihorizon_loss(
|
|
logits: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
*,
|
|
reduction: Literal["mean", "sum", "none"] = "mean",
|
|
) -> torch.Tensor:
|
|
"""Independent sigmoid/BCE loss for the three marginal activity labels."""
|
|
|
|
_validate_loss_inputs(logits, targets)
|
|
targets = targets.to(
|
|
device=logits.device,
|
|
dtype=logits.dtype,
|
|
)
|
|
element_loss = nn.functional.binary_cross_entropy_with_logits(
|
|
logits,
|
|
targets,
|
|
reduction="none",
|
|
)
|
|
if reduction == "mean":
|
|
return element_loss.mean()
|
|
if reduction == "sum":
|
|
return element_loss.sum()
|
|
if reduction == "none":
|
|
return element_loss
|
|
raise ValueError(f"Unknown reduction: {reduction}")
|
|
|
|
|
|
def four_state_multihorizon_loss(
|
|
logits: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
*,
|
|
reduction: Literal["mean", "sum", "none"] = "mean",
|
|
) -> torch.Tensor:
|
|
"""Four-state CE for silence, wearer-only, others-only, and overlap."""
|
|
|
|
if logits.ndim < 1 or logits.shape[-1] != NUM_FOUR_STATES:
|
|
raise ValueError(
|
|
f"Expected four-state logits [...,{NUM_FOUR_STATES}], got "
|
|
f"{tuple(logits.shape)}"
|
|
)
|
|
if logits.shape[:-1] != targets.shape[:-1] or targets.shape[-1] != NUM_CLASSES:
|
|
raise ValueError(
|
|
"Four-state logits/activity target shape mismatch: "
|
|
f"{tuple(logits.shape)} vs {tuple(targets.shape)}"
|
|
)
|
|
state_targets = (targets[..., 1] + 2 * targets[..., 2]).long().to(
|
|
logits.device
|
|
)
|
|
loss = nn.functional.cross_entropy(
|
|
logits.reshape(-1, NUM_FOUR_STATES),
|
|
state_targets.reshape(-1),
|
|
reduction="none",
|
|
).reshape(logits.shape[:-1])
|
|
if reduction == "mean":
|
|
return loss.mean()
|
|
if reduction == "sum":
|
|
return loss.sum()
|
|
if reduction == "none":
|
|
return loss
|
|
raise ValueError(f"Unknown reduction: {reduction}")
|
|
|
|
|
|
def marginal_probabilities_from_logits(
|
|
logits: torch.Tensor,
|
|
loss_name: str,
|
|
) -> torch.Tensor:
|
|
"""Convert any supported head output to three EgoSpeak marginals.
|
|
|
|
Four-state marginals deliberately are not renormalized: wearer and others
|
|
may both be near one when the overlap state is likely.
|
|
"""
|
|
|
|
expected_classes = output_classes_for_loss(loss_name)
|
|
if logits.ndim < 1 or logits.shape[-1] != expected_classes:
|
|
raise ValueError(
|
|
f"Loss {loss_name!r} expects {expected_classes} logits, got "
|
|
f"{tuple(logits.shape)}"
|
|
)
|
|
if loss_name == "independent-bce":
|
|
return logits.sigmoid()
|
|
probabilities = logits.softmax(dim=-1)
|
|
if loss_name != "four-state-ce":
|
|
return probabilities
|
|
return torch.stack(
|
|
(
|
|
probabilities[..., 0],
|
|
probabilities[..., 1] + probabilities[..., 3],
|
|
probabilities[..., 2] + probabilities[..., 3],
|
|
),
|
|
dim=-1,
|
|
)
|
|
|
|
|
|
def multihorizon_cross_entropy(
|
|
logits: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
) -> torch.Tensor:
|
|
"""Backward-compatible alias for the LSTR/EgoSpeak loss."""
|
|
|
|
return egospeak_multihorizon_loss(logits, targets)
|
|
|
|
|
|
def normalize_session_name(value: str | int) -> str:
|
|
text = str(value)
|
|
if text.startswith("Session_"):
|
|
suffix = text.removeprefix("Session_")
|
|
else:
|
|
suffix = text
|
|
if not suffix.isdigit():
|
|
raise ValueError(f"Invalid EasyCom session: {value}")
|
|
return f"Session_{int(suffix)}"
|
|
|
|
|
|
def normalize_sample_name(
|
|
value: str | int,
|
|
dataset: DatasetName = "easycom",
|
|
) -> str:
|
|
"""Return the canonical EasyCom session name or Ego4D clip UUID."""
|
|
|
|
if dataset == "easycom":
|
|
return normalize_session_name(value)
|
|
if dataset != "ego4d":
|
|
raise ValueError(f"Unknown dataset: {dataset}")
|
|
|
|
text = str(value).strip()
|
|
try:
|
|
return str(UUID(text))
|
|
except (ValueError, AttributeError) as error:
|
|
raise ValueError(f"Invalid Ego4D clip UUID: {value}") from error
|
|
|
|
|
|
def resolve_feature_path(
|
|
feature_root: str | Path,
|
|
sample: str | int,
|
|
*,
|
|
dataset: DatasetName = "easycom",
|
|
) -> Path:
|
|
"""Read flat features or Ego4D train/test subdirectories without link farms.
|
|
|
|
Manifest membership, not directory discovery, determines the data split.
|
|
Multiple copies are rejected rather than silently choosing stale features.
|
|
"""
|
|
name = normalize_sample_name(sample, dataset)
|
|
root = Path(feature_root)
|
|
candidates = [root / f"{name}.pt"]
|
|
if dataset == "ego4d":
|
|
candidates += [root / split / f"{name}.pt" for split in ("train", "test")]
|
|
found = [path for path in candidates if path.is_file()]
|
|
if len(found) > 1:
|
|
raise ValueError(f"Ambiguous feature copies for {name}: {found}; select one feature root")
|
|
if not found:
|
|
raise FileNotFoundError(f"No features for {name}; checked: " + ", ".join(map(str, candidates)))
|
|
return found[0]
|
|
|
|
|
|
def load_ego4d_manifest_split(
|
|
manifest: str | Path,
|
|
split: Literal["train", "test", "all"],
|
|
) -> list[str]:
|
|
"""Load ordered UIDs from ``ego4d_egospeak.tsv``; all preserves row order."""
|
|
|
|
if split not in {"train", "test", "all"}:
|
|
raise ValueError(f"Invalid Ego4D split: {split}")
|
|
path = Path(manifest)
|
|
if not path.is_file():
|
|
raise FileNotFoundError(path)
|
|
|
|
selected: list[str] = []
|
|
seen: set[str] = set()
|
|
with path.open(newline="") as file:
|
|
reader = csv.DictReader(file, delimiter="\t")
|
|
columns = set(reader.fieldnames or [])
|
|
required = {"split", "uid"}
|
|
if not required.issubset(columns):
|
|
raise ValueError(
|
|
f"Manifest must contain {sorted(required)}, got "
|
|
f"{reader.fieldnames}: {path}"
|
|
)
|
|
for row_number, row in enumerate(reader, start=2):
|
|
row_split = row["split"].strip()
|
|
if row_split not in {"train", "test"}:
|
|
raise ValueError(
|
|
f"Invalid split {row_split!r} at {path}:{row_number}"
|
|
)
|
|
uid = normalize_sample_name(row["uid"], "ego4d")
|
|
if uid in seen:
|
|
raise ValueError(
|
|
f"Duplicate Ego4D UID {uid} at {path}:{row_number}"
|
|
)
|
|
seen.add(uid)
|
|
if split == "all" or row_split == split:
|
|
selected.append(uid)
|
|
|
|
if not selected:
|
|
raise ValueError(f"No {split!r} samples found in {path}")
|
|
return selected
|
|
|
|
|
|
@dataclass
|
|
class SessionData:
|
|
name: str
|
|
states: torch.Tensor
|
|
targets: torch.Tensor
|
|
label_indices: torch.LongTensor
|
|
feature_metadata: dict[str, Any]
|
|
local_features: torch.Tensor | None = None
|
|
local_person_features: "FramePersonFeatures | None" = None
|
|
|
|
@property
|
|
def hidden_size(self) -> int:
|
|
return int(self.states.shape[-1])
|
|
|
|
@property
|
|
def num_input_layers(self) -> int:
|
|
return 1 if self.states.ndim == 2 else int(self.states.shape[-2])
|
|
|
|
def future_examples(
|
|
self,
|
|
num_horizons: int = NUM_HORIZONS,
|
|
) -> tuple[torch.Tensor, torch.Tensor, torch.LongTensor]:
|
|
"""Return states, future labels, and target indices for valid rows."""
|
|
|
|
offsets = torch.arange(1, num_horizons + 1, dtype=torch.long)
|
|
future_indices = self.label_indices[:, None] + offsets[None, :]
|
|
valid = (future_indices >= 0).all(dim=1) & (
|
|
future_indices < len(self.targets)
|
|
).all(dim=1)
|
|
rows = future_indices[valid]
|
|
return self.states[valid], self.targets[rows], rows
|
|
|
|
|
|
def load_session_data(
|
|
feature_root: str | Path,
|
|
target_root: str | Path,
|
|
session: str | int,
|
|
*,
|
|
expected_mode: str | None = "causal-prefix",
|
|
dataset: DatasetName = "easycom",
|
|
local_feature_root: str | Path | None = None,
|
|
local_modalities: str = "av",
|
|
local_contract: dict[str, Any] | None = None,
|
|
local_fusion: str = "pooled",
|
|
person_track_max_gap_seconds: float = 1.0,
|
|
) -> SessionData:
|
|
"""Load and validate one saved Omni feature payload and target array."""
|
|
|
|
name = normalize_sample_name(session, dataset)
|
|
feature_path = resolve_feature_path(feature_root, name, dataset=dataset)
|
|
target_path = Path(target_root) / f"{name}.npy"
|
|
if not feature_path.is_file():
|
|
raise FileNotFoundError(feature_path)
|
|
if not target_path.is_file():
|
|
raise FileNotFoundError(target_path)
|
|
|
|
payload = torch.load(feature_path, map_location="cpu", weights_only=False)
|
|
if not isinstance(payload, dict) or "states" not in payload:
|
|
raise ValueError(f"Invalid feature payload: {feature_path}")
|
|
states = payload["states"]
|
|
if not isinstance(states, torch.Tensor) or states.ndim not in {2, 3}:
|
|
raise ValueError(f"states must be [T,D] or [T,L,D] in {feature_path}")
|
|
if states.ndim == 3 and states.shape[1] < 2:
|
|
raise ValueError(
|
|
f"Stacked states must contain at least two layers: {feature_path}"
|
|
)
|
|
if not torch.is_floating_point(states):
|
|
raise ValueError(f"states must be floating point in {feature_path}")
|
|
if not torch.isfinite(states).all():
|
|
raise ValueError(f"states contain NaN/Inf in {feature_path}")
|
|
|
|
mode = payload.get("mode")
|
|
if expected_mode is not None and mode != expected_mode:
|
|
raise ValueError(
|
|
f"{feature_path} mode is {mode!r}, expected {expected_mode!r}"
|
|
)
|
|
label_indices = payload.get("label_indices")
|
|
if label_indices is None:
|
|
raise ValueError(f"Missing label_indices in {feature_path}")
|
|
label_indices = torch.as_tensor(label_indices, dtype=torch.long)
|
|
if label_indices.ndim != 1 or len(label_indices) != len(states):
|
|
raise ValueError(f"Invalid label_indices in {feature_path}")
|
|
if len(label_indices) and not torch.equal(
|
|
label_indices,
|
|
torch.arange(len(label_indices), dtype=torch.long),
|
|
):
|
|
raise ValueError(
|
|
f"{feature_path} does not use the verified zero-based dense alignment"
|
|
)
|
|
|
|
target_array = np.load(target_path, allow_pickle=False)
|
|
if target_array.ndim != 2 or target_array.shape[1] != NUM_CLASSES:
|
|
raise ValueError(
|
|
f"Expected target [T,{NUM_CLASSES}], got {target_array.shape}: "
|
|
f"{target_path}"
|
|
)
|
|
targets = torch.from_numpy(np.asarray(target_array, dtype=np.float32))
|
|
try:
|
|
_validated_binary_activity_targets(
|
|
targets,
|
|
allow_noncanonical_binary=dataset == "ego4d",
|
|
)
|
|
except ValueError as error:
|
|
raise ValueError(f"{error}: {target_path}") from error
|
|
if len(label_indices) and int(label_indices[-1]) >= len(targets):
|
|
raise ValueError(
|
|
f"Last label index {int(label_indices[-1])} exceeds target length "
|
|
f"{len(targets)} in {name}"
|
|
)
|
|
if len(states) != len(targets):
|
|
raise ValueError(
|
|
f"{name}: feature/target length mismatch: {len(states)} vs "
|
|
f"{len(targets)}"
|
|
)
|
|
|
|
metadata = {
|
|
key: payload.get(key)
|
|
for key in (
|
|
"model_id",
|
|
"mode",
|
|
"config",
|
|
"start",
|
|
"end",
|
|
"num_steps",
|
|
"video_sampling_grid",
|
|
)
|
|
}
|
|
expected_background = targets[:, 1:].sum(dim=-1) == 0
|
|
canonical_rows = targets[:, 0].bool() == expected_background
|
|
unique_rows, row_counts = torch.unique(
|
|
targets.to(dtype=torch.int64),
|
|
dim=0,
|
|
return_counts=True,
|
|
)
|
|
metadata["num_noncanonical_target_rows"] = int(
|
|
(~canonical_rows).sum().item()
|
|
)
|
|
metadata["target_row_pattern_counts"] = {
|
|
"".join(str(int(value)) for value in row.tolist()): int(count)
|
|
for row, count in zip(unique_rows, row_counts)
|
|
}
|
|
if local_fusion not in LOCAL_FUSION_MODES:
|
|
raise ValueError(f"Unknown local fusion: {local_fusion}")
|
|
local = None
|
|
local_people = None
|
|
if local_feature_root is not None:
|
|
local_path = resolve_feature_path(
|
|
local_feature_root,
|
|
name,
|
|
dataset=dataset,
|
|
)
|
|
if local_fusion in TRACK_PERSON_FUSION_MODES:
|
|
if local_modalities != "av":
|
|
raise ValueError(
|
|
"Track person fusion requires --local-modalities av"
|
|
)
|
|
from local_turn_features import load_track_features
|
|
|
|
local, local_people, contract = load_track_features(
|
|
local_path,
|
|
payload,
|
|
sample_id=name,
|
|
dataset=dataset,
|
|
track_max_gap_seconds=person_track_max_gap_seconds,
|
|
expected_contract=local_contract,
|
|
)
|
|
elif local_fusion in CURRENT_FRAME_PERSON_SET_FUSION_MODES:
|
|
if local_modalities != "av":
|
|
raise ValueError(
|
|
"Person-set fusion requires --local-modalities av"
|
|
)
|
|
from local_turn_features import load_frame_set_features
|
|
|
|
local, local_people, contract = load_frame_set_features(
|
|
local_path,
|
|
payload,
|
|
sample_id=name,
|
|
dataset=dataset,
|
|
expected_contract=local_contract,
|
|
oracle_analysis=local_fusion == "oracle-frame-set-attention",
|
|
)
|
|
else:
|
|
from local_turn_features import load_local_features
|
|
|
|
local, contract = load_local_features(
|
|
local_path,
|
|
payload,
|
|
sample_id=name,
|
|
dataset=dataset,
|
|
modalities=local_modalities,
|
|
expected_contract=local_contract,
|
|
)
|
|
metadata["local_contract"] = contract
|
|
elif local_contract is not None:
|
|
raise ValueError("--local-feature-root is required for this checkpoint")
|
|
elif local_fusion != "pooled":
|
|
raise ValueError("Person-set fusion requires --local-feature-root")
|
|
return SessionData(
|
|
name,
|
|
states,
|
|
targets,
|
|
label_indices,
|
|
metadata,
|
|
local,
|
|
local_people,
|
|
)
|
|
|
|
|
|
def average_precision_metrics(
|
|
scores: np.ndarray,
|
|
targets: np.ndarray,
|
|
) -> dict[str, Any]:
|
|
"""Compute EgoSpeak frame AP for all three classes, including background."""
|
|
|
|
from sklearn.metrics import average_precision_score
|
|
|
|
scores = np.asarray(scores, dtype=np.float64)
|
|
targets = np.asarray(targets, dtype=np.float64)
|
|
if scores.shape != targets.shape or scores.ndim != 2:
|
|
raise ValueError(
|
|
f"scores/targets must have equal [N,C] shape, got "
|
|
f"{scores.shape} and {targets.shape}"
|
|
)
|
|
if scores.shape[1] != NUM_CLASSES:
|
|
raise ValueError(f"Expected {NUM_CLASSES} classes, got {scores.shape[1]}")
|
|
|
|
aps = average_precision_score(targets, scores, average=None)
|
|
return {
|
|
"num_steps": int(len(scores)),
|
|
"class_ap": {
|
|
name: float(value) for name, value in zip(CLASS_NAMES, aps)
|
|
},
|
|
"map": float(np.mean(aps)),
|
|
}
|
|
|
|
|
|
def checkpoint_head_config(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return a validated head config, including legacy-checkpoint defaults."""
|
|
|
|
config = dict(payload.get("head_config", {}))
|
|
config.setdefault("hidden_size", 2048)
|
|
config.setdefault("num_horizons", NUM_HORIZONS)
|
|
config.setdefault("num_classes", NUM_CLASSES)
|
|
config.setdefault("dropout", 0.1)
|
|
config.setdefault("num_input_layers", 1)
|
|
config.setdefault("learnable_layer_mixture", False)
|
|
config.setdefault("transition_auxiliary", False)
|
|
config.setdefault("local_visual_dim", 0)
|
|
config.setdefault("local_audio_dim", 0)
|
|
config.setdefault("local_projection_dim", 256)
|
|
config.setdefault("local_fusion", "pooled")
|
|
config.setdefault("person_attention_dim", 256)
|
|
config.setdefault("person_attention_heads", 4)
|
|
config.setdefault("person_history_frames", 16)
|
|
config.setdefault("person_track_max_gap_seconds", 1.0)
|
|
config.setdefault(
|
|
"layer_fusion",
|
|
(
|
|
"scalar-mixture"
|
|
if config["learnable_layer_mixture"]
|
|
else "single"
|
|
),
|
|
)
|
|
return config
|
|
|
|
|
|
def checkpoint_loss_name(payload: dict[str, Any]) -> str:
|
|
"""Read the objective from a checkpoint, defaulting legacy files to MCE."""
|
|
|
|
training_config = payload.get("training_config", {})
|
|
if not isinstance(training_config, dict):
|
|
raise ValueError("Checkpoint training_config must be a mapping")
|
|
loss_name = str(training_config.get("loss", "lstr-mce"))
|
|
expected_classes = output_classes_for_loss(loss_name)
|
|
actual_classes = int(checkpoint_head_config(payload)["num_classes"])
|
|
if actual_classes != expected_classes:
|
|
raise ValueError(
|
|
f"Checkpoint loss {loss_name!r} requires {expected_classes} head "
|
|
f"outputs, but head_config has {actual_classes}"
|
|
)
|
|
recorded_space = payload.get("output_space")
|
|
expected_space = output_space_for_loss(loss_name)
|
|
if recorded_space is not None and recorded_space != expected_space:
|
|
raise ValueError(
|
|
f"Checkpoint output_space is {recorded_space!r}, expected "
|
|
f"{expected_space!r} for loss {loss_name!r}"
|
|
)
|
|
return loss_name
|
|
|
|
|
|
def validate_disjoint_sessions(
|
|
first: Sequence[str],
|
|
second: Sequence[str],
|
|
) -> None:
|
|
overlap = set(first) & set(second)
|
|
if overlap:
|
|
raise ValueError(f"Session sets overlap: {sorted(overlap)}")
|